RBAC Strategy: Authorization Model for SIS¶
1. Context¶
This document is the authoritative reference for the SIS authorization strategy. It covers the full stack — from database tables through API enforcement to frontend rendering.
Foundation: docs/architecture.md §6 provides a summary of the Entity-Scope permission model. This document describes the model in full detail (Section 3), then covers additional design dimensions: write protection, record-level access, caching, custom fields, and frontend patterns.
2. 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.
Decorator selection rule: Use @RequireScopes for read (GET) and update (PATCH) routes. Use @RequireAction alone for create (POST) and delete (DELETE) routes — action permissions already embed scope requirements via ActionScopeRequirement, so adding @RequireScopes would be a redundant and potentially conflicting gate.
3. The Entity-Scope Permission Model¶
3.1 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
3.2 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.
3.3 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 |
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)
3.4 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.
3.5 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 (Section 7), not scope-level restrictions. The scope permissions apply once record-level access is granted.
3.6 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>>;
}
3.7 Runtime Enforcement¶
NestJS components enforce permissions on every request via two decorator families:
Decorator selection per route type¶
| Route type | Decorator | Why |
|---|---|---|
| GET (read) | @RequireScopes(entity, 'read') |
Entity gate: user needs READ on any scope |
| PATCH (update) | @RequireScopes(entity, 'write') |
Entity gate: user needs WRITE on any scope |
| POST (create) | @RequireAction(entity, 'create') |
Action gate: action embeds scope requirements (WRITE on all required scopes) |
| DELETE | @RequireAction(entity, 'delete') |
Action gate: action embeds scope requirements |
Why no @RequireScopes on create/delete? 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.
@RequireScopes(entity, action) — Decorator for read/update routes. No scope enumeration needed — the guard checks if the user has ANY scope at the required level on the entity. Applied to GET and PATCH handlers.
@Get(':id')
@RequireScopes('students', 'read')
async findOne(...) { ... }
@Patch(':id')
@RequireScopes('students', 'write')
async update(...) { ... }
@RequireAction(entity, action) — Decorator for create/delete routes. Applied to POST and DELETE handlers.
@Post()
@RequireAction('students', 'create')
async create(...) { ... }
@Delete(':id')
@RequireAction('students', 'delete')
async remove(...) { ... }
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) against user's WRITE scopes. Rejects with FORBIDDEN_FIELDS.
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.
Full request flow:
Request
-> JwtAuthGuard (validate JWT, attach AuthenticatedUser to request)
-> ScopeGuard (check @RequireScopes metadata — read/update routes only)
-> ActionGuard (check @RequireAction metadata — create/delete routes only)
-> FieldWriteGuard (reject writes with unauthorized scope groups -> 403 FORBIDDEN_FIELDS)
-> Controller (thin, delegates to service)
-> Service (business logic, tenantId filtering in every where clause)
-> FieldFilterInterceptor (strip unauthorized scope groups from response)
-> Response
Controller-level setup:
@Controller('students')
@ProtectedResource() // Applies JwtAuthGuard + ScopeGuard + ActionGuard + FieldWriteGuard + FieldFilterInterceptor + ApiBearerAuth
export class StudentsController { ... }
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 is always 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.
3.8 Temporal Permissions¶
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 Section 8).
4. 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).
Options explored¶
| Option | Approach | Pros | Cons |
|---|---|---|---|
| A | GET /api/v1/me endpoint |
Reuses existing CompiledPermissions; JWT stays small; call once on bootstrap |
One extra HTTP request; stale for up to 15min |
| B | Embed permissions in JWT | Zero extra requests | Token grows past 4KB; proxy/CDN header limits; stale for full token lifetime |
| C | Response-driven UI (no pre-fetch) | Zero complexity | Flash-then-collapse UX; can't distinguish read-only vs writable; can't pre-hide tabs |
| D | Hybrid: scope keys in JWT + detailed endpoint | Fast tab rendering; lazy field loading | Two sources of truth; JWT still grows with scopes |
SELECTED: Option A — Dedicated endpoint (now GET /api/v1/me)¶
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) -
Reuses the already-existing
CompiledPermissionstype fromPermissionsService - JWT stays stateless, small, and within header size limits
- Frontend calls both on app bootstrap, caches in context
- Refresh on token refresh (every 15min), ensuring permissions stay current within one access token lifetime
- Response shapes:
// 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.
5. Custom Role Definitions¶
The 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
- Assign users to custom roles
- Without breaking preset roles that are managed by the system
Options explored¶
| Option | Approach | Pros | Cons |
|---|---|---|---|
| A | Full permission matrix UI (entity x scope grid) | Maximum control; clear visual | Overwhelming with 8+ entities x 8+ scopes |
| B | Clone-and-modify from presets | 90% of custom roles are preset variations; pre-filled matrix | Still shows full matrix for modification |
| C | Step-by-step wizard | Guided for non-technical admins | Too many clicks for power users |
| D | Presets as immutable templates, custom roles copy | System updates propagate to presets safely | Must create custom role even for minor changes |
SELECTED: B + D hybrid — 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. |
Sub-decisions¶
Role deletion: Return 400 Bad Request with the list of affected users if any are still assigned. The admin must reassign those users to a different role 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, and custom roles have no entry for the new scope (which means 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.
6. Write Protection¶
Status: COMPLETE. Implemented as
FieldWriteGuard(seesrc/permissions/guards/field-write.guard.ts).
The problem¶
FieldFilterInterceptor only filters read responses. On writes, there is no enforcement:
- A user with
anagraphic.writebut NOTsensitive.writesubmits a PATCH with{ firstName: "Mario", disabilityInfo: "ADHD" } ScopeGuardchecks if user has ANY write scope on students — passes (hasanagraphic.write)- The DTO validates the shape — passes (DTO accepts all student fields)
- The service updates ALL submitted fields, including
disabilityInfo - The user has written to a scope they don't have write access to
Options explored¶
| Option | Approach | Pros | Cons |
|---|---|---|---|
| A | Write-filter interceptor (silent strip) | Mirrors read interceptor | Silent data loss; confusing for clients ("I sent disabilityInfo but it wasn't saved") |
| B | Write-validation pipe (403 on forbidden fields) | Explicit; actionable error; frontend knows what failed | Requires FE to only send allowed fields |
| C | Service-layer validation | Most explicit; easiest to test | Repetitive across services; easy to forget |
| D | Dynamic DTO generation | Type-safe at runtime | Extremely complex; class-validator is compile-time |
SELECTED: Option B — Write-validation guard (FieldWriteGuard)¶
A NestJS guard that runs after ScopeGuard and ActionGuard, and before the controller handler:
- Derive entity from
@RequireScopes()or@RequireAction()metadata (whichever is present) - Read
request.permissions.scopes[entity]to determine which scopes the user has WRITE access on - Compare 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: throw
ForbiddenFieldsExceptionwith error codeFORBIDDEN_FIELDSand a list of the offending scope keys
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 to the user
- Frontend can pre-disable form sections it knows are not writable (from GET /api/v1/permissions)
- Debugging is straightforward — no "mystery" data loss
Application: Applied via @ProtectedResource() composed decorator at controller level:
@Controller('students')
@ProtectedResource() // JwtAuthGuard + ScopeGuard + ActionGuard + FieldWriteGuard + FieldFilterInterceptor + ApiBearerAuth
export class StudentsController { ... }
7. Record-Level Access¶
The problem¶
All users within a tenant currently see all records. A teacher sees every student in the school, not just their classes. A parent sees all students, not just their children. The Entity-Scope model controls which fields are visible but not which records.
Options explored¶
| Option | Approach | Pros | Cons |
|---|---|---|---|
| A | Service-layer only | Simple; testable; explicit; incremental | No safety net; must implement per-service |
| B | PostgreSQL RLS only | Defense in depth; DB-level enforcement | Hard to debug; Prisma session variables not trivial |
| C | Both (defense in depth) | Recommended in architecture.md | Double maintenance; RLS + service must stay in sync |
| D | Ownership table (record_access) |
Flexible for any entity | Table grows large; must maintain on every assignment change |
SELECTED: Option A now, migrate to Option C in Phase 2¶
Service-layer filtering is explicit, testable, and can be implemented incrementally per entity.
Pattern: RecordAccessContext
Extend the current pattern where services receive tenantId to also receive a RecordAccessContext:
interface RecordAccessContext {
tenantId: string;
userId: string;
roles: string[]; // role keys from JWT
}
Filtering logic per role type:
| Role type | Filter strategy | Join/condition |
|---|---|---|
| Admin | All records in tenant | WHERE tenantId = ? |
| Teacher | Students in teacher's classes | JOIN class_students ON ... JOIN teacher_classes ON ... |
| Parent | Linked children only | JOIN student_parents ON student_parents.parentUserId = ? |
| Student | Own record only | WHERE student.userId = ? |
| Accountant | All records (financial fields only — Layer 2 handles field restriction) | WHERE tenantId = ? |
Implementation approach:
- Add a
RecordFilterServiceper entity (or a generic one with entity-specific strategies) - Services call
recordFilterService.applyFilter(query, context)to add WHERE conditions - The filter is additive — it narrows the result set, never expands it
- Unit tests verify each role type sees only their records
Phase 2 (deferred): Add PostgreSQL RLS policies as defense in depth once Prisma session variable support stabilizes. The service-layer filters remain as the primary enforcement; RLS acts as a safety net.
8. Permission Caching¶
The problem¶
PermissionsService.getUserPermissions() executes a nested Prisma query:
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. This is the #1 performance bottleneck in the permission stack.
Options explored¶
| Option | Approach | Pros | Cons |
|---|---|---|---|
| A | Redis cache with 5min TTL | Shared across instances; standard | Requires Redis (not yet connected) |
| B | In-memory Map per instance | Zero infrastructure | Not shared across instances; memory grows unbounded |
| C | Request-scoped memoization | Zero infra; zero staleness; simplest fix | Only helps within a single request |
| D | Hybrid: request-scoped now + Redis later | Immediate 50% query reduction; future-proof | Two implementation steps |
SELECTED: Option D — Request-scoped memoization now, Redis in Phase 2¶
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 (target — not yet implemented): Service-level caching with Redis
- 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
9. Custom Fields & Permissions¶
Implementation¶
The final implementation chose single JSONB column per entity (customFields Json?) with scope assignment via custom_field_definitions.scopeId. This differs from the S2+P1 design (JSONB-per-scope) described in the design rationale below — the single-column approach was selected for these reasons:
- Zero changes to the permission system — custom fields are nested inside scope groups as
customFields: { ... }. TheFieldFilterInterceptorstrips 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 for dynamic scopes — a single JSONB column supports both platform-defined and future admin-created scopes without schema migrations.
- Default "others" scope — every entity has an
othersscope as a catch-all for custom fields that don't fit existing categories.scopeKeydefaults tootherswhen not specified.
Storage¶
Single customFields Json? column per entity table (Student, Teacher, Staff). All custom field values stored flat regardless of scope — the custom_field_definitions table determines which scope each field belongs to.
Permission¶
When the admin creates a custom field definition, they assign it to an existing scope (or leave it on others). Fields inherit that scope's access controls. Permission checks happen at the service level (not decorators) because the target entity+scope varies per request.
Definition 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])
API Response Shape¶
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": "..."
}
Dynamic Scope Discovery¶
The toScopedResponse() mapper in BaseTenantedCrudService auto-discovers custom-field-only scopes (like others or future admin scopes) from definitions — zero service changes needed when a new scope is created.
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
Design Rationale¶
The following analysis was performed before implementation. The final choice (single JSONB + scope assignment, or S1+P1 variant) emerged during implementation as a refinement of the S2+P1 approach below.
Context¶
School admins need to define custom fields per entity (e.g., "blood_type" on students, "parking_spot" on teachers). These custom fields must integrate with the existing Entity-Scope permission model so that access control is enforced consistently.
Current state: All fields are fixed columns. The permission model is fully working — FieldFilterInterceptor strips response keys not in the user's allowedFields set.
Key constraint: The interceptor (src/permissions/interceptors/field-filter.interceptor.ts:83-93) works at the top-level key level — it iterates Object.keys(response) and keeps only keys in the allowed set. It has no concept of filtering within a nested object or JSONB blob.
Two Dimensions to Decide¶
1. Storage: How are custom field values stored in the database? 2. Permission integration: How do custom fields map to scopes?
These dimensions interact — some storage choices make certain permission approaches trivial while making others painful.
Storage Approaches¶
S1: Single JSONB column per entity¶
Add one custom_fields JSONB column to each entity table.
| Pro | Simple schema, one column, easy to add/remove fields |
| Pro | GIN-indexable for queries (WHERE custom_fields->>'blood_type' = 'A+') |
| Con | The interceptor sees ONE key (customFields) — it either includes the whole blob or strips it entirely. To filter individual keys within the JSONB, the interceptor must be enhanced to "deep-filter" |
| Con | If two custom fields belong to different scopes, you can't split them at the column level |
S2: One JSONB column per scope per entity¶
Add a JSONB column for each scope that can hold custom fields.
students.custom_anagraphic = { "nickname": "Gigi" }
students.custom_sensitive = { "blood_type": "A+", "baptism_date": "2015-03-21" }
| Pro | Permission filtering works TODAY with zero interceptor changes — customAnagraphic is just another field mapped to the anagraphic scope, customSensitive to sensitive |
| Pro | Clean separation — each JSONB column's contents inherit its scope's permissions as a unit |
| Con | Moving a custom field between scopes = moving data between JSONB columns (data migration per tenant) |
| Con | Adding new scopes to an entity requires a DB migration to add the JSONB column |
| Con | Multiple JSONB columns per table (one per scope that allows custom fields) |
S3: EAV (Entity-Attribute-Value) table¶
custom_field_values (
id, tenant_id, entity_type, entity_id,
field_definition_id, value_text, value_number, value_date, value_boolean
)
| Pro | Maximum flexibility — any field, any type, per-tenant |
| Pro | Permission can be per-field (each field_definition can reference a scope) |
| Con | Slow queries (pivot joins to reconstruct an entity), poor developer ergonomics |
| Con | No type safety, complex pagination/sorting/filtering |
| Con | Response assembly requires a completely separate code path from Prisma models |
| Con | Doesn't work with the interceptor at all — needs a custom response builder |
S4: JSONB column + "virtual scope columns" in the response¶
Hybrid: store in a single JSONB column (S1), but when building the response, the service unpacks custom fields into virtual top-level keys (e.g., cf_blood_type, cf_shoe_size) so the interceptor can filter them individually.
| Pro | Single JSONB column (simple storage) but per-field filtering at the interceptor level |
| Pro | No interceptor changes — field names are top-level keys |
| Con | Services must unpack/repack JSONB on every read and write |
| Con | Key collision risk (cf_ prefix mitigates but adds noise) |
| Con | Prisma model doesn't match response shape — manual transformation always needed |
Permission Integration Approaches¶
P1: Assign to existing scope on creation¶
When the admin creates a custom field, they pick an existing scope (e.g., "sensitive"). The field's values are governed by that scope's permissions — whatever roles have READ or WRITE access on "sensitive" can see this custom field.
| Pro | Simplest mental model — "blood type is sensitive, put it there" |
| Pro | Zero changes to the permission tables — scope_field_mappings gets a new row |
| Pro | Existing role permissions automatically apply |
| Con | Coarse granularity within a scope — you can't give the nurse "blood_type" without also giving them "disability_info" (they're both in "sensitive") |
| Con | Admin must understand what scopes are (training) |
| Best with | S2 (JSONB-per-scope) or S4 (virtual columns). With S1, we need interceptor changes. |
P2: Auto-create one scope per custom field¶
Each custom field gets its own scope automatically. Creating "blood_type" on students creates scope students.cf_blood_type and the admin must assign it to roles.
| Pro | Maximum granularity — per-field control |
| Con | Scope explosion (10 custom fields = 10 new scopes x N roles to configure) |
| Con | Directly contradicts the design philosophy: "A school admin should not need to manage 200+ individual field permissions" |
| Con | Terrible admin UX — creating a field triggers a cascade of role-permission assignments |
| Best with | Any storage, but UX is bad regardless. Not recommended. |
P3: One "custom" scope per entity¶
Every entity gets a single automatic scope called custom. All custom fields for that entity live in this scope.
students.anagraphic -> firstName, lastName, ...
students.sensitive -> disabilityInfo, ...
students.custom -> ALL custom fields (blood_type, shoe_size, ...)
Roles get a single toggle: ScopeAccess on students.custom (NONE, READ, or WRITE).
| Pro | Simplest admin UX — one toggle per entity |
| Pro | Simple to implement — one new scope per entity, one JSONB column |
| Con | All-or-nothing — can't separate custom fields with different sensitivity levels |
| Con | A school that adds both "nickname" (harmless) and "blood_type" (sensitive) can't differentiate access |
| Best with | S1 (single JSONB). Conceptually clean: one column, one scope. |
P4: Tenant-level custom scopes¶
Allow tenants to create their own scopes (currently scopes are system-level only). Admin creates scope "medical", assigns custom fields to it, then grants roles access.
| Pro | Full flexibility — tenants define their own business groupings |
| Pro | Aligns with the scope philosophy ("meaningful business groupings") |
| Con | permission_scopes is currently global (no tenant_id). Making it tenant-aware requires schema changes + permission compilation changes |
| Con | System-level scopes vs tenant-level scopes creates a split (which scopes does the admin UI show? both?) |
| Con | More complex admin UI — scope creation + field assignment + role assignment |
| Best with | S1 (single JSONB) + interceptor enhancement, or S4 (virtual columns). |
P5: Separate permission layer for custom fields¶
Custom fields get their own permission table: custom_field_permissions(role_id, field_definition_id, access ScopeAccess). The interceptor is enhanced to check both scope permissions (for system fields) and custom field permissions (for custom fields).
| Pro | Maximum flexibility without touching the scope system |
| Pro | Clean separation — scopes for system fields, per-field for custom |
| Con | Two parallel permission systems = double complexity |
| Con | Inconsistent mental model for admins |
| Con | Interceptor must merge two permission sources |
| Best with | S1 or S3. Not recommended — complexity not justified. |
Combined Approach Matrix¶
| Combo | Storage | Permission | Interceptor changes | Schema changes | Admin UX | Granularity |
|---|---|---|---|---|---|---|
| S2+P1 | JSONB per scope | Assign to existing scope | None | Add JSONB cols per scope | Pick a scope | Per-scope |
| S1+P3 | Single JSONB | One "custom" scope | None | Add 1 JSONB col | None needed | All-or-nothing |
| S1+P1 | Single JSONB | Assign to existing scope | Deep-filter JSONB | Add 1 JSONB col | Pick a scope | Per-scope |
| S4+P1 | JSONB + virtual keys | Assign to existing scope | None | Add 1 JSONB col | Pick a scope | Per-field |
| S1+P4 | Single JSONB | Tenant custom scopes | Deep-filter JSONB | Scope table changes | Create scopes + assign | Custom groups |
| S3+P1 | EAV | Assign to existing scope | Custom builder | New table | Pick a scope | Per-scope |
Design-Time Selection: S2+P1 (JSONB-per-scope + assign to existing scope)¶
Note: The actual implementation used a single JSONB column (S1) with scope assignment (P1) — a variant of S1+P1 where custom fields are nested within scope groups in the response, avoiding the deep-filter concern. See "Implementation" section above for details.
Why S2+P1 was favored during design:
-
Zero changes to the permission system —
PermissionsService,ScopeGuard,FieldFilterInterceptorall work as-is. A JSONB column namedcustomSensitiveis just another field name mapped to thesensitivescope inscope_field_mappings. The interceptor includes or excludes it like any other key. -
Aligns with the existing design philosophy — scopes remain meaningful business groupings. Custom fields inherit the semantics of their 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. The admin already understands scopes from managing roles.
-
Data isolation is structural — sensitive custom data literally lives in a different column (
customSensitive) than non-sensitive custom data (customAnagraphic). No risk of a bug leaking sensitive custom fields through the wrong scope. -
Reasonable tradeoffs:
- The JSONB columns need to be added per scope per entity — but in practice there are 2-4 scopes per entity that support custom fields, so this is ~2-4 columns. Manageable.
- Moving a field between scopes requires a data migration — but this is an uncommon operation and can be an admin tool action.
- You can't have per-custom-field granularity within a scope — but this matches the scope philosophy. If a school truly needs a separate access group, they're describing a new scope, which can be addressed later with P4 as an enhancement.
What S2+P1 implementation would need¶
New table: custom_field_definitions
id, tenant_id, entity_key, scope_id, field_key, label,
field_type (text/number/date/boolean/select),
options (JSONB, for select-type fields),
sort_order, is_required, created_at, updated_at
@@unique([tenant_id, entity_key, field_key])
Schema changes per entity (migration):
model Student {
// ... existing fixed columns ...
customAnagraphic Json @default("{}") @map("custom_anagraphic")
customSensitive Json @default("{}") @map("custom_sensitive")
}
Seed changes: Add scope_field_mappings entries mapping customAnagraphic to the anagraphic scope and customSensitive to the sensitive scope.
Service changes: When creating/updating a student, validate custom field values against custom_field_definitions for that tenant+entity. When reading, the custom JSONB columns come back as response keys and the interceptor handles them like any other field.
No permission system changes. No interceptor changes.
Integration with permissions endpoint: Custom field definitions must be included in the permissions response so the frontend knows which custom fields exist per entity and per scope. Add a customFieldDefinitions key to GET /api/v1/permissions:
{
"students": {
"scopes": { "anagraphic": "WRITE", "sensitive": "WRITE" },
"actions": { "create": true },
"customFieldDefinitions": [
{ "key": "blood_type", "label": "Blood Type", "scope": "sensitive", "type": "text" },
{ "key": "nickname", "label": "Nickname", "scope": "anagraphic", "type": "text" }
]
}
}
Future upgrade path¶
If per-custom-field granularity is eventually needed, S2+P1 can be upgraded to S2+P4 (tenant-level scopes) without data migration — the JSONB storage stays the same, but tenant-created scopes get their own JSONB columns. Alternatively, the interceptor can be enhanced to deep-filter within JSONB columns (moving toward S1+P4) as a v2.
FAQ¶
Should scopes be tenant-creatable? Not now. System-level scopes cover 90%+ of cases. Revisit when a real tenant asks for a scope that doesn't map to existing ones.
What if admin wants to move a custom field between scopes? Data migration: move the key+value from one JSONB column to another for all records of that entity in that tenant. Update custom_field_definitions.scope_id. This is an uncommon operation — acceptable to require a specific admin tool action.
Custom field types? Defined in custom_field_definitions.field_type. Supported: text, number, date, boolean, select (with options JSONB for dropdown values). Validated at the service layer on write, stored as JSONB values.
Different tenants, different custom fields? Yes — custom_field_definitions is tenant-scoped. School A can have "blood_type" on students while School B doesn't. The JSONB columns exist on the table for all tenants (empty {} by default), but only tenants that define the field will have values.
Filtering within JSONB? Not needed with S2. Each JSONB column is a single response key, included or excluded as a unit by the interceptor. The contents are all within the same scope by design.
10. Frontend UI Patterns¶
Concrete guidance for the frontend team on how to consume 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