Skip to content

Role Based Access Control Documentation

This project uses a code-owned permission registry on the backend and a small authz context on the frontend.

The goal is simple:

  • backend decides access
  • frontend reads access and hides or disables UI
  • developers use the same permission keys everywhere

There are three layers:

  1. Permission definitions

    • live in config/permissionRegistry.js
    • this file now exposes the nested PERMISSIONS constant tree and the flat registry metadata from the same source
    • these are the canonical keys, labels, and descriptions
  2. Role and user assignments

    • live in the database
    • roles define the baseline permissions
    • user overrides can allow or deny specific permissions
  3. Runtime evaluation

    • backend middleware resolves the final permission set
    • frontend reads the resolved set from /api/authz/context

The backend permission registry is the source of truth for which permissions exist.

It lives in:

journey-payroll-crm-backend/config/permissionRegistry.js

Each permission definition has:

  • key
    • the stable permission id used in code and in the database
    • example: tickets.update
  • label
    • human-friendly name for the admin UI
    • example: Update Tickets
  • group
    • used to organize permissions in the UI
    • example: Tickets
  • description
    • short explanation of what the permission does

Example shape:

{
key: "tickets.update",
label: "Update Tickets",
group: "Tickets",
description: "Edit ticket fields, status, and workflow state.",
}

Use the exported PERMISSIONS tree in backend code instead of raw strings:

const { PERMISSIONS } = require("../config/permissionRegistry");
checkPermission(PERMISSIONS.RBAC.ROLE_READ);
checkPermission(PERMISSIONS.TICKETS.READ_ALL);
checkPermission(PERMISSIONS.USERS.DELETE);

The string keys still exist under the hood. The constants are just the developer-facing API.

  • the backend syncs the registry into the permissions table on startup
  • missing keys are inserted automatically
  • keys removed from the registry are pruned from the database
  • role and user assignments for removed keys are also cleaned up
  • middleware rejects unknown keys so bad permissions fail fast

When adding a new permission:

  1. add a new entry to permissionRegistry.js
  2. use the same key in backend route guards
  3. grant it to roles or users in the RBAC admin UI
  4. use the same key in frontend usePermission() or <Can />
  • do not invent permission strings in route guards without adding them to the registry
  • do not use role names as permission keys
  • do not treat the registry as a place for module policy that is not true RBAC

On backend startup:

  1. the registry is loaded
  2. the permission table is synced
  3. stale keys are removed
  4. RBAC cache is cleared
  5. the server starts serving requests

That means the registry is not just documentation. It directly controls what the system considers a valid permission.

Use these helpers in route guards:

const checkPermission = require("../middleware/rbac.middleware");
const { PERMISSIONS } = require("../config/permissionRegistry");
router.get(
"/roles",
auth,
checkPermission(PERMISSIONS.RBAC.ROLE_READ),
controller.getRoles,
);
router.post(
"/roles",
auth,
checkPermission(PERMISSIONS.RBAC.ROLE_CREATE),
controller.createRole,
);

Use role checks only when the rule is truly about a role name:

router.post(
"/tenants",
auth,
checkPermission.allowRole("super_admin"),
controller.createTenant,
);

Use authorize() when you need a custom rule:

checkPermission.authorize({
any: [PERMISSIONS.TICKETS.READ_ALL, PERMISSIONS.TICKETS.ASSIGN],
});
  • checkPermission(PERMISSIONS.DOMAIN.KEY) is the preferred form for permission keys
  • checkPermission("permission.key") still works, but constants are clearer and safer
  • allowRole("role_name") is for role names only
  • super_admin still bypasses permission checks unless a route explicitly opts out
  • unknown permission keys fail fast at startup or route registration
  • the registry is what makes a permission key valid in the first place

The frontend gets its resolved authz state from:

GET /api/authz/context

Use the provider and hooks from src/context/AuthzProvider.tsx:

import { Can, useAuthz, usePermission } from "@/context/AuthzProvider";

Show or hide a button:

<Can permission="role.assign_permission">
<button>Save changes</button>
</Can>

Check a permission in code:

const canEdit = usePermission("tickets.update");

Read the full context:

const { context, permissions, refresh } = useAuthz();
  • use usePermission() or <Can /> for permission-key checks
  • use useAuthz() when you need the full resolved context
  • keep backend checks in place for writes and protected reads
  • frontend gating is for UX, not security
  1. Add the permission key to permissionRegistry.js
  2. Add the route guard on the backend
  3. Grant the permission to the correct role or user override
  4. Use the same key in the frontend with usePermission() or <Can />
  5. If the action is sensitive, keep the backend check even if the UI hides it
  • permission keys use lowercase dot notation
    • role.read
    • tickets.assign
    • permission.update
  • backend constants use nested uppercase domains
    • PERMISSIONS.RBAC.ROLE_READ
    • PERMISSIONS.TICKETS.READ_ALL
    • PERMISSIONS.USERS.DELETE
  • role names use the database canonical name
    • super_admin
    • admin
    • sales_admin
router.delete(
"/:id",
auth,
checkPermission(PERMISSIONS.TICKETS.DELETE),
controller.deleteTicket,
);
router.get(
"/dashboard",
auth,
checkPermission.allowAny(
PERMISSIONS.TICKETS.READ_ALL,
PERMISSIONS.RBAC.ROLE_READ,
),
controller.getDashboard,
);
router.post(
"/reassign",
auth,
checkPermission.authorize({
all: [PERMISSIONS.TICKETS.READ_ALL, PERMISSIONS.TICKETS.ASSIGN],
}),
controller.reassignTicket,
);
router.post(
"/refresh-cache",
auth,
checkPermission.allowRole("super_admin"),
controller.refreshCache,
);
<Can permission="tickets.delete" fallback={null}>
<DangerButton>Delete ticket</DangerButton>
</Can>
<Can anyOf={["tickets.read", "role.read"]} fallback={<EmptyState />}>
<PermissionPanel />
</Can>
<Can allOf={["tickets.read", "tickets.assign"]}>
<AssignTicketButton />
</Can>
const canViewRoles = usePermission("role.read");
return <>{canViewRoles ? <RoleAdminTable /> : <NoAccessBanner />}</>;
const canAssign = usePermission("role.assign_permission");
return canAssign ? <EditForm /> : <ReadOnlyView />;
const canSeeAdmin = usePermissions(
["permission.read", "role.read", "role.view"],
"all",
);
return canSeeAdmin ? <NavItem label="RBAC" /> : null;
const { context } = useAuthz();
return (
<p>
Signed in as {context?.roleName} with {context?.permissions.length ?? 0}{" "}
permissions.
</p>
);
const canEditTickets = usePermission("tickets.update");
return (
<Toolbar>
<button disabled={!canEditTickets}>Save</button>
{!canEditTickets && <span>Read only</span>}
</Toolbar>
);
// backend
router.patch(
"/tickets/:id",
auth,
checkPermission("tickets.update"),
controller.updateTicket,
);
// frontend
<Can permission="tickets.update">
<Button>Edit ticket</Button>
</Can>
  • If a permission key is removed from the registry, it will be pruned from the database on startup.
  • If a user loses a role permission or override, the RBAC admin UI and frontend authz context should be refreshed.
  • For module-specific policy that is not true RBAC, keep it separate from permission keys so the permission model stays simple.