Skip to content

Authorization for GoodHabitz staff

  • Document: RFC 2026-09-NH-003
  • Status: Accepted (v1). The merge of this document was the decision.
  • Author: Simon Lind, GoodHabitz Platform Engineering
  • Date: 2026-09-15
  • Audience: platform engineers, security reviewers
  • Language: ASD-STE100 Simplified Technical English

0. How to read this document

This document gives the design of authorization for internal GoodHabitz staff. It replaces the one super-admin gate with staff roles held in Zitadel and permissions that a new authz application in account holds. Each service checks a permission through one nh-auth preHandler.

A line that starts with Rule is a decision to follow. The sentences after it give the reason.

The text states the design as decided. It holds no list of open questions and no table of decisions: each statement is a decision, and a change to one needs a new version of this document.

Non-goal. Tenant product roles such as expert, author, publisher, and tenant-admin stay app-side, resolved from team and ACL data. This document covers GoodHabitz staff only. Section 12 holds the shape that lets them use this machine later.

RFC 2026-08-NH-002 holds the rules of the platform. This document depends on three of its parts and changes none of them.

Part of RFC 2026-08-NH-002What this document takes from it
§5.4 Target grammarThe target kinds u, t, r, and the two id spaces of a target
§5.5 Authentication between applicationsThe admin secret is handled by nh-auth-user and never by a guard
§6 Row-level ACLThe overlap, the three ACL columns, and the declared entries of a row

0.2 Vocabulary

TermWhat it means here
StaffAn employee of GoodHabitz who uses their product account in the GoodHabitz tenant org
Staff roleOne of four job roles: viewer, support-specialist, integrations-engineer, platform-engineer. A person holds as many as apply
PermissionOne thing a function needs, named <domain>.<path>.<verb>: the domain first, the verb last, and one or more segments between them, for example account.tenant.view and account.tenant.serviceAccount.create. A row in authz
DeclarationThe verbs a service owns and the staff roles that hold each of them, in the configuration of that service
GrantAn ACL entry on the row of a permission. It says which target may find that row
CheckA call to the permissions route of authz as the caller. It answers the keys the caller holds: behind it, the ACL lets the caller find the rows of those permissions and no others
GoodHabitz tenant idOur UUID for the tenant row of the GoodHabitz org in account. The value of PLT_STAFF_TENANT_ID

1. Today

The Root org in Zitadel owns the Account project. It has two roles, user and super-admin. Every tenant org holds a project grant with user only. The Root org grants super-admin directly and nowhere else, so a tenant cannot forge it.

The two runtimes consume it in two ways. account runs the nh-auth server packages: the gateway verifies the token, nh-auth-context turns each grant into a target r:<grantingOrgId>:<role>, and each route guards on r:<rootOrgId>:super-admin. teach runs none of them. Each teach application verifies the Zitadel token itself and flattens the roles claim to admin or user.

The admin role in teach is not one gate. It appears in five places: requireAdmin, two inline copies in search and coordinator, the db-authorization rule that gives admin every entity, the login exception for an identity with no mapped organization, and the client-side gate of the admin console.

In account the literal also sits in the ACL entries of auth/watt.json: the provisioning, service account, and launch channel rows carry r:<rootOrgId>:super-admin as their only reader. The Platformatic admin secret mints the same target, so every call between applications is a super-admin.

Three problems follow.

  • One switch for everything. A person who can reset the search index can also provision a tenant and mint service accounts.
  • Two identities per staff member. Zitadel binds an email to one org. Staff have a product account in the GoodHabitz tenant org and a +admin account in Root with IAM_OWNER.
  • The privilege is a role name, not a fact about the function. Nothing in the code says what a route needs. It says who may call it.

2. The model

Split “may this person do this?” into two questions with two owners. Zitadel answers who holds which role. authz answers what a role may do.

Rule: a role is a job. A permission is what a function needs. A role is something you put in a job description. A permission is something you put in a route handler. A proposed role name that contains a verb is a permission.

Rule: the ACL layer is the policy engine. A permission is a row. A grant is an ACL entry on that row. A check is a read of that row as the caller. nh-auth-acl already answers “may this caller find this row?” for every other row in account, and this document adds no second engine beside it.

Rule: authz puts its engine behind an interface. A consumer sees two operations, declare and check, and nothing of the store behind them. The ACL table is the first implementation of that interface and not the contract (section 4.5). A later engine or a later source of grants replaces the implementation and touches no consumer.

Rule: a grant comes from configuration and changes with a deploy. Each service declares its verbs and the staff roles that hold them. No screen and no API changes a grant at run time. Section 12 holds that layer as a suggested improvement.

Rule: super-admin is not a platform role. It opens nothing in the console and passes nothing in authz. The Root org accounts hold it as a Zitadel console credential, for the initial grants and for repair of Zitadel itself. The Root org id is never served to a browser. Nothing bypasses authz; a locked-out platform is repaired by a platform-engineer, and a missing platform-engineer is made in the Zitadel console.

