Scopes & Capability Grants
Let non-org principals (event attendees, shuttle drivers, vendors) reach a narrow slice of your data through a server-verified, relationship-derived scope carried in a signed JWT — with least-privilege scoped roles enforced across every pillar.
Org membership answers "which tenant is this caller in?" Scopes answer a different question: "this caller isn't an org member, but they're a confirmed attendee of event E — let them see exactly E's rows, as exactly the role they hold, and nothing else."
A scope is a tenant axis whose value is established by a relationship you prove once (at POST /scope/v1/enter) and then carry in a signed JWT claim — verified cryptographically on every later request with no database round-trip. It's the REST/JWT twin of the realtime ws-ticket.
When to use it
Reach for a scope when a principal must touch a bounded slice of an instance without being an org member:
- Event attendee — reads their event's announcements / sessions, not the org.
- Shuttle driver — sees only the passengers on their bus (a row slice), can DM them.
- F&B vendor — reads dietary counts, zero attendee rows.
- Visiting nurse / carrier / contractor — one patient's meds, one carrier's shipments.
Each is event-scoped but a distinct least-privilege role. Org members are unaffected — they keep their normal tenant access.
The model
1. Declare relationships + scopes
A scope kind (event) declares named roles, each conferred by a relationship whose from row carries the role (and optionally a sub-key for row slicing):
// quickback.config.ts
authz: {
relationships: {
attendeeOf: { from: 'guests', subject: { column: 'linkedUserId', equals: 'ctx.userId' }, resource: { column: 'eventId' }, where: { status: 'confirmed' } },
organizerOf: { from: 'staff', subject: { column: 'linkedUserId', equals: 'ctx.userId' }, resource: { column: 'eventId' }, where: { role: 'organizer' } },
shuttleDriverOf: { from: 'staff', subject: { column: 'linkedUserId', equals: 'ctx.userId' }, resource: { column: 'eventId' }, where: { role: 'shuttleDriver' } },
},
scopes: {
event: {
requestField: 'eventId', // the body field the client sends to /enter
roles: {
attendee: { via: 'attendeeOf' },
organizer: { via: 'organizerOf' },
shuttleDriver: { via: 'shuttleDriverOf', subKeys: ['shuttleId'] }, // row-slice sub-key
},
},
},
}2. Enter the scope
POST /scope/v1/enter (session OR existing JWT auth)
{ "eventId": "evt_123" }The server probes every declared role for the proposed instance (one DB touch), and mints the proven roles + sub-keys into a signed JWT scope claim, returned via the set-auth-token header and the body:
// a shuttle driver who is also a confirmed guest
"scope": { "event": { "id": "evt_123", "roles": ["attendee", "shuttleDriver"], "shuttleId": "shA" } }The client sends that JWT as Authorization: Bearer … on subsequent requests. The auth middleware verifies the signature and sets ctx.scope — no DB. The claim is carried forward on every authed call (rolling refresh) until you enter a different instance.
Invariant: the client only proposes the instance id (from the URL). The server proves membership before signing. No client-asserted scope is ever trusted.
3. Gate the pillars
Scoped roles are referenced everywhere a pillar takes a role as the namespaced identifier scope:<kind>:<role>:
// features/guests/guests.ts — the manifest, row-sliced + PII-masked
export default feature('guests', {
columns: { /* … eventId, shuttleId, email, … */ },
// org staff see the whole event; a shuttle driver sees only their bus
firewall: {
any: [
{ field: 'organizationId', equals: 'ctx.activeOrgId' },
{ all: [
{ field: 'eventId', equals: 'ctx.scope.event' },
{ field: 'shuttleId', equals: 'ctx.scope.event.shuttleId' }, // sub-key row slice
] },
],
},
masking: {
email: { type: 'email', show: { roles: ['admin', 'scope:event:organizer'] } }, // hidden from the driver
},
read: {
views: {
manifest: {
fields: ['id', 'nameAtInvite', 'shuttleId', 'pickupLocation'],
access: { roles: ['admin', 'scope:event:organizer', 'scope:event:shuttleDriver'] },
},
},
},
});| Pillar | How it reads the scoped role |
|---|---|
| Firewall (rows) | all/any arms keyed on ctx.scope.<kind> (+ sub-keys) — see Firewall |
| Access (verbs/views) | access.roles: ['scope:event:shuttleDriver'] on CRUD, actions, and views |
| Masking (fields) | masking.<col>.show.roles includes / excludes scope:event:<role> |
The org / scope split (security)
Scoped roles never merge into ctx.roles. A scope:* identifier matches only the verified scope claim (ctx.scope.<kind>.roles); a bare org role (admin, member) matches only ctx.roles. The compiler enforces the split so an externally-provisioned shuttleDriver can never satisfy an admin gate by string coincidence, and a tampered scope claim can't impersonate an org role. This is the precondition for admitting limited outside users at all.
Sub-keys (row slicing)
A role's subKeys name columns on the relationship's from row that are projected into the claim. A firewall arm then slices on them:
{ field: 'shuttleId', equals: 'ctx.scope.event.shuttleId' }Because sub-key arms compose with all, an absent sub-key denies (sql\1 = 0`) rather than widening — a driver can never see *all* shuttles by simply lacking the claim. The scope accessor is always emitted null-safe (ctx.scope?.event?.shuttleId`), so an org caller who never entered the scope hits a clean deny, not a 500.
Set-valued sub-keys
A driver may cover more than one bus. Append a [] marker to the
sub-key name to project every matched from row's value into the claim
as a string[]:
roles: {
shuttleDriver: { via: 'shuttleDriverOf', subKeys: ['shuttleId[]'] }, // set-valued
}The [] is a marker only — it's stripped from the column name (the
relationship's from row still reads shuttleId). At /scope/v1/enter the
proven rows union their values, so the claim carries an array:
"scope": { "event": { "id": "evt_123", "roles": ["shuttleDriver"], "shuttleId": ["shA", "shC"] } }The matching firewall arm — written exactly as for the scalar case
({ field: 'shuttleId', equals: 'ctx.scope.event.shuttleId' }) — is
rewritten by the compiler to an inArray row-slice:
WHERE shuttle_id IN (ctx.scope?.event?.shuttleId ?? [])The ?? [] default makes an absent claim lower to inArray(col, []) — a
clean DENY, never a malformed SQL fragment or a 500. A scalar sub-key (no
[] marker) is untouched and keeps lowering to eq. You write the same
equals arm either way; declaring shuttleId[] vs shuttleId in the scope
role is the only switch.
Auto-stamping the scope column on insert (q.scope)
q.scope('organization') auto-stamps the tenant column on write, the same
way q.scope('owner') stamps ownerId. A relationship-derived kind works
identically: q.scope('event') auto-stamps the column from the verified
scope claim (ctx.scope.event.id) on insert — no need to pass eventId
in the request body:
export const eventNotes = q.table('event_notes', {
id: q.id(),
eventId: q.scope('event'), // INSERT autofill from ctx.scope.event.id
body: q.text().required(),
organizationId: q.scope('organization'), // INSERT autofill from ctx.activeOrgId
});The stamp is insert-only and null-safe — the generated CREATE handler
writes eventId: ctx.scope?.event?.id, so it never binds undefined. It
does not add a read-time WHERE filter (that's the firewall's job; a
read scoped to eventId = ctx.scope.event.id would be wrong here). Infra
kinds map to their session fields (organization → ctx.activeOrgId,
owner → ctx.userId, team → ctx.activeTeamId); any other kind maps to
ctx.scope.<kind>.id.
Namespace scope-claim refresh
A scope is normally minted at POST /scope/v1/enter. When you also mount a
namespace
that gates /event/:eventId/* on the same relationship, the namespace
resolver can refresh ctx.scope.<kind> in memory for the duration of that
request — so an attendee hitting /event/evt_123/announce gets
ctx.scope.event = { id: 'evt_123', roles: ['attendee'] } without a separate
enter round-trip. The refresh runs after the gate (the gate proves the
relationship; the refresh stamps the proven role from the path param).
This is a strictly-guarded convenience, and it stamps per matched lane. The
resolver records which via: lane opened the gate (first-match-wins) and stamps
only that lane's scope role — an attendee who matched attendeeOf gets
roles: ['attendee']; an organizer who matched organizerOf gets
roles: ['organizer']. A caller can never receive a role they didn't prove this
request.
- Relationship lanes each confer their own scope role, stamped only when that exact lane matched — so a multi-lane
via:(e.g.via: ['attendeeOf', 'organizerOf']) refreshes the matched lane's role, not an ambiguous union. - A bulk-grant lane (
{ roles: [...] }or{ team: true }) confers no scope role — it opens the gate via the caller's org role without proving any relationship, so there is nothing to stamp (stamping one would forge it). The gate still passes; the caller is simply authorized by their org role.
The refresh described so far is in-memory and single-request. To also hand the
caller a persisted scope token, opt the namespace into mintScope — see
below.
Gotcha: a namespace only emits its resolver when it claims a standalone action
This is the single most common surprise (it has tripped up a customer): a
namespace emits its resolver — and therefore the scope refresh and the
mintScope
mint — only when at least one STANDALONE action's path: falls under the
namespace prefix.
A standalone action declares its own path: (e.g.
path: '/event/:eventId/home'), so the compiler can match it against the
namespace prefix and hoist it into the synthetic namespace routes file.
Record-based actions are different — they mount under their resource's CRUD
routes (/api/v1/guests/:id/checkin), which live outside the
/event/:eventId prefix. They never match the prefix, so they never get
claimed.
A namespace that contains only record-based actions is therefore inert:
nothing matches the prefix, no synthetic routes file is emitted, and so there
is no resolver, no scope refresh, and no mint — even though the namespace
is declared and validates cleanly. The middleware you expected to gate
/event/:eventId/* simply isn't there.
The fix is to give the namespace at least one standalone action under the
prefix. In q-events, eventHome exists precisely for this — a standalone
GET /event/:eventId/home that pulls the resolver (and the §5.3 mint) into
existence:
// features/events/actions/eventHome.ts
export default defineAction({
path: '/event/:eventId/home', // ← standalone path UNDER the namespace prefix
method: 'GET',
input: z.object({}),
access: { roles: ['scope:event:attendee', 'scope:event:organizer', 'manager+'] },
async execute({ db, ctx, c }) { /* … */ },
});With that action present, the eventScope resolver is emitted, runs the
attendeeOf probe once per /event/:eventId/* request, stamps
ctx.scope.event, and (because the namespace sets mintScope: true) mints the
scope token. Without a standalone action under the prefix, none of that fires.
mintScope — mint the scope token from the namespace resolver
A scope is normally minted by an explicit POST /scope/v1/enter round-trip.
Setting mintScope: true on a namespace folds that mint into the
namespace resolver: on a successful relationship probe the resolver MINTS a
persisted scope JWT and returns it via the set-auth-token header — so a
native client hitting /event/:eventId/* gets a scope token without ever
calling /scope/v1/enter.
// quickback.config.ts
authz: {
relationships: {
attendeeOf: {
from: 'guests',
subject: { column: 'linkedUserId', equals: 'ctx.userId' },
resource: { column: 'eventId' },
where: { status: 'confirmed' },
loads: 'guests',
exposeAs: 'guest',
},
},
namespaces: {
eventScope: {
prefix: '/event/:eventId',
via: ['attendeeOf'], // one or more lanes — mints the matched lane's role
mintScope: true, // ← fold the scope-token mint into the resolver
},
},
}A confirmed attendee then hits the standalone action under the prefix:
GET /api/v1/event/evt_123/home
Authorization: Bearer <existing-jwt>
200 OK
set-auth-token: <new-scoped-jwt> ← carries scope.event.roles = ["attendee"]The client stores the set-auth-token value and sends it as the bearer on
every subsequent /event/:eventId/* request. The JWT carries
scope.event = { id: 'evt_123', roles: ['attendee'] }, so the scoped-role
access checks (scope:event:attendee) pass with no DB touch and no second
enter call.
mintScope is opt-in per namespace and defaults to false. With it off,
the resolver does the in-memory ctx.scope.<kind> refresh only (the section
above); subsequent requests must still call /scope/v1/enter for a persisted
token.
Per-matched-lane minting (forging safety)
The mint stamps the scope role of whichever lane matched, gated on the first-match-wins probe — the same per-lane discipline as the in-memory stamp, applied here with higher stakes: a minted token persists for its whole TTL, so it can only ever carry the role the caller actually proved this request.
- A multi-lane
via:(e.g.via: ['attendeeOf', 'organizerOf']) mints the matched lane's role —attendeeOf→scope.event.roles = ['attendee'],organizerOf→['organizer']. The resolver records which lane opened the gate, so there is no ambiguity. - A bulk-grant lane (
{ roles: [...] }or{ team: true }) mints no scope role — it opens the gate via the caller's org role without proving a relationship. The gate passes; the caller carries their org role, not a minted scope role.
The realtime ws-ticket can authorize through this same namespace
via:, issuing a per-matched-lane WebSocket ticket — so the socket gate and the REST gate stay identical.
In every suppressed case the gate still authorizes the request; only the mint
(and the in-memory refresh) are skipped, and those callers obtain a scope
token through /scope/v1/enter as usual. Flipping mintScope: true can never
forge a scope role — the guard, not the flag, is the gate.
TTL and best-effort semantics
- TTL is the scope-token TTL —
auth.jwt.expiresIn(default 180s), clamped to a hard ≤180s ceiling at the mint site. A longer-lived scope token would widen the post-revocation replay window past the spec bound, so the clamp is non-negotiable. - The mint is gated by the same proven-relationship guard as the in-memory stamp. It runs after the gate has granted access, reading the instance id from the proven path param (
:eventId) — never from request input — so the mintedscope:<kind>:<role>is backed by exactly the proof/scope/v1/enterrequires. The token is byte-identical to one/scope/v1/enterwould produce: same shared mint helper, same carry-forward of any other verified scope kinds already on the incoming token (the freshly-proven kind takes precedence; siblings survive the re-mint). - The mint is best-effort. A mint failure (e.g. a missing signing secret) never fails the request — the in-memory stamp already authorized it; the caller simply doesn't receive a refreshed token on that response and can fall back to
/scope/v1/enter.
Revocation & TTL
The scope claim is trusted for the JWT TTL (default 180s, auth.jwt.expiresIn) — identical to how orgId / role claims already behave. Removing a guest/staff row takes effect within one TTL (the next enter won't re-mint the role). Immediate revocation (auth.jwt.revocationCheck: 'kv') is on the roadmap.
Reference
// AppContext (generated lib/types.ts)
ctx.scope?: Record<string, { id: string; roles?: string[]; [subKey: string]: string | string[] | undefined }>;
// authz.scopes.<kind>
{
requestField: string; // body field carrying the instance id
roles: Record<string, { via: string; subKeys?: string[] }>; // 'col' → eq, 'col[]' → inArray
}requestFieldmust equal every role-relationship'sresource.column.- Each relationship's
fromtable must exist and ship a generated firewall (it can't beexception). - A
subKeysentry ending in[]is set-valued (lowers toinArray, minted asstring[]); without the marker it's scalar (eq, minted asstring). q.scope('<kind>')auto-stamps the column fromctx.scope.<kind>.idon insert.- Generated route:
POST /scope/v1/enter(mounted only whenauthz.scopesis declared).
Firewall - Data Isolation
Automatically isolate data by user, organization, or team with generated WHERE clauses. Prevent unauthorized data access at the database level.
Permissions
Name a grant rule once with anyOf / allOf / not over relationships, org roles, scoped roles, and pseudo-roles — then reference it from any firewall as { permission: 'x' }.