OneLogin を Terraform で管理できるのか試してみる

OneLogin とつきあい始めて 9 ヶ月。User に加えて、権限を管理するための Role や、User に Role を割り当てるための Mapping も増えてきました。管理対象の増加で困りごともでてきました。

公式が Terraform provider を公開していることを知り、OneLogin の各種リソースを Terraform で管理できないか試しています。

この note では、既に作成済みのリソースを Terraform の管理下におく方法の検証結果について書いています。Role が曲者でした。


その前に

Terraform の概要を知りたい方向けに ラックさんの記事をご紹介です。

Infrastracture as Code の文脈で出てくることが多い Terraform ですが、API が公開されていれば SaaS の構成 (設定) を管理することも可能です。対象 SaaS の API を操作するための provider を必要としますが、公開されているものはコチラで検索することができます。


Terraform import

Terraform には既存のリソースを管理下におくための import という機能が用意されています。コマンド実行により、指定したリソースの状態(パラメータ) が tfstate ファイルに反映されます。

通常は「tf ファイルでリソースを定義(パラメータを記述) -> terraform apply でプロビジョニング -> tfstate ファイルにリソースの状態が記録される」という流れですが、import だと「作成済みのリソースの状態を tfstate に取り込み (terraform import) -> 辻褄を合わせる形で tf ファイルでリソースを定義」 という流れになります。

import の使い方の学習はこちらのブログを参考にさせていただきました。


環境の準備

OneLogin の検証環境

こちらのページから OneLogin の開発環境を用意し、管理画面で以下のリソースを作成しました。

  • User : testuser-01@example.com

    • Attribute - Department に sales と設定 (他パラメータの説明は割愛)

  • Role: org_dept_sales

  • Mapping: org_dept_sales

    • Department の値が sales の場合、Role org_dept_sales を割り当てる

User: testuser-01@example.com
Role: org_dept_sales
Mapping: org_dept_sales


Terraform import を実行する際に対象リソースを ID で指定するのですが、当該リソース画面の URL (ID が含まれている)や Report 機能を使って確認できます。

URL でも ID を確認できるよ


Terraform が OneLogin の API にアクセスする際の Credential も作成しておきます。Terraform Import の実行だけなら Read 権限があれば良いです。Terraform でリソースの作成・更新・削除をするなら Manage の権限も付与します。今回の検証では Manage All を付与しています。


Terraform の実行環境

以下の環境で検証しました。

  • Terraform v1.1.3

  • terraform-provider-onelogin v0.1.25

  • onelogin-go-sdk v.1.1.19

    • terraform-provider-onelogin は onelogin-go-sdk を利用しています


Install Terraform を実行後、OneLogin の Provider を利用する準備をしていきます。

main.tf に provider の情報を記述します。

# main.tf

terraform {
  required_version = "= 1.1.3"
  required_providers {
    onelogin = {
      source  = "onelogin/onelogin"
      version = "0.1.25"
    }
  }
}

provider "onelogin" {
  # Configuration options
}

terraform init を実行します。

% terraform init

Initializing the backend...

Initializing provider plugins...
- Finding onelogin/onelogin versions matching "0.1.25"...
- Installing onelogin/onelogin v0.1.25...
- Installed onelogin/onelogin v0.1.25 (signed by a HashiCorp partner, key ID 610C6E2CD0C9B276)

Partner and community providers are signed by their developers.
If you'd like to know more about provider signing, you can read about it here:
https://www.terraform.io/docs/cli/plugins/signing.html

Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

各環境変数に OneLogin API の Credential や URL を設定します。

% export ONELOGIN_CLIENT_ID=<your client id>
% export ONELOGIN_CLIENT_SECRET=<your client secret>
% export ONELOGIN_OAPI_URL=<the api url for your region>


Import してみる

OneLogin の User, Role, Mappingimport してみます。コマンドの使い方は以下のとおりです。

Usage: terraform import [options] ADDRESS ID

Import will find the existing resource from ID and import it into your Terraform state at the given ADDRESS.

https://www.terraform.io/cli/commands/import
  • ADDRESS … Terrraform 内部の Resources Address

  • ID … import する対象の OneLogin リソースの ID

Resource Address は [resource_type].[resource_name] という形で表現されます。


User

まずは main.tf に以下の構文を追記します。

# main.tf

resource "onelogin_users" "testuser" {}

terraform import を実行します。
※ "onelogin_users.testuser" が Resource Address,  "161405449" が testuser-01@example.com のリソース ID に該当します