Adding a capability then touches code and one line of configuration. A developer writes the check and declares the verb with its roles. The nh-auth plugin creates the row in authz at boot. Zitadel, Terraform, and nh-auth do not change.

3. Staff roles in Zitadel

3.1 The GoodHabitz Staff project

A project GoodHabitz Staff in the Root org, beside Account and External API, with the four staff roles. Root grants it to the GoodHabitz org with the full role list, and to no other org. The Account project keeps user and super-admin, which is what every tenant sees.

Rule: a tenant grant on Account carries user alone. tenant_role_keys is an explicit allowlist in Terraform, and the runtime grant code of account appends user only. The boundary is structural: a tenant grant cannot carry a staff role, because the role does not exist on Account.

Roles are plain job names, with no prefix and no verb. The project scopes them, and the target a consumer sees carries the granting org.

3.2 Where the roles live

One Zitadel behaviour shapes this choice. It was read from the v4.16 source.

  • A client may request roles of its own project alone. A project:role:<key> scope that names a role of another project is dropped from the auth request.
  • Once any role is requested, Zitadel asserts only the requested roles, for every project in the audience.
  • project_role_assertion on the Account project makes Zitadel request every Account role on behalf of the Webapp. With it on, the filter is always active and the roles of a second project never reach the token.

Therefore the roles of a second project reach a Webapp token only when the Account project stops asserting roles. That switch is safe to turn: dam, customer, and teach all log in through the iframe of account and consume the token that account issues, so the only login code is the openid-auth plugin of account and the SCORM launch exchange, both in account.

Rule: the staff roles live in a project of their own, and Account asserts no roles. The project GoodHabitz Staff holds the four roles and is granted to the GoodHabitz org alone. project_role_assertion = false on Account. account adds projects:roles, the Account project audience, and the staff project audience to its login request; the SCORM launch exchange adds the first two. Account roles still arrive in the claim everyone reads today, and staff roles arrive under the project-qualified claim, which nh-auth reads as well.

The boundary is structural. A tenant grant on Account cannot carry a role that does not exist there, so the role set of Account stays what every tenant sees. The one rule to remember is positive and fails closed: an application in the Account project gets no roles until it asks for them, and a forgotten scope removes roles and never adds any. The rule is written in projects.tf beside the switch, and the two logins of account share one helper for the scopes so neither can forget the ask on its own. The change touches zitadel and account, and nothing in dam, customer, or teach.

The switch also governs tokens that never pass through the two logins of account. No machine identity depends on Account roles. The two machine users that nh-zitadel provisions hold instance memberships, IAM_ORG_MANAGER and IAM_LOGIN_CLIENT, and authenticate with personal access tokens, which are opaque and carry no roles. The service accounts of a tenant hold their role on the External API project, which keeps its own role assertion setting.

The alternative was verified as well: four more roles on Account beside user and super-admin, with tenant grants kept to user through an allowlist. It changes the zitadel repository alone, but it puts the boundary in the per-org role list of each project grant, and its rule to remember is a negative one, never add a staff role to a tenant grant. This document does not take it.

3.3 Token and login

account adds three scopes to the Webapp login, and the SCORM launch exchange adds the first two with the Account project. One helper, lib/project-role-scopes.ts, builds them for both.

urn:zitadel:iam:org:projects:roles
urn:zitadel:iam:org:project:id:<accountProjectId>:aud
urn:zitadel:iam:org:project:id:<staffProjectId>:aud
  • The Account roles keep arriving under urn:zitadel:iam:org:project:roles, the claim everyone reads today, because the Account project is in the audience. The normalize-claims plugin of teach reads this one and is unaffected.
  • The staff roles arrive under the project-qualified claim urn:zitadel:iam:org:project:<staffProjectId>:roles, keyed by role and then by the granting org. nh-auth-context already matches that claim and mints two targets from it: the Zitadel form r:<goodhabitzOrgId>:<role> and, because the GoodHabitz org is the org of the caller, the normalized form r:<goodhabitzTenantId>:<role> in our id space (RFC 2026-08-NH-002 §5.4).
  • whoami in account answers staffRoles as bare keys, derived server-side from the normalized form and PLT_STAFF_TENANT_ID. Therefore no client needs an org id or a tenant id.
  • The aud of the token gains the staff project. The verifier of account accepts a token when any audience entry matches, so it keeps working. teach checks no audience today.

Rule: a service that names PLT_STAFF_TENANT_ID refuses to start without a usable one. The value is one segment of an ACL target, so a service checks that it is set and that it carries no : and no ,. A blank value makes entries that are well formed, name nobody, and leave every permission held by no one, with nothing in a log to say why.

The check stops there, and does not compare the value with the GoodHabitz tenant row. That row is written at first login (section 3.4), so a new environment has nothing yet to compare with, and a service that refused to start without it would stop the login that creates it. authz could not make the comparison in any case: it holds its own database (section 4), and its credential holds no staff role, so it reaches that row neither directly nor through auth.

