Skip to main content

03 — Who can see what: the permission system

Read this first: this chapter follows one permission string — hr.employees.create — from the login response all the way to a button that quietly does not render. Along the way you discover that MotorPH's backend has no idea what a "role view" is: roles, as the sidebar shows them, are a frontend invention, derived from a flat list of strings. By the end you can trace any permission through all three gates it passes on the way to a pixel.

Time: about 30 minutes. Assumes lesson 02.

One flat array, three derived views​

When you log in, the backend returns a user object whose permissions field is a flat array of strings — 'hr.employees.view', 'payroll.manage', over a hundred of them in the full catalog. That array is the only authorization fact the frontend ever receives. The header comment in constants/permissions.ts states the division of labor:

/**
* TS mirror of backend PermissionConstants. Used to drive RBAC nav filtering and
* the <RequirePermission> route/element guard.
*/

Everything else — roles, the role switcher, "Full Access" — is derived in useAuth.ts, a hook short enough to read whole. First derivation: which roles can this user present as?

const availableRoles = useMemo<string[]>(() => {
if (!user) return [];
const userPermsSet = new Set(user.permissions);
return ROLE_DISPLAY_ORDER.filter(role =>
ROLE_PERMISSIONS[role]?.every(p => userPermsSet.has(p)) ?? false
);
}, [user]);

A role is available only if the user holds every permission in that role's set — the .every is a subset test. The role definitions live in role-permissions.ts as plain data, seven names mapped to permission lists:

export const ROLE_PERMISSIONS: Readonly<Record<string, readonly Permission[]>> = {
'Employee': [
PermissionConstants.EMPLOYEE_PROFILE_VIEW,
PermissionConstants.EMPLOYEE_LEAVE_REQUEST,
/* … */
],
'HR Administrator': [
/* … the twelve Employee permissions, then: */
PermissionConstants.HR_DASHBOARD_VIEW,
PermissionConstants.HR_EMPLOYEES_MANAGE,
PermissionConstants.HR_EMPLOYEES_VIEW,
PermissionConstants.HR_EMPLOYEES_CREATE,
/* … */
],
/* … Payroll Administrator, Account Manager, Sales Representative,
Warehouse Manager, System Administrator */
};

Second derivation: picking an activeRole narrows what you can do, it never widens it:

const effectivePermissions = useMemo<string[]>(() => {
if (!user) return [];
if (!activeRole) return user.permissions;
const rolePermsSet = new Set<string>(ROLE_PERMISSIONS[activeRole] ?? []);
return user.permissions.filter(p => rolePermsSet.has(p));
}, [user, activeRole]);

const hasPermission = (permission: string): boolean =>
effectivePermissions.includes(permission);

activeRole === null means Full Access: the raw login-response array, unfiltered. Any non-null role intersects your permissions with that role's set. The backend never learns which role you picked — every API call still carries the full-authority token, so activeRole is purely a lens, and the server re-checks real permissions on every request regardless. Why the frontend gets to invent this lens at all is ADR 0011.

The lens even survives a reload safely. redux/auth.ts re-validates the persisted role against today's permissions before trusting it, and says why:

// A persisted role view the user no longer qualifies for (same subset rule as
// useAuth.availableRoles) must not survive the reload.
const userPerms = new Set(parsed.user?.permissions ?? []);

Revoke someone's payroll permissions overnight and their saved "Payroll Administrator" view degrades to null at next load instead of rendering a UI full of requests that would all 403.

Predict: a user holds both the HR Administrator and Payroll Administrator roles and switches activeRole to Payroll Administrator. Does the "Employees" item — gated on hr.employees.view — still show in the sidebar? Write your answer down before reading on.

Resolved: no. hr.employees.view is in the user's permissions array but not in ROLE_PERMISSIONS['Payroll Administrator'], so the .filter drops it from effectivePermissions, hasPermission('hr.employees.view') returns false, and every gate you are about to meet closes. Role switching is not cosmetic — it genuinely hides pages and buttons.

Where the array comes from: how a user gains RBAC​

Before the gates, it is worth knowing how anyone comes to hold hr.employees.create at all, because the answer corrects a natural misreading: the permissions are not in the JWT. The token carries only role names —

return JWT.create()
.withSubject(user.getUsername())
.withIssuer(jwtProperties.getIssuer())
.withClaim("userId", user.getUserId())
.withClaim("employeeId", user.getEmployeeId())
.withClaim("roles", new ArrayList<>(user.getRoles()))
/* … issued/expiry … */
.sign(getAlgorithm());

