コンテンツにスキップ

セキュリティ

ひとことで

セキュリティ設計は 3 つを軸にします:ID(誰か)、権限(何ができるか。少ないほどよい)、証跡(何をしたかがすべて記録される)。 このリポジトリの原則:プログラムには長期間有効なキーを持たせず、自分の ID(インスタンス・プリンシパル / リソース・プリンシパル)を使う。権限は where で特定の 1 つのリソースに絞る。パスワードは Terraform を経由させない。

このページは Phase 0、Phase 7、Phase 8、Phase 9、Phase 10 の実験をまとめたものです。ネットワーク層のアクセス制御(セキュリティ・リスト / NSG / 要塞)は ネットワーク設計、VM 自体の設定(IMDS、転送中の暗号化)は コンピュートとロード・バランサ を参照してください。

1. まず構成要素を知る

OCI AWS ひとことで
コンパートメント(Compartment) (近い)リソース・グループ / アカウントの分割 リソースを入れる「フォルダ」で、権限と費用の境界でもある。このリポジトリの演習リソースはすべて tf-lab に置く
ユーザー / グループ(User / Group) IAM User / Group 人の ID。権限はグループに与え、ユーザーに直接は与えない
ポリシー(Policy) IAM Policy 権限を1 文で書く:Allow <誰> to <動詞> <リソース> in <範囲> where <条件>
動的グループ(Dynamic Group) (近い)IAM Role の信頼関係 ルールでリソースのまとまりを指定し(例:この VM、このコンパートメント内の Function)、それらにも権限を与えられるようにする
インスタンス・プリンシパル(Instance Principal) EC2 のインスタンス・プロファイル(IAM Role) VM 自身の ID。証明書は OCI が自動で発行・ローテーションし、VM にキーを置かない
リソース・プリンシパル(Resource Principal) Lambda の実行ロール Function、DevOps のビルド・パイプラインなどのサービス・リソース自身の ID
API キー(API Key) アクセス・キー 人がローカル PC から API を呼ぶためのキー・ペア(秘密鍵はローカル PC)
認証トークン(Auth Token) (近い)CodeCommit の Git 認証情報 git push、docker login のときにパスワードとして使う
ボールト(Vault) KMS + Secrets Manager 暗号鍵と機密情報を保管する
マスター暗号化キー(Master Encryption Key) KMS キー シークレット、バケット、ディスクなどの暗号化に使うキー。キー自体は誰にも渡さない
シークレット(Secret) Secrets Manager のシークレット マスター暗号化キーで暗号化して保存するパスワード、トークンなど
監査(Audit) CloudTrail すべての API 呼び出しの記録。デフォルトで有効
ロギング(Logging、サービス・ログ) CloudWatch Logs バケットのアクセス・ログ、VCN フロー・ログ、Function の実行ログなど
コネクタ・ハブ(Connector Hub) (近い)EventBridge Pipes / Firehose ログを別の場所へ運ぶ(例:アーカイブ用バケット)
— IAM Identity Center AWS 側:人が使うシングル・サインオン。一時的な認証情報を発行する

2. しくみ

2.1 ポリシーは 1 文で書く

Allow dynamic-group tflab9-vm to read secret-bundles in compartment tf-lab where target.secret.id = '<Secret B>'
      └──── 誰 ────────────┘    └ 動詞 ┘ └─ リソース・タイプ ┘ └──── 範囲 ─────┘ └──────────── 条件 ────────────────┘
  • 動詞は弱い順に:inspect(一覧を見る)< read(読む)< use(使う。作成・削除はできない)< manage(すべて)。作業ができる最小のものを選ぶ
  • リソース・タイプは正確に選ぶ:secret-bundles = シークレットの内容、secrets = シークレットのメタデータ。buckets = バケットの設定、objects = バケット内のファイル
  • 範囲:in compartment tf-lab または in tenancy
  • 条件(where):対象を特定の 1 つのリソースに絞る(target.bucket.name、target.secret.id、target.key.id)

2.2 プログラムはどうやって ID を得るか

flowchart TB
  R["VM / Function<br/>ビルド・パイプライン"] -->|"OCI が証明書を自動発行<br/>定期的にローテーション"| P["インスタンス /<br/>リソース・プリンシパル"]
  P -->|"マッチング・ルールに合致"| DG["動的グループ"]
  DG -->|"ポリシーで許可"| T["対象リソース<br/>この 1 つの<br/>シークレット / バケット<br/>だけ"]

マシンには長期間有効なキーが一切ありません。漏えいのリスクも、ローテーションの手間もなくなります。これが「プログラムは自分の ID を使う」という意味です。

動的グループのメンバー・ルールはできるだけ厳密にします:

# この 1 台の VM だけ(09-secops/iam.tf、抜粋)
matching_rule = "ALL {instance.id = '${oci_core_instance.vm.id}'}"

# tf-lab 内のすべての Function(08-guard/iam.tf、抜粋)
matching_rule = "ALL {resource.type = 'fnfunc', resource.compartment.id = '${var.compartment_ocid}'}"

