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):
- 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]
}