DEV Community

Cover image for Mastering Role-Based Access Control in Your Javascript CMS
Deny Herianto
Deny Herianto

Posted on

Mastering Role-Based Access Control in Your Javascript CMS

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',
};
Enter fullscreen mode Exit fullscreen mode
// configs/policies/users.ts
export const permissions = {
  '/users': {
    roles: [ROLES.SUPER_ADMIN, ROLES.ADMIN, ROLES.MANAGER],
    actions: {
      create: [ROLES.ADMIN],
      delete: [ROLES.SUPER_ADMIN],
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

<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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

<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
Enter fullscreen mode Exit fullscreen mode

<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,
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Usage

<AccessControl allowedPermissions={['read']}>
  <Content />
</AccessControl>
<AccessControl allowedPermissions={['create']}>
  <CreateButton />
</AccessControl>
Enter fullscreen mode Exit fullscreen mode

Guide: Adding RBAC to CMS Features

  1. Add Role Groups: Ensure relevant groups are defined in /configs/roles/index.ts.
  2. Configure Permission Policies: Configure page or feature-level permissions in /configs/policies/index.ts or similar.
  3. Use Access Control in Components: Integrate HOCs or hooks to check for permissions within your components.

References and Useful Links


By following these practices, you ensure your CMS remains secure, flexible, and easy to maintain as requirements evolve.

Top comments (0)