Permissions
An admin permission in this platform is a code, and a code has three separate
notions attached to it. Only one of them used to be visible anywhere, and that
is why this page exists: a reader who sees module: 'quote_requests' on
rfqs:handle reasonably concludes that quote_requests is the one thing that
decides whether the code is there. That conclusion is wrong often enough to have
caused a shipped defect; an audit found five instances of it.
1. module — a display grouping
module is the heading the role editor files a code under. Nothing more.
It is not guaranteed to be a module id: _lifecycle declares its codes under
module: 'module_lifecycle', which names no module in any deployment. Filtering
anything on module therefore deletes the lifecycle permissions from every
platform that tries it.
2. owners — whose presence keeps the code grantable
owners is the set of modules that declare the code. /admin-roles offers
the code while any owner is effectively present — both axes of module
presence, platform availability and operator activation.
It is a set because a code can be shared. integrations:manage gates the API
keys admin surface and the webhooks one, and both api_keys and webhooks
declare it; switching webhooks off must not take the API keys screen's own gate
off the role editor. Co-declaration is the mechanism, and it is the repair for a
module that enforces a code another module owns — see foreign gates below.
owners and module are different strings and treating them as one is a defect
waiting to happen. Both are on the wire on GET /api/v1/admin/permissions, and
the role editor shows the owner set wherever it says something the grouping does
not.
3. The vocabulary, and the grantable set inside it
Two questions, deliberately answered differently:
| Question | Method | Presence-filtered |
|---|---|---|
| What may an operator newly grant? | listAssignable() | yes |
| What codes does the platform know? | listKnownCodes() | no |
A role upsert validates against the vocabulary, never the grantable set, and
the difference is what keeps "switching a module off is non-destructive and
reversible" true. An "Editor" role holds blog.read; an operator switches
blog off and then renames the role. Validating the submitted list against the
grantable set would answer 400 Unknown permission(s): blog.read — so the
operator either loses the role or silently drops a grant that has to come back
when blog does. The grant is harmless meanwhile: the routes behind it answer
503 MODULE_DISABLED at their own seam.
The role editor relies on this. It seeds its selection from the role's codes and renders a checkbox only for the ones the catalogue offers, so an absent module's grant survives the round trip with no checkbox to un-tick.
PermissionCatalogueService
(packages/modules/admin_roles/src/backend/services/permission-catalogue.service.ts)
is where all three notions are merged, and it is the only place they are.
Foreign gates: enforcing a code you do not own
A module may enforce a permission code another module owns. Nothing declares
that coupling, and the cost is one-directional: the owner's presence decides
whether the code is offered, so switching the owner off takes the code off
/admin-roles while the consumer's routes — if the consumer cannot be switched
off with it — go on enforcing it. The screen sits there behind a permission
nobody can be granted.
It is derived, not declared. backend/test/helpers/foreign-gates.ts reads
every enforcement site the permission inventory resolves and every manifest, and
classifies each pair; every verdict comes off the manifests on every run, so
withdrawing a module's nonDeactivatable lock re-opens every finding that was
resting on it, in the same pipeline, with no ledger to edit.
Three repairs, in the order to reach for them:
- The consumer gates on a code it owns. Apply the label test: does the
code's label read as a sentence about the consumer's own screen? "Handle
quote requests" is not a sentence about a price-resolution endpoint, so
price_listsgates that route withprice_lists:read. - Both modules declare the code, making the consumer a second owner, so the
code survives while either surface is on. This is the shape
api_keysandwebhooksalready use. - Declare the owner in
dependencies— last, and usually wrong. A consumer that cannot be switched off, declaring an owner that can, makes the lifecycle orchestrator refuse to switch that owner off at all: the operator-toggleable rule inverted, decided by somebody else's manifest.
Declared dependencies: requires
A permission declaration may name other codes a role holding it needs:
permissions: [
{ code: 'rfqs:handle', label: 'Handle quote requests', requires: ['price_lists:read'] },
],
It is advisory and costs nothing at runtime. No guard reads it, no upsert is
refused, and it is not a lifecycle edge — it puts no module in anybody's
dependencies. What it does is put a sentence where an operator can read it: the
role editor shows the shortfall for the codes currently ticked, with a one-click
add, and a role saved without them is saved.
The example above is real. RfqCreatePage prefills an agreed price from a
price_lists route deliberately gated with price_lists:read, so a
role holding only rfqs:handle loses the prefill and falls back to manual entry.
That sentence was written in a ruling, in a route comment and in a seed, and in
nothing an operator could read.
Do not write a derived fact here. A module enforces a code another module
owns is the previous section's question, it is derived from the gates and the
manifests, and declaring it in a manifest as well would be two answers to one
question waiting to disagree. requires carries something no instrument in this
repository can work out: a coupling that runs from an admin screen's own fetches
to another module's route, plus the judgement of whether a role without the
second code is broken or merely degraded in a way somebody accepted.
What is machine-checked is that a requirement names a code the platform's
vocabulary holds — a typo, or a code its owner renamed, would otherwise advise an
operator forever to grant something that does not exist. That is
backend/test/contract/admin_users/permission-inventory.test.ts, in the same
file as the inventory's two-way sweep, because the inventory already reads every
manifest code and two derivations of one population are two answers waiting to
disagree.
A code more than one module declares takes the union of its declarers' requirements: a shared code opens more than one surface, each with its own needs, and advising more is the direction that cannot strand anybody.
Adding a permission
Declare the code in your own module's manifest.ts, label it in your own module's
i18n/en.json and i18n/pl.json under adminRoles.permission.<code>, enforce it
with a requireAdmin('…') literal that matches exactly, and run
pnpm --filter backend exec vitest run test/contract/admin_users/permission-inventory.test.ts
pnpm --filter backend run check:action-route-permissions
The first sweeps both directions — every enforced code is grantable, every
grantable code is enforced — plus label coverage and the requires declarations.
The second answers a question the two-direction sweep structurally cannot:
whether the code a palette action declares is the one enforced on that action's
own route.