Skip to content

Access Control

Daniël Smith edited this page Oct 16, 2026 · 4 revisions

Access Control: One File, Many Keys

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"]
Loading

A complete example

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 leaving columns out, grants every group, including pii. Leaving rows out likewise grants every partition. In .NET, Columns = ["*"] is the default group and Columns = 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. hiddenRowCount says 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.

In .NET

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.

What key holders can and can't see

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.

Performance

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.

Good to know

  • Keep each partition's rows together, for example with sortedBy on the partition column. A chunk holds exactly one partition, so interleaved rows produce many tiny chunks.
  • The partition column and sortedBy columns must be in the default group *, because every key needs them.
  • Partitions added later: a grant with rows: '*' (.NET Rows = 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's id (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? rotateOwnerKey gives 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, .NET JazminGrantInfo.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 ••••4821 until 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.

Clone this wiki locally