Corrige os testes de RLS e auditoria contra PostgreSQL real
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:
1 parent
b8586ba0aa
commit
84ad5c4757
3 files changed
+75
-12
No files matched your search
@@ -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]],
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createModel } from "@/server/spreadsheet-adapter/service";
|
||||
import { ctxOf, createReadyDb, depsOf, IDS } from "../helpers/world";
|
||||
import { readRlsCatalog, RLS_TABLES } from "../helpers/rls-catalog";
|
||||
|
||||
describe("RLS e permissões", () => {
|
||||
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);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createPostgresDb } from "@/infra/db/postgres";
|
||||
import { migrate, pendingMigrations } from "@/infra/db/migrate";
|
||||
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
|
||||
@@ -68,14 +69,10 @@ describe.skipIf(!enabled)("PostgreSQL real", () => {
|
||||
});
|
||||
|
||||
it("força RLS em todas as tabelas do módulo", async () => {
|
||||
const rows = await db.withService((query) =>
|
||||
query.query<{ relname: string; relrowsecurity: boolean; relforcerowsecurity: boolean }>(
|
||||
`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);
|
||||
const rows = await db.withService(readRlsCatalog);
|
||||
expect(rows.map((row) => row.relname)).toEqual([...RLS_TABLES]);
|
||||
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.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 () => {
|
||||
// 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) =>
|
||||
query.query(
|
||||
`insert into public.audit_log (workspace_id, actor_id, action, resource_type, resource_id, result)
|
||||
values ($1,$2,'test.event','workspace',$1,'success')`,
|
||||
[ALPHA, ANA],
|
||||
values ($1::uuid, $2::uuid, 'test.event', 'workspace', $3::text, 'success')`,
|
||||
[ALPHA, ANA, ALPHA],
|
||||
),
|
||||
);
|
||||
await expect(
|
||||
db.withUser(ANA, (query) => query.query(`update public.audit_log set result = 'tampered'`)),
|
||||
).rejects.toThrow();
|
||||
|
||||
// A recusa tem de vir da falta de privilégio (42501), não de sintaxe ou tipo.
|
||||
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 () => {
|
||||
|
||||
Reference in new issue
Block a user