はじめに
弊社では複数の Amazon OpenSearch Service ドメインを運用しています。これらのドメインはいずれも VPC 内に閉じた構成をとっており、セキュリティ強化を目的にきめ細やかなアクセス制御(Fine-grained Access Control; FGAC)を有効にしています。
FGAC は「誰が OpenSearch 上でどの操作を許可されるか」をロール単位で制御する仕組みです。設定には OpenSearch の Security API を VPC 内から呼び出す必要があります。以前は手順書にもとづいて手作業で設定していましたが、OpenSearch を利用するサービスやドメインが増えるにつれてスケールしなくなってきました。
本稿では、この課題への対策として以下アプローチを採ることとしました。
- FGAC 設定(ロールおよびマッピングの各定義;後述)を Terraform コードとして定義
- OpenSearch ドメインへの FGAC 設定投入を VPC 内で CodeBuild を実行することで実施するようリソースを定義
以下でこのアプローチの詳細について扱います。
FGAC で設定するもの
FGAC の設定対象は大きく2つです。
- ロール定義:OpenSearch 上のロールが持つ権限(どのインデックスにどの操作を許可するか等)を記述する
- ロールマッピング:上記ロールに「誰を」紐づけるかを記述する。AWS 環境では IAM ロールや IAM ユーザを OpenSearch 上のロールに紐付けることに対応する
これを踏まえ、弊社では用途に応じて以下の3種のロールを定義し運用しています。
| ロール名 | 用途 | マッピング対象 |
|---|---|---|
all_access / security_manager |
管理者権限 | OpenSearch の master user として機能する IAM ロール |
mntsq |
アプリケーション用 | 各サービスの ECS タスクロール等、OpenSearch にアクセスする IAM ロール |
mntsq_operators |
運用者用 | OpenSearch 運用を担当するメンバが使う IAM ロール |
課題と方針
OpenSearch を利用するサービスが追加されると、そのサービスの IAM ロールを mntsq ロールの backend_roles に追加する作業が発生します。また、新しい OpenSearch ドメインが作成された場合には3つ全てのロール設定を一から行う必要があります。この作業を続けることで以下のような課題が浮かび上がってきました。
- 手順書はあるが手作業であり、設定内容の差分管理やレビューができない
- OpenSearch ドメインの追加や既存設定の修正が発生するたびに手作業が必要となり、運用負荷が高い
こうした課題を解消すべく、FGAC 設定をコードとして管理し、OpenSearch への適用も自動化することを目指しました。
terraform-provider-opensearch ではダメなのか
OpenSearch のリソースを Terraform で管理する手段としては terraform-provider-opensearch が存在します。「それで十分ではないか」という指摘はもっともですが、弊社の構成ではこのアプローチは成立しません。
理由は VPC にあります。弊社の OpenSearch ドメインは VPC 内に閉じており、パブリックエンドポイントを持ちません。terraform-provider-opensearch は Terraform の実行環境から直接 OpenSearch にリクエストを送る必要がありますが、弊社では Terraform の実行主体は GitHub Actions であり、VPC の外にいます。つまり Provider が OpenSearch に到達できません。
「であれば Terraform の実行環境自体を VPC 内に配置すればよいのではないか」という考えもあります。たとえば CodeBuild を GitHub Actions の self-hosted runner として VPC 内で動かす方法があります。しかしこの構成では FGAC 設定に限らず全ての terraform plan / terraform apply が VPC 内を経由することになります。FGAC の設定のためだけに Terraform 全体の実行環境を切り替えるのは割に合いません。
採用したアーキテクチャ
terrarform-provider-opensearch を使わず、かつ必要な設定内容を IaC し、その反映も自動化したい。こうした些か欲張りな要件を満たすため、「定義と適用を分離する」アプローチを採用しました。考え方はシンプルです。
- Terraform が担うこと:FGAC のロール定義・ロールマッピングをコードとして宣言し、CodeBuild プロジェクトの環境変数に JSON として注入する
- CodeBuild が担うこと:VPC 内で起動し、環境変数から受け取った JSON を OpenSearch Security API に PUT する

