· · 12 min leestijd Intermediate

Kubernetes RBAC: least privilege in de praktijk

Implementeer least privilege access control in Kubernetes met RBAC. Beperk blast radius door proper role design en binding strategieën.

Een manager-oger houdt een sleutelbos zo groot als een wiel op en zij pakt de ene sleutel die ze nodig heeft

TL;DR Kubernetes levert standaard een permissiemodel waarin elke pod een token krijgt en het default service account rechten verzamelt die niemand meer bewust heeft gegeven. Least privilege betekent een service account per workload, geen token tenzij de app met de API praat, en roles die de namespace, de resource en het verb bij naam noemen. Hieronder de vier RBAC-resources, de vijf instellingen die de blast radius begrenzen, de bindings die ik het vaakst kopieer, en hoe ik dat alles controleer en afdwing.

Het eerste cluster dat ik echt in productie draaide had precies één permissiemodel, en dat was cluster-admin. M’n CI-pipeline, m’n monitoringstack, het webhook-ontvangertje dat ik op een middag in elkaar had gezet. Allemaal konden ze elk secret lezen, elke deployment weggooien en in elke namespace rommelen. Werkte prima. Tot ik ging nadenken over wat er gebeurt als één van die pods wordt overgenomen.

Kubernetes RBAC (Role-Based Access Control) beantwoordt één vraag. Wie mag wat doen met welke resources? Op de meeste clusters is het antwoord “iedereen mag alles”, en niemand heeft dat ooit besloten. Dat krijg je als je nooit iets opschrijft.

Dit interesseert me om dezelfde reden waarom ik de rest van m’n stack wil snappen. Wordt een service account overgenomen, dan wil ik weten hoe ver de schade reikt voordat het zover is. Typ ik zelf een kubectl-commando verkeerd, dan wil ik dat de schade klein genoeg is om zelf te herstellen voordat iemand het merkt. Least privilege is het plafond op allebei.

Deze post loopt van de vier RBAC-resources naar de bindings die ik in productie draai, en eindigt met hoe ik controleer of een binding doet wat ik denk dat hij doet.

De vier resources

Vier resources vormen samen Kubernetes RBAC. Twee beschrijven permissies, twee geven die permissies aan iemand.

Een Role beschrijft permissies binnen één namespace. Deze laat z’n houder pods lezen in production en verder niks.

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: production
  name: pod-reader
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list", "watch"]

Een ClusterRole beschrijft permissies over het hele cluster. Zelfde vorm, geen namespace in de metadata.

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: secret-reader
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["get", "list"]

Een RoleBinding geeft een Role aan een subject, binnen één namespace. Hier krijgt het monitoring service account de pod-reader Role van hierboven.

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: read-pods
  namespace: production
subjects:
  - kind: ServiceAccount
    name: monitoring
    namespace: monitoring
roleRef:
  kind: Role
  name: pod-reader
  apiGroup: rbac.authorization.k8s.io

Een ClusterRoleBinding doet hetzelfde met een ClusterRole, cluster-breed. Deze geeft het hele securityteam leesrechten op elk secret dat er is.

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: cluster-secret-reader
subjects:
  - kind: Group
    name: security-team
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: secret-reader
  apiGroup: rbac.authorization.k8s.io

Dat is de hele woordenschat. Een Role en een Binding, gescoped op een namespace of op het cluster. De rest van deze post is discipline in hoe je die vier gebruikt.

Wat je standaard krijgt

Voor de discipline helpt het om te zien waarom juist de defaults je bijten. Een deployment die niets anders zegt ziet er zo uit:

# Hoe veel deployments eruitzien
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      # Default service account met brede permissies
      serviceAccountName: default
      # Token auto-gemount
      automountServiceAccountToken: true

Het default service account verzamelt permissies. Iemand heeft er ooit een ClusterRole aan gehangen om iets werkend te krijgen en dat nooit teruggedraaid. Ik ook. En de automatisch gemounte token zit daarna in elk proces in de container te wachten.

Krijgt een aanvaller een shell in je app, dan is de keten kort en saai. Token lezen op /var/run/secrets/kubernetes.io/serviceaccount/token, de API-server vragen wat er mag, en doorlopen naar wat er toevallig ligt.

