fix(db): compatibiliza barreira com Supabase gerenciado
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 17:14:04 -03:00
1 parent 8c22597420
commit 2c8c5d9507
3 files changed
+69 -16

No files matched your search

+3 -1
View File
@@ -3,7 +3,9 @@
-- Não concede BYPASSRLS nem SUPERUSER.
-- Os papéis nascem NOLOGIN. A senha e o LOGIN ficam para um passo autorizado no projeto de homologação.
-- As funções são SECURITY DEFINER, com search_path travado, e EXECUTE só para adaptador_worker.
-- O dono é quem aplica a migration. No Supabase gerenciado esse dono é o postgres, que atravessa FORCE RLS.
-- O dono é quem aplica a migration. No Supabase gerenciado esse dono é o postgres,
-- com rolsuper = false e rolbypassrls = true. O BYPASSRLS do dono faz a função
-- atravessar FORCE RLS; SUPERUSER não é necessário para isso. BYPASSRLS não identifica o projeto.
-- adaptador_web e adaptador_worker são NOINHERIT: sem SET ROLE app_user não herdam os grants do papel da aplicação.
do $$ begin
+27 -14
View File
@@ -35,23 +35,32 @@ export function databaseSettings(items: string[]): Map<string, string> {
}
/**
* Prova administrativa. O hostname, o nome `postgres`, um GUC de sessão e a
* variável de ambiente, cada um sozinho, não passam. Os fatos do servidor
* precisam concordar: schemas do Supabase, papel autenticado e marcador
* persistido com ALTER DATABASE.
* 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.
*
* 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.
*
* Limitação do marcador: `app.hml_project` e `app.hml_project_ref` não são
* identidade criptográfica do projeto Supabase. São parâmetros gravados no
* catálogo `pg_db_role_setting` (setrole = 0). Um SET de sessão ou `options=-c`
* na URL não escreve nesse catálogo, por isso `current_setting` não é aceito
* como prova. Um superusuário de outro banco pode copiar os mesmos ALTER
* DATABASE. A cópia só passaria se a URL também trouxesse o pooler declarado,
* o usuário `postgres.<ref>` e o ref do marcador. Sem o marcador, ou com
* divergência, a operação para.
* 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.
*
* O pooler pode devolver `session_user` só como `postgres`, sem o sufixo do
* ref. Nesse caso o ref autenticado está no usuário da URL, e o vínculo com
* este projeto está no marcador. Os dois têm de ser o mesmo ref.
* 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.
*/
export function evaluateAdminIdentity(attestation: AdminAttestation, env: EnvMap = process.env): void {
const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? "";
@@ -62,7 +71,9 @@ export function evaluateAdminIdentity(attestation: AdminAttestation, env: EnvMap
if (session !== "postgres" && session !== `postgres.${ref}`) {
throw new DatabaseGuardError("usuario_admin_nao_comprovado");
}
if (attestation.rolsuper !== true) throw new DatabaseGuardError("admin_sem_superuser");
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");
@@ -137,8 +148,10 @@ async function readDatabaseMarker(db: Db): Promise<Map<string, string>> {
/**
* Barreira de destino. Em development só aceita host local. Em staging e
* produção a web e o worker recusam superuser e BYPASSRLS; migrate, seed e
* flag só seguem com a identidade administrativa comprovada no catálogo.
* 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.
*/
export async function assertDatabaseGuard(db: Db, purpose: DbPurpose, connectionString: string | null, env: EnvMap = process.env): Promise<void> {
if (!isDeployedEnv(env)) {
+39 -1
View File
@@ -22,7 +22,7 @@ function attestation(overrides: Partial<AdminAttestation> = {}): AdminAttestatio
return {
sessionUser: "postgres",
databaseName: "postgres",
rolsuper: true,
rolsuper: false,
rolbypassrls: true,
hasAuth: true,
hasStorage: true,
@@ -40,6 +40,35 @@ describe("barreira do banco de homologação", () => {
it("aceita o destino quando o catálogo, o papel e a URL concordam", () => {
expect(assertDeployedUrl(adminUrl(), "admin", env).hostname).toBe(DASHBOARD_HOST);
expect(() => evaluateAdminIdentity(attestation(), env)).not.toThrow();
expect(() => evaluateAdminIdentity(attestation({ sessionUser: `postgres.${REF}` }), env)).not.toThrow();
});
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, 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,
),
).toThrow(/marcador_ausente/);
expect(() =>
evaluateAdminIdentity(attestation({ sessionUser: "supabase_admin", rolsuper: false, rolbypassrls: true }), env),
).toThrow(/usuario_admin_nao_comprovado/);
expect(() => evaluateAdminIdentity(attestation({ rolsuper: null, rolbypassrls: true }), env)).toThrow(
/papel_nao_comprovado/,
);
});
it("exige o host do Dashboard e recusa índice, região ou domínio diferentes", () => {
@@ -105,6 +134,15 @@ describe("barreira do banco de homologação", () => {
});
it("recusa a credencial administrativa na web e BYPASSRLS no worker", () => {
expect(() => evaluateRuntimeRole({ sessionUser: "postgres", rolsuper: false, rolbypassrls: true }, "web", env)).toThrow(
/web_com_credencial_admin/,
);
expect(() =>
evaluateRuntimeRole({ sessionUser: "adaptador_web", rolsuper: false, rolbypassrls: true }, "web", env),
).toThrow(/web_com_credencial_admin/);
expect(() =>
evaluateRuntimeRole({ sessionUser: "adaptador_worker", rolsuper: true, rolbypassrls: false }, "worker", env),
).toThrow(/worker_privilegiado/);
expect(() => evaluateRuntimeRole({ sessionUser: "postgres", rolsuper: true, rolbypassrls: true }, "web", env)).toThrow(
/web_com_credencial_admin/,
);