A well-formed value that names the wrong row passes that check, and then every staff check denies. It is caught where it does its damage, in the entries themselves, and a later declaration repairs them (section 4.2). It is also named at the first staff login: whoami derives staffRoles from the normalized target and PLT_STAFF_TENANT_ID on every request, so a token that carries roles under the claim of the staff project yet yields an empty staffRoles is that mismatch, and auth logs a warning there that names the two ids. That needs no bootstrap order and no extra read.

Roles ride in the access token. The lifetimes of Zitadel are one instance-wide setting, twelve hours today, with no per-application value. The same lifetime is the working window of a learner in a SCORM launch, which has no refresh by design. Therefore the Webapp token is not shortened. Section 3.5 gives the alternative for a removed role.

3.4 The GoodHabitz org grants the roles

Rule: the GoodHabitz org grants the staff roles, through its project grant. Root does not grant them directly. A grant from the org of the caller gets the normalized target in our id space, so ACL entries, whoami.staffRoles, and the rows of authz carry our tenant UUID like the rest of the ACL. A grant from Root would never get the normalized form, because Root is not the org of the caller, and every entry would have to carry a Zitadel id instead. The cost is one tenant id per environment in helm, PLT_STAFF_TENANT_ID, and no org id anywhere.

Locally the dev users carry tenant_id user metadata with one fixed UUID, so the local GoodHabitz tenant row has a known id before boot. account takes the id of a tenant row from that metadata at first login, the same channel the migration used for the Experts UUIDs. A developer who logged in before this change has a GoodHabitz tenant row with a random id, and must reset the local account database once.

3.5 Assigning a role

A staff role is a user grant on the project grant of the GoodHabitz org, for the product account of the person. It is made in the Zitadel console by a member of the GoodHabitz org with ORG_OWNER, ORG_USER_MANAGER, or PROJECT_GRANT_OWNER on that grant, or by the People screen of the console (section 6.1). One user has one grant per project, so a change of roles edits the existing grant.

Zitadel reads the grants each time it issues a token. A change shows at the next login or token refresh, at the latest when the access token expires.

Rule: viewer is granted beside every other staff role. viewer is the read baseline: the two read permissions of section 8 and the find literals on the data rows (section 6.1) name it and no other role, and the other three roles add actions to it. No role holds another, so the pairing is a rule for whoever grants, not a property of the roles: a person with integrations-engineer and no viewer would pass account.tenant.serviceAccount.create and then read an empty tenant list. The People screen adds viewer when it assigns another role, and the console procedure of the zitadel repository says the same. The local dev users follow it already.

Rule: a removed role ends the sessions of the person. The token lifetime cannot be shortened for the Webapp alone (section 3.3), so a removed role would stay in a token for up to twelve hours. Instead, the People screen (section 6.1) ends the Zitadel sessions of the person through the login-client PAT that account already uses for back-channel logout. The person logs in again and receives the new roles. Adding a role never needs this: the next token carries it.

4. Permissions in authz

A Platformatic DB application in the account runtime beside auth, frontend, and gateway, with its own database and PLT_AUTHZ_DATABASE_URL. It owns one table and lets the ACL plugin do the work.

The database is its own for a second reason, which the deployment adds to the first. Postgrator keeps the applied migrations in a versions table of the database it migrates, so two applications that share a database share that table. The second one to run finds a higher version applied already, skips its own migrations, and builds its contract from a schema that holds none of its tables. The one guard against that is a database per application.

4.1 One table

ColumnPurpose
keyThe permission, <domain>.<path>.<verb> as section 0.2 gives it, for example account.tenant.serviceAccount.create. The grammar forbids : and ,, which matches the ACL segment rule. Unique among active rows, through a partial unique index, UNIQUE (key) WHERE deactivated_at IS NULL, the shape account_provisioning_live_external_id_idx already has in account (migration 009): one live row per key, any number of retired ones. A plain unique key would make the transaction of section 4.2 impossible, so this index is the constraint the audit trail depends on
domainThe service that declared it, account or experts. A service declares rows in its own domain. This is a convention and not a boundary: the declare route takes the one admin secret of account, and any holder of it can declare any domain. A credential per domain is a later option, not taken here
declaredThe staff role names that the configuration of the service granted at declaration. A later declaration is compared against this column
deactivated_at, superseded_bySet when a newer declaration replaced this row: the moment, and the id of the row that replaced it, so the history of a key is a chain one query walks. A deactivated row is never deleted: it is the audit trail of what a permission used to mean. Not deleted_at, which would tell a reader and a retention sweep the opposite of what the table guarantees; the column is named after what happened to the row, as account_provisioning.detached_at is
acl_defaults, acl_share, acl_effectiveThe ACL triple of RFC 2026-08-NH-002 §6. acl_defaults is empty: no target has default access to a row, not platform-engineer and not the admin secret. The declare route writes on the trusted path, without a request, so it needs no entry, and the ACL plugin accepts an entity that stamps no default. acl_share holds the grants, r:<goodhabitzTenantId>:viewer:find and its kin, and nothing else

