- 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
95 lines
4.2 KiB
Markdown
95 lines
4.2 KiB
Markdown
# 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`.
|