我前一篇 《给 mirrord 开发者按 namespace 签发 kubeconfig》 写完之后,第一次用 VS Code 的 mirrord 扩展真接到那套 demo 集群——结果在 agent 起来之后立刻被一个 403 Forbidden 卡住。这篇是那次排查的复盘:从 IDE 报错一路追到 Kubernetes impersonation 鉴权链路上一个我之前不知道的细节,以及最后那条最小修复怎么落到 demo 里。

相关代码都在 meirongdev/mirrord-demofeat/rbac-mirrord-governance 分支上。修复对应的 commit 是 6e44011


一、出发点:用 VS Code 扩展跑这套 demo#

前一篇 demo 用 bash run-mirrord.sh -- java -jar ... 在命令行里直接跑通。但团队里大部分人是从 VS Code / IntelliJ 直接 launch / debug 进程的,所以我得验证一遍:用 mirrord 的 VS Code 扩展,配合 alice 的 scoped kubeconfig,能不能跑出和命令行一样的效果。

流程很简单:

  1. 在 VS Code 里装 mirrord 扩展。

  2. launch.json 里加一个 Java 调试配置,target 是 demo 里的 Spring Boot app(target/mirrord-demo-0.0.1-SNAPSHOT.jar)。

  3. 用 alice 的 kubeconfig 启动 VS Code:

    KUBECONFIG=$(pwd)/rbac/.credentials/alice.kubeconfig code .
    
  4. 在 VS Code 里点 mirrord 图标,选 target deployment team-a-dev/app,按 F5 启动调试。

理想路径是:mirrord 扩展替我创建好 agent,调试进程透明地接到集群里。结果第一步就挂了。


二、现象:agent 起得来,但 WS upgrade 直接 403#

VS Code 的 mirrord 输出窗口里跳出来这段:

Failed to connect to the created mirrord-agent:
failed to upgrade to a WebSocket connection:
failed to switch protocol: 403 Forbidden

关键词是「created mirrord-agent」——agent 这一步是过了的。kubectl get pods -n team-a-dev 也能看到 agent pod 已经在 Running,logs 没有异常。403 是出在 mirrord CLI / 扩展和 agent pod 之间握手升级 WebSocket 那一步。

到这里我心里大概有几个候选:

  1. ClusterRole 里漏了 pods/portforward create
  2. PodSecurity 拒了 agent 的 capability?
  3. agent.namespace 配错了,agent job 跑到 default 去了?
  4. serviceaccounts impersonate 出问题了?

按概率排:4 我一开始觉得最不可能——mirrord-developer ClusterRole 里 serviceaccounts: get, impersonate 是写了的,RoleBinding 也按 namespace 绑好了。


三、初步排查:把能直接断言的都断言一遍#

第一轮我直接用 kubectl auth can-i 把 ClusterRole 里每条规则都问了一遍:

KUBECONFIG=.../alice.kubeconfig kubectl auth can-i create pods/portforward -n team-a-dev   # yes
KUBECONFIG=.../alice.kubeconfig kubectl auth can-i get pods/log              -n team-a-dev   # yes
KUBECONFIG=.../alice.kubeconfig kubectl auth can-i create jobs.batch         -n team-a-dev   # yes
KUBECONFIG=.../alice.kubeconfig kubectl auth can-i impersonate serviceaccounts -n team-a-dev # yes

每条都 yes。RBAC 看起来一切都好。

又顺手看了下 PodSecurity——namespace label 是 pod-security.kubernetes.io/enforce: privileged,agent pod 也确实跑起来了,所以不是这里。agent.namespace 在 .mirrord/mirror.json 里显式写了 team-a-dev,pod 也在那里,也不是这里。

到这一步线索断了。在 mirrord 的 GitHub issue 里翻了一圈,看到一条提到 403 on protocol switch 跟 impersonation 有关,但没具体写要看哪个 verb——我决定回到 auth can-i,但这次把所有跟 impersonation 沾边的姿势都打一遍。


四、auth can-i 给出第一条反直觉信号#

下面这一组 query 的结果其实是把整个排查转过来的关键:

# 这条之前问过:在 team-a-dev 里能不能 impersonate SA
kubectl auth can-i impersonate serviceaccounts --as alice -n team-a-dev
# → yes

# 同样的 verb,不带 -n —— 问的是 cluster-scoped
kubectl auth can-i impersonate serviceaccounts --as alice
# → no

# impersonate users —— 不带 -n
kubectl auth can-i impersonate users --as alice
# → no

# impersonate users —— 带 -n team-a-dev
kubectl auth can-i impersonate users --as alice -n team-a-dev
# → no   ← 这是真正反常的一条

最后一条最关键:带了 -n team-a-dev 还是 no

kubectl auth can-i 的语义是「问当前 user 能不能在指定 scope 下执行这个 verb」。一般加了 -n,K8s 会去 namespace 内查 RBAC;这里加了反而没用,意味着这个检查根本不接受 namespace 作为限定。

这是「users 这个资源根本不是 namespace-scoped 的」的直接证据——再去看 K8s 文档 Authentication > User impersonation 那一节,事情就拼起来了。


五、Kubernetes impersonation 其实查了两次#

API server 收到带 Impersonate-User header 的请求时,会在执行实际请求之前跑一组 authorization。当 header 形如 Impersonate-User: system:serviceaccount:team-a-dev:default(一个 SA 的 user-form 表示),它会同时跑两条独立的检查:

检查的资源 作用域 RoleBinding 能不能授权?
usersimpersonate 这个 verb) cluster-scoped 虚拟资源 不能
serviceaccountsimpersonate 这个 verb) namespaced

两条都得过。任何一条挂了,握手就 403。

usersgroups 不是 K8s 里真的有的对象——它们没有 CRUD,没有 kubectl get users,没有 OpenAPI schema。它们是 RBAC 子系统造出来的虚拟资源,用来表达「impersonate 某个 user / 某个 group 的能力」。它们天生没有 namespace 维度——既不是某个 namespace 里的对象,也不可能被 namespace 限定。

这就解释了为什么 kubectl auth can-i impersonate users --as alice -n team-a-dev 加了 -n 还是 no:namespace 限定对 users 是没意义的,K8s 不会因为 team-a-dev 里有人绑了 impersonate users 就允许 alice 在那里以任意用户身份发请求。


六、为什么 RoleBinding 物理上接不住#

RoleBinding 在 K8s RBAC 里的定义就是:

ClusterRole 里的规则限制到一个 namespace 内。

但要限制,前提是这些规则本身有 namespace 维度可以限。对 namespace-scoped 资源(pods、jobs、secrets…),这成立;对 cluster-scoped 资源/虚拟资源(nodes、usersgroupsclusterroles…),这根本不成立。

所以即使你写出这种 YAML:

kind: ClusterRole
metadata:
  name: mirrord-developer
rules:
  - apiGroups: [""]
    resources: ["users"]
    verbs: ["impersonate"]

再用一个 RoleBinding 把它在 team-a-dev 内绑给 alice——kubectl apply 不会报错,YAML 也合法,但authorization 时这条规则会被跳过,因为 K8s 发现 users 没有 namespace 维度,RoleBinding 的限制对它无效,干脆不当作授权来源。这是 K8s RBAC 子系统的硬约束,不是配置写错了。

可以再花一行验证一下这一点:

# 即使写在 RoleBinding 引用的 ClusterRole 里,依然 no
kubectl auth can-i impersonate users --as alice -n team-a-dev
# → no

七、为什么客户端非走 user-form header 不可#

到这一步还剩一个疑问:K8s 文档明明写过——

Impersonating a service account requires the impersonate verb on serviceaccounts in the apiGroup "" for the namespace of the impersonated service account.

也就是说纯 namespace-scoped 的 SA impersonation 是合法的。那为什么 mirrord 实际发的请求会触发 users 这一条检查?

答案在客户端怎么序列化 impersonation header 上。Kubernetes 支持两种形态:

  • SA-formImpersonate-Account: team-a-dev/default(设了这个 header,就只跑 serviceaccounts 那一条检查);
  • User-formImpersonate-User: system:serviceaccount:team-a-dev:default(K8s 看到 system:serviceaccount: 前缀会额外serviceaccounts 那一条,但 users 这一条也要跑)。

现实是 kubectl --as=...client-gorest.ImpersonationConfig.UserName、mirrord 内部基于 client-go 的实现,都是设的 user-form Impersonate-User。除非客户端代码显式去用 SA-form 那个少见的 API,否则触发的就是「users + serviceaccounts 都查」那条路径。

所以这是个工程现实问题:K8s 设计上允许「只用 RoleBinding 就够」,但主流客户端的发包格式让这条路径在实际工程里走不通。改 mirrord 客户端的 header 不在我能控制的范围内,能动的就只有「在 RBAC 这边补一个能匹配的 cluster-scoped 授权」。


八、修复:拆出一个最小的 ClusterRole#

被逼出来的方案是把 impersonation 单独拎出来。新增一个只含一条规则的 ClusterRole:

# rbac/admin/manifests/01b-mirrord-impersonator-clusterrole.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: mirrord-impersonator
rules:
  - apiGroups: [""]
    resources: ["serviceaccounts"]
    verbs: ["get", "impersonate"]

然后给每个开发者一个独立的 ClusterRoleBinding 指向它:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: mirrord-impersonator-__USER__
roleRef:
  kind: ClusterRole
  name: mirrord-impersonator
subjects:
  - kind: User
    name: __USER__

grant-namespace-access.sh 改成 apply 两份:namespace RoleBinding(绑 mirrord-developer,覆盖 pods / jobs / portforward / log / events 这些大件,作用域限定 namespace)+ cluster-scoped ClusterRoleBinding(绑 mirrord-impersonator,只 1 条规则)。落到 demo 里的完整脚本形态、verify 步骤和模型表整理在另一篇 《给 mirrord 开发者按 namespace 签发 kubeconfig》§八 里,本文不再展开。

跑完 grant 之后再回 VS Code 启动 debug,握手秒通,agent 那一侧没再出现 403。


九、为什么不直接给 mirrord-developer 加一个 ClusterRoleBinding#

最简单的写法是:mirrord-developer 已经有 serviceaccounts: impersonate 了,给 alice 加一个指向ClusterRoleBinding,一行解决。

不行。那等于把整组 namespace-scoped verb 一起提升到 cluster 作用域:alice 同时在 kube-systemdefaultteam-b-dev 里都能 list podscreate jobspods/portforwardget secrets……前一篇文章整个治理目标(按 namespace 隔离、其它 namespace 默认 deny)就被这一个 binding 推翻了。

所以「最小修改」的解读不是 binding 行数最少,而是让 alice 多出来的 cluster 作用域权限尽可能窄。拆一个只含一条规则的小 ClusterRole 出来,绑出去的是 1 个 verb;放进原来那个 fat ClusterRole 里,绑出去的是十几个 verb。形态上是「多了一个 ClusterRole + 一个 ClusterRoleBinding」,但作用域语义上是更收紧的——这是关键的取舍。


十、落地到 demo 里的两件连带改动#

修复不是孤立地多 apply 一个 ClusterRoleBinding 就完事——撤销和回归测试都得跟着改。这两件事的实现细节在另一篇 《给 mirrord 开发者按 namespace 签发 kubeconfig》 的 §八 和 §十里写完整了;这里只记我从这次踩坑里抽出来、希望以后写 RBAC 都记住的两条:

1. ClusterRoleBinding 是 per-user 的,撤销不能跟着 RoleBinding 无脑删。

原来的 revoke-namespace-access.sh 只删一条 RoleBinding,模型干净。现在多了 per-user 的 ClusterRoleBinding:如果 alice 被授权过 team-a-devteam-b-dev 两个 namespace,从 team-a-dev 撤销绝不能把 ClusterRoleBinding 顺手删掉——否则她在 team-b-dev 的 mirrord 接入会跟着坏。规则改成:删 namespace RoleBinding 之后数一下用户剩余的 RoleBinding 条数,只有为 0 时才回收 ClusterRoleBinding。「单点撤销不污染其它 namespace」的承诺这样才仍然成立。

2. cluster-scoped 检查必须进 validate 脚本,不能等真跑 mirrord 才发现。

这次 bug 没被前一篇文章里的 validate-rbac.sh 拦住,本质原因是它只 assert 了「namespace 内 verb 能不能用」,对 cluster-scoped 检查零断言。修复时同步加了 assert_allowed_cluster / assert_denied_cluster 一对 helper,断言 grant 前后 cluster-scoped impersonate serviceaccounts 的允许/拒绝状态。下次有人误删 mirrord-impersonator,或者 grant 脚本退化成只 apply RoleBinding,CI 立刻挂——比「漏 verb 然后跑 mirrord 才发现」反馈快一个量级。


十一、复盘:这次踩坑让我多记下来的几件事#

  • auth can-i 通过 ≠ 实际请求通过。mirrord 握手走的是 impersonation 这条非 trivial 鉴权路径,跟普通 kubectl get 不是同一张图。下次写 RBAC demo,我会专门加一个「以目标 SA 身份直接发请求」的断言,比如 kubectl --as=system:serviceaccount:team-a-dev:default get pods -n team-a-dev —— 这条更贴近 mirrord 真实走的鉴权路径。
  • 最小权限要按「作用域」拆,不要按「verb」拆。把 impersonate 写进 mirrord-developer 看着挺干净,但它和其它 verb 的作用域天然不同。如果一开始就按「这条规则是 cluster-scoped 还是 namespaced」分类,第二个 ClusterRole 会更自然地出现,不会等到撞 403 才被迫拆。
  • 回归测试要跟着真踩过的错走assert_allowed 只覆盖 namespace 内 verb 是不够的——cluster-scoped 检查应该也算一等公民断言。修 bug 同时把测试补上,下次才不会被同一条阴沟绊倒。这次的 bug 装回 validate-rbac.sh 之后,CI 一秒就能告诉我「ClusterRoleBinding 没了」。
  • K8s 文档要按客户端 header 一路追下去。单看 RBAC 文档以为「SA impersonation 是 namespace-scoped」就完事了——其实 authentication 文档里写明了同一个 SA 经 user-form 发出去时会触发 users 那条检查。下次涉及 impersonation 我会先用 kubectl auth can-i ... --as ... 各种姿势跑一遍,把鉴权图谱画清楚再写 RBAC。
  • IDE 路径和 CLI 路径不是一回事,得分别验证。命令行 bash run-mirrord.sh ... 跑得通,不代表 VS Code 扩展也跑得通——前者直接继承 shell env,后者有自己的 process tree 和 env 解析规则。新接入团队前,IDE 这条路径必须单独走一遍,而且要用真实开发者的 scoped kubeconfig,而不是 admin。

相关链接#