
Who a human operator is, what org and groups they belong to, and what they are
allowed to do to the controlplane's own resources (orgs, groups, agents,
policy, grants). This is a **controlplane-only** concern — see
[`architecture.md`](architecture.md) for the controlplane/enclave split this
builds on. IAM never touches sovereign data and the enclave never calls up for a
per-request identity decision; the enclave only ever consumes governance policy as
published metadata (see [`agent-identity.md`](agent-identity.md), "Layered
governance policy"), which is a different, agent-attribution problem from the
one this document covers. This document is about the **user-facing** side:
which human (or service principal) is calling the operator API, and what they
may do there.

## 1. Build vs. adopt, and why we adopt

Daevix **adopts** a hardened, purpose-built, multi-tenant IAM product —
[Zitadel](https://zitadel.com) — rather than build its own OAuth2 server.
Rolling one ourselves would mean owning password/Argon2id credential storage,
an authorization-code + PKCE + device + refresh-token implementation, session
handling, refresh-family revocation, and CSRF protection — a lot of
hand-rolled, security-critical surface where a single bug is a full identity
bypass, for a capability that has nothing to do with the platform's actual
value proposition (governing sovereign agent workloads). The rationale
(bd_2_0-26i):

- **Owning by *operating* audited IAM crypto beats owning by *authoring* it.**
  Token signing, credential storage, MFA, federation protocol handling, and
  session/refresh-token lifecycle are exactly the class of code where a bug is
  a full identity bypass. Zitadel is maintained, audited, and exercised by a
  much larger population than one product's own account system ever will be.
- **Identity is already a controlplane function** (`architecture.md`:
  "identity · policy · audit · operator API"). Adopting Zitadel does not add a
  new architectural component so much as it hardens one that was always going
  to exist.
- **Self-hostability fits the sovereignty thesis.** The whole platform's
  premise is that a customer's data plane never depends on an external SaaS
  the customer does not control (`architecture.md` invariant 2). An IAM that
  could *only* run as somebody else's cloud service would be a hole in that
  story for the one function every request depends on. Zitadel can be
  self-hosted, which means it can be deployed **in-cluster**, alongside the
  controlplane, with no mandatory external dependency — including in an
  air-gapped deployment.

We self-host Zitadel in-cluster today: `deploy/controlplane/zitadel.yaml`
deploys it as a single-replica `Deployment` in the `dvx-system` namespace,
backed by its own Postgres database (`deploy/controlplane/postgres.yaml`,
service `iam-postgres`) — physically separate from the controlplane's own
`controlplane` database, consistent with the "own database per plane/service"
posture in `internal/database`. This is a *decision*, not an accident of the
dev environment: Zitadel Cloud remains an option for a customer that prefers
SaaS-managed IAM, but self-hosting is the default and the thing we build and
test against.

## 2. Single-issuer, multi-tenant topology

There is **one Zitadel deployment**, and the controlplane trusts **exactly
one issuer** (`internal/authn/oidc.Config.Issuer` — a single string, checked
exactly against the token's `iss`; see §5). Multi-tenancy is expressed
*inside* that one Zitadel instance as **Zitadel Organizations**: each of our
orgs (tenants) is a Zitadel Organization.

This is a deliberate contrast with an issuer-per-tenant topology (the way,
say, a naive Keycloak-per-realm design would do it, where each tenant is a
separate issuer the relying party has to know about and trust individually).
Zitadel's model is instead: **one issuer, org is a claim**, with
verified-domain home-realm discovery routing a human to the right
organization's login experience at the *login* step, before a token is ever
minted. The controlplane never has to enumerate issuers, add a new one when a
customer onboards, or reason about N different signing key sets — it
validates against one JWKS (`internal/authn/oidc/jwks.go`) for the life of the
deployment.

Each org owns its **own upstream identity**, entirely inside Zitadel and
invisible to the controlplane:

- **Human federation** — a Google Workspace, Azure AD, generic OIDC, or SAML
  connector, configured per-Organization in Zitadel, so a customer keeps using
  their existing corporate IdP without the controlplane knowing or caring
  which one.
- **Workload identity federation (WIF) / machine trust** — a service
  principal's upstream trust (e.g. exchanging a cloud-workload token for a
  Zitadel-issued token) is likewise a Zitadel-side concern per Organization.

The authenticator (`internal/authn/oidc`) never sees any of this. It only ever
sees a **Zitadel-issued token** — federation happens entirely upstream, and by
the time a bearer token reaches the controlplane it is uniform regardless of
which corporate IdP or WIF path produced it.

## 3. Division of responsibility

This is the load-bearing design decision, and it is stated directly in the
package docs (`internal/authz/resolver.go`, `internal/controlplane/iam/store.go`):

> **Zitadel owns**: authentication, credentials, org-tenants (Organizations),
> group/role **membership**, service principals, upstream federation, and
> WIF token-exchange — and it emits org/group membership as **token claims**.
>
> **Our controlplane owns**: the capability × scope **authorization decision**
> — resolved from those claims against our own `grant_` rows — plus the
> mapping between Zitadel's org/group ids and our own governance-coordinate
> rows (`org` / `"group"` tables).

Concretely: Zitadel answers "is this a valid credential, and which
org/groups does this person belong to?" Our controlplane answers "given that,
is this person allowed to do *this specific thing* to *this specific
resource*?" Nothing about *authorization policy* — roles, capabilities,
grants — lives in Zitadel. Nothing about *membership* — who is in which org or
group — is duplicated into our database; it is read fresh from token claims on
every request (§6). This split is why the schema comment in
`internal/database/migrations/controlplane/00003_iam.sql` says the IAM
migration "deliberately does NOT model users, passwords, sessions or external
identities" — modeling any of those would be duplicating what Zitadel is
already the source of truth for, and duplicated identity state is exactly the
kind of thing that drifts.

## 4. Entity graph: the group as pivot

```
   org ──<── group ──<── agent          (governance coordinate chain)
    │          │
    │          └──< user (membership, asserted by Zitadel, not stored by us)
    └──────────────< user (membership, asserted by Zitadel, not stored by us)
```

- **org** is a tenant. It is both a Zitadel Organization *and* a row in our
  `org` table (`internal/database/migrations/controlplane/00003_iam.sql`),
  the latter storing the `zitadel_org_id` mapping plus whatever else the
  governance and enclave-registry systems already key on (`policy_setting.org_id`,
  `enclave.owning_org_id`).
- **group** is a subdivision within a tenant — a team, environment, or
  project (`agent-identity.md`, "Layered governance policy"). It is the same
  `dvx.io/group-id` coordinate an agent's pod label carries, and it is also
  our `"group"` table row, mapped to a `zitadel_group_id`.
- **agent** is the leaf workload. Its group membership is a pod label, not an
  IAM concept — the IAM system does not model agents at all, it only shares
  the org/group **coordinate space** those agent labels use.
- **users are members of orgs and groups** — but that membership is a Zitadel
  fact, asserted via token claims at request time, never a row we store (§6).

The **group is the pivot** that ties three otherwise-separate things
together:

1. It is a **governance-policy scope** (`internal/policy.ScopeGroup`) — where
   destination allow/deny rules and other settings can be authored.
2. It is **what agents belong to** — the `dvx.io/group-id` label that routes
   an agent under a group's governance policy.
3. It is **what users are members of** — a claim Zitadel emits for a caller.

Because all three point at the same coordinate, a user's authority over a
given agent is mediated entirely through **shared group membership**: a user
who is a member of group `g` in org `o`, and holds a grant covering `(o, g)`,
is authorized for actions against every agent labeled with that group — not
because the IAM system knows about agents, but because agents, policy, and
membership all resolve through the identical `(org, group)` coordinate.

## 5. Capability × scope authorization, and why it reuses the governance algebra

### Capabilities

A **capability** is a `resource:action` verb — the fixed vocabulary is code,
not seed data (`internal/authz/capabilities.go`), specifically to avoid
re-seed friction: adding a new permission means adding a `const`, not writing
and running a database migration:

| Capability | Grants... |
|---|---|
| `agents:read` / `agents:manage` | view / manage agents |
| `policy:read` / `policy:write` | view / author governance policy |
| `groups:read` / `groups:manage` | view / manage group coordinate rows |
| `members:read` / `members:manage` | view / manage org membership grants |
| `serviceaccounts:manage` | manage an org's service accounts (machine users that act as operators): create/delete, credential rotation, grant management (§13) |
| `enclaves:read` / `enclaves:manage` | view / manage enclave registration |
| `org:read` / `org:manage` | view / manage the org coordinate row itself |
| `audit:read` / `audit:read:all` | read own-actor / all-actor audit within a covered org |
| `platform:orgs` | org lifecycle (list/create/delete org coordinates) — platform-scoped |
| `platform:policy` | author the immutable platform policy **floor** — platform-scoped |
| `platform:config` | author platform-scoped config — platform-scoped |
| `platform:operators` | grant/revoke platform-operator grants — platform-scoped |
| `platform:audit:read` | **standing** cross-org *controlplane* audit read (non-sovereign) — platform-scoped |

There is **no super-capability** (removed in bd_2_0-acb.2). `platform:admin`
used to be matched against *every* capability check (`authz.Grant.confers`) with
`authz.EffectiveCapabilities` returning the whole vocabulary; that god-mode is
gone. Platform authority is now an **explicit, closed set** of `platform:*`
verbs (above), each genuinely platform-scoped and none conferring org-internal
data authority. The design rule: a platform op gets its **own** `platform:*`
verb whenever the underlying capability is *dual-use* (also gated at
org/group/agent scope) — so `policy:write` → `platform:policy`, `config:write` →
`platform:config`, org lifecycle → `platform:orgs` — which is exactly why a
platform operator holding these at platform scope cannot author a *customer*
org's policy/config/grants. `enclaves:manage` is reused (not re-verbed) only
because it is platform-only in the routes. `platform:audit:read` is
controlplane-audit only: a *different* capability from a tenant's own
`audit:read`, and inert on the enclave (§10).

### Roles

Four **fixed** system roles are defined in code (`internal/authz/roles.go`), the
single source of truth, again to avoid seed-migration friction:

| Role | Capabilities |
|---|---|
| `member` | read-only: `agents:read`, `policy:read`, `groups:read`, `members:read`, `enclaves:read`, `org:read` |
| `admin` | everything *within* an org: all of the above plus every `:manage` verb (agents, policy, groups, members, enclaves, org, roles, idp, serviceaccounts) and `audit:read:all` |
| `auditor` | `audit:read` **only** (bd_2_0-xew.1): read the org's audit and nothing else — own-actor by default (does not bundle `audit:read:all`) |
| `platform-operator` | the explicit platform set — `platform:orgs`, `enclaves:manage`, `platform:policy`, `platform:config`, `platform:operators`, `platform:audit:read`; admin *within the platform org*, **zero** standing authority inside any customer org (bd_2_0-acb.2) |

Per-org **custom roles** (bd_2_0-xew.7) let an org define its own role = a named
bundle of **existing** capabilities (never new verbs, never platform authority).
They live in the `role` / `role_capability` tables (`migrations/controlplane/
00016_custom_roles.sql`), are authored through the operator API under
`/api/v1/orgs/{org}/roles` (list/get on `members:read`, create/update/delete on
`roles:manage`), and are **org-scoped** — a role in org A is invisible/unusable
in org B. Two invariants keep them from becoming an escalation path:

- **System first, then custom.** System roles stay code-resident and resolve in
  the DB-free `authz` resolver; a custom role is **expanded to its capabilities
  at grant-load time** (`Store.expandGrant`), so the resolver never sees a custom
  role name. A custom role may not **shadow** a system-role name.
- **No new authority.** A custom role can only bundle capabilities that already
  exist and are **not** platform-scoped (`validateRoleCaps`), so it can never
  mint a verb or reach platform scope. Deleting the role (or the org) cascades
  the bundle away, and a custom-role grant with no surviving role expands to
  nothing (fail-closed).

### Grants and the shared cover rule

A **grant** (`internal/authz.Grant`, stored as `grant_` rows,
`internal/database/migrations/controlplane/00003_iam.sql`) is a triple:

```
(principal, role | capability, scope-coordinate)
```

`principal` is a prefixed string — `user:<zitadel-sub>` for a specific human
or service principal, `group:<org>:<group>` for "everyone the token claims
membership of in that group" (`internal/controlplane/iam.principalsOf` builds
this set for the caller of each request: their own subject plus one
`group:...` principal per group claim), or **`agent:<uid>`** for an
agent-as-operator principal (bd_2_0-bvw.6) — grantable **only at org scope**
(the platform route refuses agent principals outright; the agent-operator
surface is org-scoped by design), validated against the **path** org exactly
like an agent-scoped grant's body id (unknown or cross-org agent = 404, a
bare `agent:` = 400), and in practice granted only the **`auditor`** role:
the kind gate on every capability-gated route makes any broader role inert
for the `agent-operator` principal kind, so `auditor` (audit:read,
own-actor) *is* the v1 agent surface. Exactly one of `role`/`capability` is
set — a `CHECK` constraint enforces the XOR at the database level. The scope
coordinate is `{}` (platform), `{OrgID}` (org), `{OrgID, GroupID}` (group), or
`{OrgID, AgentID}` (agent, bd_2_0-xew.2) — the same shape as
`internal/policy.Target`, deliberately. An **agent-scoped grant** (`scope_kind =
'agent'`, `agent_id` FK to `agent(uid)`, `ON DELETE CASCADE`) lets a
role/capability be granted on a *single* agent; `Target.Covers` already resolves
it (an agent grant covers exactly that agent; org/group/platform grants still
cover agent targets). The grant API validates the named agent belongs to the
**path** org before scoping to it — the body id is checked against the route,
never trusted to widen authority. A grant *set* of agents is deliberately not
modelled (reuse org/group scope for "many"); a bespoke agent-group is the first
real relationship/ABAC primitive, left unbuilt until a concrete need appears.

