diff --git a/migrations/0005_maintenance_roles.sql b/migrations/0005_maintenance_roles.sql index 03b5045..423f180 100644 --- a/migrations/0005_maintenance_roles.sql +++ b/migrations/0005_maintenance_roles.sql @@ -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 diff --git a/src/infra/db/target-guard.ts b/src/infra/db/target-guard.ts index c543a3b..8b3ae34 100644 --- a/src/infra/db/target-guard.ts +++ b/src/infra/db/target-guard.ts @@ -35,23 +35,32 @@ export function databaseSettings(items: string[]): Map { } /** - * 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.` 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.` 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.`, + * 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> { /** * 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 { if (!isDeployedEnv(env)) { diff --git a/tests/unit/database-guard.test.ts b/tests/unit/database-guard.test.ts index e78ff88..0d7fba5 100644 --- a/tests/unit/database-guard.test.ts +++ b/tests/unit/database-guard.test.ts @@ -22,7 +22,7 @@ function attestation(overrides: Partial = {}): 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/, );