Skip to content

Permissions (RBAC)

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


Mental Model — Four Layers of Access Control

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

Layer Question Implementation Status
1. Authentication "Who are you?" JWT + Passport (JwtAuthGuard) Complete
2. Scope-level access "What can you see/edit?" Entity-Scope model (ScopeGuard + FieldFilterInterceptor) Complete
3. Action-level access "What operations can you perform?" Action permissions (ActionGuard) Complete
4. Record-level access "Which records?" Tenant isolation (service-layer tenantId). Per-user record filtering NOT yet implemented. Partial

Request pipeline

Request
  |
  +-> [JwtAuthGuard]              Layer 1: Authenticate. Attach { userId, tenantId, roles } to request.
  |       |                        Reject if token invalid/expired -> 401
  |       v
  +-> [ScopeGuard]                Layer 2a: Check entity-level access (read/update routes only).
  |       |                        Read @RequireScopes(entity, action) metadata.
  |       |                        "Does user have ANY scope on this entity?" Reject -> 403 INSUFFICIENT_SCOPE.
  |       |                        Skip if no @RequireScopes metadata (e.g. action-only routes).
  |       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 @RequireScopes or @RequireAction metadata.
  |       |                        Compare body scope keys against writable scopes. Reject -> 403 FORBIDDEN_FIELDS.
  |       v
  +-> [Controller -> Service]     Layer 4: Business logic. Service filters by tenantId (RLS deferred as safety net).
  |       |
  |       v
  +-> [FieldFilterInterceptor]    Layer 2c: Strip unauthorized scope groups from response.
          |                        Derives entity from @RequireScopes or @RequireAction metadata.
          |                        Check scope access for each top-level key.
          |                        Keep only scope groups with READ+ access + { id, createdAt, updatedAt }.
          v
       Response

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


The Entity-Scope Permission Model

Core Concept

The model answers two questions for every API request:

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

Hierarchy: EntityScopeFields

  • An entity is a domain object (students, teachers, staff, etc.)
  • A scope is a meaningful business grouping of fields within an entity (anagraphic, sensitive, 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):

Entity key Scopes Actions Notes
students anagraphic, contacts, enrollment, sensitive, documents create, delete Custom fields via others scope
teachers anagraphic, contacts, employment, documents, sensitive create, delete Custom fields via others scope
staff anagraphic, contacts, employment, documents, sensitive 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 -- Setup-only (lookup GET only)
curricula configuration create, delete Multi-table: field mappings span curricula + study_plans tables

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 (anagraphic, sensitive)
label String Display name
description String Help text for admin UI
sort_order Int Display ordering within entity

Constraint: UNIQUE(entity_id, key)

scope_field_mappings

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

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

Constraint: UNIQUE(scope_id, table_name, field_name)

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

roles

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

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

Constraint: UNIQUE(tenant_id, key)

role_permissions

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

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

Constraint: UNIQUE(role_id, scope_id)

user_roles

Assigns roles to users with temporal support (for substitute teachers, temporary permissions, etc.).

Column Type Description
id UUID PK
user_id UUID FK → users
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

Constraint: UNIQUE(user_id, role_id, tenant_id)

Students Entity — Full Scope Breakdown

The students entity is the reference implementation. Below is the planned scope breakdown for the full system. Only anagraphic and sensitive are currently seeded.

Scope Fields Description
anagraphic firstName, lastName, dateOfBirth, gender, nationality, address, photo, taxCode Basic biographical data
sensitive medical_records.*, disabilityInfo, psychological_notes.*, dietaryRestrictions Health, disability, psychological data — restricted access
attendance attendance_records.*, reason, timestamp, excusedBy, daily_class_lists.* Daily attendance tracking
scoring grades.*, evaluations.*, report_cards.* Academic performance data
financial payment_plans.*, fees.*, invoices.*, deposits.* Billing and payment data
family parents.firstName/lastName/phone/email, student_parents.*, emergency_contacts.* Family and guardian contacts
documents student_documents.* (IDs, certificates, contracts) Uploaded documents and files
enrollment enrollment_history.*, class_assignments.*, admission_applications.* Enrollment and class placement