**Cover semantics are not reimplemented here.** `authz.Grant.Scope` is a
`policy.Target`, and authorization calls `policy.Target.Covers` — the exact
same method the governance-policy resolver uses to decide whether an authored
setting at scope *c* governs a target *t* (`internal/policy/policy.go`):

```go
// Covers reports whether authority authored at coordinate c ... governs
// target t: platform covers everyone; an org coordinate covers any target in
// that org; a group coordinate covers exactly that group...
func (c Target) Covers(t Target) bool
```

So "an org grant covers all its groups/agents; a group grant covers just that
group; platform covers everything" is **one rule with two consumers** —
`internal/policy` resolving *what an agent's egress is allowed to reach*, and
`internal/authz` resolving *what a user is allowed to do* — not two
independently-maintained hierarchies that could quietly diverge. This is
called out explicitly in both packages' doc comments precisely so a future
change to the hierarchy (e.g. adding a fifth scope) cannot be made in one
consumer and forgotten in the other.

**Group cover reaches agent targets (bd_2_0-ugb.8).** The cover rule needs the
target's *group* coordinate to match a group-scope grant, but an agent-path
route (`.../agents/{agent}/...`) names only org+agent. `RequireCapability`
therefore enriches an agent-path authorization target with the agent's
**stored** group from the agent registry (`Service.enrichAgentTarget`,
`internal/controlplane/iam/authz.go`) before the grant load — so the hierarchy
applies with its designed semantics: a group-scope grant authorizes agent-path
routes for **every agent in the group, for human and service-account
principals alike**, and an agent created *after* the grant was made is covered
automatically (no re-granting). The group is a server-derived fact about the
agent named in the path, never client input, so the path-only-target
discipline (§8) holds; and the enrichment is **fail-closed** — an unknown
agent, a cross-tenant id, an agent with no group, or a registry error all
yield the un-enriched target, so a group grant never covers what the registry
cannot vouch for (org- and agent-scope grants are unaffected: neither needs
the group coordinate).