The holders of a permission are exactly the targets with find in its acl_effective, one per declared role, and nobody else. platform-engineer holds what a declaration gave it, like any other role: a grant the team does not want it to have is a grant it does not get. A super-admin of the Root org holds none of them: no row names that target, so it passes nothing in authz (section 2). The entry grammar admits a role, a tenant, or one person, so a later provider can grant to any of the three; a declaration names roles alone. authz exposes two routes: declare for the secret, and the permission keys of the caller. Nothing else, and no management API, no grant or revoke, and no share on any row.

Rule: both routes are written, and the table publishes none of its own. The row ACL is not a property of a generated route. db-authorization installs it as an entity hook on find, and it applies to every call that carries a request context, so a written route reads as the caller through the same overlap and adds no second engine. What a generated entity surface adds instead is the store itself: write routes to suppress, relation routes for the superseded_by chain, ACL columns to hide from the answer, and a page size to configure. Each is a place where the store shows through an interface whose purpose is to hide it (section 4.5), and each has to be closed again by hand. The entity is therefore ignored entirely, and the two routes are the whole contract.

The history of a key is the audit record and not a surface a consumer reads, so nothing serves it.

The gateway composes the contract under /api/v1/authz, and the client of section 4.5 is generated from it beside the one for auth. Two rules keep one application’s operations out of another’s client, because each client speaks to its own application on the mesh and a path of a neighbour names no route there. The generator cuts the /api/v1/<application> prefix on a segment and never as text, since /api/v1/authz holds /api/v1/auth as a text prefix. It then drops every path and every component of another application, which a document composed from several of them holds by nature.

4.2 Declaring a permission

One route, POST /api/v1/authz/declare, callable with the admin secret only. The secret carries r:internal:service, and the route accepts that target alone (section 9). It takes a domain, the declared verbs, each with the staff role names that hold it, and the keys of other domains the service consumes. It answers with the consumed keys that have no active row, so the boot check of section 5 is one round trip and the secret never reads the rows.

Rule: authz turns a role name into the ACL entry. It is the only service that knows the GoodHabitz tenant id and the entry grammar. It writes r:<PLT_STAFF_TENANT_ID>:<role>:find, and it refuses a role name that is not one of the four.

For each verb:

  • No active row: create it, with declared set to the role names and acl_share set to the entries derived from them.
  • An active row whose declared equals the request: reconcile its acl_share with the entries the declaration derives now, and do nothing else.
  • An active row whose declared differs: in one transaction, deactivate the old row and clear its acl_share, create the new row from the new declaration, then point the superseded_by of the old row at the new one.

Rule: a declaration reconciles the entries of a row it otherwise leaves alone. The entries come from two things, the declared roles and PLT_STAFF_TENANT_ID, and only the roles say whether a grant changed. A correction to the id would therefore never reach the rows: they would keep naming a tenant that no caller carries, and every check would deny with nothing to say why (section 3.3). The entries are rewritten in place, and the row is not superseded, because the roles that hold the permission have not changed and declared has no change to record. The repair is the deploy that carries the right value, and it needs no operator.

Rule: the old row is retired before the new one is written. The partial unique index admits one active row per key and is tested for each statement, so the other order makes the two rows active together and the write is refused. The three statements are in one transaction, so the moment when the key has no active row is never seen from outside it.

Rule: the read that decides the work is not a page. A Platformatic entity read answers ten rows when it is not told otherwise, and paginate: false is what turns the page off. The declare route names the keys it asks about, so it asks for all of them. A page hides a key that was asked for, the key then reads as absent, a second active row is made for it, and the index refuses the write. The retired rows of those keys count against a page too, so the fault comes with the history of a key and not with the twelfth verb.

The transaction is what keeps the history honest. At every moment there is one active row per key, and the deactivated rows say when the grant of a permission changed. A deactivated row is held by nobody, because its acl_share is empty: the engine answers right with no filter in the consumer, and the row stays as the audit record. declared keeps what the row used to grant, so clearing the entries loses nothing.

4.3 Checking a permission

Rule: a check runs as the caller and never as the admin secret. With the secret every row is visible and every check passes. The read passes the request context of the caller to the entity, which is what applies the row ACL, and answers the keys that survive it. A caller holds a permission when the ACL lets them find its row. The permissions route answers the keys of the rows the caller can find, which is exactly their permission set, and that answer is what the nh-auth plugin caches.

The read is protected like every other read of account. The token of the caller goes through the gateway, nh-auth verifies it and mints the targets, and the ACL filters the rows. An anonymous request is refused before the overlap runs: db-authorization holds no rule for the anonymous role, and it answers 403.

