Auth0をSAML IdPにしたCloudWatchダッシュボード共有をTerraformで行う

# はじめに
Auth0活用記事第3弾。
第一弾では、AWSのマネージメントコンソールログインをSAML認証で、第二弾では、Amazon Managed GrafanaへのログインをAuth0をIdPに設定して行えるようにした。

https://qiita.com/neruneruo/items/14ff2e9d53eb9c082faa

https://qiita.com/neruneruo/items/1db758fceec17077dbcc

今回は、CloudWatchダッシュボード共有を行う際のログインをAuth0を使って行えるようにする。
また、例によってTerraformで自動構築をできるようにしていく。

なお、CloudWatchダッシュボードについては、以前書いた以下の記事で作成する方法をまとめているため、今回の記事では作成部分は割愛する。

https://qiita.com/neruneruo/items/454dc01c489d11d76ee8

:::note warn
本記事では、Terraformのバージョン1.7.0以上の機能(importブロックのfor_each対応)を前提に構築を行っている。
古いバージョンでは実現できない箇所があるので気を付けてご留意いただきたい。
:::

# 全体の流れ
CloudWatchダッシュボードの共有は、なぜかCLIで実装されておらず、利用者向けのAPIも公開されていない。
さらに、固定の名前のリソースがus-east-1(米国(バージニア北部))で必要というかなり癖がある作りになっている。

そのため、以下のような流れで作っていく。

1. 固定名のリソースのうち、あらかじめ作れるものをTerraformで作っておく
2. AWSマネージメントコンソールから、CloudWatchダッシュボードを共有する操作を行う
3. AWSが自動で作ったリソースをTerraformのtfstateにImportし、IAMポリシー等を必要な情報で書き換える

# あらかじめ作れるリソースを作っておく
まずは、3ステップ目に作るリソースが初回のApply時に取り込まれないように以下の変数を設定する。

```HCL
variable "import_saml_provider" {
  type    = bool
  default = true
}
```

順を追って段階的にリソースのHCLを書いていくのであれば不要だが、実際の環境構築を行う際は全部のHCLが揃った状態からスタートするであろうことを想定して作成している。

デフォルト値をtrueにしているのは、falseで作成する必要があるのは初回のみであるためだ。これで、今後メンテナンスするときにいちいち変数を設定しなくても済む。

## AWS(Amazon Cognito)の設定
AWSでは、Amazon CognitoからAuth0にアクセス可能とする設定を行っておく。
前述の通り、固定の予約名が必要であるため、```CloudWatchDashboardSharing```と言う名前で、us-east-1(米国(バージニア北部))リージョンにAmazon Cognitoユーザープールを作成する。

```HCL
resource "aws_cognito_user_pool" "example" {
  provider = aws.virginia

  name                = "CloudWatchDashboardSharing"
  username_attributes = ["email"]
}

resource "aws_cognito_user_pool_domain" "example" {
  provider = aws.virginia

  user_pool_id = aws_cognito_user_pool.example.id
  domain       = local.cognito_domain_name
}

resource "aws_cognito_identity_provider" "example" {
  provider = aws.virginia

  user_pool_id  = aws_cognito_user_pool.example.id
  provider_name = "Auth0"
  provider_type = "SAML"

  provider_details = {
    IDPInit                 = true
    IDPSignout              = true
    EncryptedResponses      = false
    MetadataURL             = "https://${var.auth0_domain}/samlp/metadata/${auth0_client.cognito.id}"
    RequestSigningAlgorithm = "rsa-sha256"
  }

  attribute_mapping = {
    email = "email"
  }

  lifecycle {
    ignore_changes = [
      provider_details
    ]
  }
}
```

## Auth0の設定
今回、Auth0のユーザをAmazon Cognitoのユーザープールに紐づける。そのためのAuth0クライアントリソースを、Auth0側で作っておこう。

本記事ではリソースの分かりやすさ重視で分けて記載しているが、実際は、```aws_cognito_user_pool```, ```aws_cognito_user_pool_domain```を元に```auth0_client```のリソースを作り、さらにそのメタデータURLを使って```aws_cognito_identity_provider```を作るという流れになっている。

```callbacks```やSAMLプロバイダの```audience```属性には、Amazon Cognito指定のものを設定する。
また、mappingsはemailに関するマッピングが出来ていれば良い。

```HCL
resource "auth0_client" "cognito" {
  name        = "Amazon Cognito"
  description = "AWS Cognito Login"
  app_type    = "regular_web"

  custom_login_page_on                = true
  is_first_party                      = true
  is_token_endpoint_ip_header_trusted = false
  oidc_conformant                     = true
  require_proof_of_possession         = false

  callbacks = ["https://${aws_cognito_user_pool_domain.example.domain}.auth.us-east-1.amazoncognito.com/saml2/idpresponse"]

  grant_types = [
    "authorization_code",
    "implicit",
    "refresh_token",
    "client_credentials",
  ]

  jwt_configuration {
    alg                 = "RS256"
    lifetime_in_seconds = 36000
    secret_encoded      = false
  }

  refresh_token {
    leeway          = 0
    token_lifetime  = 31557600
    rotation_type   = "non-rotating"
    expiration_type = "non-expiring"
  }

  addons {
    samlp {
      audience = "urn:amazon:cognito:sp:${aws_cognito_user_pool.example.id}"
      mappings = {
        email = "email"
      }
      create_upn_claim                   = false
      passthrough_claims_with_no_mapping = false
    }
  }
}
```

:::note warn
```aws_cognito_identity_provider```の```EncryptedResponses```属性は、この時点でfalseにしているが、暗号化要件次第ではtrueに設定しよう。ただし、Auth0のクライアントはデフォルトでは暗号化をしない。暗号化応答するためには証明書の設定等が必要だ。Auth0の暗号化設定は本記事の趣旨からやや外れるため、最後で補足をする。
:::

ここまでのリソースで、一旦```terraform apply -var import_saml_provider=false```を実行しておく。

# CloudWatchダッシュボードを共有する操作を行う
こちらも前述の通り、CloudWatchダッシュボードはAWSマネージメントコンソールからしかできないため、Amazon CloudWatchのコンソールを開いて以下のように「ダッシュボードの共有」ボタンを押す。

![キャプチャ1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/81f8082d-b44f-a488-8682-b4139ab1cdae.png)

次のページで、「シングルサインオン (SSO) を使用してアカウントの CloudWatch ダッシュボードをすべて共有する」の「CloudWatch設定」のボタンを押す。

![キャプチャ2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/0c968104-eb45-80b9-f5a9-f8dd90ad6d43.png)

リソースが上手く作られている場合、プルダウンで「Auth0」が選択できる状態になっているので、「変更を保存」ボタンを押下する。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/673d38f3-bb3e-be92-bb1b-dde7b674020e.png)

:::note info
この操作を行うことでリソースが自動で作られるため、作業を途中でやめる場合はゴミが残らないように注意。次の章で記載するリソースの手動削除を行う必要がある。
:::

# AWSが自動で作ったリソースをTerraformのtfstateにImportする
さて、ここからのリソースは基本的にすべてimport_saml_provider=trueの場合しか作らないので、countまたはfor_eachを利用する。

## AWS(Amazon Cognito)の設定
### ユーザープールクライアント
ユーザープールのクライアントが、```CloudWatchDashboardSharing```のユーザープール内に作成されるため、おこれをImportする。なお、このリソース名も固定で「CloudWatchDashboardSharingFederated」である。設定内容は、自動で作られるリソースから差分がないようにしたものである。

なお、importするためにはユーザープールクライアントのIDが必要であるため、まずはデータリソースでIDを参照してからimportを行う。

