RBAC & ServiceAccounts

Who may do what to which resources, why RBAC is purely additive, and how to debug a permission error in one command instead of guessing at YAML.

advanced 18 min lesson hands-on task included

RBAC answers one question per request: may this subject perform this verb on this resource? It is entirely additive — there are no deny rules — which makes it simple to reason about and easy to over-grant.


Topic 1: The Four Objects

ObjectScopeSays
RoleNamespaceWhat may be done, in one namespace
ClusterRoleClusterWhat may be done, anywhere (or on cluster-scoped resources)
RoleBindingNamespaceGrants a Role or a ClusterRole, within one namespace
ClusterRoleBindingClusterGrants a ClusterRole, cluster-wide
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: payments
  name: pod-reader
rules:
  - apiGroups: [""]                       # "" = the CORE group
    resources: ["pods", "pods/log"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "update", "patch"]
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["api-config-secret"]  # ONE named secret only
    verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  namespace: payments
  name: read-pods
subjects:
  - kind: ServiceAccount
    name: api-sa
    namespace: payments
  - kind: User
    name: alice@example.com
    apiGroup: rbac.authorization.k8s.io
  - kind: Group
    name: platform-team
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: pod-reader
  apiGroup: rbac.authorization.k8s.io

Details that matter:

apiGroups: [""] is the core group — pods, services, secrets, configmaps, nodes. Deployments are in apps; ingresses in networking.k8s.io. Getting the group wrong is the second-most-common RBAC mistake, and the error is an unhelpful “forbidden”.

Subresources are separate resources. pods does not grant pods/log, pods/exec or pods/portforward. Granting pods/exec is effectively granting shell access to whatever runs in those pods — treat it as a privileged verb.

resourceNames narrows to specific objects, which is how you grant access to one Secret rather than all of them. Note it does not work with list or watch (you cannot list a filtered set), only with get, update, patch and delete.

roleRef is immutable. To change which Role a binding grants, delete and recreate it.

The verbs:

get  list  watch  create  update  patch  delete  deletecollection

Plus non-resource verbs for URLs (/healthz), and impersonate, bind, escalate — the last three being privilege-escalation vectors worth auditing specifically.


Topic 2: A ClusterRole Bound Two Ways

This is the piece that unlocks the model. A ClusterRole is just a set of permissions; where they apply depends on the binding:

# Bound cluster-wide → read secrets EVERYWHERE
kind: ClusterRoleBinding
roleRef: { kind: ClusterRole, name: secret-reader }
---
# The SAME ClusterRole, bound in one namespace → read secrets in THAT namespace only
kind: RoleBinding
metadata: { namespace: payments }
roleRef: { kind: ClusterRole, name: secret-reader }

This is why you define common permission sets once as ClusterRoles and bind them per namespace, rather than duplicating a Role into forty namespaces.

Some resources are cluster-scoped (nodes, PVs, StorageClasses, CRDs, namespaces themselves). Access to those requires a ClusterRole and a ClusterRoleBinding — a RoleBinding cannot grant them, because they do not live in a namespace.

The built-in ClusterRoles:

kubectl get clusterroles | grep -v '^system:'
NameGrants
cluster-adminEverything. * on *.
adminFull access within a namespace, including RBAC — but not quotas
editRead/write workloads; cannot touch Roles or RoleBindings
viewRead-only, excluding Secrets

view excluding Secrets is deliberate and worth knowing — people grant view expecting it to be safe, which it largely is, precisely because of that exclusion.


Topic 3: ServiceAccounts — Identity for Pods

apiVersion: v1
kind: ServiceAccount
metadata:
  name: api-sa
  namespace: payments
automountServiceAccountToken: false      # default to OFF
---
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      serviceAccountName: api-sa
      automountServiceAccountToken: true  # opt IN only where needed

Every pod runs as a ServiceAccount — the namespace’s default one if you do not say otherwise. Its identity in RBAC is:

system:serviceaccount:<namespace>:<name>

Turn off token automounting unless the pod actually calls the API. Most applications never talk to the Kubernetes API, and a mounted token is a credential an attacker can use after compromising the container. Set automountServiceAccountToken: false on the ServiceAccount and opt in per workload.

Tokens changed in 1.24:

Creating a ServiceAccount no longer creates a permanent Secret. Pods receive a projected token that is:

  • Time-bound — expires and is rotated by the kubelet automatically.
  • Audience-bound — valid only for the intended recipient.
  • Object-bound — invalidated when the pod is deleted.
volumes:
  - name: token
    projected:
      sources:
        - serviceAccountToken:
            path: token
            expirationSeconds: 3600
            audience: vault

