apistockdocs
v0.4 GitHub apistock.dev
Decisions/Records

Tenancy

ADR-0023Accepted (2026-09-14)

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#

  1. Single-tenant only; tenancy left to developers.
  2. Always multi-tenant.
  3. A creation-time choice, with both modes generated and tested, and a guided path from single to multi.

Decision#

Option 3.

The choice#

output
? 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)#

  1. HTTP: orgs.RequireMember(permission) runs before handlers.
  2. Code: generated org-scoped repositories require an OrgID parameter.
  3. Database: UNIQUE (org_id, …) constraints and composite foreign keys including org_id.
  4. 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 orgs recipe; resource templates resource/single and resource/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-single and examples/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_id and role-scope columns so v0.4 needs no rewrite.
  • Every org-scoped generated resource ships with isolation tests.
esc
↑↓ move↵ openesc close