— and the flat permissions[] array the frontend gates on is shipped separately, in the login response body, then re-resolved from the database on every backend request.

The chain in the database is three tables deep. A User has one primary role (role_id) plus any number of additional roles (user_role join table); a Role maps to permissions (role_permission join table) and may point at a parentRole it inherits from. At login — and again on each request — UserDetailsServiceImpl flattens the whole chain:

Set<Role> roles = new HashSet<>();
roles.add(user.getRole());
roles.addAll(user.getAdditionalRoles());
private void collectPermissions(Role role, Set<String> accumulator) {
if (role == null) return;
role.getPermissions().forEach(p -> accumulator.add(p.getPermissionName()));
collectPermissions(role.getParentRole(), accumulator);
}

The recursion is the parent-role inheritance, and the accumulated strings become Spring authorities verbatim — no ROLE_ prefix — which is why every controller reads @PreAuthorize("hasAuthority('hr.employees.view')") with the same strings your constants/permissions.ts mirrors.

Who fills those tables? Two answers, at two different times:

  • Migrations fill the matrix. The role→permission grants are seeded by category, not pair by pair — from the core RBAC migration:

    INSERT INTO role_permission (role_id, permission_id)
    SELECT (SELECT role_id FROM role WHERE role_name = 'HR Administrator'), permission_id
    FROM permission WHERE category IN ('Employee', 'HR');

    Every later feature migration appends its permissions and grants in the same style, which is why a new permission category can never leak into an existing role by accident. The only role anyone is ever auto-assigned is System Administrator, minted for a tenant's first user at provisioning; every other user is created by an admin who picks roles explicitly.

  • Admins edit the rest at runtime. The Users page's UserFormDrawer is the assignment UI — a required Primary Role select plus an Additional Roles checklist — saved through PUT /api/users/{id}. The Roles page's RolePermissionsDrawer edits the matrix itself: a checkbox tree grouped by category, saved as a full-replace PUT /api/roles/{id}/permissions (an empty set wipes the role, so the drawer is doing PUT semantics, not PATCH).

Predict: an admin removes your Payroll role while you are logged in. Which stops working first — the Payroll API calls, or the Payroll pages in your sidebar? Write it down.

Resolved: the API calls. The backend re-reads roles from the database on every request through the JWT filter, so the change bites on your very next call — but your browser's copy of user.permissions came from the login response and stays stale until a token refresh or re-login. That asymmetry is the price of shipping the array once, and it is safe because the backend never trusts the frontend's copy.

Gate one: the route​

You saw in lesson 02 that App.tsx nests routes under guard elements. The guard is nine lines:

export const RequirePermission = ({ permission }: { permission: Permission | Permission[] }) => {
const { hasPermission } = useAuth();

const permissions = Array.isArray(permission) ? permission : [permission];
if (!permissions.some(hasPermission)) {
return <Navigate to={ROUTES.DASHBOARD} replace />;
}

return <Outlet />;
};

The .some is the load-bearing choice: an array means OR, hold any one and you pass. That is exactly what shared pages need — Overtime Requests serves both HR and Payroll:

<Route element={<RequirePermission permission={PermissionConstants.HR_EMPLOYEES_VIEW} />}>
<Route path={ROUTES.EMPLOYEES} element={page(Employees)} />
</Route>
/* … */
<Route element={<RequirePermission permission={[PermissionConstants.HR_OVERTIME_REQUESTS_VIEW, PermissionConstants.PAYROLL_OVERTIME_APPROVE]} />}>

Fail the check and you are redirected to the dashboard, not shown an error page — a pasted URL you lack rights to lands you somewhere useful instead of somewhere accusatory.

Gate two: the nav​

Routes stop you from reaching a page; the sidebar stops you from seeing it exists. Entries in nav-config.ts carry two optional fields:

export interface NavItem {
icon: IconType;
label: string;
to: string;
permission?: Permission | Permission[];
roleContext?: string[];
}

and app-layout.tsx filters NAV_ENTRIES through both:

// Filter items by permission + roleContext
const itemVisible = (item: NavItem): boolean => {
const hasPerms = !item.permission
|| (Array.isArray(item.permission) ? item.permission.some(hasPermission) : hasPermission(item.permission));
if (!hasPerms) return false;
if (activeRole && item.roleContext) return item.roleContext.includes(activeRole);
return true;
};

const visibleEntries: NavEntry[] = NAV_ENTRIES.flatMap((entry): NavEntry[] => {
if (!isNavGroup(entry)) return itemVisible(entry) ? [entry] : [];
const visibleItems = entry.items.filter(itemVisible);
return visibleItems.length > 0 ? [{ ...entry, items: visibleItems }] : [];
});

