Skip to main content

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_roles chain
  • 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.

  • organisationId on every domain table — every table except user has organisationId FK. 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 organisationId for every query. Verify no repository method bypasses ApplicationContext

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 userId is null on membership
  • Items assigned to membership, not user — domain models (deals, tasks) reference membershipId or agentProfileId, not userId. 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?

  • organisationId in URLs — /org/[orgId]/... pattern, or document tradeoff of resolving from session only (simpler URLs vs extra DB lookup)
  • organisationId required in all server action inputs — via ApplicationContext, not client-supplied. No action trusts client-provided org ID
  • organisationId required in all repository calls — audit composition root and repository interfaces for any method that doesn't require it
  • organisationId in 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 + ApplicationContext resolves 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 — Principal type 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_roles links agentProfileId (org-scoped) to orgRoleId
  • Permissions defined as code-level constants — governance_permissions table with code field (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.effect supports allow/deny with priority. Verify deny-wins logic in PolicyEngineAdapter
  • Default-deny policy — defaultAllow: false in PolicyEngine
  • Role visible in session without DB call — UI can render role-dependent elements without extra round-trip
  • Temporal role assignments — effectiveFrom / effectiveUntil on governance_user_roles. Verify enforcement in policy engine

Billing and Subscriptions

Billing belongs to the org. Seats count memberships.

  • Billing belongs to org, not user — billingEmail and stripeCustomerId on the org
  • Per-seat billing counts memberships — when charging per user, count org memberships
  • Subscription lifecycle tables exist — org_subscription with stripeSubscriptionId, status, plan, period
  • Quota/usage tracking per org — api_organization_quota + api_usage_record cover API-level metering

Settings

Three scopes. Never cross-contaminate.

  • Org-level settings — stored on org_organisation or 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 userId set, not a new membership created

Analytics

Every event answers: which org did this happen in?

  • Analytics events include org context — every tracked event sends organisationId as 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 organisationId from session
  • Audit trail for access decisions — governance_access_audit table written on every authorize() call
  • Soft-delete with tenant scoping — deleted records retain organisationId for 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_type but 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