- 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
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 BYPASSRLS — sempre 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
- Adicionar
tenantId String @map("tenant_id") @db.Uuid+ relação comTenant. - Na migration gerada, adicionar o bloco
ENABLE/FORCE ROW LEVEL SECURITY+CREATE POLICY "tenant_isolation" ...(copiar o padrão acima). - Não é necessário adicionar
GRANTmanual parab2bcall_app— a migrationapp_role_and_grantsjá configurouALTER DEFAULT PRIVILEGEScobrindo tabelas futuras criadas pelo role de migration. - Toda leitura/escrita nessa tabela em código de aplicação deve passar por
withTenantContext.