Multi-Tenant Workspaces
Create and switch between isolated organizations — each with their own data, members, roles, and agents. The URL is the sole source of truth for the active tenant, giving per-tab and per-device independence for free.
Multi-Tenant Workspaces
The platform supports fully isolated multi-tenant workspaces. Each tenant (organization) has its own records, data types, agents, navigation config, members, and API keys. Users can belong to multiple tenants and switch between them at any time.
Overview
Every database table is scoped by tenant_id. Row-level security (RLS) policies enforce this isolation — queries from one tenant cannot read or write another tenant's data, even for system admins.
The URL is the sole source of truth for the active tenant — see ADR-0003. Each request resolves tenant freshly from the URL path (/t/<slug>/…), so two tabs on different tenants cannot drift, and devices do not share tenant state.
The resolution pipeline:
- Client request — page URL is
/t/<slug>/…(or a/api/…route called from a tenant page) - Middleware (
proxy.ts) extracts the slug viaderiveTenantSlugFromRequest()and sets thex-tenant-slugheader on the rewritten request - PostgREST
db-pre-requesthook (private.set_tenant_context) reads the header, verifies membership inuser_tenants, and pins a transaction-localapp.tenant_idGUC - RLS reads the GUC via
get_active_tenant_id()— the JWT and profile are no longer consulted - Application code uses
getTenantContext()which reads the same header
If any step can't resolve a tenant, the GUC stays unset and RLS returns zero rows. Fail-closed by design.
How It Works
Creating a tenant
From Admin > Tenant > Create Tenant, provide a name and slug. The slug must be URL-safe ([a-z0-9][a-z0-9-]*). You become the admin of the new tenant automatically; navigate to /t/<slug>/dashboard to start working in it.
Switching tenants
Tenant switching is pure navigation. The sidebar user menu renders each tenant as a <Link href={tenantUrl(slug, "/dashboard")} />. Clicking it navigates to that tenant's URL; middleware sets the header; RLS reads the new tenant. No API call, no session mutation, no refreshSession().
Because there is no shared "active tenant" state on the server:
- Two tabs can be on different tenants. Tab A on
/t/oci/…and tab B on/t/ims/…each resolve their own context per request. Switching in one does not affect the other. - Two devices can be on different tenants. Device A on laptop stays on its tenant independent of device B on phone.
A last-tenant-slug cookie is written on each tenant navigation and used to redirect bare paths (e.g. /dashboard) to /t/<last-slug>/dashboard. This cookie is a UX hint only — it is never read for authorization.
Tenant-scoped URLs:
Any URL can be prefixed with /t/[tenantSlug]/ to force a specific workspace context. For example, /t/acme-corp/opportunity renders the opportunity list for acme-corp. The middleware intercepts these paths, rewrites them to the underlying route, and sets x-tenant-slug so getTenantContext() and the DB hook both resolve the correct tenant. Use tenantUrl(slug, path) from features/tenant/constants.ts to generate these URLs programmatically.
Membership and roles
Users join tenants via the user_tenants table with a role_id FK to the roles table.
| App role | DB slug | Access level |
|---|---|---|
owner | system_admin | Full access; can manage admins |
admin | tenant_admin | Manage members, agents, data types |
member | editor or member | Create and edit records |
viewer | viewer | Read-only access |
guest | guest | Read-only; default on signup |
New signups receive guest role in the default tenant. Promote users via Admin > Members.
Inviting members
Admins invite users by email from Admin > Members > Add Member. The invited user must already have an account. Select a role at invite time.
The "Allow access to public community" checkbox controls whether the user also receives a guest membership in the default tenant. It is unchecked by default so invited users land only in the org workspace.
Default tenant and community access
The default tenant (slug: "default", id: 00000000-0000-0000-0000-000000000000) is a shared workspace that cannot be deleted or renamed. Access to it is called community access and is explicitly controlled.
| Path | Community access |
|---|---|
| Self-signup | Auto-enrolled in the default tenant as guest |
| Admin adds an existing user via Admin > Members | Default tenant is not added unless "Allow access to public community" is checked |
| Admin invites a new user by email | Default tenant is not added unless "Allow access to public community" is checked |
In Admin > Members, each member row shows a globe icon with a toggle switch. Toggling controls a user_tenants row for the default tenant.
Per-Tenant Branding
Each tenant can customize the sidebar header identity via a BrandingConfig stored in tenant_settings under the key "branding".
// features/branding/types.ts
interface BrandingConfig {
appName?: string;
logoUrl?: string;
logoIcon?: string;
tagline?: string;
}Resolution: getResolvedBranding() (features/branding/server/cached-queries.ts) cascades platform defaults with the tenant-level branding setting. Cached per request via React.cache().
API Reference
Server functions (features/tenant/context.ts)
| Function | Description |
|---|---|
getTenantContext() | Resolves active tenant + user from the x-tenant-slug request header. Throws if no header or the user is not a member. Cached per request via React.cache(). |
getActiveTenantId() | Returns the active tenant ID. Thin wrapper around getTenantContext(). |
getUserTenants() | Returns all tenants the current user belongs to. |
createTenant({ name, slug }) | Creates a new tenant and makes the caller its admin. Does NOT switch active tenant — caller navigates to tenantUrl(slug, ...). |
addTenantMember({ tenantId, email, role }) | Adds an existing user by email. Throws if the user is not found. |
removeTenantMember(tenantId, userId) | Removes a user from a tenant. |
getTenantMembers(tenantId) | Returns all members with their roles. Uses admin client. |
updateTenant({ tenantId, name?, logoUrl? }) | Updates tenant name or logo. Admin only. Cannot edit default tenant. |
updateMemberRole({ tenantId, userId, role }) | Changes a member's role. Admin only; cannot self-demote. |
ensureUserProvisioned() | Creates a profile and, for users with no memberships, adds a default tenant membership. Called on first session. |
getCommunityAccessMap(userIds) | Batch check — returns a Set<string> of user IDs with default-tenant access. |
grantCommunityAccess(userId) | Upserts a guest membership in the default tenant. |
revokeCommunityAccess(userId) | Removes the default-tenant membership. No server-side "active tenant" mutation — the URL drives which tenant a user sees on their next navigation. |
API routes
| Route | Auth | Description |
|---|---|---|
POST /api/tenants | Authenticated | Create a new tenant |
POST /api/tenants/members | Admin | Add a member by email; supports includeCommunityAccess flag |
PATCH /api/tenants/members | Admin | Change a member's role |
DELETE /api/tenants/members | Admin | Remove a member |
GET /api/tenants/members/community-access | Admin | Returns { [userId]: boolean } map for all members |
POST /api/tenants/members/community-access | Admin | Toggle community access: { userId, tenantId, enabled } |
POST /api/auth/invite | Admin | Invite a new user by email; supports includeCommunityAccess flag |
Database
- Hook:
private.set_tenant_context()— runs on every PostgREST Data API request viaALTER ROLE authenticator SET pgrst.db_pre_request = 'private.set_tenant_context'. Readsx-tenant-slug, validates membership, pinsapp.tenant_idGUC. - Function:
public.get_active_tenant_id()returns the GUC first, falls back to JWTapp_metadata.active_tenant_idonly for impersonated clients (createImpersonatedClient— API keys, background jobs). - Table:
user_tenants—(user_id, tenant_id, role_id)composite key. RLS policies on every tenant-scoped table callget_active_tenant_id()implicitly viaauthorize().
Design Decisions
URL is the sole source of truth for authorization. See ADR-0003. Session, cookie, and JWT tenant state all caused cross-tab and cross-device drift. Every major B2B SaaS (GitHub, Linear, Vercel, Slack, Notion, Stripe) uses the URL as the sole source of truth. The PostgREST db-pre-request hook extends that model all the way into RLS.
switchTenant is navigation, not an API. Removing the /api/tenants/switch route eliminates the shared-state mutation that caused cross-tab drift. Switching tenants is now <Link href={tenantUrl(slug, path)}> — zero server round-trip.
last-tenant-slug is a UX hint, never authorization. The cookie remembers the user's last-visited tenant so bare paths (/dashboard) can redirect to /t/<slug>/dashboard. It is never consulted for RLS.
Community access as a row, not a column. Community access is modeled as the presence or absence of a user_tenants row for the default tenant. Same RLS policies, same permission checks.
Workspace-scoped configuration uses the same 4-tier resolver. Settings such as agent_context in tenant_settings resolve via user > workspace > tenant > platform. See Workspaces for the full scope-resolver design and the list of the ten scope-aware tables.
DB trigger does only profile creation. handle_new_user() writes the profiles row and a default-tenant user_tenants row. It does not stamp auth.users.raw_app_meta_data.active_tenant_id — regular user JWTs no longer carry tenant state.
Self-signup still lands in the default tenant. ensureUserProvisioned() checks count on user_tenants for the user. Zero memberships means self-signup; one or more means the user arrived via an invite and already has a home.
Manual Test Script
Prerequisites
- Logged in as
adminorowner - A second user account available for invitation testing
Happy Path
-
Create a new tenant
- Go to Admin > Tenant > Create Tenant, name "Test Org" / slug "test-org"
- Expected: Admin landing — then navigate to
/t/test-org/dashboardvia the sidebar switcher or a<Link>
-
Verify data isolation
- On
/t/test-org/<any-list>— expected: empty - Navigate back to original tenant via the sidebar user menu (clicking a tenant sends you to its
/dashboard) - Expected: Original records reappear
- On
-
Per-tab independence
- Open a second tab at
/t/test-org/dashboard - In the first tab, navigate to
/t/<original-slug>/dashboard - Expected: Each tab stays on its own tenant; refreshing tab 2 does not flip it to tab 1's tenant
- Open a second tab at
-
Per-device independence
- On a second browser/device, sign in and navigate to
/t/test-org/dashboard - Expected: Laptop stays on its tenant regardless of which tenant the phone is showing
- On a second browser/device, sign in and navigate to
-
Invite a member without community access
- Go to Admin > Members > Add Member
- Enter the second user's email; set role to "member"; leave "Allow access to public community" unchecked
- Expected: User appears with correct role; community access toggle off
-
Bare-path redirect
- Navigate to
/dashboard(no tenant prefix) - Expected: 307 redirect to
/t/<your-last-visited-slug>/dashboard— uses thelast-tenant-slugcookie
- Navigate to
Edge Cases
- Duplicate slug: Error on create — slug must be globally unique across all tenants
- Tenant-scoped URL for a tenant you're not a member of:
getTenantContext()throws "Not a member of tenant …"; RLS returns zero rows via the DB hook - Bare path with no cookie (first login): Falls back to first non-default membership (or default tenant if only membership)
Regression Checks
- Data created in Tenant A is not visible in Tenant B
- Two tabs on different tenants stay independent across navigation
- Switching tenants is a
<Link>click — no POST to/api/tenants/switch(endpoint deleted) -
/t/<slug>/…URL correctly scopes data for the duration of the request - Guest users cannot see Admin tab; member/viewer users see "Access denied" if they navigate directly to
/admin - Invited users do not appear in the default tenant unless "Allow access to public community" was checked
- Self-signup users (zero prior memberships) do land in the default tenant
- Revoking community access does not break the user's navigation — URL drives tenant, not server state
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| "Not a member of tenant …" | URL has a slug for a tenant the user doesn't belong to | Navigate to one of the user's membership tenants via the switcher |
| Data from wrong tenant appearing | Extremely unlikely under URL-truth. If seen, the PostgREST db-pre-request hook did not fire — check Supabase logs for hook errors | Regenerate migrations; verify pgrst.db_pre_request is set on authenticator role |
| Tenant-scoped URL not resolving | Slug does not match any tenant | Verify the slug is correct; check the tenants table |
| Cannot edit tenant name | Trying to edit the default tenant | Only non-default tenants can be renamed |
| Community access toggle not visible | Viewing members from the default tenant, or viewer/member role | Toggle only appears for admins on non-default tenants |
Bare path /dashboard shows wrong tenant | last-tenant-slug cookie points at an old tenant | Navigate to the correct /t/<slug>/dashboard — cookie refreshes. Or clear the cookie. |
| Realtime subscription empty for the correct tenant | Realtime doesn't run the db-pre-request hook; uses JWT path | Realtime RLS still relies on JWT app_metadata.active_tenant_id for impersonated flows — not a drift issue for user sessions (no claim written). Use tenant-scoped channel names. Follow-up: migrate realtime policies to membership-based. |
Regression coverage
proxy.ts middleware has integration test coverage in proxy.test.ts protecting every guard that shipped in response to the 2026-04-16 cross-tenant data-leak incident. Each guard has a named test — if a future refactor removes a guard, the test name explains why it existed:
- Prefetch / RSC cookie guard — Next.js prefetches
/t/<other-slug>/*links from the sidebar tenant switcher. The guard skips thelast-tenant-slugcookie write whennext-router-prefetch,rsc, orpurpose: prefetchheaders are present. Without the guard, each prefetch flipped the cookie on the current tab. Under the URL-truth model the cookie is no longer an authorization input, but stale prefetch-flips still misroute bare-path navigations (/dashboard→ wrong tenant). - Cookie attribute preservation — Cookies forwarded through a rewrite response MUST be passed as full cookie objects, not
set(name, value). The attribute-stripping variant caused refreshed auth tokens to land with wrong attributes, leading to intermittent auth failures. - API Referer tenant inference — API routes called from a tenant-scoped page carry
x-tenant-slugheader forward via Referer parsing. The header is the sole input PostgREST'sdb-pre-requesthook reads to pinapp.tenant_idfor that request, so missing it causes the data API to fall back to the impersonated-JWT path or fail closed.
See proxy.test.ts and the inline comments in proxy.ts for the full rationale.
Related
- ADR-0003 — URL as tenant source of truth
- ADR-0012 — URL as workspace source of truth — same URL-truth pattern extended to workspaces (
/t/<t>/w/<w>/...) - ADR-0013 — Workspaces as scoped tenants — workspaces mirror tenants on membership, role, and settings axes; data graph stays tenant-wide (ADR-0008)
- Workspaces feature doc — workspace primitive, accent system, install gallery, scope-aware admin
.claude/rules/auth.md— enforcement rulesdocuments/work/2026-04-17-tenant-url-source-of-truth/— tenant URL-truth design specdocuments/work/2026-04-26-workspaces-as-scoped-tenants/— workspace scope spec + plan