fix(casos): o caso só nasce do motor, e a passagem de dono também

Três tabelas nasceram graváveis por QUALQUER membro da organização, pelas
duas origens: o `ALTER DEFAULT PRIVILEGES … GRANT ALL ON TABLES TO
"authenticated"` do baseline, que vale para toda tabela criada depois dele,
e uma policy `for all`/`for insert` sem papel mínimo.

O que se pagava: um `viewer` escrevia, pelo PostgREST e com o JWT dele, o
título/resumo/bloqueio que a equipe lê para decidir um caso — e, com o aviso
de caso por WhatsApp (próximas ondas), esse texto sairia no celular da equipe
pelo número da empresa, com o link legítimo ao lado. Em
`conversation_assignment_events`, um INSERT forjado faz o histórico afirmar
que alguém assumiu um atendimento que ninguém assumiu.

A migration 0279 fecha as duas origens nas três tabelas e reserva
`ai.case_opened`/`ai.case_closed` no `emit_event` (a reserva sozinha não
fecha nada: ela só barra quem tem `auth.uid()`; quem fecha a forja da linha
é o revoke). SELECT continua aberto nas três — a tela, o MCP e o motor leem.

Censo no cabeçalho da migration: nenhum caminho legítimo escreve por
`authenticated`. O motor usa `pg.Pool`, o cron usa service role, e
claim/transfer/release passam por `fn_conversation_assign`, que é
`security definer` — o controle positivo do invariante novo prova isso.

O baseline deixou de CRIAR as três policies largas para derrubá-las no fim:
criar e derrubar no mesmo arquivo faz a regra antiga valer entre os dois
pontos de toda instalação.

`tests/invariants/gov-3-assignment-events.test.ts` ganhou uma ponte mínima:
ele traduzia "barrado" em zero linhas (negação por RLS), e agora o Postgres
barra antes, por privilégio. As asserções não mudaram.