2.3 覚えておくべき 3 つの挙動

  • 権限がない場合も、存在しない場合も 404 を返す(NotAuthorizedOrNotFound):「このリソースが存在するかどうか」を漏らさないため。調査のときは両方の可能性を考える
  • IAM の反映には遅延がある(数秒〜数分):同じ apply の中で「ポリシーを作成 → すぐにその権限を使う」と失敗することがある。depends_on が保証するのは順序だけで、反映済みであることは保証しない
  • 動的グループはテナンシ(ルート)直下にしか作れない。ポリシーはコンパートメントにアタッチできる

3. 最小権限:このリポジトリの実際の書き方

誰 できること 絞り込み 出典
サイトのビルド・パイプライン サイトのバケットへのオブジェクトの書き込み、コード・リポジトリの読み取り、AWS キーのシークレットの読み取り target.bucket.name、target.secret.id Phase 7 / 10
サイトを自動停止する Function サイトのバケットの設定の変更(use buckets)、予算の読み取り target.bucket.name Phase 8
プライベート VM 1 つのシークレットの内容の読み取り target.secret.id Phase 9
オブジェクト・ストレージ・サービス マスター暗号化キーでの暗号化・復号、ライフサイクル・ルールの実行 target.key.id、target.bucket.name Phase 9
コネクタ・ハブ アーカイブ用バケットへのオブジェクトの書き込み request.principal.type、target.bucket.name Phase 9
# Function が変更できるのは tflab-site バケットの設定だけ(08-guard/iam.tf、抜粋)
#   use buckets:設定(公開 / 非公開)は変更できるが、バケットの削除や中のファイルの読み書きはできない
statements = [
  "Allow dynamic-group ${oci_identity_dynamic_group.guard_fn.name} to use buckets in compartment ${data.oci_identity_compartment.lab.name} where target.bucket.name = '${var.site_bucket_name}'",
]

細かい点:

  • 名前を直書きせず、リソースの属性を参照する(oci_identity_dynamic_group.guard_fn.name):これで Terraform が「動的グループを作ってからポリシーを作る」と判断できる
  • サービス自体にも権限が要る:オブジェクト・ストレージで CMK 暗号化を使うには、service objectstorage-<region> に use keys を許可する。ファンクション・サービス(faas)にはイメージの取得とサブネットへの接続の権限が要る
  • 動的グループのメンバーにならないサービス(コネクタ・ハブ):any-user + request.principal.type = 'serviceconnector' で絞り込む

4. 機密情報:パスワードは Terraform を経由させない

4.1 ボールトの構造

ボールト(金庫:入れ物であり、権限の境界でもある。DEFAULT タイプは無料)
 └─ マスター暗号化キー(SOFTWARE 保護:無料)
      ├─ シークレット(パスワード、トークンなど)を暗号化
      └─ バケット / ブロック・ボリュームなどを暗号化(CMK)

エンベロープ暗号化(Envelope Encryption):データは一時的なデータ暗号化キーで暗号化し、そのデータ暗号化キーをマスター暗号化キーで暗号化します。そのためマスター暗号化キーを無効にすると、データは読めなくなります。これが「キーを自分で管理する」ことの意味です。

4.2 パスワードを誰が生成するか:2 種類のシークレットの比較

Phase 9 ではあえて 2 つのシークレットを作って比較しました:

シークレット A(悪い例) シークレット B(推奨)
パスワードを生成するのは Terraform(random_password) ボールト自身(enable_auto_generation)
plan の表示 (sensitive value) (known after apply)
state の中 平文がある ない
# シークレット B:ボールト自身が生成し、Terraform は「枠」だけを作る(09-secops/vault.tf、抜粋)
resource "oci_vault_secret" "db_b" {
  vault_id               = oci_kms_vault.this.id
  key_id                 = oci_kms_key.master.id
  secret_name            = "${local.name_prefix}-db-password-gen"
  enable_auto_generation = true

  secret_generation_context {
    generation_type     = "PASSPHRASE"
    generation_template = "SECRETS_DEFAULT_PASSWORD"
    passphrase_length   = 24
  }
}

sensitive は「表示しない」だけで、「保存しない」ではない

(sensitive value) が防ぐのは見られることだけです。state ファイルにも plan ファイル(terraform show -json)にも本当の値が入っています。 → パスワードはボールト(または DB サービス)に生成させ、Terraform は枠だけを作ります。プログラムにはシークレットの OCID だけを書き、実行時に自分の ID で取得します。

4.3 外部の機密情報:Terraform は枠だけを作り、本当の値はスクリプトで書き込む

サイトのビルドは AWS にもアップロードするため、AWS のアクセス・キーが必要です(7 章を参照)。このキーも Terraform を経由させません:

# 07-site/aws.tf(抜粋)
resource "oci_vault_secret" "aws_deployer" {
  secret_content {
    content_type = "BASE64"
    content      = base64encode(jsonencode({ placeholder = true }))   # プレースホルダー
  }
  lifecycle {
    ignore_changes = [secret_content]   # ないと次の apply で本当の値がプレースホルダーに戻される
  }
}

本当の値は tools/rotate_aws_deploy_key.py で書き込みます:新しいキーを作成 → シークレットに書き込む → 成功してから古いキーを削除(書き込みに失敗したら新しいキーを削除)。キーは画面に表示せず、ディスクにも書きません。ビルド時はアップロード用のスクリプトがリソース・プリンシパルでシークレットを読み、キーはプロセスのメモリにだけ置きます。環境変数やビルド・ログには出しません。

