ADR-0004: Multi-org isolation by organization_id column, enforced in code
Status: Accepted Date: ~2026-01 (estimate) Context owner: Owner
Context
FitKit is multi-tenant. A studio’s data must never leak to another studio. The realistic options:
- Database-per-tenant. Strongest isolation, operationally crushing for a solo dev.
- Schema-per-tenant in one DB. Better than (1) operationally but adds a layer of indirection.
- Row-level isolation by
organization_idcolumn. Cheapest operationally; requires discipline at the application layer or RLS at the DB layer. - Postgres Row-Level Security (RLS). Server enforces a session variable equal to the current org; policies on each table reject cross-org reads.
Decision
Row-level isolation by organization_id, enforced in code. Every domain table has an organization_id column with an index, and every query (read or write) filters/sets it explicitly.
- The
membershipstable maps(user_id, organization_id) → role. It is the authoritative scoping table. - API services receive the org context from the authenticated user’s active membership (the user can belong to many orgs; the active one is selected via a UX affordance).
- E2E and integration tests include cross-org isolation specs (FIT-124).
RLS is not enabled today. It’s on the table as an additional defense-in-depth layer.
Consequences
Positive
- Operationally simple. One DB, one schema, one connection pool.
- Cheap to spin up new orgs (just insert a row).
- Easy to migrate: schema changes apply to all orgs at once.
- Cross-org reporting (platform admin views, FIT-platform-billing) is a single query.
Negative
- A single forgotten
where organization_id = ?is a data leak. This is the model’s central risk. - Today there is no RLS backstop — only code review and tests catch the mistake.
- Cardinality skew: a noisy org’s data is in the same table as a quiet org’s, complicating per-org query planning.
Mitigations
- Index every
organization_idcolumn. Performance and ergonomics both depend on this. - Service layer scoping helpers. Where possible, services take an
orgIdparameter and the data access code is responsible for applying it. Don’t pass raw user input as org IDs. - Permissions tests. FIT-123 (permissions matrix E2E) and FIT-124 (multi-org isolation E2E) are explicit tickets to harden the test suite.
- Future: consider enabling RLS for the most sensitive tables (
payments,forms,messages) as defense-in-depth. Pgaas providers vary on RLS performance; benchmark first.
What “shipping a cross-org bug” looks like
- A list endpoint returns rows scoped to another org. Customer reports “I see someone else’s members.”
- Sentry won’t catch this — it’s not an exception.
- PostHog might catch it if event volume on the affected org diverges sharply, but only by accident.
- The cheapest detection layer today is the test you wrote when you built the feature. Be paranoid.