コンテンツにスキップ

Phase 9:セキュリティ・運用(ボールト + ロギング + 要塞)

目標

  • 企業案件の設計書にほぼ必ず出てくる 3 項目:鍵・機密情報の管理、ログ(アクセス記録、監査)、サーバーをインターネットに公開しない
  • パスワードをコード・設定・VM に書かず、VM 自身の ID(インスタンス・プリンシパル、Instance Principal)でボールト(Vault)から取得する
  • 暗号鍵を自分で管理し(CMK)、ログを長期保存する

要塞(Bastion)は Phase 4 で構築済みなので、今回はログイン経路として再利用するだけです。

構成

flowchart LR
  cs["Cloud Shell"] -->|"セッション(1 時間)"| bastion["要塞"]
  bastion -->|":22"| vm["VM tflab9-vm<br/>プライベート・サブネット、パブリック IP なし"]
  vm -->|"サービス・ゲートウェイ<br/>(唯一の出口)"| osn["Oracle サービス<br/>dnf リポジトリ / ボールト / エージェント"]
  vm -->|"インスタンス・プリンシパル"| secret["ボールト<br/>シークレット B"]
  site["サイトのバケット"] -->|"アクセス・ログ"| conn["コネクタ・ハブ"]
  audit["監査ログ"] --> conn
  conn --> archive["アーカイブ用バケット<br/>CMK で暗号化、30 日後に削除"]
ステップ 内容
9-1 基盤 VCN + プライベート・サブネット(サービス・ゲートウェイ(Service Gateway)のみ。インターネット・ゲートウェイ / NAT ゲートウェイなし)+ プライベート VM + 要塞
9-2 ボールト ボールト + マスター暗号化キー + シークレット ×2。VM はインスタンス・プリンシパルでそのうち 1 つだけ読める
9-3 ロギング サイトのアクセス・ログ / VCN フロー・ログ / 監査(Audit)。コネクタ・ハブ(Connector Hub)→ アーカイブ用バケット(CMK)
09-secops/
├── network.tf    VCN + プライベート・サブネット + サービス・ゲートウェイ(送信:Oracle サービスの 443 + サブネット内の 22)
├── compute.tf    VM(A1 1 OCPU / 6GB、パブリック IP なし)+ NSG(SSH は要塞からのみ)
├── bastion.tf    要塞
├── vault.tf      ボールト / マスター暗号化キー / シークレット A・B
├── iam.tf        動的グループ(この VM)+ ポリシー(シークレット B の読み取り、アーカイブ用の権限)
└── logging.tf    ロググループ / アクセス・ログ / フロー・ログ / アーカイブ用バケット / ライフサイクル / コネクタ

費用(Always Free)

サービス 無料枠 今回の使用量
ボールト ソフトウェア保護のマスター暗号化キーはすべて無料。HSM のキー・バージョン 20 個、シークレット 150 個 ソフトウェア・キー 1 つ、シークレット 2 つ
要塞 無料 1 つ
ロギング 月 10GB(フロー・ログと共有) ごくわずか
コネクタ・ハブ 2 つ 1 つ
オブジェクト・ストレージ Standard 10GB + 月 5 万リクエスト アーカイブ・ファイルは最大 7 分に 1 つ

ボールトとは

ボールト(金庫:入れ物であり、権限の境界でもある)
 └─ マスター暗号化キー(キー自体は誰にも渡さない)
      ├─ シークレット(パスワード、トークンなど)を暗号化
      └─ バケット / ブロック・ボリュームなどを暗号化(CMK)
  • ボールト:DEFAULT(HSM のパーティションを共有、無料)/ VIRTUAL_PRIVATE(専有、有料)
  • マスター暗号化キー:エンベロープ暗号化(Envelope Encryption)
    • データは一時的なデータ暗号化キー(DEK)で暗号化し、DEK をマスター暗号化キーで暗号化 → マスター暗号化キーを無効にすると、データは読めなくなる
    • 保護モード:HSM(HSM の外に出ない)/ SOFTWARE(すべて無料。学習には十分)
  • シークレット:マスター暗号化キーで暗号化して保存する機密情報。プログラムにはシークレットの OCID だけを書き、実行時に取得する
  • 対応関係:OCI のボールト ≒ AWS KMS + Secrets Manager / GCP Cloud KMS + Secret Manager

2 種類のシークレットの比較

シークレット A(悪い例) シークレット B(推奨)
パスワードを生成するのは Terraform(random_password) ボールト自身(enable_auto_generation)
plan の表示 content = (sensitive value) secret_content (known after apply)
state の中 平文がある(random_password.result、base64 の content) ない
VM の読み取り権限 なし あり

(sensitive value) は「表示しない」だけ

(sensitive value) / (known after apply) / # (N unchanged attributes hidden) は、Terraform が plan を表示するときに自動で付けるものです。 sensitive が防げるのは「見られること」だけで、「保存されること」は防げません。state ファイル、plan ファイル(terraform show -json)、API リクエストには本当の値が入っています。 → 実際の案件では、パスワードはボールト(または DB サービス)に生成させ、Terraform は「枠」だけを作ります。

権限

# 9-2:VM が読めるのはシークレット B の内容だけ
Allow dynamic-group tflab9-vm to read secret-bundles in compartment tf-lab where target.secret.id = '<Secret B>'

