10 KiB
Impl: C165-F1 — Activity: update access fecha município nulo/fora da carteira (bypass REST)
Status: aprovado Atualizado em: 2026-09-15 Issue: #1030 Intenção: docs/plans/c165-f1-update-access-municipality.md Appetite restante: herdado — ~0,25 dia eng
Leitura da intenção
- Outcome:
canUpdateActivitydeixa de cobrir só a linha e passa a inspecionardata.municipalitypara um advisor (staff não-unrestricted):null(ou malformado) → recusa; id fora do write scope da carteira → recusa; chave ausente → mantém oWherede linha atual. Coordenador/candidato/admin intocados;editing: 'tudo'segue alcançando qualquer atividade com município, mas limpar paranullvira coordenação/candidato-only. Fecha o bypass REST/Local (PATCH /api/activity/:idcom JWT de campanha). - O que NÃO negociar: simetria com
canCreateActivity(que já édata-aware); action mantém guard/UX (Decisão G do C165) como defesa em profundidade, sem mensagem nova; sem field access por valor; sem migration/Consent/UI; touchpoint único no collection access. - O que reavaliar: nada estrutural — a intenção deixa o eixo de valor no collection access e o
Wherede linha intocado. Fica em aberto apenas a forma de detectar a presença demunicipalityno patch (resolvida abaixo).
Abordagem recomendada
flowchart TD
A[update activity] --> B{admin / unrestricted?}
B -- sim --> OK[true]
B -- não --> C[getFreshCampaignUser]
C --> D{advisor staff?}
D -- não --> NO[false]
D -- sim --> E{editingAccess}
E -- none --> NO
E -- carteira / tudo --> F{data.municipality !== undefined?}
F -- não --> G[carteira: advisorActivityScopeWhere / tudo: municipality exists]
F -- sim --> H{relationshipId = null?}
H -- sim --> NO
H -- não --> I{editingAccess = tudo?}
I -- sim --> G
I -- carteira --> J{id ∈ getAccessibleMunicipalityIds?}
J -- não --> NO
J -- sim --> G
Opções consideradas: A) guard de valor no collection access update (data-aware, reusando getAccessibleMunicipalityIds memoizado) | B) usar getWritableMunicipalityIds no access | C) field-level access em municipality.
Recomendação: A — porque o bypass é do valor e o dono do eixo de access de update já é esta função; data chega ao access (node_modules/payload/dist/collections/operations/updateByID.js:42-46 passa { id, data, req }), então o guard fecha por construção sem tocar a action. Para editing === 'carteira' o conjunto de ids coincide com a carteira (getAccessibleMunicipalityIds), que advisorActivityScopeWhere já chama no mesmo request — logo a checagem extra é memoizada e não adiciona query.
Rejeitadas:
- B (
getWritableMunicipalityIds): faz umfindnão-memoizado por request no caminho quente (todo update de activity passa pelo access) e, paracarteira, devolve o mesmo conjunto do memoizado; paratudodevolvenull(catálogo todo), já coberto por não checar escopo no branchtudo. - C (field access): fora de escopo pela intenção; field access recebe valor/sibling data, não o documento, e a assimetria do modelo pertence ao collection access.
| Decisão | Caro / Barato | Escolha |
|---|---|---|
| Onde mora o guard de valor | field access (caro, fora de escopo) vs collection access update (barato) |
collection access |
| Fonte dos ids da carteira | getWritableMunicipalityIds (find extra/request) vs getAccessibleMunicipalityIds (memoizado) |
memoizado |
Detectar presença de municipality |
'municipality' in data (recusa undefined explícito) vs data?.municipality !== undefined + relationshipId |
!== undefined (só a chave ausente cai no Where) |
Componentes / mudanças
-
canUpdateActivity(src/utilities/access/activities.ts:104): trocar a assinatura paraasync ({ data, req }); apósif (editingAccess === 'none') return false, inserir o guard de valor antes dos retornos de linha:if (data?.municipality !== undefined) { const municipalityID = relationshipId(data.municipality) if (municipalityID === null) return false if (editingAccess === 'carteira') { const writableIDs = await getAccessibleMunicipalityIds(req, currentUser) if (writableIDs !== null && !writableIDs.includes(municipalityID)) return false } }Preservar intactos os retornos seguintes (
tudo→{ municipality: { exists: true } }; senão →advisorActivityScopeWhere).relationshipIdjá devolvenullparanull/undefined/malformado e id para número/{id}; o!== undefinedexterno garante que chave ausente não seja recusada. Comentar comC165-F1explicando o eixo valor vs linha. -
Pin int (
tests/int/campaignActivity.int.spec.ts): novoit(...)perto do bloco:485-498; com oadvisorda carteira e um município fora (fixtures.getMunicipality()), exercitar viapayload.update({ collection: 'activity', id, data, user, overrideAccess: false }):{ municipality: null }→rejects.toThrow(/permissão/i);{ municipality: outside.id }→ rejeita;{ locality: '...' }(só outro campo) → sucesso,municipalityinalterado;- advisor
editing: 'tudo'(createCampaignUser('advisor', { editing: 'tudo' })): apontar paraoutside.idé permitido, mas{ municipality: null }é recusado.
Manter
:485-498verde (semdata→Whereexato inalterado). -
Migration: sem migration.
-
Access / Consent: só o eixo de access de
activity.update; nenhum Consent/opt-in envolvido. -
UI: N/A — sem UI.
Fases verificáveis
- Editar
canUpdateActivityemsrc/utilities/access/activities.ts(guard de valor; retornos de linha intactos). - Adicionar o pin int em
tests/int/campaignActivity.int.spec.tse confirmar:485-498intacto. - Rodar
pnpm gate:fast(inclui int) verde; checar que nenhum outro teste de activity quebrou.
Rabbit holes / Não escopo (engenharia)
- Field-level access por valor em
municipality— fora de escopo (intenção). - Hardening equivalente em outras collections — fora de escopo; só
activity.update. - Memoização global de
getWritableMunicipalityIdsno módulo — não pedido; o branchtudonão precisa de ids. - Mensagem nova/ajuste de copy da action — fora de escopo; a recusa vira o erro genérico de permissão (mesmo comportamento do bypass do create).
- Ajuste no manifest e2e (
scripts/lib/e2e-affected-manifest.mjs:116-118) — já mapeado, nenhum toque.
Riscos e mitigação
- Advisor
editing: 'tudo'hoje limpa município pela action — já era recusado no C165; o access só fecha a mesma regra por outra porta. Teste do branchtudocobrenullrecusado e remanejamento permitido. - Custo por request no caminho quente:
getAccessibleMunicipalityIdsé memoizado por request (resolveAccessibleIds,shared.ts:249) e só é chamado quandoediting === 'carteira'edata.municipalitypresente; deduplica com a chamada deadvisorActivityScopeWhere. - Regressão do pin existente (
:485-498compara oWhereexato semdata):data?.municipality === undefinedcai direto nos retornos atuais — comportamento idêntico. - Falso permitido para id malformado: coberto pelo
relationshipId === nullno mesmo guard. - Erro do access:
falseem update viraForbidden(executeAccess.js:10-14) → mensagem de permissão, sem tocar names/copy (/permissão/icomo no restante da suíte).
Aceite de engenharia
canUpdateActivityédata-aware:null/malformado →false; fora da carteira (carteira) →false; ausente →Wherede linha inalterado.editing: 'tudo'permite qualquer município communicipalitypresente, mas recusanull.- Pin int novo verde em
tests/int/campaignActivity.int.spec.ts;:485-498segue verde. pnpm gate:fastverde.- Sem migration, sem Consent, sem UI; URL/slug intactos.
Débitos da revisão (triage 2026-09-15)
Já resolvido na sessão (não reabrir): nome writableIDs → accessibleIDs (a fonte é o
escopo de leitura; na carteira coincide com o write scope); comentário do tudo reescrito;
borda positiva coberta (repoint dentro da carteira permitido) e perfil misto
visibility:'tudo' + editing:'carteira' pinado (a Visão "Tudo" não alarga a fronteira de
escrita) — trava o invariante read==write da carteira sem nova query; impl doc commitado.
Explicitamente fora / descartado deste lote:
- C90 leg
responsible× valor do município (pré-existente, sinalizado pelos dois revisores): um advisor responsável por atividade fora da carteira edita por update parcial que omitamunicipality(pin:684), mas o overlay de edição reenvia o município e a recusa por valor fora da carteira fecha o save inteiro. Já era o comportamento da action do C165 (não é regressão desta entrega) e não há Issue aberta. Gatilho: primeiro relato de assessor responsável que não consegue salvar pelo overlay, ou curadoria futura do legresponsible(C90/C141) — aí revisar action + access juntos. - Hardening de valor em outras collections: cada domínio tem seu dono; fora do appetite.
Self-score (decision-quality)
5/5.
- Problema, owner e assimetria nomeados (
activities.ts:104collection access vs action emactivity.ts:300;datachega ao access conforme Payload). - Decisão principal com trade-off explícito (memoizado vs
getWritableMunicipalityIds) e critério de correção para o branchtudo(não reaproveitar a carteira). - Borda de presença (
undefinedausente vsnullpresente) resolvida com justificativa e coberta por teste. - Aceite verificável e enxuto dentro do appetite (~0,25 dia): uma função, um teste, gate.