# Action Access

## Access Configuration

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

### Role-Based Access

```typescript
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](/define/authz-roles#centrally-defined-access-rules).

[Named gates](/define/authz-roles#named-gates-authzrules) (`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.

```typescript
// 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:

```typescript
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:

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

### OR/AND Combinations

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

### Function Access

For complex logic, use an access function:

```typescript
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`:

```ts
// 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](/define/relationships#standalone-actions-surface-keys-and-loads--exposeas-v019)
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:

```typescript
// 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](/define/relationships)
for the full surface matrix, namespace hoisting, and tenant-context stamping.

