Tenant isolation
Every organization on Flagon shares one Postgres database. Isolation between them
is enforced by the database itself, not by remembering to add a WHERE clause
in application code. This page is the whole boundary in one place, so it can be
reviewed on its own.
Row-level security, forced on
Every tenant table carries a row-level security policy that exposes only rows
whose organization_id matches the organization set for the current transaction:
-- On each tenant table (illustrative)
ALTER TABLE flags ENABLE ROW LEVEL SECURITY;
ALTER TABLE flags FORCE ROW LEVEL SECURITY;
CREATE POLICY flags_org_isolation ON flags
USING (organization_id = current_setting('app.current_org_id')::uuid);The FORCE is the important part: without it, the table's owner role would be
exempt from the policy. With it, no connection is exempt, including the one the
application uses. There is no privileged read path that quietly sees everything.
The API cannot bypass RLS
The API connects to Postgres as a restricted role that cannot bypass row-level
security (NOBYPASSRLS). Even if a query forgot its organization filter, the
database returns only the current organization's rows. The same restricted role is
used in development, in CI, and in production, so the boundary you test is the
boundary you ship.
One organization per transaction
Work for an organization runs inside a helper that opens a transaction and sets the current organization local to that transaction:
await withOrg(organizationId, async (tx) => {
// every tenant query goes through `tx`
});set_config('app.current_org_id', orgId, true) scopes the setting to the
transaction, so it is gone the moment the pooled connection is handed to the next
request. It can't leak across requests that share a connection. And a tenant query
run without an organization set sees nothing under FORCE RLS, so the system
fails closed: a missing scope yields an empty result, never someone else's
data.
Defense in depth, not the lock
RLS is the backstop, not the authorization. A request is authorized first (an
organization token for that org, or a verified
membership); only then does it enter
withOrg. RLS turns a forgotten filter from a cross-tenant leak into an empty
result. Two independent layers have to fail for data to cross.
Which tables are tenant tables
Product data (projects, flags, and the resources each product adds) is organization-owned and carries RLS. A small set of identity tables, the accounts, organizations, and memberships the console owns, sit at the auth layer and are queried to establish which organization a request may act for, before any tenant context exists. They are guarded by application authorization rather than RLS, because they are what authorization itself reads.
Verified in CI
Isolation isn't a claim we make once. A tenancy audit runs in CI, and the integration test suite executes against Postgres as the restricted, RLS-enforcing role, so cross-tenant access is actively attempted and must come back empty. Tenancy is exercised on every change, not assumed.