Skip to content

Permissions (RBAC)

Mental model — four layers of access control

Authorization has four independent concerns: authenticated identity, readable/writable fields, allowed operations, and accessible records. Passing one layer does not satisfy the others. Tenant isolation is an additional database boundary, described in multitenancy.

Concern Authority
Identity and active session JWT validation and authentication service
Field access Entity scopes, ScopeGuard, FieldWriteGuard, FieldFilterInterceptor
Operations Action grants and their scope requirements
Records Domain read/write policies and service-level authority checks

A scope is a logical group of fields or a named gate. Access is NONE, READ, or WRITE; WRITE implies READ. Actions represent operations such as create, delete, publish, send or correct. Ordinary read/update behavior uses scopes; do not invent redundant read or update actions.

Catalogue and grant ownership

The authorities are:

  • prisma/seed/rbac-catalogue.ts: entities, scopes, actions, action requirements and field mappings.
  • src/common/constants/entity-keys.ts and scope-fields.ts: runtime keys, field sets, flat-DTO entities and scope-name registry.
  • prisma/seed/roles.ts: preset grants and profile-role baselines.
  • prisma/seed/ and tools/reconcile-preset-roles.ts: expected preset grants and deployment convergence.
  • src/common/constants/management-role-keys.ts: named functional role groups.
  • src/common/i18n/: localized catalogue labels and descriptions.

Do not maintain a second exhaustive permission matrix in prose. Read these definitions and the relevant domain policy together when deciding whether a role can act.

Global permission entities/scopes/fields/actions define the vocabulary. Tenant Role rows own scope/action grants; UserRole rows assign them, optionally with validity windows and UserRoleParameter values. Global preset roles have tenantId = null and are cloned into tenants. A global role is never assigned directly to a school user.

Deployment runs migration, seed prerequisites and db:reconcile-rbac. The reconciler transactionally adds, updates and prunes recognized global and tenant presets to the code-defined matrix and checks the result. Custom roles and assignments are outside that convergence boundary. See seeding.

Public catalogue surfaces

Route Contract
GET /permissions Authenticated caller's active-session grants; every catalogue entity appears, even as an empty scope map and all-false action map
GET /roles Admin role catalogue, grouped by domain; every scope/action appears with explicit NONE/false where absent
GET /roles/presets Platform-admin global preset catalogue
PATCH /roles/presets/:key Platform-admin sparse grant edits; admin and profile-coupled roles are frozen
GET/POST /role-assignments, PATCH/DELETE /role-assignments/:id Admin management-role assignment

There is no tenant-facing permission-matrix write route or custom-role creation/deletion API. Database support for custom roles is not evidence of such a public surface. Platform preset edits affect those stored presets immediately, but deployment reconciliation restores the code-defined baseline; durable preset changes belong in code.

The four profile-coupled roles (teacher, staff, referent, student) are bound through onboarding and excluded from the editor-managed role catalogue and assignment surface. The admin role remains listed. Catalogue labels are bilingual; stored custom-role labels are the fallback. Domain grouping is presentation metadata, not another authorization layer.

Active-session narrowing

activeProfile identifies the selected person profile. activeRole optionally narrows an employee session to one role. resolveSessionRoles in src/common/utils/narrow-roles.ts defines issuance:

  • A non-null active role produces exactly that role key.
  • An employee combined view carries its profile baseline and held employee roles.
  • A referent/student view carries that profile key only.

A Teacher-backed administrator in Admin view can have roles: ['admin']. Use activeProfile or the person binding for identity questions; do not infer the person type from the presence of teacher in the role array. School membership classification belongs to User.profileType, independently of role assignments. See authentication.

Permission compilation and parameter resolution filter assignments to active session keys. A role outside the view must not lend grants or parameter values to it. Some read policies deliberately retain the user's personal Teacher relationship inside a management view; this is an explicit policy rule, not a reason to restore every role to the session.

Route protection

@ProtectedResource() composes JWT, scope, action, field-write and role guards, followed by field-filter response projection. The global tenant transaction interceptor wraps the handler after guards run.

Route shape Gate
Scope-grouped entity GET @RequireScopes(entity, 'read')
Scope-grouped entity PATCH @RequireScopes(entity, 'write')
Create/delete or another named command @RequireAction(entity, action)
Specific sub-resource @RequireScope(entity, scopeKey, mode)
Domain record admission @AppliesPolicy(policy) plus the service's policy predicate
Tighter role-only admission @RequireRoles(...keys)

Plural RequireScopes passes on any scope at the required level; field guards/projection enforce individual groups. Singular RequireScope requires the named scope. Never combine singular and plural decorators on one handler.

Action compilation already checks ActionScopeRequirement rows. Do not add a redundant plural scope gate to an action route. A specific singular gate may accompany an action where it represents an independent sub-resource requirement. A create action does not imply WRITE on every entity scope: inspect its declared requirements, and retain body-field checks.

RolesGuard uses OR across the declared role list. Repeating decorators does not express an AND requirement: metadata can overwrite. Prefer AppliesPolicy so admission and row-policy declarations stay aligned. AppliesPolicyDimensions supplies dimension metadata without replacing an explicitly tighter role list; it requires a role gate alongside it.

Parametric admission also accepts overlap between the policy's dimensions and loaded caller dimensions. Those dimensions are not in the JWT. A scope/action guard or explicit EnsurePermissionsGuard must load them first.

Platform administrators bypass permission guards/projection, but ordinary tenant requests remain within their selected tenant. Tenant admins are ordinary grant holders at the guard layer; do not assume they hold every scope. In-service admin exceptions use isPrivileged(ctx), not repeated role literals, and only where the domain contract permits that exception.

Response and write contracts

Scope-grouped responses

CRUD DTOs group fields under scope names. FieldWriteGuard rejects unauthorized top-level scope groups and system-managed fields; FieldFilterInterceptor removes unreadable groups while preserving allowed envelope fields. Field mappings use Prisma camelCase names, not database snake_case names.

Nested enrichment is not automatically recursively authorized. When adding labels, linked people or private data inside a readable group, enforce their own visibility before mapping them.

Aggregate responses

Use both @AggregateResponse() and a response class extending AggregateResponseDto for an aggregate/sub-resource response. This bypasses scope-group stripping, so every admitted caller must already be authorized for every returned field.

Top-level aggregate keys must not collide with the entity's registered scope names. The interceptor checks this at runtime, throwing outside production and logging in production. The boot scanner cannot recover the generic return type of an async Promise<Dto>; do not rely on it as complete coverage.

Adding the marker, or adding fields to an aggregate response, is an authorization change that requires checking its admission and record fences. Import create permission alone does not establish permission for arbitrary returned data.

Flat DTOs and gate-only scopes

Flat entities belong in FLAT_DTO_ENTITIES. This skips scope-group parsing of flat bodies/responses, while route and record gates remain active. An entity is not flat merely because it has one scope: School and Rooms use nested configuration groups.

A gate-only scope has an empty runtime field array and no field mappings. It grants access to a conceptual sub-resource, such as curriculum selection or mailing groups. Before naming a scope, inspect aggregate DTO keys: a generic name such as groups can collide with existing response fields.

The descriptor scopes for Budget and Purchases are consumed by external applications. In particular, purchases.management grants application admission only; RDA owns its operation and record authority. Do not derive approval power from SIS role names.

Boot-time invariants

main.ts invokes the scanners in src/common/dto/:

  • assertAggregateResponseInvariant: aggregate DTO/decorator alignment, subject to runtime type metadata limits.
  • assertPolicyScopeAlignment: policy entity agrees with scope metadata.
  • assertScopeDecoratorExclusivity: singular and plural scope decorators do not coexist.

Their production logging behavior does not replace review of the authorization contract.

Record-level access

EntityAccessPolicy — single source of truth

src/common/utils/entity-access-policy.ts supplies two policy forms:

  • definePolicy: a pure where(ctx) predicate.
  • defineResolvedPolicy: asynchronous resolve(evaluation, ctx) for relationship-dependent authority.

Both expose the same entity, roles and parametric-dimension admission metadata. resolvePolicyWhere accepts either form. StudentsReadPolicy is a resolved policy; do not copy old synchronous StudentsReadPolicy.where(ctx) examples.

