Het eerste cluster dat ik echt in productie draaide had precies één permissiemodel: alles was cluster-admin. Mijn CI pipeline, mijn monitoring stack, het kleine webhook-ontvangertje dat ik op een middag in elkaar zette. Allemaal konden ze elk secret lezen, elke deployment verwijderen en elke namespace aanraken. Het werkte prima, tot ik begon na te denken 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? Het standaardantwoord op de meeste clusters is “iedereen kan alles doen,” en dat antwoord wordt stilletjes je grootste aansprakelijkheid.

Dit interesseert me om dezelfde reden waarom ik de rest van mijn stack wil begrijpen. Wanneer een service account wordt gecompromitteerd, wil ik precies weten hoe ver de schade kan reiken voordat het zover komt. Wanneer ik om 11 uur ’s avonds een commando verkeerd typ, wil ik dat de blast radius klein genoeg is om door te kunnen slapen. Least privilege is hoe je een plafond legt op allebei.

Deze post bouwt op van de vier RBAC-primitieven naar de strategie die ik echt in productie draai. Je kunt bij elke laag stoppen en toch iets bruikbaars meenemen.

De bouwstenen

Vier resources definiëren Kubernetes RBAC. Twee beschrijven permissies, twee geven die permissies aan iemand.

Role - Definieert permissies binnen een namespace

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

ClusterRole - Definieert permissies cluster-breed

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

RoleBinding - Verleent Role permissies aan subjects in een namespace

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

ClusterRoleBinding - Verleent ClusterRole permissies cluster-breed

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. Role plus Binding, scoped naar een namespace of naar het hele cluster. Al het andere in deze post is gewoon discipline in hoe je die vier gebruikt.

Het probleem: waarom de defaults bijten

Voordat we bij de discipline komen, helpt het om te zien waarom het standaardgedrag het ding is dat je bijt. Kubernetes defaults zijn permissief:

# 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 in de loop der tijd, meestal omdat iemand er ooit een ClusterRole aan bond om iets werkend te krijgen en dat nooit terugdraaide. Die auto-gemounte token zit in elk proces in de container te wachten.

Dus wanneer een aanvaller een shell in je app krijgt, is de keten kort en saai:

  1. Ze vinden de token op /var/run/secrets/kubernetes.io/serviceaccount/token
  2. Ze vragen de API server wat ze kunnen doen
  3. Ze pivoten door welke permissies er toevallig bestaan

De fix is gelaagd. Elk van de volgende vijf principes is een stoppunt. Pas er één toe en je bent al beter af dan de defaults. Pas alle vijf toe en een gecompromitteerde pod is de moeite van de aanvaller nauwelijks waard.

Laag 1: Dedicated service accounts

Begin hier. Draai een workload nooit als 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 zijn eigen service account. Permissies landen op dat specifieke account, nooit gedeeld met de rest van de namespace. Op het moment dat je dit doet, beginnen je audit logs je te vertellen wie er daadwerkelijk iets deed in plaats van een muur van default.

Laag 2: Token auto-mount uitschakelen

Dit is het deel dat de meeste mensen overslaan. Het gros van mijn workloads praat helemaal nooit met de Kubernetes API. Een web frontend hoeft geen pods op te lijsten. Dus waarom zou je het 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 een shell in deze workload en er valt niets te stelen. Geen token op schijf betekent geen API toegang, punt uit. Deze ene instelling doodt het meest voorkomende pivotpad dat ik hierboven beschreef, en het kost je niets voor de apps die echt geen cluster toegang nodig hebben.

Laag 3: Namespace isolatie

Voor de workloads die wél API toegang nodig hebben, is de volgende vraag hoe ver die toegang reikt. Roles zijn standaard namespace-scoped, en dat is een geschenk. 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 gecompromitteerde service in production kan staging of wat dan ook niet aanraken. De blast radius stopt bij de namespace-grens, precies het soort nette containment dat ik wil wanneer er iets misgaat.

Grijp alleen naar ClusterRoles wanneer je echt cluster-brede reikwijdte nodig hebt. De meeste apps leven en sterven binnen één namespace, dus houd ze daar.

Laag 4: Specifieke resources

Namespace-scope begrenst hoe breed de toegang gaat. Deze laag begrenst hoe diep. Verleen toegang tot benoemde resources, niet 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 resourceNames veld is hier de ondergewaardeerde held. Het pint toegang vast op specifieke benoemde objecten, zodat een app die één ConfigMap nodig heeft die ene ConfigMap kan lezen en verder niets.

Laag 5: Minimale verbs

Laatste laag, en de goedkoopste winst. Je hebt bepaald welke resources een account mag aanraken. Bepaal nu wat het ermee mag doen. Verleen alleen de verbs die je daadwerkelijk gebruikt:

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

Read-only applicaties hebben get, list, watch nodig en niets meer. Een monitoring-agent heeft niks te zoeken met delete of deletecollection, dus geef het die optie niet.

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

De lagen samenvoegen: echte patronen

Vijf principes zijn makkelijk om mee te knikken en lastig om toe te passen onder deadline. Dus hier zijn de patronen die ik het vaakst kopieer-plak, elk al voorzien van alle vijf de lagen.

Applicatie die ConfigMaps 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

Operator die 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

CI/CD pipeline service account

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, maar de RoleBinding zit in production en geeft het precies genoeg om daar te deployen. De pipeline kan naar prod shippen zonder ergens admin te zijn.

Auditen wat je al hebt

Patronen helpen bij nieuwe workloads. De lastigere vraag is wat er al draait met te veel macht. Je kunt een permissiemodel dat je niet kunt inspecteren niet vertrouwen, dus maak de inspectie routine.

Check wat een ServiceAccount kan doen

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

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

Vind over-privileged accounts

# 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(", "))
'

Lijst alle RoleBindings in een namespace

kubectl get rolebindings -n production -o yaml

De regels laten plakken met Kyverno

Auditen vertelt je waar je vandaag staat. Het doet niets om te voorkomen dat de volgende persoon volgende week een pod op het default account shipt. Daarvoor moeten de regels worden afgedwongen op admission-tijd, niet in een wiki die niemand leest. Ik gebruik Kyverno om deze principes in policy om te zetten:

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

Nu wordt een deployment die deze instellingen vergeet afgewezen voordat het ooit ingepland wordt. De policy draagt het institutionele geheugen, zodat ik dat niet hoef.

Verder gaan: geaggregeerde ClusterRoles

Zodra de basis staat, heeft RBAC een paar scherpere tools die de moeite waard zijn om te kennen. Role aggregatie laat je permissies samenstellen 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 het vouwt automatisch in het aggregaat. Handig wanneer meerdere teams elk een deel van een gedeelde role bezitten.

Menselijke toegangspatronen

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

Groep-gebaseerde toegang

# 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

Emergency 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 audit logging en een alert, zodat het hele team het weet zodra iemand in een noodgeval naar cluster-admin grijpt. Break-glass toegang die je niet kunt zien is gewoon een achterdeur.

Het volledige plaatje: mijn productiestrategie

Voeg het allemaal samen en dit is de vorm van wat ik echt draai. Weet je nog dat cluster waarmee ik opende, waar alles admin was? Dit is het tegenovergestelde daarvan, laag voor laag.

# 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

Belangrijke beslissingen:

  • Default deny - Niets heeft toegang totdat ik het expliciet verleen
  • Namespace isolatie - Applicaties kunnen niet in andere namespaces reiken
  • Benoemde resources - Secret-toegang vastgepind op specifieke secrets
  • Geen wildcards - Elke permissie staat uitgeschreven

Testen voordat je het vertrouwt

RBAC-wijzigingen zijn makkelijk subtiel fout te krijgen, en een kapotte binding faalt dicht op manieren die je op het slechtste moment pagen. Dus ik pas nooit een role toe op productie zonder eerst te checken wat het verleent:

# 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 dit ertoe doet

Elke permissie die je uitdeelt is aanvalsoppervlak waar je nu over moet nadenken. Elk over-privileged account is een blast radius die op een slechte dag wacht.

Wanneer least privilege op zijn plaats staat, blijft die slechte dag klein. Een gecompromitteerde app kan alleen aanraken wat je het laat aanraken. Een misconfiguratie heeft een begrensde impact in plaats van een cluster-brede. Je audit logs betekenen iets, omdat niet alles in de eerste plaats was toegestaan. En je begrijpt uiteindelijk wat elk component echt nodig heeft, wat zijn eigen stille beloning is.

Dit is resilience door access control. Je probeert niet elk compromitteren te stoppen, je zorgt dat er één niet cascadeert in allemaal.

De trade-off is echt, en ik doe niet alsof dat niet zo is. Least privilege is meer voorbereidend werk dan één enkele cluster-admin binding, en je loopt tegen een paar “waarom kan deze pod X niet” momenten aan voordat het model is uitgekristalliseerd. Ik vind het elke keer de moeite waard, maar ga erin met de wetenschap dat je een beetje gemak ruilt voor veel containment.

Eén gewoonte zorgt dat de rest vanzelf op zijn plaats valt: begin elk nieuw account met nul permissies en voeg alleen toe wat je kunt bewijzen dat het gebruikt. Begin vanuit niets, en least privilege stopt een project te zijn en wordt de default.