この記事はFOLIO Advent Calendar 2022の12/6分の記事です。かなり遅れてサンタクロースがやってきてしまいました。

Azure Active Directory(Azure AD)には「エンタープライズアプリケーション」と「アプリ登録」と呼ばれる管理項目があります。 特に、SAMLやOIDCを利用するアプリケーションを登録するなど、運用する際に使用されることが多いでしょう。

今回はこれらをInfrastructure as Code(IaC)のデファクトスタンダードとなりつつあるTerraformで管理する方法を紹介します。

2023-01-22 追記

azuread terraform providerv2.33.0にて、新たにservice_principal_token_signing_certificateリソースが追加されました。 そのため、「サービスプロバイダがAzure ADをIdPとしてSAMLを使用して認証」する際の署名証明書の登録方法を、このリソースを使用する方法に変更しました。

Azure ADの構成をコード管理

Azure ADでアプリケーションを管理する場合、通常、Microsoft Azure Portal(Azure Portal)と呼ばれるポータルサイト上で作業します。 GUIの操作であるため、直感的でわかりやすく、ほとんどの方がこちらで作業をしていることでしょう。

しかし、なぜその変更を加えたのかの記録が残らず、複数人で変更する場合、その操作が適切なものなのかどうかを確認することが難しいです。

インフラ構成をコード管理することで、これらの課題を解決または解消できます。

今回紹介するTerraformでは、Terraformの開発元であるHashiCorp社が管理するAzure AD用のプロバイダを使用します。 このプロバイダは内部でMicrosoft Graph APIを使用しているため、Microsoft Graph APIで操作できるAzure ADの項目は一通り操作できます。

エンタープライズアプリケーションとアプリの登録について

アプリケーションをコード管理するにあたって、Azure Portalにおける管理項目でよく目にする「エンタープライズアプリケーション」と「アプリの登録」について理解しておく必要があります。

「アプリの登録」は、Azure ADに登録するSaaSなどのサービスプロバイダがMicrosoft Graph APIで要求するリソースへのアクセス管理や、サービスプロバイダがアプリケーションにアクセスするためのトークンを発行する方法などを定義します。 Azure ADではアプリケーションオブジェクトと呼ばれ、後述のサービスプリンシパルオブジェクトのひな型となります。

「エンタープライズアプリケーション」は、SaaSアプリケーションのSAMLによるSSOやユーザーやグループのプロビジョニング、Azure ADのユーザーやグループのアサインなどが設定できます。 エンタープライズアプリケーションで一覧化されているオブジェクトは、Azure ADではサービスプリンシパルオブジェクトと呼ばれます。

アプリケーションオブジェクトだけでは何も機能せず、実際にサービスプロバイダと認証情報のやりとりをするのがサービスプリンシパルオブジェクトです。

また、アプリケーションオブジェクトとサービスプリンシパルオブジェクトは、1対多の関係です。

詳しくは「Azure Active Directory のアプリケーション オブジェクトとサービス プリンシパル オブジェクト」を参照してください。

Terraformでアプリケーションを管理

今回は、アプリケーション管理で特に多く使用される、

  • サービスプロバイダがAzure ADをIdPとしてSAMLを使用して認証
  • サービスプロバイダがAzure ADをIdPとしてOIDCを使用して認証

の2つのユースケースをTerraformでコード管理してみます。

どちらのケースでも以下のように、Azure ADのプロバイダの設定をする必要があります。

terraform {
  required_providers {
    azuread = {
      source  = "hashicorp/azuread"
      version = "2.33.0" # 執筆時点の最新バーション
    }
  }
}

provider "azuread" {
  tenant_id = "ここにAzure ADのテナントIDをいれる"
}

また、今回はすでにAzure AD上に存在するkenchan0130ユーザーのみがサービスプロバイダにログインできることとします。

ユースケース1. サービスプロバイダがAzure ADをIdPとしてSAMLを使用して認証

今回は、手元にJamf Proのテナントを持っていたので、こちらのサービスプロバイダにログインできるように、Azure ADの構成をTerraform化します。

.
├── main.tf
└── provider.tf

provider.tf に前述したプロバイダーの設定、main.tf に今回のユースケースを構成します。 以下が最低限動作するコードです。

