BeyondTrust/POV

← Back to dashboard

Design: SPIRE for agents, Dex for people, and nothing existing removed

Audience: contributor · Profile: both · Read this when: you are about to give the remote agent a SPIFFE identity, put Dex in front of the dashboard's SSO or a cluster's API server, or you are wondering why the Ed25519 agent path was not retired when SPIRE arrived.

Partly built. What exists: - the two compose overlays: docker-compose.spire.yml for the dashboard and examples/remote-agent/docker-compose.spire.yml for the agent, - SPIRE attestation for agents: POST /api/agent/attest, the Migrate to SPIRE action and banner, and central credential storage for attested agents (agent 2.6.0 and later), - the Dex lab plays: examples/playbooks/dex/ and examples/playbooks/k3s/k3s-dex-auth.yml, and the on-prem Dex kubeconfig for cloud="local" clusters, - the shared AuthenticationConfiguration handling that lets the SPIFFE and Dex authenticators sit on one k3s API server.

Phase 2 lists each of these with its details, then what is still to build (Dex as the dashboard's SSO provider, a Dex overlay for the dashboard host, and the rest). Managed clusters on Dex are paused. None of it has run against a live host. The dashboard's own SPIFFE identity, which came after this note, has its own: dashboard-workload-identity.md.

The problem

There are two problems, and they are different enough that one tool should not solve both.

  1. The remote agent keeps a long-lived private key on disk. - runners/agent/agent.py generates an Ed25519 key at enrolment and writes it, unencrypted, to /var/lib/dashboard-agent/identity.json. - docs/remote-agents/agent-host.md is honest about this: whoever can read the volume is the agent until someone re-issues it.
  2. People reach Kubernetes six different ways. - EKS, AKS, GKE and OKE each have their own token exec plugin and their own user model. - k3s and other on-prem clusters have whatever the operator set up. - The dashboard's own SSO is a separate OIDC client again. - There is no single place where "this person, in this group" means the same thing everywhere.

The split - SPIRE is for workloads. It attests a machine or process and issues short-lived SVIDs. The agent is a workload. - Dex is for people. It federates upstream human IdPs (Entra, Okta, LDAP, GitHub) into one OIDC issuer that every cluster and the dashboard can trust.

Dex was looked at for the agent first and rejected for it. See Rejected.

The rule: additive

Nothing here removes or changes a path that works today, with one deliberate exception: how people get a kubeconfig for an on-prem cluster (see On-prem: the one path that changes).

Today Stays New, opt-in
Agent enrols with agte_… and keeps Ed25519 in identity.json Yes, permanently, and still the default Agent attests through a SPIRE sidecar
SSO points straight at an IdP (oidc_* settings) Yes, and still the default SSO points at Dex, which points at the IdP
Cluster access per cloud (EKS/GKE/OKE exec plugins, AKS with Entra) Yes, and the only path ~~A managed cluster's API server also trusts Dex, per cluster, opt-in~~ Paused, see Managed clusters: paused
On-prem clusters (k3s, cloud="local"): no standard way for people, only the admin kubeconfig The admin kubeconfig stays, as the dashboard's own credential and break-glass; it is never handed to people Dex, as the only way people reach an on-prem cluster

Three reasons the legacy agent path is permanent rather than deprecated: - The proxy constraint below. Some sites can never reach a SPIRE server. - Agents already in the field must keep working after a dashboard upgrade, with no action from whoever runs them. - Rollback. If a SPIRE migration goes wrong, the fix is to re-issue an agte_ code, which is the procedure operators already know.

The dashboard recommends SPIRE with a banner. It never forces it. See The banner.


Part 1: the agent attests through SPIRE

Two facts that bound what this buys

1. "No private key on disk" depends on the node attestor. It is not a given.

The SPIRE agent has to prove which node it is on before it gets an SVID. How it does that decides whether a key is stored anywhere:

Where the agent runs Node attestor Agent KeyManager Private key at rest? Restart behavior
Bare Docker host, no platform identity join_token disk Yes. The SPIRE agent's own SVID key, rotated hourly and revocable with spire-server agent ban Resumes from disk; the token is already spent
AWS EC2 aws_iid memory No Re-attests from the instance identity document
Azure VM azure_imds (azure_msi on older SPIRE) memory No Re-attests from IMDS
GCP VM gcp_iit memory No Re-attests from the instance identity token
Pod on a cluster the SPIRE server can reach k8s_psat memory No Re-attests from a projected SA token
Host with a TPM tpm_devid (or a TPM plugin) memory No; the key is in the TPM Re-attests from the TPM

2. SPIRE agent ↔ server traffic is mTLS gRPC. TLS-inspecting proxies break it.

agent_signing.py explains why the agent signs requests instead of using mTLS: an inspecting proxy terminates TLS and cannot forward a client certificate. The SPIRE agent's connection to the server is mTLS gRPC on port 8081, so it hits the same wall.

sealing.key is a separate key and is out of scope. - It is the agent's host-local AES key for password_sealed: / client_secret_sealed: values (agent.py:438). - It is not an authentication credential, and SPIRE does not replace it. - It stays in the agent_state volume in both modes. The agent overlay keeps that volume for this reason.

The flow

spire-agent (sidecar)                dashboard-agent (uid 10001)               dashboard
──────────────────────               ───────────────────────────               ─────────
node attestation ──── mTLS gRPC :8081 ───────────────────────────────────────▶ spire-server
                     ◀── agent SVID (memory or disk) ──

                     ◀── Workload API (unix socket, attested by uid 10001) ──
                         FetchJWTSVID(aud = <dashboard>/api/agent/attest)
                                     generate Ed25519 keypair IN MEMORY
                                     POST /api/agent/attest ──── HTTPS ───────▶ verify SVID
                                       body: {svid, public_key}                  (spiffe_assertion:
                                       signed with the NEW key                    sig, aud, lifetime
                                                                                  cap, single use)
                                                                                 map SPIFFE ID →
                                                                                   RemoteAgent row
                                                                                 bind public_key
                                     ◀── {agent_id, dashboard_public_key, audience}
                                     every later request: the EXISTING signed path
                                       (X-Agent-Id / -Timestamp / -Nonce / -Signature)

How each side picks a mode

Agent side - AGENT_SPIFFE_JWT_FILE set → attest through SPIRE at every start, key in memory, identity.json neither read nor written. - Otherwise → today's behavior exactly: use identity.json if present, else redeem AGENT_ENROLLMENT_CODE. - A new agent image without the sidecar is indistinguishable from the old one.

Where the token comes from (as built). Not the Workload API directly: that is gRPC, and the agent image deliberately carries four Python packages. A spiffe-helper sidecar runs as the agent's uid (10001), so the unix:uid:10001 entry matches it, and keeps a JWT-SVID for <DASHBOARD_URL>/api/agent/attest in a memory-only (tmpfs) volume that only uid 10001 can read. The agent reads that file. It needs no socket and no shared PID namespace; the helper has both. A token that a previous start already presented is refused as a replay, so the agent waits for the helper's next rotation (half the token's lifetime) instead of failing.

Server side - RemoteAgent gets two new columns: - auth_mode (ed25519 | spiffe, default ed25519), - nullable spiffe_id (unique). - The migration (the additive ALTER TABLE list in database.py) needs no backfill: every existing row is ed25519 by default, which is what it already is. - /api/agent/attest accepts only rows whose spiffe_id matches the SVID's sub. An ed25519 row cannot be taken over by someone who happens to hold an SVID.

Migrating one agent

  1. An admin clicks Migrate to SPIRE on the agent's row. This needs the SPIRE server enabled (spire_server_enabled).
  2. The dashboard creates the SPIRE entries through the server's local admin socket: - a node entry for the chosen attestor (or a join token), - a workload entry spiffe://<td>/agent/<agent-id> with parent = that node and selector unix:uid:10001.

It then sets spiffe_id on the row. auth_mode stays ed25519 for now. 3. The operator adds the sidecar overlay on the agent host and restarts. 4. On the first successful attest, the server sets auth_mode = spiffe and nulls the old public_key in the same transaction. - This mirrors what re-issue does today. The legacy key stops working only once the new path has proven itself. - Agent id, job history and policy hash carry over. 5. Rollback: re-issue an agte_ code. Re-issue already clears the key. It now also clears spiffe_id and sets auth_mode = ed25519, and deletes the SPIRE entries.

The banner

The dashboard-hosted SPIRE server

What the server is used for next (the dashboard as a workload in its own trust domain, federated cloud credentials, and Workload Lab demos that no longer need the lab's server) is in The dashboard as a SPIFFE workload.


Part 2: Dex for people

What changes, and what doesn't

Cluster Human authentication Default Can it be changed?
EKS, GKE, OKE, AKS (managed) native (the cloud's own: EKS/GKE/OKE exec plugins, AKS with Entra) native No. A dex option was designed and is paused
k3s / on-prem (cloud="local") dex dex No. There is no native option to fall back to

Managed clusters: paused

Dex for managed clusters is paused, not scheduled. Each managed cloud already gives people its own short-lived, per-person identity: the EKS, GKE and OKE exec plugins, and Entra on AKS. Dex in front of those would add one more issuer to run and make public, and a Terraform change per cluster, to replace something that already works per person. The standardization it buys is real but small next to that cost.

So for EKS, AKS, GKE and OKE: - native is the only mode. k8s_human_auth_mode and its per-cluster override are not being built. - The dex_issuer_url Terraform wiring for the four managed-cluster modules is not being built. - The managed rows of Every cluster type can trust Dex stay as reference, not as a plan. Re-check them before reviving this: the GKE row in particular should be verified against current GKE guidance (Workforce Identity Federation) rather than taken as written.

On-prem is unaffected. Dex stays the only way people reach a cloud="local" cluster (built in #995), because there is no cloud identity to be native to.

On-prem: the one path that changes

Today a registered cloud="local" cluster shows the API tunnel button, and its download (GET /api/k8s/clusters/{id}/api-tunnel-kubeconfig → k8s_service.build_api_tunnel_kubeconfig) returns the stored kubeconfig, repointed at the tunnel. - For EKS, GKE and AKS that file is token-free: its users entry is the cloud's exec plugin, so each person authenticates as themselves. - For k3s the stored kubeconfig is the admin one from k3s-kubeconfig.yml, with the client certificate and key inline. So the same download hands cluster-admin credentials to anyone with k8s:read. That credential cannot be revoked per person, and the audit trail cannot tell people apart.

"Dex is the only way on-prem" closes that. In Phase 2, for cloud="local": - the route returns a kubeconfig whose users entry is the Dex oidc-login exec block (cluster CA kept, no client certificate, no key); - if the cluster's API server does not trust Dex yet, the route refuses with a message naming k3s-dex-auth.yml. It never falls back to the stored file.

The stored admin kubeconfig keeps doing what it does for the dashboard itself (registration, the transient runners, break-glass). It just stops being downloadable by people. This is the only behavior in this note that is removed rather than added, and it is removed because it hands out a shared admin credential. - Not affected: the transient-runner kubeconfig (k8s_service._runner_kubeconfig). It swaps in a server-minted token for the dashboard's own runs and has nothing to do with people. - Groups: Dex passes the upstream IdP's groups through in the groups claim. The cluster maps them to RBAC with a prefix (dex: in the lab play), so a Dex group can never collide with a built-in system: group.

Every cluster type can trust Dex

Standardizing on Dex is therefore always possible: mandatory on-prem, and technically available on every managed cloud. The managed half is paused.

Cluster Mechanism Where it gets wired Caveat
k3s / on-prem --authentication-config JWT authenticator (structured authentication, GA in 1.34) examples/playbooks/k3s/k3s-dex-auth.yml none
EKS OIDC identity provider config aws_eks_identity_provider_config in terraform/k8s_cluster/aws_eks one external OIDC provider per cluster
GKE Identity Service for GKE identity_service_config and its ClientConfig in terraform/k8s_cluster/gcp_gke Identity Service must be enabled on the cluster
OKE Cluster OIDC token authentication open_id_connect_token_authentication_config in terraform/k8s_cluster/oci_oke check the pinned OCI provider supports it
AKS Structured authentication, preview an azapi resource in terraform/k8s_cluster/azure_aks (azurerm likely lacks it) preview, Kubernetes ≥ 1.30

On every managed row this is paused (see Managed clusters: paused); the rows are kept as reference. On the k3s row it is required and built.

Dex must be public

Managed control planes fetch the issuer's discovery document and JWKS from the internet.

Dex and SPIRE on one k3s API server

k3s takes one --authentication-config file. That file holds a list of JWT authenticators, so both can live there, as long as neither play overwrites the other's entry. The rules:


Rejected

Phase 2: what is specified here and not built

Built since: the on-prem Dex kubeconfig. For cloud="local" clusters the API-tunnel download is a kubectl oidc-login kubeconfig against Dex (k8s_service._onprem_dex_tunnel_kubeconfig), gated on the Dex settings (dex_issuer_url, dex_k8s_client_id, dex_ca_pem) and the per-cluster Trusts Dex flag (POST /api/k8s/clusters/{id}/dex-trust). Without either it refuses with the missing step. The stored admin kubeconfig is never returned. See docs/kubernetes.md.

Also built: SPIRE attestation for agents. - POST /api/agent/attest (agent_service.attest, verification by spiffe_assertion.verify + consume), off unless Settings → Remote agents → Let agents attest through SPIRE (spire_attest_enabled) is on, and checking the SVID audience against the pinned audience only. - RemoteAgent.spiffe_id / auth_mode, bound with POST /api/agents/{id}/spiffe-id (agents:write; "" unbinds and cuts off an attested key). Re-issuing an enrolment code clears both: that is the rollback. - The agent's AGENT_SPIFFE_JWT_FILE mode (agent 2.6.0) and the spiffe-helper layout in examples/remote-agent/docker-compose.spire.yml.

Also built: the one-click Migrate to SPIRE action and the banner (services/dashboard_spire.py, POST /api/agents/{id}/migrate-spire). - It drives the dashboard's SPIRE server with docker exec <spire_server_container> spire-server … -output json, the way the SPIRE lab drives its Docker-mode server. The Docker socket and CLI were already in the app image, so there is no gRPC dependency, and the compose overlay's admin-socket mount is not needed for it. - On a managed container runtime there is no Docker socket, so the same CLI calls run another way. The app runs the spire-server binary it carries against a sidecar's admin socket (SPIRE_SERVER_SOCKET; examples/cloud-spire). The worker, a separate deployment with no volume in common, relays each call through the database to an app process (services/spire_relay). One argv and one set of result checks serve all three transports. See On a managed container runtime. - One click: a one-use join token for spiffe://<td>/node/<agent-id>, the workload entry spiffe://<td>/agent/<agent-id> selecting unix:uid:10001 under it (reused if it exists), the trust domain registered from bundle show -format spiffe, and the binding. The modal shows the token once, the .env lines and the trust bundle for spire/bootstrap.crt. - The trust domain row is owned (created_by="dashboard-spire") and re-synced daily by the existing SPIRE refresh loop, because SPIRE rotates JWT keys within ca_ttl. A row a SPIRE lab or an admin registered under the same name is never overwritten. - The banner lists active Ed25519 agents when attestation is on, says "update the image first" below agent 2.6.0, and is dismissible per agent. - Not automated: cloud node attestation (aws_iid, azure_imds, gcp_iit), whose node SPIFFE ID is derived from the instance and is unknown until it attests. Those entries are still created by hand.

Also built: central credential storage as the recommended model for attested agents. - The existing per-job release routes (/api/agent/jobs/{id}/secret, /gateway-key, /ansible-bundle) were already scoped to the job, sealed per fetch and audited. What held the docs back from recommending them was that an Ed25519 agent's request ability is a file on the host. Attestation removes that file, so the docs now recommend dashboard_secret (ideally a ps_account:// reference) for attested agents and treat host-side storage as the fallback. - Settings → Remote agents → Release dashboard-held credentials only to SPIRE-attested agents (dashboard_secrets_require_spire, off by default). When it is on, all three routes refuse an agent whose auth_mode is not spiffe with 403 and a remedy. The check runs after job ownership, so it adds no oracle. The banner says when it applies.

Still to build: