Generic design for a multi-tenant platform that translates OIDC project-scoped grants into NATS transport-level ACLs. Service-declared permission manifests. Two-phase token model: Identitet for discovery, audience-scoped tokens with standard Zitadel role claims for service operation.
Relationship to Identitet
This design describes the internal mechanism that Identitet uses to bridge Zitadel (the IdP) and the NATS transport layer. Per ADR 001, Identitet is the Dataverket service responsible for identity and access management. Per ADR 023, Identitet owns:
- human authentication through OIDC
- service and workload identity
- token issuance/validation for CLIs and APIs
- RBAC and authorization context for tenant, project, and operator scopes
The NATS auth callout described here is Identitet’s runtime authorization component — the “authorization inputs” that ADR 023 requires.
Identitet is itself a Zitadel project. Its project ID in the token audience gates the callout: tokens with Identitet in aud trigger the API call; service-scoped tokens carry role claims in-token via standard Zitadel URN claims.
Identitet components in this design:
| Component | Role | NATS subject |
|---|---|---|
| Auth callout | Runtime: audience gate + grant→ACL translation | (internal NATS auth path) |
| Provisioning agent (zitadel-agent) | Admin: creates projects, grants, user_grants in Zitadel | dv.rpc.identitet.* |
| KV policy store | Stores per-service role→suffix manifests | (JetStream KV) |
dvid login | CLI auth entrypoint, obtains OIDC tokens | (client-side) |
The callout operates in two modes depending on token audience:
- Identitet token (
identitetProjectId∈aud): Calls Zitadel API with user’s own token to resolve all grants. Grants identitet-scoped ACLs. Returns grant list to client. - Service token (service project IDs ∈
aud): Reads standardurn:zitadel:iam:org:project:{projectId}:rolesclaims from JWT. Generates ACLs for those projects only. No API call.
Sentral (control-plane facade) relies on Identitet for authorization. Once NATS ACLs are set, Sentral handlers receive only authorized messages — no embedded auth model.
Architecture
Zitadel Object Model
All projects carry the same three roles. No project-type-specific roles.
| Zitadel project | Service (ADR 001) | Scope | Roles |
|---|---|---|---|
| identitet | Identitet | IAM, grant discovery | admin, member, viewer |
| platform | Plattform | Kubernetes platform | admin, member, viewer |
| compute-region-a | Maskin | Compute — VMs, bare metal (per region) | admin, member, viewer |
| s3-archive-de | Objekt | S3 storage (per location) | admin, member, viewer |
| env-prod | — | Environment boundary | admin, member, viewer |
| env-staging | — | Environment boundary | admin, member, viewer |
Identitet is a project like any other. Its project ID is the audience gate. Every platform user gets a UserGrant on identitet (typically member or viewer).
ProjectGrant: Provider org grants a project to a customer org. Controls which role_keys the customer can assign.
ProjectGrant {
project_id: compute-region-a
granted_org_id: customer-org-123
role_keys: [admin, member, viewer]
}UserGrant: Customer org assigns a user a role on a granted project (via project_grant_id for cross-org grants).
UserGrant {
project_id: compute-region-a
user_id: alice@customer
role_keys: [admin]
project_grant_id: <grant-id>
}The uniform role set means:
- Auth logic has zero project-type awareness
- No role naming collisions across project types
- Adding a new project type requires no auth changes
Standard Role Claims
Zitadel natively includes per-project role information in JWTs via URN-namespaced claims. No custom Actions or webhooks needed.
Claim Format
For each project in the token’s audience, Zitadel adds:
urn:zitadel:iam:org:project:{projectId}:rolesValue structure — roles as keys, each mapping org IDs to org domains:
"urn:zitadel:iam:org:project:{computeProjectId}:roles": {
"admin": {
"{custOrg}": "customer.example.com"
},
"member": {
"{custOrg}": "customer.example.com",
"{otherOrg}": "partner.example.com"
}
}This provides everything the callout needs:
| Field | Source in claim |
|---|---|
projectId | Claim key (between project: and :roles) |
roles | Keys of the value object |
orgId | Key in each role’s nested map |
orgDomain | Value in each role’s nested map (informational) |
A single role can map to multiple orgs (cross-org ProjectGrants). The callout generates one ACL set per (projectId, orgId, role) triple.
Enabling Role Claims
Two mechanisms (use both for defense-in-depth):
| Mechanism | Scope | Effect |
|---|---|---|
Project setting: projectRoleAssertion: true | Per project (set at provisioning) | Roles always included in tokens for this project |
OIDC scope: urn:zitadel:iam:org:projects:roles | Per token request | Roles included for all audience projects in this token |
The scope urn:zitadel:iam:org:projects:roles (note: projects plural) requests role claims for all projects in the token’s audience. The audience is controlled by urn:zitadel:iam:org:project:id:{projectId}:aud scopes.
Audience Scoping
Role claims are audience-scoped: only projects in the token’s aud get a role claim. A token with aud: [env-prod, compute-region-a] contains role claims for those two projects only. This is the same scoping the Complement Token Action provided — but built into Zitadel, no webhook required.
Provider vs. Customer Detection
The security boundary is provider vs. customer, not human vs. machine. The orgId nested inside each role claim is the natural discriminator:
| Grant origin | orgId in role claim | ACL pattern |
|---|---|---|
| Direct UserGrant (provider org) | orgId == providerOrgId | *.*.{projectId}.*.*.<suffix> (cross-org wildcard) |
| ProjectGrant (customer org) | orgId != providerOrgId | *.{orgId}.{projectId}.*.*.<suffix> (org-scoped) |
- Provider org users (human operators and platform machine users) hold direct UserGrants in the provider org. Their
orgIdmatchesproviderOrgId→ cross-org wildcard ACL, allowing them to act across all customer namespaces. - Customer org users receive grants through ProjectGrants. Their
orgIdis the customer org → org-scoped ACL, confining them to their own namespace. providerOrgIdis static configuration — set once at deployment, never changes.
Client Bootstrap
The client’s only required configuration is the platform hostname (e.g., nats.platform.example.com). All OIDC parameters are discovered via RFC 9728 — OAuth 2.0 Protected Resource Metadata.
The platform hostname serves HTTPS on port 443 (same Go binary or sidecar). The client fetches:
GET https://nats.platform.example.com/.well-known/oauth-protected-resourceResponse:
{
"resource": "https://nats.platform.example.com",
"authorization_servers": ["https://auth.platform.example.com"],
"scopes_supported": [
"openid",
"profile",
"urn:zitadel:iam:org:projects:roles"
],
"bearer_methods_supported": ["header"],
"identitet_project_id": "{identitetProjectId}",
"client_id": "{dvidClientId}"
}Standard RFC 9728 fields (resource, authorization_servers, scopes_supported, bearer_methods_supported) describe the resource server. Two additional metadata parameters (identitet_project_id, client_id) are Dataverket-specific — RFC 9728 §2 permits additional parameters. The client_id is the numeric resource ID of the Zitadel OIDC application (e.g., 284759371649234567).
The client follows authorization_servers[0] to Zitadel’s /.well-known/openid-configuration for OIDC endpoints, then performs authorization_code + PKCE using the discovered client_id.
Bootstrap sequence:
dvid login nats.platform.example.com- Fetch
/.well-known/oauth-protected-resource→ issuer, client ID, project ID - Fetch Zitadel OIDC discovery → authorization and token endpoints
- OIDC
authorization_code+ PKCE → access token withidentitetProjectIdinaud - Connect to
nats://nats.platform.example.com:4222with access token
The metadata is static (changes only when the Zitadel instance or project IDs change). Serve as a ConfigMap or static file alongside NATS. Standard HTTP caching headers apply (RFC 9728 §7.10).
In production, HAProxy terminates TLS on port 443 and routes /.well-known/* to a static backend before dispatching WebSocket or TCP traffic to NATS — single hostname, single cert.
Dev fallback: context file
In development there is typically no TLS frontend — just nats://localhost:4222. The CLI falls back to a local context file when discovery returns connection refused or 404:
dv context add --name local --server nats://localhost:4222 \
--issuer https://localhost:8080 --client-id 284759371649234567 --project-id 391048267513984201Contexts are stored in ~/.config/dv/contexts.yaml (XDG). The active context is selected with dv context use <name>. This follows the kubeconfig/nats-context pattern.
Client identity separation
The client_id served by discovery and the one used in context files can be different Zitadel application registrations. The resulting access token carries azp (authorized party) set to the application’s client_id, so the auth callout can distinguish how the client bootstrapped. Scenarios:
- Audit: log whether connections used discovery or manual config.
- Policy: reject manual client_ids in production, or restrict discovered clients to non-dev environments.
- Rotation: revoke or rotate one registration without affecting the other.
This is optional — a single registration works for both paths. Two registrations add value only when the callout needs to differentiate.
Token Model
Two-phase. Identitet for discovery, audience-scoped tokens for service operation.
Phase 1: Identitet Discovery
The client authenticates with Identitet as audience. The callout sees identitetProjectId in the aud claim, calls the Zitadel Auth API with the user’s own token, and returns the full grant list over NATS.
OIDC scopes:
openid profile
urn:zitadel:iam:org:project:id:{identitetProjectId}:audThe scope puts Identitet in aud, which the callout checks. The Auth API (/auth/v1/) identifies the user from sub — it does not require zitadel in the audience (Zitadel docs: the Auth API is exempt from the urn:zitadel:iam:org:project:id:zitadel:aud requirement).
The callout calls Zitadel’s authenticated user grant search:
POST /auth/v1/usergrants/me/_search
Authorization: Bearer {user's access token}
Content-Type: application/json
{"query": {"offset": "0", "limit": 100, "asc": true}}Pagination: limit: 100. For users with >100 grants, paginate using offset. Implementation detail.
Response (not audience-scoped — returns all grants the user holds):
{
"details": {"totalResult": "4"},
"result": [
{
"orgId": "{custOrg}",
"projectId": "{identitetProjectId}",
"projectName": "identitet",
"roleKeys": ["member"],
"userId": "{userId}",
"userType": "TYPE_HUMAN"
},
{
"orgId": "{custOrg}",
"projectId": "{platformProjectId}",
"projectName": "platform",
"roleKeys": ["admin"]
},
{
"orgId": "{custOrg}",
"projectId": "{envProdProjectId}",
"projectName": "env-prod",
"roleKeys": ["member"]
},
{
"orgId": "{custOrg}",
"projectId": "{computeProjectId}",
"projectName": "compute-region-a",
"roleKeys": ["viewer"]
}
]
}The callout generates ACLs only for the identitet project. The full grant list is cached in-process and served to the client via qry.grants.list for service/environment discovery.
Phase 2: Service Operation
The client requests a token with desired service project(s) in the audience. Zitadel includes standard urn:zitadel:iam:org:project:{projectId}:roles claims for each audience project. The callout reads from the token — no API call.
OIDC scopes:
openid profile
urn:zitadel:iam:org:projects:roles
urn:zitadel:iam:org:project:id:{envProdProjectId}:aud
urn:zitadel:iam:org:project:id:{computeProjectId}:audFirst scope requests role claims for all audience projects. Remaining scopes put specific projects in aud.
Resulting JWT:
{
"aud": ["{envProdProjectId}", "{computeProjectId}"],
"urn:zitadel:iam:org:project:{envProdProjectId}:roles": {
"member": {
"{custOrg}": "customer.example.com"
}
},
"urn:zitadel:iam:org:project:{computeProjectId}:roles": {
"viewer": {
"{custOrg}": "customer.example.com"
}
}
}Multiple audiences per token are supported. A client can either:
- One token with multiple
audscopes → one NATS connection with combined ACLs - Separate tokens per service → separate connections with minimal blast radius
Client-side trade-off: convenience vs. isolation. The callout handles both.
Token lifetime is short; refresh tokens handle re-issuance.
Machine Users
Machine users (service accounts) authenticate via client_credentials — no interactive OIDC flow, no Phase 1. Grants are provisioned at deployment; audience scopes are configured in the credentials request.
| Aspect | Human | Machine |
|---|---|---|
| Authentication | OIDC authorization code | client_credentials |
| Phase 1 (discovery) | Yes | No — grants known at deployment |
| Phase 2 (service) | Yes | Yes |
| Grant source | API (Phase 1) or standard role claims (Phase 2) | Standard role claims only |
| ACL scoping | orgId == providerOrgId → cross-org; else → org-scoped | Same rule — provider machine users get cross-org via direct UserGrant |
ACL scoping is identical for human and machine users — determined by orgId from the role claim, not by authentication method.
NATS Subject Structure
{providerId}.{customerId}.{projectId}.{serviceType}.{location}.{msgType}.{resource}[.{detail}]Positions:
- 0: provider org ID (constant per deployment)
- 1: customer org ID (from grant
orgId/ role claim org key) - 2: Zitadel project ID (from grant
projectId/ role claim URN key) - 3: service type (e.g.,
cluster,s3,env,platform) - 4: location/instance qualifier
- 5: message type (
cmd,qry,evt) - 6+: resource path
Position 2 (project ID) ties the subject to the Zitadel grant. The callout maps (projectId, role) to allowed suffixes at positions 5+.
Auth Callout
Dual-Path Design
The callout checks the JWT aud claim to decide its behavior:
| Token type | aud contains | Grant source | Zitadel API call | ACLs generated for |
|---|---|---|---|---|
| Identitet token | identitetProjectId | Zitadel Auth API (user’s own token) | Yes | identitet project only |
| Service token | service project ID(s) | Standard urn:zitadel:iam:org:project:{pid}:roles claims | No | audience projects |
| Unknown token | neither | — | No | public only |
Data Sources
| Data | Source | Latency |
|---|---|---|
| User identity | JWT sub claim | In-token |
| OIDC signature | JWKS (cached, refreshed on rotation) | In-memory |
| Grants (identitet path) | Zitadel Auth API (user’s own token) | HTTP call (cacheable) |
| Grants (service path) | JWT urn:zitadel:iam:org:project:{pid}:roles claims | In-token |
| Per-project role→suffix policy | KV rolePermissions.{projectId} (watched) | In-memory |
| Default role→suffix policy | Static config | In-memory |
Grant Transport to Handler
The callout and Identitet handler run in the same process. On the identitet path, the callout writes fetched grants to an in-process sync.Map keyed by NATS client ID. The handler reads this cache when serving qry.grants.list. Entry lifetime is bound to the NATS connection.
No security risk: in-process memory written by the trusted callout. The handler resolves client ID from NATS message metadata — no cross-connection access.
Go Implementation
Auth callout: nats-auth-callout.go
Key types and functions:
| Symbol | Purpose |
|---|---|
Grant | Unified grant type (from API or standard role claims) |
HandleAuth() | Entry point — dispatches on identitetProjectId ∈ aud |
setPermissions() | (projectId, role) → NATS ACL suffixes |
parseRoleClaims() | Extracts Grant slice from urn:zitadel:iam:org:project:{pid}:roles claims |
ZitadelGrantFetcher | Calls /auth/v1/usergrants/me/_search with user’s token |
grantCache | sync.Map — callout writes, handler reads |
Default Policy
defaultRolePermissions:
admin:
- "cmd.>"
- "qry.>"
- "evt.>"
member:
- "cmd.resource.>"
- "qry.>"
viewer:
- "qry.>"Service-Declared Policy
Services publish their role→suffix mapping when they register with the manager. The manager validates and writes to KV. The auth callout watches the KV bucket.
// KV key: rolePermissions.{s3ArchiveProjectId}
{
"admin": ["cmd.>", "qry.>", "evt.>"],
"member": ["cmd.bucket.create", "cmd.bucket.delete", "cmd.object.>", "qry.>"],
"viewer": ["qry.>"]
}The callout is a generic policy executor. Each service declares what member means for its resources. New services register — no auth code changes.
Validation constraint: the manager rejects suffixes that don’t match the pattern (cmd|qry|evt).(.+). Since ACLs are scoped to *.{orgId}.{projectId}.*.*.<suffix>, a service can only declare permissions within its own subject namespace.
Manager Handler Pattern
The manager subscribes to wildcard subjects pinned to the provider ID:
// One subscription per message type per service type. Covers all project IDs.
nc.Subscribe(fmt.Sprintf("%s.*.*.cluster.*.cmd.resource.>", providerID), clusterCmdHandler)
nc.Subscribe(fmt.Sprintf("%s.*.*.s3.*.cmd.resource.>", providerID), s3CmdHandler)
nc.Subscribe(fmt.Sprintf("%s.*.*.env.*.cmd.>", providerID), envCmdHandler)
nc.Subscribe(fmt.Sprintf("%s.*.*.platform.*.qry.>", providerID), platformQueryHandler)Handlers contain zero authorization logic. Message arrival is authorization proof.
The manager’s own machine user has UserGrants on every project it needs to handle, granting it cross-org ACLs (*.*.{projectId}.*.*.cmd.>).
Service Registration
Registration payload:
{
"serviceType": "s3",
"location": "archive-de",
"instanceProjectId": "412345678901234567",
"rolePermissions": {
"admin": ["cmd.>", "qry.>", "evt.>"],
"member": ["cmd.bucket.create", "cmd.bucket.delete", "cmd.object.>", "qry.>"],
"viewer": ["qry.>"]
}
}KV Policy Store
Implementation: nats-auth-kvstore.go
Watches a JetStream KV bucket for rolePermissions.{projectId} keys. In-memory map with read-write lock. Implements PolicyStore interface used by HandleAuth().
Security Properties
| Property | Guarantee |
|---|---|
| Subject arrival = authorization | ACLs set at connection time. Message arrival proves sender holds required grant. No app-level auth checks. |
| Audience gate | Zitadel API call fires only when identitetProjectId ∈ aud. Tokens without Identitet in audience are inert. |
| Self-contained service tokens | Service tokens carry audience-filtered roles in standard urn:zitadel:iam:org:project:{pid}:roles claims. No IdP dependency at service-connection time. |
| Per-token blast radius | Leaked token contains grants only for explicitly requested projects. env-prod token cannot produce compute-region-a ACLs. |
| No elevated credentials | Callout uses user’s own token. /auth/v1/usergrants/me/_search returns only the authenticated user’s grants. |
| Namespace isolation | ACLs contain project ID. Grant on project A cannot produce ACLs for project B. |
| Fail-closed | Zitadel API failure on identitet path → connection denied. No fallback to reduced permissions. |
| Service-declared policy | Services declare role→suffix within their namespace. Manager validates scoping. Compromised service exposes only its own handlers. |
Revocation delay. Grant removal in Zitadel takes effect on next token issuance. NATS ACLs update on reconnect. Effective delay = token_ttl + connection_duration. Clients must reconnect with a fresh token to pick up grant changes. Short token TTL (~5 min) bounds the worst case.
Latency profile.
| Path | External call | Cacheable |
|---|---|---|
| Identitet | POST /auth/v1/usergrants/me/_search | Short-TTL cache, keyed on (sub, token_hash) |
| Service | None (in-token) | N/A |
Provisioning Layer (Not Auth)
These operations call the Zitadel API. They’re admin-triggered, not on the auth path.
| Operation | Zitadel API call | Triggered by |
|---|---|---|
| Create service instance project | CreateProject, CreateProjectRole x3 | Terraform / operator |
| Grant service to customer org | CreateProjectGrant | Admin command → zitadel-agent |
| Create environment project | CreateProject, CreateProjectRole x3, CreateProjectGrant | Admin command → zitadel-agent |
| Assign user to environment | CreateUserGrant (with project_grant_id) | Admin command → zitadel-agent |
| Revoke user from environment | DeleteUserGrant | Admin command → zitadel-agent |
Terraform Example
resource "zitadel_project" "compute_region_a" {
name = "compute-region-a"
org_id = var.provider_org_id
project_role_assertion = true
}
resource "zitadel_project_role" "compute_region_a_roles" {
for_each = toset(["admin", "member", "viewer"])
project_id = zitadel_project.compute_region_a.id
org_id = var.provider_org_id
role_key = each.key
display_name = each.key
}
resource "zitadel_project_grant" "compute_region_a_to_customer" {
project_id = zitadel_project.compute_region_a.id
org_id = var.provider_org_id
granted_org_id = var.customer_org_id
role_keys = ["admin", "member", "viewer"]
}
resource "zitadel_user_grant" "alice_compute_admin" {
project_id = zitadel_project.compute_region_a.id
org_id = var.customer_org_id
user_id = var.alice_user_id
role_keys = ["admin"]
project_grant_id = zitadel_project_grant.compute_region_a_to_customer.id
}Summary
| Concern | Owner | Mechanism |
|---|---|---|
| Identity | Zitadel | OIDC JWT (signature verification) |
| Grant discovery | Auth callout (identitet path) | Zitadel Auth API with user’s own token |
| Service grants | Zitadel (standard claims) | Audience-scoped urn:zitadel:iam:org:project:{pid}:roles JWT claims |
| Grant→ACL translation | Auth callout | Generic (projectId, role) → suffixes |
| Audience gate | Auth callout | identitetProjectId ∈ aud check |
| Permission policy per service | Service agent | rolePermissions manifest in KV |
| Policy validation | Manager | Validates suffix namespace on registration |
| Transport enforcement | NATS | ACLs block unauthorized pub/sub |
| Provisioning | Zitadel-agent | Creates projects, grants, user_grants via Zitadel API |
| Handler authorization | None | Subject arrival is proof |