Repository navigation
fix(api): refuse a planned purchase cancel whose plan the caller cannot disable - #1158
Conversation
…ot disable
DELETE /api/purchases/planned/{id} cancels the execution and disables its
plan. Since LeanerCloud#621, a scoped caller whose scope covers only some of the plan's
accounts got 200 "canceled" while the plan stayed enabled, so the scheduler
kept buying for every plan account and the Plans page told the user nothing.
Check the plan's every-account mutation scope before anything is written and
refuse with a 403 that says why. The disable re-checks after the cancel; if
the plan's accounts changed in between, the request returns 409 saying the
purchase was canceled but the plan was not disabled. plan_disabled is now
true whenever present. Unrestricted callers are unchanged.
Closes LeanerCloud#623
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configuration
📒 Files selected for processing: 4
✨ Finishing Touches
Comment |
|
Independent review (claude-cc-platform reviewer, cold brief): APPROVE This fixes #623. A scoped caller whose scope does not cover every plan account now gets a 403 before any write: Authorization checks:
The "disable the plan" step has no plan-update permission gate on this route; only account scope applies. That predates this PR. Tests:
Nits:
Product decision, which you raised: a partially scoped user can no longer cancel their own child execution of a multi-account plan. The frontend still shows a generic "Failed to cancel". |
|
Independent review (claude-fleet): APPROVE 9079297 Findings (none blocking). A further non-blocking follow-up outside this diff was reported privately to the maintainers, per SECURITY.md.
Entry points:
No other path writes Attacked:
Verified locally, on a
Reviewed by an independent agent with no author context. Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
Local verification at
|
|
Marked ready and merged by the owner's agent after 2 independent reviews and local verification (see the review and verification comments above, all at |
Problem
DELETE /api/purchases/planned/{id}(deletePlannedPurchase,internal/api/handler_purchases.go) cancels the execution and then disables its plan. Since #621, the disable runs only when the caller may mutate every account on the plan (requirePlanMutationAccess). For a scoped user whose scope covers only some of the plan's accounts, the cancel still went through and the request returned 200canceledwithplan_disabled=false, but the plan stayed enabled. The scheduler (shouldNotifyPlan,Enabled && AutoPurchase) kept notifying and creating executions for every plan account. The frontend types this call asvoid, so the user saw a success and nothing else. The Plans page's "Cancel purchase and disable plan" dialog says the plan will be disabled.Fix: fail closed, not silent
Decision (please confirm): a scoped caller who may not disable the plan is now refused before anything is written. They get a 403 that says why. This reverses #621's choice to "cancel, skip the disable, report
plan_disabled=false". I chose refusal because:apiRequestcopies the JSONerrorintoerr.message, andplans.tsshowsFailed to disable purchase: <message>.Trade-off: a partially scoped user can no longer cancel their own account's child execution of a multi-account plan through this endpoint. An administrator with access to every plan account has to do it. If you would rather keep that cancel available, the alternative is a separate cancel-only route. That is a product decision, so I left it out of this PR.
Changes:
requirePlannedCancelPlanScoperuns after the existing permission, execution-scope and ownership gates and before the cancel. Unrestricted sessions return before any store read. Scoped sessions load the execution, and if it has a plan, runrequirePlanMutationAccess. A refusal becomes a 403: "canceling this scheduled purchase also disables its plan, which requires access to every account on the plan; nothing was changed. Ask an administrator ...". A store error still surfaces as a 500, and now also before the cancel. Before, the cancel was already committed when the 500 came back.disablePlanAfterCancelruns the every-account check again. If that check now refuses, the request returns 409: "this scheduled purchase was canceled, but its plan was not disabled ...". It does not return a 200, and it does not disable a plan that now reaches an out-of-scope account. A retry is then refused by the pre-check with the 403.plan_disabledis kept for compatibility and is nowtruewhenever present (omitted when there is no plan).types.goandopenapi.yamlare updated. The operation now documents the 403/409 behavior and lists409, whichcancelOrRecoverExecutioncould already return.Interaction with plan deletion:
purchase_executions.plan_idisON DELETE SET NULL, so a deleted plan shows up as an emptyPlanID. In that case the plan check is skipped and the cancel proceeds. A dangling plan ID cannot reach the 403.Verification
All of this is unit/mock evidence through
NewRouter(h).RoutewithMockConfigStore. Nothing ran against a real database or a real cloud account. Tests were run withAWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, because this machine's default AWS profile is invalid.Tests in
internal/api/delete_planned_purchase_scope_test.go:PartiallyScopedRefusedBeforeCancel: 403 with the reason, zeroTransitionExecutionStatus, zeroUpdatePurchasePlan, noGetPurchasePlan.ZeroAccountPlanRefusedBeforeCancel: same. Zero-account plans are hidden from scoped callers.PlanAccountLookupFailureSurfacesBeforeCancel: a store error is a non-client error and nothing is canceled.PlanAccountsChangeDuringCancel: the firstGetPlanAccountsreturns[A]and the second returns[A,B]. Result: one cancel, two lookups, 409 naming the half-done state, plan not disabled.ScopedNoPlanCancelsWithoutPlanCheck: a scoped cancel of a plan-less execution succeeds with no plan lookup.plan_disabledomitted without a plan.Fail before (the new tests on unmodified main):
Pass after: all 11
TestRouterDispatch_DeletePlannedPurchase_*pass.Mutation probes, each applied alone and then reverted. Every one is killed by an assertion, not by a panic:
return nil): AccountsChange fails.errNotFound: Partial and ZeroAccount fail.AllowsAllshort-circuit inverted: Partial, ZeroAccount, LookupFailure and AccountsChange fail.Commands:
Not run locally: gosec (the hook binary is not installed here), and the repo-wide
go test -race ./..., which I skipped because of limited disk on this machine. Only theinternal/apipackage was run with-race.Out of scope / follow-ups
cancelScheduledPurchaseand detail-modal cancel show a generic "Failed to cancel purchase" and drop the server message. Their dialog also says "The plan itself stays in place", which contradicts the backend disabling the plan for every caller (since fix(purchase): reject an empty CommitmentID before saving purchase history #774). The issue's other acceptance points (hide or disable "Disable plan" for scoped users on plans they cannot mutate, a frontend test) belong to a frontend change.authorizePlannedPurchaseCancelin the same file, but notdeletePlannedPurchase's body. fix(config): disable plans left with no accounts when their account is deleted #1065 and fix(config): lock a deleted account's plans before disabling them #1087 touch onlyinternal/config. fix(plans): keep a plan's other services on a legacy update #1041 touches plan updates only. I don't expect textual conflicts.Closes #623
Maintainer: please mirror the issue's triage labels on this PR:
triaged,priority/p3,severity/low,urgency/this-quarter,impact/few,effort/s,type/bug.🤖 Generated with Claude Code