fix(db): protege conexoes e migrations de homologacao
ci / lint, typecheck, testes e build (push) Canceled after 0s
ci / Postgres e Redis reais (push) Canceled after 0s
ci / imagem Docker Linux (push) Canceled after 0s

This commit is contained in:
Ferraz Fagundes committed 2026-10-19 16:32:03 -03:00
1 parent 4779d6b2a3
commit 8c22597420
21 files changed
+839 -45

No files matched your search

+15 -1
View File
@@ -31,8 +31,22 @@ ELO_PRO_APP_URL=https://hml-ada.elopro.app
AUTH_ALLOWED_RETURN_HOSTS=hml-ada.elopro.app AUTH_ALLOWED_RETURN_HOSTS=hml-ada.elopro.app
# --- Banco ---------------------------------------------------------------- # --- 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= 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-<digitos>-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: # Decisão explícita e obrigatória:
# 1 = criar o shim local de identidade (profiles, workspaces, workspace_members) # 1 = criar o shim local de identidade (profiles, workspaces, workspace_members)
# 0 = o schema do Elo Pro já existe nesse banco; não recriar # 0 = o schema do Elo Pro já existe nesse banco; não recriar
+150
View File
@@ -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;
@@ -2,6 +2,14 @@
-- Não remove profiles, workspaces, members, flags ou audit_log do Elo Pro. -- 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. -- 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_mappings;
drop table if exists public.spreadsheet_job_sources; drop table if exists public.spreadsheet_job_sources;
drop table if exists public.spreadsheet_jobs; drop table if exists public.spreadsheet_jobs;
+19 -9
View File
@@ -1,8 +1,10 @@
import path from "node:path"; 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 { 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 { createFileDb } from "../src/infra/db/pglite";
import { createPostgresDb } from "../src/infra/db/postgres"; import { createPostgresDb } from "../src/infra/db/postgres";
import { assertDatabaseGuard } from "../src/infra/db/target-guard";
import type { Db } from "../src/infra/db/types"; 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 * obrigatória: cair em PGlite com um segredo ausente faria uma migração
* silenciosa num arquivo local parecer sucesso. * silenciosa num arquivo local parecer sucesso.
*/ */
export async function openOperationalDb(): Promise<Db> { export async function openOperationalDb(purpose: DbPurpose): Promise<Db> {
const databaseUrl = requireDatabaseUrl(); const databaseUrl = databaseUrlFor(purpose);
if (databaseUrl) return createPostgresDb(databaseUrl); 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()) { 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 dataDir = path.join(process.cwd(), ".data", "pglite");
const { mkdir } = await import("node:fs/promises"); const { mkdir } = await import("node:fs/promises");
@@ -47,17 +58,16 @@ export function runScript(name: string, body: () => Promise<void>): void {
process.exitCode ??= 0; process.exitCode ??= 0;
}, },
(error: unknown) => { (error: unknown) => {
const label = error instanceof Error ? `${error.name}: ${error.message}` : "erro"; console.error(`[${name}] ${scrubDbError(error)}`);
console.error(`[${name}] ${label}`);
process.exit(1); process.exit(1);
}, },
); );
} }
/** Abre o banco, executa e fecha, mesmo em caso de falha. */ /** Abre o banco, executa e fecha, mesmo em caso de falha. */
export function runWithDb(name: string, body: (db: Db) => Promise<void>): void { export function runWithDb(name: string, purpose: DbPurpose, body: (db: Db) => Promise<void>): void {
runScript(name, async () => { runScript(name, async () => {
const db = await openOperationalDb(); const db = await openOperationalDb(purpose);
try { try {
await body(db); await body(db);
} finally { } finally {
+1 -1
View File
@@ -5,7 +5,7 @@ import { bootstrapScript, runWithDb } from "./_bootstrap";
bootstrapScript("cleanup-temp"); bootstrapScript("cleanup-temp");
runWithDb("cleanup-temp", async (db) => { runWithDb("cleanup-temp", "worker", async (db) => {
const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: createCoordinator() }); const removed = await cleanupExpired({ db, storage: createStorage(), coordinator: createCoordinator() });
console.log(`temporários expirados: ${removed}`); console.log(`temporários expirados: ${removed}`);
}); });
+1 -1
View File
@@ -11,7 +11,7 @@ const dryRun = process.argv.includes("--dry-run");
bootstrapScript("migrate"); bootstrapScript("migrate");
runWithDb("migrate", async (db) => { runWithDb("migrate", "admin", async (db) => {
const pending = await pendingMigrations(db); const pending = await pendingMigrations(db);
console.log(`alvo: ${describeTarget()}`); console.log(`alvo: ${describeTarget()}`);
console.log(`shim de identidade: ${identityShimEnabled() ? "SERÁ aplicado" : "NÃO será aplicado"}`); console.log(`shim de identidade: ${identityShimEnabled() ? "SERÁ aplicado" : "NÃO será aplicado"}`);
+1 -1
View File
@@ -9,7 +9,7 @@ if (adapterEnv() !== "development") {
process.exit(1); process.exit(1);
} }
runWithDb("seed-dev", async (db) => { runWithDb("seed-dev", "local", async (db) => {
await migrate(db); await migrate(db);
await ensureDevFixtures(db); await ensureDevFixtures(db);
console.log("fixtures de desenvolvimento conferidas"); console.log("fixtures de desenvolvimento conferidas");
+1 -1
View File
@@ -120,7 +120,7 @@ runScript("seed-staging", async () => {
seed.members ??= []; seed.members ??= [];
assertSeed(seed); assertSeed(seed);
const db = await openOperationalDb(); const db = await openOperationalDb("admin");
try { try {
const ran = await migrate(db); const ran = await migrate(db);
if (ran.length > 0) console.log(`migrations aplicadas antes do seed: ${ran.join(", ")}`); if (ran.length > 0) console.log(`migrations aplicadas antes do seed: ${ran.join(", ")}`);
+1 -1
View File
@@ -10,7 +10,7 @@ const enabled = action === "on";
bootstrapScript("set-flag"); bootstrapScript("set-flag");
runWithDb("set-flag", async (db) => { runWithDb("set-flag", "admin", async (db) => {
const existing = await db.withService((query) => const existing = await db.withService((query) =>
query.query<{ name: string }>(`select name from public.workspaces where id = $1`, [workspaceId]), query.query<{ name: string }>(`select name from public.workspaces where id = $1`, [workspaceId]),
); );
+1 -1
View File
@@ -27,7 +27,7 @@ if (jobRunsInline()) {
} }
runScript("worker", async () => { 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. // O worker não aplica migrations: isso é passo explícito e aprovado do deploy.
const pending = await pendingMigrations(db); const pending = await pendingMigrations(db);
+5 -1
View File
@@ -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 é * 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 { export function exitOnInvalidConfig(env: EnvMap = process.env): void {
try { try {
assertDeployedEnv(env); assertDeployedEnv(env);
if (isDeployedEnv(env) && env.DATABASE_URL?.trim()) {
buildPostgresOptions(env.DATABASE_URL, "web", env);
}
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Configuração recusada."; const message = error instanceof Error ? error.message : "Configuração recusada.";
console.error(`[adaptador-planilhas] ${message}`); console.error(`[adaptador-planilhas] ${message}`);
+12 -1
View File
@@ -2,8 +2,10 @@ import "server-only";
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { isDeployedEnv, requireDatabaseUrl } from "@/config/env"; import { isDeployedEnv, requireDatabaseUrl } from "@/config/env";
import { buildPostgresOptions } from "./connection-policy";
import { createFileDb } from "./pglite"; import { createFileDb } from "./pglite";
import { createPostgresDb } from "./postgres"; import { createPostgresDb } from "./postgres";
import { assertDatabaseGuard } from "./target-guard";
import type { Db } from "./types"; import type { Db } from "./types";
let singleton: Promise<Db> | null = null; let singleton: Promise<Db> | null = null;
@@ -13,7 +15,16 @@ export async function getDb(): Promise<Db> {
singleton = (async () => { singleton = (async () => {
// Lança em staging e produção quando DATABASE_URL falta. Nunca cai em PGlite. // Lança em staging e produção quando DATABASE_URL falta. Nunca cai em PGlite.
const databaseUrl = requireDatabaseUrl(); 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()) { if (isDeployedEnv()) {
throw new Error("DATABASE_URL é obrigatória fora de development."); throw new Error("DATABASE_URL é obrigatória fora de development.");
} }
+210
View File
@@ -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<DbPurpose, number> = 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;
}
+1
View File
@@ -17,6 +17,7 @@ export const EXPECTED_MIGRATIONS = [
"0002_spreadsheet_adapter.sql", "0002_spreadsheet_adapter.sql",
"0003_rls.sql", "0003_rls.sql",
"0004_audit_append_only.sql", "0004_audit_append_only.sql",
"0005_maintenance_roles.sql",
] as const; ] as const;
/** Migrations que criam o shim local de identidade do Elo Pro. */ /** Migrations que criam o shim local de identidade do Elo Pro. */
+3 -2
View File
@@ -1,8 +1,9 @@
import postgres from "postgres"; import postgres from "postgres";
import type { PostgresClientOptions } from "./connection-policy";
import { assertUuid, type Db, type Query, type Row } from "./types"; import { assertUuid, type Db, type Query, type Row } from "./types";
export function createPostgresDb(connectionString: string): Db { export function createPostgresDb(connectionString: string, options?: PostgresClientOptions): Db {
const sql = postgres(connectionString, { max: 8, prepare: false }); const sql = postgres(connectionString, options ?? { max: 8, prepare: false });
const serviceQuery: Query = { const serviceQuery: Query = {
async query<T = Row>(text: string, params: unknown[] = []) { async query<T = Row>(text: string, params: unknown[] = []) {
return sql.unsafe(text, params as never[]) as unknown as T[]; return sql.unsafe(text, params as never[]) as unknown as T[];
+173
View File
@@ -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<string, string>;
};
const PROJECT_REF = /^[a-z]{20}$/;
export function databaseSettings(items: string[]): Map<string, string> {
const settings = new Map<string, string>();
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.<ref>` 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<FactRow> {
try {
const rows = await db.withService((query) =>
query.query<FactRow>(
`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<Map<string, string>> {
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<void> {
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,
);
}
+7 -16
View File
@@ -297,10 +297,8 @@ export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId:
export async function cleanupExpired(deps: AppDeps): Promise<number> { export async function cleanupExpired(deps: AppDeps): Promise<number> {
const rows = await deps.db.withService((query) => 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 }>( 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 `select id, workspace_id, temp_storage_key, output_csv_key, output_xlsx_key
from public.spreadsheet_job_sources s from public.adapter_list_expired_sources($1::timestamptz)`,
join public.spreadsheet_jobs j on j.id = s.job_id
where s.expires_at < $1::timestamptz and s.status <> 'expired'`,
[(deps.now ?? (() => new Date()))().toISOString()], [(deps.now ?? (() => new Date()))().toISOString()],
), ),
); );
@@ -313,9 +311,7 @@ export async function cleanupExpired(deps: AppDeps): Promise<number> {
deps.capture?.(error, { module: "spreadsheet-adapter", workspace_id: row.workspace_id, error_code: "CLEANUP_FAILED" }); deps.capture?.(error, { module: "spreadsheet-adapter", workspace_id: row.workspace_id, error_code: "CLEANUP_FAILED" });
}); });
} }
await deps.db.withService((query) => await deps.db.withService((query) => query.query(`select public.adapter_mark_source_expired($1::uuid)`, [row.id]));
query.query(`update public.spreadsheet_job_sources set status = 'expired', output_csv_key = null, output_xlsx_key = null where id = $1`, [row.id]),
);
removed += 1; removed += 1;
} }
return removed; return removed;
@@ -324,19 +320,14 @@ export async function cleanupExpired(deps: AppDeps): Promise<number> {
export async function recoverStaleJobs(deps: AppDeps): Promise<number> { export async function recoverStaleJobs(deps: AppDeps): Promise<number> {
const rows = await deps.db.withService((query) => const rows = await deps.db.withService((query) =>
query.query<{ id: string; user_id: string; workspace_id: string }>( 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; let recovered = 0;
for (const row of rows) { for (const row of rows) {
const owns = await deps.coordinator.ownsLock(lockKey(row.user_id), row.id).catch(() => false); const owns = await deps.coordinator.ownsLock(lockKey(row.user_id), row.id).catch(() => false);
if (owns) continue; if (owns) continue;
await deps.db.withService((query) => await deps.db.withService((query) => query.query(`select public.adapter_mark_job_interrupted($1::uuid)`, [row.id]));
query.query(
`update public.spreadsheet_jobs set status = 'failed', error_code = 'INTERRUPTED', updated_at = now() where id = $1 and status = 'processing'`,
[row.id],
),
);
recovered += 1; recovered += 1;
} }
return recovered; return recovered;
@@ -345,7 +336,7 @@ export async function recoverStaleJobs(deps: AppDeps): Promise<number> {
export async function readJobForWorker(deps: AppDeps, jobId: string) { export async function readJobForWorker(deps: AppDeps, jobId: string) {
return deps.db.withService((query) => return deps.db.withService((query) =>
query.query<{ user_id: string; workspace_id: string; progress: unknown }>( 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], [jobId],
), ),
); );
@@ -353,7 +344,7 @@ export async function readJobForWorker(deps: AppDeps, jobId: string) {
export async function listQueuedJobIds(deps: AppDeps): Promise<string[]> { export async function listQueuedJobIds(deps: AppDeps): Promise<string[]> {
const rows = await deps.db.withService((query) => 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); return rows.map((row) => row.id);
} }
@@ -53,15 +53,8 @@ async function loadContext(deps: AppDeps, userId: string, workspaceId: string):
module_enabled: boolean; module_enabled: boolean;
allow_upload: boolean; allow_upload: boolean;
}>( }>(
`select p.display_name, p.is_superadmin, w.name as workspace_name, m.role, `select display_name, is_superadmin, role, workspace_name, module_enabled, allow_upload
coalesce(f.enabled, false) as module_enabled, from public.adapter_worker_context($1, $2)`,
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`,
[userId, workspaceId], [userId, workspaceId],
), ),
); );
@@ -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();
});
});
+23
View File
@@ -138,6 +138,29 @@ describe.skipIf(!enabled)("PostgreSQL real", () => {
expect(rows.every((row) => row.result === "success")).toBe(true); 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 () => { it("pula o shim quando a decisão é não aplicar", async () => {
const pending = await pendingMigrations(db, { applyIdentityShim: false }); const pending = await pendingMigrations(db, { applyIdentityShim: false });
expect(pending).not.toContain("0001_platform_shim.sql"); expect(pending).not.toContain("0001_platform_shim.sql");
+143
View File
@@ -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> = {}): 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://***");
});
});