Skip to content

Multi-User Architecture Plan

Status: Phases 0 through 4 landed, plus SSO/SCIM. Phase 0 added the Principal type, schema additions, and shadow-mode audit middleware. Phase 1a added scoped repositories with cross-tenant isolation enforced in SQL plus the API-route sweep. Phase 1b added vault scoping, per-user DEKs (HKDF), the WorkspaceFS sandbox, the vault rotation script, and the filesystem tool wiring. Phase 1c closed the cross-tenant gap on permissionManager.approve/deny. Phase 2 landed admin console, channel binding, and API tokens UI; Phase 3 landed Postgres RLS, shell sandbox, Docker per-user isolation, and master-key rotation; Phase 4 landed workspace_id adoption; and §22 landed SAML SSO + SCIM provisioning. Multi-user is no longer an opt-in mode: every request carries a Principal, workspaces are per-user, and the old MULTIUSER feature flag is gone — see multiuserConfigSchema in src/config/schema.ts. The remaining multiuser.* config keys (auditShadow, enforcePermissions, unattendedDenyActions, RLS enforcement) tune independent features layered on top, not whether multi-user isolation applies at all. Scope: extend Octipus from a single-tenant self-hosted instance into a central backend serving multiple authenticated users (and, optionally, organizations) with scoped data, secrets, and execution controls. Their presence is not proof of complete tenant isolation across every integration. Note: the backend runtime is Node + Hono (Bun/Elysia are gone — see DEVELOPMENT.md). Below, “Elysia plugin”/“Elysia route”/“Elysia handler” refer to the local Elysia-named compat class in src/api/http that wraps Hono, not the third-party elysia package, which is not a dependency.

This file combines implementation notes with the original phased proposal. Historical sections retain retired flags, proposed paths, estimates, and old behavior; they are not a current operator runbook or a security certification. Use Configuration, API, and Design principles for current interfaces and limits. In particular, MULTIUSER=false and system-user permission bypasses below are retired: unattended ASK blocks, attended descendants inherit the approval surface, and MCP dispatch checks canonical action permissions.

  • multiuser.{enabled,auditShadow,enforcePermissions} config flags (env: MULTIUSER, MULTIUSER_AUDIT_SHADOW, MULTIUSER_ENFORCE_PERMISSIONS). Defaults since May-5 flip: true, true, true. Legacy single-user installs opt out via MULTIUSER=false (and MULTIUSER_ENFORCE_PERMISSIONS=false if the system-user bypass on MCP-bridge calls is still needed).
  • src/security/principal.ts — Principal type + helpers (principalFromUser, principalFromMasterKey, ANONYMOUS_PRINCIPAL, SYSTEM_PRINCIPAL, canActOnUser).
  • src/api/server.ts — auth .derive() now returns principal alongside the legacy user field; MASTER_KEY Bearer fallback is suppressed when multiuser.enabled === true.
  • Migration 0029_multiuser_phase0.sql — adds nullable owner columns (embeddings.user_id, agent_events.user_id, swarm_nodes.user_id, hook_executions.user_id, users.org_id) plus indexes, and the audit_action='api_request' enum value.
  • src/api/middleware/audit-shadow.ts — Elysia plugin that writes one audit_log row per state-changing API request, gated on multiuser.auditShadow. Never blocks: any error is swallowed.
  • Tests: principal helpers (8), resourceTypeFromPath (3), end-to-end audit middleware against ephemeral PGlite (7). Net +19 passing, zero regressions.
  • src/db/repositories/scoped.ts — scopedRepos(principal) factory returning ScopedSessionRepo, ScopedMessageRepo, ScopedAgentRepo, ScopedDocumentRepo. Every default read filters by principal.userId; cross-tenant reads return null / [] so the caller can’t distinguish from “row does not exist”, blocking UUID enumeration. Mutations check ownership in the WHERE clause and strip user_id from caller-supplied patches. Admins get widened reads via isAdmin(principal); admin-only methods (listAllAdmin) throw for non-admins.
  • Cross-tenant isolation tests (scoped.isolation.test.ts, 21 tests against PGlite) cover every method on every scoped repo.
  • /api/sessions route fully converted: every handler now goes through scopedRepos(principal); the hand-rolled per-handler “is admin or owner?” checks are gone — the scope IS the check.
  • Route-level isolation test (sessions.isolation.test.ts, 6 tests) proves user A cannot read/list/mutate/delete user B’s sessions or messages through the actual Elysia handler.
  • apiContext now exposes principal to all routes (anonymous default for unauthenticated requests).
  • Net +27 passing tests, zero regressions.

The follow-up sweep converted every per-user-data route to scopedRepos(principal):

  • agents + documents — DB-history reads scoped; documents 403→404 to prevent ID enumeration.
  • notifications — fixed cross-tenant markRead (any user could flip any notification’s read flag); list/count already user-scoped.
  • trajectories — fixed cross-tenant list (every user’s runs were visible); admins still see all.
  • chat — getPendingApprovals was global (leaked pending approvals between users); resolveApproval had no ownership check. ApprovalRequest gained userId + sessionId; peek(requestId) added so callers verify ownership before resolving.
  • hooks + recurring-tasks — all per-id endpoints (GET, PATCH, DELETE, toggle, test, executions) use scoped lookups.
  • pipelines — findById for pipelines; closed two complete-bypass gaps on PUT /pipelines/templates/:id and DELETE /pipelines/templates/:id (pre-Phase-1a these had NO auth check — any authenticated user could mutate any template, including system presets).

ScopedRepos bundle now exposes: sessions, messages, agents, documents, notifications, trajectories, hooks, pipelines. Every member is Principal-bound; cross-tenant reads return null/[] (collapsing 403/404 to 404 across the board).

Cumulative test count: 1061 pass / 9 fail / 62 skip (the 9 are the same pre-existing StdioTransport failures from before Phase 0). Net +24 cross-tenant isolation tests across hooks, pipelines, chat, notifications, trajectories — all backed by ephemeral PGlite, no Docker required.

