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:
- 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 (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. 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 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_yearsis omitted — it has no actions and is lookup-only (GET /academic-years).usersis omitted — it has aprofilescope, notconfiguration.
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" (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) —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 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.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')
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 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.
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. 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_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.- Two controllers, one entity key.
CurriculumController(/api/v1/curricula) andStudyPlansController(/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-levelconfigurationkey 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:
- ScopeGuard — passes (user has WRITE on at least one scope:
anagraphic) - FieldWriteGuard — allows
{ anagraphic: { firstName: "Mario" } }. Rejects{ sensitive: { disabilityInfo: "ADHD" } }with 403FORBIDDEN_FIELDS(user has no WRITE onsensitivescope) - FieldFilterInterceptor — response includes
{ id, anagraphic: {...}, createdAt, updatedAt }. Thesensitivescope 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:
- 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 anagraphic AND sensitive). 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: '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
- 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).
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:
- 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
anagraphic,sensitive) 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.
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¶
- 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.tsand*.controller.spec.tslock both halves. - 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.
- 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. 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.- 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:
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 = {
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:
- 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": { "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(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": { "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_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 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
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