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

// features/candidates/candidates.ts
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { defineTable } from '@quickback/compiler';

export const candidates = sqliteTable('candidates', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email').notNull(),
  organizationId: text('organization_id').notNull(),
});

export default defineTable(candidates, {
  firewall: { organization: {} },  // Isolate by organization
  // ... guards, crud
});

Configuration Options

firewall: {
  // User-level ownership (personal data)
  owner?: {
    column?: string;              // Default: 'ownerId' or 'owner_id'
    source?: string;              // Default: 'ctx.userId'
    mode?: 'required' | 'optional';  // Default: 'required'
  };

  // Organization-level ownership
  organization?: {
    column?: string;              // Default: 'organizationId' or 'organization_id'
    source?: string;              // Default: 'ctx.activeOrgId'
  };

  // Team-level ownership
  team?: {
    column?: string;              // Default: 'teamId' or 'team_id'
    source?: string;              // Default: 'ctx.activeTeamId'
  };

  // Soft delete filtering
  softDelete?: {
    column?: string;  // Default: 'deletedAt'
  };

  // Error reporting mode for firewall violations
  // 'reveal' (default): 403 with structured error message
  // 'hide': Generic 404 (prevents record enumeration)
  errorMode?: 'reveal' | 'hide';

  // Opt-out for public tables (cannot combine with ownership)
  exception?: boolean;
}

Context Sources

The firewall pulls ownership values from the request context:

ScopeDefault SourceDescription
ownerctx.userIdCurrent authenticated user's ID
organizationctx.activeOrgIdUser's active organization ID
teamctx.activeTeamIdUser's active team ID

These context values are populated by the auth middleware from the user's session.


## Auto-Detection

Quickback automatically detects firewall scope based on your column names:

| Column Name | Detected Scope |
|-------------|----------------|
| `organizationId` or `organization_id` | `organization` |
| `ownerId` or `owner_id` | `owner` |
| `teamId` or `team_id` | `team` |
| `deletedAt` or `deleted_at` | `softDelete` |

This means you often don't need to specify column names:

```typescript
// Quickback detects organizationId column automatically
firewall: { organization: {} }

// Quickback detects ownerId column automatically
firewall: { owner: {} }

What exception: true Actually Does

exception: true disables tenant scoping only — the generated buildFirewallConditions() emits no owner, organization, or team WHERE clauses. It does not affect:

  • Audit field injection. createdAt, modifiedAt, createdBy, modifiedBy, deletedAt, deletedBy are always added to every table unless you disable them globally with compiler.features.auditFields: false.
  • Soft-delete filtering. If the table has a deletedAt column (which it does by default, thanks to audit injection), the firewall still emits isNull(<table>.deletedAt) so soft-deleted records stay hidden. To opt out of soft-delete filtering on an exception resource, either set compiler.features.auditFields: false globally or hand-author a schema without deletedAt.

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

Common Patterns

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

// Organization-scoped data (auto-detected from organizationId column)
firewall: { organization: {} }

// Personal user data (auto-detected from ownerId column)
firewall: { owner: {} }

// Org data with optional owner filtering
firewall: {
  organization: {},
  owner: { mode: 'optional' }
}

// With soft delete (auto-detected from deletedAt column)
firewall: {
  organization: {},
  softDelete: {}
}

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 errorMode:

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 errorMode: '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":

firewall: {
  organization: {},
  errorMode: 'hide',  // Opaque 404s prevent record enumeration
}

Rules

  • Every resource MUST have at least one ownership scope OR exception: true
  • Cannot mix exception: true with ownership scopes

Why Can't You Mix exception with Ownership?

They represent opposite intentions:

  • exception: true = "Don't generate tenant WHERE clauses, data is public/global"
  • Ownership scopes = "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
export default defineTable(templateLibrary, {
  firewall: { exception: true },
  // ...
});

// features/job-templates/custom-templates.ts - Org's custom job templates
export default defineTable(customTemplates, {
  firewall: { owner: {} },
  // ...
});

Option 2: Use Access Control Instead

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

// features/jobs/jobs.ts
export default defineTable(jobs, {
  firewall: {
    organization: {},  // Still scoped to org
  },
  crud: {
    list: {
      // Anyone in the org can list, but they see different things
      // based on a "visibility" field you check in your app logic
      access: { roles: ["owner", "hiring-manager", "recruiter", "interviewer"] },
    },
    get: {
      // Use record conditions to allow public jobs OR owned jobs
      access: {
        or: [
          { record: { visibility: { equals: "public" } } },
          { record: { ownerId: { equals: "$ctx.userId" } } },
          { roles: ["hiring-manager"] }
        ]
      }
    },
  },
  // ...
});

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