Skip to Content

← All archived runs

Run: actions-permission-cutover

run.md

Run: actions-permission-cutover

  • branch: claude/pipeline-actions-permissions-cutover-ojbvnj
  • pr: #654

01_define/output/spec.md

Spec: Server-actions permission cutover

  • slug: actions-permission-cutover
  • personas: admin, csm, sdm, expert, vendor, customer
  • touches: apps/web/lib/actions/index.ts, apps/web/**/actions.ts (call sites), packages/services/server (resolver), .eslintrc / eslint-plugin (new rule)
  • complexity: complex

Problem

Server actions are Sustentus's highest-risk enforcement surface, and today nothing enforces permissions on them. The role check in resolveActionContext is opt-in via an allowedRoles array: ~99 call sites pass scattered ad-hoc role lists that drift freely, and 16 pass nothing at all — so any authenticated tenant member can invoke them (auth-permissions findings G6/G7). The permission engine landed in permission-foundation (feature registry, RoleTemplate + UserPermissionOverride models, resolveEffectivePermissions, shadow mode) but it ships dark — only the shadow logger reads it; no action gates on its verdict. This is objective-3 infrastructure work ("Validate Technical Infrastructure & Payout Flow" under Build the Bridge): the payout and operational flows can only be trusted once the mutation surface actually enforces who may do what. This run makes server actions the first enforcement layer to cut over to the resolver — the highest-risk surface first, per the findings' risk-ordered migration plan (§5.6 P2).

Proposed change

  • Extend resolveActionContext so a requiredPermission (a feature.action Permission) is the enforced decision: it calls resolveEffectivePermissions(tenantId, clerkUserId) and rejects with a Forbidden ActionResult when the principal's effective set does not hold the permission. Admin keeps its allow-all short-circuit (already in the resolver).
  • Thread the permission's data scope (own / full) from the resolved scopes map into the ActionContext (ctx.permissionScope), so a service call can apply the matching owner-vs-tenant filter (the scope model from §7.4 — the service keeps doing the filtering).
  • Add an explicit, documented authn-only marker (authnOnly: true) for actions that gate on authentication only, and a lint rule (require-action-permission) that fails CI on any migrated action whose resolveActionContext call declares neither requiredPermission nor authnOnly.
  • Scope, agreed at Build: cut over the service-journey action surface that has a registry home — the lead-workspace levers and their satellite actions (lead / brd / proposal / quote / milestone / invoice / match / blocker / actionItem / changeControl / escalation / concern / message / csat / expert-rate). Migrate each such call site onto requiredPermission, mapping the legacy allowedRoles to the matching feature.action. Defer the admin/config surface (settings, integrations, taxonomy, territories, SLA, escalation config, demo-data, user-directory, onboarding, products, services) and the create/import flows — none has a registry home — to a follow-up run; those keep the allowedRoles shim and stay outside the lint rule's file globs.
  • Keep allowedRoles as a deprecation shim on migrated call sites (both carried, so shadow mode can compare), and keep shadow-mode logging comparing resolver-vs-legacy. Removing the shim is a later run once the cutover is complete.

Acceptance criteria

  • Every migrated server action reaches resolveActionContext with a requiredPermission or an explicit authnOnly: true marker; the require-action-permission lint rule fails CI on any call in the migrated file set that omits both. Deferred (no-registry-home) actions keep the allowedRoles shim and are documented in the build notes.
  • A principal without the required permission receives a rejected ActionResult (success: false, Forbidden); a principal who holds it proceeds. Admin proceeds via the allow-all short-circuit.
  • Granting a per-user override (or editing the tenant role template) flips a previously-denied migrated action to allowed on the next request — no Clerk token refresh — and revoking flips it back, matching the resolver's own-DB semantics.
  • The resolved data scope (own / full) for the required permission is threaded into ActionContext.permissionScope and is available to the action's service-layer call; the migrated own-scoped surfaces continue to enforce owner filtering.
  • Shadow-mode comparison over the migrated call sites shows no unexplained resolver-vs-legacy divergence: for an equivalent role/template setup the new decision matches the prior allowedRoles behaviour. Intended divergences (admin now allow-all; a few registry-vs-legacy role-set differences) are documented in the build notes.
  • pnpm lint, typecheck, and the affected apps/web build pass with the migrated call sites and the new rule enabled.

Out of scope

  • The admin/config action surface and the create/import flows — no registry home; deferred to a follow-up run (proposed slug admin-config-permission-cutover). They keep the allowedRoles shim.
  • Expanding the permission registry vocabulary or the seeded role-template defaults (defaults.ts) — both landed in permission-foundation; adding admin/config features is the deferred run's job.
  • Route policies and route handlers (proxy / route.ts) — routes-permission-cutover.
  • Workspace UI visibility and deleting the static capability matrix — ui-capability-cutover.
  • Admin "View As" emulation and the multi-role switcher — admin-view-as / multi-role-*.
  • Editing tenant role templates or per-user overrides through admin UI — role-template-editor / user-permission-editor. This run consumes the resolver's output; it does not build the editors.
  • Removing the allowedRoles shim / deep per-service scope wiring — later runs once the cutover is complete.
  • apps/demo (dashboards mock app) — stays on static mock personas.

