1
0
Fork 0

feat(rbac): migrate collection access controls to RBAC permissions

Replace legacy isDeveloper/isAdmin/hasRoles checks with
hasPermission/requirePermission across all collections. Add roleDocs
relationship to Users, enlistmentDate to Profiles, and field-level
intel_excerpt permission gate on Profiles. Auto-generated payload-types
updated to reflect the new Roles collection and roleDocs field.
This commit is contained in:
Jason Fraley 2026-08-19 19:47:51 -04:00
parent 80f28ed939
commit 653caa8064
35 changed files with 544 additions and 230 deletions

View file

@ -1,11 +1,11 @@
import { GlobalConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const Shims: GlobalConfig = {
slug: "shims",
access: {
update: isDeveloper,
read: isDeveloper,
update: requirePermission("shims:update"),
read: requirePermission("shims:read"),
},
fields: [
{

View file

@ -1,6 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import hasRoles from "@/utils/access-control/hasRoles";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const BankAccounts: CollectionConfig = {
slug: "bank-accounts",
@ -19,9 +18,15 @@ export const BankAccounts: CollectionConfig = {
},
access: {
read: ({ req }) => !!req.user,
create: ({ req }) => hasRoles(["admin", "developer"], req.user),
update: ({ req }) => hasRoles(["admin", "developer"], req.user),
delete: isDeveloper,
create: async ({ req }) => {
return hasPermission(req.payload, req.user, "bank-accounts:create");
},
update: async ({ req }) => {
return hasPermission(req.payload, req.user, "bank-accounts:update");
},
delete: async ({ req }) => {
return hasPermission(req.payload, req.user, "bank-accounts:delete");
},
},
fields: [
{

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const BankTransactions: CollectionConfig = {
slug: "bank-transactions",
@ -19,9 +19,9 @@ export const BankTransactions: CollectionConfig = {
},
access: {
read: ({ req }) => !!req.user,
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
create: requirePermission("bank-transactions:create"),
update: requirePermission("bank-transactions:update"),
delete: requirePermission("bank-transactions:delete"),
},
hooks: {
beforeValidate: [

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const LedgerEntries: CollectionConfig = {
slug: "ledger-entries",
@ -11,9 +11,9 @@ export const LedgerEntries: CollectionConfig = {
},
access: {
read: ({ req }) => !!req.user,
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
create: requirePermission("ledger-entries:create"),
update: requirePermission("ledger-entries:update"),
delete: requirePermission("ledger-entries:delete"),
},
fields: [
{

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
const TARGET_COLLECTIONS = [
{ label: "Game Structures", value: "game-structures" },
@ -38,12 +38,13 @@ export const GameEventLogs: CollectionConfig = {
access: {
read: ({ req }) => !!req.user,
create: ({ req }) => !!req.user,
update: ({ req }) => {
update: async ({ req }) => {
if (!req.user) return false;
const roles = req.user.roles as string[] | undefined;
return roles?.includes("admin") || roles?.includes("developer") || false;
return await hasPermission(req.payload, req.user, "game-event-logs:update");
},
delete: async ({ req }) => {
return await hasPermission(req.payload, req.user, "game-event-logs:delete");
},
delete: isDeveloper,
},
fields: [
{

View file

@ -1,12 +1,12 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const GameHardResources: CollectionConfig = {
slug: "game-hard-resources",
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
create: requirePermission("game-hard-resources:create"),
update: requirePermission("game-hard-resources:update"),
delete: requirePermission("game-hard-resources:delete"),
},
admin: {
group: "Game",

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const GameNpcs: CollectionConfig = {
slug: "game-npcs",
@ -12,9 +12,9 @@ export const GameNpcs: CollectionConfig = {
},
access: {
read: ({ req }) => !!req.user,
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
create: requirePermission("game-npcs:create"),
update: requirePermission("game-npcs:update"),
delete: requirePermission("game-npcs:delete"),
},
fields: [
{

View file

@ -1,11 +1,11 @@
import { GlobalConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const GameRules: GlobalConfig = {
slug: "game-rules",
access: {
read: isDeveloper,
update: isDeveloper,
read: requirePermission("game-rules:read"),
update: requirePermission("game-rules:update"),
},
fields: [
{

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
import { emitGameEvent } from "@/utils/event-log/emit";
export const GameStructures: CollectionConfig = {
@ -10,9 +10,9 @@ export const GameStructures: CollectionConfig = {
},
access: {
read: ({ req }) => !!req.user,
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
create: requirePermission("game-structures:create"),
update: requirePermission("game-structures:update"),
delete: requirePermission("game-structures:delete"),
},
hooks: {
afterChange: [

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const GameVehicles: CollectionConfig = {
slug: "game-vehicles",
@ -9,9 +9,9 @@ export const GameVehicles: CollectionConfig = {
},
access: {
read: ({ req }) => !!req.user,
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
create: requirePermission("game-vehicles:create"),
update: requirePermission("game-vehicles:update"),
delete: requirePermission("game-vehicles:delete"),
},
fields: [
{

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const Campaigns: CollectionConfig = {
slug: "campaigns",
@ -8,9 +8,9 @@ export const Campaigns: CollectionConfig = {
useAsTitle: "name",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
create: requirePermission("campaigns:create"),
update: requirePermission("campaigns:update"),
delete: requirePermission("campaigns:delete"),
read: ({ req }) => !!req.user,
},
fields: [

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const Factions: CollectionConfig = {
slug: "factions",
@ -8,9 +8,9 @@ export const Factions: CollectionConfig = {
useAsTitle: "name",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
create: requirePermission("factions:create"),
update: requirePermission("factions:update"),
delete: requirePermission("factions:delete"),
read: ({ req }) => !!req.user,
},
hooks: {

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import hasRoles from "@/utils/access-control/hasRoles";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const MissionAttendances: CollectionConfig = {
slug: "mission-attendances",
@ -8,16 +8,14 @@ export const MissionAttendances: CollectionConfig = {
defaultColumns: ["mission", "user", "response"],
},
access: {
read: ({ req }) => !!req.user,
create: ({ req, data }) => {
if (hasRoles(["admin", "developer"], req.user)) return true;
read: async ({ req }) => !!req.user,
create: async ({ req, data }) => {
if (!req.user || !data) return false;
const ownerId = typeof data.user === "object" ? data.user?.id : data.user;
return ownerId === req.user.id;
return await hasPermission(req.payload, req.user, "mission-attendances:create");
},
update: async ({ req, data, id }) => {
if (hasRoles(["admin", "developer"], req.user)) return true;
if (!req.user || typeof id !== "number") return false;
if (await hasPermission(req.payload, req.user, "mission-attendances:update")) return true;
const ownerId = typeof data?.user === "object" ? data.user?.id : data?.user;
if (ownerId === req.user.id) return true;
@ -34,7 +32,9 @@ export const MissionAttendances: CollectionConfig = {
const docOwner = typeof doc.user === "object" ? doc.user?.id : doc.user;
return docOwner === req.user.id;
},
delete: ({ req }) => hasRoles(["admin", "developer"], req.user),
delete: async ({ req }) => {
return await hasPermission(req.payload, req.user, "mission-attendances:delete");
},
},
fields: [
{

View file

@ -1,6 +1,5 @@
import type { CollectionConfig, Payload } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import hasRoles from "@/utils/access-control/hasRoles";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
type AssignmentRef = { id: number } | number;
@ -40,12 +39,18 @@ export const Missions: CollectionConfig = {
useAsTitle: "name",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
create: async ({ req }) => {
return await hasPermission(req.payload, req.user, "missions:create");
},
update: async ({ req }) => {
return await hasPermission(req.payload, req.user, "missions:update");
},
delete: async ({ req }) => {
return await hasPermission(req.payload, req.user, "missions:delete");
},
read: async ({ req, data }) => {
if (!req.user) return false;
if (hasRoles(["developer", "admin"], req.user)) return true;
if (await hasPermission(req.payload, req.user, "missions:read")) return true;
const userId = req.user.id;
@ -126,27 +131,23 @@ export const Missions: CollectionConfig = {
value: "external",
},
],
filterOptions: ({ req }) => {
return [
{
label: "Side Operation",
value: "side",
},
{
label: "External Operation",
value: "external",
},
...(isDeveloper({ req: req })
? [
{
label: "Main Operation",
value: "main",
},
]
: []),
];
},
defaultValue: ({ req }) => (isDeveloper({ req: req }) ? "main" : "side"),
filterOptions: ({ req }) => {
return [
{
label: "Side Operation",
value: "side",
},
{
label: "External Operation",
value: "external",
},
{
label: "Main Operation",
value: "main",
},
];
},
defaultValue: ({ req }) => "side",
required: true,
},
{
@ -504,9 +505,11 @@ export const Missions: CollectionConfig = {
type: "text",
hidden: true,
defaultValue: "ptf313",
access: {
read: isDeveloper,
},
access: {
read: async ({ req }) => {
return await hasPermission(req.payload, req.user, "missions:read-sensitive");
},
},
},
],
},

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isAdmin, isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const Technologies: CollectionConfig = {
slug: "technologies",
@ -8,10 +8,10 @@ export const Technologies: CollectionConfig = {
useAsTitle: "name",
},
access: {
create: isAdmin,
update: isAdmin,
delete: isAdmin,
read: isAdmin,
create: requirePermission("technologies:create"),
update: requirePermission("technologies:update"),
delete: requirePermission("technologies:delete"),
read: requirePermission("technologies:read"),
},
fields: [
{
@ -31,14 +31,15 @@ export const Technologies: CollectionConfig = {
{ label: "Revision Requested", value: "revision_requested" },
],
defaultValue: "in_progress",
filterOptions: ({ options, data, req }) => {
return !isDeveloper({ req })
? options.filter((option) =>
typeof option === "string"
? options
: ["in_progress", "ready_for_review"].includes(option.value),
)
: options;
filterOptions: ({ options, req }) => {
const roles = (req.user?.roles as string[] | undefined) ?? [];
const canReadAll = roles.includes("admin") || roles.includes("developer");
if (canReadAll) return options;
return options.filter((option) =>
typeof option === "string"
? options
: ["in_progress", "ready_for_review"].includes(option.value),
);
},
required: true,
admin: {

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
import { LOADOUT_SLOTS } from "@/lib/locker";
export const Loadouts: CollectionConfig = {
@ -10,21 +10,20 @@ export const Loadouts: CollectionConfig = {
defaultColumns: ["name", "ownerUser", "updatedAt"],
},
access: {
read: ({ req }) => {
read: async ({ req }) => {
if (!req.user) return false;
if (isDeveloper({ req })) return true;
if (await hasPermission(req.payload, req.user, "loadouts:read")) return true;
return { ownerUser: { equals: req.user.id } };
},
create: ({ req }) => !!req.user,
update: ({ req }) => {
update: async ({ req }) => {
if (!req.user) return false;
if (isDeveloper({ req })) return true;
if (await hasPermission(req.payload, req.user, "loadouts:update")) return true;
return { ownerUser: { equals: req.user.id } };
},
delete: ({ req }) => {
delete: async ({ req }) => {
if (!req.user) return false;
if (isDeveloper({ req })) return true;
return { ownerUser: { equals: req.user.id } };
return await hasPermission(req.payload, req.user, "loadouts:delete");
},
},
fields: [

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const LockerStorages: CollectionConfig = {
slug: "locker-storages",
@ -9,18 +9,23 @@ export const LockerStorages: CollectionConfig = {
defaultColumns: ["name", "ownerUser", "itemCount"],
},
access: {
read: ({ req }) => {
read: async ({ req }) => {
if (!req.user) return false;
if (isDeveloper({ req })) return true;
if (await hasPermission(req.payload, req.user, "locker-storages:read")) return true;
return { ownerUser: { equals: req.user.id } };
},
create: ({ req }) => !!req.user && isDeveloper({ req }),
update: ({ req }) => {
create: ({ req }) => {
if (!req.user) return false;
if (isDeveloper({ req })) return true;
return hasPermission(req.payload, req.user, "locker-storages:create");
},
update: async ({ req }) => {
if (!req.user) return false;
if (await hasPermission(req.payload, req.user, "locker-storages:update")) return true;
return { ownerUser: { equals: req.user.id } };
},
delete: isDeveloper,
delete: async ({ req }) => {
return await hasPermission(req.payload, req.user, "locker-storages:delete");
},
},
fields: [
{

View file

@ -1,5 +1,6 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const Assets: CollectionConfig = {
slug: "assets",
@ -595,9 +596,9 @@ export const Assets: CollectionConfig = {
condition: ({ user }) => isDeveloper({ req: { user: user } } as never),
},
access: {
read: isDeveloper,
create: isDeveloper,
update: isDeveloper,
read: requirePermission("assets:read"),
create: requirePermission("assets:create"),
update: requirePermission("assets:update"),
},
fields: [
{

View file

@ -1,6 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import hasRoles from "@/utils/access-control/hasRoles";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const Shipments: CollectionConfig = {
slug: "shipments",
@ -11,17 +10,15 @@ export const Shipments: CollectionConfig = {
},
access: {
read: ({ req }) => !!req.user,
create: ({ req }) => {
create: async ({ req }) => {
if (!req.user) return false;
if (hasRoles(["developer", "admin"], req.user)) return true;
return hasRoles(["user"], req.user);
return hasPermission(req.payload, req.user, "shipments:create");
},
update: ({ req }) => {
update: async ({ req }) => {
if (!req.user) return false;
if (hasRoles(["developer", "admin"], req.user)) return true;
return hasRoles(["user"], req.user);
return hasPermission(req.payload, req.user, "shipments:update");
},
delete: isDeveloper,
delete: requirePermission("shipments:delete"),
},
fields: [
{

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const MarketListings: CollectionConfig = {
slug: "market-listings",
@ -13,14 +13,12 @@ export const MarketListings: CollectionConfig = {
access: {
read: ({ req }) => !!req.user,
create: ({ req }) => !!req.user,
update: ({ req }) => {
update: async ({ req }) => {
if (!req.user) return false;
if (isDeveloper({ req })) return true;
return {
seller: { equals: req.user.id },
};
if (await hasPermission(req.payload, req.user, "market-listings:update")) return true;
return { seller: { equals: req.user.id } };
},
delete: isDeveloper,
delete: requirePermission("market-listings:delete"),
},
fields: [
{

View file

@ -1,7 +1,6 @@
import { CollectionConfig, Where } from "payload";
import type { User } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const MarketNegotiations: CollectionConfig = {
slug: "market-negotiations",
@ -21,24 +20,24 @@ export const MarketNegotiations: CollectionConfig = {
pagination: { defaultLimit: 50 },
},
access: {
read: ({ req }) => {
read: async ({ req }) => {
const user = req.user as User | null;
if (!user) return false;
if (hasRoles(["admin", "developer"], user)) return true;
if (await hasPermission(req.payload, user, "market-negotiations:read")) return true;
return {
or: [{ buyer: { equals: user.id } }, { "listing.seller": { equals: user.id } }],
} as Where;
},
create: ({ req }) => !!req.user,
update: ({ req }) => {
update: async ({ req }) => {
const user = req.user as User | null;
if (!user) return false;
if (hasRoles(["admin", "developer"], user)) return true;
if (await hasPermission(req.payload, user, "market-negotiations:update")) return true;
return {
or: [{ buyer: { equals: user.id } }, { "listing.seller": { equals: user.id } }],
} as Where;
},
delete: isDeveloper,
delete: requirePermission("market-negotiations:delete"),
},
fields: [
{

View file

@ -1,13 +1,13 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const MissionFiles: CollectionConfig = {
slug: "mission-files",
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
read: isDeveloper,
create: requirePermission("mission-files:create"),
update: requirePermission("mission-files:update"),
delete: requirePermission("mission-files:delete"),
read: requirePermission("mission-files:read"),
},
admin: {
group: "Server",

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const ModLists: CollectionConfig = {
slug: "mod-lists",
@ -7,10 +7,10 @@ export const ModLists: CollectionConfig = {
group: "Server",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
read: isDeveloper,
create: requirePermission("mod-lists:create"),
update: requirePermission("mod-lists:update"),
delete: requirePermission("mod-lists:delete"),
read: requirePermission("mod-lists:read"),
},
fields: [
{

View file

@ -1,6 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import hasRoles from "@/utils/access-control/hasRoles";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
import {
CLOSING_STATUSES,
STATUS_LABEL,
@ -49,17 +48,19 @@ export const Tickets: CollectionConfig = {
pagination: { defaultLimit: 50 },
},
access: {
read: ({ req }) => {
read: async ({ req }) => {
if (!req.user) return false;
if (hasRoles(["admin", "developer"], req.user)) return true;
if (await hasPermission(req.payload, req.user, "tickets:read")) return true;
return { reporter: { equals: req.user.id } };
},
create: ({ req }) => !!req.user,
update: ({ req }) => {
update: async ({ req }) => {
if (!req.user) return false;
return hasRoles(["admin", "developer"], req.user) ? true : false;
return await hasPermission(req.payload, req.user, "tickets:update");
},
delete: async ({ req }) => {
return await hasPermission(req.payload, req.user, "tickets:delete");
},
delete: isDeveloper,
},
hooks: {
beforeChange: [

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const Assignments: CollectionConfig = {
slug: "assignments",
@ -8,9 +8,9 @@ export const Assignments: CollectionConfig = {
useAsTitle: "name",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
create: requirePermission("assignments:create"),
update: requirePermission("assignments:update"),
delete: requirePermission("assignments:delete"),
},
fields: [
{

View file

@ -1,12 +1,12 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const Awards: CollectionConfig = {
slug: "awards",
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
create: requirePermission("awards:create"),
update: requirePermission("awards:update"),
delete: requirePermission("awards:delete"),
},
admin: {
useAsTitle: "name",

View file

@ -1,12 +1,12 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const Experience: CollectionConfig = {
slug: "experience",
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
create: requirePermission("experience:create"),
update: requirePermission("experience:update"),
delete: requirePermission("experience:delete"),
},
admin: {
group: "Users",

View file

@ -1,7 +1,6 @@
import { CollectionConfig } from "payload";
import { Award } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const Profiles: CollectionConfig = {
slug: "profiles",
@ -11,10 +10,18 @@ export const Profiles: CollectionConfig = {
defaultColumns: ["profileTitle", "user", "rank"],
},
access: {
read: (acl) => hasRoles(["user"], acl.req.user),
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
read: async ({ req }) => {
return await hasPermission(req.payload, req.user, "profiles:read");
},
create: async ({ req }) => {
return await hasPermission(req.payload, req.user, "profiles:create");
},
update: async ({ req }) => {
return await hasPermission(req.payload, req.user, "profiles:update");
},
delete: async ({ req }) => {
return await hasPermission(req.payload, req.user, "profiles:delete");
},
},
fields: [
{
@ -43,6 +50,12 @@ export const Profiles: CollectionConfig = {
label: "Personnel Dossier",
type: "group",
fields: [
{
name: "enlistmentDate",
type: "date",
required: true,
defaultValue: ({ user }) => user?.createdAt,
},
{
name: "personalExcerpt",
type: "textarea",
@ -51,6 +64,14 @@ export const Profiles: CollectionConfig = {
name: "intelExcerpt",
label: "Intelligence Record",
type: "richText",
access: {
create: async ({ req }) => {
return await hasPermission(req.payload, req.user, "profiles:intel_excerpt:update");
},
update: async ({ req }) => {
return await hasPermission(req.payload, req.user, "profiles:intel_excerpt:update");
},
},
},
],
},

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const Qualifications: CollectionConfig = {
slug: "qualifications",
@ -7,9 +7,9 @@ export const Qualifications: CollectionConfig = {
group: "Users",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
create: requirePermission("qualifications:create"),
update: requirePermission("qualifications:update"),
delete: requirePermission("qualifications:delete"),
},
fields: [
{

View file

@ -1,12 +1,12 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const Ranks: CollectionConfig = {
slug: "ranks",
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
create: requirePermission("ranks:create"),
update: requirePermission("ranks:update"),
delete: requirePermission("ranks:delete"),
},
admin: {
useAsTitle: "name",

View file

@ -1,7 +1,6 @@
import { CollectionConfig } from "payload";
import type { User } from "@/payload-types";
import hasRoles from "@/utils/access-control/hasRoles";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission";
export const UserNotifications: CollectionConfig = {
slug: "user-notifications",
@ -13,20 +12,20 @@ export const UserNotifications: CollectionConfig = {
pagination: { defaultLimit: 25 },
},
access: {
read: ({ req }) => {
read: async ({ req }) => {
const user = req.user as User | null;
if (!user) return false;
if (hasRoles(["admin", "developer"], user)) return true;
if (await hasPermission(req.payload, user, "user-notifications:read")) return true;
return { user: { equals: user.id } };
},
create: isDeveloper,
update: ({ req }) => {
create: requirePermission("user-notifications:create"),
update: async ({ req }) => {
const user = req.user as User | null;
if (!user) return false;
if (hasRoles(["admin", "developer"], user)) return true;
if (await hasPermission(req.payload, user, "user-notifications:update")) return true;
return { user: { equals: user.id } };
},
delete: isDeveloper,
delete: requirePermission("user-notifications:delete"),
},
fields: [
{

View file

@ -1,5 +1,5 @@
import type { CollectionConfig } from "payload";
import { isAdmin, isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission, hasPermission, isSuperuser } from "@/utils/access-control/hasPermission";
import { ensurePersonalAccount } from "@/lib/banking/index";
import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes";
@ -20,25 +20,45 @@ export const Users: CollectionConfig = {
);
}
const ranks = await payload.find({
collection: "ranks",
where: {
name: {
like: "Recruit",
let recruit: { id: number } | undefined;
try {
const ranks = await payload.find({
collection: "ranks",
where: {
name: {
like: "Recruit",
},
},
},
limit: 1,
depth: 0,
overrideAccess: true,
});
const recruit = (ranks as { docs: Array<{ id: number }> }).docs[0];
limit: 1,
depth: 0,
overrideAccess: true,
});
recruit = (ranks as { docs: Array<{ id: number }> }).docs[0];
if (!recruit) {
const created = await payload.create({
collection: "ranks",
data: {
name: "Recruit",
abbreviation: "Rct",
description: "Entry-level rank for new members.",
},
overrideAccess: true,
});
recruit = { id: created.id };
payload.logger.info("[Users] Created default 'Recruit' rank on demand.");
}
} catch (e) {
payload.logger.error(`[Users] Failed to resolve Recruit rank: ${e}`);
}
try {
await payload.create({
collection: "profiles",
data: {
user: userId,
rank: recruit?.id ?? null,
rank: recruit?.id ?? (null as unknown as number),
progression: {
qualifications: [],
experience: 0,
@ -57,22 +77,42 @@ export const Users: CollectionConfig = {
},
slug: "users",
access: {
admin: isAdmin,
unlock: isDeveloper,
create: isDeveloper,
update: ({ req, data }) => {
const isEditingDeveloper = data?.roles?.includes("developer");
if (isEditingDeveloper) {
return isDeveloper({ req });
admin: requirePermission("system:admin-access"),
unlock: requirePermission("users:unlock"),
create: requirePermission("users:create"),
update: async ({ req, id, data }) => {
if (data?.roles?.includes("developer")) {
return isSuperuser(req.payload, req.user);
}
return isAdmin({ req });
const existing = id
? ((await req.payload.findByID({
collection: "users",
id,
depth: 2,
overrideAccess: true,
})) as { roleDocs?: Array<{ isSuperuser?: boolean }> | null })
: null;
if (existing?.roleDocs?.some((r) => r.isSuperuser)) {
return isSuperuser(req.payload, req.user);
}
return hasPermission(req.payload, req.user, "users:update");
},
delete: ({ req, data }) => {
const isEditingDeveloper = data?.roles?.includes("developer");
if (isEditingDeveloper) {
return isDeveloper({ req });
delete: async ({ req, id, data }) => {
if (data?.roles?.includes("developer")) {
return isSuperuser(req.payload, req.user);
}
return isAdmin({ req });
const existing = id
? ((await req.payload.findByID({
collection: "users",
id,
depth: 2,
overrideAccess: true,
})) as { roleDocs?: Array<{ isSuperuser?: boolean }> | null })
: null;
if (existing?.roleDocs?.some((r) => r.isSuperuser)) {
return isSuperuser(req.payload, req.user);
}
return hasPermission(req.payload, req.user, "users:delete");
},
},
admin: {
@ -140,8 +180,8 @@ export const Users: CollectionConfig = {
name: "roles",
type: "select",
access: {
create: isDeveloper,
update: isDeveloper,
create: requirePermission("users:assign-roles"),
update: requirePermission("users:assign-roles"),
},
hasMany: true,
options: [
@ -173,6 +213,22 @@ export const Users: CollectionConfig = {
)
: options;
},*/
admin: {
description: "Legacy role enum — being replaced by the dynamic RBAC system (see 'Role Assignments' below).",
},
},
{
name: "roleDocs",
type: "relationship",
relationTo: "roles",
hasMany: true,
access: {
create: requirePermission("users:assign-roles"),
update: requirePermission("users:assign-roles"),
},
admin: {
description: "Dynamic RBAC role assignments. These determine the user's permissions via the roles collection.",
},
},
{
name: "preferences",

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const Maps: CollectionConfig = {
slug: "maps",
@ -8,10 +8,10 @@ export const Maps: CollectionConfig = {
useAsTitle: "name",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
read: isDeveloper,
create: requirePermission("maps:create"),
update: requirePermission("maps:update"),
delete: requirePermission("maps:delete"),
read: requirePermission("maps:read"),
},
fields: [
{

View file

@ -1,5 +1,5 @@
import { CollectionConfig } from "payload";
import { isDeveloper } from "@/utils/access-control/isRole";
import { requirePermission } from "@/utils/access-control/hasPermission";
export const NarrativeEvents: CollectionConfig = {
slug: "narrative-events",
@ -8,10 +8,10 @@ export const NarrativeEvents: CollectionConfig = {
useAsTitle: "name",
},
access: {
create: isDeveloper,
update: isDeveloper,
delete: isDeveloper,
read: isDeveloper,
create: requirePermission("narrative-events:create"),
update: requirePermission("narrative-events:update"),
delete: requirePermission("narrative-events:delete"),
read: requirePermission("narrative-events:read"),
},
fields: [
{

View file

@ -74,6 +74,7 @@ export interface Config {
'game-npcs': GameNpc;
'game-event-logs': GameEventLog;
users: User;
roles: Role;
ranks: Rank;
profiles: Profile;
awards: Award;
@ -125,6 +126,7 @@ export interface Config {
'game-npcs': GameNpcsSelect<false> | GameNpcsSelect<true>;
'game-event-logs': GameEventLogsSelect<false> | GameEventLogsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
roles: RolesSelect<false> | RolesSelect<true>;
ranks: RanksSelect<false> | RanksSelect<true>;
profiles: ProfilesSelect<false> | ProfilesSelect<true>;
awards: AwardsSelect<false> | AwardsSelect<true>;
@ -1260,7 +1262,14 @@ export interface User {
displayName: string;
payloadDisplayName?: string | null;
steamId: string;
/**
* Legacy role enum — being replaced by the dynamic RBAC system (see 'Role Assignments' below).
*/
roles?: ('guest' | 'user' | 'trusted' | 'admin' | 'developer')[] | null;
/**
* Dynamic RBAC role assignments. These determine the user's permissions via the roles collection.
*/
roleDocs?: (number | Role)[] | null;
preferences?: {
notifications?: {
mutedAll?: boolean | null;
@ -1335,6 +1344,203 @@ export interface User {
password?: string | null;
collection: 'users';
}
/**
* Dynamic roles for the RBAC permission system. Assign permissions to roles, then assign roles to users.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "roles".
*/
export interface Role {
id: number;
/**
* Display name for this role, e.g. 'Logistics Officer'.
*/
name: string;
/**
* Machine-readable key. Built-in roles: 'guest', 'user', 'admin', 'developer'. Used by seed scripts and the Discord bot to look up roles by key.
*/
slug: string;
/**
* Optional notes about what this role is for.
*/
description?: string | null;
/**
* Permissions granted by this role. The full list is defined in src/permissions/index.ts.
*/
permissions?:
| (
| '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'
| 'intelligence:manage'
| 'profiles:intel_excerpt:update'
| 'banking:manage'
| 'tickets:staff'
| 'discord:staff'
| 'discord:announce'
)[]
| null;
/**
* Parent roles to inherit permissions and settings from. Inheriting from a superuser role makes this role a superuser. Restricted to developers.
*/
parentRoles?: (number | Role)[] | null;
/**
* System roles are built-in and cannot be deleted. They can still be edited (e.g. to change their permissions).
*/
isSystem?: boolean | null;
/**
* Superuser roles bypass ALL permission checks. Use with extreme caution — this grants unrestricted access.
*/
isSuperuser?: boolean | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ranks".
@ -1358,7 +1564,8 @@ export interface Profile {
user: number | User;
rank: number | Rank;
profileTitle?: string | null;
dossier?: {
dossier: {
enlistmentDate: string;
personalExcerpt?: string | null;
intelExcerpt?: {
root: {
@ -2438,6 +2645,10 @@ export interface PayloadLockedDocument {
relationTo: 'users';
value: number | User;
} | null)
| ({
relationTo: 'roles';
value: number | Role;
} | null)
| ({
relationTo: 'ranks';
value: number | Rank;
@ -2746,6 +2957,7 @@ export interface UsersSelect<T extends boolean = true> {
payloadDisplayName?: T;
steamId?: T;
roles?: T;
roleDocs?: T;
preferences?:
| T
| {
@ -2786,6 +2998,21 @@ export interface UsersSelect<T extends boolean = true> {
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "roles_select".
*/
export interface RolesSelect<T extends boolean = true> {
name?: T;
slug?: T;
description?: T;
permissions?: T;
parentRoles?: T;
isSystem?: T;
isSuperuser?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ranks_select".
@ -2810,6 +3037,7 @@ export interface ProfilesSelect<T extends boolean = true> {
dossier?:
| T
| {
enlistmentDate?: T;
personalExcerpt?: T;
intelExcerpt?: T;
};