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/rooms/rooms.ts
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { defineTable } from '@quickback/compiler';

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

export default defineTable(rooms, {
  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: {} }

Common Patterns

// Public/system table - no filtering
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 = "Generate NO WHERE clauses, data is public/global"
  • Ownership scopes = "Generate WHERE clauses to filter data"

Combining them would be contradictory - you can't both filter and not filter.

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/templates/template-library.ts - Public templates
export default defineTable(templateLibrary, {
  firewall: { exception: true },
  // ...
});

// features/templates/user-templates.ts - User's custom templates
export default defineTable(userTemplates, {
  firewall: { owner: {} },
  // ...
});

Option 2: Use Access Control Instead

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

// features/documents/documents.ts
export default defineTable(documents, {
  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", "admin", "member"] },
    },
    get: {
      // Use record conditions to allow public docs OR owned docs
      access: {
        or: [
          { record: { visibility: { equals: "public" } } },
          { record: { ownerId: { equals: "$ctx.userId" } } },
          { roles: ["admin"] }
        ]
      }
    },
  },
  // ...
});

Which to Choose?

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

On this page