OIDC-to-NATS Authorization Gateway

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.

IdentitetZitadel (IdP engine)NATS Auth Calloutdvid CLI (login flow)Provisioning Agent (zitadel-agent)ProjectsGrantsUserGrantsaud checkidentitet → API callservice → in-tokenMap role → ACL suffix

Identitet components in this design:

ComponentRoleNATS subject
Auth calloutRuntime: audience gate + grant→ACL translation(internal NATS auth path)
Provisioning agent (zitadel-agent)Admin: creates projects, grants, user_grants in Zitadeldv.rpc.identitet.*
KV policy storeStores per-service role→suffix manifests(JetStream KV)
dvid loginCLI auth entrypoint, obtains OIDC tokens(client-side)

The callout operates in two modes depending on token audience:

  1. Identitet token (identitetProjectIdaud): Calls Zitadel API with user’s own token to resolve all grants. Grants identitet-scoped ACLs. Returns grant list to client.
  2. Service token (service project IDs ∈ aud): Reads standard urn:zitadel:iam:org:project:{projectId}:roles claims 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 (IdP)NATS Auth CalloutKV (JetStream)ProjectsProjectGrantsUserGrantsAuth APICheck audVerify OIDC sigidentitet path:fetch grants via APIservice path:read standard role claimsMap role → ACL suffixEmit NATS UserClaimsrolePermissions.{projectId}serviceBindingsJWT (with role URN claims)identitet aud:Bearer {user token}read (watched)

Zitadel Object Model

All projects carry the same three roles. No project-type-specific roles.

Zitadel projectService (ADR 001)ScopeRoles
identitetIdentitetIAM, grant discoveryadmin, member, viewer
platformPlattformKubernetes platformadmin, member, viewer
compute-region-aMaskinCompute — VMs, bare metal (per region)admin, member, viewer
s3-archive-deObjektS3 storage (per location)admin, member, viewer
env-prodEnvironment boundaryadmin, member, viewer
env-stagingEnvironment boundaryadmin, 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}:roles

Value 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:

FieldSource in claim
projectIdClaim key (between project: and :roles)
rolesKeys of the value object
orgIdKey in each role’s nested map
orgDomainValue 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):

MechanismScopeEffect
Project setting: projectRoleAssertion: truePer project (set at provisioning)Roles always included in tokens for this project
OIDC scope: urn:zitadel:iam:org:projects:rolesPer token requestRoles 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 originorgId in role claimACL 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 orgId matches providerOrgId → cross-org wildcard ACL, allowing them to act across all customer namespaces.
  • Customer org users receive grants through ProjectGrants. Their orgId is the customer org → org-scoped ACL, confining them to their own namespace.
  • providerOrgId is 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-resource

Response:

{
  "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:

  1. dvid login nats.platform.example.com
  2. Fetch /.well-known/oauth-protected-resource → issuer, client ID, project ID
  3. Fetch Zitadel OIDC discovery → authorization and token endpoints
  4. OIDC authorization_code + PKCE → access token with identitetProjectId in aud
  5. Connect to nats://nats.platform.example.com:4222 with 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 391048267513984201

Contexts 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.

Identitet HandlerAuth CalloutNATSZitadelClientIdentitet HandlerAuth CalloutNATSZitadelClientOIDC authorize (aud: identitet)Access tokenConnect (access token)Auth calloutVerify OIDC sigidentitetProjectId ∈ aud → API pathPOST /auth/v1/usergrants/me/_search (Bearer: user's token)All user grantsCache grants in-process (keyed by client ID)UserClaims (identitet ACLs only)qry.grants.listDeliver (ACL allows)Read grant cache (same process)Full grant list [(projectId, projectName, orgId, roles)]Response
OIDC scopes:
  openid profile
  urn:zitadel:iam:org:project:id:{identitetProjectId}:aud

The 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.

HandlerAuth CalloutNATSZitadelClientHandlerAuth CalloutNATSZitadelClientSubject arrival= authorization proofOIDC authorize (aud: env-prod + compute-region-a)JWT (standard role URN claims per project)Connect (service JWT)Auth calloutVerify OIDC sigidentitetProjectId ∉ aud → in-token pathParse role URN claims, map → ACLsUserClaims (env-prod + compute ACLs)cmd.resource.cluster.createResponse
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}:aud

First 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 aud scopes → 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.

AspectHumanMachine
AuthenticationOIDC authorization codeclient_credentials
Phase 1 (discovery)YesNo — grants known at deployment
Phase 2 (service)YesYes
Grant sourceAPI (Phase 1) or standard role claims (Phase 2)Standard role claims only
ACL scopingorgId == providerOrgId → cross-org; else → org-scopedSame 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 typeaud containsGrant sourceZitadel API callACLs generated for
Identitet tokenidentitetProjectIdZitadel Auth API (user’s own token)Yesidentitet project only
Service tokenservice project ID(s)Standard urn:zitadel:iam:org:project:{pid}:roles claimsNoaudience projects
Unknown tokenneitherNopublic only

Data Sources

DataSourceLatency
User identityJWT sub claimIn-token
OIDC signatureJWKS (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 claimsIn-token
Per-project role→suffix policyKV rolePermissions.{projectId} (watched)In-memory
Default role→suffix policyStatic configIn-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:

SymbolPurpose
GrantUnified 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
ZitadelGrantFetcherCalls /auth/v1/usergrants/me/_search with user’s token
grantCachesync.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

Auth CalloutJetStream KVManagerNATSService AgentAuth CalloutJetStream KVManagerNATSService Agent{serviceType, location,instanceProjectId, rolePermissions}Connect (machine JWT)Auth callout (verify + ACLs)UserClaimsPublish cmd.service.registerDeliver (ACL allows)Validate suffixesPut services.{projectId}Put rolePermissions.{projectId}Watch eventUpdate in-memory policy

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

PropertyGuarantee
Subject arrival = authorizationACLs set at connection time. Message arrival proves sender holds required grant. No app-level auth checks.
Audience gateZitadel API call fires only when identitetProjectIdaud. Tokens without Identitet in audience are inert.
Self-contained service tokensService 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 radiusLeaked token contains grants only for explicitly requested projects. env-prod token cannot produce compute-region-a ACLs.
No elevated credentialsCallout uses user’s own token. /auth/v1/usergrants/me/_search returns only the authenticated user’s grants.
Namespace isolationACLs contain project ID. Grant on project A cannot produce ACLs for project B.
Fail-closedZitadel API failure on identitet path → connection denied. No fallback to reduced permissions.
Service-declared policyServices 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.

PathExternal callCacheable
IdentitetPOST /auth/v1/usergrants/me/_searchShort-TTL cache, keyed on (sub, token_hash)
ServiceNone (in-token)N/A

Provisioning Layer (Not Auth)

These operations call the Zitadel API. They’re admin-triggered, not on the auth path.

OperationZitadel API callTriggered by
Create service instance projectCreateProject, CreateProjectRole x3Terraform / operator
Grant service to customer orgCreateProjectGrantAdmin command → zitadel-agent
Create environment projectCreateProject, CreateProjectRole x3, CreateProjectGrantAdmin command → zitadel-agent
Assign user to environmentCreateUserGrant (with project_grant_id)Admin command → zitadel-agent
Revoke user from environmentDeleteUserGrantAdmin 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

ZitadelAuth CalloutKV (JetStream)NATSService HandlersProjects (uniform roles)ProjectGrantsUserGrantsAuth APIStandard role URN claimsOIDC verifyidentitet in aud?Yes: Fetch grants(user's token)No: Parserole URN claims(projectId, role)→ ACL suffixesrolePermissions per projectserviceBindingsACL enforcementSubject namespacesZero auth logicArrival = proofJWT (role URN claims)identitet: Bearer {user token}watchedNATS UserClaimsauthorized messages
ConcernOwnerMechanism
IdentityZitadelOIDC JWT (signature verification)
Grant discoveryAuth callout (identitet path)Zitadel Auth API with user’s own token
Service grantsZitadel (standard claims)Audience-scoped urn:zitadel:iam:org:project:{pid}:roles JWT claims
Grant→ACL translationAuth calloutGeneric (projectId, role) → suffixes
Audience gateAuth calloutidentitetProjectId ∈ aud check
Permission policy per serviceService agentrolePermissions manifest in KV
Policy validationManagerValidates suffix namespace on registration
Transport enforcementNATSACLs block unauthorized pub/sub
ProvisioningZitadel-agentCreates projects, grants, user_grants via Zitadel API
Handler authorizationNoneSubject arrival is proof