Terraform はあくまで「何を設定するか」を管理し、「設定を OpenSearch に届ける」部分は VPC 内で動ける CodeBuild に委ねる形です。
実装
FGAC ロール定義
3種のロール定義とロールマッピングは Terraform の locals ブロックで宣言しています。
locals { fgac = { # 管理者用 admin = { assign_json_content = { backend_roles = [ aws_iam_role.opensearch_master_role.arn, # 必要に応じて管理操作を行う IAM ロールを追加 ] } } # アプリケーション用 app = { assign_json_map = { for key, config in var.opensearch : key => { backend_roles = flatten([ for role_name in config.user_iam_roles : data.aws_iam_roles.targets[role_name].arns ]) } } role_json_content = { description = "Role for MNTSQ services" cluster_permissions = ["*"] index_permissions = [{ index_patterns = ["*"] fls = [] masked_fields = [] allowed_actions = ["*"] }] tenant_permissions = [{ tenant_patterns = ["*"] allowed_actions = ["kibana_all_write"] }] } } # 運用者用 ops = { assign_json_map = { backend_roles = [ for group in data.aws_identitystore_groups.main.groups : group.group_id if contains(["group-a", "group-b", "group-c"], group.display_name) # OpenSearch 運用を担当するメンバが所属する IAM Identity Center グループ名を列挙 ] } role_json_content = { description = "Role for MNTSQ operators" cluster_permissions = [ "manage_snapshots", "cluster_monitor", "cluster:admin/opendistro/ism/policy/search", "cluster:admin/opendistro/ism/policy/get", # ... ISM / 通知関連の権限が続く ] index_permissions = [{ index_patterns = ["*"] allowed_actions = ["get", "search", "read", "indices_monitor", "manage"] }] tenant_permissions = [] } } } }
アプリケーション用(app)について補足します。var.opensearch は OpenSearch ドメインをキーとする map で、環境層の main.tf にて各ドメインごとに user_iam_roles(そのドメインにアクセスする必要のある IAM ロール名のリスト)を定義しています。assign_json_map はこの user_iam_roles をもとに IAM ロール ARN を引き当て、ドメインごとの backend_roles を動的に構築します。OpenSearch ドメインによってアクセス元のサービスが異なるため、マッピングもドメイン単位で分かれる必要があるということです。
運用者用(ops)は少し毛色が異なります。アプリケーション用では IAM ロールを backend_roles に設定していましたが、運用者用では IAM Identity Center のグループ ID を backend_roles として使います。data.aws_identitystore_groups で運用担当のグループを引き、そのグループに所属するメンバが OpenSearch Dashboards にアクセスした際に適切な権限が付与されるようにしています。
管理者用(admin)にはロール定義がありません。all_access と security_manager は OpenSearch に組み込みで存在するロールであり、権限の内容を改めて定義する必要がないためです。ここで管理するのは「誰をそのロールに紐づけるか」というマッピングだけです。
CodeBuild プロジェクト
FGAC の設定を OpenSearch に届けるには VPC 内から Security API を呼び出す必要がある、という点はここまでで述べたとおりです。CodeBuild には vpc_config を指定することでビルド環境を VPC 内のサブネットで起動する機能があります。これを利用すれば、Terraform 自体は VPC 外で動かしつつ、設定の適用だけを VPC 内で実行できます。
上述 locals を jsonencode() で JSON 文字列に変換し、CodeBuild プロジェクトの環境変数に渡します。HCL のオブジェクトはそのままでは環境変数(文字列)として注入できないため、この変換が必要です。
resource "aws_codebuild_project" "fgac_configuration_manager" { for_each = var.opensearch name = "${var.env}-${var.service}-${each.key}-fgac-configuraton-manager" build_timeout = 10 service_role = aws_iam_role.opensearch_master_role.arn environment { compute_type = "BUILD_GENERAL1_SMALL" image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" type = "LINUX_CONTAINER" environment_variable { name = "APP_ROLE_DEFINITION_JSON" value = jsonencode(local.fgac.app.role_json_content) } environment_variable { name = "APP_MAPPING_DEFINITION_JSON" value = jsonencode(local.fgac.app.assign_json_map[each.key]) } environment_variable { name = "ADMIN_MAPPING_DEFINITION_JSON" value = jsonencode(local.fgac.admin.assign_json_content) } environment_variable { name = "OPS_ROLE_DEFINITION_JSON" value = jsonencode(local.fgac.ops.role_json_content) } environment_variable { name = "OPS_MAPPING_DEFINITION_JSON" value = jsonencode(local.fgac.ops.assign_json_map) } environment_variable { name = "OPENSEARCH_ENDPOINT" value = "https://${module.opensearch[each.key].domain.custom_endpoint}" } } # VPC 内で実行するための設定 vpc_config { vpc_id = local.remote_state_core.vpc.vpc_id subnets = local.remote_state_core.vpc.private_subnets security_group_ids = [aws_security_group.codebuild.id] } source { type = "NO_SOURCE" buildspec = templatefile("${path.module}/buildspecs/fgac_configuration_manager.yaml.tmpl", {}) } }
for_each = var.opensearch としているので、OpenSearch ドメインの数だけ CodeBuild プロジェクトが作成されます。各プロジェクトの環境変数にはそのドメイン固有のマッピング情報が入ります。
vpc_config ブロックで VPC ID、プライベートサブネット、セキュリティグループを指定することで、CodeBuild のビルド環境が VPC 内で起動するようになります。これが本構成の核心です。
IAM ロール
CodeBuild が OpenSearch の master user として振る舞うためのロールを定義しています。
resource "aws_iam_role" "opensearch_master_role" { name = "${var.env}-${var.service}-opensearch-master" assume_role_policy = data.aws_iam_policy_document.opensearch_master_assume_role.json } data "aws_iam_policy_document" "opensearch_master_assume_role" { statement { effect = "Allow" actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = [ "codebuild.amazonaws.com", "opensearch.amazonaws.com", ] } } }
このロールは2つのサービスから Assume されます。codebuild.amazonaws.com は CodeBuild の実行ロールとして、opensearch.amazonaws.com は OpenSearch ドメインの master user として、それぞれこのロールを使います。ひとつのロールに両方の信頼関係を持たせることで、"CodeBuild がこのロールで実行する = OpenSearch の master user として振る舞える" という構図を成立させています。
Buildspec
FGAC のロール定義やロールマッピングは、OpenSearch の Security REST API に対して PUT リクエストを送ることで設定します。利用するエンドポイントは以下の2つです。
/_plugins/_security/api/roles/{ロール名}:ロール定義の作成及び更新/_plugins/_security/api/rolesmapping/{ロール名}:ロールマッピングの作成及び更新
CodeBuild ではこの API 呼び出しを以下の buildspec で実行しています。
version: 0.2 phases: install: commands: - pip3 install awscurl build: commands: # 管理者権限の設定 - 'echo "$ADMIN_MAPPING_DEFINITION_JSON" > assign_admin.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/security_manager" -d @assign_admin.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/all_access" -d @assign_admin.json -X PUT' # アプリケーション用ロールの設定 - 'echo "$APP_ROLE_DEFINITION_JSON" > role.json' - 'echo "$APP_MAPPING_DEFINITION_JSON" > assign.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/roles/mntsq" -d @role.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/mntsq" -d @assign.json -X PUT' # 運用者用ロールの設定 - 'echo "$OPS_ROLE_DEFINITION_JSON" > role.json' - 'echo "$OPS_MAPPING_DEFINITION_JSON" > assign.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/roles/mntsq_operators" -d @role.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/mntsq_operators" -d @assign.json -X PUT'
やっていることは素直です。環境変数から JSON を取り出してファイルに書き、awscurl(AWS SigV4 署名付きの curl)で OpenSearch Security API に PUT します。各ロールについて ロール定義の PUT → ロールマッピングの PUT の順で実行します。管理者用は組み込みロールへのマッピングのみなのでロール定義の PUT はありません。
awscurl を使うことで IAM ロールベースの認証が自動的に行われるため、API キーやパスワードの管理は不要です。
以上の実装をまとめたコード全体を以下に示します。
Terraform コード全体(クリックで展開)
# ================================================== # データソース # ================================================== data "aws_iam_roles" "targets" { for_each = toset(flatten(values(var.opensearch)[*].user_iam_roles)) name_regex = ".*${each.value}$" } data "aws_ssoadmin_instances" "main" {} data "aws_identitystore_groups" "main" { identity_store_id = tolist(data.aws_ssoadmin_instances.main.identity_store_ids)[0] } # ================================================== # FGAC ロール定義(locals) # ================================================== locals { fgac = { admin = { assign_json_content = { backend_roles = [ aws_iam_role.opensearch_master_role.arn, # 必要に応じて管理操作を行う IAM ロールを追加 ] } } app = { assign_json_map = { for key, config in var.opensearch : key => { backend_roles = flatten([ for role_name in config.user_iam_roles : data.aws_iam_roles.targets[role_name].arns ]) } } role_json_content = { description = "Role for MNTSQ services" cluster_permissions = ["*"] index_permissions = [{ index_patterns = ["*"] fls = [] masked_fields = [] allowed_actions = ["*"] }] tenant_permissions = [{ tenant_patterns = ["*"] allowed_actions = ["kibana_all_write"] }] } } ops = { assign_json_map = { backend_roles = [ for group in data.aws_identitystore_groups.main.groups : group.group_id if contains(["group-a", "group-b", "group-c"], group.display_name) # OpenSearch 運用を担当するメンバが所属する IAM Identity Center グループ名を列挙 ] } role_json_content = { description = "Role for MNTSQ operators" cluster_permissions = [ "manage_snapshots", "cluster_monitor", "cluster:admin/opendistro/ism/policy/search", "cluster:admin/opendistro/ism/policy/get", "cluster:admin/opendistro/ism/policy/write", "cluster:admin/opendistro/ism/policy/delete", "cluster:admin/opensearch/notifications/channels/get", "cluster:admin/opensearch/notifications/configs/get", "cluster:admin/opensearch/notifications/configs/create", "cluster:admin/opensearch/notifications/configs/update", "cluster:admin/opensearch/notifications/configs/delete", "cluster:admin/opensearch/notifications/features", "cluster:admin/opensearch/notifications/feature/send", ] index_permissions = [{ index_patterns = ["*"] dls = "" fls = [] masked_fields = [] allowed_actions = ["get", "search", "read", "indices_monitor", "manage"] }] tenant_permissions = [] } } } } # ================================================== # IAM ロール・ポリシー # ================================================== data "aws_iam_policy_document" "opensearch_master_assume_role" { statement { effect = "Allow" actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = [ "codebuild.amazonaws.com", "opensearch.amazonaws.com", ] } } } resource "aws_iam_role" "opensearch_master_role" { name = "${var.env}-${var.service}-opensearch-master" assume_role_policy = data.aws_iam_policy_document.opensearch_master_assume_role.json } data "aws_iam_policy_document" "codebuild_permissions" { statement { effect = "Allow" actions = [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents", "ec2:CreateNetworkInterface", "ec2:DescribeDhcpOptions", "ec2:DescribeNetworkInterfaces", "ec2:DeleteNetworkInterface", "ec2:DescribeSubnets", "ec2:DescribeSecurityGroups", "ec2:DescribeVpcs", "ec2:CreateNetworkInterfacePermission", ] resources = ["*"] } } data "aws_iam_policy_document" "opensearch_permissions" { statement { effect = "Allow" actions = ["es:*"] resources = ["*"] } } resource "aws_iam_policy" "codebuild" { name = "${var.env}-${var.service}-codebuild-permissions" policy = data.aws_iam_policy_document.codebuild_permissions.json } resource "aws_iam_role_policy" "opensearch_master" { role = aws_iam_role.opensearch_master_role.name policy = data.aws_iam_policy_document.opensearch_permissions.json } resource "aws_iam_role_policy_attachment" "opensearch_master" { role = aws_iam_role.opensearch_master_role.name policy_arn = aws_iam_policy.codebuild.arn } # ================================================== # セキュリティグループ # ================================================== resource "aws_security_group" "codebuild" { name = "${var.env}-${var.service}-codebuild" vpc_id = local.remote_state_core.vpc.vpc_id egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } } # ================================================== # CodeBuild プロジェクト # ================================================== resource "aws_codebuild_project" "fgac_configuration_manager" { for_each = var.opensearch name = "${var.env}-${var.service}-${each.key}-fgac-configuraton-manager" description = "Configure OpenSearch FGAC roles for ${each.key} in ${var.env}" build_timeout = 10 service_role = aws_iam_role.opensearch_master_role.arn artifacts { type = "NO_ARTIFACTS" } environment { compute_type = "BUILD_GENERAL1_SMALL" image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" type = "LINUX_CONTAINER" image_pull_credentials_type = "CODEBUILD" environment_variable { name = "APP_ROLE_DEFINITION_JSON" value = jsonencode(local.fgac.app.role_json_content) } environment_variable { name = "APP_MAPPING_DEFINITION_JSON" value = jsonencode(local.fgac.app.assign_json_map[each.key]) } environment_variable { name = "ADMIN_MAPPING_DEFINITION_JSON" value = jsonencode(local.fgac.admin.assign_json_content) } environment_variable { name = "OPS_ROLE_DEFINITION_JSON" value = jsonencode(local.fgac.ops.role_json_content) } environment_variable { name = "OPS_MAPPING_DEFINITION_JSON" value = jsonencode(local.fgac.ops.assign_json_map) } environment_variable { name = "OPENSEARCH_ENDPOINT" value = "https://${module.opensearch[each.key].domain.custom_endpoint}" } environment_variable { name = "AWS_REGION" value = data.aws_region.current.region } } logs_config { cloudwatch_logs { status = "ENABLED" } } vpc_config { vpc_id = local.remote_state_core.vpc.vpc_id subnets = local.remote_state_core.vpc.private_subnets security_group_ids = [aws_security_group.codebuild.id] } source { type = "NO_SOURCE" buildspec = templatefile("${path.module}/buildspecs/fgac_configuration_manager.yaml.tmpl", {}) } }
Buildspec 全体(クリックで展開)
version: 0.2 phases: install: commands: - pip3 install awscurl build: commands: # 管理者権限の設定 - 'echo "$ADMIN_MAPPING_DEFINITION_JSON" > assign_admin.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/security_manager" -d @assign_admin.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/all_access" -d @assign_admin.json -X PUT' # アプリケーション用ロールの設定 - 'echo "$APP_ROLE_DEFINITION_JSON" > role.json' - 'echo "$APP_MAPPING_DEFINITION_JSON" > assign.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/roles/mntsq" -d @role.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/mntsq" -d @assign.json -X PUT' # 運用者用ロールの設定 - 'echo "$OPS_ROLE_DEFINITION_JSON" > role.json' - 'echo "$OPS_MAPPING_DEFINITION_JSON" > assign.json' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/roles/mntsq_operators" -d @role.json -X PUT' - 'awscurl --service es --region ${AWS_REGION} -H "Content-Type: application/json" "${OPENSEARCH_ENDPOINT}/_plugins/_security/api/rolesmapping/mntsq_operators" -d @assign.json -X PUT'
運用フロー
初期セットアップ
新しい OpenSearch ドメインを作成した場合の流れは以下のようになります。
terraform applyで OpenSearch ドメインと CodeBuild プロジェクトが作成される- AWS コンソールから対象の CodeBuild プロジェクト(
${ENV}-search-${DOMAIN}-fgac-configuraton-manager)を手動で Start build する - FGAC のロールとマッピングが OpenSearch に設定される
terraform apply は CodeBuild プロジェクトの定義(環境変数やビルド環境の設定)を作成するのみで、ビルド自体の実行は行いません。そのため初回は手動で Start build する必要があります。
設定が正しく反映されたかどうかは、OpenSearch の /_plugins/_security/authinfo API で確認できます。対象の IAM ロールで OpenSearch にリクエストを送り、レスポンスの roles フィールドに期待するロール名が含まれていれば設定は成功です。
設定変更
新しいサービスが OpenSearch にアクセスする必要が生じた場合の流れは以下のようになります。
- 環境層の
main.tfで該当ドメインのuser_iam_rolesにロール名を追加する - PR を作成してレビューを受ける(ここで設定差分がコードとして見える)
terraform applyで CodeBuild プロジェクトの環境変数が更新される- CodeBuild を再実行して新しいマッピングを OpenSearch に適用する
ステップ 3 の terraform plan では、CodeBuild プロジェクトの環境変数(APP_MAPPING_DEFINITION_JSON 等)の値に差分が出ます。JSON 文字列の diff として「どの IAM ロール ARN が追加されたか」が確認できるため、レビュー時の判断材料になります。
Security API の PUT は既存の設定を全量上書きする動作であるため、CodeBuild の実行は冪等です。同じ設定で何度実行しても結果は変わりません。誤って二重実行してしまった場合でも問題は生じません。
設定変更が PR として可視化されるのが、手作業時代との大きな違いです。「どの環境のどのドメインに、どの IAM ロールがいつ追加されたか」がコードの履歴として残ります。
CodeBuild の手動実行について
現時点では terraform apply の後に CodeBuild を手動で実行するステップが残っています。terraform apply だけで設定の反映まで完結するのが理想ではありますが、現状の構成でも手作業で行っていた FGAC 設定がコード管理下に入り、適用も CodeBuild のボタンひとつで済むようになったことで、運用負荷は大きく改善されています。
おわりに
VPC 内に閉じた OpenSearch の FGAC 設定を構成管理するにあたり、terraform-provider-opensearch が使えないという制約のもとで Terraform で宣言し、CodeBuild で適用する というアプローチを紹介しました。
Terraform の得意なこと(宣言的な定義、差分管理、環境間の一貫性)と CodeBuild の得意なこと(VPC 内での任意のコマンド実行)を組み合わせることで、Provider が直接使えない領域にも構成管理の恩恵を持ち込むことができます。VPC 内 OpenSearch に限らず、「Terraform の実行環境からは到達できないが設定をコード管理したい」という場面で応用できるパターンかと思います。
VPC 内に存在する諸要素の構成管理に課題を感じている方に本稿で紹介した事例が一助となれば幸いです。
文責:MNTSQ 株式会社 SRE 秋本
注記:この記事は文責者の過去記事と弊社内のドキュメントをもとに Claude Opus 4.6 が作成した内容をほぼそのまま使用しています