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 landedworkspace_idadoption; 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 oldMULTIUSERfeature flag is gone — seemultiuserConfigSchemainsrc/config/schema.ts. The remainingmultiuser.*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 localElysia-named compat class insrc/api/httpthat wraps Hono, not the third-partyelysiapackage, which is not a dependency.
How to read this record
Section titled “How to read this record”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.
Phase 0 — what landed
Section titled “Phase 0 — what landed”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 viaMULTIUSER=false(andMULTIUSER_ENFORCE_PERMISSIONS=falseif the system-user bypass on MCP-bridge calls is still needed).src/security/principal.ts—Principaltype + helpers (principalFromUser,principalFromMasterKey,ANONYMOUS_PRINCIPAL,SYSTEM_PRINCIPAL,canActOnUser).src/api/server.ts— auth.derive()now returnsprincipalalongside the legacyuserfield;MASTER_KEYBearer fallback is suppressed whenmultiuser.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 theaudit_action='api_request'enum value. src/api/middleware/audit-shadow.ts— Elysia plugin that writes oneaudit_logrow per state-changing API request, gated onmultiuser.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.
Phase 1a — what landed
Section titled “Phase 1a — what landed”src/db/repositories/scoped.ts—scopedRepos(principal)factory returningScopedSessionRepo,ScopedMessageRepo,ScopedAgentRepo,ScopedDocumentRepo. Every default read filters byprincipal.userId; cross-tenant reads returnnull/[]so the caller can’t distinguish from “row does not exist”, blocking UUID enumeration. Mutations check ownership in the WHERE clause and stripuser_idfrom caller-supplied patches. Admins get widened reads viaisAdmin(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/sessionsroute fully converted: every handler now goes throughscopedRepos(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. apiContextnow exposesprincipalto all routes (anonymous default for unauthenticated requests).- Net +27 passing tests, zero regressions.
Phase 1a — sweep complete
Section titled “Phase 1a — sweep complete”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 —
getPendingApprovalswas global (leaked pending approvals between users);resolveApprovalhad no ownership check. ApprovalRequest gaineduserId+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 —
findByIdfor pipelines; closed two complete-bypass gaps onPUT /pipelines/templates/:idandDELETE /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.scopeenum (system | user | workspace) with backfill: legacyuser_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-rowallowed_tools/allowed_agentsallowlist 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_versioncolumn. v1 = legacy PBKDF2 (single key for every row). v2 = HKDF(masterKey, salt=userId, info=scope:userId). deriveDek(masterKey, scope, userId)insrc/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.
Phase 1b-3 — WorkspaceFS path sandbox
Section titled “Phase 1b-3 — WorkspaceFS path sandbox”- New
WorkspaceFS.forPrincipal(p)insrc/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 keepsdefault.forAgenttakes the agent’sAgentContext, or{ system: true, root }for a system job, and never falls back to the flat root;forSessionusessession.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')andbobFs.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
extraAllowedPrefixesescape 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.
Phase 1b — cleanup landed
Section titled “Phase 1b — cleanup landed”- Vault key rotation script (
scripts/rotate-vault-keys.ts). Walks everykey_version=1row and forces the lazy v1 → v2 re-encrypt thatvault.getdoes on read.--dry-runreports candidates without writing;--batch=Ntunes 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’svalidatePathnow delegates toWorkspaceFS.forAgent(context). Same call site, same exceptions; behavior diverges by config:multiuser.enabled=false→ flat root atconfig.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 preserveadditionalPathsand the legacy/tmp/assistant-prefix.WorkspaceFS.withRootandforAgentfactories 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.
Phase 1c — root agent permission gate
Section titled “Phase 1c — root agent permission gate”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/denycross-tenant gap. Pre-Phase-1c the WHERE clause matched(id = requestId AND status = 'pending')only — no user filter. The gateway handler calledapprove(requestId, ctx.userId)butctx.userIdwas used as theresolvedByaudit 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 requiresuser_id = resolvedBy; cross-tenant attempts returnfalse(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.isSystemUserbypass. The legacy bypass that lets MCP / API bridges skip the permission prompt is now gated onmultiuser.enforcePermissions. With the flag off, behavior is unchanged. With it on, every tool dispatch goes throughpermissionManager.check— system pseudo-users can no longer skip.getPendingRequests(userId)is already user-scoped (filters bypermission_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_tokenstable (migration0032):(id, user_id, name, token_hash, prefix, scopes, expires_at, last_used_at, revoked_at, metadata, created_at).token_hashis 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 asprefixfor display in the list view so users can identify tokens without revealing the secret. src/security/api-tokens.ts—ApiTokenManagerwithissue / validate / listForUser / revoke / countActive. Validation enforces revocation + expiry, updateslast_used_atin the background, and useslooksLikeApiToken()as a cheap pre-check so non-token Bearer values skip the DB roundtrip..derive()insrc/api/server.tsnow tries: (1) session token, (2) api token (new), (3)MASTER_KEYfallback if multiuser is off. The api-token path loads the user record and returns a normalprincipalFromUser— every downstreamscopedRepos(principal)call works identically to a browser session login./api/auth/api-tokensREST surface:GET /(list, no plaintext or hash),POST /(issue, plaintext returned exactly once,201 Created),DELETE /:id(revoke; cross-tenant attempts return404to 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.tsmock.moduleordering 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.
Phase 2b — API tokens web UI
Section titled “Phase 2b — API tokens web UI”Settings page gains an “API Tokens” tab between Security and Voice. Three things happen here:
- List existing tokens (no plaintext, no hash on the wire).
- 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.
- 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 / isActiveflags; 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.
Phase 2d — channel binding (table + helper + link-account 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.
Schema (migration 0033)
Section titled “Schema (migration 0033)”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)— checkschannel_identitiesfirst, 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— no0/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), andreapStaleCodesfor housekeeping.
REST surface
Section titled “REST surface”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.
Web /link-account
Section titled “Web /link-account”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.
Out of scope for this slice (follow-ups)
Section titled “Out of scope for this slice (follow-ups)”- Channel adapters reading the new helper. Adapters today still call
userRepository.findByChannelBinding; Phase 2e will swap them tochannelBindingManager.findUserByExternalIdso the adapter sends a deep-link reply when the lookup misses. - Bulk migration of
users.channelBindingsJSONB →channel_identities(the lazy backfill on read does this incrementally, but a one-shot script would speed up the cutover).
Phase 2 status
Section titled “Phase 2 status”With 2a/2b/2c/2d landed:
MULTIUSER=trueis 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.tsis now a thin bridge overChannelBindingManager.generateLinkCode()/redeemLinkCode()preserve their public{ success, error }shape so the auth route (/api/auth/redeem-link-code) and the/linkchannel commands keep working unchanged. Codes now live inchannel_link_codes(Postgres, 15-min TTL) instead of Redis (5-min TTL); successful redemption writes to bothchannel_identities(canonical) andusers.channelBindingsJSONB (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
userRepositoryimports in each adapter are intentional —findByChannelBindingstays 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, legacyerrorstrings 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 3a — master-key rotation tooling
Section titled “Phase 3a — master-key rotation tooling”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:
- Derive
oldDek = HKDF(oldMaster, scope, userId). - Derive
newDek = HKDF(newMaster, scope, userId). - Decrypt with
oldDek. On success, re-encrypt withnewDekand persist (UPDATE vault SET ...). - On
oldDekdecrypt failure, trynewDek— if it succeeds the row was already rotated by an earlier run, return'skipped'. - 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.
scripts/rotate-master-key.ts
Section titled “scripts/rotate-master-key.ts”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:
openssl rand -hex 32to generate the new key.- Stop the Octipus server (or maintenance window).
- Run the script with both keys in env.
- Update
MASTER_KEY=<new>in the deployment. - 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).
Design — “bypass on missing GUC”
Section titled “Design — “bypass on missing GUC””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.
Migration 0034_rls_policies.sql
Section titled “Migration 0034_rls_policies.sql”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 caveat
Section titled “PGlite caveat”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.
Operator runbook (external Postgres)
Section titled “Operator runbook (external Postgres)”To actually get RLS enforcement after merging this:
- Apply migration
0034. - 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>; - Point
DATABASE_URLat the app role. - Set
MULTIUSER_RLS=true. - Verify: cross-tenant integration test or manual
psqlprobe.
- 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,withRlsBypassreturns 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:
-
Direct
user_id(textoruuid, NOT NULL or nullable):documents,agents,hooks,pipelines,notifications,trajectory_runs,recurring_tasks,skill_permissions,permission_requests, plus the Phase-0 nullable columnsagent_events,embeddings,hook_executions,swarm_nodes. For nullable columns, rows withuser_id IS NULLnever match the per-user comparison and become invisible withoutwithRlsBypass— 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. -
messages(nouser_idcolumn; ownership lives on thesessionsFK):USING (bypass='true' OR EXISTS (SELECT 1 FROM sessionsWHERE sessions.id = messages.session_idAND sessions.user_id::text = current_setting(...)))Subqueries cost more than direct comparisons but the natural shape for strict child-by-FK relationships;
messages.session_idis already indexed. -
pipeline_stages— same shape asmessages, joined throughpipelines.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
totalTokensacross the user’s agents created in the current UTC day. - API requests per minute — count of
audit_logrows withaction='api_request'for the user in the last 60 seconds.
Schema (migration 0036)
Section titled “Schema (migration 0036)”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.
QuotaManager (src/security/quotas.ts)
Section titled “QuotaManager (src/security/quotas.ts)”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.
Admin REST (/api/admin/quotas)
Section titled “Admin REST (/api/admin/quotas)”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 explicitnullorDELETEfor “no override”). Unknown user → 404. Audit row written viasettings_changed.DELETE /:userId— drop the override row entirely.
Admin web (/admin/quotas)
Section titled “Admin web (/admin/quotas)”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;
willExceedallowed/denied with structured reason;clearOverridedrops the row.
- per-minute API rows;
- 7 admin route (
admin.isolation.test.tsextension): 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 byuser: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:
willExceeddenies 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 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.
Schema (migration 0037)
Section titled “Schema (migration 0037)”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 pairedaudit_logrows 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).
Server .derive() integration
Section titled “Server .derive() integration”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 dual-tagging
Section titled “Audit dual-tagging”audit-shadow middleware (Phase 0) gained an impersonation branch.
When principal.actorUserId is set:
- The primary
api_requestrow 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.
REST surface
Section titled “REST surface”POST /api/admin/impersonate/:userId— start a session. Body{ reason?: string }. Self-target → 400; missing → 404; admin usingMASTER_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.
ImpersonationBannermounted in the root layout readsactorUserId/actorUsernamefrom/auth/me. Yellow bar across the top: ”is acting as . Every action is audited.” Stop button posts to /impersonate/stopand reloads./admin/usersrows 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.
Config flag (security.shellSandbox)
Section titled “Config flag (security.shellSandbox)”'off'(default) — no wrapping; behavior matches pre-3e.'auto'— wrap whenbwraporfirejailis 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 isoffor no runner is found inautomode.runner:'bwrap' | 'firejail' | null.argv: the wrapped command line (or original if not wrapped).cleanup(): removes the per-spawn/tmpscratch dir; safe to call even whenwrappedis false.
Profile (bubblewrap, the preferred backend):
--unshare-pid,--unshare-uts,--unshare-ipc,--unshare-cgroup-try,--die-with-parent.--unshare-netby default;allowNetwork: truekeeps it on.--ro-bind /usr /lib /lib64 /bin /etc /opt.--bind <workspaceRoot>— read-write.- Per-spawn
--bind <fresh-tmp> /tmpfor transient writes (cleaned up bycleanup()). --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).
Shell tool integration
Section titled “Shell tool integration”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;getSandboxModereads config;'required'without a runner throws (only meaningful on hosts without bwrap). - 5 behavioral (
describe.skipIf(!hasBwrap)): bwrap argv shape;--unshare-nettoggle onallowNetwork;cleanupremoves the scratch dir;printfround-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.
Operator runbook
Section titled “Operator runbook”# Install on Debian/Ubuntu:sudo apt-get install bubblewrap
# Or Fedora/RHEL:sudo dnf install bubblewrap
# Then in your env:SHELL_SANDBOX=requiredVerify 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.
Convention
Section titled “Convention”- 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 indocker 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 runsdocker inspectto 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.
Config flag (security.dockerIsolation)
Section titled “Config flag (security.dockerIsolation)”'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 existingdockerinvocations. Empty array when isolation is off so the call sites don’t need to branch.inspectOwnership(name, userId)— runsdocker inspect -f '{{ index .Config.Labels "octipus.user_id" }}' <name>and compares; returns'ok' | 'not_found' | 'wrong_owner'.ensureUserNetwork(userId)— idempotentdocker network create --driver bridge octipus_user_<id>. Reserved for therun_containerop (not in this PR).
Docker tool wiring (src/tools/docker/index.ts)
Section titled “Docker tool wiring (src/tools/docker/index.ts)”list_containers— spliceslistFilterFlags(ctx.userId)into thedocker psinvocation.start_container / stop_container / container_logs / exec_command— each callsassertOwnership(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.
Tests (12 new)
Section titled “Tests (12 new)”- 8 helper unit tests:
userLabel/userNetworkNameshape;isolationActivecombines 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)):inspectOwnershipagainst 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 realdocker 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.
Operator runbook
Section titled “Operator runbook”# In the deployment env:MULTIUSER=trueDOCKER_ISOLATION=enforceExisting 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.
Schema (migration 0038)
Section titled “Schema (migration 0038)”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
defaultworkspace). is_defaultis 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.
Always on
Section titled “Always on”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.
Manager (src/security/orgs.ts)
Section titled “Manager (src/security/orgs.ts)”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.
REST surface
Section titled “REST surface”Mounted under /api:
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /me/workspaces | user | Lazy-creates the default workspace on first read. |
| POST | /me/workspaces | user | {slug, name, isDefault?} |
| PATCH | /me/workspaces/:id | user | Rename only — slug is immutable. |
| POST | /me/workspaces/:id/default | user | Promote to default; clears prior default in same tx. |
| DELETE | /me/workspaces/:id | user | 400 on default; 404 cross-tenant. |
| GET | /admin/orgs | admin | All orgs. |
| POST | /admin/orgs | admin | {slug, name} |
| GET | /admin/orgs/:id/members | admin | |
| POST | /admin/orgs/:id/members | admin | {userId, role?} |
| DELETE | /admin/orgs/:id/members/:userId | admin |
Cross-tenant workspace ids answer 404 Not Found, the same as a
missing one.
Tests (38 new)
Section titled “Tests (38 new)”- 20 manager unit tests (
src/security/orgs.test.ts): slug + name validation, admin gating oncreateOrg/addMember, slug conflicts, member visibility, cross-tenant collapse onfindOwnedById/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 oncannot_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.
Operator runbook
Section titled “Operator runbook”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 4 — workspace_id adoption
Section titled “Phase 4 — workspace_id adoption”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.
Schema (migration 0039)
Section titled “Schema (migration 0039)”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.
npx tsx scripts/backfill-workspace-id.ts # backfill allnpx tsx scripts/backfill-workspace-id.ts --dry-run # report onlynpx 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.
Principal extension
Section titled “Principal extension”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).
scopedRepos filtering + stamping
Section titled “scopedRepos filtering + stamping”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.
Tests (17 new)
Section titled “Tests (17 new)”- 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.
Operator runbook
Section titled “Operator runbook”# Optionally move rows with no workspace into each user's default.npx tsx scripts/backfill-workspace-id.ts --dry-run # previewnpx tsx scripts/backfill-workspace-id.ts # executeClients select a workspace per request via
X-Octipus-Workspace: <slug-or-uuid>. Omitting the header keeps
the user in their default workspace.
Out of scope (follow-ups)
Section titled “Out of scope (follow-ups)”- Vault
scope=workspacewiring (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. Goals & Non-Goals
Section titled “1. Goals & Non-Goals”- Identity — every request, WebSocket frame, channel message, agent execution, and tool call is attributable to exactly one principal (user or service account).
- Isolation — by default, no user can read, write, or influence another user’s sessions, messages, files, embeddings, secrets, settings, or running agents.
- Permissioning — the existing ALLOW / ASK / DENY tool permission model becomes enforced at the root agent/tool boundary, scoped per user.
- Administration — admins can manage users, roles, quotas, system secrets, and audit history through a first-class UI and API.
- Backwards compatibility — existing single-user
STORAGE_MODE=embeddeddeployments continue to work; a single bootstrap user is created on first boot. - Operability — per-user quotas, rate limits, and audit make a shared instance safe to run.
Non-Goals (this iteration)
Section titled “Non-Goals (this iteration)”- 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).
Threat Model
Section titled “Threat Model”| Threat | In scope |
|---|---|
| Authenticated user A reads user B’s session/messages/documents | Yes |
| Authenticated user A invokes an agent that exfiltrates user B’s vault entry | Yes |
| Authenticated user A’s filesystem tools escape into user B’s workspace | Yes |
| Channel adapter (Telegram, Slack) impersonates a user | Yes |
| Compromised browser extension acts as another user | Yes |
| 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 |
2. Tenancy Model
Section titled “2. Tenancy Model”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
personalorg 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.
3. Identity & Authentication
Section titled “3. Identity & Authentication”3.1 Principals
Section titled “3.1 Principals”| Principal | Source of truth | Use |
|---|---|---|
user | users table | Human via web UI, channel, or API token |
service_account | new service_accounts table | CI, MCP clients, browser ext |
system | sentinel UUID | Background 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 intousers. - API tokens — new
api_tokenstable:token_hash,user_id,scopes[],expires_at,last_used_at. Display token once on creation. Bearer inAuthorization: Bearer otk_…. - Channel binding — Telegram chat IDs, Slack user IDs, etc. live in a
new
channel_identitiestable (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
sessionstable 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 inauth_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-Protocolsubprotocol, validated in the gateway upgrade handler before the connection is accepted — never trust auserIdfield sent by the client over an open socket.
3.4 First-boot / migration
Section titled “3.4 First-boot / migration”- On first boot with
STORAGE_MODE=embedded, auto-provision a singleadminuser with a printed one-time password (orADMIN_BOOTSTRAP_TOKENenv). All existing data is owned by that user. - A migration script (
scripts/migrate-to-multiuser.ts) backfillsuser_idcolumns where they’re nullable today.
4. Authorization (Two Layers)
Section titled “4. Authorization (Two Layers)”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.
| Role | Examples |
|---|---|
system_admin | Manage 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 |
user | Default; full access to their own data |
viewer | Read-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 pathtime_window— only weekdays 9–17, etc.rate_limit— N calls per windownetwork_allowlist— for HTTP toolsredact_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:ASKoutside the user’s workspace. - Shell
execute:ASKalways;DENYforrm -rf,dd,mkfs, etc. - Network egress:
ALLOWfor HTTPS,ASKfor non-standard ports. - Vault read:
ALLOWfor own entries,DENYforsystementries unless explicitly granted. - Browser extension:
ASKon first use of a new origin.
5. Data Isolation
Section titled “5. Data Isolation”5.1 Schema changes
Section titled “5.1 Schema changes”Every row that today is implicitly global must carry an owner. Concrete columns to add or backfill (NOT NULL after migration):
| Table | Add columns | Notes |
|---|---|---|
agents | user_id, workspace_id, session_id | Agents are children of a session; cascade delete |
agent_events | user_id (denormalized) | Hot-path filter; index (user_id, ts) |
swarm_nodes | user_id, session_id | Already conceptually per-session |
embeddings | user_id, workspace_id, document_id | Critical — currently global RAG |
documents | workspace_id | user_id exists already |
hook_executions | user_id, workspace_id | |
pipelines | user_id (nullable for system pipelines), org_id | |
recurring_tasks | user_id, workspace_id | |
notifications | user_id | Likely present; verify NOT NULL |
audit | actor_user_id, target_user_id, org_id | |
trajectory_runs | user_id, workspace_id | |
settings | scope enum (`system | org |
vault | scope enum (`system | user |
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.
5.3 Repository layer
Section titled “5.3 Repository layer”Drizzle repositories under src/db/repositories/ are refactored so every
query takes a principal: Principal parameter:
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.
5.4 API enforcement
Section titled “5.4 API enforcement”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.
6. Secrets & Vault
Section titled “6. Secrets & Vault”6.1 Scopes
Section titled “6.1 Scopes”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).
6.2 Tool/agent allowlist
Section titled “6.2 Tool/agent allowlist”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.
6.3 Encryption keys
Section titled “6.3 Encryption keys”- 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.tsre-encrypts all rows in a background job; vault row carrieskey_version.
6.4 Secret injection
Section titled “6.4 Secret injection”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.
7. Sessions & Conversations
Section titled “7. Sessions & Conversations”7.1 Ownership
Section titled “7.1 Ownership”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.
7.2 Sharing (later)
Section titled “7.2 Sharing (later)”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).
7.3 Channel binding integrity
Section titled “7.3 Channel binding integrity”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.
8. Workspaces, Filesystem & Settings
Section titled “8. Workspaces, Filesystem & Settings”8.1 Per-user workspace roots
Section titled “8.1 Per-user workspace roots”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
bwrapwhen available).
8.2 Shell sandboxing
Section titled “8.2 Shell sandboxing”Shell tools currently run as the Octipus container user with full FS access. Tighten:
- Run shell tools in a
bubblewrap/firejailprofile 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.
8.3 Settings hierarchy
Section titled “8.3 Settings hierarchy”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.
9. Knowledge Base / RAG / Embeddings
Section titled “9. Knowledge Base / RAG / Embeddings”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 fromdocuments. - All RAG queries filter by
user_id(and optionally allowworkspace_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:
- Inbound message arrives with
(channel_type, external_id). - Adapter looks up
channel_identitiesto resolveuser_id,workspace_id. - 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).
- If found: build a
Principaland 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.
11. Agent Execution Isolation
Section titled “11. Agent Execution Isolation”- 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) getsuser_id; orphan reaper only reaps within a user. - Trajectory log files split per user:
trajectory/{user_id}/YYYY-MM-DD.jsonl.
12. Admin Console & User Administration
Section titled “12. Admin Console & User Administration”New web routes under /admin (system_admin only):
| Page | Capabilities |
|---|---|
/admin/users | List, 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/settings | Edit system-scope settings |
/admin/system/vault | Manage system-scope secrets |
/admin/system/models | Default model bindings, provider keys |
/admin/audit | Filter audit log by user/action/time |
/admin/quotas | Per-user limits dashboard |
/admin/impersonate | Start 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.
13. Audit & Compliance
Section titled “13. Audit & Compliance”- Every state-changing API call writes an
auditrow 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.
14. Rate Limiting & Quotas
Section titled “14. Rate Limiting & Quotas”| Limit | Scope | Default | Storage |
|---|---|---|---|
| API requests | user | 600/min | Postgres-backed cache, sliding window |
| WebSocket messages | connection | 60/min | In-memory token bucket |
| Concurrent agents | user | 5 | DB count + Postgres-backed cache lock |
| LLM tokens | user/day | 1M (admin override) | Postgres counter |
| Tool executions | user/min | 120 | Postgres-backed cache |
| Auth attempts | IP + username | 10/15min | Postgres-backed cache, with lockout |
All limits surface as 429 with Retry-After; the UI shows quota
usage in the user menu.
15. API Surface Changes
Section titled “15. API Surface Changes”- New base path
/api/v2for all multi-user-aware endpoints./api/v1remains for the bootstrap admin until extension consumers migrate. - All
v2endpoints requireAuthorization: Bearer …or a session cookie; anonymous mode is removed. userIdquery parameters are eliminated — the principal comes from the auth context only.- Pagination, filtering, and listing endpoints always include
WHERE user_id = :principalserver-side; the client cannot widen. - WebSocket
/gatewayupgrade requires auth; connection event payload is sanitized server-side; clients never senduserIdthemselves.
16. MCP Server & Browser Extension
Section titled “16. MCP Server & Browser Extension”- 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”| Concern | Files / dirs to touch |
|---|---|
| Auth middleware | src/api/middleware/auth-guard.ts (rewrite — current Bearer-or-MASTER_KEY fallback must go) |
| Principal type | src/security/principal.ts (new) |
| Repositories | src/db/repositories/**/*.ts (every read/write takes Principal) |
| Schema migrations | src/db/schema/*.ts + drizzle/migrations/* |
| Vault accessor | src/security/vault.ts, src/security/vault/inject.ts |
| Permission gate | src/security/policy/check.ts (new); call sites in src/core/agent/worker-spawner.ts, every src/tools/*/index.ts dispatcher |
| Settings hierarchy | src/config/runtime-loader.ts, src/config/settings-service.ts |
| Workspace FS | src/security/workspace-fs.ts (new); src/tools/filesystem/*, src/tools/shell/* |
| Gateway auth | src/core/gateway/hub.ts (upgrade handler), src/core/gateway/message-handler.ts (drop 'local'/'system' fallback at L10–L24) |
| Channel bindings | src/channels/*/handler.ts, new src/channels/binding-resolver.ts |
| Embeddings filter | src/core/rag/*.ts |
| Admin API | src/api/routes/admin/*.ts (new) |
| Web auth | web/lib/auth-context.tsx (cookies not localStorage), web/lib/api.ts (CSRF) |
| Web admin pages | web/app/admin/* (new) |
| Quotas | src/security/quotas.ts (new); call from root agent and API middleware |
| Audit | src/security/audit.ts (new); middleware + repository hooks |
| MCP auth | mcp-server/src/server.ts |
| Extension auth | browser-extension/src/background.ts, src/api/routes/browser-bridge.ts |
18. Migration & Rollout Plan
Section titled “18. Migration & Rollout Plan”Phase 0 — Preparation (no behavior change)
Section titled “Phase 0 — Preparation (no behavior change)”- Land
Principaltype,auth-guardrewrite behind a feature flag (MULTIUSER=falsekeeps 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).
Phase 1 — Enforce isolation (breaking)
Section titled “Phase 1 — Enforce isolation (breaking)”- Flip
MULTIUSER=truedefault. 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/.
Phase 2 — Admin & UX
Section titled “Phase 2 — Admin & UX”- Ship
/admin/*pages, user CRUD, quotas dashboard, audit viewer. - Channel binding flow (signup link from Telegram/Slack).
- API tokens UI under user settings.
Phase 3 — Hardening
Section titled “Phase 3 — Hardening”- 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.
Phase 4 — Optional
Section titled “Phase 4 — Optional”- Session sharing.
- Org-shared knowledge base.
- ✅ SCIM provisioning, ✅ SAML SSO (2026-05b — see § 22 below).
- ✅ Per-user billing hooks (
BillingProviderabstraction + Stripe stub, hooked intoCostTracker.logUsage).
Each phase is independently shippable with a kill switch
(MULTIUSER, ENFORCE_PERMISSIONS, RLS_ENABLED,
SHELL_SANDBOX_ENABLED).
19. Testing Strategy
Section titled “19. Testing Strategy”- 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.
20. Open Questions
Section titled “20. Open Questions”- Org layer in v1 or v2? Recommendation: ship users-only first;
add
org_idcolumns now (nullable) so the migration is cheap later. - Session sharing UX? Read-only share vs. comment vs. co-edit.
- Embedded mode (PGlite) multi-user? Probably yes for small deployments, but RLS isn’t available — application-layer filtering must be considered the security boundary.
- Shared agents / public skills? Marketplace already on roadmap; needs signing + per-install permission review.
- Quota enforcement during streaming LLM calls — abort mid-generation when quota exceeded vs. wait for next call?
- Anonymous read-only “share link” pages for sessions/documents? Useful for support, but a cross-tenant leak risk; defer.
21. Estimated Effort
Section titled “21. Estimated Effort”| Phase | Engineer-weeks |
|---|---|
| 0 — Preparation | 2 |
| 1 — Enforce isolation | 3 |
| 2 — Admin & UX | 2 |
| 3 — Hardening | 3 |
| 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.
22. SSO + SCIM (2026-05b)
Section titled “22. SSO + SCIM (2026-05b)”Per-org identity-provider integration. Configuration lives in
org_sso_config (migration 0043); one row per organization
that has SSO and/or SCIM enabled.
Database
Section titled “Database”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.
SAML 2.0 SP routes
Section titled “SAML 2.0 SP routes”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.
| Endpoint | Purpose |
|---|---|
GET /api/saml/:orgSlug/metadata | SP metadata XML — give this URL to the IdP |
GET /api/saml/:orgSlug/login | SP-initiated login — 302 to the IdP SSO URL |
POST /api/saml/:orgSlug/acs | Assertion 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:
- Verifies the assertion signature against
saml_x509_cert. - Maps attributes via
saml_attribute_map(defaults match Okta / Azure AD / OneLogin:username,email, commonurn:oid:claims, NameID fallback). - Upserts the user (rows can come from password login or
SAML —
usernameis the join key). - Ensures
org_members(orgId, userId, role='member'). - Mints a session via the existing
SessionManager, sets the samesession_tokenHttpOnly cookie used by password login. - 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.
SCIM 2.0 inbound
Section titled “SCIM 2.0 inbound”/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/*.
| Operation | Effect on Octipus state |
|---|---|
POST /Users | Upsert by userName; ensure org_members row |
PATCH /Users/:id | Update 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/:id | Drop 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 |
Admin surface
Section titled “Admin surface”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).
What’s not done
Section titled “What’s not done”- 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
/loginis wired). Adding it is mainly a matter of accepting an unsolicited assertion at ACS —samlifyalready supports it. - Auto-promotion of SCIM
org_admingroup members torole='org_admin'. Today new SCIM users land asmemberand the admin role is set manually.
23. Leaks closed (coworking S0a)
Section titled “23. Leaks closed (coworking S0a)”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 (
agentPrincipalnever carries the admin bypass). Every knowledge chunk has an owner and aKnowledgeScope(personal,space,install); product docs are readable under every scope. Admins reach the whole base only with?scope=install, which is audited. Migration0125_knowledge_scopeassigns owners to existing chunks. - Events name their user (L4). Every gateway event carries
userIdand goes to that user’s connections only; the user-less types are listed inGLOBAL_EVENT_TYPESwith a reason (gateway.md, “Event Bus”). - One trust level (L5, L6). The
localandsystemtrust levels are gone; admin rights are read from the database. Client addresses come from the socket, and forwarded headers count only fromsecurity.trustedProxies. - Deactivation takes effect at once (L7, L8).
setUserActiveis the one writer ofis_activeand recordsusers.deactivated_by(migration0126_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/resolveand/api/admin/approvals/:id/resolve, with an audited reason.
Tests: src/api/leaks-{knowledge,events,trust,deactivation}.isolation.test.ts.
24. One multi-user model (coworking S0b)
Section titled “24. One multi-user model (coworking S0b)”- Workspaces are always on. The setting that switched them off is
removed;
/api/me/workspacesand 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).
25. Workspace integrity (coworking S0c)
Section titled “25. Workspace integrity (coworking S0c)”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 everymovetable 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 samemovetables with the default workspace; a note whose slug the default workspace already uses stays user-level. - Foreign keys. Migration
0127_workspace_integrityresets 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 addsREFERENCES workspaces(id) ON DELETE SET NULLto 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 noworkspace_idbelongs 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
/gatewayconnection per browser tab (/api/auth/ws-ticket, thenauth); the legacy/wsand/ws/permissionssockets 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.maxFrameBytesandgateway.replayMaxSessionscap connections, frame size and replay buffers.replayanswers 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.
27. Shared spaces (coworking S1)
Section titled “27. Shared spaces (coworking S1)”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 throughgetMembership(src/core/spaces/service.ts). Roles are code:can(role, action)insrc/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 refuseskind <> 'personal'explicitly. -
Invites are bearer secrets:
sha256(token)at rest, one conditionalUPDATEper use, expiry clamped tospaces.inviteMaxTtlHours, revoke scoped to the invite’s space.GET /api/invites/:tokenis the one public route, listed by method and exact shape inauth-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
assertDeletablerefuses 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, afterspaces.purgeAfterArchiveDaysof archive, one transaction overWORKSPACE_TABLES(deletetables, plus rows keyed by the space’s sessions), verified empty before the workspace row goes.audit_logandcost_log(keep) carryworkspace_idwithout 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 (actionsspace_*);GET /api/spaces/:id/activitylists them for members. -
The header names the space; the route decides.
resolveWorkspaceturns a space in the request header into{ workspaceKind: 'shared', spaceRole }for a member and a 404 (workspace_denied) for anyone else. Only the routes inSPACE_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”.
28. Rooms (coworking S2)
Section titled “28. Rooms (coworking S2)”- Rooms are sessions with
kind = 'room'in a shared workspace (D7). Every personal session path filterskind = 'chat'(personalChatinscoped.ts,session-repository.ts),resolveSessionrefuses rooms, andchannelType: '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 newsession.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 byroom.subscribeafterroomAccessand pruned byonRoomAccessChangedandonMembershipChanged; 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).
29. Live documents (coworking S3)
Section titled “29. Live documents (coworking S3)”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 byspaces.noteMaxBytesandspaces.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
suggestmode (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).
30. Own models (coworking S4)
Section titled “30. Own models (coworking S4)”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 throughresolveModel({ userId, name }). Another user’s personal row is never listed, routed, defaulted, cached or passed through. - Row identity, not modelId.
AgentContext.modelName/CompletionOptions.modelConfigNamecarry 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 indocs/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)decidesownorsponsorfrom the space’sagent_funding(migration0132_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
spaceandspace_memberbudgets; own work the requester’s. Personal scopes never count sponsored rows; the token quota sums own agents only. Install-topic calls are stampedinstall(withInstallUsage) in every turn. - Space events stay on the
space:<id>resource:task.changedcarries 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
listenturn 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 throughdekForRow), 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 theguestrole) 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) gatesPOST /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'withremote_instance_idandremote_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.