小团队多环境秘钥管理:SOPS + age + 文档权限即凭证

2026-08-06#secret-management#devops#security#infra#tooling

背景:要解决的不是加密,是流程

在一个多服务后端项目里,运行时配置一度散落三处:提交在仓库里的开发配置文件、部署主机上手工维护的明文文件、以及一堆只靠 .gitignore 保护的本地 env。真实凭证(模型供应商 API key、JWT secret、数据库密码)没有一个「被评审、有版本」的家——README 教开发者把 key 粘进被 git 跟踪的文件,离永久泄漏只差一次 git add;主机上的配置则在静默漂移。

方案的目标从一开始就不是「更强的加密」,而是四件流程上的事:每个环境一份清单(manifest)承载所有随环境变化的值;密文可以提交进仓库;新人 clone 即可跑起来;CD 用环境级的钥匙解密。

核心模型

每个环境一份 env.yaml,用 SOPS + age 加密后提交进仓库,作为该环境所有值的唯一真相源。结构(shape)住在模板里,值住在 manifest 里,一个渲染步骤把两者合成每个服务的启动配置——不存在按环境复制的整份配置文件。一个环境一对 age 密钥,开发密钥永远解不开 prod。私钥的分发不新建任何账号体系:放进协作平台(飞书多维表格)按环境隔离的文档里,文档访问权限本身就是凭证——新人拿到 dev 文档权限,一条幂等的 setup 命令自动取钥、解密、渲染、起栈。CD 流水线的钥匙来自流水线自己的环境级 secret store,永远不从文档平台取。

关键决策

  1. 密文进仓库,仓库是唯一权威。 文档平台上的 vault 只是镜像 + 恢复渠道,方向永远是仓库 → vault 单向收敛;vault 里被手改了就用同步命令拉回来。
  2. 按键加密,不整文件加密。 SOPS 的 encrypted_regex 只加密名字长得像凭证的键((?i)(key|secret|password|token|dsn|credential)),结构和非秘密值在 PR diff 里保持可读。这是 git-crypt 出局的直接原因:整文件二进制 diff 不可评审,而且 .gitattributes 顺序写错会把明文漏进历史。评审可读性是这个方案的第一约束。但要直视它的弱点:覆盖由键名启发式决定,秘密存在名字不像凭证的键下(database_urlwebhook_url 这类)就会明文入库,而 CI 校验的是 SOPS 元数据和键平价,抓不到这种漏网——实际清单的规则后来就是这样补进了 database_url 的。所以键名正则只能当最后一道网:新增叶子先过配置目录的敏感级分类(见第 8 条),guard 脚本再拒绝被跟踪文件里的明文连接串字段;更彻底的设计是让覆盖由 sensitivity 分类驱动,或反转默认(默认全加密、显式豁免非秘密键),代价是牺牲一部分 diff 可读性。
  3. 权限边界复用协作平台的文档权限。 三个环境用三个独立文档而不是一个文档三张表,因为文档平台的权限是向下继承的——单文档要靠高级权限规则区分环境,一次配置失误就把 prod 钥匙暴露给全员。用「独立文档 + 成员制」这种最笨的权限模型,恰恰最难配错。
  4. PR CI 永远无钥。 PR 阶段只用明文的 example manifest(假值)做校验:密文必须带 SOPS 元数据、密文与 example 键平价、跨环境键契约(dev 有的键 staging 必须有,staging 和 prod 同形)——专抓「只加了 dev」和「忘了 prod」。解密只发生在受信任的发布任务里,钥匙来自环境级 secret,任务退出时清掉明文。不受信任的 PR 代码碰不到任何钥匙。
  5. 缺钥不阻塞开发。 没有文档权限的人 apply 会自动退回 example manifest + 确定性 mock,一切照跑;但只有 dev 允许这条退路,staging/prod 拒绝回退。
  6. 凭证唯一属主。 所有模型供应商的 key 只存在于模型网关服务的配置子树下,消费方服务只拿网关的 base URL 和内部 token,不允许任何服务再复制一份供应商 key。轮换时只有一处要改。
  7. vault 只存凭证和配套身份。 最初把所有配置叶子都镜像进 vault,后来发现:一旦非秘密值有了自己的写入面(本地 JSON、配置中心),镜像表就变成漂移源。收缩为只存 age 钥匙、秘密值、以及使用这些秘密所需的身份(用户名、账号 ID)。vault 不是配置浏览器。
  8. 先分类,再选通道。 一份机器校验的配置目录(catalog)给每个配置叶子标注:生命周期(启动期 / 热更新)、敏感级、属主、回滚方式。秘密和启动期值走加密 manifest 通道,非秘密热值走运行时配置通道。改值之前先查它是哪类,而不是凭感觉选地方。

