Skip to content

Permissions (RBAC)

This chapter is the authoritative reference for the SIS authorization strategy. It covers the full stack — from database schema through API enforcement to frontend rendering — and serves as the canonical home for all permission-related decisions.


Mental Model — Four Layers of Access Control

Authorization in SIS operates in four orthogonal layers. Each layer answers a different question:

Layer Question Implementation Status
1. Authentication "Who are you?" JWT + Passport (JwtAuthGuard) Complete
2. Scope-level access "What can you see/edit?" Entity-Scope model (ScopeGuard + FieldFilterInterceptor) Complete
3. Action-level access "What operations can you perform?" Action permissions (ActionGuard) Complete
4. Record-level access "Which records?" Tenant isolation (service-layer tenantId, in every WHERE via Policy.buildBase) + per-entity EntityAccessPolicy (@AppliesPolicy(policy) + policy.where(ctx) AND-merged into service queries). Complete on every role-aware entity (students, teachers, staff, referents, departments, grades, curricula, selection-windows, homerooms, subject-groups, academic-years, school, attendance, command-center). Tenant-flat entities (rooms, evaluation_scales, timetable_templates, users, invitations) carry no narrowing by design.

Request pipeline

Request
  |
  +-> [JwtAuthGuard]              Layer 1: Authenticate. Attach { userId, tenantId, roles, parameterDimensions } to request.
  |       |                        Reject if token invalid/expired -> 401
  |       v
  +-> [ScopeGuard]                Layer 2a: Check scope-level access (read/update routes only).
  |       |                        Reads @RequireScopes(entity, mode) (plural — coarse, ANY scope on entity)
  |       |                        OR @RequireScope(entity, scopeKey, mode) (singular — specific scope key).
  |       |                        Mutually exclusive on the same handler — having both is 500 misconfig.
  |       |                        Skip if no metadata (e.g. action-only routes). Reject -> 403 INSUFFICIENT_SCOPE.
  |       v
  +-> [ActionGuard]               Layer 3: Check action permission (create/delete routes).
  |       |                        Read @RequireAction(entity, action) metadata.
  |       |                        Check permissions.actions[entity].has(action). Reject -> 403 ACTION_NOT_PERMITTED.
  |       |                        Skip if no @RequireAction metadata on route.
  |       |                        Actions already embed scope requirements: an action is only
  |       |                        effective if the user has all required scopes (checked at
  |       |                        permission compilation time). No redundant @RequireScopes needed.
  |       v
  +-> [FieldWriteGuard]           Layer 2b: Validate write payloads (POST/PATCH only).
  |       |                        Derives entity from scope (plural or singular) OR action decorator metadata.
  |       |                        For scope-grouped bodies (plural / create action): per-key WRITE check.
  |       |                        For sub-resource bodies (singular): NEVER_WRITABLE_FIELDS still enforced.
  |       |                        Reject -> 403 FORBIDDEN_FIELDS.
  |       v
  +-> [RolesGuard]                Layer 4a: Role allowlist (opt-in).
  |       |                        Reads @RequireRoles(...keys) directly OR via @AppliesPolicy(policy) composition
  |       |                        (which emits @RequireRoles(...policy.roles)).
  |       |                        Admits when user.roles ∩ required is non-empty, OR via parametric-dimension
  |       |                        overlap: user.parameterDimensions ∩ policy.parametricDimensions
  |       |                        (the lever for custom parametric roles). Reject -> 403 ACTION_NOT_PERMITTED.
  |       v
  +-> [Controller -> Service]     Layer 4b: Business logic.
  |       |                        - Controller threads RecordAccessContext via @AccessContext().
  |       |                        - Service AND-merges `<Entity>Policy.where(ctx)` into every read/write.
  |       |                        - `tenantId` is set exclusively in `Policy.buildBase`, so it survives
  |       |                          OR composition by construction.
  |       v
  +-> [FieldFilterInterceptor]    Layer 2c: Strip unauthorized scope groups from response.
          |                        Derives entity from scope (plural or singular) OR action decorator metadata.
          |                        For aggregate routes (@AggregateResponse + AggregateResponseDto): pass-through
          |                        with a runtime safety-net assertion (no top-level key may collide with a scope name).
          |                        Otherwise: keep top-level keys with READ+ access + always-allowed (id, createdAt, updatedAt).
          v
       Response

Key insight: Layers 2, 3, and 4 are orthogonal. A teacher may have students.identity.read (Layer 2) but lack the create action (Layer 3) and should only see students in their classes (Layer 4). All must be enforced independently.


The Entity-Scope Permission Model

Core Concept

The model answers two questions for every API request:

  1. WHAT fields can a user see/edit? → Entity-Scope field mappings
  2. WHICH records can a user access? → Tenant isolation record filtering + RLS

Hierarchy: EntityScopeFields

  • An entity is a domain object (students, teachers, staff, etc.)
  • A scope is a meaningful business grouping of fields within an entity (identity, health, attendance, etc.)
  • Each scope maps to specific database fields via scope_field_mappings

Why Entity-Scope over flat permissions?

  • Avoids 200+ individual field toggles per role — scopes reduce the matrix to ~10 toggles
  • Maps to how schools actually think: "the nurse reads medical data", "the accountant sees financial data"
  • Preset roles cover 90% of use cases; custom roles handle edge cases (combined nurse-psychologist, etc.)
  • School admins should never need to manage per-field permissions

System-Level vs Tenant-Level

The permission model has two tiers:

System-level (global, seeded, version-controlled):

Table Purpose Who manages
permission_entities Domain entities (students, teachers, etc.) Developers (code/seed)
permission_scopes Logical field groups within entities Developers (code/seed)
scope_field_mappings Maps scopes to concrete table.column pairs Developers (code/seed)

These are defined in code, shared across all tenants, and deployed via the RBAC catalogue (prisma/seed/rbac-catalogue.ts). Adding a new entity or scope is a developer task (code change + migration + seed update).

Tenant-level (configurable per school):

Table Purpose Who manages
roles Role definitions (admin, teacher, custom...) School admin via UI
role_permissions Assignment matrix: role x scope → ScopeAccess School admin via UI
user_roles User-role assignments with temporal support School admin via UI

Each school can create custom roles and assign different scope permissions independently.

Database Schema

Six tables power the permission system:

permission_entities

Domain entities that the permission system governs.

Column Type Description
id UUID PK
key String, unique Machine-readable key (students, teachers)
label String Display name
description String Help text for admin UI
sort_order Int Display ordering

Registered entities (seeded via prisma/seed/rbac-catalogue.ts — that ENTITIES array is the source of truth; the rows below are illustrative of the load-bearing ones):

Entity key Scopes Actions Notes
students identity, contacts, assignment, school_relationships, health, documents, referents_and_guardians, curriculum_selection create, delete school_relationships holds hasSiblingInSchool/isChildOfEmployee (writable, admin-pinnable) + a read-only relatedPeople list. Custom fields via others scope.
teachers identity, contacts, employment, documents, health create, delete Custom fields via others scope
staff identity, contacts, employment, documents, health create, delete Custom fields via others scope
users profile -- Read-only (no CRUD controller)
departments configuration create, delete Custom fields via others scope
grades configuration create, delete Sub-resource of departments; custom fields via others scope
rooms configuration create, delete Custom fields via others scope
academic_years configuration create, delete Lookup GET + admin-only draft create/edit/delete
curricula configuration, selection_window create, delete, open_selection_window, edit_selection_window Single-table after the grid remodel — subjectLabel/trackSelectionMode fold into curricula.configuration (the old study_plans FIELD_MAPPINGS row is gone). Structure (subjects/blocks/tracks/cells) is written via the family-sync PATCH /curricula/:id, not a field-filtered surface.
referents identity, contacts, documents, health, students create, delete Custom fields not yet supported
invitations management send, reset Credential issuance to teachers, staff, and referents
school configuration -- Singleton per tenant (GET + PATCH)
evaluation_scales configuration create, delete Flat-DTO catalog entity
timetable_templates configuration create, delete, assign Flat-DTO; Day/Week template catalog + dept/grade assignments
homerooms composition create, delete Wizard cascade materialises N child Subject Groups per mandatory subject. Curriculum/Department/Grade immutable post-creation. Custom fields via others scope.
subject_groups composition create, delete Two flavours: standalone (homeroomId IS NULL) and homeroom-bound (mandatory subject, roster derived from parent Homeroom). Bound SGs reject DELETE + roster ops; PATCH allows teacher swap only. Custom fields via others scope.
timetables configuration create, delete, publish, generate Flat-DTO; hand-managed + auto-generated weekly timetables
audit_log configuration -- Append-only who-did-what trail, admin-only, descriptor-only/flat (in FLAT_DTO_ENTITIES)
attendance register take DAILY/PERIOD register, lazy grid off the PUBLISHED timetable; field-level register scope (NOT flat — reserved for future referent/student narrowing).

permission_scopes

Logical field groups within an entity. Each scope represents a meaningful business category.

Column Type Description
id UUID PK
entity_id UUID FK → permission_entities Parent entity
key String Machine-readable key (identity, health)
label String Display name
description String Help text for admin UI
sort_order Int Display ordering within entity

Constraint: UNIQUE(entity_id, key)

scope_field_mappings

Maps each scope to concrete database fields. Field names use camelCase matching Prisma model output (e.g., firstName, not first_name).

Column Type Description
id UUID PK
scope_id UUID FK → permission_scopes Parent scope
table_name String Database table (students)
field_name String Field name in camelCase (firstName, disabilityInfo)

Constraint: UNIQUE(scope_id, table_name, field_name)

Important: Field names MUST match the keys returned by Prisma queries, not the database column names. Prisma uses @map() directives to translate between camelCase model fields and snake_case columns. The FieldFilterInterceptor compares response object keys against this set, so a mismatch means fields get stripped.

roles

Per-tenant role definitions. Roles can be preset (system-provided) or custom (school-created).

Column Type Description
id UUID PK
tenant_id UUID FK → tenants Owning tenant
key String Machine-readable key (admin, teacher)
label String Display name
description String Help text
is_preset Boolean true = system preset (immutable), false = custom
created_at DateTime
updated_at DateTime

Constraint: UNIQUE(tenant_id, key)

role_permissions

The core assignment matrix linking roles to scopes with read/write flags.

Column Type Description
id UUID PK
role_id UUID FK → roles
scope_id UUID FK → permission_scopes
access ScopeAccess enum Access level: NONE, READ, or WRITE (WRITE implies READ)

Constraint: UNIQUE(role_id, scope_id)

user_roles

Assigns roles with temporal support (for substitute teachers, temporary permissions, etc.). Management-role assignments are anchored on the Teacher/Staff person row and may exist before the person has a user: user_id is nullable and stays null while the assignment is PENDING; it is backfilled to the created user at invitation-accept (ACTIVE). See Assigning roles and spec 2026-07-01-role-preassignment-on-person-design.md.

Column Type Description
id UUID PK
user_id UUID? FK → users Nullable. Null ⇒ PENDING (person not yet a user); set ⇒ ACTIVE.
recipient_type RecipientType? Person-anchor discriminator (TEACHER/STAFF) for management assignments; null for profile-coupled / bare rows. Polymorphic soft reference (no FK), same pattern as Invitation.
recipient_id UUID? Anchored Teacher/Staff row id (soft reference).
role_id UUID FK → roles
tenant_id UUID FK → tenants
valid_from DateTime When the role assignment becomes active (default: now)
valid_until DateTime? When the role assignment expires (null = permanent)
assigned_by UUID? FK → users Who assigned this role
created_at DateTime

Constraints: UNIQUE(user_id, role_id, tenant_id) (nulls are distinct in PG, so pending rows never collide here) and UNIQUE(tenant_id, recipient_type, recipient_id, role_id) (one assignment per person+role, holding across the pending→active transition).

Students Entity — Scope Breakdown

The students entity is the reference implementation. The table below mirrors the seed (prisma/seed/rbac-catalogue.ts + src/common/constants/scope-fields.ts) — every scope and every field listed exists in the DB today.

