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.mdinvariant 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
orgtable (internal/database/migrations/controlplane/00003_iam.sql), the latter storing thezitadel_org_idmapping 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 samedvx.io/group-idcoordinate an agent’s pod label carries, and it is also our"group"table row, mapped to azitadel_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:
- It is a governance-policy scope (
internal/policy.ScopeGroup) — where destination allow/deny rules and other settings can be authored. - It is what agents belong to — the
dvx.io/group-idlabel that routes an agent under a group’s governance policy. - 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
authzresolver; 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:manageover the union of (1) the bodygroupbeing authored and (2) — when the uid already exists — the existing agent’s stored group, learned by a server-sideGet(the same class of lookup asenrichAgentTarget, 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
ListForOrgin the handler: an org-scopeagents:readholder 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:
- Extracts the bearer token (
Authorization: Bearer ...). - Verifies the JWS signature against the issuer’s JWKS — RSA
(RS256/384/512) and ECDSA (ES256/384/512) only;
alg: noneand 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 viacrypto/ecdhfor EC keys) and entirely stdlib + standard crypto. - Checks
issequals the exactly-configured issuer,audcontains the configured audience (when one is set — dev leaves it unset so any Zitadel-issued token validates), andexp/nbfagainst the clock with a configurable leeway (default 60s) for skew. - Rejects on
subbeing empty. - 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
FIRSTINSTANCEmechanism creates a native first admin — a local account, over HTTP(S), configured directly indeploy/controlplane/zitadel.yaml(ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME/..._PASSWORDfrom thezitadel-secretsSecret). 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 apsqlsession 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
orgrow and aplatform-admingrant 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.yamlalso 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 inorg.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_connectortable (migrations/controlplane/00017_idp_connector.sql) has no secret column, so dvx keeps only a reference (type/name/groups-attribute/idp_ref/status). idp:manageis its own verb, notconfig: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:orgclaim (authz.Principalsbuildsgroup:<callerOrg>:<grp>), so an org’s federation can only ever mintgroup:<thatOrg>:*— never a sibling org’s groups. This is a threat-model event (new external trust dependency); it is reconciled indocs/threat-model.mdvia 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 CPgrant_schema ininternal/database/migrations/enclave/00003_iam_grant.sql, no org/group FKs; bs1.2). Grant authoring is the broker operator APIinternal/enclave/cpbroker/grantapi.go(GET/POST/DELETE /api/v1/grants,members:read/members:manage, org from token claim never body, validated againstEnclaveCapabilities), seeded at provisioning from--owning-org/--seed-admin(bs1.3). The audit read gate iscpbroker/auditscope.go(bs1.3/zb4.3), and an explicit single-tenantrequireEnclaveOrggate 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:readcapability (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:adminconfers-everything super-capability is removed (internal/authz/resolver.gois now an exact match), replaced by theplatform-operatorrole holding an explicit closed set ofplatform:*verbs (platform:orgs,platform:policy,platform:config,platform:operators,platform:audit:read, plusenclaves:manage) — see §5. The org KIND enum (platform|customer) lands inmigrations/controlplane/00013_org_kind.sqlwith a partial-unique singleton index;SeedPlatformseeds both the platform org and a data-bearingplatform-opsorg 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/partnerkinds later), not a bareis_platformboolean. 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>orgroup:<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), orservice/machine. Both humans and machines areuser:<zitadel-sub>(kind not represented in the principal id); theagent-operatorkind 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.Kindfrom validated token claims; it lands in the audit record as a dedicatedactor_kindfield (distinct from the actor id) and is targetable as a policy condition (AWSaws: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 externalsubor a dvx-minted id), it must not need to encode kind.Implemented (bvw.2–bvw.7):
Principal.Kindis populated by both authenticators (the dvx one pinsRequireKind=agent-operatorat validation); the audit schema carriesactor_kindas a first-class column (migration 00009, both audit stores); and the authz layer’s kind condition is live — every capability-gated route refuses theagent-operatorkind 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.
- 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). - 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.
- 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:adminconfers-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 explicitplatform:audit:readcap (standing cross-org controlplane audit, non-sovereign; consumed by bd_2_0-zb4.2). A platform operator gets zero standingagents:read/policy:write/ org-internal or enclaveaudit:readinside any customer org — the standingplatform:audit:readabove is controlplane audit only (non-sovereign), a different capability from the org’s ownaudit: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_kindaudit field — done (bvw.2: kind + first-classactor_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
subas 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-submapping 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.Providerport (bd_2_0-xew.3) need only require “a stable, opaque, unique-within-issuer subject” — a guarantee every OIDC IdP already meets viasub. 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 afteruser:. So the contract shape is already provider-independent regardless of who authored thesub— 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.Kindattribute and the auditactor_kindfield. (a) trivially satisfies acb.1’s “the ID must not need to encode kind” constraint.
What this pins downstream.
iam.Providerport (bd_2_0-xew.3). The port exposes a principal as an opaque stable external subjectstring. 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 tokensub→Principal.Subjectverbatim (§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
subdvx 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:manageholders: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 asdvx:groupsbackend metadata, projected into theurn:dvx:groupstoken claim like any other membership, §6): the SA’s inherited authority is exactly those groups' grant authority. Creation is gated onorg:readat 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):
- Denylist — the org-level manage verbs (
policy:write,groups:manage,members:manage,roles:manage,idp:manage,org:manage) and everyplatform:*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. - Owner-subset — the capability must be covered by the owner’s own
effective grants at that scope, checked with the same
authz.Authorized/Target.Coversthe enforcement path uses, so the ceiling cannot drift from enforcement. - 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=servicebackend metadata (projected to theurn:dvx:actor-kindclaim), so an SA’s token authenticates askind=serviceand 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_creationgates 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.