RBAC

Roles, ClusterRoles, Bindings, and ServiceAccount Tokens

Every Kubernetes API request is authenticated (who is this?) and authorized (can they do this?). RBAC is the standard authorizer: a graph of Roles that grant verbs on resources, and Bindings that connect those Roles to Subjects (Users, Groups, ServiceAccounts). The model is additive — you only get what you're explicitly granted, never anything implicit.

The main subtlety is the User/ServiceAccount split. Users come from your authenticator (OIDC, certificates, webhooks) and are not Kubernetes objects — just strings. ServiceAccounts are Kubernetes-native objects that workloads use to call the API. Modern clusters use bound tokens (short-lived, audience-scoped) for ServiceAccounts and OIDC for human users, mapped to RBAC via group claims.

The Authorization Flow

Subjects User / Group / ServiceAccount Bindings RoleBinding / ClusterRoleBinding Roles Role / ClusterRole PolicyRule apiGroups + resources + verbs API Server: Authorize(req) — walks graph, evaluates rules RBAC + ABAC + Webhook + Node + AlwaysAllow/Deny → ALLOW or DENY

The API server evaluates authorizers in a fixed priority order (Node, RBAC, Webhook, ABAC, AlwaysAllow). RBAC is evaluated second, after Node authorization (which handles kubelet-to-API-server communication). Each authorizer can ALLOW or DENY. The first decision wins — if Node allows, RBAC never runs.

Key Numbers

1.6
version that promoted RBAC to GA
2
scope levels: namespace (Role) and cluster (ClusterRole)
3
subject kinds: User, Group, ServiceAccount
~10
verbs: get, list, watch, create, update, patch, delete, deletecollection, ...
1 hour
default bound ServiceAccount token TTL
1.24
version: legacy SA tokens stopped auto-mounting by default

RBAC Objects at a Glance

Object Scope Binds To Use When
Role Namespace RoleBinding Application needs access only within its own namespace
ClusterRole Cluster-wide ClusterRoleBinding (cluster) or RoleBinding (namespace portion) Cluster-scoped resources, or a reusable template across namespaces
RoleBinding Namespace References a Role or ClusterRole Grant namespace-local permissions to users, groups, or SAs
ClusterRoleBinding Cluster-wide References a ClusterRole only Grant cluster-wide permissions, or cluster-admin to a group

A Real Role + Binding

# Role — namespace-scoped permissions
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: app
  name: pod-reader
rules:
  - apiGroups: [""]
    resources: [pods, pods/log]
    verbs: [get, list, watch]
  - apiGroups: [apps]
    resources: [deployments]
    verbs: [get, list]
  - apiGroups: [acme.io]                  # custom resources
    resources: [databases]
    verbs: [get, list, watch]
  - nonResourceURLs: [/healthz, /metrics]
    verbs: [get]

---
# RoleBinding — bind subjects to a Role within the namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  namespace: app
  name: pod-readers
subjects:
  - kind: User                            # human via OIDC
    name: [email protected]
    apiGroup: rbac.authorization.k8s.io
  - kind: Group                           # group claim from OIDC
    name: oncall-engineers
    apiGroup: rbac.authorization.k8s.io
  - kind: ServiceAccount                  # workload identity
    name: log-shipper
    namespace: app
roleRef:
  kind: Role
  name: pod-reader
  apiGroup: rbac.authorization.k8s.io
Note: apiGroup: rbac.authorization.k8s.io on subjects is always required for User and Group subjects. ServiceAccount subjects don't need it (it's always v1 in the SA group's context). The apiGroup field on a subject refers to the API group of the subject kind itself — not the resources being granted.

ClusterRole and ClusterRoleBinding

# ClusterRole — cluster-wide permissions OR a template usable by namespaced bindings
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata: { name: deployment-manager }
rules:
  - apiGroups: [apps]
    resources: [deployments, replicasets]
    verbs: ["*"]                          # everything

---
# Use it cluster-wide
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata: { name: cluster-deployment-managers }
subjects:
  - kind: Group
    name: platform-team
roleRef:
  kind: ClusterRole
  name: deployment-manager
  apiGroup: rbac.authorization.k8s.io

---
# OR scope it to one namespace via RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: app-deployment-managers
  namespace: app
subjects:
  - kind: Group
    name: app-team
roleRef:
  kind: ClusterRole                       # references the ClusterRole
  name: deployment-manager
  apiGroup: rbac.authorization.k8s.io

The same ClusterRole can be referenced by both a ClusterRoleBinding (granting it cluster-wide) and multiple RoleBindings (granting it in specific namespaces). When a RoleBinding references a ClusterRole, only the rules that apply to the binding's namespace are granted — cluster-scoped rules are ignored. This is one of RBAC's most powerful patterns for multi-tenant clusters.

ClusterRole Aggregation

# An aggregation rule — its rules are the union of any matching ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: monitoring-edit
aggregationRule:
  clusterRoleSelectors:
    - matchLabels:
        rbac.authorization.k8s.io/aggregate-to-monitoring-edit: "true"
rules: []   # filled in automatically

---
# Operators contribute by labeling their own ClusterRoles
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: prometheus-rules
  labels:
    rbac.authorization.k8s.io/aggregate-to-monitoring-edit: "true"
rules:
  - apiGroups: [monitoring.coreos.com]
    resources: [prometheusrules, servicemonitors]
    verbs: [get, list, watch, create, update, patch, delete]

# The built-in 'admin', 'edit', 'view' ClusterRoles use this same mechanism
# so cert-manager, Argo, etc. can extend them at install time.
Aggregation is live-recomputed. Every time you create, update, or delete a ClusterRole, the aggregated ClusterRole's effective rules are recalculated immediately. There's no background reconciliation loop — it's synchronous with the authorization check. If you query the aggregated ClusterRole's rules with kubectl get clusterrole monitoring-edit -o yaml, you'll see the computed union, not the empty rules: [] from the original spec.

API Groups and Resource Matching