PENDENTE, e declarado: `pnpm test:db` não foi exercitado — o Docker local
está devolvendo 500 em `docker exec` sob a carga desta máquina (208 arquivos
morreram no setup, 1 asserção). Verde hoje: typecheck, lint e os seis gates
estáticos do baseline (ordem do apêndice, derivação da cadeia, e o gate que
proíbe o baseline construir o que ele mesmo derruba).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PqKEskriDoLeY45kmFtCgE
This commit is contained in:
Pessoa
2026-09-17 19:09:31 -03:00
co-authored by Claude Opus 5
parent c21e0e3ee8
commit 51cf0c9de9
8 changed files with 837 additions and 19 deletions
+21
View File
@@ -0,0 +1,21 @@
---
impacto: nada_mudou
secao: corrigido
titulo: Quem só acompanha o atendimento não consegue mais escrever o que a IA anotou sobre ele
---
Quando o atendimento automático trava e chama uma pessoa, o sistema abre um chamado com o que
a IA entendeu: o título, o resumo da conversa e o que ficou faltando para resolver. É esse
texto que a sua equipe lê antes de assumir. Ele nascia do robô — mas o banco de dados aceitava
que qualquer pessoa da sua organização o reescrevesse por fora do sistema, inclusive quem você
cadastrou apenas como Somente leitura. O mesmo valia para o histórico de quem assumiu cada
conversa: dava para inserir um registro dizendo que alguém pegou um atendimento que ninguém
pegou.
Agora esses três registros só são escritos pelo próprio sistema. Ler continua exatamente como
era: a tela de chamados, o histórico de quem assumiu a conversa e o que o agente de IA enxerga
não mudaram em nada. Assumir, transferir e devolver conversa também seguem funcionando igual —
esses botões nunca escreveram direto no banco, eles pedem ao sistema, e é o sistema que
registra.
Você não precisa fazer nada. A correção entra sozinha quando você atualiza.
+5 -1
View File
@@ -83,7 +83,11 @@ create table if not exists conversation_assignment_events (
create index if not exists idx_cae_conversation
on conversation_assignment_events (conversation_id, created_at desc);
-- RLS: tenant org via fn_user_org_ids() (SELECT + INSERT).
-- RLS de SELECT: escopo da conversa (cae_select, migration 0173).
-- INSERT: NÃO existe policy nem GRANT para authenticated desde a 0279 — a linha
-- é escrita por dentro de fn_conversation_assign, que é security definer. Um
-- INSERT forjado pelo PostgREST fazia o histórico dizer que alguém assumiu o
-- atendimento que ninguém assumiu.
-- Append-only: sem policy de UPDATE/DELETE (mesma família de api_audit_log).
```
+8 -1
View File
@@ -217,7 +217,14 @@ create table if not exists agent_case_events (
);
create index if not exists agent_case_events_case_idx on agent_case_events (case_id, created_at);
```
Append-only, sem RLS de UPDATE/DELETE (como `api_audit_log`). RLS select/insert por org.
Append-only, sem RLS de UPDATE/DELETE (como `api_audit_log`). **RLS de SELECT por org — e só
ela, desde a migration 0279**: a policy de INSERT saiu junto com o GRANT de escrita de
`authenticated`, aqui e em `agent_cases`. Quem escreve caso é o motor (`pg.Pool` em
`lib/agent-engine/agent/human-cases.ts`) e o cron (service role); nenhum caminho do produto
escrevia por login de usuário, e enquanto a porta existiu um `viewer` reescrevia pelo PostgREST
o texto que a equipe lê para decidir. Para ver o que está em vigor sem confiar nesta linha:
`grep -nEi 'policy .*(agent_cases|agent_case_events)' supabase/baseline.sql`. Vigiado por
`tests/invariants/caso-so-nasce-do-motor.test.ts`.
### 8.3 Alterações em tabelas existentes
- `ai_agent_versions add column if not exists cases_enabled boolean not null default false;`
+193 -12
View File
@@ -5079,12 +5079,14 @@ alter table public.conversation_assignment_events enable row level security;
-- cae_select nasce na 0173, com o escopo da conversa. A versão org-flat que
-- vivia aqui era reinstalada a cada update.sh e valia até aquele bloco.
drop policy if exists cae_insert on public.conversation_assignment_events;
create policy cae_insert on public.conversation_assignment_events
for insert with check (
(organization_id in (select public.fn_user_org_ids()))
or public.fn_is_platform_admin()
);
-- `cae_insert` não nasce mais aqui, e nem é derrubada aqui: desde a 0279 a
-- tabela é server-only, e o INSERT legítimo é feito por dentro de
-- `fn_conversation_assign`, que é `security definer`. Quem a derruba — no clone
-- que já a tem — é o bloco da 0279, lá embaixo, junto do revoke. Criar aqui para
-- derrubar lá faria a regra antiga valer no meio de cada instalação
-- (tests/unit/baseline-nao-constroi-o-que-derruba.test.ts); derrubar aqui deixaria
-- a tabela sem policy nenhuma por 20 mil linhas de DDL, que num `update.sh` sobre
-- banco vivo é uma janela de leitura vazia na tela.
revoke all on public.conversation_assignment_events from anon;
@@ -7531,15 +7533,15 @@ alter table ai_agent_versions add column if not exists cases_enabled boolean not
alter table agent_cases enable row level security;
alter table agent_case_events enable row level security;
drop policy if exists tenant_isolation_agent_cases_all on agent_cases;
create policy tenant_isolation_agent_cases_all on agent_cases
for all using (organization_id in (select fn_user_org_ids())) with check (organization_id in (select fn_user_org_ids()));
-- A policy `for all` não nasce mais aqui desde a 0279: quem escreve caso é o
-- motor, e a leitura ganhou policy própria (`tenant_isolation_agent_cases_select`).
-- O par drop+create mora no bloco da 0279, colado, pelo motivo escrito acima.
drop policy if exists tenant_isolation_agent_case_events_select on agent_case_events;
create policy tenant_isolation_agent_case_events_select on agent_case_events
for select using (organization_id in (select fn_user_org_ids()));
drop policy if exists tenant_isolation_agent_case_events_insert on agent_case_events;
create policy tenant_isolation_agent_case_events_insert on agent_case_events
for insert with check (organization_id in (select fn_user_org_ids()));
-- A policy de INSERT não nasce mais aqui desde a 0279 (mesmo motivo da tabela
-- mãe); quem a derruba no clone que já a tem é o bloco da 0279. A de SELECT,
-- logo acima, fica: a tela e o MCP leem.
-- estender CHECKs de job_queue (kind + coerência kind⇔contato) p/ case_reply_turn
-- nomes reais conferidos no banco linkado: job_queue_kind_check (named) e
@@ -26994,6 +26996,185 @@ end $f$;
-- EXECUTE sai das duas origens e dos papéis que o default ACL do Supabase alcança.
revoke execute on function public.fn_aplicar_travas_de_suporte() from public, anon, authenticated, service_role;
-- ---- o caso só nasce do motor (migration 0279) ----
-- 0279 — O caso só nasce do motor.
--
-- Três tabelas nasceram graváveis por QUALQUER membro, pelas DUAS origens:
-- (A) o GRANT: `ALTER DEFAULT PRIVILEGES … GRANT ALL ON TABLES TO
-- "authenticated"` (supabase/baseline.sql:4727) vale para toda tabela
-- criada DEPOIS dele — e as três são de apêndice, criadas depois;
-- (B) a POLICY: `for all` / `for insert` sem papel mínimo.
--
-- Tratar só uma deixa o furo aberto: o revoke sozinho não sobrevive a um grant
-- futuro, e a policy sozinha faz o UPDATE casar 0 linhas e o PostgREST devolver
-- SUCESSO. As duas, na mesma migration.
--
-- Para reconferir as origens sem acreditar nesta prosa:
-- grep -n 'ALTER DEFAULT PRIVILEGES .* ON TABLES TO "authenticated"' supabase/baseline.sql
-- grep -nE 'policy .*(agent_cases|agent_case_events|cae_insert)' supabase/baseline.sql
--
-- ── O que se pagaria ────────────────────────────────────────────────────────
-- `agent_cases` guarda o título, o resumo e o bloqueio que a IA escreveu sobre
-- o atendimento de uma pessoa — o texto que a equipe lê para decidir. Um
-- `viewer` da própria organização escrevia essa linha falando direto com o
-- PostgREST, com o JWT dele. E um INSERT forjado em
-- `conversation_assignment_events` faz o histórico de dono da conversa dizer
-- que alguém assumiu um atendimento que ninguém assumiu.
--
-- ── Nenhum caminho legítimo escreve por `authenticated` (censo medido) ──────
-- · agent_cases / agent_case_events → `pg.Pool` em
-- lib/agent-engine/agent/human-cases.ts (inserts em :183, :193, :247, :297,
-- :324, :352, :383, :432; `db: pg.Pool` em :166, :236), e o admin client
-- (service role) em app/api/v1/cron/case-stale-watcher/route.ts:77,:156.
-- · conversation_assignment_events → o INSERT é feito DENTRO de
-- public.fn_conversation_assign, que é `security definer` e executa com o
-- privilégio do DONO. É por ela que passam as rotas de claim, transfer e
-- release (app/api/v1/conversations/[id]/{claim,release,transfer}/route.ts),
-- todas com o client de SESSÃO — e é por isso que elas continuam
-- funcionando depois deste revoke (controle positivo do invariante).
-- · o reset da "Zona de perigo" (lib/settings/apagar-dados-operacionais.ts)
-- apaga `conversations`; as três tabelas somem por CASCADE, que roda como o
-- dono da tabela e não como quem apagou.
-- Refeito por: rg -n "agent_cases|agent_case_events|conversation_assignment_events" app lib hooks components workers scripts
--
-- SELECT continua aberto nas três: a tela, o MCP e o motor leem.
-- Vigiado por tests/invariants/caso-so-nasce-do-motor.test.ts.
-- ── agent_cases ─────────────────────────────────────────────────────────────
revoke insert, update, delete, truncate on public.agent_cases from authenticated, anon;
drop policy if exists tenant_isolation_agent_cases_all on public.agent_cases;
drop policy if exists tenant_isolation_agent_cases_select on public.agent_cases;
create policy tenant_isolation_agent_cases_select on public.agent_cases
for select to authenticated
using (organization_id in (select public.fn_user_org_ids()));
-- ── agent_case_events ───────────────────────────────────────────────────────
revoke insert, update, delete, truncate on public.agent_case_events from authenticated, anon;
drop policy if exists tenant_isolation_agent_case_events_insert on public.agent_case_events;
-- A policy de SELECT (`tenant_isolation_agent_case_events_select`) fica como está.
-- ── conversation_assignment_events ──────────────────────────────────────────
revoke insert, update, delete, truncate on public.conversation_assignment_events
from authenticated, anon;
drop policy if exists cae_insert on public.conversation_assignment_events;
-- `cae_select` (que desde a 0173 herda o escopo da conversa) fica como está.
-- ── emit_event: reservar os eventos de caso ─────────────────────────────────
-- ⚠️ O CORPO ABAIXO É O VIGENTE, DERIVADO — não redigitado. A definição que vale
-- é a de MAIOR número de linha no baseline (há cinco lá, e a última vence):
-- grep -nEi 'create (or replace )?function ("public"\.|public\.)?"?emit_event"?' supabase/baseline.sql | tail -1
-- Copiar a errada reintroduz comportamento revogado — foi o que a 0224 fez
-- com `fn_service_event_origin` (ver o cabeçalho de
-- tests/unit/apendice-do-baseline-nao-diverge-da-cadeia.test.ts). A ÚNICA
-- mudança em relação ao corpo vigente é a lista de tipos reservados.
--
-- A reserva SOZINHA não fecha nada: ela só barra quem tem `auth.uid()`, e
-- quem fecha a forja da LINHA é o revoke acima. As duas na mesma migration.
-- Estende o produtor permitido mantendo a origem imutável da 0223.
CREATE OR REPLACE FUNCTION public.emit_event(p_event_type text, p_entity_kind text, p_entity_id uuid, p_payload jsonb DEFAULT '{}'::jsonb, p_metadata jsonb DEFAULT '{}'::jsonb, p_organization_id uuid DEFAULT NULL::uuid)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
declare
v_org_id uuid;
v_event_id uuid;
v_contact uuid;
v_origin jsonb;
begin
-- message.received nasce somente do INSERT inbound interno. Um chamador
-- público não pode reapresentar uma mensagem existente como evento novo.
-- `ai.case_opened`/`ai.case_closed` entram pela mesma razão (0279): o caso é
-- do motor, e um evento de caso forjado por login move o funil e acorda o
-- agente em nome de uma decisão que ninguém tomou.
if auth.uid() is not null and p_event_type in (
'message.received','appointment.outcome_confirmed',
'ai.case_opened','ai.case_closed'
) then
raise exception 'reserved_message_received' using errcode='42501';
end if;
-- Estes campos autorizam efeitos operacionais; não são payload público.
if auth.uid() is not null and (
coalesce(p_payload,'{}'::jsonb) ?| array['service_origin','service_boundary']
or coalesce(p_metadata,'{}'::jsonb) ?| array['service_origin','service_boundary']
) then raise exception 'reserved_service_origin' using errcode='42501'; end if;
v_org_id := coalesce(p_organization_id, (public.fn_support_context()->>'organization_id')::uuid);
if v_org_id is null then
select organization_id into v_org_id
from public.user_organizations
where user_id = auth.uid() and revoked_at is null
limit 1;
end if;
if v_org_id is null then
raise exception 'emit_event: organization_id obrigatorio';
end if;
if auth.uid() is not null
and not public.fn_role_at_least(v_org_id, 'viewer') then
raise exception 'caller_not_authorized_for_org'
using hint = 'emit_event: caller must be an active member of the organization';
end if;
if not public.fn_support_write_allowed(v_org_id) then raise exception 'support_readonly' using errcode='42501'; end if;
-- A ORIGEM E RESERVADA AO SERVIDOR — ENTAO O SERVIDOR TEM DE ESCREVE-LA.
--
-- O bloco acima recusa `service_origin` vindo de chamador autenticado (42501,
-- e com razao: e o campo que AUTORIZA efeito operacional, nao payload
-- publico). So que ninguem o escrevia no lugar dele. Efeito medido: quem move
-- o negocio pela IA carimba a origem no servidor (`agent-stage-sync`,
-- `appointment-stage-move`, `handoff-stage-move`) e o follow-up nasce; quem
-- move PELO QUADRO — o operador, pela rota HTTP autenticada — emitia um
-- evento SEM origem, `fn_service_event_origin` caia no `service_stale` final
-- (40001), `serviceForEvent` engolia como `stale_origin` e o follow-up nunca
-- nascia. Sem erro em lugar nenhum: o gatilho de etapa era inalcancavel pelo
-- caminho que o produto oferece na tela.
--
-- O retrato e tirado AQUI, no instante da emissao, que e exatamente a
-- semantica de procedencia que a 0223 quer: "quando este evento nasceu, o
-- atendimento estava assim". A resolucao do contato repete a mesma regra de
-- `fn_service_event_origin` — se ela nao souber resolver o tipo, nao ha o que
-- carimbar e o evento segue sem origem, como antes.
if not (coalesce(p_payload,'{}'::jsonb) ? 'service_origin')
and not (coalesce(p_metadata,'{}'::jsonb) ? 'service_origin') then
if p_event_type in ('lead.created','lead.stage_changed','lead.tag_added') and p_entity_kind='crm_lead' then
select contact_id into v_contact from public.crm_leads where organization_id=v_org_id and id=p_entity_id;
elsif p_event_type='contact.tag_added' and p_entity_kind='contact' then
select id into v_contact from public.contacts where organization_id=v_org_id and id=p_entity_id;
end if;
if v_contact is not null
and exists(select 1 from public.contacts
where organization_id=v_org_id and id=v_contact
and not is_anonymized and is_merged_into is null) then
v_origin := jsonb_build_object('kind','command',
'observed', public.fn_service_observe_command(v_org_id, v_contact));
end if;
end if;
insert into public.event_log
(organization_id, event_type, entity_kind, entity_id, payload, metadata)
values
(v_org_id, p_event_type, p_entity_kind, p_entity_id,
coalesce(p_payload, '{}'::jsonb)
|| case when v_origin is null then '{}'::jsonb else jsonb_build_object('service_origin', v_origin) end,
coalesce(p_metadata, '{}'::jsonb)
|| jsonb_build_object('emitted_at', extract(epoch from now())))
returning id into v_event_id;
return v_event_id;
end $function$;
-- A mensagem fica com o nome herdado (`reserved_message_received`): renomeá-la é
-- mudança de contrato observável, e NÃO MEDIMOS se alguém a trata por nome.
-- ── travas do suporte, depois de toda mudança de privilégio (migration 0274) ─
-- As três tabelas passam a ser server-only, e o ramo server-only da função
-- derruba as `support_write_*` que elas tinham. Escrever `drop policy` à mão
-- aqui seria a segunda representação da mesma regra.
do $f$ begin perform public.fn_aplicar_travas_de_suporte(); end $f$;
notify pgrst, 'reload schema';
-- ---- VARREDURA anon: função nova nasce exposta em quem ATUALIZA (migration 0116) ----
--
-- ⚠️ DE PROPÓSITO, NENHUMA FUNÇÃO É CRIADA DEPOIS DESTE BLOCO. Apêndice que cria
@@ -0,0 +1,179 @@
-- ════════════════════════════════════════════════════════════════════════════
-- 0279 — O caso só nasce do motor.
--
-- Três tabelas nasceram graváveis por QUALQUER membro, pelas DUAS origens:
-- (A) o GRANT: `ALTER DEFAULT PRIVILEGES … GRANT ALL ON TABLES TO
-- "authenticated"` (supabase/baseline.sql:4727) vale para toda tabela
-- criada DEPOIS dele — e as três são de apêndice, criadas depois;
-- (B) a POLICY: `for all` / `for insert` sem papel mínimo.
--
-- Tratar só uma deixa o furo aberto: o revoke sozinho não sobrevive a um grant
-- futuro, e a policy sozinha faz o UPDATE casar 0 linhas e o PostgREST devolver
-- SUCESSO. As duas, na mesma migration.
--
-- Para reconferir as origens sem acreditar nesta prosa:
-- grep -n 'ALTER DEFAULT PRIVILEGES .* ON TABLES TO "authenticated"' supabase/baseline.sql
-- grep -nE 'policy .*(agent_cases|agent_case_events|cae_insert)' supabase/baseline.sql
--
-- ── O que se pagaria ────────────────────────────────────────────────────────
-- `agent_cases` guarda o título, o resumo e o bloqueio que a IA escreveu sobre
-- o atendimento de uma pessoa — o texto que a equipe lê para decidir. Um
-- `viewer` da própria organização escrevia essa linha falando direto com o
-- PostgREST, com o JWT dele. E um INSERT forjado em
-- `conversation_assignment_events` faz o histórico de dono da conversa dizer
-- que alguém assumiu um atendimento que ninguém assumiu.
--
-- ── Nenhum caminho legítimo escreve por `authenticated` (censo medido) ──────
-- · agent_cases / agent_case_events → `pg.Pool` em
-- lib/agent-engine/agent/human-cases.ts (inserts em :183, :193, :247, :297,
-- :324, :352, :383, :432; `db: pg.Pool` em :166, :236), e o admin client
-- (service role) em app/api/v1/cron/case-stale-watcher/route.ts:77,:156.
-- · conversation_assignment_events → o INSERT é feito DENTRO de
-- public.fn_conversation_assign, que é `security definer` e executa com o
-- privilégio do DONO. É por ela que passam as rotas de claim, transfer e
-- release (app/api/v1/conversations/[id]/{claim,release,transfer}/route.ts),
-- todas com o client de SESSÃO — e é por isso que elas continuam
-- funcionando depois deste revoke (controle positivo do invariante).
-- · o reset da "Zona de perigo" (lib/settings/apagar-dados-operacionais.ts)
-- apaga `conversations`; as três tabelas somem por CASCADE, que roda como o
-- dono da tabela e não como quem apagou.
-- Refeito por: rg -n "agent_cases|agent_case_events|conversation_assignment_events" app lib hooks components workers scripts
--
-- SELECT continua aberto nas três: a tela, o MCP e o motor leem.
-- Vigiado por tests/invariants/caso-so-nasce-do-motor.test.ts.
-- ════════════════════════════════════════════════════════════════════════════
-- ── agent_cases ─────────────────────────────────────────────────────────────
revoke insert, update, delete, truncate on public.agent_cases from authenticated, anon;
drop policy if exists tenant_isolation_agent_cases_all on public.agent_cases;
drop policy if exists tenant_isolation_agent_cases_select on public.agent_cases;
create policy tenant_isolation_agent_cases_select on public.agent_cases
for select to authenticated
using (organization_id in (select public.fn_user_org_ids()));
-- ── agent_case_events ───────────────────────────────────────────────────────
revoke insert, update, delete, truncate on public.agent_case_events from authenticated, anon;
drop policy if exists tenant_isolation_agent_case_events_insert on public.agent_case_events;
-- A policy de SELECT (`tenant_isolation_agent_case_events_select`) fica como está.
-- ── conversation_assignment_events ──────────────────────────────────────────
revoke insert, update, delete, truncate on public.conversation_assignment_events
from authenticated, anon;
drop policy if exists cae_insert on public.conversation_assignment_events;
-- `cae_select` (que desde a 0173 herda o escopo da conversa) fica como está.
-- ── emit_event: reservar os eventos de caso ─────────────────────────────────
-- ⚠️ O CORPO ABAIXO É O VIGENTE, DERIVADO — não redigitado. A definição que vale
-- é a de MAIOR número de linha no baseline (há cinco lá, e a última vence):
-- grep -nEi 'create (or replace )?function ("public"\.|public\.)?"?emit_event"?' supabase/baseline.sql | tail -1
-- Copiar a errada reintroduz comportamento revogado — foi o que a 0224 fez
-- com `fn_service_event_origin` (ver o cabeçalho de
-- tests/unit/apendice-do-baseline-nao-diverge-da-cadeia.test.ts). A ÚNICA
-- mudança em relação ao corpo vigente é a lista de tipos reservados.
--
-- A reserva SOZINHA não fecha nada: ela só barra quem tem `auth.uid()`, e
-- quem fecha a forja da LINHA é o revoke acima. As duas na mesma migration.
-- Estende o produtor permitido mantendo a origem imutável da 0223.
CREATE OR REPLACE FUNCTION public.emit_event(p_event_type text, p_entity_kind text, p_entity_id uuid, p_payload jsonb DEFAULT '{}'::jsonb, p_metadata jsonb DEFAULT '{}'::jsonb, p_organization_id uuid DEFAULT NULL::uuid)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public'
AS $function$
declare
v_org_id uuid;
v_event_id uuid;
v_contact uuid;
v_origin jsonb;
begin
-- message.received nasce somente do INSERT inbound interno. Um chamador
-- público não pode reapresentar uma mensagem existente como evento novo.
-- `ai.case_opened`/`ai.case_closed` entram pela mesma razão (0279): o caso é
-- do motor, e um evento de caso forjado por login move o funil e acorda o
-- agente em nome de uma decisão que ninguém tomou.
if auth.uid() is not null and p_event_type in (
'message.received','appointment.outcome_confirmed',
'ai.case_opened','ai.case_closed'
) then
raise exception 'reserved_message_received' using errcode='42501';
end if;
-- Estes campos autorizam efeitos operacionais; não são payload público.
if auth.uid() is not null and (
coalesce(p_payload,'{}'::jsonb) ?| array['service_origin','service_boundary']
or coalesce(p_metadata,'{}'::jsonb) ?| array['service_origin','service_boundary']
) then raise exception 'reserved_service_origin' using errcode='42501'; end if;
v_org_id := coalesce(p_organization_id, (public.fn_support_context()->>'organization_id')::uuid);
if v_org_id is null then
select organization_id into v_org_id
from public.user_organizations
where user_id = auth.uid() and revoked_at is null
limit 1;
end if;
if v_org_id is null then
raise exception 'emit_event: organization_id obrigatorio';
end if;
if auth.uid() is not null
and not public.fn_role_at_least(v_org_id, 'viewer') then
raise exception 'caller_not_authorized_for_org'
using hint = 'emit_event: caller must be an active member of the organization';
end if;
if not public.fn_support_write_allowed(v_org_id) then raise exception 'support_readonly' using errcode='42501'; end if;
-- A ORIGEM E RESERVADA AO SERVIDOR — ENTAO O SERVIDOR TEM DE ESCREVE-LA.
--
-- O bloco acima recusa `service_origin` vindo de chamador autenticado (42501,
-- e com razao: e o campo que AUTORIZA efeito operacional, nao payload
-- publico). So que ninguem o escrevia no lugar dele. Efeito medido: quem move
-- o negocio pela IA carimba a origem no servidor (`agent-stage-sync`,
-- `appointment-stage-move`, `handoff-stage-move`) e o follow-up nasce; quem
-- move PELO QUADRO — o operador, pela rota HTTP autenticada — emitia um
-- evento SEM origem, `fn_service_event_origin` caia no `service_stale` final
-- (40001), `serviceForEvent` engolia como `stale_origin` e o follow-up nunca
-- nascia. Sem erro em lugar nenhum: o gatilho de etapa era inalcancavel pelo
-- caminho que o produto oferece na tela.
--
-- O retrato e tirado AQUI, no instante da emissao, que e exatamente a
-- semantica de procedencia que a 0223 quer: "quando este evento nasceu, o
-- atendimento estava assim". A resolucao do contato repete a mesma regra de
-- `fn_service_event_origin` — se ela nao souber resolver o tipo, nao ha o que
-- carimbar e o evento segue sem origem, como antes.
if not (coalesce(p_payload,'{}'::jsonb) ? 'service_origin')
and not (coalesce(p_metadata,'{}'::jsonb) ? 'service_origin') then
if p_event_type in ('lead.created','lead.stage_changed','lead.tag_added') and p_entity_kind='crm_lead' then
select contact_id into v_contact from public.crm_leads where organization_id=v_org_id and id=p_entity_id;
elsif p_event_type='contact.tag_added' and p_entity_kind='contact' then
select id into v_contact from public.contacts where organization_id=v_org_id and id=p_entity_id;
end if;
if v_contact is not null
and exists(select 1 from public.contacts
where organization_id=v_org_id and id=v_contact
and not is_anonymized and is_merged_into is null) then
v_origin := jsonb_build_object('kind','command',
'observed', public.fn_service_observe_command(v_org_id, v_contact));
end if;
end if;
insert into public.event_log
(organization_id, event_type, entity_kind, entity_id, payload, metadata)
values
(v_org_id, p_event_type, p_entity_kind, p_entity_id,
coalesce(p_payload, '{}'::jsonb)
|| case when v_origin is null then '{}'::jsonb else jsonb_build_object('service_origin', v_origin) end,
coalesce(p_metadata, '{}'::jsonb)
|| jsonb_build_object('emitted_at', extract(epoch from now())))
returning id into v_event_id;
return v_event_id;
end $function$;
-- A mensagem fica com o nome herdado (`reserved_message_received`): renomeá-la é
-- mudança de contrato observável, e NÃO MEDIMOS se alguém a trata por nome.
-- ── travas do suporte, depois de toda mudança de privilégio (migration 0274) ─
-- As três tabelas passam a ser server-only, e o ramo server-only da função
-- derruba as `support_write_*` que elas tinham. Escrever `drop policy` à mão
-- aqui seria a segunda representação da mesma regra.
do $f$ begin perform public.fn_aplicar_travas_de_suporte(); end $f$;
notify pgrst, 'reload schema';
+1
View File
@@ -323,3 +323,4 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
| `20260917120000` | `0271_extensoes_declarativas` | Cinco tabelas do framework declarativo: catálogo admitido, artefato imutável, instalação por origem/identidade, vínculo por organização e recibo durável. Oito RPCs service-only (admitir, preparar, concluir, falhar, cancelar, configurar, desfazer a última troca, remover da instalação) com ator vigente, fingerprint idempotente, `applied_now` que distingue a transição da repetição, e CAS pela revisão da instalação; mais a contagem de organizações ativas por instalação, que devolve só números. A instalação ganha `previous_artifact_id` (histórico de um passo), `revision` e remoção lógica (`removed_at`/`removed_by`); o vínculo ganha `deactivated_by_removal_at`. As CHECKs de `kind`/`status` do recibo são constraints nomeadas com drop/add, então um banco de desenvolvimento com a versão antiga do bloco se corrige ao reaplicar o apêndice (a cadeia `db push` não reaplica). Publicar espera a atualização do core (`dispatched` com menos de 15 min); remover não espera. prepare/finish/cancel/revert/remove e o gatilho de system_update_runs compartilham a trava consultiva; configurar serializa com a troca de ponteiro por `for share` na instalação. RLS de membership somente para leitura do vínculo; escrita direta do framework revogada inclusive de service_role. Apêndice idempotente. |
| `20260915193743` | `0263_etapa_de_perda_grava_o_motivo` | **A etapa de PERDA do lote grava o motivo na MESMA escrita.** `fn_mover_leads_em_lote` (0209) trocava só o `stage_id`; quando a etapa de destino é de perda, `trg_crm_lead_close_on_stage` fecha o negócio e a CHECK `crm_leads_lost_reason_required` recusa a linha — e, como a função é uma transação só ("move todos ou não move nenhum"), **UM** card sem motivo derrubava o LOTE INTEIRO, com a rota respondendo 500 (`internal_error`) a quem acabara de pedir o movimento. Medido no baseline (pg16, lote de 2 cards abertos para a etapa de perda): `SQLSTATE=23514 | new row for relation "crm_leads" violates check constraint "crm_leads_lost_reason_required"`. O quarto parâmetro `p_lost_reason` é a **mesma escrita** — uma escrita ANTES tem janela (entre as duas o negócio aparece `lost` sem motivo) e uma DEPOIS é linha já recusada. A coluna só entra na lista do `update` quando há motivo (SQL dinâmico) porque tocar `lost_reason` dispara `trg_validate_lost_reason_required`, que confere o valor contra o vocabulário do funil: reescrever o valor que já estava na linha recusaria um card cujo motivo saiu da configuração depois de usado (22023 `lost_reason_invalid`) enquanto o arrasto do mesmo card — que não toca a coluna — continuaria passando. `coalesce` com o valor da própria linha preserva o motivo do card que já era perdido (`p_lost_reason` nulo não apaga nada). `drop function` antes do `create or replace` porque o PostgreSQL não substitui assinatura (sem ele, `fn_mover_leads_em_lote(uuid, uuid[], uuid) is not unique`). Sem coluna nova, sem backfill, sem dado tocado; baseline com o corpo final. Os **três caminhos** ficam com uma só decisão (`lib/leads/motivo-da-perda.ts`): arrasto (`/leads/[id]/move`), lote (`/leads/bulk`) e a IA (`lib/leads/agent-stage-sync.ts` — o agente **não** move: recusa de negócio com item de inbox acionável, porque `lost_reason` não é texto livre e o motivo é decisão de quem está no negócio). Catracas: `tests/unit/etapa-de-perda-no-arrasto.test.ts`, `tests/unit/etapa-de-perda-no-lote.test.ts` e `tests/unit/etapa-de-perda-do-agente.test.ts` (vermelhas sem o fix). |
| `20260917150000` | `0274_travas_de_suporte_cobrem_toda_tabela` | **As travas do modo somente leitura do suporte cobrem toda tabela da organização já na primeira aplicação do schema**, e a instalação nova chega ao mesmo conjunto de travas que a atualização. A enumeração da 0220 que planta as restritivas `support_write_{insert,update,delete}` vira `public.fn_aplicar_travas_de_suporte()` (sem parâmetro, idempotente: `drop policy if exists` + `create policy`), chamada depois de toda tabela — na cadeia de migrations, por esta migration; no `baseline.sql`, a definição fica antes da varredura de anon e a chamada é o último bloco do arquivo (o `do` avulso do bloco da 0220 saiu, e um comentário aponta para cá). Regra de seleção inalterada: tabela comum de `public`, RLS ligada, com `organization_id` (ou `organizations`, pela `id`); gravável por `authenticated` → as três restritivas, só do servidor → nenhuma. Não é `security definer`; `revoke execute` de `public, anon, authenticated, service_role`, sem grant — só quem aplica o schema a chama. Medido em pg17 descartável com o prelúdio do `scripts/test-db.sh`, baseline aplicado UMA vez: toda tabela alcançada pela regra tem as três travas, e o conjunto de políticas é o mesmo de duas aplicações (md5 idêntico). Sem tabela, coluna ou dado novo. Invariante: `tests/invariants/travas-de-suporte-cobrem-toda-tabela-na-instalacao.test.ts`, que lê um molde de aplicação única tirado pelo `scripts/test-db.sh` entre o install e o update — e prova que ele é de uma aplicação pelo contador `test_db.aplicacoes_do_baseline`, que o script grava a cada aplicação. |
| `20260918100000` | `0279_caso_so_nasce_do_motor` | **Fecha as duas origens de escrita de `agent_cases`, `agent_case_events` e `conversation_assignment_events` para `authenticated`, e reserva `ai.case_opened`/`ai.case_closed` no `emit_event`.** As três nasceram graváveis por qualquer membro porque o `ALTER DEFAULT PRIVILEGES … GRANT ALL ON TABLES TO "authenticated"` (`baseline.sql:4727`) precede a criação delas e as policies eram `for all`/`for insert` sem papel mínimo (`tenant_isolation_agent_cases_all`, `tenant_isolation_agent_case_events_insert` e `cae_insert` — refaça a medida com `grep -nEi 'policy .*(agent_cases|agent_case_events|cae_insert)' supabase/baseline.sql`, que hoje só devolve o bloco desta migration e as duas de SELECT). **O baseline deixou de CRIAR as três para derrubá-las no fim**: criar e derrubar no mesmo arquivo faz a regra antiga valer entre os dois pontos de toda instalação, e `tests/unit/baseline-nao-constroi-o-que-derruba.test.ts` reprova. O par drop+create mora colado no bloco desta migration, e não no ponto de criação original, para que um `update.sh` sobre banco vivo não deixe a tabela sem policy nenhuma por 20 mil linhas de DDL. Um `viewer` da própria organização escrevia, falando direto com o PostgREST e com o JWT dele, o título e o resumo que a IA redigiu sobre o atendimento de uma pessoa — o texto que a equipe lê para decidir; e um INSERT forjado em `conversation_assignment_events` faz o histórico de dono dizer que alguém assumiu um atendimento que ninguém assumiu. **Nenhum caminho legítimo escreve por `authenticated`** (censo no corpo do PR): o motor usa `pg.Pool` (`lib/agent-engine/agent/human-cases.ts`), o cron usa service role (`app/api/v1/cron/case-stale-watcher/route.ts`), e a troca de dono das cinco rotas passa por `fn_conversation_assign`, que é `security definer` e insere com o privilégio do DONO — por isso claim/transfer/release seguem funcionando pelo client de sessão (controle positivo do invariante). O reset da Zona de perigo apaga `conversations` e as três somem por CASCADE, que também roda como o dono. SELECT continua aberto nas três: a tela, o MCP e o motor leem. A policy `for all` de `agent_cases` vira `for select to authenticated` — e ela precisa CONTINUAR existindo, porque `tests/invariants/rbac-config-ia-canais.test.ts` exige que toda tabela da allowlist de dívida apareça em `pg_policies`. **A reserva no `emit_event` sozinha não fecha nada** — ela só barra quem tem `auth.uid()`; quem fecha a forja da linha é o revoke, e por isso as duas andam na mesma migration. O corpo do `emit_event` é o VIGENTE derivado (a definição de maior número de linha no baseline, conferida idêntica à última da cadeia, em `0224_presenca_e_recuperacao`), com a única mudança sendo a lista de tipos reservados; a mensagem fica com o nome herdado (`reserved_message_received`) porque renomeá-la é mudança de contrato observável. Fecha com `fn_aplicar_travas_de_suporte()` (0274), que dá ZERO policies `support_write_*` a tabela server-only — o contrato mais restritivo. Número `0279` e não `0277`/`0278`: os dois estão tomados por branches locais de triagem (`triagem/714-smtp-como-opcao` e `triagem/recorte-928`) — medido sobre todas as refs, e o hook `check-migration-triple.sh` reprovou o `0278` no ato do commit. Sem tabela, coluna ou dado novo. Gate: `tests/invariants/caso-so-nasce-do-motor.test.ts`. |
@@ -0,0 +1,399 @@
/**
* O CASO SÓ NASCE DO MOTOR — migration 0279.
*
* ## O defeito
*
* `agent_cases`, `agent_case_events` e `conversation_assignment_events` nasceram
* graváveis por QUALQUER membro da organização, pelas DUAS origens que o
* baseline tem:
*
* (A) o GRANT — `ALTER DEFAULT PRIVILEGES … GRANT ALL ON TABLES TO
* "authenticated"` (`supabase/baseline.sql:4727`) vale para toda tabela
* criada DEPOIS dele, e as três são de apêndice;
* (B) a POLICY — `for all` em `agent_cases`, `for insert` em
* `agent_case_events` e `cae_insert`, todas sem papel mínimo.
*
* O que se pagava: `agent_cases` guarda o título, o resumo e o bloqueio que a IA
* escreveu sobre o atendimento de uma pessoa — o texto que a equipe lê para
* decidir. Um `viewer` escrevia essa linha falando direto com o PostgREST, com o
* JWT dele. E um INSERT forjado em `conversation_assignment_events` faz o
* histórico de dono da conversa dizer que alguém assumiu o que ninguém assumiu.
*
* ## Por que as duas origens, e não uma
*
* Os dois CONTROLES abaixo medem isso, e não é simetria decorativa:
*
* · com o grant de volta E uma policy de INSERT, a escrita PASSA — é a
* simulação do estado anterior à 0279, e sem ela um INSERT malformado
* (coluna NOT NULL esquecida) deixaria toda a sonda verde por nada;
* · com a policy de volta e o revoke MANTIDO, a escrita segue barrada — o
* revoke sozinho já basta para o PostgREST. Ele é a guarda que sobrevive a
* alguém recriar uma policy larga depois.
*
* A recíproca não vale, e é por isso que o revoke não anda sozinho na migration:
* a policy sozinha faria o UPDATE casar zero linhas e o PostgREST devolver
* SUCESSO — "a escrita não pegou" com cara de "a escrita deu certo".
*
* ## O que NÃO se fecha aqui
*
* SELECT continua aberto nas três: a tela de casos, o MCP e o motor leem. Cada
* caso de escrita barrada vem colado do seu controle de LEITURA, porque um
* `permission denied` no INSERT junto com uma leitura que também quebrou não
* seria conserto — seria a feature morta.
*/
import { beforeAll, describe, expect, it } from "vitest";
import { motivoDoErro, sql } from "./psql-transporte";
// Namespace próprio (02790000-), como em `atrito-metrics` e no bloco `dddddddd`
// de `gov-3-assignment-events`: a semente é idempotente E não colide com a de
// outro arquivo rodando em paralelo no mesmo banco.
const ORG = "02790000-0000-4000-8000-000000000001";
const AGENTE = "02790000-1111-4000-8000-000000000001";
const VIEWER = "02790000-1111-4000-8000-000000000002";
const SESSAO = "02790000-2222-4000-8000-000000000001";
const CONTATO_LEITURA = "02790000-3333-4000-8000-000000000001";
const CONTATO_CLAIM = "02790000-3333-4000-8000-000000000002";
/** Conversa só de leitura — nenhum caso a reivindica, então ela segue sem dono. */
const CONVERSA = "02790000-4444-4000-8000-000000000001";
/** Conversa reservada ao controle positivo de `fn_conversation_assign`. */
const CONVERSA_CLAIM = "02790000-4444-4000-8000-000000000002";
const CASO = "02790000-5555-4000-8000-000000000001";
const EVENTO_DO_CASO = "02790000-6666-4000-8000-000000000001";
/** Marcador das linhas de resultado: o psql também imprime BEGIN, SET, GRANT… */
const MARCA = "SONDA|";
/** Roda `corpo` numa transação DESFEITA e devolve só as linhas marcadas. */
function sondasDesfeitas(corpo: string): string[] {
return sql(`begin;\n${corpo}\nrollback;`)
.split("\n")
.filter((linha) => linha.startsWith(MARCA))
.map((linha) => linha.slice(MARCA.length));
}
/**
* O prefixo que põe a sessão no mesmo lugar em que o PostgREST põe a de um
* usuário logado: papel `authenticated` + `request.jwt.claims`, que é o caminho
* exato que `auth.uid()` e as policies de produção leem.
*/
function comoMembro(userId: string, local = false): string {
return `set ${local ? "local " : ""}role authenticated;
select set_config('request.jwt.claims', '{"sub":"${userId}"}', ${local});`;
}
function contaComoMembro(userId: string, consulta: string): number {
const saida = sql(`${comoMembro(userId)}\n${consulta};`).trim();
const ultima = saida.split("\n").at(-1) ?? "";
if (!/^\d+$/.test(ultima)) throw new Error(`saída inesperada do psql: ${saida}`);
return Number(ultima);
}
/** Devolve o erro do Postgres, ou `null` quando o comando PASSOU. */
function erroDo(script: string): string | null {
try {
sql(script);
return null;
} catch (err) {
return motivoDoErro(err);
}
}
/**
* Afirma que o Postgres recusou por PRIVILÉGIO.
*
* O modo de falha que interessa é `erroDo` devolver `null`: o comando passou. Um
* `toContain` sobre `null` reprovaria com uma mensagem que não fala de exposição
* nenhuma — daí a asserção em dois passos.
*/
function esperaBarradoPorPrivilegio(userId: string, dml: string): void {
const erro = erroDo(`${comoMembro(userId)}\n${dml};`);
expect(erro, `um membro executou "${dml}" SEM erro — a tabela está exposta`).not.toBeNull();
expect(erro).toContain("permission denied");
}
/** Privilégios que o papel tem NA TABELA, direto do catálogo. */
function privilegiosDe(papel: string, tabela: string): string[] {
return sql(`
select coalesce(string_agg(distinct privilege_type, ',' order by privilege_type), '')
from information_schema.role_table_grants
where table_schema = 'public' and table_name = '${tabela}' and grantee = '${papel}';
`)
.trim()
.split(",")
.filter(Boolean);
}
/** Policies da tabela, como `nome:comando`, com o comando em letra por extenso. */
function policiesDe(tabela: string): string[] {
return sql(`
select coalesce(string_agg(policyname || ':' || cmd, ',' order by policyname), '')
from pg_policies where schemaname = 'public' and tablename = '${tabela}';
`)
.trim()
.split(",")
.filter(Boolean);
}
const TABELAS = ["agent_cases", "agent_case_events", "conversation_assignment_events"] as const;
/** O INSERT que um membro tentaria pelo PostgREST, um por tabela. */
const ESCRITA_FORJADA: Record<(typeof TABELAS)[number], string> = {
agent_cases: `insert into public.agent_cases
(organization_id, conversation_id, title, summary, blocker)
values ('${ORG}', '${CONVERSA}', 'Forjado', 'Resumo forjado', 'Bloqueio forjado')`,
agent_case_events: `insert into public.agent_case_events
(organization_id, case_id, kind, actor_kind, body)
values ('${ORG}', '${CASO}', 'human_replied', 'human', 'Forjado')`,
conversation_assignment_events: `insert into public.conversation_assignment_events
(organization_id, conversation_id, to_user_id, changed_by, reason)
values ('${ORG}', '${CONVERSA}', '${VIEWER}', '${VIEWER}', 'claim')`,
};
/** A leitura que a tela faz, uma por tabela — o controle de cada caso de escrita. */
const LEITURA: Record<(typeof TABELAS)[number], string> = {
agent_cases: `select count(*) from public.agent_cases where id = '${CASO}'`,
agent_case_events: `select count(*) from public.agent_case_events where id = '${EVENTO_DO_CASO}'`,
conversation_assignment_events: `select count(*) from public.conversation_assignment_events
where conversation_id = '${CONVERSA}'`,
};
beforeAll(() => {
sql(`
insert into auth.users (id, email) values
('${AGENTE}', 'caso-0279-agente@invariant.test'),
('${VIEWER}', 'caso-0279-viewer@invariant.test')
on conflict do nothing;
insert into public.organizations (id, slug, legal_name, display_name)
values ('${ORG}', 'caso-0279', 'Caso 0279 Invariant', 'Caso 0279')
on conflict do nothing;
insert into public.user_organizations (user_id, organization_id, role, accepted_at) values
('${AGENTE}', '${ORG}', 'agent', now()),
('${VIEWER}', '${ORG}', 'viewer', now())
on conflict do nothing;
-- DO + exception (não ON CONFLICT): channel_sessions tem unique DEFERRABLE
-- (phone_per_org), que ON CONFLICT sem arbiter rejeita, e o arbiter (id) não
-- cobre a corrida no unique de waha_session_name entre arquivos paralelos.
do $seed$ begin
insert into public.channel_sessions (id, organization_id, waha_session_name, webhook_secret_encrypted)
values ('${SESSAO}', '${ORG}', 'caso-0279', '\\x00'::bytea);
exception when unique_violation then null; end $seed$;
-- Um contato por conversa: uniq_conversations_1to1_per_contact_session
-- (migration 0027) admite UMA conversa 1:1 por (org, contato, sessão).
insert into public.contacts (id, organization_id, display_name) values
('${CONTATO_LEITURA}', '${ORG}', 'Caso 0279 Contato Leitura'),
('${CONTATO_CLAIM}', '${ORG}', 'Caso 0279 Contato Claim')
on conflict do nothing;
insert into public.conversations (id, organization_id, contact_id, channel_session_id, status) values
('${CONVERSA}', '${ORG}', '${CONTATO_LEITURA}', '${SESSAO}', 'open'),
('${CONVERSA_CLAIM}', '${ORG}', '${CONTATO_CLAIM}', '${SESSAO}', 'open')
on conflict do nothing;
insert into public.agent_cases (id, organization_id, conversation_id, title, summary, blocker)
values ('${CASO}', '${ORG}', '${CONVERSA}', 'Caso do motor', 'Resumo do motor', 'Falta decisão')
on conflict do nothing;
insert into public.agent_case_events (id, organization_id, case_id, kind, actor_kind, body)
values ('${EVENTO_DO_CASO}', '${ORG}', '${CASO}', 'opened', 'agent', 'Aberto pelo motor')
on conflict do nothing;
insert into public.conversation_assignment_events
(organization_id, conversation_id, to_user_id, changed_by, reason)
select '${ORG}', '${CONVERSA}', '${AGENTE}', '${AGENTE}', 'routing'
where not exists (select 1 from public.conversation_assignment_events
where conversation_id = '${CONVERSA}');
`);
});
describe("0279 — a escrita das três tabelas do caso sai de `authenticated`", () => {
it.each(TABELAS)("a semente de `%s` existe — controle de vacuidade da sonda", (tabela) => {
// Sem isto, uma semente que não entrou faria toda leitura devolver 0 e os
// casos abaixo afirmariam "o membro não escreve" sobre uma tabela vazia,
// sem nunca ter provado que ele ainda LÊ.
const total = sql(`select count(*) from public.${tabela} where organization_id = '${ORG}';`);
expect(Number(total.trim().split("\n").at(-1)), `semente de ${tabela} vazia`).toBeGreaterThan(0);
});
it.each(TABELAS)("`authenticated` não tem INSERT, UPDATE, DELETE nem TRUNCATE em `%s`", (tabela) => {
// O privilégio é o que SOBRA no dia em que alguém acrescentar uma policy
// larga de volta — medi-lo é medir a guarda que não depende de policy.
const privilegios = privilegiosDe("authenticated", tabela);
expect(privilegios.length, `sonda cega: zero privilégios lidos de ${tabela}`).toBeGreaterThan(0);
expect(privilegios).toContain("SELECT");
for (const proibido of ["INSERT", "UPDATE", "DELETE", "TRUNCATE"]) {
expect(privilegios, `${tabela} ainda concede ${proibido} a authenticated`).not.toContain(
proibido,
);
}
});
it.each(TABELAS)("`anon` não tem escrita em `%s` — a anon key vai para o browser", (tabela) => {
for (const proibido of ["INSERT", "UPDATE", "DELETE", "TRUNCATE"]) {
expect(privilegiosDe("anon", tabela)).not.toContain(proibido);
}
});
it("(a) um membro NÃO insere em `agent_cases`, e CONTINUA lendo", () => {
esperaBarradoPorPrivilegio(VIEWER, ESCRITA_FORJADA.agent_cases);
esperaBarradoPorPrivilegio(AGENTE, ESCRITA_FORJADA.agent_cases);
expect(contaComoMembro(AGENTE, LEITURA.agent_cases)).toBe(1);
});
it("(b) um membro NÃO insere em `agent_case_events`, e CONTINUA lendo", () => {
esperaBarradoPorPrivilegio(VIEWER, ESCRITA_FORJADA.agent_case_events);
expect(contaComoMembro(AGENTE, LEITURA.agent_case_events)).toBe(1);
});
it("(c) um membro NÃO insere em `conversation_assignment_events`, e CONTINUA lendo", () => {
// Sem isto, um `viewer` insere {conversation_id, to_user_id} pelo PostgREST e
// o histórico de dono passa a dizer que alguém assumiu o atendimento.
esperaBarradoPorPrivilegio(VIEWER, ESCRITA_FORJADA.conversation_assignment_events);
expect(
contaComoMembro(AGENTE, LEITURA.conversation_assignment_events),
).toBeGreaterThanOrEqual(1);
});
it("CONTROLE: com o GRANT e uma policy de volta, o mesmo INSERT PASSA", () => {
// Reproduz o estado anterior à 0279 numa transação desfeita. Sem este caso,
// um INSERT malformado (uma coluna NOT NULL esquecida) devolveria erro por
// outro motivo e os casos (a)–(c) ficariam verdes sem medir privilégio.
const [inseridas] = sondasDesfeitas(`
grant insert on public.agent_cases to authenticated;
create policy tmp_0279_insert on public.agent_cases
for insert to authenticated with check (true);
${comoMembro(VIEWER, true)}
with w as (${ESCRITA_FORJADA.agent_cases} returning 1)
select '${MARCA}' || count(*) from w;
`);
expect(inseridas, "a simulação do estado pré-0279 não reproduz a escrita").toBe("1");
});
it("CONTROLE: com a POLICY de volta e o revoke MANTIDO, o INSERT segue barrado", () => {
// O revoke sozinho já basta para o PostgREST — é a guarda que sobrevive a
// alguém recriar uma policy larga depois. (A recíproca não vale: a policy
// sozinha faria o UPDATE casar zero linhas e o PostgREST devolver SUCESSO,
// que é por que a migration faz as duas coisas.)
const erro = erroDo(`
begin;
create policy tmp_0279_insert on public.agent_cases
for insert to authenticated with check (true);
${comoMembro(VIEWER, true)}
${ESCRITA_FORJADA.agent_cases};
rollback;
`);
expect(erro, "a policy sozinha reabriu a escrita — o revoke não pegou").not.toBeNull();
expect(erro).toContain("permission denied");
});
it.each(TABELAS)("`%s` fica sem policy permissiva de escrita — a origem (B) também fecha", (tabela) => {
const escrita = policiesDe(tabela).filter((p) => /:(INSERT|UPDATE|DELETE|ALL)$/.test(p));
expect(escrita, `${tabela} ainda tem policy de escrita: ${escrita.join(", ")}`).toEqual([]);
});
it.each(TABELAS)("`%s` mantém RLS ligada e a policy de leitura", (tabela) => {
const rls = sql(`
select c.relrowsecurity from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relname = '${tabela}';
`).trim();
expect(rls, `${tabela} perdeu a RLS`).toBe("t");
expect(policiesDe(tabela).filter((p) => p.endsWith(":SELECT")).length).toBeGreaterThan(0);
});
});
describe("0279 — `emit_event` reserva os eventos de caso ao servidor", () => {
const RESERVADOS = ["ai.case_opened", "ai.case_closed"] as const;
it.each(RESERVADOS)("(d) um membro NÃO emite `%s` — 42501", (tipo) => {
const erro = erroDo(`
${comoMembro(AGENTE)}
select public.emit_event('${tipo}', 'agent_case', '${CASO}'::uuid,
'{}'::jsonb, '{}'::jsonb, '${ORG}'::uuid);
`);
expect(erro, `um membro emitiu ${tipo} SEM erro`).not.toBeNull();
// A mensagem fica com o nome herdado: renomeá-la é mudança de contrato
// observável, e não medimos se alguém a trata por nome.
expect(erro).toContain("reserved_message_received");
});
it("(d) CONTROLE: o MESMO membro emite um tipo não reservado", () => {
// Sem isto, um 42501 vindo de outro lugar (membership revogada, suporte em
// modo somente leitura) leria exatamente como "a reserva funcionou".
const [emitido] = sondasDesfeitas(`
${comoMembro(AGENTE, true)}
select '${MARCA}' || (public.emit_event('ai.case_sonda', 'agent_case', '${CASO}'::uuid,
'{}'::jsonb, '{}'::jsonb, '${ORG}'::uuid) is not null);
`);
expect(emitido, "o membro não emite nem tipo livre — o 42501 acima não prova a reserva").toBe(
"t",
);
});
it.each(RESERVADOS)("(d) o SERVIDOR emite `%s` e a linha entra no event_log", (tipo) => {
// `service_role` não tem `auth.uid()`, que é a condição da reserva: o motor
// segue emitindo. Numa transação desfeita para não deixar evento solto no
// banco compartilhado da suíte.
const [gravadas] = sondasDesfeitas(`
set local role service_role;
select public.emit_event('${tipo}', 'agent_case', '${CASO}'::uuid,
'{}'::jsonb, '{}'::jsonb, '${ORG}'::uuid);
select '${MARCA}' || count(*) from public.event_log
where organization_id = '${ORG}' and event_type = '${tipo}';
`);
expect(gravadas, `o servidor não conseguiu emitir ${tipo}`).toBe("1");
});
});
describe("0279 — o que tinha de continuar funcionando", () => {
it("(e) CONTROLE POSITIVO: `fn_conversation_assign` como `agent` ainda grava o evento", () => {
// Este é o caso que diz se a 0279 quebrou o produto. As cinco rotas de troca
// de dono (claim, transfer, release) chamam esta RPC com o client de SESSÃO;
// o INSERT em conversation_assignment_events é feito DENTRO dela, que é
// `security definer` e executa com o privilégio do DONO — por isso o revoke
// de `authenticated` não a alcança. Se um dia alguém a tornar `security
// invoker`, é AQUI que aparece.
const antes = contaComoMembro(
AGENTE,
`select count(*) from public.conversation_assignment_events
where conversation_id = '${CONVERSA_CLAIM}'`,
);
// `p_enforce_expected = false` de propósito: o que este caso mede é se a
// auditoria continua sendo gravada, não a trava otimista (que tem dono em
// gov-3-assignment-events.test.ts). Com `true`, uma segunda execução contra
// o mesmo banco encontraria a conversa já reivindicada, a função devolveria
// zero linhas e o caso ficaria vermelho por estado herdado, não por defeito.
const atribuidas = contaComoMembro(
AGENTE,
`select count(*) from public.fn_conversation_assign(
'${ORG}'::uuid, '${CONVERSA_CLAIM}'::uuid, '${AGENTE}'::uuid, 'claim', null::uuid, false)`,
);
expect(atribuidas, "a RPC de troca de dono não atribuiu a conversa").toBe(1);
const depois = contaComoMembro(
AGENTE,
`select count(*) from public.conversation_assignment_events
where conversation_id = '${CONVERSA_CLAIM}' and reason = 'claim'
and to_user_id = '${AGENTE}'`,
);
expect(depois, "a RPC atribuiu a conversa e NÃO gravou a auditoria").toBe(antes + 1);
});
it("(f) `agent_cases` fica com ZERO policies `support_write_*` — é server-only", () => {
// A 0274 varre o catálogo: tabela gravável por `authenticated` recebe as três
// restritivas; tabela só do servidor recebe NENHUMA, que é o contrato mais
// restritivo. A migration chama `fn_aplicar_travas_de_suporte()` no fim, e é
// isso que este caso mede — tirar a chamada deixa as três órfãs aqui.
for (const tabela of TABELAS) {
const orfas = policiesDe(tabela).filter((p) => p.startsWith("support_write_"));
expect(orfas, `${tabela} ficou com trava de suporte órfã: ${orfas.join(", ")}`).toEqual([]);
}
});
it("(f) CONTROLE: a varredura de travas de suporte RODOU — outras tabelas as têm", () => {
// Sem este controle, um banco em que `fn_aplicar_travas_de_suporte()` nunca
// rodou passaria no caso acima por vacuidade: zero travas em toda parte.
const comTrava = sql(`
select count(*) from pg_policies
where schemaname = 'public' and policyname = 'support_write_insert';
`).trim();
expect(Number(comTrava), "nenhuma tabela tem trava de suporte — a varredura não rodou").
toBeGreaterThan(10);
});
});
@@ -156,7 +156,35 @@ describe("eixo 3 — G3-01: eventos de atribuição", () => {
expect(eventCount("true", GOV_AGENT_B)).toBe(total);
});
it("append-only: UPDATE e DELETE em conversation_assignment_events são negados por RLS", () => {
/**
* A escrita foi BARRADA — pelos dois caminhos que hoje a barram, nesta ordem.
*
* Até a migration 0279 só existia um: `authenticated` tinha o GRANT (do
* `ALTER DEFAULT PRIVILEGES … ON TABLES` do baseline) e quem recusava era a
* RLS, por não haver policy de UPDATE/DELETE — daí o `writeCountAs` traduzir
* "barrado" em ZERO LINHAS. A 0279 tirou o grant, e o Postgres passa a parar o
* comando ANTES da RLS, com `permission denied for table …`: a MESMA
* propriedade (a tabela não recebe UPDATE nem DELETE por login de usuário),
* recusada mais cedo e por uma guarda mais forte.
*
* Sem esta ponte o caso ficaria VERMELHO por ter sido reforçado, que é o
* modo de falha em que alguém "conserta" devolvendo o grant. O nome da tabela
* entra na sonda de propósito: um `permission denied` em OUTRA tabela é
* defeito de verdade e continua estourando.
*/
function escritaBarrada(dml: string): number {
try {
return writeCountAs(GOV_MANAGER, dml);
} catch (err) {
const motivo = `${err instanceof Error ? err.message : String(err)}${
(err as { stderr?: unknown }).stderr ?? ""
}`;
if (motivo.includes("permission denied for table conversation_assignment_events")) return 0;
throw err;
}
}
it("append-only: UPDATE e DELETE em conversation_assignment_events são negados (privilégio desde a 0279; RLS antes dela)", () => {
// O escritor é o MANAGER e não o agent A, de propósito: desde a 0173 o A não
// enxerga esta conversa, então um `0` escrito por ele não distinguiria
// "não existe policy de UPDATE" de "a linha está fora do escopo do leitor" —
@@ -167,15 +195,13 @@ describe("eixo 3 — G3-01: eventos de atribuição", () => {
countAs(GOV_MANAGER, `select count(*) from public.conversations where id = '${CAE_CONV}'`),
).toBe(1);
const updated = writeCountAs(
GOV_MANAGER,
const updated = escritaBarrada(
`update public.conversation_assignment_events set reason = 'routing'
where conversation_id = '${CAE_CONV}'`,
);
expect(updated).toBe(0);
const deleted = writeCountAs(
GOV_MANAGER,
const deleted = escritaBarrada(
`delete from public.conversation_assignment_events
where conversation_id = '${CAE_CONV}'`,
);