Tenant Isolation
Defense-in-depth multi-tenant isolation through three independent layers -- session context, CASL authorization, and PostgreSQL RLS.
Tenant isolation in Trovella is a three-layer defense-in-depth system. Each layer is independently sufficient to prevent cross-tenant data access. If any single layer fails -- a bug in the session middleware, a misconfigured CASL rule, a missing RLS policy -- the remaining two layers still block the leak. This page is the canonical reference for how the layers work together.
For the ADR behind this design, see ADR-003: Shared Schema + RLS.
The Three Layers
| Layer | Where | What It Does | Enforced By |
|---|---|---|---|
| 1. Session context | protectedProcedure / tenantProcedure | Extracts tenant ID from the server-side session -- clients cannot spoof it | @repo/api middleware |
| 2. Application authorization | authorizedProcedure + CASL | Verifies the user is a member of the org and checks role-based permissions | @repo/api middleware + CASL abilities |
| 3. Database enforcement | PostgreSQL RLS policies | Filters every row by organization_id = current_setting('app.tenant_id') | @repo/db RLS policies via tenantPolicies() |
The layers execute in order on every request. A cross-tenant data leak requires all three to fail simultaneously.
Request Flow Through All Three Layers
Browser request (session cookie)
|
v
Layer 1: Session
protectedProcedure -- validates session exists (UNAUTHORIZED if not)
tenantProcedure -- extracts activeOrganizationId from session (PRECONDITION_FAILED if not set)
opens withTenantContext transaction
|
v
Layer 2: Application
authorizedProcedure -- looks up member record for (userId, orgId)
rejects non-members (FORBIDDEN)
builds CASL ability from role + org type
|
v
Layer 3: Database
ctx.db (= tenant-scoped tx)
Every query filtered by RLS: organization_id = current_setting('app.tenant_id')
INSERT/UPDATE WITH CHECK prevents writing rows for other tenants
|
v
Router handler -- uses ctx.db, ctx.ability, ctx.member
Layer 1: Session Context
The tenant ID is extracted from session.activeOrganizationId, which is set server-side by Better Auth when the user switches organizations. Clients send only their session cookie -- they cannot inject or override the organization ID.
tenantProcedure in packages/api/src/trpc.ts extracts this value and passes it to withTenantContext, which sets transaction-local PostgreSQL session variables. The entire downstream middleware chain and router handler execute inside this transaction.
For session management details, see Authentication (forward-reference).
Layer 2: CASL Authorization
After the session establishes which tenant the request targets, authorizedProcedure verifies the user is actually a member of that organization and builds a CASL ability scoped to their role.
Three roles exist: owner (manage all), admin (org + member management), and member (read org/members, CRUD research). Personal organizations have an override that prevents creating members or managing invitations regardless of role.
Router handlers use ctx.ability.can() or ctx.ability.throwUnlessCan() before mutations. The CASL layer does not replace RLS -- it adds finer-grained, role-based control on top of the database boundary.
For the full role matrix and ability definitions, see Authorization.
Layer 3: Database Enforcement (RLS)
Every tenant-scoped table has four PostgreSQL RLS policies (SELECT, INSERT, UPDATE, DELETE) generated by tenantPolicies(). These policies compare the row's organization_id column against current_setting('app.tenant_id'), which is set by withTenantContext at the start of each transaction.
This is the safety net. Even if application code is completely bypassed -- a direct SQL injection, a misconfigured middleware, an AI agent that skips authorization -- the database itself rejects cross-tenant queries.
For the implementation details of withTenantContext, tenantPolicies(), and the authenticated role, see:
- Data & Storage -- Tenant Context --
withTenantContextinternals - Data & Storage -- Tenant Scoping -- table-level RLS patterns
- Data & Storage -- Procedure Chain -- how the tRPC middleware builds the context
How a Cross-Tenant Attack Is Blocked
Suppose an attacker authenticated as a member of Org A tries to read Org B's data:
-
Layer 1 blocks spoofing. The attacker's session cookie resolves to
activeOrganizationId = Org Aon the server. The attacker cannot set this to Org B because it comes from the server-side session, not client input. -
Layer 2 blocks unauthorized access. If the attacker somehow manipulated the session to point to Org B,
authorizedProcedurewould look up their membership in Org B, find none, and return FORBIDDEN. -
Layer 3 blocks at the database. If both application layers were bypassed, the transaction's
app.tenant_idis set to Org A. RLS policies on every table filter rows byorganization_id = 'Org A'. Org B's rows are invisible to SELECT and rejected by INSERT/UPDATE WITH CHECK.
Default-Deny Behavior
When app.tenant_id is not set (no withTenantContext call), current_setting('app.tenant_id') returns an empty string. The comparison organization_id = '' is always FALSE, so zero rows are returned. The system fails safe -- a missing tenant context returns nothing, not everything.
This behavior is explicitly tested in the RLS integration tests.
Organization Types
Trovella supports three organization types, all implemented as Better Auth organizations with a type field:
| Type | Description | Isolation |
|---|---|---|
| Personal | Auto-created for each user, single member | Same RLS policies; CASL prevents member/invitation management |
| Family | Shared household account | Full RLS + CASL with all roles |
| Company | Business account | Full RLS + CASL with all roles |
The organization type affects CASL abilities (Layer 2) but not RLS policies (Layer 3). All three types use identical database-level isolation.
RLS-Protected Tables
All tenant-scoped tables use tenantPolicies() and .enableRLS(). The tenant column is organization_id on every table.
| Table | Schema File |
|---|---|
member | auth.ts |
invitation | auth.ts |
ai_batch | ai.ts |
ai_usage | ai.ts |
ai_call_details | ai.ts |
research_plan | research.ts |
plan_step | research.ts |
plan_branching_condition | research.ts |
plan_audit_log | research.ts |
research_artifact | research.ts |
extraction_result | research.ts |
skill_execution | research.ts |
mcp_tool_call_log | research.ts |
research_output | research.ts |
research_feedback | research.ts |
document_chunk | search.ts |
Tables without RLS (user, session, account, verification, organization, ai_model, ai_model_pricing, personal_access_token) are either global reference data or user-scoped. See Data & Storage -- Tenant Scoping for the full list with explanations.
Pages in This Topic
| Page | What It Covers |
|---|---|
| ADR-003: Shared Schema + RLS | Why shared schema with RLS was chosen over schema-per-tenant, database-per-tenant, and application-layer filtering |
| Enforcement | How bare db access is structurally prevented, the ESLint and import boundary system, and the /client subpath pattern |
| Review Rules | Architecture review checklist items for tenant isolation -- what to verify in every PR |
Related Topics
- Authorization -- CASL ability definitions and role matrix (Layer 2 details)
- Authentication -- session management and Google OAuth (Layer 1 details)
- Data & Storage -- Tenant Scoping -- how tables are made RLS-aware at the schema level
- Data & Storage -- Tenant Context --
withTenantContextruntime pattern - Data & Storage -- Non-Tenant Queries -- when bare
dbis appropriate
Active Org Selection
How the session's active organization is set, switched, and consumed by the tRPC middleware chain.
ADR-003: Shared Schema + Row-Level Security
Decision record for choosing shared schema with PostgreSQL RLS over schema-per-tenant, database-per-tenant, and application-layer filtering.