Permissions, read routes and the Gatekeeper
How the platform decides who may read what, as of v0.95.0 — the model, the
one component every private read passes through, the four sets every GET
route is in, and the mode that bypasses all of it.
This is the page for a contributor adding a route, a module author asking "what may this caller do", or an operator wondering why an admin cannot see a campaign. The roadmap page that planned it is Roles and the Gatekeeper.
The model
One table, grants: (principal_kind, principal_id, entity_type, entity_id, role). A principal is a user (or, typed and unbuilt, a group or a
role-default). An entity is a campaign, map, token, character, party or
faction. A role is one of four built-ins — Owner, Editor, Collaborator,
Viewer, defined in code in @purple-cat/shared (BUILT_IN_ROLE_DEFINITIONS)
— or a custom role, a row in roles with a campaign and a subset of the six
verbs: read, play, edit, delete, share, manage-roles. The verb
list is closed; a custom role naming a verb that does not exist grants
nothing.
Admin is not a role. It is users.is_admin, consumed by the admin routes
and by Superuser Mode, and by nothing that decides access to an entity
(decision PM-D3). There is no deny (PM-D1): every rule below adds.
Ownership stays in its column. A campaign's owner, a character's
user_id, a party's user_id and a token's owner are read as Owner grants
on the way past. They are not copied into grants, so a code path that
creates an entity cannot forget the row.
The resolver
effectivePermissions(actor, entity) in
packages/server-core/src/utils/effectivePermissions.ts loads the cascade
and hands it to the pure rules in permissionResolution.ts, which touch no
database and are tested without one. The cascade:
token → map → campaign
↘ character (when linked) → every campaign it is linked to
map → campaign
party → campaign
faction → campaign
campaign → (root)
The rules, in order:
- An explicit grant on the entity decides, including downward: campaign Editor plus map Viewer is Viewer on that map.
- Otherwise the nearest ancestor that says anything decides; several (a character in two campaigns) are taken together.
- Owner cascades unconditionally and cannot be overridden downward — a GM cannot be locked out of their own session by a grant written on a token. The one exception is an edge that caps what it passes down: a character caps what its campaigns confer at Collaborator, so a GM may play a player's character and may not open Build mode on it (v0.61.0's rule, kept).
- Several roles at the deciding level are unioned.
- Nothing subtracts.
can(actor, entity, verb) is the yes/no form. whoCan(entity, verb) is the
inverse, implemented by running the same resolver over every candidate so it
cannot disagree with the forward answer. permissionSession(actor) memoises
the cascade for one request and can prefetch a list in a handful of
queries — this is what a map's token list uses.
The legacy tables
campaign_members, character_permissions and token_permissions are
still written by the sharing routes until the permissions editor (v0.96.0)
writes grants directly. A backfill at boot turns them into grants once, and
every write to them is mirrored into grants at the time
(services/grantsMirror.ts), so nothing waits for a restart. Nothing reads
them for a decision any more.
The Gatekeeper
packages/server-core/src/middleware/gatekeeper.ts. Express has no "after"
middleware, so it is registered before the handler and wraps res.json: the
handler builds its answer as it always has, and the wrapped res.json walks
the body against the actor before sending it. Only a 2xx body is walked.
Three shapes, and every guarded route declares one:
| Shape | The body is | On a row the actor may not read |
|---|---|---|
gatekeeper.one(entity) |
one entity | 403 |
gatekeeper.list(entity) |
an array (or { at } names the key holding one) |
the row is dropped, silently; the count is logged at debug level |
gatekeeper.within(fn) |
scoped by an entity the path names | 403 before the handler runs — there is no point doing the work and then refusing it |
entity is a grant entity type, in which case the row's id is the entity's,
or a function from the row (or the request, for within) to the entity that
decides: a drawing is not a grant entity, but the map it is on is. A mapper
may also answer PUBLIC ("nothing private here" — a system's default
conditions in a list that mixes them with a campaign's) or several entities
that all must be readable. A row the mapper cannot place fails closed.
The verb is read unless the route says otherwise; the sharing lists ask
share. The per-route checks that existed before v0.95.0 are still there and
still refuse first; the Gatekeeper is a floor on privacy, not a ceiling on
strictness, and a handler may be stricter than it.
The four sets
Every router.get in packages/server-core/src/routes/ is in exactly one set
of readRoutes.inventory.ts, and readRoutes.guard.test.ts fails the build
for one that is in none, in two, filed as Guarded without gatekeeper.<shape>(
on its registration line, or carrying the Gatekeeper while filed as needing
no check. The key is <file> <router variable> <path>.
| Set | What it means | Carries |
|---|---|---|
| GUARDED | the response passes through the Gatekeeper | the shape (one / list / within) |
| PUBLIC | anyone may read it: system reference data, module content, an invite token that is its own secret, deployment configuration | a written reason |
| SELF_SCOPED | the actor is the only subject — their settings, notifications, recycle bin, Library; nothing in the body is anyone else's | a written reason naming what scopes it |
| ADMIN_ONLY | behind requireAdmin; a capability gate, not an entity check |
a written reason |
Adding a GET means adding one line. Filing it under GUARDED means putting
the Gatekeeper on the route. Filing it anywhere else means writing down why
nobody's data is in the answer — cheap when true, uncomfortable when not,
which is the point.
Sockets
A socket carries an actor or is refused at the handshake: the client sends
the identity provider's token in auth, re-read on every reconnect, and
middleware/socketAuth.ts verifies it the way requireAuth does. A campaign
room is joined through the resolver (joinCampaign and presence:join
answer joinCampaign:denied otherwise); a user room is the actor's own,
whatever the client names. The relay events carry no private data. Details in
realtime-api.md.
Superuser Mode
An admin is an ordinary user: Owner of what they create, granted the rest
like anyone. Superuser Mode is the one way past every check —
PUT /api/admin/superuser { on: true }, behind requireAdmin, sets
users.superuser_until eight hours out; resolveActor reads it into
req.actor.superuser, and the Gatekeeper, the resolver, the socket gate and
the legacy helpers all pass while it is set. It is a server-side switch,
never a header, and it does not travel with impersonation. The client shows a
fixed banner on every screen with the one Exit Superuser Mode button;
entering it is a button on the Users admin page. Every entry and exit is
logged.
ADMIN_USER_ID is retired and read nowhere.
For module authors
A module's server router receives req.actor and req.effectivePermissions
when the caller sent a token, and nothing extra otherwise
(ModuleRequestContext, MODULE_API_VERSION 1.37.0). The resolver a module
gets is the platform's own, bound to the same actor a platform route would
see. A module's routes stay public; one that needs an actor checks for one
and answers 401 itself.
router.get('/campaigns/:id/secret', async (req, res) => {
if (!req.actor) return res.status(401).json({ error: 'Sign in first' });
const { verbs } = await req.effectivePermissions!({ type: 'campaign', id: req.params.id });
if (!verbs.has('read')) return res.status(403).json({ error: 'Not your campaign' });
res.json({ secret: '…' });
});