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
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
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 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. 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 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.
| Verb | HTTP | What it allows | Gotcha |
|---|---|---|---|
get | GET | Read a single named object | Requires resource name in URL |
list | GET (collection) | List all objects of a type; watch a collection | Does not imply get on individual items |
watch | GET with ?watch | Long-lived streaming watch on a collection | Often granted with list; rarely alone |
create | POST | Create new objects | Does not let you read what you created |
update | PUT | Replace entire object | Requires sending full object; no partial updates |
patch | PATCH | Merge a JSON patch (RFC 6902) or strategic merge | Can modify specific fields without full object |
delete | DELETE | Delete a single named object | Does not allow watching deletion progress |
deletecollection | DELETE on collection | Delete all objects of a type in a namespace | Powerful 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] * 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.
| ClusterRole | Aggregation Label | Typical 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 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
- 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
- Short TTL — max 3600s by default (configurable via
TokenRequestAPI) - 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 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.
| Aspect | RBAC | Admission 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) 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) 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
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.
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.
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.
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.
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.
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.
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.
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
- 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
- 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.