跳转至

Phase 9:安全・运维(Vault + Logging + Bastion)

目标

  • 企业项目设计书里几乎必有的三项:密钥 / 机密管理、日志(访问记录、审计)、服务器不暴露在公网
  • 密码不写在代码 / 配置 / VM 里,用 VM 自己的身份(Instance Principal)去 Vault 取
  • 加密密钥由自己管理(CMK),日志长期保存

Bastion 在 Phase 4 已经做过,这次只当作登录通道复用。

结构

flowchart LR
  cs["Cloud Shell"] -->|"session(1 小时)"| bastion["Bastion"]
  bastion -->|":22"| vm["VM tflab9-vm<br/>私有 subnet、没有公网 IP"]
  vm -->|"Service Gateway<br/>(唯一的出口)"| osn["Oracle 服务<br/>dnf 仓库 / Vault / Agent"]
  vm -->|"Instance Principal"| secret["Vault<br/>Secret B"]
  site["网站 bucket"] -->|"访问日志"| conn["Connector Hub"]
  audit["Audit 日志"] --> conn
  conn --> archive["归档 bucket<br/>CMK 加密、30 天后删除"]
步骤 内容
9-1 基础 VCN + 私有 subnet(只有 Service Gateway,没有 Internet GW / NAT GW)+ 私有 VM + Bastion
9-2 Vault Vault + 主密钥 + Secret ×2;VM 用 Instance Principal 只能读其中一个
9-3 Logging 网站访问日志 / VCN Flow Logs / Audit;Connector Hub → 归档 bucket(CMK)
09-secops/
├── network.tf    VCN + 私有 subnet + Service Gateway(出站:Oracle 服务 443 + subnet 内 22)
├── compute.tf    VM(A1 1 OCPU / 6GB、没有公网 IP)+ NSG(SSH 只接受 Bastion)
├── bastion.tf    Bastion
├── vault.tf      Vault / 主密钥 / Secret A・B
├── iam.tf        Dynamic Group(这台 VM)+ Policy(读 Secret B、归档用的权限)
└── logging.tf    Log Group / 访问日志 / Flow Logs / 归档 bucket / 生命周期 / Connector

费用(Always Free)

服务 免费额度 这次的用量
Vault 软件保护的主密钥全部免费;HSM 密钥版本 20 个;Secret 150 个 软件密钥 1 把、Secret 2 个
Bastion 免费 1 个
Logging 每月 10GB(和 Flow Logs 共用) 很少
Connector Hub 2 个 1 个
Object Storage Standard 10GB + 每月 5 万次请求 归档文件最多 7 分钟一个

Vault 是什么

Vault(保险库:容器,也是权限的边界)
 └─ 主密钥(Master Encryption Key:本身不交给任何人)
      ├─ 加密 Secret(密码、Token 等)
      └─ 加密 bucket / Block Volume 等(CMK)
  • Vault:DEFAULT(和别人共用 HSM 的分区,免费)/ VIRTUAL_PRIVATE(独占,收费)
  • 主密钥:信封加密(Envelope Encryption)
    • 数据用临时的数据密钥(DEK)加密,DEK 再用主密钥加密 → 主密钥一停用,数据就读不出来
    • 保护方式:HSM(不出 HSM)/ SOFTWARE(全部免费,学习用足够)
  • Secret:用主密钥加密保存的机密。程序只写 Secret 的 OCID,运行时去取
  • 对照:OCI Vault ≒ AWS KMS + Secrets Manager / GCP Cloud KMS + Secret Manager

两种 Secret 的比较