% terraform import onelogin_users.testuser 161405449
onelogin_users.testuser: Importing from ID "161405449"...
onelogin_users.testuser: Import prepared!
  Prepared onelogin_users for import
onelogin_users.testuser: Refreshing state... [id=161405449]

Import successful!

The resources that were imported are shown above. These resources are now in
your Terraform state and will henceforth be managed by Terraform.

リソースの状態を管理する tfstate ファイルが作成され、内容を確認すると User のパラメータ情報が記述されていることがわかります。

# teraform.tfstate

{
  "version": 4,
  "terraform_version": "1.1.3",
  "serial": 1,
  "lineage": "ab00b1e2-77e0-0fee-3363-f9f5ba9d54be",
  "outputs": {},
  "resources": [
    {
      "mode": "managed",
      "type": "onelogin_users",
      "name": "testuser",
      "provider": "provider[\"registry.terraform.io/onelogin/onelogin\"]",
      "instances": [
        {
          "schema_version": 0,
          "attributes": {
            "comment": "",
            "company": "example",
            "custom_attributes": {
              "contractType": "full",
              "flag_google_ws": "1",
              "flag_zoom": "1"
            },
            "department": "sales",
            "directory_id": 0,
            "distinguished_name": "",
            "email": "testuser-01@example.com",
            "external_id": 0,
            "firstname": "01",
            "group_id": 0,
            "id": "161405449",
            "lastname": "testuser",
            "manager_ad_id": 0,
            "manager_user_id": 0,
            "member_of": "",
            "phone": "",
            "samaccountname": "",
            "state": 1,
            "status": 1,
            "title": "leader",
            "trusted_idp_id": 0,
            "username": "testuser-01@example.com",
            "userprincipalname": null
          },
          "sensitive_attributes": [],
          "private": "eyJzY2hlbWFfdmVyc2lvbiI6IjAifQ=="
        }
      ]
    }
  ]
}

内容を確認しながら tf ファイルにパラメータを追記します。

# main.tf

resource "onelogin_users" "testuser" {
  username   = "testuser-01@example.com"
  email      = "testuser-01@example.com"
  firstname  = "01"
  lastname   = "testuser"
  title      = "leader"
  company    = "example"
  department = "sales"
  state      = 1
  status     = 1
  custom_attributes = {
    "contractType"   = "full"
    "flag_google_ws" = "1"
    "flag_zoom"      = "1"
  }
}

記述したパラメータと実設定の間で差分が無いことを確認するために、terraform plan を実行します。"No changes." が出力されたので大丈夫そうです。

% terraform plan
onelogin_users.testuser: Refreshing state... [id=161405449]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.


Role

次に Role を import するため、User のときと同様に tf ファイルにリソースを追記して import コマンドを実行します。

# main.tf

resource "onelogin_roles" "org_dept" {}
% terraform import onelogin_roles.org_dept_sales 498619
onelogin_roles.org_dept_sales: Importing from ID "498619"...
onelogin_roles.org_dept_sales: Import prepared!
  Prepared onelogin_roles for import
onelogin_roles.org_dept_sales: Refreshing state... [id=498619]

Import successful!

The resources that were imported are shown above. These resources are now in
your Terraform state and will henceforth be managed by Terraform.

tfstate ファイルに対象の Role の情報が追加されています。

# teraform.tfstate

