Skip to main content
All Docs
FeaturesagentOS Block ManagerUpdated April 11, 2026

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

LayerMechanismScope
1. AuthorgProcedure verifies org membership before any query executestRPC middleware
2. ApplicationAll routers include eq(table.orgId, ctx.orgId) in every queryQuery construction
3. Database RLSPostgres Row-Level Security with FORCE ROW LEVEL SECURITYDatabase engine
4. InfrastructureDedicated Postgres instance for enterprise-tier orgsPlatform 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_isolation policy that filters rows by the app.current_org_id session 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_members
  • subscriptions
  • usage_events
  • owners
  • block_ownership_transfers
  • role_assignments
  • managing_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 WHERE clause 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:

CoverageGradeMeaning
100%AAll tables fully protected
≥ 80%BMost tables protected
≥ 50%CPartial coverage — remediation required
< 50%DCritical 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:

  1. Auth layer — org membership verified via orgProcedure
  2. Application layer — all routers confirmed to use org_id WHERE clause
  3. Database layer — tenant_isolation RLS policy exists on all registered tables
  4. Infrastructure layer — dedicated vs. shared database reported
  5. Database layer — FORCE ROW LEVEL SECURITY confirmed 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 as security.cross_tenant_access_denied