fix(db): protege conexoes e migrations de homologacao
This commit is contained in:
1 parent
4779d6b2a3
commit
8c22597420
21 files changed
+839
-45
No files matched your search
+15
-1
@@ -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-<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:
|
||||
# 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,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.
|
||||
-- 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;
|
||||
|
||||
+19
-9
@@ -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<Db> {
|
||||
const databaseUrl = requireDatabaseUrl();
|
||||
if (databaseUrl) return createPostgresDb(databaseUrl);
|
||||
export async function openOperationalDb(purpose: DbPurpose): Promise<Db> {
|
||||
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>): 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>): void {
|
||||
export function runWithDb(name: string, purpose: DbPurpose, body: (db: Db) => Promise<void>): void {
|
||||
runScript(name, async () => {
|
||||
const db = await openOperationalDb();
|
||||
const db = await openOperationalDb(purpose);
|
||||
try {
|
||||
await body(db);
|
||||
} finally {
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
+1
-1
@@ -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"}`);
|
||||
|
||||
+1
-1
@@ -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");
|
||||
|
||||
@@ -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(", ")}`);
|
||||
|
||||
+1
-1
@@ -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]),
|
||||
);
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
+12
-1
@@ -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<Db> | null = null;
|
||||
@@ -13,7 +15,16 @@ export async function getDb(): Promise<Db> {
|
||||
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.");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<T = Row>(text: string, params: unknown[] = []) {
|
||||
return sql.unsafe(text, params as never[]) as unknown as T[];
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -297,10 +297,8 @@ export async function downloadOutput(deps: AppDeps, ctx: RequestContext, jobId:
|
||||
export async function cleanupExpired(deps: AppDeps): Promise<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
export async function recoverStaleJobs(deps: AppDeps): Promise<number> {
|
||||
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<number> {
|
||||
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<string[]> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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://***");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user