Routes that intentionally stay unscoped: /api/auth/*, /api/health/*, /api/webhooks/*, /api/oauth/* (different threat models).

Phase 1b — vault scoping, per-user DEKs, WorkspaceFS

Section titled “Phase 1b — vault scoping, per-user DEKs, WorkspaceFS”

Three commits, each independently shippable:

Phase 1b-1 — vault scope enum + strict reads

Section titled “Phase 1b-1 — vault scope enum + strict reads”
  • New vault.scope enum (system | user | workspace) with backfill: legacy user_id='system' → scope='system'; everything else → scope='user'. NOT NULL after backfill.
  • getSystemSecret(name) is now strict — pre-Phase-1b it had a fallback that scanned ALL users’ secrets, leaking another user’s private key under the right conditions. The fallback is gone.
  • New vault.getForAgent({ userId, toolId, agentId }, name) is the scope-aware lookup the secret-injector uses: user → system, with the per-row allowed_tools / allowed_agents allowlist applied.
  • 12 PGlite-backed isolation tests covering scope reads, cross-tenant denial, allowlist enforcement.

Phase 1b-2 — per-user data-encryption keys (DEK)

Section titled “Phase 1b-2 — per-user data-encryption keys (DEK)”
  • New vault.key_version column. v1 = legacy PBKDF2 (single key for every row). v2 = HKDF(masterKey, salt=userId, info=scope:userId).
  • deriveDek(masterKey, scope, userId) in src/utils/crypto.ts — deterministic per (master, scope, user) triple. Same call always yields the same key, so DEKs are never persisted.
  • New writes default to v2. Reads dispatch on key_version, fall through legacy schemes if needed, and opportunistically re-encrypt to v2 on success (no batch-rotation tool yet — separate commit).
  • Cross-user isolation: alice’s ciphertext can’t be decrypted with bob’s DEK even if the master key is constant.
  • 9 unit + integration tests against PGlite.
  • New WorkspaceFS.forPrincipal(p) in src/security/workspace-fs.ts. Per-user filesystem root at $DATA_ROOT/users/{user_id}/workspaces/{workspace_id}/files/. (Coworking S0c: the segment is the principal’s workspace id, except the user’s default workspace, which keeps default. forAgent takes the agent’s AgentContext, or { system: true, root } for a system job, and never falls back to the flat root; forSession uses session.workspaceId; forRequest(principal) gives REST routes the agent’s view with the same extras.)
  • resolve(userPath) enforces:
    • relative paths land under root
    • absolute paths must be under root (or an extra-allowed prefix)
    • .. traversal that escapes the root → throws
    • symlinks pointing outside the root → throws (via realpath)
    • null bytes / non-string inputs → throws
  • Cross-tenant property: aliceFs.resolve('foo') and bobFs.resolve('foo') produce paths in disjoint trees; alice cannot reach bob’s root via ../ traversal.
  • 19 unit tests covering construction, relative paths, traversal, absolute paths, symlinks, cross-tenant disjoint roots, and the extraAllowedPrefixes escape hatch.

Phase 1b cumulative: 1101 pass / 9 fail / 62 skip (the 9 are still the pre-existing StdioTransport tests). Net +40 isolation tests across 1b-1, 1b-2, 1b-3.

  • Vault key rotation script (scripts/rotate-vault-keys.ts). Walks every key_version=1 row and forces the lazy v1 → v2 re-encrypt that vault.get does on read. --dry-run reports candidates without writing; --batch=N tunes batch size; rows that fail to decrypt stay at v1 and are logged so an operator can investigate without aborting the run. 3 PGlite tests.
  • Filesystem tool wiring: src/tools/filesystem/index.ts’s validatePath now delegates to WorkspaceFS.forAgent(context). Same call site, same exceptions; behavior diverges by config:
    • multiuser.enabled=false → flat root at config.workspace.rootPath (legacy single-user behavior, byte-for-byte identical).
    • multiuser.enabled=true → per-user root at <workspace.rootPath>/users/{userId}/workspaces/default/files. Both modes preserve additionalPaths and the legacy /tmp/assistant- prefix. WorkspaceFS.withRoot and forAgent factories added; new tests cover the flat root path and the legacy prefix back-compat.

Phase 1b totals: 1107 pass / 9 fail / 62 skip (the 9 are still the pre-existing StdioTransport tests). Net +46 isolation tests across 1b-1, 1b-2, 1b-3, plus the rotation + filesystem cleanup.

The chokepoint was already in place — BaseTool.executeWithMiddleware calls permissionManager.check(userId, toolId, action, args) and on ASK calls requestApproval then waitForApproval. Phase 1c closes the gaps that made the chokepoint untrustworthy:

  • approve / deny cross-tenant gap. Pre-Phase-1c the WHERE clause matched (id = requestId AND status = 'pending') only — no user filter. The gateway handler called approve(requestId, ctx.userId) but ctx.userId was used as the resolvedBy audit field, NOT the ownership filter. Any authenticated caller with a leaked requestId could approve or deny another user’s pending request. Now the WHERE also requires user_id = resolvedBy; cross-tenant attempts return false (silent no-op, indistinguishable from “already resolved” or “doesn’t exist”). New { admin: true } opt-in lets the future admin console resolve any user’s request.
  • isSystemUser bypass. The legacy bypass that lets MCP / API bridges skip the permission prompt is now gated on multiuser.enforcePermissions. With the flag off, behavior is unchanged. With it on, every tool dispatch goes through permissionManager.check — system pseudo-users can no longer skip.
  • getPendingRequests(userId) is already user-scoped (filters by permission_requests.user_id); verified and covered by tests.

Phase 1c totals: 1114 pass / 9 fail / 62 skip, net +7 isolation tests, zero regressions. With this commit, every cross-tenant gap identified in the original Phase 1 audit is closed. Phase 1 is done.

Phase 2a — personal access tokens (backend)

Section titled “Phase 2a — personal access tokens (backend)”

The MASTER_KEY Bearer fallback was the only non-browser auth path. With multiuser.enabled = true it gets suppressed (Phase 0), which means CI / MCP / scripted clients couldn’t authenticate at all once the flag flipped. Phase 2a fills that gap with proper per-user personal access tokens.

  • New api_tokens table (migration 0032): (id, user_id, name, token_hash, prefix, scopes, expires_at, last_used_at, revoked_at, metadata, created_at). token_hash is SHA-256 hex of the plaintext, unique-indexed for O(1) validation lookup.
  • Token format: octi_<43-char-base64url> (32 bytes of randomness + a recognizable prefix). Plaintext is shown ONCE at creation; only the hash is persisted. The first 12 chars (octi_abc1...) are stored as prefix for display in the list view so users can identify tokens without revealing the secret.
  • src/security/api-tokens.ts — ApiTokenManager with issue / validate / listForUser / revoke / countActive. Validation enforces revocation + expiry, updates last_used_at in the background, and uses looksLikeApiToken() as a cheap pre-check so non-token Bearer values skip the DB roundtrip.
  • .derive() in src/api/server.ts now tries: (1) session token, (2) api token (new), (3) MASTER_KEY fallback if multiuser is off. The api-token path loads the user record and returns a normal principalFromUser — every downstream scopedRepos(principal) call works identically to a browser session login.
  • /api/auth/api-tokens REST surface: GET / (list, no plaintext or hash), POST / (issue, plaintext returned exactly once, 201 Created), DELETE /:id (revoke; cross-tenant attempts return 404 to prevent ID enumeration).
  • 22 PGlite-backed tests: 15 unit (format, hash determinism, validate happy/revoked/expired/unknown/malformed, list scoping, cross-tenant revoke + admin override, double-revoke, countActive) and 7 route (POST 201 + plaintext, 401 on anonymous, 400 on bad date, GET shape doesn’t leak hash, cross-tenant DELETE returns 404, owner can revoke). Verified resilient to the commands.test.ts mock.module ordering issue.

Phase 2a totals: 1136 pass / 9 fail / 62 skip, net +22, zero regressions. With this commit, deployments can flip MULTIUSER=true without breaking CI / MCP / scripted clients — operators just rotate from MASTER_KEY to per-user tokens before the cutover.

The web UI (settings page + one-time copy modal + revoke button) is the next slice (Phase 2b). Backend is mergeable on its own; admins / CI can manage tokens via the REST API in the meantime.

Settings page gains an “API Tokens” tab between Security and Voice. Three things happen here:

  1. List existing tokens (no plaintext, no hash on the wire).
  2. Generate a new token. The plaintext appears in a one-time copy modal that the user must dismiss explicitly. After dismissal the plaintext is gone from React state.
  3. Revoke a token (with a confirm step — a misclick shouldn’t kill a CI integration).

The plaintext modal is the load-bearing UX. Copy button uses navigator.clipboard.writeText with a textarea fallback for older browsers / http://. Active and revoked tokens are listed separately; revoked rows stay visible (dimmed, struck through) for audit. No backend changes — purely consumes the Phase 2a REST.

Phase 2c — admin console (users + audit)

Section titled “Phase 2c — admin console (users + audit)”

/api/admin/* routes guarded by requireAdmin(ctx): anonymous → 401, non-admin → 403. Admin → route body. Self-demotion and self-deactivation guard returns 400 to prevent the only admin from locking themselves out.

  • GET /api/admin/users — list every user (no password hashes / TOTP secrets).
  • POST /api/admin/users — create user; optional initial password (argon2id-hashed); isAdmin / isActive flags; audit row written.
  • PATCH /api/admin/users/:id — update one user (email, password, isAdmin, isActive); audit row written; unknown id → 404.

Deactivation has one writer, setUserActive (src/security/user-lifecycle.ts), which records who did it in users.deactivated_by (admin or scim:<orgId>) and takes effect at once: the user’s sessions are revoked, every socket (gateway, browser bridge, /voice) is closed, their agents stop, pending permission and approval prompts expire, and any admin impersonation of them ends. Session and API-token validation re-read is_active / is_admin on every request; hooks, heartbeats and monitors skip an inactive owner at fire time. A change of isActive or isAdmin closes the user’s gateway connections (onUserChanged), so rights are recomputed on reconnect.

  • GET /api/admin/audit — filterable audit-log viewer (action, userId, limit ≤ 1000).

Web /admin/* tree gated by useAuth().user.isAdmin in AdminLayout. Server-side check is the security boundary; the redirect is purely UX so a non-admin doesn’t land on a page where every API call returns 403. /admin/users is a table with disable / enable + grant / revoke admin actions; self-actions are disabled in the UI to mirror the server’s 400 guard. /admin/audit is a filterable viewer.

Out of scope for this slice: impersonation (start/stop with banner), quotas dashboard (no per-user quotas instrumented yet), password reset flow.

Section titled “Phase 2d — channel binding (table + helper + link-account flow)”

The legacy users.channelBindings JSONB column was the only mapping from a channel external_id (Telegram chat_id, Slack user_id, …) to an Octipus user. That made channel-side lookups O(N) over the user table and gave no place to enforce uniqueness — two users could silently share the same external_id. Phase 2d replaces it with a proper relational schema plus a one-time code flow for new bindings.

  • channel_identities (id, user_id, channel_type, external_id, external_handle, metadata, verified_at, created_at) with a unique index on (channel_type, external_id) so lookup is O(1).
  • channel_link_codes (id, code, channel_type, external_id, external_handle, expires_at, redeemed_at, redeemed_by_user_id, created_at) for the one-time code redemption.

ChannelBindingManager (src/security/channel-bindings.ts)

Section titled “ChannelBindingManager (src/security/channel-bindings.ts)”
  • findUserByExternalId(channelType, externalId) — checks channel_identities first, falls back to scanning the legacy JSONB column, opportunistically backfilling matches into the new table.
  • createPendingLink(channelType, externalId, externalHandle?) — mints a 6-character code from an unambiguous alphabet (ABCDEFGHJKMNPQRSTUVWXYZ23456789 — no 0/O, 1/I/L) with a 15 min TTL.
  • redeem(userId, code) — case-insensitive; rejects unknown, expired, or cross-user-claimed codes; idempotent for the same user re-redeeming their own code.
  • listForUser, unbind (cross-tenant safe; admin override), and reapStaleCodes for housekeeping.
  • GET /api/auth/channel-bindings — list the principal’s bindings.
  • POST /api/auth/channel-bindings/redeem — body { code }.
    • 201 on success.
    • 400 on unknown_code / expired / already_redeemed.
    • 409 on already_bound_to_another_user.
  • DELETE /api/auth/channel-bindings/:channelType/:externalId — unbind. Cross-tenant attempts return 404.

Authenticated page; if ?code=ABC123 arrives in the URL it pre-fills the input. Big single-purpose code box, 6+ digit alphanumeric mask. On success: green confirmation banner. On error: human-readable message (That code has expired. Send a fresh message in your channel to get a new one.). Lists existing bindings with an unbind button.

  • 11 unit (channel-bindings.test.ts): code shape, happy round-trip, case-insensitive redemption, unknown / expired / cross-user rejection, idempotent same-user replay, legacy-JSONB fallback + backfill, cross-tenant unbind.
  • 7 route (channel-bindings.isolation.test.ts): GET 401/200, POST 201/400/409, DELETE 200/404 cross-tenant + owner.
  • Channel adapters reading the new helper. Adapters today still call userRepository.findByChannelBinding; Phase 2e will swap them to channelBindingManager.findUserByExternalId so the adapter sends a deep-link reply when the lookup misses.
  • Bulk migration of users.channelBindings JSONB → channel_identities (the lazy backfill on read does this incrementally, but a one-shot script would speed up the cutover).

With 2a/2b/2c/2d landed:

  • MULTIUSER=true is now safe to flip in production. CI / MCP / scripted clients use API tokens; admins can manage users via /admin/users; channel adapters can adopt the binding flow at their own pace.
  • Phase 2 cumulative: 1164 pass / 9 fail / 62 skip — same 9 pre-existing StdioTransport failures, net +50 isolation tests across the four slices, zero regressions.
  • Out: Phase 3 — Postgres RLS, master-key rotation, shell sandbox via bubblewrap, Docker-in-Docker per-user labels, optional org/workspace grouping layer.

Phase 2e — channel adapters on the new manager

Section titled “Phase 2e — channel adapters on the new manager”

The four channel adapters (telegram, slack, whatsapp, teams) each called userRepository.findByChannelBinding('<channel>', externalId) to resolve an incoming message to an Octipus user. That path was O(N) over the user table, scanned the legacy users.channelBindings JSONB column, and missed the UNIQUE index the new channel_identities table provides — two users could be silently bound to the same external_id without anyone noticing.

Phase 2e swaps the lookup without changing adapter shape:

  • channels/linking.ts is now a thin bridge over ChannelBindingManager. generateLinkCode() / redeemLinkCode() preserve their public { success, error } shape so the auth route (/api/auth/redeem-link-code) and the /link channel commands keep working unchanged. Codes now live in channel_link_codes (Postgres, 15-min TTL) instead of Redis (5-min TTL); successful redemption writes to both channel_identities (canonical) and users.channelBindings JSONB (legacy mirror) so unmigrated readers stay correct.
  • Adapter lookups swap to channelBindingManager.findUserRecordByExternalId('<channel>', externalId). The new helper is O(1) on the (channel_type, external_id) unique index, with the manager’s built-in JSONB fallback + lazy backfill so legacy bindings still resolve through the same call.
  • The dropped userRepository imports in each adapter are intentional — findByChannelBinding stays on the repo for any remaining callers, but the channel surface no longer depends on it.
  • 4 new (linking.test.ts): bridge writes through the new tables, legacy error strings still surface (Invalid or expired link code, already been used, etc.), legacy JSONB column is mirrored on success.
  • Existing channel-adapter tests pass unchanged — the lookup interface is the same shape (User | null).

Cumulative: 1168 pass / 9 fail / 62 skip — same 9 pre-existing StdioTransport tests, net +4 in this commit, zero regressions.

With this commit Phase 2 is closed. From a fresh deploy on, every new channel binding lands in channel_identities with proper uniqueness; the legacy JSONB column is read-only fallback for already-deployed installs and gets backfilled into the new table on first read.

Phase 1b-2 introduced per-user DEKs derived via HKDF from the master key. The DEKs themselves never live on disk, but the master key sits in env (MASTER_KEY); when that key gets compromised — committed to a public repo, exposed in a leaked .env, or just rotated on a hygiene cadence — every vault row needs to be re-encrypted with a freshly-derived DEK. Phase 3a ships the tooling.

rotateVaultRowMasterKey(rowId, oldMaster, newMaster)

Section titled “rotateVaultRowMasterKey(rowId, oldMaster, newMaster)”

New helper exported from src/security/vault.ts. Takes both master keys explicitly so it can run safely while the live server is still on the OLD key. For one row:

  1. Derive oldDek = HKDF(oldMaster, scope, userId).
  2. Derive newDek = HKDF(newMaster, scope, userId).
  3. Decrypt with oldDek. On success, re-encrypt with newDek and persist (UPDATE vault SET ...).
  4. On oldDek decrypt failure, try newDek — if it succeeds the row was already rotated by an earlier run, return 'skipped'.
  5. Otherwise return 'failed' (the row stays untouched, the script logs and continues).

Per-row outcome is 'rotated' | 'skipped' | 'failed'. The scope + userId pair is preserved across the rewrite, so cross-tenant isolation between alice’s and bob’s rows survives the rotation.

Walks every active row in batches and calls the helper. CLI:

OLD_MASTER_KEY=<old hex> NEW_MASTER_KEY=<new hex> \
npx tsx scripts/rotate-master-key.ts [--dry-run] [--batch=N]

The script deliberately does NOT touch the deployment’s MASTER_KEY env var — that swap is a separate operator step. Documented runbook:

  1. openssl rand -hex 32 to generate the new key.
  2. Stop the Octipus server (or maintenance window).
  3. Run the script with both keys in env.
  4. Update MASTER_KEY=<new> in the deployment.
  5. Restart, verify with a vault read.

5 PGlite-backed tests (scripts/rotate-master-key.test.ts):

  • Round-trip: old-master ciphertext decrypts after rotation when derived with new-master DEK.
  • Cross-user isolation preserved (alice’s DEK still can’t decrypt bob’s row after rotation).
  • Idempotent: re-running the script on an already-rotated row returns 'skipped' and leaves the row untouched.
  • Untouched rows: a row encrypted under neither key returns 'failed' and is not modified.
  • Scope-correct: a scope='system' row uses the system DEK, not a user DEK.

Cumulative: 1173 pass / 9 fail / 62 skip — net +5, zero regressions, same 9 pre-existing StdioTransport failures.

Phase 3b — Postgres Row-Level Security (defense-in-depth)

Section titled “Phase 3b — Postgres Row-Level Security (defense-in-depth)”

The application-layer scopedRepos(principal) from Phase 1a is the primary enforcement; RLS is the belt-and-suspenders second layer that catches anything the scope might miss in the future (a forgotten WHERE, raw SQL, or new untrusted code path).

Every policy uses the same shape:

USING (
COALESCE(current_setting('app.bypass_rls', true), 'true') = 'true'
OR user_id::text = current_setting('app.current_user_id', true)
)

The COALESCE(..., 'true') makes RLS a no-op when the GUCs aren’t set. Means: every existing code path (cron jobs, system-scoped queries, anything not yet wired through the new wrapper) continues to work without changes when the migration runs. RLS only blocks when the connection wrapper explicitly sets bypass_rls='false' AND current_user_id=<uuid>.

withRlsPrincipal(principal, fn) (src/security/rls.ts)

Section titled “withRlsPrincipal(principal, fn) (src/security/rls.ts)”

Opens a Drizzle transaction, sets the GUCs scoped to the transaction, and runs the callback. The callback receives the transaction handle so its repository calls execute under RLS. On commit/rollback the GUC vanishes — pooled connections cannot leak the user id to the next request.

await withRlsPrincipal(principal, async (tx) => {
return tx.select().from(sessions);
});

withRlsBypass(fn) is the escape hatch for system jobs that legitimately read across users (orphan reaper, compaction, cron).

When multiuser.rlsEnabled = false (default), both wrappers no-op and run against the global db handle without a transaction — zero overhead, zero behavior change.

First slice covers the highest-value tables: sessions, vault, api_tokens, channel_identities. Each gets ENABLE ROW LEVEL SECURITY + FORCE ROW LEVEL SECURITY (so the policy applies even to the table owner) + CREATE POLICY ... USING ... WITH CHECK ....

vault has an extra clause: rows with scope = 'system' are visible to any authenticated principal, since application-layer allowlists (canAccessByName, allowed_tools, allowed_agents) already gate which secrets a tool can actually read.

The remaining user-owned tables (documents, agents, agent_events, embeddings, hooks, hook_executions, pipelines, notifications, trajectory_runs, swarm_nodes, recurring_tasks) follow in 3b-2 once the first slice has run in staging.

PGlite ignores RLS — its single-superuser engine bypasses policies structurally. So:

  • The migration applies cleanly on PGlite (syntax accepted) but the policies don’t fire — embedded installs are unaffected.
  • PGlite tests verify the migration applies + the wrapper sets the GUCs correctly inside its transaction. Behavioral RLS enforcement is gated on INTEGRATION=1 (Docker) tests against real Postgres + a non-superuser app role.

To actually get RLS enforcement after merging this:

  1. Apply migration 0034.
  2. Create a non-superuser app role (separate from the migration role that has BYPASSRLS):
    CREATE ROLE octipus_app NOLOGIN;
    GRANT octipus_app TO <login_role>;
  3. Point DATABASE_URL at the app role.
  4. Set MULTIUSER_RLS=true.
  5. Verify: cross-tenant integration test or manual psql probe.
  • 4 PGlite (rls.test.ts): wrapper no-ops when flag off; sets GUCs inside the transaction when flag on; rejects anonymous principals; migration installs policies on the four target tables.
  • 4 Postgres-only (skipped without INTEGRATION=1): cross-tenant enforcement, withRlsBypass returns rows from every user.

Cumulative: 1177 pass / 9 fail / 66 skip — same 9 pre-existing StdioTransport tests, net +4 PGlite + 4 added integration-skipped, zero regressions.

Phase 3b-2 — RLS sweep complete (remaining tables)

Section titled “Phase 3b-2 — RLS sweep complete (remaining tables)”

Migration 0035_rls_policies_remaining.sql extends the same “bypass on missing GUC” pattern to the rest of the user-owned tables. Three policy shapes:

  1. Direct user_id (text or uuid, NOT NULL or nullable): documents, agents, hooks, pipelines, notifications, trajectory_runs, recurring_tasks, skill_permissions, permission_requests, plus the Phase-0 nullable columns agent_events, embeddings, hook_executions, swarm_nodes. For nullable columns, rows with user_id IS NULL never match the per-user comparison and become invisible without withRlsBypass — that’s intentional: orphan rows from pre-Phase-0 data aren’t accessible to per-user reads, but system jobs (cron, reapers, compaction) reach them through the bypass path.

  2. messages (no user_id column; ownership lives on the sessions FK):

    USING (bypass='true' OR EXISTS (
    SELECT 1 FROM sessions
    WHERE sessions.id = messages.session_id
    AND sessions.user_id::text = current_setting(...)))

    Subqueries cost more than direct comparisons but the natural shape for strict child-by-FK relationships; messages.session_id is already indexed.

  3. pipeline_stages — same shape as messages, joined through pipelines.user_id.

After this commit, every user-owned table in the schema carries a policy. The RLS test suite asserts coverage on all 19 tables; the PGlite smoke test confirms the migration applies cleanly. Behavioral enforcement still only fires under external Postgres + a non-superuser app role (see Phase 3b operator runbook).

Cumulative: 1177 pass / 9 fail / 66 skip — same totals as Phase 3b (this commit only adds new assertions to the existing migration test, not new test cases). 19 policies installed; zero regressions; typecheck + lint clean.

Phase 3c-1 — per-user quotas (data + admin UI; no enforcement yet)

Section titled “Phase 3c-1 — per-user quotas (data + admin UI; no enforcement yet)”

Operator visibility + management for three quota dimensions:

  • Concurrent agents — agents WHERE user_id = … AND status='running'.
  • Daily token budget — sum of totalTokens across the user’s agents created in the current UTC day.
  • API requests per minute — count of audit_log rows with action='api_request' for the user in the last 60 seconds.

user_quotas (user_id PK, max_concurrent_agents, max_tokens_per_day, max_api_calls_per_minute, created_at, updated_at). Each cap is nullable: missing column = inherit the global default (config.agent.maxConcurrentAgents, config.agent.maxTokenBudget, config.api.rateLimitMax). Missing row = inherit every default.

The migration also adds an RLS policy in the same shape as 0034/0035 so the table participates in the framework from day one.

  • getEffectiveQuota(userId) — resolves overrides + defaults; tells the caller which fields are overridden so the UI can mark them.
  • getUsage(userId) — reads the canonical tables (no cached counters). Cheap enough at the scale this targets.
  • willExceed(userId, kind, delta) — pre-flight gate the future enforcement points (Phase 3c-2) call before letting the operation through. Returns a structured { kind, current, max } reason on denial.
  • setOverride(userId, patch) / clearOverride(userId) for the admin route.
  • GET / — list every user with effective quota + current usage.
  • GET /:userId — single-user detail.
  • PATCH /:userId — set/clear per-field overrides. Negative or zero values rejected with 400 (clearer to require explicit null or DELETE for “no override”). Unknown user → 404. Audit row written via settings_changed.
  • DELETE /:userId — drop the override row entirely.

New tab in the existing /admin/* layout next to Users + Audit log. Table view with three meter columns (current/max + colored bar at 70%/90% thresholds), edit modal that surfaces per-field “no change / set / clear” semantics. Auto-refreshes every 10s so usage figures stay current. * next to a value marks a per-user override; the RotateCcw icon clears every override at once.

  • 7 unit (quotas.test.ts): inherits global defaults; insert+update override; null clears; usage counts running agents + daily tokens
    • per-minute API rows; willExceed allowed/denied with structured reason; clearOverride drops the row.
  • 7 admin route (admin.isolation.test.ts extension): non-admin 403; GET lists; PATCH sets + audit row; PATCH negative/zero → 400; PATCH null clears; PATCH unknown user → 404; DELETE drops then 404 on second call.

Cumulative: 1191 pass / 9 fail / 66 skip — same 9 pre-existing StdioTransport tests, net +14 (7 manager + 7 route), zero regressions. Web npx tsc --noEmit clean.

No enforcement in this commit. Phase 3c-2 wires the gates at the agent-spawn / LLM-call / API-request boundaries. Operators can use the data + UI here to investigate and pre-set caps; nothing changes in user-visible runtime behavior until 3c-2 lands.

Phase 3c-2 — quota enforcement at runtime

Section titled “Phase 3c-2 — quota enforcement at runtime”

Wires the QuotaManager.willExceed gate from 3c-1 into the three runtime boundaries. New QuotaExceededError (src/security/quota-error.ts) carries { kind, current, max, userId } so callers can distinguish which dimension fired.

Each gate is conditional on multiuser.enabled so single-user installs are unaffected. The 'system' and 'local' legacy sentinels are skipped — they aren’t real users and have no quota.

Gate 1 — concurrent agents (agent-manager.spawn())

Section titled “Gate 1 — concurrent agents (agent-manager.spawn())”

Inserted right after the existing global maxConcurrentAgents check. Calls quotaManager.willExceed(userId, 'concurrentAgents', 1) and throws QuotaExceededError on denial. The global cap stays as the outer fail-safe.

Gate 2 — daily tokens (agent-worker pre-LLM-call)

Section titled “Gate 2 — daily tokens (agent-worker pre-LLM-call)”

Inserted next to the existing per-agent BudgetExceededError check in the agent loop. Calls quotaManager.willExceed(userId, 'tokensPerDay', 0) — delta=0 because we’re checking the already-credited aggregate; the next call’s tokens are credited by the post-call bookkeeping. On denial, aborts the agent’s AbortController with user_quota_exceeded:tokensPerDay and throws QuotaExceededError. Distinct from BudgetExceededError which fires on the per-agent cap.

Gate 3 — API rate limit (rate-limit middleware)

Section titled “Gate 3 — API rate limit (rate-limit middleware)”

The middleware grew a second layer (the per-IP layer for /api/auth/* is unchanged). For authenticated principals on /api/*:

  • Look up quotaManager.getEffectiveQuota(userId).maxApiCallsPerMinute.
  • Sliding-window check via the existing getRateLimiter() keyed by user:rl:<userId>.
  • 429 with Retry-After + { quota: { kind, max } } body when the budget is exhausted.

Anonymous traffic falls through (anonymous users can’t be per-user-limited).

Tests (6 new, PGlite + in-memory rate-limiter)

Section titled “Tests (6 new, PGlite + in-memory rate-limiter)”
  • Concurrent: willExceed denies with structured reason when at cap; allows when room remains.
  • Tokens: today’s aggregate over the cap denies with kind/current/max.
  • API rate-limit middleware:
    • Flag off → no per-user limit applied even with a tiny override.
    • Flag on + tiny cap → 429 after the second request, with quota.kind === 'apiCallsPerMinute' in the body.
    • Flag on + anonymous → middleware skips, no 429.

Cumulative: 1197 pass / 9 fail / 66 skip — same 9 pre-existing StdioTransport tests, net +6, zero regressions. Lint + typecheck clean.

With this commit Phase 3c is complete: operators can see + set caps (3c-1) and the runtime enforces them (3c-2). Phase 3d is next.

Phase 3d — admin impersonation with strong audit

Section titled “Phase 3d — admin impersonation with strong audit”

Admins can open a short-lived “act as ” window to investigate a bug report, validate a permission change, or reproduce something a user is seeing. While the window is open the request is routed as the target user (so scopedRepos, permission checks, RLS all see the target’s data) but every action is dual-tagged in the audit log under both the actor (admin) and the target.

impersonation_sessions (id, actor_user_id, target_user_id, actor_session_hash, started_at, expires_at, ended_at, ended_reason, reason, ip_address). Keyed by actor_session_hash (SHA-256 of the admin’s auth-session token) so the auth-derive middleware does a single indexed lookup per request without exposing tokens. RLS policy lets either party read their own row.

ImpersonationManager (src/security/impersonation.ts)

Section titled “ImpersonationManager (src/security/impersonation.ts)”
  • start(actor, targetUserId, sessionToken, opts) — validates admin status, rejects self / missing / disabled targets, closes any prior active session for the same admin (ended_reason='replaced'), inserts the row, writes paired audit_log rows under both actor and target.
  • findActive(sessionToken) — single indexed lookup. Returns null on no row, ended row, or expired row.
  • stop(sessionToken, reason='explicit') — closes the row, writes paired logout audit entries.
  • listRecent(limit) for the admin console.
  • reapExpired() for the cleanup job (out of scope here).

Default TTL: 1 hour. Short on purpose — admins finish their investigation in one sitting, not leave a backdoor open across days. One active session per admin (replace semantics).

When the auth-derive middleware sees an admin’s session token, it calls findActive(token). If a row matches, the request’s identity is swapped to the target user; principal.actorUserId / actorUsername are set so downstream audit / banner code knows who is really behind the request. Failure to look up the row is non-fatal — the request proceeds as the admin.

audit-shadow middleware (Phase 0) gained an impersonation branch. When principal.actorUserId is set:

  • The primary api_request row is filed under the target (details.impersonate = { actorUserId, actorUsername, targetUserId, targetUsername }).
  • A mirror row is filed under the actor with the same details plus mirroredFromTarget: true.

A search by either user-id finds the action.

  • POST /api/admin/impersonate/:userId — start a session. Body { reason?: string }. Self-target → 400; missing → 404; admin using MASTER_KEY (no rotatable session token) → 400.
  • POST /api/admin/impersonate/stop — end the active session for the calling token. Idempotent: 404 on second call.
  • GET /api/admin/impersonate — recent sessions for the admin console list view.
  • ImpersonationBanner mounted in the root layout reads actorUserId / actorUsername from /auth/me. Yellow bar across the top: ” is acting as . Every action is audited.” Stop button posts to /impersonate/stop and reloads.
  • /admin/users rows gain an “Act as” button that prompts for a reason and posts to /impersonate/:userId. Disabled for self and for inactive users.
  • 9 unit (impersonation.test.ts): rejects non-admin / self / missing target; happy path writes paired audit rows; findActive hits + stops + idempotent; replace semantics (ended_reason='replaced'); reapExpired sweeps unfinished rows.
  • 9 admin route (admin-impersonation.isolation.test.ts): anonymous 401; non-admin 403; admin without session token 400; self 400; missing 404; happy path returns sessionId + manager resolves it; stop closes + 404 on second; GET /impersonate lists.

Cumulative: 1215 pass / 9 fail / 66 skip — same 9 pre-existing StdioTransport tests, net +18 (9 manager + 9 route), zero regressions. Lint + typecheck + web typecheck clean.

Phase 3e — shell sandbox (bubblewrap / firejail)

Section titled “Phase 3e — shell sandbox (bubblewrap / firejail)”

Process-level isolation for shell-tool spawns. Pairs with the filesystem-level WorkspaceFS sandbox from Phase 1b-3: WorkspaceFS blocks the agent from supplying out-of-bounds paths; the shell sandbox blocks a command running through sh -c (or any binary that follows symlinks under the hood) from reading/writing outside the workspace.

  • 'off' (default) — no wrapping; behavior matches pre-3e.
  • 'auto' — wrap when bwrap or firejail is on PATH; fall back to unsandboxed execution when not.
  • 'required' — wrap when available; refuse to spawn when no runner is found (operational deployments use this once they’ve installed a runner).

Environment override: SHELL_SANDBOX=auto|required. Linux-only; on macOS/Windows the runners aren’t typically available, so auto reduces to off.

wrapCommand(argv, options) (src/security/shell-sandbox.ts)

Section titled “wrapCommand(argv, options) (src/security/shell-sandbox.ts)”

Returns { wrapped, runner, argv, cleanup }:

  • wrapped: false when sandbox is off or no runner is found in auto mode.
  • runner: 'bwrap' | 'firejail' | null.
  • argv: the wrapped command line (or original if not wrapped).
  • cleanup(): removes the per-spawn /tmp scratch dir; safe to call even when wrapped is false.

Profile (bubblewrap, the preferred backend):

  • --unshare-pid, --unshare-uts, --unshare-ipc, --unshare-cgroup-try, --die-with-parent.
  • --unshare-net by default; allowNetwork: true keeps it on.
  • --ro-bind /usr /lib /lib64 /bin /etc /opt.
  • --bind <workspaceRoot> — read-write.
  • Per-spawn --bind <fresh-tmp> /tmp for transient writes (cleaned up by cleanup()).
  • --proc /proc, --dev /dev, --chdir <workspace>.

firejail backend: --noprofile --private-tmp --private-dev --noroot --seccomp --whitelist=<workspaceRoot> plus --net=none when allowNetwork is false.

Runner detection is cached after first lookup (operators don’t install/remove sandbox runners while the server is up).

src/tools/shell/local-operations.ts builds the base argv exactly as before (safe-tokenized argv vs sh -c for unsafe), then runs it through wrapCommand before handing to child_process.spawn. Cleanup runs on both close + error paths. The unsafe path opts into allowNetwork: true so curl/wget keep working when the user has explicitly accepted the broader run.

  • 4 unit (always run): mode 'off' returns argv unwrapped; getSandboxMode reads config; 'required' without a runner throws (only meaningful on hosts without bwrap).
  • 5 behavioral (describe.skipIf(!hasBwrap)): bwrap argv shape; --unshare-net toggle on allowNetwork; cleanup removes the scratch dir; printf round-trip through bwrap; reads outside the workspace return non-zero (proves the sandbox actually blocks).

Cumulative: 1219 pass / 9 fail / 71 skip — same 9 pre-existing StdioTransport tests, net +4 unit tests + 5 added skipped (need bwrap to run), zero regressions. Lint + typecheck clean.

Terminal window
# Install on Debian/Ubuntu:
sudo apt-get install bubblewrap
# Or Fedora/RHEL:
sudo dnf install bubblewrap
# Then in your env:
SHELL_SANDBOX=required

Verify with npx vitest run src/security/shell-sandbox.test.ts — the describe.skipIf(!hasBwrap) blocks should run and pass.

Phase 3f — Docker per-user isolation (labels + network)

Section titled “Phase 3f — Docker per-user isolation (labels + network)”

The Docker tool today operates on whatever containers the daemon exposes — there’s nothing stopping user A’s agent from docker stop’ing a container created by user B, or docker exec’ing a command inside one. Phase 3f adds an opt-in label + network convention so deployments running multi-user with the Docker tool get container-level isolation alongside the FS / process / RLS layers shipped in 1b-3 / 3e / 3b.

  • Every container the tool creates carries octipus.user_id=<userId> as a Docker label.
  • Every per-user network is named octipus_user_<short-uuid> (first 12 alphanumeric chars of the userId — Docker network names need to stay readable in docker network ls).
  • Every list operation passes --filter label=octipus.user_id=<uuid> so the agent only sees its own containers.
  • Every targeted operation (start/stop/logs/exec) first runs docker inspect to verify the label; mismatches surface as “container not found” — same shape as a missing container, so attackers can’t enumerate other users’ container names by probing.
  • 'off' (default) — no filtering or enforcement. Behavior matches pre-3f.
  • 'enforce' — isolation fires for every real user. System jobs (userId === 'system') stay outside it; any other id that is not a user uuid is a bug and the helper throws rather than run unisolated.

Env: DOCKER_ISOLATION=enforce.

Helper module (src/security/docker-isolation.ts)

Section titled “Helper module (src/security/docker-isolation.ts)”
  • userLabel(userId) → 'octipus.user_id=<uuid>'.
  • userNetworkName(userId) → 'octipus_user_<12-chars>'.
  • isolationActive(userId) — combines the flag with “real user or system job”.
  • listFilterFlags(userId) / runIsolationFlags(userId) / buildIsolationFlags(userId) — argv fragments to splice into the tool’s existing docker invocations. Empty array when isolation is off so the call sites don’t need to branch.
  • inspectOwnership(name, userId) — runs docker inspect -f '{{ index .Config.Labels "octipus.user_id" }}' <name> and compares; returns 'ok' | 'not_found' | 'wrong_owner'.
  • ensureUserNetwork(userId) — idempotent docker network create --driver bridge octipus_user_<id>. Reserved for the run_container op (not in this PR).

Docker tool wiring (src/tools/docker/index.ts)

Section titled “Docker tool wiring (src/tools/docker/index.ts)”
  • list_containers — splices listFilterFlags(ctx.userId) into the docker ps invocation.
  • start_container / stop_container / container_logs / exec_command — each calls assertOwnership(name, ctx) first; mismatches throw "Container not found: <name>" (the agent sees it as a routine miss, not a permission denial).
  • build_image — auto-injects --label octipus.user_id=<uuid> so any container later run from the image carries the label.

run_container (a new operation that creates containers with full isolation flags + the per-user network) is deliberately out of scope here — the existing tool surface manages pre-existing containers, and adding a creation op is a separate feature.

  • 8 helper unit tests: userLabel / userNetworkName shape; isolationActive combines flags correctly across the four off/on/sentinel cases; flag generators emit the right argv when on, empty when off.
  • 1 behavioral (describe.skipIf(!hasDocker)): inspectOwnership against a real Docker daemon for a nonexistent container returns 'not_found'. We don’t create real containers from tests — that risks leaving state on the host; the missing-container path is enough to verify the helper handles a real docker inspect.

Cumulative: 1231 pass / 9 fail / 71 skip — same 9 pre-existing StdioTransport tests, net +12 unit tests + +1 added skipped behavioral, zero regressions. Lint + typecheck clean.

Terminal window
# In the deployment env:
MULTIUSER=true
DOCKER_ISOLATION=enforce

Existing containers without the label become invisible to every non-admin user — operators should add octipus.user_id labels to any pre-existing containers they want users to manage, or recreate them through the tool with isolation on.


Phase 3g — org / workspace grouping (schema scaffolding)

Section titled “Phase 3g — org / workspace grouping (schema scaffolding)”

Section 2 of this doc describes a three-tier tenancy model:

Organization (optional, default = "personal")
└── User (required principal)
└── Workspace (1..N per user; default = "default")
└── Sessions, Documents, Embeddings, Hooks, …

Phase 0 added users.org_id (nullable) so the migration would be cheap when the time came. Phase 3g lays the rest of the scaffolding — the tables, manager, REST surface, and feature flag — without adding a single foreign key to existing tables. That keeps single- user installs and every prior multi-user deployment byte-for-byte unchanged until the next phase wires the per-workspace data boundary.

organizations(id, slug UNIQUE, name, created_by, metadata, …)
org_members(org_id, user_id, role, joined_at, PK(org_id, user_id))
workspaces(id, user_id, slug, name, is_default, metadata, …,
UNIQUE(user_id, slug),
UNIQUE(user_id) WHERE is_default = true)
  • Slugs are URL-safe handles. Org slugs are globally unique; workspace slugs are unique per-user (so two users can both have a default workspace).
  • is_default is enforced 0-or-1 per user via a partial unique index — atomic promotion happens inside a transaction in the manager.
  • RLS policies use the same “bypass on missing GUC” pattern as 0034 / 0035 / 0037: workspaces and org_members are visible to the owning user; organizations are visible to any member via subquery against org_members.

No existing table grows a new column. Phase 4 adopts workspace_id on sessions / documents / hooks / vault.

Workspaces are always on: there is no setting to switch them off. (An earlier setting could; it is removed, and a stored settings row for it is deleted at startup.) /api/admin/orgs stays admin-gated.

OrgWorkspaceManager is the single owner of the three new tables. The route layer never queries them directly. Public methods:

class OrgWorkspaceManager {
// Organizations (admin-only)
createOrg(actor, { slug, name }): Organization
findBySlugForCaller(actor, slug): Organization | null // collapses to null for non-members
listAllAdmin(actor): Organization[] // admin only
listForUser(userId): Organization[]
addMember(actor, orgId, userId, role?): OrgMember // idempotent
removeMember(actor, orgId, userId): boolean
listMembers(actor, orgId): OrgMember[]
// Workspaces (per-user, no admin shortcut)
createWorkspace(userId, { slug, name, isDefault? }): Workspace
ensureDefaultWorkspace(userId): Workspace // lazy on first read
findOwnedById(userId, id): Workspace | null // cross-tenant collapses to null
findOwnedBySlug(userId, slug): Workspace | null
listOwn(userId): Workspace[]
rename(userId, id, name): Workspace | null
setDefault(userId, id): Workspace | null // atomic via tx
delete(userId, id): boolean // refuses default
}

All errors raise OrgWorkspaceError with a stable code field (invalid_slug | invalid_name | not_admin | slug_conflict | workspace_not_found | cannot_delete_default | …). The route layer maps each code to its HTTP status (400 / 403 / 404 / 409).

Cross-tenant safety follows the same enumeration-collapse pattern as scopedRepos and the impersonation manager: a leaked workspace UUID looks identical to “doesn’t exist” when used by a different user. Manager + route tests both verify this — bob can’t rename or delete alice’s workspace via her UUID.

Mounted under /api:

MethodPathAuthNotes
GET/me/workspacesuserLazy-creates the default workspace on first read.
POST/me/workspacesuser{slug, name, isDefault?}
PATCH/me/workspaces/:iduserRename only — slug is immutable.
POST/me/workspaces/:id/defaultuserPromote to default; clears prior default in same tx.
DELETE/me/workspaces/:iduser400 on default; 404 cross-tenant.
GET/admin/orgsadminAll orgs.
POST/admin/orgsadmin{slug, name}
GET/admin/orgs/:id/membersadmin
POST/admin/orgs/:id/membersadmin{userId, role?}
DELETE/admin/orgs/:id/members/:userIdadmin

Cross-tenant workspace ids answer 404 Not Found, the same as a missing one.

  • 20 manager unit tests (src/security/orgs.test.ts): slug + name validation, admin gating on createOrg/addMember, slug conflicts, member visibility, cross-tenant collapse on findOwnedById/rename/delete, atomic default promotion, and the “cannot delete default” guard.
  • 18 route guard tests (src/api/routes/orgs.isolation.test.ts): 401 for anon, 403 for non-admin on /admin/*, 200 happy paths, 409 on slug conflict, 400 on cannot_delete_default, 404 on cross-tenant PATCH/DELETE.

Cumulative: 1269 pass / 9 fail / 71 skip — same 9 pre-existing StdioTransport tests, net +38 new tests, zero regressions. Lint

  • typecheck clean.

The schema is created by migration 0038, which runs on boot; the routes need no configuration.

Phase 4 wires workspace_id onto the user-owned tables and lets scopedRepos filter on it.


Phase 3g shipped the workspaces table but kept it isolated from the rest of the schema. Phase 4 finishes the wiring: sessions, documents, and hooks each gain a nullable workspace_id column, the auth derive layer maps a request header to a workspace owned by the principal, and scopedRepos narrow reads + stamp writes when that context is set.

ALTER TABLE sessions ADD COLUMN workspace_id uuid REFERENCES workspaces(id) ON DELETE SET NULL;
ALTER TABLE documents ADD COLUMN workspace_id uuid REFERENCES workspaces(id) ON DELETE SET NULL;
ALTER TABLE hooks ADD COLUMN workspace_id uuid REFERENCES workspaces(id) ON DELETE SET NULL;
-- Plus per-table workspace_id index AND composite (user_id, workspace_id) index —
-- the dominant query shape once the runtime starts filtering.
  • Nullable: existing rows stay valid without backfill. NULL = “user-level” (visible to every workspace owned by the user).
  • ON DELETE SET NULL: deleting a workspace doesn’t cascade- delete the user’s sessions / documents / hooks; rows just fall back to user-level scope.
  • Idempotent: every ALTER uses IF NOT EXISTS.

Tables left for a follow-up: vault_entries (already has a scope enum that’s a nuanced enough story to deserve its own PR), agents, notifications, trajectory_runs, pipelines, embeddings. The Phase 4 slice covers the user-visible primary nouns first.

Backfill (scripts/backfill-workspace-id.ts)

Section titled “Backfill (scripts/backfill-workspace-id.ts)”

Walks every user, ensures they have a default workspace via OrgWorkspaceManager.ensureDefaultWorkspace, and stamps rows with workspace_id IS NULL to the default. Idempotent — re-runs after full backfill are no-ops.

Terminal window
npx tsx scripts/backfill-workspace-id.ts # backfill all
npx tsx scripts/backfill-workspace-id.ts --dry-run # report only
npx tsx scripts/backfill-workspace-id.ts --user=<uuid>

Skipping the backfill is also fine: rows with NULL workspace_id continue to be visible across every workspace owned by the user (the “user-level” scope), so nothing breaks; the data just isn’t partitioned.

interface Principal {
// ...existing fields...
/** Phase 4 — narrow scopedRepos to this workspace. */
workspaceId?: string | null;
}

Always optional. Off-feature deployments leave it undefined and all the existing call sites see no change.

Workspace resolver (src/security/workspace-resolver.ts)

Section titled “Workspace resolver (src/security/workspace-resolver.ts)”

resolveWorkspace(principal, header) maps the X-Octipus-Workspace request header to a workspace UUID:

  • Anonymous / system principal → { workspaceId: null }.
  • No header / "all" / "default" → user’s default workspace (lazily created on first call).
  • UUID → accepted only if owned by the principal; cross-tenant collapses to default. Same enumeration-collapse pattern as scopedRepos: bob handing alice’s UUID gets bob’s default, not alice’s row.
  • Slug → looked up by (user_id, slug); misses fall back to default.

The auth derive in src/api/server.ts runs the resolver as a second .derive() step so the auth branch above stays a flat list of early returns. It runs on every authenticated request and fails closed: when the resolver throws, the request answers 503 instead of continuing without a workspace (which would drop the workspace filter from every scoped read).

workspaceFilter(principal, column) returns

(workspace_id = $1 OR workspace_id IS NULL)

or undefined when the principal has no workspace context. The NULL clause is intentional: un-backfilled rows stay visible after the runtime starts filtering, so a deployment that never ran the backfill keeps working.

Wired into ScopedSessionRepo.findById/listOwn/update/delete, ScopedDocumentRepo.findById/listOwn/listOwnByCategory/delete/updateStatus, ScopedHookRepo.findById/listOwn. Admins are NOT exempted — an admin browsing their own UI under a specific workspace should see only that workspace’s rows; the global view is reached via the explicit *Admin methods that don’t go through scoping.

