Trovella Wiki

Tenant Scoping

How tables become tenant-isolated through RLS policies, the tenantPolicies() helper, and the withTenantContext() transaction wrapper.

Every table that stores tenant-owned data must enforce Row-Level Security (RLS). This is not optional -- it is the primary security boundary for multi-tenant isolation. Application-layer filtering is defense-in-depth; the database is the final safety net.

The Three-Part Pattern

A tenant-scoped table requires three things:

  1. An organization_id column (the tenant identifier)
  2. RLS policies generated by tenantPolicies()
  3. The .enableRLS() call on the table definition
import { pgTable, text, integer } from "drizzle-orm/pg-core";
import { tenantColumns, tenantPolicies } from "./base";

export const myFeatureTable = pgTable(
  "my_feature_table",
  {
    id: text("id").primaryKey(),
    ...tenantColumns, // adds organizationId, createdAt, updatedAt
    score: integer("score").notNull(),
  },
  (table) => [
    ...tenantPolicies("my_feature_table", "organization_id"),
    index("my_feature_table_organizationId_idx").on(table.organizationId),
  ],
).enableRLS();

If any one of these three parts is missing, the table is not isolated. The migration generator will not warn you -- this is a convention enforced by code review and architecture audits.

How tenantPolicies() Works

The tenantPolicies() function in packages/db/src/schema/base.ts generates four PostgreSQL policies for a single table:

export function tenantPolicies(tableName: string, columnName = "tenant_id") {
  const col = sql.raw(columnName);
  const using = sql`${col} = ${tenantIdExpr}`;
  const withCheck = sql`${col} = ${tenantIdExpr}`;

  return [
    pgPolicy(`${tableName}_select`, {
      as: "permissive",
      for: "select",
      to: authenticatedRole,
      using,
    }),
    pgPolicy(`${tableName}_insert`, {
      as: "permissive",
      for: "insert",
      to: authenticatedRole,
      withCheck,
    }),
    pgPolicy(`${tableName}_update`, {
      as: "permissive",
      for: "update",
      to: authenticatedRole,
      using,
      withCheck,
    }),
    pgPolicy(`${tableName}_delete`, {
      as: "permissive",
      for: "delete",
      to: authenticatedRole,
      using,
    }),
  ];
}

Each policy restricts the authenticated PostgreSQL role to rows where the tenant column matches the session variable app.tenant_id.

The initPlan Caching Optimization

The tenant ID expression wraps current_setting() in a SELECT subquery:

const tenantIdExpr = sql`(SELECT current_setting('app.tenant_id'))`;

This is not cosmetic. Without the SELECT wrapper, PostgreSQL evaluates current_setting() per-row. With it, PostgreSQL promotes the expression to an initPlan, evaluating it once per query. The performance difference is 1-3% vs 10-100x overhead on large tables.

Policy Naming

Policies are named {tableName}_{operation}:

  • research_plan_select
  • research_plan_insert
  • research_plan_update
  • research_plan_delete

These names appear in EXPLAIN ANALYZE output and in PostgreSQL's pg_policies catalog, making it straightforward to debug which policy is being evaluated.

The authenticated Role

RLS policies target a dedicated PostgreSQL role named authenticated, defined in packages/db/src/schema/roles.ts:

export const authenticatedRole = pgRole("authenticated");

This role has no default privileges. The migration 0001_setup_role_grants.sql grants it SELECT, INSERT, UPDATE, DELETE on all current and future tables in the public schema:

GRANT USAGE ON SCHEMA public TO authenticated;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO authenticated;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO authenticated;

The withTenantContext() function switches to this role inside each transaction via SET ROLE authenticated (done by Drizzle's transaction machinery). Because RLS is enabled on tenant-scoped tables, the authenticated role can only see rows matching its app.tenant_id session variable.

An anonymous role is also defined but currently unused -- reserved for future public access patterns.

Default-Deny Behavior

When app.tenant_id is not set (the session variable is missing or empty), current_setting('app.tenant_id') returns an empty string. The comparison organization_id = '' is always FALSE, so zero rows are returned.

This is critical: a missing withTenantContext() call results in an empty result set, not a data leak. The system fails safe rather than failing open.

This behavior is explicitly tested in the RLS integration tests at packages/db/src/__tests__/rls.test.ts.

Which Tables Are Tenant-Scoped

Tenant-Scoped (RLS Enabled)

TableSchema fileNotes
memberauth.tsBetter Auth org membership
invitationauth.tsBetter Auth org invitations
ai_batchai.tsBatch job lifecycle
ai_usageai.tsPer-call metering
ai_call_detailsai.tsFull request/response content
research_planresearch.tsTop-level research plans
plan_stepresearch.tsSteps within a plan
plan_branching_conditionresearch.tsConditional plan logic
plan_audit_logresearch.tsImmutable event log
research_artifactresearch.tsResearch outputs (JSONB)
extraction_resultresearch.tsStructured extractions
skill_executionresearch.tsSkill invocation tracking
mcp_tool_call_logresearch.tsMCP tool call audit log
research_outputresearch.tsFormatted deliverables
research_feedbackresearch.tsUser satisfaction data
document_chunksearch.tsChunked + embedded content

Global (No RLS)

TableSchema fileWhy no RLS
userauth.tsUsers exist across organizations
sessionauth.tsSession resolves the user, not the tenant
accountauth.tsOAuth accounts are user-scoped
verificationauth.tsEmail verification tokens are user-scoped
organizationauth.tsOrganizations are the tenant boundary itself
ai_modelai.tsGlobal reference data (all tenants read)
ai_model_pricingai.tsGlobal reference data
personal_access_tokenmcp.tsUser-scoped, filtered by application code

Column Name: organization_id, Not tenant_id

Better Auth's organization plugin uses organization_id as its column name. Rather than maintain a mapping layer between two naming conventions, all Trovella tables use organization_id directly.

When calling tenantPolicies(), pass the column name explicitly:

...tenantPolicies("my_table", "organization_id"),

The second parameter defaults to "tenant_id" for historical reasons, but every table in the codebase passes "organization_id" explicitly.

Querying Tenant-Scoped Tables

All tenant-scoped queries must use withTenantContext(), not the bare db connection:

import { withTenantContext } from "@repo/db";

const plans = await withTenantContext(orgId, userId, async (tx) => {
  return tx.select().from(researchPlan).where(eq(researchPlan.status, "executing"));
});

The bare db object connects as the superuser and bypasses RLS entirely. It is intentionally not exported from @repo/db's main entry point -- only from @repo/db/client, which is restricted by ESLint no-restricted-imports.

See Identity & Access -- Tenant Isolation for the enforcement layers that prevent accidental bare db usage.

On this page