Permissions (RBAC)¶
This chapter is the authoritative reference for the SIS authorization strategy. It covers the full stack — from database schema through API enforcement to frontend rendering — and serves as the canonical home for all permission-related decisions.
Mental Model — Four Layers of Access Control¶
Authorization in SIS operates in four orthogonal layers. Each layer answers a different question:
| Layer | Question | Implementation | Status |
|---|---|---|---|
| 1. Authentication | "Who are you?" | JWT + Passport (JwtAuthGuard) |
Complete |
| 2. Scope-level access | "What can you see/edit?" | Entity-Scope model (ScopeGuard + FieldFilterInterceptor) |
Complete |
| 3. Action-level access | "What operations can you perform?" | Action permissions (ActionGuard) |
Complete |
| 4. Record-level access | "Which records?" | Tenant isolation (service-layer tenantId, in every WHERE via Policy.buildBase) + per-entity EntityAccessPolicy (@AppliesPolicy(policy) + policy.where(ctx) AND-merged into service queries). |
Complete on every role-aware entity (students, teachers, staff, referents, departments, grades, curricula, selection-windows, homerooms, subject-groups, academic-years, school, attendance, command-center). Tenant-flat entities (rooms, evaluation_scales, timetable_templates, users, invitations) carry no narrowing by design. |
Request pipeline¶
Request
|
+-> [JwtAuthGuard] Layer 1: Authenticate. Attach { userId, tenantId, roles, parameterDimensions } to request.
| | Reject if token invalid/expired -> 401
| v
+-> [ScopeGuard] Layer 2a: Check scope-level access (read/update routes only).
| | Reads @RequireScopes(entity, mode) (plural — coarse, ANY scope on entity)
| | OR @RequireScope(entity, scopeKey, mode) (singular — specific scope key).
| | Mutually exclusive on the same handler — having both is 500 misconfig.
| | Skip if no metadata (e.g. action-only routes). Reject -> 403 INSUFFICIENT_SCOPE.
| v
+-> [ActionGuard] Layer 3: Check action permission (create/delete routes).
| | Read @RequireAction(entity, action) metadata.
| | Check permissions.actions[entity].has(action). Reject -> 403 ACTION_NOT_PERMITTED.
| | Skip if no @RequireAction metadata on route.
| | Actions already embed scope requirements: an action is only
| | effective if the user has all required scopes (checked at
| | permission compilation time). No redundant @RequireScopes needed.
| v
+-> [FieldWriteGuard] Layer 2b: Validate write payloads (POST/PATCH only).
| | Derives entity from scope (plural or singular) OR action decorator metadata.
| | For scope-grouped bodies (plural / create action): per-key WRITE check.
| | For sub-resource bodies (singular): NEVER_WRITABLE_FIELDS still enforced.
| | Reject -> 403 FORBIDDEN_FIELDS.
| v
+-> [RolesGuard] Layer 4a: Role allowlist (opt-in).
| | Reads @RequireRoles(...keys) directly OR via @AppliesPolicy(policy) composition
| | (which emits @RequireRoles(...policy.roles)).
| | Admits when user.roles ∩ required is non-empty, OR via parametric-dimension
| | overlap: user.parameterDimensions ∩ policy.parametricDimensions
| | (the lever for custom parametric roles). Reject -> 403 ACTION_NOT_PERMITTED.
| v
+-> [Controller -> Service] Layer 4b: Business logic.
| | - Controller threads RecordAccessContext via @AccessContext().
| | - Service AND-merges `<Entity>Policy.where(ctx)` into every read/write.
| | - `tenantId` is set exclusively in `Policy.buildBase`, so it survives
| | OR composition by construction.
| v
+-> [FieldFilterInterceptor] Layer 2c: Strip unauthorized scope groups from response.
| Derives entity from scope (plural or singular) OR action decorator metadata.
| For aggregate routes (@AggregateResponse + AggregateResponseDto): pass-through
| with a runtime safety-net assertion (no top-level key may collide with a scope name).
| Otherwise: keep top-level keys with READ+ access + always-allowed (id, createdAt, updatedAt).
v
Response
Key insight: Layers 2, 3, and 4 are orthogonal. A teacher may have students.identity.read (Layer 2) but lack the create action (Layer 3) and should only see students in their classes (Layer 4). All must be enforced independently.
The Entity-Scope Permission Model¶
Core Concept¶
The model answers two questions for every API request:
- 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 (code change + migration + seed update).
Tenant-level (configurable per school):
| Table | Purpose | Who manages |
|---|---|---|
roles |
Role definitions (admin, teacher, custom...) | School admin via UI |
role_permissions |
Assignment matrix: role x scope → ScopeAccess | School admin via UI |
user_roles |
User-role assignments with temporal support | School admin via UI |
Each school can create custom roles and assign different scope permissions independently.
Database Schema¶
Six tables power the permission system:
permission_entities¶
Domain entities that the permission system governs.
| Column | Type | Description |
|---|---|---|
id |
UUID PK | |
key |
String, unique | Machine-readable key (students, teachers) |
label |
String | Display name |
description |
String | Help text for admin UI |
sort_order |
Int | Display ordering |
Registered entities (seeded via prisma/seed/rbac-catalogue.ts — that ENTITIES array is the source of truth; the rows below are illustrative of the load-bearing ones):
| Entity key | Scopes | Actions | Notes |
|---|---|---|---|
students |
identity, contacts, assignment, school_relationships, health, documents, referents_and_guardians, curriculum_selection | create, delete | school_relationships holds hasSiblingInSchool/isChildOfEmployee (writable, admin-pinnable) + a read-only relatedPeople list. Custom fields via others scope. |
teachers |
identity, contacts, employment, documents, health | create, delete | Custom fields via others scope |
staff |
identity, contacts, employment, documents, health | create, delete | Custom fields via others scope |
users |
profile | -- | Read-only (no CRUD controller) |
departments |
configuration | create, delete | Custom fields via others scope |
grades |
configuration | create, delete | Sub-resource of departments; custom fields via others scope |
rooms |
configuration | create, delete | Custom fields via others scope |
academic_years |
configuration | create, delete | Lookup GET + admin-only draft create/edit/delete |
curricula |
configuration, selection_window | create, delete, open_selection_window, edit_selection_window | Single-table after the grid remodel — subjectLabel/trackSelectionMode fold into curricula.configuration (the old study_plans FIELD_MAPPINGS row is gone). Structure (subjects/blocks/tracks/cells) is written via the family-sync PATCH /curricula/:id, not a field-filtered surface. |
referents |
identity, contacts, documents, health, students | create, delete | Custom fields not yet supported |
invitations |
management | send, reset | Credential issuance to teachers, staff, and referents |
school |
configuration | -- | Singleton per tenant (GET + PATCH) |
evaluation_scales |
configuration | create, delete | Flat-DTO catalog entity |
timetable_templates |
configuration | create, delete, assign | Flat-DTO; Day/Week template catalog + dept/grade assignments |
homerooms |
composition | create, delete | Wizard cascade materialises N child Subject Groups per mandatory subject. Curriculum/Department/Grade immutable post-creation. Custom fields via others scope. |
subject_groups |
composition | create, delete | Two flavours: standalone (homeroomId IS NULL) and homeroom-bound (mandatory subject, roster derived from parent Homeroom). Bound SGs reject DELETE + roster ops; PATCH allows teacher swap only. Custom fields via others scope. |
timetables |
configuration | create, delete, publish, generate | Flat-DTO; hand-managed + auto-generated weekly timetables |
audit_log |
configuration | -- | Append-only who-did-what trail, admin-only, descriptor-only/flat (in FLAT_DTO_ENTITIES) |
attendance |
register | take | DAILY/PERIOD register, lazy grid off the PUBLISHED timetable; field-level register scope (NOT flat — reserved for future referent/student narrowing). |
permission_scopes¶
Logical field groups within an entity. Each scope represents a meaningful business category.
| Column | Type | Description |
|---|---|---|
id |
UUID PK | |
entity_id |
UUID FK → permission_entities | Parent entity |
key |
String | Machine-readable key (identity, health) |
label |
String | Display name |
description |
String | Help text for admin UI |
sort_order |
Int | Display ordering within entity |
Constraint: UNIQUE(entity_id, key)
scope_field_mappings¶
Maps each scope to concrete database fields. Field names use camelCase matching Prisma model output (e.g., firstName, not first_name).
| Column | Type | Description |
|---|---|---|
id |
UUID PK | |
scope_id |
UUID FK → permission_scopes | Parent scope |
table_name |
String | Database table (students) |
field_name |
String | Field name in camelCase (firstName, disabilityInfo) |
Constraint: UNIQUE(scope_id, table_name, field_name)
Important: Field names MUST match the keys returned by Prisma queries, not the database column names. Prisma uses
@map()directives to translate between camelCase model fields and snake_case columns. 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. |
Referent Preset Role¶
The seeded referent role grants:
- WRITE on
referents.identity,referents.contacts,referents.documents,referents.health(own record) - WRITE on
students.identity,students.contacts,students.school_relationships,students.documents,students.health,students.referents_and_guardians,students.curriculum_selection(linked children only, gated per-link 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),school.configuration,curricula.selection_window,homerooms.composition,subject_groups.composition
No create or delete actions are granted to referents. WRITE on referents.students stays admin-only so a referent cannot toggle their own canWrite.
students.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 ofStudentsPolicy:userId = ctx.userId)curricula.configuration,curricula.selection_window(the curricula-side policies carrystudentpass-through branches)
No write scopes, no actions. Grant growth (health, documents, grades, register, homework views) arrives with the SUS user stories. Spec: docs/superpowers/specs/2026-06-03-student-invitations-design.md.
Preset Role Permissions Matrix (Students)¶
Mirrors the seed (prisma/seed/roles.ts — *_WRITE_SCOPE_KEYS / *_EXCLUDED_SCOPE_KEYS / seedProfileCoupledPresetsForTenant). Columns are the actually seeded student scopes; rows are the actually seeded preset roles. Custom tenant roles deviate within these axes.
Legend: R = READ, W = WRITE, -- = NONE, (child) = record-level narrowed to linked children, (own) = record-level narrowed to the caller's own row via userId, (dept) = record-level narrowed by ctx.parameters.departmentIds, (curr) = record-level narrowed by ctx.parameters.curriculumIds (students who selected an assigned curriculum), (teaching dept) = record-level narrowed by ctx.teacherDepartmentIds
| Role | identity | contacts | assignment | school_relationships | health | documents | referents_and_guardians | curriculum_selection1 | others (custom) |
|---|---|---|---|---|---|---|---|---|---|
| Admin | R/W | R/W | R/W | R/W | R/W | R/W | R/W | R/W | R/W |
| Principal | R | R | R | R | R | R | R | R | R |
| HR4 | R | R | R | R | R | R | R | R | R |
| Secretary4 | R/W | R/W | R/W | R | R | R/W | R/W5 | R/W | R/W |
| Department Head | R/W (dept) | R/W (dept) | R/W (dept) | R/W (dept) | R/W (dept) | R/W (dept) | R/W (dept) | R/W (dept) | R/W (dept) |
| Curriculum Coordinator | R (curr) | R (curr) | R (curr) | -- | -- | -- | -- | R (curr) | R (curr) |
| Teacher | R (teaching dept) | R (teaching dept) | R (teaching dept) | R (teaching dept) | R (teaching dept) | R (teaching dept) | R (teaching dept) | R (teaching dept) | R (teaching dept) |
| Referent | R/W (child) | R/W (child) | R (child)2 | R/W (child) | R/W (child) | R/W (child) | R/W (child) | R/W (child)3 | R (child) |
| Student | R (own) | R (own) | R (own) | -- | -- | -- | -- | -- | -- |
(child)/(dept)/(teaching dept)annotations indicate record-level filtering applied byStudentsPolicy.where(ctx)(see Policy inventory), not scope-level restrictions. The scope permissions apply once record-level access is granted.
1 curriculum_selection is a gate-only scope — zero scope_field_mappings rows. Gates the per-student curriculum-selection sub-resource (GET/PATCH /students/:id/curriculum-selection) via the singular @RequireScope(STUDENTS, 'curriculum_selection', 'read'|'write'). See Gate-only scopes.
2 Referent's assignment was narrowed from WRITE to READ on 2026-05-27 (curriculum-selection scope-split spec). Referent reads dept/grade FK columns on linked children's profiles but cannot PATCH them.
3 Referent's WRITE on curriculum_selection is the load-bearing grant for the parent workflow (previously implicit via students.assignment:WRITE, which never actually gated the sub-resource AND authorized field-level assignment writes). Write access is additionally runtime-gated by an OPEN CurriculumSelectionWindow for the student's department AND StudentReferentLink.canWrite = true.
4 The combined "HR / Secretary" preset was split on 2026-07-02: hr (key unchanged, relabeled "HR") keeps only the personnel half — teachers/staff WRITE (all five scopes incl. health) + teachers/staff.{create,delete} + the invitation surface — retaining tenant-wide READ visibility elsewhere; the new secretary preset owns the office half — students, configuration (departments/grades/rooms/curricula), homerooms/SG composition, invitations — with teacher/staff directory READ but no teachers.health/staff.health. Both keep invitations.management + send/reset (accepted v1 overlap, no recipient-type gate). Spec: 2026-07-02-curriculum-coordinator-and-hr-secretary-split-design.md.
5 Secretary's WRITE on referents_and_guardians is forced by the enrolment flow: the scope block is mandatory on POST /students (at least one referent), and FieldWriteGuard requires WRITE on every top-level body key of a create — so students.create would be unusable without it. It covers only the student-side links/contacts; the standalone referents entity stays admin-only for both split roles.
Cross-Entity Preset Role Matrix (Configuration Entities)¶
Configuration entities (departments, grades, rooms, curricula) each have a single configuration scope. The matrix below covers configuration scope access and action grants.
Legend: R = READ, W = WRITE, C = create action, D = delete action, -- = NONE
| Role | departments | grades | rooms | curricula |
|---|---|---|---|---|
| Admin | R/W + C + D | R/W + C + D | R/W + C + D | R/W + C + D |
| HR | R | R | R | R |
| Secretary | R/W + C + D | R/W + C + D | R/W + C + D | R/W + C + D |
| Department Head | R/W (own dept only) | R/W + C + D (own dept only) | R | R/W + C + D (own dept only) |
| Curriculum Coordinator | R (owning dept only) | R (owning dept only) | -- | R/W (assigned curricula only — no C/D: an id-scoped role can never validate a create against its parameter set) |
| Principal | R | R | R | R |
| Teacher | R | R | R | R |
| Student | R | R | R | R |
| Parent | R | R | R | R |
| Others | -- | -- | -- | -- |
academic_yearsis omitted — 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.usersis omitted — it has aprofilescope, notconfiguration.Attendance preset access (entity
attendance, scoperegister, actiontake). Only two presets touch it: Admin holds R/W +taketenant-wide; Teacher holds R/W +take(attendance.registeris inTEACHER_WRITE_SCOPE_KEYS,attendance.takein the teacheractionsset), record-level narrowed to their teaching departments byAttendancePolicy. All other presets are--. WRITE is further gated in-service by the Y-set (lesson / homeroom / teaches-today); read visibility is the broaderAttendancePolicy(see Policy inventory). Routes also carry@RequireRoles('admin', 'teacher').Parametric roles.
Role.parameterDimdeclares the dimension a role narrows by (ParameterDimenum:DEPARTMENT—department_head— andCURRICULUM—curriculum_coordinator, added 2026-07-02). Assignment-side values live inUserRoleParameter(userRoleId, valueId)rows — a polymorphic ID whose interpretation comes from the parent role'sparameterDim. A user can hold the role for multiple values via multipleUserRoleParameterrows on a singleUserRole.PermissionsService.getAccessContextSliceaggregates active parametric assignments intoRecordAccessContext.parameters.{departmentIds, curriculumIds}(one array per registered dimension —DIMENSION_TARGETSinsrc/common/constants/parameter-dim.tsis the registry: adding a dimension there extends theParametricContexttype, the aggregator, and the assignment-time value validation in one move); the matchingparametricBranchesin each entity'sEntityAccessPolicyproduce the narrowing predicate. The scope-level matrix above is uniform across all parametric-role holders; the "own dept only" / "assigned curricula only" suffixes reflect the record-level narrowing applied by each entity'sEntityAccessPolicy(DEPARTMENT: directdepartmentId IN (…)for direct-FK entities, relational join for SubjectGroup; CURRICULUM:id IN (…)on Curriculum itself — which doubles as the write fence on every curricula write path —curriculumId INon Homeroom,curriculumSubject.curriculumId INon SubjectGroup,curriculumSelection.curriculumId INon Student,curricula somejoins on Department/Grade/SelectionWindow). Teachers has no CURRICULUM branch — there is no teacher↔curriculum relation, so the coordinator is admitted by a role-keyed pass-through instead (documented divergence; a custom CURRICULUM role fail-closes there). Tenant-flat entities (rooms, staff, evaluation_scales, timetable_templates, school, academic_years, users, invitations, referents) keep the dept_head role-keyed pass-through branch and are read tenant-wide — no parametric branch on those policies. Admin / platform admin bypass the parametric narrowing because their pass-through branch short-circuits the resolver. Custom parametric roles (any tenant-created Role withparameterDimset) plug in by data alone — they are admitted atRolesGuardvia parametric-dimension overlap withpolicy.parametricDimensions, even when their role key is not inpolicy.roles. On the polymorphicvalueId: deleting a dimension target runs an app-layeruserRoleParameter.deleteManycascade (departments delete → DEPARTMENT rows; curricula delete → CURRICULUM rows). Canonical specs:2026-05-25-preset-management-roles-dept-scoped-assignment-design.md(original dept-head FK design — superseded for the storage layer),2026-05-27-parametric-roles-design.md(current parametric-roles model), and2026-07-02-curriculum-coordinator-and-hr-secretary-split-design.md(second dimension + role split).
Permission Compilation Flow¶
PermissionsService.getUserPermissions(userId, tenantId) compiles a user's effective permissions at runtime:
- 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? }>). |
GET /roles |
@RequireRoles('admin') |
the tenant's admin-manageable roles catalogue: every management preset and custom role with its scope access + effective actions per entity, nested by domain group (role.groups[groupId][entityKey]). Powers the Roles & Permissions admin page. |
Both catalogue surfaces — GET /roles and the backoffice GET /roles/presets — exclude the profile-coupled trio (teacher/referent/student, PROFILE_COUPLED_ROLE_KEYS). Those roles are bound at invitation-accept and are neither assignable via /role-assignments nor editable via PATCH /roles(/presets)/:key; they are permission carriers, not editor-managed roles, so a shared catalogueRoleWhere (key NOT IN the trio) filters them out of fetchTenantRoles/fetchGlobalPresetRoles. admin stays listed (a real role, merely frozen).
GET /roles (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 entity in a group is emitted even with no access, so the editor can render each row. Record-level narrowing is intentionally omitted from the response (a TODO(recordScope) marks the spot) — pending a team decision on surfacing it to admins. Role editing (group-level writes for custom roles) is future work; the read catalogue is the only surface today.
Entity groups are a view-layer convenience, not a DB concept — the guard stack never resolves them and the DB stays flat (one role_permissions row per entity-scope). Membership and display order live in src/common/constants/entity-groups.ts (the source of truth): people, academic-structure, curriculum, class-composition, timetable, platform — every EntityKey in exactly one group, enforced by entity-groups.spec.ts. Group display labels are frontend-owned (it localizes them); the API conveys grouping purely by structure — role.groups[groupId][entityKey], keys in display order — so the client needs zero grouping logic.
Active-session narrowing (as of 2026-04-28)¶
request.user.roles and JwtPayload.roles reflect the active session profile, not the full set of RBAC role grants in the DB. The narrowing is applied at token-issue time by auth.service.ts:narrowRolesForActiveProfile and carried through refresh via the RefreshToken.activeProfile column. The rules:
activeProfile ∈ {teacher, staff}(employee profiles) —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 profile.
Record-level policies that branch on ctx.roles.includes('referent') (e.g. StudentsPolicy.where(ctx)) consume this narrowed set. Because activeProfile is always present in the array under the rules above, the branch fires precisely when the user is actively logged in as that profile.
RecordAccessContext.roles (consumed by entity policies like StudentsPolicy, and service methods like students.service.ts:updateForAccessContext, referents.service.ts) is populated from request.user.roles, so it shares the narrowing semantics: a referent session sees ['referent'] and a teacher session sees ['teacher', ...employee-roles]. Policy branches on ctx.roles.includes('referent' | 'teacher' | …) therefore reflect the active session's profile, not the user's full capability set.
Route Protection¶
Decorator Decision Table¶
| HTTP method | Purpose | Decorator | Reason |
|---|---|---|---|
| GET | Read | @RequireScopes(entity, 'read') |
Scope-level gate |
| PATCH | Update | @RequireScopes(entity, 'write') |
Scope-level gate |
| POST | Create | @RequireAction(entity, 'create') |
Action embeds scope requirements |
| DELETE | Delete | @RequireAction(entity, 'delete') |
Action embeds scope requirements |
Role gate decorator preference. For role-gated routes, prefer @AppliesPolicy(<Entity>Policy) when the policy admits the right roles (it composes @RequireRoles from the policy's role list, keeping decorator and record-filter in sync). Use raw @RequireRoles(...) only when the route's role gate is tighter than any per-entity policy (e.g. import routes are @RequireRoles('admin')) or for class-level platform-admin bypasses where policy doesn't apply.
Never combine @RequireScopes and @RequireAction on the same route. Action permissions are compiled with scope requirements baked in: CompiledPermissions.actions[entity] only contains an action key if the user has the action grant AND satisfies all ActionScopeRequirement rows (e.g., students.create requires WRITE on every student scope). Adding @RequireScopes on these routes would be a redundant and potentially conflicting gate.
@ProtectedResource() Composition¶
Applied at the controller class level. Composes: JwtAuthGuard + ScopeGuard + ActionGuard + FieldWriteGuard + FieldFilterInterceptor + ApiBearerAuth.
@Controller('students')
@ProtectedResource() // applies the full guard stack
export class StudentsController { ... }
Guard Chain (Execution Order)¶
| Order | Guard/Interceptor | Behavior | Rejects with |
|---|---|---|---|
| 1 | JwtAuthGuard | Authenticate, attach { userId, tenantId, roles } to request |
401 |
| 2 | ScopeGuard | "Does user have ANY scope on this entity?" Skip if no @RequireScopes |
403 INSUFFICIENT_SCOPE |
| 3 | ActionGuard | "Is user granted this action?" Skip if no @RequireAction |
403 ACTION_NOT_PERMITTED |
| 4 | FieldWriteGuard | Validate POST/PATCH body keys against WRITE scopes | 403 FORBIDDEN_FIELDS |
| 5 | RolesGuard | "Does user have at least one of the required roles?" Skip if no @RequireRoles |
403 ACTION_NOT_PERMITTED |
| 6 | Controller → Service | Business logic with tenantId filtering in every where clause |
-- |
| 7 | FieldFilterInterceptor | Strip scope groups user lacks READ access on (always keeps id, createdAt, updatedAt) |
-- |
Platform admin (isPlatformAdmin flag on user): bypasses ScopeGuard, ActionGuard, FieldWriteGuard, RolesGuard, and FieldFilterInterceptor — full access to all entities and scopes. Tenant admin (the admin role inside a tenant) does not bypass any guard; it simply happens to hold every scope/action via its role-permission grants.
Guard Behaviour in Detail¶
ScopeGuard — Reads @RequireScopes() metadata, loads CompiledPermissions (memoized on request.permissions), checks if the user has ANY scope on the entity at the required level. Throws INSUFFICIENT_SCOPE if the user has zero access. Passes through when no @RequireScopes metadata (action-only routes). This is a coarse gate — real field-level enforcement is handled by FieldWriteGuard and FieldFilterInterceptor.
Sub-resource / aggregate routes must gate on the singular scope. Because the plural
@RequireScopes(entity, mode)is satisfied 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. As of 2026-04-24, referents gain WRITE on every students.* scope (record-level filtered to their linked students via StudentsPolicy.where(ctx)), plus a PATCH /referents/:id endpoint for admin + self. The admin-controlled canWrite flag on StudentReferentLink gates whether a referent can exercise that write access on a given link; the check lives in StudentsService.updateForAccessContext. Custom fields on referents and Postgres RLS for the referent-to-student relation remain deferred.
Interceptor Contract: Scope-Grouped vs Aggregate Responses¶
The interceptor assumes every response on an entity controller is a scope-grouped entity DTO — an object whose top-level keys are scope group names (identity, health, employment, etc.) plus always-allowed fields (id, createdAt, updatedAt). That assumption holds for CRUD responses but breaks for aggregate responses that happen to live on the same controller:
- Import summaries (
{ count, items[] }) - Counters, stats, health checks
- Handshake / acknowledgement responses (
{ success: true })
For those routes, none of the top-level keys match a scope name, so the interceptor strips every field and the caller gets {}. Platform admins are unaffected because they bypass the interceptor entirely — so this bug only manifests for regular tenant users, which makes it easy to miss in development.
Resolution — @AggregateResponse() + AggregateResponseDto. Apply the decorator to the route and extend AggregateResponseDto on the response class. The two together encode an invariant — "this response contains no scope-grouped entity fields" — at both the metadata and the type level. All other guards still run (JwtAuthGuard, ScopeGuard, ActionGuard, FieldWriteGuard, RolesGuard, tenant isolation); only the response-filter step is skipped.
The invariant that makes this safe: if you are authorized to invoke the route, you are already authorized to read every field in the response. For imports this is trivially true — create implies WRITE on every scope and therefore READ on every scope. The role gate (@RequireRoles('admin')) is a second line of defence on the call side, not on the response side.
Runtime safety net. The interceptor knows the canonical scope-name set for every entity (ENTITY_SCOPE_REGISTRY, built from STUDENT_SCOPES / TEACHER_SCOPES / …). Before passing an aggregate response through unchanged, it asserts that no top-level key collides with a scope name for the entity. If someone accidentally adds a health: {...} field to an aggregate DTO:
- In dev/test (
NODE_ENV !== 'production'): the interceptor throws loudly, naming the offending keys and the handler. Tests 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 / admin-config entities with a single scope and no sensitive/non-sensitive split, so per-field visibility filtering buys nothing.
Such entities are registered in FLAT_DTO_ENTITIES (src/common/constants/scope-fields.ts). Both FieldFilterInterceptor (response) and FieldWriteGuard (body) short-circuit before per-scope-group handling for them — route protection still flows through @RequireScopes(read|write) / @RequireAction. Forgetting to register one is a latent bug: the interceptor flags every flat field as drift (dev-throws / prod-strips → empty response), and the write guard rejects every body key as an unknown scope. Both only bite non-platform-admins (admins bypass both), so platform-admin-only test coverage will not catch it.
Current members: timetables, timetable_templates, evaluation_scales, audit_log. Each has a descriptor-only *_SCOPES constant (configuration: [] as const) and no FIELD_MAPPINGS row (the drift spec's "empty scopes don't require a mapping" branch exempts them). attendance is deliberately not flat — its register scope is field-level, reserved for future referent/student narrowing.
Single-
configuration-scope entity, scope-grouped vs flat.school/departments/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. - 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). |
Canonical example: students.curriculum_selection (seeded with no field mappings; gates /students/:id/curriculum-selection routes). Spec: docs/superpowers/specs/2026-05-27-students-curriculum-selection-scope-split-design.md.
Boot-time invariants¶
Three scanners run at app bootstrap via DiscoveryService.getControllers(). All three follow the same env-aware contract: strict in dev/CI (throw at boot), forgiving in production (console.error and continue).
| Scanner | File | What it catches | Coverage |
|---|---|---|---|
assertAggregateResponseInvariant |
src/common/dto/aggregate-response-invariant.ts |
Handler returns a DTO extending AggregateResponseDto but lacks @AggregateResponse() |
Sync-only — TypeScript erases Promise<X> to bare Promise at runtime, so async handlers (essentially every controller method) are silently skipped. FieldFilterInterceptor runtime collision check is the real net. See inline doc for the Swagger-metadata upgrade path. |
assertPolicyScopeAlignment |
src/common/dto/policy-scope-invariant.ts |
Handler has BOTH @AppliesPolicy(policy) and @RequireScopes(entity, ...) but policy.entityKey !== entity |
Full — both metadata are sync. |
assertScopeDecoratorExclusivity |
src/common/dto/scope-decorator-invariant.ts |
Handler has BOTH @RequireScopes (plural) and @RequireScope (singular). They answer different questions; stacking is always a mistake |
Full — both metadata are sync. ScopeGuard is the runtime safety net (SCOPE_DECORATOR_MISCONFIGURED, 500). |
Add a fourth scanner here when a new pair of decorators needs cross-handler invariant enforcement.
Entity Gate vs Field-Level Enforcement¶
ScopeGuard acts as a coarse entity-level gate: "does this user have ANY scope access on this entity?" It does not enumerate specific scopes — that would be redundant since real field-level enforcement is handled downstream.
| Component | Operates on | Logic |
|---|---|---|
ScopeGuard |
Entity-level access check | Pass if user has ANY scope at the required level |
ActionGuard |
Route's @RequireAction metadata |
Binary — user has the action (which embeds AND scope requirements) |
FieldWriteGuard |
User's compiled scopes[entity] (WRITE access) |
Checks top-level body keys (scope group names) against user's writable scopes |
FieldFilterInterceptor |
User's compiled scopes[entity] (READ+ access) |
Strips response scope groups the user lacks READ access on. Always keeps id, createdAt, updatedAt. |
Example (update route): A route declares @RequireScopes('students', 'write'). A user with students.identity: WRITE and students.health: NONE:
- 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 only (create, delete).
Canonical examples: prisma/seed/rbac-catalogue.ts, prisma/seed/roles.ts.
Temporal Role Assignments¶
The user_roles table supports temporal assignments via validFrom and validUntil:
Use case: Substitute teacher
- 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/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 — PATCH /roles/:key¶
Admin-only (@RequireRoles('admin'), same bare guard stack as the rest of the roles surface). Edits the scope/action grants of an existing role via a sparse delta { scopes:[{entityKey,scopeKey,access}], actions:[{entityKey,actionKey,granted}] }; access: NONE / granted: false remove a grant. Only the management presets are editable — admin and the profile-coupled roles (teacher/referent/student, via PROFILE_COUPLED_ROLE_KEYS) are frozen (ROLE_NOT_EDITABLE). Editable iff key !== 'admin' && !PROFILE_COUPLED_ROLE_KEYS.includes(key).
Validation rejects the whole PATCH atomically: unknown catalogue keys (ROLE_GRANT_INVALID / UNKNOWN_ENTITY|UNKNOWN_SCOPE|UNKNOWN_ACTION) and incoherent action grants — the resulting state must hold every scope an action requires at the required access, so granting an action without its scope (or removing a scope under a still-granted action) is rejected (ACTION_MISSING_SCOPES); satisfy it by granting the action and its scope in the same PATCH.
Grants compile live (getUserPermissions reads RolePermission/RoleActionPermission per request), so edits take effect on each holder's next request — no token refresh (unlike role assignment, whose keys live in the JWT). A local/e2e destructive reseed resets roles to the canonical baseline; on hosted dev/stage and production the global-preset seeder runs on every deploy but is create-only (it never overwrites existing grants), so platform-admin edits are durable across redeploys. Role creation/deletion (custom roles) is deferred — see the appendix of docs/superpowers/specs/2026-06-09-role-grant-editing-design.md.
Global preset roles + backoffice editing — GET/PATCH /roles/presets¶
The 9 preset roles are a global, platform-curated catalogue: Role rows with tenantId IS NULL (one per key, enforced by the partial unique index roles_platform_key_key). They are the code-defined baseline (authored once by seedGlobalRolePresets); each tenant gets a clone at provisioning via cloneRolesIntoTenant (src/permissions/role-clone.ts) — the seed no longer authors per-tenant roles by hand. Presets authored after a tenant was provisioned (e.g. secretary / curriculum_coordinator, 2026-07-02) reach existing tenants via the additive-only Tier-1 step backfillMissingPresetRoles (same file): it creates only roles a tenant lacks entirely and never touches existing roles or their grants, so per-tenant grant edits survive. Compilation and assignment only ever resolve real-tenant rows, so a null-tenant preset is never assigned to a user or compiled into a session.
Platform admins edit the global presets in the backoffice: GET /roles/presets (catalogue) + PATCH /roles/presets/:key (grants), both @PlatformAdminOnly. The PATCH reuses the same RoleGrantsService.patchGrants engine generalized to a tenantId | null target, with the same editable rule — admin + the profile-coupled trio are frozen, so only principal/hr/secretary/department_head/curriculum_coordinator are editable. Edits change future clones only; existing tenants keep their copies (and their own tenant-admin PATCH /roles/:key customizations). Spec: docs/superpowers/specs/2026-06-09-backoffice-role-preset-editing-design.md.
Code-vs-DB drift guard (audit #4). Runtime authz resolves from DB grant rows, and the seed is additive, so a grant removed from code can silently linger in the DB (over-grant on children's PII). Guards: rolePermission upserts re-apply access-level changes on reseed (update: { access }); a grant removal needs a hand DELETE migration; and test/rbac-grants.db-sync.e2e-spec.ts asserts every isPreset role's DB grants equal expectedPresetGrants(catalogue) (the code mirror, prisma/seed/helpers/) in both directions — a forgotten removal turns the build red. See chapter 15 §Tier 1.
Write Protection¶
Status: COMPLETE. Implemented as
FieldWriteGuard(src/permissions/guards/field-write.guard.ts).
The problem FieldWriteGuard solves: without write validation, a user with identity.write but NOT health.write could submit a PATCH with { firstName: "Mario", disabilityInfo: "ADHD" }. ScopeGuard passes (user has ANY write scope), the DTO validates the shape, and the service updates ALL submitted fields — including disabilityInfo, a scope the user cannot write.
How it works: The guard runs after ScopeGuard and ActionGuard, before the controller handler:
- 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(StudentsPolicy)
async findAll(
@Query() query: AcademicYearPaginationQueryDto,
@AccessContext() ctx: RecordAccessContext,
) {
return this.studentsService.findAllForAccessContext(
ctx,
query.page,
query.limit,
query.academicYearId,
);
}
EntityAccessPolicy — single source of truth¶
Since 2026-05-26, record-access logic is centralised in an EntityAccessPolicy<TWhere> produced by definePolicy({ entityKey, buildBase, branches, parametricBranches? }) (see src/common/utils/entity-access-policy.ts). A policy bundles four things in one frozen declaration:
| Field | Shape | Consumed by | Purpose |
|---|---|---|---|
entityKey |
EntityKeyValue |
assertPolicyScopeAlignment boot-time check |
Locks the policy to a permission_entities.key; drift vs the route's scope decorator is rejected at boot. |
roles |
readonly string[] (frozen, in branch-declaration order) |
@AppliesPolicy(policy) → RolesGuard via @RequireRoles(...policy.roles) |
Sole source of the route's role allowlist — no separate @RequireRoles(...) list to maintain. |
parametricDimensions |
readonly ParameterDimValue[] (frozen) |
RolesGuard parametric-overlap admission |
Set of ParameterDim values this policy narrows by. Any user whose parameterDimensions overlaps this set is admitted even when their role keys don't intersect roles — the lever for custom parametric roles. |
where |
(ctx: RecordAccessContext) => TWhere \| NEVER_MATCH_WHERE |
<EntityService> (typically merged into findMany/findFirst) |
The Prisma WHERE resolver — produced by makeRoleVisibilityResolver from the supplied branches. |
Because all four come from the same definePolicy call, the route role gate, the parametric-overlap admission, the Prisma narrowing, and the boot-time entity-key alignment cannot drift independently.
Full design rationale: docs/superpowers/specs/2026-05-26-entity-access-policy-design.md. Parametric extension: docs/superpowers/specs/2026-05-27-parametric-roles-design.md.
definePolicy — branches and resolution¶
definePolicy takes a buildBase builder plus two kinds of branches: role-keyed and parametric. Both contribute Prisma WHERE fragments; the resolver OR-composes them at request time.
Role-keyed branches (required) — one entry per role key the policy admits. Each branch's build field is one of:
'pass-through'(string literal): the caller carrying this role sees the 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: StudentsPolicy.where(ctx),
skip: (page - 1) * limit,
take: limit,
});
}
async findOneForAccessContext(ctx: RecordAccessContext, id: string) {
return this.prisma.student.findFirst({
where: { ...StudentsPolicy.where(ctx), id },
});
}
Use findFirst with the policy where AND the id, not findUnique followed by a manual check. Folding the access filter into the same where produces a 404 not 403 when a caller asks for a record they cannot see — matching the cross-tenant isolation pattern and not leaking existence.
Policy inventory¶
The following policies are wired today. "Parametric dims" lists every ParameterDim value the policy admits via parametric-dimension overlap; (direct FK) / (M2M) / (via X) indicates the predicate shape the parametric branch contributes.
| Entity | Policy file | Parametric dims | Notes |
|---|---|---|---|
| Students | src/students/students.policy.ts |
DEPARTMENT (direct FK on Student.departmentId), CURRICULUM (via curriculumSelection.curriculumId — only students who selected an assigned curriculum) |
'teacher' branch narrows by ctx.teacherDepartmentIds; 'referent' branch joins through StudentReferentLink; 'student' branch narrows to the caller's own row via userId: ctx.userId. |
| Teachers | src/teachers/teachers.policy.ts |
DEPARTMENT (M2M via TeacherDepartmentAssignment) — no CURRICULUM branch |
'teacher' branch narrows to caller's own departments via ctx.teacherDepartmentIds. curriculum_coordinator is a role-keyed pass-through (tenant-wide directory): no teacher↔curriculum relation exists to narrow by, and SG-teaching-derived narrowing would be circular. A custom CURRICULUM role fail-closes here. |
| Staff | src/staff/staff.policy.ts |
none (tenant-flat) | department_head is role-keyed pass-through (no Staff↔Department model yet); flip to a parametric branch when that signal lands. Coordinator holds no staff scopes → ScopeGuard blocks first, policy fail-closes too. |
| Referents | src/referents/referents.policy.ts |
none | 'referent' branch narrows to self via userId: ctx.userId. Write-side assertCallerIsSelfOrAdmin is the redundant service-layer net. |
| Departments | src/departments/departments.policy.ts |
DEPARTMENT (id IN (...)), CURRICULUM (curricula: { some: { id IN } } — the owning department) |
The dept-head only sees their own departments; the coordinator sees the department(s) owning their curricula (READ). |
| Grades | src/departments/grades.policy.ts |
DEPARTMENT (direct FK on Grade.departmentId), CURRICULUM (via department.curricula) |
|
| Curricula | src/curriculum/curricula.policy.ts |
DEPARTMENT (direct FK on Curriculum.departmentId), CURRICULUM (id IN (...) — the assigned rows themselves) |
The CURRICULUM branch doubles as the coordinator's write fence: every curricula write path resolves the row through CurriculaPolicy.where(ctx), so an unassigned curriculum 404s on PATCH/status/delete exactly like on reads. |
| Selection Windows | src/curriculum/selection-windows.policy.ts |
DEPARTMENT (direct FK on CurriculumSelectionWindow.departmentId), CURRICULUM (via department.curricula) |
Windows are per-department: the coordinator reads the owning department's window; window actions are not granted to the role. |
| Homerooms | src/homerooms/homerooms.policy.ts |
DEPARTMENT (direct FK), CURRICULUM (direct FK on Homeroom.curriculumId) |
|
| Subject Groups | src/subject-groups/subject-groups.policy.ts |
DEPARTMENT (via curriculumSubject.curriculum.departmentId), CURRICULUM (via curriculumSubject.curriculumId — one hop shorter) |
SG has no direct departmentId; narrows through the anchor subject. |
| Attendance | src/attendance/attendance.policy.ts |
DEPARTMENT (direct FK on denormalized AttendanceRecord.departmentId) |
'teacher' branch narrows by ctx.teacherDepartmentIds. Read is intentionally broad; write authority is the stricter in-service Y-set. Routes also carry @RequireRoles('admin', 'teacher'). Only admin + teacher branches exist — resolver fails closed (NEVER_MATCH) for everyone else. |
| Academic Years | src/academic-years/academic-years.policy.ts |
none | Admin-only. |
| School | src/school/school.policy.ts |
none | Admin-only singleton. |
| Completeness (dashboard) | src/command-center/completeness.policy.ts |
none | Aggregate route — where is not consumed by the service. Narrowing for the per-student rollup is delegated to StudentsPolicy.where inside completeness.queries.ts (see "Aggregate routes" below). |
| Onboarding (dashboard) | src/command-center/onboarding.policy.ts |
none | Same shape as Completeness. |
| Study-Plan Selections (dashboard) | src/command-center/study-plan-selections.policy.ts |
none | Same shape as Completeness. |
Tenant-flat entities (rooms, evaluation_scales, timetable_templates, users, invitations) have no record-level policy — their role grants alone gate access. Add one only when a future axis (department, school year, …) needs to narrow within the tenant.
Aggregate routes (no service-level where). Command-center dashboards (completeness, onboarding, study-plan-selections) declare an EntityAccessPolicy so the route gets the standard @AppliesPolicy shape — role allowlist + parametric-overlap admission + boot-time entity-key check — but the policy's where(ctx) is never AND-ed into a top-level query. Inside the queries module, the per-entity policies (StudentsPolicy.where(ctx), etc.) are merged into the aggregation joins as usual. This keeps the route gate uniform and pushes narrowing to the underlying entities.
Canonical example¶
// src/students/students.policy.ts (excerpt)
export const StudentsPolicy = definePolicy<Prisma.StudentWhereInput>({
entityKey: EntityKey.STUDENTS,
buildBase: (ctx) => ({ tenantId: ctx.tenantId }),
branches: [
{ role: 'admin', build: 'pass-through' },
{ role: 'hr', build: 'pass-through' },
{ role: 'principal', build: 'pass-through' },
{ role: 'staff', build: 'pass-through' },
{
role: 'teacher',
build: (ctx, base) => {
if (ctx.teacherDepartmentIds.length === 0) return NEVER_MATCH_WHERE;
return { ...base, departmentId: { in: ctx.teacherDepartmentIds } };
},
},
{
role: 'referent',
build: (ctx, base) => ({
...base,
referents: {
some: { referent: { userId: ctx.userId, tenantId: ctx.tenantId } },
},
}),
},
],
parametricBranches: [
{
dim: ParameterDim.DEPARTMENT,
narrow: (base, ids) => ({ ...base, departmentId: { in: ids } }),
},
],
});
// Controller — single decorator wires the whole role gate + parametric admission
@Get(':id')
@RequireScopes(EntityKey.STUDENTS, 'read')
@AppliesPolicy(StudentsPolicy)
async findOne(@Param('id') id: string, @AccessContext() ctx: RecordAccessContext) {
return this.studentsService.findOneForAccessContext(ctx, id);
}
// Service — `policy.where(ctx)` is the only narrowing call
async findOneForAccessContext(ctx: RecordAccessContext, id: string) {
return this.prisma.student.findFirst({
where: { ...StudentsPolicy.where(ctx), id },
});
}
Filtering logic per role type (students): (resolution semantics per definePolicy)
| Role type | Resolver contribution | Effective WHERE |
|---|---|---|
| Platform admin | ctx.isPlatformAdmin → returns base immediately, before any branch is evaluated |
WHERE tenantId = ? |
| Admin / HR / Principal / Staff | Role-keyed 'pass-through' — short-circuits the resolver |
WHERE tenantId = ? |
| Teacher | Role-keyed narrowing: narrows by caller's own TeacherDepartmentAssignment set (ctx.teacherDepartmentIds). Empty set → NEVER_MATCH_WHERE, branch drops out of the OR |
WHERE tenantId = ? AND departmentId IN (ctx.teacherDepartmentIds) |
| Department Head (and any custom DEPARTMENT-parameterized role) | Parametric DEPARTMENT branch fires when ctx.parameters.departmentIds is non-empty. The resolver passes those ids into the branch as narrow(base, ids); the branch never reads off ctx directly. Admission is via parametric-dimension overlap in RolesGuard, not role-key match |
WHERE tenantId = ? AND departmentId IN (ids) |
| Referent | Role-keyed narrowing through StudentReferentLink |
WHERE tenantId = ? AND referents SOME (referent.userId = ?) |
| No matching role and no parametric branch fires | NEVER_MATCH_WHERE (fail closed) |
WHERE id IN () — Prisma optimises to a constant-false predicate |
When multiple contributions land (e.g. a user holds teacher AND a DEPARTMENT-parametric custom role), the resolver returns { OR: [...contributions] }. tenantId is in every contribution via base, so OR preserves tenant isolation.
Parametric DEPARTMENT predicate per entity (canonical narrow(base, ids) body — ids is the parametric-branch arg, sourced from ctx.parameters.departmentIds):
| Entity | Predicate shape | Notes |
|---|---|---|
| Students | { ...base, departmentId: { in: ids } } |
Direct FK |
| Teachers | { ...base, departments: { some: { departmentId: { in: ids } } } } |
M2M via TeacherDepartmentAssignment |
| Departments | { ...base, id: { in: ids } } |
The dept-head only sees their own departments |
| Grades | { ...base, departmentId: { in: ids } } |
Direct FK |
| Curricula | { ...base, departmentId: { in: ids } } |
Direct FK |
| Selection Windows | { ...base, departmentId: { in: ids } } |
Direct FK |
| Homerooms | { ...base, departmentId: { in: ids } } |
Direct FK |
| Subject Groups | { ...base, curriculumSubject: { curriculum: { departmentId: { in: ids } } } } |
Indirect via CurriculumSubject → Curriculum |
| Attendance | { ...base, departmentId: { in: ids } } |
Direct FK on denormalized AttendanceRecord.departmentId |
Tenant-flat entities (staff, rooms, evaluation_scales, timetable_templates, school, academic_years, users, invitations, referents) have no parametric DEPARTMENT branch — they expose a role-keyed 'pass-through' for department_head (and admin/hr/principal as appropriate), which short-circuits the resolver cleanly. When a dept-link signal lands on one of those entities, flip its branch from role-keyed pass-through to a parametric DEPARTMENT branch (see staff.policy.ts for the planned migration note).
Invariants¶
- 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. 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. - 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¶
StudentsPolicy (src/students/students.policy.ts) is the canonical implementation of EntityAccessPolicy. StudentsService.findAllForAccessContext / findOneForAccessContext (src/students/students.service.ts) shows how StudentsPolicy.where(ctx) is consumed in the service. The companion unit tests in students.policy.spec.ts, students.service.spec.ts, and students.controller.spec.ts cover one test per role branch, platform-admin short-circuit, fail-closed sentinel, parametric-overlap admission, and decorator/filter synchronization.
Permission Caching¶
PermissionsService.getUserPermissions() executes a nested Prisma query traversing:
userRoles -> role -> permissions -> scope -> entity
-> actionPermissions -> action -> scopeRequirements
Multiple guards and the interceptor need the same compiled permissions per request. Without memoization, the query would run for each guard.
Phase 1: Request-Scoped Memoization (Complete)¶
Pre-compile permissions once per request and attach to the request object. The ScopeGuard calls getUserPermissions() once and stores the result on request.permissions. All downstream guards and interceptors read from the memoized result:
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.
Custom Field Definitions Table¶
custom_field_definitions
id UUID PK
tenant_id UUID FK
entity_key VARCHAR -- e.g., 'students', 'teachers'
scope_id UUID FK -- links to permission_scopes
field_key VARCHAR -- e.g., 'blood_type', 'nickname'
label VARCHAR -- display name
field_type FieldType -- TEXT, NUMBER, DATE, BOOLEAN, SELECT
options Json? -- for SELECT type: ["A+","A-","B+",...]
sort_order INT
is_required BOOLEAN
created_at, updated_at
@@unique([tenant_id, entity_key, field_key])
Custom Fields Integration in Services¶
customFields Json?column on the Prisma model- Inject
CustomFieldsServicein entity service constructor (already handled byBaseTenantedCrudService) validateCustomFields()called automatically by base service on create/updatepickCustomFields()and dynamic scope auto-discovery handled bytoScopedResponse()othersscope: default scope for custom fields, auto-discovered from definitions — no entity service code needed
Validation¶
CustomFieldsService.validateCustomFields() iterates DTO scope keys generically (no hardcoded scope names). Validates:
- Type checking: TEXT→string, NUMBER→number, DATE→ISO date, BOOLEAN→boolean, SELECT→value in options
- Required fields enforced on create (skips on update for PATCH semantics)
- Unknown keys rejected
Definition Deletion¶
Removes the field key from JSONB across all tenant entity rows + deletes the definition in a single $transaction.
Integration with Permissions Endpoint¶
GET /api/v1/permissions includes customFieldDefinitions per entity, filtered by the user's scope access:
{
"students": {
"scopes": { "identity": "WRITE", "health": "WRITE", "others": "WRITE" },
"actions": { "create": true },
"customFieldDefinitions": [
{ "key": "nickname", "label": "Nickname", "scope": "identity", "type": "TEXT", "isRequired": false, "sortOrder": 0 },
{ "key": "blood_type", "label": "Blood Type", "scope": "health", "type": "SELECT", "options": ["A+","A-","B+","B-","AB+","AB-","O+","O-"], "isRequired": false, "sortOrder": 0 },
{ "key": "notes", "label": "Notes", "scope": "others", "type": "TEXT", "isRequired": false, "sortOrder": 0 }
]
}
}
Only definitions where the user has at least READ on the field's scope are included.
Key Implementation Files¶
- Service:
src/custom-fields/custom-fields.service.ts(CRUD + validation) - Controller:
src/custom-fields/custom-fields.controller.ts(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.
Frontend Permission Discovery¶
The Problem¶
The JWT payload contains { sub, tenantId, roles: ['teacher'] } — role keys only. The frontend has no way to know:
- Which entities/scopes the user can access (for tab/section visibility)
- Which fields are readable vs writable (for form rendering)
- Whether specific actions are allowed (for button states)
Without this information, the frontend either over-fetches and gets 403s, or renders everything and lets the API strip fields (poor UX).
Selected Approach: Dedicated Endpoint¶
The current implementation splits user profile and permissions into two endpoints:
GET /api/v1/auth/me— 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: NONE scopes are omitted from the response. Actions show effective permissions (granted AND all scope requirements satisfied).
Staleness trade-off: If a school admin changes a user's role, the user won't see the change until their next token refresh (max 15min). Acceptable for an EdTech system — role changes are rare and not time-critical.
Custom Role Definitions¶
Problem¶
The schema supports custom roles (Role.isPreset = false) but there's no admin API or workflow. School admins need to create custom roles for edge cases (combined nurse-psychologist, part-time secretary, etc.), assign scope permissions to custom roles, and assign users to them — without breaking preset roles that are managed by the system.
Selected Approach: Clone-and-Modify with Immutable Presets¶
- Presets are read-only templates. System deployments can safely upsert preset
role_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 (prisma/seed/roles.ts) upserts role_permissions for preset roles. Custom roles are never touched by the seed. If a new scope is added, preset roles get the appropriate default permission; custom roles have no entry for the new scope (no access — safe default).
Role naming: Custom role keys are auto-generated from the label (slugify(label)). The admin provides a label and description; the key is derived.
Frontend UI Patterns¶
Concrete guidance for the frontend team on consuming the permission system.
Permission Context Provider¶
Wrap the app in a React Context that holds the compiled permissions:
// PermissionProvider.tsx
const PermissionContext = createContext<PermissionsResponse | null>(null);
function PermissionProvider({ children }) {
const { accessToken } = useAuth();
const [permissions, setPermissions] = useState<PermissionsResponse | null>(null);
useEffect(() => {
if (accessToken) {
fetch('/api/v1/permissions', { credentials: 'include' })
.then(res => res.json())
.then(setPermissions);
}
}, [accessToken]); // Re-fetches on token refresh
return (
<PermissionContext.Provider value={permissions}>
{children}
</PermissionContext.Provider>
);
}
Permission Hooks¶
function usePermissions(): PermissionsResponse { ... }
function useCanRead(entity: string, scope: string): boolean {
const perms = usePermissions();
const access = perms?.[entity]?.scopes?.[scope];
return access === 'READ' || access === 'WRITE';
}
function useCanWrite(entity: string, scope: string): boolean {
const perms = usePermissions();
return perms?.[entity]?.scopes?.[scope] === 'WRITE';
}
function useCanAction(entity: string, action: string): boolean {
const perms = usePermissions();
return perms?.[entity]?.actions?.[action] === true;
}
Tab / Section Visibility¶
Hide tabs entirely if the user lacks read access to the relevant scope:
function StudentDetailPage() {
const canReadHealth = useCanRead('students', 'health');
const canReadAttendance = useCanRead('students', 'attendance');
return (
<Tabs>
<Tab label="General">...</Tab>
{canReadSensitive && <Tab label="Medical">...</Tab>}
{canReadAttendance && <Tab label="Attendance">...</Tab>}
</Tabs>
);
}
Form Section States (Scope-Grouped)¶
Since the API uses scope-grouped DTOs ({ identity: {...}, health: {...} }), the frontend renders form sections per scope group. Each section has one of three states:
| State | Condition | Rendering |
|---|---|---|
| Editable | User has WRITE on the scope | Normal inputs for all fields in the section |
| Read-only | User has READ on the scope | Disabled inputs or plain text |
| Hidden | User has no access to the scope (absent from permissions) | Section not rendered at all |
function StudentIdentitySection({ data }: { data: StudentIdentity }) {
const canRead = useCanRead('students', 'identity');
const canWrite = useCanWrite('students', 'identity');
if (!canRead) return null; // Hidden — no access to this scope
return (
<Section title="Identity Data" readOnly={!canWrite}>
<Input name="firstName" value={data.firstName} disabled={!canWrite} />
<Input name="lastName" value={data.lastName} disabled={!canWrite} />
{/* ... other identity fields */}
</Section>
);
}
Action Buttons¶
Show edit buttons based on scope access; show create/delete buttons based on action permissions:
function StudentActions() {
const perms = usePermissions();
const entityScopes = perms?.['students']?.scopes ?? {};
const hasAnyWrite = Object.values(entityScopes).some(s => s === 'WRITE');
const canCreate = useCanAction('students', 'create');
const canDelete = useCanAction('students', 'delete');
return (
<>
{hasAnyWrite && <Button>Edit Student</Button>}
{canCreate && <Button>Create Student</Button>}
{canDelete && <Button>Delete Student</Button>}
</>
);
}
Error Handling¶
| Error | Action |
|---|---|
401 Unauthorized |
Redirect to login |
403 Forbidden (route-level) |
Re-fetch /me, update context. Show "access denied" message. |
403 FORBIDDEN_FIELDS (write) |
Show a generic "insufficient write permissions" message. Re-fetch permissions to update form section states. |
On 403, always re-fetch permissions — the user's role may have changed since the last fetch.
Micro-Frontend Considerations¶
If the frontend uses a micro-frontend architecture:
- The orchestrator shell owns the
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