De volgende vijf instellingen sluiten elk een stuk van die keten af. Pas er één toe en je bent al beter af dan de defaults. Pas alle vijf toe en een overgenomen pod is voor de aanvaller de moeite nauwelijks waard.

Een service account per workload

Begin hier. Draai nooit een workload op het default service account.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: api-server
  namespace: production
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
spec:
  template:
    spec:
      serviceAccountName: api-server
      automountServiceAccountToken: false  # Mount niet tenzij nodig

Elke applicatie krijgt een eigen account, en permissies landen op dat account en nergens anders. Vanaf dat moment noemt je auditlog wie wat deed, in plaats van een muur van default. Alleen daarvoor al had ik dit een jaar eerder willen doen.

Geen token tenzij de app er één nodig heeft

Dit is het deel dat de meeste mensen overslaan. Het gros van m’n workloads praat helemaal nooit met de Kubernetes-API. Een webfrontend lijst geen pods op. Waarom zou je het dan een token geven?

apiVersion: v1
kind: ServiceAccount
metadata:
  name: web-frontend
automountServiceAccountToken: false
---
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      serviceAccountName: web-frontend
      automountServiceAccountToken: false  # Dubbele zekerheid

Krijg je een shell in deze workload, dan valt er niks te stelen. Geen token op schijf, geen API-toegang, klaar. Deze ene instelling maakt de pivot van hierboven onmogelijk, en het kost niets voor de apps die de API toch nooit nodig hadden.

Houd roles binnen de namespace

Voor de workloads die de API wél nodig hebben is de volgende vraag hoe ver die toegang reikt. Roles zijn standaard namespace-scoped, en dat is een cadeautje. Pak het aan.

# Role werkt alleen in production namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  namespace: production
  name: deployment-manager
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]

Een overgenomen service in production kan niet bij staging of wat dan ook. De schade stopt bij de namespacegrens, en dat is precies waar ik hem wil hebben.

Grijp alleen naar een ClusterRole als je echt het hele cluster nodig hebt. De meeste apps leven en sterven binnen één namespace, dus houd ze daar.

Noem de resources bij naam

Namespace-scope begrenst hoe breed de toegang gaat. Dit begrenst hoe diep. Geef toegang tot benoemde resources, niet tot hele API-groups.

# Slecht: toegang tot alle core resources
rules:
  - apiGroups: [""]
    resources: ["*"]
    verbs: ["*"]

# Goed: alleen specifieke benodigde resources
rules:
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get"]
    resourceNames: ["app-config"]  # Nog specifieker

Het veld resourceNames zie ik het minst gebruikt worden en vind ik het leukst. Het pint toegang vast op specifieke objecten, zodat een app die één ConfigMap nodig heeft die ene ConfigMap kan lezen en verder niks.

Alleen de verbs die het gebruikt

De laatste, en de goedkoopste. Je hebt bepaald welke resources een account mag aanraken. Bepaal nu wat het ermee mag doen.

VerbBetekenis
getLees enkele resource
listLees meerdere resources
watchStream wijzigingen
createMaak nieuwe resources
updateVervang bestaande
patchWijzig bestaande
deleteVerwijder resources
deletecollectionVerwijder meerdere

Een read-only applicatie heeft get, list en watch nodig, en verder niets. Een monitoring-agent heeft niks te zoeken met delete of deletecollection, dus geef hem die optie niet.

# Monitoring heeft alleen read toegang nodig
rules:
  - apiGroups: [""]
    resources: ["pods", "services", "endpoints"]
    verbs: ["get", "list", "watch"]

De bindings die ik het vaakst kopieer

Vijf regels zijn makkelijk om bij te knikken en lastig om toe te passen als de deadline nadert. Dus hier zijn de bindings die ik kopieer-plak, elk al met alle vijf erin.

Een applicatie die een ConfigMap leest

apiVersion: v1
kind: ServiceAccount
metadata:
  name: config-reader
  namespace: myapp
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: config-reader
  namespace: myapp
