Skip to content

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) in src/<module>/<entity>.access-context.ts (or *.queries.ts for the older entities) — switches on ctx.roles to produce the Prisma.<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 extending AggregateResponseDto. 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 in FieldFilterInterceptor catches 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 before runImportPipeline(...). 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 @RequireRoles separately 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 extends AggregateResponseDto and is missing the marker fails app startup in dev/CI. Async handlers (Promise<X>) are silently skipped — TypeScript's emitDecoratorMetadata erases the generic argument and there is no runtime hook to recover the inner type. The FieldFilterInterceptor runtime safety-net remains the primary detection surface for async routes. See the inline comment in src/common/dto/aggregate-response-invariant.ts for the full caveat and the planned-but-deferred path through Swagger's @ApiOkResponse({ type }) metadata. (Landed-state note added post-implementation 2026-05-26.)
  • assertYearWritable is 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, FieldFilterInterceptor stay unchanged.
  • Touching the request pipeline order (JwtAuthGuard → ScopeGuard → ActionGuard → FieldWriteGuard → RolesGuard → Controller → FieldFilterInterceptor).
  • Resolving the assignment scope dual-surface overload (field-level + curriculum-selection sub-resource). Tracked separately — too entangled with the dept-head / referent canWrite logic 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.tsmakeRoleVisibilityResolver, 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 assertAggregateResponseInvariant only catches synchronous handlers (see §1 footnote + inline comment in src/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 runtime FieldFilterInterceptor safety-net is the de-facto detection surface today.
  • assignment scope dual-surface unwind — the field-level + curriculum-selection-sub-resource overload is tracked separately. Touches dept-head narrowing, referent canWrite, 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 matches policy.entityKey — the entityKey field 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 EntityAccessPolicy expose entityKey: EntityKey?~~ Resolved: yes. Surfaces it for the future "policy drift" check that ties policy ↔ route's @RequireScopes ↔ catalogue. Zero cost to include now.
  • ~~Should @AppliesPolicy be 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 main findAll).
  • ~~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 in production. Mirrors how FieldFilterInterceptor'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 @RequireRoles allowlists (4 roles on basic CRUD) and the most internal helpers consuming studentsForAccessContext. The remaining entities follow the same recipe mechanically. Order: Students → Homerooms / SubjectGroups (already on makeRoleVisibilityResolver, 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.tsdefinePolicy exposes roles in branches' declaration order; where delegates to makeRoleVisibilityResolver with the same branches and produces identical output for sample contexts (platform admin → base; admin → base; dept_head with empty departmentIdsNEVER_MATCH_WHERE; dept_head with departmentIds → narrowed; referent → linked-children path; unrecognised role → NEVER_MATCH_WHERE).
  • src/common/decorators/applies-policy.decorator.spec.ts — verifies the decorator applies Roles(...policy.roles) (same metadata RolesGuard already reads) AND sets POLICY_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 extends AggregateResponseDto but 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 cover runImportPipeline calling assertYearWritable before the lookup-data callback; archived-year scenario fails with ACADEMIC_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 *ForAccessContext helper 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 into runImportPipeline does 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 extending AggregateResponseDto without the marker). Confirm npm run start:dev fails 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 @RequireRoles allowlist (verified via Reflect.getMetadata in 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.