The secret header travels on the check hop as well: nh-auth-forward sets x-platformatic-admin-secret on every request it relays, which is why Vary names it. The check stays the caller’s because authz gives that header no rule of its own. It never configures the adminSecret of Platformatic db-authorization, which would make the caller platformatic-admin with find on every entity and turn every check into every row. Instead the secret is mapped to the target r:internal:service through nh-auth/user, the way auth maps it, and no row names that target, so on a check the header adds nothing to the overlap.

Rule: the read answers the whole set of the caller. The consumer reads the answer as every permission the caller holds, so a short page is not a page to it: it is a permission the caller does not hold. The route asks the entity for every row the caller can find, with paginate: false, and answers the keys alone. There is no page to size and no ceiling to outgrow, and a caller learns nothing of the row behind a key: not the roles that hold it, and not when it was written.

The permissions read answers with Cache-Control: private, max-age=60, must-revalidate and a Vary that names the whole forwarded identity set of nh-auth, the two edge credentials, and the secret headers the application accepts. The cache that obeys them is named in section 5: the HTTP cache of undici on the client’s transport. It keys an entry on the origin, the method, the path, and the values of the headers that Vary names, so one caller gets one entry and two callers never share one. No other cache sits between the two: nh-fetch has none, and its dedupe ignores Vary, so it is not used here.

Rule: the response says private and must-revalidate, and it says neither public nor s-maxage. The rule is written for any cache that obeys RFC 9111, not for undici alone. private is the directive a shared cache between the two would obey, and it is what keeps the permission set of one caller out of the one place it may never sit. The caller carries authorization, and §3.5 lets a shared cache store the answer to such a request only when the response says public, s-maxage or must-revalidate; public and s-maxage each offer the body to a shared cache, so must-revalidate is the only one of the three the response can say, and it costs nothing here, because it governs a stale entry and max-age is the whole lifetime of this one. undici implements none of §3.5: it never reads the request authorization when it decides to store, and must-revalidate reaches only staleResponseRequiresRevalidation, which decides whether a stale entry may be served unrevalidated. So a missing must-revalidate does not switch this cache off. What does is a cache of the wrong type: a shared cache, the undici default, drops a private response and stores nothing, and that failure is silent, since every check still answers and only a count of requests shows the cache is not there. The consumer test of section 5 is what catches it.

Rule: Vary names every header that carries the identity of the caller on that hop, and a check with none of them is refused. This cache fails open: two requests that carry no identity header match one entry, so a caller whose identity arrived some other way would share the set of every such caller.

On this hop the identity is the forwarded set. nh-auth-forward relays the x-user-* headers and the secret header on every hop, and it adds authorization and cookie only when the caller holds them. A Vary of those two alone would therefore name the pair that can be absent and none of the pair that identifies the caller, and two staff members would share one entry. Vary names the forwarded set for that reason, and it is built from the header constants of nh-auth-context so that a header added there cannot stay unnamed here. A secret is an identity as well: unnamed, its answer shares an entry with every request that carries no identity header.

The plugin also sends a check only when the request carries one of the named headers, and denies otherwise.

4.4 No management surface

No screen manages permissions. There is no management API on authz, no view of the rows, and no verb that guards one. The only way to change who holds a permission is to change the configuration of the declaring service and deploy. Services read the table through the check. An operator reads it in the database.

4.5 The policy provider interface

Rule: consumers depend on the interface of authz, never on its table. The interface is small on purpose. It has two operations:

OperationCallerQuestion
declare(domain, verbs, consumes)The nh-auth plugin, with the admin secret, at boot“These are the permissions of my domain, these roles hold each of them, and these keys of other domains I check.” It answers which consumed keys nobody declared
permissions()The nh-auth plugin, as the caller, per check“Which permission keys does this caller hold?”

The account authz client exposes the two operations and nothing else. verifyPermission in the nh-auth plugin is written against the client. No service, no test, and no screen reads the table, the ACL columns, or the row grammar of section 4.1.

Behind the interface, authz holds one policy provider. The first provider is the ACL table of section 4.1: declare is the transaction of section 4.2, and permissions() is the permissions route of section 4.3. The provider is an implementation detail of authz. It can be replaced by another provider that answers the same two questions, for example an engine such as OpenFGA, or a source that reads grants from Entra (section 10), with no change to a consumer and no change to the nh-auth plugin.

The interface fixes four things that every provider must keep. A check runs in the identity of the caller and never with the admin secret. A key that no domain declared is held by nobody. A deactivated grant is held by nobody. A change of a grant leaves a record of what the permission used to mean.

5. The nh-auth permissions package

