1
0
Fork 0

feat(rbac): add dynamic roles collection and permissions system

Introduce a dynamic RBAC system with a new 'roles' collection that grants
granular permissions. Add hasPermission/requirePermission/loadUserPermissions
utilities and a central permissions registry. Register the Roles collection in
payload.config and add roleDocs relationship to Users.
This commit is contained in:
Jason Fraley 2026-08-19 19:47:36 -04:00
parent adaedaefde
commit 80f28ed939
5 changed files with 1002 additions and 0 deletions

View file

@ -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",
},
},
],
};

View file

@ -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,

616
src/permissions/index.ts Normal file
View file

@ -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);
}

View file

@ -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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> => {
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<boolean> => {
return hasAnyPermission(req.payload, req.user, ...permissions);
};
}

View file

@ -0,0 +1,121 @@
import type { Payload } from "payload";
const CACHE_TTL_MS = 30_000;
const MAX_INHERITANCE_DEPTH = 10;
interface CachedPermissions {
permissions: Set<string>;
isSuperuser: boolean;
loadedAt: number;
}
const cache = new Map<number, CachedPermissions>();
interface UserLike {
id: number | string;
roleDocs?: unknown;
}
interface RoleDoc {
id: number;
permissions?: string[] | null;
isSuperuser?: boolean | null;
parentRoles?: Array<number | { id: number }> | 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<string>; 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<string>();
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<number>();
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();
}
}