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_idFROM 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
UserFormDraweris the assignment UI — a required Primary Role select plus an Additional Roles checklist — saved throughPUT /api/users/{id}. The Roles page'sRolePermissionsDraweredits the matrix itself: a checkbox tree grouped by category, saved as a full-replacePUT /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
- frontend/src/hooks/useAuth.ts — the whole derivation, 42 lines
- frontend/src/constants/role-permissions.ts — roles as data
- frontend/src/constants/permissions.ts — the string catalog, mirrored from the backend
- frontend/src/ui/components/require-permission.tsx — gate one
- frontend/src/ui/layout/nav-config.ts and frontend/src/ui/layout/app-layout.tsx — gate two
- frontend/src/pages/hr/Employees.tsx — gate three, decoded fully in lesson 11
- frontend/src/ui/components/protected-route.tsx — the front door
- frontend/src/redux/auth.ts — persistence and the reload re-validation
- backend/…/security/service/UserDetailsServiceImpl.java — the role→permission flattening, with parent-role inheritance
- backend/…/security/jwt/JwtTokenManager.java — the token that carries roles, not permissions
- backend/…/db/migration/V9__seed_lookup_and_rbac.sql — the category-driven grant matrix
- frontend/src/components/users/UserFormDrawer.tsx and frontend/src/components/roles/RolePermissionsDrawer.tsx — where an admin grants RBAC at runtime
- Reference: ../frontend/auth-and-permissions.md — the exhaustive version of this lesson
- Decision record: ADR 0011 — permission authorities + frontend activeRole
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,
UserFormDrawerandRolePermissionsDrawerare the runtime edit paths, and the backend re-reads the whole chain from the database on every request. activeRolenarrows, never widens:effectivePermissionsis 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 —
RequirePermissionand the nav filter both use.some, because shared pages like Overtime Requests serve two roles with different permissions. - Buttons hide by three shapes — a
hiddenflag, anundefinedhandler, andcanSelect— 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.createend to end: login response →effectivePermissions→hasPermission→canCreate→primaryAction.hidden. Every other permission walks the same road.