Modern content management systems need robust access control that's both powerful and easy to understand. When building our Translation Management System with Payload CMS, we developed a straightforward pattern for Role-Based Access Control (RBAC) that has proven effective in production. This approach combines the flexibility of bitwise operations with a clean, declarative syntax that makes access rules immediately obvious to developers.
The foundation of our system is a consistent access control pattern that we apply across collections, globals or fields:
{
access: {
read: () => true, // Public access
create: isUser, // Any authenticated user
update: rbacHas([ROLE_TRANSLATOR, ROLE_EDITOR]), // Translators or editors
delete: rbacHas(ROLE_ADMIN), // Admins only
}
}
This pattern creates a clear hierarchy of permissions. Public operations like reading content are explicitly allowed with a simple true
return value. Basic operations that require authentication use the isUser
guard. More privileged operations use rbacHas
to check for specific roles, while destructive operations are typically restricted to administrators.
At the core of our implementation is a binary flag system for roles:
export const ROLE_ADMIN = 1;
export const ROLE_EDITOR = 2;
export const ROLE_TRANSLATOR = 4;
export type ROLE = typeof ROLE_ADMIN | typeof ROLE_EDITOR | typeof ROLE_TRANSLATOR;
Each role is assigned a power of 2, creating a binary pattern where each bit represents a distinct role. This approach allows us to efficiently store multiple roles as a single number in the database. For example, a user with both editor and translator roles would have a roles value of 6 (binary 110). This numerical representation makes role checking extremely efficient and allows for easy addition of new roles in the future.
Before checking specific roles, we need to verify that we're dealing with a valid user:
type User = {
id: string;
roles: number;
};
function isAuthenticated(user: User | null | undefined): user is User {
return !!user && typeof user.id === "string" && Number.isInteger(user.roles);
}
function isUser({ req }: { req: PayloadRequest }) {
return isAuthenticated(req.user);
}
The isAuthenticated
function serves as a type guard, ensuring we have a valid user with the expected properties. This TypeScript integration helps catch potential issues at compile time rather than runtime. The isUser
function adapts this check to Payload's access control format, making it easy to use in our access patterns.
The heart of our RBAC system lies in two key functions:
function hasRole(currentRoles: number, roleToCheck: number) {
if (roleToCheck === 0) return false;
return (currentRoles & roleToCheck) === roleToCheck ||
(currentRoles & ROLE_ADMIN) === ROLE_ADMIN;
}
export function rbacHas(roles: ROLE | ROLE[]) {
return ({ req }: { req: PayloadRequest }) =>
isAuthenticated(req.user) &&
[ROLE_ADMIN, ...(Array.isArray(roles) ? roles : [roles])].some((role) =>
hasRole(req.user.roles, role),
);
}
The hasRole
function uses bitwise operations to efficiently check if a user has a specific role. The expression (currentRoles & roleToCheck) === roleToCheck
performs a bitwise AND operation to see if all required role bits are present. Additionally, administrators are automatically granted all permissions through the second condition.
Building on top of hasRole
, the rbacHas
function creates a Payload-compatible access control function that can check for either a single role or an array of roles. It automatically includes admin access and handles arrays of roles using Array.some()
, allowing access if any of the specified roles match.
Let's examine how each access rule in our pattern works:
The read: () => true
rule makes content publicly accessible. This is appropriate for content that should be visible to all users, such as published translations or public documentation.
Authentication checks begin with create: isUser
, which ensures only logged-in users can create new content. This provides basic security while remaining permissive enough for general use.
For content modification, update: rbacHas([ROLE_TRANSLATOR, ROLE_EDITOR])
allows either translators or editors to make changes. This flexibility is perfect for collaborative workflows where different roles may need to modify content.
Destructive operations are protected by delete: rbacHas(ROLE_ADMIN)
, ensuring only administrators can remove content. This helps prevent accidental data loss while maintaining a clear chain of responsibility.
Version history access is controlled by readVersions: () => true
, making past versions viewable by anyone. This transparency can be valuable for tracking changes and maintaining accountability in the translation process.
This RBAC implementation has proven invaluable in our Translation Management System. It provides clear, consistent access control that's easy to understand and maintain. The use of bitwise operations keeps our database efficient, while the declarative syntax makes our access rules self-documenting. As your system grows, this pattern scales elegantly, allowing you to add new roles and permissions without restructuring your existing access control logic.
When working with security-critical code like Role-Based Access Control, having comprehensive tests and focusing on performance optimizations becomes crucial. In this article, we'll explore how we improved our RBAC system through test-driven refactoring.
Security features require exceptional attention to correctness. A single bug in access control can potentially expose sensitive data or operations to unauthorized users. Additionally, when refactoring security-critical code, having comprehensive tests provides confidence that we haven't inadvertently created vulnerabilities.
Here's is an excerpt of our test suite for the rbacHas
function:
describe("rbacHas", () => {
describe("authentication checks", () => {
it("should return false when user is not authenticated", () => {
const checkAccess = rbacHas(ROLE_EDITOR);
expect(checkAccess(createPayloadRequest())).toBe(false);
expect(checkAccess(createPayloadRequest(undefined))).toBe(false);
expect(checkAccess(createPayloadRequest(null as any))).toBe(false);
});
it("should return true when user has admin role", () => {
const checkEditor = rbacHas(ROLE_EDITOR);
const checkTranslator = rbacHas(ROLE_TRANSLATOR);
const adminRequest = createPayloadRequest({
id: "1",
roles: ROLE_ADMIN,
});
expect(checkEditor(adminRequest)).toBe(true);
expect(checkTranslator(adminRequest)).toBe(true);
});
});
describe("multiple role checks", () => {
it("should return true when user has any of the required roles", () => {
const checkAccess = rbacHas([ROLE_EDITOR, ROLE_TRANSLATOR]);
expect(
checkAccess(
createPayloadRequest({
id: "1",
roles: ROLE_EDITOR,
}),
),
).toBe(true);
expect(
checkAccess(
createPayloadRequest({
id: "1",
roles: ROLE_TRANSLATOR,
}),
),
).toBe(true);
});
});
});
These tests verify several critical aspects of our RBAC system:
Our original implementation used an array and loop to check multiple roles:
[ROLE_ADMIN, ...(Array.isArray(roles) ? roles : [roles])].some((role) =>
hasRole(req.user.roles, role)
)
While functional, this approach has linear time complexity O(n) where n is the number of roles being checked. Additionally, the array spread operation creates a new array for every check, which is inefficient.
We can leverage the power of bitwise operations to improve this. Instead of checking each role individually, we can combine all roles into a single bitmap and perform just one check. Here's the optimized implementation:
export function rbacHas(roles: ROLE | ROLE[]) {
// Convert input roles to a single combined bitmap
const combinedRoles = Array.isArray(roles)
? roles.reduce((acc, role) => acc | role, 0)
: roles;
return ({ req }: { req: PayloadRequest }) => {
// Must be authenticated
if (!isAuthenticated(req.user)) {
return false;
}
// Admin always has access
if ((req.user.roles & ROLE_ADMIN) === ROLE_ADMIN) {
return true;
}
// Check if user has any of the required roles
return (req.user.roles & combinedRoles) !== 0;
};
}
This refactored version offers several advantages:
Better Performance: The role combination happens once when creating the access function, not on every check. The actual role check is reduced to a single bitwise operation, which is one of the fastest operations available in computers.
Memory Efficiency: We no longer create temporary arrays during checks. Everything is handled with simple number operations.
Cleaner Code: The logic is more straightforward and easier to understand. We've separated the role combination from the actual checking logic.
Same Interface: Despite the internal changes, the function's interface remains identical, requiring no changes to existing code.
Our comprehensive test suite gave us confidence in this refactoring. We could verify that the new implementation maintains exactly the same behavior while achieving better performance characteristics. The time complexity for role checking is now O(1), constant time, regardless of how many roles we're checking for.
This refactoring demonstrates why having good tests is crucial when working with security features:
Tests provide immediate feedback about whether changes maintain correct behavior
They allow us to optimize with confidence
They serve as documentation for expected behavior
They catch edge cases that might be overlooked during refactoring
In security-critical code like RBAC, the cost of writing and maintaining tests is far outweighed by the benefits they provide in terms of reliability and maintainability. Our test suite continues to prove its value as we evolve and optimize the system further.
The combination of comprehensive testing and bitwise optimization has given us a RBAC system that is both reliable and performant, while remaining easy to understand and maintain.
After establishing our core RBAC system, we need to integrate it into Payload CMS's user interface. This involves creating a custom field type that handles role management through a user-friendly interface while maintaining our bitwise role system under the hood.
First, let's define our custom field type for Payload:
import { Field } from "payload/types";
import InputField from "./InputField";
import Cell from "./Cell";
import { isUser, ROLE_ADMIN } from "./roles";
import { rbacHas } from "./rbacHas";
const rbacField: Field = {
name: "roles",
label: "Roles",
type: "number",
defaultValue: 0,
saveToJWT: true,
validate: () => true,
admin: {
components: {
Field: InputField,
Cell,
},
},
access: {
create: rbacHas(ROLE_ADMIN),
read: isUser,
update: rbacHas(ROLE_ADMIN),
},
};
export default rbacField;
This field definition showcases several important features:
It uses a number
type to store our bitwise roles
Includes saveToJWT: true
to make roles available in authentication tokens
Customizes both the edit form (InputField
) and list view (Cell
) components
Implements access control using our rbacHas
function
To maintain consistency between our backend and UI, we define our roles in a shared constant:
export const ROLES = [
{ label: "Admin", value: ROLE_ADMIN },
{ label: "Editor", value: ROLE_EDITOR },
{ label: "Translator", value: ROLE_TRANSLATOR },
];
The heart of our UI implementation is the custom input component that transforms our bitwise roles into a user-friendly checkbox interface:
import React from "react";
import { useFieldType } from "payload/components/forms";
import { Label } from "payload/components/forms";
import { ROLES } from "./roles";
const RolesField = (props) => {
const {
path,
label,
required,
admin: { readOnly },
} = props;
const { value = 0, setValue } = useFieldType<number>({
path,
});
return (
<div className="field-type checkbox">
<Label htmlFor={path} label={label} required={required} />
<div>
{ROLES.map((role) => (
<div key={role.value}>
<input
type="checkbox"
disabled={readOnly}
id={`${path}-${role.value}`}
checked={(value & role.value) === role.value}
onChange={() => setValue(value ^ role.value)}
/>
<label htmlFor={`${path}-${role.value}`}>{role.label}</label>
</div>
))}
</div>
</div>
);
};
export default RolesField;
This component does several clever things:
Uses Payload's useFieldType
hook to manage the field value
Converts our bitwise number into individual checkboxes
Uses bitwise operations to check and update role values
Maintains the underlying number value while presenting a checkbox interface
The onChange
handler uses the XOR operator (^
) to toggle roles on and off, which elegantly handles both adding and removing roles with a single operation.
For the list view, we need a compact way to display assigned roles:
import React from "react";
import { Props } from "payload/components/views/Cell";
import "./styles.scss";
import { ROLES } from "./roles";
const Cell: React.FC<Props> = (props) => {
const { cellData } = props;
const roleLabels = ROLES.filter((role) =>
(Number(cellData) & role.value) === role.value
)
.map((role) => role.label)
.join(", ");
return <div className="chip">{roleLabels}</div>;
};
export default Cell;
This component transforms our bitwise roles back into human-readable labels for display in the admin UI.
Implementing the field in a User collection is straightforward:
const Users: CollectionConfig = {
slug: "users",
access: {
// ... other access controls
},
fields: [
// ... other fields
rbacField,
],
};
This implementation creates a seamless experience where:
Roles are stored efficiently as numbers in the database
Administrators get a user-friendly checkbox interface for role management
Role assignments are clearly displayed in list views
Access control is maintained throughout using our rbacHas
function
The JWT includes role information for authenticated requests
The beauty of this system is how it maintains the efficiency of bitwise operations while providing a intuitive user interface. Administrators don't need to understand the underlying bitwise system - they just see checkboxes for each role, while our code handles all the binary operations behind the scenes.
Our field implementation also benefits from the access control system we built earlier, demonstrating how these components work together to create a cohesive RBAC system. Only administrators can modify roles, but any authenticated user can view them, aligning with common security best practices.
Pas encore de commentaires, soyez le premier :
© 2024 par Moritz Thomas