create stamps the principal’s workspaceId onto the new row unless the caller-supplied data.workspaceId overrides (admin seed-data path). Off-feature deployments fall through with NULL.

  • 9 resolver tests (src/security/workspace-resolver.test.ts): no header ⇒ default; all/default ⇒ default; owned UUID ⇒ that workspace; cross-tenant UUID ⇒ default; owned slug ⇒ that workspace; unknown slug ⇒ default; cross-tenant slug ⇒ default; anonymous ⇒ null.
  • 8 scoped-repo tests (src/db/repositories/workspace-scoped.test.ts): no context ⇒ all rows; default-workspace context sees its own rows + NULL but not project-x; create stamps the workspace id; caller-supplied workspaceId on create wins; cross-workspace findById/update/delete collapse to null/false; documents repo same isolation pattern.

Cumulative: 1286 pass / 9 fail / 71 skip — same 9 pre-existing StdioTransport tests, net +17 new tests added in this PR (scaled from 1269 in Phase 3g). Lint + typecheck clean.

Terminal window
# Optionally move rows with no workspace into each user's default.
npx tsx scripts/backfill-workspace-id.ts --dry-run # preview
npx tsx scripts/backfill-workspace-id.ts # execute

Clients select a workspace per request via X-Octipus-Workspace: <slug-or-uuid>. Omitting the header keeps the user in their default workspace.

  • Vault scope=workspace wiring (vault has its own scope enum and per-row DEKs that warrant a separate PR).
  • Workspace pickers in the web UI.
  • Org-shared resources (system models, shared skills) routed through org_members.
  • Workspace-aware backfill on the rest of the user-owned tables (agents, notifications, trajectory_runs, pipelines, embeddings, agent_events, swarm_nodes).

  1. Identity — every request, WebSocket frame, channel message, agent execution, and tool call is attributable to exactly one principal (user or service account).
  2. Isolation — by default, no user can read, write, or influence another user’s sessions, messages, files, embeddings, secrets, settings, or running agents.
  3. Permissioning — the existing ALLOW / ASK / DENY tool permission model becomes enforced at the root agent/tool boundary, scoped per user.
  4. Administration — admins can manage users, roles, quotas, system secrets, and audit history through a first-class UI and API.
  5. Backwards compatibility — existing single-user STORAGE_MODE=embedded deployments continue to work; a single bootstrap user is created on first boot.
  6. Operability — per-user quotas, rate limits, and audit make a shared instance safe to run.
  • Full SaaS multi-tenancy with billing / metering / Stripe.
  • Cross-region replication / sharding.
  • End-to-end encryption of message content (vault remains AES-GCM at rest; server can read messages — required for orchestration).
  • Marketplace / public skill sharing (covered by the existing roadmap).
