Workload identity federation
Scope: how machines (CI jobs, cloud workloads, service principals) authenticate to the controlplane operator API. Human/CLI auth is
docs/iam.md; agent-to-infrastructure auth is separate (docs/agent-identity.md). bd_2_0-26i.9.
The shape after adopting Zitadel
Under the adopt-Zitadel model (see docs/iam.md) the controlplane
trusts exactly one issuer and consumes sub / urn:dvx:org /
urn:dvx:groups claims into an authn.Principal. A machine identity is not a
special case: a workload presents a Zitadel-issued token exactly like a human
does, and the same capability×scope authorization applies — a grant to
user:<sub> (where <sub> is the service principal’s Zitadel subject) is
resolved by the same internal/authz resolver, with the same cover semantics,
as a grant to a human. There is no second code path.
That means WIF becomes per-org-tenant configuration in Zitadel plus our existing claims consumption, not a bespoke verifier in the controlplane:
cloud/CI workload Zitadel (per-org tenant config) controlplane
───────────────── ─────────────────────────────── ────────────
GCP metadata ID token ─┐
GitHub Actions OIDC ─┼─ token exchange / JWT-OIDC IdP ─► Zitadel-issued ─► authn/oidc
GitLab CI OIDC ─┘ (RFC 8693 / RFC 7523) org-scoped SP validates the
EKS IRSA projected-SA ──► access token ONE issuer,
maps claims,
authz by grants
The workload exchanges its cloud-issued, short-lived, cloud-rotated token for a Zitadel-issued, org-scoped service-principal token, which the controlplane consumes like any other. Nothing on our side rotates.
Token format. The controlplane authenticator validates JWTs (via the issuer’s JWKS). Zitadel may hand a service account an opaque access token for its own API audience; that is rejected. A machine caller must present a Zitadel-signed JWT — the
id_tokenfrom the JWT-profile exchange, or an access token from an app configured for JWT access tokens. Verified live withdvx internal serviceaccount check(seedeploy/controlplane/README.md).
What each side owns
- Zitadel (per org tenant): register the workload issuer as a JWT/OIDC IdP or
a token-exchange source; mint the service principal; bind the external
identity’s claims (e.g.
repository/reffor GitHub,google.compute_engine.*for GCP) to that principal. This is per-tenant config an org admin sets up through our console, which drives the Zitadel management API (bd_2_0-26i.12). - controlplane (us): (1) automate creating those WIF connectors per org via
the Zitadel management API; (2) map the resulting service principal to our
authorization by granting
user:<sp-sub>a role/capability at the right scope. Both reuse machinery that already exists — the mgmt-API client and the grant store.
The JWT-profile grant our internal/controlplane/zitadel client already uses to
authenticate to the management API (sign a short assertion with a service-account
key, exchange it for an access token — RFC 7523) is the no-rotating-secret
pattern, applied to our own controlplane-as-a-service-account. The same grant is
what a workload uses.
The AWS SigV4 gap
GCP metadata tokens, GitHub/GitLab CI OIDC and EKS IRSA / Pod Identity (projected-ServiceAccount OIDC) are all OIDC/JWT shapes — they slot in as JWT/OIDC IdPs Zitadel can broker, so they need no custom code.
AWS IAM roles on non-EKS compute do not emit an OIDC token to arbitrary
workloads. The canonical AWS-native proof of identity there is a signed
sts:GetCallerIdentity request (SigV4): the client signs it, the server replays
it to STS and confirms the caller ARN. That is not a JWT/OIDC shape Zitadel
brokers, so non-EKS AWS would require a custom exchange in front of Zitadel (a
small verifier that validates the SigV4-signed GetCallerIdentity and then mints a
Zitadel service-principal token). It is deliberately not built: gate it behind
a concrete need, because EKS IRSA already covers AWS via the OIDC/JWT path.
Outbound: dvx as an issuer federated INTO a cloud (agent-operator tokens)
bd_2_0-bvw.8 (phase-5 proof, out of v1 core). The INBOUND direction above is cloud workloads authenticating TO the controlplane. The agent-as-operator epic added the reverse: the controlplane itself is now a public OIDC issuer (
internal/controlplane/agenttoken) whose 60s agent-operator tokens a cloud’s workload identity federation can consume.
The agent holds nothing (the standing rule); the enclave gateway presents its
SVID through the broker relay and the controlplane mints the token. For a cloud
leg, the same mint carries the cloud’s audience — a dvx-operator-api
-audience token is not a cloud credential, which is the replay bound
(recorded in bd_2_0-bvw.1: tight audiences per route class / per cloud).
Claims a cloud maps (--attribute-mapping) and gates on
(--attribute-condition): org (urn:dvx:org), agent id (sub), tier, image
digest, measurement, enclave — with the actor kind (urn:dvx:actor-kind,
structurally agent-operator at mint) as the cloud-side second kind gate. The
GCP registration (workload identity pool + external-OIDC provider + org-scoped
principalSet role binding + the RFC 8693 STS exchange) is documented in
deploy/wif/gcp/README.md, and the GCP-side
acceptance semantics — discovery resolution, kid-indexed ES256 verification,
iss/exp/iat, audience set, attribute mapping, condition evaluation, and every
refusal path — are proven against the real issuer in
internal/controlplane/agenttoken/wif_gcp_test.go. AWS and Azure are the same
pattern with different registration surfaces; unwritten until a concrete need
appears.
Fallback: OIDC client-credentials
For environments with no cloud workload identity at all, a Zitadel-issued client-credentials secret (or a service-account key) remains the fallback. It is a standing secret and therefore the least-preferred option — document WIF as preferred wherever the platform offers a workload identity.
Where this is in the code
- Claims consumption for any identity (human or machine):
internal/authn/oidc— one issuer,sub/org/groups→authn.Principal. - Authorization of a service principal:
internal/authz+ the grant store (internal/controlplane/iam) —user:<sp-sub>grants, identical to humans. - Provisioning WIF connectors + service principals: the Zitadel management-API
client
internal/controlplane/zitadel(org/connector/SP creation; bd_2_0-26i.12) — the JWT-profile assertion flow is implemented there. - OUTBOUND federation (dvx as issuer, agent-operator tokens into a cloud):
internal/controlplane/agenttoken(mint + discovery + JWKS), proven for GCP inwif_gcp_test.go+deploy/wif/gcp/README.md(bd_2_0-bvw.8).