Skip to content

T 094


uid: T-094 title: Finalize RBAC β€” addressable people roster + user-management so features can target people by role status: done area: auth-rbac created: 2026-06-23 updated: 2026-06-23 precedes: T-095


RBAC sequence: precedes T-095 β€” the it_support role can't be assigned until this roster + role-management layer exists. The people/role track (T-094 β†’ T-095) is separate from the enforcement track (T-022 β†’ T-025 β†’ T-027). See the sequence map in T-022. (Marked by EOP Local Assistance, 2026-06-23.) Renumbered T-092 β†’ T-094 on creation (2026-06-23). The T-092 UID collided three ways across in-flight branches: origin/main already had T-092 = "WOPC β€” render per-line-item notes under the fee type" (done, via PR #788) and origin/nightly had T-092 = "Restore the Claude↔NAS root-exec channel" (todo, a cloud agent). Per AGENTS.md "whoever merges second renumbers," this RBAC task took T-094 β€” past both, leaving T-093 for the nightly NAS task to renumber into when it reconciles with main. README next-free bumped to T-095. (The pre-existing main↔nightly T-092 collision between the WOPC + NAS tasks is flagged to the owner separately β€” not this agent's to resolve.) Source: EOP Local Assistance Β· local session.

Why this exists (the trigger)

