はじめに
Auth0活用記事第3弾。
第一弾では、AWSのマネージメントコンソールログインをSAML認証で、第二弾では、Amazon Managed GrafanaへのログインをAuth0をIdPに設定して行えるようにした。
今回は、CloudWatchダッシュボード共有を行う際のログインをAuth0を使って行えるようにする。
また、例によってTerraformで自動構築をできるようにしていく。
なお、CloudWatchダッシュボードについては、以前書いた以下の記事で作成する方法をまとめているため、今回の記事では作成部分は割愛する。
本記事では、Terraformのバージョン1.7.0以上の機能(importブロックのfor_each対応)を前提に構築を行っている。
古いバージョンでは実現できない箇所があるので気を付けてご留意いただきたい。
全体の流れ
CloudWatchダッシュボードの共有は、なぜかCLIで実装されておらず、利用者向けのAPIも公開されていない。
さらに、固定の名前のリソースがus-east-1(米国(バージニア北部))で必要というかなり癖がある作りになっている。
そのため、以下のような流れで作っていく。
- 固定名のリソースのうち、あらかじめ作れるものをTerraformで作っておく
- AWSマネージメントコンソールから、CloudWatchダッシュボードを共有する操作を行う
- AWSが自動で作ったリソースをTerraformのtfstateにImportし、IAMポリシー等を必要な情報で書き換える
あらかじめ作れるリソースを作っておく
まずは、3ステップ目に作るリソースが初回のApply時に取り込まれないように以下の変数を設定する。
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ユーザープールを作成する。
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に関するマッピングが出来ていれば良い。
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
}
}
}
aws_cognito_identity_provider
のEncryptedResponses
属性は、この時点でfalseにしているが、暗号化要件次第ではtrueに設定しよう。ただし、Auth0のクライアントはデフォルトでは暗号化をしない。暗号化応答するためには証明書の設定等が必要だ。Auth0の暗号化設定は本記事の趣旨からやや外れるため、最後で補足をする。
ここまでのリソースで、一旦terraform apply -var import_saml_provider=false
を実行しておく。
CloudWatchダッシュボードを共有する操作を行う
こちらも前述の通り、CloudWatchダッシュボードはAWSマネージメントコンソールからしかできないため、Amazon CloudWatchのコンソールを開いて以下のように「ダッシュボードの共有」ボタンを押す。
次のページで、「シングルサインオン (SSO) を使用してアカウントの CloudWatch ダッシュボードをすべて共有する」の「CloudWatch設定」のボタンを押す。
リソースが上手く作られている場合、プルダウンで「Auth0」が選択できる状態になっているので、「変更を保存」ボタンを押下する。
この操作を行うことでリソースが自動で作られるため、作業を途中でやめる場合はゴミが残らないように注意。次の章で記載するリソースの手動削除を行う必要がある。
AWSが自動で作ったリソースをTerraformのtfstateにImportする
さて、ここからのリソースは基本的にすべてimport_saml_provider=trueの場合しか作らないので、countまたはfor_eachを利用する。
AWS(Amazon Cognito)の設定
ユーザープールクライアント
ユーザープールのクライアントが、CloudWatchDashboardSharing
のユーザープール内に作成されるため、おこれをImportする。なお、このリソース名も固定で「CloudWatchDashboardSharingFederated」である。設定内容は、自動で作られるリソースから差分がないようにしたものである。
なお、importするためにはユーザープールクライアントのIDが必要であるため、まずはデータリソースでIDを参照してからimportを行う。
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で作ったリソースを参照するためにカオスな呼び方になっている……。
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ロールと信頼関係の設定が異なることには注意。
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つ目のステートメントのように追加で権限を付与する必要がある。
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ダッシュボードが共有できる。
これで、第三者向けにカスタマイズしたダッシュボード画面を提供できるようになった!
補足:Amazon CognitoとAuth0間の通信を暗号化・署名検証してセキュアにする
要件次第では通信を暗号化することが必須になるケースがあると思われるため、本章で補足をする。
設定は以下のドキュメントをもとに設定する。
SAMLリクエストの署名検証の設定
SAMLリクエストの署名検証は、Auth0のauth0_client
で設定する。
addons.samlp.sign_response
の属性をtrueにし、SP(今回の構成ではAmazon Cognito)の提供する署名用の証明書(certificate
属性)を設定する。certificate
属性は、証明書そのものではなくてBase64エンコードされた部分しか入っていないため、自力で-----BEGIN/END CERTIFICATE-----
を付与しよう。
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
データソースを使うのが良い。
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に変更することで暗号化リクエストを送るようにすることがかのうd
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
の分岐を入れているのがポイントだ。
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">
の要素があることが確認できる。
これで、SAML認証のセキュリティをより強固にすることができた!