Security & Multi-tenant Isolation
Race Platform stores every customer's data in a single shared Postgres
database. This page explains how a query for Account A can never
return rows for Account B, even if a route handler forgets its
WHERE account_id = … clause.
What you can rely on today
- Postgres Row Level Security (RLS) policies are enabled on every
account-scoped table. The policy compares the row's
account_idagainst a per-request session GUC namedapp.account_idand returns zero rows when they don't match. - The API auth middleware sets
app.account_idtoauth.accountIdviaSET LOCALinside a per-request transaction. Routes don't have to know this — the globaldbexport is a Proxy that delegates to the request-scoped tx viaAsyncLocalStorage. - Admin / system queries (migrations, seed scripts, the API-key
resolver that runs before auth resolves) connect as a separate
Postgres role
race_adminwith theBYPASSRLSattribute. Routes must never reach forsysDb()— it would silently leak across tenants. - The
webhook_deliveriestable has noaccount_idcolumn of its own; its policy joins throughwebhooks.account_idso a tenant only sees the delivery rows for their own webhooks.
The list of protected tables (one ALL-policy each) is in
apps/api/src/db/migrations/0007_add_row_level_security.sql. As of
v0.4.0 this covers 38 tables — every account-scoped table in the
schema plus accounts itself (matched on id) plus
webhook_deliveries (matched via parent).
How the GUC gets there
// apps/api/src/middleware/auth.ts
async function runRequestInTransaction(accountId, next) {
await db.transaction(async (tx) => {
await tx.execute(
sql`SELECT set_config('app.account_id', ${accountId}, true)`
);
await runWithTransaction(tx, next);
});
}
set_config(name, value, is_local=true) is the parameterised form of
SET LOCAL. The true for is_local resets the value at COMMIT/
ROLLBACK, so the GUC can never leak to another request even when the
underlying connection is recycled by the pool.
runWithTransaction stashes the tx in a Node AsyncLocalStorage
context for the duration of next(). Inside the storage context,
the global db Proxy from apps/api/src/db/index.ts resolves every
property access through the tx instead of the root drizzle instance —
so db.select().from(...) inside a route handler runs on the same
connection, in the same transaction, as the SET LOCAL.
Public routes (/health, /auth/login, /auth/register) skip the
middleware entirely. Their queries either don't touch account-scoped
tables (/health) or run before any account context exists, in
which case they hit sysDb() (which uses race_admin, BYPASSRLS).
Verification
# Direct SQL — proves policy filtering is real, independent of the API.
docker exec race-postgres psql -U race_test -d race \
-c "SELECT count(*) FROM laps;"
# → 0 (no GUC set)
docker exec race-postgres psql -U race_test -d race -c "
BEGIN;
SET LOCAL app.account_id = 'AAAA-...';
SELECT count(*) FROM laps;
COMMIT;
"
# → only account A's count
The integration test at apps/api/src/__tests__/rls.test.ts
codifies this against two real test accounts.
Production hardening (next-step work)
In dev, the application connects as the race Postgres role, which
the docker-compose stack creates as SUPERUSER. Postgres
superusers always bypass RLS regardless of policy state. This means
that the policies are in place but not actually enforcing
isolation for the running API in dev — the safety net activates
once the role is demoted.
To enforce in production:
-- Demote race so RLS actually filters its queries.
ALTER ROLE race NOSUPERUSER NOBYPASSRLS;
…and update both make migrate and any boot-time seed step to use
race_admin instead. The migration runner needs SUPERUSER-level
privileges (CREATE TABLE, CREATE ROLE, ALTER TABLE … FORCE ROW LEVEL SECURITY) which the demoted race role can't perform.
Concretely:
# Set on the migration / seed pod, NOT on the API pod:
DATABASE_URL_ADMIN=postgres://race_admin:${RACE_ADMIN_PASS}@db:5432/race
The API pod keeps its regular DATABASE_URL pointing at race. The
auth middleware's transaction-with-GUC pattern then begins enforcing
isolation for every request.
Test role
Migration 0007 also creates a third role race_test with the
NOSUPERUSER NOBYPASSRLS attributes — RLS-active by construction.
The integration test connects as this role to prove the policies
filter correctly, regardless of whether race has been demoted yet
in the local environment.
What's not covered
usershas noaccount_idcolumn (it's a global identity table). RLS would need a JOIN-through-memberships policy. For now, application-level filtering is the only guard. Plan: add a policy usingEXISTS (SELECT 1 FROM memberships WHERE user_id = users.id AND account_id = current_account_id())once theauth/registerflow has been audited for cross-tenant edge cases.- The application currently still connects as
race(SUPERUSER in dev). See "Production hardening" above. - Plugin runtime data that lives off-table (in object storage or
KV) isn't gated by RLS — the storage adapter checks accountId on
the path prefix. That's a separate review item tracked in
.context/progress.md.
See also
- Migration:
apps/api/src/db/migrations/0007_add_row_level_security.sql - DB Proxy:
apps/api/src/db/index.ts(runWithTransaction,sysDb) - Middleware:
apps/api/src/middleware/auth.ts(runRequestInTransaction) - Test:
apps/api/src/__tests__/rls.test.ts