Merge remote-tracking branch 'origin/main' into feat/extensoes-declarativas

This commit is contained in:
Pessoa
2026-09-17 11:27:25 -03:00
10 changed files with 248 additions and 28 deletions
+4 -3
View File
@@ -98,9 +98,10 @@ DeskcommCRM é um sistema operacional de vendas open source com agentes de IA na
sonda de `tests/invariants/retencao-poda-e-expurgo.test.ts` ficou verde duas
vezes medindo o universo errado: primeiro perguntando só por DELETE/UPDATE com
TRUNCATE concedido ao lado; depois perguntando pelos três num Postgres onde o
prelude de `scripts/test-db.sh` reproduz o default ACL do Supabase para
FUNÇÕES e não para TABELAS — um banco onde o defeito não pode existir. Quem
mede o Supabase real é
prelude de `scripts/test-db.sh` reproduzia o default ACL do Supabase só para
FUNÇÕES — um banco onde o defeito não podia existir. Desde a issue #887 o
prelude reproduz também o de TABELAS, e aquela sonda passou a medir o Supabase.
A prova com controle próprio segue sendo
`tests/invariants/audit-log-sob-o-default-acl-do-supabase.test.ts`: concede o
default ACL à tabela, reaplica o bloco da 0258 extraído do baseline e só então
sonda. **Enumerar privilégios no dump não protege tabela nenhuma no Supabase
+61
View File
@@ -210,6 +210,33 @@ alter default privileges for role postgres in schema public grant all on functio
alter default privileges for role postgres in schema public grant all on functions to service_role;
alter default privileges for role postgres in schema public revoke execute on functions from public;
-- O MESMO DEFAULT ACL, PARA TABELAS (issue #887).
--
-- O bloco acima cobria só funções, e o gate ficava cego para privilégio das
-- tabelas do CORPO do dump. Num Supabase de verdade toda tabela criada em
-- `public` nasce com privilégio total para anon, authenticated e service_role, e
-- o `GRANT` que o dump enumera depois só ACRESCENTA — não revoga nada. Aqui, sem
-- estas linhas, a tabela do corpo nascia só com o que o dump concede, e um
-- invariante do tipo "o papel X não tem o privilégio Y na tabela Z" sobre ela
-- ficava verde por construção. As tabelas do apêndice nunca tiveram o problema:
-- nascem depois do `ALTER DEFAULT PRIVILEGES … ON TABLES` que o próprio dump grava.
-- Foi assim que o `service_role` seguia apagando e reescrevendo linhas de
-- `api_audit_log` com este gate verde, até a migration 0258.
--
-- Medido em 2026-09-17 no `pg_default_acl` de um Supabase local
-- (supabase/postgres:17.6.1.106):
--
-- postgres | public | TABLES | {postgres=arwdDxtm,anon=arwdDxtm,
-- authenticated=arwdDxtm,service_role=arwdDxtm}
--
-- `grant all` reproduz as duas majors: o `m` (MAINTAIN) só existe no pg17.
--
-- `scripts/test-update-com-dados.sh` extrai este bloco inteiro, então as linhas
-- abaixo valem para os dois scripts.
alter default privileges for role postgres in schema public grant all on tables to anon;
alter default privileges for role postgres in schema public grant all on tables to authenticated;
alter default privileges for role postgres in schema public grant all on tables to service_role;
create schema if not exists auth;
create schema if not exists extensions;
@@ -343,6 +370,40 @@ if [ "$fidelidade" != "t" ]; then
fi
echo " ✓ definer nova nasce com grant direto a anon (armadilha do produto reproduzida)"
# A GÊMEA PARA TABELAS (issue #887), no mesmo instante e pelo mesmo motivo da de
# funções. O próprio baseline grava um `ALTER DEFAULT PRIVILEGES … ON TABLES`,
# DEPOIS das tabelas do corpo do dump: uma tabela de sonda criada depois do
# baseline nasceria certa com ou sem as linhas do prelude. Só antes dele a sonda
# mede o prelude e nada além. Depois do baseline os dois bancos ainda diferem,
# mas só nas tabelas do corpo cujo GRANT enumerado omite o privilégio:
# grep -nE '^GRANT [A-Z,]+ ON TABLE' supabase/baseline.sql | grep -v 'GRANT ALL'
#
# Mede os TRÊS papéis, e não só anon como a sonda de funções: o dano que abriu a
# issue foi do service_role, e uma sonda que olhasse só anon aprovaria o prelude
# sem a linha dele. DELETE é o privilégio medido porque é o que apaga linha de
# tabela append-only.
fidelidade_tabelas="$(docker exec -i "$CONTAINER" psql -U postgres -d "$TEMPLATE" -v ON_ERROR_STOP=1 -q -tA -f - <<'SQL'
create table public.sonda_fidelidade_do_harness (id int);
select count(distinct a.grantee)
from pg_class c, aclexplode(c.relacl) a
where c.oid = 'public.sonda_fidelidade_do_harness'::regclass
and a.privilege_type = 'DELETE'
and a.grantee in ('anon'::regrole, 'authenticated'::regrole, 'service_role'::regrole);
drop table public.sonda_fidelidade_do_harness;
SQL
)"
if [ "$fidelidade_tabelas" != "3" ]; then
echo "FATAL: neste banco uma tabela nova em public NÃO nasce com DELETE direto para anon," >&2
echo " authenticated e service_role (achei ${fidelidade_tabelas:-nada} de 3). Num projeto" >&2
echo " Supabase de verdade ela nasce, porque o bootstrap grava um ALTER DEFAULT PRIVILEGES" >&2
echo " … ON TABLES em pg_default_acl antes de qualquer SQL nosso. Sem reproduzir isso," >&2
echo " um invariante que afirme 'o papel X não tem o privilégio Y na tabela Z' sobre uma" >&2
echo " tabela do corpo do dump fica VERDE por construção (issue #887). Restaure as 3 linhas de" >&2
echo " 'alter default privileges … on tables' no prelude acima." >&2
exit 1
fi
echo " ✓ tabela nova nasce com DELETE direto para anon, authenticated e service_role"
echo "==> modo INSTALL: aplicando baseline.sql com ON_ERROR_STOP=1"
psql_install < "$BASELINE"
echo " ✓ install ok"
+6 -4
View File
@@ -24558,10 +24558,12 @@ comment on function public.fn_nascer_lead_da_conversa(uuid, uuid, uuid, uuid, te
-- três papéis podiam esvaziá-la com TRUNCATE. `anon`/`authenticated` só não
-- apagavam porque a RLS não tem policy de UPDATE/DELETE.
--
-- O prelude do `test:db` reproduz o default ACL do Supabase para funções, não
-- para tabelas; por isso o gate de grants ficava verde. O invariante
-- `audit-log-sob-o-default-acl-do-supabase` reproduz o de tabela e reaplica
-- ESTE bloco, extraído daqui pelo rótulo.
-- Até a issue #887 o prelude do `test:db` reproduzia o default ACL do Supabase
-- só para funções, e por isso o gate de grants ficou verde para UPDATE e DELETE
-- enquanto eles estavam abertos. O TRUNCATE vinha do próprio `GRANT` do dump e
-- ficou verde por outro motivo: a sonda não perguntava por ele. O invariante
-- `audit-log-sob-o-default-acl-do-supabase`
-- reproduz o de tabela e reaplica ESTE bloco, extraído daqui pelo rótulo.
--
-- O expurgo legítimo não depende destes grants: `fn_expurgar_auditoria_vencida`
-- (0167) é `security definer` de dono `postgres`. As FKs `on delete set null`
@@ -21,13 +21,15 @@ import { motivoDoErro, sql } from "./psql-transporte";
*
* ─── Por que um arquivo próprio ─────────────────────────────────────────────
*
* O prelude de `scripts/test-db.sh` reproduz o default ACL do Supabase para
* FUNÇÕES, não para TABELAS. No Postgres do gate a tabela nasce só com o que o
* dump concede, e a sonda de `retencao-poda-e-expurgo.test.ts` fica verde com
* ou sem o revoke de UPDATE/DELETE — ela mede um universo onde o defeito não
* pode existir. Mudar o prelude muda a régua de todos os arquivos da suíte; este
* arquivo reproduz o Supabase só para esta tabela, dentro de uma transação
* desfeita, e deixa o molde intacto.
* Ele nasceu porque o prelude de `scripts/test-db.sh` reproduzia o default ACL
* do Supabase só para FUNÇÕES: no gate a tabela nascia só com o que o dump
* concede, e a sonda de `retencao-poda-e-expurgo.test.ts` ficava verde com ou
* sem o revoke de UPDATE/DELETE. Desde a issue #887 o prelude reproduz também o
* de TABELAS, e aquela sonda passou a medir o Supabase. Este arquivo continua
* porque mede o que ela não mede: o privilégio EFETIVO, inclusive o herdado de
* outro papel; o erro de permissão nos três comandos, e não só a ausência de
* grant; que INSERT e SELECT seguem de pé; o expurgo e as FKs. E tem controle
* próprio: sem o bloco da 0258, a simulação reproduz o defeito e apaga a linha.
*
* ─── Como ───────────────────────────────────────────────────────────────────
*
@@ -4,7 +4,7 @@ import { GOV_ADMIN, GOV_MANAGER, GOV_ORG, GOV_VIEWER, seedGov, sql } from "./gov
/**
* AS CREDENCIAIS DE IA SÃO LIDAS POR QUEM NÃO É ADMIN — E O SEGREDO NÃO É
* (migration 0206, issue #292).
* (migration 0207, issue #292).
*
* ─── O defeito ──────────────────────────────────────────────────────────────
*
@@ -65,7 +65,7 @@ beforeAll(() => {
seedCredencial();
});
describe("0206 — a lista de credenciais de IA é legível por quem não é admin", () => {
describe("0207 — a lista de credenciais de IA é legível por quem não é admin", () => {
it("⭐ manager LÊ a view segura — a tela dele deixa de vir vazia", () => {
const saida = leComo(
GOV_MANAGER,
@@ -79,7 +79,7 @@ describe("0206 — a lista de credenciais de IA é legível por quem não é adm
expect(String(saida)).toContain("Chave da leitura");
});
it("viewer também LÊ — a tela é read-only e não é admin-gated", () => {
it("viewer também LÊ no banco — a policy é por organização, sem gate de papel; quem barra o viewer é a tela", () => {
const saida = leComo(
GOV_VIEWER,
`select label from public.ai_provider_credentials_safe where id = '${CRED_LEITURA}';`,
@@ -97,7 +97,7 @@ describe("0206 — a lista de credenciais de IA é legível por quem não é adm
});
});
describe("0206 — e reabrir a LEITURA não reabre o SEGREDO", () => {
describe("0207 — e reabrir a LEITURA não reabre o SEGREDO", () => {
it("⭐ manager NÃO alcança api_key_encrypted", () => {
// Sem este caso, um "conserto" que devolvesse `grant select` na tabela
// inteira ficaria verde nos casos acima — e entregaria o ciphertext, o iv e
@@ -0,0 +1,141 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import pg from "pg";
import {
buildCompromissosBlock,
compromissosDoContato,
} from "@/lib/agent-engine/agent/compromissos-do-contato";
/**
* O MOTOR DA ORGANIZAÇÃO A NÃO LÊ OS COMPROMISSOS DA B (issue #545, item 1).
*
* ═══ Por que esta leitura precisa de invariante próprio ═══
*
* `inbound-turn.ts` monta o bloco de compromissos com o `pool` do motor, que
* IGNORA RLS. O `organization_id = $1` da consulta é o filtro de organização
* desse caminho. O que caía nele vai para o prompt do agente, e dali para o
* cliente de outra empresa.
*
* A guarda que existia (`tests/unit/o-agente-enxerga-os-compromissos-do-contato`)
* conferia o TEXTO do SQL contra um dublê. Trocar o filtro por
* `(organization_id = $1 or true)` mantinha a substring e deixava aquela suíte
* inteira verde, lendo compromissos de todas as organizações. Aqui a consulta
* roda num Postgres real e o que se confere são as linhas que ela devolve.
*
* ═══ A outra camada, e por que ela não dispensa esta ═══
*
* Desde a migration 0224, o gatilho `fn_appointment_stamp` recusa gravar
* compromisso cujo contato seja de outra organização. Isso impede a LINHA
* cruzada. Não impede um CHAMADOR que passe a organização A com o contato da B,
* nem alcança linha gravada antes da 0224. É esse chamador que o caso cruzado
* abaixo simula.
*
* O `agora` é fixo e o compromisso fica num futuro fixo: a consulta compara
* `ends_at >= $3`, e usar o relógio do processo faria o caso depender da data
* em que a suíte roda.
*/
const container = process.env.TEST_DB_CONTAINER;
if (!container) {
throw new Error("TEST_DB_CONTAINER not set — rode via `pnpm test:db` (scripts/test-db.sh)");
}
const PORT = Number(process.env.TEST_DB_PORT ?? 54329);
const pool = new pg.Pool({
host: "127.0.0.1",
port: PORT,
user: "postgres",
password: "postgres",
database: "postgres",
});
const ORG_A = "c0a1a545-0000-4000-8000-00000000000a";
const ORG_B = "c0a1a545-0000-4000-8000-00000000000b";
const AGORA = new Date("2026-01-01T12:00:00.000Z");
const TITULO_DA_B = "Retorno da cliente da B";
const TITULO_DA_A = "Avaliação do cliente da A";
let contatoDaB = "";
let contatoDaA = "";
beforeAll(async () => {
for (const [id, slug] of [
[ORG_A, "compromisso-cross-a"],
[ORG_B, "compromisso-cross-b"],
] as const) {
await pool.query(
`insert into organizations (id, slug, legal_name, display_name)
values ($1, $2, 'Compromisso Cross LTDA', 'Compromisso Cross') on conflict (id) do nothing`,
[id, slug],
);
}
const b = await pool.query<{ id: string }>(
`insert into contacts (organization_id, name) values ($1, 'Cliente da B') returning id`,
[ORG_B],
);
contatoDaB = b.rows[0]!.id;
const a = await pool.query<{ id: string }>(
`insert into contacts (organization_id, name) values ($1, 'Cliente da A') returning id`,
[ORG_A],
);
contatoDaA = a.rows[0]!.id;
for (const [org, contato, titulo] of [
[ORG_B, contatoDaB, TITULO_DA_B],
[ORG_A, contatoDaA, TITULO_DA_A],
] as const) {
await pool.query(
`insert into calendar_appointments (organization_id, contact_id, title, starts_at, ends_at, status)
values ($1, $2, $3, '2030-03-10 14:00:00+00', '2030-03-10 15:00:00+00', 'confirmed')`,
[org, contato, titulo],
);
}
});
afterAll(async () => {
await pool.end();
});
describe("compromissos do contato, com duas organizações no banco", () => {
it("o cenário está montado: cada organização tem um contato com um compromisso futuro", async () => {
// Sem isto, o caso cruzado passa verde medindo um banco vazio.
expect(contatoDaA).not.toBe("");
expect(contatoDaB).not.toBe("");
const { rows } = await pool.query<{ organization_id: string; n: string }>(
`select organization_id, count(*)::text as n from calendar_appointments
where organization_id in ($1, $2) group by organization_id order by organization_id`,
[ORG_A, ORG_B],
);
expect(rows.map((r) => [r.organization_id, r.n])).toEqual([
[ORG_A, "1"],
[ORG_B, "1"],
]);
});
it("controle positivo: a organização dona lê o compromisso do próprio contato", async () => {
// Sem este caso, uma consulta que não devolvesse NADA deixaria o caso cruzado
// verde pelo motivo errado.
const daB = await compromissosDoContato(pool, ORG_B, contatoDaB, AGORA);
expect(daB.map((c) => c.title)).toEqual([TITULO_DA_B]);
const blocoDaA = await buildCompromissosBlock(pool, ORG_A, contatoDaA, AGORA);
expect(blocoDaA).toContain(TITULO_DA_A);
});
it("a organização A, chamada com o contato da B, não lê o compromisso da B", async () => {
const vazou = await compromissosDoContato(pool, ORG_A, contatoDaB, AGORA);
expect(
vazou.map((c) => c.title),
"o motor da organização A leu compromisso da organização B",
).toEqual([]);
});
it("e o bloco que vai para o prompt do agente da A fica vazio", async () => {
// É a função que `inbound-turn.ts` chama com o pool do motor, que ignora RLS.
const bloco = await buildCompromissosBlock(pool, ORG_A, contatoDaB, AGORA);
expect(bloco, "o prompt do agente da organização A recebeu compromisso da B").toBe("");
});
});
@@ -217,12 +217,13 @@ describe("append-only: por onde o expurgo pode passar, e por onde não pode", ()
// outras tabelas: `grep -nE '^GRANT [A-Z,]+ ON TABLE' supabase/baseline.sql
// | grep -v 'GRANT ALL'`).
//
// ⚠️ E ESTE CASO NÃO MEDE O SUPABASE REAL. O prelude do `test-db.sh`
// reproduz o default ACL do Supabase para funções, não para tabelas: aqui
// `api_audit_log` nasce só com o que o dump concede, e o caso fica verde
// com ou sem o revoke de UPDATE/DELETE da migration 0258. No Supabase o
// default ACL de tabelas dá UPDATE e DELETE aos três papéis; quem mede esse
// mundo é `audit-log-sob-o-default-acl-do-supabase.test.ts`.
// ⚠️ ESTE CASO SÓ MEDE O SUPABASE REAL DESDE A ISSUE #887. Até ela, o
// prelude do `test-db.sh` reproduzia o default ACL do Supabase só para
// funções: `api_audit_log` nascia só com o que o dump concede, e o caso
// ficava verde com ou sem o revoke de UPDATE/DELETE da migration 0258.
// Agora a tabela nasce com o que o Supabase dá, e o caso reprova sem esse
// revoke. Medido tirando `update, delete` do bloco da 0258: vermelho com o
// prelude novo, verde com o antigo.
const linhas = sql(`
select coalesce(string_agg(grantee || ':' || privilege_type, ',' order by grantee), '')
from information_schema.role_table_grants
@@ -249,7 +249,6 @@ const DEBITO_CONHECIDO: readonly Excecao[] = [
"ai_invocations",
"ai_knowledge_sources",
"ai_knowledge_versions",
"ai_provider_credentials",
"ai_purpose_bindings",
"ai_router_members",
"api_audit_log",
+13
View File
@@ -265,6 +265,12 @@ beforeAll(() => {
'auth-rls'
);
end if;
if not exists (select 1 from public.ai_provider_credentials where organization_id = v_org) then
insert into public.ai_provider_credentials
(organization_id, provider, label, api_key_encrypted, api_key_iv, api_key_tag, api_key_last4)
values (v_org, 'anthropic', 'rls-invariant', '\\x00'::bytea, '\\x00'::bytea, '\\x00'::bytea, '0000');
end if;
end loop;
end
$seed$;
@@ -327,6 +333,13 @@ export const TABLES = [
// aceitou o risco do segundo aparelho vinculado: vazar entre organizacoes
// diria a uma empresa quem, na outra, ligou a feature e quando.
"org_voice_calls",
// migration 0207 — as credenciais de IA da organização. A 0150 apagou a policy
// de leitura por organização sem que nada acusasse, e a 0207 a restaurou; esta
// linha é o que passa a acusar se ela sumir de novo (issue #545). A leitura é
// org-scoped sem gate de papel, então o `agent` semeado serve de controle
// positivo. O SELECT de `authenticated` é por COLUNA, sem as colunas cifradas:
// a contagem abaixo usa só `organization_id` e mede o que um membro enxerga.
"ai_provider_credentials",
// ⚠️ `webhook_lead_captures` (migration 0174) NÃO entra nesta lista, e a
// ausência é deliberada: a policy dela exige `manager`, e o usuário semeado
// aqui é `agent` — o controle positivo falharia por ACERTO, e a "correção"
@@ -104,9 +104,9 @@ const DEFEITO_DA_V1260 = `
/**
* O default ACL de TABELAS que todo projeto Supabase grava antes de qualquer SQL
* nosso, reconstruído dentro da transação (o `rollback` o desfaz). O prelude do
* `test:db` não o reproduz (issue #887); hoje ele chega mesmo assim, pelo `ALTER
* DEFAULT PRIVILEGES … ON TABLES` que o próprio dump emite — e depender disso
* faria o caso medir o dump, não o Supabase. As duas metades têm alvos diferentes:
* `test:db` o reproduz desde a issue #887, e o próprio dump também emite um
* `ALTER DEFAULT PRIVILEGES … ON TABLES` — depender de qualquer um dos dois faria
* o caso medir o ambiente do gate, não o Supabase. As duas metades têm alvos diferentes:
*
* - o `alter default privileges` decide o ACL de relação CRIADA DEPOIS, e o bloco
* da 0261 CRIA a view (`drop` + `create`). É ele que dá tudo a `anon` na view