Masked Columns - SmithSoft-Studios/JAZMIN GitHub Wiki
New in 1.7.0. Releases 1.0 to 1.6 read these files but never show the masked columns: see Good to know.
A column of a shared file can be masked: a key granted the column sees that it is there and what
the owner lets it show (•••• •••• •••• 4821), but not its values, until the column is unlocked with a password,
an unlock token from your key service (which can ask for 2FA first), or both. Masking is for values people need now and
then, not all the time: account numbers, salaries, ID numbers, phone numbers.
| Key | Sees of a masked column |
|---|---|
| Not granted the column | Nothing: no column, no masked text |
| Granted it, not unlocked | What the owner lets it show (<column>_shown), and the rows the mask leaves out |
| Granted it and unlocked | Every value it is granted |
| The owner key | Every value, always |
The locks are the same as for locked files, so one unlock can open masked columns and locked files together. Masking is enforced by encryption, not by the viewer: a masked column's values are in a column group whose key the access key alone can't open.
import { JazminKey, issueFileUnlockToken, open, write } from '@smithsoft-studios/jazmin';
const owner = JazminKey.generate(); // keep in your secret manager
const clerk = owner.createAccessKey(); // granted every column group, masked ones included
const reception = owner.createAccessKey(); // granted the default group only
write('staff.jzm', [
{ id: 1, name: 'Thandi', grade: 'A2', account: '4821 0393 7712 4821', salary: '41000.00' },
{ id: 2, name: 'Pieter', grade: 'M1', account: '5100 2231 9083 1177', salary: '78500.00' },
], {
key: owner,
columns: [
{ name: 'id', type: 'int' }, { name: 'name', type: 'string' }, { name: 'grade', type: 'string' },
{ name: 'account', type: 'string', mask: { lock: 'pay', show: { keepFormat: true, last: 4 } } },
{ name: 'salary', type: 'decimal', mask: { lock: 'hr', rows: { grade: { gte: 'M1' } } } }, // managers' rows only
],
access: { grants: [{ key: clerk, label: 'Clerk' }, { key: reception, columns: ['*'], label: 'Reception' }] },
locks: { pay: { password: 'correct horse battery staple' }, hr: { online: true } },
});
const view = open('staff.jzm', { key: clerk });
for (const m of view.masked) console.log(`${m.column}: lock ${m.lock.id} (${m.lock.kind}), shown in ${m.shown ?? '-'}, other rows in ${m.unmasked ?? '-'}`);
console.log('columns:', view.columns.map((c) => c.name).join(', '));
for (const r of view.rows()) console.log(r.name, r.account_shown, r.salary_unmasked);
view.unlock('pay', { password: 'correct horse battery staple' });
view.unlock('hr', { unlockToken: issueFileUnlockToken('staff.jzm', owner, 'hr') }); // from your key service, after 2FA
for (const r of view.rows()) console.log(r.name, r.account, r.salary ?? r.salary_unmasked);
view.close();
const desk = open('staff.jzm', { key: reception });
console.log('reception:', desk.columns.map((c) => c.name).join(', '), '- masked columns:', desk.masked.length);
desk.close();account: lock pay (password), shown in account_shown, other rows in -
salary: lock hr (online), shown in -, other rows in salary_unmasked
columns: id, name, grade, account_shown, salary_unmasked
Thandi •••• •••• •••• 4821 41000.00
Pieter •••• •••• •••• 1177 null
Thandi 4821 0393 7712 4821 41000.00
Pieter 5100 2231 9083 1177 78500.00
reception: id, name, grade - masked columns: 0
-
reader.masked(.NETreader.Masked) lists the masked columns a key was granted: thecolumn, itslock(id,kind,unlocked),shownandunmasked(the columns the writer made, below), andat, its place incolumns, so a viewer can show it there. -
Before unlocking, the masked column isn't in
columnsand a filter can't name it. Afterunlock(), rows have it too, in every table reader of the file. -
Pieter's salary is masked (his grade matches
rows), so before unlocking it isnullinsalary_unmasked; Thandi's is not masked, so the clerk sees it at once.
A column takes a Mask, and the reader lists Masked:
using Jazmin;
using Jazmin.Query;
var owner = JazminKey.Generate();
var clerk = owner.CreateAccessKey();
JazminColumn[] columns =
[
new("id", JazminType.Int), new("name", JazminType.String), new("grade", JazminType.String),
new("account", JazminType.String) { Mask = new JazminMask("pay") { Show = JazminMaskShow.KeepFormat(4) } },
new("salary", JazminType.Decimal) { Mask = new JazminMask("hr") { Rows = JazminFilter.Gte("grade", "M1") } },
];
var options = new JazminWriteOptions
{
Key = owner,
Access = new JazminAccessOptions { Grants = [new JazminGrant(clerk) { Label = "Clerk" }] },
Locks = new Dictionary<string, JazminLockOptions?> { ["pay"] = new() { Password = "correct horse battery staple" }, ["hr"] = new() { Online = true } },
};
using (var writer = JazminWriter.Create("staff.jzm", columns, options))
{
writer.WriteRow(new Dictionary<string, object?> { ["id"] = 1L, ["name"] = "Thandi", ["grade"] = "A2", ["account"] = "4821 0393 7712 4821", ["salary"] = 41000m });
writer.WriteRow(new Dictionary<string, object?> { ["id"] = 2L, ["name"] = "Pieter", ["grade"] = "M1", ["account"] = "5100 2231 9083 1177", ["salary"] = 78500m });
}
using var reader = JazminReader.Open("staff.jzm", new JazminReadOptions { AccessKey = clerk });
foreach (var m in reader.Masked) Console.WriteLine($"{m.Column}: lock {m.Lock.Id} ({m.Lock.Kind}), shown in {m.Shown ?? "-"}, other rows in {m.Unmasked ?? "-"}");
Console.WriteLine($"columns: {string.Join(", ", reader.Columns.Select(c => c.Name))}");
reader.Unlock("pay", password: "correct horse battery staple");
foreach (var row in reader.Rows()) Console.WriteLine($"{row["name"]} {row["account"]}");account: lock pay (Password), shown in account_shown, other rows in -
salary: lock hr (Online), shown in -, other rows in salary_unmasked
columns: id, name, grade, account_shown, salary_unmasked
Thandi 4821 0393 7712 4821
Pieter 5100 2231 9083 1177
Rows with masked columns are written with WriteRow (by name): the writer adds the columns it makes, so WriteValues
would need their values too. JazminMaskShow has None, LastOf(4), FirstAndLast(2, 2), KeepFormat(4) and
FromColumn("label").
show decides what a key sees before it unlocks. The writer makes the shown values into <column>_shown, an ordinary
column you can filter, sort and index.
show |
.NET |
4821 0393 7712 4821 shows |
|---|---|---|
'none' (the default) |
JazminMaskShow.None |
nothing (viewers show ••••) |
{ last: 4 } |
LastOf(4) |
••••4821 (the last 4, after four dots) |
{ first: 2, last: 2 } |
FirstAndLast(2, 2) |
48••••21 |
{ keepFormat: true, last: 4 } |
KeepFormat(4) |
•••• •••• •••• 4821 (spaces and dashes kept; an email keeps its domain) |
{ column: 'label' } |
FromColumn("label") |
a column you fill: Visa ending 4821, R40k–R50k
|
Masking only some rows (rows, a filter, .NET Rows): the other rows' values go to <column>_unmasked, which
the keys granted the masked column see at once. Viewers show the two as one column. A filter on one doesn't see the
other's values. Each time a row is written (new, or rewritten by an update) its value goes where rows says, so a row
changed to no longer match is unmasked.
-
A masked column is a column group of its own,
mask.<column>. A grant of every column (columns: '*', orcolumnsleft out) includes it, masked; a grant that lists groups gets it only if it lists that group. -
Its shown and unmasked columns go with it (a group of their own,
mask.<column>.open, granted only with the masked column), so a key without the masked column sees nothing of it, as the reception key above. A shown column you fill yourself (show: { column }) stays in the group you put it in. - Locks: a password lock can be guessed offline by whoever has the file and a key granted the column, as fast as PBKDF2 allows: use long passphrases. An online lock's secret isn't in the file. See locked files for both, and key services for tokens after 2FA.
-
The owner sees every value. For the owner,
reader.locksalso lists locks only masked columns use, andreader.maskedgives each column'sshowandrows, so tools can show and change them.
A masked column shows in its place, as one column: the row's unmasked value, else what it shows (•••• when nothing).
- Unlock… in the column's header asks for the lock's password or token, or offers Sign in at … when the lock names a key service.
- Then each value has Reveal, which shows it with a countdown and Hide.
- It is masked again after the file's seconds (30 by default), when the window is hidden, or after 60 s without input. Printing shows the masked text.
The file's package.masks (.NET JazminPackage.Masks, a JazminMaskView per column) changes that per column:
| Setting | Default | Meaning |
|---|---|---|
reveal |
'value' |
'column' reveals the whole column at once |
seconds |
30 | How long a revealed value stays shown; 0 for as long as it is unlocked |
whenHidden |
true |
Mask again when the window is hidden or loses focus |
idleSeconds |
60 | Mask again after this many seconds without input; 0 never |
export |
true |
false: viewers never export its revealed values, even when the person asks |
// fragment: the package settings of a file whose document is index.html
package: { entry: 'index.html', masks: { salary: { reveal: 'value', seconds: 15, export: false }, account: { reveal: 'column' } } },This is about people walking away from an open screen: a value once on screen can be copied. A page that holds the
viewer in an iframe can pass reportReveals: true in its jazmin:open message to hear jazmin:revealed (column, lock
and scope, never the value or the row), for systems that log reveals. In the browser reader, await reader.masked()
lists the masked columns (each with at), and unlock() builds every open table reader again with the column.
CSV, JSON and XML exports give each masked column as one column, as viewers show it: a row's unmasked value, else its value once unlocked, else what it shows.
import { JazminKey, open, toCSV, write } from '@smithsoft-studios/jazmin';
const owner = JazminKey.generate();
const clerk = owner.createAccessKey();
write('pay.jzm', [{ name: 'Thandi', account: '4821 0393 7712 4821' }, { name: 'Pieter', account: '5100 2231 9083 1177' }], {
key: owner, access: { grants: [{ key: clerk }] }, locks: { pay: { password: 'correct horse battery staple' } },
columns: [{ name: 'name', type: 'string' }, { name: 'account', type: 'string', mask: { lock: 'pay', show: { last: 4 } } }],
});
const reader = open('pay.jzm', { key: clerk });
console.log(toCSV(reader)); // locked: what values show
reader.unlock('pay', { password: 'correct horse battery staple' });
console.log(toCSV(reader)); // unlocked: the values
console.log(toCSV(reader, { revealMasked: false })); // what values show, even when unlocked
console.log(toCSV(reader, { masked: 'raw' })); // the columns as stored
reader.close();name,account
Thandi,••••4821
Pieter,••••1177
name,account
Thandi,4821 0393 7712 4821
Pieter,5100 2231 9083 1177
name,account
Thandi,••••4821
Pieter,••••1177
name,account,account_shown
Thandi,4821 0393 7712 4821,••••4821
Pieter,5100 2231 9083 1177,••••1177
-
Options:
revealMasked: falsekeeps what values show even when unlocked; a list (['account']) reveals only those columns;masked: 'raw'exports the columns as stored. The same inexportFileand the browser reader's exports.maskedView(reader, select, reveal)does the same for tools that write rows themselves. -
.NET:
reader.ExportView(filter, options, revealMasked)gives the columns and rows forCsvFormat.Write,JsonFormat.WriteandXmlFormat.Write;JazminJsonStreamuses it. -
In the web viewer, exports stay masked even once unlocked, unless the person ticks Include revealed values in
the Export menu (unticked each time a file or a lock opens, and never for a column with
export: false). A saved or typed shape that reads an unlocked masked column is refused unless the box is ticked. The note under the menu says which columns went out masked.
An update with the owner key can mask a column, unmask one, or give one a new mask (masks, .NET JazminUpdate.Masks;
per table in tables). Nobody gains access: a column masked goes to the keys that saw it, now masked; a column unmasked
goes to the keys that had it masked, now plain, and its made shown and unmasked columns fold back into it.
import { JazminKey, open, update, write } from '@smithsoft-studios/jazmin';
const owner = JazminKey.generate();
const clerk = owner.createAccessKey();
write('staff.jzm', [{ id: 1, name: 'Thandi', phone: '082 555 0142', salary: '41000.00' }], {
key: owner, access: { grants: [{ key: clerk }] }, locks: { hr: { password: 'correct horse battery staple' } },
columns: [
{ name: 'id', type: 'int' }, { name: 'name', type: 'string' }, { name: 'phone', type: 'string' },
{ name: 'salary', type: 'decimal', mask: { lock: 'hr' } },
],
});
const show = () => {
const r = open('staff.jzm', { key: clerk });
console.log([...r.rows()][0]);
r.close();
};
show();
update('staff.jzm', { key: owner, masks: { phone: { lock: 'hr', show: { keepFormat: true, last: 4 } }, salary: null } });
show();{ id: 1, name: 'Thandi', phone: '082 555 0142' }
{
id: 1,
name: 'Thandi',
salary: '41000.00',
phone_shown: '••• ••• 0142'
}
Update, compact and append keep the masks, make the shown values of new rows, and put new rows in the masked or the
unmasked column by rows. Each library reads and changes the other's masked files.
-
Older libraries (1.0 to 1.6) read the file and don't show the masked columns at all; the shown and unmasked
columns are ordinary columns to them, for the same keys. Don't rewrite or append to such a file with them: they
would drop the masks, and keys would lose the masked columns (never get them unmasked); the owner keeps the values.
1.7 refuses to change a file with a writer feature it doesn't know (
masked-columns). - Filters can't name a masked column until it is unlocked; the shown column can be filtered at any time.
- A masked column isn't a secret from the owner, and a key that unlocked it can read and copy its values. Use column groups for columns a key must never see.
-
Try it: the demo gallery (
js/examples/from-disk, pagemasked.html) has 40 staff with a bank account, an ID number, an email and directors' salaries masked. Unlock with the demo password, reveal values one at a time, and export CSV and JSON masked and revealed side by side.