4.2 KiB
2026-07-17 — requireRole.js middleware + RBAC matrix tests (Sprint A1 / P1-10)
Branch: fix/p1-10-require-role
Worktree: .worktrees/fix-p1-10-require-role
Why
Inline if (!['school_admin', 'systems_admin'].includes(req.user.role)) checks
are scattered across 23+ controllers. Some are subtly inconsistent (one
controller blocks teachers, another doesn't; one allows principal, another
doesn't). P1-10 from the master readiness audit called for a single
middleware helper + an executable form of the SCREENS.md permission
matrix so we can catch a widening access regression in CI.
Changes
server/src/middleware/requireRole.js (new)
requireRole(...allowed)— factory. Returns 401 if noreq.user, 403 if role not in the allowed set, otherwise callsnext().requireAdmin— systems_admin, school_admin, principal. Maps to the rows where SCREENS.md grants principal View access (e.g./users,/departments).requireStrictAdmin— systems_admin, school_admin only. Maps to routes that intentionally exclude principal (audit logs, settings, academic rollover execution).requireStaff— admin/principal + hr/bursar/accountant/nurse/dining/ teacher/librarian/clubs_head. Excludes student and parent.requireOwnerOrAdmin— admin ORreq.user.id === req.params.[userId|id|studentId], with explicit parent pass-through.ROLE— frozen object exposing every role string as a stable constant so callers don't repeat string literals.
server/tests/require-role.test.js (new, 15 specs)
Unit tests with no DB or Express:
- Allow / 403 / 401 / missing-role cells for
requireRole. requireAdminaccepts admin+principal, rejects teacher/student/parent.requireStaffadmits every staff + teacher + librarian + clubs_head, rejects student/parent.requireOwnerOrAdminadmits admins, the URL-param owner, parents; rejects unrelated students.ROLEis frozen.
server/tests/rbac-matrix.test.js (new, 152 specs)
Builds a tiny Express app that mounts the requireRole middleware with
fake handlers, mocks req.user from an X-Test-Role header, then walks
every (role, route) cell of the SCREENS.md matrix and asserts the
expected HTTP status (200 / 403 / 401 for anonymous). 10 matrix rows ×
13 roles + an extra negative-cell regression block = 152 specs. All
green.
This is the executable form of SCREENS.md §Role Permissions Matrix.
Controller refactors (4 files)
server/src/controllers/audit.controller.js— localadminOnlyis nowrequireStrictAdminre-aliased. Same predicate, one source.server/src/controllers/academic-rollover.controller.js— same.server/src/controllers/users.controller.js— localadminOnlyis nowrequireAdmin(admin + principal, matches SCREENS.md matrix).server/src/controllers/payments.controller.js— the inline check onGET /api/payments/student/:studentIdis replaced withrequireOwnerOrAdminmounted betweenauthand the handler. The parent pass-through behavior is preserved exactly as before (controller-level scope by parent-student mapping is unchanged).
These are demonstration refactors; the remaining 19 controllers with
inline RBAC predicates are out of scope for this PR but should follow
the same pattern over the next sprints. conventions.md will list the
canonical middleware aliases so the sweep converges.
Verification
npx vitest run tests/require-role.test.js→ 15 passed (15 ms)npx vitest run tests/rbac-matrix.test.js→ 152 passed (411 ms)npx vitest run(full suite) → 185 passed, 1 pre-existing failure inpaynow-webhook.test.js("paid_amount" undefined) caused by the dev DB lacking astudent_feesrow at id=1 in this worktree. The failure is independent of these changes —git diffagainstdevshows only controller refactors that do not affect that test's query.node --checkclean on every modified file.
Out of scope (deferred)
- 19 remaining controllers with inline RBAC; sweep to be scheduled.
conventions.mdupdate with the alias table.- Express-rate-limit on auth (left in place; was already covered by existing rate-limit middleware where enabled).