1
0
Fork 0

fix(missions): enforce visibility rules in collection read access

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-10 16:13:48 -04:00
parent 09b074734f
commit 8654eb40fa

View file

@ -1,8 +1,10 @@
import type { CollectionConfig, Payload } from "payload";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
import type { CollectionConfig, Payload, PayloadRequest, Where } from "payload";
import { requirePermission, hasPermission, isSuperuser } from "@/utils/access-control/hasPermission";
type AssignmentRef = { id: number } | number;
const DRAFT_STATUSES = ["Concept", "Planning"];
async function isInAssignments(
payload: Payload,
userId: number,
@ -32,6 +34,56 @@ function extractIds(refs: unknown[] | undefined): number[] {
);
}
/**
* Per-document read access for missions, expressed as a Payload Where
* constraint so it applies to both list queries and single-doc reads.
*
* Visibility rules (per ownershipAndStatus.visibility):
* - authors / zeus always see their own missions.
* - unit: every authenticated user.
* - leadership: command or intel assignment members (+ authors/zeus).
* - intel: intel assignment members (+ authors/zeus).
* - private: authors/zeus only.
*
* Drafts (Concept / Planning) are only visible to authors/zeus and
* command/intel members. Superusers bypass everything.
*/
async function missionReadFilter(req: PayloadRequest): Promise<boolean | Where> {
if (!req.user) return false;
if (await isSuperuser(req.payload, req.user)) return true;
const userId = req.user.id as number;
const gameRules = await req.payload.findGlobal({ slug: "game-rules" });
const commandIds = extractIds(gameRules.commandAssignments as AssignmentRef[] | undefined);
const intelIds = extractIds(gameRules.intelAssignments as AssignmentRef[] | undefined);
const [isCommand, isIntel] = await Promise.all([
isInAssignments(req.payload, userId, commandIds),
isInAssignments(req.payload, userId, intelIds),
]);
const isLeader = isCommand || isIntel;
const notDraft = { "ownershipAndStatus.status": { not_in: DRAFT_STATUSES } };
const or: Where["or"] = [
{ "ownershipAndStatus.authors": { in: [userId] } },
{ "ownershipAndStatus.zeus": { in: [userId] } },
{ and: [notDraft, { "ownershipAndStatus.visibility": { equals: "unit" } }] },
];
if (isLeader) {
or.push({ and: [notDraft, { "ownershipAndStatus.visibility": { equals: "leadership" } }] });
or.push({ "ownershipAndStatus.status": { in: DRAFT_STATUSES } });
}
if (isIntel) {
or.push({ and: [notDraft, { "ownershipAndStatus.visibility": { equals: "intel" } }] });
}
return { or };
}
export const Missions: CollectionConfig = {
slug: "missions",
admin: {
@ -71,45 +123,8 @@ export const Missions: CollectionConfig = {
delete: async ({ req }) => {
return await hasPermission(req.payload, req.user, "missions:delete");
},
read: async ({ req, data }) => {
if (!req.user) return false;
if (await hasPermission(req.payload, req.user, "missions:read")) return true;
const userId = req.user.id;
const gameRules = await req.payload.findGlobal({ slug: "game-rules" });
const commandIds = extractIds(gameRules.commandAssignments as AssignmentRef[] | undefined);
const intelIds = extractIds(gameRules.intelAssignments as AssignmentRef[] | undefined);
const [isCommand, isIntel] = await Promise.all([
isInAssignments(req.payload, userId, commandIds),
isInAssignments(req.payload, userId, intelIds),
]);
const authorIds = extractIds(data?.ownershipAndStatus?.authors as unknown[] | undefined);
const zeusIds = extractIds(data?.ownershipAndStatus?.zeus as unknown[] | undefined);
const isAuthorOrZeus = authorIds.includes(userId) || zeusIds.includes(userId);
const status = data?.ownershipAndStatus?.status;
const isDraft = status === "Concept" || status === "Planning";
if (isDraft) {
return isAuthorOrZeus || isCommand || isIntel;
}
const visibility = data?.ownershipAndStatus?.visibility;
switch (visibility) {
case "unit":
return true;
case "leadership":
return isCommand || isIntel || isAuthorOrZeus;
case "intel":
return isIntel || isAuthorOrZeus;
case "private":
return isAuthorOrZeus;
default:
return true;
}
read: async ({ req }: { req: PayloadRequest }): Promise<boolean | Where> => {
return missionReadFilter(req);
},
},
fields: [