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:
- 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."
- 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.
Recommended architecture (a LAYER, not a rewrite)¶
- 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 (
CustomRoletype,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-erlinline-copiescoaching:*:*@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)¶
- 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). - Re-sort the current 8: which become pure access badges (area+level), which become roles, how the hybrids split, and the new naming.
- 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.
- Assignment UI: the Edit-User modal assigns roles (primary), with the badge picker demoted to an advanced/override affordance.
- Permissions page: the redesign splits into Access (area+level badges, with the page thumbnails) and Roles (bundles, each showing what access composes it).
- 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); anexpiresAton 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
roleenum 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
roleenum; 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(overlayrbac/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.