**Agent create and list are group-aware too, but own the decision in the
handler (bd_2_0-oxk).** The agent *collection* routes — `POST
/api/v1/orgs/{org}/agents` (create) and `GET /api/v1/orgs/{org}/agents` (list)
— name only the org in the path, so the agent's group (the coordinate the cover
rule needs) cannot ride the path target `RequireCapability` builds. Both routes
are therefore mounted behind authentication alone and own the capability
decision in-handler (`createAgent` / `listAgents`,
`internal/controlplane/iam/agents.go`), re-checking `agents:manage` /
`agents:read` and the operator-kind gate there — the route table still declares
the capability so the OpenAPI surface documents it as required.

- **Create** is an Upsert keyed on uid, and its group is the one place a group
  coordinate is **authored** rather than merely read back from the registry. It
  authorizes the caller's `agents:manage` over the **union** of (1) the body
  `group` being authored and (2) — when the uid already exists — the existing
  agent's **stored** group, learned by a server-side `Get` (the same class of
  lookup as `enrichAgentTarget`, fail-closed on error). Authoring the body group
  is still *narrowing*, not trusting client input as a widening coordinate: the
  check only ever lets a caller create into a group it already manages, and the
  stored-group half closes the takeover hole — a caller managing group G cannot
  overwrite or re-parent an agent stored in group H. A fresh create (no existing
  agent) checks the body group alone; a replace checks both.
- **List** loads the caller's grants and filters `ListForOrg` **in the handler**:
  an org-scope `agents:read` holder sees every agent (today's behavior); a
  group-scope reader sees only agents whose **stored** group its grants cover,
  and never a groupless agent (visible to org-scope read alone — fail closed). A
  grant-load failure denies rather than lists.

`authz.Authorized(grants, cap, target)` is the decision function: true iff
some grant's scope covers the target *and* confers the capability. It is
**fail-closed / default-deny** — no covering grant means no access, mirroring
`policy.EvaluateDestination`'s "deny-trumps-allow, then default-deny" posture
on the governance side. `authz.EffectiveCapabilities` is the same walk,
collecting the full held set instead of stopping at the first match; it backs
`GET /api/v1/whoami`.

## 6. What the authenticator validates, and what is JIT from claims

`internal/authn/oidc.Authenticator` (constructed once at startup, keys cached
and refreshed on-demand) is the sole concrete `authn.Authenticator`
implementation today. On every request it:

1. Extracts the bearer token (`Authorization: Bearer ...`).
2. Verifies the JWS **signature** against the issuer's JWKS — RSA
   (RS256/384/512) and ECDSA (ES256/384/512) only; `alg: none` and HMAC are
   explicitly refused (`internal/authn/oidc/token.go`,
   `verifySignature`). JWKS parsing (`internal/authn/oidc/jwks.go`) is
   hand-rolled rather than pulled from a dependency, consistent with the
   repo's stdlib-first convention — the parsing surface is small (RSA/EC key
   reconstruction from base64url big-integers, plus an explicit on-curve
   check via `crypto/ecdh` for EC keys) and entirely stdlib + standard crypto.
3. Checks `iss` equals the exactly-configured issuer, `aud` contains the
   configured audience (when one is set — dev leaves it unset so any
   Zitadel-issued token validates), and `exp`/`nbf` against the clock with a
   configurable leeway (default 60s) for skew.
4. Rejects on `sub` being empty.
5. Maps claims into `authn.Principal`:
   - `sub` → `Principal.Subject`
   - a configurable custom claim (default `urn:dvx:org`) → `Principal.OrgID`
   - a configurable custom claim (default `urn:dvx:groups`) → `Principal.Groups`

Any failure — bad signature, wrong issuer, expired, missing subject — collapses
to the single `authn.ErrUnauthenticated`, which the shared `authn.Require`
middleware turns into an RFC 9457 401. The specific cause is never leaked to
the client, only to logs.

The org/groups claims are emitted by a per-instance Zitadel action/mapping
(configuration, not code in this repo) — Zitadel is told to stamp our
coordinate ids into the token it issues.