```HCL
data "aws_cognito_user_pool_clients" "example" {
  provider = aws.virginia
  count    = var.import_saml_provider == true ? 1 : 0

  user_pool_id = aws_cognito_user_pool.example.id
}

import {
  provider = aws.virginia
  for_each = toset(var.import_saml_provider == true ? data.aws_cognito_user_pool_clients.example.0.client_ids : [])

  id = "${aws_cognito_user_pool.example.id}/${each.key}"
  to = aws_cognito_user_pool_client.example[each.key]
}

resource "aws_cognito_user_pool_client" "example" {
  provider = aws.virginia
  for_each = toset(var.import_saml_provider == true ? data.aws_cognito_user_pool_clients.example.0.client_ids : [])

  user_pool_id = aws_cognito_user_pool.example.id
  name         = "CloudWatchDashboardSharingFederated"

  supported_identity_providers         = ["Auth0"]
  allowed_oauth_flows_user_pool_client = true
  allowed_oauth_flows                  = ["code", "implicit"]
  allowed_oauth_scopes                 = ["aws.cognito.signin.user.admin", "email", "openid", "profile"]

  callback_urls = ["https://cloudwatch.amazonaws.com/dashboard.html"]
  logout_urls   = ["https://cloudwatch.amazonaws.com/dashboard.html"]

  prevent_user_existence_errors = "ENABLED"
}
```

### アイデンティティプール
IDプールも同様にImportする。こちらもimport実行時はIDプールのIDが必要になるため、データソースで一度固定の名前で参照をする。

```client_id```が、for_eachで作ったリソースを参照するためにカオスな呼び方になっている……。

```HCL
data "aws_cognito_identity_pool" "example" {
  provider = aws.virginia
  count    = var.import_saml_provider == true ? 1 : 0

  identity_pool_name = "CloudWatchDashboardSharing"
}

import {
  provider = aws.virginia
  for_each = toset(var.import_saml_provider == true ? [data.aws_cognito_identity_pool.example.0.id] : [])

  id = each.value
  to = aws_cognito_identity_pool.example[each.key]
}

resource "aws_cognito_identity_pool" "example" {
  provider = aws.virginia
  for_each = toset(var.import_saml_provider == true ? [data.aws_cognito_identity_pool.example.0.id] : [])

  identity_pool_name               = "CloudWatchDashboardSharing"
  allow_unauthenticated_identities = false
  allow_classic_flow               = false

  cognito_identity_providers {
    client_id     = aws_cognito_user_pool_client.example[data.aws_cognito_user_pool_clients.example.0.client_ids[0]].id
    provider_name = aws_cognito_user_pool.example.endpoint
  }
}
```

## AWS(IAM)の設定
IAMにもリソースが自動で作られる。```CloudWatchDashboard-ReadOnlyAccess-ALL-XXXXXXXX```(XXXXXXXXはランダムな8桁の文字列)という名前で作られるため、こちらもデータソースで参照してロール名をピンポイントに取得して設定する。

なお、設定するロールはサービスロールなのでpathの設定が必要だ(これがないと正しく動作しない)。

Amazon Cognitoに紐づくロールであるため、通常のIAMロールと信頼関係の設定が異なることには注意。

```HCL
ata "aws_iam_roles" "auth0" {
  count = var.import_saml_provider == true ? 1 : 0

  name_regex = "CloudWatchDashboard-ReadOnlyAccess-ALL-*"
}

import {
  for_each = toset(var.import_saml_provider == true ? data.aws_iam_roles.auth0.0.names : [])

  id = each.key
  to = aws_iam_role.auth0[each.key]
}

resource "aws_iam_role" "auth0" {
  for_each = toset(var.import_saml_provider == true ? data.aws_iam_roles.auth0.0.names : [])

  name               = each.key
  path               = "/service-role/"
  description        = "Role to display ALL account's dashboards with metrics and alarms"
  assume_role_policy = data.aws_iam_policy_document.auth0_assume.0.json
}

data "aws_iam_policy_document" "auth0_assume" {
  count = var.import_saml_provider == true ? 1 : 0

  statement {
    effect = "Allow"

    principals {
      type        = "Federated"
      identifiers = ["cognito-identity.amazonaws.com"]
    }

    actions = [
      "sts:AssumeRoleWithWebIdentity",
    ]

    condition {
      test     = "StringEquals"
      variable = "cognito-identity.amazonaws.com:aud"
      values   = [data.aws_cognito_identity_pool.example.0.id]
    }

    condition {
      test     = "ForAnyValue:StringLike"
      variable = "cognito-identity.amazonaws.com:amr"
      values   = ["authenticated"]
    }
  }
}
```

