Parametric roles — generalize UserRole.departmentId, narrow teacher, flip resolution to OR¶
1. Problem distillation¶
- The
UserRole.departmentIdFK is a one-shot parameter slot. It works fordepartment_headbut doesn't generalize. A second parametric role (e.g. futurehead_of_yearoverGrade,class_teacheroverHomeroom, custom "STEM Coordinator" over a Department subset) would require either another nullable FK column with its own partial-unique-index gymnastics, or a polymorphic shape. The 2026-05-25 spec explicitly called this out as the lesser-of-two-evils for "one parametric role". - Custom parametric roles are currently impossible. A school admin who wants "Math Coordinator for Departments X+Y" cannot create such a role today: (1) the schema has no parameter slot beyond the dept_head-specific FK; (2) the per-entity policy branches are hard-coded by role name (
'department_head'), so a new role key would never trigger narrowing even if it had department assignments. - First-match resolution masks the
teacherpass-through stub. Today, every*.policy.tsdeclares{ role: 'teacher', build: 'pass-through' }with a TODO comment to narrow it. The landedmakeRoleVisibilityResolverreturns the first matching branch, so a teacher+dept_head user happens to get the correct dept_head narrowing — but a teacher alone sees the entire tenant. Switching to OR composition naturally surfaces this latent bug; narrowingteacherproperly lets OR be correct. - The picker filters (
isOnlyDeptHead) duplicate the policy WHERE.homerooms.service.ts:findEligibleStudentsandsubject-groups.service.tsquery Students directly withquery.departmentId, then hand-roll a guard viaisOnlyDeptHead(ctx). The 2026-05-26 EntityAccessPolicy refactor established that policy WHEREs are the source of truth for record visibility; pickers should compose with them, not parallel them.
Success criteria (observable behavior that proves this works):
- A Postgres schema with no
UserRole.departmentIdcolumn. Department assignments fordepartment_headlive inUserRoleParameterrows keyed byvalueId.Role.parameterDimdeclares which dimension a parametric role consumes. RecordAccessContextexposes parametric values under a typedparameterssubstruct (parameters.departmentIds) instead of a flatdepartmentIdsfield. A newteacherDepartmentIdsfield carries the same shape for the identity-narrowedteacherbranch.definePolicyaccepts an optionalparametricBranches: [{ dim, narrow }]. Per-policy'department_head'role-keyed branches are deleted and replaced with{ dim: 'DEPARTMENT', narrow }inparametricBranches. The'teacher'role-keyed branch returns a narrowing build (usingctx.teacherDepartmentIds), not'pass-through'.EntityAccessPolicy.where(ctx)returns the OR of all matching branches (parametric + role-keyed) rather than the first match. Platform-admin and tenant-full roles (admin/hr/principal/staff) short-circuit by virtue of contributingpass-throughto the OR.RolesGuardadmits a user whose role allowlist does not overlappolicy.rolesif their parametric dimensions overlappolicy.parametricDimensions. This is the lever that admits future custom parametric roles whose role-key isn't enumerated anywhere.findEligibleStudents(homerooms and subject-groups pickers) composeStudentsPolicy.where(ctx)into their Prisma WHEREs. TheisOnlyDeptHeadhelper has no remaining call sites and is deleted.- Existing E2E suites for every role-aware entity pass without modification under the new resolution. Existing per-policy specs are rewritten to assert OR composition (the prior
dept_head precedes staffpriority assertions are replaced withdept_head + staff → narrowed by deptanddept_head + admin → pass-throughassertions reflecting the OR semantics).
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- Custom-role admin CRUD endpoints. There is no
src/roles/module orController('roles')today; admins cannot create custom roles via API. The schema supports it; the surface lands when product asks. - Pre-registering additional
ParameterDimenum values (GRADE,HOMEROOM,ROOM). Conservative path: add an enum value in the migration that lands the first role using it. - Reworking
narrowRolesForActiveProfile. The synthetic-profile prepend is harmless under OR + narrowedteacher: a profile=teacher user with no real RBAC grants hasctx.teacherDepartmentIds = [], so theteacherbranch returnsNEVER_MATCHand contributes nothing to the OR. - Narrowing the
staffbranch.staffstays'pass-through'in v1 — a staff-profile user's tenant-wide visibility is desired for now; write-side narrowing is already enforced by scope grants + FieldWriteGuard. Revisit when product asks. - Splitting the
assignmentscope dual-surface. Tracked separately. - Permission caching (Redis). Deferred; request-scoped memoization remains.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/common/utils/entity-access-policy.ts + access-context-helpers.ts |
The landed definePolicy + makeRoleVisibilityResolver shape — branches, pass-through marker, NEVER_MATCH_WHERE sentinel, platform-admin short-circuit. The new parametric branches are a strict superset of the existing branch type; policy.parametricDimensions is a new sibling of policy.roles derived from the new branch array. |
First-match resolution is replaced by OR composition; comments naming "synthetic staff" are corrected to "synthetic active-profile role (staff or teacher depending on the user's Person profile)" while editing each policy file. |
docs/superpowers/specs/2026-05-25-preset-management-roles-dept-scoped-assignment-design.md |
The dept-head record-narrowing matrix per entity (direct-FK for Students/Grades/Curricula/etc., relational join for Teachers/StudyPlans/SubjectGroups) is preserved verbatim — predicates move from role-keyed build functions to dimension-keyed narrow functions, predicate shape unchanged. |
UserRole.departmentId and its partial-unique-index dance are replaced by the new UserRoleParameter polymorphic-value join table. The INVALID_ROLE_ASSIGNMENT_DEPARTMENT error code generalizes to INVALID_ROLE_ASSIGNMENT_PARAMETER. |
docs/superpowers/specs/2026-05-26-entity-access-policy-design.md |
The lockstep-collapse motivation — @RequireRoles ↔ helper-branch drift was the load-bearing reason for definePolicy. The parametric design dissolves the lockstep further: for parametric roles, the policy doesn't enumerate role keys at all; it enumerates dimensions. Custom parametric roles plug in by data without policy edits. |
The role allowlist (policy.roles) auto-derivation from role-keyed branches is unchanged. The new policy.parametricDimensions is a parallel sibling, not a replacement. |
src/files/files.service.ts (polymorphic (ownerType, ownerId) pattern) and the File model in prisma/schema.prisma |
Polymorphic-ID precedent. UserRoleParameter.valueId is interpreted by UserRole.role.parameterDim (declared on Role) similarly to how File.ownerType declares the interpretation of File.ownerId. App-layer cleanup-on-target-delete (no native FK), drift-guard spec for orphans — same mitigation pattern. |
File carries the discriminator (ownerType) on the same row as the polymorphic ID. UserRoleParameter reads the discriminator off UserRole.role.parameterDim instead, because every parameter row under a UserRole shares the same dimension (the role declares it once). |
src/permissions/permissions.service.ts — getAccessContextSlice + fetchDeptHeadDepartmentIds |
The fast-path pattern: skip the DB hit when the active role keys can't possibly need a slice. Generalizes to "fetch parametric assignments only when active roles include any parametric role" and "fetch teacher department IDs only when 'teacher' is in active role keys". |
The single-purpose fetchDeptHeadDepartmentIds becomes a generalized fetchParametricAssignments returning a per-dimension map. Type signature widens; same query shape. |
src/teachers/ — TeacherDepartment (alias TeacherDepartmentAssignment) M2M |
Source of truth for teacher↔department links. getAccessContextSlice reads from this table to populate ctx.teacherDepartmentIds when 'teacher' is in active role keys. |
This is a domain table, not a role-parameter table. It stays unchanged; the parametric design is a consumer. |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Unchanged. buildBase continues to return { tenantId: ctx.tenantId }. Parametric branches return a narrowed version of base; the tenant predicate is preserved through all compositions. |
Tenant isolation is invariant. |
| Academic-year scope | yes | Unchanged per-entity opt-in. Year-snapshotted entity policies (Students, Teachers, Staff, Homerooms, SubjectGroups, Curricula, StudyPlans) keep their year handling at the service layer. Parametric narrowing operates on the same base WHERE. | No change in year semantics; parametric branches ride on the existing base. |
| RBAC entity key | existing — none added | Every policy still declares its entityKey. assertPolicyScopeAlignment boot-time check unchanged. |
The parametric refactor is orthogonal to entity identity. |
| Scopes | no changes | Scope-level enforcement remains in ScopeGuard + FieldWriteGuard + FieldFilterInterceptor. Parametric narrowing governs which records, not which fields. |
Out of scope. |
| Actions | no changes | Action-level enforcement remains in ActionGuard. Parametric values do not gate actions. |
Out of scope. |
| Service base | no changes to BaseTenantedCrudService itself |
Services consume <Entity>Policy.where(ctx) exactly as today. The OR composition lives inside the policy; services see the same TWhere | NEVER_MATCH_WHERE return type. |
The refactor is at the policy layer, not the base service. |
queries.ts shape |
unchanged | Include/select constants and named query functions per module are untouched. permissions.queries.ts widens fetchDeptHeadDepartmentIds → fetchParametricAssignments + adds fetchTeacherDepartmentIds. |
Existing convention preserved. |
| Error codes | one rename, no new codes net | INVALID_ROLE_ASSIGNMENT_DEPARTMENT → INVALID_ROLE_ASSIGNMENT_PARAMETER with params { roleKey: string, parameterDim: string \| null, reason: 'MISSING_VALUES' \| 'UNEXPECTED_VALUES' \| 'CROSS_TENANT' \| 'UNKNOWN_VALUE' }. The rename communicates that the validation generalizes beyond departments. |
Per feedback_breaking_changes_acceptable.md, no deprecation alias needed. |
| DTO conventions | no new DTOs in this iteration | UserRole assignment endpoints don't exist yet (no src/roles/ module). When they land, request DTOs will carry parameterValueIds: string[]. |
Out of scope (admin CRUD deferred). |
| File-backed sub-resources | n/a — no file surface | n/a | |
| Custom fields | n/a — no custom-field surface | n/a | |
| Profile completeness | n/a — completeness gates do not consult parametric data | n/a |
4. Data model plan¶
Schema deltas¶
- New enum
ParameterDim { DEPARTMENT }. Single value in v1; future values added one-at-a-time in the migration that lands the first role using each. Role.parameterDim ParameterDim?(nullable). Set at creation, immutable post-creation (service-layer assert; PATCH onRoledoes not exist today, but the assertion future-proofs).- New table
UserRoleParameter:model UserRoleParameter { id String @id @default(uuid()) @db.Uuid userRoleId String @map("user_role_id") @db.Uuid valueId String @map("value_id") @db.Uuid userRole UserRole @relation(fields: [userRoleId], references: [id], onDelete: Cascade) @@unique([userRoleId, valueId]) @@index([valueId]) @@map("user_role_parameters") } - Drop
UserRole.departmentIdcolumn + FK constraint +user_roles_department_id_idx. - Drop the composite unique
@@unique([userId, roleId, tenantId, departmentId])and the partial uniqueuser_roles_unique_when_no_department. Restore the simpler@@unique([userId, roleId, tenantId]).
Migration shape¶
- Destructive — drops a column with data. Per
feedback_breaking_changes_acceptable.md, we runnpm run docker:reset(drop dev DB, recreate from migrations, reseed). No data backfill. - Single Prisma migration produces SQL roughly:
-- New enum + table CREATE TYPE "ParameterDim" AS ENUM ('DEPARTMENT'); ALTER TABLE "roles" ADD COLUMN "parameter_dim" "ParameterDim"; CREATE TABLE "user_role_parameters" ( "id" UUID PRIMARY KEY, "user_role_id" UUID NOT NULL REFERENCES "user_roles"(id) ON DELETE CASCADE, "value_id" UUID NOT NULL, UNIQUE ("user_role_id", "value_id") ); CREATE INDEX "user_role_parameters_value_id_idx" ON "user_role_parameters"("value_id"); -- Drop the dept_head FK shape DROP INDEX "user_roles_unique_when_no_department"; DROP INDEX "user_roles_user_id_role_id_tenant_id_department_id_key"; DROP INDEX "user_roles_department_id_idx"; ALTER TABLE "user_roles" DROP CONSTRAINT "user_roles_department_id_fkey"; ALTER TABLE "user_roles" DROP COLUMN "department_id"; ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_user_id_role_id_tenant_id_key" UNIQUE ("user_id", "role_id", "tenant_id"); - Hazards from chapter 12 checklist: #1 (
DROP COLUMNof a non-null-required column) — accepted because we drop+reseed; not safe for prod data but prod doesn't exist yet. #2 (CREATE UNIQUE INDEX) — safe; the new constraint is at least as strict as the prior one when restricted to non-parametric roles (parametric assignments now live elsewhere). #7 (ADD CONSTRAINT FOREIGN KEYvia the new table) — safe; new table, no rows.
Indexes and uniqueness¶
UserRoleParameter @@unique([userRoleId, valueId])— a single parameter value cannot appear twice on the same assignment.@@index([valueId])onUserRoleParameter— for cascade-cleanup lookups when a dimension target (e.g.Department) is deleted at the app layer.UserRole @@unique([userId, roleId, tenantId])— restored simple unique. Parametric roles can still have multiple UserRoles per (user, role, tenant) only if they differ in time-window (validFrom/validUntil). Multiple parameter values per (user, role, tenant) live on a single UserRole'sUserRoleParameter[].
Cascade & cleanup¶
UserRoleParameter → UserRole: native PrismaonDelete: Cascade. Deleting a UserRole or a Role (cascade → UserRole) wipes the parameter rows.UserRoleParameter ← Department(and future dimension targets): app-layer cleanup.DepartmentsService.removedeletesUserRoleParameterrows wherevalueId = <deleted dept id>AND the parentUserRole.role.parameterDim = 'DEPARTMENT'. Symmetric pattern for future dimensions.- Drift-guard spec verifies no orphan
UserRoleParameterrows after seed (similar in spirit torbac-catalogue.drift.spec.ts).
5. API surface¶
No new URL routes. No request/response shape changes on existing routes. The contract is preserved end-to-end.
Decorator surface (unchanged)¶
Every existing @AppliesPolicy(<Entity>Policy) on every role-gated route stays as-is. The decorator continues to compose RequireRoles(...policy.roles) + SetMetadata(POLICY_METADATA, policy). The only diff is that policy itself now carries parametricDimensions and the resolver internally composes OR.
definePolicy signature¶
// src/common/utils/entity-access-policy.ts
export interface EntityAccessPolicy<TWhere> {
readonly entityKey: EntityKeyValue;
readonly roles: readonly string[]; // unchanged — from role-keyed branches in declaration order
readonly parametricDimensions: readonly ParameterDim[]; // NEW — from parametricBranches
readonly where: (ctx: RecordAccessContext) => TWhere | typeof NEVER_MATCH_WHERE;
}
export function definePolicy<TWhere>(args: {
entityKey: EntityKeyValue;
buildBase: (ctx: RecordAccessContext) => TWhere;
branches: ReadonlyArray<{
role: string;
build: RoleVisibilityBranch<TWhere>;
}>;
parametricBranches?: ReadonlyArray<{ // NEW
dim: ParameterDim;
narrow: (base: TWhere, valueIds: string[]) => TWhere;
}>;
}): EntityAccessPolicy<TWhere>;
Resolution algorithm (replaces makeRoleVisibilityResolver body)¶
function where(ctx): TWhere | typeof NEVER_MATCH_WHERE {
const base = buildBase(ctx);
if (ctx.isPlatformAdmin) return base;
const narrowings: TWhere[] = [];
// Role-keyed branches: any matching `pass-through` branch short-circuits.
// OR(base, narrow) reduces to base semantically — the first disjunct
// already matches every row the second does. Short-circuiting on
// pass-through gives the same result with a cleaner SQL predicate and
// simpler test assertions.
for (const branch of branches) {
if (!ctx.roles.includes(branch.role)) continue;
if (branch.build === 'pass-through') return base;
const w = branch.build(ctx, base);
if (w !== NEVER_MATCH_WHERE) narrowings.push(w);
}
// Parametric branches: contribute a narrowed where when the user has values
// in the corresponding dimension.
for (const branch of parametricBranches ?? []) {
const ids = ctx.parameters[`${branch.dim.toLowerCase()}Ids`];
if (ids.length === 0) continue;
narrowings.push(branch.narrow(base, ids));
}
if (narrowings.length === 0) return NEVER_MATCH_WHERE;
if (narrowings.length === 1) return narrowings[0];
// OR of pure narrowings only — pass-through has already short-circuited.
// Tenant filter is in every narrowing (via base), so OR preserves tenant
// isolation.
return { OR: narrowings } as TWhere;
}
Composition semantics summary:
| User has | Result |
|---|---|
| Platform admin | base |
Any role with a matching 'pass-through' branch (admin, hr, principal, staff) |
base (short-circuit) |
| One narrowing role-branch match | that narrowing |
| One parametric-branch match (non-empty dimension values) | that narrowing |
| Multiple narrowing matches across role-keyed and/or parametric branches | { OR: [narrowing₁, narrowing₂, ...] } |
No branches match (all NEVER_MATCH or no matches) |
NEVER_MATCH_WHERE |
RolesGuard extension¶
// src/permissions/guards/roles.guard.ts (existing — extend, don't replace)
canActivate(ctx: ExecutionContext): boolean {
const required = this.reflector.get<string[]>(ROLES_KEY, ctx.getHandler());
const policy = this.reflector.get<EntityAccessPolicy<unknown> | undefined>(
POLICY_METADATA, ctx.getHandler(),
);
const req = ctx.switchToHttp().getRequest();
if (req.user.isPlatformAdmin) return true;
// Today's check: any user role overlaps the required allowlist.
if (required?.some((r) => req.user.roles.includes(r))) return true;
// NEW: admit by parametric-dimension overlap. Lets future custom parametric
// roles enter via data — they aren't enumerated in policy.roles, but their
// dimension is.
if (policy?.parametricDimensions?.length) {
const userDims = req.user.parameterDimensions ?? [];
if (policy.parametricDimensions.some((d) => userDims.includes(d))) {
return true;
}
}
throw new ForbiddenException(/* ACTION_NOT_PERMITTED */);
}
req.user.parameterDimensions is populated by the same hook that builds request.permissions (via getAccessContextSlice). It is the set of ParameterDim values for which the user has any active UserRoleParameter row in this tenant.
Swagger considerations¶
INVALID_ROLE_ASSIGNMENT_DEPARTMENT→INVALID_ROLE_ASSIGNMENT_PARAMETERrename: updatesrc/common/constants/error-examples.tsand any Swagger error annotation referencing the old code. No new examples needed.- The Cross-Entity Preset Role Matrix in
docs/04-rbac.mdgains a small note thatdepartment_headnarrowing is now driven by the parametric model. No matrix cell changes (semantics unchanged).
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (prisma/seed/rbac-catalogue.ts) |
none |
PermissionAction (prisma/seed/rbac-catalogue.ts) |
none |
ScopeFieldMapping (prisma/seed/rbac-catalogue.ts) |
none |
Role grants (prisma/seed/roles.ts) |
department_head preset upsert gets parameterDim: 'DEPARTMENT' in its identity payload. All other preset roles (admin, hr, principal, teacherRole, referentRole) leave parameterDim = null explicit (default). The seed helper seedRoleForTenant accepts a new optional parameterDim argument. The description string for department_head updates from "the parameter lives on UserRole.departmentId" to "narrowing values live in UserRoleParameter rows". |
*_SCOPES runtime constant (src/common/constants/scope-fields.ts) |
none |
E2E fixtures that assign department_head to a test user move from prisma.userRole.create({ data: { ..., departmentId } }) to prisma.userRole.create({ data: { ..., parameters: { create: [{ valueId: deptId }] } } }). Mechanical rewrite in test helpers.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
First-match priority in makeRoleVisibilityResolver (every matching role branch evaluated in declaration order; first wins) |
Replaced with OR composition — every matching branch contributes a Prisma WHERE fragment, the policy returns { OR: [...] } of all contributions (or the single fragment when only one matches). 'pass-through' contributes the base WHERE; NEVER_MATCH_WHERE contributions are filtered out. |
Multi-role semantics: a teacher who is also a department_head should see the union of what each role grants visibility on, not whichever role happens to be declared first. The recipe's "branch order matters" warning was protecting against a teacher-pass-through stub widening dept_head visibility; we close the root cause by narrowing teacher properly. |
Slightly larger SQL on multi-role users (OR of several WHERE fragments instead of a single one). Tenant filter is in every contribution via base, so OR preserves tenant isolation. The branch-order comments in every policy file lose their load-bearing meaning and are deleted (or replaced with a one-liner noting that branch order is presentational only). |
teacher branch as 'pass-through' (TODO from the EntityAccessPolicy refactor) |
Replaced with a narrowing build: (ctx, base) => ctx.teacherDepartmentIds.length === 0 ? NEVER_MATCH_WHERE : { ...base, departmentId: { in: ctx.teacherDepartmentIds } } (or the per-entity equivalent — relational join for Teachers/StudyPlans/SubjectGroups, direct FK elsewhere). |
The pre-unification "teachers see all tenant students" behavior was a stub. The first version of teacher narrowing (this spec) follows the data we have today: TeacherDepartmentAssignment — a teacher sees students/teachers/curricula/etc. in the departments they are assigned to. A finer narrowing (per-class via SubjectGroupAssignment) is a future iteration. |
Profile=teacher users with zero TeacherDepartmentAssignment rows now see nothing (NEVER_MATCH contribution; OR'd with no other matching branch → NEVER_MATCH overall). This is the correct semantics — a teacher with no assignment shouldn't see arbitrary tenant data — but is a visible behavior change from today. Tests assert it explicitly. |
UserRole.departmentId FK + composite unique + partial unique (the 2026-05-25 design) |
Replaced by Role.parameterDim + UserRoleParameter(userRoleId, valueId) join table. UserRole returns to a simple @@unique([userId, roleId, tenantId]). |
Generalizes to N dimensions without adding columns. Custom parametric roles plug in by data. | valueId is polymorphic — no native FK. Cascade-on-target-delete is app-layer (precedent: File.ownerType + ownerId). Drift-guard spec catches orphans. Renamed error code INVALID_ROLE_ASSIGNMENT_DEPARTMENT → INVALID_ROLE_ASSIGNMENT_PARAMETER. |
Picker endpoints (findEligibleStudents in homerooms + subject-groups) hand-rolling isOnlyDeptHead-based filtering parallel to the policy |
Pickers compose StudentsPolicy.where(ctx) into their Prisma where. Empty visibility (NEVER_MATCH_WHERE) → return empty cohort early. isOnlyDeptHead helper deleted (no remaining call sites). |
Aligns the pickers with the EntityAccessPolicy pattern: policy WHEREs are the single source of record visibility. Closes a known drift surface where picker filtering and policy WHERE could diverge. Also unlocks correct multi-role visibility (a dept_head + teacher in different departments can now request a picker in their teaching dept). | Pickers gain a small amount of WHERE complexity (composed AND). Test specs for pickers update to the new shape. The isOnlyDeptHead(ctx) call sites (2 production files + spec) go away — small mechanical cleanup. |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| n/a — no user story drives this. The scope was distilled in the 2026-05-27 brainstorm with Fabio, building on the chapter 16 audit and the in-flight feedback that the 2026-05-25 dept_head FK was a one-shot hack. | n/a — Resolved |
9. Deferrals¶
- Custom-role admin CRUD endpoints — no
src/roles/orsrc/user-roles/module exists today. The schema supports custom parametric roles after this lands; the admin surface to create/assign them is a separate iteration. Follow-up: future spec when product asks for "schools can create custom roles". - Additional
ParameterDimvalues (GRADE,HOMEROOM,ROOM) — conservative path. Each future value lands in the migration of the first role that uses it (head_of_yearforGRADE,class_teacherforHOMEROOM, etc.). Follow-up: per-role spec at the time. - Finer teacher narrowing (per-class via
SubjectGroupAssignment/HomeroomAssignment) — v1 narrows teachers to theirTeacherDepartmentAssignmentset, mirroring dept_head. The finer narrowing (e.g. "see only students in classes you actually teach") is a future iteration when product confirms the requirement. Follow-up: revisit alongside teacher-assignment curation (see [[project_teacher_assignments_deferred_from_curation]]). - Reworking
narrowRolesForActiveProfile— the synthetic-profile prepend is harmless under this spec. If a future iteration needs to distinguish "real RBAC grant" from "synthetic profile marker" inctx.roles, that work lives in a follow-up that re-examines the active-profile design (docs/superpowers/specs/2026-04-27-active-profile-design.md). staffbranch narrowing —staffstays'pass-through'. A staff-profile user's tenant-wide read visibility is desired today; write-side narrowing is already enforced via scope grants + FieldWriteGuard. Revisit when product asks.- Permission caching — request-scoped memoization remains. Redis caching deferred per the EntityAccessPolicy spec.
10. Open questions¶
All resolved in the 2026-05-27 design discussion. Recorded here for traceability:
- ~~Single-dimension-per-role vs multi-dimension-per-role?~~ Resolved: single. Covers every foreseeable preset; admins can union via multiple roles; keeps aggregation flat.
- ~~Identity-narrowed roles (referent, student, teacher) — bring them under the parametric model?~~ Resolved: no. Their narrowing comes from user-identity → domain links, not from assignment-time parameters. They stay as role-keyed branches with per-policy
buildfunctions. - ~~Storage shape — polymorphic ID on UserRole vs
UserRoleParameterjoin table vs per-dimension tables?~~ Resolved:UserRoleParameterjoin table (polymorphicvalueId). Best balance of extensibility and FK integrity within the codebase's existing polymorphic precedents. - ~~Composition semantics — first-match priority vs OR?~~ Resolved: OR, conditional on narrowing
teacherin the same spec to close the pass-through stub that previously made first-match safer-looking. - ~~Bundle
teachernarrowing in this spec?~~ Resolved: yes. Narrow toTeacherDepartmentAssignmentdepartments — same shape as dept_head per entity. Required for OR semantics to be correct. - ~~Remove
staffbranch from policies?~~ Resolved: no. Keep as'pass-through'. The synthetic prepend is harmless under OR + narrowed teacher because staff-profile users either have a real RBAC role (which drives visibility) or get blocked by ScopeGuard (no scope grants). - ~~Bundle the picker refactor?~~ Resolved: yes. Aligns the pickers with the post-EntityAccessPolicy pattern; deletes
isOnlyDeptHead. - ~~
RecordAccessContextshape — flat or nested?~~ Resolved: nestedparameters: { ... }substruct. Bounded churn (mostly fixture renames), lasting clarity.teacherDepartmentIdsis a separate top-level field (it's identity-narrowed data, not parametric). - ~~Initial
ParameterDimenum size?~~ Resolved: conservative —DEPARTMENTonly. Future values added one-at-a-time with their consuming roles.
11. Verification plan¶
- Unit specs:
src/common/utils/entity-access-policy.spec.ts— rewrite to cover OR composition: multi-role users (e.g. dept_head + teacher in overlapping/disjoint depts), platform-admin short-circuit, NEVER_MATCH contribution filtering, single-contribution unwrap, all-empty → NEVER_MATCH. Add coverage forparametricBranchesevaluation.src/common/utils/access-context-helpers.spec.ts— first-match assertions removed;isOnlyDeptHeadtests deleted alongside the helper.NEVER_MATCH_WHEREshape test stays.src/common/decorators/applies-policy.decorator.spec.ts— extend:parametricDimensionsflows throughSetMetadata(POLICY_METADATA, policy)and is readable from the handler viaReflect.getMetadata.src/permissions/guards/roles.guard.spec.ts— add: parametric-dimension overlap admission, no overlap → 403, missing policy metadata → falls back to the existing role-overlap check, platform admin bypass.src/permissions/permissions.service.spec.ts— extendgetAccessContextSlice:- active roles include
'department_head'→ fetchesUserRoleParameterrows for DEPARTMENT dim, returnsparameters.departmentIds = [...] - active roles include
'teacher'→ fetchesTeacherDepartmentAssignmentids, returnsteacherDepartmentIds = [...] - active roles include neither → empty slice, no DB hit (fast-path)
- returns
parameterDimensions: ParameterDim[]reflecting which dims have non-empty values
- active roles include
src/permissions/permissions.queries.spec.ts— extend withfetchParametricAssignmentsandfetchTeacherDepartmentIds(replacingfetchDeptHeadDepartmentIdstests).- Every
src/<entity>/<entity>.policy.spec.ts— fixtures gainparameters: { departmentIds: [...] }andteacherDepartmentIds: [...]. Assertions rewrite:- Old:
'dept_head precedes staff'(priority) → New:'dept_head + staff → base (staff pass-through short-circuits, dept narrowing is dropped because base subsumes it)'. Important semantic note: the prior priority-ordering ensured the narrow won; under the pass-through short-circuit rule, the base wins (staff is intended tenant-wide). This is a deliberate behavior change documented in §7 divergence — write-side narrowing is preserved via scope grants + FieldWriteGuard. - Old:
'admin precedes dept_head'→ New:'admin + dept_head → base'(admin pass-through short-circuits). - New:
'dept_head + teacher in disjoint departments → OR of two IN-filters'(both contribute narrowings; OR composes). - New:
'dept_head with empty departmentIds + teacher with empty teacherDepartmentIds → NEVER_MATCH'(both narrowings are dropped, no other branch matches).
- Old:
src/homerooms/homerooms.service.spec.tsandsrc/subject-groups/subject-groups.service.spec.ts— picker refactor: assert thatfindEligibleStudentscomposesStudentsPolicy.where(ctx)into the WHERE; dept_head outside their depts → empty cohort; dept_head + teacher in disjoint depts → composed cohort.-
Drift-guard: a fresh seed produces zero orphan
UserRoleParameterrows (novalueIdreferencing a non-existent dimension target). -
E2E specs:
- Every existing entity E2E covering role-gated routes (
test/students.e2e-spec.ts,test/teachers.e2e-spec.ts,test/staff.e2e-spec.ts,test/departments.e2e-spec.ts,test/curriculum.e2e-spec.ts,test/homerooms.e2e-spec.ts,test/subject-groups.e2e-spec.ts,test/referents.e2e-spec.ts,test/academic-years.e2e-spec.ts, command-center e2es) — re-runs unchanged. Pass = behavioral parity preserved for single-role users. - Add a multi-role scenario E2E: a user holding
department_head+ a (synthetic-via-profile or real)teachershould see the union of dept-headed and teaching departments across Students, Teachers, Homerooms. -
test/permissions.e2e-spec.ts(if it exists, otherwise add) — RolesGuard admission via parametric-dimension overlap: a user with no role keys inpolicy.rolesbut withUserRoleParameterrows in a dimension the policy admits → passes RolesGuard; without overlap → 403. -
Manual verification:
- Seed dev DB. Assign
department_headto a test user via direct Prisma (no admin endpoint yet) — confirmUserRoleParameterrows are created;UserRole.departmentIdno longer exists. - Log in as that user;
GET /studentsreturns only students in their headed departments. - Re-assign the same user as a teacher in a different department (via
TeacherDepartmentAssignment); confirm the response now includes students from both the headed and teaching departments (OR composition working). - Confirm the homerooms picker correctly returns the dept_head's homerooms when querying their dept, and an empty cohort when querying a dept they neither head nor teach.
Patterns: chapter 09 (testing), and [[feedback_e2e_isolation_patterns]] for E2E discipline.
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-05-27
- Chat reference: Approved by Fabio in the 2026-05-27 brainstorm — Q1 horizon (b), Q2 single-dimension-per-role, Q3 identity-narrowed carve-out, Q4 OR semantics (re-confirmed at Q11), Q12 conservative DEPARTMENT-only enum, Q13 nested
parameterssubstruct, Q14 keep staff as pass-through, Q15 bundle picker refactor. Implementation completed 2026-05-27 in-session under "no commits between tasks; run spec + code review after each".
Implementation notes (post-landing, 2026-05-27):
- The e2e dept_head fixture switched from Staff Person profile to Teacher Person profile (with zero
TeacherDepartmentrows) so the synthetic active-profile prepend yields'teacher'not'staff'. Under the OR + pass-through-short-circuit resolver, a Staff-profile dept_head would otherwise be widened to tenant-wide reads by the staff pass-through branch on every entity policy — defeating the parametric DEPARTMENT narrowing the dept-head e2e suite verifies. The Teacher-profile fixture preserves the test intent: teacher branch contributesNEVER_MATCH(noTeacherDepartmentrows), parametric DEPARTMENT branch carries the narrowing. - Tests that asserted teacher pass-through visibility (e.g. teacher reading curricula across the tenant, teacher reading other departments' selection windows) were updated to assert the new narrowed semantics — see
test/curricula.e2e-spec.tsandtest/selection-window.e2e-spec.ts.