Prima Get an API key

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
  • alchemy_labs/backend/api/routers/workspace_router.py
  • alchemy_labs/backend/api/routers/auth_router.py
  • alchemy_labs/backend/services/workspace/tenant_service.py
  • alchemy_labs/backend/services/workspace/models.py
  • alchemy_labs/backend/services/workspace/isolation.py
  • alchemy_labs/backend/services/auth/rbac_service.py
  • alchemy_labs/backend/services/auth/permissions.py
  • alchemy_labs/backend/middleware/workspace_middleware.py
  • alchemy_labs/backend/api/dependencies.py
  • alchemy_labs/backend/migrations/versions/20260504d_workspace_rls_policies.py
  • alchemy_labs/backend/tests/integration/test_workspace_isolation.py
  • alchemy_labs/backend/api/routers/primaapi_router.py
Measured on alchemy_labs d1a-token-source fd8558f 2026-09-12
Also measured on
  • alchemy_labs w1-auto-topup 8b5361c 2026-09-12
  • prima feature/per-model-pricing cd2c248 2026-09-12
  • prima-chat w5-billing-panel 743b840 2026-09-12
  • prima-developer w6-konto c594dfc 2026-09-12
  • philosophers_stone main 261be0e 2026-09-12
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):

RoleWritten byRBACService rung (rbac_service.py 247 to 263)IsolationService rung (isolation.py 146 to 153)Permission set (permissions.py 169 to 265)
ownercreate_workspace, every bootstrap path44all 46
adminPOST /members, PATCH /members/{id} by an owner3344 (all but workspace.delete, billing.manage)
memberthe default; owner or admin may assign2236
viewerowner or admin may assign1114
guesttyped by the SPA and mapped to 6 permissions; no route writes it00 (absent from the dict)6
desk_requesterthe desk sign-in (B20); POST /members and PATCH accept it (_MEMBER_ROLES, workspace_router.py line 73)00none (no registry entry, has_permission is False for everything)
agencyPOST /api/admin/agency-access only (B16); never assignable through this router00none

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:

