Quickback Docs

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 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'] } },
  },

  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 fields

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:

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'] },
  },
}
EndpointRoleemail in response?email value
/views/pipelineinterviewerNoN/A
/views/pipelinerecruiterNoN/A
/views/fullinterviewerYesj***@c******.com
/views/fullrecruiterYesjane@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'] },
    },
  },
});

On this page