Every resource belongs to an API group. The empty string "" represents the core group (v1) — containing primitives like Pod, Service, Secret, ConfigMap, Namespace, Node. Everything else uses named groups like apps, batch, rbac.authorization.k8s.io, or custom groups like monitoring.coreos.com.

# Core group — no apiGroups entry needed (represented as "")
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: core-resources }
rules:
  - apiGroups: [""]
    resources: [pods, services, configmaps, secrets]
    verbs: [get, list, watch]

# Named API groups — specify the group string exactly
  - apiGroups: [apps]
    resources: [deployments, statefulsets, daemonsets]
    verbs: [get, list, watch]
  - apiGroups: [batch]
    resources: [jobs, cronjobs]
    verbs: ["*"]
  - apiGroups: [networking.k8s.io]
    resources: [ingresses, networkpolicies]
    verbs: [get, update, patch]
  - apiGroups: [rbac.authorization.k8s.io]
    resources: [roles, rolebindings, clusterroles, clusterrolebindings]
    verbs: [get, list, watch]

# Non-resource URLs — for /healthz, /metrics, /api, /apis
  - nonResourceURLs: [/healthz, /readyz, /metrics, /api/*, /apis/*]
    verbs: [get]

# Wildcard apiGroup — matches ANY group including core
  - apiGroups: ["*"]
    resources: ["*"]
    verbs: [get, list]  # read across all groups
Resource names are plural and lowercase. Use pods, not Pod. Use deployments, not deployment. The one exception is resourceNames in a rule, where you list the actual object names (singular strings), e.g., resourceNames: [my-configmap, other-secret] to restrict access to specific objects.

Verbs: The Permission Matrix

RBAC verbs map closely to HTTP methods. Understanding the semantic differences is critical for writing least-privilege policies.

VerbHTTPWhat it allowsGotcha
getGETRead a single named objectRequires resource name in URL
listGET (collection)List all objects of a type; watch a collectionDoes not imply get on individual items
watchGET with ?watchLong-lived streaming watch on a collectionOften granted with list; rarely alone
createPOSTCreate new objectsDoes not let you read what you created
updatePUTReplace entire objectRequires sending full object; no partial updates
patchPATCHMerge a JSON patch (RFC 6902) or strategic mergeCan modify specific fields without full object
deleteDELETEDelete a single named objectDoes not allow watching deletion progress
deletecollectionDELETE on collectionDelete all objects of a type in a namespacePowerful and dangerous — requires a separate flag to audit
# Patch implies update for the same resource, but not vice versa
# This role can patch deployments but cannot replace them with full objects
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: deployment-patcher
  namespace: app
rules:
  - apiGroups: [apps]
    resources: [deployments]
    verbs: [patch]  # can patch, cannot update (full replace) or create

---
# deletecollection is the most dangerous verb — test it carefully
# A role with deletecollection on secrets could wipe all secrets in a namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: cleanup-script
rules:
  - apiGroups: [""]
    resources: [pods]
    verbs: [deletecollection]
Implied verb relationships: * implies all verbs. Granting ["create"] does NOT imply you have get on the created object — you need both if you want to inspect what you made. Granting ["update"] does NOT imply patch — these are separate code paths in the API server.

Subjects: Users, Groups, ServiceAccounts

A subject is the entity being granted a role. RBAC knows three kinds — plus the implicit system: identities that Kubernetes creates for itself.

Users

Users are strings — not Kubernetes objects. The API server never verifies a user exists; it trusts the authenticator. An OIDC provider might return [email protected], a certificate might encode O=engineering, CN=alice, and a webhook authenticator might return alice@ldap. All are valid RBAC subjects. This is powerful (works with any IdP) but also means there's no kubectl get users.

Groups

Groups are also strings. A user can belong to many groups simultaneously — the authenticator returns a list of groups, and RBAC evaluates all of them. This is how you grant permissions to teams: instead of binding 50 individual users, you bind one group. system:authenticated and system:unauthenticated are special groups covering all authenticated (or not) requests.

ServiceAccounts

ServiceAccounts are Kubernetes objects. They're namespaced and stored in kube-system by default, though you create them per-application namespace. The subject format for an SA includes the namespace: kind: ServiceAccount, name: my-app, namespace: production. When a Pod authenticates, its bound token's sub claim is system:serviceaccount:production:my-app, and the API server maps that to the ServiceAccount object.

# A binding with all three subject types
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: app-access
  namespace: app
subjects:
  # OIDC user — string from 'sub' or 'email' claim
  - kind: User
    name: [email protected]
    apiGroup: rbac.authorization.k8s.io

  # OIDC group — string from 'groups' claim, potentially with prefix
  - kind: Group
    name: oidc:app-developers
    apiGroup: rbac.authorization.k8s.io

  # Kubernetes ServiceAccount — namespaced, IS an object
  - kind: ServiceAccount
    name: app-backend
    namespace: app

roleRef:
  kind: Role
  name: app-developer
  apiGroup: rbac.authorization.k8s.io

---
# Implicit system groups (granted automatically by API server)
# system:authenticated  — every authenticated request
# system:unauthenticated — anonymous requests
# system:serviceaccounts — all ServiceAccounts in all namespaces
# system:serviceaccounts: — all SAs in a specific namespace
# system:node — kubelets (via Node authorizer, based on CSR node names)

Built-in ClusterRoles

Kubernetes ships four default ClusterRoles at the cluster scope. They form a hierarchy and use aggregation so operators can extend them.

ClusterRoleAggregation LabelTypical Use
cluster-admin — (hand-coded) God mode — use sparingly, ideally only via ClusterRoleBinding for break-glass scenarios
admin rbac.authorization.k8s.io/aggregate-to-admin Namespace admin — read/write most resources, can manage RBAC within the namespace
edit rbac.authorization.k8s.io/aggregate-to-edit Developer — read/write most resources, cannot read secrets or manage RBAC
view rbac.authorization.k8s.io/aggregate-to-view Read-only — can list/watch most resources, cannot see secrets or events
# view — can't read secrets, events, or execute in pods
# These restrictions are hard-coded in the view ClusterRole rules themselves,
# not in the aggregation selectors
$ kubectl get clusterrole view -o yaml | head -60
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: view
  labels:
    kubernetes.io/bootstrapping: rbac-defaults
    rbac.authorization.k8s.io/aggregate-to-view: "true"
rules:
  - apiGroups: [""]
    resources: [configmaps, endpoints, persistentvolumeclaims,
                pods, pods/log, replicationcontrollers, services]
    verbs: [get, list, watch]
  # notably ABSENT: secrets, events, pods/exec, pods/attach

---
# Operator extending 'edit' for a custom resource
# cert-manager does this to let developers manage certificates
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: cert-manager-edit
  labels:
    rbac.authorization.k8s.io/aggregate-to-edit: "true"
rules:
  - apiGroups: [cert-manager.io]
    resources: [certificates, issuers, certificaterequests]
    verbs: [get, list, watch, create, update, patch, delete]

# A developer with 'edit' on namespace 'app' automatically gets cert-manager permissions
# No changes to the developer's RoleBinding needed — aggregation handles it
The system:discovery and system:basic-user ClusterRoles are special. system:discovery grants GET on /api, /apis, /version, /healthz etc. — all authenticated users need this just to discover what's available. system:basic-user grants read on self-subject attributes (selfsubjectaccessreviews, selfsubjectrulesreviews) so users can check their own permissions. These are automatically bound to system:authenticated via system:basic-user and system:public-info-viewer ClusterRoleBindings.

ServiceAccount Tokens: Bound vs Legacy

ServiceAccount authentication uses JWT tokens. The token format, issuance mechanism, and lifetime have evolved significantly — understanding the difference is critical for cluster security.

# Legacy: an auto-created Secret containing a never-expiring JWT
# Created automatically when a ServiceAccount is created (before 1.24)
# Stored in etcd, never rotated, mounted into every Pod using that SA
$ kubectl get secret -n app
NAME                        TYPE     DATA   AGE
default-token-xxxxx         Opaque   3      2y    # never expires!

# The JWT claims:
# { "iss": "kubernetes/serviceaccount",          # who issued it
#   "namespace": "app",                           # which namespace
#   "name": "default",                            # SA name
#   "uid": "....",                                # SA UID
#   "sub": "system:serviceaccount:app:default" }  # auth identity

# Bound token (1.21+ default): short-lived, Pod-projected, auto-rotated
apiVersion: v1
kind: Pod
spec:
  serviceAccountName: my-sa
  # opt out of legacy auto-mount (1.24+ default)
  automountServiceAccountToken: false
  containers:
    - name: app
      image: my-app
      volumeMounts:
        - name: api-token
          mountPath: /var/run/secrets/tokens
          readOnly: true
  volumes:
    - name: api-token
      projected:
        sources:
          - serviceAccountToken:
              path: api-token           # inside container at this path
              audience: vault.example.com  # audience restriction
              expirationSeconds: 3600  # 1 hour TTL (max 3600 by default)

# kubelet projects the token and auto-rotates it ~80% through TTL
# Inside the container:
$ cat /var/run/secrets/tokens/api-token
eyJhbGciOiJSUzI1NiIs...      # has audience: vault.example.com, not kubernetes.default

# Token is bound to this Pod's UID — if Pod is deleted, token is immediately invalid
# On API side, API server validates 'sub' claim matches requesting pod's UID

Security Comparison

Legacy Token Risks
  • Never expires — valid until the Secret is deleted
  • Stored in etcd — if etcd is compromised, attacker authenticates as SA forever
  • Shared — all Pods using the same SA share the same token
  • No audience restriction — valid against any API audience
Bound Token Benefits
  • Short TTL — max 3600s by default (configurable via TokenRequest API)
  • Pod-bound — token dies with the Pod
  • Audience-scoped — valid only for declared audience (e.g., Vault, not generic k8s API)
  • Projected — never written to etcd; lives only in the Pod's memory/fs
  • Auto-rotated — kubelet refreshes before expiration

OIDC Integration for kubectl

OIDC (OpenID Connect) is the standard way to authenticate human users to Kubernetes. Instead of sharing static kubeconfig files, each user authenticates to your IdP (Okta, Auth0, Google Workspace, Keycloak) and receives a short-lived token. The cluster validates the token's signature against the IdP's public keys (JWKS).

# kube-apiserver OIDC flags
--oidc-issuer-url=https://accounts.example.com
--oidc-client-id=k8s-cli
--oidc-username-claim=email          # use email as the username
--oidc-username-prefix="oidc:"       # prefix to avoid collision with local users
--oidc-groups-claim=groups           # claim containing group memberships
--oidc-groups-prefix="oidc:"         # prefix all groups (e.g., oidc:admins)
--oidc-ca-file=/etc/ssl/certs/idp.crt  # CA cert to verify IdP signature

# The ID token payload (what the IdP signs):
{
  "iss": "https://accounts.example.com",
  "sub": "[email protected]",
  "email": "[email protected]",
  "groups": ["admins", "developers"],
  "aud": "k8s-cli",
  "exp": 1700000000,
  "iat": 1709999999
}

# kubeconfig — uses a credential plugin (kubelogin) to perform OIDC flow
apiVersion: v1
kind: Config
clusters:
  - name: production
    cluster:
      server: https://k8s.example.com
      certificate-authority-data: BASE64ENCODED_CA
contexts:
  - name: production
    context:
      user: alice
      cluster: production
current-context: production
users:
  - name: alice
    user:
      exec:
        apiVersion: client.authentication.k8s.io/v1beta1
        kind: ExecCredential
        command: kubelogin
        args:
          - get-token
          - --oidc-issuer-url=https://accounts.example.com
          - --oidc-client-id=k8s-cli
          - --oidc-extra-scope=email
          - --oidc-extra-scope=groups
        env:
          - name: KUBELOGIN_CLIENT_SECRET
            value: $KUBELOGIN_CLIENT_SECRET
        interactiveMode: Always

# ClusterRoleBinding to grant admins via OIDC group
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: oidc-cluster-admins
subjects:
  - kind: Group
    name: "oidc:admins"          # prefix from --oidc-groups-prefix
roleRef:
  kind: ClusterRole
  name: cluster-admin

kubelogin Flow

kubelogin (or aws-iam-authenticator, gcloud, Azure plugin) bridges kubectl and your IdP. When kubectl needs to authenticate, it calls the plugin, which opens a browser to your IdP, receives the callback with the ID token, and returns it to kubectl. kubectl embeds the token in the Authorization: Bearer <token> header. The API server's RBAC layer then sees the user [email protected] in group oidc:admins — mapped directly to ClusterRoleBindings.

Impersonation — Testing as Another User

Impersonation lets you test what permissions someone else has by temporarily running kubectl as them. The API server treats the impersonated identity exactly like a real authenticated request — RBAC is evaluated against it. This is the primary tool for verifying least-privilege policies.

# Impersonate a ServiceAccount to test its permissions
$ kubectl auth can-i create pods --as=system:serviceaccount:app:log-shipper
no

$ kubectl auth can-i list pods --as=system:serviceaccount:app:log-shipper --namespace=app
yes

# Impersonate a user with a specific group
$ kubectl get secrets [email protected] --as-group=oidc:admins
Error from server (Forbidden): secrets is forbidden ...

# Impersonate with full context
$ kubectl [email protected] \
           --as-group=oidc:developers \
           --as-group=oidc:app-team \
           --namespace=app \
           get pods

# What permissions does the app developer role actually grant?
# Create a test namespace and check
$ kubectl auth can-i --list --namespace=app --as=system:serviceaccount:app:ci-pipeline

# Impersonation requires special RBAC permission to use
# You need 'impersonate' verb on users, groups, or serviceaccounts
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: impersonator
rules:
  - apiGroups: [""]
    resources: [users]
    verbs: [impersonate]
  - apiGroups: [""]
    resources: [groups]
    verbs: [impersonate]
  - apiGroups: [""]
    resources: [serviceaccounts]
    verbs: [impersonate]
  # For serviceaccounts, also need to impersonate the 'extra' sub resource
  - apiGroups: [""]
    resources: [serviceaccounts/token]
    verbs: [impersonate]

# RBAC prevents regular users from impersonating — only admins or CI systems
# with impersonate permission can test other identities
Impersonation is audited. Every impersonated request includes Impersonate-User, Impersonate-Group, and Impersonate-UIA (User Extra Attributes) headers. The audit log records the original user in user-agent and the impersonated identity in the request — so you can trace who was acting as whom. This makes impersonation both a debugging tool and a security-sensitive operation that should itself be audited and restricted.

RBAC vs Admission Authorization

Kubernetes has two distinct authorization layers that run at different stages of API request processing. Confusing them is a common source of debugging headaches.

AspectRBACAdmission Authorization
Invocation order After authentication, before object persistence Webhook runs at admission time (before RBAC); RBAC at authorization
What it controls Who can call which API endpoints Whether an admitted object is allowed to be created/modified
Configuration Role, ClusterRole, RoleBinding, ClusterRoleBinding objects ValidatingWebhookConfiguration or MutatingWebhookConfiguration
Evaluation Attribute-based: user + verb + resource + namespace Arbitrary policy: can inspect full object, reject based on any field
Example policies SA can list pods, user can update deployments All ConfigMaps must have label X, no pods with hostPath
Can RBAC replace it? No — RBAC can't inspect field values or reject based on content
# Admission webhook — runs BEFORE RBAC authorization
# This webhook rejects any Pod that has hostPath volumes
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: no-hostpath-pods
webhooks:
  - name: validate-pods.example.com
    rules:
      - apiGroups: [""]
        apiVersions: [v1]
        operations: [CREATE, UPDATE]
        resources: [pods]
        scope: Namespaced
    clientConfig:
      url: https://webhook-service.namespace.svc/validate
      caBundle: BASE64_CA
    namespaceSelector:
      matchLabels:
        enforce-hostpath-policy: "true"
    admissionReviewVersions: [v1, v1beta1]
    sideEffects: None

# RBAC cannot enforce this — it doesn't inspect pod spec fields
# It only controls whether the 'pods' API endpoint can be called
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: pod-creator
  namespace: production
rules:
  - apiGroups: [""]
    resources: [pods]
    verbs: [create]  # can call POST /api/v1/namespaces/production/pods
    # but still might get rejected by the admission webhook above

The key insight: RBAC says "you can make this API call." Admission says "the object you're submitting passes my policy." They're independent checks — a request can pass RBAC but fail admission, or vice versa. The RBAC authorizer runs during the authorization phase (after authentication, before the object is written to etcd). Validating webhooks run during admission (after object is decoded but before it's persisted). Mutating webhooks can even change the object before RBAC evaluation happens if they run first.

SelfSubjectAccessReview and SelfSubjectRulesReview

These are special API endpoints that let any user ask "what can I do?" They bypass the normal resource-based API — instead they return the effective permissions for the requesting user (or a specified user/SA).

# SelfSubjectAccessReview — can I do this specific action?
# Returns YES/NO for a single permission check
$ kubectl create -f - <<'EOF'
apiVersion: authorization.k8s.io/v1
kind: SelfSubjectAccessReview
metadata:
  name: can-i-delete-pods
spec:
  resourceAttributes:
    namespace: app
    verb: delete
    group: ""
    resource: pods
EOF
# Response:
spec:
  allowed: true    # yes, I can delete pods in 'app'
  reason:         # auto-computed from all RoleBindings/ClusterRoleBindings

---
# SelfSubjectRulesReview — what can I do in this namespace?
# Returns the full list of permissions for a subject in a namespace
$ kubectl create -f - <<'EOF'
apiVersion: authorization.k8s.io/v1
kind: SelfSubjectRulesReview
metadata:
  name: my-permissions
spec:
  namespace: app
spec:
  namespace: app
  resources:
    - pods          # all verbs on pods
    - services      # all verbs on services
  - apiGroups: [apps]
    resources: [deployments]
    verbs: [get, list, watch, update, patch]
  - nonResourceURLs: [/healthz]
    verbs: [get]
  incomplete: false

---
# In practice — the k8s.io/python-client library uses this
# to check permissions before attempting operations
from kubernetes import client
from kubernetes.client import authorization

auth_api = authorization.AuthorizationV1Api()
review = auth_api.create_self_subject_access_review(
    body={
        "spec": {
            "resourceAttributes": {
                "namespace": "app",
                "verb": "get",
                "resource": "pods"
            }
        }
    }
)
if not review.spec.allowed:
    raise PermissionError(f"Cannot list pods in app namespace")

---
# Checking permissions for a ServiceAccount (impersonation required)
$ kubectl auth can-i list pods \
    --as=system:serviceaccount:monitoring:prometheus \
    --namespace=monitoring
yes

# This is what the Kubernetes Dashboard uses to show the "You're not allowed
# to see this" UI — it queries SelfSubjectRulesReview and hides resources
# the user has no access to.

Writing a Least-Privilege Role: Practical Walkthrough

Let's build a real-world role for a web application that reads from a database, emits metrics, and needs to update its own deployment's replica count. We'll grow it from minimal to complete.

Step 1: Identify the workloads and their needs

# Application: backend-api
# Needs:
#   - Read config from ConfigMaps (database credentials)
#   - Write to its own ConfigMaps (leader election status)
#   - Read from its own Secrets (DB password)
#   - Update deployment replica count (HPA writes this)
#   - Emit Prometheus metrics via ServiceMonitor (Prometheus scrapes)
#   - Read pods to find replica IPs for gRPC
#   - Read services for service discovery
#   - Write events to track deployments
#   - No access to other namespaces, other apps' resources, or cluster-wide stuff

Step 2: Write the Role incrementally

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: backend-api
  namespace: backend
rules:
  # ConfigMaps — read (config), write (leader election state)
  - apiGroups: [""]
    resources: [configmaps]
    verbs: [get, list, watch]
    resourceNames: [backend-config, backend-leader]  # restrict to named objects

  # Secrets — read only, and only the specific secret
  - apiGroups: [""]
    resources: [secrets]
    verbs: [get]
    resourceNames: [backend-db-credentials]  # least privilege on secrets

  # Services — read for discovery, write for endpoints updates
  - apiGroups: [""]
    resources: [services]
    verbs: [get, list, watch]

  # Pods — read for replica IP lookup
  - apiGroups: [""]
    resources: [pods]
    verbs: [get, list, watch]

  # Events — write (namespace-scoped, no resourceNames needed)
  - apiGroups: [""]
    resources: [events]
    verbs: [create]

  # Deployments — update only (for HPA and rollout pause/resume)
  # Cannot create or delete deployments — only update specific fields
  - apiGroups: [apps]
    resources: [deployments]
    verbs: [get, list, watch, update, patch]
    resourceNames: [backend-api]  # only our own deployment

  # ServiceMonitor (if using Prometheus Operator) — update metrics address
  - apiGroups: [monitoring.coreos.com]
    resources: [servicemonitors]
    verbs: [get, update, patch]
    resourceNames: [backend-api]

  # Endpoints — write for registering replica IPs
  - apiGroups: [""]
    resources: [endpoints]
    verbs: [get, update, patch]
    resourceNames: [backend-api}

# The SA that runs this pod:
apiVersion: v1
kind: ServiceAccount
metadata:
  name: backend-api
  namespace: backend
---
# RoleBinding to grant the role to the SA
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: backend-api
  namespace: backend
subjects:
  - kind: ServiceAccount
    name: backend-api
    namespace: backend
roleRef:
  kind: Role
  name: backend-api
  apiGroup: rbac.authorization.k8s.io

Step 3: Verify with impersonation

# Test all the expected permissions
$ kubectl auth can-i get configmaps/backend-config \
    --as=system:serviceaccount:backend:backend-api --namespace=backend
yes

$ kubectl auth can-i delete configmaps/backend-config \
    --as=system:serviceaccount:backend:backend-api --namespace=backend
no   # correct — delete not in role

$ kubectl auth can-i get configmaps/other-app-config \
    --as=system:serviceaccount:backend:backend-api --namespace=backend
no   # correct — resourceNames prevents cross-object access

# Full permission report
$ kubectl auth can-i --list \
    --as=system:serviceaccount:backend:backend-api --namespace=backend

RBAC in CI/CD Pipelines

CI/CD systems need to interact with Kubernetes — deploying, rolling out, checking status. The right approach depends on the pipeline's architecture.

Using a dedicated ServiceAccount per pipeline job

# Per-namespace CI SA with scoped permissions
apiVersion: v1
kind: ServiceAccount
metadata:
  name: gitlab-runner
  namespace: production
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: gitlab-runner-production
rules:
  # Deployments — can roll out, scale, restart
  - apiGroups: [apps]
    resources: [deployments]
    verbs: [get, list, watch, update, patch]
    resourceNames: ["*"]  # can manage any deployment in production
  # Rollouts — for Argo Rollouts or similar
  - apiGroups: [argoproj.io]
    resources: [rollouts]
    verbs: [get, list, watch, update, patch]
  # Jobs — can create deployment jobs
  - apiGroups: [batch]
    resources: [jobs]
    verbs: [create, delete]
  # Pods — for logs and exec (debugging)
  - apiGroups: [""]
    resources: [pods, pods/log]
    verbs: [get, list]
  # Events — read deployment status
  - apiGroups: [""]
    resources: [events]
    verbs: [get, list]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: gitlab-runner-production
  namespace: production
subjects:
  - kind: ServiceAccount
    name: gitlab-runner
    namespace: production
roleRef:
  kind: Role
  name: gitlab-runner-production
  apiGroup: rbac.authorization.k8s.io

---
# In the CI job, mount the bound token
apiVersion: v1
kind: Pod
metadata:
  name: gitlab-pipeline-runner
spec:
  serviceAccountName: gitlab-runner
  containers:
    - name: runner
      image: gitlab-runner:latest
      command: ["/bin/sh", "-c"]
      args:
        - kubectl get ns && kubectl rollout status deployment/my-app

GitOps with image pull secrets for private registries

# CI needs to push images — this uses a different auth mechanism (docker config)
# But CI deploying to k8s uses the SA approach above
# For imagePullSecrets on ServiceAccounts (for private registry pull):
apiVersion: v1
kind: ServiceAccount
metadata:
  name: ci-deploy
  namespace: production
imagePullSecrets:
  - name: ghcr-io-secret  # contains docker config.json for GHCR
secrets:
  - name: ghcr-io-secret
---
# The secret must be a kubernetes.io/dockerconfigjson type
apiVersion: v1
kind: Secret
metadata:
  name: ghcr-io-secret
  namespace: production
type: kubernetes.io/dockerconfigjson
data:
  # echo -n '{"ghcr.io":{"auth":"BASE64_USER:TOKEN"}}' | base64
  .dockerconfigjson: BASE64_ENCODED_DOCKER_CONFIG

Node Authorization and kubelet RBAC

Node authorization is a special RBAC mode for kubelets — it restricts the API server to only the permissions that a kubelet legitimately needs. It's not traditional RBAC with Role objects, but it uses the same attribute-matching logic.

# Node authorizer behavior (enabled via --authorization-mode=Node)
# A kubelet authenticates as system:node: group system:nodes
# The Node authorizer ALLOWS these specific operations:

# Read operations — kubelet reads its own node and pod specs
- apiGroups: [""]
  resources: [nodes]
  verbs: [get]           # read own node status
  resourceNames: [node-01.internal]  # only this node's node object

- apiGroups: [""]
  resources: [pods]
  verbs: [get, list]     # list pods scheduled to this node
  #kubelet reads pods to report status back to API server

- apiGroups: [""]
  resources: [pods/status]
  verbs: [get]

# Write operations — kubelet updates its own pod status
- apiGroups: [""]
  resources: [pods/status]
  verbs: [update]

- apiGroups: [""]
  resources: [events]
  verbs: [create, patch]

# Certificate signing requests — kubelet submits CSRs for client certs
- apiGroups: [certificates.k8s.io]
  resources: [certificatesigningrequests]
  verbs: [create, get, list, watch]

- apiGroups: [coordination.k8s.io]
  resources: [leases]
  verbs: [get, create, update]  # leader election via lease objects

# The node restriction admission plugin further limits kubelet to only
# mutate pods it owns (via nodeName field matching)
# Enabled via --admission-control-config-file pointing to:
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
pluginConfig:
  - name: NodeRestriction
    configuration:
      limitedByExample: "kubelet-"

# What the kubelet CANNOT do (without additional RBAC):
# - Read secrets from other namespaces
# - Access other nodes' status
# - Modify other nodes' leases
# - Create other pods on other nodes
# - Access PersistentVolumes (controller handles this)
Node authorization uses attribute restrictions, not ClusterRoles. The Node authorizer checks that user == system:node:<nodeName> and group == system:nodes, then allows a hardcoded set of operations. You can't modify this with kubectl edit clusterrole system:node:... — the node authorizer evaluates before RBAC and bypasses it entirely for node requests. To add kubelet permissions (e.g., read specific ConfigMaps), you create a ClusterRoleBinding that grants additional permissions to system:nodes group.

Inspect and Audit RBAC

# What can the current user do?
$ kubectl auth can-i create pods --namespace=app
yes

$ kubectl auth can-i delete nodes
no

# What can someone else do?
$ kubectl auth can-i list secrets \
    --as=system:serviceaccount:app:log-shipper \
    --namespace=app
no

# Full permission inventory — very useful for audit
$ kubectl auth can-i --list --namespace=app
[ ]
  resources:   # empty — no permissions!

# Check as a real user with OIDC
$ kubectl auth can-i get deployments \
    [email protected] \
    --as-group=oidc:developers \
    --namespace=production
yes

# All permissions for a ServiceAccount
$ kubectl auth can-i --list \
    --as=system:serviceaccount:monitoring:prometheus \
    --namespace=monitoring

# Who has access to a resource? (audit who can do what)
$ kubectl get clusterrolebinding -o json | \
  jq '.items[] | select(.roleRef.name=="cluster-admin") | .subjects'
[
  { "kind": "Group", "name": "oidc:admins" },
  { "kind": "User", "name": "system:kube-controller-manager" }
]

# Find all bindings granting access to 'secrets' in a namespace
$ kubectl get rolebinding,clusterrolebinding -n production \
  -o json | jq '.items[] | select(
    .roleRef.name == "secret-reader" or
    (.roleRef.kind == "ClusterRole" and
     any(.roleRef.name; . == "admin"))
  ) | .subjects'

# kube-bench checks if RBAC follows CIS benchmarks
$ kube-bench run --targets=policy

# kubectl-whoami — shows current identity and groups
$ kubectl-whoami
User: [email protected]
Groups: [oidc:admins, oidc:developers]

$ kubectl-whoami --as=system:serviceaccount:app:my-sa
User: system:serviceaccount:app:my-sa
Groups: [system:serviceaccounts:app]

Audit Log Patterns for RBAC

# In your audit policy, log all RBAC decisions
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
  # Log all requests to RBAC APIs — who is changing permissions?
  - level: RequestResponse
    resources:
      - group: rbac.authorization.k8s.io
        resources: [roles, rolebindings, clusterroles, clusterrolebindings]

  # Log all secret access
  - level: Metadata   # not RequestResponse (too much data for secrets)
    resources:
      - group: ""
        resources: [secrets]
    namespaces: [production, staging]

  # Log admin actions
  - level: RequestResponse
    users: [[email protected]]
    verbs: ["*"]

# Event that shows an RBAC-authorized request (status code 200)
{
  "kind": "Event",
  "apiVersion": "audit.k8s.io/v1alpha1",
  "metadata": { "name": "pod-create-2024-01-15" },
  "level": "RequestResponse",
  "timestamp": "2024-01-15T10:23:45Z",
  "auditID": "abcd-1234",
  "stage": "ResponseComplete",
  "user": {
    "username": "ci-pipeline@prod",
    "uid": "system:serviceaccount:production:ci-deploy",
    "groups": ["system:serviceaccounts:production"]
  },
  "verb": "create",
  "requestURI": "/api/v1/namespaces/production/pods",
  "resource": { "group": "", "version": "v1", "resource": "pods" },
  "responseStatus": { "code": 201 },
  "requestObject": { ... },
  "responseObject": { ... }
}

Escalation Prevention

Kubernetes prevents you from granting permissions you don't already have. This is called escalation prevention and it applies to both Roles and ClusterRoles referenced by Bindings.

# Alice has only 'get' and 'list' on pods
# She tries to create a Role that grants 'create' on pods → API REJECTS IT
$ kubectl create role alice-admin \
    --verb=create \
    --resource=pods
Error from server (Forbidden): ...
# "roles.rbac.authorization.k8s.io is forbidden:
#  cannot create Role with verbs that would result in
#  escalation from 'get,list' to 'create'"

# Same for ClusterRoleBinding — can't grant cluster-admin if you don't have it
# (unless you're cluster-admin, which is exempt from escalation checks)

---
# The escalation check also covers roleRef in bindings:
# If Bob only has 'view' ClusterRole permissions,
# binding Bob to 'admin' ClusterRole via RoleBinding is rejected
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: bob-admin
  namespace: app
subjects:
  - kind: User
    name: [email protected]
roleRef:
  kind: ClusterRole
  name: admin   # Bob can't grant admin if he doesn't have admin
apiGroup: rbac.authorization.k8s.io
# Error: user "[email protected]" cannot bind ClusterRole "admin"
# because the ClusterRole has permissions the user doesn't have

# BUT — if Alice has cluster-admin, she can grant admin to anyone
# (she has all permissions, so no escalation is possible)
Exemptions from escalation: system:masters group (bound to cluster-admin via a ClusterRoleBinding created by the API server bootstrap) is exempt from escalation checks. This is why cluster-admin can grant any role — it's the bootstrap superuser. The bootstrap ClusterRoleBinding is the only place system:masters appears, and it cannot be modified via RBAC — you must delete it directly.

Common Pitfalls and How to Avoid Them

❌ Wildcard everything

rules: [{apiGroups: ["*"], resources: ["*"], verbs: ["*"]}] grants cluster-admin to anyone who can create a RoleBinding referencing this ClusterRole. And if you bind it to a group, every member of that group gets full API access. Use specific resources and specific verbs.

❌ Binding cluster-admin too broadly

ClusterRoleBinding to cluster-admin with subject kind: Group, name: "[email protected]" means any compromised account in that group gets full cluster access. Reserve cluster-admin for break-glass procedures and dedicated admin groups.

❌ No deny rules

RBAC has only allow rules. If you want to block access, you must NOT grant it. There's no way to say "allow read but not write" other than omitting write verbs. Plan your role definitions accordingly.

❌ Forgetting resourceNames on secrets

verbs: [get] on secrets without resourceNames grants read on all secrets in the namespace, including DB passwords, tokens, and keys. Use resourceNames: [db-creds, api-key] to restrict to the specific secrets the workload needs.

❌ Giving 'update' instead of 'patch'

update requires the client to send the full object — which is hard to do correctly for resources with complex specs (Deployments, Services with many fields). patch is often what you actually want for HPA-driven replica changes or status updates.

❌ Confusing namespace scope with cluster scope

A RoleBinding can only grant permissions within its own namespace. A ClusterRoleBinding applies cluster-wide. If you need to apply the same permissions across 20 namespaces, create one ClusterRole and 20 RoleBindings (or use a fleet management tool), not 20 ClusterRoleBindings that all need separate maintenance.

❌ Not auditing who has cluster-admin

Run this regularly: kubectl get clusterrolebinding cluster-admin -o yaml | grep -A5 subjects Audit who can grant or escalate permissions. In large clusters, it's easy to accumulate bindings that grant cluster-admin to old employees, test accounts, or deprecated service accounts.

❌ Ignoring aggregation side effects

If you create a ClusterRole with rbac.authorization.k8s.io/aggregate-to-edit: "true", it automatically extends the edit ClusterRole. Anyone with edit in any namespace will gain those permissions. Test your operator's ClusterRoles in a sandbox before installing in prod.

Debugging RBAC: A Decision Tree

# Step 1: What identity are you running as?
$ kubectl auth whoami
# or: kubectl config view --context=your-context
# Shows username + groups

# Step 2: Can you do the specific thing?
$ kubectl auth can-i <verb> <resource> --namespace=<ns>
yes/no

# Step 3: What can you do at all? (full inventory)
$ kubectl auth can-i --list --namespace=<ns>

# Step 4: Impersonate the failing identity and reproduce
$ kubectl get <resource> --as=<failing-user> --namespace=<ns>

# Step 5: Check bindings in the failing namespace
$ kubectl get rolebinding,clusterrolebinding -n <ns> -o wide

# Step 6: Check if you're hitting the API discovery rule
# (you're authenticated but RBAC denies API group access)
$ kubectl auth can-i get /apis/monitoring.coreos.com/v1
no  # if no permissions on that API group

# Step 7: Check if the SA token is expired or misconfigured
$ kubectl get sa <sa-name> -n <ns> -o yaml
# Check if automountServiceAccountToken: false is set
# Check if projected volume is configured correctly

# Step 8: Check if you're hitting Node authorization boundaries
# (kubelet can't read secrets from other namespaces)
# The error will be: "nodes is forbidden: User system:node:xxx
#  cannot list resource 'secrets' in API group '' in the namespace 'other'"

# Step 9: Check the audit log for the actual denial
# Your audit policy should capture responseStatus.code=403
# which will show user, verb, resource, and namespace of denied request

Tradeoffs

Strengths
  • Pure-additive model — no surprising deny rules
  • Aggregation makes operators extend built-in roles cleanly
  • Bound tokens eliminate long-lived credential leakage
  • OIDC integration means existing IdPs map naturally to k8s
  • Escalation prevention prevents accidental privilege escalation
  • SelfSubjectRulesReview lets users self-audit their permissions
  • Node authorization tightly scopes kubelet API access
Sharp edges
  • No deny rules — overly broad ClusterRoleBindings are easy to create, hard to fix
  • Users are strings — there's no way to enumerate them without an IdP audit log
  • Verbs like 'patch' implicitly grant 'update' for the same resource (but not vice versa)
  • Wildcard ClusterRoles give cluster-admin to anyone who edits a Deployment with the right RBAC
  • Aggregation rules apply cluster-wide — a CRD operator's ClusterRole extends 'edit' in ALL namespaces
  • RBAC can't enforce field-level restrictions — need admission webhooks for that
  • Cannot restrict by object content (e.g., only allow ConfigMaps with label X) — only by name

Frequently Asked Questions

Role vs ClusterRole — when do I use each?

Role grants permissions inside a single namespace; ClusterRole grants cluster-wide or for non-namespaced resources (Nodes, PersistentVolumes, ClusterRoleBindings themselves). RoleBinding can reference either a Role (namespace-local) or a ClusterRole (granting just the namespace's portion of those rules). ClusterRoleBinding can only reference a ClusterRole. The pattern: define a ClusterRole once for a 'reader' or 'admin' template, then use RoleBindings in each namespace to apply it. This avoids duplicating Roles per namespace.

What's a ClusterRole aggregation?

Aggregation lets multiple ClusterRoles auto-merge into one based on labels. Define a ClusterRole with aggregationRule.clusterRoleSelectors and Kubernetes constantly recomputes its rules as the union of any ClusterRole matching those selectors. The built-in 'admin', 'edit', 'view' roles use this so that operators (cert-manager, Argo) can extend them by labeling their own ClusterRoles. Adding a CRD's permissions to 'edit' is as simple as creating a ClusterRole with the right rbac.authorization.k8s.io/aggregate-to-edit: true label.

What are the four subject kinds?

Subjects in RoleBindings/ClusterRoleBindings are: User (a human or external identity, validated by your authenticator), Group (a group claim from your authenticator), ServiceAccount (a Kubernetes-native identity for in-cluster workloads), and— there's no fourth, but you'll often see implicit 'system:authenticated' and 'system:serviceaccounts:<ns>' groups bound to default permissions. Users and Groups are not Kubernetes objects; they're just strings that an authenticator (OIDC, certificates, webhooks) returns. ServiceAccounts ARE Kubernetes objects.

Legacy vs bound ServiceAccount tokens?

Legacy tokens (auto-mounted Secrets) are JWTs that never expire and are stored in etcd. They're a security risk — anyone who reads the secret can authenticate as that SA forever. Bound tokens (BoundServiceAccountTokenVolume, default since 1.21) are JWTs with limited TTL (default 1 hour) bound to a specific Pod and audience. They're projected as a file in the Pod and rotated automatically by kubelet. Modern clusters set automountServiceAccountToken: false on SAs that don't need API access, and rely on bound tokens for those that do.

How does OIDC integration work for kubectl?

kubectl users authenticate via your OIDC provider (Okta, Auth0, Google, Keycloak). The flow: 'kubectl' calls a credential plugin (kubelogin, AWS IAM authenticator, gcloud) which performs OIDC and returns an ID token. kubectl puts that token in the Authorization header. The API server validates it via configured --oidc-* flags or a TokenReview webhook. The 'sub', 'email', or 'groups' claims become the user/groups for RBAC. ClusterRoleBindings then map specific groups (e.g., '[email protected]') to admin permissions.

What's the API discovery rule?

Every authenticated user automatically gets read access to API discovery endpoints (/api, /apis, /version, /healthz, /readyz, /openapi/*) regardless of their RBAC. This is enforced by a special path-based authorizer that runs before RBAC. Without it, kubectl couldn't even list which APIs exist on the cluster, breaking every client. You can't disable it via RBAC alone; if you really need to lock it down you need an admission webhook or audit policy.

What does 'escalation' mean in RBAC?

Escalation prevention — you're not allowed to grant permissions you yourself don't have. If you only have 'get' and 'list' on pods, you can't create a Role that grants 'create' or 'update' on pods. The API server checks that the Role being created (or a ClusterRole in a RoleBinding) doesn't grant more than the binding subject already possesses. This stops an admin with read-only access from bootstrapping write access. This applies to Roles, ClusterRoles, RoleBindings, and ClusterRoleBindings.

When do I use a RoleBinding vs ClusterRoleBinding?

Use RoleBinding when permissions should be scoped to a single namespace. Use ClusterRoleBinding for cluster-wide resources (nodes, persistent volumes, namespaces themselves) or when you want the same permissions applied across many namespaces without creating N RoleBindings. A RoleBinding can reference a ClusterRole — this is powerful for cross-namespace consistency of a 'namespace-reader' or 'developer' template.

Why can't I grant 'create' without 'get' on a resource?

You can, technically. But practically, you can't inspect what you created (no 'get' on the new object) and you can't tell if creation succeeded (no 'list'). Kubernetes RBAC doesn't enforce a dependency graph between verbs. It's your job to grant sensible combinations. Common pattern: grant 'create' + 'get' + 'list' + 'watch' together, and 'update' separately when you need mutations.

What's the difference between 'update' and 'patch'?

'update' sends the full updated object to the API (PUT semantics). 'patch' sends a partial JSON merge patch or strategic merge patch to modify specific fields. You can have one without the other. A user with 'patch' but not 'update' can modify fields but can't replace the full object. A user with 'update' but not 'patch' must send the complete object every time — which is harder to do correctly in clients.