"为什么我的 RoleBinding 不生效"大概是 K8s 权限问题里最常见的一句抱怨。这一篇把 apiserver 收到一次请求之后,RBAC 授权器到底怎么判断"这个用户能不能做这件事"的完整链路打开:一条 PolicyRule 是怎么跟 verb/resource/资源名匹配上的;RoleBinding 引用 Role 和引用 ClusterRole 的授权范围差在哪;以及一个几乎所有人都会先入为主搞错的事实——RBAC 里根本没有"拒绝"规则,只有"允许"。
延续 kube-proxy/scheduler 两篇的方法论:本机没有真实集群,但 RBAC 的核心匹配逻辑是几个不依赖集群状态的纯函数——直接把 kubernetes v1.37.0 的真实源码逐字符抄进本机一个独立的 Go 程序里,真实编译执行,而不是只在 Python 里重新实现一遍。
plugin/pkg/auth/authorizer/rbac/rbac.go · kubernetes @ v1.37.0 func RuleAllows(requestAttributes authorizer.Attributes, rule *rbacv1.PolicyRule) bool { if requestAttributes.IsResourceRequest() { combinedResource := requestAttributes.GetResource() if len(requestAttributes.GetSubresource()) > 0 { combinedResource = requestAttributes.GetResource() + "/" + requestAttributes.GetSubresource() } return rbacv1helpers.VerbMatches(rule, requestAttributes.GetVerb()) && rbacv1helpers.APIGroupMatches(rule, requestAttributes.GetAPIGroup()) && rbacv1helpers.ResourceMatches(rule, combinedResource, requestAttributes.GetSubresource()) && rbacv1helpers.ResourceNameMatches(rule, requestAttributes.GetName()) } return rbacv1helpers.VerbMatches(rule, requestAttributes.GetVerb()) && rbacv1helpers.NonResourceURLMatches(rule, requestAttributes.GetPath()) }
一条 PolicyRule(比如 verbs: [get,list], resources: [pods])要判断能不能放行一次请求,是四个独立的布尔匹配用 && 连起来——verb、apiGroup、resource(含 subresource)、resourceName 全部满足才算真的允许。少一个都不行。
pkg/apis/rbac/v1/evaluation_helpers.go · kubernetes @ v1.37.0 func ResourceMatches(rule *rbacv1.PolicyRule, combinedRequestedResource, requestedSubresource string) bool { for _, ruleResource := range rule.Resources { if ruleResource == rbacv1.ResourceAll { return true } if ruleResource == combinedRequestedResource { return true } if len(requestedSubresource) == 0 { continue } // 匹配形如 */subresource 的规则 if len(ruleResource) == len(requestedSubresource)+2 && strings.HasPrefix(ruleResource, "*/") && strings.HasSuffix(ruleResource, requestedSubresource) { return true } } return false }
rule := PolicyRule{Verbs: [get, list], Resources: [pods, */status], ResourceNames: [web-1]}
verb=get resource=pods name=web-1 -> RuleAllows=true
verb=delete resource=pods name=web-1 -> RuleAllows=false (动词不对)
verb=get resource=pods name=web-2 -> RuleAllows=false (资源名不对)
verb=get resource=pods/status name=web-1 -> RuleAllows=true (命中 */status)
verb=get resource=pods/log name=web-1 -> RuleAllows=false (*/status 不认 /log)
wildcard rule (Verbs/APIGroups/Resources 全是 "*"):
verb=get / delete / create / anything-at-all -> RuleAllows 全部 true
这几个匹配函数(VerbMatches/APIGroupMatches/ResourceMatches/ResourceNameMatches)是完全不依赖 apiserver、etcd、任何集群状态的纯函数——本文把它们的函数体逐字符转录进本机一个独立 Go 程序真实编译运行,不是"照着抄一遍逻辑",是同一份代码真的跑了一遍。*/status 这种写法专门用来"只放行某个子资源",跟直接写 pods 是两回事。
pkg/apis/rbac/v1/evaluation_helpers.go · kubernetes @ v1.37.0 func NonResourceURLMatches(rule *rbacv1.PolicyRule, requestedURL string) bool { for _, ruleURL := range rule.NonResourceURLs { if ruleURL == rbacv1.NonResourceAll { return true } if ruleURL == requestedURL { return true } if strings.HasSuffix(ruleURL, "*") && strings.HasPrefix(requestedURL, strings.TrimRight(ruleURL, "*")) { return true } } return false }
rule.NonResourceURLs = ["/healthz*"]path=/healthz -> RuleAllows=true path=/healthz/etcd -> RuleAllows=true path=/healthzzzzz -> RuleAllows=true (!) path=/healthz-internal -> RuleAllows=true (!) path=/livez -> RuleAllows=false
strings.TrimRight(ruleURL, "*") + strings.HasPrefix 是一次不折不扣的裸字符串前缀匹配,不要求 / 分隔符——/healthz* 这条规则不仅会匹配 /healthz/etcd,还会匹配长得完全不像同一个端点的 /healthzzzzz 和 /healthz-internal。写非资源 URL 规则时如果想要"只匹配这个路径底下",要自己在末尾显式写成 /healthz/*,不能指望它帮你处理路径边界。
pkg/registry/rbac/validation/rule.go · kubernetes @ v1.37.0 func (r *DefaultRuleResolver) GetRoleReferenceRules(ctx context.Context, roleRef rbacv1.RoleRef, bindingNamespace string) ([]rbacv1.PolicyRule, error) { switch roleRef.Kind { case "Role": role, err := r.roleGetter.GetRole(ctx, bindingNamespace, roleRef.Name) ... return role.Rules, nil case "ClusterRole": clusterRole, err := r.clusterRoleGetter.GetClusterRole(ctx, roleRef.Name) ... return clusterRole.Rules, nil } } // VisitRulesFor: ClusterRoleBinding 永远检查(跟查询的 namespace 无关); // RoleBinding 只有 len(namespace) > 0 时才检查,而且只看绑定在该 namespace 里的
Role "pod-reader"(定义在 ns-a) ---被 RoleBinding(ns-a)引用---> 只在 ns-a 生效 ClusterRole "view-secrets" ---被 RoleBinding(ns-a)引用---> 只在 ns-a 生效(!) ClusterRole "cluster-admin" ---被 ClusterRoleBinding引用---> 所有 namespace 都生效visitRulesFor("ns-a") 包含: cluster-admin(*), pod-reader(pods), view-secrets(secrets) visitRulesFor("ns-b") 包含: cluster-admin(*) (没有 secrets!) ns-a 查询是否含 pod-reader 规则(Role,绑定在 ns-a): true ns-a 查询是否含 view-secrets 规则(ClusterRole 但通过 RoleBinding 绑定在 ns-a): true ns-b 查询是否不含 view-secrets 规则(那条 RoleBinding 只存在于 ns-a): true ns-b 查询是否仍含 cluster-admin 规则(ClusterRoleBinding,跟 namespace 无关): true
GetRoleReferenceRules 只看 roleRef.Kind 是 Role 还是 ClusterRole 决定去哪张表查规则,完全不关心"这条 RoleBinding 本身绑在哪个命名空间"这件事——真正决定"这些规则只在 ns-a 生效"的,是 VisitRulesFor 外层那个 if rb.Namespace != namespace { continue } 过滤,不是 ClusterRole 自己有什么命名空间概念。这就是为什么"用 RoleBinding 引用一个大家共用的 ClusterRole,但只想让它在某一个命名空间生效"这个常见模式能成立的全部原理。
plugin/pkg/auth/authorizer/rbac/rbac.go · kubernetes @ v1.37.0 func (v *authorizingVisitor) visit(source fmt.Stringer, rule *rbacv1.PolicyRule, err error) bool { if rule != nil && RuleAllows(v.requestAttributes, rule) { v.allowed = true v.reason = fmt.Sprintf("RBAC: allowed by %s", source.String()) return false // 找到一条允许的规则,立刻停止遍历 } ... return true // 继续看下一条 }
rules = [broadGrant(verbs:*,resources:*), attemptedRestriction(verbs:get,resources:secrets)] request: DELETE secrets顺序 [broadGrant, attemptedRestriction]: allowed=true, 只看了 1 条规则就命中,根本没看到"限制"规则 顺序 [attemptedRestriction, broadGrant]: allowed=true, 看了 2 条规则才命中 两种顺序结果完全一致: allowed 相等 = true —— 顺序不影响结果,只影响"看了几条"
authorizingVisitor.visit 只要碰到第一条允许的规则就直接 return false 停止遍历——这段代码里从头到尾没有"拒绝"这个概念。任何一个 RoleBinding/ClusterRoleBinding 只要授予了某个权限,后面加多少条看起来更"严格"的规则都不可能收回它。真实项目里"最小权限"必须靠"一开始就不要绑定过宽的角色"来实现,没有办法靠叠加一条限制规则去收窄已经生效的权限。
把上面几组真实实验按发生顺序串成一条演示。
全部数据来自本机真实 go run 的输出(verb_resource_match.go、nonresource_footgun.go、role_vs_clusterrole.go、no_deny.go,全部基于逐字符转录的真实源码函数体),Python 脚本用独立重写(不是转录)的匹配函数和解析逻辑逐项核对,包括 9 组 verb/resource 匹配场景、5 组 NonResourceURL 场景、4 组命名空间解析场景,以及顺序无关性的验证。