Resolution starts with a tenant base, short-circuits an authorized broad branch, otherwise ORs contributing role and parameter branches. No contribution returns NEVER_MATCH_WHERE. Each contribution must preserve the base. Caller filters, academic-year conditions and detail IDs compose with the resulting predicate under AND; spreading two objects with id or OR keys can erase a fence.

For detail reads, resolve the ID together with the policy and return the domain's hidden 404 for inaccessible rows. A route decorator admits a request; it does not apply its row predicate automatically.

Resolved policies and owner queries

PolicyEvaluation carries the actual client, tenant, captured school date, year mode, immutable context snapshots and optional candidate IDs. Its memoization is operation-local. Do not retain it on a singleton service, across requests, after transaction completion, or across an authorization-relevant write.

forYear creates a sibling with separate year-bound memo entries. memoShared is only for facts independent of year, bounds and roles, with all other inputs represented in the key. Failed loads remain failures. Joining an ambient transaction does not imply repeatable-read isolation.

Resolve a complete authorization graph at its owner and consume typed membership; never nest that graph under another model's relation filter/include/count. Membership distinguishes no rows, all rows in the evaluation domain, and finite IDs. Empty IDs never mean unrestricted access, and provenance prevents reuse in another context or bound.

Canonical examples:

Concern Source
Student personal and managed reach src/students/students.policy.ts, students.policy.queries.ts
Evaluation and membership semantics src/common/utils/policy-evaluation.ts
Pure/resolved composition src/common/utils/entity-access-policy.ts
CRUD tenant predicate after resolution src/common/services/base-tenanted-crud.service.ts
Guard metadata src/common/decorators/applies-policy.decorator.ts
Composition drift checks src/common/utils/policy-query-composition.drift.spec.ts

Do not truncate authorized IDs, concatenate separately paginated chunks, or increase timeouts to hide an expensive graph. Measure representative queries with the application role and tenant GUC before changing the query representation.

Qualified authority

PermissionsService.resolveAuthorizedRoleSlice(ctx, requirement) keeps only active-session assignments that independently satisfy the requested scope level and optional action, together with their own parameter values. Use that qualified context for selection, placement, attendance and other sensitive write fences.

A READ-only department assignment cannot lend its department IDs to another role's WRITE grant. If a command needs two capabilities, qualify each from the original context and enforce both target sets. An aggregate canWrite flag is an affordance; the mutation must recheck the actual target.

School dates for dated policies come from the shared school clock. A policy that requires schoolToday must fail closed when it is absent; mutable forward placement/selection columns cannot substitute for effective history.

Domain distinctions to preserve

  • Student, Homeroom and SubjectGroup read policies may union personal Teacher reach with managed containment. Their write policies do not inherit that personal read limb.
  • Teacher directory, Teacher documents, person mutations and self-service have different policies. Access to names does not grant document access.
  • Communications unions the active profile audience with independently qualified management reach. It retains profile-derived sender identity and Reply-To. See communications.
  • Attendance read admission, actual occurrence authority and family justification are separate. A management justify grant does not bypass the referent-only family fence.
  • SEN is an explicitly qualified read role, not an admin bypass. Request loading removes expired/revoked SEN from stale session keys, and route qualification requires SEN's own grant before lending its broad record reach.
  • Assistance establishes a dated course relationship; actual attendance additionally requires the assisted occurrence.
  • Filter options represent authorized browsing/work contexts. E ∩ (S ∪ P) combines existing eligible structure with independent authority and projected visible rows; option presence grants no row or write authority. See class organization.
  • Reports add owner privacy and per-source-field authorization to their admin/grant admission. Even another admin cannot read a private run. See reports.
  • Report cards separate value writing (report_cards.entries WRITE) from the generate and correct actions. Only a correct holder writes or regenerates a published card, so a role that manages a cycle can still be refused on its published cards. Each requirement is qualified on its own: cycle commands and publication reach by department parameters, values and generation by department or curriculum parameters, and a teacher-only entries READ slice sees only the cards it holds a responsibility on. The SEN Manager preset adds READ on report_card_templates.configuration and report_cards.cycles beyond its teacher-derived grants (SEN_EXTRA_READ_SCOPE_KEYS in prisma/seed/roles.ts). See report cards.

Self-service (/me) surfaces

Self-service is identity-authorized, with its own field contract. It does not grant access to colleagues.

GET /auth/profile returns one full active-profile snapshot plus canWrite. Teacher/Staff PATCH uses SELF_EDITABLE_SCOPES union the active session's WRITE scopes, enforced by assertBodyScopesSelfEditable. Login email is refused; identification codes, file FKs and unsupported custom-field inputs remain excluded. Referent self-update uses PATCH /referents/:id with its own ID, grants and service fences. Students have no self-write route.

Own document routes resolve the person from the authenticated identity and use a self-scoped document configuration; they do not require a directory policy to admit the own row. Tenant checks and the service's ownership assertion still apply.

src/profile/self-write-map.ts projects enforcement into booleans. A missing key is not a form input; false is read-only. This map does not create grants. Refresh it after changing session view.

Temporal role assignments

Management grants anchor on a Teacher/Staff person via recipientType and recipientId. A null userId is a pending preassignment; invitation acceptance materializes the same row. Validity uses validFrom <= now and an absent or strictly future validUntil.

The assignment API validates parameter IDs, rejects profile-coupled roles, and protects the last materialized active administrator. Hard-deleting a person cleans up anchored soft references through the owning lifecycle; an email correction retains the management assignments.

Responses contain localized role labels, nullable recipient/account/assigner references, validity/status and non-recursive related-role summaries. An unresolved recipient is not proof that the grant is inactive. Bootstrap-only employee placeholders are hidden by the assignment surface without changing grant compilation or last-admin protection.

Permission caching

ensurePermissionsLoaded memoizes compiled permissions on the request. src/permissions/request-assignments.ts shares one active-assignment load in CLS for the authenticated user and tenant; consumers filter it in memory for their session keys and qualified requirements. Other-user queries, jobs and calls without request CLS load independently. Session/catalogue surfaces retain their own fresh reads.

There is no cross-request Redis permission cache. Grant changes are observed by subsequent permission compilation; newly assigned role keys require session refresh/selection to appear in JWT claims. Do not describe all changes as having one fixed delay.

Scope fields and custom fields

scope-fields.ts is the field authority shared by seed mappings and response services. If a scope uses a function mapper, update that mapper as well as the field constant. Private person data belongs to private_data; it must not leak through identity or nested summaries. One exception: a student's dateOfBirth is in students.identity, so every reader of student identity — the teacher's taught set included — sees the student's age. Teachers, staff and referents keep dateOfBirth in private_data.

Custom values are stored in JSONB and assigned to scopes by definitions. The definition service validates types, required fields and unknown keys; deleting a definition removes its values transactionally. Read mapping surfaces defined but unset values as null. missingFields follows the completion registry and required definitions.

The platform's CUSTOM_FIELDS_HIDDEN flag suppresses permission-response definitions, and preset grants exclude custom-field management/others. Retained storage and mapper support do not mean the feature is publicly enabled. Definition routes require their own scope/action gate and target-entity scope authority.

Extending permissions

  1. Check the design gate before introducing a permission primitive.
  2. Add the entity/scope/action to runtime constants and the seed catalogue; define action scope requirements explicitly.
  3. Add field mappings or an empty gate-only field set as appropriate; choose the correct flat/grouped DTO shape.
  4. Update preset definitions and their expected-grant mirror, including explicit exclusions and inclusion-only profile baselines.
  5. Supply localized labels and entity grouping.
  6. Apply route admission and the matching read/write policy at every query or command boundary.
  7. Cover non-platform sessions, denied rows, qualified parameter slices and response privacy in the relevant specs; execute checks only when authorized.

For frontend discovery, render from the content of GET /permissions, not entity-key presence or hard-coded role names. Scope WRITE and action grants are separate inputs; record-level capabilities and domain errors still govern the selected row. After a view/tenant switch, reload the corresponding session and permission state.