Quickback Docs

Firewall - Data Isolation

Automatically isolate data by user, organization, or team with generated WHERE clauses. Prevent unauthorized data access at the database level.

The firewall generates WHERE clauses automatically to isolate data by user, organization, or team.

Basic Usage

The cleanest pattern is q.scope() — declare the tenant column once and the firewall is auto-derived:

// features/candidates/candidates.ts
import { feature, q } from '@quickback/compiler';

export default feature('candidates', {
  columns: {
    id:             q.id(),
    name:           q.text().required(),
    email:          q.text().required(),
    organizationId: q.scope('organization'),  // Auto-derives the firewall
    ...q.audit(),
    ...q.softDelete(),
  },
  // `email` is detected as sensitive and every detected column needs an
  // explicit decision — `false` records "reviewed, not sensitive here".
  masking: { email: false },
  // No firewall: block needed — auto-derived as
  //   [{ field: 'organizationId', equals: 'ctx.activeOrgId' },
  //    { field: 'deletedAt', isNull: true }]
  // ... guards, crud, read
});

If you need to declare the firewall explicitly, two forms are accepted — pick whichever reads better:

Named-scope (compact, default for simple overrides):

firewall: { organization: { column: 'tenant_id' } }
firewall: { owner: { column: 'account_user_id' } }
firewall: { exception: true }   // opt out of tenant scoping entirely

Predicate array (explicit, required for in-lists or custom non-scope predicates):

firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'deletedAt', isNull: true },
]

Both shapes compile to the same canonical predicate array internally — the named-scope object is coerced at parse time. Pick by readability.

Auto-detected column synonyms

When you omit the firewall: block entirely, the compiler scans the schema for one of these isolation columns and auto-derives the predicates:

ScopeAccepted column namesBound to
OrganizationorganizationId, organisationId, orgId, organization, organisation, orgctx.activeOrgId
OwnerownerIdctx.userId
TeamteamIdctx.activeTeamId

Organization detection accepts six spellings — American (organizationId / orgId / organization / org) and British (organisationId / organisation). The first one found in the schema source is used as the predicate field. Detection matches either the JS key (organizationId) or the SQL name (organization_id).

ownerId vs userId

ownerId is auto-detected as the owner column: the compiler firewalls reads with WHERE ownerId = ctx.userId and auto-stamps the column on insert.

userId is not auto-detected on feature tables. It is a Better Auth convention on Better Auth's own tables (session, account, member, passkey), and on a feature table it usually means a user this row references (member, contact, invitee) rather than the row's owner.

What the compiler does when it sees userId on a feature table:

SituationResult
userId is the only candidate column (no organizationId / ownerId / teamId)Compile error — the table has no isolation column
userId alongside a detected isolation columnWarninguserId is neither auto-stamped on insert nor auto-scoped on read

Two escapes, both explicit:

// 1. Rename the column to ownerId — it becomes the auto-detected owner scope.

// 2. Or declare it, and keep the name:
firewall: { owner: { column: 'userId' } }
// equivalently, in predicate-array form:
firewall: [{ field: 'userId', equals: 'ctx.userId' }]

An explicit predicate on a custom-named column is honoured on that table via its own generated buildFirewallConditions helper; it does not enter the project-global owner scope rules, so other tables that happen to share the column name are unaffected.

If userId is genuinely informational, rename it to something that says so (linkedUserId, assigneeId, inviteeId) and the warning goes away.

Predicate Shape

type FirewallPredicate =
  | { field: string; equals: string; optional?: boolean; nullable?: true } // column = ctx.* (or literal)
  | { field: string; isNull: true }                       // column IS NULL
  | { field: string; in: string[] | string; optional?: boolean } // column IN (...)
  | { field: string; via: string }                        // column IN (relationship subquery)
  | { field?: string; permission: string }                // named authz.permissions reference (v0.35+)
  | { all: FirewallPredicate[] }                          // AND group (v0.35+)
  | { any: FirewallPredicate[] }                           // OR group  (v0.35+)
  | { exception: true };                                  // opt out of tenant scoping

type FirewallConfig = ReadonlyArray<FirewallPredicate>;

// Resource top-level (sibling to `firewall:`)
firewallErrorMode?: 'reveal' | 'hide';

