Skip to content

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:

  1. An explicit grant on the entity decides, including downward: campaign Editor plus map Viewer is Viewer on that map.
  2. Otherwise the nearest ancestor that says anything decides; several (a character in two campaigns) are taken together.
  3. 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).
  4. Several roles at the deciding level are unioned.
  5. 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: '…' });
});