Daevix Docs

Workload identity federation

View as Markdown Edit on Tangled
This page is the engineering document, published as-is from the repository. It is reconciled with the code on every change, so it is denser than a written guide and its section references point at the source rather than at this site. docs/wif.md · b96d1718

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_token from the JWT-profile exchange, or an access token from an app configured for JWT access tokens. Verified live with dvx internal serviceaccount check (see deploy/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/ref for 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 in wif_gcp_test.go + deploy/wif/gcp/README.md (bd_2_0-bvw.8).