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. -- 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. -- 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. -- 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. -- 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 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 * Prova administrativa do Supabase gerenciado. O hostname, o nome `postgres`,
* variável de ambiente, cada um sozinho, não passam. Os fatos do servidor * um GUC de sessão, a variável de ambiente e o atributo BYPASSRLS, cada um
* precisam concordar: schemas do Supabase, papel autenticado e marcador * sozinho, não passam. Os fatos do servidor precisam concordar: schemas
* persistido com ALTER DATABASE. * `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 * 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 * 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` * 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 * 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 * como prova. Outro parâmetro já gravado, como `app.settings.jwt_exp`, não
* DATABASE. A cópia só passaria se a URL também trouxesse o pooler declarado, * identifica o projeto e não é alterado por esta verificação. Quem puder
* o usuário `postgres.<ref>` e o ref do marcador. Sem o marcador, ou com * executar ALTER DATABASE em outro banco pode copiar os mesmos marcadores.
* divergência, a operação para. * 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 * Limitação do Session Pooler: o usuário da URL tem a forma `papel.<ref>`,
* ref. Nesse caso o ref autenticado está no usuário da URL, e o vínculo com * mas o servidor pode devolver `session_user` só como `postgres`, sem o
* este projeto está no marcador. Os dois têm de ser o mesmo ref. * 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 { export function evaluateAdminIdentity(attestation: AdminAttestation, env: EnvMap = process.env): void {
const ref = env.ADAPTER_DB_PROJECT_REF?.trim() ?? ""; 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}`) { if (session !== "postgres" && session !== `postgres.${ref}`) {
throw new DatabaseGuardError("usuario_admin_nao_comprovado"); 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 project = attestation.databaseSettings.get("app.hml_project");
const markedRef = attestation.databaseSettings.get("app.hml_project_ref"); const markedRef = attestation.databaseSettings.get("app.hml_project_ref");
if (!project || !markedRef) throw new DatabaseGuardError("marcador_ausente"); 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 * 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 * produção a web e o worker recusam SUPERUSER e BYPASSRLS. A credencial
* flag só seguem com a identidade administrativa comprovada no catálogo. * 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> { export async function assertDatabaseGuard(db: Db, purpose: DbPurpose, connectionString: string | null, env: EnvMap = process.env): Promise<void> {
if (!isDeployedEnv(env)) { if (!isDeployedEnv(env)) {
+39 -1
View File
@@ -22,7 +22,7 @@ function attestation(overrides: Partial<AdminAttestation> = {}): AdminAttestatio
return { return {
sessionUser: "postgres", sessionUser: "postgres",
databaseName: "postgres", databaseName: "postgres",
rolsuper: true, rolsuper: false,
rolbypassrls: true, rolbypassrls: true,
hasAuth: true, hasAuth: true,
hasStorage: 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", () => { it("aceita o destino quando o catálogo, o papel e a URL concordam", () => {
expect(assertDeployedUrl(adminUrl(), "admin", env).hostname).toBe(DASHBOARD_HOST); expect(assertDeployedUrl(adminUrl(), "admin", env).hostname).toBe(DASHBOARD_HOST);
expect(() => evaluateAdminIdentity(attestation(), env)).not.toThrow(); 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", () => { 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", () => { 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( expect(() => evaluateRuntimeRole({ sessionUser: "postgres", rolsuper: true, rolbypassrls: true }, "web", env)).toThrow(
/web_com_credencial_admin/, /web_com_credencial_admin/,
); );