实践流程里验证过的做法

  • Onboarding 是一条命令:装工具链、平台登录、自动取 key(写到 0600 的本地文件)、解密渲染、起整个栈。没有任何人工递钥匙的环节。
  • 改值是一条命令:静默输入新值、原地重加密、重新渲染本地配置、镜像到 vault,变更以 PR 形式出现。Reviewer 在 diff 里看到的是键名和结构变化,具体值去对应环境的 vault 里查——评审「改了什么」与保密「改成了什么」被拆开了。
  • 删除凭证行是显式操作:常规同步永不删 vault 里的行;清理必须先 dry-run 列出将删的行,人工确认后带显式开关再执行。防的是「同步顺手删掉还在用的凭证」。
  • 轮换有两层,混淆它们是最危险的误解:换 age key 只是止损,且操作上必须用会重新生成 data key 的 sops rotate——updatekeys 只是用新钥匙重新包裹旧的 data key,拿过任意历史版本的人解出这把 data key 后,连之后的新密文都能继续解开。而即便 rotate 了,拿过旧 key 的人仍然能解开全部历史提交,这是密文进仓库方案的固有代价。真正的修复是把真实凭证(供应商 key、JWT secret、数据库密码)一起换掉。成员离开或电脑丢失时两层都要轮换,并且写进 runbook 成为固定动作。
  • 明文入库不改写历史:guard 被绕过、明文 secret 进了提交,正确动作是把该值视为已烧毁、立刻轮换真实凭证、再修 manifest——而不是 rebase 掉那次提交然后假装没发生。
  • Guard rails 全部自动化:pre-commit 和 CI 同一套检查——所有被跟踪的 manifest 必须带 SOPS 元数据、仓库任何位置出现 age 私钥直接拒绝、example 里不得出现真值形状的秘密;post-merge 钩子在拉取涉及部署目录后自动重渲染本地配置,git pull 之后不需要记得做任何事。
  • prod 未就绪也先提交加密骨架:在生产基础设施存在之前就把「评审过的形状」固定进仓库,值留空或占位;发布预检在还有占位符、空值、mock 供应商时直接拒绝发布。形状先行,值后补,预检兜底。

备选方案的取舍

  • git-crypt:透明的 smudge/clean 很省事,但 diff 不可评审;且本方案本来就有显式的 apply 步骤,透明性没有收益,评审性却是净损失。拒。
  • 云 KMS / 托管 Vault:托管与审计更强,但对小团队运维太重,且失去 clone-and-go 的 onboarding。留作 prod 加固期再评估的选项,而不是第一天的选择。
  • 每人一把 age key(multi-recipient):撤销性更好——离职从 recipients 列表移除后再跟一次 sops rotate(原因见上:只 updatekeys 挡不住离开的人解开之后的新密文),不用重新分发共享钥匙;代价是维护 recipients 列表和每次成员变更的重加密。团队小的时候用共享 dev key,升级路径写进 runbook 即可,不必第一天付这个成本。

可迁移的判断

秘钥管理方案的成败约束排序:变更如何被评审 > 新人如何拿到钥匙 > 密码学强度。按键加密保住第一条,文档权限即凭证解决第二条,第三条 SOPS + age 早就够用。把身份、权限、审计外包给团队已经在用的协作平台,不新建账号体系,是小团队方案能真正跑起来的原因;而「CI 按信任面分两级(无钥的校验面 / 有钥的发布面)」和「封套钥匙轮换 ≠ 秘密轮换」这两条,与团队规模无关,是所有密文进仓库方案的通用底座。