DevOpsil
Security
90%
Fresh
Part 2 of 6 in Security Hardening

Kubernetes RBAC: A Practical Guide to Least-Privilege Access Control

Amara OkaforAmara Okafor9 min read

The Breach That Starts With cluster-admin

In 2023, a compromised CI/CD service account with cluster-admin privileges led to a full cluster takeover at a mid-sized fintech company. The attacker didn't exploit a zero-day. They didn't bypass a firewall. They simply used the permissions they were given — permissions that were far too broad for the workload.

I've audited enough clusters to know this isn't the exception. It's the norm. Most Kubernetes environments hand out cluster-admin like candy, and the teams running them don't realize the blast radius until it's too late.

RBAC isn't optional. It's your primary access control layer inside the cluster. If you're not implementing least-privilege, you're one compromised pod away from a full breach.

Understanding the RBAC Model

Kubernetes RBAC operates on four core objects:

ObjectScopePurpose
RoleNamespaceDefines permissions within a single namespace
ClusterRoleCluster-wideDefines permissions across the entire cluster
RoleBindingNamespaceBinds a Role/ClusterRole to subjects in a namespace
ClusterRoleBindingCluster-wideBinds a ClusterRole to subjects cluster-wide

The key principle: always prefer Role + RoleBinding over ClusterRole + ClusterRoleBinding. Namespace-scoped permissions contain the blast radius of a compromised identity.

Audit Your Current RBAC State First

Before you lock anything down, you need to understand what exists. Run this to find every ClusterRoleBinding that grants cluster-admin:

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

Now check for overly permissive roles using wildcard verbs or resources:

kubectl get clusterroles -o json | \
  jq -r '.items[] | select(.rules[]? |
  (.verbs[]? == "*") or (.resources[]? == "*")) |
  .metadata.name'

If the output of either command is longer than three or four lines, you have a problem. Every entry in that list is a potential escalation path.

Designing Least-Privilege Roles

Here is a Role that gives a deployment pipeline exactly what it needs to deploy — and nothing more:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: ci-deployer
  namespace: production
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "patch", "update"]
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list"]
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list", "watch"]

Notice what's absent: no create on pods (the Deployment controller handles that), no access to secrets, no delete permissions. Your CI pipeline doesn't need to delete Deployments — it needs to update them.

Bind it to a ServiceAccount, not a user:

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ci-deployer-binding
  namespace: production
subjects:
  - kind: ServiceAccount
    name: ci-deployer
    namespace: ci-cd
roleRef:
  kind: Role
  name: ci-deployer
  apiGroup: rbac.authorization.k8s.io

Threat Model: Compromised ServiceAccount Token

Assume an attacker gains access to a ServiceAccount token — through a leaked kubeconfig, a compromised pod, or a misconfigured CI secret. Here's the threat model:

Without RBAC scoping: The attacker has cluster-admin. They can read all secrets (including TLS certs, database credentials, API keys), deploy cryptominers, pivot to cloud provider APIs via IMDS, and exfiltrate data from every namespace.

With proper RBAC: The attacker can list deployments in one namespace. That's it. The blast radius shrinks from "entire infrastructure" to "one namespace's deployment metadata."

Blocking Privilege Escalation

Kubernetes has a critical subtlety: anyone who can create or update a RoleBinding can grant themselves arbitrary permissions up to and including cluster-admin. You must explicitly prevent this.

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: restricted-admin
rules:
  - apiGroups: ["apps", "batch"]
    resources: ["deployments", "statefulsets", "jobs", "cronjobs"]
    verbs: ["*"]
  - apiGroups: [""]
    resources: ["services", "configmaps", "pods", "pods/log"]
    verbs: ["*"]
  # Explicitly NO access to:
  # - rbac.authorization.k8s.io (Roles, RoleBindings)
  # - "" secrets
  # - nodes
  # - persistentvolumes (cluster-scoped)

Also deny the escalate and bind verbs. If a user can bind a ClusterRole, they can attach cluster-admin to their own ServiceAccount:

# This is what you do NOT want to see:
rules:
  - apiGroups: ["rbac.authorization.k8s.io"]
    resources: ["clusterroles"]
    verbs: ["bind", "escalate"]  # NEVER grant these

Integrate RBAC Validation Into Your Pipeline

Manual RBAC reviews don't scale. Put validation in CI. Here's a GitHub Actions step using conftest with OPA policies:

# .github/workflows/rbac-check.yml
name: RBAC Policy Check
on:
  pull_request:
    paths: ["k8s/rbac/**"]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install conftest
        run: |
          wget -q https://github.com/open-policy-agent/conftest/releases/download/v0.50.0/conftest_0.50.0_linux_amd64.tar.gz
          tar xzf conftest_0.50.0_linux_amd64.tar.gz
          sudo mv conftest /usr/local/bin/
      - name: Validate RBAC manifests
        run: conftest test k8s/rbac/ -p policy/rbac/

And the OPA policy to catch wildcard permissions:

# policy/rbac/deny_wildcards.rego
package rbac

deny[msg] {
  input.kind == "ClusterRole"
  rule := input.rules[_]
  rule.verbs[_] == "*"
  msg := sprintf("ClusterRole '%s' uses wildcard verbs — enumerate explicit permissions instead", [input.metadata.name])
}

deny[msg] {
  input.kind == "ClusterRole"
  rule := input.rules[_]
  rule.resources[_] == "*"
  msg := sprintf("ClusterRole '%s' uses wildcard resources — this violates least-privilege", [input.metadata.name])
}

deny[msg] {
  input.kind == "ClusterRoleBinding"
  input.roleRef.name == "cluster-admin"
  msg := sprintf("ClusterRoleBinding '%s' grants cluster-admin — this requires security team approval", [input.metadata.name])
}

Monitoring RBAC With Audit Logs

Enable the Kubernetes audit policy to log all RBAC-related actions:

apiVersion: audit.k8s.io/v1
kind: Policy
rules:
  - level: RequestResponse
    resources:
      - group: "rbac.authorization.k8s.io"
        resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]
  - level: Metadata
    verbs: ["create", "delete", "patch"]
    resources:
      - group: ""
        resources: ["secrets", "serviceaccounts"]

Forward these audit logs to your SIEM. Alert on any ClusterRoleBinding creation or modification. In a mature environment, those events should be rare — and when they happen, someone should be paged.

Quick Wins You Can Ship Today

  1. Find and remove all unnecessary cluster-admin bindings. Run the audit command above. Challenge every single one.
  2. Create namespace-scoped Roles for every CI/CD pipeline. No pipeline needs cluster-wide access.
  3. Block ServiceAccount token automounting on pods that don't need API access:
apiVersion: v1
kind: ServiceAccount
metadata:
  name: app-service-account
automountServiceAccountToken: false
  1. Set up short-lived credentials. Use TokenRequest API for time-bound tokens instead of long-lived secrets:
kubectl create token ci-deployer \
  --namespace ci-cd \
  --duration 15m
  1. Run kubectl auth can-i --list against every ServiceAccount in your cluster. Automate this as a weekly job and review the output.

Debugging RBAC Denials

When pods or users start getting Forbidden errors after you tighten RBAC, you need a systematic way to diagnose what's missing. Start with kubectl auth can-i:

# Check if a specific ServiceAccount can perform an action
kubectl auth can-i get deployments \
  --as=system:serviceaccount:ci-cd:ci-deployer \
  --namespace=production
# yes

kubectl auth can-i delete deployments \
  --as=system:serviceaccount:ci-cd:ci-deployer \
  --namespace=production
# no

# List everything a ServiceAccount can do in a namespace
kubectl auth can-i --list \
  --as=system:serviceaccount:ci-cd:ci-deployer \
  --namespace=production

When the output doesn't match what you expect, trace the binding chain. This script finds all Roles and ClusterRoles bound to a specific ServiceAccount:

#!/bin/bash
# scripts/trace-rbac.sh — Trace all RBAC bindings for a ServiceAccount
SA_NAME="${1:?Usage: trace-rbac.sh <sa-name> <sa-namespace>}"
SA_NAMESPACE="${2:?Usage: trace-rbac.sh <sa-name> <sa-namespace>}"

echo "=== RoleBindings in $SA_NAMESPACE ==="
kubectl get rolebindings -n "$SA_NAMESPACE" -o json | \
  jq -r --arg sa "$SA_NAME" --arg ns "$SA_NAMESPACE" \
  '.items[] | select(.subjects[]? |
    .kind == "ServiceAccount" and .name == $sa and .namespace == $ns) |
  "  \(.metadata.name) -> \(.roleRef.kind)/\(.roleRef.name)"'

echo ""
echo "=== ClusterRoleBindings ==="
kubectl get clusterrolebindings -o json | \
  jq -r --arg sa "$SA_NAME" --arg ns "$SA_NAMESPACE" \
  '.items[] | select(.subjects[]? |
    .kind == "ServiceAccount" and .name == $sa and .namespace == $ns) |
  "  \(.metadata.name) -> \(.roleRef.kind)/\(.roleRef.name)"'

Run it against your CI deployer:

./scripts/trace-rbac.sh ci-deployer ci-cd
# === RoleBindings in ci-cd ===
#   ci-deployer-binding -> Role/ci-deployer
# === ClusterRoleBindings ===
#   (none — this is what you want)

If you see unexpected ClusterRoleBindings, that's a red flag. A namespace-scoped ServiceAccount bound to a ClusterRole has broader access than the team probably intended.

Automating RBAC Audits With CronJobs

One-time audits are a start. Continuous auditing is what actually keeps your cluster secure. Deploy a CronJob that runs weekly, generates a report of all overly permissive bindings, and sends it to Slack or your ticketing system:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: rbac-audit
  namespace: security-tools
spec:
  schedule: "0 9 * * 1"  # Every Monday at 9 AM
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: rbac-auditor
          containers:
            - name: auditor
              image: bitnami/kubectl:1.30
              command: ["/bin/sh", "-c"]
              args:
                - |
                  echo "=== RBAC Audit Report $(date -I) ==="
                  echo ""
                  echo "--- cluster-admin bindings ---"
                  kubectl get clusterrolebindings -o json | \
                    jq -r '.items[] | select(.roleRef.name=="cluster-admin") |
                    "\(.metadata.name): \(.subjects)"'
                  echo ""
                  echo "--- Wildcard ClusterRoles ---"
                  kubectl get clusterroles -o json | \
                    jq -r '.items[] | select(.rules[]? |
                    (.verbs[]? == "*") or (.resources[]? == "*")) |
                    .metadata.name'
                  echo ""
                  echo "--- ServiceAccounts with automountToken enabled ---"
                  kubectl get serviceaccounts --all-namespaces -o json | \
                    jq -r '.items[] | select(.automountServiceAccountToken != false) |
                    "\(.metadata.namespace)/\(.metadata.name)"'
          restartPolicy: Never

The ServiceAccount running this CronJob needs read-only access to RBAC resources and ServiceAccounts cluster-wide. Create a dedicated ClusterRole for it:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: rbac-auditor
rules:
  - apiGroups: ["rbac.authorization.k8s.io"]
    resources: ["roles", "rolebindings", "clusterroles", "clusterrolebindings"]
    verbs: ["get", "list"]
  - apiGroups: [""]
    resources: ["serviceaccounts"]
    verbs: ["get", "list"]

Pipe the output to your alerting system. Any new cluster-admin binding that appears between runs should be investigated immediately.

The Bottom Line

Every RBAC permission you grant is an assumption that the identity holding it won't be compromised. The question isn't whether a ServiceAccount will be compromised — it's when. Least-privilege RBAC ensures that when that day comes, the attacker gets access to a narrow slice of your cluster instead of the entire thing.

Stop treating RBAC as a checkbox. Treat it as your most important security control inside the cluster perimeter. Because it is.

Share:
Amara Okafor
Amara Okafor

DevSecOps Lead

Security-first mindset in everything I ship. From zero-trust architectures to supply chain security, I make sure your pipeline doesn't become your weakest link.

Related Articles