Advanced

Policy

Mask a column, a row or a cell, and have the mask hold everywhere the value would otherwise leave: render, export, clipboard, search, facet and edit.

Basic Usage

A rule names the columns it covers and how the value is replaced. That is the whole shape: no ordering to learn and no precedence between rules.

Name
Email
Card
Salary
Ada Lovelace
a••••@example.com
••••1000
-
Bruno Dubois
b••••@example.com
••••1111
-
Chi Nguyen
c••••@example.com
••••1222
-
Dara Okafor
d••••@example.com
••••1333
-
Elif Demir
e••••@example.com
••••1444
-
Farid Haddad
f••••@example.com
••••1555
-
Grace Silva
g••••@example.com
••••1666
-
Hoang Tran
h••••@example.com
••••1777
-
8 rows

Guarded columns: salary, email, card. Read back with guarded and isGuarded(id).

import { createDataGrid, policy } from '@sv5ui/datagrid';

const grid = createDataGrid<Person>({
  columns,
  data: staff,
  getRowId: (person) => String(person.id),
  features: [
    policy<Person>({
      rules: [
        { columns: ['salary'], mask: 'hide' },
        { columns: ['email'], mask: 'email' },
        { columns: ['card'], mask: 'last4' }
      ]
    })
  ]
});

The Six Ways Out

A masked value is masked at every way out of the grid, not only on screen. The table below reads the first row of the grid above through each purpose in turn, using the same call the grid makes internally. Nothing here is written by hand.

Purposesalary (hide)email (email)
rendernulla••••@example.com
exportnulla••••@example.com
clipboardnulla••••@example.com
searchnulla••••@example.com
facetnulla••••@example.com
editnulla••••@example.com
const node = grid.preWindowNodes[0];
const salary = grid.columns.visible.find((column) => column.id === 'salary')!;

grid.getValue(node, salary, 'render');      // null
grid.getValue(node, salary, 'export');      // null
grid.getValue(node, salary, 'clipboard');   // null
grid.getValue(node, salary, 'search');      // null
grid.getValue(node, salary, 'facet');       // null
grid.getValue(node, salary, 'edit');        // null

// All six are covered by default. Naming a subset is how a value ends up
// masked on screen and plain in the CSV, so write it as the decision it is.
policy<Person>({
  rules: [{ columns: ['salary'], mask: 'hide', purposes: ['render'] }]
});

CellValuePurpose

All six are covered by default. Naming a subset is how a value ends up masked on screen and plain in the CSV, so write it as the decision it is.

PurposeWhere it is asked
renderThe cell, the tooltip, and anything a snippet draws
exportCSV, and the workbook writer
clipboardA copy, whether of a cell, a row or a range
searchThe quick filter's text, so a masked value is searched masked
facetThe values a set filter offers to tick
editThe draft an editor opens with, which is why a masked cell will not open

The Masks

Every column below holds the same card number, email or name, each under a different mask.

Row
hide
redact
last4
email
initials
Ada Lovelace
-
••••
••••1000
a••••@example.com
A. L.
Bruno Dubois
-
••••
••••1111
b••••@example.com
B. D.
Chi Nguyen
-
••••
••••1222
c••••@example.com
C. N.
Dara Okafor
-
••••
••••1333
d••••@example.com
D. O.
Elif Demir
-
••••
••••1444
e••••@example.com
E. D.
5 rows
import { policy } from '@sv5ui/datagrid';

policy<Person>({
  rules: [
    { columns: ['card'], mask: 'hide' },       // null
    { columns: ['note'], mask: 'redact' },     // ••••
    { columns: ['pan'], mask: 'last4' },       // ••••1234
    { columns: ['email'], mask: 'email' },     // a••••@example.com
    { columns: ['name'], mask: 'initials' },   // A. L.

    {
      columns: ['salary'],
      mask: (value, { purpose }) =>
        purpose === 'export' ? null : `${Math.round(Number(value) / 10000)}0k`
    }
  ]
});

// A value too short to partially mask is redacted whole rather than leaking
// what little it has: last4 of '123' is ••••, not ••••123.

Built-in Masks

MaskWhat it returns
hidenull, so a number column draws nothing rather than a zero
redactFour bullets, whatever the value was
last4Keeps the last four characters. Falls back to a full redaction when there are not enough to keep
emailKeeps the first letter and the domain, so an address stays recognisable as one
initialsA name reduced to its initials
(value, context) => unknownAnything of your own. context carries the row, the column id and the purpose

Row and Cell Level

when is asked per cell, so a rule is not limited to whole columns. Below, only the suspended rows are masked.

Name
Status
Email
Salary
Ada Lovelace
active
ada.lovelace@example.com
$128,000.00
Bruno Dubois
suspended
••••
••••
Chi Nguyen
active
chi.nguyen@example.com
$164,000.00
Dara Okafor
active
dara.okafor@example.com
$84,000.00
Elif Demir
active
elif.demir@example.com
$143,500.00
Farid Haddad
suspended
••••
••••
Grace Silva
active
grace.silva@example.com
$176,000.00
Hoang Tran
active
hoang.tran@example.com
$112,500.00
8 rows
policy<Person>({
  rules: [
    {
      columns: ['salary', 'email'],
      mask: 'redact',
      when: ({ row }) => row?.status === 'suspended'
    },
    {
      columns: ['bonus'],
      mask: 'hide',
      // row is null where the purpose has no single row behind it, such as
      // the list a set filter offers.
      when: ({ row }) => row !== null && row.level > 3
    }
  ],
  except: (column) => column.id === 'name'
});

The Open Door

The grid's own sort and column filters read the real value, not the masked one. So a masked column that is still sortable can be ranked, and a filter narrowed far enough is a search for the value it is hiding.

The grid below masks salary and leaves the column sortable, which is the mistake. Click the header: the numbers never appear, and the order still tells you who earns most.

Name
Ada Lovelace
-
Bruno Dubois
-
Chi Nguyen
-
Dara Okafor
-
Elif Demir
-
Farid Haddad
-
Grace Silva
-
Hoang Tran
-
8 rows

The rows arrive in no particular order. Sort once and they are ranked lowest to highest, sort again and highest to lowest. You now know who earns most, and the column never showed a number.

The grid warns about this once in the console, naming the columns. Close the door with sortable: false and filter: false, or pass warnOnOpenDoors: false if the ranking genuinely is not a secret.

// The grid's own sort and column filters read the real value, so a masked
// column left sortable can still be ranked. Close the door on the column:
const columns: ColumnDef<Person>[] = [
  { id: 'name', header: 'Name', flex: 1 },
  {
    id: 'salary',
    header: 'Salary',
    type: 'currency',
    sortable: false,
    filter: false
  }
];

policy<Person>({ rules: [{ columns: ['salary'], mask: 'hide' }] });

// Or say the ranking is not a secret, and mean it:
policy<Person>({ rules, warnOnOpenDoors: false });

A Masked Cell Will Not Open

An editor opened on a value the reader is not being shown would commit the substitute over the real data the moment it was saved. So a masked cell simply does not open. Both columns below are editable; only the unmasked one edits.

Name (editable)
Email (masked, so not editable)
Ada Lovelace
a••••@example.com
Bruno Dubois
b••••@example.com
Chi Nguyen
c••••@example.com
Dara Okafor
d••••@example.com
Elif Demir
e••••@example.com
Farid Haddad
f••••@example.com
Grace Silva
g••••@example.com
Hoang Tran
h••••@example.com
8 rows
const grid = createDataGrid<Person>({
  columns: [
    { id: 'name', header: 'Name', editable: true },
    { id: 'email', header: 'Email', editable: true }
  ],
  data: staff,
  getRowId,
  features: [
    editing<Person>(),
    policy<Person>({ rules: [{ columns: ['email'], mask: 'email' }] })
  ]
});