A new package, @goodhabitz/nh-auth-permissions, at the umbrella subpath ./permissions. It talks to authz through the account authz client and the two operations of section 4.5, and it exists so that no service reimplements the declaration or the check. The plugin knows nothing of the provider behind the interface.

  • Declaration at boot. The plugin reads its domain, verbs and consumes from the watt.json of the service and calls the declare route once, with the admin secret. That is the only place the plugin uses the secret. Verbs name staff roles and nothing else: the plugin knows no tenant id and builds no ACL entry. A failed declaration fails the health check of the service, in every environment: a service that cannot declare its permissions does not serve. On the first deploy of a service, or of a new verb, there is no earlier row to fall back on, and a tolerated failure would look exactly like “this person has no permission”.
  • verifyPermission(...keys). Any-of. It has the composition shape of verifyTargets, so it runs inside fastify.auth([...]) or alone as a preHandler. It does not inherit the enabled: false master switch of AccessOptions. There is no off switch and no break-glass.
  • The check runs as the caller. The plugin forwards the identity headers of the caller to authz, the way nh-auth-forward relays them today, and the gateway of account mints the targets. The plugin adds no secret of its own on a check. The relay still carries the secret header on every hop, where it adds nothing, because authz configures no adminSecret and no row names the target of the secret (section 4.3). A relay that strips the header is not what this rule asks for.
  • One request per caller per minute. On the first check of a caller the plugin calls the permissions route as the caller and receives every key that caller holds, which is their permission set, and answers every later check from it. The cache is the cache interceptor of undici (RFC 9111) on the transport of the authz client: the plugin derives a dispatcher from the global one with compose, which leaves the mesh dispatcher of the runtime untouched, and hands the client a fetch bound to it through the fetch option every generated client has. The interceptor runs as a private cache with an in-memory store bounded by entry count, because the default type is shared and a shared cache drops a private response. It therefore stores the response of section 4.3 and keys it on the values of the headers that Vary names, which are the forwarded identity set of the caller and not the edge credentials alone (section 4.3). The fetch is the one of the same undici copy the dispatcher comes from, not the global fetch of Node: Node exposes no interceptor API for its bundled undici at any version, and a dispatcher composed from one copy and a fetch bound from another is a known failure. A test of the plugin pins the property against a fake authz that counts requests: one caller twice within a minute is one request, two callers are two, and a caller with no identity header is never served another caller’s set. A stale entry while authz is unreachable denies.
  • Boot-time key check. Every key passed to verifyPermission must be one this service declared, or one it lists as consumed from another domain. The plugin checks the first half itself, against its own configuration. The second half is the answer of the declare route: the consumed keys that have no active row. The secret never calls the permissions route, which would answer it nothing, since no row names a target of the secret; the declare route compares on the trusted path inside authz. A key that is neither declared nor consumed, and a consumed key that nobody declared, fail the health check, in every environment, so a misspelt key stops a deploy instead of denying everyone with one log line as the only signal.
  • Identity source. By default heldTargets of nh-auth-http, for a service behind an nh-auth edge. teach passes targetsOf, which reads the targets it derived from its own verified claims.
  • Test fixture. A ./permissions/testing subpath with a fake authz that answers a fixture set. nh-auth-test is private and unpublished, so a service suite cannot import it.
// watt.json of the account auth application
{
"name": "@goodhabitz/nh-auth/permissions",
"options": {
"authz": { "url": "{PLT_AUTHZ_URL}", "adminSecret": "{PLT_ADMIN_SECRET}" },
"domain": "account",
"consumes": [],
"verbs": {
"tenant.view": ["viewer", "support-specialist", "integrations-engineer", "platform-engineer"],
"user.view": ["viewer", "support-specialist", "platform-engineer"],
"tenant.serviceAccount.create": ["integrations-engineer", "platform-engineer"],
"tenant.provision": ["platform-engineer"]
}
}
}
// a route
fastify.post('/tenants/:id/service-accounts', {
preHandler: fastify.auth([fastify.permissions.verifyPermission('account.tenant.serviceAccount.create')])
}, handler)

6. Consumers

6.1 account

A staff console exists at /admin, with Tenants, Users, and the service account screens, and a Maintenance screen for platform-engineer. It reads whoami.staffRoles and knows no org id or tenant id. Today it gates each screen on roles in its own table. When the plugin exists, that table gives way to the four permissions of section 8: a screen opens when the permission set of the caller holds its verb.

The People screen assigns staff roles. It writes a Zitadel user grant on the staff grant of the GoodHabitz org through the service-user PAT that account holds. It is guarded by account.staff.assign, and a person can assign only a role they hold themselves. That key is not in the first declaration of section 8: rollout step 6 adds it to the declaration of the auth application in the same change as the screen, because a checked key that no declaration names fails the health check (section 5).

The data rows keep their own ACL. r:{PLT_STAFF_TENANT_ID}:viewer reads tenants, accounts, provisioning rows, launch channels, and service accounts, and r:{PLT_STAFF_TENANT_ID}:platform-engineer has every verb on the provisioning, launch channel, and service account rows. These replaced the super-admin row literals, and they exist so that staff can see the rows. viewer alone reads, which is why section 3.5 grants it beside every other role: a literal per role would say the same thing five times on every entity. The admin secret holds no staff role: it is a trust credential, not a job, and the trusted paths it calls, the /provision routes, read and write without a request context, where the ACL does not apply. The generated reads answer the secret an empty list.

