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,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);

View File

@@ -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;

View File

@@ -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")
}