Implementing Role-Based Access Control (RBAC) in a CMS Frontend
Role-Based Access Control (RBAC) is a critical part of building secure, maintainable, and scalable content management systems (CMS). This article explores the strategies, pitfalls, and best practices for implementing RBAC, particularly in React and Next.js environments, with a focus on configuration, code structure, and practical application.
Why RBAC Matters in CMS
A CMS empowers experts to manage digital content without deep technical knowledge, saving organizations significant time and resources. However, with convenience comes the need for strict security and efficient workflows. RBAC ensures that only authorized users access sensitive data and critical site features, helping maintain integrity and compliance.
Roles
Roles are a critical part of ensuring the safety of the data stored in a CMS, creating efficient workflows, building independent teams. Each role has specific permissions that the user is allowed to perform and view in the CMS.
When it comes to roles, I recommend great flexibility, i.e. the ability to create and define user accounts and groups freely (roles like "contributor", "manager" etc. are not hard-coded, but put into a configuration file that can be changed per application). The role configuration is unaccessible to the user, but the engine itself should be free from hard-coded roles.
Key Approaches to Access Control
- Action-Based Access Control: Grants permissions based on specific actions (e.g., create, read, update, delete) that users can perform.
 - Role-Based Access Control: Grants permissions based on user roles (such as Contributor, Manager, or Admin), which group together sets of privileges.
 
While each method has merit, a combined approach often yields the most maintainable and secure system in modern CMS implementations.
Common Role Definitions
A typical CMS will include roles such as:
- Super Admin: Full access, including user and site management.
 - Admin: Manage content and users, but may not access system settings.
 - Manager: Schedule and supervise content but with limited system access.
 - Contributor: Create and edit content within set boundaries.
 - Author: Submit content, sometimes with limited publishing rights.
 
RBAC vs ACL flow
Roles Checker - per Page
Roles Checker - per Action
Access Level
Below are my approach options regarding access level definitions. We can combine the definitions of the 2 approaches. Roles will be defined on BE and Action Permissions will be defined on FE.
Examples below:
Action-based approach
Based on Common CMS roles and access levels:
Role-based Approach
Based on What names for standard website user roles?:
Problems and Challenges
Implementing RBAC can introduce issues if not planned carefully:
- Roles and permissions hard-coded in the app are hard to maintain.
 - Exposing role configuration to end-users creates security risks.
 - Ensuring consistent access checks, especially with server-side rendering (SSR), is non-trivial.
 - Permissions must adapt to evolving business needs without complete rewrites.
 
Designing Flexible Permissions Configs
A robust solution avoids hard-coded roles by storing them in configuration files (e.g., configs/roles/index.ts). This enables:
- Adjusting roles and permissions independently of the CMS core.
 - Abstracting role definitions away from UI components.
 - Easy scaling as new roles or features emerge.
 
Example configuration for roles and permissions:
// configs/roles/index.ts
export const ROLES = {
  SUPER_ADMIN: 'super.admin',
  ADMIN: 'admin',
  MANAGER: 'manager',
  CONTRIBUTOR: 'contributor',
};
// configs/policies/users.ts
export const permissions = {
  '/users': {
    roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MANAGER],
    actions: {
      create: [ROLES.ADMIN],
      delete: [ROLES.SUPER_ADMIN],
    },
  },
};
Implementation Strategies in React/Next.js
Using Higher-Order Components (HOC)
Higher-Order Components wrap pages or components to enforce authentication and permission checks, enabling server-side rendering (SSR) where required.
<root>/utils/lib/withPermission.tsx
import { useCallback } from 'react'
import NextApp from 'next/app'
import { useUserStore } from 'stores/user'
const withPermission = (App: NextApp | any) => {
  return (props) => {
    const { user } = useUserStore((state) => ({
      user: state.user,
    }))
    const hasAccess = useCallback(
      (name, permissions) => {
        if (permissions?.[name]?.['*']) {
          return permissions[name]['*'].some((role) =>
            user?.roles?.includes(role)
          )
        }
        return true
      },
      [user]
    )
    const { name, permissions } = App?.auth
    const allowed = permissions.length && hasAccess(name, permissions)
    return <>{allowed ? <App {...props} /> : <div>Permission Denied...</div>}</>
  }
}
export default withPermission
<root>/pages/manage-users/index.tsx
import React from 'react'
import Head from 'next/head'
import Layout from 'components/Layout'
import { permissions, roles } from 'configs/policies'
import withPermission from 'utils/lib/withPermission'
export const App = () => {
  return (
    <>
      <Head>
        <title>CMS - Manage Users</title>
      </Head>
      <Layout>Manage Users</Layout>
    </>
  )
}
App.auth = {
  name: 'MANAGE_USERS',
  permissions,
  roles,
}
export default withPermission(App)
Pros
- -
 
Cons
- Can only check per page, not per action
 - Need to define per page
 - Cannot check auth & session first before checking roles
 
Using Component Wrapper
<root>/components/AccessControl/index.tsx
import { usePermission } from 'utils/hooks/usePermission'
const AccessControl = ({ allowedPermissions, children, renderNoAccess }) => {
  const { checkPermissions } = usePermission()
  const permitted = checkPermissions(allowedPermissions)
  if (permitted) {
    return children
  }
  return renderNoAccess()
}
AccessControl.defaultProps = {
  allowedPermissions: [],
  renderNoAccess: () => null,
}
export default AccessControl
<root>/pages/manage-users/index.tsx
import React from 'react'
import Head from 'next/head'
import Layout from 'components/Layout'
import { permissions } from 'configs/policies'
import AccessControl from 'components/AccessControl'
export const App = ({ allowedPermissions }) => {
  return (
    <>
      <Head>
        <title>CMS - Manage Users</title>
      </Head>
      <Layout>
        <AccessControl
          allowedPermissions={
            allowedPermissions['*'] || allowedPermissions['read']
          }
          renderNoAccess={() => <div>No access</div>}
        >
          <div>
            <div id="title">Manage Users</div>
            <AccessControl
              allowedPermissions={allowedPermissions['create']}
              renderNoAccess={() => null}
            >
              <div id="write">Write</div>
            </AccessControl>
            <AccessControl
              allowedPermissions={allowedPermissions['delete']}
              renderNoAccess={() => null}
            >
              <div id="write">Delete</div>
            </AccessControl>
          </div>
        </AccessControl>
      </Layout>
    </>
  )
}
const PAGE_NAME = 'MANAGE_USERS'
App.auth = {
  name: PAGE_NAME,
  permissions,
}
App.getInitialProps = () => {
  return { allowedPermissions: permissions[PAGE_NAME] }
}
export default App
<root>/utils/hooks/usePermission.ts
import { useCallback } from 'react'
import { useUserStore } from 'stores/user'
export function usePermission() {
  const { user } = useUserStore((state) => ({
    user: state.user,
  }))
  const checkPermissions = useCallback(
    (permissions) => {
      if (permissions.length === 0) {
        return true
      }
      if (permissions) {
        return user?.roles?.some((permission) =>
          permissions?.includes(permission)
        )
      }
    },
    [user]
  )
  return {
    checkPermissions,
  }
}
Pros
- Can check per page & per action
 
Cons
- Need to define in each page
 
Using Hooks
Custom hooks like usePermission() allow components to check user permissions dynamically and render views accordingly.
<root>/utils/hooks/useAuth.tsx
import { usePermission } from 'utils/hooks/usePermission'
...
export function AuthGuard(props) {
  const router = useRouter()
  const { children, name, permissions } = props
  const { hasNextAuthSession, hasUser } = useAuthGuard()
  const { checkPermissions } = usePermission()
  const defaultPermissions = [
    ...new Set([
      ...(permissions[name]?.['*'] ?? []),
      ...(permissions[name]?.['read'] ?? []),
    ]),
  ]
  const permitted = checkPermissions(defaultPermissions)
  if (hasNextAuthSession && hasUser) {
    if (permitted) {
      return children
    }
    router.push('/permission-denied')
  }
  return (
    <>
      <LoadingState>Memuat...</LoadingState>
    </>
  )
}
Pros
- No need to define per page
 
Cons
- Can only check per page, not per action
 
Example: AccessControl Component
import { useRouter } from 'next/router'
import type { ReactElement } from 'react'
import React from 'react'
import { usePermission } from 'utils/hooks/usePermission'
export type AccessControlProps = {
  actions?: string[]
  allowedPermissions?: string[]
  children: ReactElement
  noAccessMessage?: string
  redirectUrl?: string
}
const AccessControl = ({
  actions,
  allowedPermissions,
  children,
  noAccessMessage,
  redirectUrl,
}: AccessControlProps) => {
  const { checkPermissions, currentPathPermissions } = usePermission()
  const [permitted, setPermitted] = React.useState(true)
  const router = useRouter()
  React.useEffect(() => {
    const isPermitted = checkPermissions(
      allowedPermissions.length
        ? allowedPermissions
        : currentPathPermissions(actions)
    )
    setPermitted(isPermitted)
    if (!isPermitted) {
      if (noAccessMessage) console.log({ message: noAccessMessage })
      if (redirectUrl) router.replace(redirectUrl)
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [])
  if (permitted) {
    return children
  }
  return null
}
AccessControl.defaultProps = {
  allowedPermissions: [],
}
export default AccessControl
Usage
<AccessControl allowedPermissions={['read']}>
  <Content />
</AccessControl>
<AccessControl allowedPermissions={['create']}>
  <CreateButton />
</AccessControl>
Guide: Adding RBAC to CMS Features
- 
Add Role Groups: Ensure relevant groups are defined in 
/configs/roles/index.ts. - 
Configure Permission Policies: Configure page or feature-level permissions in 
/configs/policies/index.tsor similar. - Use Access Control in Components: Integrate HOCs or hooks to check for permissions within your components.
 
References and Useful Links
- ReactJS - NextJS Authentication HOC Example
 - React Role Based Authorization Example
 - Attribute Based Access Control for React
 - Next.js Authentication HOC
 - React RBAC UI Manager
 
By following these practices, you ensure your CMS remains secure, flexible, and easy to maintain as requirements evolve.
              




    
Top comments (0)