Split students.assignment — extract students.curriculum_selection and add per-scope route gating¶
1. Problem distillation¶
- The
students.assignmentscope was documented in chapter 04 (the "Dual-surface scope" callout) as gating BOTH (a) field-level access to theenrollmentDate / departmentId / gradeId / studentType / identificationCode / statuscolumns on thestudentstable, AND (b) the per-student curriculum-selection sub-resource atGET/PATCH /students/:id/curriculum-selection(RUS-4). The doc framed this as a deliberate convergence. - In code, the convergence is not enforced. The curriculum-selection routes use
@RequireScopes(STUDENTS, 'read'|'write').ScopeGuard.checkEntityAccessaccepts any student scope at the required level — a user withstudents.identity:WRITEandstudents.assignment:NONEpasses the gate. The actual enforcement is the route's@AppliesPolicy(CurriculumSelectionRead/WritePolicy)(admin/teacher/referent) + service-level window +StudentReferentLink.canWrite. The scope keyassignmentnever participates in the check. - The Parent role's
students.assignment:WRITEgrant is simultaneously load-bearing (in the doc's mental model) and a latent footgun (in the code). The seed grants referentsassignment:WRITE. The doc's footnote-2 claims this maps to the curriculum-selection sub-resource and "not the field-level dept/grade assignment columns." But because the sub-resource doesn't read the scope grant, andFieldWriteGuarddoes acceptassignmentas a body key for any user withassignment:WRITE, a referent could in factPATCH /students/:idwith{ assignment: { enrollmentDate: ... } }and have it succeed (record gate passes for linked students; FieldWriteGuard accepts the key). That isn't intended parent behavior. - The role matrix can't express the curriculum-selection axis. A school admin managing custom roles has no scope-level lever for "this role can edit curriculum selections". The information lives in
curriculum-selection.policies.ts(which roles are admitted) andcurriculum-selection.service.ts(the window/link gates). The matrix-as-source-of-truth contract from chapter 04 is not honored for this surface. - Sub-resource routes need a per-scope-key gate.
ScopeGuardtoday only knows "user has any scope on entity at level X". A separatestudents.curriculum_selectionscope without a per-scope-key check would not actually gate the route — we would just be adding a column to the matrix that no guard reads. The split has to come with a small new RBAC primitive (a singular@RequireScope(entity, scopeKey, mode)decorator) for the matrix to mean what it says.
Success criteria (observable behavior that proves this works):
- A new
students.curriculum_selectionscope key exists in the seed with zeroScopeFieldMappingrows (gate-only scope).STUDENT_SCOPES.curriculum_selection = []insrc/common/constants/scope-fields.ts. The drift-guard spec (prisma/seed/rbac-catalogue.drift.spec.ts) passes. - A new
@RequireScope(entity, scopeKey, mode)(singular) decorator + a newPermissionsService.checkScope(permissions, entity, scopeKey, mode)method exist.ScopeGuardreads the singular metadata when present and callscheckScope; falls back to the existing entity-widecheckEntityAccesswhen only@RequireScopes(plural) is on the handler. A handler cannot carry both forms — the guard rejects the request with a clear error at runtime, and a unit spec covers the contradiction case. CurriculumSelectionControllerswaps@RequireScopes(STUDENTS, 'read'|'write')→@RequireScope(STUDENTS, 'curriculum_selection', 'read'|'write')on both routes. Behavior for admin and referent under the standard preset matrix is unchanged.- The Parent (referent) preset's seed grant on
students.assignmentis narrowed fromWRITE→READ. The Parent gainsstudents.curriculum_selection:WRITE. A regression E2E asserts that a referent callingPATCH /students/:idwith{ assignment: { enrollmentDate: '…' } }now returns 403FORBIDDEN_FIELDS. - A user with
students.identity:WRITEandstudents.curriculum_selection:NONEis rejected byScopeGuardwhen callingPATCH /students/:id/curriculum-selection— the gap this spec closes. Asserted inscope.guard.spec.tsandcurriculum-selection.controller.spec.ts. GET /api/v1/permissionsincludes the newstudents.curriculum_selectionkey in the response payload for any role that holds a grant on it.getUserPermissionsDtoflows it through automatically (no DTO change).- Chapter 04 "Dual-surface scope" callout is replaced by two single-purpose entries (
assignmentis now field-level only;curriculum_selectionis gate-only). The cross-entity matrix gains acurriculum_selectioncolumn. Chapter 11 gains (or amends) the "Add a gate-only scope" recipe with the@RequireScopepattern.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- Student self-selection. The Student role's grant for
students.curriculum_selectionstays NONE in v1 — the surface for students editing their own selection lands in a follow-up. - Splitting
students.documents(currently maps tostudent_documents.*AND gates/students/:id/documents/*sub-routes). Similar shape, separate decision — out of scope here. - Renaming
students.assignment. The key stays; only its semantics narrow. - Any change to
CurriculumSelectionReadPolicy/CurriculumSelectionWritePolicy— the role/record-level filtering stays as-is. - Any change to the service-layer window +
canWritegate. - Custom-role admin CRUD endpoints (no
src/roles/module exists yet; same deferral as the parametric-roles spec). - Migrating other potential sub-resource gates (e.g. future
students.health_records,students.attendance_records) to@RequireScope. Done one-at-a-time when the surface lands.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/permissions/decorators/require-scope.decorator.ts + src/permissions/guards/scope.guard.ts |
SetMetadata + Reflector pattern, PermissionRequirement shape ({ entity, mode }), InsufficientScopeException for denial. The new @RequireScope emits parallel metadata under a sibling key (REQUIRED_SCOPE_KEY); ScopeGuard is extended to branch on metadata shape. |
The new metadata adds a third field (scopeKey). Plural @RequireScopes keeps its current shape — the singular form is additive, not a replacement. |
docs/superpowers/specs/2026-05-26-entity-access-policy-design.md |
Pattern of adding a small declarative primitive (@AppliesPolicy then; @RequireScope now) backed by a service method, with route-level decorator composition. The two decorators co-exist on curriculum-selection routes after this spec lands — @RequireScope for the scope gate, @AppliesPolicy for role + record gate. |
No boot-time invariant needed for this primitive: the new metadata is opt-in per route and doesn't introduce a drift surface like @AppliesPolicy did. The mutual-exclusion check between @RequireScopes plural and @RequireScope singular fires inside the guard, not at boot. |
prisma/seed/rbac-catalogue.ts — SCOPES.students.school_relationships + SCOPES.curricula.selection_window + SCOPES.referents.health |
Direct precedent for gate-only / scaffolding-only scopes. Three scopes in the current seed have zero ScopeFieldMapping rows: students.school_relationships (scaffolding, fields deferred), curricula.selection_window (descriptor-only), referents.health (reserved). The drift-guard spec already permits empty *_SCOPES arrays. The new students.curriculum_selection extends this pattern with explicit gating intent. |
None — the existing precedents are silent on intent (scaffolding, descriptor, reserved). This spec is the first to declare a scope as deliberately gate-only and to wire a per-scope-key guard against it. Chapter 04 + chapter 11 get updated to name the pattern. |
src/permissions/permissions.service.ts:checkEntityAccess |
Same ScopeAccess enum, same meetsAccess plumbing, same permissions.scopes[entity] lookup path. The new checkScope(permissions, entity, scopeKey, mode) is a one-key sibling of checkEntityAccess — reads entityScopes[scopeKey] instead of Object.values(entityScopes).some(...). No new caching layer; the per-request memoization on request.permissions already covers it. |
None — pure additive method. |
src/students/curriculum-selection.policies.ts |
Existing role/record-level enforcement stays. The policy continues to drive which records the caller can see; the new @RequireScope drives whether the caller has the gate at all. The two layers compose cleanly. |
Nothing about the policy file changes in this spec. |
docs/04-rbac.md §"Lookup GETs" + §"Recipe: scope spanning multiple tables" |
Pattern of adding a sub-section to chapter 04 documenting a new RBAC primitive with a "when to reach for it / when not" framing. The new "Gate-only scopes" subsection follows the same shape. | None — additive. |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Unchanged. CurriculumSelectionService already filters by tenantId; this spec only changes the route gate. |
Tenant isolation is invariant. |
| Academic-year scope | yes | Unchanged. The selection is year-scoped via the chosen Curriculum / StudyPlan. |
No change in year semantics. |
| RBAC entity key | existing — students |
No new entity. New scope under existing entity. | One entity, two surfaces — the whole point of this spec is to separate the gates without splitting the entity. |
| Scopes | new: students.curriculum_selection (gate-only, zero field mappings) |
New entry in SCOPES.students array. Empty array in STUDENT_SCOPES.curriculum_selection. No FIELD_MAPPINGS row. |
Gate-only sub-pattern; precedent in students.school_relationships, curricula.selection_window, referents.health (which are all gate-only by coincidence today). This spec is the first to declare gate-only as a deliberate sub-pattern. |
| Actions | none | No new action keys. Read/Write semantics handled by scope mode. | Consistent with [[feedback_rbac_actions_convention]] / [[feedback_rbac_no_update_action]]. |
| Service base | n/a — CurriculumSelectionService unchanged |
The gate is at the decorator/guard layer, not the service. | This is a permissions-layer change. |
queries.ts shape |
unchanged | No new queries. | |
| Error codes | existing — INSUFFICIENT_SCOPE reused |
When the singular @RequireScope check denies, ScopeGuard throws InsufficientScopeException exactly as for the plural form. The log line includes the scope key for debuggability. The response shape is unchanged. |
One error code keeps the FE error surface stable; the log delta is server-side only. |
| DTO conventions | unchanged | WriteSelectionDto and SelectionReadResponseDto keep their current shape. Body stays under the historical assignment wrapper ({ assignment: { curriculumId, choices } }) for FE compatibility — we are NOT reshaping to { curriculum_selection: { ... } }. The wrapper used to participate in the scope-grouped PATCH convention; under the singular @RequireScope, FieldWriteGuard skips per-key WRITE checks on this route (sub-resource body, not scope-grouped), so the wrapper is now FE-compat shape rather than a permission contract. See divergence ledger §7 row 4. |
Body-reshape was considered (would have leveraged FieldWriteGuard automatically) and rejected: it solves only writes, asymmetric for reads, and forces a body-shape change that exists solely to satisfy a guard. @RequireScope is the symmetric, declarative choice. |
| File-backed sub-resources | n/a | ||
| Custom fields | n/a — gate scope has no fields, so no customFields JSONB key |
A gate-only scope does not surface a { scope: { customFields } } group in any response. |
Custom fields are intrinsically field-grouped; gate-only is a different shape. |
| Profile completeness | n/a | Curriculum-selection completeness is governed by the study-plan-selections command-center surface, not by missingFields on the Student response. |
No COMPLETION_REQUIRED_REGISTRY change. |
4. Data model plan¶
Schema deltas¶
None. This spec is purely a permission-layer change. No new tables, columns, FKs, enums, or indexes.
Migration shape¶
- No Prisma migration.
npx prisma migrate devis not run. - Seed delta only. Re-run
npx prisma db seedafter pulling.
Indexes and uniqueness¶
n/a — no schema changes.
5. API surface¶
| Verb | Path | Decorators (post-change) | Request DTO | Response DTO |
|---|---|---|---|---|
| GET | /students/:id/curriculum-selection |
@RequireScope(EntityKey.STUDENTS, 'curriculum_selection', 'read') + @AppliesPolicy(CurriculumSelectionReadPolicy) + @AggregateResponse() |
n/a | SelectionReadResponseDto (unchanged) |
| PATCH | /students/:id/curriculum-selection |
@RequireScope(EntityKey.STUDENTS, 'curriculum_selection', 'write') + @AppliesPolicy(CurriculumSelectionWritePolicy) + @AggregateResponse() |
WriteSelectionDto (unchanged) |
SelectionReadResponseDto (unchanged) |
Decorator signature¶
// src/permissions/decorators/require-scope.decorator.ts — augment existing file
export const REQUIRED_SCOPE_KEY = 'requiredSpecificScope';
export interface SpecificScopeRequirement {
entity: EntityKeyValue;
scopeKey: string;
mode: 'read' | 'write';
}
export const RequireScope = (
entity: EntityKeyValue,
scopeKey: string,
mode: 'read' | 'write',
) =>
SetMetadata(REQUIRED_SCOPE_KEY, {
entity,
scopeKey,
mode,
} satisfies SpecificScopeRequirement);
ScopeGuard extension¶
// src/permissions/guards/scope.guard.ts — extend canActivate
const specificReq = this.reflector.get<SpecificScopeRequirement | undefined>(
REQUIRED_SCOPE_KEY,
context.getHandler(),
);
const entityReq = this.reflector.get<PermissionRequirement | undefined>(
PERMISSIONS_KEY,
context.getHandler(),
);
if (specificReq && entityReq) {
// Mutually exclusive — fail loudly. A route picks one gate, not both.
this.logger.error(
`Route declares both @RequireScopes and @RequireScope — pick one`,
);
throw new AppException(
ErrorCode.NO_AUTHENTICATED_USER, // placeholder — see error-code note below
'Conflicting scope decorators on route',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
if (!specificReq && !entityReq) return true;
// … existing platform-admin bypass + permission load …
if (specificReq) {
const ok = this.permissionsService.checkScope(permissions, specificReq);
if (!ok) throw new InsufficientScopeException();
return true;
}
// fall through to existing checkEntityAccess flow
Open question on the error code for the contradictory-decorator case is resolved in §10: introduce ErrorCode.SCOPE_DECORATOR_MISCONFIGURED (server-side dev error, returned as 500 — never surfaced to clients in well-formed builds; the boot-time scanner described below catches it before requests arrive).
PermissionsService.checkScope¶
checkScope(
permissions: CompiledPermissions,
requirement: SpecificScopeRequirement,
): boolean {
const entityScopes = permissions.scopes[requirement.entity];
if (!entityScopes) return false;
const access = entityScopes[requirement.scopeKey] ?? ScopeAccess.NONE;
const requiredLevel =
requirement.mode === 'read' ? ScopeAccess.READ : ScopeAccess.WRITE;
return meetsAccess(access, requiredLevel);
}
Boot-time invariant (scope-decorator exclusivity)¶
To prevent runtime-only detection of @RequireScopes + @RequireScope on the same handler, add a boot-time scanner in the same style as assertPolicyScopeAlignment:
// src/common/dto/scope-decorator-invariant.ts (new file)
// Walks every controller; fails dev/CI if a handler carries both
// PERMISSIONS_KEY and REQUIRED_SCOPE_KEY metadata. Production logs and proceeds.
// Wired in src/main.ts alongside the two existing invariants.
This is sync metadata on handlers (not return-type erasure), so the scanner is full-coverage — no async-erasure caveat like assertAggregateResponseInvariant.
Swagger considerations¶
GET /api/v1/permissionsautomatically includesstudents.curriculum_selectiononce seeded —getUserPermissionsDtoiteratespermissions.scopes[entity]and emits each key. No DTO change.- The two curriculum-selection routes' Swagger error annotations are unchanged:
@ApiInsufficientScope()(if present) still applies. - No new
error-examples.tsentries needed —SCOPE_DECORATOR_MISCONFIGUREDis an internal misconfiguration that doesn't reach end users when the boot-time scanner is in place.
6. RBAC seed plan¶
| Seed file | Delta | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
PermissionScope (prisma/seed/rbac-catalogue.ts) |
Add { key: 'curriculum_selection', label: 'Curriculum Selection', description: 'Per-student curriculum selection (option-block choices)', sortOrder: 8 } under SCOPES.students (after referents, sortOrder 7). |
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
PermissionAction |
none | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
ScopeFieldMapping |
none — gate-only scope has zero mappings (see §7 divergence) | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Role grants (prisma/seed/roles.ts) |
Role grants on
|
| Role | Access | Why |
|---|---|---|
| admin | WRITE | Full control. |
| hr | WRITE | Confirmed in brainstorm — broad student-onboarding remit; pairs naturally with admin grants on the same entity. |
| principal | READ | Read-only across the school. |
| department_head | WRITE | Same access level as their other Students grants; record-level narrowing handled by StudentsPolicy / CurriculumSelectionReadWritePolicy (dept-head branch when the parametric-roles spec lands; until then, this role doesn't reach this surface in production presets). |
| teacher (internal) | READ | Read-only — same as students.assignment today. |
| teacher (external) | READ | Same as internal teacher for this scope. |
| staff (internal) | READ | Read-only. |
| staff (external) | NONE | No access. |
| accountant | NONE | Curriculum selection is not financial. |
| admissions_officer | WRITE | Confirmed in brainstorm — admissions sets the initial curriculum at enrollment. |
| referent (parent) | WRITE | The load-bearing grant for the parent workflow; previously implicit via students.assignment:WRITE (which never actually gated the sub-resource), now explicit. |
| student | NONE | Self-selection deferred — see §9. |
Role grant delta on students.assignment¶
| Role | Before | After | Reason |
|---|---|---|---|
| referent | WRITE | READ | Closes the latent footgun: today's grant authorizes PATCH /students/:id with { assignment: { enrollmentDate, studentType, identificationCode, status } } for any referent on a linked student. That was never intended behavior (per the chapter-04 footnote, which was wishful — the code doesn't restrict referent field-level writes the way the doc claimed). After this spec, referents have READ on assignment (matrix consistency with their child's record visibility) and WRITE on the new curriculum_selection scope (their actual workflow). |
All other role grants on students.assignment are unchanged.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
Every permission_scopes row maps to one or more scope_field_mappings rows (the "scopes group fields" rule from chapter 04 §"The Entity-Scope Permission Model") |
Adding students.curriculum_selection with zero scope_field_mappings rows, declared as gate-only by intent. |
Sub-resources with @AggregateResponse() bypass FieldFilterInterceptor entirely; their permission gate is a logical reachability check, not a field-group filter. Mapping the scope to fields on related tables (e.g. student_curriculum_selections.*) would be cosmetic — FieldFilterInterceptor never sees those rows in the sub-resource response. Precedent already exists (students.school_relationships, curricula.selection_window, referents.health) but as side-effects, not declared intent. This spec names the pattern. |
Chapter 04 gets a new "Gate-only scopes" subsection that distinguishes field-group scopes (drive FieldFilterInterceptor stripping + FieldWriteGuard body-key checks) from gate-only scopes (drive only @RequireScope route gates). Chapter 11 "Add a new scope" recipe gets a gate-only variant. Future maintainers reading the matrix can tell which is which by the empty STUDENT_SCOPES.curriculum_selection array. |
ScopeGuard enforces "user has ANY scope on entity at mode X" via @RequireScopes(entity, mode) plural |
Adding @RequireScope(entity, scopeKey, mode) (singular) + PermissionsService.checkScope(...) + a ScopeGuard branch that handles the singular form when present. |
The plural form is the right tool for multi-scope routes (e.g. PATCH /students/:id accepting any of identity / contacts / health / … in the body — FieldWriteGuard then per-key-checks). The singular form is the right tool for routes that gate on exactly one scope — sub-resources today, more later. Mixing them on one route is a bug; the guard fails the request and the boot-time scanner fails the build. |
One new decorator + one new service method + one guard branch + one boot-time scanner. ~50 lines net. Mutual exclusivity is enforced at boot-time (chapter-04 invariant section gets a new entry); runtime path returns 500 with SCOPE_DECORATOR_MISCONFIGURED if the build slipped through. |
Referent preset role holds students.assignment:WRITE (and the chapter-04 matrix footnote-2 explains the intent as "this maps to curriculum-selection, not field-level columns") |
Narrow referent's students.assignment to READ; introduce students.curriculum_selection:WRITE as the explicit grant. |
The current state is dishonest in two directions: the grant does NOT gate curriculum-selection (the policy + service do), and it DOES authorize field-level PATCH of assignment columns on linked students (the footnote's "not the field-level columns" claim is wrong against the code). The matrix should mean what it says — split the grants so each one corresponds to one observable capability. | Breaking change to the referent preset's effective field-level capabilities on assignment columns. Per feedback_breaking_changes_acceptable.md, no shim. FE was never expected to PATCH assignment fields as a referent (no such surface exists in the FE prototype). Regression E2E asserts the new 403. |
Body shape for PATCH /students/:id/curriculum-selection is flat ({ curriculumId, choices, ... }) |
Keep the flat body. | The brainstorm considered reshaping the body to { curriculum_selection: { ... } } so FieldWriteGuard would per-key-check the scope name for free. Rejected: it solves only writes (reads have no body), forces an FE-visible shape change that exists only to satisfy a guard, and would conflict with the @AggregateResponse() symmetry on the read route. @RequireScope is the symmetric, declarative choice. |
One small new RBAC primitive instead of a body reshape. Future sub-resource gates use the same decorator without body acrobatics. |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| n/a — no user story drives this. The work was sized from the 2026-05-27 brainstorm with Fabio after the RBAC-model audit surfaced the divergence between the chapter-04 "Dual-surface scope" claim and the code's actual enforcement. | n/a — Resolved |
9. Deferrals¶
- Student self-selection —
students.curriculum_selectiongrant for thestudentrole stays NONE in v1. The surface for students editing their own selection (with appropriate gating viaassertCallerIsSelfOrAdmin+ open-window) is a follow-up. Follow-up: revisit alongside the RUS-4 student-side surface. - Splitting
students.documents— analogous shape (the scope maps tostudent_documents.*field columns AND gates/students/:id/documents/*sub-routes). Could be split intostudents.document_files(field-group, for the JSONB columns and identity-document FK columns) +students.document_routes(gate-only, for the sub-resource). Out of scope; revisit when product asks or when another sub-resource bug surfaces on that key. Follow-up: ad-hoc spec at the time. - Migrating other sub-resource gates to
@RequireScope— none exist today besides curriculum-selection. As new sub-resource routes land (potentialstudents.health_records,students.attendance_records, financial sub-resources), they should adopt@RequireScopefrom day one. Pattern documented in chapter 04 + chapter 11. - Custom-role admin CRUD endpoints — same deferral as the parametric-roles spec. No
src/roles/module exists; admins can't create custom roles via API today. - Performance optimization —
checkScopereads from the same per-request memoizedrequest.permissions. No additional cache.
10. Open questions¶
All resolved in the 2026-05-27 brainstorm; recorded here for traceability:
- ~~Scope key naming (
curriculum_selectionvscurriculumvselectivesvsselection)?~~ Resolved:curriculum_selection. Verbose but unambiguous;curriculumcollides with thecurriculaentity,electivesmis-describes (mandatory option blocks exist),selectiontoo generic for future expansion. - ~~Field mappings — zero or comprehensive?~~ Resolved: zero (gate-only). Declared as a deliberate sub-pattern, documented in chapter 04 + chapter 11.
- ~~Per-scope gating mechanism — new
@RequireScopedecorator, body-reshape, or manualPermissionsServicecalls?~~ Resolved: new@RequireScope(singular) decorator. Body-reshape was rejected (asymmetric reads/writes); manual calls inverts the EntityAccessPolicy lesson (declarative beats imperative). - ~~HR/Secretary access level on
curriculum_selection?~~ Resolved: WRITE. Matches their broad student-onboarding remit. - ~~Admissions Officer access level on
curriculum_selection?~~ Resolved: WRITE. They place students into curricula at enrollment. - ~~Build as a reusable pattern (vs one-off for this surface)?~~ Resolved: build as reusable. More sub-resource gates expected; documenting the pattern once is cheaper than three ad-hoc gates.
- ~~Close the parent's latent field-level assignment-PATCH capability?~~ Resolved: yes. Narrow referent's
students.assignmentfrom WRITE to READ; the curriculum-selection capability lives on the new scope. - ~~Error code for the mutually-exclusive
@RequireScopes+@RequireScoperuntime case?~~ Resolved: newErrorCode.SCOPE_DECORATOR_MISCONFIGURED(server-side; the boot-time scanner catches it before requests in well-formed builds, so it's effectively dead code in production). - ~~Need a boot-time scanner for the new exclusivity invariant?~~ Resolved: yes. Same
DiscoveryService+MetadataScannerpattern asassertPolicyScopeAlignment; sync metadata so no async-erasure caveat.
11. Verification plan¶
- Unit specs:
src/permissions/permissions.service.spec.ts— newcheckScopecases: returns true whenpermissions.scopes[entity][scopeKey]≥ required level; false when entity absent, scope absent, NONE, or below required level. Covers bothreadandwritemodes.src/permissions/decorators/require-scope.decorator.spec.ts— extend:RequireScope(entity, scopeKey, mode)emitsSpecificScopeRequirementmetadata underREQUIRED_SCOPE_KEY, distinguishable from the existingPERMISSIONS_KEY(plural form).src/permissions/guards/scope.guard.spec.ts— extend:- Singular metadata present → calls
checkScope; passes when access ≥ required, throwsInsufficientScopeExceptionotherwise. - Plural metadata present → existing
checkEntityAccesspath unchanged. - Both present → throws
AppException(SCOPE_DECORATOR_MISCONFIGURED, 500)(or whatever the final code is) with a log line naming both keys. - Neither present → passes through (existing behavior).
- Platform admin bypass unchanged for both forms.
- Singular metadata present → calls
src/common/dto/scope-decorator-invariant.spec.ts(new file) — fixture controller with one good route, one route carrying both decorators; assertion that the scanner reports the offending handler in strict mode (throws), and logs in non-strict (production) mode.src/students/curriculum-selection.controller.spec.ts— replace@RequireScopes(STUDENTS, 'read'|'write')assertions with@RequireScope(STUDENTS, 'curriculum_selection', 'read'|'write')assertions. New negative test: user withstudents.identity:WRITEandcurriculum_selection:NONE→ ScopeGuard rejects.prisma/seed/rbac-catalogue.drift.spec.ts— assert thatSCOPES.studentscontainscurriculum_selectionANDSTUDENT_SCOPES.curriculum_selection = []is present (drift-guard exists today; this just adds a new row that satisfies it).-
prisma/seed/roles.spec.ts(or wherever role-grant tests live) — assert the new grants table (admin WRITE, hr WRITE, principal READ, internal teacher READ, external teacher READ, internal staff READ, external staff NONE, admissions WRITE, referent WRITE, student NONE, accountant NONE). -
E2E specs:
test/curriculum-selection.e2e-spec.ts— extend:- Admin + open window + valid payload → 200 (existing).
- Referent + open window + linked student with
canWrite=true+ valid payload → 200 (existing; now driven bycurriculum_selection:WRITEinstead ofassignment:WRITE). - Teacher (read-only on
curriculum_selection) calling PATCH → 403 INSUFFICIENT_SCOPE (new — the scope key is now what's checked). - Custom-role test fixture with
students.identity:WRITEonly → PATCH/students/:id/curriculum-selection→ 403 INSUFFICIENT_SCOPE (regression — the exact gap this spec closes).
test/students.e2e-spec.ts— extend:- Referent + linked student +
PATCH /students/:idwith{ assignment: { enrollmentDate: '…' } }→ 403 FORBIDDEN_FIELDS (regression: the referent no longer holdsstudents.assignment:WRITE). - Referent + linked student +
GET /students/:id→ response still includes theassignmentscope group (READ access retained).
- Referent + linked student +
-
test/permissions.e2e-spec.ts(or wherever/api/v1/permissionsis exercised) — admin response includesstudents.curriculum_selection: WRITE; referent response includes bothstudents.curriculum_selection: WRITEandstudents.assignment: READ(narrowed). -
Manual verification:
npm run docker:reset && npx prisma db seed. ConfirmGET /api/v1/permissionsas admin includesstudents.curriculum_selection: WRITE; as referent (linked-parent fixture) includes bothstudents.curriculum_selection: WRITEandstudents.assignment: READ.- As referent: PATCH
/students/:id/curriculum-selectionwith a valid payload → 200. PATCH/students/:idwith{ assignment: { enrollmentDate: '2026-09-01' } }→ 403FORBIDDEN_FIELDS. - As admin: full read/write on both surfaces unchanged.
- Confirm boot logs show the new
assertScopeDecoratorExclusivity(...)(or equivalent name) invariant ran with zero violations.
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 chat 2026-05-27 after the RBAC-model audit identified
students.assignmentas the next cleanup; brainstorm resolved six design questions (scope key naming, gate-only field mappings,@RequireScopedecorator, HR/admissions WRITE, parent assignment W→R, pattern documented as reusable); three open flags (boot-time scanner, new error code, dept_head grant) acknowledged with "go on".