volcanic-backend

Authorization Model — Roles, Capabilities & Admin Governance

Status: IMPLEMENTED (v4.0.1). This document is the authoritative specification of the authorization model in @volcanicminds/backend. It supersedes the ad-hoc backoffice role. Complements AUTH_COMPOSABLE_EVOLUTION.md (login flows); this doc is about authorization.

Released in 4.0.1 — a major, matching the breaking changes in §9.

1. Motivation

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.

2. Roles

2.1 Built-in roles

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.

2.2 Consumer-defined roles

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 backoffice must re-declare it in config/roles.ts. Roles are merged from consumer config, so this is a one-line addition.

2.3 Overriding built-in roles (protected)

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:

Capabilities therefore live only on consumer-defined roles.

“Any authenticated user” is not a capability. That tier is the existing idiom roles: [] + the isAuthenticated middleware (e.g. /users/me): anonymous → 401, any logged-in subject → allowed. Do not model it as a capability on public.

3. Capabilities

3.1 Shape & lifecycle

Roles gain an optional capability list:

{ code, name, description, capabilities?: string[] }   // default: none

3.2 Framework-reserved capabilities

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.

3.3 Enforcement mechanism (boot-time, gate unchanged)

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

3.4 Route → capability mapping

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

3.5 Boot-time validation (fail-fast)

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

4. The admin apex — “sub-admins never prevail over real admins”

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.

4.1 Accepted implication — sub-admin self-expansion

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.

5. Admin governance

6. Genesis of the first admin — Option Z

6.1 Principle

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.

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 require ADMIN_EMAIL. When the data layer is present, the reconciliation in §6.3 applies — including the fail-fast when there is no admin and no ADMIN_EMAIL to bootstrap one.

6.2 Founder identity = config match (no new column)

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.

6.3 Boot reconciliation (base/system schema)

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.

  1. ADMIN_EMAIL set → look up the user with that email:
    • not found → create it as 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).
    • found but not admin → promote it to admin (config is authoritative).
    • found and admin → no-op.
  2. 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.

6.4 Sovereign founder protections (runtime)

The founder (email === ADMIN_EMAIL) is protected against everyone, admins included:

7. Multi-tenant

8. Rough edges

Found while auditing the code. The first four were closed in this work; the capability model depends on them:

  1. ✅ allow_multiple_admin symmetry — now enforced on create, update and token creation (Rule A). Previously create-only.
  2. ✅ Last-admin protection — delete/demote/block refuse to drop the admin count to zero.
  3. ✅ Token role backdoor — Rule A covers tokens: no capability holder can mint an admin token.
  4. ✅ Public register-as-admin — 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):

  1. System-admin detection dead branch — 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.
  2. system tenant provisioning/doc — the system tenant is recognized by slug but never created by the framework; document how it is provisioned.

9. Breaking changes & migration

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.

10. Out of scope (later)