Skip to content
rifleDB
Docs: Access control

Access control

How rifleDB decides who may read, write, manage and call actions, as implemented today. The normative version (with the pseudo-code) is architecture.md §4–§5; this page explains it with the moving parts in one place.

Secret means access-controlled, not end-to-end encrypted. The server stores and can read every value. rifleDB makes no zero-knowledge claims.

Identities and keys

  • Device keys. Each browser (or Node process) has an Ed25519 key, made with WebCrypto, non-extractable, kept in IndexedDB per server origin (identity: 'auto'). The user id is derived from the first device key: uid = 'u_' + b64url(sha256('rifle/uid/v1\n' + rawPub)).slice(0, 22), so it stays the same when more devices join. There are no passwords.
  • Registration happens when a new key signs in with auth {register: true}. The policy is the registration prop of <Users> in server/rifle.config.tsx: open (the shipped config), invite (needs a claim-invite token in the same message) or closed. At most 10 registrations per client address per hour.
  • More devices: an existing device creates a link code (identity.createLink(), valid 5 minutes, single use) and the new device redeems it (identity.linkDevice(code)); its key joins the same uid. A passkey can also bring a new device in (identity.recover(), below).
  • Folder keys belong to a folder and carry a permission (read, write or admin); a folder can have any number of them. They come from <Key> in the config, from the folder's managers (rifle.share.createKeyInvite, below) or from an elevated admin (keys.registerFolderKey).
  • Service keys are folder keys with authority: server code (for example a Next.js server with rifledb/next/server) can then also write the folder's server-only paths. Mint one with pnpm service-key (SDK guide); they are declared in the config.
  • Keys can expire and be revoked. Revocation, expiry, grant, role and mode changes trigger a revocation sweep: affected subscriptions end with an access {path, code} message right away.

Every message on a socket is covered by the session's proven keys (a signature over the server's per-connection nonce, rifle/ws-auth/v1); every REST request carries its own signature (Protocol).

Folders and modes

A folder is the unit of access, loading and persistence. Paths resolve to the folder with the longest matching prefix; folders nest. Static folders come from server/rifle.config.tsx, dynamic ones are created by admins, and every user gets a set from the <Users> template:

FolderModeWho
users/<uid>privatethe owner
users/<uid>/profilepubliceveryone reads, the owner writes
users/<uid>/secretsecretthe owner, after a passkey step-up
users/<uid>/serverprivate, authority: ['**']the owner reads; only server code writes

With open registration in mind, what a user stores is bounded: in users/<uid>, /profile and /secret at most 1000 keys per folder, values of at most 16 KB, and 1 MB across the three together (E_LIMIT beyond; writes that shrink or delete are always allowed). <Users limits={{ maxKeys, maxValueBytes, maxBytes }}> changes these, also for existing users. users/<uid>/server, written by server code only, keeps the server defaults (10,000 keys of up to 64 KB).

The three modes:

  • public: anyone reads, anonymous sessions included. Writes need a grant or key, unless the folder has publicWrite: true (then anyone writes, rate-limited per address and per session) or publicWrite: 'own' (anyone writes, but only keys whose first segment below the folder is their own slot — the server-issued hello.slot of their connection, rifle.status.slot in the SDK — or their uid when signed in: presence entries nobody else can move or delete).
  • private: the owner, grants and folder keys.
  • secret: exactly one principal, and only while elevated by a passkey step-up: the owner's device key for a user's secret folder, an admin's device key for system/*. Grants, folder and service keys, invites and public actions are ignored. Admins never read users' secret values (they get metadata and a wipe action, below).

Other folder settings: inherit (grants and keys of ancestors apply; never across a secret folder), grants (uid:<uid> or role:<role> with a permission), authority (globs of server-only paths), schema (per-path Standard Schema validation, config folders only), actions, limits (maxKeys, maxValueBytes, and opsPerSec: writes per second to the folder from all clients together, over every transport and including action calls — E_RATE with scope: 'folder'), and fast (in-memory, coalesced, optionally persisted snapshots and TTLs; primitives only).

The decision, in order

For every read, write, manage (keys, grants, mode, subfolders) and action call:

  1. No folder at the path → refused.
  2. Server code (the internal session) → allowed.
  3. Writing a server-only (authority) path → only an authority service key for that folder, or an elevated admin (E_AUTHORITY otherwise). In secret folders only elevated admins, and only under system/*.
  4. Secret folder → the one principal, elevated; the right principal without elevation gets E_ELEVATION_REQUIRED, anonymous sessions E_AUTH_REQUIRED, everyone else E_FORBIDDEN.
  5. Elevated admin (the admin role proven by a device key, plus an active step-up) → every non-secret folder: read, write, manage, actions. This is what lets the admin explorer use ordinary subscriptions and ops.
  6. Public folder: read allowed; write allowed with publicWrite: true, and with publicWrite: 'own' for keys under the caller's own slot or uid (other keys go on to the rules below).
  7. Owner (device key) → allowed.
  8. Grants and keys: the highest permission from grants (and ancestors' while inherit) and from the session's proven folder/service keys must reach what the call needs (read, write, admin for manage, the action's access). Otherwise E_AUTH_REQUIRED for anonymous sessions, E_FORBIDDEN for signed-in ones.

Signature failures (unknown key, bad signature, key/pub mismatch) all answer the same generic E_AUTH_REQUIRED; revocation and expiry are reported only after a signature verified.

Actions

Actions are server-side functions on a folder (<Action name args access rate handler>). access: 'public' lets anyone call them; otherwise the caller needs that permission on the folder. They run as a transaction: all writes and events apply together, a throw applies nothing. Arguments are validated with the action's schema, calls are rate-limited per user (or session) and per address, and results are JSON up to 64 KB. Actions may only write their own folder. The demo's drop and the playground's reset are examples.

Sharing a folder

Whoever manages a folder — its owner, anyone with an admin grant or folder key on it, and elevated admins — shares it without admin help, over the socket (rifle.share.*, the share.* messages) or signed REST (/v1/_/share/*, Protocol):

await rifle.share.addGrant(`users/${uid}`, `uid:${friendUid}`, 'read') // a personal grant
await rifle.share.removeGrant(`users/${uid}`, `uid:${friendUid}`) // live subscriptions end at once
const { link, tokenHash } = await rifle.share.createClaimInvite({
  folder,
  perms: 'write',
  maxUses: 5,
  ttlMs: 86_400_000,
})
await rifle.share.revokeInvite(tokenHash)
const { link: keyLink, kid } = await rifle.share.createKeyInvite({ folder, perms: 'read', label: 'tablet' })
await rifle.share.revokeKey(kid)

The same rules hold as for the admin actions: secret folders cannot be shared (no grants, invites or keys), system/* never, grants and claim invites of config folders are set in the config (folder keys are allowed there). Changes are audited with the caller as actor and rate-limited (10 a minute per person, 30 per address). rifle.keys.createClaimInvite and createKeyInvite use this API too, unless the client was created with manage (the admin app uses ADMIN_MANAGE, the step-up-guarded admin actions).

Invites

Invite links carry everything in the URL fragment (#rifle-invite=…), which browsers never send to servers or Referer headers. They are created by the folder's managers (above) or elevated admins (invites.createClaim, keys.registerFolderKey), and redeemed with rifle.keys.importInvite(url).

  • Claim invite (preferred): a random token, stored hashed with maxUses (≤ 10,000) and an expiry (≤ 30 days). Claiming it with a signed request gives the claimer's uid a personal grant, revocable per person. The grant cannot be passed on; the link itself works for whoever holds it until its uses run out or it expires.
  • Key invite: carries a bearer folder key (private key in the fragment, made by the inviting client; the server only learns the public half); the SDK imports it non-extractable. Anyone holding the link can use the key and pass it on: transferability cannot be prevented cryptographically. Revoke the key (rifle.share.revokeKey(kid)) to end it.
  • Secret folders refuse both.

Passkeys and step-up

Passkeys (WebAuthn, @simplewebauthn) do not sign people in; device keys do. Passkeys elevate a session for 10 minutes (stepUp()), which secret folders and admin mutations require, and they recover an account on a new device.

  • Relying parties come from RIFLE_WEBAUTHN ([{rpID, rpName, origins}]); the one used is picked by the request's Origin. A passkey only works on its rpID.
  • First-passkey rule: a user with no passkey yet may register one with a device key alone (that key is already their strongest credential; audited as first-passkey). Every further passkey needs an elevated session. At most 16 passkeys per user.
  • Elevation is bound to one socket (or, over REST, to an elevation token that only works together with a signature by the same device key). A reconnect loses it; the SDK re-subscribes refused scopes after the next step-up. Removing a passkey ends the elevations it granted.
  • identity.recover() signs in on a new device with a discoverable passkey: the new device's key joins the uid and the session is elevated.
  • Users list and remove their own passkeys (rifle.passkeys.list() / remove(credId), elevated). Removing the last one is allowed; the first-passkey rule then lets a device key add a new one.

Admins

  • Bootstrap. While no admin exists, the server prints a one-time setup token at startup (32 random bytes, stored hashed, valid 60 minutes, single use; a restart prints a new one; RIFLE_ADMIN_SETUP=off disables it). The admin app's sign-in page claims the role with it (admin.bootstrap), then registers the first passkey under the first-passkey rule. Deployment steps: deploy/README.md.
  • Without elevation an admin reads what grants give the admin role: system/metrics and system/ops (live dashboards keep running when elevation expires) and the read-only views on system/admin (tree.list, folders.get, users.list, users.get, keys.list, connections.list, audit.query, secret.meta, data.meta, and metrics.history on system/metrics).
  • Elevated, an admin reaches every non-secret folder (rule 5) and the mutations on system: folders.create, folders.update, folders.delete, grants.add, grants.remove, keys.revoke, keys.registerFolderKey, invites.createClaim, users.setRoles (the last admin cannot lose the role), users.resetPasskeys, connections.kick, secret.wipe. Config folders (and their grants) are set in the config only; system/* cannot be created, changed or deleted through the API; of a user-template folder only the grants can be changed.
  • Users' secret folders are never readable by admins: secret.meta returns {exists, keys, bytes, lastWrite} and secret.wipe deletes the contents.
  • Stolen device: users.resetPasskeys {uid, revokeDevices: [kid] | 'all'} revokes the stolen device key(s) and removes the passkeys in one audited operation. Resetting passkeys without revoking the device key is not enough: under the first-passkey rule the thief's key could register a new passkey right away.
  • Admin actions are audited (actor, action, target, metadata without secrets) in the Redis stream audit.

Rate limits and address keys

Per-address limits key IPv4 addresses as they are and IPv6 addresses by their /64. Anonymous writes to publicWrite folders draw on 30/s per session and 200/s per address — fast folders on their own 1000/s per address, enough for presence of 50 visitors behind one NAT at 20 Hz; beyond it every visitor's updates slow down evenly instead of queueing (the SDK keeps one unsent write per key and holds back only that folder). One address may hold 64 sockets at once (RIFLE_MAX_CONNECTIONS_PER_IP). Behind a reverse proxy the server only sees the proxy's address unless RIFLE_TRUST_PROXY lists the proxy's network; see Operations.