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:
| Operator | Description |
|---|---|
equals | Field must equal value |
notEquals | Field must not equal value |
in | Field must be one of the values |
notIn | Field 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
whenholds 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 awhenand none holds) is 403ACCESS_NO_ARM. whenreads validated input only. Keys are'input.<field>'naming a field of the action's inlinez.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 actionsrecord:(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), andwhenoutside anarmsentry 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.