Skip to content

feat(authz): Settings → Roles management UI (placeholder hook)#358

Draft
mcharles-square wants to merge 5 commits into
mainfrom
feat/rbac-roles-management-ui
Draft

feat(authz): Settings → Roles management UI (placeholder hook)#358
mcharles-square wants to merge 5 commits into
mainfrom
feat/rbac-roles-management-ui

Conversation

@mcharles-square
Copy link
Copy Markdown
Collaborator

@mcharles-square mcharles-square commented Jun 2, 2026

⚠️ DraftuseRoleManagement and permissionCatalog.ts still depend on unshipped AuthzService RPCs. See Placeholders & blocking deps.

Summary

U11 from docs/plans/2026-05-19-001-feat-granular-rbac-plan.md. New Settings → Roles page where admins (anyone holding role:manage) can manage built-in and custom roles, plus a role Select on AddTeamMemberModal so new members can be assigned a role at creation time.

What it does

Roles page (/settings/roles)

  • Lists built-in (Owner / Admin / Field Tech) and custom roles with member counts, permission counts, and last-updated timestamps.
  • Edit action hidden on all built-in roles (server unconditionally rejects built-in mutation); custom roles only.
  • Delete action on custom roles only, gated on memberCount === 0 so members can't be orphaned mid-deletion.
  • Nav entry + route gated on role:manage. Direct visits redirect to /settings/general for callers without the key.

Create/Edit role modal

  • Grouped checkbox builder over the full permission catalog.
  • Search, collapse-by-group, "X selected" running total.
  • Automatic inclusion of read-pairing dependencies (mirrors the server's role-save validator: every action key requires its same-resource :read partner, and any miner action requires fleet:read).
  • Read keys still required by other selected actions render disabled with a (required) suffix via lockedReadKeys, so the UI never holds a selection the server would reject on save.
  • Description is preserved on update — modal omits description from the payload when no description input is surfaced; the hook leaves the existing role description untouched.

AddTeamMemberModal

  • Adds a role Select populated from useRoleManagement.listRoles.
  • Owner is excluded (ownership transfer is a deliberately separate flow, not a member default).
  • Defaults to Field Tech when present, falls back to the first assignable role.
  • roleId is now forwarded end-to-end to auth.v1.CreateUser (see "Server work" below).

Server work (in this PR)

  • proto/auth/v1/auth.protoCreateUserRequest gains optional string role_id = 2.
  • server/internal/domain/auth/service.go — resolves the role for the new user: empty role_id keeps the legacy ADMIN-default; non-empty validates org membership, rejects SUPER_ADMIN (ownership transfer is a separate flow), and runs the same parity check used by authorizeCallerForNewUserWithRole before persisting the assignment through the existing CreateUserOrganizationRole write.
  • Generated Go + TS regenerated; mocks updated. Two new test suites (TestParseInt64RoleID, TestResolveCreateUserRole_ValidationBranches) cover the resolver's validation branches. DB-backed integration coverage for the success path is left for the existing CreateUser integration suite to grow into (see test plan).

Placeholders & blocking deps

Two placeholders remain. Each is marked TODO(rbac): inline with the exact next step:

Placeholder What's needed
api/useRoleManagement.ts — in-memory dataset AuthzService with ListRoles / CreateCustomRole / UpdateCustomRole / DeleteCustomRole RPCs are now merged on main; swap each placeholder for the real authzClient.* call. The swap requires a pbToRoleItem adapter at the wire boundary — proto Role uses permissionKeys (not permissions), a numeric BuiltinKey enum (not the string union the client model uses), and Timestamp (not Date | null) for updatedAt. Inline comments at the swap sites describe the adapter shape.
features/settings/utils/permissionCatalog.ts — hand-maintained mirror of catalog.go AuthzService.GetPermissionCatalog RPC. Replace the constant with a fetch + cache; the helper functions (requiredReadsFor, withRequiredReads, lockedReadKeys) stay as-is.

Deferred from U11 plan

These U11 surfaces are intentionally not in this PR — they should land as separate follow-up work:

  • Per-user assignment editing on Team.tsx. Plan calls for a "Roles" cell with a modal that lets admins add/remove assignments on existing users. This PR only surfaces role at user creation time. Depends on AssignRole / UnassignRole / ListUserAssignments which are still Unimplemented server-side.
  • Assignment scope picker (org / site) on the assignment modal. Required for the plan's "Pool Operator" acceptance test (Site-A scoping). Lands with the per-user modal above.
  • Settings primary nav hidden when no sub-items match (FIELD_TECH scenario per plan). UI-only.
  • Delete-role with "remove all assignments and delete" affordance. Plan called for an explicit reassignment path; this PR disables Delete when memberCount > 0. UI behavior matches server semantics but doesn't match plan UX.

Files

New:

  • client/src/protoFleet/api/useRoleManagement.ts
  • client/src/protoFleet/features/settings/components/Roles.tsx
  • client/src/protoFleet/features/settings/components/CreateEditRoleModal.tsx
  • client/src/protoFleet/features/settings/components/DeleteRoleDialog.tsx
  • client/src/protoFleet/features/settings/utils/permissionCatalog.ts
  • Storybook entries for the three new components

Modified:

  • proto/auth/v1/auth.protoCreateUserRequest.role_id
  • server/internal/domain/auth/service.go (+ tests) — role resolution and validation
  • server/internal/domain/stores/interfaces/user.go + sqlstores/user.go + mocks — GetRoleByID
  • client/src/protoFleet/api/useUserManagement.ts — forwards roleId
  • client/src/protoFleet/features/settings/components/AddTeamMemberModal.tsx — role Select
  • client/src/protoFleet/features/settings/utils/formatRole.ts + test — FIELD_TECH → "Field Tech"
  • client/src/protoFleet/config/navItems.ts/settings/roles entry gated on role:manage
  • client/src/protoFleet/routePrefetch.ts + router.tsx — lazy import + route

Test plan

  • tsc --noEmit clean.
  • eslint clean.
  • vitest run src/protoFleet/features/settings src/protoFleet/api — 331 passed.
  • go build ./... clean; go test ./internal/domain/auth/... passes (including new TestParseInt64RoleID and TestResolveCreateUserRole_ValidationBranches).
  • Stories render the three new components in isolation (seeded with role:manage).
  • Manual: log in as ADMIN, walk Create/Edit/Delete on a custom role; verify the read-pairing lock behavior; add a team member with a role and confirm the assignment lands server-side.
  • Once AuthzService RPCs are wired: replace the placeholder hook body via the pbToRoleItem adapter, drop the permissionCatalog.ts mirror, confirm tests still pass.

@github-actions github-actions Bot added javascript Pull requests that update javascript code client labels Jun 2, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 2, 2026

🔐 Codex Security Review

Note: This is an automated security-focused code review generated by Codex.
It should be used as a supplementary check alongside human review.
False positives are possible - use your judgment.

Scope summary

  • Reviewed pull request diff only (134be96c232277b22ecddc2811c2f5f085c6556b...958e14e55d973e6f8ff4fe6d220a4d64ff5f84ce, exact PR three-dot diff)
  • Model: gpt-5.5

💡 Click "edited" above to see previous reviews for this PR.


Review Summary

Overall Risk: HIGH

Findings

[HIGH] Selected member role is silently dropped, creating ADMIN users

  • Category: Auth
  • Location: client/src/protoFleet/api/useUserManagement.ts:63
  • Description: createUser strips every non-numeric roleId before calling the server. The new add-member modal defaults to placeholder IDs like builtin-field-tech, so the request omits role_id. The server treats an empty role_id as the legacy default ADMIN role.
  • Impact: An operator can choose “Field Tech” or a custom restricted role in the UI, but the created user is actually granted ADMIN. The temporary password is returned immediately, so this is a real over-privilege path.
  • Recommendation: Do not show/select placeholder role IDs for user creation. Wire the modal to real AuthzService.ListRoles numeric IDs before sending role_id, or remove/disable role selection until it is backed by server data. Consider making the server reject omitted role_id for the new role-selection flow instead of silently defaulting to ADMIN.

[HIGH] Roles UI mutates only in-memory placeholder state

  • Category: Auth | Frontend
  • Location: client/src/protoFleet/api/useRoleManagement.ts:126
  • Description: useRoleManagement serves hard-coded roles and local module-level mutations instead of calling the already-generated/exported authzClient and implemented server AuthzService role CRUD RPCs.
  • Impact: Settings → Roles appears to create, edit, and delete RBAC policy, but no server role state changes. Operators can believe access was restricted or revoked when the backend still enforces the old roles.
  • Recommendation: Replace the placeholder hook with authzClient.listRoles, createCustomRole, updateCustomRole, and deleteCustomRole, including auth error handling and protobuf-to-UI mapping. If that is not ready, hide or disable the Roles route rather than shipping no-op RBAC controls.

Notes

No cryptostealing, SQL injection, command injection, network discovery, plugin, Rust, or infrastructure issues were visible in the reviewed diff.


Generated by Codex Security Review |
Triggered by: @mcharles-square |
Review workflow run

mcharles-square and others added 3 commits June 3, 2026 18:30
U11 from docs/plans/2026-05-19-001-feat-granular-rbac-plan.md. New
Settings → Roles page where admins (anyone holding role:manage) can:

  - List built-in and custom roles, with member counts and permission
    counts.
  - Edit any role except Owner (SUPER_ADMIN is immutable server-side).
  - Create custom roles via a grouped checkbox builder over the full
    permission catalog: search, collapse-by-resource, automatic
    inclusion of read-pairing dependencies, "Required" lock icons on
    reads still held by other selected actions.
  - Delete custom roles, gated on memberCount = 0 so members never end
    up orphaned mid-deletion.

Also wires role selection into Settings → Team:

  - AddTeamMemberModal grows a role Select populated from
    useRoleManagement. Owner is excluded (ownership transfer is a
    separate, deliberate flow). FIELD_TECH is the default when present.
  - useUserManagement.createUser accepts a roleId param (currently
    voided with a TODO; CreateUserRequest needs a role_id field — see
    inline comment).

Adds /settings/roles route + nav entry (gated on role:manage),
prefetch wiring, and stories for the three new components.

Notes on placeholders (each marked TODO inline):

  - api/useRoleManagement.ts serves an in-memory dataset derived from
    the catalog. The proto package authz.v1 currently exposes only the
    Permission / PermissionGroup messages; the ListRoles / CreateRole /
    UpdateRole / DeleteRole RPCs and AuthzService client land in a
    follow-up. The hook's signature mirrors useUserManagement so the
    settings components consume it identically.
  - features/settings/utils/permissionCatalog.ts is a hand-maintained
    mirror of server/internal/domain/authz/catalog.go, replaced by a
    AuthzService.GetPermissionCatalog fetch once that RPC ships.
  - useUserManagement.createUser accepts roleId but does not forward it
    yet — CreateUserRequest has no role_id field; add it then wire the
    forward in the authClient.createUser call.

formatRole gains a FIELD_TECH → "Field Tech" mapping so the role badge
on the Team table renders consistently with the role label in the
Roles list.
- CreateEditRoleModal: drop the prevKey/openKey render-phase reset
  pattern. Callers now remount the modal via key={role?.roleId ??
  "create"}, so the form state is seeded from useState defaults
  exactly once per open. Removes three inner key props on the input
  fields that existed only to remount them under the prior scheme.
- Roles.tsx: consolidate the two CreateEditRoleModal mounts (create vs
  edit) into one, gated on `open={showCreateModal || !!editRole}` with
  `role={editRole}`. Replaces the two handleX callbacks with a single
  handleEditor pair.
- useRoleManagement: extract a RoleCallbacks base for the shared
  onError/onFinally pair across the four *Props types. Drops ~8 lines
  of duplication.
- Type RoleItem.builtinKey as the union "SUPER_ADMIN" | "ADMIN" |
  "FIELD_TECH" (new BuiltinRoleKey export) instead of plain string —
  catches consumer typos at compile time.
- CreateEditRoleModal.toggleKey: when unchecking a locked read whose
  withRequiredReads expansion produces an identical set, return the
  previous reference so React doesn't schedule a no-op re-render of
  the permission tree.
…der UI

- Consolidate 12 permission groups into 6: hide fleet:read (auto-dependency),
  merge sites + racks into "Sites, buildings & racks", merge admin singletons
  (server logs, API keys, team, roles, fleet nodes) into "Administration"
- Simplify permission rows to description-only (remove action label + key)
- Remove lock badge UX; unchecking a read key now cascades removal
- Use standard modal width, design tokens, and textOnly Button for CTAs
- Drop role description field and built-in role callout banner
- Generalize edit title to "Edit role" with disabled name input for built-ins

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@mcharles-square mcharles-square force-pushed the feat/rbac-roles-management-ui branch from 9d6373d to 10dbae0 Compare June 3, 2026 15:34
Bug fixes visible today:
- CreateEditRoleModal no longer wipes role descriptions on save —
  description is optional in CreateRoleProps/UpdateRoleProps and
  updateRole preserves the existing value when omitted.
- Read-key checkboxes that still have action dependents render
  disabled with a "(required)" suffix via lockedReadKeys, replacing
  the misleading toggleKey cascade comment that promised behavior
  the code didn't implement.
- Roles page renders CreateEditRoleModal conditionally with a
  bump counter so two consecutive opens always get a fresh instance,
  fixing stale name/selection/query leak.
- Edit action is now hidden for all built-in roles (was only
  SUPER_ADMIN); server unconditionally rejects built-in mutation,
  so the UI must not expose it.

Catalog and copy alignment with server:
- Added activity:read to client permissionCatalog with the same
  description and resource as server catalog.go.
- role:manage description now matches the server: "Create, edit,
  and delete custom roles. Built-in roles cannot be modified."
- fleet is intentionally excluded from RESOURCE_TO_GROUP so
  fleet:read remains dependency-floor only — documented inline.

Land role_id end-to-end (was the unresolved P1 Codex finding):
- Added optional string role_id = 2 to auth.v1.CreateUserRequest;
  regenerated Go and TS.
- New domain helper resolveCreateUserRole: empty role_id keeps
  the legacy ADMIN-default; non-empty validates org membership,
  rejects SUPER_ADMIN, and runs the parity check used by
  authorizeCallerForNewUserWithRole before persisting the
  assignment via the existing CreateUserOrganizationRole path.
- useUserManagement.createUser forwards roleId on the wire.
- New TestParseInt64RoleID and TestResolveCreateUserRole_
  ValidationBranches cover the pre-resolver rejection paths.
- Extended UserManagementStore interface with GetRoleByID;
  regenerated mock.

Swap-target clarifications:
- TODO comments renamed to authzClient.createCustomRole /
  updateCustomRole / deleteCustomRole to match real method names.
- Added RoleItem-as-client-model comment plus pbToRoleItem
  adapter notes at swap sites: proto Role uses permissionKeys
  (not permissions), numeric BuiltinKey enum (not string union),
  and Timestamp (not Date | null) for updatedAt.

Maintainability:
- isImmutable(role) helper consolidates the SUPER_ADMIN / builtin
  predicate split.
- ALL_KEYS inlined into builtin-admin; ADMIN_KEYS alias removed.
- Migration-number comment in useRoleManagement replaced with
  intent-focused description.
The E2E teamAccounts spec failed every "add member" path with
"FleetError: invalid_argument (Common: 0) invalid role_id" because
the placeholder useRoleManagement emits non-numeric ids
("builtin-field-tech", "role-<ts>") that the server's int64 parser
rejects. Strip non-numeric roleId in useUserManagement.createUser so
the server applies its default role until the real AuthzService.ListRoles
swap provides numeric ids; the guard removes itself naturally once those
ids are on the wire.
@mcharles-square
Copy link
Copy Markdown
Collaborator Author

Superseded by #383 — production-ready version with real AuthzService RPC wire-up. Zero TODO(rbac): markers; placeholders and the wireRoleId guard are gone. Closing in favor of #383.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

client javascript Pull requests that update javascript code server shared

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants