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" } }
}
]
}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.