roleContext exists because permissions alone are not enough for a clean sidebar. Overtime Requests appears in both the HR group and the Payroll group with the same OR'd permissions — with no roleContext, a Payroll Administrator would see it twice. The role tag says which group owns the entry in which view, and a group whose items all vanish vanishes itself.

The same file explains why System Administrators start in Full Access rather than auto-selecting a role like everyone else:

// System Administrators default to Full Access (activeRole null): they qualify for
// every role, so order-based auto-select would land on HR Administrator and hide
// sysadmin-only pages like Billing.
if (user.roles.includes('System Administrator')) return;

That comment records an incident shape, not a preference: the subset rule makes a sysadmin qualify for every role, so picking the first would trap them in the narrowest lens.

Gate three: the button​

Reaching the Employees page proves you hold hr.employees.view. Whether you can act is decided inside the page. The 700-line Employees.tsx resolves its capabilities once, at the top:

const { hasPermission } = useAuth();
const canCreate = hasPermission(PermissionConstants.HR_EMPLOYEES_CREATE);
const canEdit = hasPermission(PermissionConstants.HR_EMPLOYEES_EDIT);
const canToggleStatus = hasPermission(PermissionConstants.HR_EMPLOYEES_DELETE);

Three booleans, then every affordance in the grid config keys off one of them:

selection: { canSelect: canToggleStatus },
/* … */
onImport: canCreate ? () => { setImportOpen(true); } : undefined,
/* … */
primaryAction: {
label: 'Add Employee',
hidden: !canCreate,
onClick: () => { setEditingId(null); setFormOpen(true); },
},

Notice the three shapes of hiding: primaryAction.hidden is a flag the toolbar reads; onImport: undefined removes the Import menu entry entirely, because an undefined handler means the grid never offers the action; selection.canSelect kills row checkboxes, because the only bulk actions here are status changes — selection without canToggleStatus would select rows you can do nothing with. Row menus follow the same pattern, with per-row logic layered on:

{ value: 'edit', label: 'Edit', icon: <LuPencil />, hidden: () => !canEdit, separator: true },
/* … */
// Archive and Restore are mutually exclusive — each hides itself when it doesn't apply.
{ value: 'archive', label: 'Archive', icon: <LuArchive />, hidden: row => !canToggleStatus || row.isArchived, separator: true },
{ value: 'restore', label: 'Restore', icon: <LuArchiveRestore />, hidden: row => !canToggleStatus || !row.isArchived },

So the full trace for hr.employees.create: login-response array → survives (or not) the activeRole filter in effectivePermissions → hasPermission → canCreate → primaryAction.hidden — and the Add Employee button exists, or never renders. That is the whole system; every other page repeats the same three-gate pattern with different strings.

The front door​

All three gates assume you are logged in at all. That is protected-route.tsx, which wraps everything and handles one more case worth its comment:

// Checked here rather than only after logging in, because the session survives a reload: the
// stored user carries the flag, so someone who closed the tab on the change-password screen would
// otherwise come back to a full app in which every request is refused.
if (user?.mustChangePassword && location.pathname !== ROUTES.CHANGE_PASSWORD) {
return <Navigate to={ROUTES.CHANGE_PASSWORD} replace />;
}

Unauthenticated visitors are sent to login with state: { from: … } so a bookmarked deep link survives the round trip.

Where this shows up in MotorPH​

Recap​

  • Authorization arrives as one flat permissions[] array in the login response — the JWT itself carries only role names, and role views are derived client-side by a subset test, so the backend stays a pure permission checker.
  • A user gains permissions through roles, and roles through migrations or an admin — the category-driven grants in the migrations are the source of the matrix, UserFormDrawer and RolePermissionsDrawer are the runtime edit paths, and the backend re-reads the whole chain from the database on every request.
  • activeRole narrows, never widens: effectivePermissions is an intersection, which is why switching to Payroll genuinely hides HR pages instead of just re-skinning the sidebar.
  • Arrays mean OR at every gate — RequirePermission and the nav filter both use .some, because shared pages like Overtime Requests serve two roles with different permissions.
  • Buttons hide by three shapes — a hidden flag, an undefined handler, and canSelect — so a viewer-only user gets a grid with no dead-end affordances, not a grid of disabled ones.
  • You can now trace hr.employees.create end to end: login response → effectivePermissions → hasPermission → canCreate → primaryAction.hidden. Every other permission walks the same road.

Next: 04 — The data layer: one client, seventy wrappers.