Corrige os testes de RLS e auditoria contra PostgreSQL real
ci / lint, typecheck, testes e build (push) Has been cancelled
ci / Postgres e Redis reais (push) Has been cancelled
ci / imagem Docker Linux (push) Has been cancelled

A verificação de FORCE RLS lia pg_class sem filtrar relkind e pegava
índices como spreadsheet_adapter_settings_pkey, que nunca têm RLS. Agora
lê só tabelas (r/p) do schema public e compara com a lista exata das dez
tabelas protegidas pela 0003_rls.sql.

O teste de auditoria reutilizava $1 como uuid (workspace_id) e text
(resource_id), e o Postgres recusava antes de testar a permissão. Cada
coluna tem seu parâmetro e o teste exige o código 42501 em update e
delete, confirmando que o registro continua intacto.

Regressão do catálogo de RLS também roda na suíte padrão (PGlite).
Nenhuma migration, política ou permissão foi alterada.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
camilafagundesandClaude Opus 5.5 committed 2026-10-16 14:06:47 -03:00
1 parent b8586ba0aa
commit 84ad5c4757
3 files changed
+75 -12

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
import type { Query } from "@/infra/db/types";
/** Tabelas que as migrations colocam sob RLS + FORCE RLS (migrations/0003_rls.sql). */
export const RLS_TABLES = [
"audit_log",
"profiles",
"spreadsheet_adapter_settings",
"spreadsheet_job_mappings",
"spreadsheet_job_sources",
"spreadsheet_jobs",
"spreadsheet_models",
"workspace_members",
"workspace_module_flags",
"workspaces",
] as const;
export type RlsRow = { relname: string; relkind: string; relrowsecurity: boolean; relforcerowsecurity: boolean };
/**
* Só tabelas comuns e particionadas (`relkind` r/p) do schema public. Sem esse
* filtro, `like 'spreadsheet%'` também traz índices (`*_pkey`, relkind `i`),
* que nunca têm RLS e fazem a verificação falhar por engano.
*/
export async function readRlsCatalog(query: Query): Promise<RlsRow[]> {
return query.query<RlsRow>(
`select c.relname, c.relkind::text as relkind, c.relrowsecurity, c.relforcerowsecurity
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind in ('r', 'p')
and (c.relname like 'spreadsheet%' or c.relname = any($1::text[]))
order by c.relname`,
[[...RLS_TABLES]],
);
}
+13
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createModel } from "@/server/spreadsheet-adapter/service"; import { createModel } from "@/server/spreadsheet-adapter/service";
import { ctxOf, createReadyDb, depsOf, IDS } from "../helpers/world"; import { ctxOf, createReadyDb, depsOf, IDS } from "../helpers/world";
import { readRlsCatalog, RLS_TABLES } from "../helpers/rls-catalog";
describe("RLS e permissões", () => { describe("RLS e permissões", () => {
it("impede leitura e escrita entre workspaces", async () => { it("impede leitura e escrita entre workspaces", async () => {
@@ -71,4 +72,16 @@ describe("RLS e permissões", () => {
expect(rows.every((row) => row.action !== "tamper")).toBe(true); expect(rows.every((row) => row.action !== "tamper")).toBe(true);
await db.close(); await db.close();
}); });
it("verifica RLS e FORCE RLS só em tabelas, nunca em índices", async () => {
const db = await createReadyDb();
const rows = await db.withService(readRlsCatalog);
expect(rows.map((row) => row.relname)).toEqual([...RLS_TABLES]);
expect(rows.some((row) => row.relname.endsWith("_pkey"))).toBe(false);
for (const row of rows) {
expect(row.relrowsecurity, `${row.relname} sem RLS`).toBe(true);
expect(row.relforcerowsecurity, `${row.relname} sem FORCE RLS`).toBe(true);
}
await db.close();
});
}); });
+27 -12
View File
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createPostgresDb } from "@/infra/db/postgres"; import { createPostgresDb } from "@/infra/db/postgres";
import { migrate, pendingMigrations } from "@/infra/db/migrate"; import { migrate, pendingMigrations } from "@/infra/db/migrate";
import type { Db } from "@/infra/db/types"; import type { Db } from "@/infra/db/types";
import { readRlsCatalog, RLS_TABLES } from "../helpers/rls-catalog";
/** /**
* RLS e isolamento multi-tenant contra PostgreSQL real. A suíte padrão usa * RLS e isolamento multi-tenant contra PostgreSQL real. A suíte padrão usa
@@ -68,14 +69,10 @@ describe.skipIf(!enabled)("PostgreSQL real", () => {
}); });
it("força RLS em todas as tabelas do módulo", async () => { it("força RLS em todas as tabelas do módulo", async () => {
const rows = await db.withService((query) => const rows = await db.withService(readRlsCatalog);
query.query<{ relname: string; relrowsecurity: boolean; relforcerowsecurity: boolean }>( expect(rows.map((row) => row.relname)).toEqual([...RLS_TABLES]);
`select relname, relrowsecurity, relforcerowsecurity from pg_class
where relname like 'spreadsheet%' or relname in ('profiles','workspaces','workspace_members','audit_log')`,
),
);
expect(rows.length).toBeGreaterThan(0);
for (const row of rows) { for (const row of rows) {
expect(["r", "p"], `${row.relname} não é tabela`).toContain(row.relkind);
expect(row.relrowsecurity, `${row.relname} sem RLS`).toBe(true); expect(row.relrowsecurity, `${row.relname} sem RLS`).toBe(true);
expect(row.relforcerowsecurity, `${row.relname} sem FORCE RLS`).toBe(true); expect(row.relforcerowsecurity, `${row.relname} sem FORCE RLS`).toBe(true);
} }
@@ -111,16 +108,34 @@ describe.skipIf(!enabled)("PostgreSQL real", () => {
}); });
it("não deixa o papel da aplicação alterar auditoria", async () => { it("não deixa o papel da aplicação alterar auditoria", async () => {
// workspace_id é uuid e resource_id é text: cada um tem o seu parâmetro,
// senão o Postgres recusa com "inconsistent types deduced for parameter".
await db.withService((query) => await db.withService((query) =>
query.query( query.query(
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result) `insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result)
values ($1,$2,'test.event','workspace',$1,'success')`, values ($1::uuid, $2::uuid, 'test.event', 'workspace', $3::text, 'success')`,
[ALPHA, ANA], [ALPHA, ANA, ALPHA],
), ),
); );
await expect(
db.withUser(ANA, (query) => query.query(`update public.audit_log set result = 'tampered'`)), // A recusa tem de vir da falta de privilégio (42501), não de sintaxe ou tipo.
).rejects.toThrow(); for (const statement of [
`update public.audit_log set result = 'error' where workspace_id = $1::uuid`,
`delete from public.audit_log where workspace_id = $1::uuid`,
]) {
await expect(
db.withUser(ANA, (query) => query.query(statement, [ALPHA])),
).rejects.toMatchObject({ code: "42501", message: expect.stringMatching(/permission denied/i) });
}
const rows = await db.withService((query) =>
query.query<{ result: string }>(
`select result from public.audit_log where workspace_id = $1::uuid and action = 'test.event'`,
[ALPHA],
),
);
expect(rows.length).toBeGreaterThan(0);
expect(rows.every((row) => row.result === "success")).toBe(true);
}); });
it("pula o shim quando a decisão é não aplicar", async () => { it("pula o shim quando a decisão é não aplicar", async () => {