actions-permission-cutoverrun.md01_define/output/spec.mdServer 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).
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).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).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.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.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.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.ActionResult
(success: false, Forbidden); a principal who holds it proceeds. Admin proceeds via the
allow-all short-circuit.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.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.admin-config-permission-cutover). They keep the allowedRoles shim.defaults.ts) —
both landed in permission-foundation; adding admin/config features is the deferred run's job.route.ts) — routes-permission-cutover.ui-capability-cutover.admin-view-as / multi-role-*.role-template-editor /
user-permission-editor. This run consumes the resolver's output; it does not build the editors.allowedRoles shim / deep per-service scope wiring — later runs once the cutover is
complete.apps/demo (dashboards mock app) — stays on static mock personas.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.mdfeat: actions-permission-cutover — enforce requiredPermission in server actionsThe 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).
apps/web/lib/actions/index.ts — resolveActionContext 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.| 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 |
allowedRoles shim — no clean registry home)components/proposals/proposal-detail/actions.ts → acceptProposal, 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.ts → disputeInvoiceAction: raising a dispute has
no registry action (only invoice.resolveDispute exists, for resolution).app/(app)/projects/[id]/actions.ts → completeActionItemAction: 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.allowedRoles
arrays excluded admin; the resolver now admits admin. Intended.milestoneService
advanceStatus still owner-scopes to the assigned expert, so csm/sdm get a no-op "not found".requireInvoiceForManager at the service
layer mitigates.lead.assignCsm for both the csm and sdm slots (identical holders;
the static gate can't distinguish the slot — behaviour is equivalent either way).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.Forbidden ActionResult; admin proceeds via the
resolver's allow-all short-circuit (enforced in resolveActionContext).ctx.permissionScope; migrated own-scoped surfaces continue to
enforce owner filtering at the service layer. (Deep per-service scope wiring is a later run.)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.authnOnly/requiredPermission migration are exercised by the Linting
and Typechecking workflows and the web preview build.apps/web/lib/actions/index.ts — confirm the enforcement precedence
(requiredPermission decides when present; allowedRoles shim decides otherwise) reads correctly.admin-config-permission-cutover covers them.03_release/output/investor-update.mdWho 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.mdmain (b161b56) on 2026-07-16, both gate boxes tickedapps/docs/app/technical/packages/services/page.mdx §Permissions (engine is no longer fully dark — server actions now enforce; shadow-mode note updated)/code-review high (complexity=complex; Claude CI review is disabled — the "Review diff against CONVENTIONS.md" check was skipped). Three findings:(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.PERMISSIONS_SHADOW — accepted; mitigated by the resolver's React cache() + 5s TTL (second call is a cache hit).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.Forbidden; admin via allow-all short-circuit — enforced in resolveActionContext.ctx.permissionScope; migrated own-scoped surfaces enforce owner filtering at the service layer.apps/web build pass in CI (re-run on the release commit).