stable

Permissions & Keyring

How a vault admits users: the per-user keyring file, roles, and per-collection rw/ro/none permissions.

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

RoleCollection accessCan grant / revoke
ownerall, read-writeYes — any role
adminall, read-writeadmin / operator / viewer / client
custodianall, read-writeNo
operatorexplicit list (rw or ro per collection)No
viewerall, read-onlyNo
clientexplicit list, read-onlyNo
  • owner cannot 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).
  • custodian is 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, via db.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 why viewer (read-all) outranks client (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:

  1. A fresh salt is generated for the grantee, and their KEK is derived from the secret in the grant options.
  2. The DEKs of the granted collections — which the grantor's unlocked keyring already holds in memory — are wrapped under the grantee's new KEK.
  3. A keyring record for the grantee is written to _keyring/<userId>, stamped with grantedBy for 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 into rosterCanonical, so a store can neither edit nor strip it.
  • Two KeyringTamperedError reasons — roster-epoch-rewound (the file is older than the floor you supplied) and roster-epoch-absent (it carries no epoch at all). Both arrive at err.details.reason, not err.reason.
  • assertRosterEpochCurrent(found, expected, userId) and nextRosterEpoch(current), on the root barrel since 0.7.0-pre.10. (They shipped in pre.9 reachable from no entry point at all; pre.10 exports them.)
  • All thirteen roster-mint sites stamp the epoch before the tag is minted, and writeKeyringFile refuses 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 expectedresult
equalaccepted
found greater — the roster moved on since your floor was mintedaccepted
found lower — a rewindKeyringTamperedError, roster-epoch-rewound
found absent, expected suppliedKeyringTamperedError, roster-epoch-absent
expected absent — you supplied no flooraccepted; 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.

The anchor has to arrive out of band, and that part is yours. An epoch stored beside the roster is not an anchor — a store that rewinds the roster rewinds the epoch with it. It becomes one only when compared against a value that reached the reader by a channel the store does not carry. The hub owns the epoch and the comparison; which channel a deployment trusts is deliberately the consumer's problem, because the hub cannot know.Two alternatives were considered and rejected, recorded here so they are not re-derived: time (defeats long rewinds, useless against fresh ones — and a narrowing replay is entirely the fresh case) and the vault head (circular — head buckets are read with a keyring-issued DEK, and a rewound roster renders as the benign no-expectations verdict).

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.

Not a format break — no migration, no re-seed. The epoch is bound conditionally, so a keyring written before it existed canonicalises byte-identically and its existing tag still verifies. (Binding it the way every other optional field is bound would have failed every pre-existing tag and rendered every existing vault unopenable.) This is unrelated to the 0.6.0 on-disk format breaks — nothing stored needs to change.

Permission checks on every operation

  • Writes — every put / delete checks write permission before any encryption or store I/O; a denied write throws ReadOnlyError and the store never sees the request. The rule: owner / admin / custodian always write; viewer / client never do; operator writes 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 through vault.assertCanExport(tier, format?), backed by the public hasExportCapability(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 the exportCapability grant option — and assertCanExport('bundle') for encrypted bundles, which defaults on for owner / admin and off for everyone else. Imports are default-closed for every role, including owner, until granted via importCapability.

Critical invariants

  • No vault without a keyring. Even encrypt: false mode 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 (PrivilegeEscalationError otherwise).

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 unwrap
  • db.changeSecret(vault, newSecret, opts?) — re-wrap your own keyring under a new secret
  • db.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