Preset Management Roles + Department-Scoped Role Assignment¶
1. Problem distillation¶
- Today's
roles.tsseed 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 indocs/04-rbac.md §Preset Role Permissions Matrix— Principal, 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_headis 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.
UserRoleis(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 nullabledepartmentIdslot on the assignment, which keeps Role definitions clean and letsPermissionsServicethread 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>ForAccessContexthelpers consume. Eligible holders: any User whose active profile isteacherorstaff(per the multi-profile rule that forbidsTeacher + Staffon the same User but allows either to hold management roles).referentandstudentprofiles never hold management roles — the active-profile narrowing already drops them. - The matrix from
docs/04-rbac.mdis 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'sdepartmentId— 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
principalrole: every GET returns full READ; every PATCH/POST/DELETE returns 403 (no actions, no WRITE scopes). - A user holding only
hrrole: 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 toevaluation_scales/timetable_templateswrite (admin-only catalogs). - A user holding
UserRole(department_head, departmentId=D1):GET /studentsreturns only students whosedepartmentId = 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.tsfixtures 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
teacherorreferentpreset 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 (
PermissionsServicerequest-memoization stays; Redis remains deferred). - Mailer / invitations changes (a
department_headassignment 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.ts — seedTeacherReferentPresetsForTenant |
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.ts — studentsForAccessContext |
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¶
UserRolegainsdepartmentId String? @map("department_id") @db.Uuid.UserRolegains relationdepartment Department? @relation(fields: [departmentId], references: [id], onDelete: Cascade).Departmentgains the reverse relationuserRoles UserRole[].UserRoleunique constraint changes from@@unique([userId, roleId, tenantId])to@@unique([userId, roleId, tenantId, departmentId]). Postgres treatsNULLas 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])onUserRole.
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.sqlfor 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])onuser_roles— supports the per-department lookup ingetAccessContextSlice. - 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:
READon every native scope EXCEPT the three admin-only catalog scopes (evaluation_scales.configuration,timetable_templates.configuration,invitations.management) andusers.profile(admin-only).READon everyothersscope (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 anHR_PERMISSION_RULEStable)seedDeptHeadForTenant(new — uses aDEPT_HEAD_PERMISSION_RULEStable)
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 users —
POST/PATCH/DELETE /api/v1/admin/user-roleswith{ userId, roleKey, departmentId? }. Seed is sufficient for backend correctness this iteration; the FE work is a separate epic. Follow-up: future spec underdocs/superpowers/specs/once the admin UI scope lands. - Custom-role clone-and-modify workflow —
docs/04-rbac.md §Custom Role Definitionsdescribes 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 asCompiledPermissions(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 assignments —
UserRole.assignedByexists but there's no audit trail for changes. Out of scope. Follow-up: dedicated audit spec. - BullMQ job for role expiry (
validUntil) — already deferred perdocs/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 NULLalongside 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.departmentIdcolumn.Roombelongs to the admin-only catalog tier (alongsideevaluation_scalesandtimetable_templates). Captured in §6 dept_head matrix. - Q3 — Dept-head WRITE on
referents? Resolved: READ only onreferents.*. Dept-head writes the link via the student'sreferentsscope (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 afterapplyRolesruns for a fresh tenant, six preset roles exist with the expectedisPreset=trueflag, and each has the RolePermission grants matching §6. Onedescribeblock 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:getActiveRoleKeysreturns['department_head']for a user with one dept_head assignment.- New
getAccessContextSlicereturns{ departmentIds: [D1] }for a user withUserRole(department_head, D1); returns{ departmentIds: [] }for a user with no dept_head assignment; deduplicates across multiple rows.
src/<entity>/<entity>.queries.spec.tsfor 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 emptydepartmentIds→ 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— assertdepartmentIdsis read fromreq.user(populated by the same hook that loadsrequest.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. Onedescribe('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-windowseach return only D1-belonging rows.PATCHon a D2 row of each entity returns 404 (record-filter sentinel); PATCH on a D1 row returns 200 with WRITE applied.POSTwith 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 /referentsreturn tenant-wide data (READ-only on flat entities). - Assert
PATCH /staff/:id,PATCH /rooms/:id,POST /staff,POST /roomsreturn 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 seedagainst a clean DB. Open Prisma Studio, confirmrolestable has six preset rows per tenant. Confirmrole_permissionscount 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/meand/api/v1/permissions— confirm the response shape is sensible (scopes matrix as expected; nodepartmentIdsleaks 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.