type
Reference
title
RBAC — catalogue, roles, scope
description
The permission catalogue (22 resources × read/write/delete), the four built-in roles, scope semantics (all/assigned/own), enforcement and the two caches.
tags
rbac, permissions, roles, scope, access
status
stable
stale_after
Fri Apr 30 2027 02:00:00 GMT+0200 (Central European Summer Time)
generated
techwriter/mimo-v2.6-flash · Sat Oct 31 2026 11:02:38 GMT+0100 (Central European Standard Time)
sources
catalogue → internal/domain/rbac/catalogue.go; seed → internal/domain/rbac/seed.go; role → internal/domain/rbac/role.go; accesssvc → internal/application/access/service.go; rbacsvc → internal/application/rbac/service.go; authorize → internal/interfaces/http/authorize.go; scope → internal/interfaces/http/scope.go; drift → internal/domain/rbac/drift_test.go

RBAC

One permission model decides everything: which routes a request may reach and which records it may see. It is defined in code (internal/domain/rbac), persisted as JSON on each roles row, and enforced at one choke point per request.

flowchart TD route["routes_*.go<br/>h.Require(resource, action)"] tmpl["template func perm(roleID, resource, action)<br/>(show/hide, never grants)"] can["Access.Can(userID, resource, action)<br/>→ Decision{Allowed, Scope} — fails closed"] pcache["per-user permission cache<br/>userID → {roleCode, permissions}<br/>absolute TTL 30s · single-flight"] authz["AuthorizationService<br/>CanForUser · EffectiveScope · resolve by id/code"] rcache["role cache (role ID → Role)<br/>cleared whole on any role mutation"] store[("roles table<br/>permissions = JSON")] inv["InvalidateUser / InvalidateRole<br/>on user create/edit/disable, password change, role save"] record["record-level filters (scope.go)<br/>scopedStudentIDs · canAccessStudent ·<br/>accessibleClassIDs · accessibleGradeIDs"] route --> can --> pcache pcache -->|"miss"| authz authz --> rcache rcache --> store tmpl --> authz inv --> pcache can -->|"Allowed + Scope"| route route -->|"allowed"| record

The catalogue

rbac.Catalogue() is the single source of truth for what a permission is: the roles matrix renders from it, the roles save validates against it, and the drift test (internal/domain/rbac/drift_test.go) proves it matches the route gates one-for-one — a hand-written, literal list of what the routes_*.go files actually gate, asserted bidirectionally against both the catalogue and the Resource* constants. There is deliberately no second list anywhere: no SQL copy, no template copy.

22 resources; R/W/D below are the catalogue's own flags, Scope marks the resources where record-level scope applies:

Key En label R W D Scope
academic_years Academic Years ✓ ✓ ✓
attendance Attendance ✓ ✓ ✓
audit Audit Log ✓
buses Buses ✓ ✓ ✓
cash_boxes Cash Boxes ✓ ✓ ✓
classes Classes ✓ ✓ ✓ ✓
enrollments Enrollments ✓ ✓ ✓
grades Grades ✓ ✓ ✓
guardians Guardians ✓ ✓
ledger Ledger ✓
payments Payments ✓ ✓ ✓ ✓
reports Reports ✓
roles Roles ✓ ✓
salaries Salaries ✓ ✓ ✓
settings Settings ✓ ✓
student_fees Student Fees ✓ ✓ ✓
students Students ✓ ✓ ✓ ✓
subject_marks Subject Marks ✓ ✓ ✓
subjects Subjects ✓ ✓ ✓ ✓
teachers Teachers ✓ ✓ ✓
transfers Transfers ✓ ✓ ✓
users Users ✓ ✓ ✓

subject_marks has no read flag because no route gates a marks read: the class details page embeds them behind classes read. The catalogue mirrors what routes gate, not what pages show.

The four built-in roles

Seeded at boot from Go (rbac.DefaultRoles() handed to RoleProvisioner.SyncDefaults); codes are load-bearing (the dashboard dispatches on them, the admin is attached to manager), so built-ins are never deleted and their codes cannot be reused. SyncDefaults inserts missing roles and re-wildcards a tampered manager, but never overwrites an owner's edit.

Role Grants
manager "*": read+write+delete, scope all — the wildcard; Role.Has prefers an explicit entry, so a custom row narrows a specific resource without losing the rest
accountant write: payments, salaries, cash_boxes; read: student_fees, enrollments, students, academic_years, grades, classes, attendance, subject_marks, reports, transfers, buses, plus ledger read; no settings/audit/users/roles/guardians/teachers/subjects
teacher students read+write, classes/subjects read, attendance/subject_marks read+write — all at scope assigned; transfers present but denied (visible in the matrix so it can be switched on per role)
parent students, payments, attendance, subject_marks read at scope own

Scope semantics

A permission carries a scope, returned by Can and enforced by the host's handlers, never by the access layer:

Scope Means Resolved as
all every record no restriction
assigned the user's working set teacher: classes where classes.teacher_id = userID, then their rosters/grades
own the user's own subjects parent: guardian_links rows for parent_user_id = userID

EffectiveScope(role, resource) falls back to the "*" permission and finally to all. internal/interfaces/http/scope.go turns it into record sets: scopedStudentIDs (own → guardian links; assigned → union of taught-class rosters), canAccessStudent, accessibleClassIDs, accessibleGradeIDs, and list filters apply the set in SQL before pagination so page totals stay correct (PaymentFilter.StudentIDs). An empty non-nil set means "scoped but owns nothing" and must render nothing — the parent/teacher e2e phases pin this behaviour.

Where enforcement happens

  1. Route gate. h.Require(resource, action) on every protected route (see screens for each route's gate) → access.Can → 403 on deny or internal error.
  2. Record level. Handlers call the scope helpers above; list queries filter, detail pages deny.
  3. Templates. perm() shows/hides UI but grants nothing — it asks the same AuthorizationService.

Caches and invalidation

Two caches, both process-local, both with invalidation as the correctness mechanism:

Cache Key Lifetime Invalidated by
permission set (application/access) user ID (carries role code + full permission map, so one lookup serves every gate) absolute 30 s TTL — reads never refresh it; single-flight on miss InvalidateUser on user create/edit/disable and password change (users, teachers, students/parent, auth handlers); InvalidateRole on role save
roles (application/rbac) role ID until any role mutation Invalidate clears the whole map — deleting only the changed keys would leave RoleCodeByID stale forever

Staleness across replicas is accepted by design: a second replica may serve a decision up to one TTL old; for a school install that is not worth Redis. Fail-closed is unconditional: an internal error yields Allowed=false.

What the seam costs

users.id is referenced across the host — teachers, guardian links, audit rows, payment created_by, sessions. Extracting users+access into a plugin turns those into plain string references with no FK integrity, and the host can no longer JOIN users. The seam is already in place (RoleProvisioner/AdminProvisioner receive their definitions from cmd/server); the analysis lives with the contract: Access.

Rendered from simple-lms/rbac.md · OKF bundle · regenerate with npm run render