Tenant isolation and row-level security
How Oxagen enforces tenant isolation at the database layer across Postgres, Neo4j, and ClickHouse.
Oxagen enforces tenant isolation in the database, not by application convention. A developer writing a new handler does not need to remember WHERE org_id = $orgId. The database returns zero rows if the tenant scope is missing or wrong. That is the SOC 2 argument: the infrastructure enforces isolation once, and it does not depend on every future developer doing the right thing.
The design: one seam, three enforcers
All three data stores share one identity seam (@oxagen/tenancy). Oxagen enters the seam once, inside kernel.invoke(), and AsyncLocalStorage carries the scope to every data accessor downstream. No function passes tenant IDs through its parameters by hand.
One seam, entered once inside kernel.invoke(), carries tenant scope to all three stores. No tenant ID is threaded by hand.
runInTenantScope checks that both orgId and workspaceId are valid UUIDs before it enters scope. An empty or missing ID throws a TenantScopeError immediately. The seam fails closed, not open.
Postgres: the hard wall
Every tenant-scoped table uses ENABLE ROW LEVEL SECURITY plus FORCE ROW LEVEL SECURITY. FORCE subjects the table owner to the policy as well, which closes the owner bypass that makes standard RLS insufficient for multi-tenant isolation.
The application's database role must not be a superuser or hold the BYPASSRLS attribute, because Postgres skips every policy for those roles. At startup, each service checks its connection role. It refuses to start if the role is a superuser, holds BYPASSRLS, or inherits oxagen_system (see System access). Whether this startup check runs depends on a deployment setting, TENANT_RLS_ENFORCEMENT_ENABLED.
The policy template for every standard tenant-scoped table:
CREATE POLICY tenant_isolation ON agent.agents
USING (
org_id = nullif(current_setting('app.current_org_id', true), '')::uuid
AND workspace_id = nullif(current_setting('app.current_workspace_id', true), '')::uuid
)
WITH CHECK ( … same … );No setting turns a policy off. The only way past tenant_isolation is the system_access policy described below.
The nullif(current_setting(…, true), '')::uuid expression fails closed on purpose. If the GUC is unset or empty, the expression evaluates to NULL, col = NULL is NULL, and Postgres excludes the row. An unscoped query returns no rows, not all rows.
withTenantDb sets the two GUCs inside a transaction before it runs the caller's queries:
await tx.execute(
sql`select set_config('app.current_org_id', ${orgId}, true),
set_config('app.current_workspace_id', ${workspaceId}, true)`
);SET LOCAL scopes the GUC to the transaction, so it is safe with connection poolers in transaction mode.
Policy classes
| Table class | Policy predicate | Examples |
|---|---|---|
| Standard owned | org_id = GUC.org AND workspace_id = GUC.ws | agent.agents, chat.conversations, mcp.mcp_servers |
| Workspace-nullable | org_id = GUC.org AND (workspace_id IS NULL OR workspace_id = GUC.ws) | iam.principals, security.security_events |
| Org-only | org_id = GUC.org | billing.*, org.org_users, workspace.workspaces |
| Workspace-only | workspace_id = GUC.ws (no org_id column) | workspace.workspace_users |
| Global identity | No policy (Better Auth managed) | auth.users, auth.sessions |
The RLS manifest (packages/database/src/tenant-policy.manifest.ts) is the authoritative table list. A CI test asserts that every orgScopeMixin table has a manifest entry, so a new table without a policy fails CI before it ships.
System access
Some work has to cross tenants. Examples are looking up an organization before a scope exists, inbound webhooks, and scheduled jobs. That work runs through withSystemDb, or withOrgPlaneSystemDb on an organization's own database.
These two seams start their transaction with SET LOCAL ROLE oxagen_system. oxagen_system is a role that cannot log in. Every table with RLS has one extra policy for it:
CREATE POLICY system_access ON agent.agents
FOR ALL TO oxagen_system
USING (true) WITH CHECK (true);The application role may switch to oxagen_system, but it does not inherit the role. So an ordinary tenant query never matches system_access, and only a transaction that switches roles on purpose sees every row. SET LOCAL ends with the transaction, so the role never carries over to the next user of a pooled connection.
oxagen_system holds the same table privileges as the application role. Tables the application may only append to stay append-only for system work too.
Tenant seams (withTenantDb, withOrgDb and withRepeatableReadTenantDb) set only the scope settings the policies read. They never switch roles. The decision is recorded in ADR-5879.
Neo4j: seam wall
Neo4j has no native row-level security. Oxagen enforces isolation at the one query seam. scopedSession() wraps the Neo4j driver and does two things:
- It injects
$orgIdand$workspaceIdparameters into every Cypher query. - It rejects any query run over the seam that does not reference
$orgId. The rejection is aTenantScopeErrorat the call site, before the query reaches Neo4j.
An ESLint no-restricted-imports rule and a CI guard test ban raw session() calls outside the seam package.
ClickHouse: seam wall
ClickHouse holds append-only telemetry and is never a source of truth in the four-store data model. chInsert and chSelect enforce scope at the seam:
- Inserts stamp
org_idandworkspace_idfrom the active scope before writing. - Selects must reference
org_id = {orgId:UUID}. A guard rejects any select that does not bind the org scope parameter.
What this means for a SOC 2 audit
One integration test suite shows the isolation holds. The suite seeds two organizations (A and B) with data. It then runs deliberately unfiltered SELECT * FROM <table> queries under each org's tenant scope and asserts that the other org's rows never come back. The suite covers every table policy class and the fail-closed case (no scope set, zero rows).
This is the artifact you point your auditor to: one integration test suite, one migration file defining all policies, and one enforcement flag. Your auditor can read the migration and run the test.