Skip to content

Research notes

2026-09-02

The tenant boundary in two layers: the application alone wasn't enough

Multi-tenant isolation in Ovodock started out living in a single layer: Eloquent's global scope added a tenant filter to every query automatically. That was a single-layer defense — one line writing `withoutGlobalScope('tenant')`, of the kind that jobs which legitimately need to run across tenants (a sync worker, a health check) write, could read another tenant's rows straight out of the database.

The fix was PostgreSQL's row-level security (RLS), but a plain policy wasn't enough: RLS exempts a table's owner by default, and the application connects as that owner — so writing a policy alone would have been protection in name only. `FORCE ROW LEVEL SECURITY` subjects the owner to the policy too; reads and writes are tied to the same test (`USING` plus `WITH CHECK`), and when the tenant setting is unset the comparison returns NULL and no row matches — deny by default.

The first rollout covered eleven tables; four (the tenant itself, memberships, invitations, the event outbox) were left out on purpose because each has to be readable before a tenant is even known. One table carrying the same kind of data — which role a membership holds — wasn't on either list: not exempted, just missed. We measured that a write running under a restricted role could assign a role to another tenant's member while the gap stood open; that table went under policy right after.

A hand-maintained list of tables carries the same kind of forgetting it was built to prevent. So it was replaced with a test that counts: it reads the database's own catalog, finds every table carrying a tenant column, and checks that each one is either under policy or declared exempt with a reason. The first run of that gate caught three more tables that had simply been forgotten.

The policy alone isn't sufficient either: a connection using a superuser or bypass-privileged role skips every policy, `FORCE` included. Rather than leave that silent, the health check now queries the connection's role from the database catalog and reports it as a failure if it can bypass. All of this is Postgres-specific behavior, which is why the test suite moved from SQLite to PostgreSQL: SQLite runs neither the migration nor a meaningful test for RLS, so the policy could never be verified anywhere.

There is one note here today. This page becomes a list once a second one is published.