When everything has cluster-admin, nothing is secure.
Kubernetes RBAC (Role-Based Access Control) exists to answer one question: who can do what to which resources? Most clusters answer incorrectly: “everyone can do everything.”
This isn’t just a security problem — it’s a resilience problem. When a service account gets compromised, how much damage can it do? When someone runs the wrong command, what’s the blast radius?
Least privilege limits that radius.
RBAC Building Blocks
Four resources define Kubernetes RBAC:
Role — Defines permissions within a namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: production
name: pod-reader
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
ClusterRole — Defines permissions cluster-wide
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: secret-reader
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list"]
RoleBinding — Grants Role permissions to subjects in a 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 — Grants ClusterRole permissions cluster-wide
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
The Problem: Default Permissions
Kubernetes defaults are permissive:
# What many deployments look like
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
# Default service account with broad permissions
serviceAccountName: default
# Token auto-mounted
automountServiceAccountToken: true
The default service account often has accumulated permissions over time. The auto-mounted token is available to every process in the container.
If an attacker compromises your application:
- They find the token at
/var/run/secrets/kubernetes.io/serviceaccount/token - They query the API server: what can I do?
- They pivot through whatever permissions exist
Principle 1: Dedicated Service Accounts
Never use the default service account for workloads:
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 # Don't mount unless needed
Each application gets its own service account. Permissions are granted to that specific account, not shared.
Principle 2: Disable Token Auto-Mount
Most applications don’t need Kubernetes API access:
apiVersion: v1
kind: ServiceAccount
metadata:
name: web-frontend
automountServiceAccountToken: false
---
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
serviceAccountName: web-frontend
automountServiceAccountToken: false # Belt and suspenders
If compromised, this workload can’t query the Kubernetes API at all — no token exists.
Principle 3: Namespace Isolation
Roles are namespace-scoped by default. Use this:
# Role only works 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"]
A compromised service in production can’t affect staging or other namespaces.
Avoid ClusterRoles unless genuinely needed. Most applications only need access to their own namespace.
Principle 4: Specific Resources
Grant access to specific resources, not entire API groups:
# Bad: access to all core resources
rules:
- apiGroups: [""]
resources: ["*"]
verbs: ["*"]
# Good: only specific resources needed
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get"]
resourceNames: ["app-config"] # Even more specific
The resourceNames field limits access to specific named resources.
Principle 5: Minimal Verbs
Grant only needed verbs:
| Verb | Meaning |
|---|---|
| get | Read single resource |
| list | Read multiple resources |
| watch | Stream changes |
| create | Create new resources |
| update | Replace existing |
| patch | Modify existing |
| delete | Remove resources |
| deletecollection | Remove multiple |
Read-only applications need only get, list, watch. Most don’t need delete or deletecollection.
# Monitoring only needs read access
rules:
- apiGroups: [""]
resources: ["pods", "services", "endpoints"]
verbs: ["get", "list", "watch"]
Common Patterns
Application That Reads ConfigMaps
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 That Manages Custom Resources
apiVersion: v1
kind: ServiceAccount
metadata:
name: myapp-operator
namespace: myapp-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: myapp-operator
rules:
# Own CRDs
- apiGroups: ["myapp.example.com"]
resources: ["myapps", "myapps/status"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
# Resources it creates
- 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
Note: The ServiceAccount is in ci-cd namespace, but the RoleBinding grants access to production namespace.
Auditing Current Permissions
Check What a ServiceAccount Can Do
kubectl auth can-i --list --as=system:serviceaccount:production:api-server
# Output:
# Resources Non-Resource URLs Verbs
# configmaps [] [get watch]
# pods [] [get list watch]
Find Over-Privileged Accounts
# Find all ClusterRoleBindings to cluster-admin
kubectl get clusterrolebindings -o json | jq -r '
.items[] |
select(.roleRef.name == "cluster-admin") |
.metadata.name + ": " + (.subjects // [] | map(.name) | join(", "))
'
List All RoleBindings in a Namespace
kubectl get rolebindings -n production -o yaml
Enforcing Least Privilege with Kyverno
Use Kyverno to enforce RBAC best practices:
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: "Using 'default' service account is not allowed"
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 must be explicitly set to false unless needed"
pattern:
spec:
automountServiceAccountToken: false
Aggregated ClusterRoles
Kubernetes supports role aggregation for modular permissions:
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 are automatically filled by aggregation
This lets you build roles from composable pieces.
Human Access Patterns
Group-Based Access
# Developers can view most things in their 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 # Built-in read-only role
apiGroup: rbac.authorization.k8s.io
---
# Platform team has edit in all 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 # Built-in edit role
apiGroup: rbac.authorization.k8s.io
Emergency Break-Glass
# Emergency access that's audited heavily
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: emergency-admin
annotations:
description: "Emergency access - all usage is audited and reviewed"
subjects:
- kind: Group
name: emergency-responders
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: cluster-admin
apiGroup: rbac.authorization.k8s.io
Combine with audit logging and alerting when this binding is used.
My Production RBAC Strategy
# 1. Disable default service account tokens cluster-wide
apiVersion: v1
kind: ServiceAccount
metadata:
name: default
namespace: production
automountServiceAccountToken: false
# 2. Base read-only role for all applications
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: app-base
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "watch"]
# 3. Application-specific additions
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. Combined 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
Key decisions:
- Default deny — Nothing has access until explicitly granted
- Namespace isolation — Applications can’t access other namespaces
- Named resources — Secrets access limited to specific secrets
- No wildcards — Every permission is explicit
Testing RBAC Changes
Before applying to production:
# Dry-run: what would this service account be able to do?
kubectl auth can-i --list \
--as=system:serviceaccount:production:api-server
# Test specific permission
kubectl auth can-i get secrets \
--as=system:serviceaccount:production:api-server \
-n production
# Test with impersonation
kubectl get pods -n production \
--as=system:serviceaccount:production:api-server
Why This Matters
Every permission is an attack surface. Every over-privileged account is a potential blast radius.
When you implement least privilege:
- Compromised applications can only damage what they’re allowed to touch
- Misconfigurations have bounded impact
- Audit logs are meaningful (not everything allowed)
- You understand what each component actually needs
This is resilience through access control. Not preventing compromise, but ensuring it doesn’t cascade.
The question isn’t “what permissions might this application need?” It’s “what’s the minimum required for this application to function?” Start with nothing, add only what’s proven necessary.