5. 暗号化

層 デフォルト このリポジトリのやり方
保存データ(ディスク、バケット) デフォルトで暗号化(クラウド事業者が管理するキー) ログのアーカイブ用バケットは CMK(kms_key_id = マスター暗号化キー)
VM ↔ ブート・ボリュームの転送 暗号化なし 転送中の暗号化を有効化(コンピュートとロード・バランサ を参照。API で実際に有効になったかを確認する必要がある)
サイトへのアクセス — OCI 版は oraclecloud.com の HTTPS。AWS 版は ACM 証明書 + HTTPS のみ許可

CMK(顧客管理キー)を使うのはいつか:「キーは自分たちで管理する」という要件があるときです。キーを自分で無効化 / ローテーション / 削除でき、使用のたびに監査記録が残るのが利点です。学習環境では SOFTWARE 保護のキー(無料)で十分で、本番環境やコンプライアンス要件があれば HSM を検討します。

AWS 側の state バケットは SSE-S3(デフォルトの暗号化)で、KMS の CMK は使っていません。1 本あたり 1 USD / 月かかり、ここではキーを自分で管理する必要がないためです。

6. ログと監査

flowchart TB
  A["監査<br/>すべての API 呼び出し"] --> C["コネクタ・ハブ"]
  S["サイトのバケット<br/>アクセス・ログ"] --> C
  C -->|"最大 7 分ごとに 1 ファイル"| B["アーカイブ用バケット<br/>CMK で暗号化<br/>30 日後に削除"]
  F["VCN フロー・ログ<br/>誰が誰に接続したか"] --> L["ロギング<br/>30 日保持<br/>アーカイブしない"]
ログ 内容 このリポジトリで見えたもの
監査(デフォルトで有効) 誰が、いつ、どこから、何に対して、結果はどうだったか VM がシークレット B を読む → 200、A を読む → 404。呼び出し元は VM(authType = instance)、IP は VM のプライベート IP
バケットのアクセス・ログ リクエストごとの IP、User-Agent、ステータス・コード curl 3 回 → GET index.html 200 ×3。数分以内に表示
フロー・ログ NIC レベルの通信記録 要塞 → VM:22 が ACCEPT。VM ↔ Oracle サービス :443 が ACCEPT
  • 拒否された操作も記録される:これこそ監査に必要なもの。「誰がやってはいけないことを試したか」
  • 監査ログの量は想像より多い:terraform plan 1 回で数十件の GetXxx
  • 長期保存するときはリクエスト数を計算する:コネクタが毎分 1 ファイル書くと月に 4 万回以上の PUT になり、オブジェクト・ストレージの月 5 万回の無料枠に近づく → 上限の 7 分にする

監査ログのクエリ(ログ検索):

search "<tf-lab の OCID>/_Audit" | where data.eventName = 'GetSecretBundle'

7. AWS 側:人と外部システムの ID

7.1 人:root を使わず、長期間有効なキーを保存しない

  • root:MFA を有効にし、アクセス・キーがないことを確認して、以後は使わない
  • 日常は IAM Identity Center のユーザーを使う:アクセス・ポータルからサインインし、ローカル PC の CLI は aws configure sso で一時的な認証情報(セッション 4 時間)を取得する。ディスクに長期間有効なキーは置かない
  • AdministratorAccess ≒ root だが同じではない:root のメールアドレスの変更やアカウントの解約などは root にしかできない

7.2 外部システム:アクセス・キーしか使えない場合の補完策

OCI のビルド・ランナーは AWS のリソースではないため IAM Role が使えず、IAM ユーザーのアクセス・キー(長期間有効)を発行するしかありません。そこで:

対策 内容
最小権限 できるのはサイトのバケットのオブジェクトの一覧 / 書き込み / 削除と、この distribution のキャッシュ削除だけ。読み取りすら与えない
キーは Terraform を経由させない aws_iam_access_key の secret は state に平文で残る → スクリプトで作成し、直接 OCI のボールトに書き込む
OCI 側で読めるのはこの 1 つのシークレットだけ read secret-bundles ... where target.secret.id = '<このシークレット>'
定期的なローテーション 約 90 日ごとに、スクリプトでローテーション(新しいキーの書き込みに成功してから古いキーを削除)
# 10-aws-site/site/deploy.tf(抜粋)
statement {
  sid       = "WriteSiteObjects"
  actions   = ["s3:PutObject", "s3:DeleteObject"]
  resources = ["${aws_s3_bucket.site.arn}/*"]
}
statement {
  sid       = "InvalidateSiteCache"
  actions   = ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"]
  resources = [aws_cloudfront_distribution.site.arn]
}

8. ローカル PC とリポジトリ

  • API キーの秘密鍵は ~/.oci/ に置き、プロジェクトのディレクトリにも Git にも絶対に入れない
  • .gitignore で最初から除外する:*.tfstate(パスワードが入ることがある)、*.tfvars(OCID や IP などの環境情報。コミットするのは *.example だけ)、*.pem / *.key、backend.hcl
  • plan ファイルにも本当の値が入る → 使い終わったら削除
  • サイトの公開前に tools/prepublish_check.py で実際の IP、OCID、ネームスペース、メールアドレスなどをスキャンし、サイトにはプレースホルダーだけを載せる

