Skip to content
Revoech
··/··
Work / 05

Multi-tenant data isolation in a shared PostgreSQL schema

FlowBoard is a project management SaaS where multiple organizations share one database. The hard problem was not the Kanban boards or the real-time updates. It was making sure a query for one organization could never accidentally return data from another.

Date
2024
Role
Solo project
Stack
React · TypeScript · NestJS · PostgreSQL · Redis · Graphile Worker · AWS S3
Scale
Personal SaaS project
Status
Completed 2024
01Problem

FlowBoard is a project management tool built for teams: organizations, projects, tasks, and role-based access. The core requirement is that Organization A and Organization B share the same application and the same database, but their data is completely invisible to each other.

The naive version of this is easy to state: just filter every query by organization. The problem is that a codebase large enough to have multiple developers has no reliable way to enforce that by convention alone. You are one forgotten WHERE clause away from returning another tenant's tasks in a response, and that bug is silent. Nobody panics when data is missing. They do not always notice when extra data appears.

Alongside isolation, the platform needed real-time collaborative updates: when a teammate moves a task, the Kanban board updates without a reload. Those WebSocket broadcasts also needed to be org-scoped. A message about Organization A's tasks cannot go to a connection belonging to Organization B.

02Constraints
  1. One PostgreSQL instance. A separate database per tenant provides the cleanest isolation but the operational overhead is disproportionate for a personal project. Migrations become per-tenant operations. Connection pooling gets complicated.

  2. No separate schema per tenant for the same reason. Schema-per-tenant solves the isolation problem but makes global migrations painful.

  3. Isolation had to be enforced below the route handler. Relying on every route to correctly scope its own queries is the same as relying on convention. It works until it does not.

  4. Role-based access within each organization. Owners, admins, and members have different permissions. The data model had to represent this without making queries unreadable.

  5. Background jobs needed the same isolation guarantee. A Graphile Worker job processing a file upload had to operate in the correct tenant context regardless of when the queue processed it.

03Investigation

Three approaches to multi-tenant isolation come up in every discussion of this problem. Separate database per tenant: the best isolation, the worst operational story. Separate schema per tenant: slightly better operationally, still bad for migrations. Shared schema with a discriminator column: simplest to operate, migrations are global, and isolation is entirely a query discipline problem.

For a personal project on a single database, shared schema was the only realistic option. The question shifted from which approach to how to enforce the discipline reliably.

The failure mode in shared-schema multi-tenancy is a query that runs against the right table but omits the organization scope. The data comes back clean. The response looks correct. The isolation is gone. This is the class of bug that appears in code review as an oversight and in production as a data breach.

You are one forgotten WHERE clause away from returning another tenant's data. The bug is silent. Nobody notices when extra data appears.

04Decision

Every table that holds tenant data has an organization_id foreign key referencing the organizations table. There are no exceptions. The JWT issued at login carries an organization_id claim. A NestJS auth guard extracts that claim and attaches it to the request context. Repository methods receive the org ID from request context and include it in every query. The scope is injected, not looked up.

For WebSocket broadcasts: Redis pub/sub channels are namespaced by organization ID. Task updates publish to org:{id}:updates. WebSocket connections subscribe to their organization's channel on authentication. A message for Organization A never reaches a connection that authenticated as Organization B.

For background jobs: Graphile Worker job payloads always include organization_id. The worker reads it from the payload and scopes all database operations accordingly. The tenant context travels with the job, not inferred at processing time.

Role-based access sits above the isolation layer. A user's role (owner, admin, member) is stored as a membership record joining users to organizations. Permission checks run after the organization scope is established, not instead of it.

05Outcomepersonal project
Cross-tenant data leaks in testing
none
WebSocket broadcast scope
org-scoped via Redis channels
Background job tenant context
carried in job payload
Schema migrations
global, no per-tenant operations
06ReflectionWhat I'd do differently

The application-layer approach works, but it is one missed WHERE clause away from a data leak. PostgreSQL Row-Level Security policies would add a defense-in-depth layer: even if a query forgets to filter by organization_id, the database rejects it. I did not add RLS. That is the first thing I would add on the next multi-tenant project.

The organization_id propagation chain (JWT claim to guard to request context to repository to job payload) is correct but verbose. A well-placed integration test that verifies cross-tenant isolation on every query path would be more reliable than reading every repository method and checking for the WHERE clause.

This project covered enough ground to be confident about what multi-tenancy actually requires: it is not a feature you add to a finished data model. The discriminator column has to be in the schema from the first migration or retrofitting it is expensive.