ThreatIn scope
Authenticated user A reads user B’s session/messages/documentsYes
Authenticated user A invokes an agent that exfiltrates user B’s vault entryYes
Authenticated user A’s filesystem tools escape into user B’s workspaceYes
Channel adapter (Telegram, Slack) impersonates a userYes
Compromised browser extension acts as another userYes
Admin abuse (impersonation without audit trail)Yes
Network attacker (TLS termination)Out — handled by reverse proxy
Physical disk theft (vault key derivation)Partially — covered by MASTER_KEY rotation

Two-layer model that degrades gracefully:

Organization (optional, default = "personal")
└── User (required principal)
└── Workspace (1..N per user; default = "default")
└── Sessions, Documents, Embeddings, Hooks, …
  • User is the canonical principal and the unit of isolation.
  • Organization is an optional grouping for shared admin and shared resources (system models, shared skills). For single-tenant installs the default personal org is invisible.
  • Workspace scopes data within a user — equivalent to a project. Sessions, documents, vault entries, hooks all belong to a workspace. This replaces the current global WORKSPACE_ROOT.

Every isolated row carries (org_id, user_id, workspace_id) (the latter two nullable only for org-scoped or system-scoped data). All read paths filter on user_id (or org_id for shared resources) before returning.


PrincipalSource of truthUse
userusers tableHuman via web UI, channel, or API token
service_accountnew service_accounts tableCI, MCP clients, browser ext
systemsentinel UUIDBackground jobs (cron, compaction)

3.2 Auth methods (already present, to be hardened)

Section titled “3.2 Auth methods (already present, to be hardened)”
  • Password + TOTP — existing in users.passwordHash, users.totpSecret.
  • WebAuthn passkey — existing in users.passkeyCredentials.
  • OAuth/SSO — Google, GitHub, Slack, Teams; map external ID to local user via oauth_identities (new) instead of stuffing into users.
  • API tokens — new api_tokens table: token_hash, user_id, scopes[], expires_at, last_used_at. Display token once on creation. Bearer in Authorization: Bearer otk_….
  • Channel binding — Telegram chat IDs, Slack user IDs, etc. live in a new channel_identities table (one row per (channel, external_id)) so one user can bind multiple accounts and we can revoke individually.

3.3 Sessions (auth sessions, distinct from chat sessions)