9. 設計判断の一覧

判断 採用 不採用 理由
プログラムから OCI API へのアクセス インスタンス / リソース・プリンシパル マシンに API キーを置く 長期間有効なキーがなく、ローテーション不要で、漏えいしようがない
権限の範囲 where で特定のリソースに限定 + 最小の動詞 コンパートメント全体への manage 悪用されても影響はその 1 つのリソースだけ
動的グループのメンバー OCID / リソース・タイプ + コンパートメントで厳密に指定 コンパートメント内のすべてのリソース ほかの VM がついでに権限を得ることがない
パスワードの生成 ボールトの自動生成 Terraform の random_password state に平文が残らない
外部のキー(AWS キー) スクリプトでボールトに書き込み、Terraform は枠だけ + ignore_changes aws_iam_access_key を state に入れる 同上。さらにローテーションをスクリプト化できる
マスター暗号化キーの保護方式 SOFTWARE(無料) HSM / 専有ボールト 学習環境には十分。本番は要件に応じて選ぶ
ログの長期保存 コネクタ・ハブ → CMK で暗号化したバケット + 30 日で自動削除 ロギングのデフォルト保持期間だけ キーと保存期間を自分で管理でき、ライフサイクルで費用を抑えられる
AWS の日常のサインイン Identity Center + 一時的な認証情報 root / IAM ユーザーの長期キー ローカル PC のディスクに長期キーがない

10. 検証

権限の設計では必ず逆方向のテストをします:やってはいけないことが本当にできないことを確認します。

証明したいこと 方法 結果
VM は許可されたシークレットを読める VM で --auth instance_principal を使ってシークレット B を読む 成功(長さだけを確認し、パスワードは表示しない)
VM は許可されていないシークレットを読めない(逆方向) シークレット A を読む NotAuthorizedOrNotFound(404)
監査に記録が残る ログ検索で GetSecretBundle を検索 B → 200、A → 404。呼び出し元は VM
state に平文があるか terraform state pull で有無だけを確認 A と random_password にはあり、B にはない
AWS の deployer でデプロイできる 一覧 / PUT / DELETE / キャッシュ削除 成功
AWS の deployer はほかのことができない(逆方向) オブジェクトの読み取り、state バケットへのアクセス、バケット・ポリシーの削除、バケットの一覧、IAM の参照 すべて AccessDenied
state バケットは安全でないアクセスを拒否する(逆方向) 匿名アクセス / HTTP でのアクセス 403 / バケット・ポリシーで明示的に拒否
枠だけ作ったシークレットが上書きされない 本当の値を書き込んだあとで再度 plan No changes(ignore_changes が効いている)

11. ハマりどころ

現象 原因 対策
シークレットの読み取り → 404 権限がない場合も存在しない場合も 404 ポリシー(read secret-bundles)と動的グループのルールを確認
CMK で暗号化するバケットを新規作成 → 404-NotAuthorizedOrFoundKmsKey 同じ apply で作ったばかりのポリシーがまだ反映されていない 少し待ってからもう一度 apply
ビルドがコード・リポジトリを読めない(RelatedResourceNotAuthorizedOrNotFound) repos は OCIR(コンテナ・レジストリ)のリソース・タイプ コード・リポジトリには read devops-repository
(sensitive value) のパスワードが state では平文 sensitive は表示しないだけ ボールトに生成させる
destroy のあと、同じ名前のシークレットが作れない ボールト / キー / シークレットは「削除予定」(7〜30 日)になる 名前を変えるか、削除の完了を待つ
destroy でアーカイブ用バケットが削除できない バケットが空でない。コネクタが書き込みを続けている まずコネクタを止める → バケットを空にする → 残りを destroy
OCI の config → did not find a proper configuration for private key config は行末コメントに対応しておらず、# TODO がパスの一部とみなされる # TODO を削除
Terraform は failed to refresh cached SSO token なのに CLI は使える AWS の SSO トークンの期限切れ aws sso login --profile <profile>

12. 確認問題

1. ある Function でバケットを非公開にしたい。ポリシーはどう書くのが適切ですか?

Allow dynamic-group <Function のグループ> to use buckets in compartment <名前> where target.bucket.name = '<そのバケット>'。use buckets は設定を変更できますが、バケットの削除や中のファイルの読み書きはできません。

2. VM がボールトのパスワードを読むのに、マスター暗号化キーの権限は必要ですか?

不要です。復号はボールトの内部で行われるため、VM には read secret-bundles(シークレットの内容)の権限だけがあればよく、さらに where target.secret.id でその 1 つのシークレットに絞るのが望ましいです。

3. Terraform の random_password で DB のパスワードを生成し、出力を sensitive にすれば安全ですか?

不十分です。sensitive は表示しないだけで、state ファイルにも plan ファイルにも平文が入っています。ボールトや DB サービス自身に生成させ、Terraform は枠だけを作るべきです。

