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 = マスター暗号化キー |
監査ログのクエリ(ログ検索):
- フロー・ログの 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]
}