Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@siisyphuslabs.ai>
221 lines
7.2 KiB
TypeScript
221 lines
7.2 KiB
TypeScript
import type { Access, AccessArgs, AccessResult, 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));
|
|
}
|
|
|
|
/**
|
|
* Detect whether a request targets the Payload admin panel.
|
|
*
|
|
* The admin panel is served under `config.routes.admin` (default `/admin`).
|
|
* `req.pathname` is the request URL pathname (set by Payload's request
|
|
* creation), so this reliably distinguishes admin-panel requests from REST
|
|
* API requests — unlike `x-invoke-path`, which is unreliable in layouts.
|
|
* Local API calls (server actions, seeds, MCP, bots) get a non-admin
|
|
* pathname and therefore keep the original access behavior.
|
|
*/
|
|
function isAdminPanelRequest(req: PayloadRequest): boolean {
|
|
const adminRoute = req.payload.config.routes?.admin ?? "/admin";
|
|
const pathname = req.pathname;
|
|
if (!pathname) return false;
|
|
return pathname === adminRoute || pathname.startsWith(`${adminRoute}/`);
|
|
}
|
|
|
|
/**
|
|
* Factory that wraps a collection's `read` access so that the Payload admin
|
|
* panel only shows/interacts with the collection when the user holds BOTH
|
|
* `<slug>:read` AND `admin:<slug>:manage`. Non-admin (REST/API) reads keep
|
|
* the original access behavior unchanged.
|
|
*
|
|
* Apply centrally to every collection in `payload.config.ts`:
|
|
*
|
|
* @example
|
|
* collections: collections.map((collection) => ({
|
|
* ...collection,
|
|
* access: {
|
|
* ...collection.access,
|
|
* read: requireAdminPageAccess(collection.slug, collection.access?.read),
|
|
* },
|
|
* }))
|
|
*/
|
|
export function requireAdminPageAccess(collectionSlug: string, readAccess?: Access | boolean) {
|
|
return async (args: AccessArgs): Promise<AccessResult> => {
|
|
const { req } = args;
|
|
|
|
if (isAdminPanelRequest(req)) {
|
|
// Admin panel: require both the collection read permission and the
|
|
// admin page-manage permission. Must return a plain boolean — a `Where`
|
|
// here would be treated as "no permission" by the admin UI.
|
|
return hasAllPermissions(
|
|
req.payload,
|
|
req.user,
|
|
`${collectionSlug}:read` as Permission,
|
|
`admin:${collectionSlug}:manage` as Permission,
|
|
);
|
|
}
|
|
|
|
// Non-admin (REST/API): preserve the original read access behavior.
|
|
if (typeof readAccess === "function") return readAccess(args);
|
|
if (readAccess === true) return true;
|
|
if (readAccess === false) return false;
|
|
// Omitted read access → Payload default: any logged-in user can read.
|
|
return Boolean(req.user);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Factory that creates a Payload collection access function requiring campaign ownership.
|
|
*
|
|
* A user can access a campaign if:
|
|
* 1. They own the campaign (owner field matches their user ID), OR
|
|
* 2. They have the "campaigns:manage" permission
|
|
*
|
|
* @example
|
|
* access: {
|
|
* update: requireCampaignOwnership("campaigns:update"),
|
|
* delete: requireCampaignOwnership("campaigns:delete"),
|
|
* }
|
|
*/
|
|
export function requireCampaignOwnership(permission: Permission) {
|
|
return async ({ req, id }: { req: PayloadRequest; id?: any }) => {
|
|
if (!req.user) return false;
|
|
|
|
// Check permission first (grants access to all campaigns for managers)
|
|
const { permissions: userPerms, isSuperuser: su } = await loadUserPermissions(req.payload, req.user);
|
|
if (su || userPerms.has(permission)) return true;
|
|
|
|
// If no user permissions for manage, check ownership
|
|
let campaign;
|
|
try {
|
|
campaign = await req.payload.findByID({
|
|
collection: "campaigns",
|
|
id: id,
|
|
depth: 1,
|
|
overrideAccess: true,
|
|
});
|
|
} catch (error) {
|
|
// If campaign lookup fails (e.g. invalid ID), deny access
|
|
return false;
|
|
}
|
|
|
|
if (!campaign) return false;
|
|
|
|
// User owns the campaign if owner field matches their ID
|
|
// campaign.owner can be number (ID) or User object
|
|
const campaignOwnerId = campaign.owner ? (typeof campaign.owner === "object" ? campaign.owner.id : campaign.owner) : null;
|
|
const userId = Number(req.user.id);
|
|
|
|
if (campaignOwnerId && campaignOwnerId === userId) return true;
|
|
|
|
// User does not own this campaign and lacks manage permission
|
|
return false;
|
|
};
|
|
}
|