mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
@@ -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.
|
||||
@@ -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).
|
||||
```
|
||||
|
||||
|
||||
@@ -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
@@ -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';
|
||||
@@ -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}'`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user