RouteGateCodesNotes
POST /bootstrap (102 to 136)signed in200 the first workspace the caller owns or belongs to, else the new personal oneidempotent; the only answer to a 409 no_workspace (S6)
POST / (139 to 175)signed in201; 400 on a taken slug or IntegrityError; 500 fixed stringbody {name, slug?, description?}; the caller becomes owner_id and gets the owner membership row
GET /?include_inactive= (177 to 201)signed in200 list, ownership counted, ordered by created_atTenantService.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 passed403 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"; 404body {name?, description?}
DELETE /{workspace_id} (305 to 347)check_permission("workspace.delete") (owner only)204; 403; 404soft: 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}]; 403no 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 owner200; 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; 404the whole document, integration blocks included
PATCH /{workspace_id}/settings (607 to 667)check_permission("workspace.manage_settings") (owner, admin)200; 403body {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 PUTsper S6 API surfaceowned 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:

  1. POST /api/auth/register (auth_router.py 283 to 303): TenantService.create_workspace run 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).
  2. _ensure_workspace(user) (auth_router.py 176 to 232) on every login path (605 after a password login, 1218 after a magic link): asks SELECT 1 FROM workspaces WHERE owner_id = :uid (owns one, not has one), skips a desk-only account (DESK_ONLY_ROLES membership, 196 to 210), then inserts workspace, owner membership and the two-key settings document in its own get_db_context() session; every exception is logged and swallowed.
  3. POST /api/workspaces/bootstrap and POST /api/workspaces/ through TenantService.create_workspace (tenant_service.py 34 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, owner membership, the full default settings document, one commit.
  4. create_api_only_account (services/primaapi/identity.py 401 to 420, B14): the same three rows, inside the signup transaction, no commit of its own.
  5. POST /api/admin/create-user (admin_router.py 1529 to 1552, B16) and Spark provisioning (services/spark/provisioning.py 88 to 101, whose settings document carries the third key source: "spark"): the same three rows again; the admin one inside a try/except that logs and continues.

The membership flow through the router, in commit order:

  1. add_workspace_member: can_assign_role reads the actor's own membership row (get_user_role, rbac_service.py 114 to 139, membership only), then the target user is looked up by email, then add_user_to_workspace (tenant_service.py 187 to 235) refuses a missing workspace or an existing row with ValueError and commits the new row with added_by. The actor's ownership without a membership row is not consulted (Gotchas).
  2. update_member_role: validate_role_change runs can_assign_role, can_manage_user (both actor and target roles read from workspace_users), then refuses a target whose role is owner; update_user_role (295 to 327) refuses the owner again and commits.
  3. remove_workspace_member: can_manage_user, then remove_user_from_workspace (237 to 264) refuses the owner and deletes the row.
  4. delete_workspace: check_permission("workspace.delete") (owner only, RolePermissions.ADMIN excludes it), then is_active = false. Nothing under the workspace is touched; the rows stay and S6's fences keep answering for members, since _user_has_workspace_access does not read workspaces.is_active (Gotchas).
  5. 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.tsx 532 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 in get_user_workspaces (tenant_service.py 146 to 173, the comment on why the inner join minted duplicates), in GET /api/auth/workspaces (the WHERE at auth_router.py 798) and in user_may_change_billing. The two exceptions, IsolationService.verify_workspace_access and RBACService.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_workspace on both login paths, create_workspace at registration, _create_personal_workspace at API-only signup, the admin create path. A desk-only account never owns one (_ensure_workspace 196 to 210; test_owning_a_workspace_disqualifies_a_desk_only_account, tests/test_desk_acting_email.py line 411).
  • An owner cannot be removed or demoted through the router: remove_user_from_workspace and update_user_role refuse the owner row, can_assign_role never grants owner, validate_role_change refuses an owner target; the only way to change ownership is workspaces.owner_id by hand.
  • An admin manages member, viewer and guest and assigns member and viewer; an owner manages everyone and assigns anything but owner (can_manage_user 170 to 207, can_assign_role 209 to 245). desk_requester is therefore assignable by an owner only.
  • A workspace list is deterministic: ORDER BY created_at ASC in get_user_workspaces (177 to 181, the comment on the SPA landing in a different workspace between page loads); owner-first then name in GET /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 admin member'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 plain POST /credits/topup with no save_card, which an ai key may still make and which is role-checked on the key's own user (A-1; tests/prima_credits/test_auto_topup_endpoints.py pins 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 skipped on 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/workspaces then GET /{id}/members) passes; test_invite_collaborate_share (714) creates the workspace with 200 or 201 and then fails at a GET /api/dashboards/{id} with 400, a step outside this recipe. Measured 1 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 and GET /api/workspaces/{A}/config; S6's acceptance check 5 runs it with the four containment tests from tests/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); with tests/test_admin_api_key_scopes.py the pair prints 27 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) and tests/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.py on w1-auto-topup at 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, asserting detail.error == "owner_required" at 684), test_the_credential_is_judged_before_the_person (727, the plain top-up an ai key may still make) and test_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 4 in both services and guest 0 in 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))" prints 46 46 44 36 14 6); PermissionRegistry().get_role_permissions("desk_requester") is set(); _MEMBER_ROLES is ^(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.py 109 to 163) and RBACService.get_user_role (rbac_service.py 114 to 139) both read workspace_users only, while ownership lives on workspaces.owner_id and owners without a membership row exist on production (tenant_service.py 146 to 155, auth_router.py 779 to 782). Such an owner is refused by GET /{id}, PATCH /{id}, DELETE /{id}, every members route and both settings routes of their own workspace, while GET /api/workspaces/ lists it and every S6 fence admits them. D8, the IsolationService row: both readers derive the rung from the one owner-or-member predicate (owner when owner_id matches, else the row's role), the shape user_may_change_billing already has; no code changed in this wave.
  • 2026-09-12 (measured at fd8558f): the two ladders disagree on guest and on unknown strings. RBACService.get_role_hierarchy_level maps guest to 0 explicitly and IsolationService omits it, and both score desk_requester and agency at 0 through the dict default (isolation.py 153, rbac_service.py 263), below viewer; the permission registry has no entry for desk_requester or agency at all (permissions.py 273 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 admin create-user path (same shape again) and Spark provisioning (spark- slug and a third settings key, source: "spark", provisioning.py 99). The inventory's count is superseded; a new app has one function.
  • 2026-09-12 (measured at fd8558f): POST /api/auth/register runs the async create_workspace on a fresh event loop inside a synchronous handler (auth_router.py 286 to 298). The service is async def over synchronous queries, so the loop buys nothing and costs a loop per registration; a new app makes the service synchronous and the router def.
  • 2026-09-12 (measured at fd8558f): _ensure_workspace asks "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}/settings replaces the whole document (tenant_service.py 350) with no ETag or merge, and GET /{id}/settings returns 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.tsx 532 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_invitations is declared (models.py 106 to 139) with a plaintext token column and is read or written by nothing outside the model package; POST /{id}/members adds 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} flips is_active and nothing else; _user_has_workspace_access (api/dependencies.py 621 to 642) does not read workspaces.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_workspaces by default, and GET /api/auth/workspaces through the AND w.is_active = TRUE of its query (auth_router.py 799). There is no reactivation route (activate_workspace exists in the service). A new app checks is_active in the predicate.
  • 2026-09-12 (measured at fd8558f): the POST /{id}/members 404 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-chat 743b840, prima-developer c594dfc): two consumers of GET /api/auth/workspaces are real and unrecorded in the module graph. The chat server calls it (prima-chat 743b840, server/identity.ts 441) and so does the developer portal (prima-developer c594dfc, src/lib/prima-api.ts 626), but this recipe's frontend_modules carries only F2 and F4 (inventory row B15's Frontends column), and identity-auth/ui/chat-signin-and-workspace-picker's backend_modules names 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_rls is used by no router; five routers declare DatabaseSessionWithRLS at 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_id is nullable since 20260903_0002 with no backfill (S6 Gotchas); reads treat NULL as legacy, ownership unverified.
  • 2026-09-12 (measured at 8b5361c): the owner-or-admin billing rule lives on w1-auto-topup only (user_may_change_billing, _require_labs_session, _require_billing_role), forked from feat/service-desk at 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. On d1a-token-source, grep -n "BILLING_ROLES" backend/services/primaapi/identity.py prints nothing.
  • 2026-09-12 (measured at fd8558f): tests/integration/test_workspace_isolation.py borrows db.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-source at fd8558f, backend identical to feat/service-desk b1089b6):
    • backend/api/routers/workspace_router.py: models (32 to 98, _MEMBER_ROLES 73), 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 at app_fastapi.py 823.
    • 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_permission 282 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) and MyWorkspaces (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), the create-user bootstrap (1529 to 1552), grant_agency_access and revoke_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_rls 268 to 336, _user_has_workspace_access 621 to 642, WorkspaceID 779), backend/migrations/versions/20260504d_workspace_rls_policies.py: the S6 anchors, not repeated.
    • Tests as listed under Tests.
  • alchemy_labs (w1-auto-topup at 8b5361c, unmerged), the legacy in-Labs gateway copy per D3, cited because it is the code that answers the Labs-hosted /api/prima/v1 today and because Prima carries no customer-facing credits or sparks write route; a new gateway copies Prima's files: backend/services/primaapi/identity.py BILLING_ROLES (107) and user_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_role call sites (1011, 1345, 1513, 1794, 2028, 2220) and the five unconditional _require_labs_session sites (1344, 1512, 1793, 2027, 2219) plus the conditional one behind if body.save_card: (1005 to 1006).
  • Wiki philosophers_stone (261be0e): platform/architecture.md ("Multi-Tenancy"), platform/workspace-config.md (the B142 document, for contrast with workspace_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.

  1. 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')))" prints 46 46 44 36 14 6 [].
  2. grep -n "_MEMBER_ROLES = " api/routers/workspace_router.py prints line 73 with ^(admin|member|viewer|desk_requester)$; grep -c "verify_workspace_access" api/routers/workspace_router.py prints 15; grep -c "check_permission(" api/routers/workspace_router.py prints 4.
  3. grep -n "Workspace.owner_id == user_id" services/workspace/isolation.py services/auth/rbac_service.py | wc -l prints 0 (the two membership-only readers, Gotchas); grep -n "Workspace.owner_id == user_id" services/workspace/tenant_service.py prints line 168 (ownership counted in the list).
  4. 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 -l prints 3 and grep -n "workspace = Workspace(" services/workspace/tenant_service.py api/routers/admin_router.py | wc -l prints 2: five construction sites across five files (tenant_service.py line 69 and admin_router.py line 1533 assign, the other three add inline).
  5. python -m pytest tests/test_desk_agency.py tests/test_admin_api_key_scopes.py -q --no-cov -o addopts="" prints 27 passed; python -m pytest tests/integration/test_workspace_isolation.py -q --no-cov -o addopts="" on a fresh test database prints 5 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="" prints 1 passed.
  6. 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/bootstrap twice prints the same id both times; curl -s -H "Authorization: Bearer $T" http://127.0.0.1:8020/api/auth/workspaces prints one entry with "role":"owner" and "is_owner":true and exactly the keys id, name, slug, role, is_owner.
  7. 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/ returns 201 with a slug beginning second- and four trailing characters; GET /api/workspaces/ now lists two, the older first; GET /api/forms/templates without X-Workspace-ID now returns 400 with Workspace context required (S6: the caller is multi-workspace).
  8. Same session, $WS the second workspace: POST /api/workspaces/$WS/members with {"email": "<an address with no account>", "role": "member"} returns 404 whose error string names the address (the router's detail string, workspace_router.py 424, lands in the Labs envelope's error, not in a detail key: S7, app_fastapi.py 773 to 788); with {"email": "<the owner's own address>", "role": "owner"} returns 422 (the regex); DELETE /api/workspaces/$WS/members/<the owner's user id> returns 400 whose error is Cannot remove workspace owner; DELETE /api/workspaces/$WS returns 204 and GET /api/workspaces/ lists one again while GET /api/workspaces/?include_inactive=true lists two.
  9. Same session: PATCH /api/workspaces/$WS/settings with {"settings": {"only": 1}} then GET /api/workspaces/$WS/settings prints {"only": 1} as the whole settings value (replacement, not merge: the gotcha is reproducible by reading the response).
  10. In the w1-auto-topup checkout at 8b5361c: grep -n "BILLING_ROLES = " backend/services/primaapi/identity.py prints line 107 with ("owner", "admin"); grep -c "_require_billing_role(db, ctx)" backend/api/routers/primaapi_router.py prints 6 and grep -c "_require_labs_session(ctx)" backend/api/routers/primaapi_router.py prints 6 as well, of which one is the conditional site: grep -n "if body.save_card:" backend/api/routers/primaapi_router.py prints line 1005, immediately above the only indented _require_labs_session(ctx) (1006). On d1a-token-source the first grep prints nothing.
  11. npm run check in 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.