Quickback Docs

Masking - Field Redaction

Hide sensitive data from unauthorized users with field masking. The compiler detects sensitive column names and makes you decide, per column, whether to mask them.

Hide sensitive data from unauthorized users while showing it to those with permission.

Detection (the compiler asks, you decide)

Quickback scans every table for columns whose names match its sensitive-column patterns — email, ssn, apiKey, stripeSecret, refreshToken, and the rest of the table below.

It does not decide what to do about them. It refuses to compile until you do:

customers: 2 columns match Quickback's sensitive-column patterns but carry no masking decision.

Quickback will not guess whether a value is confidential — you decide, per column:
  - email   { type: 'email', show: { or: 'owner', roles: ['admin'] } }
  - apiKey  { type: 'redact', show: { or: 'owner', roles: ['admin'] } }

Add a `masking` block to "customers". Keep the suggested rule to mask the
column, or set `false` to record "reviewed — not sensitive here" (the value is then
returned in full and stays filterable / sortable / searchable):

  masking: {
    email: { type: 'email', show: { or: 'owner', roles: ['admin'] } },  // mask it
    // email: false,  // …or this instead: reviewed, not sensitive here
    apiKey: { type: 'redact', show: { or: 'owner', roles: ['admin'] } },  // mask it
    // apiKey: false,  // …or this instead: reviewed, not sensitive here
  },

Every detected column needs one or the other — an omitted key is not a decision.

Paste the block, delete the wrong line from each pair, and you're done:

masking: {
  // Yes — mask it. Redacted in responses, and only `admin` may filter/sort/search it.
  apiKey: { type: 'redact', show: { roles: ['admin'] } },

  // No — reviewed, not sensitive here. Returned in full, freely queryable.
  email: false,
}

Why a hard error and not a default

Whether customers.email is PII to hide or the display label of a contact list is a product decision, and the compiler has no way to know which. Both possible defaults are wrong somewhere:

  • Auto-mask silently and you ship a broken list view, or you think a value is protected because a warning said so.
  • Ignore it silently and a stripeSecret column leaks in every response.

So the compiler asks. A false positive costs you one line (cc: false) — which is also a durable, reviewable record that somebody looked.

It also removes a bug class outright. When detection can never quietly supply a rule, the set of masked columns is the set you declared, so no two surfaces can disagree about it. That exact disagreement was a real defect: through v0.58.3 the response path applied declared-only masking while realtime and live views applied detected-plus-declared, so an auto-detected secret was redacted over WebSocket and returned in plaintext on GET.

What a decision controls

Surface{ type, show } rulefalse
GET /:id, list, named views, POST, PATCH, PUT, batchMasked per roleReturned in full
Realtime frames + live-view deltasMasked per-subscriber-roleSent in full
?filter= / ?sort=Only roles in the query gateUnrestricted
?search=Dropped from the default searchable setSearchable
FK display label / embeddings / action egress scanBlocked — see Egress protectionAllowed

Sensitive Keywords & Default Masks

PatternDefault MaskDescription
emailemailp***@e******.com
phone, mobile, faxphone******4567
ssn, socialsecurity, nationalidssn*****6789
creditcard, cc, cardnumber, cvvcreditCard************1234
ibanredact[REDACTED]
password, secret, token, apikey, privatekeyredact[REDACTED]
accesstoken, refreshtoken, clientsecret, signingsecret, bearerredact[REDACTED]
stripe, webhookredact[REDACTED]

Smart Pattern Matching

Detection also matches suffixes like workEmail, homePhone, apiSecret, stripeApiKey, webhookSecret, customerStripe, or orderWebhook. Every match needs a decision — including the aggressive ones. cc: false on an accounting table's carbon-copy column is a one-line answer.

The rule shown in the error is a suggestion, not a default that applies on its own — nothing is masked until you write it down.

Tables without an owner column

The suggested predicate is show: { or: 'owner', roles: ['admin'] } — admins always see the value, the row's owner sees their own. If you keep it on a table with no owner column at all (no userId / ownerId and no q.scope('owner')), the compiler drops the owner clause and warns, so the rule is still safe but transparent:

[Warning] Auto-masking on "people.email" requested owner OR-show, but "people"
  has no "ownerId" column. Falling back to roles-only (roles: ["admin"]).
  Declare `masking: { email: { show: { roles: [...] } } }` explicitly to
  silence this and pick a real predicate.

Drop the or: 'owner' clause and pick a real predicate on an ownerless table.

Choosing the rule

If you want to show a sensitive field to everyone, or use a different rule, that's just what you write in the resource.masking block:

export default defineTable(candidates, {
  masking: {
    // Masked, but visible to everyone (a rule with a wide-open audience)
    email: { type: 'email', show: { roles: ['everyone'] } },

    // Recruiter and above only
    phone: { type: 'phone', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },

    // Not masked at all, and not query-restricted either
    linkedinUrl: false,
  }
});

Your rule replaces the suggestion outright — the suggested rule is never merged into yours. That also means the rule is where you set the query gate: it defaults to show.roles, so a list UI that sorts by email for every signed-in member needs the roles spelled out.

masking: {
  email: {
    type: 'email',
    show: { roles: ['admin'] },       // still redacted in the response body…
    query: { roles: ['member', 'admin'] }, // …but members may sort/filter by it
  },
}

Basic Usage

// features/candidates/candidates.ts
import { feature, q } from '@quickback/compiler';

export default feature('candidates', {
  columns: {
    id:             q.id(),
    name:           q.text().required(),
    email:          q.text().required(),
    phone:          q.text().optional(),
    resumeUrl:      q.text().optional(),
    organizationId: q.scope('organization'),
    ...q.audit(),
    ...q.softDelete(),
  },
  guards: { createable: ["name", "email", "phone", "resumeUrl"], updatable: ["name", "phone"] },
  masking: {
    email: { type: 'email', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },
    phone: { type: 'phone', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } },
  },
  read: { access: { roles: ['owner', 'hiring-manager', 'recruiter', 'interviewer'] } },
  create: { /* ... */ },
  update: { /* ... */ },
  delete: { /* ... */ },
});

Built-in Mask Types

TypeExample InputMasked Output
'email'john@yourdomain.comj***@y*********.com
'phone'555-123-4567******4567
'ssn'123-45-6789*****6789
'creditCard'4111111111111111************1111
'name'John SmithJ*** S****
'redact'anything[REDACTED]
'custom'(your logic)(your output)

Configuration

masking: {
  // Basic masking - everyone sees masked value
  phone: { type: 'phone' },

  // Show unmasked to specific roles
  email: {
    type: 'email',
    show: { roles: ['hiring-manager', 'recruiter'] }
  },

  // Show unmasked to owner (createdBy === ctx.userId)
  resumeUrl: {
    type: 'redact',
    show: { or: 'owner' }
  },

  // Custom mask function
  externalProfileUrl: {
    type: 'custom',
    mask: (value) => value.slice(0, 4) + '...' + value.slice(-4),
    show: { roles: ['recruiter'] }
  },
}

Show Conditions

show: {
  roles?: string[];    // Unmasked if user has any of these roles
  or?: 'owner';        // Unmasked if user is the record owner
}

The 'owner' condition compares against the owner column declared via q.scope('owner') (or auto-detected from a userId / ownerId column). If no owner scope is present, it falls back to createdBy.

Relationship-aware field projection

For relationship-specific field visibility, use a named view gated by the relationship role. The relationship resolver runs once at the access layer and the view projects only the fields that caller may receive.

Quickback deliberately does not run per-row database lookups from masking.<field>.show: doing so would turn list masking into an N+1 query pipeline. A relationship-backed show.roles entry is therefore a clear compile-time refusal, never a silently ignored condition or a broader grant.

Named gates in masking

Named gates (authz.rules) follow the same discipline in show::

  • A who-only gate expands like a named role (its flat role list; the owner-gated flat-role constraint above applies).
  • A gate carrying a record: predicate is a compile error — masking cannot evaluate record conditions, so the record dimension would be silently dropped.
  • A gate carrying firewall lanes is a compile error — masking has no firewall array to satisfy, so the gate's row lanes would silently not apply. Reference a who-only rule (or inline the roles) in show:, and stamp the firewall gate on the resource's firewall: instead.

Supported patterns, depending on intent:

  • Gate on BA roles only: keep masking simple and put the relationship check at the access layer instead — read.views.<v>.access: { roles: ['guest'] } returns a curated field projection that excludes the sensitive column entirely for non-staff callers, without involving masking.
  • Move the field into a view that's gated by the relationship-role at access: same pattern — column-level security via field projection rather than per-row masking.

The schema slot remains reserved for a future batched async-mask pipeline, so adding that execution mode will not require changing the surrounding masking shape.

Masking has a second concern beyond hiding values in the response: a non-admin caller could enumerate masked PII via the query string — e.g. ?filter=email:like:%@target.com — and infer presence even when the response payload is redacted. The query allowlist closes this:

masking: {
  email: {
    type: 'email',
    show: { roles: ['admin'] },
    // Optional — defaults to show.roles when omitted
    query: { roles: ['admin'] },
  },
}

query is a single role gate that covers ?filter, ?sort, and ?search uniformly. When omitted, it inherits from show.roles — "if you can see it, you can query it" — which is the right shape for almost all PII.

A view can opt back in for a higher-privileged role pair by listing the column in its own query.{filterable,searchable,sortable} allowlist. The compiler validates at build time that the view's access roles intersect the masking query.roles (or show.roles when query is omitted), so the opt-in can never reach a caller who'd see the value masked.

A column you answered with false is not gated at all — it stays freely filterable, sortable and searchable, which is the point of saying "not sensitive here".

The build-time check sees every masked column

