Skip to content

Preset Management Roles + Department-Scoped Role Assignment

1. Problem distillation

  • Today's roles.ts seed produces three preset roles per tenant: admin (full WRITE), teacher (profile-default, READ-mostly), referent (profile-default, WRITE-on-self + record-scoped students). The "management" tier described in docs/04-rbac.md §Preset Role Permissions MatrixPrincipal, HR/Secretary, Department Head — has no seed presence; the matrix is aspirational doc only.
  • We need to materialise three more presets per tenant: principal, hr, department_head. The first two are flat (one row per tenant; permissions identical for every holder). department_head is parameterized: the same role grants WRITE on everything within a specific department, and the same user can be dept head of multiple departments.
  • The schema today carries no slot for that parameter. UserRole is (userId, roleId, tenantId) only. Without a parameter column we'd have to either (a) mint one Role row per (department_head × department) pair — pollutes the role list and couples role lifecycle to department lifecycle — or (b) add a nullable departmentId slot on the assignment, which keeps Role definitions clean and lets PermissionsService thread the parameter through to record-level filters.
  • Option (b) was selected during brainstorming (2026-05-25 chat). This spec covers schema + seed + permission-compilation plumbing + the contract that <entity>ForAccessContext helpers consume. Eligible holders: any User whose active profile is teacher or staff (per the multi-profile rule that forbids Teacher + Staff on the same User but allows either to hold management roles). referent and student profiles never hold management roles — the active-profile narrowing already drops them.
  • The matrix from docs/04-rbac.md is the canonical permission shape for principal/hr; this spec applies it verbatim (per Q2 in chat). For department_head the policy is WRITE on every scope of every department-scoped entity, gated by the assignment's departmentId — a new policy not previously documented.