The top-level array is AND'd together. firewallErrorMode lives at the resource top level — it's a presentation-layer choice (403 vs 404), not a query-shape one.

Boolean groups: all / any (v0.35+)

A flat array can only express AND. To grant access through either of two independent paths — "an org member OR an event attendee" — nest an any group. The top-level array stays sugar for all, so every existing firewall is byte-identical.

firewall: {
  any: [
    { field: 'organizationId', equals: 'ctx.activeOrgId' }, // org-member grant
    { field: 'eventId',        equals: 'ctx.scope.event'  }, // attendee grant (token-verified)
  ],
}

Null-safe scope semantics make this safe (these apply inside all/any groups, opt-in via the new syntax):

  • A scope arm (ctx.activeOrgId, ctx.activeTeamId, ctx.scope.*) whose claim is absent never binds undefined (the classic 500 / "match every row" trap). In an any group an absent arm drops; if every arm drops, the group denies with sql\1 = 0`(the empty-guard) — never a full-table read. In anall` group an absent required scope denies the group.
  • Mark an arm { optional: true } to drop-instead-of-deny when absent inside an all group (the teamless rest-of-org slice — ctx.activeTeamId is implicitly optional).
  • Owner (ctx.userId) and soft-delete (isNull deletedAt) are always present / always AND'd — never relaxed.

ctx.activeTeamId is implicitly optional only while another tenant arm bounds the query. Dropping the team filter is safe when an org (or owner, via:, permission, or a binding group) arm still constrains the rows — that is what makes it "the teamless rest-of-org slice".

When team is the only tenant arm, there is no rest-of-org slice to fall back to, so an absent claim denies with sql`1 = 0` instead of dropping. Otherwise every predicate would evaluate to undefined, the firewall would return no WHERE at all, and any authenticated caller would read every tenant's rows.

This matters because ctx.activeTeamId is absent in ordinary configurations: the Better Auth teams plugin is opt-in, and the external auth provider does not supply the claim at all. Note that soft-delete and literal predicates (kind = 'public') are row filters, not tenant boundaries — they do not count as a co-existing arm.

A table with a teamId column and no organizationId gets team-only isolation from auto-detection, without any explicit firewall block.

You will usually see this as a compile error rather than a silent deny. When the teams sub-plugin is off, ctx.activeTeamId can never be populated, so a team-only firewall would deny every request — the compiler rejects that configuration up front and tells you to either enable teams (auth: { plugins: { organization: { teams: { enabled: true } } } }, which requires object-form plugin config) or give the resource a bindable tenant scope. The runtime deny remains the backstop for the case the compiler can't see, such as teams being enabled while a particular caller has not selected a team.

nullable: true is a separate database-schema opt-in, supported only on an owner predicate whose source is exactly ctx.userId. It allows the persisted owner column to be NULL; NULL rows still do not match the owner arm because SQL equality never matches NULL. It does not make the request claim optional.

// (org OR event) AND owner AND not-deleted
firewall: {
  all: [
    { any: [
      { field: 'organizationId', equals: 'ctx.activeOrgId' },
      { field: 'eventId',        equals: 'ctx.scope.event'  },
    ] },
    { field: 'ownerId', equals: 'ctx.userId' },
  ],
}

The ctx.scope.* arms are how relationship-derived scopes (attendees, shuttle drivers, vendors) reach event-scoped rows without org membership — see Scopes & capability grants.

Relationship-based scoping (via:)

For relationship authorization — "this user can see rows X if they have a confirmed event_guests row pointing at X" — declare the relationship under authz.relationships in quickback.config.ts, then reference it from firewall: with a via: predicate. These relationships (and the named roles/permissions built on them) are defined once, centrally — see Centrally defined access rules.

// quickback.config.ts
authz: {
  relationships: {
    guestOf: {
      from: 'event_guests',                                 // table name
      subject: { column: 'userId', equals: 'ctx.userId' },  // caller side
      resource: { column: 'eventId' },                      // resource side
      where: { status: 'confirmed' },                       // optional row filter
    },
  },
}

// features/sessions/sessions.ts
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'eventId', via: 'guestOf' },
]

Compiles to:

