From 822ac47c2355f7ae31cc8cead63d5945b672afbe Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 1 Sep 2026 21:39:09 -0400 Subject: [PATCH] feat(auth): scope Payload admin pages by permissions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/payload.config.ts | 156 ++++++++++---------- src/utils/access-control/hasPermission.ts | 61 +++++++- tests/int/admin-page-access.int.spec.ts | 164 ++++++++++++++++++++++ 3 files changed, 309 insertions(+), 72 deletions(-) create mode 100644 tests/int/admin-page-access.int.spec.ts diff --git a/src/payload.config.ts b/src/payload.config.ts index 900efa0..21a65aa 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -58,6 +58,7 @@ import { migrations } from "./migrations"; import { nodemailerAdapter } from "@payloadcms/email-nodemailer"; import nodemailer from "nodemailer"; import { Shims } from "@/collections/Shims"; +import { requireAdminPageAccess } from "@/utils/access-control/hasPermission"; import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms"; import { mcpPlugin } from "@payloadcms/plugin-mcp"; import { createOpenAI } from "@ai-sdk/openai"; @@ -139,6 +140,89 @@ const dirname = path.dirname(filename); const META_TITLE = "Polaris Task Force"; const META_DESCRIPTION = "A gamified platform for Polaris Task Force, an Arma 3 unit."; +const collections = [ + Media, + + // Game + GameHardResources, + GameStructures, + GameVehicles, + GameNpcs, + GameEventLogs, + + // Users + Users, + Roles, + Ranks, + Profiles, + Awards, + Qualifications, + Assignments, + AssignmentTransfers, + Experience, + UserNotifications, + Evaluations, + + // Intel + Missions, + MissionAttendances, + Campaigns, + Factions, + Technologies, + + // Logistics + Assets, + Resources, + Vehicles, + Structures, + Shipments, + + // Banking + BankAccounts, + BankTransactions, + LedgerEntries, + + // Locker + LockerStorages, + Loadouts, + + // Market + MarketListings, + MarketNegotiations, + + // Helpdesk + Tickets, + TicketVotes, + + // Projects + Projects, + Sprints, + Releases, + Labels, + + // World + Maps, + NarrativeEvents, + + // Server + MissionFiles, + ModLists, + GameServers, + ArmaSyncEvents, + ArmaCommands, +]; + +// Scoped admin pages: a user needs BOTH `:read` AND `admin::manage` +// to see/interact with a collection in the Payload admin panel. Non-admin +// (REST/API) reads keep the collection's original access behavior. +const scopedCollections = collections.map((collection) => ({ + ...collection, + access: { + ...collection.access, + read: requireAdminPageAccess(collection.slug, collection.access?.read), + }, +})); + export default buildConfig({ // serverURL: "http://localhost:3000/", admin: { @@ -186,77 +270,7 @@ export default buildConfig({ }, }, globals: [GameRules, Shims], - collections: [ - Media, - - // Game - GameHardResources, - GameStructures, - GameVehicles, - GameNpcs, - GameEventLogs, - - // Users - Users, - Roles, - Ranks, - Profiles, - Awards, - Qualifications, - Assignments, - AssignmentTransfers, - Experience, - UserNotifications, - Evaluations, - - // Intel - Missions, - MissionAttendances, - Campaigns, - Factions, - Technologies, - - // Logistics - Assets, - Resources, - Vehicles, - Structures, - Shipments, - - // Banking - BankAccounts, - BankTransactions, - LedgerEntries, - - // Locker - LockerStorages, - Loadouts, - - // Market - MarketListings, - MarketNegotiations, - - // Helpdesk - Tickets, - TicketVotes, - - // Projects - Projects, - Sprints, - Releases, - Labels, - - // World - Maps, - NarrativeEvents, - - // Server - MissionFiles, - ModLists, - GameServers, - ArmaSyncEvents, - ArmaCommands, - ], + collections: scopedCollections, editor: lexicalEditor({ features: ({ rootFeatures }) => { return [HeadingFeature(), PayloadAiPluginLexicalEditorFeature()]; diff --git a/src/utils/access-control/hasPermission.ts b/src/utils/access-control/hasPermission.ts index f4f9d21..bbd4f2d 100644 --- a/src/utils/access-control/hasPermission.ts +++ b/src/utils/access-control/hasPermission.ts @@ -1,4 +1,4 @@ -import type { Payload, PayloadRequest } from "payload"; +import type { Access, AccessArgs, AccessResult, Payload, PayloadRequest } from "payload"; import { loadUserPermissions } from "@/utils/access-control/loadUserPermissions"; import type { Permission } from "@/permissions"; @@ -80,6 +80,65 @@ export async function hasAllPermissions( return permissions.every((p) => userPerms.has(p)); } +/** + * Detect whether a request targets the Payload admin panel. + * + * The admin panel is served under `config.routes.admin` (default `/admin`). + * `req.pathname` is the request URL pathname (set by Payload's request + * creation), so this reliably distinguishes admin-panel requests from REST + * API requests — unlike `x-invoke-path`, which is unreliable in layouts. + * Local API calls (server actions, seeds, MCP, bots) get a non-admin + * pathname and therefore keep the original access behavior. + */ +function isAdminPanelRequest(req: PayloadRequest): boolean { + const adminRoute = req.payload.config.routes?.admin ?? "/admin"; + const pathname = req.pathname; + if (!pathname) return false; + return pathname === adminRoute || pathname.startsWith(`${adminRoute}/`); +} + +/** + * Factory that wraps a collection's `read` access so that the Payload admin + * panel only shows/interacts with the collection when the user holds BOTH + * `:read` AND `admin::manage`. Non-admin (REST/API) reads keep + * the original access behavior unchanged. + * + * Apply centrally to every collection in `payload.config.ts`: + * + * @example + * collections: collections.map((collection) => ({ + * ...collection, + * access: { + * ...collection.access, + * read: requireAdminPageAccess(collection.slug, collection.access?.read), + * }, + * })) + */ +export function requireAdminPageAccess(collectionSlug: string, readAccess?: Access | boolean) { + return async (args: AccessArgs): Promise => { + const { req } = args; + + if (isAdminPanelRequest(req)) { + // Admin panel: require both the collection read permission and the + // admin page-manage permission. Must return a plain boolean — a `Where` + // here would be treated as "no permission" by the admin UI. + return hasAllPermissions( + req.payload, + req.user, + `${collectionSlug}:read` as Permission, + `admin:${collectionSlug}:manage` as Permission, + ); + } + + // Non-admin (REST/API): preserve the original read access behavior. + if (typeof readAccess === "function") return readAccess(args); + if (readAccess === true) return true; + if (readAccess === false) return false; + // Omitted read access → Payload default: any logged-in user can read. + return Boolean(req.user); + }; +} + /** * Factory that creates a Payload collection access function requiring a specific * permission. Drop-in replacement for `isDeveloper` / `isAdmin` in collection diff --git a/tests/int/admin-page-access.int.spec.ts b/tests/int/admin-page-access.int.spec.ts new file mode 100644 index 0000000..7fa6dac --- /dev/null +++ b/tests/int/admin-page-access.int.spec.ts @@ -0,0 +1,164 @@ +import { getPayload, Payload } from "payload"; +import type { Access } from "payload"; +import config from "@/payload.config"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import type { Role, User } from "@/payload-types"; +import { requireAdminPageAccess } from "@/utils/access-control/hasPermission"; +import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions"; + +let payload: Payload; + +const RUN = `adm-${Date.now().toString(36)}`; +const TIMEOUT = 30_000; + +describe("Scoped admin page access control", () => { + const roleIds: number[] = []; + const userIds: number[] = []; + + let readOnlyUser: User; + let manageOnlyUser: User; + let bothUser: User; + let devUser: User; + let neitherUser: User; + + const makeRole = async (label: string, extra: Partial = {}): Promise => { + const role = (await payload.create({ + collection: "roles", + data: { name: `${RUN}-${label}`, slug: `${RUN}-${label}`, ...extra }, + overrideAccess: true, + depth: 0, + })) as unknown as Role; + roleIds.push(role.id); + return role; + }; + + const makeUser = async (label: string, roleId: number): Promise => { + const user = (await payload.create({ + collection: "users", + data: { + username: `${RUN}-${label}`, + discordUsername: `${RUN}-${label}`, + displayName: label.toUpperCase(), + steamId: `7656119${Math.floor(Math.random() * 1e9)}`, + password: "Test123", + // Permission resolution reads roleDocs (the dynamic RBAC relationship), not + // the legacy `roles` enum — so assign a real role doc to grant permissions. + roleDocs: [roleId], + }, + overrideAccess: true, + depth: 0, + })) as unknown as User; + userIds.push(user.id); + return user; + }; + + // Invoke the admin-page wrapper exactly as Payload would for an admin-panel + // request (pathname under /admin). The vitest environment has no Next.js HTTP + // server, so calling the access function directly is the standard way to unit-test + // Payload access control. + const adminDecision = async (user: User | null): Promise => { + const fn = requireAdminPageAccess("missions"); + return await (fn as (args: { req: unknown }) => Promise)({ + req: { user, payload, pathname: "/admin/collections/missions" }, + }); + }; + + // Same wrapper, but on a REST API pathname — the original read access must be + // preserved unchanged outside the admin panel. + const apiDecision = async (user: User | null, readAccess?: Access | boolean): Promise => { + const fn = requireAdminPageAccess("missions", readAccess); + return await (fn as (args: { req: unknown }) => Promise)({ + req: { user, payload, pathname: "/api/missions" }, + }); + }; + + beforeAll(async () => { + const payloadConfig = await config; + payload = await getPayload({ config: payloadConfig }); + invalidatePermissionCache(); + + const readOnlyRole = await makeRole("readonly", { permissions: ["missions:read"] }); + const manageOnlyRole = await makeRole("manageonly", { + permissions: ["admin:missions:manage"], + }); + const bothRole = await makeRole("both", { + permissions: ["missions:read", "admin:missions:manage"], + }); + const devRole = await makeRole("dev", { isSuperuser: true }); + const neitherRole = await makeRole("neither", { permissions: [] }); + + readOnlyUser = await makeUser("readonly", readOnlyRole.id); + manageOnlyUser = await makeUser("manageonly", manageOnlyRole.id); + bothUser = await makeUser("both", bothRole.id); + devUser = await makeUser("dev", devRole.id); + neitherUser = await makeUser("neither", neitherRole.id); + }, TIMEOUT); + + afterAll(async () => { + if (!payload) return; + for (const id of userIds) { + const profiles = await payload + .find({ + collection: "profiles", + where: { user: { equals: id } }, + limit: 5, + depth: 0, + overrideAccess: true, + }) + .catch(() => null); + for (const p of profiles?.docs ?? []) { + await Promise.allSettled([ + payload.delete({ collection: "profiles", id: p.id, overrideAccess: true }), + ]); + } + await Promise.allSettled([payload.delete({ collection: "users", id, overrideAccess: true })]); + } + for (const id of roleIds) { + await Promise.allSettled([payload.delete({ collection: "roles", id, overrideAccess: true })]); + } + }); + + it("denies admin access with only the collection read permission", async () => { + expect(await adminDecision(readOnlyUser)).toBe(false); + }, TIMEOUT); + + it("denies admin access with only the admin page-manage permission", async () => { + expect(await adminDecision(manageOnlyUser)).toBe(false); + }, TIMEOUT); + + it("grants admin access with both permissions", async () => { + expect(await adminDecision(bothUser)).toBe(true); + }, TIMEOUT); + + it("grants admin access to superuser roles", async () => { + expect(await adminDecision(devUser)).toBe(true); + }, TIMEOUT); + + it("denies admin access with neither permission", async () => { + expect(await adminDecision(neitherUser)).toBe(false); + }, TIMEOUT); + + it("denies anonymous admin access", async () => { + expect(await adminDecision(null)).toBe(false); + }, TIMEOUT); + + it("preserves the default logged-in read on the API path", async () => { + expect(await apiDecision(bothUser)).toBe(true); + }, TIMEOUT); + + it("preserves the default anonymous denial on the API path", async () => { + expect(await apiDecision(null)).toBe(false); + }, TIMEOUT); + + it("delegates a Where-returning original access on the API path", async () => { + const where = { user: { equals: bothUser.id } }; + expect(await apiDecision(bothUser, () => where)).toEqual(where); + }, TIMEOUT); + + it("delegates a boolean original access on the API path", async () => { + expect(await apiDecision(bothUser, () => false)).toBe(false); + expect(await apiDecision(bothUser, () => true)).toBe(true); + }, TIMEOUT); +});