Add users.roles so Payload /admin can separate editors from admins.
Editors publish post/tag/media without PII or campaign access; existing users backfill as admin.
This commit is contained in:
parent
a5b6341ccb
commit
bb5ac362ed
22 changed files with 12462 additions and 68 deletions
File diff suppressed because one or more lines are too long
|
|
@ -39,7 +39,7 @@ Teqo is the digital platform for deputado federal Jorge Solla (PT-BA): public si
|
|||
|
||||
Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
1. **Payload admin roles are still absent (access lockdown shipped 2026-07-23).** Every collection now declares explicit `access` — `users`, `signature`, `subscription`, `consent` are Payload-admin-only, and CMS writes (`post`/`tag`/`media`/`petition`) are admin-only — so campaign JWTs can no longer act on them via `/api`. But the `users` collection still has no `roles` field: every admin user remains omnipotent inside `/admin`. Add admin roles (schema migration) before opening `/admin` to a broader team.
|
||||
1. **RESOLVED 2026-07-27 (`users.roles`):** campo `roles` (`admin` | `editor`) em `users` — migration `20260727_032523_add_users_roles` com backfill `admin`. `isPayloadAdmin` exige papel `admin`; editor entra em `/admin` e escreve `post`/`tag`/`media`; PII/`petition`/campanha seguem admin-only. Unificação com `campaignUser` rejeitada (ver [roles-admin-payload.md](docs/plans/roles-admin-payload.md)). Matriz fina por collection e editor em `petition` ficam fora.
|
||||
2. **RESOLVED 2026-07-25 (Pass 2 D3):** the last hardcoded consent document id is gone — `submitWhatsapp.ts` now resolves `Consent.key = 'whatsapp-inscricao'` fail-closed (migration `20260725_170000_whatsapp_subscription_consent_key` tags the live document; keys live in `src/lib/campaignConsentKeys.ts`).
|
||||
3. **`Post`/`Tag` ship the news system, but there is still no `Pages` collection for institutional content.** Partly resolved: the `post`/`tag` collections back the news/publications system, and the home "Últimas notícias" list plus the `/[type]`, `/[type]/[category]`, and article routes are live (see "Posts & Tags" below). Still hardcoded/pending: the home hero heading + subtitle copy (`HomePage` global still exposes only a single `image` field — the text lives in `src/app/(frontend)/(home)/page.tsx`), and there is no `Pages` collection yet for institutional content such as the bio and propostas.
|
||||
4. **Campaign LGPD consent texts are not yet configured in production.** Invites and self-service leadership flows fail closed until an admin creates `Consent.key = 'lideranca-autopreenchimento'` with counsel-approved copy. The C2 supporter registry (merged to `main`, production-blocked) additionally requires `Consent.key = 'apoiador-cadastro'` and `Consent.key = 'apoiador-intencao-voto'` (vote intention is sensitive data) — same fail-closed behavior. All four keys are bundled in the single legal batch of Onda 0 (see deploy checklist).
|
||||
|
|
@ -70,6 +70,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap.
|
|||
|
||||
**Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e.
|
||||
|
||||
**Recently resolved (2026-07-27):** **`users.roles`** — Payload admin RBAC. Field `roles` (`admin` | `editor`) on `users` (migration `20260727_032523_add_users_roles`, backfill `admin`); `isPayloadAdmin` requires the `admin` role; editors open `/admin` and write `post`/`tag`/`media`; PII/`petition`/campaign collections stay admin-only. Unifying `users` + `campaignUser` was rejected (25 FKs; structural isolation). Plano: [`docs/plans/roles-admin-payload.md`](docs/plans/roles-admin-payload.md).
|
||||
|
||||
**Recently resolved (2026-07-26):** **B8 F2** Polígonos dos Municípios-zona de Salvador (ZE 1–19) — Salvador used to be **one** polygon carrying the whole city's votes, so the choropleth could not answer "which piece of Salvador is weak, or mine"; the only per-zone view was the textual `zoneBreakdown`. Two halves shipped. **(1) The mesh:** a new committed artifact `src/lib/geometries/bahia-municipality-zones.topo.json` (72,310 bytes, ~20 KB gzip, 19 features keyed by `municipalitySlug`) built by `pnpm build:municipality-zone-geometries` — see "Campaign map geometries" for the source, the parser and the three curated reconciliation tables. The interesting part is not the dissolve, it is that **the curation lives in the script, declared line by line with its evidence, and the build fails closed**: an orphan polygon or a TRE neighbourhood with no polygon aborts it, so "170/170 → 19/19" is a tested invariant. Periperi, which the resolution splits mid-street between two ZE, goes **whole** to ZE 17 — the neighbourhood is the smallest geometry available and cutting a polygon from a prose description of a street was the rabbit hole the plan already refused. The 5 polygons the resolution never names are assigned by **longest shared boundary**, and that rule was _validated_ against the 4 it names under another label before being trusted. **(2) The key:** the choropleth is no longer keyed by `codarea` but by a **map key** (`mapKeyForMunicipality`: slug for `kind === 'zona'`, `ibgeCode` otherwise) — see the "Map" bullet in the Município model for the contract, the non-interactive Salvador base and the per-city rank limitation. Because each map key now maps to exactly one catalog entry, the rollups stopped accumulating with `+=` and `computeAggregateTerritorialClass` gave way to `computeMunicipalityTerritorialClass`; `resolveMunicipalityMapNavigation` became 1:1, and with it the `kind: 'zones'` branch, its "toque de novo" readout copy and the panel's scroll-anchor all died. `buildMunicipalitiesByIbgeCode` survives untouched: B14's list href is per city on purpose. **Two measurements, so nobody re-derives them:** Salvador's municipal polygon is **~690 km²** against **~324 km²** for the 19 zones summed — the difference is the bay water the municipal limit encloses and a neighbourhood mesh never draws, which is why the int test asserts a **land**-area range instead of a ratio against the município (the first version demanded ≥95% and was comparing land to land+water); and centroid-inside-another-zone is **not** a valid overlap check here, because ZE 16 is concave and wraps ZE 14, so 16's centroid legitimately falls in 14 — non-overlap is guaranteed structurally at build time plus the area sum. New e2e `campaignZoneMap.e2e.spec.ts` pins 436 paths / 435 interactive (the one non-interactive path is Salvador's base) and that a zone opens `/campanha/municipios/salvador-ze-N`. No migration, no collection, no server action, no `Consent`. **Open debts with triggers (in the plan, not silent):** the B14 "Onde estou" card still routes Salvador to the filtered list even though resolving the exact ZE is now possible, and per-ZE competitive rank needs a new dimension in the TSE artifact. Plano: [`docs/plans/poligonos-pracas-zona.md`](docs/plans/poligonos-pracas-zona.md).
|
||||
|
||||
**Recently resolved (2026-07-26):** **E12** Camada TI — extends the E17/B21 rollup (`territoryOverview.ts` + `loadTerritoryOverview.ts`) with E8 goal coverage (`central`), MAUP-safe regional capture (aggregate ratio Σ own votes ÷ Σ field ceiling — never the mean of municipal ratios; median, min–max, named critical municipality and beacon), and aggregated territorial class via `computeAggregateTerritorialClass` (same rule as B13 for TI groups). Staff surface: three new columns on `/campanha/territorios` (Cobertura, Captura, Classe) with B22 header descriptions and sorts `cobertura`/`captura`/`classe`; municipality detail gets intra-TI benchmark T4 on `MunicipalityGoalAccountCard` (`territoryIntraCaptureBenchmark.ts` — Salvador zone slugs compare among the 19 zones, other Metropolitano peers among RMS municipalities). Two E18 concepts (`captura-regional`, `benchmark-intra-ti`). v1 cuts kept: no `?group=territorio` on the municipality list, no TI map mode. No migration. Plano: [`docs/plans/camada-territorios-identidade.md`](docs/plans/camada-territorios-identidade.md).
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ Ledger of known engineering debt. Items large enough to be scheduled work become
|
|||
| knip loader noise: `Error loading src/payload.config.ts` (server-only guard) | knip run output | tooling noise | none (exit 0, static graph resolves) | S | P3 | open | |
|
||||
| Aikido scan unusable: `aikido_full_scan` answers Opengrep **exit code 2** for any input, including a one-line probe file, so the per-change security scan the rules mandate cannot complete. What it reported is an artifact of the failed run, not attributable to the code under review; the Aikido feed lists nothing for this repo. The `shpjs` devDependency added by B8 F2 is therefore SCA-unverified | Aikido MCP / local scanner | tooling outage (security gate) | medium — the gate is skipped, not passed | S | P2 | open 2026-07-26 (found during B8 F2) | |
|
||||
| Missing-consent window fixture releases its exclusive lease during the observation window — readers can interleave (rare flake). Full fix: session-level advisory lock spanning delete→op→restore + updated fault-path pins | `tests/helpers/testDatabaseLease.ts` (`withMissingInviteConsentFixture`) | test-infra race | low (ms-wide window; shared leases added 2026-07-23 narrow it) | M | P2 | open | |
|
||||
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | open | AGENTS Known Gaps #1 |
|
||||
| `users` collection has no roles (all admins omnipotent) | `src/collections/Users.ts` | RBAC | medium (small trusted admin set) | M (schema migration) | P1 before widening `/admin` | closed 2026-07-27 (`admin`\|`editor`; migration `20260727_032523_add_users_roles`; [roles-admin-payload.md](plans/roles-admin-payload.md)) | AGENTS Known Gaps #1 |
|
||||
| Any campaign user can enumerate staff names/roles (`canReadCampaignUsers`) | `src/utilities/access/campaignUsers.ts` | info disclosure | low | S | P2 | open | |
|
||||
| `removeSupporterData` deletes via `overrideAccess: true` while `canDeleteSupporter` is admin-only | `src/app/(campaign)/campanha/actions/supporter.ts` | access asymmetry (likely intentional LGPD path) | low | S | P3 | open — document or align | |
|
||||
| Legacy public consent flows hardcode document IDs (`consent: 2`) | `src/app/(frontend)/actions/submitWhatsapp.ts` | correctness | medium | S | P1 | closed 2026-07-25 (Pass 2 D3): resolves `Consent.key = 'whatsapp-inscricao'` fail-closed; migration `20260725_170000` tags the live doc (id 2, key vazio) com a chave | AGENTS Known Gaps #2 |
|
||||
|
|
|
|||
88
docs/plans/roles-admin-payload.md
Normal file
88
docs/plans/roles-admin-payload.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# Roles no admin Payload (`users.roles`)
|
||||
|
||||
Status: entregue 2026-07-27
|
||||
Atualizado em: 2026-07-27
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Admin Payload / Bloqueadores — campo `roles` em `users`)
|
||||
Impeccable: A — N/A sem superfície UI própria (campo renderiza no admin Payload)
|
||||
Appetite: ~0,75–1 dia eng; migration + predicados + testes; 15–30 min docs
|
||||
Responsável: —
|
||||
|
||||
_Revisão 2026-07-27: auditoria do implement-roadmap-item. Unificação `users`+`campaignUser` rejeitada (25 FKs, isolamento estrutural → dado, janela de congelamento). Escopo mínimo: `admin` | `editor`; `isPayloadAdmin` exige papel `admin`; editor escreve `post`/`tag`/`media`. Entrega: migration `20260727_032523_add_users_roles` + backfill; predicados `hasPayloadPanelAccess`/`canManagePublishedContent`/`isPayloadEditor`; `canReadMunicipality` nega ator `users` sem admin; testes unit+int (ator editor + pin `payload.auth` com `roles`); unificação documentada como rejeitada com gatilho Fase 2 white-label._
|
||||
|
||||
## Dados → decisão → apresentação
|
||||
|
||||
Dados: N/A — RBAC de accounts do `/admin`; sem KPI/mapa/série/ranking nesta entrega.
|
||||
|
||||
## Contexto
|
||||
|
||||
A collection `users` ([src/collections/Users.ts](../../src/collections/Users.ts)) não tem `roles`: todo account admin é omnipotente. O hardening Fase 0 (2026-07-23) já trancou collections CMS/PII com `payloadAdminOnly` / `isPayloadAdmin` (type guard em `user?.collection === 'users'`), mas isso não diferencia papéis **dentro** do realm admin. O gap está em AGENTS Known Gap #1, roadmap "Bloqueadores atuais" e TECH-DEBT P1.
|
||||
|
||||
`/campanha` permanece em `campaignUser` (cookie `campaign-token`, roles `coordinator|advisor|candidate|leader`). As duas collections ficam separadas de propósito nesta entrega.
|
||||
|
||||
## Objetivos
|
||||
|
||||
- Campo `roles` (`select` hasMany, `admin` | `editor`) em `users`, `required`, `defaultValue: ['admin']`, `saveToJWT: true`, field access admin-only.
|
||||
- Migration com backfill `admin` em todas as rows existentes + `down()` simétrico.
|
||||
- `isPayloadAdmin` exige `roles` contendo `admin` (falha fechado se ausente/vazio).
|
||||
- Editor entra em `/admin` e cria/edita/apaga `post`/`tag`/`media`; negado em PII, petições, campanha e gestão de `users` (exceto a própria conta).
|
||||
- Guardrails: `push: false`; Local API com `user` → `overrideAccess: false` nos testes; sem Consent novo; sem unificar collections.
|
||||
|
||||
## Decisões travadas
|
||||
|
||||
- **Vocabulário `admin` + `editor`.** Cobre a razão real de abrir `/admin` (comunicação publicando notícias) com um predicado novo. **Rejeitado:** só `admin` (campo inútil além de revogar); matriz fina `content`/`legal`/`campaign-admin` (ninguém pede hoje).
|
||||
- **`defaultValue: ['admin']`.** Bootstrap seguro: o "create first user" do Payload não expõe `roles` (field access admin-only); default `editor` deixaria o primeiro usuário sem poder promover ninguém. Mantém fixtures de teste intactas. **Rejeitado:** default `editor`; required sem default.
|
||||
- **`petition` fora do escopo do editor na v1.** Abaixo-assinados carregam assinaturas (PII) e o export CSV é admin-only. **Rejeitado:** editor em `petition` nesta fatia.
|
||||
- **`saveToJWT: true`.** Consistência com `campaignUser.role`; revogação imediata porque `payload.auth` devolve o documento fresco — pin de teste anti-lockout.
|
||||
- **Não unificar `users` + `campaignUser`.** Decidido 2026-07-27. Números: 25 FKs → `campaign_user` em 22 tabelas (incl. `_vote_pledge_v`, `municipality_rels`, `activity_rels`, internals Payload com ambas as colunas); isolamento hoje é estrutural (`collection === 'users'`); unificado vira checagem de dado sobre ~400 `leader` accounts do outro lado de PII; `loginWithUsername` + template de reset de campanha colidiriam com `/admin`. Janela: congelamento ~20/09. **Gatilho para reabrir:** Fase 2 white-label/multi-tenant, ou "uma pessoa precisa de dois logins" deixar de ser hipotético.
|
||||
- **i18n e naming:** identificadores em inglês (`roles`, `admin`, `editor`, `isPayloadEditor`, `canManagePublishedContent`, `hasPayloadPanelAccess`); labels admin em pt-BR ("Papéis", "Administrador", "Editor").
|
||||
|
||||
## Questões em aberto
|
||||
|
||||
Nenhuma bloqueante. Defaults acima carregados como decisão.
|
||||
|
||||
## Abordagem proposta
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Users["users.roles<br/>admin | editor"]
|
||||
Pred["isPayloadAdmin<br/>exige admin"]
|
||||
Panel["hasPayloadPanelAccess<br/>admin ou editor"]
|
||||
CMS["canManagePublishedContent<br/>post/tag/media"]
|
||||
AdminUI["/admin"]
|
||||
|
||||
Users --> Pred
|
||||
Users --> Panel
|
||||
Panel --> AdminUI
|
||||
Pred --> CMS
|
||||
Panel --> CMS
|
||||
```
|
||||
|
||||
1. Schema + migration `add_users_roles` (enum + tabela `users_roles` + backfill).
|
||||
2. Apertar predicados em `shared.ts`; Users.access; Post/Tag/Media; corrigir `cliReader` do script de aggregates.
|
||||
3. Testes: lockdown com ator editor; pin `payload.auth` devolve `roles`; fixtures + unit dos predicados.
|
||||
4. Gate AGENTS.md + Aikido + docs.
|
||||
|
||||
## Não escopo
|
||||
|
||||
- Unificar `users` + `campaignUser`.
|
||||
- UI de matriz de permissões por collection.
|
||||
- RBAC a nível de campo dentro do `/admin`.
|
||||
- Log de auditoria de mudança de papel.
|
||||
- Papéis por tenant / white-label.
|
||||
- Editor em `petition` / `signature` / `consent` / collections de campanha.
|
||||
|
||||
## Dependências
|
||||
|
||||
Nenhuma dura. Soft: Onda 0 jurídica é independente (Consent keys). Soft: abrir `/admin` a comunicação só depois deste ship + smoke.
|
||||
|
||||
## Verificação
|
||||
|
||||
- `pnpm migrate:status` + backfill local `roles: ['admin']`.
|
||||
- Conta `editor`: entra `/admin`, publica post; negada em signature/users/municipality.
|
||||
- Conta `admin`: comportamento pré-existente intacto.
|
||||
- `pnpm exec tsc --noEmit`, `pnpm lint`, `pnpm format:check`, `pnpm exec knip`, `pnpm check:cycles`, `pnpm test`, `pnpm test:e2e`, `pnpm build` contra banco local.
|
||||
- Scan Aikido nos arquivos first-party editados.
|
||||
|
||||
## Rabbit holes
|
||||
|
||||
Unificação de identidade; matriz fina de papéis; editor em petition; auditoria de role change.
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -71,7 +71,7 @@ assertLocalDatabase(
|
|||
const payload = await getPayload({ config: payloadConfig })
|
||||
|
||||
/** CLI reader — satisfies the election-data read assertion (admin realm). */
|
||||
const cliReader = { collection: 'users' }
|
||||
const cliReader = { collection: 'users', roles: ['admin'] }
|
||||
|
||||
const candidateNumber = BASELINE_TICKET_2022.candidate.candidateNumber
|
||||
const municipalities = {}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
import { canManagePublishedContent } from '@/utilities/campaignAccess'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
|
|
@ -14,9 +14,9 @@ export const Media: CollectionConfig = {
|
|||
// explicit overrideAccess: true after their own role checks.
|
||||
access: {
|
||||
read: () => true,
|
||||
create: payloadAdminOnly,
|
||||
update: payloadAdminOnly,
|
||||
delete: payloadAdminOnly,
|
||||
create: canManagePublishedContent,
|
||||
update: canManagePublishedContent,
|
||||
delete: canManagePublishedContent,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { slugify } from '@/lib/slug'
|
||||
import { payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
import { canManagePublishedContent } from '@/utilities/campaignAccess'
|
||||
import { revalidateDocumentById, revalidatePostsListing } from '@/utilities/documents'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
|
|
@ -23,10 +23,10 @@ export const Post: CollectionConfig<typeof slug> = {
|
|||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
readVersions: payloadAdminOnly,
|
||||
create: payloadAdminOnly,
|
||||
update: payloadAdminOnly,
|
||||
delete: payloadAdminOnly,
|
||||
readVersions: canManagePublishedContent,
|
||||
create: canManagePublishedContent,
|
||||
update: canManagePublishedContent,
|
||||
delete: canManagePublishedContent,
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { slugify } from '@/lib/slug'
|
||||
import { payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
import { canManagePublishedContent } from '@/utilities/campaignAccess'
|
||||
import { revalidateDocumentById, revalidatePostsListing } from '@/utilities/documents'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
|
|
@ -17,9 +17,9 @@ export const Tag: CollectionConfig<typeof slug> = {
|
|||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: payloadAdminOnly,
|
||||
update: payloadAdminOnly,
|
||||
delete: payloadAdminOnly,
|
||||
create: canManagePublishedContent,
|
||||
update: canManagePublishedContent,
|
||||
delete: canManagePublishedContent,
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [
|
||||
|
|
|
|||
|
|
@ -1,5 +1,36 @@
|
|||
import { isPayloadAdmin, payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import { hasPayloadPanelAccess, isPayloadAdmin, payloadAdminOnly } from '@/utilities/campaignAccess'
|
||||
import type { Access, CollectionBeforeChangeHook, CollectionConfig, FieldAccess } from 'payload'
|
||||
|
||||
const canManageUserRoles: FieldAccess = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
||||
/** Panel users may read/update themselves; admins may read/update anyone. */
|
||||
const canAccessSelfOrAdmin: Access = ({ req, id }) => {
|
||||
if (!hasPayloadPanelAccess(req.user)) return false
|
||||
if (isPayloadAdmin(req.user)) return true
|
||||
return id !== undefined && String(id) === String(req.user.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Defense in depth for Local API self-updates with `overrideAccess: true` —
|
||||
* field access already blocks role writes when access is enforced.
|
||||
*/
|
||||
const preventSelfServiceRoleEscalation: CollectionBeforeChangeHook = ({
|
||||
data,
|
||||
operation,
|
||||
originalDoc,
|
||||
req,
|
||||
}) => {
|
||||
if (operation !== 'update' || !originalDoc || !hasPayloadPanelAccess(req.user)) {
|
||||
return data
|
||||
}
|
||||
|
||||
if (isPayloadAdmin(req.user)) return data
|
||||
if (String(req.user.id) !== String(originalDoc.id)) return data
|
||||
|
||||
if ('roles' in data) delete data.roles
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
|
|
@ -14,17 +45,40 @@ export const Users: CollectionConfig = {
|
|||
auth: true,
|
||||
// Admin accounts are managed only by admin accounts. Without this, Payload's
|
||||
// "any authenticated user" default would let a campaignUser JWT manage admins
|
||||
// through /api/users — a privilege-escalation path.
|
||||
// through /api/users — a privilege-escalation path. Editors may enter the
|
||||
// panel and read/update their own account; only admins manage other users.
|
||||
access: {
|
||||
admin: ({ req }) => isPayloadAdmin(req.user),
|
||||
admin: ({ req }) => hasPayloadPanelAccess(req.user),
|
||||
create: payloadAdminOnly,
|
||||
read: payloadAdminOnly,
|
||||
update: payloadAdminOnly,
|
||||
read: canAccessSelfOrAdmin,
|
||||
update: canAccessSelfOrAdmin,
|
||||
delete: payloadAdminOnly,
|
||||
unlock: payloadAdminOnly,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [preventSelfServiceRoleEscalation],
|
||||
},
|
||||
fields: [
|
||||
// Email added by default
|
||||
// Add more fields as needed
|
||||
{
|
||||
name: 'roles',
|
||||
type: 'select',
|
||||
label: 'Papéis',
|
||||
hasMany: true,
|
||||
required: true,
|
||||
defaultValue: ['admin'],
|
||||
saveToJWT: true,
|
||||
options: [
|
||||
{ label: 'Administrador', value: 'admin' },
|
||||
{ label: 'Editor', value: 'editor' },
|
||||
],
|
||||
access: {
|
||||
create: canManageUserRoles,
|
||||
update: canManageUserRoles,
|
||||
},
|
||||
admin: {
|
||||
description:
|
||||
'Administrador: acesso total ao painel. Editor: publica notícias/tags/mídia — sem PII nem campanha. Contas novas de comunicação devem ser “Editor”.',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
11940
src/migrations/20260727_032523_add_users_roles.json
Normal file
11940
src/migrations/20260727_032523_add_users_roles.json
Normal file
File diff suppressed because it is too large
Load diff
41
src/migrations/20260727_032523_add_users_roles.ts
Normal file
41
src/migrations/20260727_032523_add_users_roles.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_users_roles" AS ENUM('admin', 'editor');
|
||||
CREATE TABLE "users_roles" (
|
||||
"order" integer NOT NULL,
|
||||
"parent_id" integer NOT NULL,
|
||||
"value" "enum_users_roles",
|
||||
"id" serial PRIMARY KEY NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "users_roles" ADD CONSTRAINT "users_roles_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "users_roles_order_idx" ON "users_roles" USING btree ("order");
|
||||
CREATE INDEX "users_roles_parent_idx" ON "users_roles" USING btree ("parent_id");
|
||||
`)
|
||||
|
||||
// Pre-existing admins were omnipotent; grant them the `admin` role so
|
||||
// tightening `isPayloadAdmin` does not lock everyone out of `/admin`.
|
||||
const backfill = await db.execute(sql`
|
||||
INSERT INTO "users_roles" ("order", "parent_id", "value")
|
||||
SELECT 1, u.id, 'admin'::"enum_users_roles"
|
||||
FROM "users" u
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "users_roles" r WHERE r."parent_id" = u.id
|
||||
)
|
||||
RETURNING "parent_id"
|
||||
`)
|
||||
|
||||
const rowCount = Array.isArray(backfill)
|
||||
? backfill.length
|
||||
: ((backfill as { rowCount?: number | null }).rowCount ?? 0)
|
||||
console.info(`[add_users_roles] backfilled admin role for ${rowCount} user(s)`)
|
||||
}
|
||||
|
||||
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP TABLE "users_roles" CASCADE;
|
||||
DROP TYPE "public"."enum_users_roles";
|
||||
`)
|
||||
}
|
||||
|
|
@ -1,35 +1,36 @@
|
|||
import * as migration_20260715_163458_initial from './20260715_163458_initial'
|
||||
import * as migration_20260715_163500_consent_text_to_jsonb from './20260715_163500_consent_text_to_jsonb'
|
||||
import * as migration_20260715_181058_add_post_and_tag from './20260715_181058_add_post_and_tag'
|
||||
import * as migration_20260715_215834_rename_tag_visible_to_hidden from './20260715_215834_rename_tag_visible_to_hidden'
|
||||
import * as migration_20260716_010420_add_campaign_user from './20260716_010420_add_campaign_user'
|
||||
import * as migration_20260718_010733_consolidate_campaign_schema from './20260718_010733_consolidate_campaign_schema'
|
||||
import * as migration_20260718_190559_territorio_multi_municipio_bairro from './20260718_190559_territorio_multi_municipio_bairro'
|
||||
import * as migration_20260718_195854_add_election_results from './20260718_195854_add_election_results'
|
||||
import * as migration_20260718_222656_add_supporter from './20260718_222656_add_supporter'
|
||||
import * as migration_20260718_222832_add_action_plan from './20260718_222832_add_action_plan'
|
||||
import * as migration_20260719_011015_add_supporter_import_batch from './20260719_011015_add_supporter_import_batch'
|
||||
import * as migration_20260719_014906_action_plan_list_perf from './20260719_014906_action_plan_list_perf'
|
||||
import * as migration_20260719_020000_add_contact_trgm_index from './20260719_020000_add_contact_trgm_index'
|
||||
import * as migration_20260719_054522_add_nucleus_goals_strategy from './20260719_054522_add_nucleus_goals_strategy'
|
||||
import * as migration_20260719_054706_add_privacy_policy_global from './20260719_054706_add_privacy_policy_global'
|
||||
import * as migration_20260719_054707_seed_onda0_consent_and_privacy from './20260719_054707_seed_onda0_consent_and_privacy'
|
||||
import * as migration_20260719_061302_add_campaign_user_avatar from './20260719_061302_add_campaign_user_avatar'
|
||||
import * as migration_20260721_020109_remodel_plazas from './20260721_020109_remodel_plazas'
|
||||
import * as migration_20260721_133444_add_plaza_expected_votes from './20260721_133444_add_plaza_expected_votes'
|
||||
import * as migration_20260721_133531_add_petition_facebook_pixel_id from './20260721_133531_add_petition_facebook_pixel_id'
|
||||
import * as migration_20260723_025513_add_import_export_plugin from './20260723_025513_add_import_export_plugin'
|
||||
import * as migration_20260723_124200_add_vote_estimate_scenarios from './20260723_124200_add_vote_estimate_scenarios'
|
||||
import * as migration_20260723_200000_remodel_municipalities from './20260723_200000_remodel_municipalities'
|
||||
import * as migration_20260723_201000_add_state_deputy from './20260723_201000_add_state_deputy'
|
||||
import * as migration_20260723_202000_reconcile_municipality_remodel from './20260723_202000_reconcile_municipality_remodel'
|
||||
import * as migration_20260724_133600_drop_municipality_vote_goals from './20260724_133600_drop_municipality_vote_goals'
|
||||
import * as migration_20260724_175500_contact_phone_optional from './20260724_175500_contact_phone_optional'
|
||||
import * as migration_20260724_180000_add_campaign_foundation_records from './20260724_180000_add_campaign_foundation_records'
|
||||
import * as migration_20260724_180000_add_campaign_goals_global from './20260724_180000_add_campaign_goals_global'
|
||||
import * as migration_20260725_022155_add_municipality_budget_notes from './20260725_022155_add_municipality_budget_notes'
|
||||
import * as migration_20260725_170000_whatsapp_subscription_consent_key from './20260725_170000_whatsapp_subscription_consent_key'
|
||||
import * as migration_20260725_213000_rename_action_plan_to_activity from './20260725_213000_rename_action_plan_to_activity'
|
||||
import * as migration_20260715_163458_initial from './20260715_163458_initial';
|
||||
import * as migration_20260715_163500_consent_text_to_jsonb from './20260715_163500_consent_text_to_jsonb';
|
||||
import * as migration_20260715_181058_add_post_and_tag from './20260715_181058_add_post_and_tag';
|
||||
import * as migration_20260715_215834_rename_tag_visible_to_hidden from './20260715_215834_rename_tag_visible_to_hidden';
|
||||
import * as migration_20260716_010420_add_campaign_user from './20260716_010420_add_campaign_user';
|
||||
import * as migration_20260718_010733_consolidate_campaign_schema from './20260718_010733_consolidate_campaign_schema';
|
||||
import * as migration_20260718_190559_territorio_multi_municipio_bairro from './20260718_190559_territorio_multi_municipio_bairro';
|
||||
import * as migration_20260718_195854_add_election_results from './20260718_195854_add_election_results';
|
||||
import * as migration_20260718_222656_add_supporter from './20260718_222656_add_supporter';
|
||||
import * as migration_20260718_222832_add_action_plan from './20260718_222832_add_action_plan';
|
||||
import * as migration_20260719_011015_add_supporter_import_batch from './20260719_011015_add_supporter_import_batch';
|
||||
import * as migration_20260719_014906_action_plan_list_perf from './20260719_014906_action_plan_list_perf';
|
||||
import * as migration_20260719_020000_add_contact_trgm_index from './20260719_020000_add_contact_trgm_index';
|
||||
import * as migration_20260719_054522_add_nucleus_goals_strategy from './20260719_054522_add_nucleus_goals_strategy';
|
||||
import * as migration_20260719_054706_add_privacy_policy_global from './20260719_054706_add_privacy_policy_global';
|
||||
import * as migration_20260719_054707_seed_onda0_consent_and_privacy from './20260719_054707_seed_onda0_consent_and_privacy';
|
||||
import * as migration_20260719_061302_add_campaign_user_avatar from './20260719_061302_add_campaign_user_avatar';
|
||||
import * as migration_20260721_020109_remodel_plazas from './20260721_020109_remodel_plazas';
|
||||
import * as migration_20260721_133444_add_plaza_expected_votes from './20260721_133444_add_plaza_expected_votes';
|
||||
import * as migration_20260721_133531_add_petition_facebook_pixel_id from './20260721_133531_add_petition_facebook_pixel_id';
|
||||
import * as migration_20260723_025513_add_import_export_plugin from './20260723_025513_add_import_export_plugin';
|
||||
import * as migration_20260723_124200_add_vote_estimate_scenarios from './20260723_124200_add_vote_estimate_scenarios';
|
||||
import * as migration_20260723_200000_remodel_municipalities from './20260723_200000_remodel_municipalities';
|
||||
import * as migration_20260723_201000_add_state_deputy from './20260723_201000_add_state_deputy';
|
||||
import * as migration_20260723_202000_reconcile_municipality_remodel from './20260723_202000_reconcile_municipality_remodel';
|
||||
import * as migration_20260724_133600_drop_municipality_vote_goals from './20260724_133600_drop_municipality_vote_goals';
|
||||
import * as migration_20260724_175500_contact_phone_optional from './20260724_175500_contact_phone_optional';
|
||||
import * as migration_20260724_180000_add_campaign_foundation_records from './20260724_180000_add_campaign_foundation_records';
|
||||
import * as migration_20260724_180000_add_campaign_goals_global from './20260724_180000_add_campaign_goals_global';
|
||||
import * as migration_20260725_022155_add_municipality_budget_notes from './20260725_022155_add_municipality_budget_notes';
|
||||
import * as migration_20260725_170000_whatsapp_subscription_consent_key from './20260725_170000_whatsapp_subscription_consent_key';
|
||||
import * as migration_20260725_213000_rename_action_plan_to_activity from './20260725_213000_rename_action_plan_to_activity';
|
||||
import * as migration_20260727_032523_add_users_roles from './20260727_032523_add_users_roles';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -192,4 +193,9 @@ export const migrations = [
|
|||
down: migration_20260725_213000_rename_action_plan_to_activity.down,
|
||||
name: '20260725_213000_rename_action_plan_to_activity',
|
||||
},
|
||||
]
|
||||
{
|
||||
up: migration_20260727_032523_add_users_roles.up,
|
||||
down: migration_20260727_032523_add_users_roles.down,
|
||||
name: '20260727_032523_add_users_roles',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -226,6 +226,10 @@ export interface CampaignUserAuthOperations {
|
|||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
/**
|
||||
* Administrador: acesso total ao painel. Editor: publica notícias/tags/mídia — sem PII nem campanha. Contas novas de comunicação devem ser “Editor”.
|
||||
*/
|
||||
roles: ('admin' | 'editor')[];
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
|
|
@ -1310,6 +1314,7 @@ export interface PayloadMigration {
|
|||
* via the `definition` "users_select".
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
roles?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
|
|
|
|||
|
|
@ -186,9 +186,11 @@ export const canReadMunicipality: Access = async ({ req }) => {
|
|||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
const currentUser = await getFreshCampaignUser(req)
|
||||
// Editors (and any non-campaign actor) must not see Municípios in /admin.
|
||||
if (!currentUser) return false
|
||||
if (isCampaignLeader(currentUser)) return false
|
||||
|
||||
const ids = await getAccessibleMunicipalityIds(req)
|
||||
const ids = await getAccessibleMunicipalityIds(req, currentUser)
|
||||
if (ids === null) return true
|
||||
|
||||
return {
|
||||
|
|
@ -202,8 +204,9 @@ export const canUpdateMunicipality: Access = async ({ req }) => {
|
|||
if (isPayloadAdmin(req.user)) return true
|
||||
|
||||
const currentUser = await getFreshCampaignUser(req)
|
||||
if (!currentUser) return false
|
||||
if (isCampaignUnrestricted(currentUser)) return true
|
||||
if (currentUser?.role !== 'advisor') return false
|
||||
if (currentUser.role !== 'advisor') return false
|
||||
|
||||
return {
|
||||
advisors: {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,25 @@ export type DynamicFind = (args: {
|
|||
where: Record<string, unknown>
|
||||
}) => Promise<{ docs: Array<Record<string, unknown>> }>
|
||||
|
||||
export const isPayloadAdmin = (user: CampaignActor): user is User => user?.collection === 'users'
|
||||
/** Narrow to the Payload `users` collection (admin or editor). */
|
||||
const isPayloadUsersActor = (user: CampaignActor): user is User => user?.collection === 'users'
|
||||
|
||||
/**
|
||||
* True Payload admin — `users` collection AND the `admin` role.
|
||||
* Fails closed when `roles` is missing or empty (pre-migration JWTs, stripped
|
||||
* fields). Returns boolean (not `user is User`) so a false result does not
|
||||
* erase editors from the User union after panel narrowing.
|
||||
*/
|
||||
export const isPayloadAdmin = (user: CampaignActor): boolean =>
|
||||
isPayloadUsersActor(user) && (user.roles?.includes('admin') ?? false)
|
||||
|
||||
/** Editorial staff: `users` with the `editor` role (may also be admin). */
|
||||
export const isPayloadEditor = (user: CampaignActor): boolean =>
|
||||
isPayloadUsersActor(user) && (user.roles?.includes('editor') ?? false)
|
||||
|
||||
/** May open `/admin` — admin or editor. */
|
||||
export const hasPayloadPanelAccess = (user: CampaignActor): user is User =>
|
||||
isPayloadUsersActor(user) && (isPayloadAdmin(user) || isPayloadEditor(user))
|
||||
|
||||
/**
|
||||
* Payload-admin-only collection access. Collections without explicit access fall
|
||||
|
|
@ -42,6 +60,9 @@ export const isPayloadAdmin = (user: CampaignActor): user is User => user?.colle
|
|||
*/
|
||||
export const payloadAdminOnly: Access = ({ req }) => isPayloadAdmin(req.user)
|
||||
|
||||
/** Admin or editor — write access to public published content (`post`/`tag`/`media`). */
|
||||
export const canManagePublishedContent: Access = ({ req }) => hasPayloadPanelAccess(req.user)
|
||||
|
||||
export const isCampaignUser = (user: CampaignActor): user is CampaignUser =>
|
||||
user?.collection === 'campaignUser'
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@
|
|||
*/
|
||||
|
||||
export {
|
||||
canManagePublishedContent,
|
||||
eligibleCampaignStaffWhere,
|
||||
hasPayloadPanelAccess,
|
||||
isCampaignCoordinator,
|
||||
isCampaignLeader,
|
||||
isCampaignStaff,
|
||||
|
|
|
|||
|
|
@ -49,7 +49,15 @@ type OwnedIDs = Record<CampaignCollection, Set<number>>
|
|||
type CampaignUserInput = Partial<
|
||||
Pick<CampaignUser, 'name' | 'role' | 'phone' | 'email' | 'username' | 'password'>
|
||||
>
|
||||
type AdminUserInput = Partial<Pick<User, 'email' | 'password'>>
|
||||
type AdminUserInput = {
|
||||
email?: string
|
||||
password?: string
|
||||
roles?: User['roles']
|
||||
}
|
||||
type EditorUserInput = {
|
||||
email?: string
|
||||
password?: string
|
||||
}
|
||||
type ConsentInput = Partial<Pick<Consent, 'key' | 'text'>>
|
||||
type ContactInput = Partial<
|
||||
Pick<Contact, 'name' | 'email' | 'phone' | 'gender' | 'state' | 'city' | 'postalCode'>
|
||||
|
|
@ -564,6 +572,7 @@ export class CampaignFixtures {
|
|||
data: {
|
||||
email: `${this.value('admin')}@example.com`,
|
||||
password: this.value('password'),
|
||||
roles: ['admin'],
|
||||
...input,
|
||||
},
|
||||
depth: 0,
|
||||
|
|
@ -572,6 +581,14 @@ export class CampaignFixtures {
|
|||
return user
|
||||
}
|
||||
|
||||
async createEditorUser(input: EditorUserInput = {}): Promise<User> {
|
||||
return this.createAdminUser({
|
||||
email: `${this.value('editor')}@example.com`,
|
||||
roles: ['editor'],
|
||||
...input,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an OWNED consent row (deleted at cleanup) — even for a leased
|
||||
* shared key, since calling this helper is an explicit ownership request.
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ export async function seedTestUser(): Promise<void> {
|
|||
// Create fresh test user
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: testUser,
|
||||
data: {
|
||||
...testUser,
|
||||
roles: ['admin'],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ describe('campaign integration fixtures', () => {
|
|||
data: {
|
||||
email: `${fixtures.value('proxy-admin')}@example.com`,
|
||||
password: fixtures.value('password'),
|
||||
roles: ['admin'],
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
|
|
@ -335,6 +336,7 @@ describe('campaign integration fixtures', () => {
|
|||
data: {
|
||||
email: `sentinel-${marker}@example.com`,
|
||||
password: `password-${marker}`,
|
||||
roles: ['admin'],
|
||||
},
|
||||
depth: 0,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { getPayload, type Payload, type RequiredDataFromCollectionSlug } from 'payload'
|
||||
import {
|
||||
getPayload,
|
||||
type Payload,
|
||||
type PayloadRequest,
|
||||
type RequiredDataFromCollectionSlug,
|
||||
} from 'payload'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createLeaderSupporterRecord } from '@/app/(campaign)/campanha/actions/leaderSupporter'
|
||||
import { Media } from '@/collections/Media'
|
||||
import { Post } from '@/collections/Post'
|
||||
import { Tag } from '@/collections/Tag'
|
||||
import config from '@/payload.config'
|
||||
import { canManagePublishedContent } from '@/utilities/access/shared'
|
||||
import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '@/utilities/campaignConsent'
|
||||
import { relationshipId } from '@/utilities/relationship'
|
||||
|
||||
|
|
@ -59,7 +68,11 @@ describe('CMS/PII collection access lockdown', () => {
|
|||
await expect(
|
||||
payload.create({
|
||||
collection: 'users',
|
||||
data: { email: 'escalated@example.com', password: 'owned-by-attacker' },
|
||||
data: {
|
||||
email: 'escalated@example.com',
|
||||
password: 'owned-by-attacker',
|
||||
roles: ['admin'],
|
||||
},
|
||||
user: coordinator,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
|
|
@ -70,6 +83,8 @@ describe('CMS/PII collection access lockdown', () => {
|
|||
const fixtures = campaignFixtures()
|
||||
const admin = await fixtures.createAdminUser()
|
||||
|
||||
expect(admin.roles).toEqual(['admin'])
|
||||
|
||||
const result = await payload.count({
|
||||
collection: 'users',
|
||||
where: { id: { equals: admin.id } },
|
||||
|
|
@ -79,6 +94,102 @@ describe('CMS/PII collection access lockdown', () => {
|
|||
expect(result.totalDocs).toBe(1)
|
||||
})
|
||||
|
||||
it('lets editors enter the panel for published content but denies PII and campaign data', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const editor = await fixtures.createEditorUser()
|
||||
expect(editor.roles).toEqual(['editor'])
|
||||
|
||||
// Call access directly — creating a Tag would hit revalidateTag outside Next.
|
||||
for (const collection of [Tag, Post, Media]) {
|
||||
expect(collection.access?.create).toBe(canManagePublishedContent)
|
||||
}
|
||||
expect(
|
||||
canManagePublishedContent({
|
||||
req: stub<PayloadRequest>({ user: editor }),
|
||||
}),
|
||||
).toBe(true)
|
||||
|
||||
await expect(
|
||||
payload.count({
|
||||
collection: 'users',
|
||||
where: {},
|
||||
user: editor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow(/permissão|not allowed/i)
|
||||
|
||||
for (const collection of [
|
||||
'signature',
|
||||
'subscription',
|
||||
'consent',
|
||||
'contact',
|
||||
'municipality',
|
||||
] as const) {
|
||||
await expect(
|
||||
payload.count({ collection, where: {}, user: editor, overrideAccess: false }),
|
||||
).rejects.toThrow(/permissão|not allowed/i)
|
||||
}
|
||||
|
||||
await expect(
|
||||
payload.create({
|
||||
collection: 'petition',
|
||||
data: stub<RequiredDataFromCollectionSlug<'petition'>>({}),
|
||||
user: editor,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
).rejects.toThrow(/permissão|not allowed/i)
|
||||
})
|
||||
|
||||
it('lets an editor read and update their own users account but not escalate roles', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const password = fixtures.value('password')
|
||||
const editor = await fixtures.createEditorUser({ password })
|
||||
|
||||
const self = await payload.findByID({
|
||||
collection: 'users',
|
||||
id: editor.id,
|
||||
user: editor,
|
||||
overrideAccess: false,
|
||||
depth: 0,
|
||||
})
|
||||
expect(self.id).toBe(editor.id)
|
||||
expect(self.roles).toEqual(['editor'])
|
||||
|
||||
await expect(
|
||||
payload.update({
|
||||
collection: 'users',
|
||||
id: editor.id,
|
||||
data: { roles: ['admin', 'editor'] },
|
||||
user: editor,
|
||||
overrideAccess: false,
|
||||
depth: 0,
|
||||
}),
|
||||
).rejects.toThrow(/inválido|permissão|not allowed|Papéis/i)
|
||||
|
||||
const afterEscalate = await payload.findByID({
|
||||
collection: 'users',
|
||||
id: editor.id,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})
|
||||
expect(afterEscalate.roles).toEqual(['editor'])
|
||||
|
||||
// Anti-lockout: payload.auth must return the document with roles populated.
|
||||
// If req.user were JWT claims alone, isPayloadAdmin would deny every admin.
|
||||
const login = await payload.login({
|
||||
collection: 'users',
|
||||
data: { email: editor.email, password },
|
||||
})
|
||||
expect(login.user).toBeDefined()
|
||||
expect(login.user?.roles).toEqual(['editor'])
|
||||
|
||||
const { user: authed } = await payload.auth({
|
||||
headers: new Headers({ Authorization: `JWT ${login.token}` }),
|
||||
})
|
||||
expect(authed?.collection).toBe('users')
|
||||
expect(authed && 'roles' in authed ? authed.roles : undefined).toEqual(['editor'])
|
||||
})
|
||||
|
||||
it.each(['signature', 'subscription', 'consent'] as const)(
|
||||
'denies campaign users and anonymous requests access to %s (PII/legal)',
|
||||
async (collection) => {
|
||||
|
|
|
|||
99
tests/unit/payloadAdminRoles.unit.spec.ts
Normal file
99
tests/unit/payloadAdminRoles.unit.spec.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import type { PayloadRequest } from 'payload'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CampaignUser, User } from '@/payload-types'
|
||||
import {
|
||||
canManagePublishedContent,
|
||||
hasPayloadPanelAccess,
|
||||
isPayloadAdmin,
|
||||
isPayloadEditor,
|
||||
} from '@/utilities/access/shared'
|
||||
import { stub } from '../helpers/stub'
|
||||
|
||||
const adminUser = stub<User>({
|
||||
id: 1,
|
||||
collection: 'users',
|
||||
email: 'admin@example.com',
|
||||
roles: ['admin'],
|
||||
})
|
||||
|
||||
const editorUser = stub<User>({
|
||||
id: 2,
|
||||
collection: 'users',
|
||||
email: 'editor@example.com',
|
||||
roles: ['editor'],
|
||||
})
|
||||
|
||||
const dualRoleUser = stub<User>({
|
||||
id: 3,
|
||||
collection: 'users',
|
||||
email: 'both@example.com',
|
||||
roles: ['admin', 'editor'],
|
||||
})
|
||||
|
||||
/** Runtime-missing roles — Partial stub omits the field. */
|
||||
const usersMissingRoles = stub<User>({
|
||||
id: 4,
|
||||
collection: 'users',
|
||||
email: 'legacy@example.com',
|
||||
})
|
||||
|
||||
const usersEmptyRoles = stub<User>({
|
||||
id: 5,
|
||||
collection: 'users',
|
||||
email: 'empty@example.com',
|
||||
roles: [],
|
||||
})
|
||||
|
||||
const campaignCoordinator = stub<CampaignUser>({
|
||||
id: 10,
|
||||
collection: 'campaignUser',
|
||||
role: 'coordinator',
|
||||
name: 'Coord',
|
||||
})
|
||||
|
||||
const accessArgs = (user: User | CampaignUser | null) =>
|
||||
stub<Parameters<typeof canManagePublishedContent>[0]>({
|
||||
req: stub<PayloadRequest>({ user }),
|
||||
})
|
||||
|
||||
describe('payload admin role predicates', () => {
|
||||
it('isPayloadAdmin requires the users collection and the admin role', () => {
|
||||
expect(isPayloadAdmin(adminUser)).toBe(true)
|
||||
expect(isPayloadAdmin(dualRoleUser)).toBe(true)
|
||||
expect(isPayloadAdmin(editorUser)).toBe(false)
|
||||
expect(isPayloadAdmin(usersMissingRoles)).toBe(false)
|
||||
expect(isPayloadAdmin(usersEmptyRoles)).toBe(false)
|
||||
expect(isPayloadAdmin(campaignCoordinator)).toBe(false)
|
||||
expect(isPayloadAdmin(null)).toBe(false)
|
||||
expect(isPayloadAdmin(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('isPayloadEditor requires the editor role', () => {
|
||||
expect(isPayloadEditor(editorUser)).toBe(true)
|
||||
expect(isPayloadEditor(dualRoleUser)).toBe(true)
|
||||
expect(isPayloadEditor(adminUser)).toBe(false)
|
||||
expect(isPayloadEditor(usersMissingRoles)).toBe(false)
|
||||
expect(isPayloadEditor(usersEmptyRoles)).toBe(false)
|
||||
expect(isPayloadEditor(campaignCoordinator)).toBe(false)
|
||||
})
|
||||
|
||||
it('hasPayloadPanelAccess allows admin or editor', () => {
|
||||
expect(hasPayloadPanelAccess(adminUser)).toBe(true)
|
||||
expect(hasPayloadPanelAccess(editorUser)).toBe(true)
|
||||
expect(hasPayloadPanelAccess(dualRoleUser)).toBe(true)
|
||||
expect(hasPayloadPanelAccess(usersMissingRoles)).toBe(false)
|
||||
expect(hasPayloadPanelAccess(usersEmptyRoles)).toBe(false)
|
||||
expect(hasPayloadPanelAccess(campaignCoordinator)).toBe(false)
|
||||
})
|
||||
|
||||
it('canManagePublishedContent mirrors panel access', () => {
|
||||
expect(canManagePublishedContent(accessArgs(adminUser))).toBe(true)
|
||||
expect(canManagePublishedContent(accessArgs(editorUser))).toBe(true)
|
||||
expect(canManagePublishedContent(accessArgs(usersEmptyRoles))).toBe(false)
|
||||
expect(canManagePublishedContent(accessArgs(campaignCoordinator))).toBe(false)
|
||||
expect(canManagePublishedContent(accessArgs(null))).toBe(false)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue