Docs · Tower
Cluster credentials: how Tower reaches
Tower’s backend (Backstage, on
’s API server trusts ’s ServiceAccount token issuer. - A token that
issues for is accepted there. - Reads use the Backstage pod’s own token, which the kubelet rotates.
- Writes use a token minted for one action and then dropped.
- Each of the two identities gets only its own permissions, and admission policies check every write.
This page explains the design, the configuration, and how to operate, verify and troubleshoot it. The background and
the rollout record are in ~/tech/SPEC-tower-cluster-credentials.md. The cluster-side mechanism is generic and lives in
apron (docs/cluster-identity.md, components.clusterIdentity).
Until 2026-10-12 Tower used one
kubernetes.io/service-account-tokenfor dev.
- It never expired.
- It lived in four places: dev, Infisical, a prod Secret, and the process environment.
- It was sent without TLS verification.
- It could create PipelineRuns in any namespace, which made it cluster-admin-equivalent.
- Any signed-in user could use it through the Kubernetes proxy.
That token and everything that carried it are deleted.

The two identities
| Reader | Writer (actor) | |
|---|---|---|
hangar:<prod-cluster>:backstage-ingestor |
hangar:<prod-cluster>:tower-actor |
|
| Group | hangar:<prod-cluster> |
hangar:<prod-cluster> |
backstage/backstage-ingestor (the Backstage pod’s own) |
backstage/tower-actor (no pod runs as it, no RBAC on |
|
| Token | projected volume at /var/run/secrets/hangar/<dev-cluster>/token: audience <dev-cluster>, 600 s, bound to the pod, refreshed by the kubelet at 80% |
TokenRequest per write action: audience <dev-cluster>, 600 s, held in memory for that action only. Not bound to the pod: Kubernetes binds a token only to a pod running as the same ServiceAccount, and none runs as tower-actor |
| Used by | the Kubernetes proxy (Tower’s reads that don’t come from glidepath-api: Argo CD, Rollouts, pods, Crossplane; the catalog ingestor), the pollers (recent activity, SLO transitions, release records), glidepath-api’s reads (runs, live runs and logs, release state, image evidence, tests) | glidepath-api’s writes (Tekton Re-run and Cancel, Flight promote and rollback runs), break-glass (grant, debug container, attach, cleanup) |
12 read bindings in gitops-cluster-dev 00-bootstrap/backstage-ingestor-rbac/rbac.yaml: view, CRDs, Crossplane, Rollouts, Gateway API, GitHub MRs, Infisical projects, RBAC objects, topology resources, Argo CD Applications, and the Prometheus and glidepath-api service proxies. No write verb. Until P6 (2026-10-30) it also had the Rekor, Testkube and Tekton Results (tekton-results-relay) service proxies. |
tower-actor-rbac.yaml: view, tower-actor-glidepath-api-proxy (services/proxy on glidepath-api in platform-system) and tower-breakglass-binder (create/delete tower-breakglass-* RoleBindings, bind the two debug roles). No Tekton grant: until P6 (2026-10-30) it held backstage-tekton-rerun (create/patch PipelineRuns) |
On system:serviceaccount:backstage:backstage-ingestor),
with its usual short-lived token. Nothing on this page changes that.
A write, end to end

- The browser posts to the backend’s own route (
/api/glidepath/tekton/rerunor/cancel; a Flight promotion or rollback:/promote,/release/rollback). Tower never writes through the Kubernetes proxy, which refuses anything but GET. - The backend checks the permission on the app’s catalog entity (
tower.pipeline.rerun/.cancel, ortower.release.promote/.rollback: owners or admins). It audits the decision either way, and mints nothing for a refused request. towerActor.tsasksfor a tower-actortoken. Backstage’s ServiceAccount may create tokens for that one name only (Roletower-actor-token-minter).- The backend calls glidepath-api with that token, through
’s API server ( /api/v1/namespaces/platform-system/services/glidepath-api:8080/proxy/v1/...), naming the person inX-Glidepath-On-Behalf-Of(packages/backend/src/glidepathApi.ts). ’s API server checks the token against’s published keys and the claim rules (below). It maps the token to hangar:<prod-cluster>:tower-actorand applies that user’s RBAC, which opens glidepath-api’s service proxy and nothing else on Tekton.- glidepath-api checks the token again (TokenReview), authorizes
tower-actorwith a SubjectAccessReview on its own resources (rerun/cancelonruns.glidepath.hangar.io,promote/rollbackonenvironments.glidepath.hangar.io, from theglidepath-api-operatorandglidepath-api-releaserroles), audits the call with the person, and creates or patches the PipelineRun as its own ServiceAccount (system:serviceaccount:platform-system:glidepath-api).glidepath-api-pipelinerun-shapeadmits only Glidepath’s own run shape.
Until P6 (2026-10-30) the backend created and patched the PipelineRun itself as tower-actor.
Break-glass works the same way. It mints a fresh token for each step (activation, attach, state reads, the end of the
session), because a session can outlive a 10-minute token. Its RoleBinding’s subject is the
hangar:<prod-cluster>:tower-actor user, which the backend learns from a SelfSubjectReview.
Guardrails

| Control | Enforced by | Where it lives |
|---|---|---|
| The Kubernetes proxy is read-only: GET/HEAD/OPTIONS, no WebSocket upgrades, however the path is spelled | Backstage root router | backstage packages/backend/src/kubernetesProxyGuard.ts |
| Each write needs the app’s ownership, and is audited | Backstage permission framework | backstage towerPermissions.ts, permissionPolicy.ts, glidepathProvenance.ts (/tekton/*), breakglass.ts |
glidepath-api authorizes every call (TokenReview, then a SubjectAccessReview on glidepath.hangar.io resources) and audits it with the person |
glidepath-api | glidepath docs/admin/api.md; the glidepath-api-operator / -releaser ClusterRoleBindings for tower-actor (glidepath charts/glidepath-control-plane/templates/api/glidepath-api.yaml, subjects from apron templates/50-glidepath/glidepath-control-plane/values.yaml api.operators / api.releasers) |
Only tower-actor tokens can be minted, and only by Backstage |
gitops-cluster-60-backstage/backstage/tower-actor.yaml |
|
The issuer is <dev-cluster>, comes from namespace backstage, belongs to backstage-ingestor or tower-actor, and lives exp - iat ≤ 600 s |
--authentication-config) |
gitops-cluster-dev 00-bootstrap/cluster-identity/authentication-config.yaml (installed as /etc/kubernetes/authn/config.yaml on the node) |
| The reader can’t write; the actor holds only glidepath-api’s service proxy and the break-glass binder | gitops-cluster-dev 00-bootstrap/backstage-ingestor-rbac/rbac.yaml, tower-actor-rbac.yaml, breakglass-rbac.yaml |
|
PipelineRuns: none from tower-actor (“Tower writes PipelineRuns only through glidepath-api”). From glidepath-api’s ServiceAccount: only in an app- or infra- -cicd namespace, a cluster-resolver pipelineRef into platform-catalog, SA pipeline-runner, no inline spec, no taskRunSpecs, podTemplate hostAliases only, emptyDir/volumeClaimTemplate workspaces, no PaC annotations. Updates may only cancel. Until P6 (2026-10-30) this was tower-pipelinerun-shape, which admitted tower-actor’s runs. |
vpol-glidepath-api-pipelinerun-shape) |
gitops-cluster-dev 30-policy/kyverno-policies/tower-identity-writes.yaml |
RoleBinding deletes: tower-breakglass-* only |
vpol-tower-rolebinding-delete) |
same file |
| Break-glass grants, debug containers, pod deletes and annotations | vpol-tower-breakglass-*) |
30-policy/kyverno-policies/tower-breakglass.yaml; see Admission policies |
No kubernetes.io/service-account-token Secrets in backstage-system |
vpol-no-long-lived-sa-tokens) |
30-policy/kyverno-policies/no-long-lived-sa-tokens.yaml |
| The API server’s certificate is verified | Backstage (caData / caFile) |
backstage app-config.yaml (the <dev-cluster> and <prod-cluster> entries) |
What remains. Code running inside the Backstage backend can mint actor tokens. Those tokens can only call glidepath-api’s writes (re-run, cancel, promote, rollback), which glidepath-api authorizes and audits, and make break-glass’s debug grants, held to their shape by the policies. Each one is gated on ownership in the routes and recorded. Writing as each user’s own identity (spec phase 5) is what would narrow it further.
Configuration
Backstage: app-config.yaml
kubernetes:
clusterLocatorMethods:
- type: config
clusters:
- name: <dev-cluster>
url: https://<dev-cluster>-control-plane:6443 # the node name, which is in the API server certificate
authProvider: serviceAccount
authMetadata:
hangar.io/token-file: /var/run/secrets/hangar/<dev-cluster>/token # reads
hangar.io/actor-service-account: backstage/tower-actor # writes, minted per action
hangar.io/actor-audience: <dev-cluster>
hangar.io/actor-issuer-cluster: <prod-cluster> # the entry that mints it
serviceAccountToken: unused-the-token-file-is-used # never sent; see below
caData: <the dev cluster's CA, base64 PEM>
- name: <prod-cluster>
url: https://kubernetes.default.svc
authProvider: serviceAccount
caFile: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
hangar.io/token-file.kubernetesAuthStrategies.tsreplaces theserviceAccountstrategy with one that reads this file on every request (and otherwise behaves like the stock strategy). It also re-registers every stock strategy, because adding any strategy replaces the plugin’s default map.clusterAccess.tsuses client-node’stokenFileprovider, which re-reads the file every 60 s.- The three
actorkeys must be set together.towerActor.tswriteKubeConfig()uses them. Without them, writes fall back to the entry’s fixed token, which is the placeholder, so they are refused: the setup fails closed. - The placeholder
serviceAccountTokenmust stay.@backstage/plugin-kubernetes-backend’s proxy (0.21.10,KubernetesProxy.getClusterForRequest) treats aserviceAccountcluster with noserviceAccountTokenas the pod’s own cluster when Backstage runs in-cluster, and rewrites its URL tokubernetes.default.svc. Removing the placeholder sends every kind-dev read to prod. That happened for 21 minutes on 2026-10-12, as 401s on everyproxy read. A local harness can’t reproduce it, because it doesn’t run in-cluster.
: 60-backstage/backstage/
60-backstage/backstage/deployment.yaml:- the
dev-cluster-tokenprojected volume (audience<dev-cluster>,expirationSeconds: 600), mounted at/var/run/secrets/hangar/<dev-cluster>; - the
<dev-cluster>-control-planehostAlias.
- the
tower-actor.yaml: thetower-actorServiceAccount and the minter Role and RoleBinding.’s API server signs tokens ashttps://<issuer-host>/oidc/<prod-cluster>. Its first--service-account-issueris that URL; the default stays as a second, accepted issuer; and--api-audienceslists both (00-bootstrap/cluster-identity/README.md).
: gitops-cluster-dev
00-bootstrap/cluster-identity/authentication-config.yaml: the trust rules. It’s node-level, so Argo CD doesn’t sync it; it’s installed with apronhack/cluster-identity.sh trust.00-bootstrap/backstage-ingestor-rbac/: the reader’s and the actor’s RBAC (rbac.yaml,tower-actor-rbac.yaml,breakglass-rbac.yaml). The reader’s glidepath-api grant keeps its old Role name,backstage-ingestor-tekton-results-proxy, so the Application (no prune) updates it in place.30-policy/kyverno-policies/:tower-identity-writes.yaml,tower-breakglass.yaml,no-long-lived-sa-tokens.yaml.
The issuer’s public keys
The keys are served by hangar’s site at https://<issuer-host>/oidc/<prod-cluster>/.well-known/openid-configuration
and /jwks.json. They’re public keys only; the source is the issuer site’s oidc/<prod-cluster>/.
Operations
Verify the trust end to end. This is read-only: it mints a token on
apron/hack/cluster-identity.sh verify --from-context <prod-cluster> --namespace backstage \
--service-account backstage-ingestor --audience <dev-cluster> --to-context <dev-cluster>
# accepted as hangar:<prod-cluster>:backstage-ingestor; wrong audience, 20-minute token, backstage/default refused
Is Backstage authenticating? apiserver_authentication_jwt_authenticator_latency_seconds_count{result="success"} keeps rising while Backstage
reads:
kubectl --context <dev-cluster> get --raw /metrics | grep '^apiserver_authentication_jwt_authenticator_latency_seconds_count'
Kill switch. Edit /etc/kubernetes/authn/config.yaml on jwt entry. The API server reloads the file within seconds, and every hack/cluster-identity.sh trust again with the file from gitops-cluster-dev.
Narrower revocation.
- Remove a user’s bindings in gitops-cluster-dev.
- Or remove
tower-actorfrom the trust file’s ServiceAccount list, which stops writes and keeps reads. - Or drop the minter Role on
.
If sa.key), republish the keys before
anything else, or
apron/hack/cluster-identity.sh publish --context <prod-cluster> --issuer https://<issuer-host>/oidc/<prod-cluster> \
--out <issuer-site>/oidc/<prod-cluster>
A recreated components.clusterIdentity /
hack/cluster-identity.sh issuer). A recreated trust), plus its new CA in
the <dev-cluster> entry’s caData.
Local harness. The old
kubectl --context <dev-cluster> --as=hangar:<prod-cluster>:tower-actor --as-group=hangar:<prod-cluster> proxy --port=8013 --reject-paths='^/never$'
tower-actor can read (view), call glidepath-api and bind the debug roles; it can’t create or patch a PipelineRun.
Use hangar:<prod-cluster>:backstage-ingestor to check that something works read-only.
Troubleshooting
| Symptom | Likely cause | Check |
|---|---|---|
| Every |
The proxy is sending the request to <dev-cluster> entry has no serviceAccountToken (the in-cluster rewrite above) |
serviceAccountToken placeholder present in the deployed app-config.yaml |
oidc: … errors |
The token was refused: wrong audience, a lifetime over 600 s, another ServiceAccount, or keys not fetchable | kubectl --context <dev-cluster> logs -n kube-system -l component=kube-apiserver | grep 'Unable to authenticate'; verify above |
socket disconnected before secure TLS connection was established |
The laptop forward to |
ssh laptop 'curl -sk -m4 -o /dev/null -w "%{http_code}" https://192.168.1.78:6443/version' |
Reads fail with unable to verify the first certificate |
The caData is wrong ( |
Compare it with kube-root-ca.crt |
A Re-run or Cancel returns 403 with …is not allowed tower.pipeline.rerun… |
The user doesn’t own the app | Expected; admins and owners only |
A write fails with TokenRequest … forbidden |
The minter Role or the tower-actor ServiceAccount is missing on |
kubectl --context <prod-cluster> -n backstage get sa tower-actor; get role tower-actor-token-minter |
A write is denied request: … |
An admission policy refused its shape | The message names the rule; see the table above |