Skip to main content
RunBook Academy

← All break/fix scenarios in Kubernetes

advancedkubernetes-serviceaccount~45 min

Overprivileged ServiceAccount

Reported symptoms

  • A compliance scan opens a Critical finding: ServiceAccount monitoring/prometheus is bound to cluster-admin
  • The same finding was raised, fixed and closed four months ago, and the fix is still in the repository
  • The in-house nightly RBAC audit job ran three hours before the scan and reported zero findings
  • Nothing is broken: scrapes are green, no alert fired, no ticket was raised by any user
  • The scan raises a second finding on the same ServiceAccount - it can create ClusterRoleBindings
  • No RBAC change appears in the platform repository git log for four months

Evidence

  • · `kubectl auth can-i --list --as=system:serviceaccount:monitoring:prometheus` prints a single `*.*` row with verbs `[*]`
  • · `kubectl auth can-i create clusterrolebindings --as=system:serviceaccount:monitoring:prometheus` answers `yes`
  • · Filtering `kubectl get clusterrolebinding -o json` by subject returns two bindings naming the same ServiceAccount
  • · One is the team minimum-surface binding; the other has `roleRef.name: cluster-admin` and Helm release annotations
  • · `helm history metrics-stack -n monitoring` shows an upgrade at 02:14 to a chart version newer than the one named in the last staging change record
  • · The deploy pipeline runs `helm upgrade --install` with no `--version` flag
  • · `helm get values metrics-stack -n monitoring` still shows the override key the team set four months ago
  • · `helm get manifest metrics-stack -n monitoring` contains a ClusterRoleBinding to cluster-admin
  • · The nightly audit job greps the output of `kubectl auth can-i --list` for the string `cluster-admin`
Diagnosis and resolutionclick to reveal

Root cause

An unpinned `helm upgrade` pulled a new major version of the monitoring chart overnight. In that version the values key that suppressed the chart's default ClusterRoleBinding was renamed, and Helm does not reject values keys a chart does not consume unless the chart ships a `values.schema.json`. The team's override therefore rendered nothing, the chart's default binding was re-created, and the monitoring ServiceAccount acquired cluster-admin alongside the minimum-surface ClusterRole the team wrote for it. RBAC is purely additive - there are no deny rules - so the union of the two bindings is cluster-admin and the team's careful Role became decorative. Nothing broke because nothing was taken away: an over-permissioned identity behaves exactly like a correctly permissioned one until somebody abuses it. The audit job missed it for a separate reason worth its own paragraph: `kubectl auth can-i --list` reports the effective resource and verb rules, never the names of the Roles that produced them, so a grep for the string `cluster-admin` searches output that could not contain it under any circumstances.

Remediation

Do not start by deleting the binding. It is owned by a Helm release, so a `kubectl delete` is reverted by the next pipeline run and destroys the evidence of how it arrived. Fix the release instead: pin the chart version in the pipeline to the version that was last validated in staging, correct the override to the key the new chart actually reads, and run `helm upgrade` again, confirming with `helm get manifest` that the rendered output no longer contains the cluster-admin binding. Removing the extra binding is low-risk here and the reason is worth stating rather than assuming: the ServiceAccount ran for four months on the minimum-surface ClusterRole alone, so that Role is known sufficient for the workload as it was. It is not automatically sufficient for the workload as it now is, because the same upgrade that restored the binding may also have added scrape targets or custom resources the old Role does not cover. If the incident lands outside a change window, holding is legitimate and should be recorded as a decision rather than a delay: leave the binding in place, note the exposure, name an owner and an end time, and remediate in hours rather than under pressure. Whichever path is taken, rotate nothing and assume nothing about abuse until the audit log has been read for the exposure window.

Verification

The check that matters is the effective permission, not the object graph. `kubectl auth can-i --list --as=system:serviceaccount:monitoring:prometheus` must no longer print a `*.*` row, and `kubectl auth can-i create clusterrolebindings --as=system:serviceaccount:monitoring:prometheus` must answer `no` and exit non-zero. Prove the check can fail by running the same command against an identity that genuinely is cluster-admin and confirming it answers `yes`; a verification that reports success everywhere reports nothing. Then confirm the fix is durable rather than manual: re-run the deploy pipeline and repeat both checks, because a release fixed by hand is a release that regresses on the next converge. Finally confirm the workload still works - Prometheus targets all up, no permission-denied lines in its logs - because the point of removing a binding is to leave a functioning system with less authority, not a broken one with none.