* notation means fields from related tables that will be added as the system grows.

Referents Entity — Scope Breakdown

The referents entity has four scopes. Only anagraphic, contacts, and documents have field mappings today; sensitive is reserved for future fields (e.g. legal notes, custody info).

Scope Fields Description
anagraphic firstName, lastName, dateOfBirth, placeOfBirth, gender, nationality, taxCode Basic biographical data
contacts email, phone, homeAddress, homeCity, homeState, homePostcode, homeCountry Contact information
documents passportNumber, passportIssueDate, passportExpiryDate, identityCardNumber, identityCardIssueDate, identityCardExpiryDate Identity documents
sensitive (no mapped fields yet) Reserved — will hold legal, custody, or health-related data

Referent Preset Role

The seeded referent role grants:

  • WRITE on all four referents.* scopes (anagraphic, contacts, documents, sensitive)
  • READ on students.anagraphic, students.contacts1, students.enrollment, students.documents, students.sensitive
  • READ on students.others (custom fields for linked children)

No create or delete actions are granted. Referents hold WRITE on their own scopes (reserved for future self-update endpoints) and READ on their children's student records.

1 The seed uses the scope key students.contacts. The aspirational Students Entity matrix above uses family for the equivalent column. This is a known vocabulary mismatch; the seed is authoritative for current behavior.

Preset Role Permissions Matrix

The following matrix defines the default permissions for each preset role across the students entity scopes. School admins can create custom roles that deviate from these presets.

Legend: R = READ, W = WRITE, -- = NONE, (self) = only own record, (child) = only linked children

Role anagraphic sensitive attendance scoring financial family documents enrollment
Admin R/W R/W R/W R/W R/W R/W R/W R/W
HR / Secretary R/W R R/W R R/W R/W R/W R/W
Principal R R R R R R R R
Internal Teacher R -- R/W R/W -- R -- R
External Teacher R -- R R/W -- -- -- --
Internal Staff R -- R -- -- -- -- --
External Staff R -- -- -- -- -- -- --
Student R (self) -- R (self) R (self) R (self) -- R (self) R (self)
Parent R (child) R (child) R (child) R (child) R (child) R (self) R (child) R (child)
Accountant R -- -- -- R/W -- R --
Admissions Officer R/W -- -- -- R R/W R/W R/W

(self) and (child) annotations indicate record-level filtering (see Record-Level Access), not scope-level restrictions. The scope permissions apply once record-level access is granted.

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 / Secretary R/W + C + D R/W + C + D R/W + C + D R/W + C + D
Principal R R R R
Teacher R R R R
Student R R R R
Parent R R R R
Others -- -- -- --

academic_years is omitted — it has no actions and is lookup-only (GET /academic-years). users is omitted — it has a profile scope, not configuration.

Permission Compilation Flow

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

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

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

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

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

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

Record-level helpers that branch on ctx.roles.includes('referent') (e.g. studentsForAccessContext) 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 — no helper changes needed.

RecordAccessContext.roles (consumed by record-level helpers like studentsForAccessContext, 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]. Helpers branching 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

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 both anagraphic and sensitive). 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.

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 anagraphic, sensitive) against user's WRITE scopes. System fields (id, createdAt, updatedAt, tenantId) are always rejected. Rejects with FORBIDDEN_FIELDS.

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

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

Role-level access — @RequireRoles()

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

Semantics:

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

When to reach for it:

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

When not to reach for it:

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

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

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

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

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