# main.tf
locals {
  jamf_pro_certificate_buffer_hour = "8760h" # 1年
}

resource "azuread_application" "jamf_pro" {
  display_name = "Jamf Pro by terraform"
  identifier_uris = ["api://example-jamf-pro"]

  web {
    redirect_uris = [
      "https://exmaple.jamfcloud.com/saml/SSO"
    ]
  }
}

resource "azuread_service_principal" "jamf_pro" {
  application_id                = azuread_application.jamf_pro.application_id
  app_role_assignment_required  = true
  preferred_single_sign_on_mode = "saml"

  feature_tags {
    enterprise            = true
    custom_single_sign_on = true
  }
}

data "azuread_user" "kenchan0130" {
  user_principal_name = "kenchan0130@exmaple.com"
}

resource "azuread_app_role_assignment" "jamf_pro" {
  for_each = toset([
    for user in [
      data.azuread_user.kenchan0130
    ] : user.object_id
  ])

  app_role_id         = "00000000-0000-0000-0000-000000000000" # default role ID
  resource_object_id  = azuread_service_principal.jamf_pro.object_id
  principal_object_id = each.value
}

resource "time_rotating" "jamf_pro_certificate" {
  rotation_years = 2
}

resource "azuread_service_principal_token_signing_certificate" "jamf_pro" {
  service_principal_id = azuread_service_principal.jamf_pro.id
  end_date             = timeadd(time_rotating.jamf_pro_certificate.rotation_rfc3339, local.jamf_pro_certificate_buffer_hour)
}

resource "null_resource" "jamf_pro_signing_certificate_activation" {
  triggers = {
    service_principal_id = azuread_service_principal.jamf_pro.id
    thumbprint           = azuread_service_principal_token_signing_certificate.jamf_pro.thumbprint
  }

  provisioner "local-exec" {
    command = <<-SHELL
      az ad sp update \
        --id ${self.triggers.service_principal_id} \
        --set preferredTokenSigningKeyThumbprint=${self.triggers.thumbprint}
SHELL
  }
}

それぞれのリソースについて、行っている内容や注意点などを見ていきます。

SAMLの構成に伴うazuread_applicationリソース

azuread_applicationは、Azure ADのアプリケーションオブジェクトを作成します。

resource "azuread_application" "jamf_pro" {
  display_name = "Jamf Pro by terraform"
  identifier_uris = ["api://example-jamf-pro"]

  web {
    redirect_uris = [
      "https://exmaple.jamfcloud.com/saml/SSO"
    ]
  }
}

webブロックのredirect_urisは、SAMLのAssertion Consumer Service(ACS)に該当します。

identifier_urisはSAMLのエンティティIDに該当します。エンティティIDはRFC7522でURIであるであることが定められています。 また、エンティティIDはAzure ADのテナント内で一意、つまり重複しないように設定する必要があります。

identifier_urisを設定するとアプリケーションオブジェクトが作成できない問題

azuread_applicationidentifier_urishttps://exmaple.jamfcloud.com/saml/metadata を設定しようとするとterraform apply時に以下のようなエラーが発生しました。

╷
│ Error: Could not create application
│
│   with azuread_application.jamf_pro,
│   on main.tf line 1, in resource "azuread_application" "jamf_pro":
│    1: resource "azuread_application" "jamf_pro" {
│
│ ApplicationsClient.BaseClient.Post(): unexpected status 400 with OData error: HostNameNotOnVerifiedDomain: Values of identifierUris property must use a
│ verified domain of the organization or its subdomain: 'https://example.jamfcloud.com/saml/metadata'
╵

原因は、「シングル テナント アプリケーションの AppId URI には、既定のスキームまたは検証済みドメインを使用する必要があります」です。 つまり、設定できるのは、検証済みのドメインのURIとapi://で始まるデフォルトスキームのURIのみです。 Azure Portalでアプリケーションオブジェクトを作成するとこの制約に引っかからないため、API経由のみの制約であると思われます。

そのため今回の例では、api://example-jamf-proというデフォルトスキームURIを採用しました。

もし、どうしても検証済みではないURIをエンティティIDとして使用したい場合は、2つの解決方法があります。

1つ目は、2回terraform applyを実行する方法です。

1回目は、identifier_urisは設定せずにterraform applyを実行します。

resource "azuread_application" "jamf_pro" {
  display_name = "Jamf Pro by terraform"

  web {
    redirect_uris = [
      "https://exmaple.jamfcloud.com/saml/SSO"
    ]
  }
}

アプリケーションオブジェクトが作成されたら、identifier_urisに検証されていないドメインを含むURIを設定し、terraform applyを実行します。

resource "azuread_application" "jamf_pro" {
  display_name = "Jamf Pro by terraform"
  identifier_uris = ["https://example.jamfcloud.com/saml/metadata"]

  web {
    redirect_uris = [
      "https://exmaple.jamfcloud.com/saml/SSO"
    ]
  }
}

この方法は、どこインフラでも同じ構成を実現するというIaCの目的から若干外れてしまいますが、特別なハックをしなくても良いというのがメリットではあります。

2つ目は、azuread_applicationリソースではidentifier_urisを設定せずに、別のリソースでidentifier_urisを設定する方法です。

resource "azuread_application" "jamf_pro" {
  display_name = "Jamf Pro by terraform"

  web {
    redirect_uris = [
      "https://onishidev.jamfcloud.com/saml/SSO"
    ]
  }

  lifecycle {
    ignore_changes = [
      identifier_uris
    ]
  }
}

resource "null_resource" "application_jamf_pro_identifier_uris" {
  # triggersに設定されているされている値が変更された場合、再度local-execが実行される
  triggers = {
    application_id  = azuread_application.jamf_pro.application_id
    # triggersはobject(string)の型を要求するため、スペース文字でjoinしている
    identifier_uris = join(" ", [
      "https://exmaple.jamfcloud.com/saml/metadata"
    ])
  }

  provisioner "local-exec" {
    command = <<-SHELL
      az ad app update --id ${self.triggers.application_id} --identifier-uris ${self.triggers.identifier_uris}
SHELL
  }
}

azuread_applicationリソースでは、identifier_urisは設定しません。 合わせて別のリソースで更新されるため、lifecycleブロックのignore_changesidentifier_urisを指定しておきます。

null_resourceリソースのlocal-execprovisionerを使うことで、ローカルの実行ファイルを呼び出せます。 これを使って、Azureのリソースを操作できるCLIであるazコマンドを介してアプリケーションオブジェクトのidentifier_urisを更新しています。1

実行環境に依存してしまう点がデメリットですが、何が起こるのかがtfファイルから読み取れるメリットがあります。

アプリケーションオブジェクトの更新で検証されていないドメインを含むURIを設定できるなら2、作成時にも設定させてほしいです。どうしてこんな変更入れてしまったの、Microsoftさん。

SAMLの構成に伴うazuread_service_principalリソース

azuread_service_principalは、Azure ADのサービスプリンシパルオブジェクトを作成します。

resource "azuread_service_principal" "jamf_pro" {
  application_id                = azuread_application.jamf_pro.application_id
  app_role_assignment_required  = true
  preferred_single_sign_on_mode = "saml"

  feature_tags {
    enterprise            = true
    custom_single_sign_on = true
  }
}

app_role_assignment_requiredが無効の場合(デフォルトでは無効)はテナントに存在するすべてのアカウントでログインできます。 今回のユースケースのようにサービスプロバイダにログインできる人を制限したい場合は、app_role_assignment_requiredを有効にする必要があります。

また、preferred_single_sign_on_modesamlにするだけでは、SAML認証ができません。 feature_tagsブロックのcustom_single_sign_onも合わせて有効にする必要があります。

合わせて、feature_tagsブロックのenterpriseを有効にすることで、Azure Portal上でエンタープライズアプリケーションとして検索できるため、特段理由がない限りは有効にしておくと良いでしょう。

SAMLの構成に伴うazuread_app_role_assignmentリソース

azuread_app_role_assignmentは、リソースアプリケーションで定義されているアプリケーションのロールを、プリンシパル(ユーザー、グループ、またはサービスプリンシパル)に紐付けるためのリソースです。

data "azuread_user" "kenchan0130" {
  user_principal_name = "kenchan0130@exmaple.com"
}

resource "azuread_app_role_assignment" "jamf_pro" {
  for_each = toset([
    for user in [
      data.azuread_user.kenchan0130
    ] : user.object_id
  ])

  app_role_id         = "00000000-0000-0000-0000-000000000000" # default role ID
  resource_object_id  = azuread_service_principal.jamf_pro.object_id
  principal_object_id = each.value
}

azuread_applicationapp_roleブロックでアプリケーションのロールを定義できます。 たとえは、ロールによってサービスプロバイダに渡す値を動的に変更することで、権限管理を柔軟に行えます。

今回特にロールは定義しておらず、サービスプロバイダにログインできる人を制限したいだけですので、デフォルトロールを使用してサービスプリンシパルオブジェクトとユーザーオブジェクトの紐付けを行っています。

往々にして、サービスプリンシパルオブジェクトには複数のユーザーおよびグループオブジェクトを紐付けたくなります。そのため、今回はfor_eachを使用して複数のユーザーおよびグループオブジェクトを紐付けれるようにしてみました。

SAMLの構成に伴うazuread_service_principal_token_signing_certificateリソース

azuread_service_principal_token_signing_certificateは、Azure ADのサービスプリンシパルオブジェクトに紐付ける署名証明書を発行します。

似たようなものにazuread_service_principal_certificateリソースがありますが、これはローカルやterraform上で署名証明書がある場合に使用するリソースです。3

locals {
  jamf_pro_certificate_buffer_hour = "8760h" # 1年
}

resource "time_rotating" "jamf_pro_certificate" {
  rotation_years = 2
}

resource "azuread_service_principal_token_signing_certificate" "jamf_pro" {
  service_principal_id = azuread_service_principal.jamf_pro.id
  end_date             = timeadd(time_rotating.jamf_pro_certificate.rotation_rfc3339, local.jamf_pro_certificate_buffer_hour)
}

resource "null_resource" "jamf_pro_signing_certificate_activation" {
  triggers = {
    service_principal_id = azuread_service_principal.jamf_pro.id
    thumbprint           = azuread_service_principal_token_signing_certificate.jamf_pro.thumbprint
  }

  provisioner "local-exec" {
    command = <<-SHELL
      az ad sp update \
        --id ${self.triggers.service_principal_id} \
        --set preferredTokenSigningKeyThumbprint=${self.triggers.thumbprint}
SHELL
  }
}

time_rotatingリソースとtimeadd関数を使用することで、azuread_service_principal_token_signing_certificateリソースのend_time3年として登録し、terraformをapplyして2年以降に再度terraformをapplyすると証明書の更新が自動で実行されるようにしています。

署名証明書が自動で更新されたくない場合は、time_rotatingリソースは使わずに、end_dateをベタ書きで指定、または何も指定しない(デフォルトでは3年)ことで対応可能です。

resource "azuread_service_principal_token_signing_certificate" "jamf_pro" {
  service_principal_id = azuread_service_principal.jamf_pro.id
  end_date             = "2023-05-01T01:02:03+09:00" # この値を変更すると、署名証明書が再発行される
}

azuread_service_principal_token_signing_certificateで署名証明書を設定してもアクティブにならない問題

azuread_service_principal_token_signing_certificateリソースでサービスプリンシパルオブジェクトに署名証明書を紐付けたとしても、証明書が有効になっておらず非アクティブの状態です。

残念ながら、署名証明書をアクティブにするTerraformリソースは存在しません。 執筆現在、サービスプリンシパルオブジェクトにPATCHリクエストを投げることでしか署名証明書をアクティブにできないためです。端的に言うと、Microsoft Graph APIの作りがイマイチなのです。

resource "null_resource" "jamf_pro_signing_certificate_activation" {
  triggers = {
    service_principal_id = azuread_service_principal.jamf_pro.id
    thumbprint           = azuread_service_principal_token_signing_certificate.jamf_pro.thumbprint
  }

  provisioner "local-exec" {
    command = <<-SHELL
      az ad sp update \
        --id ${self.triggers.service_principal_id} \
        --set preferredTokenSigningKeyThumbprint=${self.triggers.thumbprint}
SHELL
  }
}

null_resource以外にも、azuread_service_principal_token_signing_certificateリソース内でもprovisionerブロックを使用できます。しかし運用上、差分検知の文脈からnull_resourceで設定することをお勧めします。

provisionerブロック内では、Azureのリソースを操作できるCLIであるazコマンドを介してサービスプリンシパルオブジェクトのpreferredTokenSigningKeyThumbprintを更新します。1

preferredTokenSigningKeyThumbprintに証明書のThumbprintが設定されると、サービスプリンシパルオブジェクトに紐付いている署名証明書がアクティブになります。

ユースケース2. サービスプロバイダがAzure ADをIdPとしてOIDCを使用して認証

今回は、Microsoft社がチュートリアルとして出しているOIDCを使用したNode.jsのWebアプリケーションのサービスプロバイダにログインできるように、Azure ADの構成をTerraform化します。

.
├── main.tf
└── provider.tf

provider.tf に前述したプロバイダーの設定、main.tf に今回のユースケースを構成します。 以下が最低限動作するコードです。

# main.tf
data "azuread_application_published_app_ids" "well_known" {}

data "azuread_service_principal" "msgraph" {
  application_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph
}

locals {
  oidc_app_msgraph_api_scopes = [
    "openid",
    "profile",
    "offline_access",
    "Mail.Read"
  ]
}

resource "azuread_application" "oidc_app" {
  display_name = "Azure Active Directory OIDC Node.js web app sample by terraform"

  web {
    redirect_uris = [
      "http://localhost:3000/auth/openid/return"
    ]

    implicit_grant {
      id_token_issuance_enabled = true
    }
  }

  required_resource_access {
    resource_app_id = data.azuread_service_principal.msgraph.application_id

    dynamic "resource_access" {
      for_each = local.oidc_app_msgraph_api_scopes
      content {
        id   = data.azuread_service_principal.msgraph.oauth2_permission_scope_ids[resource_access.value]
        type = "Scope"
      }
    }
  }
}

resource "azuread_application_password" "oidc_app" {
  application_object_id = azuread_application.oidc_app.object_id
  display_name          = "Webアプリケーションで使用するシークレット"
  end_date_relative     = "2400h30m"
}

resource "azuread_service_principal" "oidc_app" {
  application_id                = azuread_application.oidc_app.application_id
  app_role_assignment_required  = true
  preferred_single_sign_on_mode = "oidc"

  feature_tags {
    enterprise = true
  }
}

data "azuread_user" "kenchan0130" {
  user_principal_name = "kenchan0130@example.com"
}

resource "azuread_app_role_assignment" "oidc_app" {
  for_each = toset([
    for user in [
      data.azuread_user.kenchan0130
    ] : user.object_id
  ])

  app_role_id         = "00000000-0000-0000-0000-000000000000" # default role ID
  resource_object_id  = azuread_service_principal.oidc_app.object_id
  principal_object_id = each.value
}

resource "azuread_service_principal_delegated_permission_grant" "oidc_app" {
  service_principal_object_id          = azuread_service_principal.oidc_app.object_id
  resource_service_principal_object_id = data.azuread_service_principal.msgraph.object_id
  claim_values                         = local.oidc_app_msgraph_api_scopes
}

それぞれのリソースについて、行っている内容やハマった内容などを見ていきます。

OIDCの構成に伴うazuread_applicationリソース

azuread_applicationは、Azure ADのアプリケーションオブジェクトを作成します。

data "azuread_application_published_app_ids" "well_known" {}

data "azuread_service_principal" "msgraph" {
  application_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph
}

locals {
  oidc_app_msgraph_api_scopes = [
    "openid",
    "profile",
    "offline_access",
    "Mail.Read"
  ]
}

resource "azuread_application" "oidc_app" {
  display_name = "Azure Active Directory OIDC Node.js web app sample by terraform"

  web {
    redirect_uris = [
      "http://localhost:3000/auth/openid/return"
    ]

    implicit_grant {
      id_token_issuance_enabled = true
    }
  }

  required_resource_access {
    resource_app_id = data.azuread_service_principal.msgraph.application_id

    dynamic "resource_access" {
      for_each = local.oidc_app_msgraph_api_scopes
      content {
        id   = data.azuread_service_principal.msgraph.oauth2_permission_scope_ids[resource_access.value]
        type = "Scope"
      }
    }
  }
}

webブロックのredirect_urisは、OIDCのリダイレクトURIに該当します。

今回のサービスプロバイダはOIDCのハイブリッド フローを有効化するように求められているため、implicit_grantブロックのid_token_issuance_enabledを有効にしています。

また、サービスプロバイダはスコープとして、

  • openid
  • profile
  • offline_access
  • https://graph.microsoft.com/mail.read

を要求しています。 これらのアクセス許可を定義するため、required_resource_accessブロックでMicrosoft Graph APIで定義されているスコープを許可しています。

Microsoft Graph APIではUUIDでスコープを定義しますが、UUIDが扱いづらいため、Azure ADのTerraformプロバイダにはこれらをうまく参照するためのデータソースが用意されています。

azuread_application_published_app_idsは、Azure ADのTerraformプロバイダが非公式に用意している4、Microsoft社がAzure ADにすでに用意しているアプリケーションのUUID一覧です。

azuread_service_principalは、すでにAzure ADに存在するサービスプリンシパルオブジェクトを参照するためデータソースです。

これらのデータソースにより、比較的簡単に許可するスコープを定義できます。

ちなみに、スコープが複数あったため、resource_accessブロックをdynamicブロックで記載していますが、以下と同義です。

required_resource_access {
  resource_app_id = data.azuread_service_principal.msgraph.application_id

  resource_access {
    id   = data.azuread_service_principal.msgraph.oauth2_permission_scope_ids.openid
    type = "Scope"
  }

  resource_access {
    id   = data.azuread_service_principal.msgraph.oauth2_permission_scope_ids.profile
    type = "Scope"
  }

  resource_access {
    id   = data.azuread_service_principal.msgraph.oauth2_permission_scope_ids.offline_access
    type = "Scope"
  }

  resource_access {
    id   = data.azuread_service_principal.msgraph.oauth2_permission_scope_ids["Mail.Read"]
    type = "Scope"
  }
}

OIDCの構成に伴うazuread_application_passwordリソース

azuread_application_passwordは、Azure ADのアプリケーションオブジェクトに紐付く、クライアントクレデンシャルフローで使用する、クライアントシークレットを作成します。

resource "azuread_application_password" "oidc_app" {
  application_object_id = azuread_application.oidc_app.object_id
  display_name          = "Webアプリケーションで使用するシークレット"
  end_date_relative     = "2400h30m"
}

azuread_application_passwordリソースを使用すると、tfstateファイルにクライアントシークレットの情報が残ってしまいます。 そのため、tfstateファイルを安全に取り扱うなどの対応が必要です。

tfstateに秘密情報を残したくない場合は、Terraformを経由せずにAzure PortalやMicrosoft Graph APIを利用してクライアントシークレットを発行すると良いでしょう。

OIDCの構成に伴うazuread_service_principalリソース

azuread_service_principalは、Azure ADのサービスプリンシパルオブジェクトを作成します。

resource "azuread_service_principal" "oidc_app" {
  application_id                = azuread_application.oidc_app.application_id
  app_role_assignment_required  = true
  preferred_single_sign_on_mode = "oidc"

  feature_tags {
    enterprise = true
  }
}

app_role_assignment_requiredが無効の場合(デフォルトでは無効)はテナントに存在するすべてのアカウントでログインできます。 今回のユースケースのようにサービスプロバイダにログインできる人を制限したい場合は、app_role_assignment_requiredを有効にする必要があります。

preferred_single_sign_on_modeoidcにすると、Azure Portal上のエンタープライズアプリケーションの「シングル サインオン」項目に、「アプリ登録」ページへのリンクとJWTのクレームマッピングのカスタム構成ページへのリンクが表示されます。

feature_tagsブロックのenterpriseを有効にすることで、Azure Portal上でエンタープライズアプリケーションとして検索できるため、特段理由がない限りは有効にしておくと良いでしょう。

OIDCの構成に伴うazuread_app_role_assignmentリソース

こちらについてはSAMLの構成に伴うazuread_app_role_assignmentリソースと同様のため省略します。

OIDCの構成に伴うazuread_service_principal_delegated_permission_grantリソース

azuread_service_principal_delegated_permission_grantは、Azure ADのサービスプリンシパルオブジェクトのAPIアクセス許可の権限委譲が設定できます。

locals {
  oidc_app_msgraph_api_scopes = [
    "openid",
    "profile",
    "offline_access",
    "Mail.Read"
  ]
}

resource "azuread_service_principal_delegated_permission_grant" "oidc_app" {
  service_principal_object_id          = azuread_service_principal.oidc_app.object_id
  resource_service_principal_object_id = data.azuread_service_principal.msgraph.object_id
  claim_values                         = local.oidc_app_msgraph_api_scopes
}

今回のサービスプロバイダはスコープとして、

  • openid
  • profile
  • offline_access
  • https://graph.microsoft.com/mail.read

を要求しており、azuread_applicationリソースのrequired_resource_accessでAPIアクセスの許可を定義していました。

しかし、実際にこのスコープにアクセスするには、アプリケーションオブジェクトではなく、サービスプリンシパルオブジェクトごとに管理者の同意が必要です。

OIDCでログインする際に管理者権限でAPIアクセスの許可を委任してない場合に表示されるダイアログ

azuread_service_principal_delegated_permission_grantリソースでは、この管理者の同意を設定できます。

claim_valuesは、required_resource_accessのようにUUIDではなく、クレームのキーを指定します。 Microsoft Graph APIの一貫性がなくて使いづらい点がにじみ出てきてしまっているのが残念ですが、今回の例ようにlocalsブロックなどで、変数としてスコープを定義すると扱いやすくなります。

クレームマッピングについて

SAMLではアサーション、OIDCではJWTクレームを介して、サービスプロバイダに値を渡したい場合があります。 その際は、クレームマッピングが使用できます。

以下は、SAMLの構成でクレームマッピングを使い、NameIDにディスプレイ名を割り当てた例です。

resource "azuread_claims_mapping_policy" "jamf_pro" {
  display_name = "Jamf Pro Custom Claim Policy"
  definition = [
    jsonencode(
      {
        ClaimsMappingPolicy = {
          ClaimsSchema = [
            {
              ID            = "displayname"
              SamlClaimType = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier"
              Source        = "user"
            }
          ]
          IncludeBasicClaimSet = "true"
          Version              = 1
        }
      }
    )
  ]
}

resource "azuread_service_principal_claims_mapping_policy_assignment" "jamf_pro" {
  claims_mapping_policy_id = azuread_claims_mapping_policy.jamf_pro.id
  service_principal_id     = azuread_service_principal.jamf_pro.id
}

しかし、このAPIを使用すると、Azure Portal上で現在のクレームマッピングの情報が表示されず、クレームの変更もできなくなってしまいます。

Azure PortalにおけるSAMLの属性とクレームの変更画面

公式ドキュメントにも、

これは、Azure portal で提供しているクレームのカスタマイズの後継機能です。 同一のアプリケーションに対し、このドキュメントで詳述する Microsoft Graph または Powershell を利用した方法に加えて、Azure portal でもクレームをカスタマイズした場合、そのアプリケーションのトークンでは、Azure portal での構成は無視されます。 このドキュメントで詳しく説明した方法で行った構成は、ポータルには反映されません。

と記載されており、仕様のようです。

この仕様は場合によっては運用上支障が出る可能性があるため、注意が必要です。

終わりに

今回はTerraformを使用して、

  • サービスプロバイダがAzure ADをIdPとしてSAMLを使用して認証
  • サービスプロバイダがAzure ADをIdPとしてOIDCを使用して認証

の構築例を紹介しました。

これは最低限の設定であったため、その他にも設定したい項目がある場合はTerraformのプロバイダの公式ドキュメントMicrosoft Graphの公式ドキュメントを参考に対応してみると良いと思います。

  1. なるべく依存を少なくするためにazコマンドを使わずに、Microsoft Graph APIを直接呼び出しても問題ありません。  2

  2. 更新APIを経由して、api://example.jamfcloud.comのように検証されていないドメインを含むデフォルトスキームを設定するとエラーになる 

  3. azuread_service_principal_certificateリソースを使用してterraform内で完結させる例は「TLS with Terraform and Azure: generate self-signed certificates」にまとめられている 

  4. Azure ADのTerraformのプロバイダが使用しているhamiltonライブラリで定義されている