# Masking - Field Redaction

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

## Automatic Masking (Secure by Default)

Quickback automatically applies masking to columns that match sensitive naming patterns. This ensures a high security posture even if you don't explicitly configure masking.

### Sensitive Keywords & Default Masks

| Pattern | Default Mask | Description |
| :--- | :--- | :--- |
| `email` | `email` | p***@e******.com |
| `phone`, `mobile`, `fax` | `phone` | ******4567 |
| `ssn`, `socialsecurity`, `nationalid` | `ssn` | *****6789 |
| `creditcard`, `cc`, `cardnumber`, `cvv` | `creditCard` | ************1234 |
| `iban` | `redact` | [REDACTED] |
| `password`, `secret`, `token`, `apikey`, `privatekey` | `redact` | [REDACTED] |
| `accesstoken`, `refreshtoken`, `clientsecret`, `signingsecret`, `bearer` | `redact` | [REDACTED] |
| `stripe`, `webhook` | `redact` | [REDACTED] |

> **Smart Pattern Matching**
>
> Auto-masking also matches suffixes like `workEmail`, `homePhone`, `apiSecret`, `stripeApiKey`, `webhookSecret`, `customerStripe`, or `orderWebhook`.


### Build-Time Alerts

If the compiler auto-detects a sensitive column that you haven't explicitly configured, it will emit a warning:

```bash
[Warning] Auto-masking enabled for sensitive column "users.email". Explicitly configure masking to silence this warning.
```

To silence this warning or change the behavior, simply define the field explicitly in your `masking` configuration. Explicit configurations always take precedence over auto-detected defaults.

#### Tables without an owner column

The default auto-mask predicate is `show: { or: 'owner', roles: ['admin'] }` — admins always see the value, the row's owner sees their own. If the table has no owner column at all (no `userId` / `ownerId` and no `q.scope('owner')`), the compiler drops the `owner` clause and emits a second warning so the rule is still safe but transparent:

```bash
[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.
```

This is a real behavioral signal, not boilerplate — the auto-detector can't tell whether the table is "ownerless on purpose" (e.g. a shared lookup table) or "should have an owner column but was authored without one." Declare the predicate explicitly to silence the warning and lock in the intent.

### Overriding Defaults

If you want to show a sensitive field to everyone (disable masking) or use a different rule, define it explicitly in the `resource.masking` block:

```typescript
export default defineTable(candidates, {
  masking: {
    // Show email to everyone (overrides auto-masking)
    email: { type: 'email', show: { roles: ['everyone'] } },

    // Silence warning but keep secure (recruiter and above only)
    phone: { type: 'phone', show: { roles: ['owner', 'hiring-manager', 'recruiter'] } }
  }
});
```

## Basic Usage

```typescript
// 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'),
  },
  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

| Type | Example Input | Masked Output |
|------|---------------|---------------|
| `'email'` | `john@yourdomain.com` | `j***@y*********.com` |
| `'phone'` | `555-123-4567` | `******4567` |
| `'ssn'` | `123-45-6789` | `*****6789` |
| `'creditCard'` | `4111111111111111` | `************1111` |
| `'name'` | `John Smith` | `J*** S****` |
| `'redact'` | `anything` | `[REDACTED]` |
| `'custom'` | (your logic) | (your output) |

## Configuration

```typescript
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

```typescript
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-roles in masking (v1 limitation)

Relationship-roles declared under `authz.roles` (e.g. `guest: { via: 'guestOf' }`) are **not yet supported in `show:` clauses**. The compiler refuses to build with a clear error message because async, db-aware mask functions plus a batched relationship matcher (to avoid N+1 on list responses) are required to ship safely — that pipeline has not yet landed. The runtime evaluator fails closed on unrecognized arms, so silent grants are impossible.

**Workarounds**, 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.

A future async-mask pipeline with batched lookups will lift this limitation; the schema slot is reserved so the v2 add will be non-breaking.

## Query Gate (filter / sort / search)

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:

```typescript
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.

```typescript
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` replaces the legacy per-capability flags (`filterable`, `searchable`, `sortable`). Setting any of those is rejected at compile time with a migration hint.

## Complete Example

```typescript
// 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'),
  },
  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: {
    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' },
});
```
