Build a tenant guard you cannot forget to call

Level
intermediate
Time to complete
45 min
Prerequisites
5

What you will buildOne access layer, re-asserted on the AI path, with a test that fails the build on any unscoped query. ~45 min, intermediate.

What You Will Build

Most pooled products keep every customer's data in one shared table with a tenant-id column. That is the standard, correct shape. The wall between Customer A and Customer B is the application remembering to add tenant_id = ? to every query - and the most common leak in the wild is an AI or cache path that forgot.

By the end of this you will have one data-access module that no part of your app can query around, a tenant id that the compiler forces you to pass, the same filter re-asserted on the retrieval, embedding and cache paths, and a test that turns a continuous-integration check red the moment anyone writes an unscoped query. The guard becomes structural, not a convention people remember.

Prerequisites

  • A pooled Postgres database with a tenant_id (or equivalent) column on every tenant table.
  • Node 20 and TypeScript, with the pg client installed (npm i pg).
  • A test runner (the examples use Jest-style test/expect; adapt to yours).
  • A CI provider that can block a merge on a failing check (the example uses GitHub Actions).
  • A retrieval path with a vector column and a cache, if you run RAG - the AI-path steps assume one.

Tip

You do not need database-enforced Row-Level Security in place to start. This tutorial hardens the application layer first, because that is where the real leaks happen. Real RLS policies are the backstop you add next, not a prerequisite for this.

Mental Model

Think of it as one door. Every read, every write, every retrieval, every embedding lookup and every cache key goes through a single module, and that module stamps the tenant id onto the query itself. Nothing in the rest of the app holds a raw database client, so there is no way to query around the door.

The dangerous part is not the first query. It is the second one. A tenant filter on your primary read does not travel to the AI path that sits in front of it. A vector search is a new query. A cache lookup is a new key. Each is a fresh place to drop the tenant context, and that is exactly how a well-known 2025 work-management tool exposed about a thousand organisations: the AI feature did not re-verify tenant context on cached responses. So the rule is: re-assert the tenant id on every path, never inherit it.

One door: every read, write, retrieval, embedding and cache call passes through a single guarded module that stamps the tenant id.

Steps

Follow them in order. The first two stand up the door; the rest make it impossible to walk around.

Put every tenant query behind one module

Create a single file that owns the database client and do not export the client. The rest of your app imports functions from here, never the pool.

// src/db/tenant.ts - the ONLY module the app imports for tenant data.
import { Pool } from "pg";

// Not exported. Nothing outside this file can get the raw pool.
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export type TenantContext = { tenantId: string };

export function tenantContext(tenantId: string): TenantContext {
  if (!tenantId) throw new Error("tenant: missing tenant id");
  return { tenantId };
}

You know it worked when pool is unreachable from any other file: a grep for new Pool( returns exactly this one line.

Make the tenant id a required first argument

Every data function takes a TenantContext as its first parameter. There is no zero-argument query export, so a caller cannot even reach the data layer without a tenant in hand. The type system does the nagging for you.

export async function readRows(
  ctx: TenantContext,
  table: string,
  where: Record<string, unknown> = {}
): Promise<Record<string, unknown>[]> {
  // the body lands in the next step
  throw new Error("not implemented yet");
}

You know it worked when calling readRows("documents") without a context fails to compile. The missing first argument is a type error, caught before the code ever runs.

Sources

  1. PostgreSQL Row Security: default-deny for normal roles, bypassed by BYPASSRLS rolespostgresql.org
  2. Supabase RLS: the service_role key bypasses RLS and must stay server-sidesupabase.com
  3. BleepingComputer: Asana AI feature exposed data to other orgs via uncached tenant contextbleepingcomputer.com