fix(db): implementa identidade persistente segura para homologacao
ci / lint, typecheck, testes e build (push) Canceled after 0s
ci / Postgres e Redis reais (push) Canceled after 0s
ci / imagem Docker Linux (push) Canceled after 0s

This commit is contained in:
Ferraz Fagundes committed 2026-10-19 23:39:05 -03:00
1 parent 2c8c5d9507
commit a4f0a2f649
11 files changed
+1141 -101

No files matched your search

+4 -1
View File
@@ -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-<digitos>-us-west-2.pooler.supabase.com.
+1
View File
@@ -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",
+34
View File
@@ -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);
}
});
+105
View File
@@ -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 <<PROJECT_REF>> 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', '<<PROJECT_REF>>');
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$;
+113
View File
@@ -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;
+39
View File
@@ -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;
+6 -2
View File
@@ -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";
+318
View File
@@ -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<StoredIdentity> {
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<IdentityInstallResult> {
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<void> {
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;
}
}
+95 -64
View File
@@ -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<string, string>;
projectName: string | null;
projectRef: string | null;
};
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 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.<ref>` 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.<ref>`
* 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.<ref>`,
* 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<AdminAttestation, "projectName" | "projectRef">,
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<FactRow> {
try {
const rows = await db.withService((query) =>
@@ -128,32 +136,55 @@ async function readRole(db: Db): Promise<FactRow> {
}
}
/** Marcador gravado no banco. Um SET de sessão não aparece nesta consulta. */
async function readDatabaseMarker(db: Db): Promise<Map<string, string>> {
/** 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<ProjectIdentity> {
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<void> {
export async function assertAdminTarget(db: Db, env: EnvMap = process.env): Promise<void> {
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<void> {
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,
);
}
const identity = await readProjectIdentity(db);
evaluateProjectIdentity(identity, env);
}
+361
View File
@@ -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);
});
});
+65 -34
View File
@@ -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<AdminAttestation> = {}): 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("<<PROJECT_REF>>");
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();