Beyond Roles: How BrightHR Designed a Flexible Permission System for HR

MB

April 2, 2026

Published by Maysa Bashraheel

Bright HR SVG

Most developers, when asked to "add some permissions," reach for the simplest tool available: roles. Give someone a role, and that role comes with a fixed set of things they can see and do. Simple. Done.

But real-world software rarely stays simple. HR platforms face a genuinely tricky problem: people in the same role don't always need the same access. A 'Manager' might oversee a team of five, or they might run payroll for five hundred. An 'Admin' might handle recruitment but should never touch salary data. The moment you recognise that, you understand why BrightHR's permission system goes beyond a role lookup. It separates who you are from what you can do.

This post covers how that system works and the engineering challenges that shaped it.

The Problem: Roles Are a Starting Point, Not a Solution

Imagine you're building an HR platform. You have three types of users: z

  • Admin — full access to manage the company
  • Manager — access to their team
  • Employee — access to their own data

At first, that covers most scenarios. But customers will soon start asking questions like:

"Can I give this manager access to payroll, but only for their direct reports?"

"Can I let this employee view everyone's contract info, but not edit it?"

"Can our HR officer see sensitive medical information, even though they're just an Employee?"

These are core HR workflows, not the kind of thing you can patch around. The system needs to answer them cleanly.

BrightHR's answer is a two-layer model:

  • A base role (Admin, Manager, Employee) that sets the baseline of what a person can do
  • Permission enhancements layered on top of that base, granting or adjusting individual feature access on a per-employee basis

Think of the base role as the floor. Permission enhancements are everything you build on top of it.

Permission roles diagram

Breaking Down the Architecture

Feature Keys: A Contract with the Backend

Every permission in the system is identified by a feature key — a string constant that the frontend and backend agree on as a strict contract. Change one without coordination, and things break silently.

export const permissionFeatureKeys = {
    contractAndAnnualLeave: 'ContractAndAnnualLeave',
    payrollAccess: 'Payroll',
    salaryInformation: 'SalaryInformation',
    bankDetails: 'BankDetails',
    medicalInformation: 'MedicalInformation',
    // ... and many more
};

Permission Assignments: What You Have Access To

When a user's permissions are customised, the system stores a list of permission assignments. Each assignment ties a feature key to an access option and, optionally, a list of specific target employees.

type PermissionAssignment = {
    feature: string;     // e.g. 'SalaryInformation'
    option?: string;     // e.g. 'ViewEveryone', 'EditSubordinates'
    targetIds: string[]; // specific employee GUIDs, if 'Edit selected' is chosen
};

Rather than a simple on/off switch, a permission can be set to one of several scope options:

Permission table diagram

What makes this model useful is that it answers a harder question than "can you access this?" It answers "who can you access it for?”

Permission diagram 1

Permission Definitions: Declarative by Design

Rather than scattering permission logic across components, BrightHR defines permissions in dedicated definition files. Each definition is a data object describing the permission's display name, description, and a 'getConfig' function.

// Example: contract and annual leave permission definition
const contractAndAnnualLeaveDefinition = createDefinition({
    feature: permissionFeatureKeys.contractAndAnnualLeave,
    displayName: (location) => `Contract & ${getLeaveTerm(location)}`,
    description: (location) => <>View and edit contract and {getLeaveTerm(location, true)} information.</>,
    getConfig: (userType) => {
        if (userType === UserType.Manager) {
            return {
                canToggle: true,
                canChangeOptions: true,
                options: [createDirectReportsOption(true), createViewEveryoneOption(), createEditEveryoneOption()]
            };
        }
        return { canToggle: false, canChangeOptions: false };
    }
});

The 'getConfig' function is where the base role comes back into play. A 'Manager' might see a toggle and a list of scope options, while an 'Employee' might have no control over that permission at all. The UI renders based on this config. If you want to know what the UI will allow for a given user type, this is the only place you need to look.

State Management: A Permission Reducer

Editing permissions involves multiple toggles, multiple scope dropdowns, and changes that can affect other changes. The system manages this using a reducer pattern— a step-by-step, predictable approach to updating state.

If you haven't worked with reducers before: rather than updating state directly from many places in the UI, all changes go through a single function. That function receives the current state plus a description of what changed, and returns a new state. This makes it much easier to reason about what the application is doing at any point.

