Kubernetes 系列 · 第六篇

RBAC 鉴权:一条规则到底是怎么匹配上一次请求的

"为什么我的 RoleBinding 不生效"大概是 K8s 权限问题里最常见的一句抱怨。这一篇把 apiserver 收到一次请求之后,RBAC 授权器到底怎么判断"这个用户能不能做这件事"的完整链路打开:一条 PolicyRule 是怎么跟 verb/resource/资源名匹配上的;RoleBinding 引用 Role 和引用 ClusterRole 的授权范围差在哪;以及一个几乎所有人都会先入为主搞错的事实——RBAC 里根本没有"拒绝"规则,只有"允许"

延续 kube-proxy/scheduler 两篇的方法论:本机没有真实集群,但 RBAC 的核心匹配逻辑是几个不依赖集群状态的纯函数——直接把 kubernetes v1.37.0 的真实源码逐字符抄进本机一个独立的 Go 程序里,真实编译执行,而不是只在 Python 里重新实现一遍。

4 个真实 Go 程序
逐字符转录自真实 kubernetes v1.37.0 源码,本机真实 go run
5 处真实源码
verb/资源/资源名/非资源URL 匹配 + Role/ClusterRole 解析逻辑,逐行核对

真实源码:一条规则要同时满足四个匹配,才算"允许"

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 全部满足才算真的允许。少一个都不行。

真实源码:四个匹配函数,逐字符转录进本机 Go 程序真实执行

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 是两回事。

真实实验:一个容易踩的坑——NonResourceURL 的通配符是裸字符串前缀匹配

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/*,不能指望它帮你处理路径边界。

真实源码 + 真实实验:RoleBinding 引用 ClusterRole,授权范围会被"收窄"到一个命名空间

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.KindRole 还是 ClusterRole 决定去哪张表查规则,完全不关心"这条 RoleBinding 本身绑在哪个命名空间"这件事——真正决定"这些规则只在 ns-a 生效"的,是 VisitRulesFor 外层那个 if rb.Namespace != namespace { continue } 过滤,不是 ClusterRole 自己有什么命名空间概念。这就是为什么"用 RoleBinding 引用一个大家共用的 ClusterRole,但只想让它在某一个命名空间生效"这个常见模式能成立的全部原理。

真实实验:RBAC 没有"拒绝"规则,只有一堆"允许"取并集

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 只要授予了某个权限,后面加多少条看起来更"严格"的规则都不可能收回它。真实项目里"最小权限"必须靠"一开始就不要绑定过宽的角色"来实现,没有办法靠叠加一条限制规则去收窄已经生效的权限。

交互演示:规则匹配 + Role/ClusterRole 解析 + 无拒绝语义的真实数据回放

把上面几组真实实验按发生顺序串成一条演示。

RBAC 鉴权实录未开始
点击"下一步"或"播放"开始。

全部数据来自本机真实 go run 的输出(verb_resource_match.gononresource_footgun.gorole_vs_clusterrole.gono_deny.go,全部基于逐字符转录的真实源码函数体),Python 脚本用独立重写(不是转录)的匹配函数和解析逻辑逐项核对,包括 9 组 verb/resource 匹配场景、5 组 NonResourceURL 场景、4 组命名空间解析场景,以及顺序无关性的验证。

参考与说明

  • 本文源码引用(RuleAllows/RulesAllow/authorizingVisitor 取自 plugin/pkg/auth/authorizer/rbac/rbac.go;VerbMatches/APIGroupMatches/ResourceMatches/ResourceNameMatches/NonResourceURLMatches 取自 pkg/apis/rbac/v1/evaluation_helpers.go;VisitRulesFor/GetRoleReferenceRules 取自 pkg/registry/rbac/validation/rule.go)均取自 kubernetes/kubernetes 仓库 v1.37.0 标签,跟前几篇 K8s 系列同一个版本。
  • 前两组实验(verb_resource_match.gononresource_footgun.gono_deny.go)里的匹配/授权函数是逐字符转录自真实源码,本机真实编译执行,不是重新实现;role_vs_clusterrole.go 里的 visitRulesFor/getRoleReferenceRules 是按真实源码的控制流结构重写(因为要接掉真实版本依赖的 lister/context 参数),文中已明确标注这一点区别。本机没有真实 K8s 集群,这是本系列在 kube-proxy/kube-scheduler 两篇之后第三次使用"真实源码 + 本机独立验证"的路线,这次比前两篇更进一步——直接转录真实函数体本机执行,而不只是重新实现同一个算法。
  • 演示数据的自检:Python 脚本独立重写了全部匹配和解析逻辑(不是照抄转录版本),逐项核对了 9+5+4 组场景和顺序无关性验证,全部通过。
  • 没有涉及:准入控制(ValidatingAdmissionPolicy/webhook)、Node/RBAC 授权器之外的其他授权模式(WebhookABAC)、aggregationRule 自动聚合 ClusterRole 的机制、Impersonate 相关的特殊 verb。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电