Multi-Tenant Data Isolation
Multi-Tenant Data Isolation
Every Managing Agent account operates as a completely isolated data environment. No query executed by one agent can read, write, or infer data belonging to another. This isolation is enforced through four independent layers — so even if one layer is bypassed, the others hold.
Architecture Overview
| Layer | Mechanism | Scope |
|---|---|---|
| 1. Auth | orgProcedure verifies org membership before any query executes | tRPC middleware |
| 2. Application | All routers include eq(table.orgId, ctx.orgId) in every query | Query construction |
| 3. Database RLS | Postgres Row-Level Security with FORCE ROW LEVEL SECURITY | Database engine |
| 4. Infrastructure | Dedicated Postgres instance for enterprise-tier orgs | Platform topology |
Layers 1 and 2 prevent accidental cross-tenant queries in application code. Layer 3 acts as a safety net at the database engine level — even a query that omits a WHERE clause cannot return another tenant's rows. Layer 4 provides physical isolation for the highest-sensitivity deployments.
Row-Level Security (RLS)
All tenant-scoped tables have:
- RLS enabled (
ALTER TABLE … ENABLE ROW LEVEL SECURITY) FORCE ROW LEVEL SECURITY— applies the policy even to the table owner; no bypass is possible- A
tenant_isolationpolicy that filters rows by theapp.current_org_idsession variable
The RLS context variable is set at the start of each database transaction via:
SET LOCAL app.current_org_id = '<org_id>';
This is handled automatically by withTenantScope() (see below).
Registered Tables
The following tables are covered by RLS:
org_memberssubscriptionsusage_eventsownersblock_ownership_transfersrole_assignmentsmanaging_agents- (and other tenant-scoped tables)
The canonical list is exported from src/db/rls.ts as RLS_TABLES.
Tenant Context Utilities
The src/lib/tenant-context.ts library provides helpers for writing tenant-safe application code.
assertOrgId(orgId)
Type guard that throws immediately if orgId is missing or empty. Use this at the entry point of any server action that receives an org identifier from external input.
import { assertOrgId } from "@/lib/tenant-context";
assertOrgId(ctx.orgId); // throws if orgId is falsy
withTenantScope(orgId, fn)
Resolves the correct database for the org (dedicated for enterprise tier, shared otherwise), sets the RLS context variable, and executes the callback. This is the primary wrapper for all database operations.
import { withTenantScope } from "@/lib/tenant-context";
const result = await withTenantScope(ctx.orgId, async (db) => {
return db.select().from(blocks).where(eq(blocks.orgId, ctx.orgId));
});
Even if the
WHEREclause is accidentally omitted inside the callback, RLS prevents rows from other tenants being returned.
requireTenantOwnership(table, resourceId, orgId)
Verifies that a specific resource belongs to the authenticated org. Call this before any update or delete that accepts a resource ID from external input.
import { requireTenantOwnership } from "@/lib/tenant-context";
await requireTenantOwnership("blocks", input.blockId, ctx.orgId);
// throws TRPCError NOT_FOUND if the block doesn't belong to this org
tenantFilter(orgIdColumn, orgId)
Convenience helper that returns a Drizzle eq() condition for consistent WHERE org_id = ? clauses.
import { tenantFilter } from "@/lib/tenant-context";
const rows = await db
.select()
.from(blocks)
.where(tenantFilter(blocks.orgId, ctx.orgId));
validateInsertOrgId(payload, orgId)
Validates that an INSERT payload carries the correct org_id before executing. Prevents accidental inserts into the wrong tenant's data space.
import { validateInsertOrgId } from "@/lib/tenant-context";
validateInsertOrgId(input, ctx.orgId); // throws if input.orgId !== ctx.orgId
await db.insert(blocks).values(input);
logTenantViolation(...)
Logs a cross-tenant access attempt as a security.cross_tenant_access_denied audit event and reports to error monitoring. Any occurrence of this event in the audit log should be investigated immediately.
Diagnostic Router (Principal Only)
The tenantIsolation tRPC router provides live visibility into the isolation stack. All endpoints are restricted to Principal-level users — they expose infrastructure and security topology details.
tenantIsolation.status
Returns the current isolation posture for the organisation.
Response shape:
{
orgId: string;
isDedicated: boolean; // true for enterprise-tier orgs
grade: "A" | "B" | "C" | "D"; // overall isolation grade
coverage: number; // percentage of tables fully protected (0–100)
totalRegisteredTables: number;
fullyProtectedTables: number;
tables: Array<{
table: string;
rlsEnabled: boolean;
forceRls: boolean;
policyExists: boolean;
hasOrgIdColumn: boolean;
}>;
layers: {
auth: boolean;
applicationFilter: boolean;
databaseRls: boolean;
dedicatedDatabase: boolean;
};
}
Grade thresholds:
| Coverage | Grade | Meaning |
|---|---|---|
| 100% | A | All tables fully protected |
| ≥ 80% | B | Most tables protected |
| ≥ 50% | C | Partial coverage — remediation required |
| < 50% | D | Critical gap — immediate action required |
tenantIsolation.verify
Runs a live, read-only verification across all four isolation layers and returns pass/fail results per test. Does not modify any data.
Tests performed:
- Auth layer — org membership verified via
orgProcedure - Application layer — all routers confirmed to use
org_idWHERE clause - Database layer —
tenant_isolationRLS policy exists on all registered tables - Infrastructure layer — dedicated vs. shared database reported
- Database layer —
FORCE ROW LEVEL SECURITYconfirmed on all tables
Response shape:
{
allPassed: boolean;
totalTests: number;
passedTests: number;
results: Array<{
test: string;
passed: boolean;
details: string;
}>;
}
tenantIsolation.securityEvents
Returns a paginated list of security.cross_tenant_access_denied audit events for the current org. Supports cursor-based pagination via the standard paginationInput shape.
Any entry in this list indicates an attempted cross-tenant access. Each event should be treated as a critical security incident and investigated.
Enterprise Tier: Dedicated Databases
Enterprise-tier organisations are provisioned with a dedicated Postgres instance. withTenantScope() and getDbForOrg() automatically route queries to the correct database. This provides physical-level isolation in addition to RLS.
The isDedicated flag is returned by both tenantIsolation.status and tenantIsolation.verify.
Compliance
This architecture satisfies the following compliance requirements:
- GDPR & UK Data Residency — all PII is scoped to a single tenant; no cross-tenant PII leakage is possible
- Financial Data Isolation — financial records (transactions, service charges, budgets) are isolated per tenant at all four layers
- Audit Trail — all diagnostic operations (
status,verify) are written to the audit log; cross-tenant denial events are logged assecurity.cross_tenant_access_denied