ArchitectureAuth & RBAC

Auth & RBAC

One-line: three layers work together on every request — who are you (JWT), which school (tenant), what may you do (RBAC + plan gates).

Authentication (JWT)

TokenLifetimeStoredUse
Access token15 minmemory / SecureStoreAuthorization: Bearer on every request
Refresh token30 daysHTTP-only cookie / SecureStoragemint new access tokens via POST /api/auth/refresh (X-Refresh-Token header)

Login (POST /api/auth/login) returns the access token plus the user, their permissions, and the tenant’s enabled_features — so a client knows what to show immediately. Passwords are hashed with bcrypt (cost ≥ 12). Auth endpoints are rate-limited (5 failed logins/min/account; lockout after 10 consecutive failures).

Account-status gates, checked before any token is issued:

  • Soft-deleted users are invisible to lookup → behaves as “not found” → 401.
  • Suspended users → 403 AccountSuspended.

The login/profile response also carries role/setup flags: is_platform_admin, is_subadmin, is_setup_complete, allowed_unit_ids (branch scope), and force_password_reset.

Tenancy

Every /api/* request (except health, OPTIONS, platform routes) resolves a tenant in core/tenant.py, in priority order: body tenant_id → body subdomainX-Tenant-IDX-Tenant-Subdomain → Host subdomain → dev-fallback env var. A suspended tenant → 403; unknown → 404. Details in Multi-tenancy.

Authorization (RBAC)

Data model: User → UserRole → Role → RolePermission → Permission.

  • A user can hold multiple roles; a role has multiple permissions.
  • Permissions are global (seeded once, same strings everywhere); UserRole and RolePermission are tenant-scoped.
  • Permission strings are resource.action — e.g. student.read, attendance.mark, finance.manage, timetable.manage, rbac.manage.

Enforced by decorators, stacked in order:

@auth_required
@tenant_required
@require_permission('student.read')
def list_students(): ...

Frontends mirror this with hasPermission() / <PermissionGate> (admin-web) and tab-visibility filters (mobile) — but the server is the source of truth; UI checks are cosmetic.

Seeded roles

server/scripts/seed_rbac.py seeds per tenant: School Admin (all), Teacher, Student, Finance Staff, Class Teacher. Fully customisable per tenant.

Plan feature gates

Orthogonal to RBAC: even with permission, a module is unavailable unless the tenant’s plan enables it. Keys live in core/plan_features.py (attendance, fees_management, notifications, schedule_management, student_management, teacher_management, class_management, timetable, transport, hostel) and are stored per plan in plans.features_json. Gate: @require_plan_feature('transport'). See Subscriptions & Plans.

Sub-admins (delegated tenant admins)

A School Admin can create sub-admins — tenant users with an explicitly-chosen subset of module permissions, backed by a private is_subadmin role (server/modules/sub_admins/). Delete-sensitive modules (students, teachers, finance) grant granular toggles rather than .manage (which would imply .delete).

Branch scoping: a sub-admin can be restricted to specific school_units. Enforcement (core/branch_scope.py) anchors on ClassClass.school_unit_id is the only unit-bearing column; students, attendance, fees, timetable reach a unit through a class. Restricted users fail closed (a classless student is invisible; tenant-wide structural ops like promote/copy are denied). Unrestricted users hit strict no-ops, so their behaviour is unchanged.

Passwords: reset, force-reset, change

  • Forgot/reset (/api/auth/password/forgot/reset) — emailed token, unauthenticated, rate-limited, no account enumeration. Web links need ADMIN_WEB_BASE_URL set in prod, else they point at localhost.
  • Force reset — sub-admins are provisioned with force_password_reset=True; the flag is surfaced on login/profile so clients route them into a set-password screen. (In v1 the gate is enforced client-side.)
  • Self-serve change (/api/auth/password/change) — authenticated, optional revoke of other sessions.

Strength rule everywhere: ≥ 8 chars and ≥ 1 digit. Password change/reset revokes sessions.