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 } to request (from the token).
| | parameterDimensions is NOT in the JWT — ensurePermissionsLoaded populates it later
| | (permission guard stack, one memoized DB query per 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:
- WHAT fields can a user see/edit? → Entity-Scope field mappings
- WHICH records can a user access? → Tenant isolation record filtering + RLS
Hierarchy: Entity → Scope → Fields
- 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 (runtime constants + seed catalogue + preset-matrix update); the deploy seed creates the catalogue row and the final preset reconciler propagates the authored grants to every existing tenant. Removing or renaming a catalogue key still needs an explicit migration because custom roles or structural rows may reference it.
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, Director, and Department Principal readers; descriptor-only/flat (in FLAT_DTO_ENTITIES) |
attendance |
register | take, manage_communications, justify | DAILY/PERIOD register, lazy grid off the effective timetable; field-level register scope (NOT flat — reserved for future referent/student narrowing). |
custom_fields |
configuration | create, delete | Custom-field DEFINITIONS management (/custom-fields routes). Descriptor-only/flat (in FLAT_DTO_ENTITIES). Two gates: the route gate here PLUS the service's per-target entity+scope WRITE check. Granted to NOBODY at seed time, admin included (custom fields hidden platform-wide 2026-08-17) — ADMIN_EXCLUDED_SCOPE_KEYS/ADMIN_EXCLUDED_ACTION_KEYS + per-preset exclusions; the backoffice grants it on the feature's return. Spec: docs/superpowers/specs/2026-08-18-custom-fields-rbac-entity-design.md. |
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. TheFieldFilterInterceptorcompares 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. |
†
contacts.emailis school-managed — the WRITE grant alone does not make it writable (2026-08-10). The scope covers the wholecontactsblock, so/permissionsreports the field writable andFieldWriteGuardlets the key through; the narrow lives in the service (ReferentsService.assertUpdateWriteAllowed). A non-privileged caller (a referent on their own row included — anyone who is not admin / platform admin) submitting a different address takes403 REFERENT_EMAIL_READ_ONLY; re-submitting the record's current address is a tolerated no-op, so a full-form family PATCH need not strip the field. Because the restriction is not derivable from/permissions, the frontend hard-codes the field read-only for family sessions. See the Policy inventory Referents row and the carve-out note inprisma/seed/roles.ts.
Referent Preset Role¶
The seeded referent role grants:
- WRITE on
referents.identity,referents.contacts,referents.documents,referents.health(own record only — andcontacts.emailis excluded despite the grant: it is fenced at the service layer with403 REFERENT_EMAIL_READ_ONLYfor every non-privileged caller, see the†footnote above and the Policy inventory Referents row) - 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 byStudentReferentLink.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 viaGET /referents/me),curricula.selection_window,curricula.configuration(added 2026-08-06 — the child's study-plan grid viaGET /curricula/:id, record-narrowed by theCurriculaPolicyreferent branch to curricula a linked child holds a current-or-pending selection on; also unlocks the subject-levels catalog reads),school.configuration(re-granted 2026-08-10 after the 2026-08-06 drop — the school-identity singleton viaGET /school; WRITE stays admin-only),academic_years.configuration(baseline AY-lookup grant — see below)
No
homerooms.composition/subject_groups.composition(dropped 2026-08-06, product decision — mirrors the student preset, which never held them). The referent FE has no Classes/Courses sections; the linked child's homeroom + subject-group summaries surface through thestudents.assignmentREAD instead. Filter routes remain authenticated structural endpoints, but every request now supplies a tablesurfaceand receives only values projected from policy-visible rows on that surface.school.configurationwas dropped in the same pass and re-granted READ on 2026-08-10.
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.assignmentis field-level only. GatesenrollmentDate, departmentId, gradeId, identificationCode, statuson thestudentstable viaFieldFilterInterceptor(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_selectionis a gate-only scope. Zeroscope_field_mappingsrows. Gates the per-student curriculum-selection sub-resource atGET/PATCH /students/:id/curriculum-selectionvia 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 thestudentbranch ofStudentsReadPolicy:userId = ctx.userId)curricula.configuration,curricula.selection_window(the curricula-side policies carrystudentpass-through branches)academic_years.configuration(baseline AY-lookup grant — see below)
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.
Staff Preset Role¶
The seeded staff role is the fourth profile-coupled base role. It is bound
automatically at STAFF invitation accept and exists so the Staff view resolves a
real permission carrier even when the person holds no management preset. Its
baseline is deliberately inclusion-shaped and READ-only:
school.configurationacademic_years.configurationdepartments.configurationgrades.configuration
No WRITE scopes, actions, or others grants. Both nested department grade
summaries and standalone GET /departments/:departmentId/grades are readable.
Existing activated Staff users acquire the user-only binding through the
idempotent Tier-1 seed backfill; new tenant bootstrap admins receive it inside
provisioning.
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), (related) = record-level narrowed to the caller's assignment-derived student set — effective SG teaching episode ∪ combined-class sibling roster ∪ tutored homeroom ∪ supervised-activity audience ∪ assigned break/lunch-duty audience, dated on schoolToday (studentRelatedToTeacherOn in src/students/students.policy.ts). A management reader receives related ∪ managed reach; management writes remain managed-only.
| Role | identity | contacts | assignment | school_relationships | health | documents | referents_and_guardians | curriculum_selection1 | others (custom)7 |
|---|---|---|---|---|---|---|---|---|---|
| Admin | R/W | R/W | R/W | R/W | R/W | R/W | R/W | R/W | -- |
| Director6 | R | R | R | R | R | R | R | R | -- |
| HR Manager4 | R | R | R | R | R | R | R | R | -- |
| Front Office4 | R/W | R/W | R/W | R/W | R/W | R/W | R/W5 | R/W | -- |
| Administrative Assistant | R/W | R/W | R/W | R/W | R/W | R/W | R/W | R/W | -- |
| Department Principal | 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/W (curr) | R (curr) | R (curr) | -- | R (curr) | R/W (curr) | -- |
| Teacher | R (related) | R (related) | R (related) | R (related) | R (related) | R (related) | R (related) | R (related) | -- |
| 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 | -- |
| Student | R (own) | R (own) | R (own) | -- | -- | -- | -- | -- | -- |
7 Custom fields are hidden platform-wide (2026-08-17, same product motion as the roles & permissions matrix): the others scopes are granted to no role on any entity — every preset seeds othersScopes: 'NONE', existing tenants were revoked by migration 20260817130000_revoke_custom_field_grants, and GET /permissions never advertises customFieldDefinitions (CUSTOM_FIELDS_HIDDEN in PermissionsService — this also hides definitions attached to native scopes, which grant revocation alone cannot reach). The others scopes stay in the catalogue, definitions/stored values are untouched, and entity reads keep the always-surface contract; only the FE-facing advertisement is gone.
(child)/(dept)/(curr)/(related)annotations indicate record-level filtering applied byStudentsReadPolicy.where(ctx)(see Policy inventory), not scope-level restrictions. The scope permissions apply once record-level access is granted. Department Principals and Curriculum Coordinators read the union of their related-student set and their managed parameters, including in an active management-role view; their WRITE-qualified slices useStudentsWritePolicyand cannot borrow the personal leg.Identity-only colleague directory for simple teachers (2026-08-04; the 2026-08-02 spec had removed the People section entirely). The teacher preset holds exactly ONE scope across the Teachers and Staff entities:
teachers.identityREAD — a tenant-wide name directory (TeachersPolicyteacherpass-through; field masking strips every other block, andteachers.othersstays withheld so peer custom fields don't ride along). Teacher access to the Staff entity remains at zero scopes — nostaff.otherseither (ScopeGuard passes on any scope of an entity, so the custom-fields scope had to go with the native ones;seed-role.tsgrewothersExcludedEntitiesfor exactly this). Teacher visibility of people is: self (via the scope-free/mesurfaces), the colleague identity directory, the taught student set above, and — through theReferentsPolicyteacher branch — the referents linked to a taught student. Colleague names also appear inside server-composed payloads (timetable lessons, SG teacher lists, homeroom tutor, attendance rosters) as before. Teacher document reads use the separateTeachersDocumentsReadPolicy: Curriculum Coordinator is tenant-wide for document reads, DEPARTMENT readers remain narrowed, and profile/document mutations keepTeachersWritePolicy.
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. Role matrix v3 names the roles HR Manager (hr) and Front Office (secretary). HR Manager keeps personnel WRITE plus tenant-wide non-medical referent READ; Front Office owns WRITE (and therefore READ) on every native students.* scope, referent contact editing, and READ-only curriculum configuration. The dynamic students.others scope remains NONE under the platform-wide custom-fields hide. Both roles retain invitation management. Keys are unchanged. Specs: 2026-07-02-curriculum-coordinator-and-hr-secretary-split-design.md, 2026-08-20-role-matrix-v3-preset-realignment-design.md.
5 Front Office's WRITE on students.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. Standalone referent access is separately scoped: Front Office writes referents.contacts and reads identity/documents/students; the email fence remains service-enforced.
6 Director is the display name of the role whose key is principal (renamed 2026-07-27; the key is deliberately unchanged — JWT claims, @RequireRoles('principal'), the PRINCIPAL_* grant tables and the seed all key off it). Chapter tables use the display name; anything in code, SQL, or a role-key check still reads principal.
Administrative Assistant (administrative_assistant, Italian label
Assistente Amministrativo) is the non-parametric union of the HR Manager
and Front Office functions. For each scope, WRITE wins over READ; action grants
are unioned. Runtime policies use the same HR/Front Office functional role-key
groups, so record reach and explicit role gates follow the composed grant
matrix. See the approved design.
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 Manager | R | R | R | R |
| Front Office | R | R/W + C + D | R/W + C + D | R |
| Administrative Assistant | R | R/W + C + D | R/W + C + D | R |
| Department Principal | R (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 | R/W (assigned curricula only — no C/D: an id-scoped role can never validate a create against its parameter set) |
| Director | R | R | R | R |
| Teacher | R | R | R | R |
| Staff | R | R | -- | -- |
| Student | -- | -- | -- | R |
| Parent | -- | -- | -- | R (child) |
| Others | -- | -- | -- | -- |
Department catalog writes are admin-only (2026-09-03).
departments.configurationWRITE and thedepartments.create/deleteactions are held by admin alone. Front Office, Administrative Assistant, and Department Principal were demoted to READ (the last one still record-narrowed to the headed departments viaDepartmentsPolicy). The scope also gates the per-department calendar (dates + period set under/departments/:id/periods),attendanceMode, andstudentPlatformAccess, so those become admin-only edits too. Propagated to existing tenants by the deployment reconciler (tools/reconcile-preset-roles.ts, every preset code-authoritative since 2026-08-28) — no data migration. Custom (non-preset) roles are not touched by the reconciler.Student/Parent hold no grants on departments/grades/rooms — their views populate structure names through authenticated
/filters/*routes, each carrying a requiredsurfaceand projecting values from rows visible to that student/parent on the selected table. On curricula, the student grant is study-plan visibility and the parent grant (2026-08-06) is record-narrowed by theCurriculaPolicyreferent branch to curricula a linked child holds a current-or-pending selection on.
academic_yearsis omitted from the matrix — it's a tenant-flat, admin-managed catalog (GET /academic-yearslookup plus admin-onlyPOSTcreate-draft,PATCHdraft-edit, andDELETEdraft-delete, gated by theacademic_years.create/deleteactions); it does not follow the dept-scoped configuration matrix above. Baseline read invariant (2026-07-16): every seeded role and profile-coupled base role holds at least READ onacademic_years.configurationso the AY lookup (the dropdown-population endpoint) is reachable everywhere — admin holds WRITE, all others READ. Both read routes — the listGET /academic-yearsand the detailGET /academic-years/:id— carry only@RequireScopes(ACADEMIC_YEARS, 'read')+@AggregateResponse()(no role gate), so the scope grant alone opens them for every role.academic_yearsis a single-scope entity, so the plural@RequireScopesfully gates it and skippingFieldFilterInterceptoron the aggregate response introduces no field-level bypass. Only the write paths (POST/PATCH/DELETE) stay admin-only, via@AppliesPolicy(AcademicYearsPolicy)+ thecreate/deleteactions. Teacher's read grant was dropped by20260710160000_teacher_grant_regrantthen restored (migration20260716100000_all_roles_academic_years_readbackfilled the then-existing teacher/referent/student clones); thestaffrole joined the invariant through its 2026-08-21 frozen seed baseline.usersis omitted — it has aprofilescope, notconfiguration.Attendance preset access (entity
attendance, scoperegister). Admin, Director, HR Manager, Front Office, and Administrative Assistant read tenant-wide. Director and HR Manager are READ-only and hold none oftake,manage_communications, orjustify. Admin, Front Office, and Administrative Assistant are the school-wide register writers. Department Principal and Curriculum Coordinator remain WRITE-capable inside their qualified departments, with the related-teacher fallback for register-taking. Thejustifygrant does not open the family mutation routes to management holders:AttendanceFamilyPolicyremains the referent-only write fence. A Staff-only management anchor has no teacher limb. Teacher holds WRITE +takewith its normal Y-set. The resolver qualifies each management assignment's scope/action before using its parameters or profile anchor, so grants and parameters still cannot cross-lend.Parametric roles.
Role.parameterDimdeclares the dimension a role narrows by (DEPARTMENTorCURRICULUM). Assignment values live inUserRoleParameter;PermissionsService.getAccessContextSliceaggregates active assignments intoRecordAccessContext.parameters.{departmentIds,curriculumIds}. Most policies use direct managed containment. Student, homeroom, and subject-group read policies additionally union data-derived personal teacher reach so active management views do not lose it; their separate write policies use managed containment only. Teachers still has no CURRICULUM branch because no teacher↔curriculum relation exists. Custom parametric roles plug in by dimension data and are admitted byRolesGuardoverlap even when their key is absent frompolicy.roles. Admin/platform admin pass-through still short-circuits. Canonical specs:2026-05-27-parametric-roles-design.md,2026-07-02-curriculum-coordinator-and-hr-secretary-split-design.md, and2026-08-26-teacher-record-visibility-filter-scoping-iteration-2-design.md.Role-matrix v3 qualification exception (2026-08-20). The tenant-flat summary above is no longer universal. Referent reads have a dated
CURRICULUMbranch through linked students; curriculum-selection and student placement use scope-qualified parameter slices; and attendance maps qualifiedDEPARTMENT/CURRICULUMassignments to allowed departments. Teacher-document reads use their dedicated read policy. Selection, placement, and attendance authority fences must not evaluate the original flattened parameter union.
Permission Compilation Flow¶
PermissionsService.getUserPermissions(userId, tenantId) compiles a user's effective permissions at runtime:
- Fetch active UserRoles — query
user_roleswith temporal filtering:validFrom <= NOW()AND (validUntil IS NULLORvalidUntil > NOW()) - Deep-load relationships —
UserRole -> Role -> RolePermission -> PermissionScope -> Entity(plusRoleActionPermission -> PermissionAction -> ActionScopeRequirement) - Compile into
CompiledPermissions: scopes:{ [entity]: { [scope]: ScopeAccess } }— the scope access matrix (NONE omitted, READ, or WRITE). Multi-role union: highest access wins (WRITE > READ > NONE).actions:{ [entity]: Set<actionKey> }— effective actions: role has the grant AND user's scope access satisfies all scope requirements.- 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? }>). Every catalogue entity is ALWAYS emitted (2026-08-18 — reverses the 2026-08-10 empty-entity omission): an entity the caller holds nothing on carries the empty shell { scopes: {}, actions: {…all false} }, and the FE keys section visibility on the block's content (a granted scope or a true action), never on the entity key's presence. E.g. roles — admin alone holds management READ (roles: { scopes: { management: 'READ' }, actions: {} }); every non-admin seeded role gets the empty shell, matching the admin-only /roles and /role-assignments routes. WRITE belongs to nobody (matrix read-only for schools). custom_fields is granted to NOBODY while custom fields stay hidden, so every session carries { scopes: {}, actions: { create: false, delete: false } } until the backoffice grants the scope. customFieldDefinitions is never emitted (2026-08-17) — custom fields are hidden platform-wide; see the matrix footnote 7. |
GET /roles |
@RequireRoles('admin') |
the tenant's admin-manageable roles catalogue: every management preset and custom role with the complete current scope/action matrix per entity, nested by domain group (role.groups[groupId][entityKey]). Every catalogue scope is explicit as NONE / READ / WRITE; every action is explicit as false / true. Powers the Roles & Permissions admin page. |
Both catalogue surfaces — GET /roles and the backoffice GET /roles/presets — exclude the four profile-coupled roles (teacher/staff/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 that set) filters them out of fetchTenantRoles/fetchGlobalPresetRoles. admin stays listed (a real role, merely frozen).
GET /roles (RolesController → PermissionsService.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 current catalogue entity is emitted even with no access; its scopes map enumerates every current scope (NONE included), and its actions map enumerates every current action. The permission catalogue query deep-loads both relations, so additions such as attendance, communications, disciplinary_notes, assignments, roles, and custom_fields cannot disappear from a role merely because it has no grant row. Each entity row also carries backend-owned label, scopeLabels, and actionLabels as localized { en_US, it_IT } values without changing the stable keys or primitive grant maps. entity-groups.spec.ts pins the view grouping to the seed catalogue entity set, while permission-catalogue-labels.drift.spec.ts pins translation coverage. Record-level narrowing is intentionally omitted from the response (a TODO(recordScope) marks the spot) — pending a team decision on surfacing it to admins. The read catalogue is the only tenant-facing surface: the tenant-admin grant-editing route was removed on 2026-08-12 (see "Editing role grants" below), so the Roles & Permissions page is read-only for schools.
The communications baseline for every returned management preset is
communications.mailing_groups: WRITE plus communications.send: true;
communications.management remains admin-only. Migration
20260826121000_backfill_management_communications_grants historically applied
those two baseline grants to global presets and pre-existing tenant clones. The
every-deploy all-preset reconciler now maintains that matrix. This is route
capability, not record reach: the communications recipient policy still derives a
non-admin management holder's Teacher/Staff profile tier; admin retains its
existing administrator tier.
The current attendance preset matrix makes Director and HR Manager READ-only
with no attendance actions. Admin, Front Office, Administrative Assistant,
Department Principal, and Curriculum Coordinator retain WRITE plus
take/manage_communications/justify, subject to
AttendanceAccessService. Migration
20260828120000_realign_management_role_grants superseded the older
management-attendance migrations for that rollout. The every-deploy all-preset
reconciler now maintains the same global/tenant matrix. These grants do not
widen record policy, and the family justification mutation routes retain their
AttendanceFamilyPolicy role fence.
Role names and descriptions are localized backend-side. Each catalogue item's label and description are both LocalizedMessageDto ({ en_US, it_IT }), not bare strings — the BE-owns-i18n convention (same shape as the error envelope's messages; FE picks the active locale). Copy for the management presets lives in two sibling catalogs keyed by role key — src/common/i18n/role-labels.catalog.ts (ROLE_LABELS) and role-descriptions.catalog.ts (ROLE_DESCRIPTIONS) — resolved at read by resolveRoleLabel / resolveRoleDescription; tenant-authored custom roles have no catalog entry, so their stored Role.label / Role.description free text is echoed into every language. The DB columns are now just that fallback — neither surfaces for a management preset, which also means copy changes reach existing tenants without a reseed (seedRoleForTenant is create-only on re-run, so a seed-only rename would never land). Each catalog's en_US mirrors the seeded string, so localization stays additive. The copy is FE-facing product text: no spec dates, table names, or user-story numbers (enforced by role-descriptions.drift.spec.ts; role-labels.drift.spec.ts mirrors it for names). Both guards derive the management-preset key set from the seed's grant mirror, so a new preset without localized copy fails the build.
GET /role-assignments still returns a plain English role.label from the DB column — the localized catalogue is scoped to the roles surfaces (GET /roles, GET /roles/presets, and the two PATCH responses, which all share buildRoleCatalogItem).
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-heading labels remain frontend-owned; the API conveys grouping by structure — role.groups[groupId][entityKey], keys in display order — while every entity entry supplies concise localized entity, scope, and action labels so new catalogue rows require no frontend translation patch. The API copy is deliberately user-facing and may be clearer than the seed's technical fallback label; for example communications.management is shown as “Shared Mailing Lists” / “Liste di distribuzione condivise”.
Active-session narrowing (as of 2026-04-28; view-level since 2026-08-03)¶
request.user.roles and JwtPayload.roles reflect the active session, not the full set of RBAC role grants in the DB. The narrowing is applied at token-issue time by resolveSessionRoles (src/common/utils/narrow-roles.ts) and carried through refresh via the RefreshToken.activeProfile + RefreshToken.activeRole columns. The rules:
- Active view set (
activeRole != null— the session-view switch, docs/03-auth.md §10):roles = [activeRole], full stop. A teacher-admin inteacherview carries['teacher']; inadminview['admin']— the profile key is then absent, so code must not assume "employee session ⇒ profile key present". - Combined default (
activeRole == null) — profile narrowing as before: activeProfile ∈ {teacher, staff}(employee profiles) —rolescontains all DB-granted UserRole keys, withactiveProfiledefensively prepended if not already present.activeProfile ∈ {referent, student}(non-RBAC profiles) —roles = [activeProfile]only. RBAC roles such asadminorprincipalare 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 session.
The parametric access-context slice shares the same discipline: fetchParametricAssignments filters UserRoles to role.key IN activeRoleKeys (besides the temporal filter), so ctx.parameters.*Ids and parameterDimensions never carry values from a role outside the session — a dept-head narrowed to teacher view loses the DEPARTMENT widening, and a dept-head+referent's referent session no longer loads department parameters at all.
Record-level policies that branch on ctx.roles.includes('referent') (e.g. StudentsReadPolicy.where(ctx)) consume this narrowed set. Because activeProfile is always present in the array under the combined-default rules above, the branch fires precisely when the user is actively logged in as that profile.
RecordAccessContext.roles (consumed by entity policies like StudentsReadPolicy, 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'], a teacher combined session sees ['teacher', ...employee-roles], and a view-narrowed session sees exactly the one view key. Policy branches on ctx.roles.includes('referent' | 'teacher' | …) therefore reflect the active session, not the user's full capability set.
Student, homeroom, and subject-group read policies deliberately make one relationship independent of that role-key list: a DEPARTMENT or CURRICULUM parameter branch unions its managed containment with the caller's personal teacher relationship, resolved by userId. Therefore a Department Principal or Curriculum Coordinator gets the same personal-teacher ∪ managed read reach in the combined session and in an active management-role view where the literal teacher key is absent. The matching *WritePolicy declarations omit this personal leg, and mutation services evaluate a scope/action-qualified role slice so read reach cannot lend rows to a management WRITE grant.
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 byREAD/WRITEon any one scope of the entity (checkEntityAccessuses.some()), it cannot enforce a deliberately-withheld scope — and@AggregateResponse()routes skipFieldFilterInterceptor, 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 specdocs/superpowers/specs/2026-06-25-wave1-security-hardening-design.md(which closed the original audit finding #2). The boot-timeassertScopeDecoratorExclusivityscanner 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.rolespopulated from the JWT. - Platform admin bypass.
isPlatformAdmin: trueusers pass regardless of roles. - Tenant admin is NOT special. The
adminrole 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.
@RequireActionalready 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. Referents hold linked-child writes and self profile
writes; StudentReferentLink.canWrite remains the family gate. Role matrix v3
also admits Front Office to PATCH /referents/:id, but its field grants limit
the school-side edit to contacts and the service keeps email read-only. HR
Manager is read-only on the non-medical referent scopes.
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 infield-filter.interceptor.spec.tslock 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():
- The response DTO must extend
AggregateResponseDto(marker base class insrc/common/dto/aggregate-response.dto.ts). - 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.
- 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 becausecreaterequires WRITE on every scope. - 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. - 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:
@RequireScopes(EntityKey.X, 'read')— gates access. Reuse an existing entity's scope when the lookup is a natural sub-resource (room types →EntityKey.ROOMS,configurationscope). Create a new entity +configurationscope when no natural parent exists (academic years →EntityKey.ACADEMIC_YEARS).@AggregateResponse()— bypassesFieldFilterInterceptorscope-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 inFieldFilterInterceptorstill fires if this invariant is violated.- 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_REGISTRYlists scope key sets per entity — it records thatcurriculahas aconfigurationscope, but says nothing about which tables back it. Scope membership is a logical concept; the registry drives theFieldFilterInterceptorcollision check, not field resolution.scope_field_mappingsis 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-levelconfigurationkey as usual. (Before the grid remodel a secondStudyPlansControllerat/api/v1/study-plansshared 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 / aggregate entities with one or more descriptor or gate-only scopes and no field-level 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), the singular @RequireScope, and/or @RequireAction as the surface requires. 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, communications, disciplinary_notes, and custom_fields. Each has a descriptor/gate-only *_SCOPES constant whose values are empty arrays and no FIELD_MAPPINGS row (the drift spec's "empty scopes don't require a mapping" branch exempts them). communications has the two gate-only scopes described below; the others currently have one descriptor scope. attendance is deliberately not flat — its register scope is field-level for family/student narrowing.
custom_fields— the two-gate exception (2026-08-18). Uniquely among the flat catalog entities, thecustom_fields.configurationroute gate does NOT replace the module's original permission model:CustomFieldsServicekeeps its per-target entity+scope checks (create/PATCH/DELETE additionally require WRITE on the definition's target, reads stay filtered to entities the caller holds any grant on). Both layers must pass; platform admins bypass both. The scope and its create/delete actions are seeded to nobody — admin'sALL_WRITE/actions: 'ALL'carve them out viaADMIN_EXCLUDED_SCOPE_KEYSand theseedRoleForTenantexcludedActionKeysinput (introduced for this:ActionGuardchecks the action grant alone, so an unexcluded'ALL'would keep POST/DELETE reachable without the scope)./permissionscarries thecustom_fieldskey for every session (every catalogue entity always emits) — the empty{ scopes: {}, actions: { create: false, delete: false } }shell while ungranted; the FE hides the definitions-management surface on present-but-empty content.Single-
configuration-scope entity, scope-grouped vs flat.school/departments/roomsalso have a singleconfigurationscope but are scope-grouped — their DTOs nest fields underconfiguration({ id, configuration: {...} }). Whether a single-scope entity is scope-grouped or flat is a per-entity choice; if it's flat it MUST be inFLAT_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:
- Add the scope key to
SCOPES.<entity>inprisma/seed/rbac-catalogue.ts. - Add an empty array (
<scope>: [] as const) to the matching*_SCOPESruntime constant insrc/common/constants/scope-fields.ts. The drift-guard spec's "empty scopes don't require a mapping" branch exempts gate-only scopes fromFIELD_MAPPINGSchecks; theENTITY_SCOPE_REGISTRY.<entity>set picks up the key viaObject.keys(<ENTITY>_SCOPES)automatically. - Omit any
FIELD_MAPPINGSentry for the scope. 3b. Check the key against the entity's aggregate DTOs before choosing it. Step 2 puts the key intoENTITY_SCOPE_REGISTRY, andFieldFilterInterceptor.assertAggregateShapetreats any top-level response key matching a registered scope name as a permission regression — it throws (dev/staging) or logs (prod) on every request to that route. A gate-only scope has no fields, so the collision is pure false positive, but the route still 500s. This is not hypothetical:communications.groupsshipped on 2026-08-11 and immediately brokeGET /communications/:id, whoseCommunicationDetailDtohad carried a top-levelgroupskey since the module shipped; it was renamed tocommunications.mailing_groupsthe same day. Rule: grep the entity's@AggregateResponse()DTOs for a top-level key equal to the candidate scope key. Prefer a qualified name (mailing_groups, notgroups) — domain nouns generic enough to be a scope name are exactly the ones already used as DTO fields. Note the entity being inFLAT_DTO_ENTITIESdoes not protect you: the aggregate branch runs first. - Use
@RequireScope(entity, scopeKey, mode)(singular) on the route(s) the scope gates. Mutually exclusive with@RequireScopes(plural) on the same handler —ScopeGuardrejects requests with both (SCOPE_DECORATOR_MISCONFIGURED, 500), andassertScopeDecoratorExclusivityfails the build in dev/CI. - Add role grants in
prisma/seed/roles.tsas 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). |
Current inventory: students.curriculum_selection, curricula.selection_window, communications.management, communications.mailing_groups, and disciplinary_notes.record. disciplinary_notes.record uses both levels: READ opens the register for policy-visible students, while WRITE admits school-side author/edit/withdraw surfaces; create/delete remain separately action-gated. communications.mailing_groups meaningfully separates READ (browse/use mailing groups) from WRITE (manage the caller's private rows); shared-row writes additionally require the WRITE-only communications.management gate. This follows students.curriculum_selection, whose GET/PATCH routes also exercise READ and WRITE respectively; curricula.selection_window participates in that surface's scope/action composition.
Disciplinary-note presets deliberately separate route grants from row policy:
| Preset | Scope/action grant | Row policy |
|---|---|---|
| Admin, secretary, principal | record WRITE + create/delete |
Whole tenant |
| Department head | record WRITE + create/delete |
Current placements in assigned departments |
| Curriculum coordinator | record WRITE + create/delete |
Current selections in assigned curricula |
| Teacher | record WRITE + create/delete |
Current canonical taught set; mutation is own-note only |
| Referent | record READ + acknowledge |
Current linked children, delivered/acknowledged only |
| Student, HR, generic staff | none | Fail closed |
Canonical example: students.curriculum_selection (seeded with no field mappings; gates /students/:id/curriculum-selection routes). The two communications scopes follow the same zero-mapping pattern on flat aggregate routes. Specs: docs/superpowers/specs/2026-05-27-students-curriculum-selection-scope-split-design.md, docs/superpowers/specs/2026-08-11-communications-mailing-groups-rbac-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:
- ScopeGuard — passes (user has WRITE on at least one scope:
identity) - FieldWriteGuard — allows
{ identity: { firstName: "Mario" } }. Rejects{ health: { disabilityInfo: "ADHD" } }with 403FORBIDDEN_FIELDS(user has no WRITE onhealthscope) - FieldFilterInterceptor — response includes
{ id, identity: {...}, createdAt, updatedAt }. Thehealthscope 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):
- ScopeGuard — passes through (no
@RequireScopesmetadata) - ActionGuard — checks
permissions.actions['students'].has('create'). This is only true if the user's compiled permissions satisfy allActionScopeRequirementrows (WRITE on every required scope — here,students.identity). Rejects withACTION_NOT_PERMITTEDif not. - FieldWriteGuard — derives entity from
@RequireAction, checks body scope keys. Still enforces per-scope WRITE checks on the body. - 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:
EntityKeyconstant insrc/common/constants/entity-keys.tsPermissionEntityrow in seedENTITIESarrayPermissionScope(s)in seedSCOPESobject (plusothersscope if custom fields will be supported)ScopeFieldMappingrows in seedFIELD_MAPPINGS(use shared constants fromscope-fields.ts)PermissionAction(s)in seedACTIONSarray (typicallycreate+delete)ActionScopeRequirement(s)in seedACTION_REQUIREMENTS(which WRITE scopes each action needs)- 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 (create, delete, assign, etc.) and READ-level family decisions such as attendance justify and disciplinary-note acknowledge.
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
- School admin creates
UserRolewithvalidFrom: 2026-03-01andvalidUntil: 2026-06-30 PermissionsService.getUserPermissions()automatically filters:validFrom <= NOW()ANDvalidUntil > NOW()- The substitute's permissions are active only during the specified window
- 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/staff/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-clearvalidUntil(presence semantics).DELETE /role-assignments/:id— revoke.GET /role-assignments?recipientType=&recipientId=&roleKey=— list; each row carriesstatus(PENDING/ACTIVE/EXPIRED), therecipientreference, and theuser(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 — tenant surface REMOVED (2026-08-12)¶
No tenant role — admin included — can edit the permission matrix. The tenant-admin PATCH /roles/:key route (landed 2026-06-09 as grant-editing iteration 2) was removed on 2026-08-12: the Roles & Permissions page is read-only for schools for now, and every tenant stays on the platform-curated preset baseline. The only remaining grant-editing surface is the platform-admin backoffice PATCH /roles/presets/:key (next section).
The surface is still represented in the permission model as the descriptor-only roles entity with a single management scope (catalogue sortOrder 24; ROLES_SCOPES in scope-fields.ts, PLATFORM entity group). Admin alone holds READ via ADMIN_READ_ONLY_SCOPE_KEYS (the seed helper's readOnlyScopeKeys demotion, the admin's only non-WRITE scope), and WRITE belongs to nobody. Every non-admin preset omits the grant: exclusion-shaped teacher/principal/hr/secretary/department_head/curriculum_coordinator matrices explicitly exclude it, while inclusion-shaped staff/referent/student matrices never include it. Consequently GET /permissions emits { scopes: {}, actions: {} } for every non-admin session, which is the FE's hide signal and matches the @RequireRoles('admin') gate on GET /roles and /role-assignments. Migration 20260826120000_revoke_non_admin_roles_surface_grants performed the original repair; the final all-preset deployment reconciler now prunes any recurrence from global and tenant presets automatically. Re-enabling matrix editing later = grant WRITE and restore its route; no catalogue change is needed.
The engine survives intact: RoleGrantsService.patchGrants(tenantId | null, key, dto) still accepts a tenant target (unit-covered in role-grants.service.spec.ts), so restoring the surface is just re-adding the controller handler. Its semantics — sparse delta { scopes:[{entityKey,scopeKey,access}], actions:[{entityKey,actionKey,granted}] } with access: NONE / granted: false removing a grant, frozen admin + the four profile-coupled roles (ROLE_NOT_EDITABLE), atomic validation of unknown catalogue keys (ROLE_GRANT_INVALID / UNKNOWN_ENTITY|UNKNOWN_SCOPE|UNKNOWN_ACTION) and action↔scope coherence on the resulting state (ACTION_MISSING_SCOPES) — apply today to the presets route and to the tenant route if it returns.
Grants still compile live (getUserPermissions reads RolePermission/RoleActionPermission per request), so a grant row flipped by seed, migration, backoffice, or deployment reconciliation takes effect on each holder's next request — no token refresh (unlike role assignment, whose keys live in the JWT). Historical per-tenant preset edits are no longer durable: every deploy add/update/prunes every recognized preset clone to the code matrix. Changing a preset matrix therefore means changing roles.ts plus expectedPresetGrants; the final pre-deploy transaction propagates and verifies it across existing tenants. Custom roles remain untouched and are the customization boundary. Role creation/deletion (custom roles) remains 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 11 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 by seedGlobalRolePresets); each tenant gets a clone at provisioning via cloneRolesIntoTenant (src/permissions/role-clone.ts). Presets authored after a tenant was provisioned reach existing tenants via the Tier-1 backfillMissingPresetRoles prerequisite, and the final deployment reconciler then aligns every recognized clone's metadata and grants exactly. 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. backfillStaffProfileRoleAssignments then binds the new tenant role to activated Staff users; the steps are idempotent.
Platform admins can edit the global presets in the backoffice: GET /roles/presets (catalogue) + PATCH /roles/presets/:key (grants), both @PlatformAdminOnly. The PATCH reuses RoleGrantsService.patchGrants with the existing editable rule — admin + teacher/staff/referent/student are frozen, so only the management subset is editable. Under the 2026-08-28 code-authoritative ownership rule, those hosted edits are immediate but deployment-ephemeral: they do not propagate to current tenants and the next deploy resets the global preset and every tenant clone to the code matrix. Custom roles are the durable customization seam. The API's longer-term removal/read-only conversion is deferred. Original API spec: docs/superpowers/specs/2026-06-09-backoffice-role-preset-editing-design.md.
Code-vs-DB convergence (audit #4, strengthened 2026-08-28). Runtime authz resolves from DB grant rows, so Railway finishes every deployment with db:reconcile-rbac. The privileged transaction derives the complete expected matrix from expectedPresetGrants, creates missing tenant clones, adds/updates/prunes every recognized global and tenant preset, verifies the result, and rolls back/fails deployment on drift. test/rbac-grants.db-sync.e2e-spec.ts remains the independent bidirectional oracle, while rbac-deploy-reconciliation.drift.spec.ts pins the migration → seed → reconcile command ordering. Custom roles and assignments are excluded. See chapter 15 §Deploy wiring.
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:
- Derives entity from
@RequireScopes()or@RequireAction()metadata (whichever is present) - Reads
request.permissions.scopes[entity]to determine which scopes the user has WRITE access on - Compares top-level request body keys (scope group names like
identity,health) against writable scopes - System fields (
id,createdAt,updatedAt,tenantId) are always rejected - If body contains scope groups outside the user's writable scopes: throws
ForbiddenFieldsExceptionwith error codeFORBIDDEN_FIELDS
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.tsgrepssrc/**and fails ifroles.includes('admin')reappears anywhere but the helper. CallisPrivileged(ctx)instead. - Recipe — new in-service admin bypass:
import { isPrivileged } from '../common'(or'../common/utils'), thenif (isPrivileged(ctx)) { /* skip the record check */ }orif (!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.tsasserts 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@WriteAuthoritymanifest + 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(StudentsReadPolicy)
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 entirebuildBaseslice (i.e. the tenant base). Short-circuits the resolver —OR(base, narrow)reduces tobasesemantically, so the resolver returnsbaseimmediately for a cleaner SQL predicate.(ctx, base) => TWhere | NEVER_MATCH_WHERE: a narrowing builder that returns the FULL where (typically{ ...base, ...narrow }). ReturnNEVER_MATCH_WHERE({ id: { in: [] as string[] } }) when the caller carries the role but lacks the data to narrow (e.g. a teacher with noTeacherDepartmentAssignmentrows yet) — Prisma optimises emptyinlists 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):
- Platform admin short-circuit.
ctx.isPlatformAdmin === true→ returnbaseunconditionally. Platform admins always see the full tenant slice on every entity. - Role-keyed branches, in declaration order: skip branches whose role isn't in
ctx.roles. Any matching'pass-through'branch returnsbaseimmediately (short-circuit). Each matching narrowing branch appends itsTWhereto the contribution list;NEVER_MATCH_WHEREcontributions are silently dropped. - Parametric branches, in declaration order: for each branch, if
ctx.parameters[<branch.dim>]is non-empty, appendbranch.narrow(base, ids)to the contributions. - 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: StudentsReadPolicy.where(ctx),
skip: (page - 1) * limit,
take: limit,
});
}
async findOneForAccessContext(ctx: RecordAccessContext, id: string) {
return this.prisma.student.findFirst({
where: { AND: [StudentsReadPolicy.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 (strict effective StudentPlacement), CURRICULUM (strict effective StudentCurriculumSelection) |
StudentsReadPolicy removes the dormant staff pass-through. Its teacher relationship is studentRelatedToTeacherOn: effective SG teaching ∪ combined sibling ∪ tutored homeroom ∪ supervised activity ∪ assigned break/lunch duty (all six break selectors), under the timetable governing schoolToday and without weekday narrowing. DEPARTMENT/CURRICULUM read branches return personal relationship ∪ managed containment, including active management-role views. StudentsWritePolicy keeps admin/Front Office/Administrative Assistant, linked-referent, and strict parameter containment only. |
| Teachers | src/teachers/teachers.policy.ts |
DEPARTMENT (M2M via TeacherDepartmentAssignment) — no CURRICULUM policy branch |
'teacher' branch is a tenant-wide pass-through (restored 2026-08-04; deleted 2026-08-02, dept-narrowed before that): the role holds teachers.identity READ only, so the directory it opens is name-shaped via field masking. curriculum_coordinator is a role-keyed pass-through (tenant-wide directory): no teacher↔curriculum relation exists to narrow policy reach, and SG-teaching-derived policy narrowing would be circular. The separate list predicate accepts department/grade/curriculum/homeroom facets without changing this row policy. A custom CURRICULUM role fail-closes here. |
| Staff | src/staff/staff.policy.ts |
none (tenant-flat) | No 'teacher' branch (2026-08-02, was pass-through): the role holds zero staff.* scopes. 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 |
CURRICULUM (dated through linked students' effective selections) |
Principal, HR Manager, Front Office, Administrative Assistant, and Department Principal pass through tenant-wide. 'teacher' narrows to referents linked to a taught student; 'referent' is self-only. ReferentsDocumentsPolicy deliberately omits teacher and CURRICULUM, while adding HR/Front Office/Administrative Assistant read reach. Profile writes remain self/admin except the Front Office function's field-grant-limited contact PATCH; email stays read-only. |
| 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. The referent branch (2026-08-06) narrows to curricula a linked child holds a current-or-pending selection on (dated hop — the read paths thread withSchoolToday); superseded versions don't admit, and candidate browsing stays on the selection-flow read. student stays unadmitted. |
| Selection Windows | src/curriculum/selection-windows.policy.ts |
DEPARTMENT (direct FK on CurriculumSelectionWindow.departmentId), CURRICULUM (via department.curricula) |
Windows are per-department: role matrix v3 lets a coordinator open/edit the windows of departments owning its qualified curricula. |
| Homerooms | src/homerooms/homerooms.policy.ts |
DEPARTMENT (direct FK), CURRICULUM (direct FK on Homeroom.curriculumId) |
HomeroomsReadPolicy: direct tutor (including empty homeroom) ∪ every homeroom with an effective assignment of a studentRelatedToTeacherOn student; management branches union that personal reach with managed containment and staff does not pass through. HomeroomsWritePolicy is direct managed containment only. Read details intersect roster/pending students with StudentsReadPolicy and linked courses with SubjectGroupsReadPolicy. |
| Subject Groups | src/subject-groups/subject-groups.policy.ts |
DEPARTMENT (via curriculumSubject.curriculum.departmentId), CURRICULUM (via curriculumSubject.curriculumId — one hop shorter) |
SubjectGroupsReadPolicy admits an effective own teaching episode; management branches union personal and managed reach and staff does not pass through. SubjectGroupsWritePolicy contains only broad writers and direct managed containment. Read rosters and pending student changes always intersect StudentsReadPolicy; trusted internal write reloads pass no access context. |
| Attendance | src/attendance/attendance.policy.ts |
DEPARTMENT directly; CURRICULUM is resolved to owning departments for the active year by AttendanceAccessService |
Admin/Front Office/Administrative Assistant are school-wide writers; HR Manager and Director are tenant-wide READ-only. Department and curriculum managers write inside their resolved sets (including past dates) and may use the related-teacher fallback for today/future. Staff-only anchors have no teacher limb. |
| Timetables | src/timetables/timetables.policy.ts |
none | Read capability is tenant-wide, but the row fence exposes every status only to admin/platform admin; every other reader sees PUBLISHED versions. The anchored surface=timetables filter projection applies the same fence and returns 404 for hidden versions. |
| Academic Years | src/academic-years/academic-years.policy.ts |
none | Admin-only, but applied to the write paths only (POST/PATCH; DELETE uses the delete action). The read routes (list + detail GET /:id) are scope-gated for all roles and do not apply this policy (2026-07-16) — the where builder is tenant-only anyway (buildBase: { tenantId }, no narrowing) and is not consumed by the read service methods. |
| 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 StudentsReadPolicy.where inside completeness.queries.ts (see "Aggregate routes" below). |
| Onboarding (dashboard) | src/command-center/onboarding.policy.ts |
none | Same shape as Completeness. |
| Curriculum Selections (dashboard) | src/command-center/curriculum-selections.policy.ts |
none | Same shape as Completeness. |
Audience-derived Student reach (2026-08-28). Every scheduled-activity and
break/lunch-duty selector leg inside studentRelatedToTeacherOn is intersected
with studentPlacedOnWhere({}, schoolToday). Direct STUDENT refs and
effective curriculum/track/homeroom relations therefore cannot grant profile
access before a placement opens or after it closes; grade, department, and
activity EVERYONE legs already traverse that same strict placement fact.
The governing timetable window remains date-strict and weekday remains
deliberately irrelevant to record reach. This authorization tier must not be
replaced with the display-cohort fallback used by timetable authoring.
Tenant-flat entities (rooms, evaluation_scales, timetable_templates, users, invitations, school) have no record-level policy — their role grants alone gate access. School additionally had a policy file until the singleton-PATCH route was simplified to a plain @RequireRoles('admin') gate (no record-level filtering exists for a one-row-per-tenant entity). Add one only when a future axis (department, school year, …) needs to narrow within the tenant.
Federated filter policy context. FiltersController applies
EnsurePermissionsGuard even on structural handlers without a static scope
decorator. This is required because @AccessContext() gets parametric role
assignments from the loaded permission slice, not from the JWT. Each option
surface projects only rows admitted by its canonical read policy. The anchored
timetables surface additionally performs a conditional
timetables.configuration READ check before applying TimetablesPolicy; the
shared route cannot express that source-dependent gate as a static decorator.
The /filters/teachers target keeps its independent Teachers READ scope and
TeachersPolicy intersection.
Aggregate routes (no service-level where). Command-center dashboards (completeness, onboarding, curriculum-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 (StudentsReadPolicy.where(ctx), etc.) are merged into the aggregation joins as usual. This keeps the route gate uniform and pushes narrowing to the underlying entities.
Self-service (/me) surfaces¶
RBAC role grants govern what a caller may do to other people's records (the directory / administrative surface). A person editing their own record is a separate, identity-authorized concern — it is deliberately NOT modeled as an RBAC access tier (that would require per-record field masking; see the design spec).
- Read own record:
GET /auth/profile(full, unfiltered snapshot) plus per-entityGET /teachers/me/GET /staff/me. - Write own record:
PATCH /teachers/me/PATCH /staff/me, constrained toSELF_EDITABLE_SCOPES(src/common/constants/self-editable-scopes.ts— teacher/staff:contacts,documents,health). - Own document files: full CRUD via
GET|POST|PATCH|DELETE /teachers/me/documents/*and/staff/me/documents/*(upload / replace / list / signed-URL / metadata / delete, all four personFileUsagevalues). These delegate to the sharedPersonDocumentServicewith a self-scopedPersonDocumentConfigwhosepolicy.wherereturns{}— no role-policy narrowing, because the own record id is resolved from the caller's identity first (RLS still enforces tenant) and composing the entity policy would wronglyNEVER_MATCHa teacher with zero department assignments on their own row.assertCanWrite(assertCallerIsSelfOrAdmin) re-checksuserIdas defense-in-depth.
These routes are intentionally scope-decorator-free: under @ProtectedResource() they keep JwtAuthGuard but declare no @RequireScopes, so ScopeGuard passes (no metadata), FieldWriteGuard no-ops, and FieldFilterInterceptor passes through — the caller sees their full own record even for scopes their role cannot read on peers. Authorization is: authenticated + a linked person row (resolved by userId, never an :id param) + the SELF_EDITABLE_SCOPES whitelist (assertBodyScopesSelfEditable, throws FORBIDDEN_FIELDS). The base machinery is BaseTenantedCrudService.getOwnRecord / updateOwnRecord / resolveOwnRecordId (protected) + the per-entity ownRecordWhere override.
This is why the teacher role's only directory grant is teachers.identity READ (2026-08-04 — the 2026-07-17 iteration cut colleagues to identity + contacts, 2026-08-02 removed the entity entirely, then the name directory was restored identity-only): the colleague directory shows names, yet the full own record — including employment (IBAN), documents, health — remains readable and (per the whitelist) editable via /me. Self document management does NOT re-grant teachers.documents — it is identity-authorized on the /me/documents routes, so a teacher still cannot touch a peer's files. Mirrors the shipped /referents/me + assertCallerIsSelfOrAdmin pattern. See docs/superpowers/specs/2026-07-17-self-service-profile-editing-design.md, its iteration-1 (2026-07-20-…-iteration-1-design.md, own-document files), and 2026-08-02-teacher-people-visibility-narrowing-design.md.
Canonical example¶
// src/students/students.policy.ts (excerpt)
export const StudentsReadPolicy = 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: 'teacher',
// Related set: dated SG episode ∪ CC sibling ∪ tutored homeroom ∪
// supervised activity ∪ assigned break/lunch duty audience.
build: (ctx, base) => ({
...base,
...studentRelatedToTeacherOn(ctx, requireSchoolToday(ctx)),
}),
},
{
role: 'referent',
build: (ctx, base) => ({
...base,
referents: {
some: { referent: { userId: ctx.userId, tenantId: ctx.tenantId } },
},
}),
},
],
parametricBranches: [
{
dim: ParameterDim.DEPARTMENT,
narrow: (base, ids, ctx) => ({
...base,
OR: [
studentRelatedToTeacherOn(ctx, requireSchoolToday(ctx)),
studentPlacedInDepartmentsOn(ids, requireSchoolToday(ctx)),
],
}),
},
],
});
// Controller — single decorator wires the whole role gate + parametric admission
@Get(':id')
@RequireScopes(EntityKey.STUDENTS, 'read')
@AppliesPolicy(StudentsReadPolicy)
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: { AND: [StudentsReadPolicy.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 / Front Office / Administrative Assistant / Director | Role-keyed 'pass-through' — short-circuits the resolver |
WHERE tenantId = ? |
| Teacher | Role-keyed narrowing: studentRelatedToTeacherOn (dated userId traversals, no ctx.teacherDepartmentIds). An assignment-less teacher matches no rows by data. |
WHERE tenantId = ? AND (SG ∪ combined sibling ∪ tutor ∪ activity ∪ break duty EXISTS chains) |
| Department Principal (and any custom DEPARTMENT-parameterized role) | Parametric DEPARTMENT branch fires when ctx.parameters.departmentIds is non-empty and derives personal teacher reach by userId, so it works in an active management-role view. Admission is via parametric-dimension overlap in RolesGuard. |
WHERE tenantId = ? AND (related-student ∪ effective placement department IN (ids)) |
| Curriculum Coordinator (and any custom CURRICULUM-parameterized role) | Same data-derived personal fallback, unioned with strict effective curriculum selections. | WHERE tenantId = ? AND (related-student ∪ effective selection curriculum 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, OR: [studentRelatedToTeacherOn(...), studentPlacedInDepartmentsOn(ids, today)] } |
Read policy: personal ∪ strict effective placement. Write policy uses strict placement only. |
| 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) have no parametric DEPARTMENT branch — they expose role-keyed pass-throughs as appropriate. Referents are the exception since role-matrix v3: their CURRICULUM branch narrows through linked students' dated selections.
Invariants¶
- Decorator-filter synchronization. The
@AppliesPolicy(policy)decorator and thepolicy.where(ctx)call read the same frozenpolicyobject — there is no separate role list to maintain. The boot-timeassertPolicyScopeAlignmentcheck (src/common/dto/policy-scope-invariant.ts) verifies the policy'sentityKeymatches the route's@RequireScopes/@RequireScopeentity on every handler that carries both. buildBaseis the onlytenantIdsource. Every contribution path (role-keyed narrowings, parametric narrowings, pass-through short-circuit, the fall-throughNEVER_MATCH_WHERE) readstenantIdfrombase, which is built once at the top ofwhere(ctx). Tenant isolation therefore survivesOR(...)composition by construction — there is no branch wheretenantIdcan be dropped.- Platform admin always short-circuits first. Platform admins bypass the role gate at the guard layer (see Guard Chain);
makeRoleVisibilityResolvermirrors that —ctx.isPlatformAdminreturnsbasebefore any branch is evaluated, so a platform admin with an unrelated role in their JWT still sees the full tenant slice. - Parametric overlap admits by data, not by key — on scope/action-gated routes. A user whose
parameterDimensionsoverlaps the policy'sparametricDimensionspasses the role gate even when their role keys don't intersectpolicy.roles. This is the lever that lets a tenant define a custom DEPARTMENT-parameterized role and have it work on every entity that admitsDEPARTMENT— without changes topolicy.roles. This admission depends onuser.parameterDimensionsbeing populated, which happens only whereScopeGuard/ActionGuardranensurePermissionsLoadedbeforeRolesGuard— i.e. routes carrying a scope or action decorator (parameterDimensionsis not in the JWT). A route gated by@RequireRolesalone (no scope/action decorator) sees an emptyparameterDimensionsand the parametric branch cannot admit; such routes must rely on static role keys. The parametric managers' boards (grouped-homerooms,grouped-courses,eligible-for-*) all carry@RequireScopes(entity, 'read'), which is what makes their overlap admission fire. - Additive, never expansive. The policy's
where(ctx)output is ANDed into the service'swhere. It narrows the result set; it never grants access the service would otherwise deny. findFirstwith mergedwhereforfindOne. Merging the record-scope filter into the samewhereclause 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.- 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)returnsNEVER_MATCH_WHERE— defined as{ id: { in: [] as string[] } }. Prisma optimises the emptyinlist to a constant-false predicate, so the query returns zero rows. The unit specs inentity-access-policy.spec.tsand each<entity>.policy.spec.tsassert this explicitly.
Canonical reference¶
StudentsReadPolicy (src/students/students.policy.ts) is the canonical implementation of EntityAccessPolicy. StudentsService.findAllForAccessContext / findOneForAccessContext (src/students/students.service.ts) shows how StudentsReadPolicy.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:
ScopeGuardcallsensurePermissionsLoaded(request, permissionsService)— loads and memoizesActionGuardreads fromrequest.permissions(already compiled)FieldWriteGuardreads fromrequest.permissions(already compiled)FieldFilterInterceptorreads fromrequest.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
CompiledPermissionsin Redis with keypermissions:{userId}:{tenantId}and 5min TTL - Invalidate on role/permission admin changes (admin API calls
redis.del(key)) - BullMQ scheduled job for cache invalidation at
validUntilexpiry - 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:
- Zero changes to the permission system — custom fields are nested inside scope groups.
FieldFilterInterceptorstrips entire scope groups based on access, so custom fields are automatically filtered with their parent scope. - 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.
- Simple admin UX — "Which category does this field belong to?" is the only question at creation time.
- Future-proof — a single JSONB column supports both platform-defined and future admin-created scopes without schema migrations.
- Default
othersscope — every entity has anothersscope as a catch-all for custom fields that don't fit existing categories.scopeKeydefaults tootherswhen not specified. On the person entities (students/teachers/staff)othersis a first-class, always-present section: it is declared on the create/update/response DTOs (so theforbidNonWhitelistedpipe acceptsothers.customFieldswrites and OpenAPI documents it) and registered in the service'sgetScopeFieldMappings()with an emptycustomFields: {}baseline, so every read emitsothers: { customFields, missingFields }even when noothersfield is defined (specdocs/superpowers/specs/2026-07-21-others-scope-people-design.md). On the other custom-field entitiesothersstays a purely dynamic scope (emitted only when a def exists). It remains excluded fromENTITY_SCOPE_REGISTRYeither way —FieldFilterInterceptorspecial-cases the catch-all to strip it for callers without the grant, and completeness (registry-driven) ignores it.
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+",...]
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
CustomFieldsServicein entity service constructor (already handled byBaseTenantedCrudService) validateCustomFields()called automatically by base service on create/updatepickCustomFields()and dynamic scope auto-discovery handled bytoScopedResponse()othersscope: catch-all for custom fields, auto-discovered from definitions. On non-person entities no entity service code is needed (dynamic scope). Onstudents/teachers/staffit is registered ingetScopeFieldMappings()(others: () => ({ customFields: {} })) so the section is always present and writable — see point 5 above.
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 },
{ "key": "blood_type", "label": "Blood Type", "scope": "health", "type": "SELECT", "options": ["A+","A-","B+","B-","AB+","AB-","O+","O-"], "isRequired": false },
{ "key": "notes", "label": "Notes", "scope": "others", "type": "TEXT", "isRequired": false }
]
}
}
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(withEnsurePermissionsGuard) - Types:
src/custom-fields/interfaces/definition-with-scope.type.ts - Barrel:
src/custom-fields/index.ts(exportsCustomFieldsModule,CustomFieldsService,CustomFieldResponseDto,DefinitionsByScope) forwardRef()used betweenPermissionsModule↔CustomFieldsModulefor 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.
Serialization is write-aware (2026-08-31). Computation stays caller-agnostic, but what ships depends on the caller's write surface — missingFields is a "what can you still fill" to-do list, not an audit report:
- Permission-gated routes:
FieldFilterInterceptoremits a kept scope group'smissingFieldsas[]when the caller's rank on that scope is READ (not WRITE). Blank, never omitted — the key stays a shape contract; the group object is copied, never mutated. Platform admins bypass the interceptor and see raw record completeness. - Identity-authorized self routes (
GET|PATCH /teachers/me,/staff/me, theGET /auth/profilesnapshots): no compiled permissions exist on the request, soblankNonSelfCompletableMissingFieldsrestricts toSELF_COMPLETABLE_SCOPES(src/common/constants/self-completable-scopes.ts) — teachers/staff reference theSELF_EDITABLE_SCOPESsets; referents keepidentity/contacts/documents/health; students keep nothing. A drift spec pins the referent/student entries to the frozen preset grants inprisma/seed/roles.ts, soGET /referents/me(interceptor path) andGET /auth/profile(constant path) can never disagree. - Record completeness remains available to admins: the command-center completeness surfaces compute their own rollups from raw records (
@AggregateResponseroutes) and are untouched.
See docs/superpowers/specs/2026-08-31-missing-fields-write-aware-design.md.
Frontend Permission Discovery¶
Role-matrix v3 qualified authority (2026-08-20)¶
The v3 management matrix is enforced as grants plus record policy, not as a
role-name-only allowlist. PermissionsService.resolveAuthorizedRoleSlice(ctx,
requirement) reloads active assignments restricted to the current session
view and keeps only roles—and only their parameters—that independently satisfy
the requested scope access and optional action. Selection, placement, and
attendance use that qualified copy so a READ-only parametric assignment cannot
lend its ids to another role's WRITE/action grant.
Curriculum-selection reads admit principal, HR Manager, Front Office,
Administrative Assistant, teacher, linked referent, DEPARTMENT and CURRICULUM
limbs. A CURRICULUM-only response
intersects applicableCurricula with the qualified curriculum ids. Writes admit
Front Office, Administrative Assistant, DEPARTMENT, CURRICULUM, and
linked-referent limbs. School-side
management bypasses the family window/confirmation/carried locks and stores the
legacy SelectionEditorRole.ADMIN; the referent path keeps every family gate
and stores REFERENT. canWrite is an aggregate affordance only: every target
is checked again on PATCH.
Referents are tenant-wide for principal/HR Manager/Front Office/Administrative
Assistant/Department Principal, taught-set for teachers, self-only for
referents, and dated through
the selected-student link for CURRICULUM roles. Teacher document reads have a
separate TeachersDocumentsReadPolicy: coordinators read tenant-wide, while all
mutations continue to use TeachersWritePolicy.
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— returnsAuthUserDto { user: UserProfileDto, accessTokenExpiresAt }(user profile + session metadata)GET /api/v1/permissions— returns entity-groupedPermissionsResponseDtowhere 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: On
GET /permissions, NONE scopes are omitted from the response. On the role-cataloguegroupsmatrix, every scope is present and NONE is explicit. 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_permissionswithout 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 authors catalogue/global/missing-role prerequisites, then db:reconcile-rbac transactionally aligns every global and tenant preset to roles.ts + expectedPresetGrants (add/update/prune and verify). Custom roles are never touched. If a new scope is added, each preset receives exactly its code-authored access; 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
PermissionProviderand 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
Class-register assignments¶
assignments is a flat/gate-only entity with zero-mapping record scope.
assignments.create, assignments.delete, and
assignments.set_lesson_content each require record:WRITE; Assignment PATCH
uses WRITE directly. Teacher receives WRITE plus all three actions. Admin
inherits the same command capabilities tenant-wide in the active year and does
not need a Teacher profile. Student and referent receive READ for the family
surfaces. Principal and secretary read school-wide; department/curriculum
parameter roles read only matching immutable course anchors. HR and generic
staff receive no scope. Existing tenants require the normal production role
reseed.
The policy set is split by row-set purpose in
src/assignments/assignments.policy.ts: author and lesson-content policies
admit Admin and Teacher; the service then applies tenant-wide Admin authority
or dated teaching-episode authority. The school policy admits whole-school,
qualified, and dated-Teacher reads; the family policy admits Student/Referent
while the service applies self/current-link and temporal course-archive
predicates. See chapter 26.
Assignment attachment mutations add no permission primitive. File/link batch
POST, link PATCH, and child DELETE all require the existing
assignments.record:WRITE and reuse the author policy: Admin is active-year
tenant-wide, while Teacher authority is evaluated on the parent Assignment's
stored assigned date. The school download route reuses record:READ plus the
school parent fence. The family download route also uses record:READ, but
names a student and re-proves self/current referent link plus that student's
retrospective course audience. Metadata follows the parent in every existing
projection. No role reseed is required for this additive child surface.