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:
Francisco Ignácio Fontoura Solla 2026-07-27 01:54:29 -03:00
parent a5b6341ccb
commit bb5ac362ed
22 changed files with 12462 additions and 68 deletions

File diff suppressed because one or more lines are too long

View file

@ -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).

View file

@ -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 |

View 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

View file

@ -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 = {}

View file

@ -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: [
{

View file

@ -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: [

View file

@ -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: [

View file

@ -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”.',
},
},
],
}

File diff suppressed because it is too large Load diff

View 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";
`)
}

View file

@ -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',
},
];

View file

@ -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;

View file

@ -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: {

View file

@ -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'

View file

@ -6,7 +6,9 @@
*/
export {
canManagePublishedContent,
eligibleCampaignStaffWhere,
hasPayloadPanelAccess,
isCampaignCoordinator,
isCampaignLeader,
isCampaignStaff,

View file

@ -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.

View file

@ -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'],
},
})
}

View file

@ -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,
})

View file

@ -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) => {

View 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)
})
})