§ unswayed-backend
Admin panel & RBAC (Phase 12)
Admin panel & RBAC (Phase 12)
Phase 12 is the back office — and the one phase of the rebuild where the wire was designed rather than reproduced. The legacy admin console was a server-rendered Blade app behind a session guard, entirely outside the frozen /api JSON contract. So src/admin/ ships a greenfield /api/admin/* JSON surface for a future admin SPA: legacy behaviour (auth model, RBAC semantics, protected-rule strings) on the repo's standard envelope. It closed the final planned phase — all 13 roadmap phases are done.
A genuinely separate identity
Admins live in their own admins table and authenticate through a dedicated admin-jwt passport strategy. The deciding move: the admin JWT signs with its own required secret (ADMIN_JWT_SECRET, validated at boot to differ from every end-user secret). That makes cross-guard token validation cryptographically impossible — an admin token presented to GET /api/feedback fails signature verification before any claim logic runs, and vice-versa. No shared-secret claim filtering to get subtly wrong; the e2e suite proves both directions.
The strategy re-resolves the admin's roles and permissions from the database on every request, so revoking a role takes effect on the next call — no token reissue needed. Logout is stateless (the client discards the token); passwords are argon2id like the rest of the rebuild, not legacy bcrypt.
Bootstrap without committed secrets. Legacy seeded two shared accounts with the literal password Admin@1100 in a committed seeder. Here, seed:admin (idempotent, upsert-only) always seeds the permission catalogue and the Super Admin/Admin roles, but creates the single Super Admin account only when ADMIN_SEED_PASSWORD is set in the environment — and stamps it mustResetPassword: true. Until that admin rotates its password via change-password, every admin route except me, change-password, and logout answers 403 (@SkipPasswordResetCheck marks exactly those three). Re-running the seeder never touches an existing password.
RBAC that cannot drift
The legacy system had two RBAC bugs worth naming. Admin\PostController guarded on a show-post permission that no seeder ever created — so non-Super-Admins could never see posts, and nobody noticed because Super Admin bypassed everything. And the sync:permissions command maintained a different permission list from the seeder, truncating the table on every run (orphaning grants).
The structural fix is one file: src/admin/rbac/permission.catalog.ts — a const tuple of all 66 dash-cased permission names (show-role, create-country, … plus the drift-fixed show-post and the new show-log). Both the seeder and the guard consume it, and @RequirePermissions(...)'s parameters are typed to the catalogue's union type — guarding a route on a permission the seeder doesn't create is now a compile error, not a production surprise. Seeding is upsert-only; the legacy *-module permissions weren't ported (their admin-sidebar surface was commented-out scaffolding), which leaves the seeded Admin role with no grants — faithfully matching production, where that role could only see a module tree that didn't exist.
PermissionsGuard implements two semantics worth knowing: ANY-of across the listed permissions (legacy Spatie permission:a|b middleware allowed any — list routes are gated on all four CRUD verbs), and the Super Admin bypass implemented exactly once inside the guard (the legacy Gate::before). Routes with no metadata pass on authentication alone — which is how the dashboard stays deliberately permission-free, as in legacy. Join tables are plain admin_roles/role_permissions; Spatie's polymorphic model_type columns were dead weight (only Admin is ever a subject).
The surfaces
- Roles & admins CRUD (
/api/admin/roles,/api/admin/users— the latter manages the admins table, never end-users; the legacy naming trap, documented away). Every legacy protected rule survives with its exact string and its exact check order: the Super Admin role is immutable (SUPER ADMIN ROLE CAN NOT BE EDITED/DELETED), you can't delete a role you hold (CAN NOT DELETE SELF ASSIGNED ROLE), a Super Admin account is self-edit-only and undeletable, and nobody deletes their own account (CAN NOT DELETE SELF USER) — with the Super-Admin check winning when both apply. A blank PATCH password keeps the old hash; the password policy (≥8, uppercase, digit, special) is one shared decorator. - Dashboard — five KPI counts plus two leaderboards. One adjudication to remember: the build guide claimed legacy left
jobs_countun-month-scoped, but the actual legacy source month-scopes all five counts. Code governs over doc; the discrepancy is recorded in ADR-0036. Leaderboards are all-time: top-5 jobs by application count, top-5 employers by review count then zero-safe average rating (an unreviewed employer ranks 0/0 — no divide-by-zero). - Master-data write CRUD — the counterpart to the frozen §1/§28 reads (untouched), 11 resources through one registry-driven service. The legacy DataTables AJAX feeds became
?q+ standard pagination. Thestatuscheckbox semantics survive (stored = input == 1) but with JSON-PATCH manners: an absentstatusleaves the row unchanged (legacy HTML forms full-replaced). Deleting a row that job pivots still reference maps Prisma's P2003 to a clean 409. - Admin blog CRUD — thumbnails now upload to Cloudinary through
MediaStoragePort(legacy wrote to the local public disk — not containerizable), with the Phase-8 upload-first/compensate/delete-old-after discipline.created_byis stamped with the acting admin, andBlog.createdByfinally became a real foreign key (SET NULL) — the deferral Phase 8 logged is closed. - Read-only oversight — employers, applicants, jobs, applications, posts. Strictly GET (a route-table test pins it). Two legacy bugs fixed here:
PostController.showrendered a job (copy/paste) — the new post detail returns the actual post; and the route typohitory-details/{id}is corrected toapplication-history/{id}, which parses the history's JSON payload and resolves it to the referencedJobOfferorJobInterviewby type. The end-userdeactivatedvisibility scopes are deliberately not applied — the back office sees everything. - Audit log — the legacy
logstable existed but was never written to. The greenfieldAdminAuditLogrecords every admin mutation via an interceptor (@Audited('role')etc.): actor (SET NULL + email snapshot so the trail survives admin deletion), action, target, before/after JSON — both recursively stripped of password-ish keys — and ip, written fire-and-forget so a logging failure never breaks the mutation. Read atGET /api/admin/logsbehindshow-log.
Gotchas
AdminSeedServicelives in the CLI module graph, not the app's — e2e suites can'tapp.getit; they seed RBAC rows via Prisma and log in through the real endpoint.- CSV-seeded tables never advanced their identity sequences (
seed:master-datainserts explicit ids) — the first runtimecreateoncountriescollided on id 1. The Postman harness nowsetvals all 11 lookup sequences; remember this for any table seeded with explicit ids. - The blog e2e suites now require an
adminsrow to exist (thecreatedByFK). - Admin wire conventions: flash-style messages (
X Added Successfully!), every list paginated,ParseIntPipe400s on non-numeric ids, protection-before-validation ordering on PATCH.
Tests: 451 unit tests across core + four slices (100% coverage each), four e2e suites built through author → adversarial-review → patch pipelines, and Postman folder 21-admin (47 requests / 197 assertions; full collection 545/2156 green). Decisions: ADR-0036; memory entry 0022.
Subpages