authorization-model
Read before deciding who may call what in a generated app — authoring or reviewing security.json, an openapi.yaml security block, a screen gate, a 401 handler, or a /me/ path. States the platform's au
SKILL.mdskills/authorization-model/SKILL.md
GitHubThe authorization model
One page, so the design skills and the coding skills stop restating it. Each
rule names the measurement or the incident behind it and the ADR that holds the
argument. Nothing here is a procedure: security-design, openapi-conventions,
prototype, thunder-authentication, react-webapp and api-management say
what to write; this page says what must stay true whatever they write.
The invariants
Scopes are the one authority. A caller may do what the access token's
scopesays, and nothing else decides — not a groups claim, not a role name, not a header. A handle is<resource>:<action>fromspecs/design/security.json; a role grants handles; a group says who holds a role. Why: group names are the organisation's namespace, shared across projects; a handle means one thing in one project. ADR-0030.Whole-string compare, everywhere.
claims:read-alldoes not satisfyclaims:read. A role that needs both holds both, and nothing implies anything. Why: measured on the pinned gateway (gateway-1.2.2, jwt-authv1.3.0); an implication would live only in a gate the runtime does not have. ADR-0030.One scope per operation. An operation's
securityis absent (any signed-in caller),[](public), or one requirement namingoauth2with at most one scope. Never two requirements, never two scopes. Why: the policy has one failure status, so a two-handle guard cannot say which handle was missing; and "which handle does this operation need" must have one answer for every reader of the block. ADR-0030.Reach is the path.
/me/<resource>is the caller's rows, resolved through the gateway assertion;/me/<relation>/<resource>is a relation's; anything else is every row. A handle guards operations on one side of/me/, never both. A capability at two reaches is two operations with two handles. Nothing widens on a scope, and a row that is not the caller's is 404, never 403. ADR-0031.A reach declares the field it matches on, and the assertion must carry that identifier.
subis a directory UUID; a model that names its person by login name or email must be matched on that instead. The design says which field before any code is written. A UUID compared to a name matches nothing for every caller, and an identity that will not resolve is an error, never an empty result.The gateway is the whole "may this be called" decision. It is rendered per operation from the same
securityblock the gates read. The service holds no scope table; it verifies the gateway's RS256-signedx-jwt-assertiononce at its edge and reads the caller from it. No assertion continues with no caller, an unverifiable one is 401, and thex-user-*headers are read by nothing. No generated service answers 403. Why: the headers are unsigned and settable by anything that reaches the pod; the assertion is not. ADR-0032.The gateway answers a bare 401 for every refusal, with no
WWW-Authenticate— no token, an expired one, and a valid one lacking the scope are byte-identical on the wire. The browser resolves which it was from its own token: 401 with a still-valid token is Forbidden; 401 with an absent or expired token is sign in, at most once per page load. Why:if (401) signIn()put a correctly provisioned user in an endless sign-in loop on the one screen their role exists for. ADR-0030 point 6.A screen's gate is the scope of the operation it loads. Nothing about screens is authored in
security.json. The SPA gates each route withRequireOperation op=and each rail item withCan op=, both reading the generated operations table; the coding agent names each screen's load operation once, insrc/authz/screens.ts. A screen in a flow with noroleline is public and is routed above the sign-in guard. Why: the screen table was a third statement of the contract, consumed by nothing at runtime, and a gate that can disagree with the operation it fronts turns a user away from a page the API would serve. ADR-0033.NoAccessreplaces the shell;Forbiddensits inside it. A caller who can reach no screen gets the message alone, naming the groups to ask for; a caller who holds other scopes keeps the rail and lands on/forbiddeninside it. Why: a rail with no items around "you have no access" tells the user less than the message alone, and a real run drew exactly that.A refresh narrows and never widens. A grant removed from a role disappears at the next silent renew; a grant added appears only after a full sign-out and sign-in. Say so wherever a design or a UI explains an access change. Why: RFC 6749 §6, and it is the first thing an operator hits after adding somebody to a group.
No default role, no cold start. A signed-in user whose groups hold no project role has a valid token with the OIDC scopes and no handle. That is a designed state: the operations with no
securityoverride serve them, and the SPA showsNoAccess. Inferring a role from the absence of one is fail-open. ADR-0030 point 8.Every user role has a test login; no role has a registration flow. Whatever its
enrolment, akind: userrole gets a disposable test account — an admin role's joins anassignTogroup, a self-service role's binds straight to the role as a user principal. Onlykind: servicegets none. The platform provisions no self-registration flow, so a self-service role's test login is its only principal, and sign-up, password reset and MFA are identity-provider surface that no generated app renders. A criterion depending on one is a platform finding, never an application bug. Why:enrolmentwas read as "can this role be exercised?", which it never answered; a role standing with zero principals made every criterion it served ungradable. ADR-0030's 2026-09-20 amendment.A screen shows only what its role can reach. Every field a screen displays is carried by the operation it loads, under a handle that screen's role holds. A criterion naming a field no permitted operation returns is a design contradiction, not a coding task: put the field on the projection the screen already reads, never widen the role to reach a second one. Why: otherwise the screen renders an id where a name belongs, and no code can fix it.
What this rules out, by name
- A
groupsread anywhere in generated code, for any purpose. - A scope check in a service handler or a mock handler; a
hasScopethat chooses rows. anyOfscopes, a second requirement object, or abearerAuthscheme on a generated contract.- A
WWW-Authenticateread, a 403 from a generated service, or a 403 from the mock gateway. ownership,screens[],coldStartRole,publicComponentsor athunderblock insecurity.json; a handle retyped as a string literal in JSX.- A role name, group name or administrator hardcoded in
NoAccessorForbidden; both read the generated tables.
Where each half is decided
| Question | Decided in | Read by |
|---|---|---|
| which handles exist, which roles hold them, who holds a role | security.json (security-design) |
Thunder at Build; gen-authz.mjs |
| which handle an operation needs | openapi.yaml security (openapi-conventions) |
the gateway trait; the build gate; gen-authz.mjs; the mock gateway |
| which rows an operation reaches | the operation's path (openapi-conventions) |
the generated handler |
| which screens exist and which role reaches them | prototype.json (prototype) |
the walk (mock-verification) |
| which operation a screen loads | src/authz/screens.ts (thunder-authentication) |
RequireOperation, Can, the rail, the landing redirect |