Skip to Content

← All archived runs

Run: auth-hygiene-fixes

run.md

Run: auth-hygiene-fixes

  • branch: claude/auth-hygiene-fixes-rqjey3
  • pr: #650

01_define/output/spec.md

Spec: Auth hygiene & role-guard fixes (Phase 0)

  • slug: auth-hygiene-fixes
  • personas: Admin (all personas benefit indirectly — the guard protects every persona dashboard)
  • touches: apps/web/proxy.ts, apps/web/lib/route-policies.ts, apps/web/lib/resolve-app-user-for-tenant.ts, apps/web/lib/auth.ts, apps/web/app/(app)/{admin,csm,sdm,expert,vendor,customer}, apps/web/app/(auth)/no-role, packages/services/src/db/models/{types,user}.ts, apps/demo/lib/personas.ts
  • complexity: standard

Problem

The auth system carries live defects that are independent of the wider access-and-permissions redesign, so they can and should be fixed now. The worst is a security-class gap: persona dashboard routes are unguardedrequireRole() exists (apps/web/lib/auth.ts) and protects admin settings pages, but no persona dashboard segment calls it. Root entry redirects by role, yet with no per-route guard a stale redirect on account switch or direct navigation can render another role's dashboard (e.g. a customer seeing the admin dashboard). Alongside it sit four smaller hygiene defects: the CSM portfolio-snapshot cron can never run in production (the proxy 302s /api/cron/* to sign-in before the handler's CRON_SECRET check runs); an unrecognized Clerk org role is JIT-provisioned as admin (?? "admin" — latent privilege escalation); ROUTE_POLICIES ordering is manual and untested with a docstring that misdescribes the actual deny behaviour; and the role enum is duplicated in ≥4 places with no shared constant. This is Phase 0 of the platform-access-permissions epic (Build the Bridge → 2026-Q2 Objective 3, Validate Technical Infrastructure & Payout Flow): making the existing role system trustworthy is the prerequisite for the multi-role work that follows, and G2/G5 are shared prerequisites of that track. The role-guard gap is also catalogued in the qa-backlog report, which explicitly cross-links the fix to this epic rather than duplicating it — this run owns and closes it, so the two do not double-track.

Proposed change

Fix the Phase 0 set, with no behaviour change for legitimate users:

  • Role guard (P1, security): add a role guard at each persona route segment — a per-segment layout guard is preferred over per-page — so a signed-in user only ever renders their own persona dashboard. Confirm role resolution is correct when switching accounts (no stale role rendering the previous persona's dashboard).
  • G1 — cron reachability: add /api/cron(.*) to the proxy's public-route matcher so the request reaches the handler; the handler keeps its CRON_SECRET gate as the sole authorization check.
  • G2 — fail closed on unknown role: remove the ?? "admin" JIT-provisioning fallback; an unmapped Clerk org role provisions no role and lands the user on /no-role.
  • G4 — safe route-policy ordering: make ROUTE_POLICIES ordering safe (sort by specificity at module load, or a unit test asserting no entry shadows a longer/more-specific one) and correct the stale getAllowedRoles docstring so it matches the proxy's actual deny-by-default behaviour.
  • G5 — one role enum: export a single UserRole source of truth from packages/services (its shared entry point) and consume it in apps/web/lib/auth.ts, the Mongoose user enum, and apps/demo/lib/personas.ts, removing the independent definitions.
  • G9 — /no-role hygiene (minor): style the /no-role page to match the app and stop rendering the raw Clerk userId.

Acceptance criteria

  • A signed-in user can only render their own persona dashboard; direct navigation to another persona's dashboard route, or a stale role after an account switch, never renders another role's dashboard (it redirects or is denied instead).
  • An unauthenticated request to /api/cron/csm-portfolio-snapshot reaches the handler and is rejected only by the CRON_SECRET check — no redirect to /sign-in.
  • A user whose Clerk org role is unmapped is NOT provisioned as admin; they land on /no-role.
  • A test fails if any ROUTE_POLICIES entry shadows a more specific one, and the getAllowedRoles docstring matches the proxy's actual deny behaviour.
  • There is one UserRole source of truth exported from packages/services; a grep finds no independently-defined role enums in web, demo, or services.
  • The /no-role page is styled consistently with the app and no longer renders the raw Clerk userId.

Out of scope

  • G3 (stale role mirror between Clerk and the app user) — owned by the clerk-role-sync feature.
  • Any permission-model / capability work (route-permission cutover, role templates, multi-role, view-as, etc.) — those are later stubs in this epic. This run touches only the existing role system, not a new permission model.
  • Broader QA-backlog root-cause fixes (broken links, deny-by-default routing symptoms) — this run closes only the role-guard security item that the QA backlog cross-links here.

Open questions

  • none. The guard mechanism (per-segment layout guard preferred) and each G-item's approach are settled by the stub and the report's Phase 0 section; Build implements them. Exact per-segment vs. shared-layout placement is an implementation detail within the agreed "guard at each persona route segment" outcome.

02_build/output/notes.md

Build notes: auth-hygiene-fixes

  • commits: feat: auth-hygiene-fixes — role guards, cron route, fail-closed provisioning, safe route-policy ordering, shared UserRole, /no-role hardening

What changed

Role guard (P1, security)

  • apps/web/app/(app)/{csm,sdm,customer,expert,vendor}/layout.tsx (new) — per-segment layout guards calling requireRole("<persona>"). These five segments are single-persona (every sub-route belongs to that one role), so a blanket segment guard is safe and is the report-preferred approach.
  • apps/web/app/(app)/admin/dashboard/page.tsx — in-page await requireRole("admin"). The admin segment is the exception: /admin/settings/*, /admin/industries, /admin/status are intentionally shared with CSM/SDM, so a segment-level requireRole("admin") layout would lock legitimate CSM/SDM users out (a behaviour change we must avoid). admin/page.tsx re-exports the dashboard page, so guarding the page covers both /admin and /admin/dashboard without touching the shared sub-routes. This is defence-in-depth on top of the existing proxy policy — a stale role on account switch or direct navigation can no longer render another persona's dashboard.

G1 — cron reachable

  • apps/web/proxy.ts — added /api/cron(.*) to the isPublicRoute matcher. The handler keeps its CRON_SECRET bearer check as the sole authorization; the proxy no longer 302s the cron request to /sign-in before that check runs.

G2 — fail closed on unknown role

  • apps/web/lib/resolve-app-user-for-tenant.ts — removed the ?? "admin" JIT-provisioning fallback. An unmapped Clerk org role now returns { ok: false } (no provisioning) instead of silently minting an admin. The proxy already redirects an unmapped-role user to /no-role before they reach the app; this closes the defence-in-depth escalation for any other entry path. All callers already handle the ok: false branch (discriminated union), so no caller change.

G4 — safe route-policy ordering + docstring

  • apps/web/lib/route-policies.ts — the authored rules are now ROUTE_POLICY_RULES, and the exported ROUTE_POLICIES is that list sorted by path length descending (then exact-first). Shadowing can only happen when a non-exact rule's path is a prefix of another's, and a prefix is always strictly shorter — so the sort guarantees the more-specific rule is evaluated first, regardless of authoring order. This prevents shadowing by construction, which is stronger than a test — and the repo has no test infrastructure (CONVENTIONS bans writing tests), so the stub's "unit test" alternative was not an option. Fixed the stale getAllowedRoles docstring (and the header comment) which claimed a non-matching path "passes through unrestricted" — the proxy actually treats null as deny-by-default and redirects to the user's home.

G5 — single UserRole source of truth

  • packages/services/src/shared/roles.ts (new) — canonical USER_ROLES tuple + derived UserRole type, in the isomorphic /shared entry point.
  • packages/services/src/shared/index.ts — export the new module.
  • packages/services/src/db/models/types.tsUserRole is now imported from shared/roles and re-exported, so every existing models/types importer resolves unchanged.
  • packages/services/src/db/models/user.ts — Mongoose role enum is now [...USER_ROLES] instead of a hardcoded array.
  • apps/web/lib/auth.ts — removed the local VALID_ROLES tuple + UserRole type; imports both from @sustentus/services/shared and re-exports UserRole so the many @/lib/auth importers are unaffected.
  • apps/demo/lib/personas.ts — kept dependency-free (per the standalone-dashboards-app rule and an explicit product decision); added a comment tying its persona ids to the canonical USER_ROLES and marking it the single local source of truth for the demo.

G9 — /no-role hardening

  • apps/web/app/(auth)/no-role/page.tsx — restyled with Typography + a centred card and no longer renders (or even reads) the raw Clerk userId.

Acceptance criteria status

  • A signed-in user can only render their own persona dashboard — five per-segment layout guards + the admin dashboard in-page guard; wrong-role renders redirect via requireRole.
  • Unauthenticated /api/cron/csm-portfolio-snapshot reaches the handler, rejected only by CRON_SECRET — cron path added to the proxy public matcher.
  • Unmapped Clerk org role is NOT provisioned as admin; lands on /no-role?? "admin" removed (fail closed) + existing proxy redirect to /no-role.
  • No route-policy entry shadows a more specific one; docstring matches deny behaviour — specificity sort at module load (by construction) + docstring/header comment corrected. Note: implemented as the sort option, not a test, because CONVENTIONS forbids writing tests.
  • One UserRole source of truth in packages/services; consumed in web + Mongoose enum — shared/roles.ts. The remaining full-six-role literals in the codebase are role-subset allow-lists (typed UserRole[], not enum definitions) and one pre-existing test fixture (apps/web/lib/workspace/capabilities.test.ts), left untouched (no drive-by refactor; demo left dependency-free by decision).
  • /no-role no longer renders the raw Clerk userId — page rewritten.

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

  • Guard placement is deliberately mixed: layout guards for the five single-persona segments, in-page guard for admin (because /admin/* has routes shared with CSM/SDM). See the role-guard section above.
  • apps/demo was deliberately NOT wired to the shared UserRole — it is the standalone, dependency-free dashboards app, and importing @sustentus/services would regress that. Confirmed as a product decision during Build.

03_release/output/investor-update.md

Persona dashboards now lock to their owner

Who it's for: Admins — and, indirectly, all six personas. What shipped: Every persona dashboard is now role-guarded, the daily portfolio-snapshot job can run in production, and an unrecognised sign-in can no longer be granted admin access. Why it matters: We closed a set of access-control gaps before real money moves — hardening the technical infrastructure that Build the Bridge depends on (2026-Q2 Objective 3).

Dig deeper: <merged-PR URL>