**Membership is never stored by the controlplane.** There is no membership
table in `00003_iam.sql` by design (see §3's schema-comment quote) — every
request re-derives "which org, which groups" from the token's claims,
just-in-time. This means a membership change in Zitadel (add/remove a user
from a group) takes effect on that user's *next* token, with no controlplane
write, no cache to invalidate, and no possibility of our copy of membership
drifting from Zitadel's.

**Answering membership questions (bd_2_0-xew.6).** Because the controlplane
stores no membership, "who is in this group?" and "what can this principal do
here?" are answered as a **live read-proxy** over the `iam.Provider` port, in dvx
vocabulary, rather than by exposing the backend's admin surface:
`GET /api/v1/orgs/{org}/groups/{group}/members` proxies the backend's live
membership, and `GET /api/v1/orgs/{org}/principals/{sub}/effective` composes the
principal's dvx-authoritative grants with backend-authoritative membership through
the *same* `authz.EffectiveCapabilities` the enforcement path uses. Both are gated
on `members:read` + `OrgFromPath` (org-scoped: an org admin never sees another
org's principals). It is a live proxy, **not** a synced projection — no membership
table, nothing to drift; a materialized read model would only be added later as an
explicit, TTL'd, clearly-non-authoritative cache. The load-bearing rule: **this
read model NEVER feeds an authorization decision.** Enforcement stays token-claim
driven and fail-closed (`authz.Authorized` reads only validated claims + stored
grants), so the query path can never become a side channel that makes *displayed*
and *enforced* authority drift into a fail-open bug — it is visibility/UX/audit
only.

## 7. Bootstrap

The classic chicken-and-egg problem for any IAM system is: how does the
*first* administrator get in, before any grant exists to authorize them? Two
independent bootstraps compose to answer it (bd_2_0-26i.6):

- **Zitadel-side**: Zitadel's own `FIRSTINSTANCE` mechanism creates a native
  first admin — a local account, over HTTP(S), configured directly in
  `deploy/controlplane/zitadel.yaml` (`ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME`
  / `..._PASSWORD` from the `zitadel-secrets` Secret). This is Zitadel
  bootstrapping *itself* — it needs no help from the controlplane and exists
  independent of whether our database has been seeded at all. It also
  dissolves any "DB-from-laptop" bootstrap problem: there is no longer
  a moment where the only way to create the first account is a `psql` session
  against production. A human logs in through Zitadel's own UI like any other
  Zitadel deployment.
- **Controlplane-side**: separately, the controlplane's own database is
  seeded with the platform `org` row and a `platform-admin` grant for that
  first Zitadel identity's subject — in-boundary (i.e. done as part of
  deploying the controlplane, not exposed as a self-service "become admin"
  API route). `deploy/controlplane/zitadel.yaml` also provisions a **machine
  user** (`dvx-controlplane`) at first-instance time specifically so the
  controlplane itself can later drive Zitadel's management API
  (bd_2_0-26i.12, §9) without a human in the loop.

**Inviting** a subsequent user is the composition of the same two halves in
either order: pre-create a `grant_` row for a principal that does not exist
in Zitadel yet (the grant simply does nothing until that subject logs in and
gets a token), or pre-create the Zitadel user/invite and then grant them once
they exist — both are "create a row," and neither requires a bespoke
invite-token or signup/reset flow of our own; credentials and invites are
Zitadel's concern.

## 8. Operator-API discipline, threat model, and boundary notes

**IAM is controlplane-only.** `internal/controlplane/iam` never imports
`internal/enclave`, and it deals exclusively in coordinate rows (`org`,
`"group"`) and grants — never sovereign data, never anything that crosses
into a customer's enclave. The Zitadel deployment itself lives beside the
controlplane, not in any enclave.

**The target coordinate always comes from the authenticated principal and
validated route path parameters — never the request body, with one documented,
narrowing-only exception (group-aware agent create, below).** This is stated as
an explicit discipline in `internal/controlplane/iam/authz.go`'s
`TargetFunc` doc comment and enforced structurally: every capability check
via `Service.RequireCapability` is handed a `TargetFunc` — `PlatformTarget()`,
`OrgFromPath("org")`, or `GroupFromPath("org", "group")` — that reads only
`r.PathValue(...)`, never the decoded JSON body. `internal/controlplane/iam/api.go`
carries the same discipline into every mutating handler: `createOrgGrant`
takes its org from `r.PathValue("org")`, not from anything the client put in
the POST body. The consequence is structural, not just a convention: a caller
who tries to smuggle a different org/group id into a request body cannot
widen their authority or reach another tenant's rows, because that id is
simply never consulted for the authorization decision. A cross-tenant
attempt surfaces as a 403 (authorization denied) or 404 (`iam.ErrNotFound`,
e.g. `deleteOrgGrant` checking `g.OrgID.String != org` before confirming a
grant even exists) — **never** a data leak that confirms another tenant's
resource exists.

The **one** documented exception is **group-aware agent create**
(`createAgent`, bd_2_0-oxk, §5). Here the request body's `group` *does* enter
the authorization decision — but as the group *attribute being authored* on the
agent, not as a borrowed authorization coordinate, and only ever to **narrow**:
the handler requires the caller to already hold `agents:manage` over that group
(and, on an Upsert of an existing agent, over its stored group too), so a body
group a caller does not manage is a 403, never a widening or a reach into
another tenant. The authorization *org* stays path-derived
(`r.PathValue("org")`) exactly as everywhere else, and reads of an existing
agent's group remain server-derived via `enrichAgentTarget` — only the authoring
of a brand-new group attribute consults the body, under managed-group cover.
This is the sole place a body field is consulted; every other coordinate stays
principal- or path-derived.

**Fail-closed everywhere**: `authz.Authorized` default-denies with no
covering grant (§5); `RequireCapability` 401s with no principal and 403s on a
failed authorization check; a malformed `DestinationPolicy` value on the
governance side fails safe by contributing no allows (`internal/policy/resolver.go`,
`ResolveDestination`) — the same posture, applied consistently across both
consumers of the scope hierarchy.

**Last-admin / no-self-demotion guards are live-database re-checks, not
mint-time state.** Revoking a grant that would leave an org with zero
`admin`-role holders, or the platform with zero `platform-admin` holders, is
refused with a 409 — but the check (`CountOrgAdmins` / `CountPlatformAdmins`)
runs against the database at delete time, not against some cached count taken
when the grant was minted. This matters because grants can be added and
removed by other requests between when a client observes the state and when
it issues the delete; a re-check at the moment of the destructive operation
is the only version of this guard that cannot be raced. The same guard covers
BULK teardown: deleting a whole service account, or offboarding a user (which
reaps their self-service service accounts), atomically refuses with a 409 when
the batch would strip some org's last `admin` grant, leaving grants, rows, and
the upstream identity untouched until the authority is transferred first.

## 9. The provisioning sync (forward-looking)

Today the operator API (`internal/controlplane/iam/api.go`) is the front door
for org/group coordinate rows, and the `provider_org_ref` / `provider_group_ref`
columns exist specifically so those rows can be **mapped** to their backend
counterparts (`UpsertOrg`/`UpsertGroup` accept an optional backend ref;
`org.provider_org_ref` is `NULL` "until the org is provisioned"). The intended
shape (bd_2_0-26i.12, partially implemented / ongoing) is that creating an org
through our API **drives the IAM backend's management API** to keep the two
sides in sync:

- Create an org via `POST /api/v1/orgs` → create the matching backend
  Organization → store the returned id in `org.provider_org_ref`.
- The same for groups, upstream IdP connectors per org, service principals,
  and WIF connectors — all driven from the controlplane's operator API rather
  than requiring an operator to separately configure the backend by hand.

**Provider-neutral port (bd_2_0-xew.3).** The backend is reached only through
the `iam.Provider` port (`internal/controlplane/iam/authz.go`), expressed
entirely in dvx domain terms — no Zitadel vocabulary (Action/Flow/Trigger/
project) or Zitadel ids cross it, and the response/store surface carries a
neutral `provider_org_ref` / `provider_group_ref` rather than a `zitadel_*` id.
**Zitadel is one adapter** (`internal/controlplane/zitadel`), confined by a
`depguard` fence (`zitadeladapter` in `.golangci.yml`) that fails the build if
any package other than that adapter, the controlplane composition root, or the
`dvx internal serviceaccount` CLI imports the Zitadel client. Handlers and
domain code compile against the port only, so a second IAM adapter would need no
API-contract change. The composition root
(`internal/controlplane/controlplane.go`) is the single place the adapter is
constructed and injected behind the port.

**Per-org upstream IdP federation (bd_2_0-xew.5).** An operator binds a customer
org to its own upstream IdP (Google/Entra/Okta/OIDC/SAML) through
`PUT /api/v1/orgs/{org}/idp` (gated on `idp:manage`, `OrgFromPath`). The request
is provider-neutral (`iam.IdPConnector`, no Zitadel vocabulary); the handler
loads the org's `provider_org_ref` and calls `Provider.ConfigureOrgIdP`, which
the Zitadel adapter implements by creating the org-scoped IdP, adding it to the
org login policy, and ensuring the Complement-Token action that projects the
connector's `groups_attribute` into `urn:dvx:groups` — finally lighting up the
`group:<org>:<grp>` grant path. Three properties are load-bearing:

- **Secret custody is pass-through.** The client secret flows to the backend and
  is **never** stored by dvx: the `idp_connector` table
  (`migrations/controlplane/00017_idp_connector.sql`) has no secret column, so
  dvx keeps only a reference (type/name/groups-attribute/`idp_ref`/status).
- **`idp:manage` is its own verb**, not `config:manage`, because federation is a
  new external trust dependency — an explicit, separately-grantable authority.
- **Cross-org isolation is structural.** Group principals are namespaced by the
  caller's own `urn:dvx:org` claim (`authz.Principals` builds
  `group:<callerOrg>:<grp>`), so an org's federation can only ever mint
  `group:<thatOrg>:*` — never a sibling org's groups. This is a **threat-model
  event** (new external trust dependency); it is reconciled in
  `docs/threat-model.md` via bd_2_0-xew.9.

The machine user Zitadel provisions at first-instance time
(`ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: "dvx-controlplane"` in
`deploy/controlplane/zitadel.yaml`) exists for exactly this: it is the
credential the controlplane itself will use to call Zitadel's management API,
rather than requiring a human to click through Zitadel's console for routine
org/group lifecycle operations. As of this writing the coordinate rows and
the id-mapping columns exist and are populated manually/via the operator
API; the automatic drive-Zitadel-on-write direction is the ongoing part of
bd_2_0-26i.12.

## 10. Enclave-local authorization (one resolver, two stores)

Everything above is the **controlplane** plane. The **enclave** plane needs its
own authorization for the operator surfaces the broker serves — today audit
read/tail (`internal/enclave/cpbroker/secretapi.go`), tomorrow secret-metadata
and enclave-local config/policy reads — but it must decide these **without ever
consulting the controlplane at request time** (architecture invariant 2: the
enclave authorizes from validated token claims + *enclave-local* grants, never a
controlplane authz assertion; no enclave process dials the controlplane). This
section records the scope decision for that (bd_2_0-bs1.1); the implementation
lands in bd_2_0-bs1.2 (grant table) and bd_2_0-bs1.3 (grant authoring +
provisioning seed), and the audit-persona consumer is bd_2_0-zb4.3.

