Views - Column Level Security
Named field projections with per-view access control. Views live under the read pipeline (read.views) and implement 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.
Migration note: views were previously declared at the resource top level (
views: {...}). In DSL v2 they live underread.viewsso the read pipeline owns its own configuration. The top-level form is rejected at compile time.
| 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'] } },
},
read: {
access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
// 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
Each named view generates a dedicated GET endpoint:
GET /api/v1/candidates # Full read - read.access fields
GET /api/v1/candidates/views/pipeline # interviewer/recruiter/etc.
GET /api/v1/candidates/views/full # hiring-manager/recruiter only
GET /api/v1/candidates/views/report # report fieldsIf read.defaultView is set, bare GET /api/v1/candidates can also resolve
to that named view. Without defaultView, bare GET /api/v1/candidates
returns VIEW_REQUIRED once views are declared.
Query 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:
read: {
access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
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'] } },
},
read: {
access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
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'] } },
},
read: {
access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
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 |
Aggregations
Views can declare pre-computed aggregations that run alongside the row
query and surface as aggregations: { ... } on the response. This avoids a
custom standalone action for every "count of X grouped by Y" dashboard
tile — the firewall and view filters already in place drive the metric.
read: {
access: { roles: ['owner', 'admin', 'member'] },
views: {
pipeline: {
fields: ['id', 'jobId', 'candidateId', 'status', 'appliedAt'],
aggregations: {
totalApplications: { fn: 'count' },
byStatus: { fn: 'groupBy', field: 'status' },
},
},
},
}Response now carries the aggregations alongside data:
{
"data": [/* paginated rows */],
"view": "pipeline",
"pagination": { "count": 50, "page": 1, "pageSize": 50, "hasMore": true },
"aggregations": {
"totalApplications": 173,
"byStatus": {
"applied": 102,
"screening": 41,
"interview": 18,
"offer": 7,
"rejected": 5
}
}
}Supported aggregation functions
fn | field | Returns | Example |
|---|---|---|---|
sum | required, numeric | scalar number | { fn: 'sum', field: 'guestCount' } |
avg | required, numeric | scalar number | { fn: 'avg', field: 'salary' } |
min | required | scalar number | { fn: 'min', field: 'price' } |
max | required | scalar number | { fn: 'max', field: 'price' } |
count | optional | COUNT(*) if omitted, COUNT(field) (non-null) if given | { fn: 'count' } |
count_distinct | required | scalar number | { fn: 'count_distinct', field: 'sourceId' } |
groupBy | required | { value: count } map | { fn: 'groupBy', field: 'status' } |
groupBy always returns a count map keyed by the distinct values of field.
NULL values bucket under the literal string "(none)". Other rollup
functions (groupBy + sum of another column) are deferred to a future
release.
Filter and firewall inheritance
Aggregations always run over the same filtered, firewalled set as the row query. Pagination is intentionally ignored — you get the value over every matching record, not just the current page.
In practice: a request like
GET /applications/views/pipeline?status=applied&jobId=job_123&limit=10
returns the first 10 applied rows for that job, AND byStatus reflects
only that filtered set (which will be { applied: <total> } since the
filter pinned the status). This is the only consistent default — divorcing
the metric from the user's filters would surprise.
Constraints (compile-time)
fieldmust reference a real column on the table.sum/avgrequire numeric column types (int,real,decimal,numeric,bigint). Non-numeric columns are rejected at compile time.- The same masking opt-out gate that governs
query.{filterable, sortable, searchable}applies to aggregations: the view's roles must satisfy the masking rule, otherwise the aggregation is rejected. (Asum(salary)leaks structure across the masked set even when the row payload is redacted.) - Aggregation names cannot collide with the reserved response fields
data,pagination,view. - No raw SQL
expr:form. Column references only — declare a generated column or write a custom action if you need arithmetic.
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",
"pagination": {
"count": 2,
"page": 1,
"pageSize": 50,
"hasMore": false
}
}When the view declares aggregations, the response also includes an
aggregations: { ... } field. See Aggregations.
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'] } },
},
read: {
access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] },
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'] },
},
},
},
create: { access: { roles: ['owner', 'hiring-manager'] } },
update: { access: { roles: ['owner', 'hiring-manager'] } },
delete: { access: { roles: ['owner', 'hiring-manager'] } },
});