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

View File

@@ -0,0 +1,91 @@
/**
* Verificação de isolamento multi-tenant (agente.md secao 204).
*
* Cria dois tenants + um usuário em cada, e confirma que a RLS de
* `tenant_memberships` nunca deixa o contexto do Tenant A enxergar dados
* do Tenant B — mesmo usando a mesma conexão/role de banco.
*
* Roda com: pnpm --filter @b2bcall/database run test:isolation
*/
import { randomUUID } from "node:crypto";
import { getPrismaClient, withTenantContext } from "../index";
function assert(condition: boolean, message: string): void {
if (!condition) {
throw new Error(`FALHOU: ${message}`);
}
console.log(`OK: ${message}`);
}
async function main() {
const prisma = getPrismaClient();
const suffix = randomUUID().slice(0, 8);
const tenantA = await prisma.tenant.create({
data: { code: `test-a-${suffix}`, slug: `test-a-${suffix}`, legalName: "Tenant A LTDA" },
});
const tenantB = await prisma.tenant.create({
data: { code: `test-b-${suffix}`, slug: `test-b-${suffix}`, legalName: "Tenant B LTDA" },
});
const userA = await prisma.user.create({
data: { email: `user-a-${suffix}@test.local`, passwordHash: "x", name: "User A" },
});
const userB = await prisma.user.create({
data: { email: `user-b-${suffix}@test.local`, passwordHash: "x", name: "User B" },
});
// As INSERTs abaixo precisam ir através do contexto correto: RLS com
// FORCE ROW LEVEL SECURITY também bloqueia INSERT sem app.current_tenant_id
// compatível.
await withTenantContext(prisma, tenantA.id, (tx) =>
tx.tenantMembership.create({ data: { tenantId: tenantA.id, userId: userA.id } }),
);
await withTenantContext(prisma, tenantB.id, (tx) =>
tx.tenantMembership.create({ data: { tenantId: tenantB.id, userId: userB.id } }),
);
// Contexto do Tenant A: só pode ver a própria membership.
const seenFromA = await withTenantContext(prisma, tenantA.id, (tx) =>
tx.tenantMembership.findMany(),
);
assert(seenFromA.length === 1, "Tenant A vê exatamente 1 membership");
assert(seenFromA[0]!.tenantId === tenantA.id, "Tenant A só vê a própria membership");
assert(
!seenFromA.some((m) => m.tenantId === tenantB.id),
"Tenant A NUNCA vê membership do Tenant B",
);
// Contexto do Tenant B: simetricamente, só vê a própria.
const seenFromB = await withTenantContext(prisma, tenantB.id, (tx) =>
tx.tenantMembership.findMany(),
);
assert(seenFromB.length === 1, "Tenant B vê exatamente 1 membership");
assert(seenFromB[0]!.tenantId === tenantB.id, "Tenant B só vê a própria membership");
// Sem contexto de tenant nenhum: deny-by-default, zero linhas.
const seenWithoutContext = await prisma.tenantMembership.findMany();
assert(
seenWithoutContext.filter((m) => m.tenantId === tenantA.id || m.tenantId === tenantB.id)
.length === 0,
"Sem app.current_tenant_id definido, nenhuma membership de teste é visível (deny-by-default)",
);
// Limpeza.
await withTenantContext(prisma, tenantA.id, (tx) =>
tx.tenantMembership.deleteMany({ where: { tenantId: tenantA.id } }),
);
await withTenantContext(prisma, tenantB.id, (tx) =>
tx.tenantMembership.deleteMany({ where: { tenantId: tenantB.id } }),
);
await prisma.user.deleteMany({ where: { id: { in: [userA.id, userB.id] } } });
await prisma.tenant.deleteMany({ where: { id: { in: [tenantA.id, tenantB.id] } } });
console.log("\nIsolamento multi-tenant OK: Tenant A e Tenant B nunca se enxergam.");
await prisma.$disconnect();
}
main().catch(async (err) => {
console.error(err);
process.exit(1);
});

View File

@@ -1,4 +1,4 @@
import { PrismaClient } from "@prisma/client";
import { PrismaClient, Prisma } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
export * from "@prisma/client";
@@ -7,8 +7,37 @@ let prisma: PrismaClient | undefined;
export function getPrismaClient(): PrismaClient {
if (!prisma) {
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
// APP_DATABASE_URL must point to a non-superuser, non-BYPASSRLS role
// (b2bcall_app) — superusers always bypass Row Level Security, so the
// running application can never connect as the migration/owner role
// (DATABASE_URL) without silently defeating tenant isolation.
const connectionString = process.env.APP_DATABASE_URL;
if (!connectionString) {
throw new Error(
"APP_DATABASE_URL is not set. The application must connect through the " +
"restricted b2bcall_app role, not the migration superuser (DATABASE_URL).",
);
}
const adapter = new PrismaPg({ connectionString });
prisma = new PrismaClient({ adapter });
}
return prisma;
}
/**
* Runs `fn` inside a transaction with the Postgres RLS tenant context set via
* `set_config('app.current_tenant_id', tenantId, true)` — transaction-local,
* safe under connection pooling. Every tenant-scoped query must go through
* this helper; never query tenant-scoped tables outside of it (see
* docs/TENANT_ISOLATION.md).
*/
export async function withTenantContext<T>(
client: PrismaClient,
tenantId: string,
fn: (tx: Prisma.TransactionClient) => Promise<T>,
): Promise<T> {
return client.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`;
return fn(tx);
});
}