export function permissionActionReducer(
    state: PermissionAssignment[] | null,
    action: PermissionStateAction
) {
    switch (action.action) {
        case 'set':    return action.payload;
        case 'update': // find and update the matching feature assignment
        case 'remove': // filter out the matching feature assignment
        default:       return state;
    }
}

Three operations handle everything: Set, Update and Remove

Permission diagram 2

Reactive Permissions: When One Change Triggers Another

Some permissions are logically linked. Take payroll access: when a manager is granted payroll access, certain other permissions (such as salary information) automatically lock to their direct reports. Granting payroll changes the behaviour of salary permissions — one toggle affects another.

The system handles this through an onAssignmentsChanged callback defined on each permission definition. Whenever any permission changes, every definition's handler fires. Definitions that care about a specific other feature can react and dispatch their own state updates through the reducer:

onAssignmentsChanged: ({ type, feature, dispatch, ownerState }) => {
    if (feature === permissionFeatureKeys.payrollAccess && type === 'update') {
        // Cascade the effect: lock salary information to direct reports
        dispatch({ action: 'update', payload: { ... } });
    }
}

Permitting one toggle to affect another without component state bleeding everywhere is surprisingly hard to get right. This approach does it. The sequence diagram below illustrates how a single user action can trigger a chain of updates:

Permission diagram 3

The Challenges

1. Protecting Employees from Losing Access to Their Own Data

One subtle but critical rule: an employee must always be able to see and edit their own data. If a manager is configuring another employee's permissions, they should not be able to accidentally remove that employee's access to their own contact details or emergency information.

This is enforced through two lists of "non-deselectable" permissions:

export const assigneeNotDeselectableViewPermissions = [
    permissionFeatureKeys.contractAndAnnualLeave,
    permissionFeatureKeys.salaryInformation,
    permissionFeatureKeys.contactInformation,
];

export const assigneeNotDeselectableEditPermissions = [
    permissionFeatureKeys.contactInformation,
    permissionFeatureKeys.personalInformation,
    permissionFeatureKeys.emergencies,
];

When the UI renders scope options for a permission, it checks whether the employee being configured is also the target. If so, those options are locked. Because this logic lives in the definition layer rather than inside individual components, there is no way to accidentally leave it out.

2. Full Audit History

For an HR system, knowing who changed what and when is not optional — it is often a legal requirement. The service layer supports fetching a full permission history for any employee:

type PermissionHistory = {
    lastModified: string;
    changeMadeByGuid: string;
    enabledPermissions: Permission[];
    disabledPermissions: Permission[];
    modifiedPermissions: Permission[];
    action: 'Reset' | 'Patch' | 'RoleChange';
};

Every change is categorised as a 'Reset' (back to role defaults), a 'Patch' (an individual adjustment), or a 'RoleChange' (the base role changed). This makes it possible to reconstruct exactly what any employee was permitted to see at any point in time. Building it from the start, rather than retrofitting it, is what lets HR teams actually answer compliance questions when they come up.

3. Unsaved Change Guards

Any UI where users configure multiple connected settings carries the risk of accidental data loss. Navigate away mid-edit, hit the back button — the work is gone. The system guards against this with a component that intercepts navigation and asks for confirmation when there are pending unsaved changes:

<Prompt
    when={areChangesPending}
    message="You have unsaved changes, are you sure you want to leave?"
/>

It is a small detail. But for an HR admin who has just spent ten minutes carefully configuring permissions for fifteen people, it matters enormously.

Conclusion

The BrightHR permission system is what you get when you take domain complexity seriously at the design level, instead of deferring it. Roles being insufficient is not a bug in the design. It is just how organisations work. The interesting engineering happened when the team stopped treating a permission as on or off, and started treating it as something with a scope, a set of options, a locale context, and a history.

The patterns here — declarative definitions, a centralised reducer, a clean separation between what a role gives you by default and what can be layered on top — are transferable to any system where simple role-based access starts to crack under the pressure of real user needs.

If there is one thing worth carrying away from all of this: when your users start asking "can I make it so this specific person can only see this specific thing for these specific employees?" — that is not a feature request to push back on. That is the product growing , so design for it early.

Registered Office: Bright HR Limited, The Peninsula, Victoria Place, Manchester, M4 4FB. Registered in England and Wales No: 9283467. Tel: 0844 892 3928. I Copyright © 2024 BrightHR