Secret A(反面教材) Secret B(推荐)
谁生成密码 Terraform(random_password) Vault 自己(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 请求里都有真值。 → 实际项目:密码让 Vault(或 DB 服务)生成,Terraform 只建"框"。

权限

# 9-2:VM 只能读 Secret B 的内容
Allow dynamic-group tflab9-vm to read secret-bundles in compartment tf-lab where target.secret.id = '<Secret B>'

# 9-3:都限定在这一把密钥 / 这一个 bucket
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>'}
  • Dynamic Group:ALL {instance.id = '<VM 的 OCID>'} → 成员只有这一台
  • secret-bundles = Secret 的内容(secrets = 元数据)。解密在 Vault 内部完成 → VM 不需要密钥的权限
  • Object Storage 服务:use keys = CMK 加密 / 解密;manage object-family = 执行生命周期规则
  • Connector Hub 不是 Dynamic Group 的成员 → any-user + request.principal.type 限定

流程和观察

9-1:私有 VM + Bastion

确认项(VM 上) 结果
Cloud Shell → Bastion → VM 的 SSH 登录成功
curl -m 5 ifconfig.me Connection timed out → 出不了公网
sudo dnf -y install jq 成功(经过 Service Gateway 访问 OCI 区域内的仓库)
Bastion plugin VM 启动后约 80 秒变成 RUNNING(Agent 能上报 = SGW 通)

9-2:Vault

确认项 结果
OCI CLI python39-oci-cli(OCI 区域仓库 ol9_oci_included)→ sudo dnf -y install
VM 读 Secret B(--auth instance_principal) 24(只看长度,不 echo 密码)
VM 读 Secret A NotAuthorizedOrNotFound(404)
state(terraform state pull,只看有没有) A 和 random_password 有明文,B 没有
  • OCI 对"没权限"和"不存在"都返回 404 → 不泄露资源是否存在

9-3:Logging

日志 看到的内容
Audit(查询见表下) VM 读 B → 200、读 A → 404。调用者 = VM 的 OCID(authType = instance)、IP = VM 的私有 IP
网站访问日志 curl 3 次 → GET index.html 200 ×3(访问者 IP、User-Agent)。几分钟内出现
Flow Logs Bastion(10.9.1.101)→ VM:22 ACCEPT;VM ↔ Oracle 服务 :443 ACCEPT
归档 Connector 建好约 8 分钟后第一个文件(gzip、82 条:Audit + 网站访问日志)。bucket 的 kms_key_id = 主密钥

Audit 的查询(Logging Search):

search "<tf-lab 的 OCID>/_Audit" | where data.eventName = 'GetSecretBundle'
  • Flow Logs 的 REJECT:Oracle 服务的 443 → VM 的临时端口(入站),每分钟 1〜2 条。推测是连接结束后迟到的包(stateful 的连接跟踪已过期)→ 正常现象
  • curl ifconfig.me(没有路由)在 Flow Logs 里没有记录。推测:路由阶段就被丢掉,不经过安全规则的判定
  • Audit 量比想象的多:terraform plan 一次就有几十条 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 时 in-transit 加密被忽略

代码写 is_pv_encryption_in_transit_enabled = true,建好后 API(VM 的 launch_options、boot volume attachment)都是 false。删掉 launch_options 重建也一样。 → 文档:A1.Flex 支持,建好后也能打开(UpdateInstance,会重启)→ 第 2 次 apply(in-place update)后两边都变成 true。 → 新建 VM 时 apply 两次。只看 plan 发现不了:用 API 确认实际状态。

Policy 刚建好,bucket 的 CMK 报 404

同一次 apply 建 Policy 和 bucket → 404-NotAuthorizedOrFoundKmsKey。depends_on 只保证顺序,不保证 IAM 已经生效。 → 过一会儿再 apply。

Connector 的批量时间上限是 7 分钟

batch_rollover_time_in_ms = 600000 → 400-InvalidParameter(范围 60000〜420000)。 → 420000。每分钟写一个文件的话一个月最多 4 万多次 PUT,接近 Object Storage 5 万次/月的免费额度。

Security List:Bastion 的 SSH 是\"出站\"

Bastion 的 private endpoint 也在同一个 subnet,它 → VM 的 22 对它来说是出站。Security List 只放行"Oracle 服务 443"的话 session 连不上 → 加"subnet 内 22 出站"。

经过两层 SSH 粘贴出现 ^[[200~

bracketed paste 的控制字符没有被识别。→ bind 'set enable-bracketed-paste off' 或手打。

其他

  • Secret 的 generation_type / generation_template 的值:SDK 的 PassphraseGenerationContext 查到
  • Logging 的类别名(objectstorage:read / write,flowlogs:all):LoggingManagementClient.list_services()
  • Vault / 密钥 / Secret:destroy 后进入"计划删除"(7〜30 天),这期间同名 Secret 不能再建
  • 归档 bucket 里有对象时 destroy 失败 → 先清空

完整代码

09-secops/vault.tf
# ------------------------------------------------------------
# 9-2:Vault + 主密钥 + Secret
#
#   Vault(DEFAULT:和别人共用 HSM 的分区,免费)
#    └─ 主密钥(SOFTWARE 保护:免费;HSM 保护只有 20 个版本免费)
#         ├─ Secret A:Terraform 的 random_password 生成 → 放进 Vault(反面教材:state 里有明文)
#         ├─ Secret B:Vault 自己生成(auto-generation)→ Terraform 只管"框",state 里没有密码
#         └─ 9-3:日志归档 bucket 的 CMK
#
# ⚠ 删除:destroy 后不会马上消失,进入"计划删除"(7〜30 天)
#   这期间同名的 Secret 不能再建(名字被占着)。软件密钥不收费
# ------------------------------------------------------------

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 是每个 Vault 自己的 endpoint
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
}

# ---- Secret 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
}

# ---- Secret B:Vault 自己生成(推荐)----
#   generation_type / generation_template 的值:SDK 的 PassphraseGenerationContext 查到
#   密码只在 Vault 里 → Terraform 的 state、plan、本机都碰不到
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 用 Instance Principal 读 Secret
#   Instance Principal = VM 本身的身份(证书由 OCI 自动发放、轮换)→ VM 上不放 API Key
#   (Phase 7 的 Build Pipeline / Phase 8 的 Function 用的 Resource Principal 是同一个思路)
# ------------------------------------------------------------

# tf-lab 的父 = tenancy(Dynamic Group 只能建在 tenancy 下)
data "oci_identity_compartment" "lab" {
  id = var.compartment_ocid
}

# 成员只有这一台 VM(按 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
}