4. シークレットを読むと 404 が返ります。考えられる原因は?

シークレットが存在しないか、権限がないかです。OCI はどちらの場合も 404 を返します。ポリシー、動的グループのメンバー・ルール、IAM がすでに反映されているかを確認します。

5. なぜ VM / Function には API キーを置かず、インスタンス / リソース・プリンシパルを使うのですか?

API キーは長期間有効なので、マシンに置けば漏えいのリスクがあり、ローテーションも自分で行う必要があります。プリンシパルの証明書は OCI が自動で発行・ローテーションし、マシンには長期キーが残りません。

6. OCI のビルドから AWS にアップロードするとき、なぜアクセス・キーしか使えないのですか?リスクはどう下げますか?

OCI のビルド・ランナーは AWS のリソースではないため、IAM Role を引き受けられません。リスクを下げるには:権限を最小にする、キーを Terraform に通さない(OCI のボールトに置く)、OCI 側で読めるのをその 1 つのシークレットだけにする、定期的にローテーションする。

7. 「VM はシークレット B しか読めない」ことはどう証明しますか?

正方向:VM で B の読み取りに成功する。逆方向:VM で A を読むと 404 が返る。さらに監査で 2 回の呼び出しがどちらも記録されている(200 / 404)ことを確認します。

8. CMK とデフォルトの暗号化は何が違いますか?CMK が必要なのはいつですか?

デフォルトの暗号化はクラウド事業者がキーを管理します。CMK は自分で管理し、自分で無効化 / ローテーション / 削除でき、使用のたびに監査記録が残ります。「キーは自分たちで管理する」という要件があるときにだけ必要です。

コード全文

09-secops/iam.tf(動的グループ + ポリシー)
# ------------------------------------------------------------
# 9-2:VM がインスタンス・プリンシパルでシークレットを読む
#   インスタンス・プリンシパル = VM 自身のアイデンティティ(証明書は OCI が自動で発行・ローテーション)→ VM に API キーを置かない
#   (Phase 7 のビルド・パイプライン / Phase 8 の Function が使うリソース・プリンシパルと同じ考え方)
# ------------------------------------------------------------

# tf-lab の親 = テナンシ(動的グループはテナンシ直下にしか作れない)
data "oci_identity_compartment" "lab" {
  id = var.compartment_ocid
}

# メンバーはこの VM 1 台だけ(OCID で指定)。VM を再作成すると OCID が変わる → ルールも追従して更新される
resource "oci_identity_dynamic_group" "vm" {
  compartment_id = data.oci_identity_compartment.lab.compartment_id
  name           = "${local.name_prefix}-vm"
  description    = "The 09-secops VM (${oci_core_instance.vm.display_name})"
  matching_rule  = "ALL {instance.id = '${oci_core_instance.vm.id}'}"

  freeform_tags = local.common_tags
}

# シークレット B の内容の読み取りだけを許可(secret-bundles = シークレットの内容、secrets = メタデータ)
#   シークレット A はわざと許可しない → VM から A を読むと拒否される = 最小権限の確認
#   復号はボールト・サービスの内部で行われる → VM にマスター暗号化キーの権限は不要
resource "oci_identity_policy" "vm" {
  compartment_id = var.compartment_ocid
  name           = "${local.name_prefix}-vm"
  description    = "09-secops VM reads one secret"

  statements = [
    "Allow dynamic-group ${oci_identity_dynamic_group.vm.name} to read secret-bundles in compartment ${data.oci_identity_compartment.lab.name} where target.secret.id = '${oci_vault_secret.db_b.id}'",
  ]

  freeform_tags = local.common_tags
}

# ------------------------------------------------------------
# 9-3:ログ・アーカイブ用の権限(どれもこのキー 1 つ / このバケット 1 つに限定)
#   Object Storage サービス(objectstorage-<region>):
#     - use keys:マスター暗号化キーでバケット内のデータを暗号化 / 復号(CMK)
#     - manage object-family:ライフサイクル・ルールを実行(30 日後に削除)
#   コネクタ・ハブ:サービス自体は動的グループのメンバーではない → any-user + request.principal.type で限定
# ------------------------------------------------------------
locals {
  archive_bucket_name = "${local.name_prefix}-log-archive"
}

resource "oci_identity_policy" "archive" {
  compartment_id = var.compartment_ocid
  name           = "${local.name_prefix}-archive"
  description    = "09-secops log archive: CMK, lifecycle, connector writes"

  statements = [
    "Allow service objectstorage-${var.region} to use keys in compartment ${data.oci_identity_compartment.lab.name} where target.key.id = '${oci_kms_key.master.id}'",
    "Allow service objectstorage-${var.region} to manage object-family in compartment ${data.oci_identity_compartment.lab.name} where target.bucket.name = '${local.archive_bucket_name}'",
    "Allow any-user to manage objects in compartment ${data.oci_identity_compartment.lab.name} where all {request.principal.type = 'serviceconnector', target.bucket.name = '${local.archive_bucket_name}', request.principal.compartment.id = '${var.compartment_ocid}'}",
  ]

  freeform_tags = local.common_tags
}
09-secops/vault.tf(ボールト / マスター暗号化キー / 2 種類のシークレット)
# ------------------------------------------------------------
# 9-2:ボールト + マスター暗号化キー + シークレット
#
#   ボールト(DEFAULT:ほかのユーザーと共用の HSM パーティション、無料)
#    └─ マスター暗号化キー(SOFTWARE 保護:無料。HSM 保護は 20 キー・バージョンまでしか無料にならない)
#         ├─ シークレット A:Terraform の random_password で生成 → ボールトに格納(反面教師:state に平文が残る)
#         ├─ シークレット B:ボールト自身が生成(auto-generation)→ Terraform は「枠」だけ管理、state にパスワードはない
#         └─ 9-3:ログ・アーカイブ用バケットの CMK
#
# ⚠ 削除:destroy してもすぐには消えず、「削除保留中」になる(7〜30 日)
#   この間は同名のシークレットを再作成できない(名前が使われたまま)。ソフトウェア・キーは課金されない
# ------------------------------------------------------------

