DevOpsIndex

Kubernetes RBAC


Auth Chain: Every Request to the API Server

graph LR
    classDef request fill:#e74c3c,stroke:#c0392b,color:#fff
    classDef authn fill:#3498db,stroke:#2980b9,color:#fff
    classDef authz fill:#9b59b6,stroke:#8e44ad,color:#fff
    classDef admission fill:#e67e22,stroke:#d35400,color:#fff
    classDef ok fill:#2ecc71,stroke:#27ae60,color:#fff
    classDef deny fill:#c0392b,stroke:#922b21,color:#fff

    REQ["API Request kubectl get pods Pod calling K8s API"]:::request

    REQ --> AUTHN["Authentication Who are you? client cert, bearer token, OIDC (EKS IAM), ServiceAccount token"]:::authn
    AUTHN -->|"identity established"| AUTHZ["Authorization (RBAC) Are you allowed? check Role/ClusterRole bindings"]:::authz
    AUTHN -->|"unknown identity"| DENY1["401 Unauthorized"]:::deny

    AUTHZ -->|"allowed"| ADMISSION["Admission Controllers Mutate then Validate LimitRanger, PodSecurity, Webhooks"]:::admission
    AUTHZ -->|"no matching rule"| DENY2["403 Forbidden"]:::deny

    ADMISSION -->|"passes all webhooks"| PERSIST["Persist to etcd 200 OK"]:::ok
    ADMISSION -->|"webhook rejects"| DENY3["400/403 from webhook"]:::deny

RBAC Building Blocks

graph TD
    classDef sa fill:#3498db,stroke:#2980b9,color:#fff
    classDef role fill:#9b59b6,stroke:#8e44ad,color:#fff
    classDef binding fill:#e67e22,stroke:#d35400,color:#fff
    classDef action fill:#2ecc71,stroke:#27ae60,color:#fff
    classDef clusterscope fill:#1abc9c,stroke:#16a085,color:#fff

    subgraph Identities["Who (Subject)"]
        SA["ServiceAccount my-app namespace: payments"]:::sa
        USER["User deepanshu (IAM/OIDC)"]:::sa
        GROUP["Group system:masters"]:::sa
    end

    subgraph Permissions["What (Rules)"]
        ROLE["Role namespace-scoped verbs: get,list,watch resources: pods,services"]:::role
        CR["ClusterRole cluster-scoped can reference any namespace or cluster resources like nodes"]:::clusterscope
    end

    subgraph Bindings["Link (Binding)"]
        RB["RoleBinding binds Role or ClusterRole to subjects in ONE namespace"]:::binding
        CRB["ClusterRoleBinding binds ClusterRole to subjects CLUSTER-WIDE"]:::binding
    end

    SA --> RB
    USER --> RB
    ROLE --> RB
    CR --> RB
    CR --> CRB
    SA --> CRB

    RB --> ACTION["Can get/list/watch pods in the payments namespace"]:::action
    CRB --> ACTION2["Can get/list/watch pods in ALL namespaces"]:::action

ServiceAccount

A ServiceAccount is a Kubernetes identity for pods. Every pod runs as a ServiceAccount. If you don't specify one, it runs as the default ServiceAccount in its namespace.

graph TD
    classDef sa fill:#3498db,stroke:#2980b9,color:#fff
    classDef secret fill:#e74c3c,stroke:#c0392b,color:#fff
    classDef pod fill:#2ecc71,stroke:#27ae60,color:#fff
    classDef api fill:#9b59b6,stroke:#8e44ad,color:#fff

    SA["ServiceAccount: my-app namespace: payments"]:::sa
    TOKEN["Projected ServiceAccount Token /var/run/secrets/kubernetes.io/serviceaccount/token auto-mounted into pod expires every 1h (rotated by kubelet)"]:::secret
    POD["Pod: my-app uses ServiceAccount: my-app token auto-mounted"]:::pod
    API["K8s API Server validates token identity: system:serviceaccount:payments:my-app"]:::api

    SA --> TOKEN
    TOKEN --> POD
    POD -->|"Bearer token in API calls"| API
