Skip to Content

← All archived runs

Run: multi-role-principal

run.md

Run: multi-role-principal

  • branch: claude/multi-role-principal-pipeline-2d7ttu
  • pr: #653

01_define/output/spec.md

Spec: Multi-role principal — hold many roles, switch in-session

  • slug: multi-role-principal
  • personas: Admin, CSM, SDM, Expert, Vendor
  • touches: packages/services/src/db/models/user.ts (+ index migration in packages/services/src/db/migrations/), packages/services/src/db/services/users, apps/web/lib/auth.ts, apps/web/proxy.ts, apps/web/lib/resolve-app-user-for-tenant.ts, apps/web (switchActiveRole server action), Clerk session-token template (ops)
  • complexity: complex

Problem

Each user is tied to exactly one role — the Clerk org-membership role, mirrored once into Mongo — so holding, say, both CSM and SDM means separate logins. The access-and-permissions direction (2026-Q2 Objective 3, "Validate Technical Infrastructure & Payout Flow", under the Build-the-Bridge initiative) needs a single principal to hold multiple granted roles and switch between them inside one authenticated session, with no sign-out, while single-role users see zero change. Two hard constraints shape the design: Clerk allows only one role per org membership, and every domain model references users by role-typed foreign keys (Lead, Invoice, Quote, CSAT, Milestone, Proposal, ExpertRating). This feature is the principal plumbing only — the same active-role principal the permission resolver will consume — not the switcher UI or admin grant/revoke UI.

Proposed change

"One principal, many role documents, one active role" — the plumbing that lets a granted user's effective role change in-session, backward-compatible for everyone else.

  • Granted roles in Clerk (tamper-proof). The org-membership role stays primary/default. Extra grants live in org-membership publicMetadata.roles and are surfaced as a session-token claim so middleware reads the granted set with no DB or Clerk API call. Granted set = { primary role } ∪ metadata.roles, each validated against the shared UserRole/VALID_ROLES constant. A missing claim means "no extra grants" (see Decisions).
  • Active role in an httpOnly cookie (sustentus-active-role, per tenant). A switchActiveRole(role) server action validates role is in the JWT-verified granted set, sets the cookie, and redirects to ROLE_HOME[role]. Middleware resolves the effective role = cookie value if in the granted set, else primary. No cookie → exactly today's behaviour. A tampered cookie can at worst select a role the user legitimately holds — no escalation, so no signing is needed.
  • Mongo: one doc per (clerkUserId, tenantId, role). Keep the discriminator model and all role-typed FKs intact. Replace the clerkUserId_tenantId_unique index with a partial-unique { clerkUserId, tenantId, role } (partial on clerkUserId existing). Ship the index change as a migration in packages/services/src/db/migrations/ (autoIndex is off); the migration verifies there are no existing duplicate (clerkUserId, tenantId) pairs before swapping the index.
  • Role-aware service API. userService.findByClerkUserId becomes role-aware (a role param / findByClerkUserIdAndRole); add findRolesByClerkUserId for downstream (switcher/admin) consumers. Provisioning stays lazy — the first switch into a role provisions that role's doc via ensureProvisionedFromClerk, and experts keep isActive: false so the onboarding gate fires on first switch into expert.
  • Auth helpers. apps/web/lib/auth.ts gains getGrantedRoles() and getActiveRole(); getRole()/requireRole() delegate to the active role. The effective-role resolution fails closed (no ?? "admin" fallback — shared with the auth-hygiene-fixes G2 hardening; if that run already removed the fallback this is a no-op, but this run must not depend on the fallback existing).

Decisions (settled at Define — not open)

  1. Grantable roles are internal onlyadmin, csm, sdm, expert, vendor. customer stays non-switchable (externally provisioned, billing implications). (Report decision 1.)
  2. Orphaned role docs are soft-deactivated (isActive: false), never hard-deleted — role-typed FKs and historical attribution point at them. Revoking a user's active role falls back to primary on the next request. (Report decision 2.)
  3. Notification preferences (per-role vs per-person) and notification fan-out dedupe are out of scope for this run (carried by multi-role-switcher-ui), so the per-role vs per-person product question does not block this plumbing.
  4. Session-token claim name is coordinated with ops across dev and prod Clerk instances (one agreed claim name added to the session-token template before the middleware change ships); code treats a missing claim as "no extra grants". (Report decision 4.)

Acceptance criteria

  • A multi-granted user switches roles in-session via the switchActiveRole server action with no re-login, landing on ROLE_HOME[role].
  • Single-role users (no active-role cookie, no extra grants) see zero behaviour change — verified by a parity smoke test.
  • A tampered or stale active-role cookie can at worst select a role the user legitimately holds; a value outside the JWT-verified granted set falls back to primary.
  • Role-typed FKs and attribution keep working unchanged; a switch into expert triggers onboarding (isActive: false); revoking the active role mid-session falls back to primary on next request.
  • findByClerkUserId resolves the correct per-role doc; findRolesByClerkUserId returns the full granted set of role docs; first switch into a not-yet-provisioned role lazily provisions its doc.
  • The index migration replaces clerkUserId_tenantId_unique with the partial-unique { clerkUserId, tenantId, role }, dry-runs clean on a production snapshot, and aborts if any duplicate (clerkUserId, tenantId) pair exists.

Out of scope

  • The sidebar role-switcher UI, active-role badge, admin grant/revoke ("manage roles") UI, and notification fan-out dedupe — multi-role-switcher-ui.
  • Admin "View As" emulation — admin-view-as (it reuses this principal plumbing).
  • Making customer switchable — externally provisioned, billing implications; not initially.
  • The permission resolver / feature-set registry itself — this run only supplies the active-role principal that resolver consumes (coordinated with permission-foundation, may run in parallel).

Open questions

none — the four report decisions are settled above, and the claim name is an ops coordination step (code treats a missing claim as "no extra grants"), not a build blocker.

02_build/output/notes.md

Build notes: multi-role-principal

  • commits: feat: multi-role-principal — principal plumbing (model + migration, service API, active-role auth, switch action)

What changed

  • packages/services/src/db/models/user.ts — replaced the clerkUserId_tenantId_unique index with a partial-unique { clerkUserId, tenantId, role } (clerkUserId_tenantId_role_unique, partial on clerkUserId being a string). One principal can now hold one doc per (tenant, role). The discriminator model and every role-typed FK are untouched. The partial filter uses $type: "string" rather than $exists: true because the collection carries non-Clerk rows with clerkUserId: null (field present) that must stay unconstrained — $exists would include them and they collide.
  • packages/services/src/db/migrations/1784100000000-multi-role-principal-user-index.ts — new migration that swaps the index (autoIndex is off). up aborts if any duplicate (clerkUserId, tenantId) pair exists (the new unique index couldn't build), then drops the old index and creates the new one; down is symmetric. Applies automatically on merge to main.
  • packages/services/src/db/services/users/index.tsfindByClerkUserId gains an optional role param (role-aware selection; omitted = existing single-role behaviour); added findRolesByClerkUserId returning all of a principal's role docs; ensureProvisionedFromClerk's existence check is now role-scoped so the first switch into a new role provisions that role's doc instead of short-circuiting on a sibling doc.
  • apps/web/lib/auth.ts — added ACTIVE_ROLE_COOKIE, GRANTED_ROLES_CLAIM (org_roles), GRANTABLE_ROLES (internal roles only), pure deriveGrantedRoles / resolveEffectiveRole, and async getGrantedRoles / getActiveRole. getRole() and requireRole() now delegate to the active role.
  • apps/web/proxy.ts — middleware resolves the effective role from the granted set plus the active-role cookie (request.cookies), falling back to primary when the cookie is absent, stale, tampered, or names a revoked role. No cookie → primary (unchanged).
  • apps/web/lib/resolve-app-user-for-tenant.ts — when a granted user has switched into a non-primary role, resolves that role's own doc, provisioning it lazily from the primary doc's identity (username deliberately not copied — it's unique per tenant and would collide; expert defaults to isActive: false so onboarding gates on first switch). Single-role users never enter this branch.
  • apps/web/lib/actions/switch-active-role.ts — new switchActiveRole(role) server action: validates the target is in the granted set, sets the httpOnly active-role cookie, redirects to ROLE_HOME[role]. Refuses a role outside the granted set.

Acceptance criteria status

  • Multi-granted user switches roles in-session via switchActiveRole (validate → set cookie → redirect to ROLE_HOME[role]), no re-login.
  • Single-role users see zero behaviour change — implemented so (no cookie + no extra grants ⇒ active role always equals primary ⇒ every path is the pre-existing one), but the parity smoke test is the reviewer's runtime check.
  • Tampered/stale active-role cookie can at worst select a role the user legitimately holds; a value outside the JWT-verified granted set falls back to primary (resolveEffectiveRole).
  • Role-typed FKs and attribution unchanged; switch into expert provisions isActive: false (onboarding gate fires); revoking the active role drops it from the granted set, so the next request falls back to primary.
  • findByClerkUserId resolves the correct per-role doc; findRolesByClerkUserId returns the full set; first switch into a not-yet-provisioned role lazily provisions its doc.
  • Index migration replaces clerkUserId_tenantId_unique with the partial-unique { clerkUserId, tenantId, role } and aborts on any duplicate (clerkUserId, tenantId) pair. The dry-run on a production snapshot is an ops/reviewer step before the merge applies it.

Verify result

  • mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here. No check is expected to fail.

Notes for review

  • Ops prerequisite before this ships behaviour: add the org_roles claim (from org-membership publicMetadata.roles) to the session-token template on both the dev and prod Clerk instances. Until then the claim is absent → every user has "no extra grants" → single-role behaviour, which is the safe default and how parity is preserved.
  • Out of scope, as specced: the sidebar switcher UI + active-role badge, admin grant/revoke UI, and notification fan-out dedupe all belong to multi-role-switcher-ui; View As to admin-view-as. switchActiveRole is the plumbing those will call.
  • syncRoleFromClerk (webhook / JIT primary-role mirror) left as-is — it rewrites a doc's role in place and is owned by clerk-role-sync. It only runs on the primary-role path and cannot collide while no user holds extra grants yet (claim absent); coordinate any change there with this principal when grants go live.
  • The active-role cookie is validated against the JWT-derived granted set on every read, so it is intentionally unsigned — no escalation is possible.

03_release/output/investor-update.md

Foundation shipped for multi-role access from a single login

Who it's for: Internal admin, CSM, SDM, expert, and vendor users What shipped: Backend plumbing for one person to hold several roles and switch between them in-session — no change for single-role users. Why it matters: Foundational infrastructure for Build the Bridge, Objective 3 (Validate Technical Infrastructure & Payout Flow) — safer operation and demos from one login.

A production-safe index migration landed clean, with zero change for existing users.

Dig deeper: <merged-PR URL>

03_release/output/release.md

Release: multi-role-principal

  • pr: #653 (https://github.com/sustentus/sustentus/pull/653) · merged: pending (Ready to merge ticked)
  • CI: green on f41c867 — Quality Project, Migrate preview database, Audit database, Vercel Agent Review all success; production migration runs on merge. (Earlier "Migrate preview database" failure on 9fd4d3a — null-clerkUserId index collision — fixed in d7562c4 by scoping the partial index to $type: "string".)
  • technical docs: no technical docs impact — the user-model uniqueness/single-role reality is not documented in apps/docs/app/technical/** (only in the off-limits reports/**); the new multi-role plumbing is internal and not yet user-visible.
  • business docs: no business docs impact — no user-facing behaviour changed (single-role users unchanged; the switcher UI is multi-role-switcher-ui).
  • release notes: investor-only — no end-user note (internal change). Investor draft in this PR; changelog intentionally omitted.
  • sent: pending (investor update, after merge)

Review summary

  • No correctness findings. The one real bug (primary-role fallthrough could serve an arbitrary sibling role doc) was caught by the Vercel review bot and fixed in f41c867 (scope the primary lookup to the primary role, fall back to unscoped for JIT role-sync). Bot re-confirmed ISSUE_RESOLVED.
  • switch-active-role.ts omits resolveActionContext — accepted: the action validates against the JWT granted set and needs no tenant/app-user resolution; adding it would be a pointless DB round-trip.
  • Redundant second auth() in resolveAppUserForTenant (via getActiveRole) — accepted: Clerk memoises auth() per request, so the cost is negligible.
  • readPrimaryRaw duplication across proxy.ts/resolve-app-user-for-tenant.ts — accepted as a cosmetic dedup not worth churning a green, approved PR.

Acceptance check (vs spec)

  • Multi-granted user switches roles in-session via switchActiveRole (validate granted set → set httpOnly cookie → redirect to ROLE_HOME[role]), no re-login.
  • Single-role users see zero behaviour change — by construction: no cookie + no extra grants ⇒ active role always equals primary ⇒ every resolution path is the pre-existing one. (Runtime parity smoke test is the reviewer's; left unticked on the PR.)
  • Tampered/stale active-role cookie can at worst select a role the user legitimately holds; a value outside the JWT-verified granted set falls back to primary (resolveEffectiveRole).
  • Role-typed FKs/attribution unchanged; switch into expert provisions isActive: false (onboarding gate fires); a revoked active role drops from the granted set so the next request falls to primary.
  • findByClerkUserId resolves the correct per-role doc; findRolesByClerkUserId returns the full set; first switch into a not-yet-provisioned role lazily provisions its doc.
  • Index migration replaces clerkUserId_tenantId_unique with the partial-unique { clerkUserId, tenantId, role } (partial on a string clerkUserId) and aborts on any duplicate (clerkUserId, tenantId) pair; verified green on the preview database.