Multi-Tenant SaaS
What breaks first when a second organisation signs up?
Multi-tenancy is the architectural decision that costs 10x more to retrofit than to build on day one. Every table, every query, every session must answer: whose data is this?
Foundational Model
Who is the tenant? What access model governs them?
- Top-level tenant model exists — named
Organisation(or equivalent) - Access model chosen deliberately — GitHub / Google / Linear model. Documented why
- Users can belong to multiple orgs — composite unique constraint on
(externalAuthId, organisationId). UX supports org switcher and concurrent sessions - Membership model exists — explicit join table linking users to orgs with role. Consider whether a single membership table simplifies the current
org_system_user+agent_profile+governance_user_roleschain - Personal accounts are single-user orgs — no separate "personal vs business" type needed
Data Isolation
The rule that never bends: every row belongs to exactly one org.
-
organisationIdon every domain table — every table exceptuserhasorganisationIdFK. Audit all tables for gaps - All DB reads scoped by
organisationId— every repository query includes the filter. No cross-tenant reads without explicit system-level justification - All DB writes include
organisationId— every INSERT sets tenant ownership - Enforcement approach documented — loose (separate FK + index) vs strict (compound PK). Document the tradeoff and compensating controls
- Access layer enforces scoping — code-level abstraction requires
organisationIdfor every query. Verify no repository method bypassesApplicationContext
User-Org Association
Items belong to memberships, not users. Revoking access can't orphan data.
- Invitation flow uses membership-first pattern — create membership before user accepts. Support pre-acceptance invites where
userIdis null on membership - Items assigned to membership, not user — domain models (deals, tasks) reference
membershipIdoragentProfileId, notuserId. Verify consistency - Revocation doesn't orphan data — revoking org access handles reassignment of items owned by that membership
- Invite-before-signup works — can assign issues/tasks to an invited-but-not-yet-signed-up user
Organisation ID Propagation
How does organisationId flow from session to database?
-
organisationIdin URLs —/org/[orgId]/...pattern, or document tradeoff of resolving from session only (simpler URLs vs extra DB lookup) -
organisationIdrequired in all server action inputs — viaApplicationContext, not client-supplied. No action trusts client-provided org ID -
organisationIdrequired in all repository calls — audit composition root and repository interfaces for any method that doesn't require it -
organisationIdin all mutation inputs — every create/update/delete scopes to the authenticated org
Authentication and Sessions
Can the system handle a user who works across three orgs in one browser?
- Session stores org context — session +
ApplicationContextresolves org. Session contains enough to avoid per-request DB lookups for role - Concurrent org sessions supported — user can access resources in multiple orgs without logging out. If not, documented as known limitation
- Org switcher exists — UI for switching between orgs the user belongs to
- Cross-org URL access works — visiting a URL for org2 while active in org1 resolves the correct context
- Auth provider decoupled —
Principaltype supports multiple providers (Clerk, Privy, Worldcoin). Abstraction holds for multi-org sessions
RBAC
Role lives on the membership, not the user. Deny beats allow.
- Role stored per-org, not per-user — role lives on membership/org relationship, not globally.
governance_user_roleslinksagentProfileId(org-scoped) toorgRoleId - Permissions defined as code-level constants —
governance_permissionstable withcodefield (e.g.,rfp_project:read) - Permission check on every protected endpoint —
withPermission()/assertPermission()wrappers. Audit for any server action that skips this - Deny takes precedence over allow —
governance_role_permissions.effectsupports allow/deny with priority. Verify deny-wins logic inPolicyEngineAdapter - Default-deny policy —
defaultAllow: falseinPolicyEngine - Role visible in session without DB call — UI can render role-dependent elements without extra round-trip
- Temporal role assignments —
effectiveFrom/effectiveUntilongovernance_user_roles. Verify enforcement in policy engine
Billing and Subscriptions
Billing belongs to the org. Seats count memberships.
- Billing belongs to org, not user —
billingEmailandstripeCustomerIdon the org - Per-seat billing counts memberships — when charging per user, count org memberships
- Subscription lifecycle tables exist —
org_subscriptionwithstripeSubscriptionId, status, plan, period - Quota/usage tracking per org —
api_organization_quota+api_usage_recordcover API-level metering
Settings
Three scopes. Never cross-contaminate.
- Org-level settings — stored on
org_organisationor dedicated settings table - User-per-org settings — stored on membership/
agent_profile(notification preferences per org) - User global settings — stored on user record (dark mode, language)
- Settings scoped correctly — changing a setting in org1 doesn't affect org2
Onboarding
First impressions are transactional — all-or-nothing.
- New signup creates org + membership atomically — verify it's transactional (rollback on any failure)
- Domain-based auto-join —
findOrgByEmailDomain()exists for automatic org discovery. Verify it's opt-in per org - Onboarding state tracked — prevents half-onboarded users from accessing the app
- Invitation acceptance links user to existing membership — existing membership row gets
userIdset, not a new membership created
Analytics
Every event answers: which org did this happen in?
- Analytics events include org context — every tracked event sends
organisationIdas group/context - User linked to org in analytics provider —
analytics.group()call links user to org with traits - Org-level analytics dashboards — can view metrics per-org, not just per-user
Security and Isolation
The paranoid checklist. Test the negative cases.
- No cross-tenant data leakage — negative security tests: user in org1 cannot access org2 data. Tests exist per critical endpoint
- Fail-closed on missing org context —
requireOrgContext()returns failure if no org resolved - Client-supplied IDs never trusted for ownership — always re-verify via
organisationIdfrom session - Audit trail for access decisions —
governance_access_audittable written on everyauthorize()call - Soft-delete with tenant scoping — deleted records retain
organisationIdfor audit/recovery
Future-Proofing
Build for one org. Design for the relationships between orgs.
- Parent/child orgs supported — for enterprise accounts managing sub-orgs. Schema supports
org_typebut no parent FK yet - Org-to-org collaboration — one org can invite another org's members (agency/consultant access)
- Sandbox/clone capability — can an org be cloned for testing?
- SSO/SAML per org — enterprise orgs enforce their own identity provider
Context
- Data Engineering — the capability this protocol serves
- Hexagonal Architecture — the pattern that makes isolation enforceable
- Database ORMs — where multi-tenant schema meets implementation
- Identity and Security — auth provider decisions
- SaaS Products — the jobs multi-tenancy enables