A leaked bound token expires. A leaked legacy token is valid forever. If you find long-lived kubernetes.io/service-account-token Secrets in your cluster, that is technical debt worth clearing.

Cloud identity federation:

Rather than putting cloud credentials in a Secret, map a ServiceAccount to a cloud role:

metadata:
  name: api-sa
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/api-role      # IRSA
    iam.gke.io/gcp-service-account: api@project.iam.gserviceaccount.com      # Workload Identity

The pod’s projected token is exchanged for cloud credentials via OIDC. No static secret exists to leak, and rotation is automatic. This is the correct pattern on every major cloud.


Topic 4: Debugging Permissions in One Command

kubectl auth can-i create deployments -n payments
kubectl auth can-i delete pods --as=system:serviceaccount:payments:api-sa -n payments
kubectl auth can-i get secrets --as=alice@example.com -n payments
kubectl auth can-i '*' '*' --all-namespaces
kubectl auth can-i --list --as=system:serviceaccount:payments:api-sa -n payments

kubectl auth can-i --list --as=... prints everything a subject may do. It answers most RBAC questions without reading a single YAML file, and it is the first command to run — not the last.

# Who is bound to cluster-admin? (audit this regularly)
kubectl get clusterrolebindings -o json | jq -r '
  .items[] | select(.roleRef.name=="cluster-admin") |
  "\(.metadata.name): \(.subjects // [] | map(.kind+"/"+.name) | join(", "))"'

# What does this Role actually allow?
kubectl describe role pod-reader -n payments
kubectl get clusterrole edit -o yaml

# Who am I right now?
kubectl auth whoami

Reading the error properly:

Error from server (Forbidden): pods is forbidden:
  User "system:serviceaccount:payments:api-sa" cannot list resource "pods"
  in API group "" in the namespace "monitoring"

The message contains everything: the subject, the verb, the resource, the API group, and the namespace. Most fixes are visible in it — here, the binding exists in payments but the request was for monitoring.


Topic 5: Least Privilege in Practice

# A controller that manages only its own CRD, plus the resources it creates
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: widget-operator
rules:
  - apiGroups: ["example.com"]
    resources: ["widgets", "widgets/status", "widgets/finalizers"]
    verbs: ["get", "list", "watch", "update", "patch"]
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
  - apiGroups: [""]
    resources: ["events"]
    verbs: ["create", "patch"]

Principles worth holding to:

  1. Start from nothing and add what fails. Deploy with no permissions, read the Forbidden errors, grant exactly those. It is faster than it sounds and produces a genuinely minimal Role.
  2. Prefer namespaced Roles. A ClusterRole should be a deliberate decision.
  3. Never grant * on *. Enumerate; a new resource type should not be automatically in scope.
  4. Watch the escalation verbs: escalate, bind, impersonate, create on pods (you can mount any Secret), pods/exec, and create/patch on ValidatingWebhookConfigurations.
  5. create on pods is close to node-level access. Anyone who can create a pod can mount hostPath, run privileged, or use another ServiceAccount’s token — which is why Pod Security Admission (next lesson) exists alongside RBAC.

Auditing:

kubectl get clusterrolebindings,rolebindings -A -o wide | grep -i cluster-admin
kubectl get sa -A -o json | jq -r '.items[] | select(.automountServiceAccountToken != false) | "\(.metadata.namespace)/\(.metadata.name)"'

Tools worth knowing: kubectl-who-can (reverse lookup — who can do X), rbac-tool (visualise and generate least-privilege roles), and kubectl auth can-i --list as the always-available baseline.


Topic 6: Where RBAC Sits Among the Others

RBAC is one of several authorisation modes, and the API server can run more than one:

--authorization-mode=Node,RBAC
  • Node — restricts each kubelet to objects relevant to its own node. Without it, one compromised node could read every Secret in the cluster.
  • RBAC — everything above.
  • ABAC — legacy, file-based, requires an API server restart to change. Avoid.
  • Webhook — delegate to an external service.

Modes are evaluated in order and any allow wins. This is why RBAC has no deny rules: a deny would be meaningless when another authoriser could still permit the request.

Beyond RBAC: admission control (next lessons) enforces what an object may look like, which RBAC cannot express. “May create pods” is RBAC; “may not create privileged pods” is admission. You need both.

Try it yourself: Create a ServiceAccount with no bindings at all, run a pod as it, and from inside the pod curl the API server using the projected token. Read the Forbidden response, then grant exactly the one permission it names and try again.

Common mistake: Debugging RBAC by editing YAML and redeploying. kubectl auth can-i --as=<subject> --list answers the question in one command, against the live cluster, with no deployment at all — and it is the same evaluation the API server performs.