apiVersion: v1
kind: ServiceAccount
metadata:
  name: my-app
  namespace: payments
  annotations:
    # IRSA: also allows pod to assume AWS IAM role
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789:role/my-app-s3-role
automountServiceAccountToken: true   # default — set false if pod doesn't call K8s API

Role and ClusterRole

# Role — namespaced (only works within payments namespace)
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: pod-reader
  namespace: payments
rules:
  - apiGroups: [""]            # "" = core API group (pods, services, configmaps)
    resources: ["pods", "pods/log"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["apps"]        # apps group (deployments, replicasets)
    resources: ["deployments"]
    verbs: ["get", "list"]
---
# ClusterRole — cluster-scoped (works across all namespaces or for cluster resources)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: node-reader
rules:
  - apiGroups: [""]
    resources: ["nodes"]       # nodes are cluster-scoped, need ClusterRole
    verbs: ["get", "list", "watch"]
  - apiGroups: ["metrics.k8s.io"]
    resources: ["nodes", "pods"]
    verbs: ["get", "list"]

Verbs reference:

Verb HTTP Description
get GET + name Get a specific resource
list GET (collection) List resources
watch GET + watch=true Stream updates
create POST Create resource
update PUT Replace resource
patch PATCH Partial update
delete DELETE Delete resource
deletecollection DELETE (collection) Delete many
* all Wildcard — all verbs

RoleBinding and ClusterRoleBinding

# RoleBinding: grant Role to ServiceAccount IN one namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: my-app-pod-reader
  namespace: payments
subjects:
  - kind: ServiceAccount
    name: my-app
    namespace: payments
  - kind: User             # also works for users
    name: deepanshu
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: pod-reader
  apiGroup: rbac.authorization.k8s.io
---
# ClusterRoleBinding: grant ClusterRole across ALL namespaces
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: monitoring-cluster-reader
subjects:
  - kind: ServiceAccount
    name: prometheus
    namespace: monitoring
roleRef:
  kind: ClusterRole
  name: cluster-reader
  apiGroup: rbac.authorization.k8s.io

Trick: ClusterRole + RoleBinding — you can bind a ClusterRole via a RoleBinding to scope it to one namespace. Useful for reusable role definitions:

# Define once as ClusterRole
kind: ClusterRole
metadata:
  name: app-role    # reusable template

---
# Bind in namespace A
kind: RoleBinding
metadata:
  namespace: payments    # scoped to payments only
roleRef:
  kind: ClusterRole
  name: app-role

---
# Bind in namespace B with same ClusterRole
kind: RoleBinding
metadata:
  namespace: orders      # scoped to orders only
roleRef:
  kind: ClusterRole
  name: app-role

RBAC Patterns

graph TD
    classDef good fill:#2ecc71,stroke:#27ae60,color:#fff
    classDef bad fill:#e74c3c,stroke:#c0392b,color:#fff
    classDef sa fill:#3498db,stroke:#2980b9,color:#fff

    subgraph Good["Good Patterns"]
        G1["Dedicated ServiceAccount per app not sharing default SA"]:::good
        G2["Narrow verbs: get,list,watch not wildcard *"]:::good
        G3["Namespace-scoped Role when possible not ClusterRole unless needed"]:::good
        G4["automountServiceAccountToken: false for pods that don't call K8s API"]:::good
    end

    subgraph Bad["Anti-Patterns"]
        B1["Using default ServiceAccount all apps in namespace share same identity"]:::bad
        B2["ClusterRoleBinding to system:masters grants cluster-admin to everyone"]:::bad
        B3["Wildcard resources: * wildcard verbs: * = full cluster access"]:::bad
    end

Debug RBAC issues:

# Check what a ServiceAccount can do
kubectl auth can-i get pods \
  --as=system:serviceaccount:payments:my-app \
  -n payments

# List all permissions for a ServiceAccount
kubectl auth can-i --list \
  --as=system:serviceaccount:payments:my-app \
  -n payments

# Describe a RoleBinding to see who has what
kubectl describe rolebinding my-app-pod-reader -n payments

# Check if a specific action is allowed
kubectl auth can-i create deployments --as=system:serviceaccount:payments:my-app -n payments

Check if a specific action is allowed

kubectl auth can-i create deployments --as=system:serviceaccount:payments:my-app -n payments

Who has cluster-admin? (critical audit check)

kubectl get clusterrolebindings -o json |
jq '.items[] | select(.roleRef.name=="cluster-admin") | {name: .metadata.name, subjects: .subjects}'


---

## RBAC — Advanced Patterns and EKS

### kubectl auth reconcile

`kubectl apply` on RBAC objects can produce conflicts if a ClusterRole already exists with different rules. `kubectl auth reconcile` is the safe way to apply RBAC — it adds missing rules without removing existing ones:

```bash
# Apply RBAC idempotently (safe for GitOps pipelines)
kubectl auth reconcile -f rbac-manifests/

# Dry-run first
kubectl auth reconcile -f rbac-manifests/ --dry-run=client

Aggregated ClusterRoles

ClusterRoles can be composed from smaller roles using aggregationRule. Any ClusterRole with a matching label automatically has its rules merged in. Used by Kubernetes to build view, edit, admin roles from extension API groups:

# Base aggregated role — collects rules from labeled sub-roles
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: custom-platform-role
aggregationRule:
  clusterRoleSelectors:
    - matchLabels:
        rbac.example.com/aggregate-to-platform: "true"
rules: []  # auto-populated from matching ClusterRoles

---
# Sub-role that gets merged in automatically
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: platform-secrets-reader
  labels:
    rbac.example.com/aggregate-to-platform: "true"  # triggers aggregation
rules:
  - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["get", "list"]

Token projection and bound service account tokens

Modern K8s (1.21+) uses projected tokens — short-lived (1h default), audience-bound, and tied to a specific pod. They replace the old static Secrets-based tokens.

# Explicitly configure projected token (usually auto-injected, but here for clarity)
spec:
  volumes:
    - name: token
      projected:
        sources:
          - serviceAccountToken:
              path: token
              expirationSeconds: 3600      # 1 hour
              audience: "https://kubernetes.default.svc"
  containers:
    - name: app
      volumeMounts:
        - name: token
          mountPath: /var/run/secrets/kubernetes.io/serviceaccount
# Decode the token to inspect claims
kubectl exec <pod> -- cat /var/run/secrets/kubernetes.io/serviceaccount/token | \
  cut -d. -f2 | base64 -d 2>/dev/null | jq .
# {
#   "aud": ["https://kubernetes.default.svc"],
#   "exp": 1700000000,
#   "iat": 1699996400,
#   "iss": "https://kubernetes.default.svc",
#   "kubernetes.io": {
#     "namespace": "payments",
#     "pod": { "name": "my-app-xxx", "uid": "..." },
#     "serviceaccount": { "name": "my-app", "uid": "..." }
#   },
#   "sub": "system:serviceaccount:payments:my-app"
# }

EKS — aws-auth ConfigMap (legacy) vs Access Entries (current)

EKS authenticates using IAM. The mapping from IAM identity → Kubernetes username/groups is configured two ways:

aws-auth ConfigMap (EKS < 1.30, legacy):

# kubectl edit configmap aws-auth -n kube-system
apiVersion: v1
kind: ConfigMap
metadata:
  name: aws-auth
  namespace: kube-system
data:
  mapRoles: |
    # Worker node IAM role → system:nodes group (required for nodes to join)
    - rolearn: arn:aws:iam::123456789:role/eks-node-role
      username: system:node:{{EC2PrivateDNSName}}
      groups:
        - system:bootstrappers
        - system:nodes
    # Human IAM role → Kubernetes RBAC group
    - rolearn: arn:aws:iam::123456789:role/devops-team
      username: devops-{{SessionName}}
      groups:
        - platform-admins   # bind this group to a ClusterRole in RBAC
  mapUsers: |
    # Specific IAM user (avoid when possible — use roles)
    - userarn: arn:aws:iam::123456789:user/alice
      username: alice
      groups:
        - developers

Access Entries (EKS 1.30+, recommended):

# Create an access entry (replaces aws-auth ConfigMap rows)
aws eks create-access-entry \
  --cluster-name my-cluster \
  --principal-arn arn:aws:iam::123456789:role/devops-team \
  --type STANDARD \
  --kubernetes-groups platform-admins

# Associate with an access policy (AWS-managed or custom)
aws eks associate-access-policy \
  --cluster-name my-cluster \
  --principal-arn arn:aws:iam::123456789:role/devops-team \
  --policy-arn arn:aws:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy \
  --access-scope '{"type": "cluster"}'

# List all access entries
aws eks list-access-entries --cluster-name my-cluster

Access Entries survive aws-auth ConfigMap corruption (a common incident that locks everyone out of the cluster).

IRSA — IAM Roles for Service Accounts

IRSA lets a Kubernetes ServiceAccount assume an AWS IAM role without static credentials. The pod gets a projected token, exchanges it at the STS OIDC endpoint, and receives temporary AWS credentials.

# 1. Create OIDC provider for your EKS cluster (one-time per cluster)
eksctl utils associate-iam-oidc-provider \
  --cluster my-cluster --approve

# 2. Create IAM role with trust policy scoped to specific ServiceAccount
OIDC_ID=$(aws eks describe-cluster --name my-cluster \
  --query "cluster.identity.oidc.issuer" --output text | cut -d/ -f5)

aws iam create-role \
  --role-name my-app-s3-role \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": {"Federated": "arn:aws:iam::123456789:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/'"$OIDC_ID"'"},
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "oidc.eks.us-east-1.amazonaws.com/id/'"$OIDC_ID"':aud": "sts.amazonaws.com",
          "oidc.eks.us-east-1.amazonaws.com/id/'"$OIDC_ID"':sub": "system:serviceaccount:payments:my-app"
        }
      }
    }]
  }'
# 3. Annotate the ServiceAccount
apiVersion: v1
kind: ServiceAccount
metadata:
  name: my-app
  namespace: payments
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789:role/my-app-s3-role
    eks.amazonaws.com/token-expiration: "3600"  # optional: shorter token lifetime
# 4. Verify — pod should have these env vars injected by the EKS pod identity webhook:
kubectl exec <pod> -- env | grep AWS
# AWS_ROLE_ARN=arn:aws:iam::123456789:role/my-app-s3-role
# AWS_WEB_IDENTITY_TOKEN_FILE=/var/run/secrets/eks.amazonaws.com/serviceaccount/token
# AWS_DEFAULT_REGION=us-east-1

# If missing: check the pod admission mutation webhook is running
kubectl get mutatingwebhookconfigurations | grep pod-identity

Common RBAC misconfigurations

Misconfiguration Risk Detection
default SA used for all pods Blast radius: any compromised pod has the same identity kubectl get rolebindings,clusterrolebindings -A -o json | jq '.items[].subjects[] | select(.name=="default")'
cluster-admin for CI/CD SA CI pipeline compromise = full cluster takeover kubectl get clusterrolebindings -o json | jq ... | select(.roleRef.name=="cluster-admin")'
automountServiceAccountToken: true on non-API pods Token in every pod, even ones that don't need K8s API Set false on Deployment spec, override on SA
Wildcard verb+resource verbs: ["*"] resources: ["*"] = cluster-admin equivalent Audit: kubectl get roles,clusterroles -A -o yaml | grep '"*"'
RoleBinding to system:authenticated Every authenticated user (including service accounts) gets the role Audit ClusterRoleBindings for system:authenticated subject
Stale bindings after team changes Former employees' IAM roles still mapped in aws-auth Review aws-auth ConfigMap quarterly

RBAC audit one-liners

# Find all cluster-admin bindings
kubectl get clusterrolebindings -o json | \
  jq -r '.items[] | select(.roleRef.name=="cluster-admin") |
  "\(.metadata.name): \(.subjects // [] | map(.name) | join(", "))"'

# Find all ServiceAccounts with cluster-wide permissions
kubectl get clusterrolebindings -o json | \
  jq -r '.items[] | .subjects[]? | select(.kind=="ServiceAccount") |
  "\(.namespace)/\(.name)"' | sort -u

# What can a given ServiceAccount do across all namespaces?
for ns in $(kubectl get ns -o jsonpath='{.items[*].metadata.name}'); do
  kubectl auth can-i --list \
    --as=system:serviceaccount:payments:my-app \
    -n $ns 2>/dev/null | grep -v "^Resources" | grep -v "^\*"
done