Rule: the two layers stay distinct. authz rows gate verbs. Entity rows gate data. When a verb is opened to a role, the matching data literal opens with it in the same change, followed by a recompute, or the route passes and the read comes back empty.

Routes and resolvers swap verifySuperAdmin and superAdminPolicy for verifyPermission with keys that the auth application declares.

6.2 teach

teach does not need to adopt the nh-auth edge to start. The seam is normalize-claims.ts: instead of flattening to admin, it derives targets for every role in every roles claim and puts them on request.user.targets. The plugin reads them through targetsOf. Then, route by route, requireAdmin becomes verifyPermission with a key in the experts domain, and the two inline copies use it too. The admin console reads the permission set of the caller instead of roles.includes('admin').

The admin db-authorization rule in shared/plugins/acl.ts is the own migration of teach and is not decided here.

teach checks no audience today (section 3.3). With the staff roles in the token it adds a check of the client: the azp claim must be the Webapp client id. An audience list alone would not do, because the verifier accepts a token when any entry matches, and a token that another client asked the staff project audience for carries that project in aud as well; urn:zitadel:iam:org:projects:roles asks for every role the person holds, and Zitadel asserts them for each project in the audience. Only the client of the token tells the two apart.

7. A request, end to end

sequenceDiagram
    participant U as Staff member
    participant Z as Zitadel
    participant T as teach route
    participant P as nh-auth-permissions
    participant X as account authz
    Note over P,X: at boot: declare domain verbs (admin secret), rows created or regenerated
    U->>Z: Login (Webapp client)
    Z-->>U: Access token, staff roles claim
    U->>T: Request with token
    T->>T: Verify JWT, derive targets
    T->>P: verifyPermission('experts.config.edit')
    P->>X: GET /permissions as the caller (once per caller per 60s)
    X-->>P: the keys of the rows the ACL lets this caller find
    P->>P: key in the set of the caller?
    P-->>T: pass or 403

Evaluation in the package, in order.

  1. Look up the cached permission set of the caller. Missing or older than a minute: fetch every active authz row the caller can see, as the caller. authz unreachable and nothing cached: deny.
  2. Pass when any required key is in the set. Otherwise 403 with the key in the log line.

8. First permissions

Four, all in the account domain, declared by the configuration of the auth application. Two let the console show its Tenants and Users screens to the roles that may read them. Two guard the mutations that exist today. Everything else, teach included, follows once the plugin exists.

PermissionWhat it opensRoles
account.tenant.viewThe Tenants screen and the tenant reads behind itviewer
account.user.viewThe Users screen and the account reads behind itviewer
account.tenant.serviceAccount.createProvisioning a service account for a tenantintegrations-engineer, platform-engineer
account.tenant.provisionThe tenant provisioning routes and mutationsplatform-engineer

9. Security notes

A security review must read this section.

  • Self-escalation. Declarations grant find alone, acl_defaults is empty, and no route exposes share, so nobody holds share on a permission row and no signed-in person can widen a permission at run time. Only a deploy can. A holder of account.staff.assign could grant themselves platform-engineer through the People screen, so that screen applies the rule that you cannot assign a role you do not hold.

  • The admin secret. For authz it carries r:internal:service and no other target: not a staff role, and not super-admin. A staff role on the secret would let it find whatever that role may find, and every later literal that names the role would widen the secret without a decision. No authz row names a target of the secret, so it reaches a row on the trusted path of the declare route alone, without a request. It must never be used for a check: the plugin holds it for the one declare call at boot, which also answers the key check (section 5), and forwards the identity of the caller for everything else. The header still travels on a check, because nh-auth-forward relays it on every hop; it adds nothing there because authz never configures the adminSecret of db-authorization and no row names the target of the secret (section 4.3).

    Rule: the declare route is guarded on a target that no token gives. A declaration rewrites the grant of every staff role, and the design holds that a grant changes by a deploy and by nothing else. A guard on super-admin puts the most privileged person inside that rule, because a Root org operator holds that target from a login. A role target from verified claims is always r:<grantingOrgId>:<role>, and internal is not an organization in Zitadel and cannot become one, so no login gives r:internal:service whatever roles a project holds. A real role name is only as safe as the promise that nobody grants it. authz therefore names no organization at all, and it needs no PLT_ROOT_ORG_ID.

    The account provisioning routes keep their super-admin guard and their own secret source. This rule is for authz.

  • No off switch. verifyPermission ignores AccessOptions.enabled. Local development runs authz like every other environment.

  • Stale set. Two cases. A permission grant changes by deploy, and the set of a caller lives for a minute in the plugin, so the change lands within a minute of the deploy. A removed role is different: it stays in the access token of the person for up to twelve hours, so the People screen ends the sessions of the person (section 3.5). An outage of authz denies every check whose set has expired, so a long outage locks every staff function until authz is back. There is no break-glass by design.

  • Row filters in account. Staff reads are one literal per readable entity, r:{PLT_STAFF_TENANT_ID}:viewer with find. The id is ours: the UUID of the GoodHabitz tenant row, which the normalized target of the caller carries for a role the GoodHabitz org granted.

