security: lock public globals update to payloadAdminOnly (Pass 4 P0)

The four public globals (site-settings, home, metadata, privacy-policy)
spelled update access as "any authenticated user" — and campaign JWTs
authenticate against /api/*, so any campaign session (leader included)
could PATCH the public site. payloadAdminOnly matches the collection
lockdown precedent; editors lose config-global writes deliberately
(content editing stays on canManagePublishedContent collections).

Pins: identity + deny (coordinator/editor) + allow (admin fn) + anonymous
read in collectionAccessLockdown.int.spec.ts. Recurrence guard:
globalAccessConventions.unit.spec.ts bans the Boolean(user) shape in
src/globals.

Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-07-31 04:57:05 +00:00 committed by Francisco Ignácio Fontoura Solla
parent 84fed02ee5
commit 6705db80eb
6 changed files with 118 additions and 5 deletions

View file

@ -1,3 +1,4 @@
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateGlobal } from '@/utilities/globals'
import { GlobalConfig } from 'payload'
@ -13,7 +14,8 @@ export const HomePage: GlobalConfig = {
},
access: {
read: () => true,
update: ({ req: { user } }) => Boolean(user),
// Admin-only like the other site globals (Pass 4) — campaign JWTs reach /api/*.
update: payloadAdminOnly,
},
hooks: {
afterChange: [revalidate],

View file

@ -1,3 +1,4 @@
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateGlobal } from '@/utilities/globals'
import { GlobalConfig } from 'payload'
@ -13,7 +14,8 @@ export const Metadata: GlobalConfig = {
},
access: {
read: () => true,
update: ({ req: { user } }) => Boolean(user),
// Admin-only like the other site globals (Pass 4) — campaign JWTs reach /api/*.
update: payloadAdminOnly,
},
hooks: {
afterChange: [revalidate],

View file

@ -1,3 +1,4 @@
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateGlobal } from '@/utilities/globals'
import type { GlobalConfig } from 'payload'
@ -18,7 +19,9 @@ export const PrivacyPolicy: GlobalConfig = {
},
access: {
read: () => true,
update: ({ req: { user } }) => Boolean(user),
// Legal text: admin-only like the other site globals (Pass 4) — campaign
// JWTs reach /api/*.
update: payloadAdminOnly,
},
hooks: {
afterChange: [revalidate],

View file

@ -1,3 +1,4 @@
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateGlobal } from '@/utilities/globals'
import type { GlobalConfig } from 'payload'
@ -13,7 +14,9 @@ export const SiteSettings: GlobalConfig = {
},
access: {
read: () => true,
update: ({ req: { user } }) => Boolean(user),
// Site-wide config: campaign JWTs authenticate against /api/*, so "any
// authenticated user" here was a write path into the public site (Pass 4).
update: payloadAdminOnly,
},
hooks: {
afterChange: [revalidate],

View file

@ -12,9 +12,13 @@ import { createLeaderSupporterRecord } from '@/app/(campaign)/campanha/actions/l
import { Media } from '@/collections/Media'
import { Post } from '@/collections/Post'
import { Tag } from '@/collections/Tag'
import { HomePage } from '@/globals/HomePage'
import { Metadata } from '@/globals/Metadata'
import { PrivacyPolicy } from '@/globals/PrivacyPolicy'
import { SiteSettings } from '@/globals/SiteSettings'
import { relationshipId } from '@/lib/relationship'
import config from '@/payload.config'
import { canManagePublishedContent } from '@/utilities/access/shared'
import { canManagePublishedContent, payloadAdminOnly } from '@/utilities/access/shared'
import { SUPPORTER_REGISTRATION_CONSENT_KEY } from '@/utilities/campaignConsent'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
@ -244,6 +248,75 @@ describe('CMS/PII collection access lockdown', () => {
)
})
/**
* The globals sibling of the collection lockdown above: until Pass 4 the four
* public globals spelled `update` as "any authenticated user", and campaign
* JWTs authenticate against `/api/*` — a campaign session (leader included)
* could PATCH the public site's settings, home, metadata and privacy policy.
* The allow path is asserted on the access function itself because a real
* `updateGlobal` fires the afterChange `revalidateTag` outside Next.
*/
describe('public globals update lockdown', () => {
const publicGlobals = [
{ config: SiteSettings, slug: 'site-settings' },
{ config: HomePage, slug: 'home' },
{ config: Metadata, slug: 'metadata' },
{ config: PrivacyPolicy, slug: 'privacy-policy' },
] as const
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('spells update as payloadAdminOnly on every public global', () => {
for (const { config: globalConfig } of publicGlobals) {
expect(globalConfig.access?.update).toBe(payloadAdminOnly)
}
})
it.each(publicGlobals)('denies campaign users update on global %s', async ({ slug }) => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
await expect(
payload.updateGlobal({
slug,
data: {},
user: coordinator,
overrideAccess: false,
}),
).rejects.toThrow(/permissão|not allowed/i)
})
it.each(publicGlobals)('denies editors update on global %s', async ({ slug }) => {
const editor = await campaignFixtures().createEditorUser()
await expect(
payload.updateGlobal({
slug,
data: {},
user: editor,
overrideAccess: false,
}),
).rejects.toThrow(/permissão|not allowed/i)
})
it('lets payload admins update through the access function', async () => {
const admin = await campaignFixtures().createAdminUser()
for (const { config: globalConfig } of publicGlobals) {
expect(globalConfig.access?.update?.({ req: stub<PayloadRequest>({ user: admin }) })).toBe(
true,
)
}
})
it.each(publicGlobals)('keeps anonymous read access on global %s', async ({ slug }) => {
// Never-saved globals resolve with field defaults (no id) — the pin is
// that anonymous read is not denied, not the document shape.
await expect(payload.findGlobal({ slug, overrideAccess: false })).resolves.toBeDefined()
})
})
describe('leader supporter creation scope', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })

View file

@ -0,0 +1,30 @@
import { readdirSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
// Pass 4 (2026-07-31): the four public globals spelled `update` as
// `({ req: { user } }) => Boolean(user)` — "any authenticated user" — and
// campaign JWTs authenticate against `/api/*`, so a campaign session (leader
// included) could PATCH the public site's settings, home, metadata and privacy
// policy. The lockdown is `payloadAdminOnly`; this guard keeps the
// any-authenticated shape out of global update access for good. Collections
// are covered elsewhere (every collection declares access; lockdown pins live
// in `collectionAccessLockdown.int.spec.ts`).
describe('global update access lockdown (Pass 4)', () => {
const globalsRoot = resolve(process.cwd(), 'src/globals')
it('never spells global update access as "any authenticated user"', () => {
const offenders: string[] = []
for (const entry of readdirSync(globalsRoot, { withFileTypes: true })) {
if (!entry.isFile() || !entry.name.endsWith('.ts')) continue
const source = readFileSync(resolve(entry.parentPath, entry.name), 'utf8')
if (/=>\s*Boolean\(user\)/.test(source)) offenders.push(entry.name)
}
expect(
offenders,
'global update access must name a policy (payloadAdminOnly, isPayloadAdmin…), not Boolean(user)',
).toEqual([])
})
})