
> Scope: how **machines** (CI jobs, cloud workloads, service principals)
> authenticate to the controlplane operator API. Human/CLI auth is
> [`docs/iam.md`](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`](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`](../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](../internal/controlplane/zitadel/zitadel.go)).
- **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`](../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`](../deploy/wif/gcp/README.md)
  (bd_2_0-bvw.8).