// name opens an editor. email is editable and masked, so it does not open:
// an editor on a substitute would commit the substitute over the real data.

// Scoping a rule away from 'edit' re-opens the column, with the real value
// in the editor. That is a decision, so make it look like one.
policy<Person>({
  rules: [
    { columns: ['email'], mask: 'email', purposes: ['render', 'export', 'clipboard'] }
  ]
});

Options

OptionDescription
rulescolumns, mask, and optionally when and purposes
warnOnOpenDoorsWarn once when a masked column is left sortable or filterable
exceptColumns the policy never touches, whatever the rules say

What Crossed the Network

Every demo above holds its rows in the page, which is the one setting where the difference this section is about cannot be seen. Here are two grids over the same endpoint. The first is sent everything and masks the salary; the second is never sent it.

They look almost the same. Underneath each is the first row exactly as it arrived, and that is where they stop being the same: one response carries the number, and no amount of masking in the browser takes it back out of the tab it is now sitting in.

sent, then masked policy: salary is hidden
Role

No data

0 rows

What the browser received:

...
never sent fields: ['name', 'team', 'role']
Role

No data

0 rows

What the browser received:

...
// The same grid, twice, against the same endpoint.

// 1. Everything is sent, and the browser hides one field.
const masked = createDataGrid<Person>({
  columns, data: [], getRowId,
  rowModel: 'server',
  features: [
    sorting(),
    policy({ rules: [{ columns: ['salary'], mask: 'hide' }] }),
    serverRowModel(peopleOver(undefined), { mode: 'infinite', placeholder })
  ]
});
// The response body still reads:
// { "id": 1, "name": "Hoang Kowalski", "salary": 127691, ... }

// 2. The field is never sent, so there is nothing to hide.
const withheld = createDataGrid<Person>({
  columns, data: [], getRowId,
  rowModel: 'server',
  features: [
    sorting(),
    serverRowModel(peopleOver(['name', 'team', 'role']), { mode: 'infinite', placeholder })
  ]
});
// { "id": 1, "name": "Hoang Kowalski", "team": "Design", "role": "Manager" }

// And on the server, where the decision belongs:
const allowed = new Set([...fields, 'id']);

// A caller who may not read a field may not order or filter by it either.
// An ordering hands back the ranking, which is most of what the number was
// protecting; a filter answers "> 120000" one bit at a time until the number
// falls out. Both are dropped, and the response says which.
const keptSort = sort.filter((entry) => allowed.has(entry.field));

return json({ rows: project(page, fields), rowCount, refused });

Behind a Pivot Cell

A pivot reads its measures through the same mask the cells are drawn with, so a masked column is measured as masked and a total cannot carry the real figure out.

One call goes around it. rowsBehind() answers which rows a cell was measured from, and it hands back the rows themselves. A row has not been through any mask. Draw that result as it comes and the real values are on screen again. The grid warns about this once in the console when rowsBehind is called on a grid that gates cell values. Mask the rows again before drawing them, or do not offer a drill-down on a masked grid.

What This Is, and Is Not

// policy() masks a value on its way out of the grid. It does not remove it
// from the page: the real number is still in the data array, in the browser.

const rows = await fetchStaff(canSeeSalary ? ALL_FIELDS : WITHOUT_SALARY);

const grid = createDataGrid<Person>({
  columns,
  data: rows,
  getRowId,
  features: [policy<Person>({ rules })]
});

// The fetch above is the permission boundary. policy() is what keeps a value
// that legitimately belongs in the page from being read over a shoulder,
// exported by accident, or pasted into a chat window.

Driving It From Code

import { getPolicy } from '@sv5ui/datagrid';

const guard = getPolicy(grid)!;

guard.rules;
guard.guarded;                                       // ['salary', 'email', 'card']
guard.isGuarded('salary');                           // true
guard.valueFor(128000, person, 'salary', 'export');  // null
guard.readValue(person, column);