Scope Fields on Student Description
identity firstName, lastName, nickName, dateOfBirth, placeOfBirth, gender, nationality, taxCode Basic biographical data
contacts homePhone, homeAddress, homeCity, homeState, homePostcode, homeCountry, schoolEmail Contact information
assignment enrollmentDate, departmentId, gradeId, identificationCode, status Department/grade placement and lifecycle status
school_relationships hasSiblingInSchool, isChildOfEmployee (writable); *Locked + relatedPeople read-only US-13: auto-derived flags + admin pin; relatedPeople surfaces the people behind them
health attentionFlag, medicalProblems, medications, allergies, dietType, learningSupportType Medical and dietary data
documents passportFileId, identityCardFileId FK to uploaded ID documents in files
referents_and_guardians (relational — no Student columns) Referent links (StudentReferentLink rows + the joined Referent) plus lightweight student-owned Guardian contacts. Response group built by StudentsService.enrichReferentInvitations. WRITE gates the guardians sub-resource (POST/PATCH/DELETE /students/:id/guardians).
curriculum_selection (none — gate-only scope) Gates GET/PATCH /students/:id/curriculum-selection via @RequireScope(STUDENTS, 'curriculum_selection', ...). Zero scope_field_mappings rows.
others (catch-all) Custom-field surface — auto-seeded as others for every entity in OTHERS_SCOPES.

scoring, financial, and family scopes from earlier brainstorms are not seeded and are not gated by any route today. (Attendance shipped as its own attendance entity with a register scope, not as a students scope.) Add them via the New Entity Permission Checklist recipe when the corresponding domain models land.

Referents Entity — Scope Breakdown

The referents entity has five scopes seeded. Two carry no field mappings — health is reserved, students is relational (link metadata on StudentReferentLink).

Scope Fields on Referent (or join) Description
identity firstName, lastName, dateOfBirth, placeOfBirth, gender, nationality, taxCode, profession, preferredLanguage Basic biographical data
contacts email, homePhone, cellPhone, homeAddress, homeCity, homeState, homePostcode, homeCountry Contact information
documents passportFileId, identityCardFileId FK to uploaded ID documents
health (none yet) Reserved — will hold medical/dietary data
students (relational — on StudentReferentLink) studentId, relationshipType, isAuthorizedPickup, isEmergencyContact, canWrite Symmetric to students.referents_and_guardians. Gates write access to "which students this referent is linked to" on POST /referents. WRITE is admin-only so referents can't toggle their own canWrite.

Referent Preset Role

The seeded referent role grants:

  • WRITE on referents.identity, referents.contacts, referents.documents, referents.health (own record)
  • WRITE on students.identity, students.contacts, students.school_relationships, students.documents, students.health, students.referents_and_guardians, students.curriculum_selection (linked children only, gated per-link by StudentReferentLink.canWrite)
  • READ on students.assignment (narrowed from WRITE on 2026-05-27 — see callout below)
  • READ on students.others (custom fields for linked children), referents.students (own link rows via GET /referents/me), school.configuration, curricula.selection_window, homerooms.composition, subject_groups.composition

No create or delete actions are granted to referents. WRITE on referents.students stays admin-only so a referent cannot toggle their own canWrite.

students.assignment is field-level only. Gates enrollmentDate, departmentId, gradeId, identificationCode, status on the students table via FieldFilterInterceptor (response stripping) + FieldWriteGuard (body-key writes). It does NOT gate the curriculum-selection sub-resource — that surface has its own scope (see below).

students.curriculum_selection is a gate-only scope. Zero scope_field_mappings rows. Gates the per-student curriculum-selection sub-resource at GET/PATCH /students/:id/curriculum-selection via the singular @RequireScope(STUDENTS, 'curriculum_selection', 'read'|'write') decorator. See "Gate-only scopes" subsection below. Spec: docs/superpowers/specs/2026-05-27-students-curriculum-selection-scope-split-design.md.

Student Preset Role

Like referent, the seeded student role is a profile-coupled base role, not an admin-curated management preset: it exists so the [activeProfile] narrowing (see Role narrowing) resolves real grants for a student session, and it is bound automatically at invitation accept (RECIPIENT_PRESET_ROLE_KEY.STUDENT). v1 grants, all READ, deliberately minimal:

  • students.identity, students.contacts, students.assignment (own row only — record-level narrowing via the student branch of StudentsPolicy: userId = ctx.userId)
  • curricula.configuration, curricula.selection_window (the curricula-side policies carry student pass-through branches)

No write scopes, no actions. Grant growth (health, documents, grades, register, homework views) arrives with the SUS user stories. Spec: docs/superpowers/specs/2026-06-03-student-invitations-design.md.

Preset Role Permissions Matrix (Students)

Mirrors the seed (prisma/seed/roles.ts*_WRITE_SCOPE_KEYS / *_EXCLUDED_SCOPE_KEYS / seedProfileCoupledPresetsForTenant). Columns are the actually seeded student scopes; rows are the actually seeded preset roles. Custom tenant roles deviate within these axes.

Legend: R = READ, W = WRITE, -- = NONE, (child) = record-level narrowed to linked children, (own) = record-level narrowed to the caller's own row via userId, (dept) = record-level narrowed by ctx.parameters.departmentIds, (curr) = record-level narrowed by ctx.parameters.curriculumIds (students who selected an assigned curriculum), (teaching dept) = record-level narrowed by ctx.teacherDepartmentIds

Role identity contacts assignment school_relationships health documents referents_and_guardians curriculum_selection1 others (custom)
Admin R/W R/W R/W R/W R/W R/W R/W R/W R/W
Principal R R R R R R R R R
HR4 R R R R R R R R R
Secretary4 R/W R/W R/W R R R/W R/W5 R/W R/W
Department Head R/W (dept) R/W (dept) R/W (dept) R/W (dept) R/W (dept) R/W (dept) R/W (dept) R/W (dept) R/W (dept)
Curriculum Coordinator R (curr) R (curr) R (curr) -- -- -- -- R (curr) R (curr)
Teacher R (teaching dept) R (teaching dept) R (teaching dept) R (teaching dept) R (teaching dept) R (teaching dept) R (teaching dept) R (teaching dept) R (teaching dept)
Referent R/W (child) R/W (child) R (child)2 R/W (child) R/W (child) R/W (child) R/W (child) R/W (child)3 R (child)
Student R (own) R (own) R (own) -- -- -- -- -- --

(child) / (dept) / (teaching dept) annotations indicate record-level filtering applied by StudentsPolicy.where(ctx) (see Policy inventory), not scope-level restrictions. The scope permissions apply once record-level access is granted.

1 curriculum_selection is a gate-only scope — zero scope_field_mappings rows. Gates the per-student curriculum-selection sub-resource (GET/PATCH /students/:id/curriculum-selection) via the singular @RequireScope(STUDENTS, 'curriculum_selection', 'read'|'write'). See Gate-only scopes.

2 Referent's assignment was narrowed from WRITE to READ on 2026-05-27 (curriculum-selection scope-split spec). Referent reads dept/grade FK columns on linked children's profiles but cannot PATCH them.

3 Referent's WRITE on curriculum_selection is the load-bearing grant for the parent workflow (previously implicit via students.assignment:WRITE, which never actually gated the sub-resource AND authorized field-level assignment writes). Write access is additionally runtime-gated by an OPEN CurriculumSelectionWindow for the student's department AND StudentReferentLink.canWrite = true.

4 The combined "HR / Secretary" preset was split on 2026-07-02: hr (key unchanged, relabeled "HR") keeps only the personnel half — teachers/staff WRITE (all five scopes incl. health) + teachers/staff.{create,delete} + the invitation surface — retaining tenant-wide READ visibility elsewhere; the new secretary preset owns the office half — students, configuration (departments/grades/rooms/curricula), homerooms/SG composition, invitations — with teacher/staff directory READ but no teachers.health/staff.health. Both keep invitations.management + send/reset (accepted v1 overlap, no recipient-type gate). Spec: 2026-07-02-curriculum-coordinator-and-hr-secretary-split-design.md.

5 Secretary's WRITE on referents_and_guardians is forced by the enrolment flow: the scope block is mandatory on POST /students (at least one referent), and FieldWriteGuard requires WRITE on every top-level body key of a create — so students.create would be unusable without it. It covers only the student-side links/contacts; the standalone referents entity stays admin-only for both split roles.

Cross-Entity Preset Role Matrix (Configuration Entities)

Configuration entities (departments, grades, rooms, curricula) each have a single configuration scope. The matrix below covers configuration scope access and action grants.

Legend: R = READ, W = WRITE, C = create action, D = delete action, -- = NONE

Role departments grades rooms curricula
Admin R/W + C + D R/W + C + D R/W + C + D R/W + C + D
HR R R R R
Secretary R/W + C + D R/W + C + D R/W + C + D R/W + C + D
Department Head R/W (own dept only) R/W + C + D (own dept only) R R/W + C + D (own dept only)
Curriculum Coordinator R (owning dept only) R (owning dept only) -- R/W (assigned curricula only — no C/D: an id-scoped role can never validate a create against its parameter set)
Principal R R R R
Teacher R R R R
Student R R R R
Parent R R R R
Others -- -- -- --

academic_years is omitted — it's a tenant-flat, admin-managed catalog (GET /academic-years lookup plus admin-only POST create-draft, PATCH draft-edit, and DELETE draft-delete, gated by the academic_years.create/delete actions); it does not follow the dept-scoped configuration matrix above. users is omitted — it has a profile scope, not configuration.

Attendance preset access (entity attendance, scope register, action take). Only two presets touch it: Admin holds R/W + take tenant-wide; Teacher holds R/W + take (attendance.register is in TEACHER_WRITE_SCOPE_KEYS, attendance.take in the teacher actions set), record-level narrowed to their teaching departments by AttendancePolicy. All other presets are --. WRITE is further gated in-service by the Y-set (lesson / homeroom / teaches-today); read visibility is the broader AttendancePolicy (see Policy inventory). Routes also carry @RequireRoles('admin', 'teacher').

Parametric roles. Role.parameterDim declares the dimension a role narrows by (ParameterDim enum: DEPARTMENTdepartment_head — and CURRICULUMcurriculum_coordinator, added 2026-07-02). Assignment-side values live in UserRoleParameter(userRoleId, valueId) rows — a polymorphic ID whose interpretation comes from the parent role's parameterDim. A user can hold the role for multiple values via multiple UserRoleParameter rows on a single UserRole. PermissionsService.getAccessContextSlice aggregates active parametric assignments into RecordAccessContext.parameters.{departmentIds, curriculumIds} (one array per registered dimension — DIMENSION_TARGETS in src/common/constants/parameter-dim.ts is the registry: adding a dimension there extends the ParametricContext type, the aggregator, and the assignment-time value validation in one move); the matching parametricBranches in each entity's EntityAccessPolicy produce the narrowing predicate. The scope-level matrix above is uniform across all parametric-role holders; the "own dept only" / "assigned curricula only" suffixes reflect the record-level narrowing applied by each entity's EntityAccessPolicy (DEPARTMENT: direct departmentId IN (…) for direct-FK entities, relational join for SubjectGroup; CURRICULUM: id IN (…) on Curriculum itself — which doubles as the write fence on every curricula write path — curriculumId IN on Homeroom, curriculumSubject.curriculumId IN on SubjectGroup, curriculumSelection.curriculumId IN on Student, curricula some joins on Department/Grade/SelectionWindow). Teachers has no CURRICULUM branch — there is no teacher↔curriculum relation, so the coordinator is admitted by a role-keyed pass-through instead (documented divergence; a custom CURRICULUM role fail-closes there). Tenant-flat entities (rooms, staff, evaluation_scales, timetable_templates, school, academic_years, users, invitations, referents) keep the dept_head role-keyed pass-through branch and are read tenant-wide — no parametric branch on those policies. Admin / platform admin bypass the parametric narrowing because their pass-through branch short-circuits the resolver. Custom parametric roles (any tenant-created Role with parameterDim set) plug in by data alone — they are admitted at RolesGuard via parametric-dimension overlap with policy.parametricDimensions, even when their role key is not in policy.roles. On the polymorphic valueId: deleting a dimension target runs an app-layer userRoleParameter.deleteMany cascade (departments delete → DEPARTMENT rows; curricula delete → CURRICULUM rows). Canonical specs: 2026-05-25-preset-management-roles-dept-scoped-assignment-design.md (original dept-head FK design — superseded for the storage layer), 2026-05-27-parametric-roles-design.md (current parametric-roles model), and 2026-07-02-curriculum-coordinator-and-hr-secretary-split-design.md (second dimension + role split).

Permission Compilation Flow