IAMポリシーも、IAMロールと同じ名前で作られる(ポリシーだがパスにもroleと付与される)。
こちらはimportに際してARNが必要だが、既に取得したデータソース等を駆使すれば作れるので、以下の通りに設定しよう。

デフォルトでは、このポリシーにはCloudWatchダッシュボードを共有するための1つ目のステートメントしか付与されていない。たとえば、カスタムウィジェットでLambdaを実行するダッシュボードである場合は、以下の2つ目のステートメントのように追加で権限を付与する必要がある。

```HCL
import {
  for_each = toset(var.import_saml_provider == true ? data.aws_iam_roles.auth0.0.names : [])

  id = "arn:aws:iam::${data.aws_caller_identity.self.id}:policy/service-role/${each.key}"
  to = aws_iam_policy.auth0[each.key]
}

resource "aws_iam_policy" "auth0" {
  for_each = toset(var.import_saml_provider == true ? data.aws_iam_roles.auth0.0.names : [])

  name = each.key
  path = "/service-role/"

  policy = data.aws_iam_policy_document.auth0_custom.json
}

data "aws_iam_policy_document" "auth0_custom" {
  statement {
    effect = "Allow"

    actions = [
      "cloudwatch:GetInsightRuleReport",
      "cloudwatch:GetMetricData",
      "cloudwatch:DescribeAlarms",
      "cloudwatch:GetDashboard",
      "ec2:DescribeTags",
    ]

    resources = ["*"]
  }

  statement {
    effect = "Allow"

    actions = [
      "lambda:InvokeFunction",
    ]

    resources = [
      aws_lambda_function.example2.arn,
    ]
  }
}
```

これをApplyした後、最初のダッシュボード共有画面に設定されている「共有可能なリンク」をコピーして、Auth0の認証を行うと、CloudWatchダッシュボードが共有できる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/5537c14e-b1a4-25ca-1bf8-3f26bf2875b2.png)

これで、第三者向けにカスタマイズしたダッシュボード画面を提供できるようになった！

# 補足：Amazon CognitoとAuth0間の通信を暗号化・署名検証してセキュアにする
要件次第では通信を暗号化することが必須になるケースがあると思われるため、本章で補足をする。
設定は以下のドキュメントをもとに設定する。

https://github.com/auth0/docs/blob/master/articles/protocols/saml/saml-configuration/special-configuration-scenarios/signing-and-encrypting-saml-requests.md

## SAMLリクエストの署名検証の設定
SAMLリクエストの署名検証は、Auth0の```auth0_client```で設定する。

```addons.samlp.sign_response```の属性をtrueにし、SP(今回の構成ではAmazon Cognito)の提供する署名用の証明書(```certificate```属性)を設定する。```certificate```属性は、証明書そのものではなくてBase64エンコードされた部分しか入っていないため、自力で```-----BEGIN/END CERTIFICATE-----```を付与しよう。

```diff_HCL
resource "auth0_client" "cognito" {
  // (中略)
  addons {
    samlp {
      // (中略)
+     sign_response = true
+     signing_cert  = "-----BEGIN CERTIFICATE-----\n${data.aws_cognito_user_pool_signing_certificate.example.certificate}\n-----END CERTIFICATE-----\n"
      // (中略)
    }
  }
  // (中略)
}
```

この設定を行うことで、Amazon Cognitoがリクエストを送る際の署名と突き合せて検証するようになる。

## SAMLアサーションの暗号化
### 暗号化証明書から公開鍵を取り出す
SAMLアサーションの暗号化を行う際、SPの提供する暗号化証明書以外に、公開鍵が必要になる。
残念ながらAmazon Cognitoからは公開鍵の取得をすることはできず、Terraformの標準機能およびtls_providerでは、暗号化証明書から公開鍵を取り出すことができないため、自力でopensslを使って取り出す。

取り出した標準出力を他の場所で使えるようにするには、Terraformの```external```データソースを使うのが良い。

https://registry.terraform.io/providers/hashicorp/external/latest/docs

