BeyondTrust/POV

← Back to dashboard

Design: a workload reaching Kubernetes with a short-lived token

Audience: contributor · Profile: demo · Read this when: you are about to build the Workload Lab's Kubernetes tab, or you are deciding whether a SPIFFE identity should be able to authenticate to a cluster at all.

Built, and never run against a live pair. The five stages in The playbooks are written, their invariants are pinned by tests/test_playbook_spire.py and tests/test_playbook_k3s.py, and the dashboard drives them from the Kubernetes action on a SPIRE lab's row (POST /api/spire-lab/{id}/k8s-link, job type spirelab_k8s_link). Every play is still marked NEVER LIVE-VALIDATED in its own header. Treat the first real run as the validation.

This is not a tab. It was, briefly, and that was the wrong shape: a tab explaining a by-hand procedure with copy-pasted join tokens is precisely what the SPIRE page exists to replace — standing this up by hand is the afternoon that page eliminates. It is a panel on the lab's own row, and the orchestrator passes the join token and trust bundle between hosts so nothing is copied by hand.

The problem

The SPIRE lab mints audience-scoped JWT-SVIDs and nothing in this dashboard ever presents one to anything. docs/workload-lab/spiffe.md proves issuance and proves governance — discovery returns 8 of 11 entries, attestation-policy findings fire — but the credential's whole point is that some relying party accepts it, and no page demonstrates a relying party at all. A reviewer can reasonably ask whether the SVID works, and the honest answer today is "the plugin's test suite says so."

Kubernetes is the relying party worth picking, because the dashboard already provisions clusters and already has a competing answer to compare against.

1. There is already a short-lived token path here, and it is the baseline

k8s-sa-token-rotation.md and docs/kubernetes.md describe POST /clusters/{id}/ps-token: a ServiceAccount token onboarded as a Password Safe managed account on the Kubernetes Service Account Token plugin, with Bound mode issuing TokenRequest-API bound tokens.

That is a real short-lived token and it is not what this replaces. It is what the pattern here has to be better than, on one axis:

Bound-mode SA token (built, live) JWT-SVID over the Workload API (plays written, unvalidated)
What authenticates a bearer token a bearer token
Where the credential rests Password Safe, and PRA Vault via the synced account nowhere — process memory, for minutes
Who can replay it anyone who retrieves it from either vault a caller on the attested workload's own machine
What binds it the ServiceAccount's uid claim node + workload attestation
Audience-scoped yes yes
Revocation delete the SA (invalidates every token ever issued) delete the entry; the SVID lives out its TTL
Works on EKS / AKS / GKE yes no — see §3
Governed as an inventory row yes no

The row that matters is the second one. docs/workload-lab/spiffe.md already argues this in the other direction — minting into a vault is "a strict downgrade… if a workload can reach the Workload API, it should use the Workload API" — and then the lab never shows the un-downgraded path. Putting both on one page, with this table, is a stronger demonstration than either alone, because the interesting claim is not "short-lived tokens are good" but "here is what each one still leaves lying around."

So this tab is not a replacement for the ps-token path and must not be built as one. A cluster whose broker needs a vaulted credential still needs Bound mode; the two answer different questions.

2. The pattern exists upstream, and using it beats inventing one

Two projects in the SPIFFE GitHub org, both Apache-2.0, do exactly this:

yaml user: exec: apiVersion: "client.authentication.k8s.io/v1" command: "k8s-spiffe-workload-jwt-exec-auth" interactiveMode: Never

It also takes SPIFFE_JWT_SOURCE=server-admin-api to mint from the SPIRE Server admin API instead of the Workload API. Do not use that mode in this lab. It is the same attestation bypass docs/workload-lab/spiffe.md spends a section refusing to make the default, and choosing it here would demonstrate the downgrade while claiming to demonstrate the upgrade.

yaml jwt: - issuer: url: https://oidc-discovery.example.org audiences: - k8s

Consumed with --authentication-config=/etc/kubernetes/pki/auth-config.yaml.

Both carry a "Development Phase" badge and have single-digit stars. They are real and officially owned, not production-proven, and that belongs on the tab rather than in a footnote — the same way the SPIRE and Certificate features already say which of their paths has never been run against a live authority. The value here is that the pattern is upstream and standard: the API server side is ordinary OIDC, so nothing in this repo invents a trust mechanism.

Note the discovery provider is a third daemon, not a SPIRE server flag, and it ships in the spire-extras tarball rather than the spire one the server play fetches. It must also be reachable by the API server over TLS, which makes it a network question as much as a packaging one: spire-oidc-provider.yml refuses an oidc_domain of localhost for exactly that reason. Neither is the SPIRE agent something the lab had before — spire-server-install.yml deliberately installs none, because the plugin only ever talks to the server's API.

3. --authentication-config is what picks the cluster, and it excludes all three clouds

Structured Authentication Configuration is stable as of Kubernetes 1.34 (apiVersion: apiserver.config.k8s.io/v1; it first shipped in 1.31 behind earlier API versions). It replaces the old single-provider --oidc-* flags, takes multiple JWT issuers, maps claims to username and groups through CEL, and reloads on file change.

It is also a kube-apiserver flag, which is the constraint that decides everything else:

So this is a k3s story. That is a real limitation to state plainly on the tab, not to work around: the pattern's reach is self-managed control planes, and a customer running only EKS cannot adopt it.

4. The demo must not touch the seed playbook's entry count

spire-seed-entries.yml writes 11 registration entries and docs/workload-lab/spiffe.md asserts discovery returns exactly 8 of them. That number is load-bearing — it is the assertion that caught the plugin defaulting its discovery path filter to the mintable prefix, and a test reads it.

The workload's entry (a unix-UID selector, with k8s in its audience list) therefore goes in the new playbook, not in spire-seed-entries.yml. Adding it to the seed set would move the count and silently retire the one assertion that has already caught a real bug.

The playbooks

Four, not the three this note first sketched. The sketch put the workload's registration entry in k3s-spiffe-auth.yml, which cannot work: spire-server entry create needs the server's own CLI and its admin socket, and that play targets the k3s node. Anything the SPIRE server has to be told is therefore its own play, on its own host.

Play Host Does
spire/spire-oidc-provider.yml SPIRE server The OIDC Discovery Provider, publishing /.well-known/openid-configuration and JWKS over TLS on an address the API server can reach
spire/spire-k8s-entry.yml SPIRE server A join token for the k3s node, the workload entry carrying the k8s audience, and the trust bundle both later plays need
spire/spire-agent-install.yml k3s node The SPIRE agent, and a JWT-SVID fetched as the workload account to prove the chain before reporting success
k3s/k3s-spiffe-auth.yml k3s server The AuthenticationConfiguration, the apiserver flag as a config.yaml.d drop-in, an RBAC binding whose subject is the SPIFFE ID, and the exec-auth binary plus a credential-free kubeconfig

Five stages, because a plain VM has to become a k3s server first (k3s/k3s-server-init.yml runs before the four above). All are fetched by bare filename from the storage backend and run through Config Management, as the spire-* four are — uploading them to Storage is the same prerequisite.

Three things about the orchestration are worth recording, because none is recoverable from the code alone:

The ACL is deliberately asymmetric: only the SPIRE host gains a rule, for tcp/8081 and tcp/8443, sourced from the k3s node's private address alone. The k3s node needs nothing inbound, because the workload runs on it.

Four things the writing settled, each of them a trap:

And two that are still open:

What this still does not demonstrate