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.
| Verb | Betekenis |
|---|---|
| get | Lees enkele resource |
| list | Lees meerdere resources |
| watch | Stream wijzigingen |
| create | Maak nieuwe resources |
| update | Vervang bestaande |
| patch | Wijzig bestaande |
| delete | Verwijder resources |
| deletecollection | Verwijder 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.