> **Status: implemented.** The enclave grant store + shared-resolver loader is
> `internal/enclave/enclaveauthz` (mirrors the CP `grant_` schema in
> `internal/database/migrations/enclave/00003_iam_grant.sql`, no org/group FKs;
> bs1.2). Grant authoring is the broker operator API
> `internal/enclave/cpbroker/grantapi.go` (`GET`/`POST`/`DELETE /api/v1/grants`,
> `members:read`/`members:manage`, org from token claim never body, validated
> against `EnclaveCapabilities`), seeded at provisioning from
> `--owning-org`/`--seed-admin` (bs1.3). The audit read gate is
> `cpbroker/auditscope.go` (bs1.3/zb4.3), and an explicit single-tenant
> `requireEnclaveOrg` gate refuses a token whose org claim ≠ the enclave's
> owning org (bs1.5). `EnclaveCapabilities` = `{audit:read, audit:read:all,
> members:read, members:manage}` — `platform:*` verbs are excluded, so a CP-side
> platform-operator grant confers nothing here.

**One resolver, two stores.** The decision layer is **not** re-rolled: the
enclave links the same plane-neutral `internal/authz` (the `Grant`/`Capability`/
`Role` vocabulary, `Covers`, `Authorized`, `EffectiveCapabilities`) the
controlplane uses. `internal/authz` imports only `internal/policy`, and neither
imports `internal/controlplane` or `internal/enclave`, so the enclave can link
it without tripping the `depguard` boundary fence (`.golangci.yml`) — verified,
this is the load-bearing precondition. What differs per plane is only the
**store**: the controlplane loads `grant_` rows from its own database; the
enclave gets its **own** `grant_` table in its **own** sovereign database (a new
`internal/database/migrations/enclave/` migration, mirroring
`00003_iam.sql`'s shape), with an enclave-local loader that maps rows to
`[]authz.Grant` exactly as `internal/controlplane/iam.GrantsForPrincipals` /
`toAuthzGrant` do, then calls the *same* `authz.Authorized`. The two grant
stores are physically separate and never cross (sovereignty): an enclave's
authority is authored and evaluated entirely within that enclave.

**Capability vocabulary: shared verbs, enclave-meaningful subset.** The verb
vocabulary stays a single platform-owned source of truth in
`internal/authz/capabilities.go` — no plane mints its own verbs, and shared
verbs (`audit:read`, `audit:read:all` — the zb4 personas) are named exactly
once. What is *enclave-meaningful* is a subset: audit read (the persona
scoping), and later secret **metadata** reads, agent-runtime ops, and
enclave-local config/policy reads. The rest (`org:*`, `groups:*`, `members:*`,
`enclaves:manage`, `policy:write`, `platform:admin`, the interactive-access mint
verbs) are controlplane-only. The split is enforced by construction, not by a
second vocabulary: the enclave grant-authoring API (bd_2_0-bs1.3) validates
against an **enclave-allowed capability subset**, and the enclave simply exposes
no endpoint that checks a controlplane-only verb, so any inert verb in a grant
is never consulted there.

**Reading a specific enclave's audit hinges on an enclave-local `audit:read` /
`audit:read:all` grant in *that enclave's* store — never on `platform:audit:read`.**
This is the load-bearing consequence of per-plane evaluation, and it is worth
stating outright because the shared verb vocabulary could mislead. `platform:audit:read`
(§11 tier 1) is a **controlplane** verb, held in the controlplane grant store; it
grants standing cross-org read of *controlplane* audit and **nothing** on the
enclave plane. It is not in the enclave-allowed subset, and — decisively — the
enclave authorizes only from its **own** grant table, so a platform operator who
presents a token to the enclave broker carrying only a CP-side `platform:audit:read`
grant has **no covering enclave-local grant → fail-closed deny**. The platform
cannot self-grant enclave access either: the controlplane physically cannot write
into an enclave's grant table (the two stores never cross). So an out-of-org
principal (a platform operator) reads a specific enclave's audit only under a
**customer-authored** enclave-local `audit:read`/`audit:read:all` grant in that
enclave — the future out-of-org-enclave-grant capability — whose authoring and
every use are audited into the org's own view (§11, reciprocal transparency). This
is exactly why CP audit read is standing (non-sovereign, tier 1) while enclave
audit read is customer-consented only (sovereign, tier 3).

**Roles reuse the fixed bundles.** The enclave recognizes the same code-defined
`member`/`admin` bundles (`internal/authz/roles.go`) rather than a second
enclave-specific role vocabulary. Because the enclave only exposes
enclave-meaningful endpoints, a bundle's controlplane-only verbs are inert on
that plane; what matters is the enclave-meaningful subset — `member` → own-actor
audit (`audit:read`), `admin` → all-actor audit (`audit:read:all`) plus
enclave-local management. One role vocabulary, no drift.

**Group principals** are derived identically: `group:<org>:<grp>` built from the
token's group claims, plus the caller's `user:<sub>`. To keep the principal
string format a single source across both planes, the format helpers
(`GroupPrincipal`, the `principalsOf` construction) move into the plane-neutral
`internal/authz` (bd_2_0-bs1.2), so the enclave loader cannot drift from the
controlplane's.

**What an enclave grant may confer (v1): management / audit / metadata only.**
The **secret-value floor is preserved**: no grant at any scope yields sovereign
secret *plaintext*, and no token-reachable secret-value read path is added —
values stay attestation-only. A "break-glass secret-value read" is an explicit
**out-of-v1** decision: if ever built it is a *separate* capability,
customer-authored only, that bypasses attestation and is heavily audited — never
something the audit/metadata verbs above quietly confer.

**Bootstrap: the controlplane seeds the first grant at provisioning.** Mirroring
the platform-admin seed (§7), when the controlplane provisions an enclave
(broker registration) it hands over the owning org plus an **initial org-admin
principal/group** as one-time provisioning metadata; the broker writes that seed
grant into its local grant table at first start. This is provisioning-time
config, **not** runtime controlplane trust — thereafter all authoring is
enclave-local and customer-controlled (bd_2_0-bs1.3), and the enclave never asks
the controlplane to authorize a request.

**Audit read is always pull-to-the-enclave, gated by enclave-local authz.** The
enclave never *originates* an outward audit or signal flow: a reader comes to
the enclave and its enclave-local grants decide. Consequences for threat
detection (the §4.3 posture, feeds bd_2_0-bs1.5):

- **Cross-org *controlplane* audit** (platform operators monitoring the
  controlplane they run) is non-sovereign and handled entirely on the CP plane
  by the explicit `platform:audit:read` capability (bd_2_0-acb.2, consumed by
  bd_2_0-zb4.2) — nothing to do with the enclave.
- **Enclave threat detection is the owning org's own job**, done against the
  audit it already reaches: an org-admin or (future) auditor principal holding
  enclave-local `audit:read`/`audit:read:all`. bs1 adds **no** special detection
  mechanism, no platform-facing signal surface, and no alerts-egress path.
- **Platform-operator detection *on enclave audit* is out-of-v1.** It depends on
  a future, not-yet-existing capability to grant an **out-of-org** principal
  access to enclave data. When that lands, a platform operator is simply another
  **grantee that calls the enclave** under a customer-authored, revocable,
  audited enclave-local grant — raw records still never leave the enclave. In v1
  every grantee is homed in the owning org (authority from token claims);
  out-of-org grantees are future work.

## 11. Platform-org and the principal model (decomposing platform-admin)

`platform:admin` today is a **super-capability**: `authz.Grant.confers` matches
it against *every* capability check (§5), so a platform operator implicitly holds
god-mode over every org's internal data authority. That is the thing the `acb`
epic decomposes into least-privilege. Before splitting the god-cap
(bd_2_0-acb.2), standing up JIT shadow access (bd_2_0-acb.3), or agent-as-operator
(bd_2_0-bvw), the foundational model has to be pinned down. This section records
that decision (bd_2_0-acb.1); it sets constraints the rest of `acb` implements.

> **Status: partially implemented.** bd_2_0-acb.2 shipped: the `platform:admin`
> confers-everything super-capability is **removed** (`internal/authz/resolver.go`
> is now an exact match), replaced by the `platform-operator` role holding an
> explicit closed set of `platform:*` verbs (`platform:orgs`, `platform:policy`,
> `platform:config`, `platform:operators`, `platform:audit:read`, plus
> `enclaves:manage`) — see §5. The org **KIND** enum (`platform` | `customer`)
> lands in `migrations/controlplane/00013_org_kind.sql` with a partial-unique
> **singleton** index; `SeedPlatform` seeds both the platform org and a
> data-bearing `platform-ops` org at bootstrap; the platform org is undeletable.
> `platform:audit:read` (standing cross-org *controlplane* audit) is consumed by
> bd_2_0-zb4.2. **Not yet built:** JIT customer-approved shadow access
> (bd_2_0-acb.3) — until it lands there is simply *no* path for a platform
> operator into a customer's management plane.

**Platform-org — "Model B".** The platform is a **distinguished org**, not a
magic scope floating outside the org model (today "platform" is only
`scope_kind='platform'` with a NULL org). It is:

- a **singleton**, and **non-data-bearing** — the enforceable invariant is *the
  org with cross-fleet authority cannot run workloads or hold sovereign data*.
- marked by a **kind enum** on the org (room for `operator`/`partner` kinds
  later), **not** a bare `is_platform` boolean. Singleton is the v1 shape; the
  kind enum leaves room for an MSP/reseller hierarchy without a rewrite.
- accompanied by a separate **platform-ops org** — a normal, data-bearing
  customer org that hosts Daevix's *own* operational workloads, kept distinct
  from the platform org precisely so the "cross-fleet authority holds no data"
  invariant survives. It is **seeded automatically at daevix setup** (the same
  in-boundary bootstrap that seeds the platform org + first platform-admin, §7),
  **not** deferred. It is *not* tied to agent-as-operator: that (bd_2_0-bvw) is a
  **generic per-org capability** any org can use, unrelated to platform-ops.

**Principal model.** A principal is **homed in exactly one org**; **authority
spans orgs only via explicit grants**. Group-derived authority stays in the home
org; cross-org authority is an explicit `user:`-scoped grant. This is the single
mechanism JIT shadow access (bd_2_0-acb.3) and enclave-local grants (bd_2_0-bs1,
§10) both ride — an "out-of-org grantee" is always an explicit, revocable grant,
never an implicit reach.

**Two orthogonal identity axes — do not conflate them.**

- **Grant-principal *type*** — what a grant is *attached to*: `user:<sub>` or
  `group:<org>:<grp>` (§5). This already exists in the principal string.
- **Operator *kind*** — *what sort of actor* is making the request:
  `human-operator`, `agent-operator` (bd_2_0-bvw), or `service`/machine. Both
  humans and machines are `user:<zitadel-sub>` (kind not represented in the
  principal id); the `agent-operator` kind is carried by the controlplane's own
  60s agent-operator token class (`internal/controlplane/agenttoken`), which the
  operator API accepts through a **second authenticator** composed with the
  Zitadel one (`authn.Any`): the kind comes from *which authenticator accepted
  the token* — structurally, never from a claim a caller could choose — so an
  agent token cannot be read as a human principal and vice versa.

  **Kind is carried as a validated request *attribute*, not encoded in the
  principal ID.** authn derives `Principal.Kind` from validated token claims; it
  lands in the audit record as a dedicated `actor_kind` field (distinct from the
  actor id) and is targetable as a **policy condition** (AWS
  `aws:PrincipalType`-style). The principal **ID stays opaque/neutral**. This
  keeps kind orthogonal and extensible, gives bvw its agent-vs-human audit
  distinction, and — crucially — *constrains* the principal-id-neutrality
  decision (bd_2_0-xew.4) without pre-empting it: whatever ID shape xew.4 picks
  (opaque external `sub` or a dvx-minted id), it must **not** need to encode kind.

  *Implemented (bvw.2–bvw.7):* `Principal.Kind` is populated by both
  authenticators (the dvx one pins `RequireKind=agent-operator` at validation);
  the audit schema carries `actor_kind` as a first-class column (migration 00009,
  both audit stores); and the authz layer's **kind condition** is live — every
  capability-gated route refuses the `agent-operator` kind before the grant load
  (`kindAllowed`, `internal/controlplane/iam/authz.go`), so the agent's v1
  surface is exactly the own-actor audit read regardless of what it is granted.
  `agent:<uid>` grants land per §5; the whole caller class is live-verified on
  daevix-dev (bd_2_0-bvw.7).

**Platform reach into an org comes in three tiers — do not conflate them.**

1. **Standing, unconditional — reading non-sovereign controlplane data.** The
   platform *operates* the controlplane, so reading any org's controlplane audit
   is a **standing** platform authority (`platform:audit:read`, bd_2_0-acb.2 /
   bd_2_0-zb4.2) — **no approval, no break-glass, no asking the org owner**. This
   is the price of using a controlplane, and exactly why sovereign data never
   lives there. It is *not* "reaching into the org's management plane"; it is the
   platform reading its own observability over non-sovereign identity/policy
   metadata. The org's *consent* is not required — but the read is never invisible
   to the org (reciprocal transparency, below).
2. **Approval-gated — controlplane management mutations on a customer org.**
   Acting *on* an org's governance (policy authoring, members/grants, agents) is
   **not** standing: a platform operator holds **zero** standing management
   authority inside a customer org. Elevation comes only via **JIT
   customer-approved** shadow access (bd_2_0-acb.3), expiring — there is **no**
   unilateral platform emergency lever (bd_2_0-acb.4 is retired). This is the
   "never without approval" invariant: it deliberately trades away an
   unreachable-customer emergency lever for the guarantee that management
   authority into a customer plane is *always* customer-consented.
3. **Customer-authored only — the enclave / sovereign plane.** Reaching sovereign
   data is **never** a platform standing authority and **never** a platform
   break-glass; it is only ever a **customer-authored, revocable** enclave-local
   grant (bd_2_0-bs1, §10), never platform-minted.

A standing customer-*configured* emergency path for tier 2 is not part of v1; if
ever wanted it is additive and re-filed, customer-configured (never
platform-minted), and never yields sovereign data.

**What this constrains downstream:**

- **acb.2** removes the `platform:admin` confers-everything behavior and defines a
  **platform-operator** role holding an explicit, closed set of platform-scoped
  caps (org lifecycle, enclave registration, platform config, the policy floor,
  managing other platform operators) plus the explicit `platform:audit:read` cap
  (standing cross-org *controlplane* audit, non-sovereign; consumed by
  bd_2_0-zb4.2). A platform operator gets **zero** standing `agents:read` /
  `policy:write` / *org-internal or enclave* `audit:read` inside any customer org
  — the standing `platform:audit:read` above is **controlplane** audit only
  (non-sovereign), a different capability from the org's own `audit:read`.
- **acb.3** is the *only* path for a platform operator to reach into a customer
  org's management plane, and it is customer-approved and expiring.
- **acb.4** (unilateral break-glass) is **retired** by the break-glass decision
  above.
- **bvw** consumes the operator-kind attribute + `actor_kind` audit field —
  **done** (bvw.2: kind + first-class `actor_kind`; bvw.3: the CP token issuer;
  bvw.5: gateway injection + the second authenticator; bvw.6: `agent:` grants;
  bvw.7: live-verified; bvw.8: outbound WIF federation proof,
  `docs/wif.md`/`deploy/wif/gcp/README.md`; bvw.9 cross-enclave trust deferred
  by design).

**Reciprocal transparency: an org can always see who-outside-it touched its
audit.** Standing access (tier 1) is *unconditional but never invisible*, and
granted access (tier 3) is logged at both grant and use: whenever a principal
homed *outside* an org accesses that org's audit — via a standing platform
authority (CP) or a customer-authored grant (enclave) — the **use**, and where a
grant exists its **authoring**, is audited **into the target org's own audit
view**, so the org can see "external principal P (`actor_kind`, home org) read /
was granted my audit at T", even though P is not a member. The watched can see the
watcher. This holds on both planes:

- **Controlplane plane:** a platform operator's cross-org read
  (`platform:audit:read`, bd_2_0-acb.2 / bd_2_0-zb4.2) is standing and needs no
  approval, but it still emits a read event the **target org** can see in its own
  audit — not merely a platform-side log (there is no grant step to log here,
  since the read is standing; the transparency is the read event itself).
  *Design nuance for zb4.2:* a fleet-wide (`OrgScope=all`) read must surface a
  read event **each affected org can see in its own audit view** (e.g. a per-org
  read record for every org whose rows were returned, or an equivalent the org's
  audit query resolves), not a single platform-only event — otherwise the
  transparency guarantee is silently lost for a fleet read.
- **Enclave plane:** because enclave audit read is pull-to-the-enclave (§10),
  both the authoring of a (future) out-of-org enclave grant **and** the grantee's
  reads are recorded in the **enclave's own** audit, inherently visible to the
  owning org.
- **acb.3 JIT lifecycle** (request/approve/deny/revoke/expiry) is likewise
  visible to the org whose plane is being accessed.

This is a transparency *strengthening* (never a widening); its threat-model
reconciliation rides bd_2_0-zb4.2 (the CP cross-org read path) and bd_2_0-bs1.5
(the enclave plane).

## 12. Principal identity neutrality (bd_2_0-xew.4 decision)

The one place the IAM abstraction is load-bearing for *swappability of identity*
— not just of the API shape — is the `sub` baked into `user:<sub>` grant
principals (§5) and re-derived from every token's claims (§6). Two ways to
represent it were on the table:

- **(a) opaque external subject** — treat `sub` as an opaque, stable id the
  provider supplies; dvx stores it verbatim. Cheap; contractually assumes *the
  IdP owns principal identity*.
- **(b) dvx-minted principal id** — dvx mints its own stable id and keeps an
  `id → provider-sub` mapping table, so a principal survives an IdP swap
  unchanged. Fully swappable; needs a stored identity-mapping table.

**Decision: (a) — the principal id is the opaque, stable external subject the
provider presents. dvx does not mint its own principal id, and there is no
`id → sub` mapping table.**

**Rationale.**

- **It preserves the "identity is never stored, always re-derived from claims"
  invariant (§6, §3).** A dvx-minted-id mapping table is exactly the kind of
  stored identity state that §6 deliberately does *not* have — the one thing
  that could drift from the IdP's truth and need cache invalidation. (b) buys
  swappability by re-introducing the state the whole design is built to avoid.
- **The port contract stays trivial.** The provider-neutral `iam.Provider` port
  (bd_2_0-xew.3) need only require "a stable, opaque, unique-within-issuer
  subject" — a guarantee *every* OIDC IdP already meets via `sub`. No IdP has to
  understand a dvx-internal id space.
- **The neutrality that actually matters is already met.** Grants store an
  opaque prefixed string; the cover rule (§5) and the `grant_` schema never
  interpret the bytes after `user:`. So the *contract shape* is already
  provider-independent regardless of who authored the `sub` — which is the goal
  of the xew epic. Provider-*independence of identity itself* (surviving an IdP
  swap without touching grants) is a materially weaker requirement than
  independence of the API, and is not worth standing state to buy up front.
- **Kind stays orthogonal (bd_2_0-acb.1, §11), by construction.** An opaque
  subject encodes no operator kind; kind travels as the validated `Principal.Kind`
  attribute and the audit `actor_kind` field. (a) trivially satisfies acb.1's
  "the ID must not need to encode kind" constraint.

**What this pins downstream.**

- **`iam.Provider` port (bd_2_0-xew.3).** The port exposes a principal as an
  opaque stable external subject `string`. No provider vocabulary or provider id
  (`zitadel_sub`, IdP-specific ids) may appear *in the principal* or cross into
  handlers/domain — the same discipline the port applies to org/group ids. The
  authenticator continues to map the token `sub` → `Principal.Subject` verbatim
  (§6); that is the entire identity contract.
- **Grant principal key — no schema change.** The grant principal stays
  `user:<sub>` where `<sub>` is the opaque external subject.
  `internal/authz.Principals` / `GroupPrincipal` (`internal/authz/principals.go`)
  remain the *single* source of the principal-string format for both planes, and
  are unchanged by this decision.
- **Federation (bd_2_0-xew.5) does not change the contract.** When per-org
  upstream IdP federation lands, dvx keys on the subject *Zitadel* presents (its
  stable user id for the federated identity), never the raw upstream-IdP subject.
  Federation is therefore invisible to the principal contract — the single-issuer
  topology (§2) already makes Zitadel the one issuer whose `sub` dvx trusts.

**The (b) escape hatch stays open and cheap.** Because grants store an opaque
prefixed string and `internal/authz.Principals` is the single chokepoint that
turns claims into that string, adopting (b) later — *if* provider-independence of
identity ever becomes as valuable as independence of the API — is a localized
additive change (a mapping table plus that one function), not a contract or
schema break. Choosing (a) now forecloses nothing; it just declines to pay for
state we do not yet need.

## 13. Service accounts: machine users as operators (bd_2_0-ugb)

A **service account** (SA) is a machine principal that acts as an operator under
its own grants: it authenticates to the operator API with its own credential
(private_key_jwt via `internal/authn/jwtprofile` — no client_secret anywhere) and
is authorized by exactly the same capability × scope machinery as a human (§5).
SAs exist so automation never has to borrow a human's token. The controlplane
records each SA as a `service_account` row
(`internal/database/migrations/controlplane/00021_service_account.sql`): an
**opaque dvx id** (the only id the public API ever returns), the owning org, a
**level** (`org` | `self-service` — the settled vocabulary; there is **no
"personal" tier**, that concept was superseded by the group-scoped self-service
design and user scope was deferred in the closed decision bead bd_2_0-ugb.4), an
`owner_subject` (self-service only; the creator's full grant principal
`user:<sub>`, which anchors the delegation ceiling and the offboarding cascade),
the upstream `provider_sub` (**stored internally, never returned** — grants key
on its `user:<sub>` principal, but callers only ever handle the dvx id), and the
current/retiring key ids — key *material* is never stored.

### Two tiers

- **Org-level SA** — owned by the org, managed by `serviceaccounts:manage`
  holders: `POST`/`GET /api/v1/orgs/{org}/serviceaccounts`, `GET`/`DELETE
  .../serviceaccounts/{sa}`, `POST .../keys` (rotation). Created with no
  inherited authority; it holds only what it is explicitly granted.
- **Self-service (group-scoped) SA** — created by any **human** operator for
  themselves via `POST /api/v1/orgs/{org}/serviceaccounts/self-service`, joined
  to one or more groups **the creator is a member of** (recorded as `dvx:groups`
  backend metadata, projected into the `urn:dvx:groups` token claim like any
  other membership, §6): the SA's *inherited* authority is exactly those groups'
  grant authority. Creation is gated on `org:read` at the path org — the baseline
  membership verb every org member holds — because the ceiling, not the gate, is
  what bounds the SA's authority. Creation is also confined to the caller's **own
  org** (group-membership claims are namespaced by the caller's org, so a
  cross-org create could otherwise join the SA to a same-named group the
  creator's membership says nothing about).

### The delegation ceiling (self-service)

Every own-grant requested for a self-service SA — at creation and at every later
grant change — must pass the **delegation ceiling** (`selfServiceCeilingCheck`,
`internal/controlplane/iam/selfserviceapi.go`): three conjunctive rules checked
against the **owner's** effective grants (composed live — direct grants plus
group-derived authority read from the backend, failing closed on a lookup
error):

1. **Denylist** — the org-level manage verbs (`policy:write`, `groups:manage`,
   `members:manage`, `roles:manage`, `idp:manage`, `org:manage`) and every
   `platform:*` verb are **never** delegable, even when the owner holds them:
   the SA carries the owner's operational reach into their groups, not their
   administrative authority, onto a credential that outlives a session.
2. **Owner-subset** — the capability must be covered by the owner's own
   effective grants at that scope, checked with the same `authz.Authorized` /
   `Target.Covers` the enforcement path uses, so the ceiling cannot drift from
   enforcement.
3. **Scope cap** — group or agent scope only; never org, never platform.

Self-service SAs accept **capability-only** grants (never a role bundle — a
bundle could smuggle a denylisted verb past the ceiling). Every post-create
grant change re-checks the **current** owner's ceiling
(`selfServiceOwnerGrants`): after a transfer the *new* owner's ceiling applies,
never the caller's, so a manage holder who out-wields the owner cannot widen the
SA through them.

### Kind gate and kill-switch

Two fail-closed gates sit in front of the self-service surface:

- **Human-only kind gate**: every self-service route (create, transfer, and the
  per-SA routes when the SA is self-service) denies a non-human caller **before
  any grant is consulted** — no SA and no agent-operator can mint or administer
  a self-service SA. SA provisioning stamps `dvx:kind=service` backend metadata
  (projected to the `urn:dvx:actor-kind` claim), so an SA's token authenticates
  as `kind=service` and cannot slip past the gate as an unspecified caller. An
  SA *authenticating as an operator* on the ordinary capability-gated routes is
  of course the point of the feature — the kind gate applies only to the
  self-service SA lifecycle surface.
- **Kill-switch**: the org-scope, platform-lockable configvalue key
  `iam.sa_creation` gates self-service creation. **Unset allows** (the switch
  only ever disables); an org admin sets it false to close the door for their
  org; a platform-scope **lock** pins it off for every org below, which an org
  admin cannot re-enable. A settings-read error or a malformed value fails
  closed (creation refused).

### Credential custody, rotation, and audit

The credential (the service-account key file for private_key_jwt) is returned
**exactly once**, at creation or rotation, and is never stored or logged by
dvx. Creation and every key mint emit AUTHORITY-tier audit events
(`serviceaccount.create`, `serviceaccount.key.mint`; `serviceaccount.transfer`
on ownership transfer) through `emitAuthority` (§8). **Rotation** (`POST
.../serviceaccounts/{sa}/keys`) mints a new credential and retires the old key
id at the provider (`iam.Provider.RemoveMachineKey`, idempotent — the rotation
teardown half of `AddMachineKey`). The store tracks `key_id` /
`retiring_key_id` so that **at every instant, every key live at the provider is
one of the two**: a rotation interrupted mid-flight compensates on
cancellation-independent contexts and converges on retry rather than leaving an
untracked live credential. Rotation is ownership-aware like the other per-SA
routes: for a self-service SA the **owner or a manage holder** may rotate (an
owner locked out of a lost or compromised credential until an admin happens by
would defeat the self-service intent); org-level SA rotation stays manage-only.

### Ownership transfer

`POST /api/v1/orgs/{org}/serviceaccounts/{sa}/transfer` — `serviceaccounts:manage`
at the path org, human-only — re-points `owner_subject`: the administrative
answer when an SA outlives its creator's membership. Self-service only (an
org-level SA has no owner); idempotent. Transfer does **not** re-validate the
SA's `dvx:groups` membership against the new owner: an SA transferred to a
principal who is **not** a member of one of its groups **keeps** that inherited
group authority, which can exceed the new owner's own. This is deliberate — the
group membership belongs to the org's coordinate space, not to the owner, and
stripping it on transfer would silently change what the automation the SA runs
can do. Later **grant changes** are still bounded by the new owner's ceiling
(above), but the inherited group authority stands. The administrator's remedy
for an SA whose inherited authority is unwanted is deletion (or out-of-band
membership correction), not transfer.

