Multi-tenancy from the first model
How organisation isolation went into the platform's base model in its first week, where the automatic filter stops, and what I still owe it.
In the platform, no endpoint has to filter by organisation. The database session does it: every read, update and delete on a table an organisation owns gets the organisation filter added on its way to PostgreSQL, whatever code wrote the query. That rule went into the base model on 4 February 2026, three days after the first commit, before there was a single table to migrate.
One database, one filter#
The plan written on the first evening chose logical separation in one database. Every tenant's rows sit in the same tables, told apart by an organisation column, rather than in a database or a schema per tenant. What that buys is one schema, one migration chain and one connection pool, however many organisations sign up. What it costs is that isolation becomes the application's job on every single query, and a query that forgets is a leak.
So the decision that mattered was where the filter lives. Leaving it to each endpoint means trusting every future query to remember. Putting it under the ORM means a query has to go out of its way to escape it.
Splitting the base model#
The first base model added the organisation column to everything. On the fourth day I split it in
two: a base Model with an identifier, timestamps and authorship, and a tenant model on top of it
adding the organisation column. A user is not owned by one organisation, so a user record has no
business carrying one, and the split made that difference something the type says rather than
something each model remembers.
The subclass was called TenantModel then. In April, when tenancy was rebuilt around
organisations and many-to-many memberships, it went back to the plan's original word and became
OrganizationModel. The mechanism did not change with the name.
The filter itself#
Three days after the split, the async database layer landed with two SQLAlchemy hooks. A listener
on every ORM execution adds a loader criterion to every SELECT, UPDATE and DELETE that
touches a tenant model, organization_id equal to the organisation in the request context, and
it applies to aliases too, so a join against the same table is filtered on both sides. A second
listener stamps the context's organisation on any new tenant row that does not name one.
The first isolation test wrote a row for each of two organisations, then read the table as each of them and checked that each saw only its own. Then it fetched the other organisation's row by its identifier, which is what an attacker guessing identifiers would do, and checked that the answer was nothing. The escape hatch was tested too: a statement can opt out of the filter explicitly, and the test checked that one which did saw both rows. That opt-out, and the system identity that privileged internal work runs as, are the only ways past it, and both are visible in the code that uses them.
The first migration forgot the tenants#
The same commit set up Alembic for migrations, but the tests built their tables straight from the models, so for a month nothing needed a migration file. Early in March, when the application began seeding data at startup, it crashed writing into a tenants table that did not exist: the migration for it had never been written. The fix was one generated file.
Migrations got a tenancy rule of their own later. In April a hook began refusing any unique constraint or unique index on a tenant table that leaves out the organisation column, so a value can only be unique within one organisation. A constraint across the whole table would let one organisation's data decide what another may save, and an error would tell the second one that the value exists somewhere.
Where the filter does not reach#
The filter covers rows an organisation owns. Everything shared across organisations sits outside it by construction, and users are the main example: a user is one record, a member of any number of organisations. What another member may see of a user is decided by the access rules instead.
That boundary is where a real leak turned up. In April a read permission on users proved to be granted too widely, so any signed-in account could list the users of every organisation. The tenant filter was working exactly as designed; users were never inside it. The fix narrowed the rule and added a test with two users in two organisations, each unable to find the other. It landed months before the application had any environment beyond development and CI.
The second gap was the request context itself. The filter trusts the organisation the context
names, so the context is only as good as the check that sets it. In May, a request naming an
organisation the user does not belong to became a 403, where it had fallen back to the union of
the user's rights across all of their organisations.
What I still owe it#
The filter lives in the application. It sees what goes through the ORM, and a hand-written SQL statement does not go through the ORM. The layer I want under it is row-level security in PostgreSQL, with a runtime database role that cannot bypass it and a policy that returns no rows when no organisation is set. It is written up and not built, as of September 2026.