mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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:
parent
84fed02ee5
commit
6705db80eb
6 changed files with 118 additions and 5 deletions
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
30
tests/unit/globalAccessConventions.unit.spec.ts
Normal file
30
tests/unit/globalAccessConventions.unit.spec.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue