← Dev log

security

Row-level multi-tenancy, and the one bug that matters

Row-level multi-tenancy, and the one bug that matters

Instead of a separate database or schema per tenant, every tenant-owned table carries a tenant_id (sometimes a branch_id too), and every query is expected to filter by it. One schema. Isolation enforced entirely in application logic rather than the database structurally keeping tenants apart.

Row-level is dramatically simpler to operate — one set of migrations, one connection pool, trivial cross-tenant admin queries and analytics, scales to thousands of tenants with no per-tenant provisioning. Schema-per-tenant gives you structural isolation, where a bug literally can't leak across tenants because the data lives somewhere the query can't see, at the cost of migration complexity multiplying by tenant count and much harder cross-tenant operations.

For a platform expecting many organisations with one person maintaining it, row-level is the right trade. But the isolation guarantee is only as strong as the discipline of every single query.

The bug class

Because there's no structural barrier, isolation is exactly as strong as "did every query remember to filter by tenant_id." Miss it once — one endpoint, one report, one admin tool — and one tenant's data is readable or writable by another. This is the highest-severity bug class in a row-level system, and it's silent: nothing crashes, it just quietly returns or modifies the wrong tenant's rows.

Guardrails that actually help

  • Scope at the query-builder or repository layer, not per call site. A base method that always injects tenant_id from the authenticated request context is far safer than trusting every controller to remember.
  • A dedicated policy layer that checks tenant ownership before any read or write, not after fetching. In my platform this is core/policy/, so the scoping logic isn't reinvented in every module.
  • Cross-tenant admin and impersonation are a deliberately separate, audited path — not a bypass hiding inside normal query logic. Impersonation is its own table with confirmation tokens and a full audit trail, not an admin flag that skips the filter.
  • Always test with two tenants side by side. A suite seeding only one organisation's data will never catch a missing filter — the query "accidentally" returns correct-looking results with nothing else present to leak.

The same discipline runs vertically too, not just sideways. A branch admin's actions need to be overridable by organisation- or platform-level roles, because a branch-level decision might be a genuine mistake that a higher authority has to be able to correct. That's the reasoning behind the platform-to-organisation-to-branch settings hierarchy: isolation sideways between tenants, and a controlled override path upward in the privilege chain. Both directions need to be designed on purpose, not discovered as gaps later.