Trovella Wiki

Enforcement Layers

How bare database access is structurally prevented through import restrictions, the /client subpath pattern, and CI architecture tests.

RLS policies only protect against cross-tenant access when queries run inside withTenantContext. If a developer (or an AI agent) uses the bare db connection for a tenant-scoped query, RLS is bypassed entirely. This page documents the multi-layer enforcement system that prevents that from happening.

The Problem: TRO-10

During TRO-10, a proactive code review discovered that tenantProcedure was passing the bare db pool to ctx.db instead of the transaction-scoped tx from withTenantContext(). The code compiled. The app functioned. Single-tenant tests passed. But downstream queries were executing with no RLS context, meaning they would return all tenants' data.

The fix was a one-character change: db to tx. The incident drove an evolution through three enforcement phases.

Enforcement Evolution

Phase 1: Advisory (TRO-8)

CLAUDE.md warned: "never use bare db for tenant-scoped queries." This caught nothing -- AI agents do not reliably read or follow advisory warnings in documentation.

Phase 2: ESLint Ban (TRO-88)

no-restricted-imports rules in ESLint blocked importing { db } from @repo/db in router and MCP tool directories. This caught direct imports at lint time, but could be circumvented by importing from @repo/db/client directly.

Phase 3: Structural Removal (TRO-94)

The bare db object was removed from @repo/db's main entry point entirely. It now lives only at @repo/db/client -- a deliberate import path that signals "I know what I'm doing and I have a legitimate reason." The default import (@repo/db) exports withTenantContext, schema definitions, and query helpers, but not db.

Current Enforcement Stack

LayerMechanismWhat It Catches
Package structuredb not exported from @repo/db main pathTypeScript error on import { db } from "@repo/db"
ESLintno-restricted-imports on @repo/db in routers/toolsImport of db in directories that should only use ctx.db
Architecture testsVitest tests scan router files for authorizedProcedure usageRouter files that skip the full auth+RLS middleware chain
Code reviewArch review checklist Section 1Imports from @repo/db/client in tenant-scoped code
AI review/arch-review skillSame as code review, automated on PRs

The /client Subpath Pattern

Only four legitimate use cases import from @repo/db/client:

  1. AI usage recording -- system-level telemetry that crosses tenant boundaries
  2. MCP auth resolution -- resolveOrganizationId() runs before a tenant context exists
  3. Health checks -- SELECT 1 has no tenant context
  4. Migrations and seeds -- schema changes require superuser access

If you see import { db } from "@repo/db/client" in any other context, it is a violation.

For the full pattern and examples of legitimate bare db usage, see Data & Storage -- Non-Tenant Queries.

Zero ESLint Exceptions

The PAT router initially used eslint-disable to import bare db (PATs are user-scoped, not tenant-scoped). Rather than allow the exception, the PAT query functions were refactored into @repo/db as named functions (listUserPATs, createPAT, revokePAT), eliminating the only eslint-disable in the boundary enforcement system.

The principle: no exceptions means no precedent for future exceptions. Every eslint-disable for an architectural rule requires justification strong enough to survive an architecture review.

Architecture Tests

The Vitest architecture test in packages/api/src/__tests__/arch.test.ts scans all router files and asserts that they use authorizedProcedure. An explicit allowlist exists for the small number of routers that legitimately use lower-level procedures (health checks, pre-auth endpoints).

Expanding the allowlist without justification is a red flag in architecture review. See Review Rules for what to check.

What AI Agents Get Wrong

AI agents writing code take the shortest path to working functionality. Common bypass patterns:

  1. Importing from @repo/db/client instead of using ctx.db -- works around the main export restriction
  2. Modifying @repo/db/src/index.ts to re-export db -- removes the structural protection at its source
  3. Adding entries to the architecture test allowlist with weak justifications like "CASL not needed yet"
  4. Using eslint-disable comments to suppress no-restricted-imports

Each of these is caught by a different layer of enforcement. The redundancy is intentional -- defense-in-depth applies to the enforcement system itself.

Adding Enforcement for a New Package

When creating a new @repo/* package that touches tenant-scoped data:

  1. Add restrictedImports("@repo/new-package") to its eslint.config.js
  2. The restriction map in packages/config-eslint/restrictions.js controls which SDKs and internal packages are banned in which directories
  3. Run pnpm lint to verify zero violations
  4. Run pnpm dep-cruise to validate the dependency graph

For the full procedure, see the "Package Boundaries" section in the project CLAUDE.md.

On this page