Trovella Wiki

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

LayerWhereWhat It DoesEnforced By
1. Session contextprotectedProcedure / tenantProcedureExtracts tenant ID from the server-side session -- clients cannot spoof it@repo/api middleware
2. Application authorizationauthorizedProcedure + CASLVerifies the user is a member of the org and checks role-based permissions@repo/api middleware + CASL abilities
3. Database enforcementPostgreSQL RLS policiesFilters 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:

How a Cross-Tenant Attack Is Blocked

Suppose an attacker authenticated as a member of Org A tries to read Org B's data:

  1. Layer 1 blocks spoofing. The attacker's session cookie resolves to activeOrganizationId = Org A on the server. The attacker cannot set this to Org B because it comes from the server-side session, not client input.

  2. Layer 2 blocks unauthorized access. If the attacker somehow manipulated the session to point to Org B, authorizedProcedure would look up their membership in Org B, find none, and return FORBIDDEN.

  3. Layer 3 blocks at the database. If both application layers were bypassed, the transaction's app.tenant_id is set to Org A. RLS policies on every table filter rows by organization_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:

TypeDescriptionIsolation
PersonalAuto-created for each user, single memberSame RLS policies; CASL prevents member/invitation management
FamilyShared household accountFull RLS + CASL with all roles
CompanyBusiness accountFull 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.

TableSchema File
memberauth.ts
invitationauth.ts
ai_batchai.ts
ai_usageai.ts
ai_call_detailsai.ts
research_planresearch.ts
plan_stepresearch.ts
plan_branching_conditionresearch.ts
plan_audit_logresearch.ts
research_artifactresearch.ts
extraction_resultresearch.ts
skill_executionresearch.ts
mcp_tool_call_logresearch.ts
research_outputresearch.ts
research_feedbackresearch.ts
document_chunksearch.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

PageWhat It Covers
ADR-003: Shared Schema + RLSWhy shared schema with RLS was chosen over schema-per-tenant, database-per-tenant, and application-layer filtering
EnforcementHow bare db access is structurally prevented, the ESLint and import boundary system, and the /client subpath pattern
Review RulesArchitecture review checklist items for tenant isolation -- what to verify in every PR

On this page