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:
| Scope | Default Source | Description |
|---|---|---|
owner | ctx.userId | Current authenticated user's ID |
organization | ctx.activeOrgId | User's active organization ID |
team | ctx.activeTeamId | User'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: truewith 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?
| Scenario | Recommendation |
|---|---|
| Truly global data (app config, public templates) | exception: true in separate resource |
| "Public within org" but still org-isolated | Ownership scope + permissive access rules |
| User can toggle their own data public/private | Ownership scope + visibility field + access conditions |
Database Schema
Define your database schema using Drizzle ORM with defineTable. Combine schema definition and security configuration in a single TypeScript file.
Access - Role & Condition-Based Access Control
Define who can perform CRUD operations and under what conditions. Configure role-based and condition-based access control for your API endpoints.