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.
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
- 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. - Record level. Handlers call the scope helpers above; list queries filter, detail pages deny.
- Templates.
perm()shows/hides UI but grants nothing — it asks the sameAuthorizationService.
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