feat(G2-03): role-based RLS on config tables — migration 0030 [gov-loop]

Triple migration: crm_pipelines/crm_stages write manager+, conversations
write agent+ (viewer read-only), SELECTs unchanged; old ALL policies dropped.
Ratchet flip: 2 GAP(G2) it.fails in tests/invariants/gov-1-rbac.test.ts
flipped to normal tests (DESKCOMM_GOV_INVARIANTS_EDIT=1, legit exception).
Verified-by: gov-verifier PASS 2026-07-16T23:07:32-0300
This commit is contained in:
Rafael Melgaço
2026-07-16 23:07:32 -03:00
parent b157aeeaa3
commit f3954ab7bf
8 changed files with 302 additions and 11 deletions
+32 -2
View File
@@ -257,6 +257,36 @@ Notas:
Enforcement em **duas camadas obrigatórias**: RLS (fronteira) + helper único de
rota (`require-role`, G2-01) — nunca só UI (anti-padrão 3).
### 4.1 Auditoria de policies RLS por role (G2-03)
Auditoria mecânica do `supabase/baseline.sql` (gov/G2, 2026-07-16): tabela →
policy de escrita → role mínimo efetivo. **Org-flat** = qualquer membro da org
(incl. viewer) escreve. Tabelas fora da matriz §4 (ai_*, channel_sessions,
contacts operacionais etc.) não são "config" e ficam fora do alvo desta fase.
| Tabela | Policy de escrita (baseline) | Role mínimo | Org-flat? | Config §4? | Ação G2-03 |
|---|---|---|---|---|---|
| api_tokens | `api_tokens_admin_only` | admin | não | sim | manter |
| lgpd_requests | `lgpd_requests_admin_write` | admin | não | — | manter |
| merge_queue | `merge_queue_manager_write` | manager | não | — | manter |
| tenant_integrations | `tenant_integrations_admin_write` | manager | não | sim (integrações) | manter |
| user_organizations (team) | `user_orgs_insert/update/delete` | admin | não | sim | manter |
| organizations (settings) | `orgs_write_platform_admin` | platform admin (tenant escreve via service role + guard de rota) | não | sim | manter |
| **crm_pipelines** | `tenant_isolation_crm_pipelines_all` (ALL) | **qualquer membro** | **sim** | **sim (pipelines config)** | **migration 0030: write manager+** |
| **crm_stages** | `tenant_isolation_crm_stages_all` (ALL) | **qualquer membro** | **sim** | **sim (config de pipeline, nota 4)** | **migration 0030: write manager+** |
| **conversations** | `conversations_tenant_isolation_all` (ALL) | **qualquer membro (incl. viewer)** | **sim** | não é config, mas viewer é read-only (nota 1) | **migration 0030: write agent+; SELECT intocado (escopo own é G4-01)** |
| messages | `messages_tenant_isolation_all` (ALL) | qualquer membro | sim | não (operacional; escopo segue conversations) | G4-01 |
| contacts | `tenant_isolation_contacts_all` (ALL) | qualquer membro | sim | não (agent tem org:write, nota 3; viewer-write fica pra G4 junto do escopo) | G4 |
| crm_leads / crm_lead_activities / crm_lead_links | `tenant_isolation_*` (ALL) | qualquer membro | sim | não (operacional; escopo own é G4-01) | G4-01 |
| channel_sessions, ai_*, orders, nuvemshop_products, idempotency_keys, warmup, storage_redaction_queue | `*_tenant_isolation_*` (ALL) | qualquer membro | sim | não classificado na matriz §4 | fora do escopo G2-03 |
| api_audit_log | `audit_log_insert_tenant_member` (insert-only, append) | qualquer membro | por design | — | manter (select manager é read, não write) |
Resultado: as tabelas de **config org-flat** são `crm_pipelines` e `crm_stages`;
a migration `20260716120000_0030_config_rls_role_policies.sql` aplica
`fn_role_at_least(organization_id, 'manager')` nas write-policies delas e
`fn_role_at_least(organization_id, 'agent')` no write de `conversations`
(viewer read-only), mantendo todos os SELECTs org-flat.
## 5. Roteamento (decisões G1-06; G5 implementa)
- **Modos no MVP** (decisão G1-06b): `manual` (só claim/atribuição humana) e
@@ -312,8 +342,8 @@ entre orgs (pré-requisito de tudo) já é coberto por
| 1. RBAC | `gov-1-rbac.test.ts` → "fn_user_role_in mapeia viewer→1, agent→2, manager→3, admin→4" | passa |
| 1. RBAC | `gov-1-rbac.test.ts` → "RLS impede agent de se auto-promover (user_orgs_update é admin-only)" | passa |
| 1. RBAC | `gov-1-rbac.test.ts` → "role de membro é editável via API — PATCH /api/v1/team/[user_id]/role existe" (gap do plano JÁ fechado pelo EPIC-09) | passa |
| 1. RBAC | `gov-1-rbac.test.ts` → "agent NÃO escreve config de pipeline (spec 13 §4: manager+)" | GAP G2 |
| 1. RBAC | `gov-1-rbac.test.ts` → "viewer NÃO escreve em conversations (spec 13 §4: viewer é read-only)" | GAP G2 |
| 1. RBAC | `gov-1-rbac.test.ts` → "agent NÃO escreve config de pipeline (spec 13 §4: manager+)" | passa (fechado por G2-03, migration 0030) |
| 1. RBAC | `gov-1-rbac.test.ts` → "viewer NÃO escreve em conversations (spec 13 §4: viewer é read-only)" | passa (fechado por G2-03, migration 0030) |
| 2. Atribuição | `gov-2-assignment.test.ts` → "conversations tem assigned_to_user_id + assigned_at, com FK para auth.users" | passa |
| 2. Atribuição | `gov-2-assignment.test.ts` → "crm_leads tem owner_user_id" | passa |
| 2. Atribuição | `gov-2-assignment.test.ts` → "mudança de owner em crm_leads emite lead.assigned no event_log" | passa |
+7 -2
View File
@@ -226,8 +226,13 @@
"priority": 30,
"lane": "core",
"kind": "build",
"passes": false,
"verification": null
"passes": true,
"verification": {
"verdict": "PASS",
"by": "gov-verifier",
"at": "2026-07-16T23:07:32-0300",
"commit": "self"
}
},
{
"id": "G2-04",
+17
View File
@@ -154,3 +154,20 @@
(pré-existente do EPIC-09, race de ms entre 2 admins) — candidata a inbox se
o dono quiser fechar com constraint/trigger.
- Próxima sessão: G2-03 (RLS por role nas tabelas de config — migration tripla).
## 2026-07-16 — sessão 11 do loop (core) — G2-03
- G2-03 (RLS por role): migration 0030 em tripla (migrations/ + apêndice
baseline + MANIFEST; types.ts intocado — policies não mudam contrato).
crm_pipelines/crm_stages: SELECT org-flat + write manager+; conversations:
SELECT byte-idêntico ao antigo (leitura NÃO estreitada — own-scope é G4-01)
+ write agent+ (viewer read-only). Policies ALL antigas dropadas (sem OR órfão).
- Flip da catraca: 2 it.fails GAP(G2) de gov-1-rbac viraram testes normais
(única mudança no arquivo; commit com DESKCOMM_GOV_INVARIANTS_EDIT=1).
Novo invariante gov-1-rbac-config-write.test.ts (positivos+negativos).
- Auditoria de policies registrada como spec 13 §4.1; Apêndice A: 2 GAP G2 → passa.
- gov-verifier: PASS 1ª rodada, hash-check OK. test:db install+update verdes,
35/35 invariantes pós-update, 123 unit.
- INB-05 aberto (proposal): spec 13 §4 nota 8 prevê api_audit_log SELECT
manager+ "aplicada em G2", mas nenhuma feature G2 cobre — decisão do dono.
- Próxima sessão: G2-04 (e2e Playwright de papéis) fecha a fase → checkpoint G2.
+71
View File
@@ -4201,3 +4201,74 @@ revoke all on function public.fn_mark_conversation_message(uuid, text, text, tim
grant execute on function public.fn_upsert_wa_contact(uuid, text, text, text, text, text) to service_role;
grant execute on function public.fn_upsert_wa_conversation(uuid, uuid, uuid) to service_role;
grant execute on function public.fn_mark_conversation_message(uuid, text, text, timestamptz) to service_role;
-- ---- RLS por role em tabelas de config + viewer read-only (migration 0030) ----
-- G2-03: spec 13 §4 — pipelines/stages (config) write manager+; conversations
-- write agent+ (viewer read-only). SELECT permanece org-flat (escopo own é G4).
-- Idempotente: drop if exists + create (auto-curativo no update.sh de clones).
drop policy if exists "tenant_isolation_crm_pipelines_all" on public.crm_pipelines;
drop policy if exists "crm_pipelines_select" on public.crm_pipelines;
drop policy if exists "crm_pipelines_manager_write" on public.crm_pipelines;
create policy "crm_pipelines_select" on public.crm_pipelines
for select using (
(organization_id in (select public.fn_user_org_ids()))
or public.fn_is_platform_admin()
);
create policy "crm_pipelines_manager_write" on public.crm_pipelines
using (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'manager'))
)
with check (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'manager'))
);
drop policy if exists "tenant_isolation_crm_stages_all" on public.crm_stages;
drop policy if exists "crm_stages_select" on public.crm_stages;
drop policy if exists "crm_stages_manager_write" on public.crm_stages;
create policy "crm_stages_select" on public.crm_stages
for select using (
(organization_id in (select public.fn_user_org_ids()))
or public.fn_is_platform_admin()
);
create policy "crm_stages_manager_write" on public.crm_stages
using (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'manager'))
)
with check (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'manager'))
);
drop policy if exists "conversations_tenant_isolation_all" on public.conversations;
drop policy if exists "conversations_select" on public.conversations;
drop policy if exists "conversations_agent_write" on public.conversations;
create policy "conversations_select" on public.conversations
for select using (
(organization_id in (select public.fn_user_org_ids()))
or public.fn_is_platform_admin()
);
create policy "conversations_agent_write" on public.conversations
using (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'agent'))
)
with check (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'agent'))
);
@@ -0,0 +1,83 @@
-- 0030_config_rls_role_policies — G2-03: RLS por role nas tabelas de config
-- (defesa em profundidade da matriz spec 13 §4; padrão fn_role_at_least já
-- usado em api_tokens/lgpd_requests/merge_queue).
--
-- Alvos (auditoria em docs/specs/13-spec-governanca-atendimento.md §4.1):
-- * crm_pipelines / crm_stages — "pipelines (config)": read org-flat,
-- WRITE manager+ (era org-flat: qualquer membro escrevia config).
-- * conversations — viewer é read-only: WRITE agent+. SELECT permanece
-- org-flat intocado (escopo own/unassigned é G4-01, não aqui).
--
-- Idempotente e auto-curativo: drop policy if exists + create. Sem dados a
-- corrigir (policies não invalidam linhas existentes). Sem BEGIN/COMMIT
-- (runner envolve em transação). Portável em psql puro.
-- crm_pipelines: split da policy ALL org-flat em SELECT org + WRITE manager+
drop policy if exists "tenant_isolation_crm_pipelines_all" on public.crm_pipelines;
drop policy if exists "crm_pipelines_select" on public.crm_pipelines;
drop policy if exists "crm_pipelines_manager_write" on public.crm_pipelines;
create policy "crm_pipelines_select" on public.crm_pipelines
for select using (
(organization_id in (select public.fn_user_org_ids()))
or public.fn_is_platform_admin()
);
create policy "crm_pipelines_manager_write" on public.crm_pipelines
using (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'manager'))
)
with check (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'manager'))
);
-- crm_stages: mesma regra (stages são config de pipeline — spec 13 §4 nota 4)
drop policy if exists "tenant_isolation_crm_stages_all" on public.crm_stages;
drop policy if exists "crm_stages_select" on public.crm_stages;
drop policy if exists "crm_stages_manager_write" on public.crm_stages;
create policy "crm_stages_select" on public.crm_stages
for select using (
(organization_id in (select public.fn_user_org_ids()))
or public.fn_is_platform_admin()
);
create policy "crm_stages_manager_write" on public.crm_stages
using (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'manager'))
)
with check (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'manager'))
);
-- conversations: viewer read-only (spec 13 §4 nota 1). SELECT continua
-- org-flat com a MESMA expressão da policy ALL antiga; write vira agent+.
drop policy if exists "conversations_tenant_isolation_all" on public.conversations;
drop policy if exists "conversations_select" on public.conversations;
drop policy if exists "conversations_agent_write" on public.conversations;
create policy "conversations_select" on public.conversations
for select using (
(organization_id in (select public.fn_user_org_ids()))
or public.fn_is_platform_admin()
);
create policy "conversations_agent_write" on public.conversations
using (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'agent'))
)
with check (
public.fn_is_platform_admin()
or ((organization_id in (select public.fn_user_org_ids()))
and public.fn_role_at_least(organization_id, 'agent'))
);
+1
View File
@@ -33,6 +33,7 @@ Migrations applied to Supabase project `rrydmwnporysaiysiztn` (sa-east-1, Postgr
| `20260506100000` | `0025_fix_publish_fn_and_realtime_publication` | Forward-fix: qualifies column refs in fn_publish_ai_agent_version to resolve `agent_id` ambiguity against RETURNS TABLE output params; adds ai_agent_runs/ai_agents/ai_knowledge_sources to the supabase_realtime publication. |
| `20260706200000` | `0026_fix_publish_fn_status_case` | Forward-fix: fn_publish_ai_agent_version compared channel_sessions.status against lowercase `'working'`, but the canonical value (channel_sessions_status_check, written by the WAHA webhook handler) is uppercase `'WORKING'`. Publish always raised `channel_session_offline`, even for a genuinely connected session. Bug present since 0024, carried forward unchanged by 0025. |
| `20260706210000` | `0027_whatsapp_conversation_unification` | Bugfix de governança de conversas WhatsApp. Causa-raiz: contatos @lid sem unique key + resolução check-then-act, e o WAHA emitindo `message`+`message.any` por mensagem → 1 pessoa virava N contatos/conversas (medido: 1 lid = 12 contatos). Adiciona coluna gerada `contacts.wa_identity` (`phone:+E164`/`lid:<digits>`), faz merge idempotente do histórico duplicado repontando todas as FKs (usa `is_merged_into` como mapa, sem temp tables → portável em psql), cria `uniq_contacts_org_wa_identity` + `uniq_conversations_1to1_per_contact_session` (a antiga unique incluía group_chat_id NULL, que no Postgres não protege 1:1), e as funções de upsert atômico `fn_upsert_wa_contact`/`fn_upsert_wa_conversation`/`fn_mark_conversation_message` que a app passa a usar (lib/waha/ingest.ts) no lugar do check-then-act. Também corrige mapeamento de `type` WAHA→CRM (`chat`→`text` etc.) que fazia mensagens reais violarem messages_type_check e sumirem. |
| `20260716120000` | `0030_config_rls_role_policies` | G2-03 (gov-loop): RLS por role nas tabelas de config, fechando no banco o que a G2-01 fechou na API (spec 13 §4; auditoria em §4.1). crm_pipelines/crm_stages: policy ALL org-flat vira SELECT org + WRITE manager+ (`fn_role_at_least`, padrão de api_tokens/merge_queue) — agent deixa de escrever config de pipeline. conversations: WRITE vira agent+ (viewer é read-only); SELECT permanece org-flat com a mesma expressão (escopo own/unassigned é G4-01). NNNN pula 0028/0029 (ocupados nas branches vendaval/F2-*). Sem mudança de contrato — database.types.ts intocado. |
## Reproducibility
@@ -0,0 +1,84 @@
import { beforeAll, describe, expect, it } from "vitest";
import {
GOV_AGENT_A,
GOV_CONV_UNASSIGNED,
GOV_MANAGER,
GOV_PIPELINE,
GOV_STAGE,
GOV_VIEWER,
countAs,
seedGov,
writeCountAs,
} from "./gov-helpers";
/**
* Eixo 1 — RBAC em tabelas de config (G2-03, migration 0030).
* spec 13 §4.1: crm_pipelines/crm_stages write manager+; conversations write
* agent+ (viewer read-only). SELECT permanece org-flat — os controles de
* leitura abaixo garantem que a migration NÃO estreitou visibilidade
* (escopo own/unassigned é G4-01, fora daqui).
*/
beforeAll(() => {
seedGov();
});
describe("eixo 1 — RBAC de escrita em config (migration 0030)", () => {
it("manager ESCREVE config de pipeline (spec 13 §4: manager+)", () => {
const updated = writeCountAs(
GOV_MANAGER,
`update public.crm_pipelines set name = name where id = '${GOV_PIPELINE}'`,
);
expect(updated).toBe(1);
});
it("agent NÃO escreve em crm_stages (config de pipeline, spec 13 §4 nota 4)", () => {
const updated = writeCountAs(
GOV_AGENT_A,
`update public.crm_stages set name = name where id = '${GOV_STAGE}'`,
);
expect(updated).toBe(0);
});
it("manager ESCREVE em crm_stages", () => {
const updated = writeCountAs(
GOV_MANAGER,
`update public.crm_stages set name = name where id = '${GOV_STAGE}'`,
);
expect(updated).toBe(1);
});
it("agent continua escrevendo em conversations (controle positivo do write agent+)", () => {
const updated = writeCountAs(
GOV_AGENT_A,
`update public.conversations set status = status where id = '${GOV_CONV_UNASSIGNED}'`,
);
expect(updated).toBe(1);
});
it("viewer NÃO faz UPDATE em conversations (complementa o probe de INSERT do gov-1)", () => {
const updated = writeCountAs(
GOV_VIEWER,
`update public.conversations set status = status where id = '${GOV_CONV_UNASSIGNED}'`,
);
expect(updated).toBe(0);
});
it("SELECT continua org-flat: viewer e agent leem pipelines, stages e conversations", () => {
for (const userId of [GOV_VIEWER, GOV_AGENT_A]) {
expect(
countAs(userId, `select count(*) from public.crm_pipelines where id = '${GOV_PIPELINE}';`),
).toBe(1);
expect(
countAs(userId, `select count(*) from public.crm_stages where id = '${GOV_STAGE}';`),
).toBe(1);
expect(
countAs(
userId,
`select count(*) from public.conversations where id = '${GOV_CONV_UNASSIGNED}';`,
),
).toBe(1);
}
});
});
+7 -7
View File
@@ -88,10 +88,10 @@ describe("eixo 1 — RBAC", () => {
expect(readFileSync(route, "utf8")).toContain("export async function PATCH");
});
// GAP(G2): spec 13 §4 — pipelines (config) é manager+:write, agent=none.
// Hoje a policy tenant_isolation_crm_pipelines_all é org-flat: qualquer
// membro (incl. agent) escreve config de pipeline.
it.fails("agent NÃO escreve config de pipeline (spec 13 §4: manager+)", () => {
// Corrigido (G2-03, migration 0030): crm_pipelines_manager_write aplica
// fn_role_at_least(org, 'manager') — spec 13 §4: pipelines (config) é
// manager+:write, agent=none.
it("agent NÃO escreve config de pipeline (spec 13 §4: manager+)", () => {
const updated = writeCountAs(
GOV_AGENT_A,
`update public.crm_pipelines set name = name where id = '${GOV_PIPELINE}'`,
@@ -99,9 +99,9 @@ describe("eixo 1 — RBAC", () => {
expect(updated).toBe(0);
});
// GAP(G2): spec 13 §4 — viewer é read-only em conversations. Hoje a policy
// org-flat (WITH CHECK por org) deixa o viewer inserir/escrever.
it.fails("viewer NÃO escreve em conversations (spec 13 §4: viewer é read-only)", () => {
// Corrigido (G2-03, migration 0030): conversations_agent_write aplica
// fn_role_at_least(org, 'agent') — spec 13 §4: viewer é read-only.
it("viewer NÃO escreve em conversations (spec 13 §4: viewer é read-only)", () => {
const inserted = writeCountAs(
GOV_VIEWER,
`insert into public.conversations (id, organization_id, contact_id, channel_session_id, status)