# 只允许读 Secret B 的内容(secret-bundles = Secret 的内容;secrets = 元数据)
#   Secret A 故意不授权 → VM 上读 A 被拒绝 = 最小权限的确认
#   解密由 Vault 服务内部完成 → 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:日志归档用的权限(都限定在这一把密钥 / 这一个 bucket)
#   Object Storage 服务(objectstorage-<region>):
#     - use keys:用主密钥加密 / 解密 bucket 里的数据(CMK)
#     - manage object-family:执行生命周期规则(30 天后删除)
#   Connector Hub:服务本身不是 dynamic group 的成员 → 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:日志
#   ① 网站 bucket(07-site 的 tflab-site)的访问日志(Object Storage read)
#   ② VCN Flow Logs(09 的 subnet)
#   ③ Audit 日志:默认就有(365 天),不用建 → Logging Search 里查
#   ④ Connector Hub:① + ③ → 归档 bucket(9-2 的主密钥加密 = CMK、30 天后自动删除)
#
# 类别名(read / write、all):LoggingManagementClient.list_services() 查到
# 日志量很小 → Logging 每月 10GB 免费额度内;Connector 免费 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 了哪个对象
#   bucket 是 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 Flow Logs:subnet 里每个 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
}

# ---- ④ 归档 ----

# 归档 bucket:9-2 的主密钥加密(CMK)
#   Object Storage 服务要先有 use keys 的权限(iam.tf 的 archive policy)→ depends_on
# ⚠ destroy:bucket 里有对象时删不掉 → 先清空(Console 或 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 的 policy 里也用这个名字
  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]
}

# Connector Hub:日志 → 归档 bucket(gzip 的 JSON 文件)
#   Audit 的 log group 是固定名 "_Audit"(compartment 单位)
#   batch_rollover_time:最多 7 分钟写一个文件(每分钟的话一个月最多 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]
}