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:
@@ -8,7 +8,8 @@
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:deploy": "prisma migrate deploy",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:isolation": "tsx src/__tests__/tenant-isolation.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
@@ -17,6 +18,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.23.1",
|
||||
"prisma": "7.10.0"
|
||||
"prisma": "7.10.0",
|
||||
"tsx": "^4.23.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "user_status" AS ENUM ('ACTIVE', 'DISABLED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" UUID NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"status" "user_status" NOT NULL DEFAULT 'ACTIVE',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"deleted_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "tenant_memberships" (
|
||||
"id" UUID NOT NULL,
|
||||
"tenant_id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "tenant_memberships_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "tenant_memberships_tenant_id_idx" ON "tenant_memberships"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tenant_memberships_tenant_id_user_id_key" ON "tenant_memberships"("tenant_id", "user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tenant_memberships" ADD CONSTRAINT "tenant_memberships_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "tenant_memberships" ADD CONSTRAINT "tenant_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- Row Level Security: tenant_memberships is the first tenant-scoped table.
|
||||
-- Every future tenant-scoped table must repeat this pattern.
|
||||
--
|
||||
-- Convention: the application sets a session-local Postgres setting
|
||||
-- `app.current_tenant_id` (via set_config(..., true) inside a transaction,
|
||||
-- see packages/database withTenantContext()) before running tenant-scoped
|
||||
-- queries. When unset, current_setting(..., true) returns NULL, and the
|
||||
-- comparison below evaluates to NULL/false — deny-by-default, no data leaks.
|
||||
--
|
||||
-- FORCE ROW LEVEL SECURITY makes the policy apply even to the table owner
|
||||
-- (the same role used for migrations), so app code cannot accidentally
|
||||
-- bypass isolation just because it runs as that role.
|
||||
ALTER TABLE "tenant_memberships" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "tenant_memberships" FORCE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "tenant_isolation" ON "tenant_memberships"
|
||||
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
|
||||
@@ -0,0 +1,26 @@
|
||||
-- The Docker Postgres image always makes the initial user (POSTGRES_USER,
|
||||
-- e.g. "b2bcall") a SUPERUSER. Superusers (and BYPASSRLS roles) always
|
||||
-- bypass Row Level Security, no matter FORCE ROW LEVEL SECURITY — so the
|
||||
-- application must NEVER connect as that role for normal queries.
|
||||
--
|
||||
-- This migration creates a separate, unprivileged role for the running
|
||||
-- application. Only this role should be used for APP_DATABASE_URL. The
|
||||
-- superuser role stays reserved for migrations/schema changes (DATABASE_URL
|
||||
-- used by `prisma migrate`).
|
||||
--
|
||||
-- The role's password is set out-of-band (scripts/db-setup-app-role.sh),
|
||||
-- never embedded in a migration file that gets committed to Git.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'b2bcall_app') THEN
|
||||
CREATE ROLE b2bcall_app LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
GRANT USAGE ON SCHEMA public TO b2bcall_app;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO b2bcall_app;
|
||||
|
||||
-- Applies automatically to tables created by future migrations (run as the
|
||||
-- same owner role), so we don't need to repeat these grants every time.
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO b2bcall_app;
|
||||
@@ -32,5 +32,47 @@ model Tenant {
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
memberships TenantMembership[]
|
||||
|
||||
@@map("tenants")
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
ACTIVE
|
||||
DISABLED
|
||||
|
||||
@@map("user_status")
|
||||
}
|
||||
|
||||
// Identidade global do usuário. NUNCA carrega tenant_id diretamente — o tenant
|
||||
// é sempre resolvido via TenantMembership (agente.md secao 31).
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
email String @unique
|
||||
passwordHash String @map("password_hash")
|
||||
name String
|
||||
status UserStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
memberships TenantMembership[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// Tabela tenant-scoped protegida por Row Level Security (ver migration
|
||||
// 'tenant_isolation' e docs/TENANT_ISOLATION.md).
|
||||
model TenantMembership {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
tenantId String @map("tenant_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([tenantId, userId])
|
||||
@@index([tenantId])
|
||||
@@map("tenant_memberships")
|
||||
}
|
||||
|
||||
91
packages/database/src/__tests__/tenant-isolation.test.ts
Normal file
91
packages/database/src/__tests__/tenant-isolation.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user