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.
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[] }>;
}requestFieldmust equal every role-relationship'sresource.column.- Each relationship's
fromtable must exist and ship a generated firewall (it can't beexception). - 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.
Access - Role & Condition-Based Access Control
Define who can perform read and write operations and under what conditions. Configure role-based and condition-based access control for your API endpoints.