Skip to content

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.role are not built. Extends the three-layer capability model in b2c-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

  1. 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.
  2. 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.
  3. 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.
  4. Validated? Yes — reproduced on two live staging tenants; the HoD / fee-admin examples are real institutional structures the owner named.
  5. 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 is staff — 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 scalar user.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 under manage. 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_admin and principal are seeded with every atom (catalog._ALL_PERMS). hod and faculty are deliberately empty: they mark identity for future label/nav work, and granting them power was not needed for enforcement. A faculty holder 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 tablespublic 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_admin as "scoped org-wide." As built, org-wide reach is expressed by seeding one org_admin assignment per unit in the organization. The effect is the same and RLS is untouched, but it means a user with a NULL org_unit_id on 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:

  • hr is 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 explicit hr grant row; a user who does hold a grant is enforced by their role.
  • /payroll/me/payslips stays on the raw module gate, never coupled to payroll.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.role is still authoritative. ~395 call sites in the backend still branch on it, including _STAFF_ROLES / _FULL_ACCESS_ROLES in erp/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.ts still mirrors the backend's scalar _FULL_ACCESS_ROLES via can_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:

  1. The grant write-path (sync_user_module_roles, #1527). Saving a staff member's ERP module grants reconciles their <module>_editor / _admin roles for that unit. Delete-then-recreate, so a revoked grant drops its role. Module roles only — never the authority roles.
  2. The backfill migration 00078_seed_roles_catalog_and_backfill (#1542). Seeds the catalog, then derives: base role for everyone, org_admin at every unit of the organization or principal at the holder's own unit, module roles from ERP grants, faculty from 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_id resolves to zero permissions. Two staging org_admin accounts are in this state. Harmless today — require_erp_module 404s a NULL-unit user before any permission check — but their seeded org_admin rows only take effect once they are given a unit.
  • hod and faculty carry 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 00078 before 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_only flag on ErpStaffModulePermission). Rejected: gives three tiers but not a standard permission substrate.
  • Keep scalar role as 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.py defines MODULE_KEY + PERMISSION_DENIED_DETAIL and a local _require_<module> factory; erp/forms/* uses the generic require_permission on router dependencies
  • Grant sync: erp/framework/role_sync.py, called from framework/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.refund vs fees.waive) before the first customer runs a real fee desk? Today both are fees.manage.
  • Should hod / faculty acquire permissions, or stay pure identity labels resolved for display only?
  • Who may grant an authority role once P4 lands — org_admin only, or principal within 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 by user_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/manage actions instead of verb-level atoms; org_admin/principal seeded with the full atom set while hod/faculty carry 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-role removal 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.