Role Based Access Control Documentation
RBAC API Guide
Section titled “RBAC API Guide”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
The model
Section titled “The model”There are three layers:
-
Permission definitions
- live in
config/permissionRegistry.js - this file now exposes the nested
PERMISSIONSconstant tree and the flat registry metadata from the same source - these are the canonical keys, labels, and descriptions
- live in
-
Role and user assignments
- live in the database
- roles define the baseline permissions
- user overrides can allow or deny specific permissions
-
Runtime evaluation
- backend middleware resolves the final permission set
- frontend reads the resolved set from
/api/authz/context
Permission registry
Section titled “Permission registry”The backend permission registry is the source of truth for which permissions exist.
It lives in:
journey-payroll-crm-backend/config/permissionRegistry.jsWhat it contains
Section titled “What it contains”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.",}Constant access pattern
Section titled “Constant access pattern”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.
How it works
Section titled “How it works”- the backend syncs the registry into the
permissionstable 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
What devs should do
Section titled “What devs should do”When adding a new permission:
- add a new entry to
permissionRegistry.js - use the same key in backend route guards
- grant it to roles or users in the RBAC admin UI
- use the same key in frontend
usePermission()or<Can />
What devs should not do
Section titled “What devs should not do”- 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
Registry lifecycle
Section titled “Registry lifecycle”On backend startup:
- the registry is loaded
- the permission table is synced
- stale keys are removed
- RBAC cache is cleared
- the server starts serving requests
That means the registry is not just documentation. It directly controls what the system considers a valid permission.
Backend API usage
Section titled “Backend API usage”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],});Backend rules
Section titled “Backend rules”checkPermission(PERMISSIONS.DOMAIN.KEY)is the preferred form for permission keyscheckPermission("permission.key")still works, but constants are clearer and saferallowRole("role_name")is for role names onlysuper_adminstill 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
Frontend API usage
Section titled “Frontend API usage”The frontend gets its resolved authz state from:
GET /api/authz/contextUse the provider and hooks from src/context/AuthzProvider.tsx:
import { Can, useAuthz, usePermission } from "@/context/AuthzProvider";Common patterns
Section titled “Common patterns”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();Frontend rules
Section titled “Frontend rules”- 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
Recommended workflow for new RBAC work
Section titled “Recommended workflow for new RBAC work”- Add the permission key to
permissionRegistry.js - Add the route guard on the backend
- Grant the permission to the correct role or user override
- Use the same key in the frontend with
usePermission()or<Can /> - If the action is sensitive, keep the backend check even if the UI hides it
Naming conventions
Section titled “Naming conventions”- permission keys use lowercase dot notation
role.readtickets.assignpermission.update
- backend constants use nested uppercase domains
PERMISSIONS.RBAC.ROLE_READPERMISSIONS.TICKETS.READ_ALLPERMISSIONS.USERS.DELETE
- role names use the database canonical name
super_adminadminsales_admin
Quick examples
Section titled “Quick examples”Route guard
Section titled “Route guard”router.delete( "/:id", auth, checkPermission(PERMISSIONS.TICKETS.DELETE), controller.deleteTicket,);Any-of route guard
Section titled “Any-of route guard”router.get( "/dashboard", auth, checkPermission.allowAny( PERMISSIONS.TICKETS.READ_ALL, PERMISSIONS.RBAC.ROLE_READ, ), controller.getDashboard,);All-of route guard
Section titled “All-of route guard”router.post( "/reassign", auth, checkPermission.authorize({ all: [PERMISSIONS.TICKETS.READ_ALL, PERMISSIONS.TICKETS.ASSIGN], }), controller.reassignTicket,);Role-only route guard
Section titled “Role-only route guard”router.post( "/refresh-cache", auth, checkPermission.allowRole("super_admin"), controller.refreshCache,);UI gate
Section titled “UI gate”<Can permission="tickets.delete" fallback={null}> <DangerButton>Delete ticket</DangerButton></Can>UI gate with any-of permissions
Section titled “UI gate with any-of permissions”<Can anyOf={["tickets.read", "role.read"]} fallback={<EmptyState />}> <PermissionPanel /></Can>UI gate with all-of permissions
Section titled “UI gate with all-of permissions”<Can allOf={["tickets.read", "tickets.assign"]}> <AssignTicketButton /></Can>Conditionally render a section
Section titled “Conditionally render a section”const canViewRoles = usePermission("role.read");
return <>{canViewRoles ? <RoleAdminTable /> : <NoAccessBanner />}</>;Read-only page state
Section titled “Read-only page state”const canAssign = usePermission("role.assign_permission");
return canAssign ? <EditForm /> : <ReadOnlyView />;Hide a navigation item
Section titled “Hide a navigation item”const canSeeAdmin = usePermissions( ["permission.read", "role.read", "role.view"], "all",);
return canSeeAdmin ? <NavItem label="RBAC" /> : null;Show context in the UI
Section titled “Show context in the UI”const { context } = useAuthz();
return ( <p> Signed in as {context?.roleName} with {context?.permissions.length ?? 0}{" "} permissions. </p>);Read-only action toolbar
Section titled “Read-only action toolbar”const canEditTickets = usePermission("tickets.update");
return ( <Toolbar> <button disabled={!canEditTickets}>Save</button> {!canEditTickets && <span>Read only</span>} </Toolbar>);Backend and frontend pair
Section titled “Backend and frontend pair”// backendrouter.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.