Quickback Docs

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 under read.views so the read pipeline owns its own configuration. The top-level form is rejected at compile time.

Security LayerControlsQuickback Feature
Row Level SecurityWhich recordsFirewall
Column Level SecurityWhich fieldsViews

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

ConceptPurposeExample
CRUD listFull recordsReturns all columns
MaskingHide valuesEmail j***@c******.com
ViewsExclude columnsOnly 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 fields

If 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

ParameterDescriptionDefaultMax
limitNumber of records to return50100
offsetNumber of records to skip0-
# 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=10

Filtering

# 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=Smith

Sorting

ParameterDescriptionDefault
sortField to sort bycreatedAt
orderSort direction (asc or desc)desc
GET /api/v1/candidates/views/pipeline?sort=name&order=asc

Security

All four security pillars apply to views:

PillarBehavior
FirewallWHERE clause applied (same as list)
AccessPer-view access control
GuardsN/A (read-only)
MaskingApplied 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'] },
    },
  },
}
EndpointRoleemail in response?email value
/views/pipelineinterviewerNoN/A
/views/pipelinerecruiterNoN/A
/views/fullinterviewerYesj***@c******.com
/views/fullrecruiterYesjane@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

fnfieldReturnsExample
sumrequired, numericscalar number{ fn: 'sum', field: 'guestCount' }
avgrequired, numericscalar number{ fn: 'avg', field: 'salary' }
minrequiredscalar number{ fn: 'min', field: 'price' }
maxrequiredscalar number{ fn: 'max', field: 'price' }
countoptionalCOUNT(*) if omitted, COUNT(field) (non-null) if given{ fn: 'count' }
count_distinctrequiredscalar number{ fn: 'count_distinct', field: 'sourceId' }
groupByrequired{ 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)

  • field must reference a real column on the table.
  • sum/avg require 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. (A sum(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'] } },
});

On this page