Skip to Content

← All archived runs

Run: routes-permission-cutover

run.md

Run: routes-permission-cutover

  • branch: claude/pipeline-routes-permissions-cutover-lp66xw
  • pr: #656

01_define/output/spec.md

Spec: Route policies & handlers permission cutover

  • slug: routes-permission-cutover
  • personas: admin, csm, sdm, expert, vendor, customer
  • touches: apps/web/lib/route-policies.ts, apps/web/lib/api/require-permission.ts, apps/web/app/api/brd/chat/route.ts, apps/web/app/api/onboarding/chat/route.ts, packages/services/src/shared/index.ts (expose the pure permission vocabulary for edge/middleware use)
  • complexity: complex

Problem

Authorization on apps/web is still role-only and enforced by four uncoordinated layers. The route gate is one of them: apps/web/lib/route-policies.ts is ~50 hand-ordered { path, roles } entries plus three hardcoded special cases in getAllowedRoles (the service-lead-detail regex, /api/brd/, /api/onboarding/), and the two AI route handlers (api/brd/chat, api/onboarding/chat) hand-roll their own role !== "…" checks. None of it is connected to the permission model landed by permission-foundation, so route reachability and action access can silently disagree (findings §2.3 Layers 1 & 3, §5.3, defect G6). This is the route-layer half of the epic's enforcement cutover under Build the Bridge / 2026-Q2 Objective 3 — Validate Technical Infrastructure & Payout Flow: until every layer reads one resolved permission set, "who can reach this" drifts from "who can do this", and a role-template change has to be chased through hand-written route code.

Proposed change