Success criteria (observable behavior that proves this works):

  • After seed runs, every tenant has six preset roles: admin, principal, hr, department_head, teacher, referent. rbac-catalogue.drift.spec (and the matching role-presence check we'll add) green.
  • A user holding only principal role: every GET returns full READ; every PATCH/POST/DELETE returns 403 (no actions, no WRITE scopes).
  • A user holding only hr role: matches the matrix in §6 below — WRITE on most scopes, READ on health/scoring-shaped scopes, all C/D actions for admin-shaped entities, no access to evaluation_scales / timetable_templates write (admin-only catalogs).
  • A user holding UserRole(department_head, departmentId=D1): GET /students returns only students whose departmentId = D1; PATCH on a student in D2 → 404 (record-filter sentinel); PATCH on a student in D1 → 200 with WRITE on every student scope.
  • The same user with a second UserRole(department_head, departmentId=D2): GET returns students in {D1, D2}; PATCH allowed in either.
  • prisma/seed/users.ts fixtures gain a dept-head e2e user wired to one department, and the existing admin/teacher/referent e2e fixtures remain green.

Non-goals (in-scope-shaped things this iteration is explicitly not doing):

  • Admin UI to assign these roles to users (FE work; the schema + seed are sufficient for e2e coverage).
  • Custom-role variants of these presets (presets are immutable; custom-role cloning is a separate deferred item, see §9).
  • Changing the teacher or referent preset shapes — left exactly as today (Q3 in chat).
  • Bulk-importing user-role assignments — out of scope; admin assigns one user at a time.
  • Caching changes (PermissionsService request-memoization stays; Redis remains deferred).
  • Mailer / invitations changes (a department_head assignment isn't tied to invitation accept — admin attaches it after the user activates).

2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
prisma/seed/roles.tsseedTeacherReferentPresetsForTenant Per-tenant preset upsert shape; EXCLUDED_SCOPE_KEYS set + iterate-and-assign loop; allOthersScopeIds handling. New presets reuse the same upsert pattern. The function is per-role-pair (teacher + referent together). The new presets are independent of each other and of the existing ones — we add three more functions or split into a single shared helper.
src/students/students.queries.tsstudentsForAccessContext Canonical <entity>ForAccessContext(ctx) shape: branch on ctx.roles.includes(...), return Prisma.<Entity>WhereInput, fail-closed sentinel. Today the helper only sees ctx.roles (string array). We need ctx.departmentIds for the dept-head branch — a new field on RecordAccessContext.
docs/superpowers/specs/2026-04-27-active-profile-design.md Role-narrowing rules at token-issue time; roles[] reflects the active session profile. Management roles attach to teacher or staff profiles; narrowing already drops them for referent/student profiles — no new narrowing logic needed. Active profile is about WHICH role keys to load. This spec adds WHICH PARAMETER each role assignment carries — orthogonal axis, not handled by narrowing.
docs/superpowers/specs/2026-04-24-rbac-quick-wins-design.md EXCLUDED_SCOPE_KEYS discipline for non-admin presets; rationale for keeping catalog-shaped scopes (evaluation_scales, timetable_templates) admin-only. This spec only added principal-shaped exclusions in passing; the present spec formalises HR's exclusion list separately and introduces the dept_head WRITE-everywhere-in-dept policy.
docs/04-rbac.md §Record-Level Access The five invariants of the helper pattern (decorator-filter synchronization, platform-admin short-circuit, additive-never-expansive, findFirst for findOne, unknown-role sentinel). All carry over. The chapter assumes ctx.roles[] is the only branching axis. Dept-head adds ctx.departmentIds[]. Chapter update mandatory in §6.
prisma/seed/rbac-catalogue.ts ACTION_REQUIREMENTS shape (which WRITE scopes each action needs). dept_head's WRITE-everywhere posture means it satisfies every existing action's requirements automatically once we grant WRITE on the relevant scopes — no ACTION_REQUIREMENTS change. This catalogue is entity-keyed; dept-head's "everything in MY dept" is a record-level axis the catalogue doesn't model. The filter helpers do, not the catalogue.

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Roles are already tenant-scoped (Role.tenantId, UserRole.tenantId). Preset roles seed once per tenant. New UserRole.departmentId carries an FK to a Department row; the department itself is tenant-scoped, so the assignment inherits tenant isolation by transitivity. Service-layer assertion: when creating a UserRole, validate department.tenantId === user.tenantId if departmentId is set. Per chapter 02 every business row carries tenantId; UserRole already does, and Department.tenantId provides the cross-check.
Academic-year scope no Roles and assignments are AY-independent — a dept head of D1 is dept head across all years (analogous to admin). Department membership of students is per-AY (Student is AY-scoped) but the role assignment is not. Record-level filtering combines tenantId + departmentId + (optional) academicYearId at the call site, never inside the role catalogue. A management role binding that expired with each AY rollover would force admins to re-attach roles annually — bad UX. Departments persist across AYs (the catalogue is tenant-flat).
RBAC entity key existing No new entity keys. We're seeding role rows + permissions over the entities already in src/common/constants/entity-keys.ts. The seven roles act on the existing entity-scope-action surface. No new entity = no entity-keys.ts delta.
Scopes existing No new scopes. Reuses every scope already in SCOPES / OTHERS_SCOPES. Same as above.
Actions existing No new actions. dept_head's WRITE-on-everything posture satisfies the existing ACTION_REQUIREMENTS automatically once we grant the matching scopes. We don't need a manage_department meta-action because RoleActionPermission × ActionScopeRequirement already composes correctly.
Service base n/a — no new service This is RBAC plumbing, not a domain module. PermissionsService (src/permissions/permissions.service.ts) gets a new method getAccessContextSlice(userId, tenantId, activeRoleKeys) returning { departmentIds: string[] } derived from active UserRole rows where role.key = 'department_head'. Called from @AccessContext() decorator. The decorator currently pulls everything off req.user. To know which departments the user is dept-head-of, the decorator (or a request-scoped helper) has to query UserRole. Co-located with permissions compilation (same access patterns, same caching window).
queries.ts shape existing per entity; new where missing Department-scoped entities — students, teachers, departments, grades, curricula, study_plans, curriculum selection windows, homerooms, subject_groups — each get an <entity>ForAccessContext(ctx) helper (creating it where absent) with a dept_head branch returning a where that filters by the appropriate department predicate (direct departmentId IN ctx.departmentIds, or relational join for indirect cases — see §4 helper table). The matching controllers switch their list/find routes to @AccessContext() + <entity>ForAccessContext. Tenant-flat entities consumed READ-only by dept_head (rooms, staff, academic_years, school, invitations, evaluation_scales, timetable_templates, users, referents) need no helper update — the existing tenant-base filtering already returns the correct READ set for them. Wiring every department-scoped helper in this iteration (per chat 2026-05-25 ack) avoids partial enforcement where one entity respects the dept boundary and another silently leaks. The new branches all follow the chapter-04 helper invariants.
Error codes new INVALID_ROLE_ASSIGNMENT_DEPARTMENT — thrown when an admin creates a UserRole(department_head, departmentId=null) (dept_head requires a department), or a UserRole(non-dept-head-role, departmentId=X) (only dept_head accepts a department parameter), or when department.tenantId ≠ user.tenantId. Params: { roleKey: string; departmentIdProvided: boolean }. The invariant must fail loud at the write site — silent acceptance would corrupt permission compilation. Per chapter 06, every domain rule that can be violated gets a typed ErrorCode.
DTO conventions n/a this iteration No new admin-facing endpoint lands here. The existing role-assignment write paths (only seed today; admin UI deferred per §9) consume the schema directly. When the admin UI is built, it'll need an assignRoleToUser.dto.ts with optional departmentId. Spec scope is schema + seed + compilation; not the admin CRUD surface.
File-backed sub-resources n/a — pure RBAC
Custom fields n/a — RBAC catalogue is not custom-fielded
Profile completeness no Role assignment is not a completeness signal. A user without an assigned role still has a complete profile in the completeness sense (the absence shows up in admin command-center, not in person-entity completeness).

4. Data model plan

Schema deltas

  • UserRole gains departmentId String? @map("department_id") @db.Uuid.
  • UserRole gains relation department Department? @relation(fields: [departmentId], references: [id], onDelete: Cascade).
  • Department gains the reverse relation userRoles UserRole[].
  • UserRole unique constraint changes from @@unique([userId, roleId, tenantId]) to @@unique([userId, roleId, tenantId, departmentId]). Postgres treats NULL as distinct in unique constraints, so this preserves "one row per (user, role, tenant)" semantics for non-parameterized roles only by runtime validation — see §10 Open question 1.
  • New index @@index([departmentId]) on UserRole.

Migration shape

  • Additive: nullable column + nullable FK + new index + replaced unique constraint.
  • Data backfill: none. Existing (userId, roleId, tenantId) triples migrate to (userId, roleId, tenantId, departmentId=NULL) naturally; the new unique still permits them.
  • Hazards from chapter 12 checklist:
  • "Dropping or renaming a unique constraint" → flagged. The replacement is a superset (NULL is distinct) so no existing row violates it. Mitigation: include the constraint replacement in the same migration as the column add, so the two land atomically. Audit the generated migration.sql for the exact DROP/ADD order before commit.
  • "Adding an FK column" → adds an implicit table-level lock during constraint validation. Acceptable: the table is small (one row per user-role-tenant), and we're additive.

Indexes and uniqueness

  • New: @@index([departmentId]) on user_roles — supports the per-department lookup in getAccessContextSlice.
  • Replaced: @@unique([userId, roleId, tenantId, departmentId]) — Prisma-managed.
  • New (raw-SQL in the migration): partial unique index CREATE UNIQUE INDEX user_roles_unique_when_no_department ON user_roles (user_id, role_id, tenant_id) WHERE department_id IS NULL; — closes the NULL-is-distinct gap so non-parameterized roles (admin/principal/hr/teacher/referent) cannot be double-assigned. Confirmed during chat 2026-05-25 (see §7 Divergence ledger row 1).
  • Existing: @@index([tenantId]), @@index([roleId]), @@index([validUntil]) — unchanged.

Record-level filter helpers required this iteration

Entity Helper file Status Dept-head branch predicate
Students src/students/students.queries.ts (exists) Update — add dept_head branch { tenantId, departmentId: { in: ctx.departmentIds } }
Teachers src/teachers/teachers.queries.ts Create or update { tenantId, departments: { some: { departmentId: { in: ctx.departmentIds } } } } (via TeacherDepartmentAssignment)
Departments src/departments/departments.queries.ts Create or update { tenantId, id: { in: ctx.departmentIds } }
Grades src/departments/grades.queries.ts (or wherever Grade queries live) Create or update { tenantId, departmentId: { in: ctx.departmentIds } }
Curricula src/curriculum/curriculum.queries.ts Create or update { tenantId, departmentId: { in: ctx.departmentIds } }
Study Plans src/curriculum/study-plans.queries.ts (or co-located) Create or update { tenantId, grade: { departmentId: { in: ctx.departmentIds } } } (indirect via Grade)
Curriculum Selection Windows src/curriculum/selection-window.queries.ts Create or update { tenantId, departmentId: { in: ctx.departmentIds } }
Homerooms src/homerooms/homerooms.access-context.ts (exists per memory) Update — add dept_head branch { tenantId, departmentId: { in: ctx.departmentIds } }
Subject Groups src/subject-groups/... (exists per memory) Update — add dept_head branch { tenantId, studyPlanSubject: { studyPlan: { grade: { departmentId: { in: ctx.departmentIds } } } } }

Tenant-flat entities consumed READ-only by dept_head (staff, rooms, academic_years, school, users, referents, invitations, evaluation_scales, timetable_templates) need no helper update this iteration — their READ surface is already correctly tenant-base.

Each helper update is paired with: - Controller switch to @AccessContext() on list/find routes (where not already). - *.queries.spec.ts — new branch unit test (one per role: admin / teacher / referent / dept_head with empty departmentIds / dept_head with N departmentIds / unknown-role sentinel). - *.controller.spec.ts — assertion that the decorator's @RequireRoles(...) list includes department_head for routes the role should reach.


5. API surface

Verb Path Decorators Request DTO Response DTO

No new endpoints this iteration. Seed alone is sufficient for backend correctness. The admin-facing role-assignment endpoint (planned POST /api/v1/admin/user-roles with { userId, roleKey, departmentId? }) is deferred to the admin-UI iteration (see §9).

The existing GET /api/v1/permissions response shape is unchanged — it returns the compiled scope/action matrix, which is identical across all of a user's dept_head assignments (the matrix is per-role, not per-department). The departmentId axis surfaces only through record-level filters, never through GET /permissions.

Swagger considerations

  • n/a — no new routes.

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none — no new scopes.
PermissionAction (rbac-catalogue.ts) none — no new actions.
ScopeFieldMapping (rbac-catalogue.ts) none.
Role grants (roles.ts) new presets per tenant: principal, hr, department_head. See matrices below. Existing admin, teacher, referent untouched.
*_SCOPES runtime constant none — no scope changes.

Principal role grants (per tenant)

  • isPreset: true, description: "Read-only access across the school".
  • RolePermission: READ on every native scope EXCEPT the three admin-only catalog scopes (evaluation_scales.configuration, timetable_templates.configuration, invitations.management) and users.profile (admin-only). READ on every others scope (custom fields visibility).
  • RoleActionPermission: none. Principal cannot create or delete anything.

HR role grants (per tenant)

Per docs/04-rbac.md §Preset Role Permissions Matrix plus the cross-entity matrix:

Scope Access
students.identity WRITE
students.contacts WRITE
students.assignment WRITE
students.school_relationships READ (scaffolding; HR doesn't manage sibling flags)
students.documents WRITE
students.health READ (matrix says R, not W — health is nurse/medic territory)
students.referents READ
teachers.* (identity/contacts/employment/documents/health) WRITE on all five — HR manages personnel
staff.* (identity/contacts/employment/documents/health) WRITE on all five
users.profile READ
departments.configuration WRITE
grades.configuration WRITE
rooms.configuration WRITE
curricula.configuration WRITE
curricula.selection_window READ
academic_years.configuration READ
referents.identity / contacts / documents WRITE
referents.health READ
referents.students READ
school.configuration READ (admin-only write)
evaluation_scales.configuration none (admin-only catalog — listed in HR exclusion set)
timetable_templates.configuration none (admin-only catalog — listed in HR exclusion set)
homerooms.composition WRITE
subject_groups.composition WRITE
invitations.management WRITE (HR sends invitations)
All others scopes WRITE

Actions granted to HR: - students.create, students.delete - teachers.create, teachers.delete - staff.create, staff.delete - departments.create, departments.delete - grades.create, grades.delete - rooms.create, rooms.delete - curricula.create, curricula.delete, curricula.open_selection_window, curricula.edit_selection_window - referents.create, referents.delete - homerooms.create, homerooms.delete - subject_groups.create, subject_groups.delete - invitations.send, invitations.reset

Not granted: evaluation_scales.*, timetable_templates.*, academic_years.*. Those stay admin-only.

Department Head role grants (per tenant)

Policy: WRITE on every native scope of every department-scoped entity. READ on tenant-flat entities. All actions on department-scoped entities. No actions on tenant-flat entities.

Department-scoped entities (record-level filter narrows to ctx.departmentIds):

Scope Access
students.* (all 7 scopes) WRITE
teachers.* (all 5 scopes) WRITE
departments.configuration WRITE (only their own department row)
grades.configuration WRITE (only grades within their dept)
curricula.configuration WRITE (only curricula targeting their dept)
curricula.selection_window WRITE (only windows for their dept)
homerooms.composition WRITE
subject_groups.composition WRITE

Tenant-flat entities (READ only; record-level filter returns tenant base):

Scope Access
staff.* (all 5 scopes) READ
rooms.configuration READ (admin-only catalog — Room.departmentId exists in schema but room config is managed centrally)
academic_years.configuration READ
school.configuration READ
users.profile READ
referents.* (identity/contacts/documents) READ (the referent record itself is admin/HR territory; dept-head reaches referents via the student's referents scope)
referents.students, referents.health READ
evaluation_scales.configuration READ (catalog visible but not editable)
timetable_templates.configuration READ
invitations.management READ

All others scopes: WRITE (mirrors the native-scope access of the parent entity).

Actions granted to dept_head: - students.create, students.delete (record-filter on creation enforces departmentId IN ctx.departmentIds) - teachers.create, teachers.delete - departments.create is not granted — dept heads can edit their own department configuration but cannot create new departments (admin-only). departments.delete likewise admin-only. - grades.create, grades.delete - curricula.create, curricula.delete, curricula.open_selection_window, curricula.edit_selection_window - homerooms.create, homerooms.delete - subject_groups.create, subject_groups.delete

Not granted: staff.* actions, rooms.* actions, academic_years.* actions, referents.* actions, evaluation_scales.* actions, timetable_templates.* actions, invitations.* actions.

roles.ts structural change

Today roles.ts has two helpers: per-tenant admin upsert + seedTeacherReferentPresetsForTenant. After this spec it'll have:

  • seedAdminForTenant (extracted from the existing inline loop)
  • seedTeacherReferentPresetsForTenant (unchanged)
  • seedPrincipalForTenant (new — flat permission set)
  • seedHrForTenant (new — uses an HR_PERMISSION_RULES table)
  • seedDeptHeadForTenant (new — uses a DEPT_HEAD_PERMISSION_RULES table)

Top-level applyRoles becomes a fan-out over allTenants calling each helper. RolesResult gains nullable handles for the new presets where downstream seed (users.ts) needs them (only department_head is consumed downstream, to wire the e2e dept-head fixture user).


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
UserRole is (userId, roleId, tenantId) — no per-assignment parameter Add nullable departmentId slot used only by the department_head role The role policy "WRITE on everything in my department" is a per-assignment fact, not a per-role fact. Encoding it as a Role row per department (the rejected alternative in chat) pollutes the role list and couples role lifecycle to department lifecycle. One scalar slot covers the only parameterized role we anticipate; if a future role needs a different parameter (e.g. "head of grade X"), we revisit (could become a params Json column or a separate RoleParameter table).
RecordAccessContext carries only roles[] for branching Add departmentIds: string[] field The dept-head record-filter branch needs to know which departments the user heads. Computing it lazily inside each helper would re-query UserRole on every read; computing once per request matches the existing memoization pattern for request.permissions. One extra DB hit per request when the user has any department_head assignment. Mitigated by joining it into the same query that loads CompiledPermissions (single round-trip).
<entity>ForAccessContext(ctx) branches only on ctx.roles.includes(...) Dept-head branch additionally consults ctx.departmentIds Same root cause as above. Helper functions stay pure (no DB calls); the data they branch on is already on ctx.
permission_entities describe a flat WHAT-can-be-acted-on surface Dept-head's WHICH-rows axis is encoded outside the catalogue, in the helpers Putting record-filter rules in the catalogue would re-invent the helper layer poorly (catalogue is declarative key-value; record filters are Prisma WHEREs). The catalogue stays the source of truth for field-level access; helpers stay the source of truth for row-level access. Decorator/helper synchronization (chapter 04 invariant 1) still applies.

8. Pushback log

US says Conflicts with Proposed instead Status
User chat: "department head (needs the indication of which department)" A literal reading would suggest dept-head is a different ROLE per department (department_head_d1, department_head_d2, …). That would pollute the role list, break preset immutability (rows get created/deleted as departments change), and force roles.ts to know the department list at seed time. One generic department_head role; the department is a parameter on the assignment (UserRole.departmentId). The seed creates one role row per tenant, the same as admin / principal / hr. Resolved (Q1 in chat — user picked "Add departmentId to UserRole").
User chat: "departments head have write on everything of that department" "Everything" could include tenant-flat resources like rooms, evaluation_scales, timetable_templates, staff. If interpreted literally as WRITE-on-the-tenant-shaped-everything, it duplicates admin. "Everything of that department" reads as "everything that has a department dimension": students, teachers (whose departments includes the dept), grades, curricula, homerooms, subject_groups, the dept itself. Tenant-flat resources are READ-only. Resolved in this spec — see §6 dept_head grants. Open for user review at sign-off (could expand to include rooms WRITE if dept heads manage their own rooms; flagged as §10 Open question 2).
User chat: "remember that a teacher can also have these, so staff + teacher are the people eligible for roles" Could be read as "teacher and staff are themselves roles eligible to receive other roles", but teacher/staff here are profiles, not RBAC roles. The profile vs role distinction (per docs/04-rbac.md) makes them orthogonal. Eligibility = a User has an active profile of teacher OR staff. Both can hold admin / principal / hr / department_head role grants. referent and student profiles never receive management roles (the active-profile narrowing already enforces this — roles = [activeProfile] for non-RBAC profiles). Resolved — no schema change needed; narrowing already correct. Test added in §11 to lock the behavior.

9. Deferrals

  • Admin UI + endpoints for assigning these roles to usersPOST/PATCH/DELETE /api/v1/admin/user-roles with { userId, roleKey, departmentId? }. Seed is sufficient for backend correctness this iteration; the FE work is a separate epic. Follow-up: future spec under docs/superpowers/specs/ once the admin UI scope lands.
  • Custom-role clone-and-modify workflowdocs/04-rbac.md §Custom Role Definitions describes it; not relevant to preset seeding. Follow-up: revisit when product asks for custom-role admin UI.
  • Multi-department dept_head as a single UserRole row — today's design uses N rows for N departments. A future evolution could collapse this into a UserRole.departmentIds Uuid[] array column. Deferred because N-row is simpler to validate, audit, and revoke per-department. Follow-up: revisit if admin UX feedback shows it.
  • Caching of ctx.departmentIds — same lifetime as CompiledPermissions (per-request memoization). Redis caching of either remains deferred — docs/04-rbac.md §Permission Caching Phase 2. Follow-up: when Redis lands.
  • Audit log of role assignmentsUserRole.assignedBy exists but there's no audit trail for changes. Out of scope. Follow-up: dedicated audit spec.
  • BullMQ job for role expiry (validUntil) — already deferred per docs/04-rbac.md §Temporal Role Assignments. Unchanged.

10. Open questions

All resolved during chat 2026-05-25.

  • Q1 — Partial uniqueness on UserRole. Resolved: option B. Add a raw-SQL partial unique index ... WHERE department_id IS NULL alongside the Prisma @@unique([userId, roleId, tenantId, departmentId]). Defense in depth; closes the NULL-is-distinct gap for non-parameterized roles. Captured in §4 Indexes and uniqueness.
  • Q2 — Dept-head WRITE on rooms? Resolved: READ only. Rooms config is an admin-only catalog regardless of the nullable Room.departmentId column. Room belongs to the admin-only catalog tier (alongside evaluation_scales and timetable_templates). Captured in §6 dept_head matrix.
  • Q3 — Dept-head WRITE on referents? Resolved: READ only on referents.*. Dept-head writes the link via the student's referents scope (record-filtered to their dept's students) but the referent record's own identity/contacts/documents stays admin/HR territory. Captured in §6 dept_head matrix.

11. Verification plan

  • Unit specs:
  • prisma/seed/roles.spec.ts (new) — assert that after applyRoles runs for a fresh tenant, six preset roles exist with the expected isPreset=true flag, and each has the RolePermission grants matching §6. One describe block per role.
  • prisma/seed/rbac-catalogue.drift.spec.ts (existing) — extend the drift check to assert that every seeded role's permissions correspond to existing scopes (no orphaned grants).
  • src/permissions/permissions.service.spec.ts — add cases:
    • getActiveRoleKeys returns ['department_head'] for a user with one dept_head assignment.
    • New getAccessContextSlice returns { departmentIds: [D1] } for a user with UserRole(department_head, D1); returns { departmentIds: [] } for a user with no dept_head assignment; deduplicates across multiple rows.
  • src/<entity>/<entity>.queries.spec.ts for every entity listed in §4 helper table (students, teachers, departments, grades, curricula, study_plans, selection_windows, homerooms, subject_groups). Each spec adds: admin → base; teacher → base (or pre-existing branch); referent → pre-existing branch; dept_head with N departments → in-clause filter per the table; dept_head with empty departmentIds → never-match sentinel; unknown role → never-match sentinel. Lock the decorator/filter synchronization (chapter 04 invariant 1).
  • src/common/decorators/access-context.decorator.spec.ts — assert departmentIds is read from req.user (populated by the same hook that loads request.permissions).

  • E2E specs:

  • test/rbac-presets.e2e-spec.ts (new) — for each preset, create a user, assign the role, hit a representative GET / PATCH / POST / DELETE on every department-scoped entity, assert the matrix from §6 holds. One describe('preset: hr', ...) per role.
  • test/dept-head.e2e-spec.ts (new) — provision two departments, create a dept_head user wired to D1 only. Every department-scoped entity listed in §4 helper table gets its own assertion block:
    • GET /students, /teachers, /departments, /grades, /curricula, /study-plans, /homerooms, /subject-groups, /selection-windows each return only D1-belonging rows.
    • PATCH on a D2 row of each entity returns 404 (record-filter sentinel); PATCH on a D1 row returns 200 with WRITE applied.
    • POST with a D2 department ref returns 403 (or 422 — the write-side enforcement form is decided during implementation; assertion is "rejected", not the specific code).
    • Add a second UserRole(department_head, D2) to the same user; assert each GET now returns rows from both departments.
    • Assert GET /staff, GET /rooms, GET /school, GET /academic-years, GET /referents return tenant-wide data (READ-only on flat entities).
    • Assert PATCH /staff/:id, PATCH /rooms/:id, POST /staff, POST /rooms return 403 — dept_head holds no actions / WRITE on these.
  • test/roles.e2e-spec.ts (existing, if present; else absorbed into rbac-presets) — assert preset roles cannot be mutated/deleted by tenant admin once the admin UI exists. n/a this iteration.

  • Manual verification:

  • Run npx prisma db seed against a clean DB. Open Prisma Studio, confirm roles table has six preset rows per tenant. Confirm role_permissions count matches the per-role expected count (assertion in seed spec, but eyeball as a sanity check).
  • Log in as the e2e dept_head fixture user; hit /api/v1/auth/me and /api/v1/permissions — confirm the response shape is sensible (scopes matrix as expected; no departmentIds leaks into the public permissions response).

Patterns: chapter 09 (testing), feedback_e2e_isolation_patterns.md for E2E isolation discipline.


12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-05-25
  • Chat reference: Approved by Fabio in chat 2026-05-25 ("go on") after walkthrough of §6 dept_head matrix, resolution of Q1 (raw-SQL partial unique), Q2 (rooms admin-only catalog), Q3 (referent identity stays admin/HR), and the in-scope decision to wire every department-scoped record-level helper this iteration.

Implementation may now begin. Ordered task plan lives at docs/superpowers/plans/2026-05-25-preset-management-roles-dept-scoped-assignment.md.