Quickback Docs

Action Access

Roles, record conditions, context substitution, and relationship-roles on actions.

Access Configuration

Access controls who can execute an action and under what conditions.

Role-Based Access

access: {
  roles: ["hiring-manager", "recruiter"]  // OR logic: user needs any of these roles
}

If the same role expression shows up on more than one action or resource, define it once in authz.roles and reference it by name — see Centrally defined access rules.

Named gates (authz.rules) also work in an action's roles: [...] array — a who-only gate expands as its who, a who+record gate as { and: [who, { record }] } (the record predicate is evaluated against the bound record, exactly like an inline access.record). Function-form access is opaque to the rewrite and is left untouched.

A firewall-bearing gate on an action is a compile error. An action has no firewall array — there is no row query for the gate's lanes to constrain before execution — so the compiler refuses rather than letting the row lanes silently not apply. The escape hatch: split the gate into a who-rule referenced from the action plus a firewall-only gate stamped on the owning resource's firewall:.

Public Access

Use roles: ["PUBLIC"] for unauthenticated endpoints (contact forms, webhooks, public APIs). Every invocation is mandatory audit logged with IP address, input, result, and timing.

// features/contact/actions/submit.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "Public contact form submission",
  path: "/submit",
  input: z.object({
    name: z.string().min(1),
    email: z.string().email(),
    message: z.string().min(10),
  }),
  access: { roles: ["PUBLIC"] },
  async execute({ db, input }) {
    // ... persist submission
    return { ok: true };
  },
});

The wildcard "*" is not supported and will throw a compile-time error. Use "PUBLIC" explicitly.

Record Conditions

For record-based actions, you can require the record to be in a specific state:

access: {
  roles: ["hiring-manager"],
  record: {
    stage: { equals: "screening" }  // Precondition
  }
}

Supported operators:

OperatorDescription
equalsField must equal value
notEqualsField must not equal value
inField must be one of the values
notInField must not be one of the values

Context Substitution

Use $ctx to reference the current user's context:

access: {
  record: {
    ownerId: { equals: "$ctx.userId" },
    organizationId: { equals: "$ctx.activeOrgId" }
  }
}

OR/AND Combinations

access: {
  or: [
    { roles: ["hiring-manager"] },
    {
      roles: ["recruiter"],
      record: { ownerId: { equals: "$ctx.userId" } }
    }
  ]
}

Arms conditioned on input (access.arms)

Some actions admit different callers depending on what they ask for: anyone on the site may post a normal notice, but an urgent one is staff-only. Declaring the wide rule and narrowing inside execute lets the two drift. Declare the arms instead:

export default defineAction({
  description: "Post a notice; urgent notices are staff-only.",
  path: "/site/:siteId/notices",
  input: z.object({ urgency: z.enum(["normal", "urgent"]), body: z.string().max(2000) }),
  access: {
    arms: [
      { when: { "input.urgency": "urgent" }, roles: ["siteStaff"] },
      { roles: ["AUTHENTICATED"] },           // default arm — no `when`, last
    ],
  },
  async execute({ input }) { /* … */ },
});
  • The first arm whose when holds decides. No other arm can admit the request, so an urgent notice from a non-staff caller is a 403 even though the default arm would have let them post a normal one. An input that matches no arm (every arm has a when and none holds) is 403 ACCESS_NO_ARM.
  • when reads validated input only. Keys are 'input.<field>' naming a field of the action's inline z.object (an unknown field is a compile error). A value is a literal (equality; null = absent), or one of { equals }, { notEquals }, { in: [...] }, { notIn: [...] }. Several keys must all hold.
  • Each arm body is an ordinary access rule — roles (named rules, pseudo-roles, scope:<kind>:<role>), userRole, or/and, and on record-based actions record: (a state miss is 409, as for plain access).
  • A when-less arm anywhere but last is a compile error (it would make the arms after it unreachable), and when outside an arms entry is rejected rather than ignored.