Make the route gate read from the permission model where the model covers the surface, and give the AI route handlers a shared, permission-based gate — while keeping the proxy a coarse edge check per Clerk's guidance that middleware is not the resource-level gate.

  • Route policies declare a requiredPermission for the surfaces the registry models. For the shared workspace-feature routes (leads, proposals, activity, finances, csat, people), each policy names the permission it requires; the allowed-roles set the proxy uses is derived from that permission against the code-defined default role templates (PLATFORM_ROLE_TEMPLATE_DEFAULTS) — a static inversion computed at module load, not a per-request database read. Admin is included for every permission (it holds the full registry), mirroring the resolver's admin allow-all.
  • The permission registry is workspace-feature-scoped, so the ~30 admin/config/settings, role-home and persona-specific routes it does not model keep an explicit role list, each with a one-line documented reason (see scope decision 1 below). Extending the registry to cover them is permission-foundation (stub #3), out of scope here.
  • The proxy stays coarse and derivation may broaden reachability (scope decision 2). Feature-view permissions are coarser than the old route allow-lists, so deriving roles from them makes several routes reachable by more personas at the proxy. This is the intended coarse-proxy behaviour; the finer read-gating (who sees the staff list vs. their own records) lands with the workspace UI cutover (ui-capability-cutover, stub #7). Per-tenant template customisations and per-user overrides are not resolved in the proxy.
  • Fold the three hardcoded special cases into the policy table. /api/brd becomes a brd.view policy entry, /api/onboarding an explicit {admin,vendor} entry, and the service-lead-detail regex is removed — the /service-leads prefix rule already covers detail pages.
  • Add a shared requirePermission() helper for route handlers (apps/web/lib/api/require-permission.ts) and adopt it in api/brd/chat with brd.view, replacing its hand-rolled role !== "customer" check; its lead-ownership check stays (among brd.view holders only the lead's own customer passes it, so the endpoint stays customer-scoped). api/onboarding/chat keeps a documented role gate — no registry feature models fresh-tenant onboarding. The cron route keeps its CRON_SECRET gate.

Acceptance criteria

  • Route policies for surfaces the registry models carry a requiredPermission; those routes' proxy roles are derived from that permission against the default role templates, not hand-written. Routes with no registry feature keep an explicit role list, each annotated with a reason.
  • Editing a role template's default (which roles hold a route's requiredPermission) changes which personas the proxy admits to the corresponding routes without editing route code, demonstrated for at least one route/permission pair.
  • The three hardcoded special cases in getAllowedRoles are gone: /api/brd and /api/onboarding are policy-table entries, and the service-lead-detail regex is removed (covered by the /service-leads prefix rule).
  • api/brd/chat enforces access via the shared requirePermission() helper (returning 403 for a principal lacking the permission) with its lead-ownership check intact; api/onboarding/chat keeps its role gate with a documented reason (no registry feature to require).
  • The route ordering/shadowing invariant (specificity sort) still holds; no route that previously matched a policy now returns null (deny-all), and no route becomes reachable without a valid session and role. Where derivation broadens a route's reachable role set, that is the accepted coarse-proxy behaviour (decision 2), contained by stub #7 downstream.

Out of scope

  • Server actions (actions-permission-cutover, stub 5) and workspace UI visibility (ui-capability-cutover, stub 7) — the latter is what contains the coarse-proxy broadening.
  • Extending the permission registry to model admin/config/settings, onboarding, or persona-specific route surfaces — that is permission-foundation (stub 3), which this run consumes unchanged.
  • Making the proxy enforce per-tenant template customisations or per-user overrides — deliberate: the proxy stays a coarse reachability gate; resource-level enforcement is the action/handler layer's job.
  • The cron route's auth — it keeps its CRON_SECRET gate unchanged.

Open questions

  • none. Two scope decisions were settled during Build and are folded into the criteria above: (1) the registry only models workspace features, so route derivation is narrowed to the routes it covers; the rest keep documented explicit role lists (rather than extending the registry, which is stub #3). (2) derivation is allowed to broaden reachability at the coarse proxy, per the stub's stated coarse-proxy design, with the finer read-gating deferred to stub #7. A small packaging change — exposing the pure permission vocabulary via @sustentus/services/shared — was needed so the middleware-bundled route policies can derive without importing the DB-backed /server resolver.

02_build/output/notes.md

Build notes: routes-permission-cutover

  • commits: feat: route policies derive from the permission model + shared requirePermission() handler gate

Two scope decisions taken during Build (owner-confirmed)

The permission-foundation registry that this run consumes is an 18-feature workspace/delivery vocabulary (lead, invoice, proposal, csat, people, activity, …) — it does not model the ~30 admin/config/settings, role-home and persona-specific routes in route-policies.ts, and its feature-view permissions are coarser than the old route allow-lists. Two decisions were confirmed with the owner (via AskUserQuestion) before coding and are reflected in the revised spec.md:

  1. Narrow derivation to the routes the registry models. The shared workspace-feature routes derive their roles from a requiredPermission; the ~30 routes with no feature keep an explicit role list with a documented reason. Extending the registry to cover them is stub #3, not this run.
  2. Coarse proxy — derivation may broaden reachability. Deriving from feature-view makes several routes (e.g. /service-leads, /proposals, /finances) reachable by more personas at the proxy. This is the stub's stated coarse-proxy design; the finer read-gating that contains it lands with ui-capability-cutover (stub #7). Accepted interim exposure.

What changed

  • packages/services/src/shared/index.ts — re-export the pure half of the permission engine (Permission type, PLATFORM_ROLE_TEMPLATE_DEFAULTS) via /shared. route-policies.ts is bundled into the Clerk middleware, so it cannot import the DB-backed /server barrel (Mongoose) to derive roles. /shared is the isomorphic, edge/Node-middleware-safe entry; the modules re-exported carry no DB dependency.
  • apps/web/lib/route-policies.ts — rewritten. A policy is now either { requiredPermission } (roles derived from the default role templates by a static rolesForPermission() inversion) or { roles, reason } (explicit, for surfaces the registry doesn't model). Derived: /workspace, /service-leadslead.view; /proposalsproposal.view; /activityactivity.view; /finances/invoices, /financesinvoice.view; /finances/approvalsinvoice.approve; /userspeople.view; /csatcsat.view; and the folded /api/brdbrd.view. The specificity sort (auth-hygiene G4 invariant) and getAllowedRoles' deny-by-default are preserved.
  • getAllowedRoles — the three hardcoded branches removed: /api/brd and /api/onboarding are now policy-table entries; the service-lead-detail regex is gone (the /service-leads prefix rule covers detail pages, now at lead.view = all six).
  • apps/web/lib/api/require-permission.ts — new shared route-handler gate mirroring the server-action resolveActionContext permission path: resolves principal + tenant, runs the DB-backed resolveEffectivePermissions, enforces one permission (401/403), returns { userId, tenantId, appUser, scope } on success so handlers keep their resource-level checks.
  • apps/web/app/api/brd/chat/route.ts — adopts requirePermission("brd.view"); the hand-rolled auth() + role !== "customer" checks are replaced. Lead-ownership check retained → still customer-scoped (only the lead's own customer, among brd.view holders, passes it).
  • apps/web/app/api/onboarding/chat/route.ts — role gate retained with a documenting comment (no registry feature models onboarding; that's stub #3).

Acceptance criteria status

  • Registry-modelled routes carry requiredPermission and derive their roles via rolesForPermission(); unmodelled routes keep explicit roles + a reason — route-policies.ts.
  • A role-template edit reroutes personas without touching route code — e.g. removing lead.view from the sdm template in PLATFORM_ROLE_TEMPLATE_DEFAULTS drops sdm from /workspace and /service-leads on the next module load, no edit to route-policies.ts.
  • The three hardcoded special cases are folded/removed from getAllowedRoles.
  • api/brd/chat enforces via requirePermission("brd.view") (403 on failure) with lead-ownership intact; api/onboarding/chat keeps a documented role gate.
  • Specificity sort preserved; deny-by-default unchanged; no route returns null that previously matched. Broadening on some derived routes is the accepted coarse-proxy behaviour (decision 2).

Verify result

  • mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here. Release reads them back from the PR's check runs.
  • One cross-package note for CI: apps/web now imports Permission + PLATFORM_ROLE_TEMPLATE_DEFAULTS from @sustentus/services/shared; turbo's typecheck depends on ^build, so services rebuilds its dist/shared/*.d.ts first — expected to resolve cleanly.

Security carve-out (VADE review, PR #656)

/users and /activity are deliberately not derived — their pages render the full tenant directory / platform-wide activity log with no requireRole guard and no per-persona scoping, so deriving from people.view / activity.view would admit own-scoped personas (customer, and expert/vendor for activity) to every user's PII (emails, phone numbers). They keep today's staff-only {admin,csm,sdm} allow-list with a documented reason, restoring the exact pre-cutover access for these two routes. All other broadening (decision 2) stands.

Notes for review

  • Interim broadening (decision 2): /service-leads, /proposals, /finances, /csat (and the lead.view detail pages) become reachable by more personas at the proxy until stub #7 adds page-level view gating — the accepted coarse-proxy behaviour. /users and /activity are carved back to staff-only (see above) because their pages leak PII with no downstream containment. If the same staff-only treatment is wanted for the workspace-list routes too, say so and I'll pin them.
  • brd.view on /api/brd: staff (csm/sdm/admin/expert) now pass the proxy and the requirePermission check but 403 at lead-ownership; vendor (no brd.view) is blocked at the proxy. Net behaviour is still customer-only, now permission-derived.
  • /shared packaging change: exposing the pure permission vocabulary isomorphically is the minimal way to let the middleware derive without the DB barrel — no registry change.

03_release/output/investor-update.md

Route access now flows from one permission model, not scattered role checks

Who it's for: All six personas — platform-wide enforcement. What shipped: The web app's route gate and AI chat endpoints now derive access from the central permission model, replacing ~50 hand-maintained role rules. Why it matters: One authorization source across every layer hardens the infrastructure behind the payout flow, and lets a role change reroute access without code edits.

Advances Build the Bridge — Objective 3: validate technical infrastructure & payout flow.

Dig deeper: <merged-PR URL> · <technical docs URL>

03_release/output/release.md

Release: routes-permission-cutover

  • pr: #656 (https://github.com/sustentus/sustentus/pull/656) · merged: no — awaiting Ready to merge tick
  • CI: web preview Ready (green); format/lint/typecheck run in CI on the branch
  • technical docs: updated apps/docs/app/technical/packages/services/page.mdx (Permissions section — pure vocabulary now also on /shared; route policies + AI handlers now enforce via the model)
  • business docs: no business docs impact (internal enforcement change; the epic deliberately excludes the feature-role-matrix)
  • release notes: investor-only — internal/security change, no end-user changelog entry. Investor draft in this PR (03_release/output/investor-update.md)
  • sent: pending merge

Review summary (/code-review high)

  • No correctness findings. Verified no route policy was dropped — all 46 pre-cutover paths are present; only /api/brd and /api/onboarding were added (folding the hardcoded getAllowedRoles branches), and the service-lead-detail regex is subsumed by the /service-leads prefix rule (same all-six result). Specificity sort + deny-by-default unchanged. getAllowedRoles's only consumer is proxy.ts (signature unchanged); the RoutePolicyRoutePolicyRule/ResolvedPolicy rename has no external importers.
  • VADE security review flagged one over-exposure — /users and /activity deriving from people.view/activity.view would leak full-tenant PII to own-scoped personas. Fixed in 879f64c (carved back to staff-only with REASON_FULL_TENANT_PII); VADE marked it ISSUE_RESOLVED and confirmed /csat stays safely derived (CsatTable self-scopes). Thread resolved.
  • Accepted (not a defect): coarse-proxy broadening on /service-leads, /proposals, /finances, /csat — owner decision 2; finer read-gating lands with stub #7. Surfaced to the owner.
  • Non-blocking: gate.scope is unused in api/brd/chat (handler does explicit lead-ownership); the resolver does one internal user lookup after findByClerkUserId (mirrors resolveActionContext, cached). Neither warrants a change.

Acceptance check (vs spec)

  • Registry-modelled routes carry requiredPermission; roles derived via rolesForPermission() (inverting PLATFORM_ROLE_TEMPLATE_DEFAULTS); unmodelled routes keep explicit roles + reason.
  • A role-template edit reroutes personas without touching route code — derivation is a pure inversion recomputed at module load.
  • The three hardcoded getAllowedRoles branches are folded/removed (verified: /api/brd + /api/onboarding entries present; regex gone; all 46 paths retained).
  • api/brd/chat enforces via requirePermission("brd.view") (403 on failure) with lead-ownership intact; api/onboarding/chat keeps a documented role gate.
  • Specificity sort preserved; no previously-matched route returns null; broadening is the accepted coarse-proxy behaviour; the PII routes carved back to staff-only.