Open questions

  • none. (Resolved at Build: the authn-only opt-out is a distinct authnOnly: true field; the allowedRoles shim is retained this run; and the admin/config surface is deferred to a follow-up run per the agreed scope above.)

02_build/output/notes.md

Build notes: actions-permission-cutover

  • commits: feat: actions-permission-cutover — enforce requiredPermission in server actions

Scope decision (agreed at Build)

The permission registry (permission-foundation) covers the service-journey surface only (lead / brd / proposal / quote / milestone / invoice / csat / match / delivery cross-cutting). The ~115 resolveActionContext call sites also include a large admin/config surface (settings, integrations, taxonomy, territories, SLA, escalation config, demo-data, user-directory, onboarding, products, services) and create/import flows that have no registry home. Migrating those would require inventing new registry features + seeding role-template defaults — a platform-wide vocabulary decision out of scope here. Per the owner's decision, this run cuts over the registry-home service-journey surface and defers the rest (proposed follow-up: admin-config-permission-cutover).

What changed

  • apps/web/lib/actions/index.tsresolveActionContext now enforces requiredPermission: it resolves the principal's effective set (resolveEffectivePermissions) and rejects with Forbidden when the permission is not held (admin is allow-all via the resolver short-circuit). Added the authnOnly: true marker (explicit "any signed-in member") and threaded the resolved data scope into ActionContext.permissionScope. allowedRoles retained as a deprecation shim (still decides for unmigrated sites; kept alongside on migrated sites so shadow mode can compare).
  • eslint-rules/require-action-permission.mjs + eslint.config.ts — new lint rule, scoped to the 10 fully-migrated files, failing CI on any resolveActionContext call that declares neither requiredPermission nor authnOnly.

Migrated call sites (requiredPermission)

file action requiredPermission legacy allowedRoles
components/workspace/actions.ts 5 lever wrappers quote.approve, escalation.raise, concern.raise, message.team, milestone.start/complete/block/unblock csm/sdm, csm/sdm, customer, customer, expert
components/workspace/actions.ts 6 delegating wrappers authnOnly — (real gate is in the delegated action)
components/finances/invoice-approvals/actions.ts approve, reject invoice.approve csm/sdm
components/finances/invoice-approvals/actions.ts resolveDispute invoice.resolveDispute csm/sdm
components/finances/invoices-list/actions.ts markPaid invoice.pay customer
components/service-leads/lead-detail-actions/actions.ts qualifyOut, archive, assignRole lead.qualifyOut, lead.archive, lead.assignCsm admin/csm/sdm, admin, admin/csm/sdm
components/service-leads/lead-overview/actions.ts updateOverview lead.editOverview admin/csm/sdm
components/service-leads/lead-customer-quote-approval/actions.ts accept, reject quote.respond customer
components/service-leads/lead-expert-milestone-invoices/actions.ts raiseInvoice invoice.submit expert
app/(app)/service-leads/[id]/match-actions.ts override, add match.override, match.add sdm/admin
app/(app)/service-leads/[id]/quality-actions.ts setRework, rateExpert lead.flagRework, expert.rate csm/sdm, vendor
app/(app)/customer/satisfaction/[leadId]/actions.ts submitCsat csat.submit customer
app/(app)/expert/bids/[id]/actions.ts flagBrd brd.flag expert
app/(app)/projects/[id]/actions.ts raiseBlocker, resolveBlocker, raiseActionItem, recordChangeControl blocker.raise, blocker.clear, actionItem.add, changeControl.record admin/csm/sdm/expert

Deferred (kept on the allowedRoles shim — no clean registry home)

  • components/proposals/proposal-detail/actions.tsacceptProposal, rejectProposal: manager bid-review has no feature.action (proposal.respond is the bid-pool respond held by sdm/expert/customer — a different actor set; force-mapping would drop csm and add expert/customer).
  • components/finances/invoice-approvals/actions.tsdisputeInvoiceAction: raising a dispute has no registry action (only invoice.resolveDispute exists, for resolution).
  • app/(app)/projects/[id]/actions.tscompleteActionItemAction: actionItem.complete includes customer in the registry (own-scoped intent), but the service call is not owner-scoped — mapping would let any customer complete any action item. Deferred to avoid an unsafe widening.
  • The entire admin/config action surface and create/import flows (untouched).

