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
This commit is contained in:
2026-08-28 05:47:23 -03:00
parent c0f29328bd
commit d66170c795
11 changed files with 676 additions and 6 deletions

94
docs/TENANT_ISOLATION.md Normal file
View File

@@ -0,0 +1,94 @@
# 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:
```sql
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:
```sql
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`:
```ts
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:
```bash
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`.