# 9-3:すべてこの 1 つのキー / この 1 つのバケットに限定
Allow service objectstorage-ap-osaka-1 to use keys in compartment tf-lab where target.key.id = '<マスター暗号化キー>'
Allow service objectstorage-ap-osaka-1 to manage object-family in compartment tf-lab where target.bucket.name = 'tflab9-log-archive'
Allow any-user to manage objects in compartment tf-lab where all {request.principal.type = 'serviceconnector', target.bucket.name = 'tflab9-log-archive', request.principal.compartment.id = '<tf-lab>'}
  • 動的グループ:ALL {instance.id = '<VM の OCID>'} → メンバーはこの 1 台だけ
  • secret-bundles = シークレットの内容(secrets = メタデータ)。復号はボールトの内部で行われる → VM にキーの権限は不要
  • オブジェクト・ストレージ・サービス:use keys = CMK による暗号化・復号、manage object-family = ライフサイクル・ルールの実行
  • コネクタ・ハブは動的グループのメンバーではない → any-user + request.principal.type で限定

手順と観察

9-1:プライベート VM + 要塞

確認項目(VM 上) 結果
Cloud Shell → 要塞 → VM の SSH ログイン成功
curl -m 5 ifconfig.me Connection timed out → インターネットに出られない
sudo dnf -y install jq 成功(サービス・ゲートウェイ経由で OCI リージョン内のリポジトリにアクセス)
要塞プラグイン VM 起動から約 80 秒で RUNNING(エージェントが状態を報告できる = サービス・ゲートウェイが通っている)

9-2:ボールト

確認項目 結果
OCI CLI python39-oci-cli(OCI リージョンのリポジトリ ol9_oci_included)→ sudo dnf -y install
VM でシークレット B を読む(--auth instance_principal) 24(長さだけを確認し、パスワードは表示しない)
VM でシークレット A を読む NotAuthorizedOrNotFound(404)
state(terraform state pull、有無だけを確認) A と random_password には平文があり、B にはない
  • OCI は「権限がない」と「存在しない」をどちらも 404 で返す → リソースが存在するかどうかを漏らさない

9-3:ロギング

ログ 見えた内容
監査(クエリは表の下) VM が B を読む → 200、A を読む → 404。呼び出し元 = VM の OCID(authType = instance)、IP = VM のプライベート IP
サイトのアクセス・ログ curl 3 回 → GET index.html 200 ×3(アクセス元 IP、User-Agent)。数分以内に表示
フロー・ログ 要塞(10.9.1.101)→ VM:22 が ACCEPT。VM ↔ Oracle サービス :443 が ACCEPT
アーカイブ コネクタ作成から約 8 分後に最初のファイル(gzip、82 件:監査 + サイトのアクセス・ログ)。バケットの kms_key_id = マスター暗号化キー

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

search "<tf-lab の OCID>/_Audit" | where data.eventName = 'GetSecretBundle'
  • フロー・ログの REJECT:Oracle サービスの 443 → VM のエフェメラル・ポート(受信)が毎分 1〜2 件。接続終了後に遅れて届いたパケット(ステートフルの接続追跡が期限切れ)と推測 → 正常な現象
  • curl ifconfig.me(ルートなし)はフロー・ログに記録されない。ルーティングの段階で破棄され、セキュリティ・ルールの判定を通らないためと推測
  • 監査ログの量は想像より多い:terraform plan 1 回で数十件の GetXxx

ハマりどころ

launch_options には network_type が必須

400-InvalidParameter, If LaunchOptions is provided, NetworkType must be specified(Phase 4 では同じ書き方でエラーにならなかった)。 → network_type = "PARAVIRTUALIZED"(イメージのデフォルトの launch_options と同じ値。SDK の list_images で確認)。

VM 作成時に転送中の暗号化が無視される

コードでは is_pv_encryption_in_transit_enabled = true なのに、作成後の API(VM の launch_options、ブート・ボリュームのアタッチメント)はどちらも false でした。launch_options を削除して再作成しても同じです。 → ドキュメント:A1.Flex は対応しており、作成後にも有効化できる(UpdateInstance、再起動あり)→ 2 回目の apply(in-place update)で両方 true になりました。 → 新しく VM を作るときは apply が 2 回必要です。plan だけでは気付けないので、API で実際の状態を確認します。

ポリシー作成直後、バケットの CMK が 404

同じ apply でポリシーとバケットを作成 → 404-NotAuthorizedOrFoundKmsKey。depends_on が保証するのは順序だけで、IAM が反映済みかどうかは保証しません。 → 少し待ってからもう一度 apply します。

コネクタのバッチ時間の上限は 7 分

batch_rollover_time_in_ms = 600000 → 400-InvalidParameter(範囲は 60000〜420000)。 → 420000。1 分ごとにファイルを書くと月に最大 4 万回以上の PUT になり、オブジェクト・ストレージの月 5 万リクエストの無料枠に近づきます。

セキュリティ・リスト:要塞の SSH は「送信」

要塞のプライベート・エンドポイントも同じサブネットにあり、そこから VM の 22 への通信はエンドポイントにとっては送信です。セキュリティ・リストで「Oracle サービスの 443」だけを許可するとセッションがつながりません → 「サブネット内の 22 の送信」を追加します。

SSH を 2 段経由して貼り付けると ^[[200~ が出る

ブラケット・ペーストの制御文字が解釈されていません。→ bind 'set enable-bracketed-paste off' するか、手で入力します。

その他

  • シークレットの generation_type / generation_template の値:SDK の PassphraseGenerationContext で確認
  • ロギングのカテゴリ名(objectstorage:read / write、flowlogs:all):LoggingManagementClient.list_services()
  • ボールト / キー / シークレット:destroy すると「削除保留中」(7〜30 日)になり、その間は同じ名前のシークレットを作れない
  • アーカイブ用バケットにオブジェクトがあると destroy に失敗する → 先に空にする

コード全体

09-secops/vault.tf
# ------------------------------------------------------------
# 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/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/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]
}