WHERE sessions.organizationId = ?
  AND sessions.eventId IN (
        SELECT eventId FROM event_guests
        WHERE userId = ?
          AND status = 'confirmed'
          AND <event_guests' own firewall — tenant scope + soft-delete>
      )

Key properties:

  • Firewall stays the outermost AND. A guest of an event in another tenant is still blocked by your tenant predicate. Relationship-based scoping never bypasses tenant isolation.
  • The relationship table's own firewall is AND'd into the subquery. Soft-deleting a event_guests row revokes access immediately; cross-tenant relationship rows can't grant access.
  • Anonymous callers fail closed. ctx.userId is null-guarded; the subquery short-circuits to WHERE 1 = 0 for unauthenticated requests.
  • The relationship table must be tenant-scoped (firewall: { exception: true } is rejected at compile time on relationship tables).

See Access — relationships for the full authz.relationships shape and role-binding patterns.

Named permissions ({ permission })

A via: arm references one relationship. When the same grant rule — "organizer or confirmed attendee" — recurs across several resources, name it once under authz.permissions and reference it from any firewall with a { permission } arm. See Permissions for the full DSL.

// quickback.config.ts
authz: {
  relationships: { organizerOf: { /* … */ }, attendeeOf: { /* … */ } },
  permissions: {
    'event:view': { anyOf: ['organizerOf', 'attendeeOf'] },
  },
}

// features/sessions/sessions.ts
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'eventId', permission: 'event:view' },
]

The compiler lowers { permission } to the same all / any relationship subqueries the expression names — anyOf becomes an OR of subqueries, allOf an AND. field is the resource-side column those subqueries constrain (the same column you'd put on a via: arm).

A permission referenced from a firewall can also resolve an arrow — an { arrowRef, permission } leaf that traverses a foreign key to inherit authority from a parent object ("org admins of the event's org can see its announcements", "anyone who administers an ancestor folder can read this one"). An arrow leaf lowers to an FK-hop subquery (non-recursive) or a bounded WITH RECURSIVE CTE (self-FK hierarchy), tenant-constrained at every row.

M1 limit — firewall arms must be SQL-pushable. A firewall runs as a WHERE clause, so a permission referenced from one may only resolve to relationship subqueries. A permission whose expression contains a claim-site leaf ({ role }, { scopeRole }, { pseudoRole }) or a not is rejected at compile time when referenced from a firewall — those shapes are valid in the DSL but reserved for future claim/runtime enforcement sites. Use a role gate on read.access / crud.*.access for membership checks.

The compile-time analyzer additionally flags a referenced permission that can never be satisfied, an arm shadowed by a broader one, and the SQL / CTE cost of each permission — see Diagnostics.

Named tenant anchors ({ tenant })

The tenant predicate { field: 'organizationId', equals: 'ctx.activeOrgId' } tends to be repeated on every table. Declare it once as a named anchor and reference it by name:

// quickback.config.ts
authz: {
  tenants: {
    org: { column: 'organizationId', equals: 'ctx.activeOrgId' },
  },
}

// any firewall — top level or inside all/any groups:
firewall: [
  { tenant: 'org' },                       // → { field: 'organizationId', equals: 'ctx.activeOrgId' }
  { field: 'deletedAt', isNull: true },
]

// per-site COLUMN override (the claim side is never overridable):
firewall: [{ field: 'orgId', tenant: 'org' }]

The anchor's equals must be a verified ctx claim: ctx.activeOrgId | ctx.userId | ctx.scope.<kind> | ctx.scope.<kind>.<subKey>. ctx.activeTeamId (drop-when-absent semantics stay a per-resource decision) and legacy aliases are rejected at declaration time — an anchor can never bind a request-derived value. A { tenant } reference carrying its own equals is a compile error (one source of truth); an unknown anchor name is a compile error listing the declared anchors.

Expansion happens at compile time, before rendering — the emitted SQL is byte-identical to writing the predicate inline. Auth-DB read views accept the same anchors as a string: tenant: 'org' — with one extra constraint: an anchor referenced from an auth-view include must bind a single-segment session claim (ctx.<claim>); scope-principal claims (ctx.scope.*) are firewall-lane bindings and are rejected there.

Named gate references ({ rule })

A gate that declares firewall lanes can be stamped onto a resource by name:

// quickback.config.ts
authz: {
  rules: {
    eventRow: {
      who: { roles: ['owner', 'admin', 'member'] },
      firewall: [{ any: [
        { tenant: 'org' },                                // org staff
        { field: 'eventId', equals: 'ctx.scope.event' },  // signed scope claim
      ] }],
    },
  },
}

// the resource:
firewall: [{ rule: 'eventRow' }]
// composes with the resource's own lanes (top-level AND):
firewall: [{ rule: 'eventRow' }, { field: 'kind', equals: 'ctx.scope.event.kind' }]

Semantics — AND-compose, top-level only, a reference can only narrow:

  • The gate's lanes splice into the resource's top-level AND array in place. They never replace the resource's own lanes, and composition can only narrow the row set.
  • { rule } is not valid inside an { all } / { any } group — a gate's firewall is one-or-more AND lanes, and splicing a multi-lane gate into an any group would OR-flatten it, admitting rows in other tenants that merely match one lane. This is a type error at authoring time and a compile error at coerce time.
  • A gate's firewall may not contain { exception: true } (a named bundle can never smuggle a firewall opt-out) or another { rule } (gates don't nest — expansion is single-step, so cycles are impossible by construction).
  • A gate with no firewall arm referenced from firewall: is a compile error — a who/record-only gate would silently contribute zero row lanes while the record: filter only ever applies at access sites.

The splice expands before validation and rendering, so the emitted buildFirewallConditions helper is byte-identical to writing the lanes inline — same groups, same absent-claim 1 = 0 guards, same via: subqueries.

Context Sources

When equals references the request context, use these standard sources:

SourceDescription
ctx.userIdCurrent authenticated user's ID
ctx.activeOrgIdUser's active organization ID
ctx.activeTeamIdUser's active team ID
ctx.scope.<kind> / ctx.scope.<kind>.<subKey>A verified scope-principal claim (see Scopes)
ctx.<claim>.<subKey>A feature-area context anchor — a row the area/relationship middleware loaded from a proven route param and exposed on ctx (e.g. ctx.event.id, ctx.event.organizationId). Present only on the area-claimed route; renders null-safe (deny-when-absent) inside all/any groups

These values are populated by the auth middleware (or, for scope/area anchors, the area middleware) from the request. Any predicate where equals starts with ctx. is treated as server-derived: the column lands in GUARDS_CONFIG.systemManaged (clients cannot submit it) and is auto-populated on create / changeset-root insert / upsert from that context source — a raw-Drizzle selected-event fence like { field: 'eventId', equals: 'ctx.event.id' } stamps eventId from ctx.event.id the same way q.scope('organization') stamps organizationId.

Only conjunctive arms auto-populate. A ctx.*-bound column is auto-populated only when its arm is conjunctive — reachable through the top-level array or a nested all: group, so it holds for every admitted row. A column bound only through an any: branch is disjunctive and lane-dependent (e.g. an attendee's own eventPersonId, which must come from a proven scope claim, not the org lane), so the compiler does not guess a create value for it. Give such a column its own population source — declare it with q.scope() / q.stamp() (a proven-identity column force-stamps from the scope claim), let it inherit through a changeset inherit/fk, or write it in an authored action. A NOT NULL disjunctive-only column with none of these has no create path and will hit the database NOT NULL constraint on insert.

Auto-Derivation

When the firewall: block is omitted, the compiler scans the schema for known scope columns and synthesises the predicate array:

Column foundPredicate emitted
organizationId / organisationId / orgId / organization / organisation / org{ field: '...', equals: 'ctx.activeOrgId' }
ownerId{ field: 'ownerId', equals: 'ctx.userId' }
teamId{ field: 'teamId', equals: 'ctx.activeTeamId' }
deletedAt (present whenever the resource soft-deletes){ field: 'deletedAt', isNull: true } — appended automatically whenever the table carries the column, regardless of which firewall shape you wrote
userIdNothing. Not an auto-detected owner column — see ownerId vs userId

q.scope(kind) is the canonical way to declare the column — auto-derivation works the same way for plain q.text().required() or Drizzle text(...).notNull() columns named to match the convention.

Rules:

  • Exactly one isolation column → auto-derived.
  • Zero columns AND no PUBLIC route → compile-time error ("missing isolation column"). If the only candidate is userId, the error says so specifically.
  • Two or more (e.g. organizationId + ownerId) → compile-time error; declare firewall: explicitly.

What { exception: true } Actually Does

{ exception: true } disables tenant scoping only — the generated buildFirewallConditions() emits no tenant WHERE clauses. It does not affect:

  • Audit columns. createdAt, modifiedAt, createdBy, modifiedBy are on every table (declared with ...q.audit()), and deletedAt / deletedBy are on every table whose resource soft-deletes (declared with ...q.softDelete()). Disable the whole mechanism globally with compiler.features.auditFields: false.
  • Soft-delete filtering. If the table has a deletedAt column — which it does unless both crud.delete.mode and crud.batchDelete.mode are 'hard' — the firewall still emits isNull(<table>.deletedAt) so soft-deleted records stay hidden. To opt out on an exception resource, switch the resource to hard deletes or set compiler.features.auditFields: false globally.

So firewall: [{ exception: true }] on the default configuration returns isNull(<table>.deletedAt) from buildFirewallConditions() — no tenant filter, soft-delete filter still applied.

Sysadmin Escape Hatch

When cms: { sysadmin: true } is set on the project (introduced in v0.15.0), every buildFirewallConditions() emits a runtime branch that drops tenant scopes for ctx.userRole === 'sysadmin' callers while keeping the soft-delete predicate:

// Generated for an org-scoped table when cms.sysadmin: true
export function buildFirewallConditions(ctx: AppContext): SQL | undefined {
  if (ctx.userRole === 'sysadmin') {
    return isNull(invoices.deletedAt);  // tenant predicate dropped, soft-delete kept
  }
  return and(
    eq(invoices.organizationId, ctx.activeOrgId!),
    isNull(invoices.deletedAt),
  );
}

The escape branch is only emitted when:

  • cms.sysadmin: true is set on the project config
  • The table has at least one tenant-scope predicate (org / owner / team / via: relationship). Tables with no tenant scopes already return what a sysadmin would see, so the runtime check would be dead code.
  • The firewall does not declare { exception: true } (no tenant scopes to drop).

Sysadmin is distinct from appmanager. userRole === 'appmanager' callers still go through the firewall normally — only userRole === 'sysadmin' triggers the bypass. Appmanager is the platform control-plane tier, expressed with plain userRole checks; sysadmin is the opt-in cross-tenant DB-admin tier. See CMS Configuration.

If your project doesn't set cms.sysadmin: true, the escape branch is never emitted and the role value is functionally inert.

Common Patterns

// Public/system table - no tenant filtering (soft-delete still applied)
firewall: [{ exception: true }]

// Organization-scoped (auto-derived when organizationId is present — block can be omitted)
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'deletedAt', isNull: true },
]

// User-owned personal data
firewall: [
  { field: 'ownerId', equals: 'ctx.userId' },
  { field: 'deletedAt', isNull: true },
]

// Multi-tenant + per-team scoping
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'teamId', equals: 'ctx.activeTeamId' },
  { field: 'deletedAt', isNull: true },
]

// Custom ctx field
firewall: [
  { field: 'workspaceId', equals: 'ctx.activeWorkspaceId' },
]

// Status-gated (literal value, not ctx-derived — does NOT add to systemManaged)
firewall: [
  { field: 'organizationId', equals: 'ctx.activeOrgId' },
  { field: 'status', in: ['active', 'pending'] },
]

Error Responses

When a record isn't found behind the firewall (wrong org, soft-deleted, or genuinely doesn't exist), the error response depends on firewallErrorMode:

Reveal Mode (Default)

Returns 403 with a structured error:

{
  "error": "Record not found or not accessible",
  "layer": "firewall",
  "code": "FIREWALL_NOT_FOUND",
  "hint": "Check the record ID and your organization membership"
}

This is developer-friendly and helps debug access issues during development.

Hide Mode

Returns 404 with a generic error:

{
  "error": "Not found",
  "code": "NOT_FOUND"
}

Use firewallErrorMode: 'hide' for security-hardened deployments where you don't want attackers to distinguish between "record exists but you can't access it" and "record doesn't exist":

// features/records/records.ts
import { q, defineTable } from '@quickback/compiler';

export const records = q.table('records', {
  id:             q.id(),
  title:          q.text().required(),
  organizationId: q.scope('organization'),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(records, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  firewallErrorMode: 'hide',  // Opaque 404s prevent record enumeration
});

Foreign-Key Referential Checks

Generated POST / and POST /batch handlers validate every foreign-key column against the target table's firewall before inserting. If a record references a row that the caller can't see (wrong org, soft-deleted, or non-existent), the request fails with:

{
  "error": "Referenced jobs row not found",
  "code": "FK_NOT_FOUND",
  "layer": "validation",
  "field": "jobId"
}

This closes a cross-tenant injection vector: even if an attacker knows a foreign tenant's row ID, they cannot attach a child record to it, and they cannot trigger a cross-tenant cascade delete. The check uses the target table's buildXFirewallConditions(ctx) helper, so it honors whatever firewall predicates the target declared (organization, owner, team, custom). FKs to tables with { exception: true } get a plain existence check (still verifies the row exists, doesn't tenant-scope it).

The check runs after guards but before the insert, so it appears as layer: "validation" in error responses. Status code is 400, not 404 — returning 404 would leak existence of out-of-scope rows.

Pre-Record ID-Probe Defense

Independent of firewallErrorMode, generated /:id handlers run a role-only access check before the firewall query (see Access ordering). A caller without the right role gets 403 before the database is touched, so they can't distinguish "row exists out-of-scope" from "row doesn't exist" via response timing or status differences.

firewallErrorMode controls what happens after the role check passes but the firewall hides the row — i.e., when an authorized caller asks for a record in another tenant. Use 'hide' to make that case indistinguishable from a non-existent ID.

Rules

  • Every resource MUST have at least one tenant predicate OR { exception: true } OR a PUBLIC route
  • { exception: true } cannot be combined with tenant predicates in the same array

Why Can't You Mix { exception: true } with Tenant Predicates?

They represent opposite intentions:

  • { exception: true } = "Don't generate tenant WHERE clauses, data is public/global"
  • Tenant predicates = "Generate tenant WHERE clauses to isolate data"

Combining them would be contradictory — you can't both isolate and not isolate. (Note: soft-delete filtering is independent of this decision, see above.)

Handling "Some Public, Some Private" Data

If you need records that are sometimes public and sometimes scoped, you have two options:

Option 1: Two Separate Tables

Split into two tables - one public, one scoped:

// features/job-templates/template-library.ts - Public job templates
import { q, defineTable } from '@quickback/compiler';

export const templateLibrary = q.table('templateLibrary', {
  id:    q.id(),
  title: q.text().required(),
  body:  q.text().required(),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(templateLibrary, {
  firewall: [{ exception: true }],
  // ...
});

// features/job-templates/custom-templates.ts - Org's custom job templates
import { q, defineTable } from '@quickback/compiler';

export const customTemplates = q.table('customTemplates', {
  id:      q.id(),
  title:   q.text().required(),
  body:    q.text().required(),
  ownerId: q.text().required(),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(customTemplates, {
  firewall: [{ field: 'ownerId', equals: 'ctx.userId' }],
  // ...
});

Option 2: Use Access Control Instead

Keep tenant scope but make access permissive, then control visibility via access:

// features/jobs/jobs.ts
import { q, defineTable } from '@quickback/compiler';

export const jobs = q.table('jobs', {
  id:             q.id(),
  title:          q.text().required(),
  isPublic:       q.bool().default(false).required(),
  organizationId: q.scope('organization'),
  ...q.audit(),
  ...q.softDelete(),
});

export default defineTable(jobs, {
  firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
  read: {
    // Anyone in the org can list — visibility differences are handled
    // via record-level access conditions on /:id
    access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
  },
  // Use record conditions to allow public jobs OR owned jobs on GET /:id
  // (single-record GET inherits read.access, with optional record-level overrides)
  // ...
});

Which to Choose?

ScenarioRecommendation
Truly global data (app config, public job templates)exception: true in separate resource
"Public within org" but still org-isolatedOwnership scope + permissive access rules
Recruiter can toggle job postings public/privateOwnership scope + visibility field + access conditions

On this page