Skip to content

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:

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

Hierarchy: 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:

rbac-er

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. The FieldFilterInterceptor compares response object keys against this set, so a mismatch means fields get stripped.

roles

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

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

Constraint: UNIQUE(tenant_id, key)

role_permissions

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

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

Constraint: UNIQUE(role_id, scope_id)

user_roles

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

Column Type Description
id UUID PK
user_id UUID FK -> users
role_id UUID FK -> roles
tenant_id UUID FK -> tenants
valid_from DateTime When the role assignment becomes active (default: now)
valid_until DateTime? When the role assignment expires (null = permanent)
assigned_by UUID? FK -> users Who assigned this role
created_at DateTime

Constraint: UNIQUE(user_id, role_id, tenant_id)

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:

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

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:

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

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

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

Why this is secure: Scope-group enforcement 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

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

Deferred: BullMQ scheduled job for cache invalidation at role expiry (relevant once Redis caching is implemented — see 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 — returns AuthUserDto { user: UserProfileDto, accessTokenExpiresAt } (user profile + session metadata)
  • GET /api/v1/permissions — returns entity-grouped PermissionsResponseDto where each key is an entity with { scopes, actions } (compiled permissions)

  • Reuses the already-existing CompiledPermissions type from PermissionsService

  • 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_permissions without conflicting with tenant customizations.
  • Admin workflow: Click "Create Custom Role" -> pick the closest preset as a base -> see pre-filled permission matrix -> toggle individual scopes -> save.
  • New scope additions: When a deployment adds new scopes, preset roles auto-update. Custom roles show a "new scope available" notification in the admin UI.

Backend API design

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

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 (see src/permissions/guards/field-write.guard.ts).

The problem

FieldFilterInterceptor only filters read responses. On writes, there is no enforcement:

  1. A user with anagraphic.write but NOT sensitive.write submits a PATCH with { firstName: "Mario", disabilityInfo: "ADHD" }
  2. ScopeGuard checks if user has ANY write scope on students — passes (has anagraphic.write)
  3. The DTO validates the shape — passes (DTO accepts all student fields)
  4. The service updates ALL submitted fields, including disabilityInfo
  5. 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:

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

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

Why explicit 403 over silent stripping: The frontend should know exactly why a write was rejected. This enables: - Clear error messages 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 RecordFilterService per 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:

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

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

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

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

Phase 2 (target — not yet implemented): Service-level caching with Redis

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

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:

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

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 (with EnsurePermissionsGuard)
  • Types: src/custom-fields/interfaces/definition-with-scope.type.ts
  • Barrel: src/custom-fields/index.ts (exports CustomFieldsModule, CustomFieldsService, CustomFieldResponseDto, DefinitionsByScope)
  • forwardRef() used between PermissionsModuleCustomFieldsModule for circular dependency

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.

students.custom_fields = { "blood_type": "A+", "shoe_size": "42", "baptism_date": "2015-03-21" }
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:

  1. Zero changes to the permission systemPermissionsService, ScopeGuard, FieldFilterInterceptor all work as-is. A JSONB column named customSensitive is just another field name mapped to the sensitive scope in scope_field_mappings. The interceptor includes or excludes it like any other key.

  2. 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.

  3. 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.

  4. 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.

  5. Reasonable tradeoffs:

  6. 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.
  7. Moving a field between scopes requires a data migration — but this is an uncommon operation and can be an admin tool action.
  8. 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 PermissionProvider and fetches permissions once
  • MFEs receive permissions via shared React Context or an event bus
  • Each MFE does NOT independently fetch /me — this would duplicate requests
  • MFEs can import shared permission hooks from a common package