rules:
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "watch"]
    resourceNames: ["app-settings"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: config-reader
  namespace: myapp
subjects:
  - kind: ServiceAccount
    name: config-reader
    namespace: myapp
roleRef:
  kind: Role
  name: config-reader
  apiGroup: rbac.authorization.k8s.io

Een operator die z’n eigen custom resources beheert

apiVersion: v1
kind: ServiceAccount
metadata:
  name: myapp-operator
  namespace: myapp-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: myapp-operator
rules:
  # Eigen CRDs
  - apiGroups: ["myapp.example.com"]
    resources: ["myapps", "myapps/status"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
  # Resources die het aanmaakt
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
  - apiGroups: [""]
    resources: ["services", "configmaps"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: myapp-operator
subjects:
  - kind: ServiceAccount
    name: myapp-operator
    namespace: myapp-system
roleRef:
  kind: ClusterRole
  name: myapp-operator
  apiGroup: rbac.authorization.k8s.io

Een CI-pipeline die naar productie deployt

apiVersion: v1
kind: ServiceAccount
metadata:
  name: gitlab-deployer
  namespace: ci-cd
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: deployer
  namespace: production
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "update", "patch"]
  - apiGroups: [""]
    resources: ["configmaps", "secrets"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: gitlab-deployer
  namespace: production
subjects:
  - kind: ServiceAccount
    name: gitlab-deployer
    namespace: ci-cd
roleRef:
  kind: Role
  name: deployer
  apiGroup: rbac.authorization.k8s.io

Let op de cross-namespace-truc. Het ServiceAccount leeft in ci-cd, de RoleBinding zit in production, en die binding geeft precies genoeg om daar te deployen. De pipeline kan naar prod zonder ergens admin te zijn.

Auditen wat er al draait

Patronen helpen bij nieuwe workloads. De lastigere vraag is wat er al draait met te veel macht. Een permissiemodel dat ik niet kan inspecteren vertrouw ik niet, dus die inspectie is routine.

Wat één service account mag

kubectl auth can-i --list --as=system:serviceaccount:production:api-server

# Output:
# Resources                          Non-Resource URLs   Verbs
# configmaps                         []                  [get watch]
# pods                               []                  [get list watch]

Wie cluster-admin heeft

# Vind alle ClusterRoleBindings naar cluster-admin
kubectl get clusterrolebindings -o json | jq -r '
  .items[] |
  select(.roleRef.name == "cluster-admin") |
  .metadata.name + ": " + (.subjects // [] | map(.name) | join(", "))
'

De eerste keer dat ik dit draaide op een cluster dat ik had geërfd, was de lijst langer dan ik hier wil opschrijven.

Elke binding in een namespace

kubectl get rolebindings -n production -o yaml

Afdwingen met Kyverno

Auditen vertelt je waar je vandaag staat. Het houdt niet tegen dat de volgende persoon volgende week een pod op het default account shipt. Daarvoor moeten de regels bij admission gecheckt worden, niet in een wiki die niemand leest. Daar gebruik ik Kyverno voor.

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-dedicated-service-account
spec:
  validationFailureAction: Enforce
  rules:
    - name: no-default-serviceaccount
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        message: "Gebruik van 'default' service account is niet toegestaan"
        pattern:
          spec:
            serviceAccountName: "!default"
---
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: disable-automount-by-default
spec:
  validationFailureAction: Enforce
  rules:
    - name: disable-automount
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        message: "automountServiceAccountToken moet expliciet op false staan tenzij nodig"
        pattern:
          spec:
            automountServiceAccountToken: false

Een deployment die deze instellingen vergeet wordt nu afgewezen voordat hij wordt ingepland. Ik hoef de regel niet te onthouden, en de volgende ook niet.

Geaggregeerde ClusterRoles

Staat de basis, dan heeft RBAC nog een scherper gereedschap dat het kennen waard is. Role-aggregatie stelt permissies samen uit gelabelde stukken.

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: monitoring-base
  labels:
    rbac.example.com/aggregate-to-monitoring: "true"
rules:
  - apiGroups: [""]
    resources: ["pods", "services"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: monitoring-extended
  labels:
    rbac.example.com/aggregate-to-monitoring: "true"
rules:
  - apiGroups: ["apps"]
    resources: ["deployments", "replicasets"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: monitoring
aggregationRule:
  clusterRoleSelectors:
    - matchLabels:
        rbac.example.com/aggregate-to-monitoring: "true"
rules: []  # Rules worden automatisch gevuld door aggregatie

Plak het juiste label op een nieuwe ClusterRole en hij vouwt vanzelf in het aggregaat. Handig als meerdere teams elk een stuk van één gedeelde role beheren.

Mensen

Alles tot nu toe ging over machines. Mensen hebben ook RBAC nodig, en in m’n ervaring vragen ze om meer dan ze nodig hebben. De ingebouwde view en edit ClusterRoles dekken het meeste.

Bind groepen, geen gebruikers

# Developers kunnen de meeste dingen bekijken in hun namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: developers-view
  namespace: development
subjects:
  - kind: Group
    name: developers
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: view  # Ingebouwde read-only role
  apiGroup: rbac.authorization.k8s.io
---
# Platform team heeft edit in alle namespaces
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: platform-team-edit
subjects:
  - kind: Group
    name: platform-team
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: edit  # Ingebouwde edit role
  apiGroup: rbac.authorization.k8s.io

Break-glass

# Noodtoegang die zwaar geaudit wordt
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: emergency-admin
  annotations:
    description: "Noodtoegang - al het gebruik wordt geaudit en gereviewed"
subjects:
  - kind: Group
    name: emergency-responders
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: cluster-admin
  apiGroup: rbac.authorization.k8s.io

Koppel deze binding aan auditlogging en een alert, zodat het hele team het weet zodra iemand naar cluster-admin grijpt. Zonder die alert is het een achterdeur die toevallig een YAML-bestand heeft.

Wat ik draai

Zet het bij elkaar en dit is hoe m’n clusters eruitzien. Het cluster waarmee ik opende had één binding, en die was cluster-admin. Dit is wat ervoor in de plaats kwam.

# 1. Schakel default service account tokens cluster-breed uit
apiVersion: v1
kind: ServiceAccount
metadata:
  name: default
  namespace: production
automountServiceAccountToken: false

# 2. Basis read-only role voor alle applicaties
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: app-base
rules:
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "watch"]

# 3. Applicatie-specifieke toevoegingen
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: api-server
  namespace: production
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["get"]
    resourceNames: ["api-credentials", "db-credentials"]

# 4. Gecombineerde binding
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: api-server
  namespace: production
subjects:
  - kind: ServiceAccount
    name: api-server
    namespace: production
roleRef:
  kind: Role
  name: api-server
  apiGroup: rbac.authorization.k8s.io

Niets heeft toegang tot ik het geef. Applicaties blijven binnen hun namespace. Secret-toegang staat vastgepind op benoemde secrets, en geen enkele regel bevat een wildcard.

Testen voordat je het vertrouwt

RBAC-wijzigingen gaan makkelijk subtiel fout, en een kapotte binding faalt dicht op het moment dat het jou het slechtst uitkomt. Dus ik zet nooit een role op productie zonder eerst te checken wat hij geeft.

# Dry-run: wat zou dit service account kunnen doen?
kubectl auth can-i --list \
  --as=system:serviceaccount:production:api-server

# Test specifieke permissie
kubectl auth can-i get secrets \
  --as=system:serviceaccount:production:api-server \
  -n production

# Test met impersonatie
kubectl get pods -n production \
  --as=system:serviceaccount:production:api-server

Waarom ik de moeite neem

Elke permissie die je uitdeelt is iets waar je vanaf nu over na moet denken. Elk account met te veel rechten is schade die op een slechte dag wacht.

Staat least privilege, dan blijft die slechte dag klein. Een overgenomen app raakt alleen aan wat jij hem laat aanraken. Een misconfiguratie breekt één namespace in plaats van het cluster. De auditlog betekent iets, omdat niet alles bij voorbaat mocht. En je weet uiteindelijk wat elk component nodig heeft, en dat ben ik op zichzelf gaan waarderen.

Dat bedoel ik met resilience door access control. Eén overname hoeft niet alle overnames te worden.

De trade-off is echt. Least privilege is meer werk vooraf dan één cluster-admin binding, en je loopt tegen een paar “waarom mag deze pod X niet” momenten aan voordat het model staat. Ik vind het elke keer de moeite waard, maar ga erin met de wetenschap dat je wat gemak ruilt voor een hoop containment.

Elk nieuw service account op m’n clusters begint met nul permissies en krijgt een regel pas als ik kan aanwijzen welke call hem nodig heeft. Af en toe struikel ik nog over een verb dat ontbreekt. Dat heb ik liever dan het cluster waarmee ik begon, waar alles alles kon weggooien.