From d66170c795c8624452694dceaa64d373d7a79dff Mon Sep 17 00:00:00 2001 From: Matheus Date: Fri, 28 Aug 2026 05:47:23 -0300 Subject: [PATCH] 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 --- .env.example | 3 + TODO.md | 10 +- docs/TENANT_ISOLATION.md | 94 ++++++ packages/database/package.json | 6 +- .../migration.sql | 59 ++++ .../migration.sql | 26 ++ packages/database/prisma/schema.prisma | 42 +++ .../src/__tests__/tenant-isolation.test.ts | 91 ++++++ packages/database/src/index.ts | 33 +- pnpm-lock.yaml | 290 ++++++++++++++++++ scripts/db-setup-app-role.sh | 28 ++ 11 files changed, 676 insertions(+), 6 deletions(-) create mode 100644 docs/TENANT_ISOLATION.md create mode 100644 packages/database/prisma/migrations/20260828084235_tenant_isolation/migration.sql create mode 100644 packages/database/prisma/migrations/20260828090000_app_role_and_grants/migration.sql create mode 100644 packages/database/src/__tests__/tenant-isolation.test.ts create mode 100755 scripts/db-setup-app-role.sh diff --git a/.env.example b/.env.example index 026d326..6158414 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,5 @@ FREESWITCH_PAT= DATABASE_URL=postgresql://user:password@localhost:5432/b2bcall?schema=public +POSTGRES_APP_USER= +POSTGRES_APP_PASSWORD= +APP_DATABASE_URL=postgresql://user:password@localhost:5432/b2bcall?schema=public diff --git a/TODO.md b/TODO.md index a5db4b5..b0c006b 100644 --- a/TODO.md +++ b/TODO.md @@ -19,8 +19,14 @@ - [x] Tabela `tenants` criada via migration (seção 29 do agente.md) ## PHASE 03 — Tenant Isolation -- [ ] Tabela `tenants` + RLS -- [ ] Tenant context em transação PostgreSQL +- [x] Tabelas `users` + `tenant_memberships` (tenant-scoped) +- [x] RLS (`ENABLE`/`FORCE ROW LEVEL SECURITY` + policy) em `tenant_memberships` +- [x] Tenant context via `set_config('app.current_tenant_id', ..., true)` (transaction-local) +- [x] Helper `withTenantContext()` em `packages/database` +- [x] Role de banco separado para runtime (`b2bcall_app`, sem SUPERUSER/BYPASSRLS) — + achado crítico: o role padrão do Docker Postgres é SUPERUSER e SEMPRE ignora RLS, + até com FORCE. Ver `docs/TENANT_ISOLATION.md`. +- [x] Teste automatizado de isolamento (`pnpm --filter @b2bcall/database run test:isolation`) ## PHASE 04 — Authentication / RBAC - [ ] Login (Argon2id), access/refresh tokens diff --git a/docs/TENANT_ISOLATION.md b/docs/TENANT_ISOLATION.md new file mode 100644 index 0000000..60bd275 --- /dev/null +++ b/docs/TENANT_ISOLATION.md @@ -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', '', 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 "" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "" FORCE ROW LEVEL SECURITY; + +CREATE POLICY "tenant_isolation" ON "" + 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`. diff --git a/packages/database/package.json b/packages/database/package.json index d99f80d..c259e68 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -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" } } diff --git a/packages/database/prisma/migrations/20260828084235_tenant_isolation/migration.sql b/packages/database/prisma/migrations/20260828084235_tenant_isolation/migration.sql new file mode 100644 index 0000000..0537824 --- /dev/null +++ b/packages/database/prisma/migrations/20260828084235_tenant_isolation/migration.sql @@ -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); diff --git a/packages/database/prisma/migrations/20260828090000_app_role_and_grants/migration.sql b/packages/database/prisma/migrations/20260828090000_app_role_and_grants/migration.sql new file mode 100644 index 0000000..37ae46c --- /dev/null +++ b/packages/database/prisma/migrations/20260828090000_app_role_and_grants/migration.sql @@ -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; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index ba25d34..0756043 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -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") +} diff --git a/packages/database/src/__tests__/tenant-isolation.test.ts b/packages/database/src/__tests__/tenant-isolation.test.ts new file mode 100644 index 0000000..ea7b7d1 --- /dev/null +++ b/packages/database/src/__tests__/tenant-isolation.test.ts @@ -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); +}); diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index f2d5c42..04324f5 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -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( + client: PrismaClient, + tenantId: string, + fn: (tx: Prisma.TransactionClient) => Promise, +): Promise { + return client.$transaction(async (tx) => { + await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`; + return fn(tx); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4655fe9..f16a685 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: prisma: specifier: 7.10.0 version: 7.10.0(@types/react@19.2.18)(magicast@0.5.4)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3) + tsx: + specifier: ^4.23.12 + version: 4.23.12 packages/shared: dependencies: @@ -80,6 +83,162 @@ packages: '@electric-sql/pglite@0.4.3': resolution: {integrity: sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@prisma/adapter-pg@7.10.0': resolution: {integrity: sha512-N7nwSor0HO1Kz6xBv0TPAjAPysKK0fac6p4fVN3ensLOuzc/83Fgmln5k92eK/cvzqdkSR/2kkAqlbcdwVrwpw==} @@ -421,6 +580,11 @@ packages: resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + exsolve@1.1.1: resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} @@ -448,6 +612,11 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + generate-function@2.3.1: resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} @@ -679,6 +848,11 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + tsx@4.23.12: + resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + engines: {node: '>=18.0.0'} + hasBin: true + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -736,6 +910,84 @@ snapshots: '@electric-sql/pglite@0.4.3': {} + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + '@prisma/adapter-pg@7.10.0': dependencies: '@prisma/driver-adapter-utils': 7.10.0 @@ -1138,6 +1390,35 @@ snapshots: env-paths@3.0.0: {} + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + exsolve@1.1.1: {} fast-check@3.23.2: @@ -1165,6 +1446,9 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fsevents@2.3.3: + optional: true + generate-function@2.3.1: dependencies: is-property: 1.0.2 @@ -1363,6 +1647,12 @@ snapshots: std-env@3.10.0: {} + tsx@4.23.12: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + typescript@5.9.3: {} undici-types@8.3.0: {} diff --git a/scripts/db-setup-app-role.sh b/scripts/db-setup-app-role.sh new file mode 100755 index 0000000..8e56cc1 --- /dev/null +++ b/scripts/db-setup-app-role.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Sets/rotates the password of the restricted, non-superuser Postgres role +# used by the running application (b2bcall_app). The role itself is created +# by the "app_role_and_grants" Prisma migration; this script only sets the +# secret, which must never be embedded in a committed migration file. +# +# Reads POSTGRES_USER/PASSWORD (superuser, to run the ALTER ROLE) and +# POSTGRES_APP_USER/POSTGRES_APP_PASSWORD from .env. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +set -a +# shellcheck disable=SC1091 +source "$ROOT_DIR/.env" +set +a + +: "${POSTGRES_APP_USER:?POSTGRES_APP_USER not set in .env}" +: "${POSTGRES_APP_PASSWORD:?POSTGRES_APP_PASSWORD not set in .env}" + +docker exec -i b2bcall-postgres psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \ + -v ON_ERROR_STOP=1 \ + -v app_user="$POSTGRES_APP_USER" \ + -v app_password="$POSTGRES_APP_PASSWORD" \ + <<'SQL' +ALTER ROLE :"app_user" WITH PASSWORD :'app_password'; +SQL + +echo "Senha do role '${POSTGRES_APP_USER}' aplicada."