BeyondTrust/POV

← Back to dashboard

What consumes these credentials

Audience: operator · Profile: demo · Read this when: someone asks what actually spends what the lab issues, or you need to pick the consumer to run in front of a room.

Preview. Two of the consumers here have not run live end to end: the MCP worker (its SPIRE fetches and the dashboard's acceptance of its JWT-SVID have run against SPIRE 1.15.3 locally; the loop on a real host against a live MCP endpoint has not) and the wlc / ps token sources it offers (no Workload Credentials tenant, no registered Workload Identity, no federation trust). The shipped playbooks have code behind every claim on this page; whether each has been run against a live authority is stated per tab, and the register at the end is the honest summary.

Every tab governs what it creates is the invariant of the feature. This page is its mirror: every tab is spent by something, and the something is a file with a path.

It needs stating because the record was wrong about it for a while. The demo cells we do not have argued that the Workload Lab issued four credentials and that nothing consumed them — calling the candidates the hub names, "a pipeline, a broker or a cluster", hypothetical. That was an under-reading of what had already shipped, and it has been corrected three times since. This page is the register, so the claim cannot go stale again in either direction: it names what spends each credential, what that consumer has to hold in order to retrieve, and the places where the answer is still nothing does.


Something spends each of the four

Tab What spends it What that consumer holds in order to retrieve
Certificates examples/playbooks/certificates/ci-fetch-cert.yml, against nginx-mtls-endpoint.yml a Password Safe OAuth client, injected into the runner
SPIRE examples/playbooks/agent/files/mcp_agent.py nothing — the SVID is re-fetched from the workload API every loop
Kubernetes examples/playbooks/k8s/ci-deploy-with-ps-token.yml, ci-read-with-ps-token.yml, and the agent cell's --k8s-episode the plays: a Password Safe OAuth client. The agent: nothing
Cloud examples/playbooks/cloud/ci-run-with-dynamic-creds.yml, and the agent cell's --cloud-episode the play: a Workload Credentials token in the runner's environment. The agent: nothing — its own identity token

The third column is the one to read. "A consumer exists" is a low bar — every vault has consumers. What differs between these four is how much the consumer must already hold to get what it is asking for, and that is the axis the whole feature is arguing along.


The certificate is spent against an endpoint that checks the name

Two plays, and they are halves of one demonstration:

File What it is
examples/playbooks/certificates/nginx-mtls-endpoint.yml nginx on :8443 with ssl_verify_client on, echoing $ssl_client_s_dn
examples/playbooks/certificates/ci-fetch-cert.yml fetches both halves, splits the bundle, calls the endpoint

The proof is the echo, not the handshake. curl succeeding proves TLS completed. The endpoint answering CN=svc-deploy-pipeline proves the certificate authenticated as the right identity — which is step 6 of Proving it works, and the step demonstrations usually skip. A certificate that verifies in a console but fails a handshake has proven nothing.

The consumer fetches both halves, which is what makes the two-halves split visible as a control rather than an implementation detail: the passphrase from the managed account with beyondtrust.secrets_safe.managed_account, the bundle from a Secrets Safe file secret with beyondtrust.secrets_safe.secret. Retrieving one without the other yields nothing usable. The reason string carries the build_id, so the audit trail answers which build spent it.

What it holds: PASSWORD_SAFE_API_URL, _CLIENT_ID and _CLIENT_SECRET, injected into every runner. Not nothing — see the register below.

What is deliberately not consumed here: the endpoint's server certificate is self-signed and unrelated to the lab CA, which is why the play uses curl -k. Issuing it from the lab CA would blur which half of the handshake is being demonstrated.


The SVID: evidence by default, the authenticator with a service account

examples/playbooks/agent/files/mcp_agent.py re-fetches its SVID from the SPIRE workload API on every loop and prints it beside the credential it spent:

[agent] spiffe://weaverlab.test/agent/mcp-reader · token "mcp-reader-pat" · 14 active jobs, 2 failed today · 14:02:11

The worker is installed by examples/playbooks/agent/agent-install.yml on the lab's Kubernetes-linked node — the host that runs a SPIRE agent — and its registration entry comes from agent-spiffe-entry.yml on the SPIRE server.

With a PAT or a client secret, the SVID is evidence. It is consumed as proof of who is running, and the PAT or client pair is what is actually spent. The worker prints both on one line precisely so that gap stays visible rather than being narrated away.

With a service account and a registered trust domain, the SVID is the authenticator. --token-source spiffe presents a fresh JWT-SVID at the dashboard's /api/oauth/token as an OAuth client assertion, and the dashboard — which verifies it against the trust domain's keys, fetched from the lab's JWKS URL — returns an access token that lives minutes. Nothing is stored on the host. It still does not authenticate to /mcp directly (web_dashboard/api/mcp_server.py has no mTLS path; it sees the bearer token), and the other bridge — the SVID minting a credential inside Password Safe through the SPIFFE SVID plugin — is still unresolved (services/spire_lab_service.py). The exchange has run against a real SPIRE 1.15.3 JWT-SVID, with a replay, a wrong audience and a revoked client all refused; the full loop on a host has not.

There is one more path where the SVID is an authenticator: --identity-platform spire mints a JWT-SVID and presents it to Workload Credentials, because the lab already publishes the trust domain as an OIDC issuer. That is the row that covers a bare-metal host with no cloud underneath it. It has never been run against a Workload Credentials tenant.

The SPIRE lab's own plays in examples/playbooks/spire/ stand the trust domain up. They do not spend an SVID, and this page does not count them as consumers.


The cluster token has three consumers, and they differ in what they hold

examples/playbooks/k8s/ci-deploy-with-ps-token.yml and ci-read-with-ps-token.yml are the first Kubernetes plays in this repo that authenticate with something they fetched themselves. The other four in that folder — deployment-apply.yml, helm-install.yml, list-nodes.yml, namespace-ensure.yml — take the kubeconfig the dashboard injects and supply nothing, which is exactly the property these two remove.

The refusals are asserted tasks inside the plays, not runbook steps. Deploying into another namespace must come back 403; reading a Secret as reader must come back 403. A step in a runbook gets skipped, and an assertion does not — if the refusals do not refuse, the plays fail.

The third consumer is the agent cell, and what it adds is not a consumer but a consumer that holds nothing to retrieve with. POST /api/agentcell/agent/{id}/k8s-request (web_dashboard/api/agentcell.py) opens one bounded episode — the Request access button on the lab's own Agent tab, once the agent is linked to a Kubernetes token there — and mcp_agent.py --k8s-episode runs it: it asks, waits for a person, runs the same two probes as steps 3 and 4 of Proving it works, and checks the request slot back in. Its chain is workload identity → Workload Credentials → the Password Safe client pair → POST Auth/Connect/Token → SignAppIn → POST Requests → GET Credentials/{id} → PUT Requests/{id}/Checkin.

Exit codes are the punctuation: 0 proved the scope, 3 was never approved, 4 means a refusal did not refuse — the one outcome that would otherwise look like success.

See Agent Demo Cell for the presenter's version of this, including what the approval gate does not prove.


The cloud credential has two consumers, and only one of them mints

examples/playbooks/cloud/ci-run-with-dynamic-creds.yml uses a credential minted beforehand, then asserts the same call is refused once the lease expires — with a real wait, not a faked clock. A play that faked the clock would prove it can print a failure message, not that the credential died.

The play does not mint, and the second reason is the one that matters. Issuance is metered, so a play that minted on every run would bill on every run. More importantly, the consumer retrieving with its own token is the point — a play that minted on the operator's behalf would put the operator in Workload Credentials' audit log, which is the exact property the mechanism exists to remove.

Revocation is asymmetric and the play says which case it is in: Azure leases can be released early, AWS leases cannot be revoked at all, so there the TTL is the only control there is.

The agent mints, because the agent is the consumer

This section said "the agent cell does not spend this one" until the worker learned to. The sentence it rested on — that tab's credential "is returned to nobody" — was always about the dashboard, and is still true of the dashboard: no route there returns a cloud credential, and api/agentcell's docstring records why one was not added. It was being read as a statement about the mechanism.

So the two consumers differ in a way worth stating rather than smoothing over: the play reads a credential somebody else wrote into its environment; the worker is the thing the credential was issued to. mcp_agent.py --cloud-episode presents this machine's own identity token to Workload Credentials, generates against the dynamic secret named by the link, and the values never pass through the dashboard. The audit entry is the workload.

Its chain is short, and the shortness is the point: workload identity → Workload Credentials → POST /dynamic/{name}/generate. No Password Safe — the other two episodes bootstrap a retrieval of something the vault already held, and there is nothing held here, because WC mints.

Three consequences the other episodes do not have:

The scope is not asserted by the dashboard, and cannot be. The other two episodes probe a limit the dashboard chose — a RoleBinding, a certificate profile. Here the dynamic secret's definition in WC decides, and neither the dashboard nor the worker can read it, so the deny probe is an assertion the operator makes (--cloud-deny-probe, default iam-list-users on AWS and graph-directory-read on Azure). --cloud-deny-probe none is allowed and prints that the run proves authentication and not scope.

The link itself still records a lease state worth reporting beside the agent, and workload_cloud_service.lease_state keeps "expired" and "failed" apart so an expiry reads as the mechanism working rather than as a fault.


The worker itself is the fifth consumer

The MCP worker spends a dashboard credential against /mcp on a loop, and --token-source decides what sits on the host:

--token-source What sits on the host
spiffe nothing. A JWT-SVID from the local SPIRE agent, exchanged at the dashboard's token endpoint for a minutes-long access token
file (default) a 0600 file holding the PAT or client pair — a static secret, smaller than an env var but still one
wlc nothing. The platform vouches for the machine; WC serves the PAT from its own store
ps nothing. WC hands over the Password Safe client pair and the worker requests the credential

Re-running the install play with wlc or ps removes any token a previous file install left behind, so switching is a migration rather than an accumulation.


What is not consumed, and saying so is the point

The section that keeps this page honest. Each of these is true of a named file or setting.


Two traps every consumer here encodes

Cross-cutting, and worth one heading because the same bug has now been written twice.

Blank what the runner already has. The k8s plays blank K8S_AUTH_KUBECONFIG and KUBECONFIG; the cloud play blanks AWS_ACCESS_KEY_ID and its siblings. Left set, the client falls back to them: every task passes, the refusal never refuses, the expiry assertion never fires, and the play reports a successful demonstration of a mechanism it never touched.

The agent's cloud episode sidesteps this rather than solving it, and the reason is worth knowing: it signs every request itself from the values it just minted, so there is no credential chain to fall back to. A worker host carrying AWS_ACCESS_KEY_ID would not change a single call the episode makes.

failed_when: false, never ignore_errors. The refusal task has to fail so the next task can judge why. ignore_errors also swallows an unreachable API server, a missing CLI or a broken proxy — and the assertion then passes on a typo. Every assertion here checks both that the call failed and that it failed for the right reason: a 403, or a message naming an expiry or an auth failure.


Where things live

Certificate consumers examples/playbooks/certificates/
Cloud consumer examples/playbooks/cloud/
Cluster-token consumers examples/playbooks/k8s/ — ci-deploy-with-ps-token.yml, ci-read-with-ps-token.yml
The agent cell examples/playbooks/agent/, files/mcp_agent.py
SPIRE trust domain examples/playbooks/spire/
Episode API web_dashboard/api/agentcell.py
Lease and token state web_dashboard/services/workload_cloud_service.py, workload_k8s_service.py
The MCP surface the worker reads web_dashboard/api/mcp_server.py
The design note this page answers The demo cells we do not have