payload-gatekeeper
Version:
The ultimate access control gatekeeper for Payload CMS v3 - Advanced RBAC with wildcard support, auto role assignment, and flexible configuration
153 lines • 6.08 kB
JavaScript
import { PERMISSIONS } from '../constants';
import { getRolesSlug } from './getRolesSlug';
// Regex cache for compiled patterns - safe for multi-instance as patterns don't change
const regexCache = new Map();
const MAX_REGEX_CACHE_SIZE = 100;
/**
* Get or create cached regex for permission pattern
*/
const getPermissionRegex = (pattern) => {
let regex = regexCache.get(pattern);
if (!regex) {
const regexPattern = pattern
.replace(/\./g, '\\.') // Escape dots
.replace(/\*/g, '.*'); // Convert * to .*
regex = new RegExp(`^${regexPattern}$`);
// Limit cache size to prevent memory issues
if (regexCache.size < MAX_REGEX_CACHE_SIZE) {
regexCache.set(pattern, regex);
}
}
return regex;
};
/**
* Check if user has required permission
* Supports wildcards and pattern matching
* Optimized for performance with early returns
*/
export const hasPermission = (userPermissions, requiredPermission) => {
// Early return for invalid input
if (!userPermissions?.length) {
return false;
}
// Most common case: Super admin check
if (userPermissions.includes(PERMISSIONS.ALL)) {
return true;
}
// Second most common: Exact permission match
if (userPermissions.includes(requiredPermission)) {
return true;
}
// Optimized wildcard checking
for (const permission of userPermissions) {
// Skip non-wildcard permissions (already checked above)
if (!permission.includes('*')) {
continue;
}
// Fast check for common "collection.*" pattern (but only if it's the last segment)
if (permission.endsWith('.*') && permission.indexOf('*') === permission.length - 1) {
const prefix = permission.slice(0, -1); // Remove the asterisk
if (requiredPermission.startsWith(prefix)) {
return true;
}
continue;
}
// All other wildcard patterns (including multiple wildcards, use regex)
if (getPermissionRegex(permission).test(requiredPermission)) {
return true;
}
}
return false;
};
/**
* Check if a permission is covered by user permissions
* Handles wildcards like 'users.*' covering 'users.read'
* Optimized version using early returns and cached regex
*/
export const isPermissionCovered = (permission, userPermissions) => {
// Early return for empty permissions
if (!userPermissions.length)
return false;
// Use optimized hasPermission function which already handles all cases efficiently
return hasPermission(userPermissions, permission);
};
/**
* Check if user can assign a specific role
* Based on permission subset check and protected flag
*/
export const canAssignRole = (userPermissions, targetRole) => {
// Protected roles need * permission
if (targetRole.protected && !userPermissions.includes('*')) {
return false;
}
// Check if all target permissions are covered by user permissions
const targetPermissions = targetRole.permissions || [];
return targetPermissions.every((perm) => isPermissionCovered(perm, userPermissions));
};
/**
* Helper function for access control checks
* Supports string ID, number ID, populated role object, null, and undefined
* Note: With afterRead hook, role should usually be populated already
*/
export const checkPermission = async (payload,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
userRole, permission, userId, options) => {
try {
// Special case: First user (ID 1) always has all permissions
// This handles the case where the first user is created without a role
if (userId && (userId === 1 || userId === '1')) {
return true;
}
// Public user (no userId and no role) - use public role permissions
if (!userId && !userRole) {
// Check if public role is disabled
if (options?.disablePublicRole) {
return false;
}
// Get public permissions (custom or default)
const publicPermissions = options?.publicRolePermissions || ['*.read'];
// Check permission against public permissions
return hasPermission(publicPermissions, permission);
}
// No role = no permissions (except for first user and public)
// Allow role ID 0 but not null/undefined
if (userRole === null || userRole === undefined) {
return false;
}
// Get role details based on type
let role = null;
if (typeof userRole === 'object') {
// Role is already populated (should be the normal case with authenticate hook)
role = userRole;
}
else if (typeof userRole === 'string' || typeof userRole === 'number') {
// Role is an ID - this is a fallback for cases where authenticate hook didn't run
// (e.g., during seeding, migrations, or direct API calls)
const roleId = String(userRole); // Convert number to string for findByID
try {
role = await payload.findByID({
collection: getRolesSlug(),
id: roleId,
depth: 0, // Don't need nested data
});
}
catch (err) {
// Role not found or error loading
console.warn(`Could not load role ${roleId}:`, err);
return false;
}
}
// Check if role exists and is active
if (!role || (typeof role === 'object' && 'active' in role && !role.active)) {
return false;
}
// Check permissions
const permissions = typeof role === 'object' && 'permissions' in role ? role.permissions : [];
return hasPermission(permissions, permission);
}
catch (error) {
console.error('Permission check error:', error);
return false;
}
};
//# sourceMappingURL=checkPermission.js.map