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 sharp from "sharp";
|
||||||
|
|
||||||
import { Users } from "@/collections/users/Users";
|
import { Users } from "@/collections/users/Users";
|
||||||
|
import { Roles } from "@/collections/users/Roles";
|
||||||
import { Awards } from "@/collections/users/Awards";
|
import { Awards } from "@/collections/users/Awards";
|
||||||
import { Media } from "@/collections/Media";
|
import { Media } from "@/collections/Media";
|
||||||
import { Ranks } from "@/collections/users/Ranks";
|
import { Ranks } from "@/collections/users/Ranks";
|
||||||
|
|
@ -190,6 +191,7 @@ export default buildConfig({
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
Users,
|
Users,
|
||||||
|
Roles,
|
||||||
Ranks,
|
Ranks,
|
||||||
Profiles,
|
Profiles,
|
||||||
Awards,
|
Awards,
|
||||||
|
|
@ -280,6 +282,7 @@ export default buildConfig({
|
||||||
|
|
||||||
// Users
|
// Users
|
||||||
[Users.slug]: true,
|
[Users.slug]: true,
|
||||||
|
[Roles.slug]: true,
|
||||||
[Ranks.slug]: true,
|
[Ranks.slug]: true,
|
||||||
[Profiles.slug]: true,
|
[Profiles.slug]: true,
|
||||||
[Awards.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