Skip to Content

← All archived runs

Run: in-app-role-assignment

run.md

Run: in-app-role-assignment

  • branch: claude/in-app-role-assignment-pipeline-4z9rp2
  • pr: #664

01_define/output/spec.md

Spec: In-app role assignment

  • slug: in-app-role-assignment
  • personas: Admin
  • touches: apps/web/app/(app)/users/[id]/actions.ts, apps/web/components/users/user-profile, packages/services/src/db/services/users/index.ts, packages/services/src/db/audit
  • complexity: standard

Problem

Changing a user's primary role is Clerk Dashboard work today — the only in-app role path is customer provisioning. Admins who otherwise manage access from inside the product (the point of the platform-access-permissions epic) still have to leave Sustentus to promote or move someone between personas. The extra-roles panel on the user-directory detail page already tells admins their "primary role is changed from role assignment" — but that surface doesn't exist yet, so the sentence points nowhere. This advances Build the Bridge / 2026-Q2 Objective 3 — Validate Technical Infrastructure & Payout Flow: with the role mirror now live (clerk-role-sync), the last step is letting admins drive the authoritative change (Clerk) from the directory and have Mongo follow immediately, instead of waiting on the Dashboard and a webhook.

Proposed change

Add a primary role-change action to the admin user-directory detail page (apps/web/app/(app)/users/[id]), sitting alongside the existing extra-roles panel:

  1. Assign role (write-through to Clerk). An admin picks the target user's new primary role and confirms. A server action writes the change to Clerk with clerkClient().organizations.updateOrganizationMembership({ organizationId, userId, role }) — Clerk stays the source of truth for the org-membership role.
  2. Immediate mirror re-sync. Right after the Clerk write succeeds, the action calls the existing syncRoleFromClerk(tenantId, clerkUserId, role) from clerk-role-sync so the Mongo mirror reflects the new role on the very next request, without waiting for the organizationMembership.updated webhook. The discriminator consequence is handled exactly as clerk-role-sync settled it: the base role is written through the raw user collection and the outgoing persona's discriminator fields are left dormant (not $unset). No new discriminator policy is introduced here.
  3. Last-admin guard. Before any write, the action blocks a change that would remove the tenant's last admin (an admin demoting the only remaining admin — including themselves). It uses the existing countByRole(tenantId) to check the admin count and rejects with a clear error; neither the Clerk membership nor the Mongo mirror is touched when the guard trips.
  4. Audit. Every successful role change writes an audit record (actor, target user, old role, new role, tenant, timestamp) via the existing packages/services/src/db/audit surface, so access changes are reviewable.

The action is admin-only (same authorization the extra-roles actions already enforce) and tenant-scoped. Assignable roles are the standard mapped personas; customer provisioning keeps its own dedicated flow and is not routed through this action.

Acceptance criteria

  • An admin changes a user's primary role from the user-directory detail page; the Clerk org membership role and the Mongo user.role mirror both reflect the new role, and the user's effective permissions follow the new role's template on their next request (mirror re-synced immediately, not on webhook delay).
  • A cross-persona change (e.g. expert → csm) writes the base role through the raw user collection per the clerk-role-sync policy; subsequent reads resolve the new persona's discriminator and the outgoing persona's fields are left dormant (not unset).
  • Attempting to demote the tenant's last admin (including an admin demoting themselves) is blocked with a clear error, and neither Clerk nor the Mongo mirror is changed.
  • Each successful role change is audited with actor, target user, old role → new role, tenant, and timestamp.
  • The action is available to admins only; a non-admin cannot invoke it (parity with the existing extra-roles actions).
  • Customer provisioning continues to work unchanged (not routed through this action).

Out of scope

  • Granting/revoking extra roles (membership publicMetadata.roles) — that is the existing Manage-roles panel / multi-role-switcher-ui; this run changes only the primary org-membership role. The two surfaces stay coherent on the same detail page.
  • Per-user permission overrides — that is user-permission-editor (stub 11); this is role change only, no override editing.
  • Inviting new users / creating memberships beyond the existing customer provisioning flow.
  • Re-syncing the mirror machinery itself, the webhook, or the discriminator policy — all delivered and settled by clerk-role-sync; this run consumes them, it does not change them.
  • Backfilling or reconciling historically drifted roles.

Open questions

  • none — scope, persona, the discriminator policy (inherited from clerk-role-sync), the last-admin guard, and the audit requirement are all settled. Confirmation UX and the exact placement of the panel are presentation details for Build, not requirement decisions.

02_build/output/notes.md

Build notes: in-app-role-assignment

  • commits: feat: in-app-role-assignment — admin primary role change (Clerk write-through + immediate mirror re-sync)

What changed

  • apps/web/app/(app)/users/[id]/actions.ts:
    • New assignPrimaryRole server action (admin-only, via resolveActionContext({ allowedRoles: ["admin"] }) — parity with the existing grant/revoke actions). It: reads the target's Clerk org membership; blocks a no-op (already that role); runs the last-admin guard before any write (if the current primary is admin and the new role isn't, userService.countByRole(tenantId).admin <= 1 → reject, nothing written); writes the new role through to Clerk via client.organizations.updateOrganizationMembership({ organizationId, userId, role: \org:${role}` })(Clerk is the source of truth); then immediately re-mirrors onto Mongo viauserService.syncRoleFromClerk(theclerk-role-sync` machinery — raw discriminator-key write, outgoing persona fields left dormant); then audits the change.
    • Audit is a structured server log — console.info("[role-assignment] primary_role_changed", { tenantId, actorClerkUserId, targetUserId, targetClerkUserId, oldRole, newRole, at }) — following the house [tag] event { … } convention (see webhooks/clerk, lib/tenant). There is no general audit-log collection, and the lead-scoped ActivityService / the DB-health db/audit module are neither a fit; a new collection + migration would be scope creep for this feature.
    • Extended getManageableRoles to also return assignablePrimary (the GRANTABLE_ROLES set minus the current primary) so the client needs no server-only import to build its options.
  • apps/web/components/users/user-profile/assign-primary-role-panel.tsx (new): client panel — shows the current primary badge and a Select + Change button that calls assignPrimaryRole, surfacing the action's error string inline (same pattern as ManageRolesPanel).
  • apps/web/components/users/user-profile/manage-roles.tsx: renders the new primary-role panel above the existing extra-roles panel in the same admin "Manage roles" card, with copy clarifying primary vs. extra roles.

Assignable-role decision

Assignable primary targets mirror GRANTABLE_ROLES (admin, csm, sdm, expert, vendor) — customer is deliberately excluded, keeping the spec's "customer provisioning keeps its own dedicated flow and is not routed through this action" and matching the existing extra-roles surface.

Acceptance criteria status

  • Admin changes a user's primary role from the directory detail page; Clerk membership role and the Mongo user.role mirror both update, and permissions follow the new role's template next request — updateOrganizationMembership then syncRoleFromClerk (immediate, not webhook-delayed).
  • Cross-persona change writes the base role through the raw user collection per clerk-role-sync policy (dormant outgoing fields) — delegated to the existing syncRoleFromClerk.
  • Demoting the tenant's last admin (incl. self) is blocked with a clear error and nothing is written — guard runs before the Clerk write, keyed on countByRole(tenantId).admin <= 1.
  • Each change is audited with actor, target, old → new role, tenant, timestamp — structured console.info audit line.
  • Admin-only — same allowedRoles: ["admin"] gate as the extra-roles actions.
  • Customer provisioning unchanged — not routed through this action; customer isn't an assignable target here.

Verify result

  • mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here.
  • No check expected to fail. updateOrganizationMembership's role param is OrganizationMembershipRole (org:${string}), which the `org:${role}` template literal satisfies.

Notes for review

  • Current role is read from the live Clerk membership (readMembershipRoles), consistent with the grant/revoke actions; the last-admin count uses the Mongo mirror via countByRole, per the spec.
  • The audit is a log line by design (no audit-log collection exists). If a persisted, queryable audit trail is wanted later, that's a separate feature (new model + migration), not this run's scope. </content>

03_release/output/changelog.md

Changelog: Change a user's role from the user directory

You can now change a user's primary role right from their profile in the user directory — no need to leave the product to do it.

  • Open a user's detail page, pick their new role, and confirm — their access updates to match the new role the next time they load a page.
  • This sits alongside the existing extra-roles controls: the primary role is the one a person starts on, while extra roles are the ones they can switch into.
  • You can't remove the last admin from your organisation, so no one can accidentally leave it without an administrator.
  • Every role change is recorded, so there's always a trail of who changed what and when.

03_release/output/investor-update.md

Admins now manage every role change inside the product

Who it's for: Platform administrators What shipped: Admins can change a user's role from the user directory, instead of leaving the product to edit it in the identity provider. Why it matters: Advances Build the Bridge / 2026-Q2 Objective 3 — Validate Technical Infrastructure & Payout Flow — by keeping access management in one trusted place.

Guard rails block removing the last admin, and every change is audited.

Dig deeper: <merged-PR URL> · <changelog entry URL>