Building the IR56M sign-and-seal distribution flow (T-047 phase 4 #5) surfaced a gap: when a document is signed, the signing director should pick which actual people receive it β€” shown as real identities with their role, e.g. "Jeffero Chan β€” Director, Jake Ngai β€” Director, Alison Tang β€” Accountant" β€” not just abstract role names. Today the only real users are the two directors (project_eop_user_base); roles like accountant / bookkeeper exist in the model (aote-system/users β†’ super_admin / admin / auditing_accountant / bookkeeper / project_admin / pending) but there's no finalized roster of people in those roles and no management surface to add/assign them. So a people-picker can't yet list "everyone in the web app with their role."

The owner flagged this directly while specifying the IR56M distribution recipients (see evidence). It's a prerequisite for any feature that addresses people β€” IR56M distribution, the planned notification center (T-037), email targeting, approvals.

Scope

Finalize the user-management / people side of RBAC (distinct from enforcement β€” see "vs T-022"): - A canonical, queryable people roster: every web-app person with a stable identity (name, contact email) + their role(s) + subsidiary access, readable by features that need to target recipients. - A management surface (UI) to add people, assign roles, and set/revoke access β€” so non-director roles (accountant, bookkeeper, …) become real, addressable users instead of placeholders. Reuse the existing plumbing: lib/rbac/ (roles.ts, claims.ts, invitations.ts, approval.ts, accessExpiry.ts, permissions.ts) + the invitations API (pages/api/admin/invitations/). The brief in the project_user_mgmt_notification_onboarding memory is the starting point. - A small shared accessor features can call β€” "list active people (with role + email), optionally filtered by role/subsidiary" β€” so pickers (IR56M distribution, notifications) don't each re-derive it. - Reconcile the directors registry (lib/directors/registry.ts, hard-coded 2 directors) with the aote-system/users roster so a person isn't represented twice with different ids.

This task vs siblings (RBAC initiative)

The RBAC work is already split across UIDs; this one is the people/roster/management side, which none of the others own: - T-022 β€” enforcement (strict Security Rules for all DBs + position claims + flip RBAC_ENABLED). - T-025 β€” migrate user-facing Firestore reads/writes to the client SDK. - T-026 β€” re-introduce the firestore.rules CI auto-deploy workflow. - T-027 β€” narrow the Firestore Admin SDK credential (per-DB SA scoping). T-092 = the roster + management + addressable-people accessor. Cross-links T-047 (the driver) and T-037 (notifications, which will consume the same roster).

Interim unblock for T-047 #5 (so distribution ships before this lands)

The IR56M distribution picker will source recipients from whatever real people exist today (the two directors + the IR56M recipient's payee email), with the default selection = directors + recipient + (accountant + bookkeeper when they exist). As T-092 fills the roster, those roles auto-appear in the picker with no IR56M change. The IR56M feature must degrade gracefully when a defaulted role has no person yet (skip + flag, never block the send).

Decision record

  • βœ… Attestation (EOP Local Assistance): read AGENTS.md; created this task by scope (checked the board β€” no existing task covers the user-roster/management layer; T-022 is enforcement-only), keeping the board current.
  • Source: EOP Local Assistance Β· local Claude Code session
  • Proposed by: EOP Local Assistance Β· Approved by: the owner (asked for the task explicitly).
  • Evidence β€” owner, 2026-06-23 (verbatim):

    "list out not just the role name (like, Directors/recipient/super admin/ admin etc...) but the actual information of those people (like, Jeffero Chan - Director, Jake Ngai - Director, Alison Tang - Accountant). … this instance actually also makes me realize that I'll have to further develop and finalize the RBAC. Can you help me create a task and stating why it was created?"

Log

  • 2026-06-23 created (owner-requested) while building T-047 #5 IR56M distribution. Scoped as the RBAC people-roster/management layer; cross-linked to the enforcement sibling T-022 and the notifications consumer T-037.
  • 2026-06-23 picked up by User Management (status: todo β†’ doing). Design entry below.

Decision log

2026-06-23 β€” picked up, scope refined to "thin shared accessor + sync"

  • βœ… Attestation (User Management): read AGENTS.md; scope-scanned the board β€” confirmed no other task covers the people-roster/management layer; T-026 (which I just closed) is workflow CI, T-088 is degraded-link tolerance, neither overlaps.
  • Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW
  • Proposed by: User Management. Approved by: the owner.
  • Rationale (the landscape I found): ~70% of T-094's surface area is already built.
  • lib/rbac/types.ts already carries the full profile shape: uid, email, displayName, firstName/lastName, role, status, subsidiaryAccess, primarySubsidiary, jobTitle, organization, isExternal, phoneNumber, whatsappNumber, preferredContactMethod, groups. No schema change needed.
  • lib/rbac/claims.ts::listUsers() is the canonical server-side enumerator (aote-system/users, filterable by status/role).
  • pages/api/admin/users/{index,[uid]}.ts is the admin CRUD surface.
  • components/settings/SettingsApp.tsx already mounts a full Settings β†’ User Management tab with sub-tabs Users / Approvals / Line Managers / Roles / Invitations, each backed by the admin API.
  • The current UI gap is narrow: the Users sub-tab's table shows only Name+Email / Role / Status / Actions, omitting subsidiary access β€” but the EDIT modal already exposes it. So the management surface is real; the table needs a subsidiary column for quick triage.
  • What's actually missing β†’ this PR's scope:
  • A shared, addressable-people accessor for features that need to target recipients (IR56M distribution per T-047 #5, future notification picker per T-037, approval routing). New module lib/people/roster.ts (server-side, wraps listUsers() + the directors registry); REST endpoint GET /api/people/roster for client consumers; React Query hook usePeopleRoster() for client pickers.
  • Light reconciliation with the directors registry. lib/directors/registry.ts carries loginEmails per director; the accessor cross-references by email, attaches isDirector
    • the registry id + directorClosingName. Directors missing from aote-system/users (haven't signed in yet, or are stale) are emitted as synthetic roster entries so a picker that needs "the directors" always sees both, regardless of user-record state. A full merger of the registry into aote-system/users (so directors stop being a second source of truth) is deliberately out of scope here β€” it's the structural follow-up hinted at in lib/directors/registry.ts's own header comment ("When that lands, this registry should become a typed view over it rather than a second source of truth"). Open a separate T-NNN if/when it lands.
  • Surface subsidiaries in the existing Users tab so admins can triage who has what access without opening the Edit modal for every row.
  • Why this shape and not a bigger structural rewrite: the registry merger is genuinely larger work β€” it changes who owns the closing-signatory data, touches WOPC pipeline invariants (resolveRequiredClosingDirector etc.), and needs a migration that re-keys user docs. T-094's stated goal is unblocking IR56M distribution and the notification centre, not consolidating director identity. A thin accessor satisfies the consumers today; the deeper consolidation can ship later with its own task carrying the WOPC blast-radius analysis.
  • Evidence β€” owner, 2026-06-23 (verbatim):

    "You're User Management, register yourself as such please."

"Proceed with with 1-3 first then" (in reply to my task-triage of T-022/T-025/T-026/ T-027/T-083/T-088/T-094/T-095, where I recommended: 1) close T-026, 2) T-094, 3) T-095) - Plan: ship as PR off claude/t094-people-roster β†’ main; status flips to done once the accessor, endpoint, hook, and UsersTab subsidiary column land green on tsc.

2026-06-23 β€” closed (accessor + endpoint + hook + UsersTab subsidiary column shipped)

  • βœ… Attestation (User Management): read AGENTS.md; per the close-the-loop rule, capturing outcome + commits + blast-radius before flipping to done.
  • Source: User Management Β· https://claude.ai/code/session_01GGT5n9vCxKWoUQSRAfMSiW
  • Outcome / verdict: shipped the thin shared accessor exactly as designed.
  • lib/people/roster.ts β€” server-side accessor + the PersonRosterEntry shape; listRoster(filter?), getRosterEntryByUid, getRosterEntryByEmail. Directors missing from aote-system/users are emitted as synthetic entries with source: 'directors-registry' so a picker that needs "the two directors" always sees both regardless of user-doc state. The directors registry is cross-referenced by email; the deeper structural merger (directors β†’ users single source of truth) is the WOPC-blast-radius follow-up flagged in lib/directors/registry.ts's own header β€” not in this PR.
  • pages/api/people/roster.ts β€” GET endpoint, authenticated active users only, 30s shared CDN cache.
  • lib/people/usePeopleRoster.ts β€” React Query hook with 30s stale / 5min gc; multiple pickers share a single fetch.
  • components/settings/SettingsApp.tsx β€” UsersTab table now shows a Subsidiaries column with code chips and a β˜… on the primary, closing the at-a-glance triage gap noted in the design entry above.
  • Commit: d14dbeb4 (PR β€” opened on claude/t094-people-roster, will be merged to main on owner's go-ahead). Status flipped to done in the merge commit.
  • Blast-radius note for other agents:
  • T-047 #5 IR56M distribution picker β€” can now stop deferring and call usePeopleRoster({ roles: ['admin', 'auditing_accountant', 'bookkeeper'] }) for the default recipient set. Directors are always present (synth fallback) so the picker no longer has to special-case the registry.
  • T-037 notification centre β€” when it lands, the recipient picker pulls from the same hook.
  • lib/notifications/notify.ts β€” notifyProjectsUsers still iterates listUsers() + evaluates the projects page rule; no change here, but a future refactor could call the roster accessor instead for a consistent "addressable for projects" semantic.
  • Directors registry (lib/directors/registry.ts) β€” unchanged, still the source for WOPC closing names / signature blocks. Anyone touching the registry should be aware the roster accessor reads loginEmails[0] to detect "the same person" β€” adding a director with no loginEmails would leak past this matching.
  • Sequence-map impact: unblocks T-095 (it_support role addition). The role picker UI it amends already exists in UserEditModal.tsx and now displays subsidiaries inline.