Skip to Content
Living documentation — last reviewed 2026-05-28
DecisionsADR-0004: Multi-org isolation by organization_id column, enforced in code

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:

  1. Database-per-tenant. Strongest isolation, operationally crushing for a solo dev.
  2. Schema-per-tenant in one DB. Better than (1) operationally but adds a layer of indirection.
  3. Row-level isolation by organization_id column. Cheapest operationally; requires discipline at the application layer or RLS at the DB layer.
  4. 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 memberships table 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_id column. Performance and ergonomics both depend on this.
  • Service layer scoping helpers. Where possible, services take an orgId parameter 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.