
同一份 kubeconfig 能列出 Pod，查看日志却返回 Forbidden。给 ServiceAccount 换了一个新 token，结果仍然相同。问题不在 token 是否够新：读取 Pod 对象和读取 Pod 日志是两个授权请求。

排查这类问题，需要把连接目标、证明身份和允许动作分开。kubeconfig 保存客户端连接配置；认证把凭据转换成用户身份；授权检查这个身份能否对指定资源执行指定动作。本文沿 Kubernetes v1.35.1 的实现解释这三个层次，并用真实 ServiceAccount token 验证 Namespace 和子资源的权限。

<!--more-->

## kubeconfig 的 user 名称不等于服务端身份

kubeconfig 的 cluster 包含 API Server 地址与服务端证书信任信息，user 条目包含客户端凭据或获取凭据的 exec 配置，context 把 cluster、user 和默认 Namespace 组合起来。user 条目的名字只是本地引用名：把它改成 `admin`，不会让 API Server 把请求当成管理员。

API Server 根据实际提交的凭据认证。例如客户端证书先经过证书链验证，随后 [CommonNameUserConversion](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/authentication/request/x509/x509.go#L279-L302) 将证书的 Common Name 转成用户名，将 Organization 转成用户组。这个映射来自本版本源码；本文没有执行客户端证书签发实验。普通用户也不需要先创建一个 Kubernetes `User` 对象，证书认证得到的身份就可以成为 RoleBinding 的 subject。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/authentication/request/x509/x509.go#L279-L302" >}}

ServiceAccount 是 API 对象。它的用户名遵循 `system:serviceaccount:<namespace>:<name>`，并带有 ServiceAccount 相关用户组。现代 Pod 通常通过投射卷使用有期限、受 audience 和对象绑定条件约束的 token；挂载 token 只提供证明身份的材料，不自动授予业务 API 权限。`automountServiceAccountToken: false` 可以阻止自动挂载，但不会删除 ServiceAccount，也不会撤销此前另外签发的全部 token。

签名正确还不够，[ServiceAccount token 的验证入口](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/serviceaccount/claims.go#L144-L170)还需检查有效期，随后检查 ServiceAccount 和绑定对象的 UID、删除状态。不能从 JWT 能被本地解码，就推断它一定能通过 API Server 认证。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/serviceaccount/claims.go#L144-L170" >}}

同名重建的 ServiceAccount 不能沿用旧 UID 的凭据；绑定到 Pod 的 token 还需核对 Pod 的 UID 和删除时间。删除检查包含代码中的时间容差，不等于收到删除请求的同一瞬间所有 token 都立即失效。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/serviceaccount/claims.go#L179-L193" >}}

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/serviceaccount/claims.go#L212-L227" >}}

## 认证成功后，请求才携带可授权的身份

[认证过滤器](https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/endpoints/filters/authentication.go#L60-L93) 调用 `AuthenticateRequest`。失败时进入失败处理器；成功时检查 audience，并通过 `WithUser` 把认证结果放入请求上下文。后面的授权器读取的是这份身份信息。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/staging/src/k8s.io/apiserver/pkg/endpoints/filters/authentication.go#L60-L93" >}}

因此，Unauthorized 通常提示凭据认证失败，Forbidden 则要继续看请求身份和被拒绝的动作。这里不能把 HTTP 状态码当作唯一根因：启用匿名认证时，没有凭据的请求可能被识别为 `system:anonymous`，然后在授权阶段得到 Forbidden；通过授权后，准入插件也可以返回 Forbidden。完整错误中的用户名、资源、子资源、动词和 Namespace 才是下一步的依据。

```mermaid
sequenceDiagram
    participant C as kubectl
    participant A as API Server 认证过滤器
    participant R as 授权链中的 RBAC
    participant H as 资源处理器
    C->>A: 请求和客户端凭据
    A->>A: 验证凭据并生成 user.Info
    A->>R: 请求属性和身份
    R->>R: 查找绑定并匹配规则
    R-->>A: Allow 或 NoOpinion
    A->>H: 授权链允许后继续
    H-->>C: 资源响应
```

图画的是认证与授权成功路径，省略了准入和存储细节。RBAC 自己未找到允许规则时返回 NoOpinion，最终是否允许由整个授权链决定，不能把“RBAC 没有允许”画成所有集群都固定执行的一条直接拒绝指令。

## Role 定义规则，Binding 决定谁在什么范围使用它

Role 保存一个 Namespace 内的规则；ClusterRole 是集群范围的规则定义，可以表达 Namespace 资源、集群资源和非资源 URL 的权限。只有角色定义，没有关联身份的 Binding，请求者不会因此得到权限。

RoleBinding 在自己的 Namespace 内生效，可以引用同 Namespace 的 Role，也可以引用 ClusterRole。引用 ClusterRole 不会把这个 RoleBinding 变成集群级授权：例如在 `team-a` 内绑定一个允许读 Pod 的 ClusterRole，仍然只授予 `team-a` 内的对应权限。ClusterRoleBinding 则引用 ClusterRole，并在集群范围授予相应规则。

这一点可以从 [DefaultRuleResolver.VisitRulesFor](https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/registry/rbac/validation/rule.go#L179-L225) 直接看出来：它先找适用于身份的 ClusterRoleBinding；当请求带有 Namespace 时，再查这个 Namespace 内的 RoleBinding，解析 `roleRef` 得到规则。请求某个 Namespace 的 Pod，不会遍历其他 Namespace 的 RoleBinding 来寻找额外授权。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/registry/rbac/validation/rule.go#L179-L225" >}}

RBAC 的规则是累加的。[authorizingVisitor.visit](https://github.com/kubernetes/kubernetes/blob/v1.35.1/plugin/pkg/auth/authorizer/rbac/rbac.go#L63-L82) 一旦遇到匹配规则，就记录 allowed 并结束继续访问。这也解释了为什么新增一个“空 Role”不能抵消已有的宽泛权限：RBAC 没有通过另一条角色规则覆盖允许结果的 deny 语义。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/plugin/pkg/auth/authorizer/rbac/rbac.go#L63-L82" >}}

RoleBinding 的 `roleRef` 也有更新约束：`ValidateRoleBindingUpdate` 拒绝更改它，ClusterRoleBinding 的校验同样如此。调整 subjects 与更换所引用角色是不同权限动作；需要更换角色时应重建 Binding，并考虑删除与重建期间的权限窗口，而不是用 patch 绕过不可变字段。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/apis/rbac/validation/validation.go#L161-L170" >}}

## pods、pods/log 与 pods/exec 匹配的是不同资源

[RuleAllows](https://github.com/kubernetes/kubernetes/blob/v1.35.1/plugin/pkg/auth/authorizer/rbac/rbac.go#L178-L193) 有一段很小但关键的组合逻辑：

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/plugin/pkg/auth/authorizer/rbac/rbac.go#L178-L193" >}}

后续分别匹配动词、API group、组合后的资源和可选资源名。读取 Pod 本体的 `get pods` 不会自动覆盖 `get pods/log`。常见 exec 客户端通过 POST 访问 `pods/exec`，需要相应的 `create` 权限；使用其他连接方式的客户端还需按实际 HTTP 请求检查动词，不能只凭终端命令名字判断。

核心 API group 在规则中写空字符串 `""`；Deployment 的 API group 是 `apps`。把资源名写对、group 写错，同样匹配不到。普通 `list`、`get`、`watch` 也分别授权，能够读取一个已知名字的 Pod，不代表能够 watch 全 Namespace。

## 实验：同一个 token 的能力随着角色规则变化

实验在 `zj` 的隔离 kind 集群 `article-api` 执行，kind v0.33.0，kubectl 与服务端均为 v1.35.1，源码和服务端提交为 `8fea90b45245ef5c8ba54e7ae044d3e777c22500`。配置、脚本、证据和复现说明见 [k8sdev/article-api-rbac](https://github.com/jimyag/k8sdev/tree/3cfa654905a750b1716de8c32aa42d7573220954/article-api-rbac)。先按该目录 README 的固定提交准备步骤创建独立工作目录，再运行实验。

权限实验使用同一 token 发起实际请求，并在修改 Role 后重新检查日志子资源权限。

{{< github-file url="https://github.com/jimyag/k8sdev/blob/3cfa654905a750b1716de8c32aa42d7573220954/article-api-rbac/run.py#L45-L65" >}}

实验创建 `article-rbac:reader` ServiceAccount，通过 TokenRequest 签发 token，并构造不含管理员客户端证书的独立客户端配置。这一步很重要：如果测试命令仍携带管理员证书，再加一个待测 token，实际认证路径可能使测试无法代表预期身份。

Role 最初只允许本 Namespace 的 `get/list pods`。实际请求得到：

```text
read_pod: demo
cannot get resource "pods/log" ... namespace "article-rbac"
cannot patch resource "pods" ... namespace "article-rbac"
cannot list resource "pods" ... namespace "article-rbac-other"
```

这些拒绝使用同一个有效 token，错误里识别出的用户为 `system:serviceaccount:article-rbac:reader`。因此它们证明的是动作或作用域不足，不是身份认证失败。

随后给 Role 增加 `get pods/log`，使用原 token 的权限检查由 `no` 变成 `yes`。检查调用明确使用 `kubectl auth can-i get pods --subresource=log`；把 `pods/log` 随手写成资源参数，可能被客户端当成“名为 log 的 Pod”，检查了错误对象。权限检查通过只证明授权判断，不代表容器已启动、日志文件存在或 kubelet 链路正常。

最后换成一个非法 token，移除其他认证材料，实际请求返回：

```text
error: You must be logged in to the server (Unauthorized)
```

`auth can-i` 使用访问审查 API 判断权限，不会替请求创建真实资源，也不经过待创建资源的全部准入条件。检查显示 yes 后仍然创建失败，应继续读实际错误中的 quota、安全策略或 Webhook 信息。[准入控制文章](/posts/kubernetes-admission-security/)接着讨论这一层。

## 用失败请求反推最小权限

能够创建 Role 或 RoleBinding，不等于能够给自己任意提权。Role 的存储入口会检查请求者是否已拥有拟授予的权限；缺少这些权限时，需要对该角色资源有显式 `escalate` 授权。Binding 的入口则检查是否有目标角色的 `bind` 授权，否则仍需确认请求者已拥有该角色包含的权限。常规授权通过后还存在这些防提权检查，不能只授予 `create roles` 就推断用户可以自建管理员角色。特权身份和显式授权的例外见下列入口，本文没有为这些新增分支补做运行实验。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/registry/rbac/role/policybased/storage.go#L67-L78" >}}

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/registry/rbac/rolebinding/policybased/storage.go#L70-L100" >}}

`RoleEscalationAuthorized` 和 `BindingAuthorized` 把 `escalate`、`bind` 作为授权动词，再以目标角色资源和相应作用域构造请求属性。

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/registry/rbac/escalation_check.go#L78-L87" >}}

{{< github-file url="https://github.com/kubernetes/kubernetes/blob/v1.35.1/pkg/registry/rbac/escalation_check.go#L111-L137" >}}

最有效的排查输入是实际请求的身份和属性：使用哪份 context、API group 是什么、有没有子资源、动词是什么、Namespace 是什么。确认这些后，检查对应 Binding 的 subject 与 roleRef，再回到 Role 规则；不要先授予 `cluster-admin` 来判断问题是否消失。

长期权限还要考虑权限组合。例如允许创建任意 Pod，可能让请求者选择高权限 ServiceAccount 并挂载敏感资源；只限制直接读取 Secret 的权限，不足以证明这个身份无法取得 Secret。RBAC 的最小规则需要和 Pod 安全约束、可用 ServiceAccount 及 Namespace 隔离一起设计。[官方 RBAC 文档](https://kubernetes.io/docs/reference/access-authn-authz/rbac/)列出了角色与绑定的完整语义。

本文验证了 token 认证、资源动作、Namespace 隔离和日志子资源授权检查；客户端证书、OIDC 和 exec 会话的机制解释来自对应源码或 API 语义，没有用这组实验替它们宣称运行验证。