resource "oci_kms_vault" "this" {
  compartment_id = var.compartment_ocid
  display_name   = "${local.name_prefix}-vault"
  vault_type     = "DEFAULT" # VIRTUAL_PRIVATE(専用の HSM パーティション)は無料枠の対象外

  freeform_tags = local.common_tags
}

# マスター暗号化キー:AES-256(length の単位はバイト → 32)
#   management_endpoint:キーの管理 API はボールトごとに専用のエンドポイント
resource "oci_kms_key" "master" {
  compartment_id      = var.compartment_ocid
  display_name        = "${local.name_prefix}-master-key"
  management_endpoint = oci_kms_vault.this.management_endpoint
  protection_mode     = "SOFTWARE"

  key_shape {
    algorithm = "AES"
    length    = 32
  }

  freeform_tags = local.common_tags
}

# ---- シークレット A:Terraform で生成(反面教師)----
#   random_password の値は sensitive → plan / apply の出力には表示されない
#   ただし state には平文で入る(terraform state show でも伏せられるだけで、state ファイル自体を見れば読める)
resource "random_password" "db_a" {
  length  = 24
  special = true
}

resource "oci_vault_secret" "db_a" {
  compartment_id = var.compartment_ocid
  vault_id       = oci_kms_vault.this.id
  key_id         = oci_kms_key.master.id
  secret_name    = "${local.name_prefix}-db-password-tf"
  description    = "DB password generated by Terraform (plaintext in state)"

  secret_content {
    content_type = "BASE64"
    content      = base64encode(random_password.db_a.result)
  }

  freeform_tags = local.common_tags
}

# ---- シークレット B:ボールト自身が生成(推奨)----
#   generation_type / generation_template の値:SDK の PassphraseGenerationContext で調べた
#   パスワードはボールトの中だけ → Terraform の state、plan、ローカル PC のどこにも出てこない
resource "oci_vault_secret" "db_b" {
  compartment_id         = var.compartment_ocid
  vault_id               = oci_kms_vault.this.id
  key_id                 = oci_kms_key.master.id
  secret_name            = "${local.name_prefix}-db-password-gen"
  description            = "DB password generated by Vault (not in Terraform state)"
  enable_auto_generation = true

  secret_generation_context {
    generation_type     = "PASSPHRASE"
    generation_template = "SECRETS_DEFAULT_PASSWORD"
    passphrase_length   = 24
  }

  freeform_tags = local.common_tags
}
09-secops/logging.tf(アクセス・ログ / フロー・ログ / アーカイブ)
# ------------------------------------------------------------
# 9-3:ログ
#   ① サイトのバケット(07-site の tflab-site)のアクセス・ログ(Object Storage の read)
#   ② VCN フロー・ログ(09 のサブネット)
#   ③ 監査(Audit)ログ:最初から有効(365 日)、作成不要 → ログ検索(Logging Search)で検索
#   ④ コネクタ・ハブ:① + ③ → アーカイブ用バケット(9-2 のマスター暗号化キーで暗号化 = CMK、30 日後に自動削除)
#
# カテゴリ名(read / write、all):LoggingManagementClient.list_services() で調べた
# ログ量はごく少ない → Logging の毎月 10GB の無料枠内。コネクタは 2 つまで無料、ここでは 1 つ使う
# ------------------------------------------------------------

data "oci_objectstorage_namespace" "ns" {
  compartment_id = var.compartment_ocid
}

resource "oci_logging_log_group" "this" {
  compartment_id = var.compartment_ocid
  display_name   = "${local.name_prefix}-logs"

  freeform_tags = local.common_tags
}

# ① サイトのアクセス・ログ:誰が(IP、User-Agent)いつどのオブジェクトを GET したか
#   バケットは 07-site が管理している → ここでは名前で参照するだけ(ログ設定は 09 に集約)
resource "oci_logging_log" "site_read" {
  log_group_id       = oci_logging_log_group.this.id
  display_name       = "${local.name_prefix}-site-read"
  log_type           = "SERVICE"
  is_enabled         = true
  retention_duration = 30 # 日(最短 30)

  configuration {
    source {
      category    = "read"
      resource    = var.site_bucket_name
      service     = "objectstorage"
      source_type = "OCISERVICE"
    }
  }

  freeform_tags = local.common_tags
}

