From 5b342146d583c1ff705a124807ba7e9f9de6d437 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Sun, 9 Aug 2026 00:55:11 -0300 Subject: [PATCH] fix(C96): intersect creator scope in calendar feed read buildFeedWhere trusted filterMunicipality without intersecting the creator's current municipality scope when accessibleMunicipalityIds is non-null (advisor). An advisor removed from a municipality after creating a pinned feed kept serving its activities. Now the read re-derives the creator's scope on every request (fail-closed, aligned with C16) and intersects it into the where clause via the canonical advisorMunicipalityScopeWhere fragment. Int tests cover: advisor in-scope lists, advisor removed stops serving, coordinator (unrestricted) unchanged. No migration (config/consulta only). --- ...c96-ical-feed-intersect-scope-read-impl.md | 66 ++++++++++ src/utilities/calendarFeed.ts | 8 ++ tests/int/calendarFeed.int.spec.ts | 119 ++++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 docs/plans/c96-ical-feed-intersect-scope-read-impl.md diff --git a/docs/plans/c96-ical-feed-intersect-scope-read-impl.md b/docs/plans/c96-ical-feed-intersect-scope-read-impl.md new file mode 100644 index 00000000..8dc3e669 --- /dev/null +++ b/docs/plans/c96-ical-feed-intersect-scope-read-impl.md @@ -0,0 +1,66 @@ +# Impl: C96 — Feed iCal: interseptar escopo do criador no read (defesa em profundidade) + +Status: aprovado +Atualizado em: 2026-08-09 +Issue: #455 +Intenção: docs/plans/c96-ical-feed-intersect-scope-read.md +Appetite restante: ~0,25–0,5 dia eng (herdado) + +## Leitura da intenção + +- **Outcome:** no GET `/campanha/agenda/ical/[secret]`, um feed de assessor cujo `filterMunicipality` caiu fora dos municípios atualmente administrados pelo criador para de servir atividades dele — fail-closed (feed vazio, alinhado ao C16). Sem mudança para coordinator/candidate/admin. Sem mudança no write, sem migration. +- **O que NÃO negociar:** leader lockdown; cancelado fica fora; o segredo não vaza; coordinator/candidate/admin inalterados. +- **O que reavaliar:** hipótese da intenção ("validar antes e 404" vs "interseptar na where") — ver decisão abaixo. Resolvido a favor da **interseção na `where`** porque é o único lugar que o módulo de acesso do feed já orquestra o escopo no read e já tem o fragmento canônico `advisorMunicipalityScopeWhere`; 404 é aceitável mas a interseção na query serve o mesmo outcome sem nova ramificação no route. + +## Abordagem recomendada + +```mermaid +flowchart LR + GET[route.ts] --> resolve[resolveFeedCreatorAccess] + resolve --> load[loadFeedActivities] + load --> where[buildFeedWhere] + where --> f1[range/status/deputy/tag] + where --> f2[filterMunicipality equals] + where --> f3[advisor scope in ids] + f2 & f3 --> and[and: {...}] +``` + +**Opções consideradas:** A | B +**Recomendação:** A — porque o escopo do assessor já é resolvido no read (`resolveFeedCreatorAccess` → `accessibleMunicipalityIds`); interseptá-lo na `where` muda o mínimo, reusa o fragmento canônico `advisorMunicipalityScopeWhere` (obrigatório pela convention guard), e produz feed vazio (aceitável no aceite) sem tocar o route. +**Rejeitadas:** B (validar antes e 404) porque duplica a decisão de acesso no route (nova ramificação + novo helper de resolução do id do filtro), quando a query já é o ponto único de orquestração. + +### Componentes / mudanças + +- **`buildFeedWhere`** (`src/utilities/calendarFeed.ts:104-112`): quando `feed.filterMunicipality` estiver setado **e** `accessibleMunicipalityIds` não-nulo, adicionar **também** o fragmento `advisorMunicipalityScopeWhere('municipality', accessibleMunicipalityIds)` ao array de filtros (hoje o `else if` o ignora quando há filtro). `accessibleMunicipalityIds === null` (coordinator/candidate/admin) → comportamento inalterado. `[]` → `in: []` não casa nada → feed vazio. +- **Migration:** sem migration (config/consulta only). +- **Access / Consent:** sem `Consent` novo; mesmos `overrideAccess: true` já documentados no read (feed unauthenticated por secret; escopo re-derivado do autor). Invariante de `overrideAccess:false` com `user` intocado (não há `user` neste read). +- **UI:** Impeccable A — N/A sem UI. + +## Fases verificáveis + +1. **Schema/server** — `buildFeedWhere` (intersecção condicional) em `src/utilities/calendarFeed.ts`. +2. **Testes int** — extensão de `tests/int/calendarFeed.int.spec.ts`: + - assessor cria feed pinado a X (`filterMunicipality`) com atividade em X → `resolveFeedCreatorAccess` devolve `[X]` → `loadFeedActivities` lista a atividade (regressão positiva); + - assessor removido de X (`assignMunicipalityAdvisors(X, [])`) → `resolveFeedCreatorAccess` devolve `[]` → `loadFeedActivities` **não** lista a atividade (aceite C96); + - coordinator/candidate pinados a X inalterados: `accessibleMunicipalityIds === null` → atividade de X continua listada. + - fixtures: reusar `createActivityRecord` (coordinator), `assignMunicipalityAdvisors`, `getMunicipality`, `own`. +3. **Gates** — `pnpm gate:fast` na iteração; `pnpm test` (int afetado); `pnpm build` local. + +## Rabbit holes / Não escopo (engenharia) + +- **Refactor do modelo de acesso do feed** (maior pool de escopo para além da where) — a intenção declara fora de escopo. +- **404 vs feed vazio** — decisão de UX documentada (C16 aceita ambos); implementado feed vazio; sem nova ramificação no route. +- **UI do feed / sem-filtros (C94/C93)** — Issues separadas. + +## Riscos e mitigação + +- **Regressão em coordinator/candidate:** interseção só ativa quando `accessibleMunicipalityIds` não-nulo (somente advisor); teste int cobre o caminho null. +- **`in: []` não casando nada:** comportamento já estabelecido do fragmento canônico usado em todo o repo (fail-closed); teste int cobre o advisor removido. +- **Dupla cláusula `municipality` (`equals` + `in`) no msm `and`:** Payload combina cláusulas sob a mesma chave via `and`; idempotente para X ∈ escopo (equals domina), vazio para X ∉ escopo. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (advisor fora do escopo → feed não inclui; unrestricted/admin inalterados; sem write; sem migration) +- [ ] Invariantes AGENTS/engineering-standards (fragmento canônico de escopo; copy pt-BR; identificadores EN; sem `overrideAccess:false` invertido) +- [ ] Testes int de domínio (advisor no escopo lista; advisor removido não lista; coordinator inalterado) +- [ ] `pnpm gate:fast` + `pnpm test` + `pnpm build` verdes diff --git a/src/utilities/calendarFeed.ts b/src/utilities/calendarFeed.ts index 99f17e1f..343b2647 100644 --- a/src/utilities/calendarFeed.ts +++ b/src/utilities/calendarFeed.ts @@ -107,6 +107,14 @@ const buildFeedWhere = ( ? feed.filterMunicipality : feed.filterMunicipality.id filters.push({ municipality: { equals: municipalityId } }) + // Defense in depth (C96): an advisor's feed is intersected with the + // municipality ids the creator currently administers, so a feed pinned to a + // municipality the advisor was removed from stops serving it — the creator's + // read scope is re-derived on every request (resolveFeedCreatorAccess), never + // trusted from write time. `null` = coordinator/candidate/admin (unrestricted). + if (accessibleMunicipalityIds) { + filters.push(advisorMunicipalityScopeWhere('municipality', accessibleMunicipalityIds)) + } } else if (accessibleMunicipalityIds) { filters.push(advisorMunicipalityScopeWhere('municipality', accessibleMunicipalityIds)) } diff --git a/tests/int/calendarFeed.int.spec.ts b/tests/int/calendarFeed.int.spec.ts index cbafa5b3..b2e0f0e7 100644 --- a/tests/int/calendarFeed.int.spec.ts +++ b/tests/int/calendarFeed.int.spec.ts @@ -3,6 +3,7 @@ import { getPayload, type Payload } from 'payload' import { beforeAll, describe, expect, it } from 'vitest' +import { createActivityRecord } from '@/app/(campaign)/campanha/actions/activity' import { createCalendarFeedLinkRecord, listCalendarFeedsRecord, @@ -10,6 +11,7 @@ import { type CalendarFeedLinkResult, } from '@/app/(campaign)/campanha/actions/calendarFeed' import config from '@/payload.config' +import { loadFeedActivities, resolveFeedCreatorAccess } from '@/utilities/calendarFeed' import { hookFilledCreateData } from '@/utilities/hookFilledData' import { installCampaignFixtures, relationId } from '../helpers/campaignFixtures' @@ -32,6 +34,15 @@ const requireOk = (result: CalendarFeedLinkResult): CalendarFeedOk => { return result } +const validActivityInput = (municipalityId: number, title: string) => ({ + title, + tags: ['Caminhada'], + status: 'confirmado' as const, + startAt: new Date(Date.now() + 86_400_000).toISOString(), + municipality: municipalityId, + locality: 'Centro', +}) + describe('campaign calendar feed domain', () => { beforeAll(async () => { payload = await getPayload({ config: await config }) @@ -238,4 +249,112 @@ describe('campaign calendar feed domain', () => { }) expect((feed as unknown as CalendarFeedWithSecret).secretSlug).toBeUndefined() }) + + it('lets a pinned feed of an in-scope advisor serve that municipality activities (C96)', async () => { + const advisor = await campaignFixtures().createCampaignUser('advisor') + const coordinator = await campaignFixtures().createCampaignUser('coordinator') + const municipality = await campaignFixtures().getMunicipality() + await campaignFixtures().assignMunicipalityAdvisors(municipality, [advisor.id]) + + const activity = await createActivityRecord( + payload, + coordinator, + validActivityInput(relationId(municipality), campaignFixtures().value('Caminhada assessor')), + ) + campaignFixtures().own('activity', activity.id) + + const ok = requireOk( + await createCalendarFeedLinkRecord(payload, advisor, { + label: 'Carteira', + filterMunicipality: relationId(municipality), + }), + ) + campaignFixtures().own('calendarFeed', ok.feedId) + + const feed = await payload.findByID({ + collection: 'calendarFeed', + id: ok.feedId, + depth: 0, + overrideAccess: true, + }) + const access = await resolveFeedCreatorAccess(payload, feed) + expect(access.accessible).toBe(true) + expect(access.municipalityIds).toContain(relationId(municipality)) + + const activities = await loadFeedActivities(payload, feed, access.municipalityIds) + expect(activities.map((doc) => doc.id)).toContain(activity.id) + }) + + it('stops serving a pinned municipality once the advisor is removed (C96)', async () => { + const advisor = await campaignFixtures().createCampaignUser('advisor') + const coordinator = await campaignFixtures().createCampaignUser('coordinator') + const municipality = await campaignFixtures().getMunicipality() + await campaignFixtures().assignMunicipalityAdvisors(municipality, [advisor.id]) + + const activity = await createActivityRecord( + payload, + coordinator, + validActivityInput(relationId(municipality), campaignFixtures().value('Caminhada removida')), + ) + campaignFixtures().own('activity', activity.id) + + const ok = requireOk( + await createCalendarFeedLinkRecord(payload, advisor, { + label: 'Carteira', + filterMunicipality: relationId(municipality), + }), + ) + campaignFixtures().own('calendarFeed', ok.feedId) + + // The advisor loses the municipality after creating the feed. The read must + // re-derive scope on every request (fail-closed), not trust write time. + await campaignFixtures().assignMunicipalityAdvisors(municipality.id, []) + + const feed = await payload.findByID({ + collection: 'calendarFeed', + id: ok.feedId, + depth: 0, + overrideAccess: true, + }) + const access = await resolveFeedCreatorAccess(payload, feed) + expect(access.accessible).toBe(true) + expect(access.municipalityIds).toEqual([]) + + const activities = await loadFeedActivities(payload, feed, access.municipalityIds) + expect(activities.map((doc) => doc.id)).not.toContain(activity.id) + expect(activities).toHaveLength(0) + }) + + it('keeps a coordinator pinned feed serving the municipality (unrestricted, C96)', async () => { + const coordinator = await campaignFixtures().createCampaignUser('coordinator') + const municipality = await campaignFixtures().getMunicipality() + + const activity = await createActivityRecord( + payload, + coordinator, + validActivityInput(relationId(municipality), campaignFixtures().value('Caminhada coord')), + ) + campaignFixtures().own('activity', activity.id) + + const ok = requireOk( + await createCalendarFeedLinkRecord(payload, coordinator, { + label: 'Coord geral', + filterMunicipality: relationId(municipality), + }), + ) + campaignFixtures().own('calendarFeed', ok.feedId) + + const feed = await payload.findByID({ + collection: 'calendarFeed', + id: ok.feedId, + depth: 0, + overrideAccess: true, + }) + const access = await resolveFeedCreatorAccess(payload, feed) + expect(access.accessible).toBe(true) + expect(access.municipalityIds).toBeNull() + + const activities = await loadFeedActivities(payload, feed, access.municipalityIds) + expect(activities.map((doc) => doc.id)).toContain(activity.id) + }) })