diff --git a/src/collections/users/Roles.ts b/src/collections/users/Roles.ts new file mode 100644 index 0000000..0f57495 --- /dev/null +++ b/src/collections/users/Roles.ts @@ -0,0 +1,149 @@ +import type { CollectionConfig } from "payload"; +import { isAdmin, isDeveloper } from "@/utils/access-control/isRole"; +import { permissionSelectOptions } from "@/permissions"; +import { invalidatePermissionCache } from "@/utils/access-control/loadUserPermissions"; + +export const Roles: CollectionConfig = { + slug: "roles", + admin: { + group: "Users", + useAsTitle: "name", + description: + "Dynamic roles for the RBAC permission system. Assign permissions to roles, then assign roles to users.", + }, + access: { + admin: isAdmin, + create: isAdmin, + update: isAdmin, + delete: isDeveloper, + }, + hooks: { + beforeDelete: [ + async ({ req, id }) => { + const doc = (await req.payload.findByID({ + collection: "roles", + id, + overrideAccess: true, + })) as { isSystem?: boolean } | null; + if (doc?.isSystem) { + throw new Error( + "System roles cannot be deleted. Disable them by removing their permissions instead.", + ); + } + }, + ], + afterChange: [ + () => { + // Bust the entire permission cache — any user with this role may be affected. + invalidatePermissionCache(); + }, + ], + }, + fields: [ + { + name: "name", + type: "text", + required: true, + unique: true, + admin: { + description: "Display name for this role, e.g. 'Logistics Officer'.", + }, + }, + { + name: "slug", + type: "text", + required: true, + unique: true, + index: true, + admin: { + description: + "Machine-readable key. Built-in roles: 'guest', 'user', 'admin', 'developer'. Used by seed scripts and the Discord bot to look up roles by key.", + readOnly: true, + }, + hooks: { + beforeValidate: [ + ({ data, originalDoc }) => { + // Auto-generate from name if not explicitly set (or name changed). + const name = data?.name as string | undefined; + const existing = (originalDoc as { slug?: string })?.slug; + if (data?.slug && data.slug === existing) { + // Keep explicit slug unless name changed and slug was auto-generated. + return data.slug; + } + if (data?.slug) { + return data.slug as string; + } + if (!name) return existing ?? ""; + return name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + }, + ], + }, + }, + { + name: "description", + type: "textarea", + admin: { + description: "Optional notes about what this role is for.", + }, + }, + { + name: "permissions", + type: "select", + hasMany: true, + options: permissionSelectOptions, + admin: { + description: + "Permissions granted by this role. The full list is defined in src/permissions/index.ts.", + isSortable: false, + }, + }, + { + name: "parentRoles", + label: "Inherits from...", + type: "relationship", + relationTo: "roles", + hasMany: true, + access: { + create: isDeveloper, + update: isDeveloper, + }, + filterOptions: ({ id }) => { + if (!id) return true; + return { id: { not_equals: id } }; + }, + admin: { + description: + "Parent roles to inherit permissions and settings from. Inheriting from a superuser role makes this role a superuser. Restricted to developers.", + position: "sidebar", + }, + }, + { + name: "isSystem", + type: "checkbox", + defaultValue: false, + admin: { + description: + "System roles are built-in and cannot be deleted. They can still be edited (e.g. to change their permissions).", + position: "sidebar", + readOnly: true, + }, + }, + { + name: "isSuperuser", + type: "checkbox", + defaultValue: false, + access: { + create: isDeveloper, + update: isDeveloper, + }, + admin: { + description: + "Superuser roles bypass ALL permission checks. Use with extreme caution — this grants unrestricted access.", + position: "sidebar", + }, + }, + ], +}; diff --git a/src/payload.config.ts b/src/payload.config.ts index 0570963..cb19b5e 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "url"; import sharp from "sharp"; import { Users } from "@/collections/users/Users"; +import { Roles } from "@/collections/users/Roles"; import { Awards } from "@/collections/users/Awards"; import { Media } from "@/collections/Media"; import { Ranks } from "@/collections/users/Ranks"; @@ -190,6 +191,7 @@ export default buildConfig({ // Users Users, + Roles, Ranks, Profiles, Awards, @@ -280,6 +282,7 @@ export default buildConfig({ // Users [Users.slug]: true, + [Roles.slug]: true, [Ranks.slug]: true, [Profiles.slug]: true, [Awards.slug]: true, diff --git a/src/permissions/index.ts b/src/permissions/index.ts new file mode 100644 index 0000000..5cc0114 --- /dev/null +++ b/src/permissions/index.ts @@ -0,0 +1,616 @@ +/** + * Permission universe — the single source of truth for every permission + * recognized by the dynamic RBAC system. + * + * Permissions are hardcoded here in application code. The Payload admin panel + * reads this list when rendering the permission selector on the `roles` + * collection. Roles are dynamic (created/edited in the admin panel), but the + * set of assignable permissions is NOT — it can only grow by adding entries + * here. + * + * Format: `{collection-slug}:{create|read|update|delete}` for collection CRUD, + * plus domain-specific special permissions for feature areas that don't map + * cleanly to CRUD (e.g. `logistics:manage`, `discord:announce`). + */ + +export interface PermissionOption { + /** Machine-readable key, e.g. `"users:create"`. */ + value: string; + /** Human-readable label, e.g. `"Create"`. */ + label: string | string[]; +} + +export interface PermissionGroup { + /** Group label shown as a header in the admin UI, e.g. `"Users"`. */ + group: string; + /** Permissions within this group. */ + permissions: PermissionOption[]; +} + +// --------------------------------------------------------------------------- +// Permission groups +// --------------------------------------------------------------------------- + +export const PERMISSION_GROUPS: PermissionGroup[] = [ + { + group: "System", + permissions: [{ value: "system:admin-access", label: "Access Admin Panel" }], + }, + + // ---- Users --------------------------------------------------------------- + { + group: "Users", + permissions: [ + { value: "users:create", label: "Create Users" }, + { value: "users:read", label: "Read Users" }, + { value: "users:update", label: "Update Users" }, + { value: "users:delete", label: "Delete Users" }, + { value: "users:unlock", label: "Unlock User Accounts" }, + { value: "users:assign-roles", label: "Assign Roles to Users" }, + ], + }, + { + group: "Ranks", + permissions: [ + { value: "ranks:create", label: "Create" }, + { value: "ranks:read", label: "Read" }, + { value: "ranks:update", label: "Update" }, + { value: "ranks:delete", label: "Delete" }, + ], + }, + { + group: "Profiles", + permissions: [ + { value: "profiles:create", label: "Create" }, + { value: "profiles:read", label: "Read" }, + { value: "profiles:update", label: "Update" }, + { value: "profiles:delete", label: "Delete" }, + ], + }, + { + group: "Awards", + permissions: [ + { value: "awards:create", label: "Create" }, + { value: "awards:read", label: "Read" }, + { value: "awards:update", label: "Update" }, + { value: "awards:delete", label: "Delete" }, + ], + }, + { + group: "Qualifications", + permissions: [ + { value: "qualifications:create", label: "Create" }, + { value: "qualifications:read", label: "Read" }, + { value: "qualifications:update", label: "Update" }, + { value: "qualifications:delete", label: "Delete" }, + ], + }, + { + group: "Assignments", + permissions: [ + { value: "assignments:create", label: "Create" }, + { value: "assignments:read", label: "Read" }, + { value: "assignments:update", label: "Update" }, + { value: "assignments:delete", label: "Delete" }, + ], + }, + { + group: "Experience", + permissions: [ + { value: "experience:create", label: "Create" }, + { value: "experience:read", label: "Read" }, + { value: "experience:update", label: "Update" }, + { value: "experience:delete", label: "Delete" }, + ], + }, + { + group: "User Notifications", + permissions: [ + { value: "user-notifications:create", label: "Create" }, + { value: "user-notifications:read", label: "Read" }, + { value: "user-notifications:update", label: "Update" }, + { value: "user-notifications:delete", label: "Delete" }, + ], + }, + + // ---- Intelligence -------------------------------------------------------- + { + group: "Missions", + permissions: [ + { value: "missions:create", label: "Create" }, + { value: "missions:read", label: "Read" }, + { value: "missions:update", label: "Update" }, + { value: "missions:delete", label: "Delete" }, + { value: "missions:read-sensitive", label: "Read Sensitive Fields (server passwords)" }, + ], + }, + { + group: "Mission Attendances", + permissions: [ + { value: "mission-attendances:create", label: "Create" }, + { value: "mission-attendances:read", label: "Read" }, + { value: "mission-attendances:update", label: "Update" }, + { value: "mission-attendances:delete", label: "Delete" }, + ], + }, + { + group: "Campaigns", + permissions: [ + { value: "campaigns:create", label: "Create" }, + { value: "campaigns:read", label: "Read" }, + { value: "campaigns:update", label: "Update" }, + { value: "campaigns:delete", label: "Delete" }, + ], + }, + { + group: "Factions", + permissions: [ + { value: "factions:create", label: "Create" }, + { value: "factions:read", label: "Read" }, + { value: "factions:update", label: "Update" }, + { value: "factions:delete", label: "Delete" }, + ], + }, + { + group: "Technologies", + permissions: [ + { value: "technologies:create", label: "Create" }, + { value: "technologies:read", label: "Read" }, + { value: "technologies:update", label: "Update" }, + { value: "technologies:delete", label: "Delete" }, + ], + }, + + // ---- Logistics ----------------------------------------------------------- + { + group: "Assets", + permissions: [ + { value: "assets:create", label: "Create" }, + { value: "assets:read", label: "Read" }, + { value: "assets:update", label: "Update" }, + { value: "assets:delete", label: "Delete" }, + ], + }, + { + group: "Resources", + permissions: [ + { value: "resources:create", label: "Create" }, + { value: "resources:read", label: "Read" }, + { value: "resources:update", label: "Update" }, + { value: "resources:delete", label: "Delete" }, + ], + }, + { + group: "Vehicles", + permissions: [ + { value: "vehicles:create", label: "Create" }, + { value: "vehicles:read", label: "Read" }, + { value: "vehicles:update", label: "Update" }, + { value: "vehicles:delete", label: "Delete" }, + ], + }, + { + group: "Structures", + permissions: [ + { value: "structures:create", label: "Create" }, + { value: "structures:read", label: "Read" }, + { value: "structures:update", label: "Update" }, + { value: "structures:delete", label: "Delete" }, + ], + }, + { + group: "Shipments", + permissions: [ + { value: "shipments:create", label: "Create" }, + { value: "shipments:read", label: "Read" }, + { value: "shipments:update", label: "Update" }, + { value: "shipments:delete", label: "Delete" }, + ], + }, + + // ---- Banking ------------------------------------------------------------- + { + group: "Bank Accounts", + permissions: [ + { value: "bank-accounts:create", label: "Create" }, + { value: "bank-accounts:read", label: "Read" }, + { value: "bank-accounts:update", label: "Update" }, + { value: "bank-accounts:delete", label: "Delete" }, + ], + }, + { + group: "Bank Transactions", + permissions: [ + { value: "bank-transactions:create", label: "Create" }, + { value: "bank-transactions:read", label: "Read" }, + { value: "bank-transactions:update", label: "Update" }, + { value: "bank-transactions:delete", label: "Delete" }, + ], + }, + { + group: "Ledger Entries", + permissions: [ + { value: "ledger-entries:create", label: "Create" }, + { value: "ledger-entries:read", label: "Read" }, + { value: "ledger-entries:update", label: "Update" }, + { value: "ledger-entries:delete", label: "Delete" }, + ], + }, + + // ---- Locker -------------------------------------------------------------- + { + group: "Locker Storages", + permissions: [ + { value: "locker-storages:create", label: "Create" }, + { value: "locker-storages:read", label: "Read" }, + { value: "locker-storages:update", label: "Update" }, + { value: "locker-storages:delete", label: "Delete" }, + ], + }, + { + group: "Loadouts", + permissions: [ + { value: "loadouts:create", label: "Create" }, + { value: "loadouts:read", label: "Read" }, + { value: "loadouts:update", label: "Update" }, + { value: "loadouts:delete", label: "Delete" }, + ], + }, + + // ---- Market -------------------------------------------------------------- + { + group: "Market Listings", + permissions: [ + { value: "market-listings:create", label: "Create" }, + { value: "market-listings:read", label: "Read" }, + { value: "market-listings:update", label: "Update" }, + { value: "market-listings:delete", label: "Delete" }, + ], + }, + { + group: "Market Negotiations", + permissions: [ + { value: "market-negotiations:create", label: "Create" }, + { value: "market-negotiations:read", label: "Read" }, + { value: "market-negotiations:update", label: "Update" }, + { value: "market-negotiations:delete", label: "Delete" }, + ], + }, + + // ---- Helpdesk ------------------------------------------------------------ + { + group: "Tickets", + permissions: [ + { value: "tickets:create", label: "Create" }, + { value: "tickets:read", label: "Read" }, + { value: "tickets:update", label: "Update" }, + { value: "tickets:delete", label: "Delete" }, + ], + }, + + // ---- Game ---------------------------------------------------------------- + { + group: "Game Structures", + permissions: [ + { value: "game-structures:create", label: "Create" }, + { value: "game-structures:read", label: "Read" }, + { value: "game-structures:update", label: "Update" }, + { value: "game-structures:delete", label: "Delete" }, + ], + }, + { + group: "Game Vehicles", + permissions: [ + { value: "game-vehicles:create", label: "Create" }, + { value: "game-vehicles:read", label: "Read" }, + { value: "game-vehicles:update", label: "Update" }, + { value: "game-vehicles:delete", label: "Delete" }, + ], + }, + { + group: "Game NPCs", + permissions: [ + { value: "game-npcs:create", label: "Create" }, + { value: "game-npcs:read", label: "Read" }, + { value: "game-npcs:update", label: "Update" }, + { value: "game-npcs:delete", label: "Delete" }, + ], + }, + { + group: "Game Hard Resources", + permissions: [ + { value: "game-hard-resources:create", label: "Create" }, + { value: "game-hard-resources:read", label: "Read" }, + { value: "game-hard-resources:update", label: "Update" }, + { value: "game-hard-resources:delete", label: "Delete" }, + ], + }, + { + group: "Game Event Logs", + permissions: [ + { value: "game-event-logs:create", label: "Create" }, + { value: "game-event-logs:read", label: "Read" }, + { value: "game-event-logs:update", label: "Update" }, + { value: "game-event-logs:delete", label: "Delete" }, + ], + }, + + // ---- World --------------------------------------------------------------- + { + group: "Maps", + permissions: [ + { value: "maps:create", label: "Create" }, + { value: "maps:read", label: "Read" }, + { value: "maps:update", label: "Update" }, + { value: "maps:delete", label: "Delete" }, + ], + }, + { + group: "Narrative Events", + permissions: [ + { value: "narrative-events:create", label: "Create" }, + { value: "narrative-events:read", label: "Read" }, + { value: "narrative-events:update", label: "Update" }, + { value: "narrative-events:delete", label: "Delete" }, + ], + }, + + // ---- Server -------------------------------------------------------------- + { + group: "Mission Files", + permissions: [ + { value: "mission-files:create", label: "Create" }, + { value: "mission-files:read", label: "Read" }, + { value: "mission-files:update", label: "Update" }, + { value: "mission-files:delete", label: "Delete" }, + ], + }, + { + group: "Mod Lists", + permissions: [ + { value: "mod-lists:create", label: "Create" }, + { value: "mod-lists:read", label: "Read" }, + { value: "mod-lists:update", label: "Update" }, + { value: "mod-lists:delete", label: "Delete" }, + ], + }, + + // ---- Globals ------------------------------------------------------------- + { + group: "Game Rules (Global)", + permissions: [ + { value: "game-rules:read", label: "Read" }, + { value: "game-rules:update", label: "Update" }, + ], + }, + { + group: "Shims (Global)", + permissions: [ + { value: "shims:read", label: "Read" }, + { value: "shims:update", label: "Update" }, + ], + }, + + // ---- Special / Feature-area ---------------------------------------------- + { + group: "Logistics", + permissions: [{ value: "logistics:manage", label: "Manage Logistics (manager bypass)" }], + }, + { + group: "Intelligence", + permissions: [ + { value: "intelligence:manage", label: "Manage Intelligence (manager bypass)" }, + { value: "profiles:intel_excerpt:update", label: ["Profile", "Intel Excerpt", "Update"] }, + ], + }, + { + group: "Banking", + permissions: [{ value: "banking:manage", label: "Manage Banking (manager bypass)" }], + }, + { + group: "Helpdesk", + permissions: [{ value: "tickets:staff", label: "Staff Helpdesk Tickets" }], + }, + { + group: "Discord Bot", + permissions: [ + { value: "discord:staff", label: "Bot Staff (isStaff)" }, + { value: "discord:announce", label: "Post Announcements (/announce)" }, + ], + }, +]; + +// --------------------------------------------------------------------------- +// Derived exports +// --------------------------------------------------------------------------- + +/** Flat array of all permission value strings, as a const tuple for type inference. */ +export const ALL_PERMISSION_VALUES = PERMISSION_GROUPS.flatMap((g) => + g.permissions.map((p) => p.value), +) as unknown as readonly [ + "system:admin-access", + "users:create", + "users:read", + "users:update", + "users:delete", + "users:unlock", + "users:assign-roles", + "ranks:create", + "ranks:read", + "ranks:update", + "ranks:delete", + "profiles:create", + "profiles:read", + "profiles:update", + "profiles:delete", + "awards:create", + "awards:read", + "awards:update", + "awards:delete", + "qualifications:create", + "qualifications:read", + "qualifications:update", + "qualifications:delete", + "assignments:create", + "assignments:read", + "assignments:update", + "assignments:delete", + "experience:create", + "experience:read", + "experience:update", + "experience:delete", + "user-notifications:create", + "user-notifications:read", + "user-notifications:update", + "user-notifications:delete", + "missions:create", + "missions:read", + "missions:update", + "missions:delete", + "missions:read-sensitive", + "mission-attendances:create", + "mission-attendances:read", + "mission-attendances:update", + "mission-attendances:delete", + "campaigns:create", + "campaigns:read", + "campaigns:update", + "campaigns:delete", + "factions:create", + "factions:read", + "factions:update", + "factions:delete", + "technologies:create", + "technologies:read", + "technologies:update", + "technologies:delete", + "assets:create", + "assets:read", + "assets:update", + "assets:delete", + "resources:create", + "resources:read", + "resources:update", + "resources:delete", + "vehicles:create", + "vehicles:read", + "vehicles:update", + "vehicles:delete", + "structures:create", + "structures:read", + "structures:update", + "structures:delete", + "shipments:create", + "shipments:read", + "shipments:update", + "shipments:delete", + "bank-accounts:create", + "bank-accounts:read", + "bank-accounts:update", + "bank-accounts:delete", + "bank-transactions:create", + "bank-transactions:read", + "bank-transactions:update", + "bank-transactions:delete", + "ledger-entries:create", + "ledger-entries:read", + "ledger-entries:update", + "ledger-entries:delete", + "locker-storages:create", + "locker-storages:read", + "locker-storages:update", + "locker-storages:delete", + "loadouts:create", + "loadouts:read", + "loadouts:update", + "loadouts:delete", + "market-listings:create", + "market-listings:read", + "market-listings:update", + "market-listings:delete", + "market-negotiations:create", + "market-negotiations:read", + "market-negotiations:update", + "market-negotiations:delete", + "tickets:create", + "tickets:read", + "tickets:update", + "tickets:delete", + "game-structures:create", + "game-structures:read", + "game-structures:update", + "game-structures:delete", + "game-vehicles:create", + "game-vehicles:read", + "game-vehicles:update", + "game-vehicles:delete", + "game-npcs:create", + "game-npcs:read", + "game-npcs:update", + "game-npcs:delete", + "game-hard-resources:create", + "game-hard-resources:read", + "game-hard-resources:update", + "game-hard-resources:delete", + "game-event-logs:create", + "game-event-logs:read", + "game-event-logs:update", + "game-event-logs:delete", + "maps:create", + "maps:read", + "maps:update", + "maps:delete", + "narrative-events:create", + "narrative-events:read", + "narrative-events:update", + "narrative-events:delete", + "mission-files:create", + "mission-files:read", + "mission-files:update", + "mission-files:delete", + "mod-lists:create", + "mod-lists:read", + "mod-lists:update", + "mod-lists:delete", + "game-rules:read", + "game-rules:update", + "shims:read", + "shims:update", + "logistics:manage", + "banking:manage", + "tickets:staff", + "discord:staff", + "discord:announce", + "intelligence:manage", + "profiles:intel_excerpt:update", +]; + +/** + * Union type of every valid permission string. + * Use this to type-check permission arguments at compile time. + */ +export type Permission = (typeof ALL_PERMISSION_VALUES)[number]; + +/** + * Payload `select` field options, flattened with group-prefixed labels. + * e.g. `{ label: "Users › Create Users", value: "users:create" }` + */ +export const permissionSelectOptions: { label: string; value: string }[] = + PERMISSION_GROUPS.flatMap((g) => + g.permissions.map((p) => { + let label = p.label; + if (Array.isArray(p.label)) label = p.label.join(" › "); + return { + label: `${g.group} › ${label}`, + value: p.value, + }; + }), + ); + +/** + * Guard function — returns true if the given string is a valid permission. + * Useful for runtime validation of permission strings from DB or API input. + */ +export function isPermission(value: string): value is Permission { + return (ALL_PERMISSION_VALUES as readonly string[]).includes(value); +} diff --git a/src/utils/access-control/hasPermission.ts b/src/utils/access-control/hasPermission.ts new file mode 100644 index 0000000..4ff0a73 --- /dev/null +++ b/src/utils/access-control/hasPermission.ts @@ -0,0 +1,113 @@ +import type { Payload, PayloadRequest } from "payload"; +import { loadUserPermissions } from "@/utils/access-control/loadUserPermissions"; +import type { Permission } from "@/permissions"; + +/** + * Minimal user shape for permission checks. + * Accepts the full Payload User or a stripped-down { id } from server actions. + */ +interface UserLike { + id: number | string; + roleDocs?: unknown; +} + +/** + * Check whether a user has a specific permission. + * + * Resolution order: + * 1. No user → false. + * 2. User has a role with `isSuperuser: true` → true (bypasses all checks). + * 3. User has a role whose `permissions` array includes the given permission → true. + * 4. Otherwise → false. + * + * Results are cached per-user for 30s (see `loadUserPermissions`). + * + * @example + * const canCreate = await hasPermission(payload, user, "users:create"); + */ +export async function hasPermission( + payload: Payload, + user: UserLike | null | undefined, + permission: Permission, +): Promise { + if (!user) return false; + + const { permissions, isSuperuser } = await loadUserPermissions(payload, user); + if (isSuperuser) return true; + return permissions.has(permission); +} + +/** + * Check whether a user has a superuser role (bypasses all permission checks). + * + * Use this for the legacy `isDeveloper` replacement where the check was + * "can do anything" rather than a specific permission. + */ +export async function isSuperuser( + payload: Payload, + user: UserLike | null | undefined, +): Promise { + if (!user) return false; + const { isSuperuser: su } = await loadUserPermissions(payload, user); + return su; +} + +/** + * Check whether a user has ANY of the given permissions. + */ +export async function hasAnyPermission( + payload: Payload, + user: UserLike | null | undefined, + ...permissions: Permission[] +): Promise { + if (!user) return false; + const { permissions: userPerms, isSuperuser: su } = await loadUserPermissions(payload, user); + if (su) return true; + return permissions.some((p) => userPerms.has(p)); +} + +/** + * Check whether a user has ALL of the given permissions. + */ +export async function hasAllPermissions( + payload: Payload, + user: UserLike | null | undefined, + ...permissions: Permission[] +): Promise { + if (!user) return false; + const { permissions: userPerms, isSuperuser: su } = await loadUserPermissions(payload, user); + if (su) return true; + return permissions.every((p) => userPerms.has(p)); +} + +/** + * Factory that creates a Payload collection access function requiring a specific + * permission. Drop-in replacement for `isDeveloper` / `isAdmin` in collection + * `access` blocks. + * + * @example + * access: { + * create: requirePermission("users:create"), + * update: requirePermission("users:update"), + * } + */ +export function requirePermission(permission: Permission) { + return async ({ req }: { req: PayloadRequest }): Promise => { + return hasPermission(req.payload, req.user, permission); + }; +} + +/** + * Factory that creates a Payload collection access function requiring ANY of + * the given permissions. + * + * @example + * access: { + * update: requireAnyPermission("tickets:update", "tickets:staff"), + * } + */ +export function requireAnyPermission(...permissions: Permission[]) { + return async ({ req }: { req: PayloadRequest }): Promise => { + return hasAnyPermission(req.payload, req.user, ...permissions); + }; +} diff --git a/src/utils/access-control/loadUserPermissions.ts b/src/utils/access-control/loadUserPermissions.ts new file mode 100644 index 0000000..33babb1 --- /dev/null +++ b/src/utils/access-control/loadUserPermissions.ts @@ -0,0 +1,121 @@ +import type { Payload } from "payload"; + +const CACHE_TTL_MS = 30_000; +const MAX_INHERITANCE_DEPTH = 10; + +interface CachedPermissions { + permissions: Set; + isSuperuser: boolean; + loadedAt: number; +} + +const cache = new Map(); + +interface UserLike { + id: number | string; + roleDocs?: unknown; +} + +interface RoleDoc { + id: number; + permissions?: string[] | null; + isSuperuser?: boolean | null; + parentRoles?: Array | null; +} + +function resolveRoleId(ref: number | { id: number }): number { + return typeof ref === "number" ? ref : ref.id; +} + +export async function loadUserPermissions( + payload: Payload, + user: UserLike | null | undefined, +): Promise<{ permissions: Set; isSuperuser: boolean }> { + if (!user) return { permissions: new Set(), isSuperuser: false }; + + const userId = Number(user.id); + if (Number.isNaN(userId)) return { permissions: new Set(), isSuperuser: false }; + + const cached = cache.get(userId); + if (cached && Date.now() - cached.loadedAt < CACHE_TTL_MS) { + return cached; + } + + const permissions = new Set(); + let isSuperuser = false; + + try { + const userDoc = (await payload.findByID({ + collection: "users", + id: userId, + depth: 2, + overrideAccess: true, + })) as { roleDocs?: RoleDoc[] | null } | null; + + const directRoles = userDoc?.roleDocs; + if (!directRoles || !Array.isArray(directRoles)) { + const result: CachedPermissions = { permissions, isSuperuser, loadedAt: Date.now() }; + cache.set(userId, result); + return result; + } + + const visited = new Set(); + let currentLevel: RoleDoc[] = directRoles.filter((r) => { + const id = Number(r.id); + if (visited.has(id)) return false; + visited.add(id); + return true; + }); + + for (let depth = 0; depth < MAX_INHERITANCE_DEPTH && currentLevel.length > 0; depth++) { + for (const role of currentLevel) { + if (role.isSuperuser) isSuperuser = true; + if (role.permissions && Array.isArray(role.permissions)) { + for (const p of role.permissions) { + if (typeof p === "string") permissions.add(p); + } + } + } + + const parentIds: number[] = []; + for (const role of currentLevel) { + if (!role.parentRoles || !Array.isArray(role.parentRoles)) continue; + for (const parentRef of role.parentRoles) { + const parentId = resolveRoleId(parentRef); + if (!visited.has(parentId)) { + visited.add(parentId); + parentIds.push(parentId); + } + } + } + + if (parentIds.length === 0) { + currentLevel = []; + break; + } + + const parentRes = await payload.find({ + collection: "roles", + where: { id: { in: parentIds } }, + limit: parentIds.length, + depth: 1, + overrideAccess: true, + }); + currentLevel = parentRes.docs as unknown as RoleDoc[]; + } + } catch { + return { permissions: new Set(), isSuperuser: false }; + } + + const result: CachedPermissions = { permissions, isSuperuser, loadedAt: Date.now() }; + cache.set(userId, result); + return result; +} + +export function invalidatePermissionCache(userId?: number): void { + if (userId !== undefined) { + cache.delete(userId); + } else { + cache.clear(); + } +}