PermissionsService.getUserPermissions(userId, tenantId) compiles a user's effective permissions at runtime:

  1. Fetch active UserRoles — query user_roles with temporal filtering: validFrom <= NOW() AND (validUntil IS NULL OR validUntil > NOW())
  2. Deep-load relationshipsUserRole -> Role -> RolePermission -> PermissionScope -> Entity (plus RoleActionPermission -> PermissionAction -> ActionScopeRequirement)
  3. Compile into CompiledPermissions:
  4. scopes: { [entity]: { [scope]: ScopeAccess } } — the scope access matrix (NONE omitted, READ, or WRITE). Multi-role union: highest access wins (WRITE > READ > NONE).
  5. actions: { [entity]: Set<actionKey> } — effective actions: role has the grant AND user's scope access satisfies all scope requirements.
  6. Union semantics — if a user has multiple roles, their permissions are ORed together. A user with both "teacher" (identity read) and "nurse" (health read) gets both scopes. For scope merging, the highest access rank wins (WRITE > READ > NONE).
interface CompiledPermissions {
  scopes: Record<string, Record<string, ScopeAccess>>;
  actions: Record<string, Set<string>>;
}

Two read surfaces — GET /permissions vs GET /roles

Endpoint Gate Returns
GET /permissions any authenticated user the caller's compiled grants for their active-profile roles, grouped by entity (Record<entity, { scopes, actions, customFieldDefinitions? }>).
GET /roles @RequireRoles('admin') the tenant's admin-manageable roles catalogue: every management preset and custom role with its scope access + effective actions per entity, nested by domain group (role.groups[groupId][entityKey]). Powers the Roles & Permissions admin page.

Both catalogue surfaces — GET /roles and the backoffice GET /roles/presetsexclude the profile-coupled trio (teacher/referent/student, PROFILE_COUPLED_ROLE_KEYS). Those roles are bound at invitation-accept and are neither assignable via /role-assignments nor editable via PATCH /roles(/presets)/:key; they are permission carriers, not editor-managed roles, so a shared catalogueRoleWhere (key NOT IN the trio) filters them out of fetchTenantRoles/fetchGlobalPresetRoles. admin stays listed (a real role, merely frozen).

GET /roles (RolesControllerPermissionsService.getTenantRolesCatalog) reuses the same compilation primitives (compileScopes / collectGrantedActions / computeEffectiveActions) driven per-role instead of per-user, and nests each role's permissions by domain group. Every entity in a group is emitted even with no access, so the editor can render each row. Record-level narrowing is intentionally omitted from the response (a TODO(recordScope) marks the spot) — pending a team decision on surfacing it to admins. Role editing (group-level writes for custom roles) is future work; the read catalogue is the only surface today.

Entity groups are a view-layer convenience, not a DB concept — the guard stack never resolves them and the DB stays flat (one role_permissions row per entity-scope). Membership and display order live in src/common/constants/entity-groups.ts (the source of truth): people, academic-structure, curriculum, class-composition, timetable, platform — every EntityKey in exactly one group, enforced by entity-groups.spec.ts. Group display labels are frontend-owned (it localizes them); the API conveys grouping purely by structure — role.groups[groupId][entityKey], keys in display order — so the client needs zero grouping logic.

Active-session narrowing (as of 2026-04-28)

request.user.roles and JwtPayload.roles reflect the active session profile, not the full set of RBAC role grants in the DB. The narrowing is applied at token-issue time by auth.service.ts:narrowRolesForActiveProfile and carried through refresh via the RefreshToken.activeProfile column. The rules:

  • activeProfile ∈ {teacher, staff} (employee profiles) — roles contains all DB-granted UserRole keys, with activeProfile defensively prepended if not already present.
  • activeProfile ∈ {referent, student} (non-RBAC profiles) — roles = [activeProfile] only. RBAC roles such as admin or principal are dropped; they are never meaningfully granted to referents or students and must never bleed across sessions.

PermissionsService.getUserPermissions(userId, tenantId, activeRoleKeys) accepts a third argument and filters the UserRole query to role.key IN activeRoleKeys before compilation. ensurePermissionsLoaded reads request.user.roles (already narrowed) and passes it as activeRoleKeys. The result: permission checks only ever see grants relevant to the active profile.

Record-level policies that branch on ctx.roles.includes('referent') (e.g. StudentsPolicy.where(ctx)) consume this narrowed set. Because activeProfile is always present in the array under the rules above, the branch fires precisely when the user is actively logged in as that profile.

RecordAccessContext.roles (consumed by entity policies like StudentsPolicy, and service methods like students.service.ts:updateForAccessContext, referents.service.ts) is populated from request.user.roles, so it shares the narrowing semantics: a referent session sees ['referent'] and a teacher session sees ['teacher', ...employee-roles]. Policy branches on ctx.roles.includes('referent' | 'teacher' | …) therefore reflect the active session's profile, not the user's full capability set.


Route Protection

Decorator Decision Table

HTTP method Purpose Decorator Reason
GET Read @RequireScopes(entity, 'read') Scope-level gate
PATCH Update @RequireScopes(entity, 'write') Scope-level gate
POST Create @RequireAction(entity, 'create') Action embeds scope requirements
DELETE Delete @RequireAction(entity, 'delete') Action embeds scope requirements

Role gate decorator preference. For role-gated routes, prefer @AppliesPolicy(<Entity>Policy) when the policy admits the right roles (it composes @RequireRoles from the policy's role list, keeping decorator and record-filter in sync). Use raw @RequireRoles(...) only when the route's role gate is tighter than any per-entity policy (e.g. import routes are @RequireRoles('admin')) or for class-level platform-admin bypasses where policy doesn't apply.

Never combine @RequireScopes and @RequireAction on the same route. Action permissions are compiled with scope requirements baked in: CompiledPermissions.actions[entity] only contains an action key if the user has the action grant AND satisfies all ActionScopeRequirement rows (e.g., students.create requires WRITE on every student scope). Adding @RequireScopes on these routes would be a redundant and potentially conflicting gate.

@ProtectedResource() Composition

Applied at the controller class level. Composes: JwtAuthGuard + ScopeGuard + ActionGuard + FieldWriteGuard + FieldFilterInterceptor + ApiBearerAuth.

@Controller('students')
@ProtectedResource()   // applies the full guard stack
export class StudentsController { ... }

Guard Chain (Execution Order)

Order Guard/Interceptor Behavior Rejects with
1 JwtAuthGuard Authenticate, attach { userId, tenantId, roles } to request 401
2 ScopeGuard "Does user have ANY scope on this entity?" Skip if no @RequireScopes 403 INSUFFICIENT_SCOPE
3 ActionGuard "Is user granted this action?" Skip if no @RequireAction 403 ACTION_NOT_PERMITTED
4 FieldWriteGuard Validate POST/PATCH body keys against WRITE scopes 403 FORBIDDEN_FIELDS
5 RolesGuard "Does user have at least one of the required roles?" Skip if no @RequireRoles 403 ACTION_NOT_PERMITTED
6 Controller → Service Business logic with tenantId filtering in every where clause --
7 FieldFilterInterceptor Strip scope groups user lacks READ access on (always keeps id, createdAt, updatedAt) --

Platform admin (isPlatformAdmin flag on user): bypasses ScopeGuard, ActionGuard, FieldWriteGuard, RolesGuard, and FieldFilterInterceptor — full access to all entities and scopes. Tenant admin (the admin role inside a tenant) does not bypass any guard; it simply happens to hold every scope/action via its role-permission grants.

Guard Behaviour in Detail

ScopeGuard — Reads @RequireScopes() metadata, loads CompiledPermissions (memoized on request.permissions), checks if the user has ANY scope on the entity at the required level. Throws INSUFFICIENT_SCOPE if the user has zero access. Passes through when no @RequireScopes metadata (action-only routes). This is a coarse gate — real field-level enforcement is handled by FieldWriteGuard and FieldFilterInterceptor.

Sub-resource / aggregate routes must gate on the singular scope. Because the plural @RequireScopes(entity, mode) is satisfied by READ/WRITE on any one scope of the entity (checkEntityAccess uses .some()), it cannot enforce a deliberately-withheld scope — and @AggregateResponse() routes skip FieldFilterInterceptor, so there is no field-level backstop either. Any route whose authorization hinges on a specific scope (notably the person-document routes :id/documents…, which gate sensitive ID-document blobs) must use the singular @RequireScope(entity, '<scope>', 'read'|'write') instead. The person-document routes on students/teachers/staff/referents were re-gated to @RequireScope(entity, 'documents', …) for exactly this reason — see the Wave-1 spec docs/superpowers/specs/2026-06-25-wave1-security-hardening-design.md (which closed the original audit finding #2). The boot-time assertScopeDecoratorExclusivity scanner forbids carrying both decorators on one handler.

ActionGuard — Reads @RequireAction() metadata, checks permissions.actions[entity].has(action). Throws ACTION_NOT_PERMITTED if denied. Passes through when no @RequireAction metadata (read/update routes).

FieldWriteGuard — Derives entity from either @RequireScopes or @RequireAction metadata. Checks top-level body keys (scope group names like identity, health) against user's WRITE scopes. System fields (id, createdAt, updatedAt, tenantId) are always rejected. Rejects with FORBIDDEN_FIELDS.

RolesGuard — Reads @RequireRoles(...roles) metadata. Opt-in: passes through if the route has no metadata. Platform admins bypass. Otherwise checks that the user holds at least one of the listed role keys (OR semantics). Rejects with ACTION_NOT_PERMITTED and a message naming the required roles. No DB query — checks the roles array already attached to the request by JwtAuthGuard. Declaring @RequireRoles() with no arguments is a programming error and is rejected at runtime.

FieldFilterInterceptor — Derives entity from either @RequireScopes or @RequireAction metadata. Filters response at the scope-group level: keeps top-level keys where the user has READ+ access, plus always-allowed fields (id, createdAt, updatedAt). Works with single objects, arrays, and paginated { data: [...], meta: {...} } responses. Routes decorated with @AggregateResponse() are passed through unchanged — see below.

Role-level access — @RequireRoles()

Scope/action permissions answer "can this user see/change this field?". @RequireRoles() answers a different question: "is this user in a role we've decided may even call this route?" It is additive to @RequireAction / @RequireScopes, not a replacement: the user must satisfy both the permission check and the role check.

Semantics:

  • OR across the list. @RequireRoles('admin', 'secretary') means admin OR secretary. To require multiple roles simultaneously, stack multiple decorators — but we have no such route today and don't anticipate one.
  • Opt-in. Routes without @RequireRoles() are not gated by role at all; the rest of the stack still applies.
  • Cheap. No DB round-trip — reads user.roles populated from the JWT.
  • Platform admin bypass. isPlatformAdmin: true users pass regardless of roles.
  • Tenant admin is NOT special. The admin role inside a tenant is an ordinary role key — it only satisfies @RequireRoles('admin') because the string matches.

When to reach for it:

  • Bulk / destructive operations whose blast radius we want to restrict beyond the per-field permission model (e.g. imports, bulk deletes, tenant-wide resets).
  • Aggregate endpoints where the scope-filter interceptor can't meaningfully enforce per-field access anyway (see below).

When not to reach for it:

  • As a substitute for proper scope/action modelling. If a field is sensitive, model it as a scope and let the interceptor strip it — don't role-gate the route.
  • To replicate the permission matrix in decorators. @RequireAction already embeds scope requirements.

Example — import routes, currently admin-only but expected to open up to other roles later:

@Post('import')
@HttpCode(HttpStatus.OK)
@RequireAction(EntityKey.STUDENTS, 'create')
@RequireRoles('admin')  // widen to ('admin', 'secretary', ...) when the product team approves
@AggregateResponse()
async importStudents(...) { ... }

Example — student read routes, open to admin, teacher, and referent (record-level filtering scopes the result set per role — see Record-Level Access):

@Get(':id')
@RequireScopes(EntityKey.STUDENTS, 'read')
@RequireRoles('admin', 'teacher', 'referent', 'department_head')
async findOne(...) { ... }

GET /students and GET /students/:id carry @RequireRoles('admin', 'teacher', 'referent', 'department_head'). All four roles call the same endpoints; the service applies record-level filtering via @AccessContext() so each role sees only the records they are allowed to see (see Record-Level Access below). Widen the @RequireRoles list when a new role needs student read access; narrow the set when a role should no longer reach this surface. A long-term @DenyRoles alternative is out of scope today.

Referent write paths. As of 2026-04-24, referents gain WRITE on every students.* scope (record-level filtered to their linked students via StudentsPolicy.where(ctx)), plus a PATCH /referents/:id endpoint for admin + self. The admin-controlled canWrite flag on StudentReferentLink gates whether a referent can exercise that write access on a given link; the check lives in StudentsService.updateForAccessContext. Custom fields on referents and Postgres RLS for the referent-to-student relation remain deferred.

Interceptor Contract: Scope-Grouped vs Aggregate Responses

The interceptor assumes every response on an entity controller is a scope-grouped entity DTO — an object whose top-level keys are scope group names (identity, health, employment, etc.) plus always-allowed fields (id, createdAt, updatedAt). That assumption holds for CRUD responses but breaks for aggregate responses that happen to live on the same controller:

  • Import summaries ({ count, items[] })
  • Counters, stats, health checks
  • Handshake / acknowledgement responses ({ success: true })

For those routes, none of the top-level keys match a scope name, so the interceptor strips every field and the caller gets {}. Platform admins are unaffected because they bypass the interceptor entirely — so this bug only manifests for regular tenant users, which makes it easy to miss in development.

Resolution — @AggregateResponse() + AggregateResponseDto. Apply the decorator to the route and extend AggregateResponseDto on the response class. The two together encode an invariant — "this response contains no scope-grouped entity fields" — at both the metadata and the type level. All other guards still run (JwtAuthGuard, ScopeGuard, ActionGuard, FieldWriteGuard, RolesGuard, tenant isolation); only the response-filter step is skipped.

The invariant that makes this safe: if you are authorized to invoke the route, you are already authorized to read every field in the response. For imports this is trivially true — create implies WRITE on every scope and therefore READ on every scope. The role gate (@RequireRoles('admin')) is a second line of defence on the call side, not on the response side.

Runtime safety net. The interceptor knows the canonical scope-name set for every entity (ENTITY_SCOPE_REGISTRY, built from STUDENT_SCOPES / TEACHER_SCOPES / …). Before passing an aggregate response through unchanged, it asserts that no top-level key collides with a scope name for the entity. If someone accidentally adds a health: {...} field to an aggregate DTO:

  • In dev/test (NODE_ENV !== 'production'): the interceptor throws loudly, naming the offending keys and the handler. Tests in field-filter.interceptor.spec.ts lock this in.
  • In production: the interceptor logs an error-level entry with the same information but still returns the response — we prefer a logged incident over a user-visible 500 on an endpoint that was working yesterday.

This is why we ask for the base class rather than relying on the decorator alone: the base class is the hook a future reviewer will notice when editing the DTO, and the runtime assertion catches the case where the reviewer edits it anyway.

Boot-time scanner (partial — sync-only). See the Boot-time invariants subsection below for the consolidated list of all three scanners (assertAggregateResponseInvariant + assertPolicyScopeAlignment + assertScopeDecoratorExclusivity) and their coverage characteristics. The @AggregateResponse scanner specifically is sync-only — TypeScript's emitDecoratorMetadata erases Promise<X> to the bare Promise constructor at runtime, so async handlers (essentially every controller method) are silently skipped. The runtime safety-net above is the de-facto detection surface for that scanner.

Rules for applying @AggregateResponse():

  1. The response DTO must extend AggregateResponseDto (marker base class in src/common/dto/aggregate-response.dto.ts).
  2. Top-level response keys must not match any scope name for the route's entity. The runtime assertion enforces this, but don't rely on it — think about it at design time.
  3. Every caller authorized to invoke the route must already be authorized to read every field in the response. For imports, @RequireAction(entity, 'create') guarantees this because create requires WRITE on every scope.
  4. Treat adding @AggregateResponse() as a permission change and review it as such. Treat enriching a response guarded by the marker (adding new fields later) the same way — always re-check rules 2 and 3.
  5. For AI agents: if any of the above is uncertain, do not apply the marker. Surface the question to a human reviewer.

Applied to (keep this list current): - POST /students/import@RequireRoles('admin') + @AggregateResponse() - POST /teachers/import@RequireRoles('admin') + @AggregateResponse() - POST /staff/import@RequireRoles('admin') + @AggregateResponse()

@Post('import')
@HttpCode(HttpStatus.OK)
@RequireAction(EntityKey.STUDENTS, 'create')
@RequireRoles('admin')
@AggregateResponse()  // response extends AggregateResponseDto; see rules above
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 10_485_760 } }))
@ApiImportStudents()
async importStudents(...) { ... }

