From a4f0a2f649e6ce221bd51407e6adc1119bc75142 Mon Sep 17 00:00:00 2001 From: Ferraz Fagundes Date: Mon, 19 Oct 2026 23:39:05 -0300 Subject: [PATCH] fix(db): implementa identidade persistente segura para homologacao --- .env.staging.example | 5 +- package.json | 1 + scripts/init-project-identity.ts | 34 +++ scripts/sql/init-project-identity.sql | 105 +++++++ scripts/sql/revert-project-identity.sql | 113 ++++++++ scripts/sql/verify-project-identity.sql | 39 +++ src/infra/db/connection-policy.ts | 8 +- src/infra/db/project-identity.ts | 318 +++++++++++++++++++++ src/infra/db/target-guard.ts | 169 ++++++----- tests/services/project-identity.test.ts | 361 ++++++++++++++++++++++++ tests/unit/database-guard.test.ts | 99 ++++--- 11 files changed, 1146 insertions(+), 106 deletions(-) create mode 100644 scripts/init-project-identity.ts create mode 100644 scripts/sql/init-project-identity.sql create mode 100644 scripts/sql/revert-project-identity.sql create mode 100644 scripts/sql/verify-project-identity.sql create mode 100644 src/infra/db/project-identity.ts create mode 100644 tests/services/project-identity.test.ts diff --git a/.env.staging.example b/.env.staging.example index 19817f4..96b7e53 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -39,8 +39,11 @@ DATABASE_URL= 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. +# Reference ID do Dashboard: 20 letras ou dígitos minúsculos. Sozinho não autoriza migration. ADAPTER_DB_PROJECT_REF= +# Gravação explícita de adaptador_guard. Vazio por omissão. O valor 1 só no comando identity:init. +# migrate, migrate:check, seed e deploy não criam essa tabela. +ADAPTER_INIT_PROJECT_IDENTITY= # Host do Session Pooler copiado do Dashboard. O índice aws-N não se deduz da região. # O Dashboard deste projeto mostra host iniciado por aws-1-us-west-2.pooler.supabase.com. # Só é aceito aws--us-west-2.pooler.supabase.com. diff --git a/package.json b/package.json index 6d90585..941ce75 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:services": "vitest run --config vitest.services.config.ts", "migrate": "node --import=tsx scripts/migrate.ts", "migrate:check": "node --import=tsx scripts/migrate.ts --dry-run", + "identity:init": "node --import=tsx scripts/init-project-identity.ts", "seed": "node --import=tsx scripts/seed-dev.ts", "seed:staging": "node --import=tsx scripts/seed-staging.ts", "worker": "node --import=tsx scripts/worker.ts", diff --git a/scripts/init-project-identity.ts b/scripts/init-project-identity.ts new file mode 100644 index 0000000..d91f25a --- /dev/null +++ b/scripts/init-project-identity.ts @@ -0,0 +1,34 @@ +import { buildPostgresOptions, databaseUrlFor, DatabaseGuardError } from "../src/infra/db/connection-policy"; +import { createPostgresDb } from "../src/infra/db/postgres"; +import { assertInitAuthorized, installProjectIdentity } from "../src/infra/db/project-identity"; +import { assertAdminTarget } from "../src/infra/db/target-guard"; +import { bootstrapScript, runScript } from "./_bootstrap"; + +/** + * Grava `adaptador_guard.project_identity` uma única vez. + * Exige ADAPTER_INIT_PROJECT_IDENTITY=1 e o destino administrativo já conferido. + * Migrate, seed e deploy não chamam este script. + */ +try { + assertInitAuthorized(process.env); +} catch (error) { + const code = error instanceof DatabaseGuardError ? error.code : "init_recusada"; + console.error(`[init-project-identity] ${code}`); + process.exit(1); +} + +bootstrapScript("init-project-identity"); + +runScript("init-project-identity", async () => { + const databaseUrl = databaseUrlFor("admin"); + if (!databaseUrl) throw new DatabaseGuardError("url_ausente"); + const db = createPostgresDb(databaseUrl, buildPostgresOptions(databaseUrl, "admin")); + try { + await assertAdminTarget(db); + const ref = process.env.ADAPTER_DB_PROJECT_REF?.trim() ?? ""; + const result = await installProjectIdentity(db, ref); + console.log(`identidade: ${result}`); + } finally { + await db.close().catch(() => undefined); + } +}); diff --git a/scripts/sql/init-project-identity.sql b/scripts/sql/init-project-identity.sql new file mode 100644 index 0000000..ee88958 --- /dev/null +++ b/scripts/sql/init-project-identity.sql @@ -0,0 +1,105 @@ +-- Inicialização explícita da identidade de adaptador-planilha-hml. +-- Não faz parte de migrations/. Migrate, seed e deploy não executam este arquivo. +-- Substitua <> pelo Reference ID do Dashboard: 20 letras ou dígitos minúsculos. +-- Rode o bloco inteiro de uma vez. Qualquer falha desfaz o schema inteiro. +-- Não altera app.settings.jwt_exp nem os schemas auth e storage. +-- Se o schema já existir, não use este arquivo. O comando identity:init recusa linha divergente e objeto desconhecido. + +do $identity$ +begin + create schema adaptador_guard; + revoke all on schema adaptador_guard from public; + + create table adaptador_guard.project_identity ( + singleton boolean primary key default true check (singleton), + project_name text not null, + project_ref text not null, + constraint project_identity_name check (project_name = 'adaptador-planilha-hml'), + constraint project_identity_ref check (project_ref ~ '^[a-z0-9]{20}$') + ); + revoke all on table adaptador_guard.project_identity from public; + alter table adaptador_guard.project_identity enable row level security; + alter table adaptador_guard.project_identity force row level security; + comment on table adaptador_guard.project_identity is + 'Identidade persistente do projeto de homologacao. Nao e prova criptografica.'; + + create function adaptador_guard.reject_identity_change() + returns trigger + language plpgsql + security invoker + set search_path = pg_catalog + as $body$ + begin + if tg_op <> 'INSERT' then + raise exception 'identidade imutavel' using errcode = '42501'; + end if; + if session_user <> 'postgres' and session_user !~ '^postgres[.][a-z0-9]{20}$' then + raise exception 'identidade protegida' using errcode = '42501'; + end if; + return new; + end; + $body$; + + create trigger project_identity_guard + before insert or update or delete on adaptador_guard.project_identity + for each row execute function adaptador_guard.reject_identity_change(); + + create function adaptador_guard.adapter_project_identity() + returns table (project_name text, project_ref text) + language sql + stable + security definer + set search_path = pg_catalog + as $body$ + select i.project_name, i.project_ref + from adaptador_guard.project_identity i + $body$; + + revoke all on function adaptador_guard.adapter_project_identity() from public; + revoke all on function adaptador_guard.reject_identity_change() from public; + + insert into adaptador_guard.project_identity (project_name, project_ref) + values ('adaptador-planilha-hml', '<>'); + + revoke all on schema adaptador_guard from public; + revoke all on table adaptador_guard.project_identity from public; + revoke all on all functions in schema adaptador_guard from public; + alter default privileges in schema adaptador_guard revoke all on tables from public; + alter default privileges in schema adaptador_guard revoke execute on functions from public; + + if exists (select 1 from pg_catalog.pg_roles where rolname = 'adaptador_web') then + revoke all on schema adaptador_guard from adaptador_web; + revoke all on table adaptador_guard.project_identity from adaptador_web; + revoke all on all functions in schema adaptador_guard from adaptador_web; + grant usage on schema adaptador_guard to adaptador_web; + grant execute on function adaptador_guard.adapter_project_identity() to adaptador_web; + end if; + if exists (select 1 from pg_catalog.pg_roles where rolname = 'adaptador_worker') then + revoke all on schema adaptador_guard from adaptador_worker; + revoke all on table adaptador_guard.project_identity from adaptador_worker; + revoke all on all functions in schema adaptador_guard from adaptador_worker; + grant usage on schema adaptador_guard to adaptador_worker; + grant execute on function adaptador_guard.adapter_project_identity() to adaptador_worker; + end if; + if exists (select 1 from pg_catalog.pg_roles where rolname = 'anon') then + revoke all on schema adaptador_guard from anon; + revoke all on table adaptador_guard.project_identity from anon; + revoke all on all functions in schema adaptador_guard from anon; + end if; + if exists (select 1 from pg_catalog.pg_roles where rolname = 'authenticated') then + revoke all on schema adaptador_guard from authenticated; + revoke all on table adaptador_guard.project_identity from authenticated; + revoke all on all functions in schema adaptador_guard from authenticated; + end if; + if exists (select 1 from pg_catalog.pg_roles where rolname = 'service_role') then + revoke all on schema adaptador_guard from service_role; + revoke all on table adaptador_guard.project_identity from service_role; + revoke all on all functions in schema adaptador_guard from service_role; + end if; + if exists (select 1 from pg_catalog.pg_roles where rolname = 'app_user') then + revoke all on schema adaptador_guard from app_user; + revoke all on table adaptador_guard.project_identity from app_user; + revoke all on all functions in schema adaptador_guard from app_user; + end if; +end +$identity$; diff --git a/scripts/sql/revert-project-identity.sql b/scripts/sql/revert-project-identity.sql new file mode 100644 index 0000000..8fa92ab --- /dev/null +++ b/scripts/sql/revert-project-identity.sql @@ -0,0 +1,113 @@ +-- Remove somente os objetos conhecidos de adaptador_guard. +-- Recusa se faltar peça, se houver objeto estranho ou se outro schema depender da identidade. +-- Não apaga auth, storage, public nem app.settings.jwt_exp. +-- O bloco e os DROP seguem juntos: se a conferência falhar, nada é removido. + +do $revert$ +begin + if not exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') then + raise exception 'marcador_ausente' using errcode = 'P0002'; + end if; + if exists ( + select kind, name + from ( + select 'missing'::text as kind, 'project_identity'::text as name + where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') + and not exists ( + select 1 + from pg_catalog.pg_class c + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'adaptador_guard' and c.relname = 'project_identity' and c.relkind = 'r' + ) + union all + select 'missing', 'adapter_project_identity' + where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') + and not exists ( + select 1 + from pg_catalog.pg_proc p + join pg_catalog.pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'adaptador_guard' and p.proname = 'adapter_project_identity' + ) + union all + select 'missing', 'reject_identity_change' + where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') + and not exists ( + select 1 + from pg_catalog.pg_proc p + join pg_catalog.pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'adaptador_guard' and p.proname = 'reject_identity_change' + ) + union all + select 'unknown_relation', c.relname + from pg_catalog.pg_class c + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'adaptador_guard' + and not ( + (c.relname = 'project_identity' and c.relkind = 'r') + or ( + c.relkind = 'i' + and exists ( + select 1 + from pg_catalog.pg_index i + join pg_catalog.pg_class t on t.oid = i.indrelid + where i.indexrelid = c.oid + and t.relname = 'project_identity' + and t.relnamespace = n.oid + ) + ) + ) + union all + select 'unknown_function', p.proname + from pg_catalog.pg_proc p + join pg_catalog.pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'adaptador_guard' + and p.proname not in ('adapter_project_identity', 'reject_identity_change') + union all + select 'unknown_type', t.typname + from pg_catalog.pg_type t + join pg_catalog.pg_namespace n on n.oid = t.typnamespace + where n.nspname = 'adaptador_guard' + and not exists ( + select 1 + from pg_catalog.pg_class c + where c.oid = t.typrelid + and c.relname = 'project_identity' + and c.relnamespace = n.oid + ) + and not exists ( + select 1 + from pg_catalog.pg_type elem + join pg_catalog.pg_class c on c.oid = elem.typrelid + where elem.oid = t.typelem + and c.relname = 'project_identity' + and c.relnamespace = n.oid + ) + union all + select 'external', vn.nspname || '.' || v.relname + from pg_catalog.pg_depend d + join pg_catalog.pg_rewrite rw on rw.oid = d.objid + join pg_catalog.pg_class v on v.oid = rw.ev_class + join pg_catalog.pg_namespace vn on vn.oid = v.relnamespace + join pg_catalog.pg_class t on t.oid = d.refobjid + join pg_catalog.pg_namespace tn on tn.oid = t.relnamespace + where tn.nspname = 'adaptador_guard' + and vn.nspname <> 'adaptador_guard' + union all + select 'external', n.nspname || '.' || p.proname + from pg_catalog.pg_depend d + join pg_catalog.pg_proc p on p.oid = d.objid + join pg_catalog.pg_namespace n on n.oid = p.pronamespace + join pg_catalog.pg_class t on t.oid = d.refobjid + join pg_catalog.pg_namespace tn on tn.oid = t.relnamespace + where tn.nspname = 'adaptador_guard' + and n.nspname <> 'adaptador_guard' + ) findings + ) then + raise exception 'reversao_recusada' using errcode = '55000'; + end if; +end +$revert$; +drop function adaptador_guard.adapter_project_identity(); +drop table adaptador_guard.project_identity; +drop function adaptador_guard.reject_identity_change(); +drop schema adaptador_guard restrict; diff --git a/scripts/sql/verify-project-identity.sql b/scripts/sql/verify-project-identity.sql new file mode 100644 index 0000000..f8691f6 --- /dev/null +++ b/scripts/sql/verify-project-identity.sql @@ -0,0 +1,39 @@ +-- Leitura da identidade e dos privilégios. Não grava. + +select project_name, project_ref +from adaptador_guard.adapter_project_identity(); + +select p.proname, p.prosecdef, p.proconfig +from pg_catalog.pg_proc p +join pg_catalog.pg_namespace n on n.oid = p.pronamespace +where n.nspname = 'adaptador_guard' +order by p.proname; + +select c.relname, c.relrowsecurity, c.relforcerowsecurity +from pg_catalog.pg_class c +join pg_catalog.pg_namespace n on n.oid = c.relnamespace +where n.nspname = 'adaptador_guard' + and c.relkind = 'r' +order by c.relname; + +select 'public' as papel, + has_schema_privilege('public', 'adaptador_guard', 'usage') as uso, + has_table_privilege('public', 'adaptador_guard.project_identity', 'select') as leitura, + has_table_privilege('public', 'adaptador_guard.project_identity', 'update') as alteracao, + has_function_privilege('public', 'adaptador_guard.adapter_project_identity()', 'execute') as executar +union all +select r.rolname, + has_schema_privilege(r.oid, 'adaptador_guard', 'usage'), + has_table_privilege(r.oid, 'adaptador_guard.project_identity', 'select'), + has_table_privilege(r.oid, 'adaptador_guard.project_identity', 'update'), + has_function_privilege(r.oid, 'adaptador_guard.adapter_project_identity()', 'execute') +from pg_catalog.pg_roles r +where r.rolname in ('anon', 'authenticated', 'service_role', 'app_user', 'adaptador_web', 'adaptador_worker') +order by papel; + +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 +order by item; diff --git a/src/infra/db/connection-policy.ts b/src/infra/db/connection-policy.ts index e884da5..fa7ec70 100644 --- a/src/infra/db/connection-policy.ts +++ b/src/infra/db/connection-policy.ts @@ -50,7 +50,11 @@ export class DatabaseGuardError extends Error { } } -const PROJECT_REF = /^[a-z]{20}$/; +/** + * Reference ID do Supabase: 20 caracteres minúsculos, letras ou dígitos. + * O valor real sai do Dashboard. Sozinho, não prova qual banco atendeu a sessão. + */ +export const PROJECT_REF_PATTERN = /^[a-z0-9]{20}$/; export function scrubDbError(error: unknown): string { const raw = error instanceof Error ? `${error.name}: ${error.message}` : "erro"; @@ -135,7 +139,7 @@ export function assertDeployedUrl(connectionString: string, purpose: DbPurpose, throw new DatabaseGuardError("destino_incompativel"); } const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? ""; - if (!PROJECT_REF.test(ref)) { + if (!PROJECT_REF_PATTERN.test(ref)) { throw new DatabaseGuardError("ref_ausente"); } const expectedUser = purpose === "admin" ? "postgres" : purpose === "worker" ? "adaptador_worker" : "adaptador_web"; diff --git a/src/infra/db/project-identity.ts b/src/infra/db/project-identity.ts new file mode 100644 index 0000000..6aa3c05 --- /dev/null +++ b/src/infra/db/project-identity.ts @@ -0,0 +1,318 @@ +import { isDeployedEnv, type EnvMap } from "@/config/env"; +import { AUTHORIZED_HML_PROJECT, PROJECT_REF_PATTERN, DatabaseGuardError } from "./connection-policy"; +import type { Db } from "./types"; + +export type IdentityInstallResult = "criada" | "inalterada"; + +/** + * Autorização explícita da gravação. Migrate, migrate:check, seed e deploy + * não definem esta variável e não importam este módulo. + */ +export function assertInitAuthorized(env: EnvMap = process.env): void { + if (env.ADAPTER_INIT_PROJECT_IDENTITY !== "1") throw new DatabaseGuardError("init_nao_autorizada"); + if (!isDeployedEnv(env)) throw new DatabaseGuardError("init_fora_de_homologacao"); +} + +type StoredIdentity = + | { kind: "ausente" } + | { kind: "presente"; projectName: string; projectRef: string } + | { kind: "inconsistente" }; + +/** + * Fecha privilégios operacionais. Web e worker ficam só com USAGE e EXECUTE + * da função de leitura. Os demais papéis ficam sem acesso. + */ +const LOCK_BODY = ` + revoke all on schema adaptador_guard from public; + revoke all on table adaptador_guard.project_identity from public; + revoke all on all functions in schema adaptador_guard from public; + alter default privileges in schema adaptador_guard revoke all on tables from public; + alter default privileges in schema adaptador_guard revoke execute on functions from public; + + if exists (select 1 from pg_catalog.pg_roles where rolname = 'adaptador_web') then + revoke all on schema adaptador_guard from adaptador_web; + revoke all on table adaptador_guard.project_identity from adaptador_web; + revoke all on all functions in schema adaptador_guard from adaptador_web; + grant usage on schema adaptador_guard to adaptador_web; + grant execute on function adaptador_guard.adapter_project_identity() to adaptador_web; + end if; + if exists (select 1 from pg_catalog.pg_roles where rolname = 'adaptador_worker') then + revoke all on schema adaptador_guard from adaptador_worker; + revoke all on table adaptador_guard.project_identity from adaptador_worker; + revoke all on all functions in schema adaptador_guard from adaptador_worker; + grant usage on schema adaptador_guard to adaptador_worker; + grant execute on function adaptador_guard.adapter_project_identity() to adaptador_worker; + end if; + if exists (select 1 from pg_catalog.pg_roles where rolname = 'anon') then + revoke all on schema adaptador_guard from anon; + revoke all on table adaptador_guard.project_identity from anon; + revoke all on all functions in schema adaptador_guard from anon; + end if; + if exists (select 1 from pg_catalog.pg_roles where rolname = 'authenticated') then + revoke all on schema adaptador_guard from authenticated; + revoke all on table adaptador_guard.project_identity from authenticated; + revoke all on all functions in schema adaptador_guard from authenticated; + end if; + if exists (select 1 from pg_catalog.pg_roles where rolname = 'service_role') then + revoke all on schema adaptador_guard from service_role; + revoke all on table adaptador_guard.project_identity from service_role; + revoke all on all functions in schema adaptador_guard from service_role; + end if; + if exists (select 1 from pg_catalog.pg_roles where rolname = 'app_user') then + revoke all on schema adaptador_guard from app_user; + revoke all on table adaptador_guard.project_identity from app_user; + revoke all on all functions in schema adaptador_guard from app_user; + end if; +`; + +/** SQL da primeira gravação. Um único bloco: se falhar, o schema inteiro é desfeito. */ +export function projectIdentityDefinitionSql(projectRef: string): string { + if (!PROJECT_REF_PATTERN.test(projectRef)) throw new DatabaseGuardError("ref_ausente"); + return ` +do $identity$ +begin + create schema adaptador_guard; + revoke all on schema adaptador_guard from public; + + create table adaptador_guard.project_identity ( + singleton boolean primary key default true check (singleton), + project_name text not null, + project_ref text not null, + constraint project_identity_name check (project_name = '${AUTHORIZED_HML_PROJECT}'), + constraint project_identity_ref check (project_ref ~ '^[a-z0-9]{20}$') + ); + revoke all on table adaptador_guard.project_identity from public; + alter table adaptador_guard.project_identity enable row level security; + alter table adaptador_guard.project_identity force row level security; + comment on table adaptador_guard.project_identity is + 'Identidade persistente do projeto de homologacao. Nao e prova criptografica.'; + + create function adaptador_guard.reject_identity_change() + returns trigger + language plpgsql + security invoker + set search_path = pg_catalog + as $body$ + begin + if tg_op <> 'INSERT' then + raise exception 'identidade imutavel' using errcode = '42501'; + end if; + if session_user <> 'postgres' and session_user !~ '^postgres[.][a-z0-9]{20}$' then + raise exception 'identidade protegida' using errcode = '42501'; + end if; + return new; + end; + $body$; + + create trigger project_identity_guard + before insert or update or delete on adaptador_guard.project_identity + for each row execute function adaptador_guard.reject_identity_change(); + + create function adaptador_guard.adapter_project_identity() + returns table (project_name text, project_ref text) + language sql + stable + security definer + set search_path = pg_catalog + as $body$ + select i.project_name, i.project_ref + from adaptador_guard.project_identity i + $body$; + + revoke all on function adaptador_guard.adapter_project_identity() from public; + revoke all on function adaptador_guard.reject_identity_change() from public; + + insert into adaptador_guard.project_identity (project_name, project_ref) + values ('${AUTHORIZED_HML_PROJECT}', '${projectRef}'); +${LOCK_BODY} +end +$identity$; +`; +} + +const GRANT_SQL = ` +do $grant$ +begin +${LOCK_BODY} +end +$grant$; +`; + +const SHAPE_SQL = ` +select kind, name +from ( + select 'missing'::text as kind, 'project_identity'::text as name + where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') + and not exists ( + select 1 + from pg_catalog.pg_class c + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'adaptador_guard' and c.relname = 'project_identity' and c.relkind = 'r' + ) + union all + select 'missing', 'adapter_project_identity' + where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') + and not exists ( + select 1 + from pg_catalog.pg_proc p + join pg_catalog.pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'adaptador_guard' and p.proname = 'adapter_project_identity' + ) + union all + select 'missing', 'reject_identity_change' + where exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') + and not exists ( + select 1 + from pg_catalog.pg_proc p + join pg_catalog.pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'adaptador_guard' and p.proname = 'reject_identity_change' + ) + union all + select 'unknown_relation', c.relname + from pg_catalog.pg_class c + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'adaptador_guard' + and not ( + (c.relname = 'project_identity' and c.relkind = 'r') + or ( + c.relkind = 'i' + and exists ( + select 1 + from pg_catalog.pg_index i + join pg_catalog.pg_class t on t.oid = i.indrelid + where i.indexrelid = c.oid + and t.relname = 'project_identity' + and t.relnamespace = n.oid + ) + ) + ) + union all + select 'unknown_function', p.proname + from pg_catalog.pg_proc p + join pg_catalog.pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'adaptador_guard' + and p.proname not in ('adapter_project_identity', 'reject_identity_change') + union all + select 'unknown_type', t.typname + from pg_catalog.pg_type t + join pg_catalog.pg_namespace n on n.oid = t.typnamespace + where n.nspname = 'adaptador_guard' + and not exists ( + select 1 + from pg_catalog.pg_class c + where c.oid = t.typrelid + and c.relname = 'project_identity' + and c.relnamespace = n.oid + ) + and not exists ( + select 1 + from pg_catalog.pg_type elem + join pg_catalog.pg_class c on c.oid = elem.typrelid + where elem.oid = t.typelem + and c.relname = 'project_identity' + and c.relnamespace = n.oid + ) + union all + select 'external', vn.nspname || '.' || v.relname + from pg_catalog.pg_depend d + join pg_catalog.pg_rewrite rw on rw.oid = d.objid + join pg_catalog.pg_class v on v.oid = rw.ev_class + join pg_catalog.pg_namespace vn on vn.oid = v.relnamespace + join pg_catalog.pg_class t on t.oid = d.refobjid + join pg_catalog.pg_namespace tn on tn.oid = t.relnamespace + where tn.nspname = 'adaptador_guard' + and vn.nspname <> 'adaptador_guard' + union all + select 'external', n.nspname || '.' || p.proname + from pg_catalog.pg_depend d + join pg_catalog.pg_proc p on p.oid = d.objid + join pg_catalog.pg_namespace n on n.oid = p.pronamespace + join pg_catalog.pg_class t on t.oid = d.refobjid + join pg_catalog.pg_namespace tn on tn.oid = t.relnamespace + where tn.nspname = 'adaptador_guard' + and n.nspname <> 'adaptador_guard' +) findings +`; + +/** Remove só os objetos conhecidos. Recusa se houver objeto ou dependência fora da lista. */ +export function projectIdentityRevertSql(): string { + return ` +do $revert$ +begin + if not exists (select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') then + raise exception 'marcador_ausente' using errcode = 'P0002'; + end if; + if exists (${SHAPE_SQL}) then + raise exception 'reversao_recusada' using errcode = '55000'; + end if; +end +$revert$; +drop function adaptador_guard.adapter_project_identity(); +drop table adaptador_guard.project_identity; +drop function adaptador_guard.reject_identity_change(); +drop schema adaptador_guard restrict; +`; +} + +async function readStoredIdentity(db: Db): Promise { + let schemaExists = false; + let findings: { kind: string; name: string }[] = []; + try { + const presence = await db.withService((query) => + query.query<{ exists: boolean }>( + `select exists(select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') as exists`, + ), + ); + schemaExists = Boolean(presence[0]?.exists); + if (schemaExists) { + findings = await db.withService((query) => query.query<{ kind: string; name: string }>(SHAPE_SQL)); + } + } catch { + throw new DatabaseGuardError("marcador_indisponivel"); + } + if (!schemaExists) return { kind: "ausente" }; + if (findings.length > 0) return { kind: "inconsistente" }; + try { + const rows = await db.withService((query) => + query.query<{ project_name: string; project_ref: string }>( + `select project_name, project_ref from adaptador_guard.project_identity`, + ), + ); + if (rows.length !== 1) return { kind: "inconsistente" }; + return { kind: "presente", projectName: rows[0]?.project_name ?? "", projectRef: rows[0]?.project_ref ?? "" }; + } catch { + throw new DatabaseGuardError("marcador_indisponivel"); + } +} + +/** + * Grava a identidade uma vez, ou só reaplica os privilégios se a linha já concorda. + * Recusa schema incompleto, objeto desconhecido e Reference ID diferente. + * Não é chamada por migrate, seed nem deploy. + */ +export async function installProjectIdentity(db: Db, projectRef: string): Promise { + if (!PROJECT_REF_PATTERN.test(projectRef)) throw new DatabaseGuardError("ref_ausente"); + const current = await readStoredIdentity(db); + if (current.kind === "inconsistente") throw new DatabaseGuardError("marcador_indisponivel"); + if (current.kind === "presente") { + if (current.projectName !== AUTHORIZED_HML_PROJECT || current.projectRef !== projectRef) { + throw new DatabaseGuardError("marcador_diverge"); + } + await db.exec(GRANT_SQL); + return "inalterada"; + } + await db.exec(projectIdentityDefinitionSql(projectRef)); + return "criada"; +} + +/** Desfaz a identidade sem CASCADE. Não toca em outro schema. */ +export async function revertProjectIdentity(db: Db): Promise { + try { + await db.exec(projectIdentityRevertSql()); + } catch (error) { + const message = error instanceof Error ? error.message : ""; + if (message.includes("marcador_ausente")) throw new DatabaseGuardError("marcador_ausente"); + if (message.includes("reversao_recusada")) throw new DatabaseGuardError("reversao_recusada"); + throw error; + } +} diff --git a/src/infra/db/target-guard.ts b/src/infra/db/target-guard.ts index 8b3ae34..71a42aa 100644 --- a/src/infra/db/target-guard.ts +++ b/src/infra/db/target-guard.ts @@ -2,6 +2,7 @@ import type { EnvMap } from "@/config/env"; import { isDeployedEnv } from "@/config/env"; import { AUTHORIZED_HML_PROJECT, + PROJECT_REF_PATTERN, type DbPurpose, DatabaseGuardError, assertLocalDatabaseUrl, @@ -14,57 +15,58 @@ export type RoleFacts = { rolbypassrls: boolean | null; }; +export type ProjectIdentity = { + projectName: string | null; + projectRef: string | 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; + projectName: string | null; + projectRef: string | null; }; -const PROJECT_REF = /^[a-z]{20}$/; - -export function databaseSettings(items: string[]): Map { - const settings = new Map(); - for (const item of items) { - const eq = item.indexOf("="); - if (eq <= 0) continue; - settings.set(item.slice(0, eq), item.slice(eq + 1)); - } - return settings; -} - /** * Prova administrativa do Supabase gerenciado. O hostname, o nome `postgres`, * um GUC de sessão, a variável de ambiente e o atributo BYPASSRLS, cada um - * sozinho, não passam. Os fatos do servidor precisam concordar: schemas - * `auth` e `storage`, papel autenticado e marcador persistido com ALTER DATABASE. + * sozinho, não passam. * - * No projeto `adaptador-planilha-hml` o papel `postgres` tem `rolsuper = false` - * e `rolbypassrls = true`. SUPERUSER não é exigido. BYPASSRLS também não - * autoriza: o mesmo atributo existe em outros papéis e em outros projetos, e - * a leitura de `pg_roles` não diz qual projeto atendeu a sessão. + * O PostgreSQL 17.11 de `adaptador-planilha-hml` recusou + * `ALTER DATABASE ... SET "app.hml_project"` com 42501. Parâmetro personalizado + * não é gravável por esse papel, e `app.settings.jwt_exp` permanece intocado. + * A identidade persistente fica na tabela `adaptador_guard.project_identity`, + * lida pela função `adaptador_guard.adapter_project_identity()`. Um SET de + * sessão ou `options=-c` na URL não escreve nessa tabela. * - * 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. Outro parâmetro já gravado, como `app.settings.jwt_exp`, não - * identifica o projeto e não é alterado por esta verificação. Quem puder - * executar ALTER DATABASE em outro banco pode copiar os mesmos marcadores. - * A cópia só passaria se a URL também trouxesse o pooler declarado, o usuário - * `postgres.` e o ref do marcador. Sem o marcador, ou com divergência, - * a operação para. + * A função de leitura é SECURITY DEFINER, com `search_path = pg_catalog` e nome + * qualificado. Só `adaptador_web` e `adaptador_worker` recebem EXECUTE. A linha + * não é prova criptográfica do projeto. Quem tem a credencial administrativa + * pode apagar o schema e criar outra linha igual em outro banco. A cópia só + * passa se a URL também trouxer o pooler declarado, o usuário `postgres.` + * e o mesmo ref. Sem a linha, ou com divergência, a operação para. * - * Limitação do Session Pooler: o usuário da URL tem a forma `papel.`, - * mas o servidor pode devolver `session_user` só como `postgres`, sem o - * sufixo. O ref autenticado fica então no usuário da URL, e o vínculo com - * este projeto fica no marcador. Os dois têm de ser o mesmo ref. O pooler - * também não torna exclusivo o nome do banco `postgres` nem o host da região. + * O Session Pooler pode devolver `session_user` só como `postgres`. O ref + * autenticado fica no usuário da URL e o vínculo com este projeto fica na + * tabela. Os dois têm de ser o mesmo ref. O nome do banco `postgres` e o host + * da região se repetem em outros projetos. */ -export function evaluateAdminIdentity(attestation: AdminAttestation, env: EnvMap = process.env): void { +export function evaluateProjectIdentity(identity: ProjectIdentity, env: EnvMap = process.env): void { const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? ""; - if (!PROJECT_REF.test(ref)) throw new DatabaseGuardError("ref_ausente"); + if (!PROJECT_REF_PATTERN.test(ref)) throw new DatabaseGuardError("ref_ausente"); + if (!identity.projectName || !identity.projectRef) throw new DatabaseGuardError("marcador_ausente"); + if (identity.projectName !== AUTHORIZED_HML_PROJECT || identity.projectRef !== ref) { + throw new DatabaseGuardError("marcador_diverge"); + } +} + +export function evaluateAdminConnection( + attestation: Omit, + env: EnvMap = process.env, +): void { + const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? ""; + if (!PROJECT_REF_PATTERN.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; @@ -74,12 +76,11 @@ export function evaluateAdminIdentity(attestation: AdminAttestation, env: EnvMap if (attestation.rolsuper === null || attestation.rolbypassrls === null) { throw new DatabaseGuardError("papel_nao_comprovado"); } - 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 evaluateAdminIdentity(attestation: AdminAttestation, env: EnvMap = process.env): void { + evaluateAdminConnection(attestation, env); + evaluateProjectIdentity({ projectName: attestation.projectName, projectRef: attestation.projectRef }, env); } export function evaluateRuntimeRole(facts: RoleFacts, purpose: "web" | "worker", env: EnvMap = process.env): void { @@ -92,7 +93,7 @@ export function evaluateRuntimeRole(facts: RoleFacts, purpose: "web" | "worker", 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 (PROJECT_REF_PATTERN.test(ref)) allowed.add(`${expected}.${ref}`); if (!allowed.has(facts.sessionUser)) throw new DatabaseGuardError("papel_recusado"); } @@ -105,6 +106,13 @@ type FactRow = { has_storage: boolean; }; +function postgresErrorCode(error: unknown): string | null { + if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string") { + return error.code; + } + return null; +} + async function readRole(db: Db): Promise { try { const rows = await db.withService((query) => @@ -128,32 +136,55 @@ async function readRole(db: Db): Promise { } } -/** Marcador gravado no banco. Um SET de sessão não aparece nesta consulta. */ -async function readDatabaseMarker(db: Db): Promise> { +/** Lê a linha gravada no banco. GUC de sessão e variável de ambiente não entram aqui. */ +export async function readProjectIdentity(db: Db): Promise { try { const rows = await db.withService((query) => - query.query<{ item: string }>( - `select item - from pg_catalog.pg_db_role_setting s - cross join lateral unnest(s.setconfig) as item - where s.setdatabase = (select oid from pg_catalog.pg_database where datname = current_database()) - and s.setrole = 0`, + query.query<{ project_name: string; project_ref: string }>( + `select project_name, project_ref from adaptador_guard.adapter_project_identity()`, ), ); - return databaseSettings(rows.map((row) => row.item)); - } catch { + if (rows.length !== 1) throw new DatabaseGuardError(rows.length === 0 ? "marcador_ausente" : "marcador_diverge"); + return { projectName: rows[0]?.project_name ?? null, projectRef: rows[0]?.project_ref ?? null }; + } catch (error) { + if (error instanceof DatabaseGuardError) throw error; + const code = postgresErrorCode(error); + if (code === "42P01" || code === "3F000") throw new DatabaseGuardError("marcador_ausente"); 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. A credencial - * administrativa do Supabase gerenciado não é superusuária; migrate, seed e - * flag só seguem quando o catálogo comprova o projeto, sem usar BYPASSRLS - * como autorização. + * Confere o destino administrativo antes da gravação da identidade. + * Não cria a tabela e não aceita a ausência dela como sucesso de migration. */ -export async function assertDatabaseGuard(db: Db, purpose: DbPurpose, connectionString: string | null, env: EnvMap = process.env): Promise { +export async function assertAdminTarget(db: Db, env: EnvMap = process.env): Promise { + const facts = await readRole(db); + evaluateAdminConnection( + { + sessionUser: facts.session_user, + databaseName: facts.database_name, + rolsuper: facts.rolsuper, + rolbypassrls: facts.rolbypassrls, + hasAuth: facts.has_auth, + hasStorage: facts.has_storage, + }, + env, + ); +} + +/** + * 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, + * flag, web e worker só seguem quando a tabela de identidade concorda com o + * ref da URL. Nenhum desses comandos grava essa tabela. + */ +export async function assertDatabaseGuard( + db: Db, + purpose: DbPurpose, + connectionString: string | null, + env: EnvMap = process.env, +): Promise { if (!isDeployedEnv(env)) { if (connectionString) assertLocalDatabaseUrl(connectionString); return; @@ -163,8 +194,7 @@ export async function assertDatabaseGuard(db: Db, purpose: DbPurpose, connection const facts = await readRole(db); if (purpose === "admin") { - const settings = await readDatabaseMarker(db); - evaluateAdminIdentity( + evaluateAdminConnection( { sessionUser: facts.session_user, databaseName: facts.database_name, @@ -172,15 +202,16 @@ export async function assertDatabaseGuard(db: Db, purpose: DbPurpose, connection rolbypassrls: facts.rolbypassrls, hasAuth: facts.has_auth, hasStorage: facts.has_storage, - databaseSettings: settings, }, env, ); - return; + } else { + evaluateRuntimeRole( + { sessionUser: facts.session_user, rolsuper: facts.rolsuper, rolbypassrls: facts.rolbypassrls }, + purpose, + env, + ); } - evaluateRuntimeRole( - { sessionUser: facts.session_user, rolsuper: facts.rolsuper, rolbypassrls: facts.rolbypassrls }, - purpose, - env, - ); + const identity = await readProjectIdentity(db); + evaluateProjectIdentity(identity, env); } diff --git a/tests/services/project-identity.test.ts b/tests/services/project-identity.test.ts new file mode 100644 index 0000000..cffbc07 --- /dev/null +++ b/tests/services/project-identity.test.ts @@ -0,0 +1,361 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createPostgresDb } from "@/infra/db/postgres"; +import { installProjectIdentity, projectIdentityDefinitionSql, revertProjectIdentity } from "@/infra/db/project-identity"; +import { migrate } from "@/infra/db/migrate"; +import { assertAdminTarget, assertDatabaseGuard, readProjectIdentity } from "@/infra/db/target-guard"; +import type { Db } from "@/infra/db/types"; + +/** + * Identidade persistente contra PostgreSQL 17 descartável. + * A suíte só roda com ADAPTER_TEST_DATABASE_URL. Sem essa URL o caso fica + * pendente e não conta como aprovação. + */ +const url = process.env.ADAPTER_TEST_DATABASE_URL; +const enabled = Boolean(url); +const REF = "abcd1234abcd1234wxyz"; +const OUTRO_REF = "zzzzzzzzzzzzzzzzzzz1"; +const env = { + ADAPTER_ENV: "staging", + ADAPTER_DB_PROJECT_REF: REF, + ADAPTER_DB_POOLER_HOST: "aws-1-us-west-2.pooler.supabase.com", +}; + +let db: Db; + +function roleUrl(role: "adaptador_web" | "adaptador_worker"): string { + const parsed = new URL(url as string); + parsed.username = role; + parsed.password = ""; + return parsed.toString(); +} + +describe.skipIf(!enabled)("identidade no PostgreSQL 17", () => { + beforeAll(async () => { + db = createPostgresDb(url as string); + await db.exec(` + drop schema if exists adaptador_guard cascade; + drop schema if exists public cascade; + create schema public; + drop schema if exists auth cascade; + create schema auth; + drop schema if exists storage cascade; + create schema storage; + `); + await db.exec(` + do $$ + begin + if exists (select 1 from pg_catalog.pg_roles where rolname = 'adaptador_web') then + drop role adaptador_web; + end if; + if exists (select 1 from pg_catalog.pg_roles where rolname = 'adaptador_worker') then + drop role adaptador_worker; + end if; + if exists (select 1 from pg_catalog.pg_roles where rolname = 'app_user') then + drop role app_user; + end if; + if exists (select 1 from pg_catalog.pg_roles where rolname = 'ident_owner') then + drop role ident_owner; + end if; + end + $$; + `); + await db.exec(`alter database postgres set "app.settings.jwt_exp" = '3600'`); + }); + + afterAll(async () => { + if (!db) return; + await db.exec(`drop schema if exists adaptador_guard cascade`).catch(() => undefined); + await db.close(); + }); + + it("não cria a identidade na migration e impede a web de alterá-la", async () => { + await db.exec(`create schema adaptador_guard`); + await expect(installProjectIdentity(db, REF)).rejects.toMatchObject({ code: "marcador_indisponivel" }); + const vazio = await db.withService((query) => + query.query<{ relname: string | null }>( + `select c.relname + from pg_catalog.pg_class c + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'adaptador_guard' and c.relkind = 'r'`, + ), + ); + expect(vazio).toEqual([]); + await db.exec(`drop schema adaptador_guard restrict`); + + const original = projectIdentityDefinitionSql(REF); + const quebrado = original.replace( + /end\s*\$identity\$;\s*$/, + "raise exception 'falha_injetada';\nend\n$identity$;\n", + ); + expect(quebrado).toContain("falha_injetada"); + await expect(db.exec(quebrado)).rejects.toThrow(/falha_injetada/); + const aposFalha = await db.withService((query) => + query.query<{ exists: boolean }>( + `select exists(select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') as exists`, + ), + ); + expect(aposFalha[0]?.exists).toBe(false); + + await migrate(db, { applyIdentityShim: true }); + const antes = await db.withService((query) => + query.query<{ exists: boolean }>( + `select exists(select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') as exists`, + ), + ); + expect(antes[0]?.exists).toBe(false); + await db.exec(` + drop owned by adaptador_web, adaptador_worker, app_user; + drop role adaptador_web; + drop role adaptador_worker; + drop role app_user; + drop schema public cascade; + create schema public; + `); + + expect(await installProjectIdentity(db, REF)).toBe("criada"); + await migrate(db, { applyIdentityShim: true }); + const loginAntes = await db.withService((query) => + query.query<{ rolname: string; rolcanlogin: boolean; rolsuper: boolean; rolbypassrls: boolean }>( + `select rolname, rolcanlogin, rolsuper, rolbypassrls + from pg_catalog.pg_roles + where rolname in ('adaptador_web', 'adaptador_worker', 'app_user')`, + ), + ); + expect(loginAntes).toHaveLength(3); + for (const role of loginAntes) { + expect(role.rolcanlogin, role.rolname).toBe(false); + expect(role.rolsuper, role.rolname).toBe(false); + expect(role.rolbypassrls, role.rolname).toBe(false); + } + + const semGrant = await db.withService((query) => + query.query<{ web: boolean; worker: boolean; app: boolean }>( + `select + has_function_privilege('adaptador_web', 'adaptador_guard.adapter_project_identity()', 'execute') as web, + has_function_privilege('adaptador_worker', 'adaptador_guard.adapter_project_identity()', 'execute') as worker, + has_function_privilege('app_user', 'adaptador_guard.adapter_project_identity()', 'execute') as app`, + ), + ); + expect(semGrant[0]).toEqual({ web: false, worker: false, app: false }); + + await db.exec(`alter role adaptador_web login; alter role adaptador_worker login`); + const webAntes = createPostgresDb(roleUrl("adaptador_web")); + await expect(assertDatabaseGuard(webAntes, "web", roleUrl("adaptador_web"), env)).rejects.toMatchObject({ + code: "marcador_indisponivel", + }); + await webAntes.close(); + + expect(await installProjectIdentity(db, REF)).toBe("inalterada"); + const antesDaSegunda = await db.withService((query) => + query.query<{ project_ref: string; xmin: string }>( + `select project_ref, xmin::text as xmin from adaptador_guard.project_identity`, + ), + ); + expect(await installProjectIdentity(db, REF)).toBe("inalterada"); + const depoisDaSegunda = await db.withService((query) => + query.query<{ project_ref: string; xmin: string }>( + `select project_ref, xmin::text as xmin from adaptador_guard.project_identity`, + ), + ); + expect(depoisDaSegunda).toEqual(antesDaSegunda); + await expect(installProjectIdentity(db, OUTRO_REF)).rejects.toMatchObject({ code: "marcador_diverge" }); + const aposRefErrado = await db.withService((query) => + query.query<{ project_ref: string }>(`select project_ref from adaptador_guard.project_identity`), + ); + expect(aposRefErrado[0]?.project_ref).toBe(REF); + + await assertAdminTarget(db, env); + await assertDatabaseGuard(db, "admin", url, env); + await expect(assertDatabaseGuard(db, "admin", url, { ...env, ADAPTER_DB_PROJECT_REF: OUTRO_REF })).rejects.toMatchObject({ + code: "marcador_diverge", + }); + + const web = createPostgresDb(roleUrl("adaptador_web")); + const worker = createPostgresDb(roleUrl("adaptador_worker")); + try { + await assertDatabaseGuard(web, "web", roleUrl("adaptador_web"), env); + await assertDatabaseGuard(worker, "worker", roleUrl("adaptador_worker"), env); + await expect( + web.exec(`update adaptador_guard.project_identity set project_ref = '${OUTRO_REF}'`), + ).rejects.toMatchObject({ code: "42501" }); + await expect(worker.exec(`delete from adaptador_guard.project_identity`)).rejects.toMatchObject({ code: "42501" }); + } finally { + await web.close(); + await worker.close(); + } + + await expect(db.exec(`update adaptador_guard.project_identity set project_name = project_name`)).rejects.toMatchObject({ + code: "42501", + }); + await expect(db.exec(`delete from adaptador_guard.project_identity`)).rejects.toMatchObject({ code: "42501" }); + await expect( + db.exec(`do $$ begin set local role app_user; perform * from adaptador_guard.adapter_project_identity(); end $$;`), + ).rejects.toMatchObject({ code: "42501" }); + + await db.exec(` + create role ident_owner nologin nosuperuser bypassrls; + grant usage on schema adaptador_guard to ident_owner; + alter table adaptador_guard.project_identity owner to ident_owner; + alter function adaptador_guard.adapter_project_identity() owner to ident_owner; + `); + const comoDono = await db.withService((query) => + query.query<{ project_name: string; project_ref: string; rolsuper: boolean; rolbypassrls: boolean }>( + `select i.project_name, i.project_ref, r.rolsuper, r.rolbypassrls + from pg_catalog.pg_roles r + cross join adaptador_guard.adapter_project_identity() i + where r.rolname = 'ident_owner'`, + ), + ); + expect(comoDono[0]).toMatchObject({ + project_name: "adaptador-planilha-hml", + project_ref: REF, + rolsuper: false, + rolbypassrls: true, + }); + + const lida = await readProjectIdentity(db); + expect(lida).toEqual({ projectName: "adaptador-planilha-hml", projectRef: REF }); + + const marcadores = 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`, + ), + ); + const itens = marcadores.map((row) => row.item); + expect(itens).toContain("app.settings.jwt_exp=3600"); + expect(itens.some((item) => item.startsWith("app.hml_project"))).toBe(false); + + const internos = await db.withService((query) => + query.query<{ schema_name: string; total: number }>( + `select n.nspname as schema_name, count(c.oid)::int as total + from pg_catalog.pg_namespace n + left join pg_catalog.pg_class c on c.relnamespace = n.oid and c.relkind in ('r', 'p', 'v', 'm') + where n.nspname in ('auth', 'storage') + group by n.nspname + order by n.nspname`, + ), + ); + expect(internos).toEqual([ + { schema_name: "auth", total: 0 }, + { schema_name: "storage", total: 0 }, + ]); + + const proteção = await db.withService((query) => + query.query<{ relrowsecurity: boolean; relforcerowsecurity: boolean }>( + `select c.relrowsecurity, c.relforcerowsecurity + from pg_catalog.pg_class c + join pg_catalog.pg_namespace n on n.oid = c.relnamespace + where n.nspname = 'adaptador_guard' and c.relname = 'project_identity'`, + ), + ); + expect(proteção[0]).toEqual({ relrowsecurity: true, relforcerowsecurity: true }); + + const definidor = await db.withService((query) => + query.query<{ proname: string; prosecdef: boolean; proconfig: string[] | null }>( + `select p.proname, p.prosecdef, p.proconfig + from pg_catalog.pg_proc p + join pg_catalog.pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'adaptador_guard' + order by p.proname`, + ), + ); + expect(definidor).toEqual([ + { proname: "adapter_project_identity", prosecdef: true, proconfig: ["search_path=pg_catalog"] }, + { proname: "reject_identity_change", prosecdef: false, proconfig: ["search_path=pg_catalog"] }, + ]); + + await db.exec(` + create table adaptador_guard.extra (id int); + `); + await expect(installProjectIdentity(db, REF)).rejects.toMatchObject({ code: "marcador_indisponivel" }); + await db.exec(`drop table adaptador_guard.extra`); + + await db.exec(` + do $$ + begin + if not exists (select 1 from pg_catalog.pg_roles where rolname = 'anon') then + create role anon nologin nosuperuser nobypassrls; + end if; + if not exists (select 1 from pg_catalog.pg_roles where rolname = 'authenticated') then + create role authenticated nologin nosuperuser nobypassrls; + end if; + if not exists (select 1 from pg_catalog.pg_roles where rolname = 'service_role') then + create role service_role nologin nosuperuser bypassrls; + end if; + end + $$; + `); + expect(await installProjectIdentity(db, REF)).toBe("inalterada"); + const privilegios = await db.withService((query) => + query.query<{ papel: string; uso: boolean; leitura: boolean; alteracao: boolean; executar: boolean; criar: boolean }>( + `select papel, uso, leitura, alteracao, executar, criar + from ( + select 'public'::text as papel, + has_schema_privilege('public', 'adaptador_guard', 'usage') as uso, + has_schema_privilege('public', 'adaptador_guard', 'create') as criar, + has_table_privilege('public', 'adaptador_guard.project_identity', 'select') as leitura, + has_table_privilege('public', 'adaptador_guard.project_identity', 'update') as alteracao, + has_function_privilege('public', 'adaptador_guard.adapter_project_identity()', 'execute') as executar + union all + select r.rolname, + has_schema_privilege(r.oid, 'adaptador_guard', 'usage'), + has_schema_privilege(r.oid, 'adaptador_guard', 'create'), + has_table_privilege(r.oid, 'adaptador_guard.project_identity', 'select'), + has_table_privilege(r.oid, 'adaptador_guard.project_identity', 'update'), + has_function_privilege(r.oid, 'adaptador_guard.adapter_project_identity()', 'execute') + from pg_catalog.pg_roles r + where r.rolname in ('anon', 'authenticated', 'service_role', 'app_user', 'adaptador_web', 'adaptador_worker') + ) privilegio + order by papel`, + ), + ); + const porPapel = new Map(privilegios.map((row) => [row.papel, row])); + for (const papel of ["public", "anon", "authenticated", "service_role", "app_user"]) { + expect(porPapel.get(papel), papel).toMatchObject({ uso: false, leitura: false, alteracao: false, executar: false, criar: false }); + } + for (const papel of ["adaptador_web", "adaptador_worker"]) { + expect(porPapel.get(papel), papel).toMatchObject({ uso: true, leitura: false, alteracao: false, executar: true, criar: false }); + } + await expect( + db.exec(`do $$ begin set local role service_role; perform * from adaptador_guard.adapter_project_identity(); end $$;`), + ).rejects.toMatchObject({ code: "42501" }); + await expect( + db.exec(`do $$ begin set local role adaptador_web; create table adaptador_guard.extra (id int); end $$;`), + ).rejects.toMatchObject({ code: "42501" }); + + await db.exec(`create view public.identity_leak as select project_name from adaptador_guard.project_identity`); + await expect(revertProjectIdentity(db)).rejects.toMatchObject({ code: "reversao_recusada" }); + const vazou = await db.withService((query) => + query.query<{ nome: string | null }>(`select to_regclass('public.identity_leak')::text as nome`), + ); + expect(vazou[0]?.nome).toBe("identity_leak"); + const aindaLa = await db.withService((query) => + query.query<{ exists: boolean }>( + `select exists(select 1 from pg_catalog.pg_namespace where nspname = 'adaptador_guard') as exists`, + ), + ); + expect(aindaLa[0]?.exists).toBe(true); + await db.exec(`drop view public.identity_leak`); + await revertProjectIdentity(db); + const restante = await db.withService((query) => + query.query<{ nspname: string; profiles: string | null }>( + `select n.nspname, to_regclass('public.profiles')::text as profiles + from pg_catalog.pg_namespace n + where n.nspname in ('public', 'auth', 'storage', 'adaptador_guard') + order by n.nspname`, + ), + ); + expect(restante.map((row) => row.nspname)).toEqual(["auth", "public", "storage"]); + expect(restante.every((row) => row.profiles === "profiles")).toBe(true); + }); +}); + +describe.skipIf(enabled)("identidade no PostgreSQL 17", () => { + it("PENDENTE: defina ADAPTER_TEST_DATABASE_URL para executar", () => { + expect(enabled).toBe(false); + }); +}); diff --git a/tests/unit/database-guard.test.ts b/tests/unit/database-guard.test.ts index 0d7fba5..f99513e 100644 --- a/tests/unit/database-guard.test.ts +++ b/tests/unit/database-guard.test.ts @@ -9,9 +9,13 @@ import { resolvePoolMax, scrubDbError, } from "@/infra/db/connection-policy"; -import { evaluateAdminIdentity, evaluateRuntimeRole, type AdminAttestation } from "@/infra/db/target-guard"; +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { assertInitAuthorized, projectIdentityDefinitionSql, projectIdentityRevertSql } from "@/infra/db/project-identity"; +import { evaluateAdminIdentity, evaluateProjectIdentity, evaluateRuntimeRole, type AdminAttestation } from "@/infra/db/target-guard"; const REF = "abcdefghijabcdefghij"; +const REF_COM_DIGITO = "abcd1234abcd1234wxyz"; const DASHBOARD_HOST = "aws-1-us-west-2.pooler.supabase.com"; function adminUrl(user = `postgres.${REF}`, sslmode = "verify-full", host = DASHBOARD_HOST): string { @@ -26,10 +30,8 @@ function attestation(overrides: Partial = {}): AdminAttestatio rolbypassrls: true, hasAuth: true, hasStorage: true, - databaseSettings: new Map([ - ["app.hml_project", AUTHORIZED_HML_PROJECT], - ["app.hml_project_ref", REF], - ]), + projectName: AUTHORIZED_HML_PROJECT, + projectRef: REF, ...overrides, }; } @@ -44,24 +46,9 @@ describe("barreira do banco de homologação", () => { }); it("reconhece o postgres do Supabase gerenciado sem tratar BYPASSRLS como autorização", () => { - const settings = new Map([ - ["app.settings.jwt_exp", "3600"], - ["app.hml_project", AUTHORIZED_HML_PROJECT], - ["app.hml_project_ref", REF], - ]); + expect(() => evaluateAdminIdentity(attestation({ rolsuper: false, rolbypassrls: true }), env)).not.toThrow(); expect(() => - evaluateAdminIdentity(attestation({ rolsuper: false, rolbypassrls: true, databaseSettings: settings }), env), - ).not.toThrow(); - expect(settings.get("app.settings.jwt_exp")).toBe("3600"); - expect(() => - evaluateAdminIdentity( - attestation({ - rolsuper: false, - rolbypassrls: true, - databaseSettings: new Map([["app.settings.jwt_exp", "3600"]]), - }), - env, - ), + evaluateAdminIdentity(attestation({ projectName: null, projectRef: null, rolsuper: false, rolbypassrls: true }), env), ).toThrow(/marcador_ausente/); expect(() => evaluateAdminIdentity(attestation({ sessionUser: "supabase_admin", rolsuper: false, rolbypassrls: true }), env), @@ -71,6 +58,48 @@ describe("barreira do banco de homologação", () => { ); }); + it("aceita Reference ID com dígito e recusa GUC ou variável como única prova", () => { + const envComDigito = { ...env, ADAPTER_DB_PROJECT_REF: REF_COM_DIGITO }; + expect(() => + evaluateProjectIdentity({ projectName: AUTHORIZED_HML_PROJECT, projectRef: REF_COM_DIGITO }, envComDigito), + ).not.toThrow(); + expect(() => evaluateProjectIdentity({ projectName: null, projectRef: null }, envComDigito)).toThrow(/marcador_ausente/); + expect(() => evaluateProjectIdentity({ projectName: AUTHORIZED_HML_PROJECT, projectRef: REF }, envComDigito)).toThrow( + /marcador_diverge/, + ); + expect(() => evaluateAdminIdentity(attestation(), { ...env, ADAPTER_DB_PROJECT_REF: "ABCD1234ABCD1234WXYZ" })).toThrow( + /ref_ausente/, + ); + expect(() => assertInitAuthorized({ ADAPTER_ENV: "staging" })).toThrow(/init_nao_autorizada/); + expect(() => assertInitAuthorized({ ADAPTER_ENV: "development", ADAPTER_INIT_PROJECT_IDENTITY: "1" })).toThrow( + /init_fora_de_homologacao/, + ); + expect(() => assertInitAuthorized({ ADAPTER_ENV: "staging", ADAPTER_INIT_PROJECT_IDENTITY: "1" })).not.toThrow(); + const sql = projectIdentityDefinitionSql(REF_COM_DIGITO); + expect(sql).toContain(REF_COM_DIGITO); + expect(sql.split("do $identity$")).toHaveLength(2); + expect(sql).toContain("security definer"); + expect(sql).toContain("set search_path = pg_catalog"); + expect(sql).toContain("revoke all on table adaptador_guard.project_identity from service_role"); + expect(sql.toLowerCase()).not.toContain("jwt_exp"); + expect(sql.toLowerCase()).not.toContain("alter database"); + expect(sql).not.toContain("auth."); + expect(sql).not.toContain("storage."); + const prepared = readFileSync(path.join(process.cwd(), "scripts/sql/init-project-identity.sql"), "utf8"); + expect(prepared).toContain("<>"); + expect(prepared).toContain("security definer"); + expect(prepared.toLowerCase()).not.toContain("cascade"); + const revert = projectIdentityRevertSql(); + const revertFile = readFileSync(path.join(process.cwd(), "scripts/sql/revert-project-identity.sql"), "utf8"); + expect(revert).toContain("drop schema adaptador_guard restrict;"); + expect(revert.toLowerCase()).not.toContain("cascade"); + expect(revertFile).toContain("drop schema adaptador_guard restrict;"); + expect(revertFile.toLowerCase()).not.toContain("cascade"); + const initScript = readFileSync(path.join(process.cwd(), "scripts/init-project-identity.ts"), "utf8"); + expect(initScript).not.toContain("console.log(databaseUrl"); + expect(initScript).not.toContain("console.log(process.env"); + }); + 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); @@ -90,18 +119,19 @@ describe("barreira do banco de homologação", () => { 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/); + expect(() => evaluateAdminIdentity(attestation({ projectName: null, projectRef: null }), env)).toThrow(/marcador_ausente/); + expect(() => evaluateAdminIdentity(attestation({ projectRef: "zzzzzzzzzzzzzzzzzzzz" }), env)).toThrow(/marcador_diverge/); + expect(() => evaluateProjectIdentity({ projectName: "outro-projeto", projectRef: REF }, env)).toThrow(/marcador_diverge/); + const migrations = readdirSync(path.join(process.cwd(), "migrations"), { recursive: true }).filter( + (file) => typeof file === "string" && file.endsWith(".sql"), + ); + for (const file of migrations) { + const sql = readFileSync(path.join(process.cwd(), "migrations", file), "utf8"); + expect(sql, file).not.toContain("adaptador_guard"); + } + for (const file of ["scripts/migrate.ts", "scripts/seed-staging.ts", "scripts/seed-dev.ts", "scripts/set-flag.ts"]) { + expect(readFileSync(path.join(process.cwd(), file), "utf8"), file).not.toContain("installProjectIdentity"); + } }); it("não aceita só o nome postgres, o host ou um usuário sem o ref", () => { @@ -155,6 +185,7 @@ describe("barreira do banco de homologação", () => { expect(() => evaluateRuntimeRole({ sessionUser: "adaptador_web", rolsuper: false, rolbypassrls: false }, "web", env), ).not.toThrow(); + expect(() => evaluateProjectIdentity({ projectName: null, projectRef: null }, env)).toThrow(/marcador_ausente/); expect(() => evaluateRuntimeRole({ sessionUser: `adaptador_worker.${REF}`, rolsuper: false, rolbypassrls: false }, "worker", env), ).not.toThrow();