Multi-tenancy: user accounts and per-user vaults #111

Open
opened 2026-09-04 16:17:53 +02:00 by lz · 0 comments
Owner

Exploratory. Nexus is single-operator by design ("There is no user table — a master passphrase unlocks the vault", AGENTS.md). This issue records what actually has to change if that stops being true, so the decision is made deliberately rather than discovered halfway through.

Not a commitment to build it. Prerequisite for internet exposure: #110.

The load-bearing assumption

event.locals.masterKey is not an identity token — it is the unlocked master key, held in RAM for the duration of a session (hooks.server.ts:17, auth/session.ts:4). Everything sealed under it (connections.encrypted_token, repo_env_vars) is instance-global, decryptable by anyone holding the one passphrase.

Multi-tenancy is not "add a user table and a user_id column". It requires deciding what a tenant owns and re-keying accordingly. The realistic shape is per-user key material: a per-user key wrapped by that user's password-derived key, so a user's secrets are unreadable without their credentials. That is a vault redesign, and it invalidates the current rotateMasterPassphrase + sealed-columns.test.ts contract.

What each subsystem inherits from single-tenancy

  • The claude OAuth session is fleet-shared. /session/dot-claude/ is one directory symlinked into every worker, one .credentials.json, one account (AGENTS.md fact #3). Quota is account-global and stored once, last-write-wins (fact #19). Tenants sharing one Anthropic account is almost certainly not what you want, and separating it means per-tenant session volumes — which touches the worker entrypoint, the Config Explorer bind mount, and quota.
  • The shared ~/.claude config volumesettings.json, CLAUDE.md, skills/, agents/ — is live-shared across the whole fleet. Under multi-tenancy every edit in the Config Explorer is an edit to everyone's agent config.
  • Workers, sessions, previews, artifacts, mounts all carry no owner. Ownership columns and authorization at every route are mechanical but wide.
  • /api/auth/lock is global (#110 item 3).
  • Preview proxy binding is instance-wide (preview_bind_interface). One tenant switching to unrestricted exposes every tenant's previews.
  • Instance settings are exactly that — instance-scoped. Several would need to become per-user, which the settings registry has no concept of.

Session model

Whatever else changes, sessions need what #108 establishes: sliding idle timeout plus an absolute ceiling, and sliding driven by user-initiated requests rather than background polls. #108 deliberately builds that shape now so it does not need revisiting here.

Sessions also stop being able to live only in a Map — see #110 item 2. The hard part is not storage, it is that a persisted session without its key is authenticated but locked, so key custody across restarts has to be answered first.

Suggested decomposition, if pursued

  1. #110 first — hardening is required regardless and is useful on its own.
  2. Identity: user table, password login, session→user binding. No authorization yet.
  3. Vault redesign: per-user key wrapping, re-seal, rotate.
  4. Ownership + authorization on workers/sessions/previews/artifacts.
  5. Per-tenant claude session volume and quota attribution.
  6. Per-user vs instance settings split.

Steps 3 and 5 are the expensive ones and are where this should be reconsidered.

Exploratory. Nexus is single-operator by design ("There is no user table — a master passphrase unlocks the vault", AGENTS.md). This issue records what actually has to change if that stops being true, so the decision is made deliberately rather than discovered halfway through. Not a commitment to build it. Prerequisite for internet exposure: #110. ## The load-bearing assumption `event.locals.masterKey` is not an identity token — it **is** the unlocked master key, held in RAM for the duration of a session (`hooks.server.ts:17`, `auth/session.ts:4`). Everything sealed under it (`connections.encrypted_token`, `repo_env_vars`) is instance-global, decryptable by anyone holding the one passphrase. Multi-tenancy is not "add a user table and a `user_id` column". It requires deciding what a tenant *owns* and re-keying accordingly. The realistic shape is per-user key material: a per-user key wrapped by that user's password-derived key, so a user's secrets are unreadable without their credentials. That is a vault redesign, and it invalidates the current `rotateMasterPassphrase` + `sealed-columns.test.ts` contract. ## What each subsystem inherits from single-tenancy - **The claude OAuth session is fleet-shared.** `/session/dot-claude/` is one directory symlinked into every worker, one `.credentials.json`, one account (AGENTS.md fact #3). Quota is account-global and stored once, last-write-wins (fact #19). Tenants sharing one Anthropic account is almost certainly not what you want, and separating it means per-tenant session volumes — which touches the worker entrypoint, the Config Explorer bind mount, and quota. - **The shared `~/.claude` config volume** — `settings.json`, `CLAUDE.md`, `skills/`, `agents/` — is live-shared across the whole fleet. Under multi-tenancy every edit in the Config Explorer is an edit to everyone's agent config. - **Workers, sessions, previews, artifacts, mounts** all carry no owner. Ownership columns and authorization at every route are mechanical but wide. - **`/api/auth/lock` is global** (#110 item 3). - **Preview proxy binding** is instance-wide (`preview_bind_interface`). One tenant switching to `unrestricted` exposes every tenant's previews. - **Instance settings** are exactly that — instance-scoped. Several would need to become per-user, which the settings registry has no concept of. ## Session model Whatever else changes, sessions need what #108 establishes: sliding idle timeout plus an absolute ceiling, and sliding driven by user-initiated requests rather than background polls. #108 deliberately builds that shape now so it does not need revisiting here. Sessions also stop being able to live only in a `Map` — see #110 item 2. The hard part is not storage, it is that a persisted session without its key is authenticated but locked, so key custody across restarts has to be answered first. ## Suggested decomposition, if pursued 1. #110 first — hardening is required regardless and is useful on its own. 2. Identity: user table, password login, session→user binding. No authorization yet. 3. Vault redesign: per-user key wrapping, re-seal, rotate. 4. Ownership + authorization on workers/sessions/previews/artifacts. 5. Per-tenant claude session volume and quota attribution. 6. Per-user vs instance settings split. Steps 3 and 5 are the expensive ones and are where this should be reconsidered.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lz/agent-nexus#111
No description provided.