... 
  "resources": [
    {
      "mode": "managed",
      "type": "onelogin_roles",
      "name": "org_dept",
      "provider": "provider[\"registry.terraform.io/onelogin/onelogin\"]",
      "instances": [
        {
          "schema_version": 0,
          "attributes": {
            "admins": [],
            "apps": [],
            "id": "498619",
            "name": "org_dept_sales",
            "users": [
              161405449
            ]
          },
          "sensitive_attributes": [],
          "private": "eyJzY2hlbWFfdmVyc2lvbiI6IjAifQ=="
        }
      ]
    },
...


ここで Role - User の関係が気になりました。User に Role を割り当てる方法は 2 種類あります。

  • 直接 Role を割り当てる方法 (Added Manually)

  • Mapping で自動的にに Role を割り当てる方法 (User Attribute の条件を指定、等)

今回は Mapping を利用していますが、その場合でも "users" key に当該 User が含まれています。直接 or Mapping のどちらで割り当てても同じ扱いになるようです。
Mapping は「対象 User に Role を割り当てる状態を維持する」機能ではなく、単純なジョブの設定 (ジョブ実行後は状態に関与しない)なので、当然といえば当然ですね。(私はよく前者の機能と勘違いしてしまいがちです…)


tf ファイル内で users の引数に User ID を追記して、 terraform plan, apply を実行してみます。

# main.tf

resource "onelogin_roles" "org_dept" {
  name   = "org_dept_sales"
  apps   = []
  users  = [161405449]
  admins = []
}
% terraform plan -out=terraform.tfplan 
onelogin_roles.org_dept: Refreshing state... [id=498619]
onelogin_users.testuser: Refreshing state... [id=161405449]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.
% terraform apply
onelogin_roles.org_dept: Refreshing state... [id=498619]
onelogin_users.testuser: Refreshing state... [id=161405449]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

plan の結果が "No changes." なので、apply による変更も発生しませんでした(OneLogin API へのリクエスト自体発生しない)。OneLogin の管理画面でも当該 Role のパラメータ変更はありませんでした。


では、次のように「Role に User を割り当てない」ように書き換えるとどうなるでしょうか。

# main.tf

resource "onelogin_roles" "org_dept" {
  name   = "org_dept_sales"
  apps   = []
  users  = []
  admins = []
}
% terraform plan -out=terraform.tfplan
onelogin_roles.org_dept: Refreshing state... [id=498619]
onelogin_users.testuser: Refreshing state... [id=161405449]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # onelogin_roles.org_dept will be updated in-place
  ~ resource "onelogin_roles" "org_dept" {
        id     = "498619"
        name   = "org_dept_sales"
      ~ users  = [
          - 161405449,
        ]
        # (2 unchanged attributes hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Saved the plan to: terraform.tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "terraform.tfplan"
% terraform apply                     
onelogin_roles.org_dept: Refreshing state... [id=498619]
onelogin_users.testuser: Refreshing state... [id=161405449]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # onelogin_roles.org_dept will be updated in-place
  ~ resource "onelogin_roles" "org_dept" {
        id     = "498619"
        name   = "org_dept_sales"
      ~ users  = [
          - 161405449,
        ]
        # (2 unchanged attributes hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

onelogin_roles.org_dept: Modifying... [id=498619]
onelogin_roles.org_dept: Modifications complete after 1s [id=498619]

Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

リソースに変更が発生する(した)旨のメッセージが出力されましたが、管理画面を見ると Mapping により Role が割り当った状態に変更はありません。

"Check existing or add new users to this role" での割り当て状況を確認 

API 操作のイベントを確認すると Role の Update が記録されているので、リクエスト自体は行われていたことがわかります。なお 1 回目の apply では tf ファイルと tfstate ファイル間で差分がないため、Update のリクエストは実行はされていません。この動作は Terraform の仕様によるものです。

Events で API 操作のイベントを確認


結論から書くと、User への Role の割り当てにおいて、tf ファイルの記述通りとならないケースがあることがわかりました。

  • onelogin_roles の引数 users を空にした場合 

    • User への割り当て状態が更新されない

  • onelogin_roles の引数 users で User ID を指定した場合 

    • 当該ユーザに直接 Role が割り当たる

    • Mapping で割り当てていた User にも直接 Role が割り当たる


Onelogin の Terraform Provider は、公式の SDK を利用して API を操作しています。

pkg/services/roles/model.go で Role の構造体が定義されていますが、各フィールドに omitempty タグがついています。

package roles

// RoleQuery represents available query parameters
type RoleQuery struct {
	Limit  string
	Page   string
	Cursor string
}

// Role represents the Role resource in OneLogin
type Role struct {
	ID     *int32  `json:"id,omitempty"`
	Name   *string `json:"name,omitempty"`
	Admins []int32 `json:"admins,omitempty"`
	Apps   []int32 `json:"apps,omitempty"`
	Users  []int32 `json:"users,omitempty"`
}

omitempty タグがついたフィールドの値が空の場合、JSON へのエンコード時に当該フィールドが無視されます (JSON データに key 自体が含まれなくなる)。

The "omitempty" option specifies that the field should be omitted from the encoding if the field has an empty value, defined as false, 0, a nil pointer, a nil interface value, and any empty array, slice, map, or string.

https://pkg.go.dev/encoding/json#Marshal

API へのリクエストに含める JSON データを生成するタイミングで、値が空の Field が無視され、User への割り当て解除の情報が欠落しているのではないかと推測しています。(以下は構造体を JSON にエンコードしている処理、pkg/services/olhttp/olrequest.go からの抜粋)

// Update updates a resource in its remote location over HTTP
func (svc OLHTTPService) Update(r interface{}) ([]byte, error) {
	resourceRequest := r.(OLHTTPRequest)
	var (
		req    *http.Request
		reqErr error
	)
	if resourceRequest.Payload != nil {
		bodyToSend, marshErr := json.Marshal(resourceRequest.Payload)

これは OneLogin の API の仕様 に合わせた結果かと思いますが、気づかずに使うと意図しない操作をしてしまいそうですね。(Role から User の割り当てを外したいときはどうすればいいのだろう…)
omitempty タグが外れると、Mapping によって Role を割り当てている大量の User を指定せねばならなくなるので、それはそれで困りますし悩みどころです。。


OneLogin SDK の挙動に Terraform の仕様 (tfstate - tf 間の差分の有無)が組み合わさると、複雑度が増します。

tf ファイルで定義した意図 (期待した挙動)と、実際の挙動で違いが出てくる可能性があります。

  • User に直接 Role を割り当てている状態を解除したい

    • -> tf ファイル内で 引数 "users" から User ID を削除

      • -> が、OneLogin SDK の仕様で Update がかからない

  • Mapping による割り当てを変更するつもりが無かったが、tf ファイル内で "user" に User ID を記述した

    • -> 当該 User に Role が直接に割り当たる (※ Mapping のCondition にマッチしなくなっても割り当たり続ける)

などなど。。


Mapping

同様に進めます。

# main.tf

resource "onelogin_user_mappings" "org_dept" {}
% terraform import onelogin_user_mappings.org_dept 354633
onelogin_user_mappings.org_dept: Importing from ID "354633"...
onelogin_user_mappings.org_dept: Import prepared!
  Prepared onelogin_user_mappings for import
onelogin_user_mappings.org_dept: Refreshing state... [id=354633]

Import successful!

The resources that were imported are shown above. These resources are now in
your Terraform state and will henceforth be managed by Terraform.
# terraform.tfstate

...
    {
      "mode": "managed",
      "type": "onelogin_user_mappings",
      "name": "org_dept",
      "provider": "provider[\"registry.terraform.io/onelogin/onelogin\"]",
      "instances": [
        {
          "schema_version": 0,
          "attributes": {
            "actions": [
              {
                "action": "add_role",
                "value": [
                  "498619"
                ]
              }
            ],
            "conditions": [
              {
                "operator": "=",
                "source": "department",
                "value": "sales"
              }
            ],
            "enabled": true,
            "id": "354633",
            "match": "all",
            "name": "org_dept_sales",
            "position": 1
          },
          "sensitive_attributes": [],
          "private": "eyJzY2hlbWFfdmVyc2lvbiI6IjAifQ=="
        }
      ]
    },
...


main.tf の resouce の記述を更新します。

# main.tf

resource "onelogin_user_mappings" "org_dept" {
  name     = "org_dept_sales"
  enabled  = true
  match    = "all"
  position = 1


  actions {
    action = "add_role"
    value  = ["498619"]
  }

  conditions {
    operator = "="
    source   = "custom_attribute_contractType"
    value    = "full"
  }
}
% terraform plan -out=terraform.tfplan                   
onelogin_roles.org_dept: Refreshing state... [id=498619]
onelogin_users.testuser: Refreshing state... [id=161405449]
onelogin_user_mappings.org_dept: Refreshing state... [id=354633]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.

"No changes" と出力されることを確認できました。


特定のリソースを指定して plan を実行することもできますが、全体の依存関係が壊れてしまいかねないためか Warning が表示されます。このオプションは使わないほうが良さそうですね。

% terraform plan -target=onelogin_user_mappings.org_dept
onelogin_user_mappings.org_dept: Refreshing state... [id=354633]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.
╷
│ Warning: Resource targeting is in effect
│ 
│ You are creating a plan with the -target option, which means that the result of this plan may not represent all of the changes requested by the current configuration.
│ 
│ The -target option is not for routine use, and is provided only for exceptional situations such as recovering from errors or mistakes, or when Terraform specifically suggests to use it as part of an error message.
╵



検証中に気になったこと

複数のリソースをまとめて import したい

特に User は全員分を一度に import したいです。しかし現バージョンでは bulk のような処理はできず、1回の import で取得できるのは 1つのリソース のみのようです。リソース ID を 2 つ渡したら怒られました。

% terraform import onelogin_users.testuser 161405449 161405498
The import command expects two arguments.
Usage: terraform [global options] import [options] ADDR ID

OneLogin の Report 機能で User 情報を一括取得・加工するほうが効率が良さそうです。


Mapping と Role の依存関係

onelogin_user_mappings の conditions で Role の割り当て処理を定義しています。よって Mapping の作成前に対象の Role を作成しておく必要があります。

Terraform でこの依存関係を管理するために、Mapping から Role を参照する形にします。具体的には、直接 Role ID を記述せずに以下のように指定します。

# main.tf

resource "onelogin_user_mappings" "org_Ct_full" {
  name     = "org_Ct_full"
  enabled  = true
  match    = "all"
  position = 1


  actions {
    action = "add_role"
    value  = [onelogin_roles.org_Ct_full.id]
  }

  conditions {
    operator = "="
    source   = "custom_attribute_contractType"
    value    = "full"
  }
}

これにより、Terraform が リソース間の参照を検出して依存関係を把握し、リソースの作成順序コントロールしてくれるようです。

依存関係は graph コマンドで確認できます。

% terraform graph                        
digraph {
        compound = "true"
        newrank = "true"
        subgraph "root" {
                "[root] onelogin_roles.org_dept (expand)" [label = "onelogin_roles.org_dept", shape = "box"]
                "[root] onelogin_user_mappings.org_dept (expand)" [label = "onelogin_user_mappings.org_dept", shape = "box"]
                "[root] onelogin_users.testuser (expand)" [label = "onelogin_users.testuser", shape = "box"]
                "[root] provider[\"registry.terraform.io/onelogin/onelogin\"]" [label = "provider[\"registry.terraform.io/onelogin/onelogin\"]", shape = "diamond"]
                "[root] onelogin_roles.org_dept (expand)" -> "[root] provider[\"registry.terraform.io/onelogin/onelogin\"]"
                "[root] onelogin_user_mappings.org_dept (expand)" -> "[root] onelogin_roles.org_dept (expand)"
                "[root] onelogin_users.testuser (expand)" -> "[root] provider[\"registry.terraform.io/onelogin/onelogin\"]"
                "[root] provider[\"registry.terraform.io/onelogin/onelogin\"] (close)" -> "[root] onelogin_user_mappings.org_dept (expand)"
                "[root] provider[\"registry.terraform.io/onelogin/onelogin\"] (close)" -> "[root] onelogin_users.testuser (expand)"
                "[root] root" -> "[root] provider[\"registry.terraform.io/onelogin/onelogin\"] (close)"
        }
}

Graphviz というツールを使って、画像として描画することも可能なようです。

% terraform graph | dot -Tpng > graph.png
生成された graph.png


User へ Mapping が適用されるタイミング

「Terraform で User の作成・更新をした場合に Mapping は適用されるのか?」と気になりました。

onelogin-go-sdk を確認したところ Version 2 の API が使われており、API (V2) の仕様としては User の作成・更新時に Mapping が自動適用されるとのことでした。

Mappings
By default, mappings are run after the response is returned. If you rely on mappings to update a user value and you want that in the response then set the mappings query parameter to sync.

https://developers.onelogin.com/api-docs/2/users/create-user

Mappings
By default mappings will be run after the response for this API is returned. If you’re relying on mappings to update a user value and you want that in the response then set the mappings query parameter to sync.

https://developers.onelogin.com/api-docs/2/users/update-user


ただし、Mapping を新規に作成・更新した場合には、明示的に Reapply Enterprise Maping を実行するまで User への適用がされません。これは管理画面で手動操作した場合と同様です。


終わりに

「Terraform を使えば、対象サービスが違っても同様に管理できるんだろうな」と妄想していましたが、実際に検証してみると provider の挙動やサービスの API の仕様に影響を受ける部分があることを学べました。Mapping のような動的な要素が入る場合は Terraform で管理するのが難しいのかもしれないですね。検証して良かった。


Terraform はチュートリアルも豊富なので、気になった方はこちらからどうぞ!

IT/SaaS Providers」のカテゴリも用意されており、2021/01/11 時点では Azure AD, Google Workspace, GitHub の 3 サービスのコンテンツがありました。このページに載る SaaS は増えてくるのでしょうか。気になるところです 👀 👀

いただいたサポートは記事を書くためのエネルギー(珈琲、甘いもの)に変えさせていただきます!