Views - Column Level Security
Views implement Column Level Security (CLS) - controlling which columns users can access. This complements Row Level Security (RLS) provided by the Firewall, which controls which rows users can access.
| Security Layer | Controls | Quickback Feature |
|---|---|---|
| Row Level Security | Which records | Firewall |
| Column Level Security | Which fields | Views |
Views provide named field projections with role-based access control. Use views to return different sets of columns to different users without duplicating CRUD endpoints.
When to Use Views
| Concept | Purpose | Example |
|---|---|---|
| CRUD list | Full records | Returns all columns |
| Masking | Hide values | Email j***@c******.com |
| Views | Exclude columns | Only id, name, source |
- CRUD list returns all fields to authorized users
- Masking transforms sensitive values but still includes the column
- Views completely exclude columns from the response
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(),
phone: text('phone'),
source: text('source'),
resumeUrl: text('resume_url'),
internalNotes: text('internal_notes'),
organizationId: text('organization_id').notNull(),
});
export default defineTable(candidates, {
masking: {
email: { type: 'email', show: { roles: ['hiring-manager', 'recruiter'] } },
phone: { type: 'phone', show: { roles: ['hiring-manager', 'recruiter'] } },
},
crud: {
list: { access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] } },
get: { access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] } },
},
// Views: Named field projections
views: {
pipeline: {
fields: ['id', 'name', 'source'],
access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
},
full: {
fields: ['id', 'name', 'email', 'phone', 'source', 'resumeUrl', 'internalNotes'],
access: { roles: ['hiring-manager', 'recruiter'] },
},
report: {
fields: ['id', 'name', 'source', 'createdAt'],
access: { roles: ['owner', 'hiring-manager'] },
},
},
});Generated Endpoints
Views generate GET endpoints at /{resource}/views/{viewName}:
GET /api/v1/candidates # CRUD list - all fields
GET /api/v1/candidates/views/pipeline # View - only pipeline fields
GET /api/v1/candidates/views/full # View - all fields (hiring-manager/recruiter only)
GET /api/v1/candidates/views/report # View - report fieldsQuery Parameters
Views support the same query parameters as the list endpoint:
Pagination
| Parameter | Description | Default | Max |
|---|---|---|---|
limit | Number of records to return | 50 | 100 |
offset | Number of records to skip | 0 | - |
# Get first 10 records
GET /api/v1/candidates/views/pipeline?limit=10
# Get records 11-20
GET /api/v1/candidates/views/pipeline?limit=10&offset=10Filtering
# Filter by exact value
GET /api/v1/candidates/views/pipeline?source=linkedin
# Filter with operators
GET /api/v1/candidates/views/pipeline?createdAt.gt=2024-01-01
# Multiple filters (AND logic)
GET /api/v1/candidates/views/pipeline?source=linkedin&name.like=SmithSorting
| Parameter | Description | Default |
|---|---|---|
sort | Field to sort by | createdAt |
order | Sort direction (asc or desc) | desc |
GET /api/v1/candidates/views/pipeline?sort=name&order=ascSecurity
All four security pillars apply to views:
| Pillar | Behavior |
|---|---|
| Firewall | WHERE clause applied (same as list) |
| Access | Per-view access control |
| Guards | N/A (read-only) |
| Masking | Applied to returned fields |
Firewall
Views automatically apply the same firewall conditions as the list endpoint. Users only see records within their organization scope.
Access Control
Each view has its own access configuration:
views: {
// Pipeline view - available to all roles
pipeline: {
fields: ['id', 'name', 'source'],
access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
},
// Full view - recruiter and above
full: {
fields: ['id', 'name', 'source', 'email', 'phone', 'internalNotes'],
access: { roles: ['hiring-manager', 'recruiter'] },
},
}Masking
Masking rules are applied to the returned fields. If a view includes a masked field like email, the masking rules still apply:
masking: {
email: { type: 'email', show: { roles: ['hiring-manager', 'recruiter'] } },
},
views: {
// Even if an interviewer accesses the 'full' view, email will be masked
// because masking rules take precedence
full: {
fields: ['id', 'name', 'email'],
access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
},
}How Views and Masking Work Together
Views and masking are orthogonal concerns:
- Views control field selection (which columns appear)
- Masking controls field transformation (how values appear based on role)
Example configuration:
masking: {
email: { type: 'email', show: { roles: ['hiring-manager', 'recruiter'] } },
},
views: {
pipeline: {
fields: ['id', 'name'], // email NOT included
access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
},
full: {
fields: ['id', 'name', 'email'], // email included
access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
},
}| Endpoint | Role | email in response? | email value |
|---|---|---|---|
/views/pipeline | interviewer | No | N/A |
/views/pipeline | recruiter | No | N/A |
/views/full | interviewer | Yes | j***@c******.com |
/views/full | recruiter | Yes | jane@company.com |
Response Format
View responses include metadata about the view:
{
"data": [
{ "id": "cnd_123", "name": "Jane Doe", "source": "linkedin" },
{ "id": "cnd_456", "name": "John Smith", "source": "referral" }
],
"view": "pipeline",
"fields": ["id", "name", "source"],
"pagination": {
"limit": 50,
"offset": 0,
"count": 2
}
}Complete Example
// features/jobs/jobs.ts
export default defineTable(jobs, {
guards: {
createable: ['title', 'department', 'status', 'salaryMin', 'salaryMax'],
updatable: ['title', 'department', 'status'],
},
masking: {
salaryMin: { type: 'redact', show: { roles: ['owner', 'hiring-manager'] } },
salaryMax: { type: 'redact', show: { roles: ['owner', 'hiring-manager'] } },
},
crud: {
list: { access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] } },
get: { access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] } },
create: { access: { roles: ['owner', 'hiring-manager'] } },
update: { access: { roles: ['owner', 'hiring-manager'] } },
delete: { access: { roles: ['owner', 'hiring-manager'] } },
},
views: {
// Public job board view
board: {
fields: ['id', 'title', 'department', 'status'],
access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
},
// Internal view with salary info
internal: {
fields: ['id', 'title', 'department', 'status', 'salaryMin', 'salaryMax'],
access: { roles: ['owner', 'hiring-manager'] },
},
// Compensation report
compensation: {
fields: ['id', 'title', 'department', 'salaryMin', 'salaryMax'],
access: { roles: ['owner', 'hiring-manager'] },
},
},
});