### Teardown: delete and the offboarding cascade

Deleting any SA (`DELETE .../serviceaccounts/{sa}`) is a full teardown: revoke
every grant held by its `user:<provider_sub>` principal, clear its `dvx:groups`
membership metadata, delete the upstream machine user, remove the row.
`POST /api/v1/orgs/{org}/users/{sub}/offboard` (`members:manage` at the path
org, subject from the path never the body) runs the same cascade for every
self-service SA the subject owns **within the path org**: the lookup is
org-scoped like every other store query, so an admin offboarding a subject from
org A can never touch that subject's SAs in org B — the destructive surface is
bounded by the capability at the path org *and* an org-scoped data lookup. A
subject with no self-service SAs in the org is an idempotent success no-op. Both
paths converge on retry (an already-absent upstream machine user counts as
done), and both run the bulk **last-org-admin guard** (§8): when the batch's
union of grants holds some org's sole admin grant(s), the teardown refuses with
409 **before** any upstream or database change, so a cascade can never strand an
org with zero admins.

### Grant management is SA-scoped

Grants on an SA are managed through `.../serviceaccounts/{sa}/grants`
(create/list/revoke), addressed by the SA's opaque dvx id — request and response
**never** carry the raw `user:<sub>` principal string, so operators never handle
a backend subject (the no-leaks discipline). Authorization on these routes is
the ownership-aware `saAccess` rule (`RequireServiceAccountAccess`), **not**
`members:manage` — who may wield a service account is an SA-management decision,
not member lifecycle: org-level SAs require `serviceaccounts:manage` at the path
org; a self-service SA admits its **owner** (no capability needed) or a manage
holder, behind the human-only kind gate, with agent-operators refused up front.
A missing or cross-tenant SA id resolves as 404 for a manage holder (they may
list the org's SAs anyway) and 403 for anyone else — never a cross-tenant
existence leak. A grant on the SA's principal in *another* org or at platform
scope is invisible to the SA-scoped list and undeletable through it.

### Token acquisition

A service account turns its credential into an access token at the **token
facade**: `POST /oauth/token` on the controlplane (bd_2_0-0kj). The facade is
the **intended path** — a caller configures **only dvx**: one base URL, one CA
bundle, and no knowledge of the identity issuer's address, endpoint layout, or
trust store. The facade proxies the exchange to the deployment's identity
issuer and returns the issuer's response verbatim; the issuer keeps assertion
verification, key lookup, and revocation, and the controlplane never stores,
caches, or logs assertion or token material.

The key file returned at creation or rotation is **the service account
credential** (a private_key_jwt key; there is no client_secret anywhere). To
exchange it, the caller signs a short-lived client assertion (RFC 7523) with
that key and POSTs a form to the facade:

```
POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion=<signed JWT>
&scope=openid
```

The assertion carries `iss`/`sub` = the service account's subject (both are in
the credential file), a short `exp`, and an `aud` of **the audience your
credential file is bound to** — the deployment's identity-issuer identifier,
supplied to you alongside the controlplane address. This is the one value the
caller must know beyond dvx's own URL, and it is worth being precise about
why: the issuer validates the assertion's `aud` strictly against the issuer
URL *it* sees, and the facade forwards the signed assertion byte-for-byte (a
proxy cannot rewrite signed content), so the audience must name the issuer as
the issuer knows itself — never the facade's own address. Note the division of
labor this preserves: the caller never *dials* the issuer (every token request
goes to dvx), it only embeds the issuer's identifier inside the signed
assertion, exactly as RFC 7523 requires wherever the request is sent.

Responses are OAuth 2.0 shapes end to end: a successful exchange is the
issuer's token response verbatim with `Cache-Control: no-store`, and every
error — the facade's own refusals and the issuer's, forwarded verbatim with
the issuer's status — is an RFC 6749 §5.2 OAuth error body (`error`,
`error_description`), **not** an RFC 9457 problem document. That is the
controlplane's one documented exception to the problem-document rule, made so
standard OAuth clients work unchanged (see
[`http-api-conventions.md`](http-api-conventions.md)). The facade is
fail-closed and abuse-bounded: jwt-bearer is the only grant type it forwards
(anything else is a local `unsupported_grant_type` refusal with no upstream
call), the form body is size-capped, requests are rate-limited, the upstream
call has a bounded timeout with no retries, and every request emits an
AUTHORITY-tier `token.issue` audit event (`docs/threat-model.md` §4.4).

**Direct-to-issuer acquisition remains supported** for existing clients —
nothing about the facade breaks a caller that already exchanges its assertion
at the issuer's own token endpoint — but it is no longer the documented path:
new integrations should know dvx and only dvx.
