Skip to main content

ADR 0005 — RBAC: canonical roles, scopes, and permissions

Updates

  • 2026-05-15: Added canonical role student (linked child login via students.child_login_user_id); baseline permissions mirror parent minus enrollment; student rows are filtered in GET /api/students by child_login_user_id.

Status

Accepted — 2026-05-15

Context

The MVP used four Dutch role strings on users.role (overheid, school, ouder, chauffeur) as the only authorization signal. Government-side operators need finer separation (e.g. read-only viewers), school/fleet operators need scoped access, and feedback flows must allow POST actions for viewers without granting domain writes.

Decision

  1. Canonical English roles stored in users.role: government, government_operator, government_viewer, school, school_operator, fleet_operator, parent, student, driver.
  2. Permissions as string keys in user_permissions (user_id, permission); handlers use AuthzContext.has(permission) (with government treated as super-admin in code).
  3. Scopes as rows in user_scopes (user_id, scope_type, scope_id with '' when no id): global, government_readonly, schools_all, school_single, fleet_all, fleet_school, own_children, own_bus.
  4. Migration: ensureSchema-time helper migrateLegacyRbac maps legacy Dutch roles to canonical roles, then seeds empty permission/scope tables from role baselines (idempotent per user).
  5. Feedback exception: government_viewer receives read-style domain permissions plus feedback_support_create, feedback_feature_create, feedback_vote; GitHub sync and merge remain behind feedback_github_sync / feedback_feature_merge / feedback_support_read_all as implemented in server.ts and src/feedback/FeedbackPages.tsx.
  6. Socket.IO: fleet operator room uses fleet:operators (no Dutch role name in room identifiers).

Consequences

  • All new routes must check permissions + scope (not user.role alone) for security-sensitive behavior.
  • UI visibility follows /api/me permissions / scopes returned from userJsonForClient.
  • Caching: at 40M+ users, permission/scope reads should move behind a cache (e.g. Redis) with clear invalidation on replaceUserAuthz; first implementation loads from Postgres once per request.

References

  • Implementation: lib/authz.ts, server.ts (attachAuthz, migrateLegacyRbac), seedIraqDemo.ts, src/authzClient.ts, src/App.tsx.