EntityAccessPolicy — collapse the lockstep surface between the role gate and record visibility¶
1. Problem distillation¶
The "which roles may call this route, and which records do they see" contract today lives in three loose pieces per entity:
@RequireRoles(...)on the controller method (string allowlist, opt-in)<entity>ForAccessContext(ctx)insrc/<module>/<entity>.access-context.ts(or*.queries.tsfor the older entities) — switches onctx.rolesto produce thePrisma.<Entity>WhereInput- Per-entity unit tests asserting the first two agree
This is a lockstep invariant the type system does not enforce. Adding a role to the decorator without adding a branch silently returns empty results (the helper falls through to NEVER_MATCH_WHERE). Adding a branch without adding the role to the decorator silently returns 403 at RolesGuard. Both shapes are drift the compiler can't see; both shapes get caught only by tests written after the bug ships.
Two adjacent foot-guns share the same root cause:
@AggregateResponse()opt-out (16 §5.5). Adding a non-CRUD endpoint to an entity controller requires the decorator AND a response DTO extendingAggregateResponseDto. Forgetting the decorator strips the response to{}for tenant users; platform admins bypass the interceptor, masking the bug in dev. The runtime safety-net assertion inFieldFilterInterceptorcatches scope-name collisions in dev/test but only logs in prod.assertYearWritable()in import pipelines. The base service auto-calls it on single-record CRUD, but import callers must call it explicitly beforerunImportPipeline(...). One more thing the new contributor has to remember.
Same root pattern: the permission contract for a route is decomposed into independent pieces, and consistency is a test invariant rather than a type or boot-time invariant.
Success criteria (observable behavior that proves this works):
- The set of roles a controller method admits, and the set of roles the helper branches on, share a single declared source. Removing a branch automatically shrinks the role allowlist; the type system makes the desync impossible.
- Adding
@RequireRolesseparately to a controller is no longer the recipe. The recipe is "reference the policy in@AppliesPolicy(...)." - The
@AggregateResponse()decorator footgun is closed at boot for synchronous controller methods: a method whose declared return type directly extendsAggregateResponseDtoand is missing the marker fails app startup in dev/CI. Async handlers (Promise<X>) are silently skipped — TypeScript'semitDecoratorMetadataerases the generic argument and there is no runtime hook to recover the inner type. TheFieldFilterInterceptorruntime safety-net remains the primary detection surface for async routes. See the inline comment insrc/common/dto/aggregate-response-invariant.tsfor the full caveat and the planned-but-deferred path through Swagger's@ApiOkResponse({ type })metadata. (Landed-state note added post-implementation 2026-05-26.) assertYearWritableis impossible to forget in an import pipeline — the orchestrator owns the call.- Students is migrated end-to-end as the proof; behavior is unchanged (existing E2E suite passes without modification).
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- Replacing the Entity-Scope-Action model.
ScopeGuard,ActionGuard,FieldWriteGuard,FieldFilterInterceptorstay unchanged. - Touching the request pipeline order (JwtAuthGuard → ScopeGuard → ActionGuard → FieldWriteGuard → RolesGuard → Controller → FieldFilterInterceptor).
- Resolving the
assignmentscope dual-surface overload (field-level + curriculum-selection sub-resource). Tracked separately — too entangled with the dept-head / referentcanWritelogic to bundle. - Splitting the Students entity into smaller modules. Tracked separately.
Scope decision (2026-05-26) — every role-aware entity migrates in this iteration, not just Students. Earlier draft restricted scope to "Students as proof, others on touch"; the user (Fabio) widened the scope in chat so the codebase ends this work with one consistent recipe rather than a hybrid. The plan (
../plans/2026-05-26-entity-access-policy.md) lists every entity touched.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/common/utils/access-context-helpers.ts — makeRoleVisibilityResolver, NEVER_MATCH_WHERE, RoleVisibilityBranch<TWhere>, isOnlyDeptHead |
Per-role branch composition with explicit branch-ordering semantics (dept_head before staff/teacher). Platform-admin short-circuit. Defensive fall-through to NEVER_MATCH_WHERE. The wrapped resolver already returns a typed Prisma.<Entity>WhereInput or sentinel. |
The helper returns only the resolver function. The policy needs the role list (branches.map(b => b.role)) back out for the decorator binding. Wrap it; don't replace it. |
src/homerooms/homerooms.access-context.ts + src/subject-groups/subject-groups.access-context.ts |
Canonical entity-access-context shape — declarative branches array, role + build per entry. Branch-order comment is preserved verbatim. These two modules already factor the helper into its own file. |
Older entities (students.queries.ts, teachers.queries.ts, staff.queries.ts, departments.queries.ts, curriculum.queries.ts, selection-window.queries.ts) still inline the helper in *.queries.ts. Migration extracts each to <entity>.access-context.ts mirroring the Homerooms/SubjectGroups shape. |
src/common/decorators/protected-resource.decorator.ts — @ProtectedResource() composes JwtAuthGuard + ScopeGuard + ActionGuard + FieldWriteGuard + FieldFilterInterceptor + @ApiBearerAuth |
Decorator-composition recipe. @AppliesPolicy(policy) composes Roles(...policy.roles) + SetMetadata(POLICY_METADATA, policy) exactly the same way. |
@ProtectedResource() is class-level. @AppliesPolicy() is method-level by default (per-route policy) with class-level opt-in for entity-wide policies. |
src/common/dto/aggregate-response.dto.ts + @AggregateResponse() decorator + FieldFilterInterceptor runtime safety-net assertion (per 04-rbac.md "Interceptor Contract") |
The brand class + the runtime assertion are kept. We add a boot-time scan that finds the inverse case — a controller method whose declared return type extends the brand but lacks the decorator. | The current decorator is a SetMetadata mark. We keep the decorator but make its omission detectable at boot via reflect-metadata. |
docs/superpowers/specs/2026-05-25-preset-management-roles-dept-scoped-assignment-design.md |
The RecordAccessContext.departmentIds shape. The "every dept_head's JWT carries the synthetic staff profile role" branch-order rationale. The isOnlyDeptHead helper invariant. |
Fits cleanly. This spec is a direct iteration on the role-context infrastructure that one introduced. |
docs/16-maintainability.md §4.4 ("Add a new role"), §5.5 ("Aggregate-response opt-out is a perennial foot-gun"), §7.3 ("Define the non-fail-soft cross-module side-effect pattern before the second feature needs it") |
Names the exact two foot-guns this spec closes. §4.4 documents makeRoleVisibilityResolver as the existing per-role factor; the missing half is the decorator binding. §7 (recommendations) sizes this work as "bounded, none are urgent." |
n/a — fits cleanly; this spec is the §7-recommendation surface for §5.5 + §4.4. |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Unchanged. buildBase(ctx) returns { tenantId: ctx.tenantId } exactly as it does today inside makeRoleVisibilityResolver. The policy is a strict wrapper, not a replacement. |
Tenant-scope invariant is enforced one layer below the policy. |
| Academic-year scope | yes | Per-entity opt-in. Policies for year-snapshotted entities (Students, Teachers, Staff, Homerooms, SubjectGroups) extend buildBase with academicYearId: ctx.academicYearId where applicable. Configuration entities (Rooms, Departments, RoomType, etc.) keep their existing year-handling. |
No change in semantics; the wrapper passes the year through. |
| RBAC entity key | existing — none added | Every policy declares its entityKey: EntityKey. Surfaced for tooling (boot-time drift check verifies @RequireScopes(entityKey, ...) on the route matches the policy's entityKey) and for future audit/telemetry. |
Doc-only today; future-proof for a "policy drift" check that ties policy ↔ route ↔ catalogue. |
| Scopes | no changes | Scope-level enforcement remains in ScopeGuard + FieldWriteGuard + FieldFilterInterceptor. Policies are orthogonal to scopes — they govern which records, not which fields. |
Out of scope (§1 non-goal). |
| Actions | no changes | Action-level enforcement remains in ActionGuard. Policies do not gate actions. |
Out of scope. |
| Service base | no changes to BaseTenantedCrudService itself |
Services consume policy.where(ctx) directly. The base service's getBaseWhere() is unaffected; the policy's output is ANDed in by the service exactly as *ForAccessContext is today. |
Refactor is decorator + helper composition, not base service surgery. |
queries.ts shape |
unchanged except for migrated entities | <entity>.queries.ts keeps include/select constants + named pure functions. The role-branch helper moves out of queries (for older entities) into <entity>.access-context.ts as part of the migration, mirroring the Homerooms/SubjectGroups shape already documented in 16-maintainability.md §2.2. |
Already the documented direction; this spec finishes the moves. |
| Error codes | none added | Unrecognised role still falls to NEVER_MATCH_WHERE; existing 403 INSUFFICIENT_SCOPE / ACTION_NOT_PERMITTED unchanged. |
Policy is plumbing; semantics unchanged. |
| DTO conventions | none added; aggregate-response brand is reinforced | Boot-time check: every controller method whose declared return type extends AggregateResponseDto must carry @AggregateResponse(). Failure mode: app startup error in development/test; warn + throw-on-first-request in production (mirrors how FieldFilterInterceptor's scope-collision check already triages). |
Closes 16 §5.5 footgun. |
| File-backed sub-resources | n/a — no file-backed surface added | n/a | |
| Custom fields | n/a — no custom-field surface added | n/a | |
| Profile completeness | n/a — completeness gates do not consult policies (they consult request.permissions) |
n/a |
4. Data model plan¶
Schema deltas¶
None. This is a code-shape refactor.
Migration shape¶
n/a — no DB migration.
Indexes and uniqueness¶
n/a.
5. API surface¶
No URL changes. No request/response shape changes. The contract is preserved end-to-end.
The decorator on existing routes flips from:
@Get(':id')
@RequireScopes(EntityKey.STUDENTS, 'read')
@RequireRoles('admin', 'teacher', 'referent', 'department_head')
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@AccessContext() ctx: RecordAccessContext,
) { ... }
to:
@Get(':id')
@RequireScopes(EntityKey.STUDENTS, 'read')
@AppliesPolicy(StudentsPolicy)
async findOne(
@Param('id', ParseUUIDPipe) id: string,
@AccessContext() ctx: RecordAccessContext,
) { ... }
@AppliesPolicy(StudentsPolicy) is applyDecorators(Roles(...policy.roles), SetMetadata(POLICY_METADATA, policy)). The existing RolesGuard keeps reading the @RequireRoles metadata — no guard changes. The added POLICY_METADATA is for the boot-time drift check and future tooling.
Policy definition shape¶
// src/common/permissions/entity-access-policy.ts
export interface EntityAccessPolicy<TWhere> {
readonly entityKey: EntityKey;
readonly roles: readonly string[]; // SOURCE OF TRUTH — derived from branches in declaration order
readonly where: (ctx: RecordAccessContext) => TWhere | typeof NEVER_MATCH_WHERE;
}
export function definePolicy<TWhere>(args: {
entityKey: EntityKey;
buildBase: (ctx: RecordAccessContext) => TWhere;
branches: ReadonlyArray<{
role: string;
build: RoleVisibilityBranch<TWhere>;
}>;
}): EntityAccessPolicy<TWhere>;
Service-side consumption¶
// Before — src/students/students.service.ts
const where = studentsForAccessContext(ctx);
const rows = await this.prisma.student.findMany({ where, ... });
// After
const where = StudentsPolicy.where(ctx);
const rows = await this.prisma.student.findMany({ where, ... });
Behavior identical. The migration of an entity is mechanically: rename + redirect imports.
Swagger considerations¶
None. The underlying @RequireRoles metadata is still applied by @AppliesPolicy; OpenAPI annotations are 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) |
none |
*_SCOPES runtime constant (src/common/constants/scope-fields.ts) |
none |
This refactor does not touch the RBAC catalogue. The policy abstraction is a consumption-side binding; the catalogue remains the source of truth for what scopes and actions exist.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
@RequireRoles(string[]) declared independently on the controller, with the matching role-branch logic separately maintained in <entity>ForAccessContext |
Replaced by @AppliesPolicy(policy), which derives the role allowlist from the same object that defines the per-role branches. |
Eliminates the controller-decorator ↔ access-context-helper lockstep that drives 16 §5.5 and §4.4 risk. The drift the test invariants currently catch becomes a type invariant. | One additional indirection (decorator → policy → role list). Same number of files to edit per entity at migration time; one fewer drift-prone surface afterwards. |
@AggregateResponse() SetMetadata-only marker, enforced at request time |
Add a boot-time invariant: any controller method whose declared return type extends AggregateResponseDto must carry the marker. Failure mode triages by NODE_ENV (hard fail in dev/test/CI; warn-then-runtime-error in prod). |
Forgetting the marker today returns {} to non-platform-admin callers and is invisible in dev when testing as platform admin. Boot-time check fails fast in CI. |
Slight startup overhead (one-time reflection scan of every controller); negligible. The decorator stays as the marker — only the detection of its absence becomes stricter. |
Year-writability assertion called by the import-route service handler (assertYearWritable(tenantId, yearId) before runImportPipeline(...)) |
Move the call inside runImportPipeline orchestrator. The pipeline already resolves the year (or accepts it from the caller); it asserts writability before invoking the four callbacks. |
One fewer pre-flight callers can forget. Symmetric with how BaseTenantedCrudService.create/update/remove auto-calls it. |
The pipeline's contract grows by one assertion; callers stop owning the gate. Acceptable — chapter 07 already documents "must call it explicitly," which is a code smell when the orchestrator has everything it needs. |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| n/a — no US is driving this. This is an internal refactor sized from the 2026-05-26 chapter 16 audit. The scope was distilled in chat on 2026-05-26 ("EntityAccessPolicy: collapse the lockstep surface between role gate and record visibility"). | n/a — Resolved |
9. Deferrals¶
- Type-level subset check across multiple policies on one route — a hypothetical route that joins two policies (cross-entity reads) is left case-by-case. No current routes do this; the abstraction supports it but doesn't ship tooling for it.
- Async-aware aggregate-response scanner — the boot-time
assertAggregateResponseInvariantonly catches synchronous handlers (see §1 footnote + inline comment insrc/common/dto/aggregate-response-invariant.ts). Pivoting to read the@nestjs/swagger@ApiOkResponse({ type })metadata would close the async-method gap because the stored class reference is generic-erased. Deferred — the runtimeFieldFilterInterceptorsafety-net is the de-facto detection surface today. assignmentscope dual-surface unwind — the field-level + curriculum-selection-sub-resource overload is tracked separately. Touches dept-head narrowing, referentcanWrite, and the curriculum-selection module; too entangled to bundle.- Replacing
@AggregateResponse()with a return-type-typed route helper (e.g.AggregateGet<T>(...)) — bigger ergonomic gain but bigger refactor. The boot-time check is the minimal fix that closes the footgun without rewriting every aggregate-route declaration. - Scope-groups refactor (ClickUp
86c8rqer6) — future replacement of the@AggregateResponse()bridge entirely. This spec adds the safety net; the refactor obsoletes the marker. Out of scope here. - A "policy drift" check verifying
@RequireScopes(entityKey, ...)on the route matchespolicy.entityKey— theentityKeyfield is added now to enable this, but the check itself ships in a follow-up iteration if drift shows up in practice.
10. Open questions¶
All resolved in the 2026-05-26 design discussion. Recorded here for traceability:
- ~~Should
EntityAccessPolicyexposeentityKey: EntityKey?~~ Resolved: yes. Surfaces it for the future "policy drift" check that ties policy ↔ route's@RequireScopes↔ catalogue. Zero cost to include now. - ~~Should
@AppliesPolicybe method-level only, class-level, or both?~~ Resolved: both. Mirrors@RequireRoles. Most use is method-level because different methods on the same controller may take different policies (e.g. an eligible-students picker uses a narrower policy than the mainfindAll). - ~~Should the boot-time aggregate-response check be a hard failure or a warn-then-runtime-fail?~~ Resolved: hard failure in
development/test/CI; warn-then-throw-on-first-request inproduction. Mirrors howFieldFilterInterceptor's scope-collision check already triages. - ~~Migration order — Students first, or one of the smaller entities?~~ Resolved: Students first as the proof, then every other role-aware entity in the same iteration (scope widened 2026-05-26 — see §1). Students validates the abstraction shape because it has the largest
@RequireRolesallowlists (4 roles on basic CRUD) and the most internal helpers consumingstudentsForAccessContext. The remaining entities follow the same recipe mechanically. Order: Students → Homerooms / SubjectGroups (already onmakeRoleVisibilityResolver, lowest risk) → Departments / Grades → Curricula / StudyPlans → SelectionWindows → Teachers → Staff → Referents → AcademicYears → School → command-center → Students/curriculum-selection.
11. Verification plan¶
- Unit specs:
src/common/permissions/entity-access-policy.spec.ts—definePolicyexposesrolesin branches' declaration order;wheredelegates tomakeRoleVisibilityResolverwith the same branches and produces identical output for sample contexts (platform admin → base; admin → base; dept_head with emptydepartmentIds→NEVER_MATCH_WHERE; dept_head withdepartmentIds→ narrowed; referent → linked-children path; unrecognised role →NEVER_MATCH_WHERE).src/common/decorators/applies-policy.decorator.spec.ts— verifies the decorator appliesRoles(...policy.roles)(same metadataRolesGuardalready reads) AND setsPOLICY_METADATA. Class-level and method-level both work. The method-level decorator overrides a class-level one on the same handler.src/common/dto/aggregate-response-invariant.spec.ts— boot-time scanner finds controller methods whose declared return type extendsAggregateResponseDtobut lack@AggregateResponse(). Two fixture controllers in the spec: one conformant (scanner passes), one violating (scanner throws with a clear message naming the offending controller + method).src/common/services/import-pipeline.spec.ts— extend existing tests to coverrunImportPipelinecallingassertYearWritablebefore the lookup-data callback; archived-year scenario fails withACADEMIC_YEAR_ARCHIVED. Caller no longer needs to pre-assert.src/<entity>/<entity>.policy.spec.ts— fresh spec per migrated entity covering<Entity>Policy.where(ctx)per role branch. Lifts existing coverage from*.queries.spec.ts(Students, Teachers, Staff, Departments, Grades, Curricula, StudyPlans, SelectionWindows) and from the existing*.access-context.spec.ts(Homerooms, SubjectGroups). Old*ForAccessContexthelper tests are deleted as part of the migration; the new policy spec is the canonical role-branch test surface per entity.- E2E specs:
- Every existing entity E2E that covers 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 = behavioural parity is preserved across every migrated entity. test/setup-wizard.e2e-spec.ts(or the per-people-entity import e2es) — confirms the year-writability assertion folded intorunImportPipelinedoes not break the happy path; explicit archived-year test exercises the new failure path inside the pipeline (instead of before it).- Manual verification:
- Boot-time aggregate-response check: temporarily introduce a violating controller in dev (
@Get('/break')returning a class extendingAggregateResponseDtowithout the marker). Confirmnpm run start:devfails with a clear error and the controller path in the message. - Removal verification: delete one role from
StudentsPolicy.branches. Confirm@AppliesPolicy(StudentsPolicy)automatically excludes the role from the route's@RequireRolesallowlist (verified viaReflect.getMetadatain a one-off spec, or by hitting the route as that role and observing 403). No separate controller edit needed.
Patterns: chapter 09 (testing), and feedback_e2e_isolation_patterns.md for E2E discipline.
12. Sign-off¶
- Approved by:
- Date:
- Chat reference:
Until this section is filled, no implementation code is written.