Repository navigation
Access Control
Send one file to many people, and let each person read only their part. An access-controlled file has one owner
key (jzk1-...) and any number of access keys (jza1-...). Each access key opens only what it was granted:
-
Partitions: the rows sharing a value of one column (
partitionBy), such as one client's statement, one branch, or one field worker's visits. -
Column groups: named sets of columns, such as
pii: ['salary', 'idNumber']. Every other column is in the default group*.
| Who | Can read | Can change the file and grant access |
|---|---|---|
| Owner key | Everything, with indexes | Yes, and is the only key that can |
| Access key | Only its granted rows and columns; other rows are simply not there | No |
| Any other key | Nothing | No |
Every access-controlled file is signed by the owner. An access key can't produce a file that verifies as the owner's, and readers reject files whose signature or contents were changed.
flowchart LR
O["Owner key<br/>(your server)"] -->|writes, signs, grants| F[("statements.jzm")]
F -->|"jza1 key: rows of ACC-1"| A["Ann's app<br/>sees ACC-1 only"]
F -->|"jza1 key: rows of ACC-2"| B["Bob's app<br/>sees ACC-2 only"]
F -->|"jza1 key: all rows + pii"| C["Auditor<br/>sees everything"]
import { JazminKey, grantAccess, open, revokeAccess, write } from '@smithsoft-studios/jazmin';
const owner = JazminKey.generate(); // keep in your secret manager: it controls the file
const ann = owner.createAccessKey(); // send ann.export() to Ann over a secure channel
const bob = owner.createAccessKey();
const auditor = owner.createAccessKey();
const lines = [];
for (const account of ['ACC-1', 'ACC-2', 'ACC-3']) {
for (let n = 1; n <= 4; n++) lines.push({ account, line: n, amount: n * 100, salary: '50000.00', idNumber: `ID-${account}` });
}
write('statements.jzm', lines, {
key: owner,
sortedBy: ['account', 'line'], // keeps each partition's rows together: fewer, fuller chunks
access: {
partitionBy: 'account',
columnGroups: { pii: ['salary', 'idNumber'] },
grants: [
{ key: ann, rows: ['ACC-1'], columns: ['*'], label: 'Ann' }, // columns ['*']: the default group only, no pii
{ key: bob, rows: ['ACC-2'], columns: ['*'], label: 'Bob' },
],
},
});
const view = (who, key) => {
try {
const r = open('statements.jzm', { key: key.export() });
console.log(`${who}: ${r.rowCount} rows (${r.hiddenRowCount} hidden), columns ${r.columns.map((c) => c.name).join(', ')},`,
`partitions ${r.access.visiblePartitions.join(', ')}, total ${r.columnArrays(null, { select: ['amount'] }).values.amount.reduce((a, b) => a + b, 0)}`);
r.close();
} catch (e) {
console.log(`${who}: ${e.message}`);
}
};
view('Ann', ann);
view('Bob', bob);
view('Auditor', auditor); // not granted yet
grantAccess('statements.jzm', owner, auditor, { rows: '*', columns: ['*', 'pii'], label: 'Auditor' });
view('Auditor', auditor);
revokeAccess('statements.jzm', owner, bob); // the new version is locked with fresh secrets
view('Bob', bob);
const o = open('statements.jzm', { key: owner });
console.log('Owner:', o.rowCount, 'rows; grants:', o.access.grants.map((g) => `${g.label} rows=${JSON.stringify(g.rows)} columns=${JSON.stringify(g.columns)}`).join('; '));
console.log('Owner: column groups', o.access.groupColumns);Ann: 4 rows (8 hidden), columns account, line, amount, partitions ACC-1, total 1000
Bob: 4 rows (8 hidden), columns account, line, amount, partitions ACC-2, total 1000
Auditor: This key has not been granted access to this file
Auditor: 12 rows (0 hidden), columns account, line, amount, salary, idNumber, partitions ACC-1, ACC-2, ACC-3, total 3000
Bob: This key has not been granted access to this file
Owner: 12 rows; grants: Ann rows=["ACC-1"] columns=["*"]; Auditor rows="*" columns=["*","pii"]
Owner: column groups { '*': [ 'account', 'line', 'amount' ], pii: [ 'salary', 'idNumber' ] }
-
columns: ['*']versus'*': the list['*']grants the default group only. The string'*', or leavingcolumnsout, grants every group, includingpii. Leavingrowsout likewise grants every partition. In .NET,Columns = ["*"]is the default group andColumns = null(the default) is every group. Always list the groups for people who must not see everything. -
Hidden columns don't exist for a key that wasn't granted them: they're not in
columns, and a filter that names them is an error (unknown column). -
Hidden rows aren't returned, and their chunks aren't even decrypted.
hiddenRowCountsays how many there are. - Revoking protects new versions only. Every rewrite uses fresh secrets, so a revoked key can't open the new file. A copy of the old file that the person already has stays readable with their old key.
using Jazmin;
var owner = JazminKey.Generate();
var ann = owner.CreateAccessKey();
var columns = new[]
{
new JazminColumn("account", JazminType.String) { Nullable = false },
new JazminColumn("amount", JazminType.Float),
new JazminColumn("salary", JazminType.Decimal),
};
var options = new JazminWriteOptions
{
Key = owner,
SortedBy = ["account"],
Access = new JazminAccessOptions
{
PartitionBy = "account",
ColumnGroups = new() { ["pii"] = ["salary"] },
Grants = [new JazminGrant(ann) { Rows = ["ACC-1"], Columns = ["*"], Label = "Ann" }],
},
};
using (var writer = JazminWriter.Create("statements.jzm", columns, options))
{
writer.WriteValues("ACC-1", 100.0, 50000m);
writer.WriteValues("ACC-1", 200.0, 50000m);
writer.WriteValues("ACC-2", 300.0, 61000m);
}
// Ann's app receives her key text and opens the file with it
var annKey = JazminAccessKey.Parse(ann.Export());
using (var view = JazminReader.Open("statements.jzm", new JazminReadOptions { AccessKey = annKey }))
{
Console.WriteLine($"Ann: {view.RowCount} rows, {view.HiddenRowCount} hidden, columns {string.Join(", ", view.Columns.Select(c => c.Name))}");
foreach (var row in view.Rows()) Console.WriteLine($" {row["account"]} {row["amount"]}");
}
using (var all = JazminReader.Open("statements.jzm", new JazminReadOptions { Key = owner }))
Console.WriteLine($"Owner: {all.RowCount} rows, groups {string.Join(", ", all.Access!.ColumnGroups)}, grants {string.Join(", ", all.Access.Grants!.Select(g => g.Label))}");Ann: 2 rows, 1 hidden, columns account, amount
ACC-1 100
ACC-1 200
Owner: 3 rows, groups *, pii, grants Ann
JazminFile.GrantAccess(path, owner, new JazminGrant(key) { ... }) and JazminFile.RevokeAccess(path, owner, key)
change grants afterwards. A grant's Rows and Columns default to all, so set them for anyone who must see less.
| Hidden from an access key | Visible to every access key |
|---|---|
| Other partitions' rows, statistics and chunk directories | The default group's column names and types |
| Names and types of columns in groups it wasn't granted | File metadata |
| Embedded files of other partitions and file groups | How many partitions there are, and the size of each section |
| Other people's grants and labels | Its own grant: partitions, groups, expiry |
Don't put secrets in metadata or in the default group's column names.
The owner keeps full speed, and each person's open reads only their own part. On 1.37 million statement lines in 5,000 sections, with one client key per section:
| JavaScript | .NET | |
|---|---|---|
| Write the whole file | 2.0 s | 1.2 s |
| Client: open the file and read their section | 1.7 ms | 1.6 ms |
| Owner: open the file and read one section | 2.1 ms | 1.8 ms |
| Owner: each further section, file open | 0.27 ms | 0.21 ms |
- Opening stays fast however many keys the file has: 1.4 ms in .NET with 1, 5,000 or 50,000 keys. A key reads a short signed list of key-slot pages, the one page with its slot, the header, and its own partitions' directories.
- Owner lookups by index read only the partitions holding the matching records (a small chunk map records which partition each chunk is in). Finding one record by id among 250,000: 1.7 ms with 100 partitions, 2.0 ms with 1,000.
- Only the owner uses indexes, because an index would reveal every partition's values. Access keys don't need them: their own rows are found directly, and chunk statistics still skip chunks within them.
-
Keep each partition's rows together, for example with
sortedByon the partition column. A chunk holds exactly one partition, so interleaved rows produce many tiny chunks. -
The partition column and
sortedBycolumns must be in the default group*, because every key needs them. -
Partitions added later: a grant with
rows: '*'(.NETRows = null) sees them; a grant listing names doesn't. -
Key delivery: fetch the access key text from your secrets endpoint at start-up and pass it to
open/JazminAccessKey.Parse. Never log it. The key'sid(key.id,key.Id) is safe to log. - Browsers open shared files with access keys; they refuse the owner key, which should never be on a web page. For browsing everything, grant a full-read access key.
-
The owner key leaked?
rotateOwnerKeygives the file a new owner key and every person a new access key with the same rights: see Encryption and Keys and Security Model. - Several tables in one file share partitions and grants: see Several Tables.
-
Changing a grant in a tool (1.6.0): the owner's grant list also gives each key's extra file groups
(
grants[i].files, .NETJazminGrantInfo.Files). A new grant for a key replaces the old one whole, so copy them across to keep them. -
A password or 2FA as well as the key (1.7.0): a column can be masked, shown as
••••4821until the key holder unlocks it, and a stored file can be locked. Both use the same locks: a password, an unlock token from your key service, or both. A leaked access key alone then opens neither. - Access that ends: see Time-Limited Access. Records sent back by key holders: see Sending Records Back.
JAZMIN 1.2 · file format 1.0 · Repository · npm · NuGet · MIT license · Examples in this wiki are run and checked by tools/check-examples.mjs
Start
Using files
- Writing Files
- Nested Columns
- Reading and Querying
- Filter Language
- .NET Serializer and LINQ
- Updating and Appending
- Several Tables
- Converting JSON, CSV, XML
- Export Shapes
Security and sharing
- Encryption and Keys
- Access Control
- Masked Columns
- Time-Limited Access
- Sending Records Back
- Security Model
Documents and apps
Performance
Reference