Section titled “3.3 Sessions (auth sessions, distinct from chat sessions)”
  • Rename current chat sessions table mental model is fine — it’s already conversational. Auth sessions live in the Postgres-backed cache (src/db/cache.ts) keyed by an opaque token; row in auth_sessions (new) for revocation and audit.
  • Web UI: HttpOnly + Secure + SameSite=Lax cookie containing the token. Drop localStorage usage in web/lib/auth-context.tsx. CSRF: double-submit cookie pattern on state-changing requests.
  • WebSocket: token passed as Sec-WebSocket-Protocol subprotocol, validated in the gateway upgrade handler before the connection is accepted — never trust a userId field sent by the client over an open socket.
  • On first boot with STORAGE_MODE=embedded, auto-provision a single admin user with a printed one-time password (or ADMIN_BOOTSTRAP_TOKEN env). All existing data is owned by that user.
  • A migration script (scripts/migrate-to-multiuser.ts) backfills user_id columns where they’re nullable today.

4.1 Layer 1 — Role-Based Access Control (system actions)

Section titled “4.1 Layer 1 — Role-Based Access Control (system actions)”

Coarse-grained, governs administrative and system operations.

RoleExamples
system_adminManage users, system vault, model bindings, hooks for any user, view all audit
org_admin (multi-org only)Manage users in their org, org-scoped settings
userDefault; full access to their own data
viewerRead-only on their own data; no agent execution

Stored in users.roles text[] (or pivot user_roles for org-scoped roles once orgs land). RBAC is checked at API route entry via middleware.

4.2 Layer 2 — Permission Policies (agent/tool actions)

Section titled “4.2 Layer 2 — Permission Policies (agent/tool actions)”

Reuse and enforce the existing skill_permissions schema: (user_id, tool_id, action, level, conditions, expires_at) with levels ALLOW | ASK | DENY.

Add a single chokepoint in the root agent:

// src/security/policy/check.ts (new)
async function checkToolCall(
ctx: { userId: UUID; sessionId: UUID; agentId: UUID; workspaceId: UUID },
toolId: string,
action: 'read' | 'write' | 'execute',
args: ToolArgs,
): Promise<'allow' | { ask: PermissionRequestId } | 'deny'>

checkToolCall is called from every tool invocation site (search the codebase for the current direct dispatcher and route through this gate). On ask, the root agent inserts a permission_requests row, pauses the agent, emits a permission.request gateway event to the user’s client, and resumes when the response arrives or times out (deny on timeout).

Conditions JSON (already in schema) supports:

  • path_pattern — glob matched against resolved absolute path
  • time_window — only weekdays 9–17, etc.
  • rate_limit — N calls per window
  • network_allowlist — for HTTP tools
  • redact_secrets — strip vault values from logs

4.3 Default policies (seeded per new user)

Section titled “4.3 Default policies (seeded per new user)”

Conservative defaults installed on user creation:

  • Filesystem read / write: ASK outside the user’s workspace.
  • Shell execute: ASK always; DENY for rm -rf, dd, mkfs, etc.
  • Network egress: ALLOW for HTTPS, ASK for non-standard ports.
  • Vault read: ALLOW for own entries, DENY for system entries unless explicitly granted.
  • Browser extension: ASK on first use of a new origin.

Every row that today is implicitly global must carry an owner. Concrete columns to add or backfill (NOT NULL after migration):

