Skip to content

T 151


uid: T-151 title: Two-layer RBAC β€” Access badges (area + level) + Roles (named badge bundles) on top of T-022 status: doing area: auth-rbac created: 2026-07-02 updated: 2026-07-02 owner: girafeev1 assignee: User Management succeeds: T-022 related: T-150, T-025


Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW Origin: owner observation (2026-07-02) while reviewing the redesigned Permissions page β€” the flat badge list mixes two different kinds of thing. Discussed and shaped in-session; this captures the direction + the open decisions. No code until the owner signs off on the plan (same as T-025/T-150).

The idea

The current T-022 badge list conflates two concepts the owner wants separated:

  1. Access badge = atomic capability β€” area + permission level + subsidiary. e.g. "MEL coaching β€” admin", "EPL projects β€” admin", "ERL accounting β€” bookkeeper", "ERL bank β€” view". Pure "what can be done, where, at what level."
  2. Role = a named bundle of access badges β€” the thing a person actually is/holds. e.g. Director, Auditing Accountant, Super Admin, IT Support. Not a capability β€” a job composed of Layer-1 access badges.

Today's 8 badges straddle these: bookkeeper-mel / bookkeeper-epl are really Layer-1 access units; director-erl, auditing-accountant-erl, super-admin, it-support are really Layer-2 roles that contain access units; mel-administrator / epl-administrator are hybrids (an area-admin bundle that could also be a whole job). This is the seam the owner spotted.

  • Access badges stay the enforcement substrate. Keep the T-022 machinery exactly as shipped β€” the grammar surface:resource:action@subsidiary, canPerformAction, the strict Security Rules, the overlay (T-150). Reframe/name them by area + level so they read as capabilities, not jobs. No security-layer rewrite.
  • Roles = named collections of access badges, and roles are the thing you assign to a person. Assigning the Director role simply expands to Director's access badges on the user record / claims. Gates still ask "does this user hold a badge granting X?" β€” unchanged.
  • This absorbs the app's existing half-built "Custom Roles" feature (CustomRole type, RolesTab, /api/admin/roles) β€” unify it with badges instead of running two role systems.

Why it's worth it

  • Mental model: "she's a Director," not "she has these six badges."
  • No duplication: today director-erl inline-copies coaching:*:*@mel + projects:*:*@epl + …; a role that references access badges inherits changes to those badges automatically.
  • Assignment ergonomics: pick one role vs. remembering to tick five badges.

Honest caveats

  • More concept + UI (two things to manage). Payoff is clarity/reuse, not scale β€” but the owner keeps tripping on the flat model, so the clarity is real.
  • The badge model just went live + the Permissions redesign is in flight β€” layer this on deliberately.
  • Re-sorting the current 8 into access-units vs roles needs care (the hybrids).

Scope / deliverables (draft β€” refine at planning)

  1. Model: a Role = { id, label, description, badgeIds[] }. A user is assigned role(s); effective badges = union of the roles' badges (+ any ad-hoc direct badges, if we keep that door open). Resolution to grants unchanged (roles β†’ badges β†’ grants).
  2. Re-sort the current 8: which become pure access badges (area+level), which become roles, how the hybrids split, and the new naming.
  3. Storage + editor: roles in the Firestore overlay alongside badges (extend T-150); the editor gets a Roles view (compose a role from access badges) + the access-badge view.
  4. Assignment UI: the Edit-User modal assigns roles (primary), with the badge picker demoted to an advanced/override affordance.
  5. Permissions page: the redesign splits into Access (area+level badges, with the page thumbnails) and Roles (bundles, each showing what access composes it).
  6. Migration: map every current user's badge set β†’ the nearest role(s); dual-read during cutover.

Decisions (owner, 2026-07-02)

  • D1 β€” RESOLVED: union of roles. A person may hold many roles (e.g. "Director + Super Admin"); effective access = union of all their roles' badges. Consistent with how badges already stack.
  • D2 β€” RESOLVED: no ad-hoc badges for now; time-limited access noted as future. All access flows through roles. The owner sees no case for one-off direct badges but likes the idea of a time-limited badge/role β€” no application yet, "good to have." Note: account-level expiry already exists (accessExpiresAt, used for external auditors); an expiresAt on a role assignment is the natural future extension. Recorded, not in scope.
  • D3 β€” RESOLVED: "Role" for the top layer (owner preference), "Access badge" for the atomic unit. Safe because of D4: the legacy role enum is retired first, so the word isn't overloaded. During the window, UI copy must say "legacy role" for the old field.
  • D4 β€” RESOLVED: yes β€” T-025 phase 4 (retire legacy role/ROLE_PERMISSIONS) lands before this task's implementation.

Plan v1 (awaiting owner sign-off before code)

