Status: IMPLEMENTED (v4.0.1). This document is the authoritative specification of the authorization model in
@volcanicminds/backend. It supersedes the ad-hocbackofficerole. ComplementsAUTH_COMPOSABLE_EVOLUTION.md(login flows); this doc is about authorization.Released in
4.0.1— a major, matching the breaking changes in §9.
Today the framework ships three built-in roles — public, admin, backoffice — but only two
are coherent. admin is a well-defined universal superuser; public is the anonymous role.
backoffice, instead, is an accreted role: it exists only as a scattered set of route grants
(GET /admin/manifest, the whole /token API, and /users/:id/block|unblock) with no designed
persona. It can block a user but not list users; it can fully CRUD integration tokens. It answers
no clear question (“limited operator of what?”) because the framework has no domain resources of
its own.
An opinionated framework must be coherent. The target model keeps exactly two authority primitives built in — anonymous and superuser — and expresses every “in-between” operator as a capability grantable to a consumer-defined role.
| Role | Meaning |
|---|---|
public |
Implicit anonymous role. Routes with roles: [] are open to everyone. |
admin |
Universal superuser. Appended to every route’s allowed roles at load (lib/loader/router.ts). Within a tenant, can do everything. |
backoffice is removed from the built-in catalog.
Any project that needs an operator role (e.g. backoffice, editor, support) declares it in
its own config/roles.ts and grants it the capabilities it needs. The framework never ships a
half-defined middle role again.
Breaking change. Consumers currently referencing
backofficemust re-declare it inconfig/roles.ts. Roles are merged from consumer config, so this is a one-line addition.
admin and public are protected built-ins. A consumer’s config/roles.ts may override
only their name and description (labels); their code and capabilities are locked, and
any capabilities provided for them are ignored. This requires the role loader to move from
replace-by-code to a protected merge: a code matching a built-in → keep the built-in’s code
and (empty) capability set, apply only the provided labels; any new code → added verbatim (full
freedom over code, labels, and capabilities).
Neither protected role can carry capabilities, by design:
admin already satisfies every capability via the router append — listing capabilities on it is a
no-op.public is the anonymous/base role. Access for public comes from routes declared roles: [],
not from capabilities: since any route whose allowed roles include public is treated as fully
public (open to everyone, incl. anonymous), granting a capability to public would collapse that
capability’s routes to anonymous-open — defeating the capability.Capabilities therefore live only on consumer-defined roles.
“Any authenticated user” is not a capability. That tier is the existing idiom
roles: []+ theisAuthenticatedmiddleware (e.g./users/me): anonymous → 401, any logged-in subject → allowed. Do not model it as a capability onpublic.
Roles gain an optional capability list:
{ code, name, description, capabilities?: string[] } // default: none
admin and public hold no capabilities (see §2.3): admin because it is the superuser
(appended everywhere, so it satisfies every capability implicitly); public because its access is
defined by roles: [] routes, not capabilities. Capabilities live only on consumer-defined roles.The framework hard-codes and honors exactly three capability names on its own routes:
| Capability | Grants access to | Framework routes |
|---|---|---|
manifest |
Loading the admin console manifest | GET /admin/manifest |
users |
User management | /users/* (CRUD + block/unblock + reset) |
tokens |
Integration-token management | /token/* |
Consumers may coin their own capability names for their own routes; the vocabulary is open, the framework only reserves the three above.
A route declares the capability it requires:
config: { requireCapability: 'users' } // default: none → admin-only (via the global append)
At router load, the loader resolves the route’s effective allowed roles to
[admin] ∪ { roles whose config declares that capability }. The request-time gate
(lib/hooks/onRequest.ts) is unchanged — it still performs a plain role intersection, and admin
always passes via the global append.
Consequence (desirable): the generated manifest reflects capability holders automatically.
The manifest generator reads each route’s resolved allowed roles, so sub-admin role codes appear in
the per-capability roles[], and the admin UI’s access control shows/hides correctly with no
special casing. (Implementation note: the loader must write the resolved role set to the same field
both the request gate and lib/manifest/generator.ts read.)
| Route group | Today | Target |
|---|---|---|
GET /admin/manifest |
[admin, backoffice] |
requireCapability: 'manifest' |
/users/* |
[admin] |
requireCapability: 'users' |
/token/* |
[admin, backoffice] |
requireCapability: 'tokens' |
/tenants/* |
[admin] |
unchanged — platform/system surface, not a sub-admin capability |
Role references and capability requirements are validated once at boot, after the role catalog
(config/roles.ts + built-ins) and all routes are loaded. The server fails fast on
misconfiguration instead of silently dropping routes. (Today, an unknown role reference falls into a
try/catch in lib/loader/router.ts that sets config.enable = false — the route disappears with
no error.)
backoffice or pippo never declared in config/roles.ts), startup aborts with
an explicit error. All offenders are aggregated and reported together (route method, path,
handler + role), not one at a time.roles: ['pippo']) is reported by name; an
object reference that resolves to undefined (roles: [roles.pippo]) can only be reported as
“undefined role on route X” — the name is already lost at import time. String codes give the better
diagnostics; migrating core routes to string codes (optional) yields uniform named errors.requireCapability naming a capability that no role
holds is valid — the route stays admin-only via the append — so it is at most a startup
warning, never a failure. The reserved names users / tokens / manifest are always valid.admin is a restricted group (default size 1, see §5) that governs the whole privilege
structure. Capability holders are operational admins: they can run the machine, but they can
never touch the apex nor become it. Three rules enforce this; each closes a concrete backdoor.
Rule A — no elevation to admin. A capability holder can never create or assign the admin
role — to a user or to an integration token. This closes a real backdoor: integration
tokens carry roles[] used for authorization (onRequest normalizes req.token.roles), so a
tokens holder minting a roles:['admin'] token would obtain admin. Only a real admin can grant
admin, and only when allow_multiple_admin is true (§5).
Rule B — no action against admins. A capability holder can never update / delete / block /
unblock / reset-password / reset-mfa a subject that currently is an admin. Admins are
visible in list/read to users holders, but untouchable except by a real admin.
Rule C — capabilities are immutable. No one can create or associate capabilities at runtime;
they live only in role definitions resolved at boot. A users holder can assign roles to users
but cannot redefine capabilities.
Because capabilities are immutable but role-assignment is permitted, a users holder may assign any
non-admin role — including roles that carry tokens/manifest/users. A users sub-admin
can therefore spawn or expand other sub-admins (and self-grant existing capabilities), but can
never reach admin. This auto-expansion below the apex is explicitly accepted: sub-admins
may become “de facto admins” while remaining powerless against real admins.
config.options.allow_multiple_admin (default false) sets the posture:
false → single-admin: the API will not mint a second admin.true → the founder can create other admins, who can create others (chain).allow_multiple_admin guard must be enforced symmetrically on create, update, and
token creation (today it exists only on POST /users create; update and token creation
bypass it — see §8).An admin is always born from provisioning, never from public self-registration. This is already
how multi-tenant works: createTenant seeds the tenant admin during provisioning
(lib/database/typeorm/loader/tenantManager.ts). We align the base/system bootstrap to the same
principle.
POST /auth/register never creates an admin — not even the first one. It only creates
non-privileged users.ADMIN_EMAIL (env) is mandatory and identifies the sovereign founder.Gated on a configured data layer. Genesis needs a user store, so it runs only when a live connection is present (
global.connection; the core does not start a DB itself, the consumer does). A core-only deployment with no data layer performs no genesis and does not requireADMIN_EMAIL. When the data layer is present, the reconciliation in §6.3 applies — including the fail-fast when there is no admin and noADMIN_EMAILto bootstrap one.
The founder is “the user whose email === ADMIN_EMAIL” (normalized lowercase/trim). Protection is
derived from the config match at each mutation — no persistent protected column, no
migration. Transfer/offboarding fits the env-driven deploy: change ADMIN_EMAIL and redeploy →
the new address becomes the founder; the previous one reverts to an ordinary admin.
Runs only single-tenant with a live data-layer connection. Multi-tenant tenant admins come from provisioning and the system founder is seeded out-of-band, so genesis is skipped there.
ADMIN_EMAIL set → look up the user with that email:
admin. Password: ADMIN_PASSWORD if provided, otherwise a
strong random one written to stdout only (never the structured logger, which may be
shipped/retained); rotation on first login is a documented follow-up, not yet enforced (§10).admin (config is authoritative).ADMIN_EMAIL unset → allowed only when an admin already exists (peer admins, governed by
never-zero); if there are zero admins, fail-fast at startup.The instance therefore never boots with zero admins: either the founder is provisioned from
ADMIN_EMAIL, or an admin already exists, or startup aborts.
The founder (email === ADMIN_EMAIL) is protected against everyone, admins included:
admin role), not blockable;ADMIN_EMAIL protects the system/base founder only. That
identity is sovereign over the whole platform.createTenant) and are the
apex within their own tenant, subject to the standard rules (never-zero-admins guard,
allow_multiple_admin). They receive no founder protection./tenants, tenantContext:false,
public schema) and impersonates into a target tenant via POST /tenants/impersonate, which
mints a tenant-bound token (tid = target tenant, impersonator = audit). The tid
invariant (onRequest rejects token.tid !== req.tenant.id) plus schema isolation keep every
data operation single-schema and tenant-bound.Tenant record.Found while auditing the code. The first four were closed in this work; the capability model depends on them:
allow_multiple_admin symmetry — now enforced on create, update and token creation
(Rule A). Previously create-only.admin token.POST /auth/register no longer grants admin; the founder is
provisioned from ADMIN_EMAIL (§6).Still open (multi-tenant, out of scope for this release):
lib/api/tenants/controller/tenants.ts checks
req.user?.tenantId === 'system', but the User entity has no tenantId; detection effectively
relies on req.tenant?.slug === 'system'. Consolidate.system tenant provisioning/doc — the system tenant is recognized by slug but never
created by the framework; document how it is provisioned.| Change | Impact | Migration |
|---|---|---|
backoffice removed from built-ins |
Consumers referencing it | Re-declare in config/roles.ts with the needed capabilities |
/auth/register no longer creates admin |
Bootstrap flow | Use the ADMIN_EMAIL genesis (§6) |
ADMIN_EMAIL now mandatory |
Startup | Set it in the environment; boot fail-fast without it |
/users, /token, /admin/manifest re-gated to capabilities |
Non-admin access | Grant users / tokens / manifest to the consumer role |
Released as 4.0.1 (major). A route gated on a now-undeclared role fails fast at boot (§3.5) —
this is intentional, so the removal of backoffice surfaces loudly in any consumer that forgot to
re-declare it, instead of silently disabling routes.
users / tokens capability read/write split (kept coarse for now).tenants capability (tenant management stays hard [admin] / system).