Role-Based Access Control in Next.js
This documentation covers the implementation of role-based access control (RBAC) in a Next.js application using a Role Management System. It includes examples for both server operations and ordinary functions.
This is a separate module that doesn't care and doesn't know anything about application logic. It should be placed at a root level inside a folder/dir called RoleManagementSystem/RBAC or something similar. No application logic should be placed inside that folder
Overview
- RoleManagementSystem Class: Centralized role and permission management.
- withPermission Higher-Order Function: Simplifies permission checks for any SERVER-side function that require permission checks.
- useHasAccess Custom Hook: Client-side permission checks.
- Middleware for Route Protection: Secures routes with role-based access control.
Configuration
Roles Configuration
Define the roles and permissions configuration in a separate file at an application level.
// rolesConfig.ts
export const getRoleManagementSystemConfig = () => ({
roles: {
guest: {
routes: ["/"],
operations: ["read_profile"],
},
user: {
routes: ["/", "/dashboard*"],
operations: ["read_profile", "update_profile"],
},
admin: {
routes: ["*"],
operations: ["create_profile", "delete_profile", "read_profile", "update_profile"],
},
},
});Types
Define types to represent the structure of the config with rules and permision. This is defined at module level generally shouldn't be changed except if there are some specific use cases in the project that differentiate from other implementations Config that's passed to a constructor MUST follow RolesConfig interface
// types.d.ts
export interface RolePermissions {
routes: string[];
operations: string[];
}
export interface Roles {
[role: string]: RolePermissions;
}
export interface RolesConfig {
roles: Roles;
}Define types to represent the structure of the response and operations. This is defined at application level to allow any response structure you want
// Define a type for the error object
export type ErrorObject = {
message: string;
[key: string]: any;
};
// A generic type to represent the response of any operation
export type OperationResponse<T> = {
success: boolean;
data?: T | null;
error?: ErrorObject | null
};
// A generic type to represent any operation that returns a Promise of OperationResponse
export type Operation<T> = (...args: any[]) => Promise<OperationResponse<T>>;RoleManagementSystem Class
The RoleManagementSystem class is responsible for managing the roles and permissions of users. It provides methods to check if a user has access to certain operations or routes based on their role.
// RoleManagementSystem.ts
import { Roles, RolesConfig } from "./types";
class RoleManagementSystem {
private roles: Roles;
private userRole: string = "guest";
constructor(config: RolesConfig, role: string) {
this.roles = config.roles;
this.userRole = role;
}
public setUserRole(role: string): void {
this.userRole = role;
}
public hasOperationAccess(permission: string): boolean {
const rolePermissions = this.roles[this.userRole] || {};
const operations = rolePermissions.operations || [];
return operations.some((op: string) => op === permission);
}
private matchRoute(configRoute: string, routeToCheck: string): boolean {
const regex = new RegExp(`^${configRoute.replace(/\*/g, ".*")}$`);
return regex.test(routeToCheck);
}
public hasRouteAccess(url: string): boolean {
const rolePermissions = this.roles[this.userRole] || {};
const routes = rolePermissions.routes || [];
return routes.some((route: string) => this.matchRoute(route, url));
}
}
export default RoleManagementSystem;withPermission Higher-Order Function
The withPermission function wraps any server-side operation to enforce permission checks.
The withPermission higher-order function simplifies permission checks for server-side operations. It ensures that the specified permission is verified before allowing the operation to proceed.
import { getRoleManagementSystemConfig } from "@/app/lib/getRoleManagementSystemConfig";
import RoleManagementSystem from "../RoleManagementSystem";
import { auth } from "@/auth";
import { ServerOperation } from "@/app/lib/types";
export const withPermission = <T>(permission: string, action: ServerOperation<T>) => {
return async (...args: any[]) => {
const session = await auth()
const role = session?.user?.role || "guest";
const roleManagementSystem = new RoleManagementSystem(getRoleManagementSystemConfig(), role);
const hasAccess = roleManagementSystem.hasOperationAccess(permission);
if (!hasAccess) {
return {
success: false,
error: {
message: "You do not have permission to perform this operation.",
}
}
}
return action(...args);
}
}How withPermission Works
Wraps Server-Side Operations
- The function takes a permission string and an operation (function) as arguments.
- It returns a new function that wraps the original operation.
Session Handling
- Inside the returned function, it retrieves the user session using
auth(). - The user's role is extracted from the session.
Role Management Integration
- A new instance of
RoleManagementSystemis created with the current role. - The function checks if the user has the required permission using
hasOperationAccess.
Permission Check
- If the user lacks the required permission, the function returns an error response.
- If the user has the required permission, the original operation is executed with the provided arguments.
Usage Example
import { OperationResponse } from "./path/to/types";
import { withPermission } from "@/RoleManagementSystem/utils/withPermission
export const updateAuthenticatedUser = withPermission(
"update_user",
_updateAuthenticatedUser
);
async function _updateAuthenticatedUser(
prevState: any,
formData: FormData
): Promise<OperationResponse<{ message: string }>> {
try {
// some logic here, e.g. validation, async calls, etc...
return { success: true, data: { message: "User updated successfully" } };
} catch (error) {
console.error("Failed to update user:", error);
return { success: false, error: { message: "Failed to update user" } };
}
}useHasAccess custom hook
The useHasAccess hook checks if the current user has access to a specific operation on the client side. The useHasAccess custom hook simplifies client-side permission checks. It determines if the current user has the required permission to perform a specific operation.
// useHasAccess.ts
import { useSession } from "next-auth/react";
import RoleManagementSystem from "../RoleManagementSystem";
import { getRoleManagementSystemConfig } from "@/app/lib/getRoleManagementSystemConfig";
const roleManagementSystem = new RoleManagementSystem(getRoleManagementSystemConfig(), "guest");
export const useHasAccess = (permission: string): boolean => {
const { data: session, status } = useSession();
const role = session?.user?.role || "guest";
roleManagementSystem.setUserRole(role);
return roleManagementSystem.hasOperationAccess(permission);
};How useHasAccess Works
Session Integration:
- Uses the useSession hook from next-auth to retrieve the current user session.
- Determines the user's role from the session data.
Role Management Integration
- Sets the user's role in the RoleManagementSystem.
- Checks if the user has the required permission using hasOperationAccess.
Client-Side Permission Check
- The hook returns a boolean indicating whether the user has access to the specified permission.
Usage Example
import {Â useHasAccess } from "@/RoleManagementSystem/hooks/useHasAccess
export const UserActions = () => {
const hasAccess = useHasAccess("DELETE_USER")
return (
<Button>Edit</Button>
{hasAccess && <Button>Delete</Button>}
)
}Middleware for Route Protection
The middleware secures routes with role-based access control. The middleware function protects routes by enforcing role-based access control. It ensures that only users with the appropriate permissions can access certain routes.
// middleware.ts
import RoleManagementSystem from "@/RoleManagementSystem/RoleManagementSystem";
import { NextResponse } from "next/server";
import NextAuth from "next-auth";
import { authConfig } from "./auth.config";
import { getRoleManagementSystemConfig } from "./app/lib/getRoleManagementSystemConfig";
import { NextAuthRequest } from "next-auth/lib";
const { auth } = NextAuth(authConfig);
export default auth((req: NextAuthRequest) => {
const session = req.auth;
const userRole = session?.user?.role || "guest";
const rolesConfig = getRoleManagementSystemConfig();
const roleManagementSystem = new RoleManagementSystem(rolesConfig, userRole);
const hasAccess = roleManagementSystem.hasRouteAccess(req.nextUrl.pathname);
if (userRole === "guest" && !hasAccess) {
return NextResponse.redirect(new URL("/login", req.nextUrl));
}
if (!hasAccess) {
return NextResponse.redirect(new URL("/unauthorized", req.nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: [ "/((?!api|_next/static|_next/image|.*\\.ico$|.*\\.png$).*)" ],
};How the Middleware Works Authentication
- The middleware retrieves the user session using auth().
- The user's role is extracted from the session.
Role Management Integration
A new instance of RoleManagementSystem is created with the current role. The middleware checks if the user has the required permission using hasRouteAccess.
Permission Check
- If the user is a guest and does not have access, they are redirected to the login page.
- If the user does not have the required permission, they are redirected to the unauthorized page.
- Specific redirection logic for certain roles and routes can be added as needed.
Matcher
Note: Middleware can also handle api route calls. To be added