Docs · Tower
Testing and verification
Break-glass is verified at three levels: unit tests for the pure logic, policy checks against the real API servers, and end-to-end sessions as Backstage’s real identity. Each change to it should pass all three that it touches.
Unit tests
| Where | Run | Covers |
|---|---|---|
backstage packages/backend/src/breakglass.test.ts |
cd packages/backend && CI=1 yarn backstage-cli package test src/breakglass.test.ts |
object shapes (RoleBinding, debug container, copy pod, volume mounts, token shadow), request parsing, the store against real SQLite (claims, migrations from every earlier schema), sweepAction, settleDebugState, tierFor, reviewRefusal, retention |
tower src/tabs/topology/BreakglassPanel.test.tsx, castPlayback.test.ts |
CI=1 yarn backstage-cli package test src/tabs/topology |
the panel in every state (Ground, Flight requester and approver, extension, glass broken, review), the player, the cast parser and hash |
Set CI=1: without it the Backstage CLI runs tests in watch mode and never exits. In the backstage repo also run
yarn tsc from a clean cache before merging; Jest does not type-check.
Policy checks with server-side dry runs
A dry run goes through admission like a real request, so it tests the live policies without creating anything.
As Backstage, for rules that depend on the requester (RoleBindings, pod deletes):
SA=system:serviceaccount:backstage:backstage-ingestor # <prod-cluster>
# <dev-cluster>: --as=hangar:<prod-cluster>:tower-actor --as-group=hangar:<prod-cluster>, and a User subject:
# subjects: [{kind: User, apiGroup: rbac.authorization.k8s.io, name: hangar:<prod-cluster>:tower-actor}]
cat <<EOF | kubectl --context <prod-cluster> --as=$SA create --dry-run=server -f -
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: tower-breakglass-drytest
namespace: app-sky-marshall-staging
labels: {cleanup.kyverno.io/ttl: "90m"}
annotations: {hangar.io/breakglass-requester: "user:default/a", hangar.io/breakglass-approver: "user:default/b"}
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: tower-breakglass-debug-copy}
subjects: [{kind: ServiceAccount, name: backstage-ingestor, namespace: backstage}]
EOF
# expect: denied … cleanup.kyverno.io/ttl of 15m to 75m
As yourself, for rules matched by labels (copy pods): the Flight debug-container policy does not depend on who asks, so an admin’s dry run exercises it with no grant needed. Vary one field per run and expect exactly one outcome:
| Case | Expect |
|---|---|
tower-debug digest, TTL 75m, hardened |
created (server dry run) |
image by tag (:latest) |
denied: hardened like an ephemeral debug container |
TTL 90m |
denied: TTL of 15m to 75m |
runAsGroup: 0, or a capability added, or a writable mount |
denied |
hostNetwork: true (Flight) |
denied: no host namespaces |
On system:masters, so test them as Backstage under a real copy grant: create a
tower-breakglass-<name> RoleBinding with a 15m TTL as Backstage, run the dry runs as Backstage, delete the binding.
Testing a policy rule in isolation
Before changing a rule, test the exact CEL on
- Extract the rule into a throwaway ValidatingAdmissionPolicy, bound with
matchResources.namespaceSelectorto a scratch namespace only. - Rename the prefix it matches (for example
celtest-instead oftower-breakglass-). Otherwise the live policies match your test objects too, and an “allowed” case is refused by them, not by your rule. - Dry-run every allow and every deny case into the scratch namespace.
- Delete the namespace, the policy and the binding.
For example, the glass-broken approver rule ("false", neither, no requester, no annotations (all refused).
End-to-end, without touching production Backstage
The local harness runs a Backstage backend (and optionally the app) on your machine, acting as Backstage’s real identity on each cluster, so the real RBAC and the real policies apply.
-
A worktree of the backstage repo with dependencies installed, and an
app-config.local.yaml(gitignored). -
Impersonating proxies, one per cluster:
kubectl --context <dev-cluster> --as=hangar:<prod-cluster>:tower-actor --as-group=hangar:<prod-cluster> proxy --port=8013 --reject-paths='^/never$' & kubectl --context <prod-cluster> --as=system:serviceaccount:backstage:backstage-ingestor proxy --port=8014 --reject-paths='^/never$' &--reject-pathsreplaces kubectl proxy’s default, which blocksattachandexec. Point the config’s clusters (<dev-cluster>,<prod-cluster>) athttp://localhost:8013and:8014. -
A throwaway recordings store: a MinIO pod in a scratch namespace on
, port-forwarded to :9000, with atower-breakglassbucket. Setglidepath.breakglass.recordings.endpoint: http://127.0.0.1:9000and theTOWER_BREAKGLASS_MINIO_*variables to its own (generated) credentials. Never the real key. -
A file-backed database (
backend.database.client: better-sqlite3,connection.directory: <dir>) so sessions survive a restart. -
Switching people: the guest provider’s
auth.providers.guest.userEntityRefdecides who you are. Restart the backend between people (requester, approver, reviewer) and keep the database. -
Catalog stubs for the apps under test (a Component with
spec.ownerandgithub.com/project-slug), plusexamples/org.yamlfor users and groups. -
Start with
GLIDEPATH_NOTIFY_TOKEN=<random>(the backend needs it) and drive the routes with a small script: guest refresh for a token, thenPOST /api/glidepath/breakglass/sessions, a WebSocket to/attachwith the token as the subprotocol,POST …/end.
What was verified this way (2026-10-10 to 10-11), each against the real policies:
| Flow | Result |
|---|---|
| Ground copy and live, with process and volume access | opened; uid=65532; writes refused; the token absent in copy mode; cleaned up |
| Flight two-person | self-approve 403, start-before-approval 409, approve → start → attach → end; deny path |
| Exit and reconnect | exit ends the session; a dropped stream offers Reconnect |
| Unmanaged pod recycle | deleted directly, admitted by tower-breakglass-pod-delete |
| Infra service (skyport-auth dev) | Ground by cluster; extension once, second refused |
| Glass broken | non-admin 403; refused by the old policy before #270 synced (fail closed, still review-due); self-review 403; review 400 without note, 200, then 409 |
| Recording player | played in a browser with SHA-256 matches the session record |
Not run live: the 30-minute request expiry and the 10-minute start window (unit-tested in sweepAction; live would
mean waiting).
After every deploy
- The backend pod runs the new image and logs no break-glass warnings.
- The live policies contain the change (not just Synced).
- One real session per tier touched, ending with a clean namespace (no
tower-breakglass-*RoleBinding, no copy pod, no live pod still carrying a debug container) and a recording that plays and matches its hash.