Tenant Isolation
Row-Level Security, AsyncLocalStorage, and ltree hierarchy — how BrainstormRouter isolates tenant data.
Three layers of isolation
BrainstormRouter uses three complementary mechanisms for tenant isolation. None of them alone is sufficient — the combination provides defense in depth.
Layer 1: Row-Level Security (Postgres)
Tenant-scoped tables enforce RLS at the database level. From src/db/schema/tenants.ts:76-92:
const tenantRlsUsing = sql`tenant_id = current_setting('app.current_tenant')::uuid`;
export const tenantProviderKeys = pgTable(
"tenant_provider_keys",
{
/* columns */
},
(table) => [
pgPolicy("tpk_select", {
for: "select",
using: tenantRlsUsing,
}),
pgPolicy("tpk_insert", {
for: "insert",
withCheck: tenantRlsUsing,
}),
pgPolicy("tpk_update", {
for: "update",
using: tenantRlsUsing,
withCheck: tenantRlsUsing,
}),
pgPolicy("tpk_delete", {
for: "delete",
using: tenantRlsUsing,
}),
],
);
Every SELECT, INSERT, UPDATE, and DELETE on a tenant-scoped table is filtered by current_setting('app.current_tenant'). This is a Postgres session variable set by the application before each query. Even if application code has a bug that omits a WHERE tenant_id = ? clause, the database enforces isolation.
The same RLS pattern applies to:
tenant_provider_keys— encrypted BYOK API keysorganizations— org structure within a tenantusers— user accounts within a tenant
Layer 2: AsyncLocalStorage (Node.js)
From src/db/tenant-als.ts:
import { AsyncLocalStorage } from "node:async_hooks";
export type TenantContext = {
tenantId: string;
roles?: RbacRole[];
actorId?: string;
};
export const tenantAls = new AsyncLocalStorage<TenantContext>();
export function getCurrentTenantId(): string | undefined {
return tenantAls.getStore()?.tenantId;
}
Auth middleware sets the tenant context at request ingress. Every downstream function — database queries, memory operations, billing — reads from tenantAls without explicit parameter threading. This prevents the most common multi-tenancy bug: forgetting to pass tenantId through a deep call chain.
Layer 3: ltree hierarchy (MSP sub-tenants)
For managed service providers, tenants can have sub-tenants in a tree structure:
// From tenants table schema
parentTenantId: uuid("parent_tenant_id"),
tenantPath: ltree("tenant_path"),
The tenant_path column uses Postgres's ltree extension for O(1) hierarchical queries. A path like root.parent_uuid.child_uuid (hyphens converted to underscores) enables:
- Subtree queries:
WHERE tenant_path <@ 'root.parent_uuid'— find all sub-tenants - Ancestor queries:
WHERE 'root.parent_uuid.child_uuid' <@ tenant_path— find all parents - Depth queries:
WHERE nlevel(tenant_path) = 2— find direct children
System tables: intentional RLS exceptions
Two tables skip RLS by design:
API Keys (api_keys)
API keys are a system authentication table. The auth middleware needs to look up any API key to determine which tenant it belongs to. RLS would create a chicken-and-egg problem: you need the tenant ID to query with RLS, but you need to query to find the tenant ID.
Memory Extraction Queue (memory_extraction_queue)
From the schema comment in src/db/schema/memory-extraction.ts:4-10:
> No RLS — this is a system/infrastructure queue table (like api_keys). > The worker must claim jobs cross-tenant; RLS would block withSystemContext > since it clears app.current_tenant to ''. > > Tenant isolation is enforced at the application layer: enqueue is scoped by > the authenticated tenantId, and the worker processes each job in its own > tenant context for RMM writes.
The extraction worker processes jobs for all tenants. It claims a batch of pending jobs (any tenant), then runs each job within that job's tenant context. Application-layer isolation for the queue; database-layer isolation for the memory writes.
Encrypted provider keys
Tenant provider keys (BYOK) use a KEK/DEK encryption model:
// From tenantProviderKeys schema
apiKeyEncrypted: customType<{ data: Buffer }>({
dataType() { return "bytea"; },
})("api_key_encrypted").notNull(),
keyEncryptionKeyId: text("key_encryption_key_id"),
Keys are encrypted at rest with a data encryption key (DEK), which is itself encrypted by a key encryption key (KEK) stored in AWS Secrets Manager. Even if the database is compromised, API keys are unreadable without the KEK.