Files
B2BCall-dialer/docs/TENANT_ISOLATION.md
Matheus d66170c795 feat: implement tenant isolation with PostgreSQL RLS
- users + tenant_memberships tables (tenant-scoped)
- RLS policy on tenant_memberships using set_config('app.current_tenant_id', ...)
- withTenantContext() helper for transaction-scoped tenant context
- separate non-superuser app role (b2bcall_app): the default Docker postgres
  user is SUPERUSER and always bypasses RLS even with FORCE, so the app must
  never connect through the migration/owner role. Documented in
  docs/TENANT_ISOLATION.md.
- automated isolation test proving tenant A never sees tenant B's data
2026-08-28 05:47:23 -03:00

4.2 KiB

Isolamento Multi-Tenant

Mecanismo

Tabelas tenant-scoped usam PostgreSQL Row Level Security (RLS). A aplicação define, dentro de cada transação, o tenant atual via:

SELECT set_config('app.current_tenant_id', '<uuid>', true)

O terceiro argumento (true = is_local) faz o valor durar só a transação atual — seguro sob connection pooling, já que a mesma conexão física é reaproveitada por requisições de tenants diferentes.

Toda tabela tenant-scoped repete o mesmo padrão de policy:

ALTER TABLE "<tabela>" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "<tabela>" FORCE ROW LEVEL SECURITY;

CREATE POLICY "tenant_isolation" ON "<tabela>"
  USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);

Sem app.current_tenant_id definido, current_setting(..., true) retorna NULL, a comparação vira NULL/falsa, e a query não retorna nenhuma linha — deny-by-default.

Em código (packages/database), use sempre o helper withTenantContext:

import { getPrismaClient, withTenantContext } from "@b2bcall/database";

const prisma = getPrismaClient();
const memberships = await withTenantContext(prisma, tenantId, (tx) =>
  tx.tenantMembership.findMany(),
);

Nunca faça prisma.tenantMembership.findMany() direto (fora de withTenantContext) em código de aplicação — isso corre em cima da conexão sem contexto de tenant e, com a policy acima, simplesmente não retorna nada (falha "segura", mas ainda assim é um bug: use sempre o helper).

Achado crítico: role de conexão importa mais que a policy

A imagem oficial do Postgres no Docker sempre cria o usuário inicial (POSTGRES_USER, aqui b2bcall) como SUPERUSER. Superusers — e qualquer role com o atributo BYPASSRLSsempre ignoram RLS, mesmo com FORCE ROW LEVEL SECURITY habilitado. Não existe exceção via configuração de policy: é uma regra do Postgres anterior à avaliação de qualquer USING/WITH CHECK.

Por isso o projeto usa dois roles de banco diferentes:

Role Uso Privilégios Variável de ambiente
b2bcall Migrations, DDL, dono das tabelas SUPERUSER (padrão da imagem) DATABASE_URL
b2bcall_app Runtime da aplicação (API, workers) Sem SUPERUSER, sem BYPASSRLS APP_DATABASE_URL

getPrismaClient() em packages/database/src/index.ts só aceita APP_DATABASE_URL e lança erro se não estiver definida — propositalmente, para impedir que algum serviço se conecte por engano como o role superuser e sile nciosamente ignore o isolamento entre tenants.

O role b2bcall_app é criado pela migration app_role_and_grants (sem senha — senha nunca fica em migration versionada no Git). A senha é aplicada via scripts/db-setup-app-role.sh, que lê POSTGRES_APP_PASSWORD do .env (fora do Git).

Teste de verificação

packages/database/src/__tests__/tenant-isolation.test.ts cria dois tenants e confirma que o contexto de um nunca enxerga dados do outro, e que sem contexto nenhuma linha tenant-scoped é visível. Rodar com:

pnpm --filter @b2bcall/database run test:isolation

Isso corresponde ao critério de aceite da seção 204 do agente.md ("User Tenant A tentando acessar dado Tenant B: resultado obrigatório 403/404, nunca dado do Tenant B" — aqui verificado na camada de banco, a camada de API/HTTP ainda vai reforçar isso com 403/404 quando a Fase de Autenticação/RBAC existir).

Ao criar uma nova tabela tenant-scoped

  1. Adicionar tenantId String @map("tenant_id") @db.Uuid + relação com Tenant.
  2. Na migration gerada, adicionar o bloco ENABLE/FORCE ROW LEVEL SECURITY + CREATE POLICY "tenant_isolation" ... (copiar o padrão acima).
  3. Não é necessário adicionar GRANT manual para b2bcall_app — a migration app_role_and_grants já configurou ALTER DEFAULT PRIVILEGES cobrindo tabelas futuras criadas pelo role de migration.
  4. Toda leitura/escrita nessa tabela em código de aplicação deve passar por withTenantContext.