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.
policy() is
for the value that legitimately belongs in the page and should not be read over a shoulder, exported
by accident, or pasted into a chat window. 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.
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.
| Purpose | salary (hide) | email (email) |
|---|---|---|
render | null | a••••@example.com |
export | null | a••••@example.com |
clipboard | null | a••••@example.com |
search | null | a••••@example.com |
facet | null | a••••@example.com |
edit | null | a••••@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.
| Purpose | Where it is asked |
|---|---|
render | The cell, the tooltip, and anything a snippet draws |
export | CSV, and the workbook writer |
clipboard | A copy, whether of a cell, a row or a range |
search | The quick filter's text, so a masked value is searched masked |
facet | The values a set filter offers to tick |
edit | The 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.
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
| Mask | What it returns |
|---|---|
hide | null, so a number column draws nothing rather than a zero |
redact | Four bullets, whatever the value was |
last4 | Keeps the last four characters. Falls back to a full redaction when there are not enough to keep |
email | Keeps the first letter and the domain, so an address stays recognisable as one |
initials | A name reduced to its initials |
(value, context) => unknown | Anything 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.
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.
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.
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
| Option | Description |
|---|---|
rules | columns, mask, and optionally when and purposes |
warnOnOpenDoors | Warn once when a masked column is left sortable or filterable |
except | Columns 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.
No data
What the browser received:
...
No data
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 });> 120000 one bit at a time. That check belongs where the data is, which is the same place the
field list belongs. 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);