# ② VCN フロー・ログ:サブネット内の各 VNIC の通信(送信元 / 宛先 IP、ポート、ACCEPT / REJECT)
resource "oci_logging_log" "flow" {
  log_group_id       = oci_logging_log_group.this.id
  display_name       = "${local.name_prefix}-flow"
  log_type           = "SERVICE"
  is_enabled         = true
  retention_duration = 30

  configuration {
    source {
      category    = "all"
      resource    = oci_core_subnet.private.id
      service     = "flowlogs"
      source_type = "OCISERVICE"
    }
  }

  freeform_tags = local.common_tags
}

# ---- ④ アーカイブ ----

# アーカイブ用バケット:9-2 のマスター暗号化キーで暗号化(CMK)
#   Object Storage サービスに先に use keys の権限が必要(iam.tf の archive ポリシー)→ depends_on
# ⚠ destroy:バケットにオブジェクトがあると削除できない → 先に空にする(コンソールまたは SDK)
resource "oci_objectstorage_bucket" "archive" {
  #checkov:skip=CKV_OCI_8:Log archive objects are write-once; versioning is not needed
  #checkov:skip=CKV_OCI_7:No event consumers for the archive bucket
  compartment_id = var.compartment_ocid
  namespace      = data.oci_objectstorage_namespace.ns.namespace
  name           = local.archive_bucket_name # iam.tf のポリシーでもこの名前を使う
  access_type    = "NoPublicAccess"
  storage_tier   = "Standard"
  versioning     = "Disabled"
  kms_key_id     = oci_kms_key.master.id

  freeform_tags = local.common_tags

  depends_on = [oci_identity_policy.archive]
}

# 30 日後に自動削除(ストレージ費用 / 10GB の無料枠)
#   ライフサイクルは Object Storage サービスが実行する → サービスにも権限が必要(iam.tf)
resource "oci_objectstorage_object_lifecycle_policy" "archive" {
  namespace = data.oci_objectstorage_namespace.ns.namespace
  bucket    = oci_objectstorage_bucket.archive.name

  rules {
    name        = "delete-after-30-days"
    action      = "DELETE"
    is_enabled  = true
    target      = "objects"
    time_amount = 30
    time_unit   = "DAYS"
  }

  depends_on = [oci_identity_policy.archive]
}

# コネクタ・ハブ:ログ → アーカイブ用バケット(gzip された JSON ファイル)
#   監査(Audit)のロググループは固定名 "_Audit"(コンパートメント単位)
#   batch_rollover_time:最長 7 分ごとに 1 ファイル書き込む(1 分ごとだと月に最大 4 万回超のリクエストになり、無料枠の 5 万回に近い)
#     範囲は 60000〜420000 ms(10 分 → 400 InvalidParameter)→ 上限の 7 分を採用:月に最大約 6,200 ファイル
resource "oci_sch_service_connector" "archive" {
  compartment_id = var.compartment_ocid
  display_name   = "${local.name_prefix}-log-archive"
  description    = "site access logs + audit -> ${oci_objectstorage_bucket.archive.name}"

  source {
    kind = "logging"

    log_sources {
      compartment_id = var.compartment_ocid
      log_group_id   = oci_logging_log_group.this.id
      log_id         = oci_logging_log.site_read.id
    }

    log_sources {
      compartment_id = var.compartment_ocid
      log_group_id   = "_Audit"
    }
  }

  target {
    kind                       = "objectStorage"
    namespace                  = data.oci_objectstorage_namespace.ns.namespace
    bucket                     = oci_objectstorage_bucket.archive.name
    object_name_prefix         = "logs"
    batch_rollover_time_in_ms  = 420000 # 7 分(上限)
    batch_rollover_size_in_mbs = 100
  }

  freeform_tags = local.common_tags

  depends_on = [oci_identity_policy.archive]
}
08-guard/iam.tf(Function の権限)
# ------------------------------------------------------------
# Phase 8:権限
#   Function はリソース・プリンシパル(Phase 7 のビルド・パイプラインと同じく、API キーは発行しない)
# ------------------------------------------------------------

# tf-lab の親 = テナンシ(動的グループはテナンシ直下にしか作れない)
data "oci_identity_compartment" "lab" {
  id = var.compartment_ocid
}

# tf-lab 内の Function はすべてこの動的グループのメンバー
resource "oci_identity_dynamic_group" "guard_fn" {
  compartment_id = data.oci_identity_compartment.lab.compartment_id
  name           = "${var.project_name}-guard-fn"
  description    = "Functions in ${data.oci_identity_compartment.lab.name}"
  matching_rule  = "ALL {resource.type = 'fnfunc', resource.compartment.id = '${var.compartment_ocid}'}"

  freeform_tags = local.common_tags
}