10. Entra later

Entra SSO attaches to the existing GoodHabitz org as an org-level identity provider. It is not a new tenant. A staff member who signs in through Entra links to the same product account, with the same sub, the same org id, and the same account_id metadata. Every grant of the form r:<goodhabitzTenantId>:<role> therefore keeps working unchanged. The only new question is how an Entra group becomes a staff role.

Rule: map an Entra group to a staff role at the Zitadel boundary. A post-authentication Action, or the login sync of account, reads the Entra groups claim and sets the matching staff roles on the grant of the person. The token, the targets, nh-auth, and authz are unchanged, and roles stay the only vocabulary a consumer sees. A mapping inside authz is possible later, because a grant is an ACL entry and the entry grammar admits more kinds, but it would move revocation to Entra and grow every token with a group list.

11. Rollout

  1. Prove the token. Real logins of dev users. Account roles arrive under the unqualified claim, staff roles under the claim of the staff project, the learner carries no staff role, and whoami answers all of it.
  2. Terraform and login scopes. account grants the staff project to the GoodHabitz org, helm carries the staff project id and PLT_STAFF_TENANT_ID, and current super-admins get platform-engineer on their product accounts. The zitadel merge request must say that a developer resets the local account database once.
  3. authz application and its client. One table under the ACL plugin, the declare route with its transaction, the permissions route with its cache headers, and the two operations of the client (section 4.5). The first rows come from the own declaration of account at boot.
  4. nh-auth-permissions. Plugin with declaration at boot, the per-caller cached check, the verifier, and the testing subpath, written against the client. Release the umbrella with the new subpath.
  5. Consumers, first routes. account declares the four permissions of section 8, swaps verifySuperAdmin for verifyPermission, and the console reads the permission set. teach derives targets in normalize-claims and swaps requireAdmin route by route. Deploy order follows the keys: a service that lists a key of another domain as consumed boots after that domain has declared it, so teach declares its experts.* keys before any other service consumes one.
  6. Admin UI. People only, guarded by account.staff.assign, which the declaration of the auth application gains in the same change.
  7. Retire the switch. When every staff function has a permission, super-admin is a console credential only. Review who still needs a Root org account.

12. Suggested improvements

Not in scope for the first cut. Each is a feature of its own, to pick up once the core is in use.

  • A tenant role holds a permission. Today a declaration names staff roles alone, because authz writes r:<PLT_STAFF_TENANT_ID>:<role>:find and that entry names one tenant. A paying tenant asks the same question, for example an admin of a tenant who opens the reports pane. One entry for each role of each tenant is the wrong shape: the count grows with the number of tenants, and a new tenant is run-time data while a declaration is a deploy. Instead the row holds a role-any entry, r:*:<role>:find, and authz adds the matching form to the targets of the caller before the overlap runs. The wire grammar of RFC 2026-08-NH-002 §5.4 does not change, and the role-any form never leaves authz, so no data row can name it and no header carries it. The configuration keeps the shape of section 5, with the role vocabulary namespaced, staff:viewer beside tenant:admin, because both vocabularies want a role called admin. One thing this needs first, and the non-goal of section 0 holds it open: a tenant role must reach a service as a target. A product-roles project in Zitadel, granted to each tenant org, gives r:<tenantId>:<role> from verified claims with no new code, and it keeps the boundary of section 3.1, because Account still carries user alone. permissions() answers the declared column with each key, and the plugin joins it against the role targets of the caller, so a person who is admin in one tenant and expert in another gets the right answer in each.

    Three rules make it safe, and each one needs writing before this is built. authz never isolates a tenant: the permission row gates the verb, and the row ACL of the data application gates the rows, which is section 6.1 with more than one tenant behind it. A tenant-scoped entity names its tenant from a column of the row, and never from a bare role literal, because a bare role literal is what turns a role-any grant into a cross-tenant read. The tenant of a check comes from the verified identity of the caller, and never from a path parameter. A plan and a seat count stay out of this: license owns an entitlement (RFC 2026-08-NH-002 §3.7), a role says what a job does, and the two change for different reasons.

  • Managing permissions at run time. First a read-only view of the rows and their history, then widening or narrowing a permission by hand through the grant and revoke of the ACL plugin, with share on the row deciding who may. It needs a rule for how a later declaration treats a hand grant, and a change log, before it is safe. Until then every grant change is a deploy.

  • CDC. Changes of authz rows captured by the existing Debezium connectors into Redpanda, so security can watch them.

  • Declared permission keys. Each service declares the keys it checks, so a future permissions view can list keys no row defines. For the first cut the plugin fails the health check on a key that is neither declared nor consumed, and on a consumed key that no domain declared (section 5).

  • GraphQL twin. A permissions: check in the @auth directive, so the own fields of account guard with the same keys.