Daevix Docs

IAM

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/iam.md · 2b997eb0

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 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, “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 — 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:

CapabilityGrants…
agents:read / agents:manageview / manage agents
policy:read / policy:writeview / author governance policy
groups:read / groups:manageview / manage group coordinate rows
members:read / members:manageview / manage org membership grants
serviceaccounts:managemanage an org’s service accounts (machine users that act as operators): create/delete, credential rotation, grant management (§13)
enclaves:read / enclaves:manageview / manage enclave registration
org:read / org:manageview / manage the org coordinate row itself
audit:read / audit:read:allread own-actor / all-actor audit within a covered org
platform:orgsorg lifecycle (list/create/delete org coordinates) — platform-scoped
platform:policyauthor the immutable platform policy floor — platform-scoped
platform:configauthor platform-scoped config — platform-scoped
platform:operatorsgrant/revoke platform-operator grants — platform-scoped
platform:audit:readstanding 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:

RoleCapabilities
memberread-only: agents:read, policy:read, groups:read, members:read, enclaves:read, org:read
admineverything within an org: all of the above plus every :manage verb (agents, policy, groups, members, enclaves, org, roles, idp, serviceaccounts) and audit:read:all
auditoraudit: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-operatorthe 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):

// 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). 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.