Permissions & Keyring
Always-on. Every vault has a keyring — a single-owner keyring is the minimum. Source of truth:
packages/hub/src/with-party/team/keyring.ts
What it is
noy-db encrypts each collection with its own DEK (data-encryption key). Your copy of
those DEKs lives in your keyring: a per-user record stored at _keyring/<userId>
inside the vault. The DEKs in the keyring are wrapped — encrypted with AES-KW — under
your KEK (key-encryption key), which is derived from your secret and never written
to disk. The full key hierarchy is covered in Core 02 — Enclave;
this page assumes it.
That design makes the keyring both the access-control list and the cryptographic gate: what you can read is exactly what your keyring can unwrap. The keyring record holds:
- Your role (
owner/admin/custodian/operator/viewer/client) - Per-collection permissions (
{ collectionName: 'rw' | 'ro' }) for the explicit-grant roles - Wrapped DEKs — one per collection you can decrypt
- The salt used to derive your KEK from your secret
- A canary — a fixed value wrapped under your KEK, so the hub can tell a wrong secret apart from on-disk corruption
- Optional export/import capability grants for the
@noy-db/as-*packages
A vault opens only for a user whose keyring unwraps. Secret unlock is the default;
passkey, OIDC, magic-link, and other unlock methods come from the @noy-db/on-* packages
(see auth-landscape) via the getKeyring option.
Roles
| Role | Collection access | Can grant / revoke |
|---|---|---|
owner | all, read-write | Yes — any role |
admin | all, read-write | admin / operator / viewer / client |
custodian | all, read-write | No |
operator | explicit list (rw or ro per collection) | No |
viewer | all, read-only | No |
client | explicit list, read-only | No |
ownercannot be revoked — by anyone, ever.admins delegate laterally: an admin can grant another admin, but never an owner or a custodian. When an admin is revoked, the admins they granted are revoked too (see Revoke below).custodianis the sovereign-custody role: operationally admin-rank (reads and writes every collection) but provably non-owning — it can never grant, revoke, or rotate keys. Only the owner can mint or remove one, viadb.grantCustodian()/db.revokeCustodian()(opt-in:custodyStrategy: withCustody()— see the custody service).- Privilege rank (used by
db.listAccessibleVaults({ minRole })):client (1) < viewer (2) < operator (3) < custodian = admin (4) < owner (5). The order reflects how much a principal can see, not how much it can modify — which is whyviewer(read-all) outranksclient(explicit-read).
Single-owner flow
Single-user vaults need nothing beyond the core. The first openVault() for a brand-new
vault creates the owner keyring automatically:
import { createNoydb } from '@noy-db/hub'
// No store → the kernel's built-in in-memory store. Add `store: …` to persist.
const db = await createNoydb({ user: 'me', secret: 'correct-horse-battery-staple' })
// First open of a new vault: a random salt is generated, the KEK is derived
// from the secret, and an owner keyring is persisted to _keyring/me.
const vault = await db.openVault('personal')
await db.listUsers('personal')
// → [{ userId: 'me', displayName: 'me', role: 'owner', permissions: {},
// createdAt: '2026-07-08T10:00:00.000Z', grantedBy: 'me' }]
Collection DEKs are minted lazily: the first time a collection is touched, the hub
generates a fresh DEK, wraps it under your KEK, and re-persists the keyring. On every
later open, the hub re-derives the KEK from your secret plus the stored salt and
unwraps the DEKs — a wrong secret throws InvalidKeyError; a keyring whose bytes
were damaged throws KeyringCorruptError (the canary is what distinguishes the two).
Multi-user: grant and revoke
Multi-user membership is the team capability, opt-in since 0.3: pass
teamStrategy: withTeam() to createNoydb(), otherwise db.grant / db.revoke /
db.rotate throw TeamNotEnabledError. Magic-link invites live with the
team service; cross-tier delegation moved to
tiers, and the session-authentication ladder has its own page at
session-tiers.
import { createNoydb } from '@noy-db/hub'
import { withTeam } from '@noy-db/hub/team'
const db = await createNoydb({
user: 'owner@example.com',
secret: 'owner-secret-goes-here',
teamStrategy: withTeam(),
})
await db.openVault('acme')
// Owner grants Alice read-write on invoices
await db.grant('acme', {
userId: 'alice@example.com',
displayName: 'Alice',
role: 'operator',
secret: 'alice-initial-secret',
permissions: { invoices: 'rw' },
})
await db.listUsers('acme')
// → [ { userId: 'owner@example.com', role: 'owner', … },
// { userId: 'alice@example.com', role: 'operator', permissions: { invoices: 'rw' },
// grantedBy: 'owner@example.com', … } ]
// Owner revokes Alice; the DEKs of her collections are rotated
await db.revoke('acme', { userId: 'alice@example.com' })
What a grant does
A grant is a key ceremony, not a row in an ACL table:
- A fresh salt is generated for the grantee, and their KEK is derived from the
secretin the grant options. - The DEKs of the granted collections — which the grantor's unlocked keyring already holds in memory — are wrapped under the grantee's new KEK.
- A keyring record for the grantee is written to
_keyring/<userId>, stamped withgrantedByfor the delegation trail.
The records themselves are never re-encrypted, and no plaintext is touched. A grantor can
only hand out DEKs it holds itself — a grant that would widen access beyond the grantor's
own keyring throws PrivilegeEscalationError.
Revoke
db.revoke(vault, { userId }) deletes the target's keyring and rotates the DEKs of
every collection that keyring could decrypt — each affected record is re-encrypted under a
fresh DEK. That rotation is the same operation as db.rotate() below, so it has the same
consequence: the remaining members also lose their keys for those collections and must
be re-granted.
Rotation is unconditional — there is no option to skip it. RevokeOptions carries userId
and cascade, and nothing else. An earlier rotateKeys: false escape hatch was removed
because it made revocation a no-op: leaving the DEKs in place means the revoked member's
copied key material keeps opening the collection, including records written after the
revocation. Losing the other members' keys is the cost of revocation actually revoking.
When the revoked user is an admin, the revoke
cascades through every admin they (transitively) granted; pass cascade: 'warn' to leave
those descendants in place and get a console warning instead.
To rotate without removing anyone (e.g. after a suspected key leak), use
db.rotate(vault, ['invoices', …]) — but read the next section first: "without removing
anyone" does not mean "without taking anything away." To change a role, display name, or permission map
without touching any keys, use db.updateUser(vault, { userId, role?, displayName?, permissions? }) — a header-only rewrite that leaves DEK wrappings and enrolled
authenticators intact, guarded by the same role hierarchy as grant.
Rotation drops every other member's access
db.rotate(vault, collections) keeps everyone's membership but removes their keys for the
rotated collections. Every other member is locked out of those collections until you re-grant them.
db.revoke(vault, { userId }) runs the same rotation by default, so revoking one member locks
out the rest of the team on that member's collections.This is the zero-knowledge property working as designed rather than a gap. A member's DEKs are wrapped under that member's KEK, and a KEK derives only from that member's secret — so the caller cannot derive anyone else's KEK, and therefore cannot re-wrap a fresh DEK for them. There is no key material in the caller's hands that would let rotation preserve someone else's access.
What rotation does not do is remove anyone from the vault: members keep their membership, their
role, and their keys for every collection you did not rotate. That is the only sense in which this
is the "just rotate" path — "revoke and rotate" is db.revoke(vault, { userId }), which
always rotates.
So the recovery step is always the same: re-run grant() for each (member, collection) pair you
rotated.
const { needsRegrant } = await db.rotate('acme', ['invoices'])
// → [{ userId: 'bob', collection: 'invoices' }]
for (const { userId, collection } of needsRegrant) {
await db.grant('acme', { userId, permissions: { [collection]: 'rw' }, /* … */ })
}
rotate() returns that list as needsRegrant (noy-db#854), so you do not have to track the
affected pairs yourself before rotating.
The roster epoch — refusing a replayed roster
Since 0.7.0-pre.9 a keyring carries roster_epoch, bumped on every roster write and bound into
the roster's canonical form so a store can neither edit it nor strip it (noy-db#1097).
It exists because a roster tag authenticates the roster's contents and says nothing about it
being current — and the gap is reachable without forging anything. There is no role-change API,
so narrowing someone's standing means calling grant again with a lower role, which overwrites in
place. The previous, broader file was legitimately minted by the vault, so a store that kept a copy
can re-serve it: the KEK unwraps, the canary checks out, the tag verifies, and the replay restores
the higher role, usably. Rotation does not close this — it converts live access into stale access,
and role gates capabilities rather than keys.
What ships in 0.7.0-pre.9:
KeyringFile.roster_epoch?: number— bumped on every roster write and bound intorosterCanonical, so a store can neither edit nor strip it.- Two
KeyringTamperedErrorreasons —roster-epoch-rewound(the file is older than the floor you supplied) androster-epoch-absent(it carries no epoch at all). Both arrive aterr.details.reason, noterr.reason. assertRosterEpochCurrent(found, expected, userId)andnextRosterEpoch(current), on the root barrel since0.7.0-pre.10. (They shipped inpre.9reachable from no entry point at all;pre.10exports them.)- All thirteen roster-mint sites stamp the epoch before the tag is minted, and
writeKeyringFilerefuses to write a keyring without one — an output-domain guard, so a future mint site cannot ship the field empty.
import { assertRosterEpochCurrent } from '@noy-db/hub'
// `expected` is your floor, and it must reach you by a channel the store does not carry.
try {
assertRosterEpochCurrent(keyring.roster_epoch, expected, userId)
} catch (err) {
// err.details.reason === 'roster-epoch-rewound' | 'roster-epoch-absent'
}
found vs expected | result |
|---|---|
| equal | accepted |
found greater — the roster moved on since your floor was minted | accepted |
found lower — a rewind | KeyringTamperedError, roster-epoch-rewound |
found absent, expected supplied | KeyringTamperedError, roster-epoch-absent |
expected absent — you supplied no floor | accepted; the check is opt-in |
nextRosterEpoch(undefined) returns 1, not 0, so "has an epoch" is never confusable with
falsy. It is the only sanctioned way to produce the next value: a write that reused or lowered an
epoch would mint a file the comparison above cannot tell from a replay.
Opt-in, and absence is never zero. With no expected epoch, nothing changes for a caller. When
one is supplied, a file carrying no epoch is refused as roster-epoch-absent rather than read as
epoch 0 — treating absence as zero would accept every pre-epoch file, which is exactly the replay
the mechanism refuses. found > expected is accepted: the anchor is a floor, not an equality, since
a roster may legitimately have moved on since the floor was issued.
0.6.0 on-disk format breaks — nothing stored needs to change.Permission checks on every operation
- Writes — every
put/deletechecks write permission before any encryption or store I/O; a denied write throwsReadOnlyErrorand the store never sees the request. The rule:owner/admin/custodianalways write;viewer/clientnever do;operatorwrites where its permission entry is'rw'. - Reads — vault-wide enumeration silently skips collections the keyring has no access to. The floor is cryptographic, not advisory: without the wrapped DEK for a collection, there is nothing to decrypt.
- Grant / revoke / role changes — an operation the caller's role does not permit
throws
PermissionDeniedError. - Exports — the
@noy-db/as-*packages authorize throughvault.assertCanExport(tier, format?), backed by the publichasExportCapability(keyring, tier, format?)check. Two tiers:assertCanExport('plaintext', 'xlsx')for plaintext formats — the default is empty for every role, so plaintext export must be positively granted via theexportCapabilitygrant option — andassertCanExport('bundle')for encrypted bundles, which defaults on forowner/adminand off for everyone else. Imports are default-closed for every role, including owner, until granted viaimportCapability.
Critical invariants
- No vault without a keyring. Even
encrypt: falsemode synthesizes an in-memory owner keyring, so the permission code path is uniform. - Wrapped DEKs are persisted; raw DEKs and the KEK never are. The keyring record's
metadata (role, permissions, salt) is readable on the store, but an attacker reading
_keyring/<userId>cannot decrypt anything without the user's secret. - Owner is irrevocable, and no role below owner can create or destroy an owner-shaped keyring.
- A grant can never widen access: every DEK wrapped into a new keyring must come from
the grantor's own unlocked DEK set (
PrivilegeEscalationErrorotherwise).
Public surface
db.team.getKeyring(vault)— defensive copy of the unlocked keyring (UnlockedKeyring)db.listUsers(vault)— every member's{ userId, displayName, role, permissions, createdAt, grantedBy }db.listAccessibleVaults({ minRole? })—{ id, role }per vault the current user can unwrapdb.changeSecret(vault, newSecret, opts?)— re-wrap your own keyring under a new secretdb.updateUser(vault, opts)— header-only role / name / permissions change- With
withTeam():db.grant(vault, opts),db.revoke(vault, opts),db.rotate(vault, collections) - With
withCustody():db.grantCustodian(vault, opts),db.revokeCustodian(vault, opts)
See also
- team — multi-user grant/revoke/rotate, magic-link, delegation
- session — token sessions on top of keyrings
- auth-landscape — the
@noy-db/on-*unlock methods (secret, WebAuthn, OIDC, magic-link, …) - Core 02 — Enclave — the key hierarchy this page builds on