Lookup GETs

Flat reads for dropdowns and filter population (e.g. GET /rooms/types, GET /academic-years) use a three-part recipe:

  1. @RequireScopes(EntityKey.X, 'read') — gates access. Reuse an existing entity's scope when the lookup is a natural sub-resource (room types → EntityKey.ROOMS, configuration scope). Create a new entity + configuration scope when no natural parent exists (academic years → EntityKey.ACADEMIC_YEARS).
  2. @AggregateResponse() — bypasses FieldFilterInterceptor scope-group stripping. The response shape must be flat (no top-level keys that match a canonical scope name for the entity). The runtime safety-net assertion in FieldFilterInterceptor still fires if this invariant is violated.
  3. Response DTO extends AggregateResponseDto — encodes the invariant at the type level (same contract as import responses; see the Aggregate Responses section above).

Adding a lookup GET is a permission change and must be reviewed as such: the entity key, scope, and @AggregateResponse() together determine exactly who can call the route and what they receive. A potential future refactor would collapse the three orthogonal sub-resource markers (@RequireScope singular, @AggregateResponse, @AppliesPolicy vs @RequireRoles) into one "sub-resource route" classifier — see docs/analysis/sub-resource-decorator.md for the simplification opportunity. Not currently planned.

Applied to (keep this list current): - GET /rooms/types@RequireScopes(EntityKey.ROOMS, 'read') + @AggregateResponse() - GET /academic-years@RequireScopes(EntityKey.ACADEMIC_YEARS, 'read') + @AggregateResponse() - GET /curricula/presets@RequireScopes(EntityKey.CURRICULA, 'read') + @AggregateResponse()

Recipe: scope spanning multiple tables

A single scope can map fields from more than one database table — scope_field_mappings is keyed (scopeId, tableName, fieldName), so one scope key can reference columns across tables without conflict. curricula.configuration used to demonstrate this with a second study_plans FIELD_MAPPINGS row; the 2026-06-04 grid remodel collapsed the per-grade substrate and folded subjectLabel/trackSelectionMode into the curricula table, so no live scope is multi-table today. The mechanism below still applies if one is needed again.

Why this works:

  • ENTITY_SCOPE_REGISTRY lists scope key sets per entity — it records that curricula has a configuration scope, but says nothing about which tables back it. Scope membership is a logical concept; the registry drives the FieldFilterInterceptor collision check, not field resolution.
  • scope_field_mappings is the physical layer. Its composite unique key is (scopeId, tableName, fieldName), so the same scope can reference columns in different tables without conflict.
  • One controller, one entity key. CurriculumController (/api/v1/curricula) declares @RequireScopes(EntityKey.CURRICULA, ...) / @RequireAction(EntityKey.CURRICULA, ...); field filtering uses the response DTO's top-level configuration key as usual. (Before the grid remodel a second StudyPlansController at /api/v1/study-plans shared this entity key — that surface is deleted.)

When adding a multi-table scope: add one FIELD_MAPPINGS row per table, keep a single SCOPES entry, and register the scope key set once in ENTITY_SCOPE_REGISTRY.

Flat-DTO (catalog) entities

FieldFilterInterceptor and FieldWriteGuard both assume an entity's response/body is scope-grouped — top-level keys are scope names (identity, configuration, …) and the actual fields live one level deep. Some entities deliberately ship flat DTOs instead: their create/update body and their response are { id, fieldA, fieldB, … }, where the top-level keys are field names, not scope groups. These are catalog / admin-config entities with a single scope and no sensitive/non-sensitive split, so per-field visibility filtering buys nothing.

Such entities are registered in FLAT_DTO_ENTITIES (src/common/constants/scope-fields.ts). Both FieldFilterInterceptor (response) and FieldWriteGuard (body) short-circuit before per-scope-group handling for them — route protection still flows through @RequireScopes(read|write) / @RequireAction. Forgetting to register one is a latent bug: the interceptor flags every flat field as drift (dev-throws / prod-strips → empty response), and the write guard rejects every body key as an unknown scope. Both only bite non-platform-admins (admins bypass both), so platform-admin-only test coverage will not catch it.

Current members: timetables, timetable_templates, evaluation_scales, audit_log. Each has a descriptor-only *_SCOPES constant (configuration: [] as const) and no FIELD_MAPPINGS row (the drift spec's "empty scopes don't require a mapping" branch exempts them). attendance is deliberately not flat — its register scope is field-level, reserved for future referent/student narrowing.

Single-configuration-scope entity, scope-grouped vs flat. school / departments / rooms also have a single configuration scope but are scope-grouped — their DTOs nest fields under configuration ({ id, configuration: {...} }). Whether a single-scope entity is scope-grouped or flat is a per-entity choice; if it's flat it MUST be in FLAT_DTO_ENTITIES, if it's scope-grouped it must NOT be.

Gate-only scopes

A scope can be declared with zero scope_field_mappings rows and used purely as a permission gate on sub-resource routes. Unlike field-group scopes (which drive FieldFilterInterceptor response stripping and FieldWriteGuard body-key checks), gate-only scopes participate only in the route-level scope check.

Pattern:

  1. Add the scope key to SCOPES.<entity> in prisma/seed/rbac-catalogue.ts.
  2. Add an empty array (<scope>: [] as const) to the matching *_SCOPES runtime constant in src/common/constants/scope-fields.ts. The drift-guard spec's "empty scopes don't require a mapping" branch exempts gate-only scopes from FIELD_MAPPINGS checks; the ENTITY_SCOPE_REGISTRY.<entity> set picks up the key via Object.keys(<ENTITY>_SCOPES) automatically.
  3. Omit any FIELD_MAPPINGS entry for the scope.
  4. Use @RequireScope(entity, scopeKey, mode) (singular) on the route(s) the scope gates. Mutually exclusive with @RequireScopes (plural) on the same handler — ScopeGuard rejects requests with both (SCOPE_DECORATOR_MISCONFIGURED, 500), and assertScopeDecoratorExclusivity fails the build in dev/CI.
  5. Add role grants in prisma/seed/roles.ts as usual.

When to reach for it: sub-resource routes that gate on a single conceptual scope where FieldFilterInterceptor / FieldWriteGuard don't apply — typically routes carrying @AggregateResponse() with a separate sub-resource data model.

When NOT to reach for it: entity-shaped CRUD where the user can write multiple scope groups in one body. That's the plural @RequireScopes case, with FieldWriteGuard doing per-key checks.

Singular vs plural decorator semantics:

Decorator Gate semantics Use case
@RequireScopes(entity, mode) (plural) Coarse — passes if the user has ANY scope on the entity at mode. Used in combination with FieldWriteGuard (per-key write checks) and FieldFilterInterceptor (per-key response stripping). Entity CRUD where the body / response is a scope-grouped DTO.
@RequireScope(entity, scopeKey, mode) (singular) Fine — passes only if the user has the specific scope key at mode. Used on sub-resource routes; no body-key or response-stripping interaction. Sub-resource gates (curriculum-selection, future gates).

Canonical example: students.curriculum_selection (seeded with no field mappings; gates /students/:id/curriculum-selection routes). Spec: docs/superpowers/specs/2026-05-27-students-curriculum-selection-scope-split-design.md.

Boot-time invariants

Three scanners run at app bootstrap via DiscoveryService.getControllers(). All three follow the same env-aware contract: strict in dev/CI (throw at boot), forgiving in production (console.error and continue).

Scanner File What it catches Coverage
assertAggregateResponseInvariant src/common/dto/aggregate-response-invariant.ts Handler returns a DTO extending AggregateResponseDto but lacks @AggregateResponse() Sync-only — TypeScript erases Promise<X> to bare Promise at runtime, so async handlers (essentially every controller method) are silently skipped. FieldFilterInterceptor runtime collision check is the real net. See inline doc for the Swagger-metadata upgrade path.
assertPolicyScopeAlignment src/common/dto/policy-scope-invariant.ts Handler has BOTH @AppliesPolicy(policy) and @RequireScopes(entity, ...) but policy.entityKey !== entity Full — both metadata are sync.
assertScopeDecoratorExclusivity src/common/dto/scope-decorator-invariant.ts Handler has BOTH @RequireScopes (plural) and @RequireScope (singular). They answer different questions; stacking is always a mistake Full — both metadata are sync. ScopeGuard is the runtime safety net (SCOPE_DECORATOR_MISCONFIGURED, 500).

Add a fourth scanner here when a new pair of decorators needs cross-handler invariant enforcement.

Entity Gate vs Field-Level Enforcement

ScopeGuard acts as a coarse entity-level gate: "does this user have ANY scope access on this entity?" It does not enumerate specific scopes — that would be redundant since real field-level enforcement is handled downstream.

Component Operates on Logic
ScopeGuard Entity-level access check Pass if user has ANY scope at the required level
ActionGuard Route's @RequireAction metadata Binary — user has the action (which embeds AND scope requirements)
FieldWriteGuard User's compiled scopes[entity] (WRITE access) Checks top-level body keys (scope group names) against user's writable scopes
FieldFilterInterceptor User's compiled scopes[entity] (READ+ access) Strips response scope groups the user lacks READ access on. Always keeps id, createdAt, updatedAt.

Example (update route): A route declares @RequireScopes('students', 'write'). A user with students.identity: WRITE and students.health: NONE:

  1. ScopeGuard — passes (user has WRITE on at least one scope: identity)
  2. FieldWriteGuard — allows { identity: { firstName: "Mario" } }. Rejects { health: { disabilityInfo: "ADHD" } } with 403 FORBIDDEN_FIELDS (user has no WRITE on health scope)
  3. FieldFilterInterceptor — response includes { id, identity: {...}, createdAt, updatedAt }. The health scope group is stripped because the user has NONE access.

Example (create route): A route declares @RequireAction('students', 'create'). The create action requires WRITE on students.identity only (the canonical creation scope — see the ACTION_REQUIREMENTS convention comment in rbac-catalogue.ts; person create/delete deliberately narrow to identity so e.g. HR can keep health/etc. at READ):

  1. ScopeGuard — passes through (no @RequireScopes metadata)
  2. ActionGuard — checks permissions.actions['students'].has('create'). This is only true if the user's compiled permissions satisfy all ActionScopeRequirement rows (WRITE on every required scope — here, students.identity). Rejects with ACTION_NOT_PERMITTED if not.
  3. FieldWriteGuard — derives entity from @RequireAction, checks body scope keys. Still enforces per-scope WRITE checks on the body.
  4. FieldFilterInterceptor — derives entity from @RequireAction, filters response scope groups normally.

Why this is secure: Scope-group enforcement always uses the user's actual per-scope permissions as compiled in CompiledPermissions. A user cannot read or write scope groups they lack access to, regardless of which decorator controls route access.

See chapter 05 for the full controller recipe with decorators in context.


Permission Seed Patterns

New Entity Permission Checklist

When adding a new entity to the permission system:

  1. EntityKey constant in src/common/constants/entity-keys.ts
  2. PermissionEntity row in seed ENTITIES array
  3. PermissionScope(s) in seed SCOPES object (plus others scope if custom fields will be supported)
  4. ScopeFieldMapping rows in seed FIELD_MAPPINGS (use shared constants from scope-fields.ts)
  5. PermissionAction(s) in seed ACTIONS array (typically create + delete)
  6. ActionScopeRequirement(s) in seed ACTION_REQUIREMENTS (which WRITE scopes each action needs)
  7. Role grants in roles.ts: admin WRITE all scopes + all actions, teacher READ subset

Condensed Seed Pattern

// prisma/seed/rbac-catalogue.ts — declarative arrays, seeder loops through them
const ENTITIES = [{ key: 'students', label: 'Students', sortOrder: 1 }, ...];
const SCOPES   = { students: [{ key: 'identity', ... }, { key: 'contacts', ... }] };
const ACTIONS  = [{ entityKey: 'students', key: 'create', ... }];
const ACTION_REQUIREMENTS = { 'students.create': { scopeKeys: ['students.identity', ...] } };
const FIELD_MAPPINGS = [
  { entityKey: 'students', scopeKey: 'identity', tableName: 'students',
    fields: STUDENT_SCOPES.identity },   // uses shared constants — no duplication
];

// prisma/seed/roles.ts — admin gets WRITE on all scopes + all actions
for (const scopeId of allScopeIds) {
  await prisma.rolePermission.upsert({
    where: { roleId_scopeId: { roleId, scopeId } },
    create: { roleId, scopeId, access: 'WRITE' }, update: {},
  });
}

No update actions — scope WRITE already handles field-level write access for update operations. Actions are for binary operations only (create, delete).

Canonical examples: prisma/seed/rbac-catalogue.ts, prisma/seed/roles.ts.


Temporal Role Assignments

The user_roles table supports temporal assignments via validFrom and validUntil:

Use case: Substitute teacher

  1. School admin creates UserRole with validFrom: 2026-03-01 and validUntil: 2026-06-30
  2. PermissionsService.getUserPermissions() automatically filters: validFrom <= NOW() AND validUntil > NOW()
  3. The substitute's permissions are active only during the specified window
  4. No manual cleanup needed — expired roles are excluded at query time

Deferred: BullMQ scheduled job for cache invalidation at role expiry (relevant once Redis caching is implemented — see Permission Caching).

Assigning roles — POST/PATCH/DELETE /role-assignments

Admin-only (@RequireRoles('admin'), same bare guard stack as RolesController — no @ProtectedResource). Assigns the management roles (admin/principal/hr/secretary/department_head/curriculum_coordinator + future custom roles) to a Teacher/Staff person row (RoleAssignmentsController / RoleAssignmentsService, renamed from /user-roles on 2026-07-01). The profile-coupled roles (teacher/referent/student, listed in PROFILE_COUPLED_ROLE_KEYS) are bound at invitation-accept and rejected here (ROLE_NOT_ASSIGNABLE); REFERENT/STUDENT recipient types are rejected at DTO validation (management roles attach to employees only).

Person-anchored, PENDING→ACTIVE. The assignment targets (recipientType, recipientId) — the exact anchor Invitation uses — so a role can be pre-assigned before the person has a user (before any invite). It is one UserRole row: userId null while PENDING, backfilled to the created user inside the accept transaction (InvitationsService.acceptToken, right after setRecipientUserId), where it becomes ACTIVE and its role key is folded into the first JWT. Assigning to a person who already has a user lands ACTIVE immediately (live on next token refresh). Parametric UserRoleParameter rows are created once and ride the pending→active transition unchanged.

  • POST /role-assignments — all-or-nothing batch { recipientType: TEACHER|STAFF, recipientId, assignments: [{ roleKey, parameterValueIds?, validUntil? }] }.
  • PATCH /role-assignments/:id — replace parametric values / set-clear validUntil (presence semantics).
  • DELETE /role-assignments/:id — revoke.
  • GET /role-assignments?recipientType=&recipientId=&roleKey= — list; each row carries status (PENDING/ACTIVE/EXPIRED), the recipient reference, and the user (null while PENDING).

Parametric values are validated against DIMENSION_TARGETS[dim].delegate (the four INVALID_ROLE_ASSIGNMENT_PARAMETER reasons). The last active admin cannot be revoked or expired (LAST_ADMIN_REQUIRED) — the guard counts only materialized admin rows (userId: { not: null }), so a PENDING admin pre-assignment never masks a real zero-admin lockout. On Teacher/Staff hard-delete, anchored UserRole rows are cleaned up in InvitationsService.invalidateByRecipient(..., 'ROLE_DELETED') (soft reference has no FK cascade); an email change keeps them. Specs: docs/superpowers/specs/2026-06-08-role-assignment-design.md (iteration 1) + docs/superpowers/specs/2026-07-01-role-preassignment-on-person-design.md (person-anchored pre-assignment, iteration 4).

Editing role grants — PATCH /roles/:key

Admin-only (@RequireRoles('admin'), same bare guard stack as the rest of the roles surface). Edits the scope/action grants of an existing role via a sparse delta { scopes:[{entityKey,scopeKey,access}], actions:[{entityKey,actionKey,granted}] }; access: NONE / granted: false remove a grant. Only the management presets are editable — admin and the profile-coupled roles (teacher/referent/student, via PROFILE_COUPLED_ROLE_KEYS) are frozen (ROLE_NOT_EDITABLE). Editable iff key !== 'admin' && !PROFILE_COUPLED_ROLE_KEYS.includes(key).

Validation rejects the whole PATCH atomically: unknown catalogue keys (ROLE_GRANT_INVALID / UNKNOWN_ENTITY|UNKNOWN_SCOPE|UNKNOWN_ACTION) and incoherent action grants — the resulting state must hold every scope an action requires at the required access, so granting an action without its scope (or removing a scope under a still-granted action) is rejected (ACTION_MISSING_SCOPES); satisfy it by granting the action and its scope in the same PATCH.

Grants compile live (getUserPermissions reads RolePermission/RoleActionPermission per request), so edits take effect on each holder's next request — no token refresh (unlike role assignment, whose keys live in the JWT). A local/e2e destructive reseed resets roles to the canonical baseline; on hosted dev/stage and production the global-preset seeder runs on every deploy but is create-only (it never overwrites existing grants), so platform-admin edits are durable across redeploys. Role creation/deletion (custom roles) is deferred — see the appendix of docs/superpowers/specs/2026-06-09-role-grant-editing-design.md.

Global preset roles + backoffice editing — GET/PATCH /roles/presets

The 9 preset roles are a global, platform-curated catalogue: Role rows with tenantId IS NULL (one per key, enforced by the partial unique index roles_platform_key_key). They are the code-defined baseline (authored once by seedGlobalRolePresets); each tenant gets a clone at provisioning via cloneRolesIntoTenant (src/permissions/role-clone.ts) — the seed no longer authors per-tenant roles by hand. Presets authored after a tenant was provisioned (e.g. secretary / curriculum_coordinator, 2026-07-02) reach existing tenants via the additive-only Tier-1 step backfillMissingPresetRoles (same file): it creates only roles a tenant lacks entirely and never touches existing roles or their grants, so per-tenant grant edits survive. Compilation and assignment only ever resolve real-tenant rows, so a null-tenant preset is never assigned to a user or compiled into a session.

Platform admins edit the global presets in the backoffice: GET /roles/presets (catalogue) + PATCH /roles/presets/:key (grants), both @PlatformAdminOnly. The PATCH reuses the same RoleGrantsService.patchGrants engine generalized to a tenantId | null target, with the same editable ruleadmin + the profile-coupled trio are frozen, so only principal/hr/secretary/department_head/curriculum_coordinator are editable. Edits change future clones only; existing tenants keep their copies (and their own tenant-admin PATCH /roles/:key customizations). Spec: docs/superpowers/specs/2026-06-09-backoffice-role-preset-editing-design.md.

Code-vs-DB drift guard (audit #4). Runtime authz resolves from DB grant rows, and the seed is additive, so a grant removed from code can silently linger in the DB (over-grant on children's PII). Guards: rolePermission upserts re-apply access-level changes on reseed (update: { access }); a grant removal needs a hand DELETE migration; and test/rbac-grants.db-sync.e2e-spec.ts asserts every isPreset role's DB grants equal expectedPresetGrants(catalogue) (the code mirror, prisma/seed/helpers/) in both directions — a forgotten removal turns the build red. See chapter 15 §Tier 1.


Write Protection

Status: COMPLETE. Implemented as FieldWriteGuard (src/permissions/guards/field-write.guard.ts).

The problem FieldWriteGuard solves: without write validation, a user with identity.write but NOT health.write could submit a PATCH with { firstName: "Mario", disabilityInfo: "ADHD" }. ScopeGuard passes (user has ANY write scope), the DTO validates the shape, and the service updates ALL submitted fields — including disabilityInfo, a scope the user cannot write.

How it works: The guard runs after ScopeGuard and ActionGuard, before the controller handler:

  1. Derives entity from @RequireScopes() or @RequireAction() metadata (whichever is present)
  2. Reads request.permissions.scopes[entity] to determine which scopes the user has WRITE access on
  3. Compares top-level request body keys (scope group names like identity, health) against writable scopes
  4. System fields (id, createdAt, updatedAt, tenantId) are always rejected
  5. If body contains scope groups outside the user's writable scopes: throws ForbiddenFieldsException with error code FORBIDDEN_FIELDS
{
  "statusCode": 403,
  "code": "FORBIDDEN_FIELDS",
  "message": "Insufficient write permissions"
}

Security note: The specific forbidden scope keys are logged server-side for debugging but intentionally excluded from the API response to avoid leaking internal permission structure.

Why explicit 403 over silent stripping: The frontend should know exactly why a write was rejected. This enables clear error messages, pre-disabled form sections (using GET /api/v1/permissions data), and straightforward debugging with no mystery data loss.

Admin bypass — isPrivileged(ctx)

In-service record gates (the referent canWrite gate, attendance's Y-set, the grades assigned-teacher gate, the selection-window lock) all let an admin / platform admin through. That "acts as admin" test has a single source of truth: isPrivileged(ctx) (src/common/utils/is-privileged.ts) = ctx.isPlatformAdmin || ctx.roles.includes('admin').

  • Never inline the literal. src/permissions/no-admin-bypass-literal.drift.spec.ts greps src/** and fails if roles.includes('admin') reappears anywhere but the helper. Call isPrivileged(ctx) instead.
  • Recipe — new in-service admin bypass: import { isPrivileged } from '../common' (or '../common/utils'), then if (isPrivileged(ctx)) { /* skip the record check */ } or if (!isPrivileged(ctx)) { /* apply the narrowing */ }.
  • Write-authority ≤ read-visibility is not auto-enforced. Where a write gate diverges from the read policy (attendance's Y-set is intentionally broader than read), nothing structural checks the relation — the guarantee is the adversarial e2e: test/write-authority-adversarial.e2e-spec.ts asserts a principal who passes the scope+role gate but fails the record gate gets 403/404 with no mutation. Add a scenario there for every new record-scoped write sub-resource. (Architecture-audit #3, Wave 3 sliver; the heavier @WriteAuthority manifest + boot scanner were deferred — RLS is the structural backstop for the "forgot the record filter" class.)

Record-Level Access

The Problem

Tenant isolation (tenantId in every where) is the first level of record filtering — see chapter 02. Inside a tenant, different roles should still see different subsets of the same entity:

  • Admin sees every student in the school.
  • Teacher sees students they teach.
  • Referent (parent/guardian) sees their linked children only.

The Entity-Scope model controls which fields are visible but not which records. Record-level filtering closes that gap.

Selected Approach

Now (Phase 1): Service-layer filtering through RecordAccessContext. Explicit, testable, and rolled out incrementally per entity.

Phase 2 (deferred): Add PostgreSQL RLS policies as defense in depth once Prisma session-variable support stabilizes. Service-layer filters remain primary; RLS acts as a safety net.

Pattern: RecordAccessContext

Controllers build a RecordAccessContext from the authenticated request and pass it into service methods in place of (or alongside) tenantId:

// src/common/interfaces/record-access-context.interface.ts
export interface ParametricContext {
  departmentIds: string[];
  // Future per-dimension arrays land here as new ParameterDim values land.
}

export interface RecordAccessContext {
  tenantId: string;
  userId: string;
  roles: string[];                         // role keys from JWT (active-profile narrowed)
  isPlatformAdmin: boolean;
  parameters: ParametricContext;           // values from active parametric UserRoles
  teacherDepartmentIds: string[];          // identity-narrowed: caller's TeacherDepartment set
  parameterDimensions: ParameterDimValue[]; // dimensions with non-empty `parameters.*` arrays
}

parameters is populated by PermissionsService.getAccessContextSlice as part of the same request hook that compiles request.permissions. It walks active UserRole rows whose Role.parameterDim is non-null and groups their UserRoleParameter.valueIds by dimension. Empty arrays when the user holds no parametric assignment in that dimension.

teacherDepartmentIds is identity-narrowed (not assignment-parameterized): it's the TeacherDepartment.departmentId set for the caller's Teacher record in the tenant. Only fetched when 'teacher' is in active role keys (fast-path).

parameterDimensions is the set of ParameterDim values for which parameters has non-empty arrays. Consumed by RolesGuard for dimension-overlap admission — a user whose parameterDimensions overlaps a policy's parametricDimensions is admitted even when their role keys don't intersect policy.roles. This is the lever that admits custom parametric roles by data alone.

The @AccessContext() param decorator (src/common/decorators/access-context.decorator.ts) lifts this off req.user in one step — the same ergonomics as @TenantId():

@Get()
@RequireScopes(EntityKey.STUDENTS, 'read')
@AppliesPolicy(StudentsPolicy)
async findAll(
  @Query() query: AcademicYearPaginationQueryDto,
  @AccessContext() ctx: RecordAccessContext,
) {
  return this.studentsService.findAllForAccessContext(
    ctx,
    query.page,
    query.limit,
    query.academicYearId,
  );
}

EntityAccessPolicy — single source of truth

Since 2026-05-26, record-access logic is centralised in an EntityAccessPolicy<TWhere> produced by definePolicy({ entityKey, buildBase, branches, parametricBranches? }) (see src/common/utils/entity-access-policy.ts). A policy bundles four things in one frozen declaration:

Field Shape Consumed by Purpose
entityKey EntityKeyValue assertPolicyScopeAlignment boot-time check Locks the policy to a permission_entities.key; drift vs the route's scope decorator is rejected at boot.
roles readonly string[] (frozen, in branch-declaration order) @AppliesPolicy(policy)RolesGuard via @RequireRoles(...policy.roles) Sole source of the route's role allowlist — no separate @RequireRoles(...) list to maintain.
parametricDimensions readonly ParameterDimValue[] (frozen) RolesGuard parametric-overlap admission Set of ParameterDim values this policy narrows by. Any user whose parameterDimensions overlaps this set is admitted even when their role keys don't intersect roles — the lever for custom parametric roles.
where (ctx: RecordAccessContext) => TWhere \| NEVER_MATCH_WHERE <EntityService> (typically merged into findMany/findFirst) The Prisma WHERE resolver — produced by makeRoleVisibilityResolver from the supplied branches.

Because all four come from the same definePolicy call, the route role gate, the parametric-overlap admission, the Prisma narrowing, and the boot-time entity-key alignment cannot drift independently.

Full design rationale: docs/superpowers/specs/2026-05-26-entity-access-policy-design.md. Parametric extension: docs/superpowers/specs/2026-05-27-parametric-roles-design.md.

definePolicy — branches and resolution

definePolicy takes a buildBase builder plus two kinds of branches: role-keyed and parametric. Both contribute Prisma WHERE fragments; the resolver OR-composes them at request time.

Role-keyed branches (required) — one entry per role key the policy admits. Each branch's build field is one of:

  • 'pass-through' (string literal): the caller carrying this role sees the entire buildBase slice (i.e. the tenant base). Short-circuits the resolverOR(base, narrow) reduces to base semantically, so the resolver returns base immediately for a cleaner SQL predicate.
  • (ctx, base) => TWhere | NEVER_MATCH_WHERE: a narrowing builder that returns the FULL where (typically { ...base, ...narrow }). Return NEVER_MATCH_WHERE ({ id: { in: [] as string[] } }) when the caller carries the role but lacks the data to narrow (e.g. a teacher with no TeacherDepartmentAssignment rows yet) — Prisma optimises empty in lists to a constant-false predicate.

Parametric branches (optional) — one entry per ParameterDim value the entity narrows by. Each branch is { dim, narrow: (base, valueIds) => TWhere }. Activated by ctx.parameters[<dim>] being non-empty for that dimension; role-agnostic — fires for ANY user with parametric values in that dimension, including custom tenant-defined roles whose Role.parameterDim is set. Today's only dimension is DEPARTMENT; parameters.departmentIds is the corresponding ctx slice.

Resolution algorithm (makeRoleVisibilityResolver, evaluated on every policy.where(ctx) call):

  1. Platform admin short-circuit. ctx.isPlatformAdmin === true → return base unconditionally. Platform admins always see the full tenant slice on every entity.
  2. Role-keyed branches, in declaration order: skip branches whose role isn't in ctx.roles. Any matching 'pass-through' branch returns base immediately (short-circuit). Each matching narrowing branch appends its TWhere to the contribution list; NEVER_MATCH_WHERE contributions are silently dropped.
  3. Parametric branches, in declaration order: for each branch, if ctx.parameters[<branch.dim>] is non-empty, append branch.narrow(base, ids) to the contributions.
  4. Compose: 0 contributions → return NEVER_MATCH_WHERE (fail closed); 1 contribution → return it directly; 2+ contributions → return { OR: [...contributions] }.

Tenant filter is in every contribution via base (set exclusively in buildBase), so OR(...) preserves tenant isolation by construction. Branch order is therefore presentational — pass-through wins by short-circuit and narrowings compose by union, not by first-match priority.

@AppliesPolicy — the controller composition

@AppliesPolicy(<Entity>Policy) (in src/common/decorators/applies-policy.decorator.ts) is the only decorator a controller method needs to wire a policy. It composes two things:

applyDecorators(
  SetMetadata(POLICY_METADATA, policy),   // read by RolesGuard + assertPolicyScopeAlignment
  RequireRoles(...policy.roles),          // populates the existing ROLES_KEY metadata
);

The existing RolesGuard keeps working unchanged — it reads ROLES_KEY as always — and the new metadata key (POLICY_METADATA, a Symbol defined in src/common/constants/policy-metadata.ts) is read by:

Consumer What it reads What it does
RolesGuard policy.parametricDimensions After the static role allowlist check fails, admits users whose user.parameterDimensions overlaps policy.parametricDimensions. This is the only way a tenant's custom DEPARTMENT-parameterized role enters a route without its role key being statically listed.
assertPolicyScopeAlignment (src/common/dto/policy-scope-invariant.ts) policy.entityKey Boot-time drift check: for handlers that carry BOTH @AppliesPolicy(policy) AND a scope decorator (@RequireScopes plural OR @RequireScope singular), the policy's entityKey must equal the scope decorator's entity. Routes that have only one of the two are skipped (e.g. command-center aggregate routes have @AppliesPolicy but no scope decorator).

Use raw @RequireRoles(...) only when a route's role gate is tighter than any per-entity policy (e.g. imports are @RequireRoles('admin') regardless of which entity's policy is in play). Everywhere else, @AppliesPolicy is the preferred shape because the role list and the WHERE branches travel together.

@AppliesPolicyDimensions(policy) (same file) is the metadata-only sibling for the tighter-gate case that must still admit parametric managers by data: it sets POLICY_METADATA without composing RequireRoles(...policy.roles). The placement boards + eligibility pickers use it — @RequireRoles('admin', 'department_head') stays the static gate (teachers/referents/hr keep their 403), while RolesGuard's dimension-overlap admission reads the metadata and admits curriculum_coordinator / custom parametric roles, whose responses then self-narrow through policy.where(ctx). Pair it with an explicit @RequireRoles — on its own it gates nothing.

Service consumption

The service merges Policy.where(ctx) into every read on that entity:

// src/students/students.service.ts — canonical pattern
async findAllForAccessContext(ctx: RecordAccessContext, page: number, limit: number) {
  return this.prisma.student.findMany({
    where: StudentsPolicy.where(ctx),
    skip: (page - 1) * limit,
    take: limit,
  });
}

async findOneForAccessContext(ctx: RecordAccessContext, id: string) {
  return this.prisma.student.findFirst({
    where: { ...StudentsPolicy.where(ctx), id },
  });
}

Use findFirst with the policy where AND the id, not findUnique followed by a manual check. Folding the access filter into the same where produces a 404 not 403 when a caller asks for a record they cannot see — matching the cross-tenant isolation pattern and not leaking existence.

Policy inventory

The following policies are wired today. "Parametric dims" lists every ParameterDim value the policy admits via parametric-dimension overlap; (direct FK) / (M2M) / (via X) indicates the predicate shape the parametric branch contributes.

Entity Policy file Parametric dims Notes
Students src/students/students.policy.ts DEPARTMENT (direct FK on Student.departmentId), CURRICULUM (via curriculumSelection.curriculumId — only students who selected an assigned curriculum) 'teacher' branch narrows by ctx.teacherDepartmentIds; 'referent' branch joins through StudentReferentLink; 'student' branch narrows to the caller's own row via userId: ctx.userId.
Teachers src/teachers/teachers.policy.ts DEPARTMENT (M2M via TeacherDepartmentAssignment) — no CURRICULUM branch 'teacher' branch narrows to caller's own departments via ctx.teacherDepartmentIds. curriculum_coordinator is a role-keyed pass-through (tenant-wide directory): no teacher↔curriculum relation exists to narrow by, and SG-teaching-derived narrowing would be circular. A custom CURRICULUM role fail-closes here.
Staff src/staff/staff.policy.ts none (tenant-flat) department_head is role-keyed pass-through (no Staff↔Department model yet); flip to a parametric branch when that signal lands. Coordinator holds no staff scopes → ScopeGuard blocks first, policy fail-closes too.
Referents src/referents/referents.policy.ts none 'referent' branch narrows to self via userId: ctx.userId. Write-side assertCallerIsSelfOrAdmin is the redundant service-layer net.
Departments src/departments/departments.policy.ts DEPARTMENT (id IN (...)), CURRICULUM (curricula: { some: { id IN } } — the owning department) The dept-head only sees their own departments; the coordinator sees the department(s) owning their curricula (READ).
Grades src/departments/grades.policy.ts DEPARTMENT (direct FK on Grade.departmentId), CURRICULUM (via department.curricula)
Curricula src/curriculum/curricula.policy.ts DEPARTMENT (direct FK on Curriculum.departmentId), CURRICULUM (id IN (...) — the assigned rows themselves) The CURRICULUM branch doubles as the coordinator's write fence: every curricula write path resolves the row through CurriculaPolicy.where(ctx), so an unassigned curriculum 404s on PATCH/status/delete exactly like on reads.
Selection Windows src/curriculum/selection-windows.policy.ts DEPARTMENT (direct FK on CurriculumSelectionWindow.departmentId), CURRICULUM (via department.curricula) Windows are per-department: the coordinator reads the owning department's window; window actions are not granted to the role.
Homerooms src/homerooms/homerooms.policy.ts DEPARTMENT (direct FK), CURRICULUM (direct FK on Homeroom.curriculumId)
Subject Groups src/subject-groups/subject-groups.policy.ts DEPARTMENT (via curriculumSubject.curriculum.departmentId), CURRICULUM (via curriculumSubject.curriculumId — one hop shorter) SG has no direct departmentId; narrows through the anchor subject.
Attendance src/attendance/attendance.policy.ts DEPARTMENT (direct FK on denormalized AttendanceRecord.departmentId) 'teacher' branch narrows by ctx.teacherDepartmentIds. Read is intentionally broad; write authority is the stricter in-service Y-set. Routes also carry @RequireRoles('admin', 'teacher'). Only admin + teacher branches exist — resolver fails closed (NEVER_MATCH) for everyone else.
Academic Years src/academic-years/academic-years.policy.ts none Admin-only.
School src/school/school.policy.ts none Admin-only singleton.
Completeness (dashboard) src/command-center/completeness.policy.ts none Aggregate route — where is not consumed by the service. Narrowing for the per-student rollup is delegated to StudentsPolicy.where inside completeness.queries.ts (see "Aggregate routes" below).
Onboarding (dashboard) src/command-center/onboarding.policy.ts none Same shape as Completeness.
Study-Plan Selections (dashboard) src/command-center/study-plan-selections.policy.ts none Same shape as Completeness.

Tenant-flat entities (rooms, evaluation_scales, timetable_templates, users, invitations) have no record-level policy — their role grants alone gate access. Add one only when a future axis (department, school year, …) needs to narrow within the tenant.

Aggregate routes (no service-level where). Command-center dashboards (completeness, onboarding, study-plan-selections) declare an EntityAccessPolicy so the route gets the standard @AppliesPolicy shape — role allowlist + parametric-overlap admission + boot-time entity-key check — but the policy's where(ctx) is never AND-ed into a top-level query. Inside the queries module, the per-entity policies (StudentsPolicy.where(ctx), etc.) are merged into the aggregation joins as usual. This keeps the route gate uniform and pushes narrowing to the underlying entities.

Canonical example

// src/students/students.policy.ts (excerpt)
export const StudentsPolicy = definePolicy<Prisma.StudentWhereInput>({
  entityKey: EntityKey.STUDENTS,
  buildBase: (ctx) => ({ tenantId: ctx.tenantId }),
  branches: [
    { role: 'admin', build: 'pass-through' },
    { role: 'hr', build: 'pass-through' },
    { role: 'principal', build: 'pass-through' },
    { role: 'staff', build: 'pass-through' },
    {
      role: 'teacher',
      build: (ctx, base) => {
        if (ctx.teacherDepartmentIds.length === 0) return NEVER_MATCH_WHERE;
        return { ...base, departmentId: { in: ctx.teacherDepartmentIds } };
      },
    },
    {
      role: 'referent',
      build: (ctx, base) => ({
        ...base,
        referents: {
          some: { referent: { userId: ctx.userId, tenantId: ctx.tenantId } },
        },
      }),
    },
  ],
  parametricBranches: [
    {
      dim: ParameterDim.DEPARTMENT,
      narrow: (base, ids) => ({ ...base, departmentId: { in: ids } }),
    },
  ],
});

// Controller — single decorator wires the whole role gate + parametric admission
@Get(':id')
@RequireScopes(EntityKey.STUDENTS, 'read')
@AppliesPolicy(StudentsPolicy)
async findOne(@Param('id') id: string, @AccessContext() ctx: RecordAccessContext) {
  return this.studentsService.findOneForAccessContext(ctx, id);
}

// Service — `policy.where(ctx)` is the only narrowing call
async findOneForAccessContext(ctx: RecordAccessContext, id: string) {
  return this.prisma.student.findFirst({
    where: { ...StudentsPolicy.where(ctx), id },
  });
}

Filtering logic per role type (students): (resolution semantics per definePolicy)

Role type Resolver contribution Effective WHERE
Platform admin ctx.isPlatformAdmin → returns base immediately, before any branch is evaluated WHERE tenantId = ?
Admin / HR / Principal / Staff Role-keyed 'pass-through' — short-circuits the resolver WHERE tenantId = ?
Teacher Role-keyed narrowing: narrows by caller's own TeacherDepartmentAssignment set (ctx.teacherDepartmentIds). Empty set → NEVER_MATCH_WHERE, branch drops out of the OR WHERE tenantId = ? AND departmentId IN (ctx.teacherDepartmentIds)
Department Head (and any custom DEPARTMENT-parameterized role) Parametric DEPARTMENT branch fires when ctx.parameters.departmentIds is non-empty. The resolver passes those ids into the branch as narrow(base, ids); the branch never reads off ctx directly. Admission is via parametric-dimension overlap in RolesGuard, not role-key match WHERE tenantId = ? AND departmentId IN (ids)
Referent Role-keyed narrowing through StudentReferentLink WHERE tenantId = ? AND referents SOME (referent.userId = ?)
No matching role and no parametric branch fires NEVER_MATCH_WHERE (fail closed) WHERE id IN () — Prisma optimises to a constant-false predicate

When multiple contributions land (e.g. a user holds teacher AND a DEPARTMENT-parametric custom role), the resolver returns { OR: [...contributions] }. tenantId is in every contribution via base, so OR preserves tenant isolation.

Parametric DEPARTMENT predicate per entity (canonical narrow(base, ids) body — ids is the parametric-branch arg, sourced from ctx.parameters.departmentIds):

Entity Predicate shape Notes
Students { ...base, departmentId: { in: ids } } Direct FK
Teachers { ...base, departments: { some: { departmentId: { in: ids } } } } M2M via TeacherDepartmentAssignment
Departments { ...base, id: { in: ids } } The dept-head only sees their own departments
Grades { ...base, departmentId: { in: ids } } Direct FK
Curricula { ...base, departmentId: { in: ids } } Direct FK
Selection Windows { ...base, departmentId: { in: ids } } Direct FK
Homerooms { ...base, departmentId: { in: ids } } Direct FK
Subject Groups { ...base, curriculumSubject: { curriculum: { departmentId: { in: ids } } } } Indirect via CurriculumSubject → Curriculum
Attendance { ...base, departmentId: { in: ids } } Direct FK on denormalized AttendanceRecord.departmentId

Tenant-flat entities (staff, rooms, evaluation_scales, timetable_templates, school, academic_years, users, invitations, referents) have no parametric DEPARTMENT branch — they expose a role-keyed 'pass-through' for department_head (and admin/hr/principal as appropriate), which short-circuits the resolver cleanly. When a dept-link signal lands on one of those entities, flip its branch from role-keyed pass-through to a parametric DEPARTMENT branch (see staff.policy.ts for the planned migration note).

Invariants

  1. Decorator-filter synchronization. The @AppliesPolicy(policy) decorator and the policy.where(ctx) call read the same frozen policy object — there is no separate role list to maintain. The boot-time assertPolicyScopeAlignment check (src/common/dto/policy-scope-invariant.ts) verifies the policy's entityKey matches the route's @RequireScopes / @RequireScope entity on every handler that carries both.
  2. buildBase is the only tenantId source. Every contribution path (role-keyed narrowings, parametric narrowings, pass-through short-circuit, the fall-through NEVER_MATCH_WHERE) reads tenantId from base, which is built once at the top of where(ctx). Tenant isolation therefore survives OR(...) composition by construction — there is no branch where tenantId can be dropped.
  3. Platform admin always short-circuits first. Platform admins bypass the role gate at the guard layer (see Guard Chain); makeRoleVisibilityResolver mirrors that — ctx.isPlatformAdmin returns base before any branch is evaluated, so a platform admin with an unrelated role in their JWT still sees the full tenant slice.
  4. Parametric overlap admits by data, not by key. A user whose parameterDimensions overlaps the policy's parametricDimensions passes the role gate even when their role keys don't intersect policy.roles. This is the lever that lets a tenant define a custom DEPARTMENT-parameterized role and have it work on every entity that admits DEPARTMENT — without changes to policy.roles.
  5. Additive, never expansive. The policy's where(ctx) output is ANDed into the service's where. It narrows the result set; it never grants access the service would otherwise deny.
  6. findFirst with merged where for findOne. Merging the record-scope filter into the same where clause as the id lookup produces a 404 not 403 when a caller asks for a record they cannot see — consistent with the cross-tenant isolation pattern and avoids leaking existence.
  7. Fail-closed sentinel. When no branch contributes (no matching role-keyed branch, no parametric branch with non-empty IDs, and the caller isn't a platform admin), where(ctx) returns NEVER_MATCH_WHERE — defined as { id: { in: [] as string[] } }. Prisma optimises the empty in list to a constant-false predicate, so the query returns zero rows. The unit specs in entity-access-policy.spec.ts and each <entity>.policy.spec.ts assert this explicitly.

Canonical reference

StudentsPolicy (src/students/students.policy.ts) is the canonical implementation of EntityAccessPolicy. StudentsService.findAllForAccessContext / findOneForAccessContext (src/students/students.service.ts) shows how StudentsPolicy.where(ctx) is consumed in the service. The companion unit tests in students.policy.spec.ts, students.service.spec.ts, and students.controller.spec.ts cover one test per role branch, platform-admin short-circuit, fail-closed sentinel, parametric-overlap admission, and decorator/filter synchronization.


Permission Caching

PermissionsService.getUserPermissions() executes a nested Prisma query traversing:

userRoles -> role -> permissions -> scope -> entity
                  -> actionPermissions -> action -> scopeRequirements

Multiple guards and the interceptor need the same compiled permissions per request. Without memoization, the query would run for each guard.

Phase 1: Request-Scoped Memoization (Complete)

Pre-compile permissions once per request and attach to the request object. The ScopeGuard calls getUserPermissions() once and stores the result on request.permissions. All downstream guards and interceptors read from the memoized result:

  1. ScopeGuard calls ensurePermissionsLoaded(request, permissionsService) — loads and memoizes
  2. ActionGuard reads from request.permissions (already compiled)
  3. FieldWriteGuard reads from request.permissions (already compiled)
  4. FieldFilterInterceptor reads from request.permissions (already compiled)

This ensures exactly one permission DB query per request, regardless of how many guards run.

// In ScopeGuard (first guard):
await ensurePermissionsLoaded(request, this.permissionsService);
const permissions = request.permissions; // freshly compiled

// In ActionGuard, FieldWriteGuard, FieldFilterInterceptor:
const permissions = request.permissions; // already memoized

Phase 2: Redis Caching (Deferred)

  • Cache CompiledPermissions in Redis with key permissions:{userId}:{tenantId} and 5min TTL
  • Invalidate on role/permission admin changes (admin API calls redis.del(key))
  • BullMQ scheduled job for cache invalidation at validUntil expiry
  • Request-scoped memoization remains as a request-level optimization on top of Redis

Scope Fields & Custom Fields

Shared Scope Field Constants

All field name arrays live in src/common/constants/scope-fields.ts. This single source of truth is consumed by both the seed (for ScopeFieldMapping rows) and services (for getScopeFieldMappings()):

// src/common/constants/scope-fields.ts
export const STUDENT_SCOPES = {
  identity: ['firstName', 'lastName', 'nickName', 'dateOfBirth', ...] as const,
  contacts: ['homePhone', 'homeAddress', 'homeCity', ...] as const,
  assignment: ['enrollmentDate', 'departmentId', 'gradeId', ...] as const,
  health: ['attentionFlag', 'medicalProblems', ...] as const,
  documents: ['passportNumber', 'passportIssueDate', ...] as const,
} as const;
// Also: TEACHER_SCOPES, STAFF_SCOPES, REFERENT_SCOPES, USER_SCOPES, DEPARTMENT_SCOPES,
// GRADE_SCOPES, ROOM_SCOPES, ACADEMIC_YEAR_SCOPES, CURRICULUM_SCOPES, STUDY_PLAN_SCOPES

Adding a field: add the field name to the relevant array in scope-fields.ts. Both getScopeFieldMappings() and the seed ScopeFieldMapping rows pick it up automatically. No seed changes needed (seed uses the shared constant arrays).

If the scope uses a function mapping (not array) in getScopeFieldMappings() — like assignment in StudentsService — also update that function to include the new field.

Custom Fields

The implementation uses a single JSONB column per entity (customFields Json?) with scope assignment via custom_field_definitions.scopeId. Custom fields are nested within scope groups in entity responses:

{
  "id": "student-uuid",
  "identity": {
    "firstName": "Marco",
    "lastName": "Rossi",
    "customFields": { "nickname": "Marc" }
  },
  "health": {
    "disabilityInfo": null,
    "customFields": { "blood_type": "O+" }
  },
  "others": {
    "customFields": { "notes": "Transfer student" }
  },
  "createdAt": "...",
  "updatedAt": "..."
}

Why single JSONB + scope assignment:

  1. Zero changes to the permission system — custom fields are nested inside scope groups. FieldFilterInterceptor strips entire scope groups based on access, so custom fields are automatically filtered with their parent scope.
  2. Aligns with the scope philosophy — custom fields inherit the semantics of their assigned scope. "Blood type is sensitive data" is the same mental model whether it's a system field or a custom field.
  3. Simple admin UX — "Which category does this field belong to?" is the only question at creation time.
  4. Future-proof — a single JSONB column supports both platform-defined and future admin-created scopes without schema migrations.
  5. Default others scope — every entity has an others scope as a catch-all for custom fields that don't fit existing categories. scopeKey defaults to others when not specified.

Custom Field Definitions Table

custom_field_definitions
  id UUID PK
  tenant_id UUID FK
  entity_key VARCHAR        -- e.g., 'students', 'teachers'
  scope_id UUID FK          -- links to permission_scopes
  field_key VARCHAR         -- e.g., 'blood_type', 'nickname'
  label VARCHAR             -- display name
  field_type FieldType      -- TEXT, NUMBER, DATE, BOOLEAN, SELECT
  options Json?             -- for SELECT type: ["A+","A-","B+",...]
  sort_order INT
  is_required BOOLEAN
  created_at, updated_at
  @@unique([tenant_id, entity_key, field_key])

Custom Fields Integration in Services

  • customFields Json? column on the Prisma model
  • Inject CustomFieldsService in entity service constructor (already handled by BaseTenantedCrudService)
  • validateCustomFields() called automatically by base service on create/update
  • pickCustomFields() and dynamic scope auto-discovery handled by toScopedResponse()
  • others scope: default scope for custom fields, auto-discovered from definitions — no entity service code needed

Validation

CustomFieldsService.validateCustomFields() iterates DTO scope keys generically (no hardcoded scope names). Validates: - Type checking: TEXT→string, NUMBER→number, DATE→ISO date, BOOLEAN→boolean, SELECT→value in options - Required fields enforced on create (skips on update for PATCH semantics) - Unknown keys rejected

Definition Deletion

Removes the field key from JSONB across all tenant entity rows + deletes the definition in a single $transaction.

Integration with Permissions Endpoint

GET /api/v1/permissions includes customFieldDefinitions per entity, filtered by the user's scope access:

{
  "students": {
    "scopes": { "identity": "WRITE", "health": "WRITE", "others": "WRITE" },
    "actions": { "create": true },
    "customFieldDefinitions": [
      { "key": "nickname", "label": "Nickname", "scope": "identity", "type": "TEXT", "isRequired": false, "sortOrder": 0 },
      { "key": "blood_type", "label": "Blood Type", "scope": "health", "type": "SELECT", "options": ["A+","A-","B+","B-","AB+","AB-","O+","O-"], "isRequired": false, "sortOrder": 0 },
      { "key": "notes", "label": "Notes", "scope": "others", "type": "TEXT", "isRequired": false, "sortOrder": 0 }
    ]
  }
}

Only definitions where the user has at least READ on the field's scope are included.

Key Implementation Files

  • Service: src/custom-fields/custom-fields.service.ts (CRUD + validation)
  • Controller: src/custom-fields/custom-fields.controller.ts (with EnsurePermissionsGuard)
  • Types: src/custom-fields/interfaces/definition-with-scope.type.ts
  • Barrel: src/custom-fields/index.ts (exports CustomFieldsModule, CustomFieldsService, CustomFieldResponseDto, DefinitionsByScope)
  • forwardRef() used between PermissionsModuleCustomFieldsModule for circular dependency

Profile completeness — missingFields

Each registered entity emits a missingFields: string[] per scope group of every GET response. Population is governed by COMPLETION_REQUIRED_REGISTRY in src/common/constants/completion-required-fields.ts and isRequired flags on custom field definitions. The FieldFilterInterceptor enforces presence via assertMissingFieldsPresence (dev-throws / prod-logs) for registered entities only — unregistered entities are unaffected. See docs/superpowers/specs/2026-04-27-missing-fields-design.md.


Frontend Permission Discovery

The Problem

The JWT payload contains { sub, tenantId, roles: ['teacher'] } — role keys only. The frontend has no way to know:

  • Which entities/scopes the user can access (for tab/section visibility)
  • Which fields are readable vs writable (for form rendering)
  • Whether specific actions are allowed (for button states)

Without this information, the frontend either over-fetches and gets 403s, or renders everything and lets the API strip fields (poor UX).

Selected Approach: Dedicated Endpoint

The current implementation splits user profile and permissions into two endpoints:

  • GET /api/v1/auth/me — returns AuthUserDto { user: UserProfileDto, accessTokenExpiresAt } (user profile + session metadata)
  • GET /api/v1/permissions — returns entity-grouped PermissionsResponseDto where each key is an entity with { scopes, actions } (compiled permissions)

Both endpoints reuse the already-existing CompiledPermissions type from PermissionsService. The JWT stays stateless, small, and within header size limits. The frontend calls both on app bootstrap and caches in context. Re-fetch on token refresh (every 15min) keeps permissions current within one access token lifetime.

// GET /api/v1/auth/me
{
  "user": {
    "id": "uuid",
    "email": "admin@tenant.dev",
    "firstName": "School",
    "lastName": "Admin",
    "tenantId": "uuid",
    "roles": ["admin"],
    "isPlatformAdmin": false
  },
  "accessTokenExpiresAt": 1740066000
}
// GET /api/v1/permissions — entity-grouped format
{
  "students": {
    "scopes": { "identity": "WRITE", "health": "WRITE" },
    "actions": { "create": true, "delete": true }
  },
  "teachers": {
    "scopes": { "identity": "WRITE" },
    "actions": { "create": true, "delete": true }
  },
  "users": {
    "scopes": { "profile": "WRITE" },
    "actions": {}
  }
}

Note: NONE scopes are omitted from the response. Actions show effective permissions (granted AND all scope requirements satisfied).

Staleness trade-off: If a school admin changes a user's role, the user won't see the change until their next token refresh (max 15min). Acceptable for an EdTech system — role changes are rare and not time-critical.


Custom Role Definitions

Problem

The schema supports custom roles (Role.isPreset = false) but there's no admin API or workflow. School admins need to create custom roles for edge cases (combined nurse-psychologist, part-time secretary, etc.), assign scope permissions to custom roles, and assign users to them — without breaking preset roles that are managed by the system.

Selected Approach: Clone-and-Modify with Immutable Presets

  • Presets are read-only templates. System deployments can safely upsert preset role_permissions without conflicting with tenant customizations.
  • Admin workflow: Click "Create Custom Role" → pick the closest preset as a base → see pre-filled permission matrix → toggle individual scopes → save.
  • New scope additions: When a deployment adds new scopes, preset roles auto-update. Custom roles show a "new scope available" notification in the admin UI.

Backend API Design

Endpoint Method Description
/api/v1/admin/roles GET List all roles for the current tenant (presets + custom)
/api/v1/admin/roles POST Create custom role (optionally specify basePresetKey to pre-fill permissions)
/api/v1/admin/roles/:id PATCH Update custom role permissions. Returns 403 for preset roles.
/api/v1/admin/roles/:id DELETE Delete custom role. Returns 400 with affected user list if any users are assigned.
/api/v1/admin/permission-matrix GET Full structure: entities → scopes → field mappings. Used by frontend to render the permission grid.

Role deletion: Returns 400 Bad Request with the list of affected users if any are still assigned. The admin must reassign those users before deleting. This prevents orphaned users with no permissions.

Preset sync on deploy: The seed (prisma/seed/roles.ts) upserts role_permissions for preset roles. Custom roles are never touched by the seed. If a new scope is added, preset roles get the appropriate default permission; custom roles have no entry for the new scope (no access — safe default).

Role naming: Custom role keys are auto-generated from the label (slugify(label)). The admin provides a label and description; the key is derived.


Frontend UI Patterns

Concrete guidance for the frontend team on consuming the permission system.

Permission Context Provider

Wrap the app in a React Context that holds the compiled permissions:

// PermissionProvider.tsx
const PermissionContext = createContext<PermissionsResponse | null>(null);

function PermissionProvider({ children }) {
  const { accessToken } = useAuth();
  const [permissions, setPermissions] = useState<PermissionsResponse | null>(null);

  useEffect(() => {
    if (accessToken) {
      fetch('/api/v1/permissions', { credentials: 'include' })
        .then(res => res.json())
        .then(setPermissions);
    }
  }, [accessToken]); // Re-fetches on token refresh

  return (
    <PermissionContext.Provider value={permissions}>
      {children}
    </PermissionContext.Provider>
  );
}

Permission Hooks

function usePermissions(): PermissionsResponse { ... }

function useCanRead(entity: string, scope: string): boolean {
  const perms = usePermissions();
  const access = perms?.[entity]?.scopes?.[scope];
  return access === 'READ' || access === 'WRITE';
}

function useCanWrite(entity: string, scope: string): boolean {
  const perms = usePermissions();
  return perms?.[entity]?.scopes?.[scope] === 'WRITE';
}

function useCanAction(entity: string, action: string): boolean {
  const perms = usePermissions();
  return perms?.[entity]?.actions?.[action] === true;
}

Tab / Section Visibility

Hide tabs entirely if the user lacks read access to the relevant scope:

function StudentDetailPage() {
  const canReadHealth = useCanRead('students', 'health');
  const canReadAttendance = useCanRead('students', 'attendance');

  return (
    <Tabs>
      <Tab label="General">...</Tab>
      {canReadSensitive && <Tab label="Medical">...</Tab>}
      {canReadAttendance && <Tab label="Attendance">...</Tab>}
    </Tabs>
  );
}

Form Section States (Scope-Grouped)

Since the API uses scope-grouped DTOs ({ identity: {...}, health: {...} }), the frontend renders form sections per scope group. Each section has one of three states:

State Condition Rendering
Editable User has WRITE on the scope Normal inputs for all fields in the section
Read-only User has READ on the scope Disabled inputs or plain text
Hidden User has no access to the scope (absent from permissions) Section not rendered at all
function StudentIdentitySection({ data }: { data: StudentIdentity }) {
  const canRead = useCanRead('students', 'identity');
  const canWrite = useCanWrite('students', 'identity');

  if (!canRead) return null;  // Hidden — no access to this scope

  return (
    <Section title="Identity Data" readOnly={!canWrite}>
      <Input name="firstName" value={data.firstName} disabled={!canWrite} />
      <Input name="lastName" value={data.lastName} disabled={!canWrite} />
      {/* ... other identity fields */}
    </Section>
  );
}

Action Buttons

Show edit buttons based on scope access; show create/delete buttons based on action permissions:

function StudentActions() {
  const perms = usePermissions();
  const entityScopes = perms?.['students']?.scopes ?? {};
  const hasAnyWrite = Object.values(entityScopes).some(s => s === 'WRITE');
  const canCreate = useCanAction('students', 'create');
  const canDelete = useCanAction('students', 'delete');

  return (
    <>
      {hasAnyWrite && <Button>Edit Student</Button>}
      {canCreate && <Button>Create Student</Button>}
      {canDelete && <Button>Delete Student</Button>}
    </>
  );
}

Error Handling

Error Action
401 Unauthorized Redirect to login
403 Forbidden (route-level) Re-fetch /me, update context. Show "access denied" message.
403 FORBIDDEN_FIELDS (write) Show a generic "insufficient write permissions" message. Re-fetch permissions to update form section states.

On 403, always re-fetch permissions — the user's role may have changed since the last fetch.

Micro-Frontend Considerations

If the frontend uses a micro-frontend architecture:

  • The orchestrator shell owns the PermissionProvider and fetches permissions once
  • MFEs receive permissions via shared React Context or an event bus
  • Each MFE does NOT independently fetch /me — this would duplicate requests
  • MFEs can import shared permission hooks from a common package