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 entirelyPredicate 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:
| Scope | Accepted column names | Bound to |
|---|---|---|
| Organization | organizationId, organisationId, orgId, organization, organisation, org | ctx.activeOrgId |
| Owner | ownerId | ctx.userId |
| Team | teamId | ctx.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:
| Situation | Result |
|---|---|
userId is the only candidate column (no organizationId / ownerId / teamId) | Compile error — the table has no isolation column |
userId alongside a detected isolation column | Warning — userId 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 bindsundefined(the classic 500 / "match every row" trap). In ananygroup an absent arm drops; if every arm drops, the group denies withsql\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 anallgroup (the teamless rest-of-org slice —ctx.activeTeamIdis 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_guestsrow revokes access immediately; cross-tenant relationship rows can't grant access. - Anonymous callers fail closed.
ctx.userIdis null-guarded; the subquery short-circuits toWHERE 1 = 0for 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 ananygroup 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
firewallarm referenced fromfirewall:is a compile error — a who/record-only gate would silently contribute zero row lanes while therecord: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:
| Source | Description |
|---|---|
ctx.userId | Current authenticated user's ID |
ctx.activeOrgId | User's active organization ID |
ctx.activeTeamId | User'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 found | Predicate 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 |
userId | Nothing. 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; declarefirewall: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,modifiedByare on every table (declared with...q.audit()), anddeletedAt/deletedByare on every table whose resource soft-deletes (declared with...q.softDelete()). Disable the whole mechanism globally withcompiler.features.auditFields: false. - Soft-delete filtering. If the table has a
deletedAtcolumn — which it does unless bothcrud.delete.modeandcrud.batchDelete.modeare'hard'— the firewall still emitsisNull(<table>.deletedAt)so soft-deleted records stay hidden. To opt out on an exception resource, switch the resource to hard deletes or setcompiler.features.auditFields: falseglobally.
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: trueis 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 tenantWHEREclauses, data is public/global"- Tenant predicates = "Generate tenant
WHEREclauses 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?
| Scenario | Recommendation |
|---|---|
| Truly global data (app config, public job templates) | exception: true in separate resource |
| "Public within org" but still org-isolated | Ownership scope + permissive access rules |
| Recruiter can toggle job postings public/private | Ownership scope + visibility field + access conditions |
Security Overview
Rows, columns, fields, and who — the four questions the security pillars answer, and which page owns each one.
Access - Role & Condition-Based Access Control
Define who can perform read and write operations and under what conditions. Configure role-based and condition-based access control for your API endpoints.