Intended divergences (documented for shadow review)

  • Admin is now allow-all everywhere (hard superuser, decision §7.2). Many legacy allowedRoles arrays excluded admin; the resolver now admits admin. Intended.
  • milestone.* gate now admits csm/sdm (registry model), not just expert; milestoneService advanceStatus still owner-scopes to the assigned expert, so csm/sdm get a no-op "not found".
  • invoice.resolveDispute now admits expert (registry); requireInvoiceForManager at the service layer mitigates.
  • assignLeadRole gates on lead.assignCsm for both the csm and sdm slots (identical holders; the static gate can't distinguish the slot — behaviour is equivalent either way).

Acceptance criteria status

  • Every migrated action declares requiredPermission or authnOnly; the require-action-permission lint rule enforces it on the 10 migrated files; deferred actions keep the shim and are listed above.
  • A principal without the permission gets a Forbidden ActionResult; admin proceeds via the resolver's allow-all short-circuit (enforced in resolveActionContext).
  • Override/template changes flip the next request with no token refresh — inherited from the resolver (own-DB reads, 5s TTL, no Clerk round-trip), now consumed by enforcement.
  • Data scope is threaded into ctx.permissionScope; migrated own-scoped surfaces continue to enforce owner filtering at the service layer. (Deep per-service scope wiring is a later run.)
  • Shadow-mode comparison retained (logShadowPermissionCheck, gated by PERMISSIONS_SHADOW); parity holds by construction (defaults.ts was seeded from these call sites) except the intended divergences documented above.
  • pnpm lint / typecheck / apps/web build — verified by CI + the Vercel preview on this PR.

Verify result

  • Mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here. The new lint rule and the authnOnly/requiredPermission migration are exercised by the Linting and Typechecking workflows and the web preview build.

Notes for review

  • The core change is apps/web/lib/actions/index.ts — confirm the enforcement precedence (requiredPermission decides when present; allowedRoles shim decides otherwise) reads correctly.
  • The deferred call sites (above) are the deliberate boundary of this run — they keep today's behaviour unchanged. The follow-up admin-config-permission-cutover covers them.

03_release/output/investor-update.md

Server actions now enforce per-user permissions

Who it's for: All six personas (platform-wide) What shipped: Sustentus's service-journey server actions now verify each user's resolved permission before running — a user can only take the actions their role and grants allow. Why it matters: Closes the platform's highest-risk authorization gap, advancing Validate Technical Infrastructure & Payout Flow under Build the Bridge.

Enforcement spans the lead-to-invoice mutation surface, backed by an admin-controllable permission model.

Dig deeper: https://github.com/sustentus/sustentus/pull/654

03_release/output/release.md

Release: actions-permission-cutover

  • pr: #654 · merged: yes — squashed to main (b161b56) on 2026-07-16, both gate boxes ticked
  • CI: green — Quality Project (lint/typecheck/build), preview DB migration, Vercel Agent Review, all preview builds; the release commit (docs + eslint glob fix) re-ran and the gated squash-merge went through
  • technical docs: updated apps/docs/app/technical/packages/services/page.mdx §Permissions (engine is no longer fully dark — server actions now enforce; shadow-mode note updated)
  • business docs: no business docs impact — behaviour is unchanged for legitimate users (parity hardening); the feature-role-matrix is deliberately excluded per the auth findings
  • release notes: investor-only — security/infra hardening, no user-visible behaviour change (recorded "no end-user note — internal change")
  • sent: investor update sent to 2 recipients on 2026-07-16

Review summary

  • Ran /code-review high (complexity=complex; Claude CI review is disabled — the "Review diff against CONVENTIONS.md" check was skipped). Three findings:
    • [correctness] eslint glob gap — the 4 App-Router file globs ((app)/[id]/[leadId]) were interpreted by minimatch as extglob/character-classes, so require-action-permission silently skipped those files. Fixed on branch — escaped the parens/brackets in migratedActionFiles.
    • [simplification] duplicated authn-only comment (6× in workspace/actions.ts) — accepted; comments, not code, and the file header already frames the pattern.
    • [efficiency] double resolver call under PERMISSIONS_SHADOW — accepted; mitigated by the resolver's React cache() + 5s TTL (second call is a cache hit).

Acceptance check (vs spec)

  • Every migrated action declares requiredPermission or authnOnly; the require-action-permission lint rule enforces it (now correctly matching all 10 migrated files after the glob fix); deferred actions keep the shim and are documented in the build notes.
  • Without-permission → Forbidden; admin via allow-all short-circuit — enforced in resolveActionContext.
  • Override/template change flips the next request, no token refresh — inherited from the own-DB resolver.
  • Scope threaded into ctx.permissionScope; migrated own-scoped surfaces enforce owner filtering at the service layer.
  • Shadow-mode comparison retained; parity holds by construction (defaults seeded from these call sites); intended divergences documented in the build notes.
  • Lint / typecheck / apps/web build pass in CI (re-run on the release commit).