# 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 no `req.user`,
  403 if role not in the allowed set, otherwise calls `next()`.
- `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 OR `req.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`.
- `requireAdmin` accepts admin+principal, rejects teacher/student/parent.
- `requireStaff` admits every staff + teacher + librarian + clubs_head,
  rejects student/parent.
- `requireOwnerOrAdmin` admits admins, the URL-param owner, parents;
  rejects unrelated students.
- `ROLE` is 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`** — local `adminOnly` is
  now `requireStrictAdmin` re-aliased. Same predicate, one source.
- **`server/src/controllers/academic-rollover.controller.js`** — same.
- **`server/src/controllers/users.controller.js`** — local `adminOnly` is
  now `requireAdmin` (admin + principal, matches SCREENS.md matrix).
- **`server/src/controllers/payments.controller.js`** — the inline check
  on `GET /api/payments/student/:studentId` is replaced with
  `requireOwnerOrAdmin` mounted between `auth` and 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
  in `paynow-webhook.test.js` ("paid_amount" undefined) caused by the
  dev DB lacking a `student_fees` row at id=1 in this worktree. The
  failure is independent of these changes — `git diff` against `dev`
  shows only controller refactors that do not affect that test's query.
- `node --check` clean on every modified file.

## Out of scope (deferred)

- 19 remaining controllers with inline RBAC; sweep to be scheduled.
- `conventions.md` update with the alias table.
- Express-rate-limit on auth (left in place; was already covered by
  existing rate-limit middleware where enabled).
