Docs · Tower
Admission policies
Backstage’s own checks are the first lock. These policies are the second, inside each cluster’s API server, so that a bug in the backend, or a compromised Backstage, still cannot widen what a session may do. They all fail closed.
| Policy | Ground ( |
Flight ( |
Guards |
|---|---|---|---|
tower-breakglass-rolebinding |
✓ | ✓ (+ approver rule) | the only grant Backstage may create |
tower-breakglass-debug-container |
✓ | ✓ (+ host namespaces) | what runs in a debug container or copy pod |
tower-breakglass-pod-delete |
✓ | ✓ | which pods Backstage may delete |
tower-breakglass-no-exec |
n/a | ✓ | exec and attach in app namespaces |
Ground writes them as Kyverno ValidatingPolicy (gitops-cluster-dev 30-policy/kyverno-policies/tower-breakglass.yaml)
with autogen.validatingAdmissionPolicy.enabled: true. Kyverno compiles each into a native
ValidatingAdmissionPolicy, vpol-<name>, and registers no webhook, so the API server enforces it even while Kyverno is
down. (They were ClusterPolicy webhooks until 2026-10-11; Kyverno 1.19 deprecates ClusterPolicy, and the webhooks
made break-glass unavailable whenever Kyverno lost its lease on a busy node.)
Flight writes them directly as ValidatingAdmissionPolicy + binding (gitops-cluster-30-policy/breakglass/policies.yaml, Argo CD app tower-breakglass, sync wave -1).
tower-breakglass-rolebinding
Matches CREATE and UPDATE of rolebindings where the name starts with tower-breakglass-, or the writer is
Backstage. Every other RoleBinding write skips it.
| Rule | Message |
|---|---|
Backstage may create only tower-breakglass-* bindings |
Backstage may only create RoleBindings named tower-breakglass-*. |
roleRef is ClusterRole tower-breakglass-debug-live or -copy |
…binds tower-breakglass-debug-live or -copy (a ClusterRole)… |
exactly one subject: Backstage’s write identity on this cluster (hangar:<prod-cluster>:tower-actor; backstage/backstage-ingestor) |
…binds … to Tower’s write identity only … |
label cleanup.kyverno.io/ttl is 15m, 30m, 45m, 60m or 75m |
…carries cleanup.kyverno.io/ttl of 15m to 75m (a session plus its one 15-minute extension). |
the namespace is labelled hangar.io/app |
…may only be created in an app namespace (labelled hangar.io/app). |
Flight only: a requester annotation, and either an approver annotation that differs from it, or hangar.io/breakglass-glass-broken: "true" with no approver |
A Flight break-glass RoleBinding names an approver who is not the requester, or is marked glass broken … with no approver. |
Kubernetes’ own RBAC escalation check adds one more: Backstage can bind only roles it is allowed to bind, which the
binder role limits to the two debug roles by resourceNames.
tower-breakglass-debug-container
Matches UPDATE of pods/ephemeralcontainers, and CREATE of pods labelled hangar.io/breakglass-copy. Every
other pod create skips it.
Every debug container (an ephemeral container, or a debug-* container in a copy pod) must have:
| Rule | Why |
|---|---|
image is exactly the pinned digest |
no other image, no tag (a tag can move) |
runAsNonRoot: true and a non-zero runAsGroup |
the spike’s busybox ran with group 0 by default |
allowPrivilegeEscalation: false |
no setuid escalation |
readOnlyRootFilesystem: true |
nothing written in the debug container |
privileged absent or false |
|
capabilities drop ALL, add none |
no NET_RAW, SYS_PTRACE, … |
every volumeMount has readOnly: true |
volume access can read, never write |
Copy pods additionally must carry a cleanup.kyverno.io/ttl label of 15m to 75m (this rule applies to admins too,
so even an admin’s copy expires). On Flight a copy pod also may not use hostNetwork, hostPID or hostIPC.
Process access (G14). An ephemeral container this update adds with targetContainerName is admitted only if the
pod carries hangar.io/breakglass-process-access: <session id> and the container is named debug-<that id>. The
backend writes that annotation, under tower-breakglass-pod-annotate below, only for an approved request that had
process access; so a backend bug (or a hand-made request) cannot add process access the approver never saw. Containers
already in the pod are not re-judged.
Ground exempts system:masters from the container rules (an operator’s own kubectl debug) and applies them only in
hangar.io/app namespaces. Flight applies them to everyone, in every namespace: an operator’s own
kubectl debug on --profile=restricted alone does not set a
group or a read-only root, so add them:
cat > debug-custom.json <<'EOF'
{"securityContext": {"runAsUser": 65532, "runAsGroup": 65532, "runAsNonRoot": true, "allowPrivilegeEscalation": false,
"readOnlyRootFilesystem": true, "capabilities": {"drop": ["ALL"]}}}
EOF
kubectl debug -it <pod> -n <ns> --image=ghcr.io/<owner>/tower-debug@sha256:<digest> \
--profile=restricted --custom=debug-custom.json -- sh
tower-breakglass-pod-delete
Matches DELETE of pods by Backstage only.
Both debug roles include delete pods for the length of a session (copy mode deletes its copy; live mode restarts a pod
Argo CD does not manage). Without this policy, a session’s grant would reach every pod in the namespace. The rule: the
pod being deleted (oldObject) is a copy pod (hangar.io/breakglass-copy: "true") or carries an ephemeral container
named debug-*.
tower-breakglass-pod-annotate
Matches UPDATE of pods (the main resource, not a subresource) by Backstage only.
Backstage’s live role includes patch pods for the length of a session, used for one thing: the process-access
annotation above. This policy makes sure that is all the patch can do: object.spec == oldObject.spec, the labels are
unchanged, and the annotations differ in no key other than hangar.io/breakglass-process-access (added, changed or
removed). Tested in isolation on
tower-breakglass-debug-image
Matches the same requests as tower-breakglass-debug-container (an ephemeral-container update, or a copy pod), and
only looks at images under ghcr.io/<owner>/tower-debug.
An ImageValidatingPolicy: the image must carry the cosign keyless signature of tower’s debug-image.yml workflow
on main (issuer https://token.actions.githubusercontent.com, the public Fulcio and Rekor). The digest pin in
tower-breakglass-debug-container says which build; this says the build came from that workflow. Image verification
needs Kyverno’s admission controller, so this is the one break-glass policy that runs as a webhook, and it fails
closed; it is therefore scoped: Kyverno copies the policy’s matchConditions onto the fine-grained webhook pair it
registers for it (validating and mutating: ivpol.*.kyverno.svc-fail-finegrained-tower-breakglass-debug-image), so
the webhook sees nothing but those two kinds of request. Check it after any change:
kubectl get validatingwebhookconfigurations,mutatingwebhookconfigurations -o yaml | grep -B2 -A6 finegrained-tower.
(The CRD has no webhookConfiguration.matchConditions; setting one is pruned and leaves Argo CD OutOfSync.)
Verified on both clusters: a plain pod create is untouched; a copy pod with the signed digest is admitted; a tag under
the debug repository that does not exist is refused.
On Flight this is why 30-policy/kyverno: admission and cleanup controllers only,
no background or reports controller): this policy, and the cleanup controller’s TTL backstop
(30-policy/kyverno-policies/tower-breakglass-rbac.yaml gives it delete on RoleBindings and pods). Validation stays in
the hand-written VAPs; if Kyverno is down on
tower-breakglass-no-exec (Flight)
Matches CONNECT on pods/exec and pods/attach in namespaces labelled hangar.io/app, for anyone not in
kubeadm:cluster-admins or system:masters.
| Rule | Message |
|---|---|
| exec is allowed only to ServiceAccounts other than Backstage (operators such as CNPG exec into their own pods) | No exec into Flight pods. Use a Tower break-glass session (a recorded debug container) instead. |
attach is allowed to other ServiceAccounts, and to Backstage only on a container named debug-* |
Attaching to a Flight pod is only for a break-glass debug container, through Tower. |
This makes “never exec on Flight” a cluster fact, not a Tower convention. People keep kubectl logs, port-forward and
everything else their RBAC allows.
Writing CEL that fails closed safely
With failurePolicy: Fail, a CEL evaluation error refuses the request. In a match condition that is scoped too
widely, one error refuses every request the policy matches, cluster-wide.
This happened on dev on 2026-10-11: a match condition read request.subResource unguarded. On a plain pod
create that field is absent (not empty), the expression errored, and every pod create in the cluster was refused
for about ten minutes. The rules that came out of it:
- Guard every optional field with
has()before reading it, in match conditions, variables and validations:has(request.subResource) && request.subResource == 'ephemeralcontainers'. That includesobject.metadata.labels,.annotations,request.userInfo.username,object.subjects,c.securityContextand its children. - Scope each policy with match conditions to break-glass objects only (name prefix, label, or Backstage’s username), so an error can only ever affect break-glass.
- On
DELETE, the object isoldObject;objectis null. - A Kyverno policy on Pods alone also autogenerates controller variants (Deployment, Job, …) and is then served
from a webhook instead of a VAP.
tower-breakglass-pod-deletesetsautogen.podControllers.controllers: [].
Changing a policy without an outage
-
Test the exact CEL first, on
, in a throwaway policy bound to a scratch namespace (see Testing). Cover every allow and every deny. -
Widen before you narrow. When a value changes (the image digest, the TTL list), first make the policy accept both old and new, deploy, then switch the backend, then drop the old value.
’s debug-container policy keeps the allowed images in a list variable ( variables.images) for this. Doing it in the other order refuses every session in between. -
Order on Flight: the policy PR merges and syncs before the backstage bump that relies on it. Check that the live object changed, not just that Argo CD says Synced (Argo CD can report Synced against the previous revision):
kubectl get validatingadmissionpolicy tower-breakglass-rolebinding -o yaml | grep <new text> kubectl -n argocd get app tower-breakglass -o jsonpath='{.status.sync.revision}' -
Dry-run against the live policy as Backstage after it syncs (see Testing).