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:
parent
adaedaefde
commit
80f28ed939
5 changed files with 1002 additions and 0 deletions
149
src/collections/users/Roles.ts
Normal file
149
src/collections/users/Roles.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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
616
src/permissions/index.ts
Normal 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);
|
||||
}
|
||||
113
src/utils/access-control/hasPermission.ts
Normal file
113
src/utils/access-control/hasPermission.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
121
src/utils/access-control/loadUserPermissions.ts
Normal file
121
src/utils/access-control/loadUserPermissions.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue