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:
- Ze vinden de token op
/var/run/secrets/kubernetes.io/serviceaccount/token - Ze vragen de API server wat ze kunnen doen
- 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:
| 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 |
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.
