From 8654eb40fa88ba1c6992f9468c3a8437899bd9b8 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Thu, 10 Sep 2026 16:13:48 -0400 Subject: [PATCH] fix(missions): enforce visibility rules in collection read access Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/collections/intelligence/Missions.ts | 97 ++++++++++++++---------- 1 file changed, 56 insertions(+), 41 deletions(-) diff --git a/src/collections/intelligence/Missions.ts b/src/collections/intelligence/Missions.ts index 9cb60d5..1b72544 100644 --- a/src/collections/intelligence/Missions.ts +++ b/src/collections/intelligence/Missions.ts @@ -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 { + 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 => { + return missionReadFilter(req); }, }, fields: [