GET /students and GET /students/:id carry @RequireRoles('admin', 'teacher', 'referent'). All three 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 studentsForAccessContext), 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 (anagraphic, sensitive, 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 sensitive: {...} field to an aggregate DTO:

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

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

Rules for applying @AggregateResponse():

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

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

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

Lookup GETs

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

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

Adding a lookup GET is a permission change and must be reviewed as such: the entity key, scope, and @AggregateResponse() together determine exactly who can call the route and what they receive. The @AggregateResponse() bridge is expected to be replaced by the planned scope-groups refactor (ClickUp 86c8rqer6); GET /rooms/types and GET /academic-years have a small migration surface when that lands.

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. The curricula entity demonstrates this: the scope key is curricula.configuration, but FIELD_MAPPINGS in rbac-catalogue.ts has two rows — one for the curricula table (name, departmentId, academicYearId) and one for the study_plans table (subjectLabel, curriculumId, gradeId, academicYearId).

Why this works:

  • ENTITY_SCOPE_REGISTRY lists scope key sets per entity — it records that curricula has a configuration scope, but says nothing about which tables back it. Scope membership is a logical concept; the registry drives the FieldFilterInterceptor collision check, not field resolution.
  • scope_field_mappings is the physical layer. Its composite unique key is (scopeId, tableName, fieldName), so the same scope can reference columns in different tables without conflict.
  • Two controllers, one entity key. CurriculumController (/api/v1/curricula) and StudyPlansController (/api/v1/study-plans) both declare @RequireScopes(EntityKey.CURRICULA, ...) / @RequireAction(EntityKey.CURRICULA, ...). The guard stack sees a single entity; field filtering uses the response DTO's top-level configuration key as usual.

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.

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.anagraphic: WRITE and students.sensitive: NONE:

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

Example (create route): A route declares @RequireAction('students', 'create'). The create action requires WRITE on both anagraphic AND sensitive:

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

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

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


Permission Seed Patterns

New Entity Permission Checklist

When adding a new entity to the permission system:

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

Condensed Seed Pattern

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

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

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

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


Temporal Role Assignments

The user_roles table supports temporal assignments via validFrom and validUntil:

Use case: Substitute teacher

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

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


Write Protection

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

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

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

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

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

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


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 RecordAccessContext {
  tenantId: string;
  userId: string;
  roles: string[];         // role keys from JWT
  isPlatformAdmin: boolean;
}

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')
@RequireRoles('admin', 'teacher', 'referent')
async findAll(
  @Query() query: AcademicYearPaginationQueryDto,
  @AccessContext() ctx: RecordAccessContext,
) {
  return this.studentsService.findAllForAccessContext(
    ctx,
    query.page,
    query.limit,
    query.academicYearId,
  );
}

Query-Helper Convention

Each entity that participates in record-level filtering exposes a pure <entity>ForAccessContext(ctx) function in its queries.ts. The function returns a Prisma.<Entity>WhereInput that the service merges into every read. Keeping the role-branch logic in a pure function makes it trivial to unit-test and keeps the service method free of branching.

// src/students/students.queries.ts
export function studentsForAccessContext(
  ctx: RecordAccessContext,
): Prisma.StudentWhereInput {
  const base: Prisma.StudentWhereInput = { tenantId: ctx.tenantId };
  if (ctx.isPlatformAdmin) return base;
  if (ctx.roles.includes('admin')) return base;
  // TODO: narrow to teacher's class/department assignments when class models land.
  if (ctx.roles.includes('teacher')) return base;
  if (ctx.roles.includes('referent')) {
    return {
      ...base,
      referents: {
        some: { referent: { userId: ctx.userId, tenantId: ctx.tenantId } },
      },
    };
  }
  return { id: '__never_matches__' };
}

Filtering logic per role type (students):

Role type Filter strategy Join/condition
Platform admin Short-circuit — full tenant view WHERE tenantId = ?
Admin All records in tenant WHERE tenantId = ?
Teacher All records in tenant (TODO: narrow to class/department assignments) WHERE tenantId = ?
Referent Linked children only WHERE tenantId = ? AND referents SOME (referent.userId = ?)
Unknown / no matching role No rows WHERE id = '__never_matches__'

Invariants

  1. Decorator-filter synchronization. The @RequireRoles(...) list on the route must exactly match the set of roles the helper branches on. If you add a role to the decorator without a branch, the helper returns the sentinel and callers see an empty list (silent failure); if you add a branch without the decorator, the role never reaches the service (403 at the guard). Tests in *.queries.spec.ts and *.controller.spec.ts lock both halves.
  2. Platform admin always short-circuits first. Platform admins bypass the role gate entirely (see Permissions Guard Stack); the helper mirrors that so a platform admin with an unrelated role in their JWT still sees everything.
  3. Additive, never expansive. The helper's output is ANDed into the service's where. It narrows the result set; it never grants access the service would otherwise deny.
  4. findFirst with merged where for findOne. Merging the record-scope filter into the same where clause as the id lookup produces a 404 not 403 when a caller asks for a record they cannot see — consistent with the cross-tenant isolation pattern and avoids leaking existence.
  5. Unknown-role sentinel. Returning { id: '__never_matches__' } for a role not in the branch list fails closed rather than silently returning the tenant-scoped base. Tests assert this explicitly.

Canonical Reference

StudentsService.findAllForAccessContext / findOneForAccessContext (src/students/students.service.ts) is the first implementation of this pattern and should be mirrored when other entities adopt it. The companion unit tests in students.queries.spec.ts, students.service.spec.ts, and students.controller.spec.ts document the expected coverage (one test per role branch, platform-admin short-circuit, unknown-role sentinel, decorator/filter synchronization).


Permission Caching

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

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

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

Phase 1: Request-Scoped Memoization (Complete)

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

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

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

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

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

Phase 2: Redis Caching (Deferred)

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

Scope Fields & Custom Fields

Shared Scope Field Constants

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

// src/common/constants/scope-fields.ts
export const STUDENT_SCOPES = {
  anagraphic: ['firstName', 'lastName', 'nickName', 'dateOfBirth', ...] as const,
  contacts: ['homePhone', 'homeAddress', 'homeCity', ...] as const,
  enrollment: ['enrollmentDate', 'departmentId', 'gradeId', ...] as const,
  sensitive: ['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 enrollment 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",
  "anagraphic": {
    "firstName": "Marco",
    "lastName": "Rossi",
    "customFields": { "nickname": "Marc" }
  },
  "sensitive": {
    "disabilityInfo": null,
    "customFields": { "blood_type": "O+" }
  },
  "others": {
    "customFields": { "notes": "Transfer student" }
  },
  "createdAt": "...",
  "updatedAt": "..."
}

Why single JSONB + scope assignment:

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

Custom Field Definitions Table

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

Custom Fields Integration in Services

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

Validation

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

Definition Deletion

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

Integration with Permissions Endpoint

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

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

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

Key Implementation Files

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

Profile completeness — missingFields

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


Frontend Permission Discovery

The Problem

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

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

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

Selected Approach: Dedicated Endpoint

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

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

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

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

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

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


Custom Role Definitions

Problem

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

Selected Approach: Clone-and-Modify with Immutable Presets

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

Backend API Design

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

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

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

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


Frontend UI Patterns

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

Permission Context Provider

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

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

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

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

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

Permission Hooks

function usePermissions(): PermissionsResponse { ... }

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

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

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

Tab / Section Visibility

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

function StudentDetailPage() {
  const canReadSensitive = useCanRead('students', 'sensitive');
  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 ({ anagraphic: {...}, sensitive: {...} }), 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 StudentAnagraphicSection({ data }: { data: StudentAnagraphic }) {
  const canRead = useCanRead('students', 'anagraphic');
  const canWrite = useCanWrite('students', 'anagraphic');

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

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

Action Buttons

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

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

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

Error Handling

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

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

Micro-Frontend Considerations

If the frontend uses a micro-frontend architecture:

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