From 8c22597420b55c565a765c5191a2636e90d316f9 Mon Sep 17 00:00:00 2001 From: Ferraz Fagundes Date: Mon, 19 Oct 2026 16:32:03 -0300 Subject: [PATCH] fix(db): protege conexoes e migrations de homologacao --- .env.staging.example | 16 +- migrations/0005_maintenance_roles.sql | 150 +++++++++++++ .../down/0002_spreadsheet_adapter_down.sql | 8 + scripts/_bootstrap.ts | 28 ++- scripts/cleanup-temp.ts | 2 +- scripts/migrate.ts | 2 +- scripts/seed-dev.ts | 2 +- scripts/seed-staging.ts | 2 +- scripts/set-flag.ts | 2 +- scripts/worker.ts | 2 +- src/config/fail-fast.ts | 6 +- src/infra/db/client.ts | 13 +- src/infra/db/connection-policy.ts | 210 ++++++++++++++++++ src/infra/db/migration-manifest.ts | 1 + src/infra/db/postgres.ts | 5 +- src/infra/db/target-guard.ts | 173 +++++++++++++++ src/server/spreadsheet-adapter/process-job.ts | 23 +- src/server/spreadsheet-adapter/worker-loop.ts | 11 +- tests/integration/maintenance-roles.test.ts | 62 ++++++ tests/services/postgres.test.ts | 23 ++ tests/unit/database-guard.test.ts | 143 ++++++++++++ 21 files changed, 839 insertions(+), 45 deletions(-) create mode 100644 migrations/0005_maintenance_roles.sql create mode 100644 src/infra/db/connection-policy.ts create mode 100644 src/infra/db/target-guard.ts create mode 100644 tests/integration/maintenance-roles.test.ts create mode 100644 tests/unit/database-guard.test.ts diff --git a/.env.staging.example b/.env.staging.example index d9ceb1e..19817f4 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -31,8 +31,22 @@ ELO_PRO_APP_URL=https://hml-ada.elopro.app AUTH_ALLOWED_RETURN_HOSTS=hml-ada.elopro.app # --- Banco ---------------------------------------------------------------- -# Banco de HOMOLOGAÇÃO. Nunca o de produção. +# Três credenciais. Nenhuma é a do Supabase self-hosted. +# Web: papel adaptador_web, sem SUPERUSER e sem BYPASSRLS. +# sslmode=verify-full é obrigatório. Não use require, prefer ou disable. DATABASE_URL= +# Worker: papel adaptador_worker. Não reutilize a URL da web nem a administrativa. +ADAPTER_WORKER_DATABASE_URL= +# Migrations, seed:staging e flag. Papel postgres deste projeto, só na execução aprovada. +ADAPTER_MIGRATION_DATABASE_URL= +# Ref do projeto adaptador-planilha-hml. Sozinho não autoriza migration. +ADAPTER_DB_PROJECT_REF= +# Host do Session Pooler copiado do Dashboard. O índice aws-N não se deduz da região. +# O Dashboard deste projeto mostra host iniciado por aws-1-us-west-2.pooler.supabase.com. +# Só é aceito aws--us-west-2.pooler.supabase.com. +ADAPTER_DB_POOLER_HOST= +# Teto do pool por processo. Vazio usa 2 na web, 2 no worker e 1 na migration. Máximo 4. +ADAPTER_DB_POOL_MAX= # Decisão explícita e obrigatória: # 1 = criar o shim local de identidade (profiles, workspaces, workspace_members) # 0 = o schema do Elo Pro já existe nesse banco; não recriar diff --git a/migrations/0005_maintenance_roles.sql b/migrations/0005_maintenance_roles.sql new file mode 100644 index 0000000..03b5045 --- /dev/null +++ b/migrations/0005_maintenance_roles.sql @@ -0,0 +1,150 @@ +-- Papéis e funções de manutenção da homologação. +-- Não altera os schemas auth e storage do Supabase. +-- Não concede BYPASSRLS nem SUPERUSER. +-- Os papéis nascem NOLOGIN. A senha e o LOGIN ficam para um passo autorizado no projeto de homologação. +-- As funções são SECURITY DEFINER, com search_path travado, e EXECUTE só para adaptador_worker. +-- O dono é quem aplica a migration. No Supabase gerenciado esse dono é o postgres, que atravessa FORCE RLS. +-- adaptador_web e adaptador_worker são NOINHERIT: sem SET ROLE app_user não herdam os grants do papel da aplicação. + +do $$ begin + create role adaptador_web nologin nosuperuser nobypassrls noinherit; +exception when duplicate_object then null; +end $$; + +do $$ begin + create role adaptador_worker nologin nosuperuser nobypassrls noinherit; +exception when duplicate_object then null; +end $$; + +grant app_user to adaptador_web; +grant app_user to adaptador_worker; +grant usage on schema public to adaptador_web, adaptador_worker; +grant select on table public.schema_migrations to adaptador_web, adaptador_worker; + +create or replace function public.adapter_list_queued_job_ids() +returns table (id uuid) +language sql +stable +security definer +set search_path = pg_catalog +as $$ + select j.id + from public.spreadsheet_jobs j + where j.status = 'queued' + order by j.created_at asc + limit 5 +$$; + +create or replace function public.adapter_read_queued_job(p_job uuid) +returns table (user_id uuid, workspace_id uuid, progress jsonb) +language sql +stable +security definer +set search_path = pg_catalog +as $$ + select j.user_id, j.workspace_id, j.progress + from public.spreadsheet_jobs j + where j.id = p_job and j.status = 'queued' +$$; + +create or replace function public.adapter_worker_context(p_user uuid, p_workspace uuid) +returns table ( + display_name text, + is_superadmin boolean, + role text, + workspace_name text, + module_enabled boolean, + allow_upload boolean +) +language sql +stable +security definer +set search_path = pg_catalog +as $$ + select p.display_name, + p.is_superadmin, + m.role, + w.name, + coalesce(f.enabled, false), + coalesce(s.allow_non_admin_model_upload, false) + from public.profiles p + join public.workspace_members m on m.user_id = p.id and m.workspace_id = p_workspace + join public.workspaces w on w.id = p_workspace + left join public.workspace_module_flags f + on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter' + left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id + where p.id = p_user +$$; + +create or replace function public.adapter_list_expired_sources(p_now timestamptz) +returns table ( + id uuid, + workspace_id uuid, + temp_storage_key text, + output_csv_key text, + output_xlsx_key text +) +language sql +stable +security definer +set search_path = pg_catalog +as $$ + select s.id, j.workspace_id, s.temp_storage_key, s.output_csv_key, s.output_xlsx_key + from public.spreadsheet_job_sources s + join public.spreadsheet_jobs j on j.id = s.job_id + where s.expires_at < p_now and s.status <> 'expired' +$$; + +create or replace function public.adapter_mark_source_expired(p_source uuid) +returns void +language plpgsql +security definer +set search_path = pg_catalog +as $$ +begin + update public.spreadsheet_job_sources + set status = 'expired', output_csv_key = null, output_xlsx_key = null + where id = p_source and status <> 'expired'; +end; +$$; + +create or replace function public.adapter_list_processing_jobs() +returns table (id uuid, user_id uuid, workspace_id uuid) +language sql +stable +security definer +set search_path = pg_catalog +as $$ + select j.id, j.user_id, j.workspace_id + from public.spreadsheet_jobs j + where j.status = 'processing' +$$; + +create or replace function public.adapter_mark_job_interrupted(p_job uuid) +returns void +language plpgsql +security definer +set search_path = pg_catalog +as $$ +begin + update public.spreadsheet_jobs + set status = 'failed', error_code = 'INTERRUPTED', updated_at = now() + where id = p_job and status = 'processing'; +end; +$$; + +revoke all on function public.adapter_list_queued_job_ids() from public, app_user, adaptador_web; +revoke all on function public.adapter_read_queued_job(uuid) from public, app_user, adaptador_web; +revoke all on function public.adapter_worker_context(uuid, uuid) from public, app_user, adaptador_web; +revoke all on function public.adapter_list_expired_sources(timestamptz) from public, app_user, adaptador_web; +revoke all on function public.adapter_mark_source_expired(uuid) from public, app_user, adaptador_web; +revoke all on function public.adapter_list_processing_jobs() from public, app_user, adaptador_web; +revoke all on function public.adapter_mark_job_interrupted(uuid) from public, app_user, adaptador_web; + +grant execute on function public.adapter_list_queued_job_ids() to adaptador_worker; +grant execute on function public.adapter_read_queued_job(uuid) to adaptador_worker; +grant execute on function public.adapter_worker_context(uuid, uuid) to adaptador_worker; +grant execute on function public.adapter_list_expired_sources(timestamptz) to adaptador_worker; +grant execute on function public.adapter_mark_source_expired(uuid) to adaptador_worker; +grant execute on function public.adapter_list_processing_jobs() to adaptador_worker; +grant execute on function public.adapter_mark_job_interrupted(uuid) to adaptador_worker; diff --git a/migrations/down/0002_spreadsheet_adapter_down.sql b/migrations/down/0002_spreadsheet_adapter_down.sql index d566353..b4bb613 100644 --- a/migrations/down/0002_spreadsheet_adapter_down.sql +++ b/migrations/down/0002_spreadsheet_adapter_down.sql @@ -2,6 +2,14 @@ -- Não remove profiles, workspaces, members, flags ou audit_log do Elo Pro. -- Funções de autorização usadas também pelo audit_log permanecem. +drop function if exists public.adapter_mark_job_interrupted(uuid); +drop function if exists public.adapter_list_processing_jobs(); +drop function if exists public.adapter_mark_source_expired(uuid); +drop function if exists public.adapter_list_expired_sources(timestamptz); +drop function if exists public.adapter_worker_context(uuid, uuid); +drop function if exists public.adapter_read_queued_job(uuid); +drop function if exists public.adapter_list_queued_job_ids(); + drop table if exists public.spreadsheet_job_mappings; drop table if exists public.spreadsheet_job_sources; drop table if exists public.spreadsheet_jobs; diff --git a/scripts/_bootstrap.ts b/scripts/_bootstrap.ts index 04c5880..39fd89b 100644 --- a/scripts/_bootstrap.ts +++ b/scripts/_bootstrap.ts @@ -1,8 +1,10 @@ import path from "node:path"; -import { adapterEnv, identityShimEnabled, isDeployedEnv, requireDatabaseUrl } from "../src/config/env"; +import { adapterEnv, identityShimEnabled, isDeployedEnv } from "../src/config/env"; import { exitOnInvalidConfig } from "../src/config/fail-fast"; +import { buildPostgresOptions, databaseUrlFor, scrubDbError, type DbPurpose } from "../src/infra/db/connection-policy"; import { createFileDb } from "../src/infra/db/pglite"; import { createPostgresDb } from "../src/infra/db/postgres"; +import { assertDatabaseGuard } from "../src/infra/db/target-guard"; import type { Db } from "../src/infra/db/types"; /** @@ -10,11 +12,20 @@ import type { Db } from "../src/infra/db/types"; * obrigatória: cair em PGlite com um segredo ausente faria uma migração * silenciosa num arquivo local parecer sucesso. */ -export async function openOperationalDb(): Promise { - const databaseUrl = requireDatabaseUrl(); - if (databaseUrl) return createPostgresDb(databaseUrl); +export async function openOperationalDb(purpose: DbPurpose): Promise { + const databaseUrl = databaseUrlFor(purpose); + if (databaseUrl) { + const db = createPostgresDb(databaseUrl, buildPostgresOptions(databaseUrl, purpose)); + try { + await assertDatabaseGuard(db, purpose, databaseUrl); + return db; + } catch (error) { + await db.close().catch(() => undefined); + throw error; + } + } if (isDeployedEnv()) { - throw new Error("DATABASE_URL é obrigatória fora de development."); + throw new Error("DATABASE_URL é obrigatória fora do PGlite de development."); } const dataDir = path.join(process.cwd(), ".data", "pglite"); const { mkdir } = await import("node:fs/promises"); @@ -47,17 +58,16 @@ export function runScript(name: string, body: () => Promise): void { process.exitCode ??= 0; }, (error: unknown) => { - const label = error instanceof Error ? `${error.name}: ${error.message}` : "erro"; - console.error(`[${name}] ${label}`); + console.error(`[${name}] ${scrubDbError(error)}`); process.exit(1); }, ); } /** Abre o banco, executa e fecha, mesmo em caso de falha. */ -export function runWithDb(name: string, body: (db: Db) => Promise): void { +export function runWithDb(name: string, purpose: DbPurpose, body: (db: Db) => Promise): void { runScript(name, async () => { - const db = await openOperationalDb(); + const db = await openOperationalDb(purpose); try { await body(db); } finally { diff --git a/scripts/cleanup-temp.ts b/scripts/cleanup-temp.ts index 28769d0..813c524 100644 --- a/scripts/cleanup-temp.ts +++ b/scripts/cleanup-temp.ts @@ -5,7 +5,7 @@ import { bootstrapScript, runWithDb } from "./_bootstrap"; bootstrapScript("cleanup-temp"); -runWithDb("cleanup-temp", async (db) => { +runWithDb("cleanup-temp", "worker", async (db) => { const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: createCoordinator() }); console.log(`temporários expirados: ${removed}`); }); diff --git a/scripts/migrate.ts b/scripts/migrate.ts index 3f28c7a..59d266a 100644 --- a/scripts/migrate.ts +++ b/scripts/migrate.ts @@ -11,7 +11,7 @@ const dryRun = process.argv.includes("--dry-run"); bootstrapScript("migrate"); -runWithDb("migrate", async (db) => { +runWithDb("migrate", "admin", async (db) => { const pending = await pendingMigrations(db); console.log(`alvo: ${describeTarget()}`); console.log(`shim de identidade: ${identityShimEnabled() ? "SERÁ aplicado" : "NÃO será aplicado"}`); diff --git a/scripts/seed-dev.ts b/scripts/seed-dev.ts index f7d869e..4705c55 100644 --- a/scripts/seed-dev.ts +++ b/scripts/seed-dev.ts @@ -9,7 +9,7 @@ if (adapterEnv() !== "development") { process.exit(1); } -runWithDb("seed-dev", async (db) => { +runWithDb("seed-dev", "local", async (db) => { await migrate(db); await ensureDevFixtures(db); console.log("fixtures de desenvolvimento conferidas"); diff --git a/scripts/seed-staging.ts b/scripts/seed-staging.ts index 6f98e18..cb0b027 100644 --- a/scripts/seed-staging.ts +++ b/scripts/seed-staging.ts @@ -120,7 +120,7 @@ runScript("seed-staging", async () => { seed.members ??= []; assertSeed(seed); - const db = await openOperationalDb(); + const db = await openOperationalDb("admin"); try { const ran = await migrate(db); if (ran.length > 0) console.log(`migrations aplicadas antes do seed: ${ran.join(", ")}`); diff --git a/scripts/set-flag.ts b/scripts/set-flag.ts index e6733a9..f34a57f 100644 --- a/scripts/set-flag.ts +++ b/scripts/set-flag.ts @@ -10,7 +10,7 @@ const enabled = action === "on"; bootstrapScript("set-flag"); -runWithDb("set-flag", async (db) => { +runWithDb("set-flag", "admin", async (db) => { const existing = await db.withService((query) => query.query<{ name: string }>(`select name from public.workspaces where id = $1`, [workspaceId]), ); diff --git a/scripts/worker.ts b/scripts/worker.ts index e0b2c7b..5a6a8b2 100644 --- a/scripts/worker.ts +++ b/scripts/worker.ts @@ -27,7 +27,7 @@ if (jobRunsInline()) { } runScript("worker", async () => { - const db = await openOperationalDb(); + const db = await openOperationalDb("worker"); // O worker não aplica migrations: isso é passo explícito e aprovado do deploy. const pending = await pendingMigrations(db); diff --git a/src/config/fail-fast.ts b/src/config/fail-fast.ts index b66ddf7..6e196bf 100644 --- a/src/config/fail-fast.ts +++ b/src/config/fail-fast.ts @@ -1,4 +1,5 @@ -import { assertDeployedEnv, type EnvMap } from "./env"; +import { buildPostgresOptions } from "@/infra/db/connection-policy"; +import { assertDeployedEnv, isDeployedEnv, type EnvMap } from "./env"; /** * Encerra o processo com código diferente de zero quando a configuração é @@ -11,6 +12,9 @@ import { assertDeployedEnv, type EnvMap } from "./env"; export function exitOnInvalidConfig(env: EnvMap = process.env): void { try { assertDeployedEnv(env); + if (isDeployedEnv(env) && env.DATABASE_URL?.trim()) { + buildPostgresOptions(env.DATABASE_URL, "web", env); + } } catch (error) { const message = error instanceof Error ? error.message : "Configuração recusada."; console.error(`[adaptador-planilhas] ${message}`); diff --git a/src/infra/db/client.ts b/src/infra/db/client.ts index df85f5f..3005a0d 100644 --- a/src/infra/db/client.ts +++ b/src/infra/db/client.ts @@ -2,8 +2,10 @@ import "server-only"; import { mkdir } from "node:fs/promises"; import path from "node:path"; import { isDeployedEnv, requireDatabaseUrl } from "@/config/env"; +import { buildPostgresOptions } from "./connection-policy"; import { createFileDb } from "./pglite"; import { createPostgresDb } from "./postgres"; +import { assertDatabaseGuard } from "./target-guard"; import type { Db } from "./types"; let singleton: Promise | null = null; @@ -13,7 +15,16 @@ export async function getDb(): Promise { singleton = (async () => { // Lança em staging e produção quando DATABASE_URL falta. Nunca cai em PGlite. const databaseUrl = requireDatabaseUrl(); - if (databaseUrl) return createPostgresDb(databaseUrl); + if (databaseUrl) { + const db = createPostgresDb(databaseUrl, buildPostgresOptions(databaseUrl, "web")); + try { + await assertDatabaseGuard(db, "web", databaseUrl); + return db; + } catch (error) { + await db.close().catch(() => undefined); + throw error; + } + } if (isDeployedEnv()) { throw new Error("DATABASE_URL é obrigatória fora de development."); } diff --git a/src/infra/db/connection-policy.ts b/src/infra/db/connection-policy.ts new file mode 100644 index 0000000..e884da5 --- /dev/null +++ b/src/infra/db/connection-policy.ts @@ -0,0 +1,210 @@ +import type { EnvMap } from "@/config/env"; +import { isDeployedEnv } from "@/config/env"; + +/** Projeto autorizado a receber migrations. Não é segredo. */ +export const AUTHORIZED_HML_PROJECT = "adaptador-planilha-hml"; + +/** + * Forma do Session Pooler IPv4 em us-west-2. + * + * O índice `aws-0` ou `aws-1` não se deduz da região. O valor `aws-0` chegou a + * ser usado como constante, mas era uma suposição, não uma leitura do Dashboard + * de `adaptador-planilha-hml`. O Dashboard desse projeto mostra um host que + * começa por `aws-1-us-west-2.pooler.supabase.com`. O host esperado entra em + * `ADAPTER_DB_POOLER_HOST` e precisa caber neste formato. Qualquer outro + * destino, inclusive `*.elopro.app` e o host direto `db.*.supabase.co`, é recusado. + * O hostname sozinho não identifica o projeto. + */ +export const POOLER_HOST_PATTERN = /^aws-[0-9]+-us-west-2\.pooler\.supabase\.com$/; + +export const LOCAL_DATABASE_HOSTS = new Set([ + "localhost", + "127.0.0.1", + "::1", + "postgres", + "db", + "host.docker.internal", +]); + +export type DbPurpose = "web" | "worker" | "admin" | "local"; + +export type ParsedDatabaseUrl = { + username: string; + hostname: string; + port: number; + database: string; + sslmode: string | null; + hasPassword: boolean; + sessionOption: string | null; +}; + +export class DatabaseGuardError extends Error { + readonly code: string; + + constructor(code: string) { + super( + `Banco recusado (${code}). A identidade do destino não foi comprovada. A operação foi interrompida e exige aprovação manual. Nenhum schema foi alterado.`, + ); + this.name = "DatabaseGuardError"; + this.code = code; + } +} + +const PROJECT_REF = /^[a-z]{20}$/; + +export function scrubDbError(error: unknown): string { + const raw = error instanceof Error ? `${error.name}: ${error.message}` : "erro"; + return raw + .replace(/postgres(?:ql)?:\/\/\S+/gi, "postgres://***") + .replace(/\b[\w.+-]+:[^@\s/]+@/g, "***@") + .replace(/password=\S+/gi, "password=***"); +} + +/** Lê a URL sem devolver senha, usuário completo ou connection string. */ +export function parseDatabaseUrl(connectionString: string): ParsedDatabaseUrl { + let url: URL; + try { + url = new URL(connectionString); + } catch { + throw new DatabaseGuardError("url_invalida"); + } + if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") { + throw new DatabaseGuardError("url_invalida"); + } + const database = decodeURIComponent(url.pathname.replace(/^\//, "")); + const port = url.port ? Number(url.port) : 5432; + if (!url.hostname || !database || !Number.isInteger(port)) { + throw new DatabaseGuardError("url_invalida"); + } + return { + username: decodeURIComponent(url.username), + hostname: url.hostname.toLowerCase(), + port, + database, + sslmode: url.searchParams.get("sslmode")?.toLowerCase() ?? null, + hasPassword: url.password.length > 0, + sessionOption: url.searchParams.get("options"), + }; +} + +export function isLocalDatabaseHost(hostname: string): boolean { + return LOCAL_DATABASE_HOSTS.has(hostname.toLowerCase()); +} + +export function assertLocalDatabaseUrl(connectionString: string): void { + const parsed = parseDatabaseUrl(connectionString); + if (parsed.hostname === "supabase.elopro.app" || parsed.hostname.endsWith(".elopro.app")) { + throw new DatabaseGuardError("host_elopro_preservado"); + } + if (parsed.hostname.endsWith(".supabase.com") || parsed.hostname.endsWith(".supabase.co")) { + throw new DatabaseGuardError("development_nao_acessa_supabase"); + } + if (!isLocalDatabaseHost(parsed.hostname)) { + throw new DatabaseGuardError("development_somente_local"); + } +} + +/** Host declarado para a homologação. Precisa ser o pooler de us-west-2, nada além disso. */ +export function expectedPoolerHost(env: EnvMap = process.env): string { + const host = env.ADAPTER_DB_POOLER_HOST?.trim().toLowerCase() ?? ""; + if (!host) throw new DatabaseGuardError("pooler_host_ausente"); + if (!POOLER_HOST_PATTERN.test(host)) throw new DatabaseGuardError("pooler_host_recusado"); + return host; +} + +/** + * Confere a URL antes de conectar. Não autentica o projeto: isso só o servidor + * faz, pela leitura do catálogo. Rejeita TLS fraco e parâmetro de sessão. + */ +export function assertDeployedUrl(connectionString: string, purpose: DbPurpose, env: EnvMap = process.env): ParsedDatabaseUrl { + const parsed = parseDatabaseUrl(connectionString); + if (parsed.hostname === "supabase.elopro.app" || parsed.hostname.endsWith(".elopro.app")) { + throw new DatabaseGuardError("host_elopro_preservado"); + } + if (parsed.sessionOption?.includes("hml_project")) { + throw new DatabaseGuardError("parametro_de_sessao_recusado"); + } + if (parsed.sslmode !== "verify-full") { + throw new DatabaseGuardError("tls_inseguro"); + } + if (!parsed.hasPassword) { + throw new DatabaseGuardError("credencial_ausente"); + } + const expectedHost = expectedPoolerHost(env); + if (parsed.hostname !== expectedHost || parsed.port !== 5432 || parsed.database !== "postgres") { + throw new DatabaseGuardError("destino_incompativel"); + } + const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? ""; + if (!PROJECT_REF.test(ref)) { + throw new DatabaseGuardError("ref_ausente"); + } + const expectedUser = purpose === "admin" ? "postgres" : purpose === "worker" ? "adaptador_worker" : "adaptador_web"; + if (parsed.username !== `${expectedUser}.${ref}`) { + throw new DatabaseGuardError("usuario_url_diverge"); + } + return parsed; +} + +export function resolvePoolMax(purpose: DbPurpose, env: EnvMap = process.env): number { + const deployed = isDeployedEnv(env); + const defaults: Record = deployed + ? { web: 2, worker: 2, admin: 1, local: 1 } + : { web: 8, worker: 8, admin: 2, local: 2 }; + const cap = deployed ? 4 : 8; + const raw = env.ADAPTER_DB_POOL_MAX?.trim(); + const value = raw ? Number(raw) : defaults[purpose]; + if (!Number.isInteger(value) || value < 1 || value > cap) { + throw new DatabaseGuardError("pool_recusado"); + } + return value; +} + +export type PostgresClientOptions = { + max: number; + prepare: false; + connect_timeout: number; + idle_timeout: number; + max_lifetime: number; + ssl?: { rejectUnauthorized: true }; + connection?: { family: 4 }; +}; + +/** Opções do driver. Em ambiente implantado o certificado é verificado sempre. */ +export function buildPostgresOptions(connectionString: string, purpose: DbPurpose, env: EnvMap = process.env): PostgresClientOptions { + const deployed = isDeployedEnv(env); + if (deployed) assertDeployedUrl(connectionString, purpose, env); + else if (connectionString) assertLocalDatabaseUrl(connectionString); + const options: PostgresClientOptions = { + max: resolvePoolMax(purpose, env), + prepare: false, + connect_timeout: 10, + idle_timeout: 20, + max_lifetime: 60 * 30, + }; + if (deployed) { + options.ssl = { rejectUnauthorized: true }; + options.connection = { family: 4 }; + } + return options; +} + +export function databaseUrlFor(purpose: DbPurpose, env: EnvMap = process.env): string | null { + if (purpose === "local") { + if (isDeployedEnv(env)) throw new DatabaseGuardError("seed_dev_fora_de_development"); + return env.DATABASE_URL?.trim() || null; + } + if (!isDeployedEnv(env)) return env.DATABASE_URL?.trim() || null; + if (purpose === "admin") { + const url = env.ADAPTER_MIGRATION_DATABASE_URL?.trim(); + if (!url) throw new DatabaseGuardError("migration_url_ausente"); + return url; + } + if (purpose === "worker") { + const url = env.ADAPTER_WORKER_DATABASE_URL?.trim(); + if (!url) throw new DatabaseGuardError("worker_url_ausente"); + return url; + } + const url = env.DATABASE_URL?.trim(); + if (!url) throw new DatabaseGuardError("web_url_ausente"); + return url; +} diff --git a/src/infra/db/migration-manifest.ts b/src/infra/db/migration-manifest.ts index 1886e06..ed78a67 100644 --- a/src/infra/db/migration-manifest.ts +++ b/src/infra/db/migration-manifest.ts @@ -17,6 +17,7 @@ export const EXPECTED_MIGRATIONS = [ "0002_spreadsheet_adapter.sql", "0003_rls.sql", "0004_audit_append_only.sql", + "0005_maintenance_roles.sql", ] as const; /** Migrations que criam o shim local de identidade do Elo Pro. */ diff --git a/src/infra/db/postgres.ts b/src/infra/db/postgres.ts index a12e283..7e169ed 100644 --- a/src/infra/db/postgres.ts +++ b/src/infra/db/postgres.ts @@ -1,8 +1,9 @@ import postgres from "postgres"; +import type { PostgresClientOptions } from "./connection-policy"; import { assertUuid, type Db, type Query, type Row } from "./types"; -export function createPostgresDb(connectionString: string): Db { - const sql = postgres(connectionString, { max: 8, prepare: false }); +export function createPostgresDb(connectionString: string, options?: PostgresClientOptions): Db { + const sql = postgres(connectionString, options ?? { max: 8, prepare: false }); const serviceQuery: Query = { async query(text: string, params: unknown[] = []) { return sql.unsafe(text, params as never[]) as unknown as T[]; diff --git a/src/infra/db/target-guard.ts b/src/infra/db/target-guard.ts new file mode 100644 index 0000000..c543a3b --- /dev/null +++ b/src/infra/db/target-guard.ts @@ -0,0 +1,173 @@ +import type { EnvMap } from "@/config/env"; +import { isDeployedEnv } from "@/config/env"; +import { + AUTHORIZED_HML_PROJECT, + type DbPurpose, + DatabaseGuardError, + assertLocalDatabaseUrl, +} from "./connection-policy"; +import type { Db } from "./types"; + +export type RoleFacts = { + sessionUser: string; + rolsuper: boolean | null; + rolbypassrls: boolean | null; +}; + +export type AdminAttestation = RoleFacts & { + databaseName: string; + hasAuth: boolean; + hasStorage: boolean; + /** Configuração gravada no banco, lida de pg_db_role_setting. Não é SET de sessão. */ + databaseSettings: ReadonlyMap; +}; + +const PROJECT_REF = /^[a-z]{20}$/; + +export function databaseSettings(items: string[]): Map { + const settings = new Map(); + for (const item of items) { + const eq = item.indexOf("="); + if (eq <= 0) continue; + settings.set(item.slice(0, eq), item.slice(eq + 1)); + } + return settings; +} + +/** + * Prova administrativa. O hostname, o nome `postgres`, um GUC de sessão e a + * variável de ambiente, cada um sozinho, não passam. Os fatos do servidor + * precisam concordar: schemas do Supabase, papel autenticado e marcador + * persistido com ALTER DATABASE. + * + * Limitação do marcador: `app.hml_project` e `app.hml_project_ref` não são + * identidade criptográfica do projeto Supabase. São parâmetros gravados no + * catálogo `pg_db_role_setting` (setrole = 0). Um SET de sessão ou `options=-c` + * na URL não escreve nesse catálogo, por isso `current_setting` não é aceito + * como prova. Um superusuário de outro banco pode copiar os mesmos ALTER + * DATABASE. A cópia só passaria se a URL também trouxesse o pooler declarado, + * o usuário `postgres.` e o ref do marcador. Sem o marcador, ou com + * divergência, a operação para. + * + * O pooler pode devolver `session_user` só como `postgres`, sem o sufixo do + * ref. Nesse caso o ref autenticado está no usuário da URL, e o vínculo com + * este projeto está no marcador. Os dois têm de ser o mesmo ref. + */ +export function evaluateAdminIdentity(attestation: AdminAttestation, env: EnvMap = process.env): void { + const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? ""; + if (!PROJECT_REF.test(ref)) throw new DatabaseGuardError("ref_ausente"); + if (attestation.databaseName !== "postgres") throw new DatabaseGuardError("banco_nome"); + if (!attestation.hasAuth || !attestation.hasStorage) throw new DatabaseGuardError("schema_supabase_ausente"); + const session = attestation.sessionUser; + if (session !== "postgres" && session !== `postgres.${ref}`) { + throw new DatabaseGuardError("usuario_admin_nao_comprovado"); + } + if (attestation.rolsuper !== true) throw new DatabaseGuardError("admin_sem_superuser"); + const project = attestation.databaseSettings.get("app.hml_project"); + const markedRef = attestation.databaseSettings.get("app.hml_project_ref"); + if (!project || !markedRef) throw new DatabaseGuardError("marcador_ausente"); + if (project !== AUTHORIZED_HML_PROJECT || markedRef !== ref) { + throw new DatabaseGuardError("marcador_diverge"); + } +} + +export function evaluateRuntimeRole(facts: RoleFacts, purpose: "web" | "worker", env: EnvMap = process.env): void { + if (facts.rolsuper === null || facts.rolbypassrls === null) { + throw new DatabaseGuardError("papel_nao_comprovado"); + } + if (facts.rolsuper || facts.rolbypassrls) { + throw new DatabaseGuardError(purpose === "web" ? "web_com_credencial_admin" : "worker_privilegiado"); + } + const expected = purpose === "web" ? "adaptador_web" : "adaptador_worker"; + const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? ""; + const allowed = new Set([expected]); + if (PROJECT_REF.test(ref)) allowed.add(`${expected}.${ref}`); + if (!allowed.has(facts.sessionUser)) throw new DatabaseGuardError("papel_recusado"); +} + +type FactRow = { + session_user: string; + database_name: string; + rolsuper: boolean | null; + rolbypassrls: boolean | null; + has_auth: boolean; + has_storage: boolean; +}; + +async function readRole(db: Db): Promise { + try { + const rows = await db.withService((query) => + query.query( + `select session_user::text as session_user, + current_database()::text as database_name, + r.rolsuper, + r.rolbypassrls, + exists(select 1 from information_schema.schemata where schema_name = 'auth') as has_auth, + exists(select 1 from information_schema.schemata where schema_name = 'storage') as has_storage + from pg_catalog.pg_roles r + where r.rolname = session_user::text`, + ), + ); + const facts = rows[0]; + if (!facts) throw new DatabaseGuardError("papel_nao_comprovado"); + return facts; + } catch (error) { + if (error instanceof DatabaseGuardError) throw error; + throw new DatabaseGuardError("identidade_indisponivel"); + } +} + +/** Marcador gravado no banco. Um SET de sessão não aparece nesta consulta. */ +async function readDatabaseMarker(db: Db): Promise> { + try { + const rows = await db.withService((query) => + query.query<{ item: string }>( + `select item + from pg_catalog.pg_db_role_setting s + cross join lateral unnest(s.setconfig) as item + where s.setdatabase = (select oid from pg_catalog.pg_database where datname = current_database()) + and s.setrole = 0`, + ), + ); + return databaseSettings(rows.map((row) => row.item)); + } catch { + throw new DatabaseGuardError("marcador_indisponivel"); + } +} + +/** + * Barreira de destino. Em development só aceita host local. Em staging e + * produção a web e o worker recusam superuser e BYPASSRLS; migrate, seed e + * flag só seguem com a identidade administrativa comprovada no catálogo. + */ +export async function assertDatabaseGuard(db: Db, purpose: DbPurpose, connectionString: string | null, env: EnvMap = process.env): Promise { + if (!isDeployedEnv(env)) { + if (connectionString) assertLocalDatabaseUrl(connectionString); + return; + } + if (!connectionString) throw new DatabaseGuardError("url_ausente"); + if (purpose === "local") throw new DatabaseGuardError("seed_dev_fora_de_development"); + + const facts = await readRole(db); + if (purpose === "admin") { + const settings = await readDatabaseMarker(db); + evaluateAdminIdentity( + { + sessionUser: facts.session_user, + databaseName: facts.database_name, + rolsuper: facts.rolsuper, + rolbypassrls: facts.rolbypassrls, + hasAuth: facts.has_auth, + hasStorage: facts.has_storage, + databaseSettings: settings, + }, + env, + ); + return; + } + evaluateRuntimeRole( + { sessionUser: facts.session_user, rolsuper: facts.rolsuper, rolbypassrls: facts.rolbypassrls }, + purpose, + env, + ); +} diff --git a/src/server/spreadsheet-adapter/process-job.ts b/src/server/spreadsheet-adapter/process-job.ts index cf2b106..a3bfdb7 100644 --- a/src/server/spreadsheet-adapter/process-job.ts +++ b/src/server/spreadsheet-adapter/process-job.ts @@ -297,10 +297,8 @@ export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId: export async function cleanupExpired(deps: AppDeps): Promise { const rows = await deps.db.withService((query) => query.query<{ id: string; workspace_id: string; temp_storage_key: string; output_csv_key: string | null; output_xlsx_key: string | null }>( - `select s.id, j.workspace_id, s.temp_storage_key, s.output_csv_key, s.output_xlsx_key - from public.spreadsheet_job_sources s - join public.spreadsheet_jobs j on j.id = s.job_id - where s.expires_at < $1::timestamptz and s.status <> 'expired'`, + `select id, workspace_id, temp_storage_key, output_csv_key, output_xlsx_key + from public.adapter_list_expired_sources($1::timestamptz)`, [(deps.now ?? (() => new Date()))().toISOString()], ), ); @@ -313,9 +311,7 @@ export async function cleanupExpired(deps: AppDeps): Promise { deps.capture?.(error, { module: "spreadsheet-adapter", workspace_id: row.workspace_id, error_code: "CLEANUP_FAILED" }); }); } - await deps.db.withService((query) => - query.query(`update public.spreadsheet_job_sources set status = 'expired', output_csv_key = null, output_xlsx_key = null where id = $1`, [row.id]), - ); + await deps.db.withService((query) => query.query(`select public.adapter_mark_source_expired($1::uuid)`, [row.id])); removed += 1; } return removed; @@ -324,19 +320,14 @@ export async function cleanupExpired(deps: AppDeps): Promise { export async function recoverStaleJobs(deps: AppDeps): Promise { const rows = await deps.db.withService((query) => query.query<{ id: string; user_id: string; workspace_id: string }>( - `select id, user_id, workspace_id from public.spreadsheet_jobs where status = 'processing'`, + `select id, user_id, workspace_id from public.adapter_list_processing_jobs()`, ), ); let recovered = 0; for (const row of rows) { const owns = await deps.coordinator.ownsLock(lockKey(row.user_id), row.id).catch(() => false); if (owns) continue; - await deps.db.withService((query) => - query.query( - `update public.spreadsheet_jobs set status = 'failed', error_code = 'INTERRUPTED', updated_at = now() where id = $1 and status = 'processing'`, - [row.id], - ), - ); + await deps.db.withService((query) => query.query(`select public.adapter_mark_job_interrupted($1::uuid)`, [row.id])); recovered += 1; } return recovered; @@ -345,7 +336,7 @@ export async function recoverStaleJobs(deps: AppDeps): Promise { export async function readJobForWorker(deps: AppDeps, jobId: string) { return deps.db.withService((query) => query.query<{ user_id: string; workspace_id: string; progress: unknown }>( - `select user_id, workspace_id, progress from public.spreadsheet_jobs where id = $1 and status = 'queued'`, + `select user_id, workspace_id, progress from public.adapter_read_queued_job($1::uuid)`, [jobId], ), ); @@ -353,7 +344,7 @@ export async function readJobForWorker(deps: AppDeps, jobId: string) { export async function listQueuedJobIds(deps: AppDeps): Promise { const rows = await deps.db.withService((query) => - query.query<{ id: string }>(`select id from public.spreadsheet_jobs where status = 'queued' order by created_at asc limit 5`), + query.query<{ id: string }>(`select id from public.adapter_list_queued_job_ids()`), ); return rows.map((row) => row.id); } diff --git a/src/server/spreadsheet-adapter/worker-loop.ts b/src/server/spreadsheet-adapter/worker-loop.ts index 893ccfb..6de2aa5 100644 --- a/src/server/spreadsheet-adapter/worker-loop.ts +++ b/src/server/spreadsheet-adapter/worker-loop.ts @@ -53,15 +53,8 @@ async function loadContext(deps: AppDeps, userId: string, workspaceId: string): module_enabled: boolean; allow_upload: boolean; }>( - `select p.display_name, p.is_superadmin, w.name as workspace_name, m.role, - coalesce(f.enabled, false) as module_enabled, - coalesce(s.allow_non_admin_model_upload, false) as allow_upload - from public.profiles p - join public.workspace_members m on m.user_id = p.id and m.workspace_id = $2 - join public.workspaces w on w.id = $2 - left join public.workspace_module_flags f on f.workspace_id = w.id and f.module_key = 'spreadsheet_adapter' - left join public.spreadsheet_adapter_settings s on s.workspace_id = w.id - where p.id = $1`, + `select display_name, is_superadmin, role, workspace_name, module_enabled, allow_upload + from public.adapter_worker_context($1, $2)`, [userId, workspaceId], ), ); diff --git a/tests/integration/maintenance-roles.test.ts b/tests/integration/maintenance-roles.test.ts new file mode 100644 index 0000000..ee4cd9a --- /dev/null +++ b/tests/integration/maintenance-roles.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { createReadyDb } from "../helpers/world"; + +describe("papéis de manutenção", () => { + it("cria web e worker sem superuser, sem bypass e sem herança", async () => { + const db = await createReadyDb(); + const rows = await db.withService((query) => + query.query<{ rolname: string; rolsuper: boolean; rolbypassrls: boolean; rolinherit: boolean; rolcanlogin: boolean }>( + `select rolname, rolsuper, rolbypassrls, rolinherit, rolcanlogin + from pg_roles + where rolname in ('adaptador_web', 'adaptador_worker', 'app_user') + order by rolname`, + ), + ); + expect(rows.map((row) => row.rolname)).toEqual(["adaptador_web", "adaptador_worker", "app_user"]); + for (const row of rows) { + expect(row.rolsuper, row.rolname).toBe(false); + expect(row.rolbypassrls, row.rolname).toBe(false); + expect(row.rolcanlogin, row.rolname).toBe(false); + } + expect(rows.find((row) => row.rolname === "adaptador_web")?.rolinherit).toBe(false); + expect(rows.find((row) => row.rolname === "adaptador_worker")?.rolinherit).toBe(false); + await db.close(); + }); + + it("impede a web e o app_user de varrer a fila, e deixa o worker só nas funções", async () => { + const db = await createReadyDb(); + const denied = [ + `begin; set local role adaptador_web; select * from public.spreadsheet_jobs; commit;`, + `begin; set local role app_user; select * from public.adapter_list_queued_job_ids(); commit;`, + `begin; set local role adaptador_web; select * from public.adapter_worker_context('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', '11111111-1111-4111-8111-111111111111'); commit;`, + ]; + for (const statement of denied) { + await expect(db.exec(statement)).rejects.toThrow(/permission denied/i); + await db.exec("rollback"); + } + await expect( + db.exec(`begin; set local role adaptador_worker; select * from public.adapter_list_queued_job_ids(); commit;`), + ).resolves.toBeUndefined(); + const signature = await db.withService((query) => + query.query<{ result: string; prosecdef: boolean; proconfig: string[] | null }>( + `select pg_get_function_result(p.oid) as result, p.prosecdef, p.proconfig + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' and p.proname = 'adapter_read_queued_job'`, + ), + ); + expect(signature[0]?.prosecdef).toBe(true); + expect(signature[0]?.proconfig?.join(" ")).toContain("search_path=pg_catalog"); + expect(signature[0]?.result).not.toMatch(/content|cells|columns_schema|storage_key/i); + await db.close(); + }); + + it("mantém SET LOCAL ROLE dentro da transação do usuário", async () => { + const db = await createReadyDb(); + const rows = await db.withUser("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", (query) => + query.query<{ current_user: string }>(`select current_user`), + ); + expect(rows[0]?.current_user).toBe("app_user"); + await db.close(); + }); +}); diff --git a/tests/services/postgres.test.ts b/tests/services/postgres.test.ts index 1994f04..f17dde2 100644 --- a/tests/services/postgres.test.ts +++ b/tests/services/postgres.test.ts @@ -138,6 +138,29 @@ describe.skipIf(!enabled)("PostgreSQL real", () => { expect(rows.every((row) => row.result === "success")).toBe(true); }); + it("mantém web e worker sem bypass e sem varredura direta", async () => { + const roles = await db.withService((query) => + query.query<{ rolname: string; rolsuper: boolean; rolbypassrls: boolean }>( + `select rolname, rolsuper, rolbypassrls from pg_roles + where rolname in ('adaptador_web', 'adaptador_worker', 'app_user')`, + ), + ); + expect(roles).toHaveLength(3); + for (const role of roles) { + expect(role.rolsuper).toBe(false); + expect(role.rolbypassrls).toBe(false); + } + await expect( + db.exec(`do $$ begin set local role adaptador_web; perform 1 from public.spreadsheet_jobs; end $$;`), + ).rejects.toMatchObject({ code: "42501" }); + await expect( + db.exec(`do $$ begin set local role app_user; perform * from public.adapter_list_queued_job_ids(); end $$;`), + ).rejects.toMatchObject({ code: "42501" }); + await db.exec(`do $$ begin set local role adaptador_worker; perform * from public.adapter_list_queued_job_ids(); end $$;`); + const queued = await db.withUser(ANA, (query) => query.query<{ current_user: string }>(`select current_user`)); + expect(queued[0]?.current_user).toBe("app_user"); + }); + it("pula o shim quando a decisão é não aplicar", async () => { const pending = await pendingMigrations(db, { applyIdentityShim: false }); expect(pending).not.toContain("0001_platform_shim.sql"); diff --git a/tests/unit/database-guard.test.ts b/tests/unit/database-guard.test.ts new file mode 100644 index 0000000..e78ff88 --- /dev/null +++ b/tests/unit/database-guard.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vitest"; +import { + AUTHORIZED_HML_PROJECT, + POOLER_HOST_PATTERN, + DatabaseGuardError, + assertDeployedUrl, + assertLocalDatabaseUrl, + expectedPoolerHost, + resolvePoolMax, + scrubDbError, +} from "@/infra/db/connection-policy"; +import { evaluateAdminIdentity, evaluateRuntimeRole, type AdminAttestation } from "@/infra/db/target-guard"; + +const REF = "abcdefghijabcdefghij"; +const DASHBOARD_HOST = "aws-1-us-west-2.pooler.supabase.com"; + +function adminUrl(user = `postgres.${REF}`, sslmode = "verify-full", host = DASHBOARD_HOST): string { + return `postgres://${user}:segredo@${host}:5432/postgres?sslmode=${sslmode}`; +} + +function attestation(overrides: Partial = {}): AdminAttestation { + return { + sessionUser: "postgres", + databaseName: "postgres", + rolsuper: true, + rolbypassrls: true, + hasAuth: true, + hasStorage: true, + databaseSettings: new Map([ + ["app.hml_project", AUTHORIZED_HML_PROJECT], + ["app.hml_project_ref", REF], + ]), + ...overrides, + }; +} + +const env = { ADAPTER_ENV: "staging", ADAPTER_DB_PROJECT_REF: REF, ADAPTER_DB_POOLER_HOST: DASHBOARD_HOST }; + +describe("barreira do banco de homologação", () => { + it("aceita o destino quando o catálogo, o papel e a URL concordam", () => { + expect(assertDeployedUrl(adminUrl(), "admin", env).hostname).toBe(DASHBOARD_HOST); + expect(() => evaluateAdminIdentity(attestation(), env)).not.toThrow(); + }); + + it("exige o host do Dashboard e recusa índice, região ou domínio diferentes", () => { + expect(POOLER_HOST_PATTERN.test(DASHBOARD_HOST)).toBe(true); + expect(expectedPoolerHost(env)).toBe(DASHBOARD_HOST); + expect(() => expectedPoolerHost({ ADAPTER_DB_POOLER_HOST: "aws-0-us-west-2.pooler.supabase.com" })).not.toThrow(); + expect(() => assertDeployedUrl(adminUrl(`postgres.${REF}`, "verify-full", "aws-0-us-west-2.pooler.supabase.com"), "admin", env)).toThrow( + /destino_incompativel/, + ); + expect(() => expectedPoolerHost({})).toThrow(/pooler_host_ausente/); + expect(() => expectedPoolerHost({ ADAPTER_DB_POOLER_HOST: "aws-1-eu-central-1.pooler.supabase.com" })).toThrow( + /pooler_host_recusado/, + ); + expect(() => expectedPoolerHost({ ADAPTER_DB_POOLER_HOST: "supabase.elopro.app" })).toThrow(/pooler_host_recusado/); + expect(() => expectedPoolerHost({ ADAPTER_DB_POOLER_HOST: "db.abcdefghijabcdefghij.supabase.co" })).toThrow( + /pooler_host_recusado/, + ); + }); + + it("recusa identidade ausente, marcador ausente e marcador divergente", () => { + expect(() => evaluateAdminIdentity(attestation(), {})).toThrow(DatabaseGuardError); + expect(() => evaluateAdminIdentity(attestation({ databaseSettings: new Map() }), env)).toThrow(/marcador_ausente/); + expect(() => + evaluateAdminIdentity( + attestation({ + databaseSettings: new Map([ + ["app.hml_project", AUTHORIZED_HML_PROJECT], + ["app.hml_project_ref", "zzzzzzzzzzzzzzzzzzzz"], + ]), + }), + env, + ), + ).toThrow(/marcador_diverge/); + }); + + it("não aceita só o nome postgres, o host ou um usuário sem o ref", () => { + expect(() => evaluateAdminIdentity(attestation({ sessionUser: "postgres", hasAuth: false }), env)).toThrow( + /schema_supabase_ausente/, + ); + expect(() => assertDeployedUrl(adminUrl("postgres"), "admin", env)).toThrow(/usuario_url_diverge/); + expect(() => + assertDeployedUrl( + `postgres://postgres.${REF}:segredo@supabase.elopro.app:5432/postgres?sslmode=verify-full`, + "admin", + env, + ), + ).toThrow(/host_elopro_preservado/); + expect(() => + assertDeployedUrl(`postgres://postgres.${REF}:segredo@db.exemplo.supabase.co:5432/postgres?sslmode=verify-full`, "admin", env), + ).toThrow(/destino_incompativel/); + }); + + it("recusa TLS fraco, parâmetro de sessão e pool acima do teto", () => { + expect(() => assertDeployedUrl(adminUrl(`postgres.${REF}`, "require"), "admin", env)).toThrow(/tls_inseguro/); + expect(() => assertDeployedUrl(adminUrl(`postgres.${REF}`, "disable"), "admin", env)).toThrow(/tls_inseguro/); + expect(() => + assertDeployedUrl(`${adminUrl()}&options=-c%20app.hml_project%3Dadaptador-planilha-hml`, "admin", env), + ).toThrow(/parametro_de_sessao_recusado/); + expect(() => resolvePoolMax("web", { ...env, ADAPTER_DB_POOL_MAX: "16" })).toThrow(/pool_recusado/); + expect(resolvePoolMax("web", env)).toBe(2); + expect(resolvePoolMax("worker", env)).toBe(2); + expect(resolvePoolMax("admin", env)).toBe(1); + }); + + it("recusa a credencial administrativa na web e BYPASSRLS no worker", () => { + expect(() => evaluateRuntimeRole({ sessionUser: "postgres", rolsuper: true, rolbypassrls: true }, "web", env)).toThrow( + /web_com_credencial_admin/, + ); + expect(() => + evaluateRuntimeRole({ sessionUser: `postgres.${REF}`, rolsuper: true, rolbypassrls: false }, "web", env), + ).toThrow(/web_com_credencial_admin/); + expect(() => + evaluateRuntimeRole({ sessionUser: "adaptador_worker", rolsuper: false, rolbypassrls: true }, "worker", env), + ).toThrow(/worker_privilegiado/); + expect(() => + evaluateRuntimeRole({ sessionUser: "adaptador_web", rolsuper: false, rolbypassrls: false }, "web", env), + ).not.toThrow(); + expect(() => + evaluateRuntimeRole({ sessionUser: `adaptador_worker.${REF}`, rolsuper: false, rolbypassrls: false }, "worker", env), + ).not.toThrow(); + expect(() => evaluateRuntimeRole({ sessionUser: "adaptador_web", rolsuper: null, rolbypassrls: false }, "web", env)).toThrow( + /papel_nao_comprovado/, + ); + }); + + it("separa o development local do Supabase", () => { + expect(() => assertLocalDatabaseUrl("postgres://adaptador:local@postgres:5432/adaptador")).not.toThrow(); + expect(() => assertLocalDatabaseUrl("postgres://adaptador:local@127.0.0.1:55432/adaptador")).not.toThrow(); + expect(() => assertLocalDatabaseUrl(adminUrl())).toThrow(/development_nao_acessa_supabase/); + expect(() => assertLocalDatabaseUrl("postgres://postgres:local@supabase.elopro.app:5432/postgres")).toThrow( + /host_elopro_preservado/, + ); + }); + + it("não deixa senha nem URL no erro visível", () => { + const scrubbed = scrubDbError(new Error("falha em postgres://postgres.abc:segredo@aws-0-us-west-2.pooler.supabase.com:5432/postgres")); + expect(scrubbed).not.toContain("segredo"); + expect(scrubbed).not.toContain("postgres://postgres"); + expect(scrubbed).toContain("postgres://***"); + }); +});