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

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