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
},
// 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 |
|---|---|
| Organization | organizationId, organisationId, orgId, organization, organisation, org |
| User | userId |
| Team | teamId |
ownerId is not auto-detected — it's reserved for business-logic ownership (who owns this record), not access control. A table that has only ownerId and no isolation column raises a targeted error explaining the distinction; either rename to userId if the column controls access, add an isolation column, or specify firewall: explicitly.
Predicate Shape
type FirewallPredicate =
| { field: string; equals: string } // column = ctx.* (or literal)
| { field: string; isNull: true } // column IS NULL
| { field: string; in: string[] | string }// column IN (...)
| { field: string; via: string } // column IN (relationship subquery)
| { exception: true }; // opt out of tenant scoping
type FirewallConfig = ReadonlyArray<FirewallPredicate>;
// Resource top-level (sibling to `firewall:`)
firewallErrorMode?: 'reveal' | 'hide';Predicates are AND'd together. firewallErrorMode lives at the resource top level — it's a presentation-layer choice (403 vs 404), not a query-shape one.
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:
// 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.
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 |
These values are populated by the auth middleware from the user's session. 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 / upsert from the context.
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' } |
userId | { field: 'userId', equals: 'ctx.userId' } |
teamId | { field: 'teamId', equals: 'ctx.activeTeamId' } |
deletedAt (always present via audit injection) | { field: 'deletedAt', isNull: true } — appended automatically whenever the schema carries the column, regardless of which firewall shape you wrote |
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").
- 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 field injection.
createdAt,modifiedAt,createdBy,modifiedBy,deletedAt,deletedByare always added to every table unless you disable them globally withcompiler.features.auditFields: false. - Soft-delete filtering. If the table has a
deletedAtcolumn (which it does by default, thanks to audit injection), the firewall still emitsisNull(<table>.deletedAt)so soft-deleted records stay hidden. To opt out of soft-delete filtering on an exception resource, either setcompiler.features.auditFields: falseglobally or hand-author a schema withoutdeletedAt.
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 admin. userRole === 'admin' callers still go through the firewall normally — only userRole === 'sysadmin' triggers the bypass. Admins keep their existing universal-bypass at the access layer (cross-org reads on per-record routes via ?organizationId=), but the firewall's row predicate remains in force. Sysadmin is opt-in, configured per-project, and intended for the cross-tenant CMS 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":
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
export default defineTable(templateLibrary, {
firewall: [{ exception: true }],
// ...
});
// features/job-templates/custom-templates.ts - Org's custom job templates
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
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 |
feature() — single-export sugar
One function call per feature file — declare the table and its security contract together. Equivalent to `q.table()` + `defineTable()` but half the boilerplate.
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.