oxagenDocs
Security

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.

Loading diagram

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:

sql
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:

ts
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 classPolicy predicateExamples
Standard ownedorg_id = GUC.org AND workspace_id = GUC.wsagent.agents, chat.conversations, mcp.mcp_servers
Workspace-nullableorg_id = GUC.org AND (workspace_id IS NULL OR workspace_id = GUC.ws)iam.principals, security.security_events
Org-onlyorg_id = GUC.orgbilling.*, org.org_users, workspace.workspaces
Workspace-onlyworkspace_id = GUC.ws (no org_id column)workspace.workspace_users
Global identityNo 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:

sql
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:

  1. It injects $orgId and $workspaceId parameters into every Cypher query.
  2. It rejects any query run over the seam that does not reference $orgId. The rejection is a TenantScopeError at 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_id and workspace_id from 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.

On this page