Docs · Tower
Setup: enabling break-glass on a cluster
Break-glass needs five things on each cluster it reaches, plus a recordings store and backend configuration once. Work through them in order; each step ends with a check. The two clusters in this lab are already set up; this page is how they were set up, and how to add a third.
| Step | Once or per cluster | Repository |
|---|---|---|
| 1. Prerequisites | per cluster | n/a |
| 2. Admission policies | per cluster, before the RBAC | the cluster’s gitops repo |
| 3. RBAC | per cluster | the cluster’s gitops repo |
| 4. The recordings store | once | gitops-cluster-dev, Infisical, |
| 5. Backend configuration | once (plus the cluster’s tier) | backstage app-config |
| 6. Permissions | once | backstage catalog |
| 7. Verify | per cluster | n/a |
1. Prerequisites
- Kubernetes 1.30 or later (ValidatingAdmissionPolicy is GA; on Flight it must match
CONNECTonpods/exec).runs 1.37. - Ephemeral containers enabled (default since 1.25).
- App namespaces labelled
hangar.io/app: <app>andhangar.io/env: <env>. Airframe’s environment composition does this. Break-glass refuses any namespace whose labels do not name the app and environment requested, and the policies refuse grants outside namespaces labelledhangar.io/app. PR preview namespaces also carryhangar.io/ephemeral-env: "true"(that is what makes them Ground). - Backstage can reach the cluster: an entry in
kubernetes.clusterLocatorMethods(itsnameis the cluster name break-glass uses) and a ServiceAccount identity there. - Argo CD’s
backstageaccount may delete pods (role:tower-pod, the same grant Tower’s Restart pod uses). Live mode recycles the pod through it. Without it, the backend falls back to deleting the pod directly under the session’s grant, which the policy allows only for a pod with a debug container.
2. Admission policies
Install the policies before the RBAC that they constrain: the moment Backstage can bind a debug role, the policy
must already be there to narrow it. On -1, RBAC at
0).
What each rule does, and how to edit one without an outage, is in Admission policies.
Ground cluster (Kyverno 1.19+)
- Apply
30-policy/kyverno-policies/tower-breakglass.yaml: threepolicies.kyverno.io/v1ValidatingPolicyresources withautogen.validatingAdmissionPolicy.enabled: true. Kyverno compiles each into a native ValidatingAdmissionPolicy namedvpol-<name>and registers no webhook for it, so the API server enforces it even when Kyverno is down. - Apply
30-policy/kyverno-policies/tower-breakglass-rbac.yaml: aggregated ClusterRoles that let Kyverno’s controllers readpods/ephemeralcontainersand let its cleanup controller delete expired RoleBindings and pods (it has no delete on those kinds by default). The cleanup controller acts on thecleanup.kyverno.io/ttllabel. - Replace the literal username
hangar:<prod-cluster>:tower-actor(’s federated write identity, see Cluster credentials) with Backstage’s write identity on the new cluster, everywhere it appears. That includes the RoleBinding policy’s subject check, which expects a Usersubject there.
Check:
kubectl get validatingpolicies.policies.kyverno.io | grep tower-breakglass # READY true
kubectl get validatingadmissionpolicy | grep vpol-tower-breakglass # three
Flight cluster (no Kyverno needed)
- Apply
30-policy/breakglass/policies.yaml: four hand-written ValidatingAdmissionPolicies with their bindings (tower-breakglass-rolebinding,-debug-container,-pod-delete,-no-exec). - Replace
system:serviceaccount:backstage:backstage-ingestorwith Backstage’s identity on the new cluster. tower-breakglass-no-execexemptskubeadm:cluster-adminsandsystem:masters(operators) and other ServiceAccounts (operators such as CNPG exec into their own pods). Check that your operators’ group is one of these.
Check:
kubectl get validatingadmissionpolicy,validatingadmissionpolicybinding | grep tower-breakglass # four of each
3. RBAC
Apply the cluster’s breakglass-rbac.yaml / rbac.yaml, with the ClusterRoleBinding’s subject set to Backstage’s
ServiceAccount there:
| Object | Grants |
|---|---|
ClusterRole tower-breakglass-debug-live |
pods: get, list, delete · pods/ephemeralcontainers: get, patch, update · pods/attach: get, create |
ClusterRole tower-breakglass-debug-copy |
pods: get, list, create, delete · pods/attach: get, create |
ClusterRole tower-breakglass-binder |
rolebindings: get, list, create, delete · clusterroles: bind, resourceNames the two roles above |
| ClusterRoleBinding with the binder role | on tower-actor-breakglass-binder (tower-actor-rbac.yaml), to the User hangar:<prod-cluster>:tower-actor; on backstage-ingestor-breakglass-binder, to Backstage’s ServiceAccount |
The debug roles are never bound at rest. Backstage binds one to itself, per session, in one namespace.
Check, as Backstage (replace the identity and namespace):
SA=system:serviceaccount:backstage:backstage-ingestor; NS=app-sky-marshall-staging # <prod-cluster>
# <dev-cluster>: AS="--as=hangar:<prod-cluster>:tower-actor --as-group=hangar:<prod-cluster>"; NS=app-sky-marshall-dev
kubectl auth can-i create rolebindings -n $NS --as=$SA # yes
kubectl auth can-i create pods/attach -n $NS --as=$SA # no
kubectl auth can-i patch pods/ephemeralcontainers -n $NS --as=$SA # no
kubectl auth can-i delete pods -n $NS --as=$SA # no
4. The recordings store
Once, on
-
Bucket. gitops-cluster-dev
40-observability/minio/create-breakglass-locked-bucket-job.yamlcreatestower-breakglass-v2with object lock (versioned), a default retention of 365 days in governance mode, and a lifecycle rule expiring current and noncurrent versions after 365 days. Check it:mc retention info <alias>/tower-breakglass-v2, then trymc rm --version-id <v> …as root: WORM protected. (The first bucket,tower-breakglass, fromcreate-breakglass-bucket-job.yaml, is unlocked and keeps the recordings written before phase B until they expire; the backend reads those rows from it asrecordings.legacyBucket.) Both Jobs use the MinIO server image’smc:quay.io/minio/mcno longer pulls.) -
Access key, by hand in the MinIO console (a service account of the root user with an embedded policy), with exactly this policy: put and get on both buckets, plus reading a named version in the locked one; never list, delete or bypass-governance. (Extended to the second bucket on 2026-10-12 with
mc admin user svcacct edit <key> --policyas root inside the MinIO pod;mc admin user svcacct info <key> --policyshows it.){"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:PutObject","s3:GetObject","s3:GetObjectVersion"], "Resource":["arn:aws:s3:::tower-breakglass/*","arn:aws:s3:::tower-breakglass-v2/*"]}]} -
Infisical. Plant the key and secret as
tower-breakglass-minio-access-keyandtower-breakglass-minio-secret-key(, platform-cicd-<prod-cluster>, shared). Never paste them anywhere else. -
The anchor key (phase B). Generate an ECDSA P-256 private key and plant it as
tower-breakglass-anchor-key(same project):openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt. The ExternalSecretbackstage-breakglass-anchormounts it asTOWER_BREAKGLASS_ANCHOR_KEY. The public half travels in every Rekor entry, so rotating the key invalidates nothing. Without it, recordings are stored but not anchored (the record says so), and the sweep anchors them once the key appears, for a day after each session’s end. -
Rekor route. gitops-cluster-dev
60-gateway-routes/rekor-httproute.yamlexposesrekor.<dev-domain>; theBackstage Deployment’s hostAliasesmaps it likeminio.<dev-domain>. -
Into Backstage. gitops-cluster-
60-backstage/backstage/breakglass-minio-external-secret.yamlsyncs them into Secretbackstage-breakglass-minio; the Deployment maps them toTOWER_BREAKGLASS_MINIO_ACCESS_KEYandTOWER_BREAKGLASS_MINIO_SECRET_KEY.
Without both variables the backend refuses every session with recordings are not configured.
Check:
mc ilm rule ls <alias>/tower-breakglass # Expiration 365 days, Enabled
kubectl -n backstage get externalsecret backstage-breakglass-minio # SecretSynced
5. Backend configuration
Defaults suit this lab; set only what differs. Full list in the configuration reference.
glidepath:
breakglass:
groundClusters: [<dev-cluster>] # clusters whose undeclared environments are Ground; all others are Flight
recordings:
endpoint: http://minio.<dev-domain>:8080
bucket: tower-breakglass-v2 # the object-locked bucket; rows written before it read from legacyBucket
legacyBucket: tower-breakglass # default when bucket is not the first one
region: us-east-1
retentionDays: 365 # must match the bucket's lifecycle rule; shown to users
anchor:
rekorUrl: http://rekor.<dev-domain>:8080 # the default; the key comes from TOWER_BREAKGLASS_ANCHOR_KEY
A new Ground cluster must be added to groundClusters, or infra services on it are treated as Flight (apps with a
cicd.yaml take its declared tier regardless).
6. Permissions
- Owners come from the catalog: the Component’s
spec.owner, through the permission policy’s owner rule. Nothing to configure per app. - Admins are members of
group:default/admins. They hold every Tower permission, including the grant-onlytower.breakglass.terminateandtower.breakglass.self(glass broken). - A second person for Flight needs a second user who owns the app. In this lab that is
riley-roooo(team-skyport). Add users in backstageexamples/org.yaml.
7. Verify end to end
-
At rest (step 3’s checks): Backstage cannot attach, patch ephemeral containers, or delete pods.
-
Policies refuse what they should, as Backstage, with server-side dry runs. The testing guide has scripts for each case: a 90-minute TTL, an unpinned image, a root group, an extra capability, another subject, a non-app namespace, a self-approved Flight grant.
-
A real session from Tower on a test app: Ground copy mode, then live mode; on Flight, a request approved by a second user. After each, check the namespace is clean:
kubectl -n $NS get rolebindings,pods -l app.kubernetes.io/managed-by=tower-breakglass # grants: none left kubectl -n $NS get pods -l hangar.io/breakglass-copy=true # copies: none leftand that the session’s recording plays with SHA-256 matches the session record.