Roles & Permissions — ERP-standard RBAC for Kwilo¶
Status: Partially shipped (2026-08-08). The substrate, the catalog and server-side enforcement across 11 ERP modules are live on staging. The read side (nav, labels, guards), the assignment UI, and the removal of the scalar
user.roleare not built. Extends the three-layer capability model inb2c-persona-capabilities.md.
Why this exists¶
Kwilo's auth model had exactly one scalar role per user (UserRole, 8 values,
apps/backend/src/models/user.py:90). That single field was asked to express both
authorization tier (admin vs staff vs learner) and job function (Faculty).
Because there was no non-teaching-staff concept, every non-admin staff member —
office assistant, accountant, librarian, lab technician — was assigned instructor
and rendered as "Faculty."
Product Mindset Gate¶
- Who asked? Platform owner, from dogfooding two live staging tenants (Sapthagiri University / School): an office assistant shown as "Faculty" with a locked module catalog and a broken My Payslips; attendance-marking filed under Admin, invisible to the faculty who actually mark it.
- User pain. "Dr. Anita is HoD and teaches and administers fees — the system makes her pick one identity, mislabels her, and shows her the wrong navigation." A non-teaching clerk is told they are a lecturer.
- Cost of not doing it. Every institution with mixed-duty staff (all of them) sees wrong labels, wrong nav, dead links. Blocks credibility in university/college demos where designations matter.
- Validated? Yes — reproduced on two live staging tenants; the HoD / fee-admin examples are real institutional structures the owner named.
- Success signal. A staff member with three duties sees one correct identity label and the union of the three navs, nothing locked-and-teasing; an office assistant is never called Faculty. Counterfactual: revert it and admins complain "why is my accountant a teacher again?"
Model — one unified role system¶
Everything is a role. A single user_roles table holds two kinds:
- Base role — one per user. The account discriminator. Shrinks to the distinct
populations:
staff, learner, guardian, b2c_user, platform_admin. Every institutional employee isstaff— Principal, HOD, Fee Admin, Librarian, Faculty are not account types, they are assigned roles. As built, these rows are seeded but nothing reads them yet — routing and RLS context still read the scalaruser.role(see Not built). - Assigned roles — zero or more, staff only. Two sub-kinds in the same table:
- Authority roles:
org_admin,principal,hod,faculty. - Module roles: per-module preset bundles (Fees, Library, HR…).
Scope lives on the assignment, authority in the role. user_roles carries
org_unit_id — the same axis RLS uses to isolate rows. A plain staff with no
assigned roles gets nothing but self-service (least privilege by default).
Permissions are atomic (module.action)¶
Every permission is <module>.<action>. As built, the action set is uniformly
view / write / manage across all 12 modules — 36 atoms total:
students.view students.write students.manage
fees.view fees.write fees.manage
admissions.* timetable.* attendance.* exam_ops.*
hr.* library.* payroll.* helpdesk.*
id_cards.* forms.*
manage is the privileged tier (refund / waive / publish / schema edits).
Deviation from the original design. The design proposed verb-level atoms (
fees.collect,fees.refund,fees.waive,exams.publish). The build settled on three uniform actions per module instead. Separation of duties within a module — "collect but never refund" — is therefore not expressible today; both live undermanage. Adding a finer atom is a catalog + migration change, not a schema change, so this stays open rather than closed.
The three levels are seeded preset roles¶
| Seeded role | Bundles | Example (Fees) |
|---|---|---|
<module>_viewer |
<module>.view |
fees.view |
<module>_editor |
view + write | fees.view, fees.write |
<module>_admin |
view + write + manage | + fees.manage |
Assigning a level is assigning a preset role. ErpStaffModulePermission.is_module_manager
maps to _admin; a plain grant maps to _editor.
Dr. Anita = base staff + principal + fees_admin + library_editor + faculty.
Authority roles¶
| Role | Permissions | Notes |
|---|---|---|
org_admin |
all 36 atoms | Seeded once per org unit in the organization |
principal |
all 36 atoms | Seeded at the holder's own unit |
hod |
none | Exists as a label; carries no permissions yet |
faculty |
none | Seeded from a teaching HR record; label only |
The original open question — implicit superset vs explicit seeding — resolved as explicit:
org_adminandprincipalare seeded with every atom (catalog._ALL_PERMS).hodandfacultyare deliberately empty: they mark identity for future label/nav work, and granting them power was not needed for enforcement. Afacultyholder reaches teaching surfaces through the unchanged module gate, not through a permission.
Catalog totals (as seeded)¶
| Count | |
|---|---|
| Permission atoms | 36 (12 modules × 3 actions) |
| Roles | 45 (5 base + 4 authority + 36 module presets) |
role_permissions links |
144 |
The in-code catalog is the runtime source of truth. roles_service resolves
permissions from catalog.role_permission_keys(), never from the role_permissions
table. Those rows are seeded for completeness and future org-custom roles, and are
read by nothing at runtime.
Data model¶
Core tables — public schema, main alembic tree — because they govern the whole
platform and must exist when ERP is disabled. Core code never imports src/erp/*;
module roles reference ERP modules by string key only.
roles—(id, key, kind ['base'|'authority'|'module'], module_key?, level?, is_system, org_unit_id?)permissions—(id, key 'module.action', module_key, description)role_permissions—(role_id, permission_id)user_roles—(user_id, role_id, org_unit_id), unique per triple
Scope — strict per-unit, no wildcard¶
ERP row-security is single-unit by design (src/erp/rls.py). A staff member operates
in one active org unit at a time.
A permission check passes only when an assignment exists at exactly the active
org_unit_id. An assignment with a NULL org_unit_id matches no unit —
there is no org-wide wildcard.
Deviation from the original design, which described
org_adminas "scoped org-wide." As built, org-wide reach is expressed by seeding oneorg_adminassignment per unit in the organization. The effect is the same and RLS is untouched, but it means a user with a NULLorg_unit_idon their account resolves to zero permissions everywhere — see Known gaps.
Enforcement¶
Enforcement composes with, never replaces, the existing ERP module gate. Each
module keeps require_erp_module / require_erp_module_manager running first, then
adds the permission check:
MODULE_KEY = "fees"
async def _dep_staff(user: FeesStaff, db: DB) -> User:
if not await user_has_permission(db, user, user.org_unit_id, MODULE_KEY, action):
raise HTTPException(403, detail=PERMISSION_DENIED_DETAIL)
return user
FeesView = Annotated[User, Depends(_require_fees("view", manager=False))]
FeesWrite = Annotated[User, Depends(_require_fees("write", manager=False))]
FeesManage = Annotated[User, Depends(_require_fees("manage", manager=True))]
Ordering matters and is deliberate: a disabled module still 404s before the permission layer can 403, so enforcement never leaks which modules a tenant has.
Coverage¶
| Module | Enforced | Actions | PR |
|---|---|---|---|
| fees | ✅ | view / write / manage | #1524 |
| library | ✅ | view / write / manage | #1528 |
| admissions | ✅ | view / write | #1531 |
| hr | ✅ | view / write / manage | #1533 |
| payroll | ✅ | view / write / manage | #1534 |
| exam_ops | ✅ | view / write | #1535 |
| students | ✅ | view / write | #1536 |
| timetable | ✅ | view / write | #1537 |
| helpdesk | ✅ | view / write / manage | #1538 |
| id_cards | ✅ | view / write / manage | #1539 |
| forms | ✅ | view / manage | #1540 |
| attendance | ❌ | — | atoms seeded, never checked |
attendance is a core platform feature (/admin/attendance), not an ERP router, so
its three atoms exist in the catalog but gate nothing.
Modules with no manager tier (exam_ops, students, timetable) have no manage action
wired — publish/allocation are staff-tier there.
Self-service carve-outs¶
Three surfaces deliberately sit outside the permission layer, because they serve the person whose own records they are:
hris in_SELF_SERVICE_MODULE_KEYS: every staff account reaches HR without a grant, so a plain instructor can view and apply for their own leave. The view/write dependency therefore falls back to the legacy behaviour for a user with no explicithrgrant row; a user who does hold a grant is enforced by their role./payroll/me/payslipsstays on the raw module gate, never coupled topayroll.view.- Library borrower surfaces use
require_erp_module_borrower(learners included).
What is not built¶
The read side of the original plan is untouched. Enforcement is server-side only.
- The scalar
user.roleis still authoritative. ~395 call sites in the backend still branch on it, including_STAFF_ROLES/_FULL_ACCESS_ROLESinerp/deps.py. Base-role rows are seeded but read by nothing. resolve_permissions()is never called in production code — no endpoint exposes the resolved permission set, so the frontend cannot consume it.- Nav, labels and guards are unmigrated.
apps/web/src/erp/nav.tsstill mirrors the backend's scalar_FULL_ACCESS_ROLESviacan_manage_permissions. An office assistant is still rendered as "Faculty." - No assignment UI. Roles are only reachable through ERP module grants (which sync
to
<module>_editor/_admin) or the backfill. Authority roles cannot be granted or revoked from the product at all.
Consequence: a viewer is genuinely blocked from writing by the API, but the UI still shows them the button.
Seeding & backfill¶
Roles only exist for a user if something writes a user_roles row. Two paths do:
- The grant write-path (
sync_user_module_roles, #1527). Saving a staff member's ERP module grants reconciles their<module>_editor/_adminroles for that unit. Delete-then-recreate, so a revoked grant drops its role. Module roles only — never the authority roles. - The backfill migration
00078_seed_roles_catalog_and_backfill(#1542). Seeds the catalog, then derives: base role for everyone,org_adminat every unit of the organization orprincipalat the holder's own unit, module roles from ERP grants,facultyfrom teaching employees.
The gap this closed¶
The foundation migration created the four tables and left them empty, and nothing
ever populated them: seed_catalog / backfill_user / backfill_user_erp were called
only from tests. Once enforcement was wired onto every module, user_has_permission
returned False for every non-platform-admin and the entire ERP suite 403'd — a
unit manager could not open the Fees overview. The router docstrings stated the
precondition ("assumes the role backfill has already run") but nothing ran it.
Trap worth remembering: every erp.* table has a forced RLS policy keyed on
app.current_org_unit_id. A migration has no request context, so the grant and employee
reads matched zero rows and the ERP half of the backfill silently did nothing. FORCE
means even the table owner is filtered. The fix is the policy's own app.bypass_rls
escape hatch, scoped with SET LOCAL. This is invisible locally, where a superuser
bypasses RLS outright — it only reproduces against a real tenant DB.
Staging outcome (2026-08-08)¶
| before | after | |
|---|---|---|
| permissions | 0 | 36 |
| roles | 0 | 45 |
| role_permissions | 0 | 144 |
| user_roles | 0 | 496 |
392 of 392 users hold at least one role. All four unit managers resolve to full access across fees, admissions, students, hr, timetable, exam_ops and forms.
Known gaps¶
- A user with a NULL
org_unit_idresolves to zero permissions. Two stagingorg_adminaccounts are in this state. Harmless today —require_erp_module404s a NULL-unit user before any permission check — but their seededorg_adminrows only take effect once they are given a unit. hodandfacultycarry no permissions, so neither can be used to gate anything yet.- No separation of duties inside a module (see the action-set deviation above).
- Prod has not been backfilled. It needs
00078before the next release, migrations before traffic per the canary ordering.
Non-goals¶
- Not building per-institution custom roles (schema supports it; UI deferred).
- Not per-action UI granularity — three preset levels; atoms underneath.
- Not reshaping RLS or tenant isolation.
- Not touching B2C plan/quota (Layer 3) — this is Layer 1/2 only.
Rejected alternatives¶
- Minimal boolean patch (a
read_onlyflag onErpStaffModulePermission). Rejected: gives three tiers but not a standard permission substrate. - Keep scalar
roleas a separate tier beside the new layer. Lower risk, but the owner chose full unification. (In practice the build has landed here anyway — the scalar still runs everything and the new layer sits beside it. The unification is deferred, not abandoned.) - Full Odoo groups engine with admin-authored groups in v1. More than needed; the schema is forward-compatible with it.
Where it lives¶
- Core:
core/authorization/{catalog,roles_service,dependencies,seed_roles}.py - Enforcement: each
erp/<module>/router.pydefinesMODULE_KEY+PERMISSION_DENIED_DETAILand a local_require_<module>factory;erp/forms/*uses the genericrequire_permissionon router dependencies - Grant sync:
erp/framework/role_sync.py, called fromframework/service.set_module_grants - Backfill:
erp/framework/backfill_roles.py,alembic/versions/00078_* - Frontend (unmigrated):
constants/roles.ts,erp/nav.ts,components/guards/*
Remaining rollout¶
- P3 — Read side. Expose the resolved permission set; migrate nav / guards /
labels; Campus home shows only granted + self-service; labels from
designation. - P4 — Assignment UI. Assign base + authority + module roles in
/admin/users. Until this exists, authority roles are unmanageable from the product. - P5 — Drop scalar
role. Only after every call site reads the base role.
Open questions¶
- Do we need verb-level atoms (
fees.refundvsfees.waive) before the first customer runs a real fee desk? Today both arefees.manage. - Should
hod/facultyacquire permissions, or stay pure identity labels resolved for display only? - Who may grant an authority role once P4 lands —
org_adminonly, orprincipalwithin their unit?
Changelog¶
- 2026-08-04 — Initial design from the roles brainstorm (composition + graded levels + ERP-best-standard substrate; full unification; 3 preset levels to start).
- 2026-08-04 — Decision: collapse all institutional tiers into base
staff; Principal / HOD / Fee Admin / Librarian / Faculty / org-admin become assigned roles scoped byuser_roles.org_unit_id. - 2026-08-08 — Reconciled with the shipped implementation. Foundation (#1523),
grant↔role sync (#1527) and enforcement on 11 modules (#1524, #1528, #1531,
#1533–#1540) are merged. Recorded the as-built deviations: uniform
view/write/manageactions instead of verb-level atoms;org_admin/principalseeded with the full atom set whilehod/facultycarry none; strict per-unit scope with no NULL wildcard; enforcement composing with the module gate rather than replacing it. Documented that the read side, the assignment UI and the scalar-roleremoval are not built. - 2026-08-08 — Added the seeding/backfill section after the catalog was found empty in
every environment, 403-ing the whole ERP suite; migration
00078(#1542) applied to staging (496 assignments, 392/392 users). Recorded the forced-RLS trap that made the ERP half of the backfill a silent no-op. Prod still pending.