GitOps 身份、密钥与访问治理:从仓库认证到租户退出
凌晨的发布窗口里,Argo CD 显示仓库连接成功,Application 也已经 Synced,目标命名空间却出现了一个不该由发布控制器创建的 ClusterRoleBinding。团队最初怀疑清单被人偷偷修改,追查后才发现:读取仓库的只读 Token 没有问题,真正过宽的是目标集群凭据。AppProject 虽然限制了应用目的地,控制器持有的 Kubernetes 身份仍然能够写集群级 RBAC。GitOps 界面上的绿色状态,只证明某一段动作成功,不能证明整条身份链没有越权。
另一个现场恰好相反。Flux 能拉取仓库,也能验证提交签名,SOPS 解密却持续失败。工程师把 age 私钥补进控制器后,发布恢复了,但几分钟后支持包中出现了渲染后的 Secret 片段。问题不是“密文是否进入 Git”这么简单,而是明文在哪个进程内出现、是否进入缓存或日志、由哪个 ServiceAccount 写入 API Server、谁还能通过 Secret、Pod 或审计后端再次读到它。身份、签名与密钥必须按不同控制点拆开,才能知道该收紧哪里、轮换什么、撤销谁。
先把部署账号拆成彼此独立的身份
GitOps 控制器不是一个拿着万能 Token 的机械手。一次调和至少经过五种主体:source identity 读取 Git、Helm 仓库或 OCI registry;signer identity 证明 commit、tag 或 artifact 由受信发布者签过;cluster identity 向目标 API Server 完成认证;reconciliation identity 执行 diff、apply、delete 或 prune;secret identity 调用 KMS、Vault 或外部密钥服务。人和 CI 调用 Argo API、修改 Flux CRD 时,还存在独立的 operator identity。
这几种身份回答的问题不同。仓库 Token 能读代码,不代表提交者可信;签名有效,不代表签名者有权发布到生产;集群认证成功,不代表可以写任意 namespace;SOPS 能解密,不代表控制器可以读取所有租户的 KMS key;用户能点 Sync,也不应自动拥有 override、logs 或 exec。把它们合成“部署账号”,轮换时就只能整套停机,失陷时也很难判断攻击者究竟能走到哪一步。
一个可维护的身份台账不记录秘密值,只记录稳定指纹和关系:主体名称、认证方式、目标对象、允许动作、信任策略、过期或轮换状态、owner,以及最后一次成功和拒绝证据。尤其要避免同一主体同时拥有 source write、签名、sync、cluster-admin 与 decrypt;否则一个凭据失陷就能完成“改期望状态、签名、解密、落地、掩盖现场”的闭环。
source-reader -> read repository/registry
release-signer -> sign commit/tag/artifact
cluster-authn -> authenticate to one API server
tenant-applier -> apply approved GVRs in one namespace
secret-reader -> decrypt one key or read one provider prefix
operator -> inspect/sync one project, without override/exec这张关系图也给排障提供了顺序:拉取失败先看 source identity 与服务端身份;verification 失败看 signer trust;forbidden 看 reconciliation identity;DecryptionFailed 看 secret identity;能够同步却落到错误集群,则检查 cluster endpoint、CA、audience 和 subject,而不是继续扩大 RBAC。
SSH、HTTPS 与 OCI 要同时验证两端
SSH 私钥只证明客户端是谁,known_hosts 才绑定服务端 host key。Flux 的 GitRepository SSH Secret通常同时保存 identity 与 known_hosts;Argo CD 也要独立维护受信 host key。host key 轮换应先并行信任新旧 key,完成服务端切换并验证连接,再删除旧 key。遇到不匹配就关闭 host key 检查,会把“服务器换钥”降级成“任何中间人都可冒充服务器”。Flux 使用标准 ssh://user@host:port/path URL,不应把 Git 客户端常见的 scp-like 地址原样搬过来。
HTTPS Token、Git App 私钥或 client certificate 解决客户端认证,TLS CA 解决服务端身份。自签 CA 应进入控制器支持的信任配置;mTLS 的 client key 仍是高价值凭据。insecure、跳过 CA 校验或退回明文 HTTP 只能让连接看似恢复,却抹掉了传输链最关键的身份判断。纯拉取场景只给 read 权限;需要镜像自动化写回时,另建 writer identity,避免 source-controller 因一个写回任务获得所有仓库写权限。
Argo CD 的 repository credential template 按 URL 前缀匹配。前缀写到整个主机,等于让同一凭据可能被意外仓库继承;单个 repository 条目若已经带凭据信息,又可能不再继承模板。评审时要同时看 host、组织、路径与项目归属,而不是只确认 Secret 名。Flux 的 secretRef 与 Source 同 namespace,减少了直接跨 namespace 引用,但该 namespace 的 Secret 修改权仍可把正确仓库替换成攻击者控制的 source。
OCI 也有两条链。registry login 证明“有权拉取”,Cosign 或 Notation 验证证明“拉到的 artifact 满足签名策略”。生产清单优先固定 digest,keyless Cosign 还要限制 OIDC issuer 与 subject;只验证公共透明日志路径而不约束发布者身份,仍可能接受错误组织的合法签名。Git 同理:PGP/SSH 验证需要明确检查的是 HEAD、tag、tag 与 commit,还是更长的历史链。签名证明内容未被签名后篡改,不会自动证明评审、测试和依赖扫描已经通过。
不同控制器的实现不能互相套用。Flux 的 Git source 可按模式验证 PGP 或 SSH 签名,OCIRepository 可使用 Cosign 或 Notation;Argo CD 传统 GPG 校验只覆盖 Git source,不等于 Helm/OCI 已验证,也不是完整 OpenPGP Web of Trust。Argo CD 3.5 文档中的 AppProject.spec.sourceIntegrity 与旧 signatureKeys 的迁移关系属于明确版本边界,旧控制器不能直接采用新字段。升级签名策略时要先验证 CRD/schema、目标 source 类型和失败 condition,再切换 trust list。
集群身份与工作负载身份不能互相冒充
Argo CD 的 cluster Secret 可能保存 bearer token、client certificate、TLS CA、云角色或动态认证配置。它能重建完整 REST client,应按最高敏感级处理。namespaces、project-scoped cluster 与 AppProject destination 是有用的控制层,但最终写权限仍由目标 Kubernetes RBAC决定。若凭据还能创建 CRD、ClusterRoleBinding 或 Namespace,控制面配置被放宽或绕过时,集群依然可能被接管。
Flux 远端调和更容易混淆两种 ServiceAccount:kubeConfig 对应的主体先向远端集群完成认证,spec.serviceAccountName 指定的主体再被 impersonate,后者决定实际 apply 权限。认证主体只应拥有连接和必要的 impersonate 能力;租户 ServiceAccount 只在同名 namespace 中写批准的 GVR。静态 kubeconfig 即使被 Secret 包住,仍可能包含长期 token 或 client key;能动态构建凭据时,优先使用短期 token 与 workload identity。
workload identity 消除了云 AK/SK 或长寿命 kubeconfig,并没有消除授权设计。Kubernetes ServiceAccount、外部 IAM trust、OIDC issuer、subject、audience、TokenRequest 权限和云角色动作共同决定最终能力。对象级身份尤其要审查控制器是否能为其他 ServiceAccount 创建 serviceaccounts/token;一个可任意选择高权限 SA 的租户,仍可借短期 token 完成长期权限提升。
上线前用五个问题固定动态身份:哪个 Pod 内的哪个 ServiceAccount 发起交换;token 的 issuer、subject、audience 与有效期是什么;外部 trust 是否精确到 namespace 和 SA;角色只能访问哪个 repo、registry、KMS key 或 cluster;对象没有指定身份时会回退到哪个 controller-global identity。随后故意使用错误 audience、错误 namespace 与错误目标,只有交换或 API 请求被拒绝,最小权限才是服务端事实。
让明文生命周期决定 SOPS 或 External Secrets
SOPS 保护的是 Git 中的静态密文。Flux 的 kustomize-controller 在调和时取得 age/PGP 私钥或 KMS 身份,解密后把普通 Kubernetes Secret 写进 API Server。明文因此至少经过控制器内存、渲染与 apply 路径、API Server、etcd、目标 Secret、Pod 挂载或环境变量。etcd 静态加密只保护存储介质,不保护有权限的 API 读取、控制器内存、Pod、日志、备份或错误的审计策略。
.sops.yaml 的 creation rule 只影响新文件。增加或删除 recipient 后,要对既有文件执行 sops updatekeys;若旧 key 已泄露,还需要 sops rotate 生成新的 data key。只从 metadata 删除旧 recipient,不能收回对历史密文和旧 data key 的访问。共享集群应按租户拆 KMS key 或 recipient,并让对象级 decryption ServiceAccount 只能解自己的 key。键名、注释和路径也可能暴露客户号、主机名等元数据,不要因为 value 已加密就把结构视为无敏感信息。
External Secrets Operator 保存外部对象引用,由控制器从 provider 读取值,在目标 namespace 生成 Secret。它让业务明文不进入 Git,provider 轮换也可以独立于应用 revision;代价是 ESO controller、provider identity、SecretStore 修改权与目标 Secret 都成为新的高价值面。SecretStore 在 namespace 内使用更容易形成租户单元;ClusterSecretStore 可共享,但即使限制了哪些 namespace 能引用,也不一定能限制每个 namespace 可读取的 remote key 或 prefix。真正隔离还需要每租户 provider role、独立 store,或 admission 对 remote key、dataFrom 与 target template 施加约束。
选择时不要问“哪种更安全”,而要画出明文出现的位置。密钥值需要随 Git revision 原子回退、团队能管理 recipient 生命周期时,SOPS 更自然;值由安全平台独立轮换、应用只消费当前版本时,ESO 更合适。两者最终都通常生成 Kubernetes Secret,都要处理 etcd 加密、Secret RBAC、Pod 间接读取、应用 reload 和日志脱敏。Argo CD 中在 repo-server 插件阶段注入明文尤其危险,因为生成清单可能以明文进入缓存、diff、插件输出或支持包;优先让 Secret 在目标集群内生成。
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: orders-db
namespace: tenant-a
spec:
refreshInterval: 1h
secretStoreRef:
kind: SecretStore
name: tenant-a-store
target:
name: orders-db
creationPolicy: Owner
data:
- secretKey: password
remoteRef:
key: tenant-a/orders/database
property: password这个对象只说明引用关系,不包含真实值。creationPolicy、deletionPolicy 与 refresh 策略必须和业务可用性一起决定:Orphan/Retain 可能在租户退出后留下明文,Delete 可能把 provider 侧误删传播为服务中断,多个对象 Merge 同一 key 还可能持续振荡。
用 AppProject 和 Flux RBAC 收紧写入路径
Argo CD 的 AppProject 应明确列出允许的 source repo、目标 cluster/namespace 与资源种类。自动生成的 default 项目不适合作为共享生产入口。项目角色只管理该项目内的 Argo 动作,全局 policy.default 则是所有已认证用户的权限下限,后续 deny 无法收回已经由默认角色授予的能力,因此默认角色必须足够小。
applications sync 与 override、logs get、exec create 是四种完全不同的能力。override 可以绕过 Git 同步本地 manifest,exec 接近 Pod shell,日志可能包含敏感值。发布人员只需要查看与同步时,不要顺手授予后三项。拥有 Argo namespace 中 Application、AppProject、repository/cluster Secret 修改权的 Kubernetes 用户,本质上也是 Argo 管理员;Argo API RBAC 挡不住其直接改控制面对象。
Flux 没有另一套用户权限系统,谁能创建或修改 GitRepository、Kustomization、HelmRelease 与 Secret,完全由 Kubernetes RBAC决定。控制器默认高权限部署适合平台自管层,不适合不互信租户。共享集群要让租户对象通过 spec.serviceAccountName impersonate 低权限 SA,并使用 --default-service-account 防止省略字段时回退到控制器身份;同时打开 --no-cross-namespace-refs=true 与 --no-remote-bases=true,阻止跨 namespace 引用和未注册远端 base。
namespace 只是 namespaced API 对象的管理单元,不会隔离 CRD、ClusterRole、StorageClass、Node 或 Webhook,也不会默认隔离网络、节点内核、DNS、存储与资源竞争。平台调和器负责 cluster-scoped 对象,租户调和器只写自己的 namespace;再配合 default-deny NetworkPolicy、Pod Security、ResourceQuota、受控 StorageClass 与 admission。租户彼此不信任、必须管理集群级对象或需要独立故障域时,应选择独立控制器、虚拟控制面或独立集群。
用一组允许和拒绝实验证明权限
先为 tenant-a 创建一个只管理 Deployment、Service 和 ConfigMap 的 ServiceAccount。Secret 不在常规 apply 权限里,集群级资源也不在其中。RoleBinding 的授权效果只落在它所在的 namespace,但 roleRef 既可以引用同 namespace Role,也可以引用 ClusterRole,subject 还可以来自其他 namespace;下面刻意使用同 namespace Role 和 ServiceAccount,减少跨租户关系。平台管理员负责创建这段基线,租户仓库不能修改自己的授权对象。
apiVersion: v1
kind: ServiceAccount
metadata:
name: gitops-reconciler
namespace: tenant-a
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: gitops-reconciler
namespace: tenant-a
rules:
- apiGroups: ["", "apps"]
resources: ["configmaps", "services", "deployments", "replicasets"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: gitops-reconciler
namespace: tenant-a
subjects:
- kind: ServiceAccount
name: gitops-reconciler
namespace: tenant-a
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: gitops-reconciler不要只运行一个成功命令。下面的矩阵同时证明允许项、同 namespace 敏感项、跨 namespace 与集群级拒绝。预期依次为 yes、no、no、no;任一拒绝项返回 yes,都应先停止自动调和,再检查 RoleBinding、聚合角色、controller fallback identity 与 impersonation 配置。
SUBJECT='system:serviceaccount:tenant-a:gitops-reconciler'
kubectl auth can-i --as="$SUBJECT" create deployments -n tenant-a
kubectl auth can-i --as="$SUBJECT" get secrets -n tenant-a
kubectl auth can-i --as="$SUBJECT" patch deployments -n tenant-b
kubectl auth can-i --as="$SUBJECT" create clusterrolebindings把同一反例放进租户 Git 仓库更重要:提交一个指向 tenant-b 的对象,再提交一个 ClusterRoleBinding。GitOps condition 应保留明确的 forbidden,目标对象不得出现。随后恢复安全 revision,确认控制器能继续更新 tenant-a Deployment。这样才能区分“控制器整体坏了”和“服务器正确拒绝越权”。对 source 再做一组反例:错误 SSH host key、错误 CA、未签 commit 或错误 Cosign subject 必须让 artifact 不产生或 verification condition 变为 False,而且不得自动降级到 insecure。
Secret 的间接读取也要测试。Kubernetes 中,能创建 Pod 的主体可能挂载同 namespace Secret,或选择一个更高权限 ServiceAccount;list/watch secrets 更等价于批量读取。单独让 get secrets 返回 no 并不充分。admission 应拒绝租户选择平台 SA、挂载高价值 Secret、使用特权 Pod 或访问 hostPath,并留下审计事件。
轮换与撤销必须经过双轨切换
仓库或 OCI 凭据轮换从创建新 identity 开始,新身份的权限不能大于旧身份。先在服务端并行允许新 PAT、deploy key、App key 或 client certificate,再更新 Argo repository/repo-creds 或 Flux Secret/ServiceAccount。强制 reconcile 后,保存目标 revision/digest、TLS 或 host key、signature condition 与业务对象证据。确认新身份独立工作后撤销旧身份,并用旧凭据做一次失败连接;只删除集群 Secret 而不在 Git/registry 端撤权,不叫撤销。
cluster/workload identity 也走同样的先增后减。先创建新的 SA、云角色和 federation,完成允许/拒绝矩阵,再切换 GitOps 对象。新身份应能 read、diff、apply 和必要的 prune,同时无法访问其他 namespace、cluster-scoped 对象与错误 cluster。之后撤销旧 RoleBinding、client certificate、token 或 trust,等待旧短期 token 过期并再次确认拒绝。旧 SA 和 Secret 只有在没有对象引用、没有活跃调和、审计中没有继续命中时才删除。
SOPS 先增加新 recipient 或 KMS identity 并更新所有密文,再移除旧 recipient;发生泄漏时还要 rotate data key。ESO 先建立新 provider role 与 store identity,确认目标 Secret 的 version 已更新,再验证应用是否真正 reload:volume 文件可能更新,环境变量不会在现有进程中自动改变,连接池和长连接也可能继续使用旧密码。轮换完成的判据不是 ExternalSecret Ready,而是新身份成功、旧身份被拒绝、目标值已更新、应用已切换、旧连接不再可用。
紧急账号同样需要退出时刻。break-glass 临时开放 override、exec、cluster-admin 或共享 KMS decrypt 后,应固定安全 revision、记录操作主体,结束后删除绑定并执行拒绝测试。把临时权限留到“稍后再清”,最终往往会变成下一次事故的永久入口。
日志与审计只保存关联证据
完整证据链需要把 Git commit/digest 与签名者、GitOps resolved revision 与 condition、Kubernetes audit request UID、目标对象 managedFields、KMS/provider audit 以及轮换记录关联起来。Git 历史只能回答期望状态由谁改过,不能单独回答谁在何时把它同步到哪个集群;控制器 Ready 也不能证明业务已经使用新 Secret。
日志、diff、Event、status message、notification 与支持包都可能泄露 repo URL、用户名、Secret key 名、KMS key ID、namespace、客户标识甚至渲染值。控制器日志只记录 credential fingerprint、secret version、hash、revision 与 request UID,不打印 Token、private key、完整 kubeconfig、Secret data 或解密后的 manifest。Shell 调试要关闭 set -x,避免把认证 header 和环境变量带入流水线日志;SOPS exec-env、临时文件与崩溃转储也要纳入清理。
Kubernetes audit policy 对 Secret、repository/cluster credential Secret、SOPS key Secret 和 ESO 对象记录 Metadata 级 create、update、patch、delete 以及必要的读取动作。不要对 Secret 使用 RequestResponse,否则审计后端本身会变成明文仓库。还应记录 pods/exec、pods/log、serviceaccounts/token、RoleBinding、AppProject、GitRepository、Kustomization、SecretStore 与 ExternalSecret 的变更,并对短时间批量 list Secret、关闭 TLS 校验、放宽 credential template、修改 signer trust 和改变目标集群等行为告警。
只读盘点应避免 kubectl get secret -o yaml。下面的命令只查看对象关系与 condition,不读取 Secret data;输出中若 repo 地址、namespace 或 key 标识仍有业务含义,进入工单前继续脱敏。
flux get sources all -A
flux get kustomizations -A
kubectl get externalsecret -A \
-o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,STORE:.spec.secretStoreRef.name,READY:.status.conditions[-1].status,REASON:.status.conditions[-1].reason'
kubectl get secret -n argocd \
-l argocd.argoproj.io/secret-type=cluster \
-o custom-columns='NAME:.metadata.name,TYPE:.metadata.labels.argocd\.argoproj\.io/secret-type'租户退出要先停调和再拆身份
租户退出不是删除 namespace。先 suspend 对应 Application、Kustomization、HelmRelease 与 ExternalSecret 刷新,固定最后安全 revision,并确认没有进行中的 sync、prune、hook 或 secret refresh。随后导出不含明文的关系证据:source revision、inventory、目标对象、credential fingerprint、provider secret version、RoleBinding 与 owner。这样即使后续对象被删,也能解释哪些资源由谁管理过。
第二步先撤主动能力:移除 Git/OCI read-write、GitOps API token、cluster credential、workload federation、KMS decrypt 和 provider read。每种旧身份都做服务端拒绝测试。第三步再处理运行对象:根据资源所有权决定归还、接管或删除,清理 ExternalSecret 生成的 Secret、SOPS key Secret、repo/cluster Secret、RoleBinding 与 tenant ServiceAccount,并检查 Orphan/Retain 策略留下的明文。namespace 只能在控制器 inventory、Webhook、finalizer、PV、云负载均衡器和外部 secret version 都有明确去向后删除。
最后检查共享层没有残留租户入口:repository credential template 不再匹配旧路径,ClusterSecretStore 不再接受旧 namespace 标签,signer trust list 不再信任离场 key,controller-global identity 没有被临时扩大,审计与日志保留策略也不会无限保存敏感标识。租户治理真正完成的信号,是新身份能够持续完成自己的调和,离场身份在仓库、集群、KMS 与 GitOps API 四个入口都被明确拒绝,而且没有一份明文因为缓存、Secret、Pod 或保留策略继续漂在系统里。