Ordering. The route runs: auth gate → input validation → the union of all arms as the ordinary access check (pre-record role gate; on record actions the record fetch and post-record check) → arm selection on the validated input → evaluation of the selected arm → refs → execute. The union check keeps the ID-probe protection of plain access; the selected arm is the decision. On a bulk variant the batch shares one input, so one arm is selected for the batch and every record must satisfy it.

Relationship arms (a role defined via: a relationship, like siteStaff above) are decided from the lanes the namespace gate proved on this request (ctx.lanes), never by a fresh query. They are legal only on a standalone action whose namespace lists that relationship in via: and declares lanes: 'all'; anywhere else (record-based actions included) the compiler refuses the arm. FGA arms are refused in arms.

Asking a named role in the body (ctx.is)

When the body must branch on a role — show staff everything, show others their own rows — ask the declared rule instead of re-deriving it:

async execute({ ctx, db }) {
  if (ctx.is("siteStaff")) { /* … */ }
}

ctx.is(name) is generated from authz.roles: name is typed as the union of declared names (a typo is a tsc error), and each name is expanded exactly as it is in access.roles, so the body and the gates cannot disagree. It evaluates against the request's current context — a sysadmin passes, pseudo-roles and scope:<kind>:<role> work, and a via: arm is true only when a namespace gate with lanes: 'all' recorded that lane on this request. ctx.is never queries; outside such a namespace a via: arm is false.

Function Access

For complex logic, use an access function:

access: async (ctx, record) => {
  return ctx.roles.includes('admin') || record.ownerId === ctx.userId;
}

Relationship-roles in actions

Both standalone and record-based actions support relationship roles directly in access.roles. Declare the relationship under authz.relationships, name it under authz.roles, and use that name like any organization or pseudo-role.

For a standalone action, optional loads / exposeAs fields can hydrate the matched resource onto ctx:

// quickback.config.ts
authz: {
  relationships: {
    attendeeOf: {
      from: 'guests',
      subject: { column: 'userId', equals: 'ctx.userId' },
      resource: { column: 'eventId' },
      where: { status: 'confirmed' },
      loads: 'events',
      exposeAs: 'event',
    },
  },
  roles: { attendee: { via: 'attendeeOf' } },
}

// features/chat/actions/getInbox.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";

export default defineAction({
  description: "List the caller's chat inbox for one event they attend.",
  path: '/chat/get-inbox',
  input: z.object({ eventId: z.number().int().positive() }),
  access: { roles: ['admin', 'member', 'attendee'] },
  execute: async ({ db, ctx, input }) => {
    // The resolver already verified attendance and loaded ctx.event.
    // No hand-rolled `SELECT … FROM guests WHERE …` needed.
  },
});

The standalone action's surface (path params + input fields) must expose the relationship's resource.column. A missing key is a compile error, so the resolver can never be skipped silently. See Access — standalone actions for the full mechanism.

For a record-based action, Quickback already has the firewalled record in scope. It emits an inline relationship match against that record before execute() runs:

// features/sessions/actions/comment.ts
import { z } from "zod";
import { defineAction } from "../.quickback/define-action";
import { comments } from "../comments";

export default defineAction({
  description: "Add a comment to a session the caller is a guest of.",
  input: z.object({ text: z.string().max(2000) }),
  access: { roles: ['guest', 'admin'] },
  async execute({ db, record, input }) {
    // The route loaded `record` through its firewall, then verified guestOf
    // against record.eventId. Only domain logic remains here.
    return db.insert(comments).values({
      sessionId: record.id,
      text: input.text,
    });
  },
});

Simple role arms are checked before the record lookup to preserve ID-probe resistance. Relationship arms run only after the record has been loaded through the firewall, because the record supplies the relationship key. No manual lookup or firewall exception is required.

See Access — relationship authorization for the full surface matrix, namespace hoisting, and tenant-context stamping.

On this page