Quickback Docs

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.scopeno 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'] },
      },
    },
  },
});
PillarHow 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[] }>;
}
  • requestField must equal every role-relationship's resource.column.
  • Each relationship's from table must exist and ship a generated firewall (it can't be exception).
  • Generated route: POST /scope/v1/enter (mounted only when authz.scopes is declared).

On this page