Tenancy
Status: Accepted (2026-09-14) · Supersedes: ADR-0013 · Amended by: ADR-0033, ADR-0048 (org roles in org_members, 404 for non-members, invitation and personal workspace rules, aps add orgs moved to v0.5)
Context#
Some apps serve one user base; B2B SaaS apps serve many organisations whose data must be isolated. Tenancy affects every table, query, permission and audit event. Retrofitting it into a live app is expensive, and a single missed tenant filter leaks customer data.
Options#
- Single-tenant only; tenancy left to developers.
- Always multi-tenant.
- A creation-time choice, with both modes generated and tested, and a guided path from single to multi.
Decision#
Option 3.
The choice#
? Will different companies or teams use your app, each with their own separate data?
● No — one user base (single-tenant)
○ Yes — organisations with members (multi-tenant)- Default (
--yes): single-tenant. Flag:--tenancy=single|multi. - Stored in
apistock.yaml(tenancy,personal_workspace); every later command reads it.
Single-tenant#
| Topic | Decision |
|---|---|
| Ownership | Resources belong to a user (owner_id) or are global |
| Roles | Platform roles only |
| Routes | /v1/<resources> |
Multi-tenant#
| Topic | Decision |
|---|---|
| Model | Shared database and schema; tenant-owned rows have org_id NOT NULL |
| Personal workspace | Each user gets a personal organisation at signup (default on) |
| Membership | Users may belong to many organisations |
| Routes | Explicit: /v1/orgs/{orgId}/...; no implicit current-org header |
| Roles | Platform roles (staff, /ops/*) separate from org roles (owner, admin, member, custom from the permission catalog) |
| Owners | At least one owner; last owner can't leave; ownership transferable |
| Invitations | Email; hashed single-use tokens; 7-day expiry; revocable; existing or new users |
| Lifecycle | Create, rename, transfer, soft delete → purge job; all audited |
Isolation (all four layers required)#
- HTTP:
orgs.RequireMember(permission)runs before handlers. - Code: generated org-scoped repositories require an
OrgIDparameter. - Database:
UNIQUE (org_id, …)constraints and composite foreign keys includingorg_id. - Tests: generated cross-org denial tests for every org-scoped resource.
Row-level security is an optional additional layer in v1.1. org_id is carried in audit events, job metadata and log attributes.
Generator#
- Multi-tenant = base recipes + the
orgsrecipe; resource templatesresource/singleandresource/org. aps gen resource <Name> --scope=org|user|global.- apistock's own library tables (audit, sessions, settings) always include a nullable
org_id. River's job tables don't; a job's org ID is in its metadata (ADR-0033). - Golden apps
examples/full-singleandexamples/full-multi, both tested.
Changing modes#
| Change | Support |
|---|---|
| Single → multi | aps add orgs: adds the module, switches mode, new resources org-scoped; generates a data-migration skeleton and checklist for existing resources |
| Multi → single | Not supported |
Not supported#
Schema-per-tenant, database-per-tenant. Later: subdomain tenant resolution, per-org quotas and billing, per-org enterprise SSO.
Why#
The shared-schema model is the standard for SaaS; personal workspaces avoid painful later migrations; layered isolation means one mistake doesn't leak data.
Trade-offs#
- Two generation variants and golden apps to maintain.
- Multi-tenant apps carry org IDs in every route.
Consequences#
- v0.2 creates library tables with nullable
org_idand role-scope columns so v0.4 needs no rewrite. - Every org-scoped generated resource ships with isolation tests.