Prevention

Pin the chart version in the pipeline and update the pin in a reviewed pull request, so the deployed software is a function of the repository rather than of the calendar. Treat every values override as an assertion that can silently stop being true: ask the chart for a `values.schema.json`, and where there is none, assert the outcome instead of the input by diffing `helm get manifest` or `helm template` against the previous release as a pipeline gate. Audit effective permissions rather than object names - the audit job should test the specific escalation primitives with `kubectl auth can-i create clusterrolebindings`, `kubectl auth can-i '*' '*'` and a check for a `*.*` row, none of which depend on knowing what any Role is called. Fail the pipeline on a new binding to `cluster-admin` from any namespace, since that is a small, cheap, unambiguous gate. And make the disagreement between two scanners an incident in itself: when an external scanner and an internal audit report opposite results, one of them is broken, and finding out which is more valuable than the finding that started the argument.

Reported symptoms

At 09:20 the quarterly compliance scan lands in the platform team’s queue with two Critical findings against one identity:

CRITICAL  monitoring/prometheus  ServiceAccount is bound to cluster-admin
CRITICAL  monitoring/prometheus  ServiceAccount can create clusterrolebindings

The team has seen the first one before. Four months ago the same scanner raised it, the team traced it to the monitoring chart’s default RBAC, disabled that default with a values override, wrote a minimum-surface ClusterRole covering exactly what Prometheus needs, put both in the platform repository, and closed the finding. git log shows no change to any RBAC manifest since.

Three facts make this look like a scanner fault rather than a cluster fault:

  • The team’s own audit disagrees. A nightly job walks every ServiceAccount in the cluster and reports over-permissioned ones. It ran at 06:00 and reported zero findings, as it has every night for four months.
  • Nothing is broken. Prometheus is scraping, every target is up, no alert fired, and no user raised anything. Whatever changed, it did not remove a capability from anything.
  • The reviewed fix is still there. helm get values metrics-stack -n monitoring prints the override the team wrote, unchanged, exactly as the repository has it.

The on-call engineer’s first instinct is that the scanner has been upgraded and is now reporting false positives. That instinct is reasonable, it is the cheapest hypothesis to test, and it is wrong.

Evidence provided

Read-only / Safethe effective permissions of the monitoring identity
$ kubectl auth can-i --list --as=system:serviceaccount:monitoring:prometheus
Resources   Non-Resource URLs   Resource Names   Verbs
*.*         []                  []               [*]
[*]                 []               [*]

Illustrative output

Read-only / Safethe escalation primitive, asked directly
$ kubectl auth can-i create clusterrolebindings --as=system:serviceaccount:monitoring:prometheus
yes

Illustrative output

Read-only / Safetwo bindings name the same ServiceAccount
$ kubectl get clusterrolebinding -o custom-columns=NAME:.metadata.name,ROLE:.roleRef.name,SUBJECT:.subjects[*].name | grep prometheus
metrics-stack-prometheus     cluster-admin                prometheus
platform-prometheus-scrape   platform-prometheus-scrape   prometheus

Illustrative output

Read-only / Safewho owns the binding the team did not write
$ kubectl get clusterrolebinding metrics-stack-prometheus -o yaml | head -12
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
annotations:
meta.helm.sh/release-name: metrics-stack
meta.helm.sh/release-namespace: monitoring
labels:
app.kubernetes.io/managed-by: Helm
name: metrics-stack-prometheus
roleRef:
kind: ClusterRole
name: cluster-admin

Illustrative output

Read-only / Safean upgrade nobody requested, on the night before the scan
$ helm history metrics-stack -n monitoring
REVISION  UPDATED                   STATUS      CHART                 APP VERSION  DESCRIPTION
17        Mon Aug 10 02:14:07 2026  superseded  metrics-stack-4.9.2   2.54.1       Upgrade complete
18        Tue Aug 11 02:14:11 2026  deployed    metrics-stack-5.0.0   2.55.0       Upgrade complete

Illustrative output

Read-only / Safethe pipeline command, unchanged for a year
$ grep -n 'helm upgrade' deploy/monitoring/deploy.sh
42:helm upgrade --install metrics-stack platform/metrics-stack \
43:  --namespace monitoring -f values/monitoring.yaml --wait

Illustrative output

Read-only / Safethe rendered release contains the binding
$ helm get manifest metrics-stack -n monitoring | grep -B4 'name: cluster-admin'
kind: ClusterRoleBinding
metadata:
name: metrics-stack-prometheus
roleRef:
kind: ClusterRole
name: cluster-admin

Illustrative output

Work the evidence before reading on

Two scanners disagree about the same cluster on the same morning. Exactly one of them is right, and the interesting question is not which finding is true but why the other tool could not see it.

  1. There are two ClusterRoleBindings naming this ServiceAccount, and both are valid objects. What is the effective permission set of an identity that has two bindings? Is there any mechanism in RBAC by which the narrower one constrains the wider one?
  2. The team’s override is still in helm get values, and the chart version in helm history is not the version the change record names. What does Helm do with a values key that the chart it is rendering does not read?
  3. Read the nightly audit’s check one more time. It runs kubectl auth can-i --list and greps the output for cluster-admin. Look at the output of that command in the evidence above. Which column would the string cluster-admin appear in?

Before continuing: the ServiceAccount has cluster-admin and the audit that looks for cluster-admin reports clean. Both statements are true at the same time. How?

Root cause

1. RBAC is additive, so the widest binding wins

The team’s platform-prometheus-scrape ClusterRole is correct. It grants get, list and watch on the objects Prometheus discovers targets from, and nothing else. It is still bound, still valid, and still exactly what a reviewer would approve.

It is also irrelevant. Kubernetes RBAC is purely additive: there are no deny rules, and no binding can subtract from what another binding grants. An identity’s effective permissions are the union of every rule reachable from every binding that names it. Add cluster-admin to a subject that already has a narrow Role and the result is cluster-admin, with the narrow Role contributing a subset of what the union already contains.

This is why kubectl describe clusterrolebinding platform-prometheus-scrape looks perfect and proves nothing. Inspecting one binding answers “is this object what I intended”, which is a different question from “what can this identity do”.

2. The binding came back because the values key stopped existing

The pipeline runs helm upgrade --install with no --version. Without a pin, Helm installs whatever version the repository holds at that moment, so the deployed software is a function of the calendar rather than of the repository. On the night of 11 August the mirror had metrics-stack-5.0.0 where the last staging validation had exercised 4.9.2, and the pipeline took it.

The new major version renamed the values key that suppressed the chart’s default RBAC. The team’s values/monitoring.yaml still sets the old key. Helm does not object: unless a chart ships a values.schema.json, values keys the chart never reads are accepted silently and simply have no effect. The upgrade succeeded, reported Upgrade complete, and rendered the chart’s default ClusterRoleBinding to cluster-admin because, from the chart’s point of view, nobody had asked it not to.

That is the entire mechanism. A successful upgrade, a green pipeline, an unchanged repository, and a silently ignored override.

3. The audit grepped for a string the command never prints

The nightly job’s check is, in essence:

NS=monitoring
SA=prometheus
if kubectl auth can-i --list -n "$NS" \
     --as="system:serviceaccount:$NS:$SA" | grep -q cluster-admin; then
  echo "FINDING: $NS:$SA is cluster-admin"
fi

It has never produced a true positive and never can. kubectl auth can-i --list returns the effective rules for an identity: a table of resources, non-resource URLs, resource names and verbs. It does not return, and has no field in which to return, the names of the Roles or ClusterRoles those rules came from. The string cluster-admin cannot appear in that output regardless of what the identity is bound to.

The green result was therefore not evidence of a healthy cluster. It was the absence of a signal the check was structurally incapable of producing - which is the failure mode that makes a broken check worse than no check, because the team had been treating four months of green as confirmation.

Resolution

  1. Capture the evidence before changing anything: kubectl get clusterrolebinding metrics-stack-prometheus -o yaml > /tmp/cluster-admin-binding.yaml, plus the outputs of helm history, helm get values and helm get manifest. The incident record needs the object as it was.
  2. Establish the exposure window from helm history: the binding exists from the timestamp of the revision that created it until now. That window, not the age of the finding, is the period the audit log must be reviewed for.
  3. Decide explicitly between fixing now and holding. Holding is legitimate when the change window is closed or the monitoring stack is mid-upgrade; it is only legitimate when it is recorded with an owner, a stated exposure, and an end time. An undecided delay is not a hold.
  4. Find the key the new chart actually reads. helm show values platform/metrics-stack --version 5.0.0 prints the new default values; compare it against the override in values/monitoring.yaml and identify which key the team was setting and what replaced it.
  5. Pin the pipeline to the chart version that was last validated in staging by adding --version to the helm upgrade line. Pinning first means the next run is reproducible, which every later step depends on.
  6. Correct the override in the repository, in a reviewed pull request, alongside the pin. Both changes belong to the same review because either one alone leaves a release that can still drift.
  7. Render before you apply: helm template metrics-stack platform/metrics-stack --version <pinned> -f values/monitoring.yaml | grep -A3 cluster-admin must return nothing. This is the cheapest check in the whole procedure and it answers the question directly.
  8. Run the pipeline. Confirm from helm get manifest metrics-stack -n monitoring that the rendered release no longer contains the binding, and that Helm has removed the object rather than orphaning it.
  9. Review the audit log for the exposure window, filtering on the ServiceAccount as the requesting user. Read it for what the identity did, not for what it could have done: an over-permissioned identity that only ever performed its normal scrape discovery is a different incident from one that touched Secrets or RBAC.
  10. Repair the nightly audit check in the same change. A check that cannot produce a true positive is a defect of the same severity as the finding it missed.

Verification

  1. The effective permissions changed. kubectl auth can-i --list --as=system:serviceaccount:monitoring:prometheus no longer prints a *.* row, and the rules that remain match the minimum-surface ClusterRole.
  2. The escalation primitive is gone. kubectl auth can-i create clusterrolebindings --as=system:serviceaccount:monitoring:prometheus answers no and exits non-zero.
  3. The check can fail. Run the same command against an identity that genuinely is cluster-admin and confirm it answers yes. A control that reports success against every input is not reporting anything.
  4. Only one binding remains. Re-run the kubectl get clusterrolebinding -o json filter and confirm it returns the team binding alone.
  5. The workload still works. Every Prometheus target is up, and the Prometheus container log contains no forbidden or cannot list resource lines. Removing authority is only a success if the system that lost it still functions.
  6. The fix survives a converge. Re-run the deploy pipeline and repeat the first two checks. This is where a hand-deleted object is exposed, and it is the step teams skip.
  7. The repaired audit reports the truth. Point the fixed nightly check at a deliberately over-permissioned test ServiceAccount in a scratch namespace and confirm it raises a finding, then delete the test identity.
  8. The two scanners now agree. Re-run the compliance scan and confirm both findings close, with the internal audit still clean for the right reason rather than the previous one.

Prevention

  • Pin the chart version, and change the pin in a pull request. An unpinned helm upgrade makes the deployed software a function of when the pipeline ran. Every incident that begins “nothing changed” and is caused by an unpinned dependency was preventable by one flag.
  • Assert the output, not the input. A values override is a request; a rendered manifest is a fact. Gate the pipeline on helm template output - specifically, on the absence of any binding to cluster-admin - so an override that silently stops applying fails the build instead of shipping.
  • Prefer charts with a values.schema.json, and ask for one where it is missing. A schema turns a renamed key from a silent no-op into an error at render time, which is the only place it is cheap to fix.
  • Audit effective permissions, never role names. The escalation questions worth asking are concrete and name-independent: can this identity create clusterrolebindings, can it get Secrets outside its namespace, does its rule list contain a *.* row. All three are one kubectl auth can-i call and none depend on knowing what anything is called.
  • Test the audit against a known-bad identity on a schedule. A detection control that has never fired is indistinguishable from a broken one. Create a deliberately over-permissioned ServiceAccount in a scratch namespace, confirm the audit finds it, and delete it - monthly, in the audit job itself.
  • Treat scanner disagreement as an incident. When two tools report opposite results about the same object, at least one is wrong, and which one is wrong matters more than the original finding. The morning the internal audit contradicted the external scanner was the morning the team learned their audit had never worked.
  • Alert on the creation of any binding to cluster-admin. It is a rare, high-signal event with almost no legitimate cause outside cluster bootstrap, and it is trivially detectable in the audit log.