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:
94
docs/TENANT_ISOLATION.md
Normal file
94
docs/TENANT_ISOLATION.md
Normal 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`.
|
||||
Reference in New Issue
Block a user