Because the decision gate guarantees every detected column is in your masking block, this view check covers all of them. There is no category of "masked but invisible to the validator" — that gap existed only while detection could silently supply rules the validator never saw.

masking: {
  email: { type: 'email', show: { roles: ['admin'] } },
  // query: defaults to show.roles → ['admin']
},
read: {
  views: {
    pipeline: {
      fields: ['id', 'name'],
      access: { roles: ['member', 'admin'] },
      query: {
        searchable: ['name'],   // email NOT listed → not searchable on this view
      },
    },
    full: {
      fields: ['id', 'name', 'email'],
      access: { roles: ['admin'] },
      query: {
        filterable: ['email'],  // ✓ admin satisfies email's query gate
        searchable: ['name', 'email'],
      },
    },
  },
},

query is the unified replacement for the legacy per-capability masking flags (filterable, searchable, sortable). If an older definition still uses one, the compiler stops with the exact migration to the single role gate.

Egress protection

Masking redacts a column on the read paths (GET /:id, list, views, realtime) per role. It does not automatically follow the value if you copy it into a derived artifact that bypasses those read paths — a different record, an html/text string, or an outbound payload. Quickback guards those egress points so a masked token/PII can't quietly ship in the clear:

  • Compile-time block (declarative). A masked column is rejected at build time if it appears in embeddings.fields / embeddings.metadata (the value would be embedded and stored beside the vector, and returned in search hits) or is used as the table's displayColumn (it would render as the FK label inside every referencing record).
  • Compile-time scan (actions). A record-based action that copies record.<maskedColumn> into another record's column, an html/text template that gets stored, or a payload object fails compilation. Plain reads, comparisons, logging, and tagged templates (sql`…${record.col}` ) are fine — only the storage/payload sinks are flagged.
  • Realtime → webhook. Rows pushed to a realtime webhook sink are masked (an external sink carries no role, so masked columns are redacted), matching what WebSocket subscribers see.

Egress protection follows your decision

These checks read your masking block, which the decision gate guarantees covers every detected column. A column answered with a rule is egress-protected; a column answered false is not — you reviewed it and said it isn't confidential, so copying it into an embedding or an outbound payload is allowed.

Revealing a value on purpose: unmask()

When an action legitimately needs the raw value (e.g. calling an external API with a token), route it through unmask() — the sanctioned, audited reveal. It returns the raw value and records a best-effort masked_reveal entry in the audit database when one is configured.

import { unmask } from '../../../lib/masking-reveal';

// in an action's execute():
const token = await unmask(c, record.apiToken, 'apiToken');
await fetch(externalUrl, { headers: { Authorization: `Bearer ${token}` } });

Opting a column out: egress: 'allow'

If a column is masked for display tidiness rather than secrecy, opt it out of both the compile-time block and the action scan:

masking: {
  shortCode: { type: 'redact', show: { roles: ['admin'] }, egress: 'allow' },
}

egress defaults to 'block' (fail-closed). The action scan is an accident-prevention layer: it catches direct record.col → sink flows without pretending to be a whole-program information-flow proof. Aliased values, whole-row spreads, and values re-fetched through db.select() remain ordinary application code. Pair masking with the encryption pillar whenever the field needs a cryptographic boundary at rest or from the server itself.

Complete Example

// features/candidates/candidates.ts
import { feature, q } from '@quickback/compiler';

export default feature('candidates', {
  columns: {
    id:             q.id(),
    name:           q.text().required().filterable('like').searchable(),
    email:          q.text().required().filterable('eq').searchable(),
    phone:          q.text().optional().filterable('eq'),
    resumeUrl:      q.text().optional(),
    organizationId: q.scope('organization'),
    ...q.audit(),
    ...q.softDelete(),
  },
  guards: {
    createable: ['name', 'email', 'phone', 'resumeUrl'],
    updatable:  ['name', 'phone'],
  },
  masking: {
    email:     { type: 'email',  show: { roles: ['admin+'] } },
    phone:     { type: 'phone',  show: { roles: ['admin+'] } },
    resumeUrl: {
      type: 'custom',
      mask: () => '[RESUME LINK HIDDEN]',
      show: { roles: ['admin+'] },
    },
  },
  read: {
    // "+" role expansion requires auth.roleHierarchy in quickback.config.ts
    access: { roles: ['member+'] },
    views: {
      pipeline: {
        fields: ['id', 'name'],
        access: { roles: ['member+'] },
        query: { searchable: ['name'] },
      },
      full: {
        fields: ['id', 'name', 'email', 'phone', 'resumeUrl'],
        access: { roles: ['admin+'] },
        query: {
          filterable: ['email', 'phone'],
          searchable: ['name', 'email'],
        },
      },
    },
  },
  create: { access: { roles: ['admin+'] } },
  update: { access: { roles: ['admin+'] } },
  delete: { access: { roles: ['owner'] }, mode: 'soft' },
});

On this page