Model. Role = { id, label, description, badgeIds[] }, stored in the Firestore overlay beside badges (aote-system/rbac/roles/keys/{id}, extends T-150). Users get roles: string[]; their badges field + custom claims are materialized at assignment time (union of the roles' badgeIds) and recomputed when a role is edited. Enforcement β€” gates, strict rules, claims shape β€” is literally unchanged; roles are an assignment-time convenience layer.

Baseline grants. Every current badge repeats notifications:*:read@all + profile:self:*@all. Proposal: make these implicit baseline grants for any active user and drop them from badges.

Draft re-sort of the current 8 (access badges named <area>-<level>@<sub>):

Today Becomes
bookkeeper-mel / bookkeeper-epl Role "Bookkeeper β€” MEL/EPL" = books-keeper@mel|epl + bank-view@erl
mel-administrator Role "MEL Administrator" = coaching-admin@mel + students-admin@mel
epl-administrator Role "EPL Administrator" = projects-admin@epl + clients-admin@epl
auditing-accountant-erl Role "Auditing Accountant" = accounting-auditor@erl + bank-view@erl + records-view@erl
director-erl Role "Director" = the MEL+EPL admin units + accounting-full@erl + bank-full@erl + records-full@erl + contacts-full@all + audit-view@all + gadgets-view@erl
super-admin Role "Super Admin" = rbac-admin@all + users-admin@all + settings-admin@all + tools-admin@all + audit-view@all
it-support Role "IT Support" = infra-dmarc-view@all

UI. Permissions page splits into Access (area+level badges, with the approved page-thumbnail column design) and Roles (bundles, each showing the access it's composed of). Edit-User assigns roles; the raw badge picker demotes to an advanced affordance (or goes away, per D2).

Migration. Map each live user's badge set β†’ role(s) (trivial at current headcount β€” see the coverage report in T-025); dual-read window; then flip assignment UI to roles.

Sequencing (per D4): β‘  live badge-coverage check (done 2026-07-02, results + remediations in T-025) β†’ β‘‘ gate cutover β€” T-025 phase 4 (drop legacy fallback, strip hasAnyRole) coordinated with T-150 phase 4 (server gates read the merged overlay registry; client hide-until-confirmed); one PR or two back-to-back, decide at execution β†’ β‘’ build this task β†’ β‘£ split the Permissions page.

Relationship to other tasks

  • succeeds T-022 β€” extends the badge model with a role layer; enforcement substrate unchanged.
  • related T-150 β€” the badges editor grows a Roles view; the overlay stores roles too.
  • related T-025 β€” its phase 4 retires the legacy role enum; do that before reusing the word "role" for the new layer (see D4). This task does NOT depend on T-025's read-migration.

Execution (2026-07-03, owner: "phases 1-4 in one go, then deploy; phase 5 separately")

  • βœ… P1+P3 (9f993586) β€” roleBundles.ts (model, 8 starter roles, expand/merge/validate, 12 tests)
  • roleBundlesStore.ts (overlay rbac/roles/keys, 60s cache, fail-open). Bootstrap adjustment: starter roles map 1:1 onto existing badge ids β€” the live rules enumerate badge ids, so the finer access-badge re-sort belongs to phase 5 as a coordinated rules+migration change.
  • βœ… P2+P4 (c23bb536) β€” /api/rbac/roles (validated, audited), Permissions page β†’ Roles + Access badges sub-tabs (RoleEditor), Edit User β†’ Roles picker with materialization (badges = union of roles' bundles βˆͺ advanced/manual badges; sent only when changed), users PATCH validates roles + badge ids against MERGED registries. Enforcement-neutral by construction.
  • βœ… P5a (2026-07-03) β€” all 5 users migrated onto ROLES (1:1 from their badges; badges/claims untouched β†’ zero enforcement delta). Legacy UI retired: the roleβ†’permission matrix collapse and the old custom-Roles sub-tab (+ dead imports) removed from Settings β€” the T-151 Roles editor is the one roles system now.
  • πŸ“Œ Owner note (2026-07-04): per-section/tab visibility inside a contact drawer. "Not all people should be able to view all tabs of detail within a single contact" β€” e.g. Banking or HKID sections visible only to finance/admin tiers. Fold into the same follow-up as page-tab / drawer-tab governance (the owner's earlier ask): each drawer section becomes a permission-gated surface (contacts:banking:read@…-style grants), with the hide-until-confirmed convention. Design when the vocabulary re-sort (P5b) lands, since section grants want the finer access-badge vocabulary.
  • ⏳ P5b remaining (own verified pass): the re-sort (new access-badge vocabulary + rules lists + live migration), Permissions-page Access/Roles refinement, retire remaining legacy role consumers.

Log

  • 2026-07-02 created + shaped by User Management from the owner's observation on the redesigned Permissions page. Awaiting owner decisions D1–D4 before a full plan; no code yet.
  • 2026-07-02 owner resolved D1–D4 (union of roles Β· no ad-hoc badges, time-limited noted as future Β· "Role" naming Β· legacy-role retirement first). Plan v1 written; coverage check run (see T-025). Awaiting owner sign-off on Plan v1 + the re-sort table before code.