feat(access): division-scoped admin page access
- scopedAdminPageAccess replaces requireAdminPageAccess: technologies pass for intelligence division members, assets/resources/vehicles for logistics; everyone else still needs admin:<slug>:manage - technology approval is stripped from create/update below admin so division members can never self-approve; technologies access moves to the intelligence permission - assets/resources/vehicles move to logistics division permissions
This commit is contained in:
parent
c177f2486f
commit
4dde790aa8
8 changed files with 652 additions and 56 deletions
|
|
@ -1,6 +1,14 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import { requirePermission } from "@/utils/access-control/hasPermission";
|
||||
import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
|
||||
import { requireApprovalPermission, requireIntelligencePermission } from "@/utils/access-control/divisionAccess";
|
||||
|
||||
// Approval is a super-user decision: the field is stripped from create/update
|
||||
// payloads of everyone below the Admin tier, so the schema default
|
||||
// ("in_progress") applies and division members can never self-approve.
|
||||
const approvalFieldAccess = {
|
||||
create: requireApprovalPermission(),
|
||||
update: requireApprovalPermission(),
|
||||
};
|
||||
|
||||
export const Technologies: CollectionConfig = {
|
||||
slug: "technologies",
|
||||
|
|
@ -9,9 +17,9 @@ export const Technologies: CollectionConfig = {
|
|||
useAsTitle: "name",
|
||||
},
|
||||
access: {
|
||||
create: requirePermission("technologies:create"),
|
||||
update: requirePermission("technologies:update"),
|
||||
delete: requirePermission("technologies:delete"),
|
||||
create: requireIntelligencePermission("technologies:create"),
|
||||
update: requireIntelligencePermission("technologies:update"),
|
||||
delete: requireIntelligencePermission("technologies:delete"),
|
||||
read: requirePermission("technologies:read"),
|
||||
},
|
||||
fields: [
|
||||
|
|
@ -24,6 +32,7 @@ export const Technologies: CollectionConfig = {
|
|||
name: "approvalStatus",
|
||||
type: "select",
|
||||
label: "Approval Status",
|
||||
access: approvalFieldAccess,
|
||||
options: [
|
||||
{ label: "In Progress", value: "in_progress" },
|
||||
{ label: "Ready for Review", value: "ready_for_review" },
|
||||
|
|
@ -32,18 +41,6 @@ export const Technologies: CollectionConfig = {
|
|||
{ label: "Revision Requested", value: "revision_requested" },
|
||||
],
|
||||
defaultValue: "in_progress",
|
||||
filterOptions: ({ options, req }) => {
|
||||
const roles = isPayloadUser(req.user)
|
||||
? ((req.user.roles as string[] | undefined) ?? [])
|
||||
: [];
|
||||
const canReadAll = roles.includes("admin") || roles.includes("developer");
|
||||
if (canReadAll) return options;
|
||||
return options.filter((option) =>
|
||||
typeof option === "string"
|
||||
? options
|
||||
: ["in_progress", "ready_for_review"].includes(option.value),
|
||||
);
|
||||
},
|
||||
required: true,
|
||||
admin: {
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import { isDeveloper } from "@/utils/access-control/isRole";
|
||||
import { requirePermission } from "@/utils/access-control/hasPermission";
|
||||
import {
|
||||
requireApprovalPermission,
|
||||
requireLogisticsPermission,
|
||||
} from "@/utils/access-control/divisionAccess";
|
||||
|
||||
const approvalFieldAccess = {
|
||||
create: requireApprovalPermission(),
|
||||
update: requireApprovalPermission(),
|
||||
};
|
||||
|
||||
export const Assets: CollectionConfig = {
|
||||
slug: "assets",
|
||||
|
|
@ -8,6 +15,11 @@ export const Assets: CollectionConfig = {
|
|||
group: "Logistics",
|
||||
useAsTitle: "name",
|
||||
},
|
||||
access: {
|
||||
create: requireLogisticsPermission("assets:create"),
|
||||
update: requireLogisticsPermission("assets:update"),
|
||||
delete: requireLogisticsPermission("assets:delete"),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
|
|
@ -18,6 +30,7 @@ export const Assets: CollectionConfig = {
|
|||
name: "approvalStatus",
|
||||
type: "select",
|
||||
label: "Approval Status",
|
||||
access: approvalFieldAccess,
|
||||
options: [
|
||||
{ label: "In Progress", value: "in_progress" },
|
||||
{ label: "Ready for Review", value: "ready_for_review" },
|
||||
|
|
@ -26,15 +39,6 @@ export const Assets: CollectionConfig = {
|
|||
{ label: "Revision Requested", value: "revision_requested" },
|
||||
],
|
||||
defaultValue: "in_progress",
|
||||
filterOptions: ({ options, req }) => {
|
||||
return !isDeveloper({ req })
|
||||
? options.filter((option) =>
|
||||
typeof option === "string"
|
||||
? options
|
||||
: ["in_progress", "ready_for_review"].includes(option.value),
|
||||
)
|
||||
: options;
|
||||
},
|
||||
required: true,
|
||||
admin: {
|
||||
description:
|
||||
|
|
@ -643,14 +647,15 @@ export const Assets: CollectionConfig = {
|
|||
label: "Auditing",
|
||||
description: "Auditing and moderation for this Asset.",
|
||||
access: {
|
||||
read: requirePermission("assets:read"),
|
||||
create: requirePermission("assets:create"),
|
||||
update: requirePermission("assets:update"),
|
||||
read: requireLogisticsPermission("assets:read"),
|
||||
create: requireLogisticsPermission("assets:create"),
|
||||
update: requireLogisticsPermission("assets:update"),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "isLive",
|
||||
type: "checkbox",
|
||||
access: approvalFieldAccess,
|
||||
admin: {
|
||||
description:
|
||||
'If checked, this item is approved and considered "live" (i.e. it will be considered in calculations and made available in-game.)',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { CollectionConfig, PayloadRequest } from "payload";
|
||||
import { AccessArgs, AccessResult, CollectionConfig, PayloadRequest } from "payload";
|
||||
import type { Permission } from "@/permissions";
|
||||
import { isDeveloper } from "@/utils/access-control/isRole";
|
||||
import {
|
||||
requireApprovalPermission,
|
||||
requireLogisticsPermission,
|
||||
} from "@/utils/access-control/divisionAccess";
|
||||
|
||||
const onlyIfNotPrimaryCurrency = async ({ req }: { req: PayloadRequest }) => {
|
||||
if (isDeveloper({ req })) {
|
||||
|
|
@ -31,6 +36,21 @@ const onlyIfNotPrimaryCurrency = async ({ req }: { req: PayloadRequest }) => {
|
|||
return true;
|
||||
};
|
||||
|
||||
// Logistics members may write resources, but the primary-currency guard
|
||||
// (developer-only, non-primary docs only) is layered on top, not replaced.
|
||||
const logisticsWriteWithCurrencyGuard = (permission: Permission) => {
|
||||
const logisticsCheck = requireLogisticsPermission(permission);
|
||||
return async (args: AccessArgs): Promise<AccessResult> => {
|
||||
if (!(await logisticsCheck(args))) return false;
|
||||
return onlyIfNotPrimaryCurrency(args);
|
||||
};
|
||||
};
|
||||
|
||||
const approvalFieldAccess = {
|
||||
create: requireApprovalPermission(),
|
||||
update: requireApprovalPermission(),
|
||||
};
|
||||
|
||||
export const Resources: CollectionConfig = {
|
||||
slug: "resources",
|
||||
admin: {
|
||||
|
|
@ -39,8 +59,9 @@ export const Resources: CollectionConfig = {
|
|||
},
|
||||
access: {
|
||||
read: ({ req }) => !!req.user,
|
||||
update: onlyIfNotPrimaryCurrency,
|
||||
delete: onlyIfNotPrimaryCurrency,
|
||||
create: requireLogisticsPermission("resources:create"),
|
||||
update: logisticsWriteWithCurrencyGuard("resources:update"),
|
||||
delete: logisticsWriteWithCurrencyGuard("resources:delete"),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
|
|
@ -118,6 +139,7 @@ export const Resources: CollectionConfig = {
|
|||
name: "approvalStatus",
|
||||
type: "select",
|
||||
label: "Approval Status",
|
||||
access: approvalFieldAccess,
|
||||
options: [
|
||||
{ label: "In Progress", value: "in_progress" },
|
||||
{ label: "Ready for Review", value: "ready_for_review" },
|
||||
|
|
@ -126,15 +148,6 @@ export const Resources: CollectionConfig = {
|
|||
{ label: "Revision Requested", value: "revision_requested" },
|
||||
],
|
||||
defaultValue: "in_progress",
|
||||
filterOptions: ({ options, data, req }) => {
|
||||
return !isDeveloper({ req })
|
||||
? options.filter((option) =>
|
||||
typeof option === "string"
|
||||
? options
|
||||
: ["in_progress", "ready_for_review"].includes(option.value),
|
||||
)
|
||||
: options;
|
||||
},
|
||||
required: true,
|
||||
admin: {
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
import { CollectionConfig } from "payload";
|
||||
import { isDeveloper } from "@/utils/access-control/isRole";
|
||||
import {
|
||||
requireApprovalPermission,
|
||||
requireLogisticsPermission,
|
||||
} from "@/utils/access-control/divisionAccess";
|
||||
|
||||
const approvalFieldAccess = {
|
||||
create: requireApprovalPermission(),
|
||||
update: requireApprovalPermission(),
|
||||
};
|
||||
|
||||
export const Vehicles: CollectionConfig = {
|
||||
slug: "vehicles",
|
||||
|
|
@ -7,6 +15,11 @@ export const Vehicles: CollectionConfig = {
|
|||
useAsTitle: "name",
|
||||
group: "Logistics",
|
||||
},
|
||||
access: {
|
||||
create: requireLogisticsPermission("vehicles:create"),
|
||||
update: requireLogisticsPermission("vehicles:update"),
|
||||
delete: requireLogisticsPermission("vehicles:delete"),
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
label: "Identity",
|
||||
|
|
@ -29,6 +42,7 @@ export const Vehicles: CollectionConfig = {
|
|||
name: "approvalStatus",
|
||||
type: "select",
|
||||
label: "Approval Status",
|
||||
access: approvalFieldAccess,
|
||||
options: [
|
||||
{ label: "In Progress", value: "in_progress" },
|
||||
{ label: "Ready for Review", value: "ready_for_review" },
|
||||
|
|
@ -37,15 +51,6 @@ export const Vehicles: CollectionConfig = {
|
|||
{ label: "Revision Requested", value: "revision_requested" },
|
||||
],
|
||||
defaultValue: "in_progress",
|
||||
filterOptions: ({ options, data, req }) => {
|
||||
return !isDeveloper({ req })
|
||||
? options.filter((option) =>
|
||||
typeof option === "string"
|
||||
? options
|
||||
: ["in_progress", "ready_for_review"].includes(option.value),
|
||||
)
|
||||
: options;
|
||||
},
|
||||
required: true,
|
||||
admin: {
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ import { CustomMinefieldScores } from "@/collections/minigames/CustomMinefieldSc
|
|||
import { CustomRadioTests } from "@/collections/minigames/CustomRadioTests";
|
||||
import { CustomRadioTestScores } from "@/collections/minigames/CustomRadioTestScores";
|
||||
import { RibbonSubmissions } from "@/collections/users/RibbonSubmissions";
|
||||
import { requireAdminPageAccess } from "@/utils/access-control/hasPermission";
|
||||
import { scopedAdminPageAccess } from "@/utils/access-control/divisionAccess";
|
||||
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
|
||||
import type { SeedPromptFunction } from "@ai-stack/payloadcms/types";
|
||||
import { mcpPlugin } from "@payloadcms/plugin-mcp";
|
||||
|
|
@ -331,13 +331,15 @@ const collections = [
|
|||
];
|
||||
|
||||
// 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
|
||||
// to see/interact with a collection in the Payload admin panel, except the four
|
||||
// division-scoped collections (technologies → intelligence; assets/resources/
|
||||
// vehicles → logistics), where a division member passes instead. 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),
|
||||
read: scopedAdminPageAccess(collection.slug, collection.access?.read),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
|||
123
src/utils/access-control/divisionAccess.ts
Normal file
123
src/utils/access-control/divisionAccess.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import type { Access, AccessArgs, AccessResult, Payload, PayloadRequest } from "payload";
|
||||
import type { Permission } from "@/permissions";
|
||||
import {
|
||||
canAccessAdminPanel as canAccessAdminPanelByPermissions,
|
||||
hasPermission,
|
||||
isAdminPanelRequest,
|
||||
requireAdminPageAccess,
|
||||
} from "@/utils/access-control/hasPermission";
|
||||
import { hasIntelligenceQualification } from "@/utils/access-control/hasIntelligenceQualification";
|
||||
import { hasLogisticsQualification } from "@/utils/access-control/hasLogisticsQualification";
|
||||
|
||||
/**
|
||||
* Division-scoped access control.
|
||||
*
|
||||
* Division membership is established by the two qualification helpers:
|
||||
* - Intelligence: `hasIntelligenceQualification` (intel-domain RBAC permissions
|
||||
* or the "intelligence" profile qualification)
|
||||
* - Logistics: `hasLogisticsQualification` (logistics-domain RBAC permissions
|
||||
* or a "logistics" profile qualification)
|
||||
*
|
||||
* Explicit RBAC permissions always still work on their own - these helpers are
|
||||
* a union (permission OR qualification OR superuser), never a replacement.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collections whose admin panel is delegated to a division. Division members
|
||||
* get full admin-panel access to exactly these collections (and nothing else,
|
||||
* because `requireAdminPageAccess` still gates every other collection's
|
||||
* admin-panel read behind `admin:<slug>:manage` permissions).
|
||||
*/
|
||||
const DIVISION_ADMIN_QUALIFICATIONS: Record<
|
||||
string,
|
||||
(payload: Payload, user: { id: number | string }) => Promise<boolean>
|
||||
> = {
|
||||
technologies: hasIntelligenceQualification,
|
||||
assets: hasLogisticsQualification,
|
||||
resources: hasLogisticsQualification,
|
||||
vehicles: hasLogisticsQualification,
|
||||
};
|
||||
|
||||
/**
|
||||
* Access factory: allows users holding the explicit permission OR members of
|
||||
* the Intelligence division.
|
||||
*/
|
||||
export function requireIntelligencePermission(permission: Permission) {
|
||||
return async ({ req }: { req: PayloadRequest }): Promise<boolean> => {
|
||||
if (await hasPermission(req.payload, req.user, permission)) return true;
|
||||
if (!req.user) return false;
|
||||
return hasIntelligenceQualification(req.payload, req.user);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Access factory: allows users holding the explicit permission OR members of
|
||||
* the Logistics division.
|
||||
*/
|
||||
export function requireLogisticsPermission(permission: Permission) {
|
||||
return async ({ req }: { req: PayloadRequest }): Promise<boolean> => {
|
||||
if (await hasPermission(req.payload, req.user, permission)) return true;
|
||||
if (!req.user) return false;
|
||||
return hasLogisticsQualification(req.payload, req.user);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Field-level access factory for approval-gating fields (approval status,
|
||||
* is-live flags, ...). Only "super users" may write them: superuser roles or
|
||||
* holders of the `system:admin-access` permission (the Admin tier).
|
||||
*
|
||||
* Used as both `create` and `update` field access: on create the field is
|
||||
* stripped from non-superuser payloads so the schema default (e.g.
|
||||
* "in_progress") applies; on update attempts to change the value are ignored.
|
||||
*/
|
||||
export function requireApprovalPermission() {
|
||||
return async ({ req }: { req: PayloadRequest }): Promise<boolean> =>
|
||||
hasPermission(req.payload, req.user, "system:admin-access");
|
||||
}
|
||||
|
||||
/**
|
||||
* Same contract as `requireAdminPageAccess`, with one addition: for the four
|
||||
* division-scoped collections an admin-panel request also passes when the user
|
||||
* qualifies for the owning division. Non-admin (REST/API) requests keep the
|
||||
* original read access behavior unchanged.
|
||||
*/
|
||||
export function scopedAdminPageAccess(
|
||||
collectionSlug: string,
|
||||
readAccess?: Access | boolean,
|
||||
): Access {
|
||||
const base = requireAdminPageAccess(collectionSlug, readAccess);
|
||||
const divisionCheck = DIVISION_ADMIN_QUALIFICATIONS[collectionSlug];
|
||||
|
||||
if (!divisionCheck) return base;
|
||||
|
||||
return async (args: AccessArgs): Promise<AccessResult> => {
|
||||
const { req } = args;
|
||||
if (req.user && isAdminPanelRequest(req)) {
|
||||
try {
|
||||
if (await divisionCheck(req.payload, req.user)) return true;
|
||||
} catch {
|
||||
// Qualification lookup failed; fall through to the permission-based
|
||||
// decision instead of failing the request outright.
|
||||
}
|
||||
}
|
||||
return base(args);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Division-aware admin panel gate: permission-based access (superuser or any
|
||||
* `admin:<slug>:manage` permission) OR membership of a division that owns at
|
||||
* least one admin panel collection.
|
||||
*/
|
||||
export async function canAccessAdminPanel(
|
||||
payload: Payload,
|
||||
user: { id: number | string; roleDocs?: unknown } | null | undefined,
|
||||
): Promise<boolean> {
|
||||
if (!user) return false;
|
||||
if (await canAccessAdminPanelByPermissions(payload, user)) return true;
|
||||
for (const divisionCheck of Object.values(DIVISION_ADMIN_QUALIFICATIONS)) {
|
||||
if (await divisionCheck(payload, user)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -90,7 +90,7 @@ export async function hasAllPermissions(
|
|||
* 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 {
|
||||
export function isAdminPanelRequest(req: PayloadRequest): boolean {
|
||||
const adminRoute = req.payload.config.routes?.admin ?? "/admin";
|
||||
const pathname = req.pathname;
|
||||
if (!pathname) return false;
|
||||
|
|
|
|||
451
tests/int/division-admin-access.int.spec.ts
Normal file
451
tests/int/division-admin-access.int.spec.ts
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
import { getPayload, Payload } from "payload";
|
||||
import type { Access, AccessArgs } from "payload";
|
||||
import config from "@/payload.config";
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import type { Role, User } from "@/payload-types";
|
||||
import {
|
||||
canAccessAdminPanel,
|
||||
requireApprovalPermission,
|
||||
requireIntelligencePermission,
|
||||
requireLogisticsPermission,
|
||||
scopedAdminPageAccess,
|
||||
} from "@/utils/access-control/divisionAccess";
|
||||
import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions";
|
||||
|
||||
let payload: Payload;
|
||||
|
||||
const RUN = `div-${Date.now().toString(36)}`;
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
describe("Division-scoped admin access (intelligence / logistics)", () => {
|
||||
const roleIds: number[] = [];
|
||||
const userIds: number[] = [];
|
||||
const assetIds: number[] = [];
|
||||
const resourceIds: number[] = [];
|
||||
const vehicleIds: number[] = [];
|
||||
const technologyIds: number[] = [];
|
||||
const createdQualificationIds: number[] = [];
|
||||
|
||||
let intelUser: User;
|
||||
let logiUser: User;
|
||||
let plainUser: User;
|
||||
let superUser: 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",
|
||||
roleDocs: [roleId],
|
||||
},
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as User;
|
||||
userIds.push(user.id);
|
||||
return user;
|
||||
};
|
||||
|
||||
const findOrCreateQualification = async (name: string): Promise<number> => {
|
||||
const found = (await payload.find({
|
||||
collection: "qualifications",
|
||||
where: { name: { equals: name } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Array<{ id: number }> };
|
||||
if (found.docs.length > 0) return found.docs[0].id;
|
||||
const created = (await payload.create({
|
||||
collection: "qualifications",
|
||||
data: { name },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
})) as unknown as { id: number };
|
||||
createdQualificationIds.push(created.id);
|
||||
return created.id;
|
||||
};
|
||||
|
||||
const grantQualification = async (user: User, qualificationId: number) => {
|
||||
const profile = (await payload.find({
|
||||
collection: "profiles",
|
||||
where: { user: { equals: user.id } },
|
||||
limit: 1,
|
||||
depth: 0,
|
||||
overrideAccess: true,
|
||||
})) as unknown as { docs: Array<{ id: number }> };
|
||||
expect(profile.docs.length).toBeGreaterThan(0);
|
||||
await payload.update({
|
||||
collection: "profiles",
|
||||
id: profile.docs[0].id,
|
||||
data: { progression: { qualifications: [qualificationId] } },
|
||||
overrideAccess: true,
|
||||
depth: 0,
|
||||
});
|
||||
};
|
||||
|
||||
// Invoke the scoped admin-page wrapper exactly as Payload would for an
|
||||
// admin-panel request (pathname under /admin).
|
||||
const adminDecision = async (slug: string, user: User | null): Promise<boolean> => {
|
||||
const fn = scopedAdminPageAccess(slug);
|
||||
return Boolean(
|
||||
await (fn as (args: { req: unknown }) => Promise<unknown>)({
|
||||
req: { user, payload, pathname: `/admin/collections/${slug}` },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const apiDecision = async (slug: string, user: User | null, readAccess?: Access | boolean) => {
|
||||
const fn = scopedAdminPageAccess(slug, readAccess);
|
||||
return Boolean(
|
||||
await (fn as (args: { req: unknown }) => Promise<unknown>)({
|
||||
req: { user, payload, pathname: `/api/${slug}` },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const accessFnDecision = async (fn: (args: AccessArgs) => Promise<boolean>, user: User) =>
|
||||
Boolean(await fn({ req: { payload, user } } as unknown as AccessArgs));
|
||||
|
||||
const expectAccessDenied = async (fn: () => Promise<unknown>) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
const name = (e as { name?: string })?.name ?? "";
|
||||
const message = e instanceof Error ? e.message : "";
|
||||
expect(
|
||||
name === "AccessError" || name === "Forbidden" || /not permitted|not allowed|access denied/i.test(message),
|
||||
).toBe(true);
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected operation to be denied");
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const payloadConfig = await config;
|
||||
payload = await getPayload({ config: payloadConfig });
|
||||
invalidatePermissionCache();
|
||||
|
||||
const bareRole = await makeRole("bare", { permissions: [] });
|
||||
const superRole = await makeRole("super", { isSuperuser: true });
|
||||
|
||||
intelUser = await makeUser("intel", bareRole.id);
|
||||
logiUser = await makeUser("logi", bareRole.id);
|
||||
plainUser = await makeUser("plain", bareRole.id);
|
||||
superUser = await makeUser("super", superRole.id);
|
||||
|
||||
const intelligenceId = await findOrCreateQualification("Intelligence");
|
||||
const logisticsId = await findOrCreateQualification("Logistics");
|
||||
await grantQualification(intelUser, intelligenceId);
|
||||
await grantQualification(logiUser, logisticsId);
|
||||
}, TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
if (!payload) return;
|
||||
type TestSlug = "assets" | "resources" | "vehicles" | "technologies";
|
||||
const docs: Array<[TestSlug, number]> = [
|
||||
...assetIds.map((id) => ["assets", id] as [TestSlug, number]),
|
||||
...resourceIds.map((id) => ["resources", id] as [TestSlug, number]),
|
||||
...vehicleIds.map((id) => ["vehicles", id] as [TestSlug, number]),
|
||||
...technologyIds.map((id) => ["technologies", id] as [TestSlug, number]),
|
||||
];
|
||||
for (const [collection, id] of docs) {
|
||||
await payload.delete({ collection, id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
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 payload.delete({ collection: "profiles", id: p.id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of roleIds) {
|
||||
await payload.delete({ collection: "roles", id, overrideAccess: true }).catch(() => {});
|
||||
}
|
||||
for (const id of createdQualificationIds) {
|
||||
await payload
|
||||
.delete({ collection: "qualifications", id, overrideAccess: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
describe("admin panel visibility (scopedAdminPageAccess)", () => {
|
||||
it("intelligence qualification grants technologies only", async () => {
|
||||
expect(await adminDecision("technologies", intelUser)).toBe(true);
|
||||
expect(await adminDecision("assets", intelUser)).toBe(false);
|
||||
expect(await adminDecision("resources", intelUser)).toBe(false);
|
||||
expect(await adminDecision("vehicles", intelUser)).toBe(false);
|
||||
expect(await adminDecision("missions", intelUser)).toBe(false);
|
||||
}, TIMEOUT);
|
||||
|
||||
it("logistics qualification grants assets, resources, and vehicles only", async () => {
|
||||
expect(await adminDecision("assets", logiUser)).toBe(true);
|
||||
expect(await adminDecision("resources", logiUser)).toBe(true);
|
||||
expect(await adminDecision("vehicles", logiUser)).toBe(true);
|
||||
expect(await adminDecision("technologies", logiUser)).toBe(false);
|
||||
expect(await adminDecision("missions", logiUser)).toBe(false);
|
||||
expect(await adminDecision("structures", logiUser)).toBe(false);
|
||||
}, TIMEOUT);
|
||||
|
||||
it("plain users and anonymous users see none of the four", async () => {
|
||||
for (const slug of ["technologies", "assets", "resources", "vehicles"]) {
|
||||
expect(await adminDecision(slug, plainUser)).toBe(false);
|
||||
expect(await adminDecision(slug, null)).toBe(false);
|
||||
}
|
||||
}, TIMEOUT);
|
||||
|
||||
it("superusers keep full admin panel access", async () => {
|
||||
for (const slug of ["technologies", "assets", "resources", "vehicles"]) {
|
||||
expect(await adminDecision(slug, superUser)).toBe(true);
|
||||
}
|
||||
}, TIMEOUT);
|
||||
|
||||
it("preserves the original read behavior on non-admin (REST) paths", async () => {
|
||||
expect(await apiDecision("assets", logiUser)).toBe(true);
|
||||
expect(await apiDecision("assets", null)).toBe(false);
|
||||
expect(await apiDecision("technologies", logiUser, () => false)).toBe(false);
|
||||
}, TIMEOUT);
|
||||
});
|
||||
|
||||
describe("admin panel gate (canAccessAdminPanel)", () => {
|
||||
it("division members pass; plain users and anonymous users do not", async () => {
|
||||
expect(await canAccessAdminPanel(payload, intelUser)).toBe(true);
|
||||
expect(await canAccessAdminPanel(payload, logiUser)).toBe(true);
|
||||
expect(await canAccessAdminPanel(payload, superUser)).toBe(true);
|
||||
expect(await canAccessAdminPanel(payload, plainUser)).toBe(false);
|
||||
expect(await canAccessAdminPanel(payload, null)).toBe(false);
|
||||
}, TIMEOUT);
|
||||
});
|
||||
|
||||
describe("collection write access", () => {
|
||||
it("logistics members fully manage assets but cannot self-approve", async () => {
|
||||
const asset = (await payload.create({
|
||||
collection: "assets",
|
||||
data: {
|
||||
name: `${RUN} Asset`,
|
||||
className: "test-asset",
|
||||
assetType: "weapon",
|
||||
approvalStatus: "approved",
|
||||
crafting: { craftingData: { craftingTimePerUnit: 1, batchSize: 1 } },
|
||||
storageDimensions: { gridWidth: 1, gridHeight: 1 },
|
||||
},
|
||||
user: logiUser,
|
||||
overrideAccess: false,
|
||||
})) as unknown as { id: number; approvalStatus: string };
|
||||
assetIds.push(asset.id);
|
||||
expect(asset.approvalStatus).toBe("in_progress");
|
||||
|
||||
const renamed = (await payload.update({
|
||||
collection: "assets",
|
||||
id: asset.id,
|
||||
data: { name: `${RUN} Asset v2`, approvalStatus: "rejected" },
|
||||
user: logiUser,
|
||||
overrideAccess: false,
|
||||
})) as unknown as { name: string; approvalStatus: string };
|
||||
expect(renamed.name).toBe(`${RUN} Asset v2`);
|
||||
expect(renamed.approvalStatus).toBe("in_progress");
|
||||
|
||||
const approved = (await payload.update({
|
||||
collection: "assets",
|
||||
id: asset.id,
|
||||
data: { approvalStatus: "approved", isLive: true },
|
||||
user: superUser,
|
||||
overrideAccess: false,
|
||||
})) as unknown as { approvalStatus: string; isLive: boolean };
|
||||
expect(approved.approvalStatus).toBe("approved");
|
||||
expect(approved.isLive).toBe(true);
|
||||
|
||||
const demoted = (await payload.update({
|
||||
collection: "assets",
|
||||
id: asset.id,
|
||||
data: { isLive: false },
|
||||
user: logiUser,
|
||||
overrideAccess: false,
|
||||
})) as unknown as { isLive: boolean };
|
||||
expect(demoted.isLive).toBe(true);
|
||||
}, TIMEOUT);
|
||||
|
||||
it("intelligence members fully manage technologies but cannot self-approve", async () => {
|
||||
const tech = (await payload.create({
|
||||
collection: "technologies",
|
||||
data: {
|
||||
name: `${RUN} Tech`,
|
||||
summary: "Division test tech",
|
||||
type: "upgrade",
|
||||
approvalStatus: "approved",
|
||||
researchCosts: { minimumResearchDuration: 1 },
|
||||
},
|
||||
user: intelUser,
|
||||
overrideAccess: false,
|
||||
})) as unknown as { id: number; approvalStatus: string };
|
||||
technologyIds.push(tech.id);
|
||||
expect(tech.approvalStatus).toBe("in_progress");
|
||||
|
||||
const renamed = (await payload.update({
|
||||
collection: "technologies",
|
||||
id: tech.id,
|
||||
data: { name: `${RUN} Tech v2`, approvalStatus: "approved" },
|
||||
user: intelUser,
|
||||
overrideAccess: false,
|
||||
})) as unknown as { name: string; approvalStatus: string };
|
||||
expect(renamed.name).toBe(`${RUN} Tech v2`);
|
||||
expect(renamed.approvalStatus).toBe("in_progress");
|
||||
|
||||
await expectAccessDenied(() =>
|
||||
payload.create({
|
||||
collection: "technologies",
|
||||
data: {
|
||||
name: `${RUN} Tech Denied`,
|
||||
summary: "no",
|
||||
type: "upgrade",
|
||||
approvalStatus: "in_progress",
|
||||
researchCosts: { minimumResearchDuration: 1 },
|
||||
},
|
||||
user: logiUser,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
);
|
||||
await expectAccessDenied(() =>
|
||||
payload.create({
|
||||
collection: "technologies",
|
||||
data: {
|
||||
name: `${RUN} Tech Denied 2`,
|
||||
summary: "no",
|
||||
type: "upgrade",
|
||||
approvalStatus: "in_progress",
|
||||
researchCosts: { minimumResearchDuration: 1 },
|
||||
},
|
||||
user: plainUser,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
);
|
||||
}, TIMEOUT);
|
||||
|
||||
it("logistics members manage resources and vehicles; plain users are denied", async () => {
|
||||
const resource = (await payload.create({
|
||||
collection: "resources",
|
||||
data: {
|
||||
name: `${RUN} Fuel`,
|
||||
codeName: `res_fuel_${RUN}`,
|
||||
unitOfMeasure: "liter",
|
||||
massPerUnit: 0,
|
||||
gridWidth: 1,
|
||||
gridHeight: 1,
|
||||
type: "fluid",
|
||||
baseValue: 1,
|
||||
rarity: "common",
|
||||
approvalStatus: "approved",
|
||||
},
|
||||
user: logiUser,
|
||||
overrideAccess: false,
|
||||
})) as unknown as { id: number; approvalStatus: string };
|
||||
resourceIds.push(resource.id);
|
||||
expect(resource.approvalStatus).toBe("in_progress");
|
||||
|
||||
await expectAccessDenied(() =>
|
||||
payload.create({
|
||||
collection: "resources",
|
||||
data: {
|
||||
name: `${RUN} Denied`,
|
||||
codeName: `res_denied_${RUN}`,
|
||||
unitOfMeasure: "unit",
|
||||
massPerUnit: 0,
|
||||
gridWidth: 1,
|
||||
gridHeight: 1,
|
||||
type: "physical",
|
||||
baseValue: 1,
|
||||
rarity: "common",
|
||||
approvalStatus: "in_progress",
|
||||
},
|
||||
user: plainUser,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const vehicle = (await payload.create({
|
||||
collection: "vehicles",
|
||||
data: {
|
||||
name: `${RUN} Truck`,
|
||||
transportMode: "ground",
|
||||
approvalStatus: "approved",
|
||||
fuel: { fuelType: resource.id, fuelCapacity: 100, fuelConsumptionRate: 1 },
|
||||
},
|
||||
user: logiUser,
|
||||
overrideAccess: false,
|
||||
})) as unknown as { id: number; approvalStatus: string };
|
||||
vehicleIds.push(vehicle.id);
|
||||
expect(vehicle.approvalStatus).toBe("in_progress");
|
||||
|
||||
await expectAccessDenied(() =>
|
||||
payload.create({
|
||||
collection: "vehicles",
|
||||
data: {
|
||||
name: `${RUN} Denied Truck`,
|
||||
transportMode: "ground",
|
||||
approvalStatus: "in_progress",
|
||||
fuel: { fuelType: resource.id, fuelCapacity: 10, fuelConsumptionRate: 1 },
|
||||
},
|
||||
user: intelUser,
|
||||
overrideAccess: false,
|
||||
}),
|
||||
);
|
||||
}, TIMEOUT);
|
||||
|
||||
it("superusers create pre-approved documents directly", async () => {
|
||||
const asset = (await payload.create({
|
||||
collection: "assets",
|
||||
data: {
|
||||
name: `${RUN} Super Asset`,
|
||||
className: "test-asset",
|
||||
assetType: "weapon",
|
||||
approvalStatus: "approved",
|
||||
crafting: { craftingData: { craftingTimePerUnit: 1, batchSize: 1 } },
|
||||
storageDimensions: { gridWidth: 1, gridHeight: 1 },
|
||||
},
|
||||
user: superUser,
|
||||
overrideAccess: false,
|
||||
})) as unknown as { id: number; approvalStatus: string };
|
||||
assetIds.push(asset.id);
|
||||
expect(asset.approvalStatus).toBe("approved");
|
||||
}, TIMEOUT);
|
||||
|
||||
it("permission holders bypass the qualification requirement", async () => {
|
||||
const assetsCreate = requireLogisticsPermission("assets:create");
|
||||
expect(await accessFnDecision(assetsCreate, logiUser)).toBe(true);
|
||||
|
||||
const technologiesCreate = requireIntelligencePermission("technologies:create");
|
||||
expect(await accessFnDecision(technologiesCreate, intelUser)).toBe(true);
|
||||
}, TIMEOUT);
|
||||
|
||||
it("approval fields reject writes from everyone below the super-user tier", async () => {
|
||||
const approvalUpdate = requireApprovalPermission();
|
||||
expect(await accessFnDecision(approvalUpdate, superUser)).toBe(true);
|
||||
expect(await accessFnDecision(approvalUpdate, logiUser)).toBe(false);
|
||||
expect(await accessFnDecision(approvalUpdate, intelUser)).toBe(false);
|
||||
expect(await accessFnDecision(approvalUpdate, plainUser)).toBe(false);
|
||||
}, TIMEOUT);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue