Module kit / Identity and auth
Multi-tenant workspaces
identity-auth/multi-tenant-workspaces
| Kind | backend |
|---|---|
| Domain | identity-auth |
| Maturity | prod |
| Reusable | extract |
| Stack | fastapi, sqlalchemy-sync, postgres |
| Depends on | _shared/fastapi-project-layout, _shared/auth-dependency-pattern, _shared/workspace-scoping, _shared/alembic-migrations, _shared/test-layout |
| Frontends | identity-auth/ui/workspace-switcher-and-bootstrap, identity-auth/ui/admin-users-console |
| Reference implementation |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-12 |
| Also measured on |
|
| Version | 1 |
Purpose
Every account that can sign in owns or belongs to at least one workspace, and a workspace is the unit everything else is filed under: the tenant every row carries, the thing a person switches between, the row billing attributes spend to, the scope a membership role applies inside. The invariant that makes it trustworthy is that access is one predicate asked everywhere, workspaces.owner_id = user OR an active workspace_users row, and a role is a string on that membership row read live on every call: an owner is inside every fence with no role consulted, a member's rung is looked up at the moment of the request, and nothing about tenancy is carried in a token except the one tid Prima reads when the answer is unambiguous.
This recipe owns the lifecycle: how a workspace comes to exist (five code paths at fd8558f, four shapes), the CRUD, members and settings routes, the role vocabulary and the two permission ladders, and the owner-or-admin rule for money. How a request is scoped to a workspace once it exists is _shared/workspace-scoping (S6), and this recipe does not repeat it.
When to use
Read it before adding a way to create a workspace, before adding a role, before letting a route decide "may this person do this here", before touching workspace_settings, and when a person reports "workspace context required", "I have two workspaces I never made" or "I cannot edit my own workspace". Trigger phrases: "bootstrap", "personal workspace", "add a teammate", "change their role", "owner cannot be removed", "admin or owner only", "who may change billing", "the agency role", "desk_requester has no role", "RLS".
Not this recipe: resolving the tenant on a request, the X-Workspace-ID header, the 400/403/409 table and the middleware (S6); which account is calling (S5); the desk domain sign-in that mints desk_requester memberships (identity-auth/desk-domain-sign-in, B20); the per-workspace configuration document with ETags and versions under /api/workspaces/{id}/config (workspace-config-document, B142, a different table); the integration documents stored inside workspace_settings.settings (/smtp, /imap, /payrexx, /quentn, /email-design: their own recipes in messaging and commerce; S6 records their role gates); admin views across every workspace (identity-auth/admin-user-management-and-impersonation, B16, and the desk agency queue, B137).
Stack assumptions
Labs at fd8558f: fastapi==0.118.2, SQLAlchemy==2.0.43 synchronous Session, Postgres 14 on the test side. The router's handlers are async def calling async service methods that execute synchronous queries on the request's session (TenantService, IsolationService, RBACService are all async def over self.db.execute); the services take the session in their constructor. Foundations assumed: S4 (one mount, app_fastapi.py line 823, LABS_SURFACES; the whole /api/workspaces/ prefix is on the middleware's skip list because these routes name their workspace in the path and check it themselves), S5 (get_current_user on every route; the Prima surfaces cannot reach this router, and GET /api/auth/workspaces exists for them), S6 (WorkspaceID for everyone else), S8 (the tenant tables predate the chain; RLS is workspace_rls_001).
D3 in force for the gateway half of this recipe: the standalone prima repo is the PrimaAPI reference, and the two Labs files cited for the billing guards (backend/services/primaapi/identity.py, backend/api/routers/primaapi_router.py) are the legacy in-Labs gateway copy. They are cited because they are the code that answers the Labs-hosted /api/prima/v1 today, and because Prima carries no customer-facing counterpart for the guard to live on: at cd2c248 prima/api/v1.py declares /models (172), /chat/completions (420), /embeddings (918) and /usage (1091) and no /credits/* or /sparks/* write, the money-in seam being the server-to-server POST /internal/credits/grant (prima/api/internal.py 79). A new gateway copies Prima's files, not these.
Environment: none of this reads a configuration key. Fail behaviour: TenantService.create_workspace raises ValueError on a slug collision (tenant_service.py 66) or on an IntegrityError, rolled back by the two except blocks at 110 to 117, which the router renders as 400; every other service failure in the router is caught and rendered as a 500 with a fixed string (the except Exception blocks throughout workspace_router.py), so a database error never reaches the client as text.
Data model
The four tenant tables (services/workspace/models.py; S6 Data model carries the same shape and is the citation for the column list):
workspaces (
id uuid PRIMARY KEY,
name varchar(255) NOT NULL,
slug varchar(255) NOT NULL UNIQUE, -- idx_workspace_slug
description text NULL,
owner_id uuid NOT NULL REFERENCES users(id), -- idx_workspace_owner; ownership, the column
is_active boolean DEFAULT true, -- idx_workspace_active; DELETE is this flag
created_at, updated_at timestamp
);
workspace_users (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role varchar(50) NOT NULL DEFAULT 'member', -- no CHECK; see the vocabulary below
is_active boolean DEFAULT true,
joined_at, updated_at timestamp, added_by uuid NULL REFERENCES users(id),
UNIQUE (workspace_id, user_id) -- idx_workspace_user
);
workspace_settings (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL UNIQUE REFERENCES workspaces(id) ON DELETE CASCADE,
settings json NOT NULL DEFAULT '{}', -- json, not jsonb: no path index, whole-document writes
created_at, updated_at timestamp
);
workspace_invitations ( -- declared, written by nothing (Gotchas)
id uuid PRIMARY KEY, workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
email varchar(255) NOT NULL, role varchar(50) NOT NULL DEFAULT 'member',
token varchar(255) NOT NULL UNIQUE, -- plaintext; no writer at fd8558f
accepted boolean DEFAULT false, accepted_at timestamp, accepted_by uuid NULL,
invited_by uuid NOT NULL REFERENCES users(id), expires_at timestamp NOT NULL, created_at timestamp
);
The role vocabulary, as the code reads it at fd8558f (there is no CHECK; the column accepts any string):
| Role | Written by | RBACService rung (rbac_service.py 247 to 263) | IsolationService rung (isolation.py 146 to 153) | Permission set (permissions.py 169 to 265) |
|---|---|---|---|---|
owner | create_workspace, every bootstrap path | 4 | 4 | all 46 |
admin | POST /members, PATCH /members/{id} by an owner | 3 | 3 | 44 (all but workspace.delete, billing.manage) |
member | the default; owner or admin may assign | 2 | 2 | 36 |
viewer | owner or admin may assign | 1 | 1 | 14 |
guest | typed by the SPA and mapped to 6 permissions; no route writes it | 0 | 0 (absent from the dict) | 6 |
desk_requester | the desk sign-in (B20); POST /members and PATCH accept it (_MEMBER_ROLES, workspace_router.py line 73) | 0 | 0 | none (no registry entry, has_permission is False for everything) |
agency | POST /api/admin/agency-access only (B16); never assignable through this router | 0 | 0 | none |
The default settings document (tenant_service.py _get_default_settings, 405 onward): data_retention_days: 90, allow_public_sharing: false, require_2fa: false, allowed_file_types, max_file_size_mb: 100, enable_audit_log: true, enable_api_access: true, default_query_timeout: 300, max_connections: 10, and a tracking block (enabled, the pixel and conversion ids as null, three event switches). The four bootstrap paths outside TenantService write a minimal document with ORM inserts instead: the two-key {"timezone": "Europe/Zurich", "currency": "CHF"} at auth_router.py 226, services/primaapi/identity.py 418 and admin_router.py 1550, and those two keys plus "source": "spark" at services/spark/provisioning.py 99. Integration recipes read-merge-write their own top-level keys into the same document (services/smtp_config.py save_smtp_config, 74 to 78, "never clobbers other settings").
What is NOT stored: a role CHECK; a "current workspace" per user (the SPA keeps currentWorkspaceId in localStorage, F2); an invitation anyone can redeem; the tenant in a session token except Prima's tid (S5).
Row-level security: 13 tables carry FORCE ROW LEVEL SECURITY and the workspace_isolation policy on current_setting('app.workspace_id', true) (migrations/versions/20260504d_workspace_rls_policies.py, revision workspace_rls_001; S6 Data model quotes the policy). It is opt-in: a session that never sets the GUC passes.
API surface
api/routers/workspace_router.py, prefix /api/workspaces, mounted LABS_SURFACES, every route Depends(get_current_user) then Depends(get_db), the Labs envelope on refusal (S7). A workspace body is WorkspaceResponse (44 to 52), eight fields, {id, name, slug, description, owner_id, is_active, created_at, updated_at}: the workspaces row itself, with no computed field such as a member count, returned by POST /bootstrap, POST /, GET /, GET /{id} and PATCH /{id}. The gate column names which of the two ladders decides:
| Route | Gate | Codes | Notes |
|---|---|---|---|
POST /bootstrap (102 to 136) | signed in | 200 the first workspace the caller owns or belongs to, else the new personal one | idempotent; the only answer to a 409 no_workspace (S6) |
POST / (139 to 175) | signed in | 201; 400 on a taken slug or IntegrityError; 500 fixed string | body {name, slug?, description?}; the caller becomes owner_id and gets the owner membership row |
GET /?include_inactive= (177 to 201) | signed in | 200 list, ownership counted, ordered by created_at | TenantService.get_user_workspaces (131 to 185) |
GET /{workspace_id} (204 to 245) | IsolationService.verify_workspace_access (membership row only) | 200; 403 "You don't have access to this workspace"; 404 "Workspace not found" after access passed | 403 not 404: the caller named the workspace, S6 |
PATCH /{workspace_id} (247 to 302) | RBACService.check_permission("workspace.update") (owner, admin) | 200; 403 "You don't have permission to update this workspace"; 404 | body {name?, description?} |
DELETE /{workspace_id} (305 to 347) | check_permission("workspace.delete") (owner only) | 204; 403; 404 | soft: is_active = false (deactivate_workspace, 366 to 377); activate_workspace (379 to 390) has no route |
GET /{workspace_id}/members?include_inactive= (350 to 387) | check_permission("user.view_list") (owner, admin, member) | 200 [{id, user_id, role, is_active, joined_at}]; 403 | no email in the row; the SPA joins names from elsewhere |
POST /{workspace_id}/members (390 to 455) | RBACService.can_assign_role(role) (owner: anything but owner; admin: member, viewer) | 201; 403 "You don't have permission to assign '<role>' role"; 404 "User with email '<email>' not found"; 400 already a member or workspace missing (ValueError) | body {email, role} with role matching `^(admin |
PATCH /{workspace_id}/members/{user_id} (458 to 509) | validate_role_change (367 to 395): can_assign_role and can_manage_user and the target is not the owner | 200; 403 with the failing rule's sentence; 400 not a member | "Cannot change owner role" is a 403 here and a 400 from the service if reached |
DELETE /{workspace_id}/members/{user_id} (512 to 561) | can_manage_user (170 to 207: owner manages everyone, admin manages member, viewer, guest) | 204; 403; 404 "User not found in workspace"; 400 "Cannot remove workspace owner" | the row is deleted, not deactivated |
GET /{workspace_id}/settings (564 to 604) | verify_workspace_access (membership only) | 200 {id, workspace_id, settings, created_at, updated_at}; 403; 404 | the whole document, integration blocks included |
PATCH /{workspace_id}/settings (607 to 667) | check_permission("workspace.manage_settings") (owner, admin) | 200; 403 | body {settings: {...}} replaces the whole document (update_workspace_settings, 329 to 355); no ETag, no merge |
the integration documents /smtp, /imap, /payrexx, /quentn, /email-design (670 to 1128) | verify_workspace_access, required_role="admin" on three of the five PUTs | per S6 API surface | owned by their own recipes |
Reachable from a Prima surface (api/routers/auth_router.py 733 to 820, ActiveUser, mounted with /api/auth on ALL_SURFACES): GET /api/auth/workspaces returns {workspaces: [{id, name, slug, role, is_owner}]}, the caller's own memberships with ownership counted, ordered owner-first then name, five fields and nothing else, so the chat server and the portal can fill a picker for a multi-workspace subject (the docstring at 738 to 782 says why the query must stay identical to the one that authorises a key mint).
Platform-wide (admin, B16): GET /api/admin/workspaces?page=&page_size= (admin_router.py 124 to 195) lists every workspace with owner email and member count; POST /api/admin/agency-access writes role='agency' memberships for the calling admin.
On the gateway (the legacy in-Labs gateway copy per D3, kept because it is the code that answers the Labs-hosted /api/prima/v1 today; w1-auto-topup at 8b5361c, unmerged): every billing write under /api/prima/v1/credits/* and the legacy /sparks/* passes _require_billing_role (primaapi_router.py 832 to 869: user_may_change_billing, else 403 {"error": "owner_required"}) on all six sites, and _require_labs_session (792 to 830: auth_method == "jwt", else 403 {"error": "session_required"}) on five of them. The session guard is unconditional on PUT /credits/auto-topup (1344), DELETE /credits/auto-topup (1512) and the three /sparks/* writes (checkout 1793, change 2027, cancel 2219); on POST /credits/topup it runs only when the body carries save_card: true (1005 to 1006), because banking a card changes the tenant's instrument while a plain top-up does not. So one of the six writes stays open to an ai-scoped prima_pk_ key, and that one is role-checked on the key's own user_id: the owner's or an admin's key still tops up, a member's key is owner_required (the helper's docstring, 850 to 854). Reads stay open to any member and to the key.
Application logic
How a workspace comes to exist, the five sites at fd8558f and their four shapes:
POST /api/auth/register(auth_router.py283 to 303):TenantService.create_workspacerun on a fresh event loop (asyncio.new_event_loop().run_until_complete, 288 to 296) because the handler is synchronous; a failure is logged and registration succeeds without a workspace (299 to 303)._ensure_workspace(user)(auth_router.py176 to 232) on every login path (605 after a password login, 1218 after a magic link): asksSELECT 1 FROM workspaces WHERE owner_id = :uid(owns one, not has one), skips a desk-only account (DESK_ONLY_ROLESmembership, 196 to 210), then inserts workspace,ownermembership and the two-key settings document in its ownget_db_context()session; every exception is logged and swallowed.POST /api/workspaces/bootstrapandPOST /api/workspaces/throughTenantService.create_workspace(tenant_service.py34 to 117): optional slug or_generate_slug(392 to 403: lower-cased, punctuation collapsed to hyphens, four random characters), the slug-taken check, workspace flushed,ownermembership, the full default settings document, one commit.create_api_only_account(services/primaapi/identity.py401 to 420, B14): the same three rows, inside the signup transaction, no commit of its own.POST /api/admin/create-user(admin_router.py1529 to 1552, B16) and Spark provisioning (services/spark/provisioning.py88 to 101, whose settings document carries the third keysource: "spark"): the same three rows again; the admin one inside atry/exceptthat logs and continues.
The membership flow through the router, in commit order:
add_workspace_member:can_assign_rolereads the actor's own membership row (get_user_role,rbac_service.py114 to 139, membership only), then the target user is looked up by email, thenadd_user_to_workspace(tenant_service.py187 to 235) refuses a missing workspace or an existing row withValueErrorand commits the new row withadded_by. The actor's ownership without a membership row is not consulted (Gotchas).update_member_role:validate_role_changerunscan_assign_role,can_manage_user(both actor and target roles read fromworkspace_users), then refuses a target whose role isowner;update_user_role(295 to 327) refuses the owner again and commits.remove_workspace_member:can_manage_user, thenremove_user_from_workspace(237 to 264) refuses the owner and deletes the row.delete_workspace:check_permission("workspace.delete")(owner only,RolePermissions.ADMINexcludes it), thenis_active = false. Nothing under the workspace is touched; the rows stay and S6's fences keep answering for members, since_user_has_workspace_accessdoes not readworkspaces.is_active(Gotchas).update_workspace_settings: the client sends the whole document and the service replaces it (workspace_settings.settings = settings, 350); the SPA's tracking page does a GET, spreads the result and PATCHes it back (frontend/src/pages/TrackingSettings.tsx532 to 540).
The billing rule (w1-auto-topup, services/primaapi/identity.py 107 to 143, decision A-1 of 2026-09-11): user_may_change_billing(db, user_id, workspace_id) is one text-SQL query, w.owner_id = :uid OR (an active workspace_users row with role IN ('owner', 'admin')), BILLING_ROLES = ("owner", "admin") at line 107, composed from the ownership half of _user_has_workspace_access and the role rung IsolationService.verify_workspace_access(required_role="admin") applies, written sync because every gateway handler is a plain def.
Invariants
- Access is
owner_id = user OR active membership, asked in every fence S6 lists and inget_user_workspaces(tenant_service.py146 to 173, the comment on why the inner join minted duplicates), inGET /api/auth/workspaces(theWHEREatauth_router.py798) and inuser_may_change_billing. The two exceptions,IsolationService.verify_workspace_accessandRBACService.get_user_role, read the membership row only, and this recipe names them under Gotchas rather than copying them. - Every account that can sign in has a workspace after sign-in:
_ensure_workspaceon both login paths,create_workspaceat registration,_create_personal_workspaceat API-only signup, the admin create path. A desk-only account never owns one (_ensure_workspace196 to 210;test_owning_a_workspace_disqualifies_a_desk_only_account,tests/test_desk_acting_email.pyline 411). - An owner cannot be removed or demoted through the router:
remove_user_from_workspaceandupdate_user_rolerefuse theownerrow,can_assign_rolenever grantsowner,validate_role_changerefuses an owner target; the only way to change ownership isworkspaces.owner_idby hand. - An admin manages
member,viewerandguestand assignsmemberandviewer; an owner manages everyone and assigns anything butowner(can_manage_user170 to 207,can_assign_role209 to 245).desk_requesteris therefore assignable by an owner only. - A workspace list is deterministic:
ORDER BY created_at ASCinget_user_workspaces(177 to 181, the comment on the SPA landing in a different workspace between page loads); owner-first then name inGET /api/auth/workspaces. - Delete is a flag: no row under a workspace is removed by
DELETE /{workspace_id}, and there is no route that reactivates. - A billing write is the owner's or an active
adminmember's on all six sites, never a plain member's; and the standing instruction and the banked instrument are changed from a signed-in session, never with the inference key. The one write that is not session-gated is a plainPOST /credits/topupwith nosave_card, which anaikey may still make and which is role-checked on the key's own user (A-1;tests/prima_credits/test_auto_topup_endpoints.pypins both refusals and that exception on that branch: 621, 630, 648, 668, 727). - The settings document is written whole: a PATCH body is the new document, and a client that omits a top-level key deletes it.
Frontend
The modules that render this, by id:
identity-auth/ui/workspace-switcher-and-bootstrap F2, SPA: WorkspaceContext reads GET /api/workspaces/, the switcher in
AppShell, bootstrap on a 409, the role badge (owner, admin, member,
viewer, guest, desk_requester), a hard reload on switch,
currentWorkspaceId in localStorage cleared only at logout
identity-auth/ui/admin-users-console F4, SPA: /admin/users; memberships per account, the agency
self-grant, the platform-wide workspace list
identity-auth/ui/chat-signin-and-workspace-picker F6, chat: GET /api/auth/workspaces fills the picker for a
multi-workspace subject; one workspace is silent, zero is its
own screen (listed by S6; the recipe id is B21's frontend, and
neither side of the graph records the edge: Gotchas)
Minimum admin and debug surface: GET /api/admin/workspaces (every workspace, owner, member count); GET /api/workspaces/{id}/members (the roles the switcher shows); SELECT w.id, w.name, w.owner_id, count(wu.id) FROM workspaces w LEFT JOIN workspace_users wu ON wu.workspace_id = w.id AND wu.is_active GROUP BY 1, 2, 3 HAVING count(wu.id) = 0 (owners without a membership row, the population get_user_workspaces was changed for); SELECT owner_id, count(*) FROM workspaces WHERE is_active GROUP BY 1 HAVING count(*) > 1 (the duplicates the inner join used to mint, and every account that will answer 400 without a header); the log lines Bootstrapped workspace <id> for user <id>, Auto-created workspace <id> for user <id> and _ensure_workspace skipped for desk-only user <id>.
Security & tenancy
Auth dependency: get_current_user on every route in this router (S5); ActiveUser on GET /api/auth/workspaces; AdminUser on the admin list. Workspace derivation: the path segment, checked by the route itself against the caller's membership or role; the middleware skips the prefix (S6 Application logic step 1). A desk-only account may GET /api/workspaces and GET /api/workspaces/{id} and nothing under it (desk_only_path_allowed, S6), which _enforce_desk_only_containment applies before any handler here runs.
Fail direction: every gate here is a boolean from a query on the caller's own row; a missing row is False and a refusal, so the router fails closed on membership, and the service layer's ValueErrors are 400s, never silent. _ensure_workspace and the two admin-side bootstraps fail open by swallowing exceptions (an account can end up without a workspace and answer 409 until the next login, which then repairs it). RBAC and isolation refuse an owner who lacks a membership row (Gotchas), which is fail-closed in the wrong direction.
Credentials at rest: none of this recipe's own; the integration blocks inside workspace_settings.settings hold values encrypted by their recipes (B19), and GET /{id}/settings returns the document with those ciphertexts to any active member (S6 Security & tenancy, the reason a desk-only account may fetch the workspace object and not this).
Rate limits: none specific. Attacker-controlled inputs: the path workspace_id (a UUID, then a membership query); slug on create (unique index, ValueError on collision; the middleware accepts a slug in the header for resolution, S6 Gotchas); email on add-member (an exact-match lookup on users.email, and the 404 text echoes it back to a caller who is already an owner or admin of a workspace); role (a regex on the body plus the ladder checks); the settings body (dict, unvalidated, written whole).
Tests
On the test Postgres of S9, all at fd8558f, run 2026-09-12:
tests/integration/test_workspace_isolation.py:test_form_templates_isolated(90),test_leads_isolated(107),test_client_tags_isolated(120),test_tracking_sessions_isolated(131),test_tracking_contacts_isolated(147); two workspaces, one row each, a query scoped to A returns exactly the A row. Measured:5 skippedon a fresh test database (test DB has no users, the order dependence S6 records); they pass only after a test in the same session has created a user.tests/integration/test_full_user_journey.py::TestTeamCollaborationJourney:test_permission_management(767:POST /api/workspacesthenGET /{id}/members) passes;test_invite_collaborate_share(714) creates the workspace with 200 or 201 and then fails at aGET /api/dashboards/{id}with 400, a step outside this recipe. Measured1 failed, 1 passed.tests/test_desk_lifecycle.py::test_tenant_isolation_other_workspace_404_non_member_403(957 to 1000): the 404-versus-403 vector on the child routes andGET /api/workspaces/{A}/config; S6's acceptance check 5 runs it with the four containment tests fromtests/test_desk_acting_email.py(391, 395, 411, 505).tests/test_desk_agency.py(7 cases):test_platform_admin_sees_every_workspace(146),test_agency_filters_and_out_of_scope_workspace_is_403(91); withtests/test_admin_api_key_scopes.pythe pair prints27 passed in 16.32s(2026-09-12).tests/test_surface_scoped_access.py::test_api_only_account_is_still_a_workspace_owner_and_that_is_now_harmless(316) andtests/test_primaapi_identity.py::test_signup_creates_user_workspace_and_key(135): the fourth creation path.tests/v2/conftest.py::cross_tenant_client(83 to 120): a second user in a second workspace with the header preset, used by four v2 files.tests/prima_credits/test_auto_topup_endpoints.pyonw1-auto-topupat 8b5361c:test_the_billing_roles_are_the_ones_the_rest_of_labs_calls_admin_or_better(621),test_the_owner_without_a_membership_row_may_write(630),test_an_admin_member_may_write(648),test_a_plain_member_is_refused_on_every_write_and_nothing_moves(668, assertingdetail.error == "owner_required"at 684),test_the_credential_is_judged_before_the_person(727, the plain top-up anaikey may still make) andtest_a_plain_member_is_refused_on_the_legacy_sparks_writes(931). These are the tests the billing Invariant rests on; they were read at 8b5361c and not re-run in this wave (the branch's HEAD has moved past that commit), so the recipe claims their content and not a measured result.- Known-answer vectors: the role ladder is
viewer 1, member 2, admin 3, owner 4in both services andguest 0in one of them;len(Permission) == 46, and the per-role sets are 46, 44, 36, 14, 6 (python -c "from services.auth.permissions import Permission, RolePermissions as R; print(len(Permission), len(R.OWNER), len(R.ADMIN), len(R.MEMBER), len(R.VIEWER), len(R.GUEST))"prints46 46 44 36 14 6);PermissionRegistry().get_role_permissions("desk_requester")isset();_MEMBER_ROLESis^(admin|member|viewer|desk_requester)$;BILLING_ROLES == ("owner", "admin")on 8b5361c. - What a new app adds and Labs lacks: a hermetic test per router route (bootstrap idempotence with an owner who has no membership row; the admin-cannot-assign-admin refusal; the owner-cannot-be-removed refusal; delete leaves the child rows and the member list intact; settings PATCH replaces the whole document). Playwright: none dedicated; the S2 shell spec exercises the switcher.
Deploy & ops
Config keys: none. Migrations order: the four tenant tables are in the baseline; workspace_rls_001 (20260504d_workspace_rls_policies.py, revises tracking_sites_001) is reversible; 20260903_0002_projects_workspace_id.py adds the nullable projects.workspace_id (S6). A new tenant table ships its workspace_id NOT NULL FK in the same revision and runs python scripts/check_workspace_isolation.py by hand from the repo root, not from backend/ (the script is alchemy_labs/scripts/check_workspace_isolation.py), without refreshing the baseline (S6 Deploy & ops; the ratchet is red with 19 unclassified tables at fd8558f).
Scheduler jobs: none. Argus checks: POST /api/workspaces/bootstrap with the seeded owner's token returns 200 with the same id on two consecutive calls; the duplicates query under Frontend returns zero rows on the customer host, or a known list. Rollback: nothing deployable beyond the RLS migration, which downgrades cleanly.
Operational repair, from the comment at tenant_service.py 146 to 155: accounts that accumulated several personal workspaces before get_user_workspaces counted ownership are still on production; each is a manual is_active = false on the extras after checking nothing is filed under them, never a delete.
Gotchas
- 2026-09-12 (measured at fd8558f):
IsolationService.verify_workspace_access(isolation.py109 to 163) andRBACService.get_user_role(rbac_service.py114 to 139) both readworkspace_usersonly, while ownership lives onworkspaces.owner_idand owners without a membership row exist on production (tenant_service.py146 to 155,auth_router.py779 to 782). Such an owner is refused byGET /{id},PATCH /{id},DELETE /{id}, every members route and both settings routes of their own workspace, whileGET /api/workspaces/lists it and every S6 fence admits them. D8, theIsolationServicerow: both readers derive the rung from the one owner-or-member predicate (owner whenowner_idmatches, else the row's role), the shapeuser_may_change_billingalready has; no code changed in this wave. - 2026-09-12 (measured at fd8558f): the two ladders disagree on
guestand on unknown strings.RBACService.get_role_hierarchy_levelmapsguestto 0 explicitly andIsolationServiceomits it, and both scoredesk_requesterandagencyat 0 through the dict default (isolation.py153,rbac_service.py263), belowviewer; the permission registry has no entry fordesk_requesteroragencyat all (permissions.py273 to 279). The outcome is coherent (a desk requester and an agency member pass no role gate in this router and are contained by the desk allow-list or served-workspace list instead), but it is coherent by accident. A new app has one ladder, one registry, and a CHECK on the column. - 2026-09-12 (measured at fd8558f): the inventory says "three creation paths"; five sites write the three rows, in four shapes:
TenantService.create_workspace(full default settings, slug from the name),_ensure_workspace(ORM inserts, two-key settings,ws-slug, own session, every error swallowed),_create_personal_workspace(same shape, inside the signup transaction), the admincreate-userpath (same shape again) and Spark provisioning (spark-slug and a third settings key,source: "spark",provisioning.py99). The inventory's count is superseded; a new app has one function. - 2026-09-12 (measured at fd8558f):
POST /api/auth/registerruns theasynccreate_workspaceon a fresh event loop inside a synchronous handler (auth_router.py286 to 298). The service isasync defover synchronous queries, so the loop buys nothing and costs a loop per registration; a new app makes the service synchronous and the routerdef. - 2026-09-12 (measured at fd8558f):
_ensure_workspaceasks "does this user own a workspace" (line 186), so an account that is only a member of somebody else's workspace gets a personal one minted at first login and is a multi-workspace user from then on (S6 Gotchas records the 400 that follows). ADR-001's corollary that every user must have a workspace is satisfied; whether "has" should mean "owns" is a product question the code answered by itself. - 2026-09-12 (measured at fd8558f):
PATCH /{id}/settingsreplaces the whole document (tenant_service.py350) with no ETag or merge, andGET /{id}/settingsreturns the whole document including the integration blocks. Two editors lose each other's writes, and a client that sends its own block alone deletes every other block (the SPA's tracking page avoids this by GET, spread, PATCH,TrackingSettings.tsx532 to 540). The desk's config document (B142) has ETags, sectional PATCH and versions; this one predates it. A new app gives the settings route the B142 shape. - 2026-09-12 (measured at fd8558f):
workspace_invitationsis declared (models.py106 to 139) with a plaintexttokencolumn and is read or written by nothing outside the model package;POST /{id}/membersadds an existing account by email instead. Invitations for people without an account are B1's registration plus this route. A new app either drops the table or builds it with a hashed token like B14's intents. - 2026-09-12 (measured at fd8558f):
DELETE /{id}flipsis_activeand nothing else;_user_has_workspace_access(api/dependencies.py621 to 642) does not readworkspaces.is_active, so a member of a deactivated workspace keeps reading and writing its rows with the header set, and only the two listings hide it:get_user_workspacesby default, andGET /api/auth/workspacesthrough theAND w.is_active = TRUEof its query (auth_router.py799). There is no reactivation route (activate_workspaceexists in the service). A new app checksis_activein the predicate. - 2026-09-12 (measured at fd8558f): the
POST /{id}/members404 echoes the submitted email in the detail string (line 424). The caller is already an owner or admin of a workspace, so the enumeration is bounded, and the string is still user input reflected verbatim; a new app answers"No account for that address". - 2026-09-12 (measured at fd8558f,
prima-chat743b840,prima-developerc594dfc): two consumers ofGET /api/auth/workspacesare real and unrecorded in the module graph. The chat server calls it (prima-chat743b840,server/identity.ts441) and so does the developer portal (prima-developerc594dfc,src/lib/prima-api.ts626), but this recipe'sfrontend_modulescarries only F2 and F4 (inventory row B15's Frontends column), andidentity-auth/ui/chat-signin-and-workspace-picker'sbackend_modulesnames B21, B5, B11 and B4 and not B15. D8: the edge is missing in both directions and no file was changed in this wave; the inventory row is the place to add it. - 2026-09-12 (measured at fd8558f): the inventory's B15 row says
get_db_with_rlsis used by no router; five routers declareDatabaseSessionWithRLSat 22 sites (S6 Gotchas, measured 2026-09-11). RLS is live on those routes and opt-in elsewhere. - 2026-09-12 (measured at fd8558f):
projects.workspace_idis nullable since20260903_0002with no backfill (S6 Gotchas); reads treatNULLas legacy, ownership unverified. - 2026-09-12 (measured at 8b5361c): the owner-or-admin billing rule lives on
w1-auto-topuponly (user_may_change_billing,_require_labs_session,_require_billing_role), forked fromfeat/service-deskat 1379a38, unmerged; the inventory records the W1 branch as failed in wave 1. The rule stands as this recipe's rule (A-1, founder decision 2026-09-11); the reference for it is that branch until it lands. Ond1a-token-source,grep -n "BILLING_ROLES" backend/services/primaapi/identity.pyprints nothing. - 2026-09-12 (measured at fd8558f):
tests/integration/test_workspace_isolation.pyborrowsdb.query(User).first()for the owner and skips when there is none, so it proves nothing on a fresh database (S6 Gotchas). A new app's isolation test creates its own two users inside the savepoint. - 2026-09-11 (wiki 261be0e,
platform/architecture.md"Multi-Tenancy"): the page still opens with the middleware skipping its access check for JWT requests; since 66be4f7 (2026-05-04) it does not (S6 Gotchas). The code wins.
Reference implementation
Paths are relative to the repo root; commits as in measured_on and also_measured_on.
alchemy_labs(d1a-token-sourceat fd8558f, backend identical tofeat/service-deskb1089b6):backend/api/routers/workspace_router.py: models (32 to 98,_MEMBER_ROLES73),bootstrap_workspace(102 to 136),create_workspace(139 to 175),list_workspaces(177 to 201),get_workspace(204 to 245),update_workspace(247 to 302),delete_workspace(305 to 347),list_workspace_members(350 to 387),add_workspace_member(390 to 455),update_member_role(458 to 509),remove_workspace_member(512 to 561),get_workspace_settings(564 to 604),update_workspace_settings(607 to 667), the integration documents (670 to 1128); the mount atapp_fastapi.py823.backend/services/workspace/tenant_service.py:create_workspace(34 to 117),get_workspace_by_id(119),get_workspace_by_slug(125),get_user_workspaces(131 to 185),add_user_to_workspace(187 to 235),remove_user_from_workspace(237 to 264),get_workspace_user(266 to 279),get_workspace_members(281 to 293),update_user_role(295 to 327),update_workspace_settings(329 to 355),get_workspace_settings(357 to 364),deactivate_workspace(366 to 377),activate_workspace(379 to 390),_generate_slug(392 to 403),_get_default_settings(405 to 462).backend/services/workspace/models.py:Workspace(17 to 46),WorkspaceUser(49 to 79),WorkspaceSettings(82 to 103),WorkspaceInvitation(106 to 139).backend/services/workspace/isolation.py:verify_workspace_access(109 to 163).backend/services/auth/rbac_service.py:Role(26 to 32),check_permission(44 to 75),get_user_role(114 to 139),can_manage_user(170 to 207),can_assign_role(209 to 245),get_role_hierarchy_level(247 to 263),validate_role_change(367 to 395; the file is 395 lines).backend/services/auth/permissions.py:Permission(15 to 88, 46 members),RolePermissions(169 to 265),PermissionRegistry(268 to 465,has_permission282 to 294).backend/api/routers/auth_router.py:_ensure_workspace(176 to 232), the registration bootstrap (283 to 303), the login callers (605, 1218),MyWorkspace(718 to 726) andMyWorkspaces(729 to 730),list_my_workspaces(733 to 820, the predicate at 798).backend/services/primaapi/identity.py:_user_has_workspace_access(82 to 95),_create_personal_workspace(401 to 420);backend/api/routers/admin_router.py:list_workspaces(124 to 195), thecreate-userbootstrap (1529 to 1552),grant_agency_accessandrevoke_agency_access(3101 to 3199);backend/services/spark/provisioning.py(88 to 101).backend/middleware/workspace_middleware.py,backend/api/dependencies.py(get_db_with_rls268 to 336,_user_has_workspace_access621 to 642,WorkspaceID779),backend/migrations/versions/20260504d_workspace_rls_policies.py: the S6 anchors, not repeated.- Tests as listed under Tests.
alchemy_labs(w1-auto-topupat 8b5361c, unmerged), the legacy in-Labs gateway copy per D3, cited because it is the code that answers the Labs-hosted/api/prima/v1today and because Prima carries no customer-facing credits or sparks write route; a new gateway copies Prima's files:backend/services/primaapi/identity.pyBILLING_ROLES(107) anduser_may_change_billing(110 to 143);backend/api/routers/primaapi_router.py_require_labs_session(792 to 830),_require_billing_role(832 to 869), its six_require_billing_rolecall sites (1011, 1345, 1513, 1794, 2028, 2220) and the five unconditional_require_labs_sessionsites (1344, 1512, 1793, 2027, 2219) plus the conditional one behindif body.save_card:(1005 to 1006).- Wiki
philosophers_stone(261be0e):platform/architecture.md("Multi-Tenancy"),platform/workspace-config.md(the B142 document, for contrast withworkspace_settings),operations/decisions/0001-workspace-id-dependency-for-tenant-isolation.md,operations/prima-modules-inventory.md(rows B15, F2, F4; the D8 identity list in the wave 2 section).
Deviations of the reference from this recipe, all dated 2026-09-12: the two membership-only readers refusing row-less owners; the ladders' disagreement on guest and their silent zero for unknown roles; five creation sites in four shapes, one on a throwaway event loop; _ensure_workspace asking "owns"; the whole-document settings PATCH with no ETag; the unused invitation table with a plaintext token; deactivation not read by the access predicate and no reactivation route; the echoed email in a 404; the billing rule on an unmerged branch; the isolation test that skips on a fresh database.
Acceptance checks
Run from alchemy_labs/backend at fd8558f with the :5434 test Postgres up, or from a new app built to this recipe. Expected observations are exact.
python -c "from services.auth.permissions import Permission, RolePermissions as R, PermissionRegistry; print(len(Permission), len(R.OWNER), len(R.ADMIN), len(R.MEMBER), len(R.VIEWER), len(R.GUEST), sorted(PermissionRegistry().get_role_permissions('desk_requester')))"prints46 46 44 36 14 6 [].grep -n "_MEMBER_ROLES = " api/routers/workspace_router.pyprints line 73 with^(admin|member|viewer|desk_requester)$;grep -c "verify_workspace_access" api/routers/workspace_router.pyprints15;grep -c "check_permission(" api/routers/workspace_router.pyprints4.grep -n "Workspace.owner_id == user_id" services/workspace/isolation.py services/auth/rbac_service.py | wc -lprints0(the two membership-only readers, Gotchas);grep -n "Workspace.owner_id == user_id" services/workspace/tenant_service.pyprints line 168 (ownership counted in the list).grep -n "db.add(Workspace(" api/routers/auth_router.py services/primaapi/identity.py api/routers/admin_router.py services/spark/provisioning.py services/workspace/tenant_service.py | wc -lprints3andgrep -n "workspace = Workspace(" services/workspace/tenant_service.py api/routers/admin_router.py | wc -lprints2: five construction sites across five files (tenant_service.pyline 69 andadmin_router.pyline 1533 assign, the other three add inline).python -m pytest tests/test_desk_agency.py tests/test_admin_api_key_scopes.py -q --no-cov -o addopts=""prints27 passed;python -m pytest tests/integration/test_workspace_isolation.py -q --no-cov -o addopts=""on a fresh test database prints5 skipped(the measured state, not a pass);python -m pytest "tests/integration/test_full_user_journey.py::TestTeamCollaborationJourney::test_permission_management" -q --no-cov -o addopts=""prints1 passed.- With the harness up (AUTHORING.md "Renderings", API on
127.0.0.1:8020) and the seeded owner's token:curl -s -X POST -H "Authorization: Bearer $T" http://127.0.0.1:8020/api/workspaces/bootstraptwice prints the sameidboth times;curl -s -H "Authorization: Bearer $T" http://127.0.0.1:8020/api/auth/workspacesprints one entry with"role":"owner"and"is_owner":trueand exactly the keysid, name, slug, role, is_owner. - Same session:
curl -s -X POST -H "Authorization: Bearer $T" -H 'content-type: application/json' -d '{"name":"Second"}' http://127.0.0.1:8020/api/workspaces/returns201with aslugbeginningsecond-and four trailing characters;GET /api/workspaces/now lists two, the older first;GET /api/forms/templateswithoutX-Workspace-IDnow returns400withWorkspace context required(S6: the caller is multi-workspace). - Same session,
$WSthe second workspace:POST /api/workspaces/$WS/memberswith{"email": "<an address with no account>", "role": "member"}returns404whoseerrorstring names the address (the router's detail string,workspace_router.py424, lands in the Labs envelope'serror, not in adetailkey: S7,app_fastapi.py773 to 788); with{"email": "<the owner's own address>", "role": "owner"}returns422(the regex);DELETE /api/workspaces/$WS/members/<the owner's user id>returns400whoseerrorisCannot remove workspace owner;DELETE /api/workspaces/$WSreturns204andGET /api/workspaces/lists one again whileGET /api/workspaces/?include_inactive=truelists two. - Same session:
PATCH /api/workspaces/$WS/settingswith{"settings": {"only": 1}}thenGET /api/workspaces/$WS/settingsprints{"only": 1}as the wholesettingsvalue (replacement, not merge: the gotcha is reproducible by reading the response). - In the
w1-auto-topupcheckout at 8b5361c:grep -n "BILLING_ROLES = " backend/services/primaapi/identity.pyprints line 107 with("owner", "admin");grep -c "_require_billing_role(db, ctx)" backend/api/routers/primaapi_router.pyprints6andgrep -c "_require_labs_session(ctx)" backend/api/routers/primaapi_router.pyprints6as well, of which one is the conditional site:grep -n "if body.save_card:" backend/api/routers/primaapi_router.pyprints line1005, immediately above the only indented_require_labs_session(ctx)(1006). Ond1a-token-sourcethe first grep prints nothing. npm run checkin this repo prints no line for this recipe.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.