feat(auth): scope Payload admin pages by permissions
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@siisyphuslabs.ai>
This commit is contained in:
parent
e29b403d06
commit
822ac47c23
3 changed files with 309 additions and 72 deletions
|
|
@ -58,6 +58,7 @@ import { migrations } from "./migrations";
|
||||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { Shims } from "@/collections/Shims";
|
import { Shims } from "@/collections/Shims";
|
||||||
|
import { requireAdminPageAccess } from "@/utils/access-control/hasPermission";
|
||||||
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
|
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
|
||||||
import { mcpPlugin } from "@payloadcms/plugin-mcp";
|
import { mcpPlugin } from "@payloadcms/plugin-mcp";
|
||||||
import { createOpenAI } from "@ai-sdk/openai";
|
import { createOpenAI } from "@ai-sdk/openai";
|
||||||
|
|
@ -139,6 +140,89 @@ const dirname = path.dirname(filename);
|
||||||
const META_TITLE = "Polaris Task Force";
|
const META_TITLE = "Polaris Task Force";
|
||||||
const META_DESCRIPTION = "A gamified platform for Polaris Task Force, an Arma 3 unit.";
|
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 `<slug>:read` AND `admin:<slug>: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({
|
export default buildConfig({
|
||||||
// serverURL: "http://localhost:3000/",
|
// serverURL: "http://localhost:3000/",
|
||||||
admin: {
|
admin: {
|
||||||
|
|
@ -186,77 +270,7 @@ export default buildConfig({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
globals: [GameRules, Shims],
|
globals: [GameRules, Shims],
|
||||||
collections: [
|
collections: scopedCollections,
|
||||||
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,
|
|
||||||
],
|
|
||||||
editor: lexicalEditor({
|
editor: lexicalEditor({
|
||||||
features: ({ rootFeatures }) => {
|
features: ({ rootFeatures }) => {
|
||||||
return [HeadingFeature(), PayloadAiPluginLexicalEditorFeature()];
|
return [HeadingFeature(), PayloadAiPluginLexicalEditorFeature()];
|
||||||
|
|
|
||||||
|
|
@ -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 { loadUserPermissions } from "@/utils/access-control/loadUserPermissions";
|
||||||
import type { Permission } from "@/permissions";
|
import type { Permission } from "@/permissions";
|
||||||
|
|
||||||
|
|
@ -80,6 +80,65 @@ export async function hasAllPermissions(
|
||||||
return permissions.every((p) => userPerms.has(p));
|
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
|
||||||
|
* `<slug>:read` AND `admin:<slug>: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<AccessResult> => {
|
||||||
|
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
|
* Factory that creates a Payload collection access function requiring a specific
|
||||||
* permission. Drop-in replacement for `isDeveloper` / `isAdmin` in collection
|
* permission. Drop-in replacement for `isDeveloper` / `isAdmin` in collection
|
||||||
|
|
|
||||||
164
tests/int/admin-page-access.int.spec.ts
Normal file
164
tests/int/admin-page-access.int.spec.ts
Normal file
|
|
@ -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<Role> = {}): Promise<Role> => {
|
||||||
|
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<User> => {
|
||||||
|
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<unknown> => {
|
||||||
|
const fn = requireAdminPageAccess("missions");
|
||||||
|
return await (fn as (args: { req: unknown }) => Promise<unknown>)({
|
||||||
|
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<unknown> => {
|
||||||
|
const fn = requireAdminPageAccess("missions", readAccess);
|
||||||
|
return await (fn as (args: { req: unknown }) => Promise<unknown>)({
|
||||||
|
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);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue