Skip to content

Multi-tenancy

Model

Shared database, shared schema, discriminator column. Every tenant-owned table has a tenant_id. There is no schema-per-tenant and no database-per-tenant.

This was the deliberate choice over the alternatives:

  • Database-per-tenant gives the strongest isolation but means running migrations N times, connection-pooling N databases, and cross-tenant reporting (if ever needed) becomes a fan-out query. Not worth it for the actual tenant count and growth rate here.
  • Schema-per-tenant is a middle ground but still multiplies migration and connection-pool complexity roughly the same way, for isolation guarantees this system doesn't need yet.
  • Shared schema + discriminator is the cheapest to operate and migrate, and it's the standard starting point precisely because the isolation gap it leaves — a missing or wrong tenant_id filter — can be closed with database-enforced row-level security once you need the stronger guarantee. That closing step is planned but not done yet (see below).

How a request gets scoped

  1. Host → tenant. A middleware resolves the request's Host header to a tenant, by subdomain (acme.platform.example → the acme tenant), with a fallback to a single default tenant while real subdomain infrastructure isn't in front of the deployment yet. The resolved tenant is cached briefly (60s) to avoid a lookup on every request.
  2. JWT carries a tenant claim. On login, the issued token embeds the user's tenant_id.
  3. Auth middleware cross-checks the two. If the token names a tenant and it doesn't match the tenant resolved from the host, the request is rejected with 401 — this is what stops a valid token for tenant A being replayed against tenant B's subdomain. Older tokens issued before this claim existed fall back to one indexed lookup on the user row rather than being rejected outright.
  4. The resolved tenant id is carried on the request context for the rest of the request — every handler and service downstream reads it from there rather than re-deriving it.

Writes

A single GORM hook, registered once per database connection, stamps tenant_id onto every row being created — using whatever tenant is in the request context — unless the row already has one set explicitly. If a create happens with no tenant in context and no explicit exemption, it fails loudly rather than silently writing a NULL. Two paths are deliberately exempted: database seeding, and the CLI command that creates the first admin user — both run outside any request, before any tenant necessarily exists.

A companion hook on updates keeps a Save() call from ever nulling out a row's tenant_id when the incoming struct simply didn't populate that field — a common shape when a handler rebuilds a struct from a request body and saves it whole.

Reads

Each service adds an explicit tenant_id = ? to its queries, scoped from the request context the same way writes are. This has been rolled out domain by domain rather than all at once — each domain's queries are converted, tested, and verified against two real tenants (checking that tenant A never sees tenant B's rows, and that a cross-tenant write attempt is rejected) before moving to the next. As of this writing, several domains are converted; a few read paths are still being migrated.

Some data is deliberately never tenant-scoped: request logs and daily analytics rollups are cross-tenant-by-design aggregate/operational data, not tenant-owned business data.

What happens today when a scope check is missing or fails

There is no database-level backstop yet, so a miss today shows up at the application layer, depending on what's missing:

  • No tenant resolvable from context at all → the write or read fails immediately with an explicit "no tenant in context" error, not a silent NULL.
  • A JWT's tenant claim doesn't match the host-resolved tenant → the request never reaches a handler; the auth middleware returns 401.
  • An origin doesn't resolve to an active tenant subdomain → CORS rejects it outright, rather than falling back to the default tenant the way plain HTTP routing does (a fallback there would let any origin masquerade as the default tenant's frontend).
  • A read path that hasn't been converted to tenant-scoping yet → this is the honest gap: until every read is converted, a bug or omission in a not-yet-scoped query could return rows across tenants. This is why the rollout is being done as a controlled, verified migration rather than treated as already finished.

Planned: PostgreSQL row-level security

The infrastructure for row-level security exists but isn't switched on. The idea: wrap tenant-scoped database work in a transaction that sets a Postgres session variable (SET LOCAL app.current_tenant = '<tenant-id>') scoped to that transaction only — so it can never leak to another request that reuses the same pooled connection — and then attach a CREATE POLICY to each tenant-owned table that filters every row against that variable, at the database level, regardless of what the application's WHERE clause did or didn't say.

Under that design, a miss changes character entirely: instead of an apparently-successful query that happens to return the wrong rows, a query missing tenant context returns zero rows — because the session variable is unset and the policy has nothing to match — and an insert with the wrong tenant filled in is rejected by the constraint layer, not just by application logic. The gap this closes is exactly the one described above: a not-yet-converted or buggy read path stops being a cross-tenant leak and becomes a query that quietly returns nothing.

This is intentionally sequenced after the read-scoping rollout above, not before it — turning on RLS while reads still rely on unscoped queries would break those reads outright (they'd get zero rows) rather than surface the gap in a way that's easy to trace back to a specific query.

Rollout approach

The whole migration — nullable column → backfilled → composite-unique indexes → NOT NULL → scoped reads → (planned) RLS — was done as an expand-and-contract sequence: each step is additive and reversible until the last one, so the system keeps running unmodified between every step, and any step can be rolled back independently if something goes wrong. Reference-number uniqueness moved from a single global column constraint to a composite (tenant_id, reference_number) index as part of this, which is also why two tenants can each have their own INC00001 without a collision.