TableAdd columnsNotes
agentsuser_id, workspace_id, session_idAgents are children of a session; cascade delete
agent_eventsuser_id (denormalized)Hot-path filter; index (user_id, ts)
swarm_nodesuser_id, session_idAlready conceptually per-session
embeddingsuser_id, workspace_id, document_idCritical — currently global RAG
documentsworkspace_iduser_id exists already
hook_executionsuser_id, workspace_id
pipelinesuser_id (nullable for system pipelines), org_id
recurring_tasksuser_id, workspace_id
notificationsuser_idLikely present; verify NOT NULL
auditactor_user_id, target_user_id, org_id
trajectory_runsuser_id, workspace_id
settingsscope enum (`systemorg
vaultscope enum (`systemuser

New tables:

  • organizations (id, name, slug, created_at, settings)
  • workspaces (id, user_id, org_id, name, slug, root_path, created_at)
  • oauth_identities (provider, external_id, user_id)
  • channel_identities (channel_type, external_id, user_id, workspace_id)
  • api_tokens (id, user_id, token_hash, scopes, expires_at, last_used_at, name)
  • auth_sessions (id, user_id, created_at, expires_at, ip, user_agent, revoked_at)
  • service_accounts (id, name, owner_user_id, scopes, created_at)
  • user_roles (user_id, org_id nullable, role)

5.2 Defense-in-depth: Postgres Row-Level Security

Section titled “5.2 Defense-in-depth: Postgres Row-Level Security”

Where feasible enable RLS so a query bug can’t leak across tenants:

ALTER TABLE sessions ENABLE ROW LEVEL SECURITY;
CREATE POLICY sessions_owner ON sessions
USING (user_id = current_setting('app.current_user_id')::uuid);

The DB connection pool sets SET LOCAL app.current_user_id = $1 per request inside a transaction. RLS is bypassed when the connection authenticates as the migration role. PGlite (embedded mode) does not support RLS — there application-layer filters are the only line of defense.

Drizzle repositories under src/db/repositories/ are refactored so every query takes a principal: Principal parameter:

src/db/repositories/sessions.ts
async function findSession(
principal: Principal,
sessionId: UUID,
): Promise<Session | null> {
const row = await db.select().from(sessions)
.where(and(eq(sessions.id, sessionId), eq(sessions.userId, principal.userId)))
.limit(1);
return row[0] ?? null;
}

Repositories never accept a raw userId string — only a Principal constructed by middleware. This makes “forgot to filter” a type error, not a runtime bug.

Every Elysia route under src/api/routes/ gets:

.use(authGuard()) // populates ctx.principal or 401s
.use(rateLimit('user')) // per-user bucket
.derive(({ principal }) => ({ repo: scopedRepos(principal) }))

scopedRepos(principal) returns repository instances with the principal pre-bound, so route handlers cannot accidentally call an unscoped repository.


Vault entries gain a scope column:

  • system — readable only by system-admin tools (e.g., shared OpenAI key for orgs that pool quotas).
  • user — owned by one user; default for personal API keys.
  • workspace — shared across agents in one workspace; owned by user who created it.

A vault read query is always (scope='user' AND scope_id=:userId) OR (scope='workspace' AND scope_id=:workspaceId) OR (scope='system' AND explicitly granted).

The existing allowed_tools and allowed_agents columns become enforced by the vault accessor. A vault entry is only handed to a tool whose ID is in allowed_tools; otherwise the read raises a VaultAccessDenied error logged to audit.

  • One MASTER_KEY (existing) derives a per-user data-encryption-key (DEK) via HKDF(MASTER_KEY, salt=user_id). Vault rows store (iv, ciphertext, auth_tag) encrypted with the per-user DEK.
  • This isolates the blast radius if one DEK is somehow leaked and lays groundwork for KMS / per-user envelope encryption later.
  • Key rotation: scripts/rotate-master-key.ts re-encrypts all rows in a background job; vault row carries key_version.

Today the root agent pulls vault entries unscoped. The replacement:

// src/security/vault/inject.ts (new)
async function injectSecrets(
agent: AgentContext, // carries userId, workspaceId, allowedTools
toolId: string,
template: string, // "Authorization: Bearer ${secrets.openai_key}"
): Promise<string>

Resolution order: workspace → user → system (only if granted). Misses become hard errors, never empty strings.


sessions.user_id is already present; make it NOT NULL and add workspace_id. Every API route handler verifies session.user_id === principal.user_id before returning messages, agent events, or accepting new input.

A future session_shares (session_id, shared_with_user_id, role) table allows a user to grant read or comment access to a teammate. Out of scope for v1; design the API to accept it later (X-On-Behalf-Of header, denied unless a share row exists).

channel_identities is the only mapping from a channel external ID to a user. Channel adapters in src/channels/* resolve incoming messages via this table; no fallback to 'local' or 'system'. If a binding is missing, the channel replies with a one-time signup link instead of routing to a default user.


Replace the global WORKSPACE_ROOT env with a per-user directory layout:

$DATA_ROOT/
users/{user_id}/
workspaces/{workspace_id}/
files/ ← rootPath for that workspace
documents/ ← uploaded docs
cache/
system/
skills/ ← read-only seed data
extensions/

Filesystem tools take a WorkspaceFS handle, not raw paths. WorkspaceFS:

  • Resolves user-supplied paths relative to the workspace root.
  • Rejects .. traversal, absolute paths, and symlinks that escape root.
  • Logs every read/write to the audit trail with the resolved absolute path.
  • Optionally chroot’s the spawned shell tool process (Linux user namespaces or bwrap when available).

Shell tools currently run as the Octipus container user with full FS access. Tighten:

  • Run shell tools in a bubblewrap/firejail profile that bind-mounts only the user’s workspace.
  • For Docker-in-Docker tools, label spawned containers with octipus.user_id=… and a user-scoped network so a runaway container cannot reach another user’s containers.
  • Per-user CPU/memory cgroup limits via Docker --cpus/--memory.

The settings table becomes hierarchical:

effective(key) = workspace[scope_id=ws] ?? user[scope_id=u] ?? org[scope_id=o] ?? system
  • Bootstrap settings (STORAGE_MODE, DATABASE_URL, MASTER_KEY, JWT_SECRET, SESSION_SECRET) stay in env — never in DB.
  • System settings (e.g., default model bindings) editable only by system_admin.
  • User and workspace overrides (model preference, theme, default channel) editable by their owner.
  • The settings service exposes getSetting(principal, key, scopeOverride?) and resolves through the chain.

src/config/runtime-loader.ts is rewritten to maintain a per-principal lazy view rather than a single global config object. Hot-reload events fan out to subscribers filtered by scope.


The current embeddings table is the most dangerous global pool — a user’s documents become retrievable as RAG context for any other user’s agent.

Required:

  • Add (user_id, workspace_id, document_id) to embeddings; backfill from documents.
  • All RAG queries filter by user_id (and optionally allow workspace_id IN (…shared workspaces…)).
  • For the optional org/system knowledge base, embeddings carry scope='org'|'system'; agents may opt in to system knowledge but it is clearly separated in the prompt context.
  • pgvector index becomes partial per-user where the volume justifies it, or a single index with the user_id filter pushed into the ANN query (HNSW + filter).

10. Channels (Telegram, Slack, Teams, WhatsApp, WebChat)

Section titled “10. Channels (Telegram, Slack, Teams, WhatsApp, WebChat)”

Channel adapters are reworked around channel_identities:

  1. Inbound message arrives with (channel_type, external_id).
  2. Adapter looks up channel_identities to resolve user_id, workspace_id.
  3. If not found: emit “binding required” reply with a deep-link to the web UI’s “Link account” page (which generates a one-time code).
  4. If found: build a Principal and hand off to the gateway exactly as a web-authenticated session would.

Outbound DMs to a user use the binding in reverse. Group/channel contexts (e.g., Slack channel mentions) require an explicit group_bindings row authorizing the bot to act there, and messages are attributed to the user who triggered them.


  • Every spawned agent record carries (user_id, workspace_id, session_id).
  • Root agent queues are partitioned per user; a runaway user cannot starve others. Concurrency cap per user (default 5 agents, admin configurable).
  • Token budgets: per-user daily / monthly token cap, evaluated on every LLM call. Soft warning at 80%, hard stop at 100%, surfaced in UI.
  • Swarm tree (swarm_nodes) gets user_id; orphan reaper only reaps within a user.
  • Trajectory log files split per user: trajectory/{user_id}/YYYY-MM-DD.jsonl.

New web routes under /admin (system_admin only):

PageCapabilities
/admin/usersList, create, disable, reset password, force-logout, set role, set quotas
/admin/users/[id]Profile, channel bindings, vault entries (metadata only — never plaintext), running agents, audit
/admin/orgs(optional) Create/edit orgs, assign users
/admin/system/settingsEdit system-scope settings
/admin/system/vaultManage system-scope secrets
/admin/system/modelsDefault model bindings, provider keys
/admin/auditFilter audit log by user/action/time
/admin/quotasPer-user limits dashboard
/admin/impersonateStart an impersonation session — strongly audited

Admin APIs sit under /api/admin/* and require both system_admin role and a recent re-auth (max_age=300s). Impersonation creates an impersonation_sessions row, banners the UI, and tags every action with both the actor and the impersonated user in audit.


  • Every state-changing API call writes an audit row via middleware: (actor_user_id, target_user_id, org_id, action, resource_type, resource_id, ip, user_agent, request_id, payload_hash, ts).
  • Every tool invocation, vault read, settings write, permission decision, and admin action goes through audit.
  • Per-user retention policy (default 90 days, admin-configurable).
  • Audit log is append-only; users can read their own entries; admins can read all.
  • Optional sink to external SIEM via webhook.

LimitScopeDefaultStorage
API requestsuser600/minPostgres-backed cache, sliding window
WebSocket messagesconnection60/minIn-memory token bucket
Concurrent agentsuser5DB count + Postgres-backed cache lock
LLM tokensuser/day1M (admin override)Postgres counter
Tool executionsuser/min120Postgres-backed cache
Auth attemptsIP + username10/15minPostgres-backed cache, with lockout

All limits surface as 429 with Retry-After; the UI shows quota usage in the user menu.


  • New base path /api/v2 for all multi-user-aware endpoints. /api/v1 remains for the bootstrap admin until extension consumers migrate.
  • All v2 endpoints require Authorization: Bearer … or a session cookie; anonymous mode is removed.
  • userId query parameters are eliminated — the principal comes from the auth context only.
  • Pagination, filtering, and listing endpoints always include WHERE user_id = :principal server-side; the client cannot widen.
  • WebSocket /gateway upgrade requires auth; connection event payload is sanitized server-side; clients never send userId themselves.

  • MCP server (mcp-server/) — MCP clients (Claude Desktop, Claude Code) authenticate via API token. Each MCP connection is bound to one user; tool calls execute in that user’s context.
  • Browser extension — connection is upgraded with the user’s API token (entered once in the popup, stored in extension’s secure storage). The bridge endpoint validates the token and binds the connection to a (user_id, workspace_id) before forwarding any Playwright commands.

17. Where the Code Changes — Concrete Map

Section titled “17. Where the Code Changes — Concrete Map”
ConcernFiles / dirs to touch
Auth middlewaresrc/api/middleware/auth-guard.ts (rewrite — current Bearer-or-MASTER_KEY fallback must go)
Principal typesrc/security/principal.ts (new)
Repositoriessrc/db/repositories/**/*.ts (every read/write takes Principal)
Schema migrationssrc/db/schema/*.ts + drizzle/migrations/*
Vault accessorsrc/security/vault.ts, src/security/vault/inject.ts
Permission gatesrc/security/policy/check.ts (new); call sites in src/core/agent/worker-spawner.ts, every src/tools/*/index.ts dispatcher
Settings hierarchysrc/config/runtime-loader.ts, src/config/settings-service.ts
Workspace FSsrc/security/workspace-fs.ts (new); src/tools/filesystem/*, src/tools/shell/*
Gateway authsrc/core/gateway/hub.ts (upgrade handler), src/core/gateway/message-handler.ts (drop 'local'/'system' fallback at L10–L24)
Channel bindingssrc/channels/*/handler.ts, new src/channels/binding-resolver.ts
Embeddings filtersrc/core/rag/*.ts
Admin APIsrc/api/routes/admin/*.ts (new)
Web authweb/lib/auth-context.tsx (cookies not localStorage), web/lib/api.ts (CSRF)
Web admin pagesweb/app/admin/* (new)
Quotassrc/security/quotas.ts (new); call from root agent and API middleware
Auditsrc/security/audit.ts (new); middleware + repository hooks
MCP authmcp-server/src/server.ts
Extension authbrowser-extension/src/background.ts, src/api/routes/browser-bridge.ts

Phase 0 — Preparation (no behavior change)

Section titled “Phase 0 — Preparation (no behavior change)”
  • Land Principal type, auth-guard rewrite behind a feature flag (MULTIUSER=false keeps current behavior).
  • Land schema additions as nullable columns; backfill in a script; flip to NOT NULL in a follow-up migration.
  • Add audit middleware in shadow mode (writes rows, doesn’t enforce).
  • Flip MULTIUSER=true default. Single-user installs auto-create the bootstrap admin and own all existing rows.
  • Wire permission gate into root agent; defaults are permissive for the bootstrap user, restrictive for new users.
  • Per-user workspaces; data migration script moves files into $DATA_ROOT/users/{admin_id}/workspaces/default/files/.
  • Ship /admin/* pages, user CRUD, quotas dashboard, audit viewer.
  • Channel binding flow (signup link from Telegram/Slack).
  • API tokens UI under user settings.
  • Postgres RLS turned on (external mode).
  • Per-user DEKs; key rotation script.
  • Shell sandboxing via bubblewrap; Docker-in-Docker per-user labels and network.
  • Optional org layer.
  • Session sharing.
  • Org-shared knowledge base.
  • ✅ SCIM provisioning, ✅ SAML SSO (2026-05b — see § 22 below).
  • ✅ Per-user billing hooks (BillingProvider abstraction + Stripe stub, hooked into CostTracker.logUsage).

Each phase is independently shippable with a kill switch (MULTIUSER, ENFORCE_PERMISSIONS, RLS_ENABLED, SHELL_SANDBOX_ENABLED).


  • Authorization unit tests per repository: “user A cannot read user B’s row” matrix.
  • Integration tests in tests/multiuser/: two-user fixtures hitting every API route, asserting cross-user reads return 404 and writes return 403.
  • Red-team plugin (existing eval harness) gains a “cross-tenant exfiltration” suite — agent prompted to leak another user’s data; must always fail.
  • Property test on WorkspaceFS: random path inputs never resolve outside the workspace root.
  • Migration test: snapshot of a v1 single-user DB → run migration → assert all rows owned by bootstrap admin and round-trip via v2 API.

  1. Org layer in v1 or v2? Recommendation: ship users-only first; add org_id columns now (nullable) so the migration is cheap later.
  2. Session sharing UX? Read-only share vs. comment vs. co-edit.
  3. Embedded mode (PGlite) multi-user? Probably yes for small deployments, but RLS isn’t available — application-layer filtering must be considered the security boundary.
  4. Shared agents / public skills? Marketplace already on roadmap; needs signing + per-install permission review.
  5. Quota enforcement during streaming LLM calls — abort mid-generation when quota exceeded vs. wait for next call?
  6. Anonymous read-only “share link” pages for sessions/documents? Useful for support, but a cross-tenant leak risk; defer.

PhaseEngineer-weeks
0 — Preparation2
1 — Enforce isolation3
2 — Admin & UX2
3 — Hardening3
Total to production-ready multi-user~10 weeks for one engineer; ~5 weeks for a pair

Schema is already 80% ready; the dominant cost is the enforcement sweep through repositories, the root agent gate, and the admin UI.


Per-org identity-provider integration. Configuration lives in org_sso_config (migration 0043); one row per organization that has SSO and/or SCIM enabled.

CREATE TABLE org_sso_config (
org_id uuid PRIMARY KEY REFERENCES organizations(id) ON DELETE CASCADE,
saml_enabled boolean NOT NULL DEFAULT false,
saml_entity_id text,
saml_sso_url text,
saml_x509_cert text,
saml_attribute_map jsonb NOT NULL DEFAULT '{}',
scim_enabled boolean NOT NULL DEFAULT false,
scim_token_vault_ref text,
metadata jsonb NOT NULL DEFAULT '{}',
created_at timestamp NOT NULL DEFAULT now(),
updated_at timestamp NOT NULL DEFAULT now()
);

scim_token_vault_ref is the name of a vault entry in the system scope that holds the actual bearer token. Storing the ref instead of the token keeps a database dump from leaking a usable credential.

samlify does the XML / signature heavy lifting. The SP entity is built per request from the per-org config so a single Octipus install serves many orgs each with their own IdP.

EndpointPurpose
GET /api/saml/:orgSlug/metadataSP metadata XML — give this URL to the IdP
GET /api/saml/:orgSlug/loginSP-initiated login — 302 to the IdP SSO URL
POST /api/saml/:orgSlug/acsAssertion Consumer Service

The auth-guard exempts /api/saml/* because the routes do their own validation (signature check by samlify). On a successful ACS the handler:

  1. Verifies the assertion signature against saml_x509_cert.
  2. Maps attributes via saml_attribute_map (defaults match Okta / Azure AD / OneLogin: username, email, common urn:oid: claims, NameID fallback).
  3. Upserts the user (rows can come from password login or SAML — username is the join key).
  4. Ensures org_members(orgId, userId, role='member').
  5. Mints a session via the existing SessionManager, sets the same session_token HttpOnly cookie used by password login.
  6. Redirects to RelayState (sanitized to same-origin paths) or /.

The schema validator defaults to a noop (samlify still checks signatures). Operators wanting strict XSD validation install @authenio/samlify-xsd-schema-validator and set SAML_SCHEMA_VALIDATOR=strict.

/api/scim/v2/Users supports List / Get / Create / PATCH / DELETE; /Groups supports List + Get. RFC 7643 / 7644 shapes.

Per-org Bearer auth: the route resolves the org by matching the incoming token against every org_sso_config row’s scim_token_vault_ref (vault entries in the system scope). The auth-guard exempts /api/scim/*.

OperationEffect on Octipus state
POST /UsersUpsert by userName; ensure org_members row
PATCH /Users/:idUpdate username / email; active: false deactivates only when no other org holds the user (else drops this org’s membership); active: true re-activates only an account this org deactivated (409 otherwise)
DELETE /Users/:idDrop org membership; deactivate the user when no other org membership remains

PATCH and DELETE answer 404 for a user who is not a member of the token’s org. | GET /Groups | Returns the org_admin group with current admins |

GET /api/admin/orgs/:id/sso and PATCH /api/admin/orgs/:id/sso manage the row. The web admin page at /admin/orgs/[id]/sso provides the paste-in form (entity ID, SSO URL, x509 cert, attribute map, SCIM toggle, vault-ref).

  • Single Logout (SLO). The IdP can revoke its own session; the octipus session lives until the cookie expires or the user signs out manually.
  • IdP-initiated login (today only SP-initiated via /login is wired). Adding it is mainly a matter of accepting an unsolicited assertion at ACS — samlify already supports it.
  • Auto-promotion of SCIM org_admin group members to role='org_admin'. Today new SCIM users land as member and the admin role is set manually.

Closes the cross-user leaks listed in the coworking spec (§1.1, L1–L10).

  • Documents and knowledge are per user (L1–L3). The documents tool goes through the scoped repos (agentPrincipal never carries the admin bypass). Every knowledge chunk has an owner and a KnowledgeScope (personal, space, install); product docs are readable under every scope. Admins reach the whole base only with ?scope=install, which is audited. Migration 0125_knowledge_scope assigns owners to existing chunks.
  • Events name their user (L4). Every gateway event carries userId and goes to that user’s connections only; the user-less types are listed in GLOBAL_EVENT_TYPES with a reason (gateway.md, “Event Bus”).
  • One trust level (L5, L6). The local and system trust levels are gone; admin rights are read from the database. Client addresses come from the socket, and forwarded headers count only from security.trustedProxies.
  • Deactivation takes effect at once (L7, L8). setUserActive is the one writer of is_active and records users.deactivated_by (migration 0126_user_deactivation), so SCIM cannot re-enable an account an admin switched off. It revokes sessions, refuses tokens, passkeys and SAML, closes every socket, stops agents and expires pending prompts. A SCIM token acts only on its org’s users.
  • Requests are answered by their requester (L10). Admins answer someone else’s permission request or approval only through /api/admin/permission-requests/:id/resolve and /api/admin/approvals/:id/resolve, with an audited reason.

Tests: src/api/leaks-{knowledge,events,trust,deactivation}.isolation.test.ts.

  • Workspaces are always on. The setting that switched them off is removed; /api/me/workspaces and the workspace header always work.
  • Resolution fails closed. An authenticated request whose workspace cannot be resolved answers 503 instead of running without a workspace filter.
  • No stand-in users. A user id that is not a real user is an error, never “the first admin” or “the first user”; 'system' is only a system job.
  • Deletion is guarded by one check, assertDeletable, which refuses the last active admin (and, from S1, the last owner of a space or an author of space content).

src/db/workspace-tables.ts holds WORKSPACE_TABLES: every table with a workspace_id column, its owner column, and its action on workspace transfer (move / n/a) and on space purge (delete / keep). A database test reads information_schema.columns and fails when a workspace_id table is missing from the list, so transfer, the backfill script and the purge cannot forget a table.

  • Transfer (OrgWorkspaceManager.transfer) reassigns the previous owner’s rows of every move table in one transaction. Workspace secrets (vault.scope='workspace') are decrypted under the old owner’s DEK and re-encrypted under the recipient’s. Notifications, cleanup history and artifacts (keyed by workspace alone) do not move.
  • Backfill (scripts/backfill-workspace-id.ts) stamps the user-level rows of the same move tables with the default workspace; a note whose slug the default workspace already uses stays user-level.
  • Foreign keys. Migration 0127_workspace_integrity resets notes, tasks, knowledge links, workspace repos and background jobs that named a missing workspace or another user’s workspace to user-level (renaming a note that would clash with a user-level slug to <slug>-<id prefix>), then adds REFERENCES workspaces(id) ON DELETE SET NULL to those five columns. Deleting a workspace renames its notes the same way first.
  • Notes follow the request’s workspace under the personal rule (workspace_id = $ws OR workspace_id IS NULL); a slug lookup prefers the workspace’s note, then a user-level one.
  • Vault. getByName(user, name, { workspaceId }) returns the workspace’s secret, decrypted under the row’s own scope and owner, ahead of a user secret of the same name. A workspace secret with no workspace_id belongs to no workspace and is not returned.

26. The web on the gateway (coworking S0d)

Section titled “26. The web on the gateway (coworking S0d)”
  • One socket per tab. The web app uses one /gateway connection per browser tab (/api/auth/ws-ticket, then auth); the legacy /ws and /ws/permissions sockets are removed. Every event goes to its user’s connections only, whatever the tab, and a tab ignores another session’s events.
  • Bounded per user. gateway.maxConnectionsPerUser, gateway.maxFrameBytes and gateway.replayMaxSessions cap connections, frame size and replay buffers. replay answers for the caller’s own sessions only.
  • Workspace switches are clean. The workspace header carries the workspace id and changes synchronously; workspace-scoped queries key on it.

Frame mapping and limits: gateway.md.

A space is a workspace with kind = 'shared' and user_id NULL (migration 0128_spaces; operator and user guide: docs/SPACES.md). Access to it is membership, never ownership:

  • Membership is the only door. workspace_members (workspace_id, user_id, role, scope) is read per request through getMembership (src/core/spaces/service.ts). Roles are code: can(role, action) in src/security/space-access.ts (owner | editor | commenter | viewer | guest). Non-members get 404 for every space id, admins included; an admin reaches a space only by being a member of it.

  • No owner row, no personal leak. Every personal workspace query filters user_id = me, so a space never matches it: the creator’s workspace list, findOwned*, rename, transfer and the personal delete never see a space. The personal delete also refuses kind <> 'personal' explicitly.

  • Invites are bearer secrets: sha256(token) at rest, one conditional UPDATE per use, expiry clamped to spaces.inviteMaxTtlHours, revoke scoped to the invite’s space. GET /api/invites/:token is the one public route, listed by method and exact shape in auth-guard.ts (isPublicRoute); both invite routes count as credential attempts in the per-IP rate limiter.

  • Removal and downgrade take effect at once (onMembershipChanged): the member’s agents in the space stop (AgentManager.stopWorkspace), their pending permission requests there expire (permission_requests.workspace_id, or a session of the space) along with their root-agent approvals, and data sources they own on the space’s artifacts pause (artifact_data_sources.paused_at).

  • Last owner. No path removes, demotes or lets the last owner leave, and assertDeletable refuses to delete a user who is the last owner of a space.

  • Purge (purgeSpace, src/core/spaces/purge.ts) is the only deletion of a shared workspace: owner-only, after spaces.purgeAfterArchiveDays of archive, one transaction over WORKSPACE_TABLES (delete tables, plus rows keyed by the space’s sessions), verified empty before the workspace row goes. audit_log and cost_log (keep) carry workspace_id without a foreign key and survive. Space directories are removed after commit; the cron runner sweeps leftovers hourly.

  • Audit. Every space change writes an audit row with workspace_id, in the same transaction (actions space_*); GET /api/spaces/:id/activity lists them for members.

  • The header names the space; the route decides. resolveWorkspace turns a space in the request header into { workspaceKind: 'shared', spaceRole } for a member and a 404 (workspace_denied) for anyone else. Only the routes in SPACE_ROUTES (src/api/space-routes.ts) act on it, through the space access layer (spaceRepos, src/db/repositories/space.ts); every other route runs in the caller’s default personal workspace. Agents and pipelines addressed by id follow their session’s space for reads and stops only. See docs/SPACES.md, “Working in a space”.

  • Rooms are sessions with kind = 'room' in a shared workspace (D7). Every personal session path filters kind = 'chat' (personalChat in scoped.ts, session-repository.ts), resolveSession refuses rooms, and channelType: 'room' is refused on personal creates — so a room is invisible to every personal route, its creator included (I2).
  • One door, one helper. roomAccess(userId, roomId) reads the space membership and, for a private room, room_members, per request (D5). canActInSession(session, userId, action) replaces inline owner checks; a source test fails on a new session.userId !== comparison.
  • Turns run as the requester (D8), entered only through handleRoomMessage, serialized by the room queue, with the flow label reset per turn and private reads ASK to the requester. No room agent outlives its turn. Approvals are answered by the requester only (D9).
  • Delivery. Room events go to the room:<id> resource only, filled by room.subscribe after roomAccess and pruned by onRoomAccessChanged and onMembershipChanged; every other event keeps the user rule (I11).
  • Membership changes (I5). Removal from a space or a private room stops the member’s running and queued room turns there, expires their requests there and ends their room and space subscriptions.
  • Space memory replaces personal memories in space sessions (D10, I7).

Space notes are live documents (migration 0130_live_documents; details in docs/SPACES.md, “Live documents”).

  • One hub per process (src/core/docs/hub.ts) holds a Yjs document per open note and checks the member’s role on every frame: commenters and viewers read, editors and owners write. A membership change takes effect on the next frame. Frames are capped by spaces.noteMaxBytes and spaces.docMaxUpdatesPerSecond.
  • Every writer merges or is refused. Saves, captures, archive and meeting imports go through the hub: a write names its base (baseSha256) and is merged three-way, or refused as stale; it never reverts a member’s edit. Revisions record authors and on-behalf-of.
  • The agent proposes. In suggest mode (the default) the notes tool’s writes in a space become the session’s pending edit proposal, which a member accepts or rejects.
  • File leases. A member editing a space file holds a lease; every file-changing tool checks it under per-path locks. Shell, git, docker, skill scripts and CLI agents are advisory only.
  • Reindex is install work billed to the space’s last editor, with the space on the cost row (funding: 'install', workspace_id).

Users bring their own models: personal model_config rows (owner_user_id, named u/<userId>/<slug>, migration 0131_personal_models) with their key in the owner’s vault, bound to text lanes through user_model_bindings. The full contract is in docs/SPACES.md (“Own models”); the multi-user invariants it keeps:

  • No cross-user reach. Install-level registry queries filter owner_user_id IS NULL; modelId lookups take { userId } and fall back to that user’s own rows only; explicit names resolve through resolveModel({ userId, name }). Another user’s personal row is never listed, routed, defaulted, cached or passed through.
  • Row identity, not modelId. AgentContext.modelName / CompletionOptions.modelConfigName carry the row; providers re-read it by name. Pricing by modelId reads install rows only.
  • Keys under the row owner (resolveModelKey), never under the requester; the instrumented provider boundary injects a personal row’s key and fails loud without one.
  • Admin surfaces stay install-level: the admin model and topic routes refuse personal rows; the owner manages them at /api/me/models.
  • CLI credentials per owner (cliEnvFor): per-user CLI home, server auth stripped, the owner part of the session store key, resume fingerprint and quota key. Same-OS-user limits are documented in docs/SPACES.md.

31. Sponsor and team surface (coworking S5, §9.1–§9.3)

Section titled “31. Sponsor and team surface (coworking S5, §9.1–§9.3)”
  • Funding is explicit (D13). fundingFor(trigger, space, settings) decides own or sponsor from the space’s agent_funding (migration 0132_space_funding); a cell without a sponsor is refused (funding_off), never charged to someone else. A sponsor names themselves; losing owner rights (removal, demotion, leaving) clears the sponsor in the same transaction, audits it and stops sponsored agents.
  • Sponsored turns run on the sponsor’s models (AgentSponsor, inherited by children), never on the requester’s personal rows.
  • Budgets per payer. Sponsored work checks the space’s space and space_member budgets; own work the requester’s. Personal scopes never count sponsored rows; the token quota sums own agents only. Install-topic calls are stamped install (withInstallUsage) in every turn.
  • Space events stay on the space:<id> resource: task.changed carries ids only; assignment notices re-read the assignee’s membership.
  • Room modes reuse the group channels’ gate; the probe is install work, the answering turn a listen turn run as the member who asked.

32. Group-channel bridge and space connectors (coworking S5, §9.4–§9.5)

Section titled “32. Group-channel bridge and space connectors (coworking S5, §9.4–§9.5)”

Details: docs/SPACES.md, “Group channels bound to a space” and “Space connectors” (migration 0133_space_bridge_connectors).

  • Binding needs both owners. Only a member who owns both the group channel and the space can bind them, with an explicit acknowledgement that everyone in the channel can read what the room shows; it is audited. Losing space ownership, a channel take-over or removal ends the binding.
  • Turns run as the requester. A bound thread is a room of the space; a member’s message runs as a room turn as that member, with their role and funding. Linked people outside the space get a private hint and no turn; unlinked people’s posts are only fenced transcript context.
  • Space secrets are their own vault scope (space, keyed by the space through dekForRow), read only by connector code after a membership check, never through {{secret:}}, and never exempt from the flow guard.
  • No host identity in a space. Each run of the shell, the Git and GitHub tools and CLI agents in a space gets a fresh temporary tool home (0700), removed after the run; the host’s credential variables and helpers are stripped.

33. Guests and registration modes (coworking S6)

Section titled “33. Guests and registration modes (coworking S6)”

Details: docs/SPACES.md, “Guests” and “Registration modes” (migration 0135_guests_remote).

  • Guests reach only their scope. A guest’s { rooms, folders } scope lives on the membership (a CHECK ties it to the guest role) and is applied by every space repo and route: the named rooms and their members, files and notes under the folders, tasks raised from those rooms and the matching knowledge chunks. Documents, artifacts, space memory and private chats are out of reach. A malformed stored scope reads as the empty scope.
  • Registration modes. security.registration (open, invite_only, closed) gates POST /api/auth/register, which runs in one transaction with first-account detection and the invite’s redemption. SAML JIT, SCIM and admin-created accounts are gated by the IdP or an admin instead.

34. Remote members (coworking S7 contract)

Section titled “34. Remote members (coworking S7 contract)”

S7 itself is a contract on the unbuilt federation transport; only the member representation is built (migration 0135_guests_remote; see docs/SPACES.md, “Across installs”).

  • Remote users are rows that cannot sign in. users.kind = 'remote' with remote_instance_id and remote_user_ref; sessions, API tokens, impersonation, SAML and passkeys refuse them, and admin user lists and SCIM leave them out.
  • Local usernames may not start with ~. Registration, admin creation, SCIM and SAML JIT refuse it, and a CHECK enforces it after the migration renamed existing ones; ~name@<instance> is reserved for remote members.

Edit this page in the repository.