```HCL
data "external" "example" {
  count = var.import_saml_provider == true ? 1 : 0

  program = ["bash", "-c", <<EOT
PUBLIC_KEY=`echo -e '-----BEGIN CERTIFICATE-----\n${aws_cognito_identity_provider.example.provider_details.ActiveEncryptionCertificate}\n-----END CERTIFICATE-----' | openssl x509 -in - -pubkey -noout | awk 'NF {sub(/\r/, ""); printf "%s\\\n",$0;}'`
echo "{\"public_key\":\"$PUBLIC_KEY\"}"
EOT
  ]
}
```

まず、暗号化証明書は```aws_cognito_identity_provider.example.provider_details.ActiveEncryptionCertificate```から取得が可能だ。ただし、署名用証明書同様、自力で```-----BEGIN/END CERTIFICATE-----```を付与する必要がある。

これで、暗号化証明書が準備できたので、```openssl x509 -in - -pubkey -noout```で公開鍵を取り出す。

本来はこれだけで済むのだが、```external```データソースの出力には改行を含むことができないため、awkで改行をエスケープし、```public_key```という属性に設定する、といった流れだ。

### Amazon CognitoからSAMLアサーションを暗号化するリクエストを送る設定を行う
Amazon Cognitoでは、```aws_cognito_identity_provider```のリソースで、```EncryptedResponses```の値をfalse→trueに変更することで暗号化リクエストを送るようにすることがかのうｄ

```diff_HCL
resource "aws_cognito_identity_provider" "example" {
  // (中略)
  provider_details = {
    IDPInit                 = true
    IDPSignout              = true
-   EncryptedResponses      = false
+   EncryptedResponses      = true
    MetadataURL             = "https://${var.auth0_domain}/samlp/metadata/${auth0_client.cognito.id}"
    RequestSigningAlgorithm = "rsa-sha256"
  }
  // (中略)
```

### SAMLアサーションを暗号化するAuth0 Actionsを設定する
SAMLアサーションを暗号化するには、Auth0 Actionsを使用する必要がある。

以下のようにAuth0 Actionsを定義して、```post-login```のアクションに追加をしよう。
この際、Auth0 ActionsのSecretsを使うことで、Auth0コンソールから鍵の情報を見えないようにすることができるので、セキュアにするためにもSecretsを活用しよう。

間違ってAmazon Cognito経由のリクエスト以外を暗号化しないように、```event.client.client_id```の分岐を入れているのがポイントだ。

```HCL
resource "auth0_action" "example" {
  count = var.import_saml_provider == true ? 1 : 0

  name    = "SAML Encryption For Amazon CloudWatch Dashboard"
  runtime = "node18"
  deploy  = true

  supported_triggers {
    id      = "post-login"
    version = "v3"
  }

  code = <<-EOT
  exports.onExecutePostLogin = async (event, api) => {
    if(event.client.client_id === event.secrets.AMAZON_COGNITO_CLIENT_ID) {
      api.samlResponse.setEncryptionCert(event.secrets.ENCRYPTION_CERT);
      api.samlResponse.setEncryptionPublicKey(event.secrets.ENCRYPTION_PUBLIC_KEY);
    }
  };
EOT

  secrets {
    name  = "AMAZON_COGNITO_CLIENT_ID"
    value = auth0_client.cognito.id
  }

  secrets {
    name  = "ENCRYPTION_CERT"
    value = "-----BEGIN CERTIFICATE-----\n${aws_cognito_identity_provider.example.provider_details.ActiveEncryptionCertificate}\n-----END CERTIFICATE-----"
  }

  secrets {
    name  = "ENCRYPTION_PUBLIC_KEY"
    value = data.external.example.0.result["public_key"]
  }
}

resource "auth0_trigger_action" "example" {
  count = var.import_saml_provider == true ? 1 : 0

  trigger   = auth0_action.example.0.supported_triggers.0.id
  action_id = auth0_action.example.0.id
}
```

これで、暗号化前と同じようにログインできればOKだ。
念のため、SAMLアサーションを確認する(以下リンク参照)と、```<saml:EncryptedAssertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">```の要素があることが確認できる。

https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/troubleshoot_saml_view-saml-response.html

これで、SAML認証のセキュリティをより強固にすることができた！