Security

Scope RBAC for an AI Agent's Kubernetes Access

Intermediate45 min to complete9 min readAugust 22, 2026Updated August 26, 2026

Quick answer

Build an explicit, non-aggregated ClusterRole for an AI agent, issue it a short-lived audience-bound token, verify the ceiling in a scriptable check, and flip your audit policy so every read the agent makes is logged as the egress event it actually is.

intermediate · 45 min

Before you begin

  • A cluster you can use (kind or minikube is fine) and kubectl configured with cluster-admin
  • Basic familiarity with Kubernetes RBAC (Roles, ClusterRoles, bindings)
  • Comfort editing YAML
RBAC
Kubernetes
AI Agents
Security
Audit Logging
ServiceAccount

"Just give it read-only access" undersells what's actually different about an AI agent's credential. A human with view looks at a Secret and closes the terminal; an agent forwards whatever it reads to a model provider, where it's retained, possibly echoed back, and very likely captured in your tracing. The RBAC mechanics you already know still apply — this tutorial is about which grants are safe to hand an agent and how to prove the ceiling holds, not a general RBAC primer. If you need that first, start with Kubernetes RBAC in Practice.

What You'll Build

  • A namespace and ServiceAccount dedicated to one agent
  • An explicit, non-aggregated ClusterRole that excludes Secrets and ConfigMaps by default
  • A short-lived, audience-scoped token issued the way you'd issue one in CI, not kubectl config copy-paste
  • A scriptable kubectl auth can-i check that fails the build if the ceiling ever widens
  • An inverted audit policy that logs the agent's reads at full detail

Step 1: Create the Identity

bash
kubectl create namespace agents
kubectl create serviceaccount k8s-agent -n agents

One ServiceAccount per agent, not a shared one. If two agents share an identity, you can't tell from the audit log which one read what.

Step 2: Write an Explicit ClusterRole — Not view

view looks like the obvious grant and is the one to avoid here. view is aggregated: its rules are assembled at runtime from every ClusterRole labelled rbac.authorization.k8s.io/aggregate-to-view: "true". Any operator installed later can silently widen it — for every subject bound to view, with nothing in your Git history. Check what's currently feeding it in your own cluster:

bash
kubectl get clusterroles -l rbac.authorization.k8s.io/aggregate-to-view=true -o name

Write the role yourself instead, scoped to exactly what the agent's tools need:

yaml
1# agent-role.yaml
2apiVersion: rbac.authorization.k8s.io/v1
3kind: ClusterRole
4metadata:
5  name: agent-readonly
6rules:
7  - apiGroups: [""]
8    resources: ["pods", "events", "services", "nodes", "namespaces"]
9    verbs: ["get", "list"]
10  - apiGroups: [""]
11    resources: ["pods/log"]
12    verbs: ["get"]
13  - apiGroups: ["apps"]
14    resources: ["deployments", "replicasets", "statefulsets", "daemonsets"]
15    verbs: ["get", "list"]
16  - apiGroups: ["batch"]
17    resources: ["jobs", "cronjobs"]
18    verbs: ["get", "list"]

Two resources are deliberately missing: Secrets and ConfigMaps. Granting get on Secrets means copying every credential in scope into a context window that leaves the cluster. ConfigMaps are barely safer — in a real cluster they routinely hold connection strings and internal hostnames that should have been Secrets in the first place.

bash
kubectl apply -f agent-role.yaml

Step 3: Bind It — Namespaced, Not Cluster-Wide

yaml
1# agent-binding.yaml
2apiVersion: rbac.authorization.k8s.io/v1
3kind: RoleBinding
4metadata:
5  name: agent-readonly-binding
6  namespace: workloads
7subjects:
8  - kind: ServiceAccount
9    name: k8s-agent
10    namespace: agents
11roleRef:
12  kind: ClusterRole
13  name: agent-readonly
14  apiGroup: rbac.authorization.k8s.io
bash
kubectl create namespace workloads   # or point this at a real target namespace
kubectl apply -f agent-binding.yaml

A RoleBinding referencing a ClusterRole scopes the grant to one namespace, even though the role itself is cluster-scoped — that's what lets you reuse the same ClusterRole across many teams' namespaces without a ClusterRoleBinding. One catch: nodes and namespaces in the role above are themselves cluster-scoped resources, so this RoleBinding grants nothing for those two lines however the role is written. If the agent genuinely needs them, bind a separate ClusterRole with a ClusterRoleBinding deliberately — don't assume a namespaced binding covers cluster-scoped resources.

Step 4: Issue a Short-Lived Token

Don't hand out a long-lived kubeconfig. Mint a token that expires:

bash
kubectl create token k8s-agent -n agents --duration=15m

If the agent presents this token to something other than the API server — a proxy, a sidecar — bind a custom audience so a leaked token is useless outside that one context:

bash
kubectl create token k8s-agent -n agents --duration=15m --audience=kubernetes-agent

Leave --audience off when the token goes straight to the API server; kubectl create token already requests the right default audience for that case, and an explicit mismatched audience gets the token refused.

Step 5: Verify the Ceiling, Scriptably

The system prompt telling the model "you have read-only access" is a request. The control is that the credential physically cannot do more — and that's the thing worth testing, not the prompt wording.

bash
1#!/usr/bin/env bash
2# verify-agent-ceiling.sh
3set -euo pipefail
4
5SA=system:serviceaccount:agents:k8s-agent
6FAILED=0
7
8for check in "delete pods" "create pods --subresource=exec" "get secrets" \
9             "create serviceaccounts --subresource=token" "patch deployments"; do
10  if kubectl auth can-i $check --as=$SA -n workloads --quiet 2>/dev/null; then
11    echo "FAIL: agent can $check"
12    FAILED=1
13  fi
14done
15
16if [ "$FAILED" -eq 0 ]; then
17  echo "OK: agent ceiling holds"
18else
19  exit 1
20fi
bash
chmod +x verify-agent-ceiling.sh
./verify-agent-ceiling.sh

Run this after every RBAC change, and — because of the aggregation problem in Step 2 — after every operator install too. An unrelated Helm chart can move the view ceiling without touching a single one of your own manifests; it can't move this one, because this role isn't aggregated. Wire the script into CI so a widened grant fails the pipeline instead of getting noticed in an incident review.

Step 6: Invert the Audit Policy

Standard audit policy treats reads as cheap and logs them at Metadata or not at all. For an agent, the read is the event — it's the moment data left the cluster toward a model provider.

yaml
1# audit-policy.yaml
2apiVersion: audit.k8s.io/v1
3kind: Policy
4rules:
5  # Expected agent behaviour — log it fully. This is the egress record.
6  - level: RequestResponse
7    users: ["system:serviceaccount:agents:k8s-agent"]
8    verbs: ["get", "list"]
9    resources:
10      - group: ""
11        resources: ["pods", "pods/log", "events"]
12
13  # Anything else from this identity is, by definition, unexpected.
14  - level: RequestResponse
15    users: ["system:serviceaccount:agents:k8s-agent"]
16
17  # Catch-all. Without this, every identity that isn't the agent stops
18  # being audited at all — see the warning below.
19  - level: Metadata

These are two rules to add to your policy, not a policy to drop in whole. Audit rules are first-match-wins, and a request matching no rule is not logged. Both agent rules are scoped to users: [...], so a file containing only them audits the agent and nothing else — replace a running cluster's policy with that and you silently stop recording every human, controller, and other ServiceAccount on the cluster. Merge the two agent rules into the top of your existing policy, above its own catch-all, or keep the trailing - level: Metadata above so the rest of the cluster stays covered at its previous level.

Wire this into your API server's --audit-policy-file (managed clusters expose this differently — EKS via control plane logging config, GKE via Cloud Audit Logs). The second rule is the one that earns its place: the agent should only ever perform the reads in rule one, so anything else it attempts — including a 403 from a prompt-injection attempt — becomes a finding you can alert on, not a line you'd have to notice by hand.

Step 7: Confirm It End-to-End

Simulate what an agent's tool call would do:

bash
TOKEN=$(kubectl create token k8s-agent -n agents --duration=15m)

curl -sS --cacert /path/to/ca.crt \
  -H "Authorization: Bearer $TOKEN" \
  "https://<api-server>/api/v1/namespaces/workloads/pods" | jq '.items[].metadata.name'

Then check the audit log (or your cluster's equivalent) and confirm that request shows up at RequestResponse level with the ServiceAccount identity attached. If it doesn't, the audit policy isn't wired to the API server the way you think it is — fix that before trusting the log during an incident.

Where This Leaves You

You have an identity that can't do more than five specific checks allow, a token that expires in minutes, a script that fails loudly if the ceiling ever widens, and a log that answers "what did the agent see?" without guessing.

What this setup does not do: stop data egress you explicitly authorized (if pod logs contain PII, a perfectly-scoped read-only agent still reads that PII — fix the logging, not the RoleBinding), or make the agent's output correct. Permissions are a containment story, not a correctness story.

Next steps:

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.