# tf-lab にアタッチ
#   Function:use buckets + where バケット名 → tflab-site の設定の読み取り / 変更だけ(バケットの削除、オブジェクトやほかのバケットには触れない)
#   ファンクション・サービス自体(faas):Function をサブネットに接続、OCIR からイメージを取得
#   イベント・サービス(cloudEvents):予算アラートのイベントをトピックに送る
resource "oci_identity_policy" "guard" {
  compartment_id = var.compartment_ocid
  name           = "${var.project_name}-guard"
  description    = "site-guard function and the Functions service"

  statements = [
    "Allow dynamic-group ${oci_identity_dynamic_group.guard_fn.name} to use buckets in compartment ${data.oci_identity_compartment.lab.name} where target.bucket.name = '${var.site_bucket_name}'",
    "Allow service faas to use virtual-network-family in compartment ${data.oci_identity_compartment.lab.name}",
    "Allow service faas to read repos in compartment ${data.oci_identity_compartment.lab.name}",
    # イベント(予算アラートのルール)から tf-lab のアラート用トピックへメッセージを送る
    "Allow service cloudEvents to use ons-topics in compartment ${data.oci_identity_compartment.lab.name}",
  ]

  freeform_tags = local.common_tags
}
07-site/aws.tf(AWS キーのシークレット:枠だけを作る)
# ------------------------------------------------------------
# Phase 10-4:build 後に AWS にもアップロード(10-aws-site/site の S3 + CloudFront)
#   AWS の Access Key(IAM ユーザー tflab-site-deployer)はここの Vault Secret に入れる
#   build runner は Resource Principal でこの Secret を読む → コード、build_spec、build ログのどこにも Key はない
#
#   Vault(DEFAULT、無料)
#    └─ マスター暗号化キー(SOFTWARE、無料)
#         └─ Secret:{"access_key_id": ..., "secret_access_key": ...}(JSON)
#
#   Secret の中身は Terraform を通さない:
#     Terraform は Secret(中身はプレースホルダー)を作成するだけ + ignore_changes
#     本当の値は tools/rotate_aws_deploy_key.py が新しいバージョンとして書き込む(そうしないと次の apply で本当の値がプレースホルダーに戻される)
#
# ⚠ 削除:destroy 後は「削除保留中」になり(7〜30 日)、その間は同名の Secret を再作成できない
# ------------------------------------------------------------

resource "oci_kms_vault" "site" {
  compartment_id = var.compartment_ocid
  display_name   = "${var.project_name}-site-vault"
  vault_type     = "DEFAULT"

  freeform_tags = local.common_tags
}

resource "oci_kms_key" "site" {
  compartment_id      = var.compartment_ocid
  display_name        = "${var.project_name}-site-key"
  management_endpoint = oci_kms_vault.site.management_endpoint
  protection_mode     = "SOFTWARE"

  key_shape {
    algorithm = "AES"
    length    = 32
  }

  freeform_tags = local.common_tags
}

resource "oci_vault_secret" "aws_deployer" {
  compartment_id = var.compartment_ocid
  vault_id       = oci_kms_vault.site.id
  key_id         = oci_kms_key.site.id
  secret_name    = "${var.project_name}-aws-site-deployer"
  description    = "AWS access key of IAM user tflab-site-deployer (written by tools/rotate_aws_deploy_key.py)"

  secret_content {
    content_type = "BASE64"
    content      = base64encode(jsonencode({ placeholder = true }))
  }

  lifecycle {
    ignore_changes = [secret_content]
  }

  freeform_tags = local.common_tags
}
10-aws-site/site/deploy.tf(AWS のデプロイ用 IAM ユーザー)
# ------------------------------------------------------------
# Phase 10-4:OCI DevOps の build がこのサイトへアップロードするための IAM ユーザー
#   OCI の build runner は AWS のリソースではない → IAM Role(AWS で「プログラムが自分の ID を使う」やり方)は使えない
#   → Access Key(長期有効)を発行するしかない。そのため、できることを最小限に絞る:
#     サイト bucket のオブジェクトの一覧 / 書き込み / 削除と、この distribution のキャッシュ削除だけ(bucket 自体やほかのリソースには触れない)
#   Access Key は Terraform では作成しない:aws_iam_access_key の secret が平文で state に残る
#     → tools/rotate_aws_deploy_key.py が Key を作成し、そのまま OCI Vault の Secret に書き込む(ローカルに保存せず、表示もしない)
# ------------------------------------------------------------

resource "aws_iam_user" "deployer" {
  name = "tflab-site-deployer"
  path = "/tflab/"
}

data "aws_iam_policy_document" "deployer" {
  # 同期時に bucket 内の既存オブジェクトの ETag と比較する(list_objects_v2)
  statement {
    sid       = "ListSiteBucket"
    actions   = ["s3:ListBucket"]
    resources = [aws_s3_bucket.site.arn]
  }

  statement {
    sid       = "WriteSiteObjects"
    actions   = ["s3:PutObject", "s3:DeleteObject"]
    resources = ["${aws_s3_bucket.site.arn}/*"]
  }

  statement {
    sid       = "InvalidateSiteCache"
    actions   = ["cloudfront:CreateInvalidation", "cloudfront:GetInvalidation"]
    resources = [aws_cloudfront_distribution.site.arn]
  }
}

# ユーザーのインライン policy(このユーザー専用。独立した管理 policy は不要)
resource "aws_iam_user_policy" "deployer" {
  name   = "site-deploy"
  user   = aws_iam_user.deployer.name
  policy = data.aws_iam_policy_document.deployer.json
}