Repository navigation
fix(api): enforce cancel permission constraints on planned purchase cancel - #978
Conversation
Independent review verdict: CLEAN at bbad6ebReviewer: the same fresh Claude security reviewer as #975 (Sonnet, high effort), not the author, cold brief. It reviewed the delta Blocking findings: none. On the planned DELETE route, cancel-any and cancel-own are now constrained by the stored execution's scope and evaluated on the session's effective permissions. Non-blocking, pre-existing (not introduced here):
Entry points after #975 + #978: session cancel, What was attacked and how (local macOS, unit tests with mocks):
Gate:
|
…elete
DELETE /api/purchases/planned/{id} moves a pending or notified execution to
canceled with the same cancel-any / cancel-own verbs as the session cancel,
but authorizePlannedPurchaseCancel returned nil on a bare cancel-any check, so
the Constraints configured on that permission were bypassed here even after
the session cancel path enforced them.
The cancel-any branch now evaluates the strict per-recommendation sets from
the #404/#681 revoke fix and falls back to the creator branch only on a 403.
The creator branch keeps its creator-only semantics and message, and applies
cancel-own Constraints only when the creator also holds cancel-own, so a
creator holding delete:purchases without cancel-own still deletes. update-any
and delete stay unconstrained (#961).
Refs #678, #404, #681
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The creator branch of the planned-purchase DELETE asked whether the owner holds cancel-own, but requirePermissionConstraints evaluates a user-API-key session against the KEY's effective permissions. A delete:purchases-only key whose owner created the execution and holds cancel-own was therefore denied by the cancel-own Constraints check, where it used to be allowed. sessionHoldsVerb now asks the principal that is actually evaluated: the owner for user sessions, the key for key sessions (probed with an empty set, which any held permission satisfies). Cancel-own Constraints apply only when that principal holds cancel-own. Adds tests for a delete-only key, a key with out-of-scope and in-scope cancel-own, and a cancel-any constraint store error that must not fall back to the creator branch. Refs #678 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A store error while probing whether the API key holds cancel-own must refuse the planned delete with that error instead of treating the verb as not held. Refs #678 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bbad6eb to
74c0b62
Compare
|
Retargeted to main after #975 merged (f08485d) and rebased with All three commits are identical, so the CLEAN verdict above carries over to 74c0b62. A local |
…ETE (#1097) DELETE /api/purchases/planned/{id} granted full scope to any holder of update-any:purchases on the verb alone, so a session whose update-any was constrained to one provider, service, region or account could cancel any user's scheduled purchase outside that scope. authorizePlannedPurchaseCancel now evaluates update-any exactly as #678 (#978) evaluates cancel-any: strict per-recommendation constraint sets built from the stored row (revokeExecutionConstraintSets), checked through requirePermissionConstraints on the session's effective permissions (the key for a user API key, #979). A Constraints 403 falls through to cancel-any and then to the creator branch; a non-creator keeps the terminal constraint message. Probe and constraint-store errors refuse and never fall through. Closes #961 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Closes #678. Stacked on #975 (base
fix/cancel-permission-constraints); retarget to main after #975 merges.DELETE /api/purchases/planned/{id}(authorizePlannedPurchaseCancel) also cancels pending, notified and paused executions. It used to gate on barecancel-anyand on a creator match, so a constrainedcancel-anyholder who was denied by #975 on POST /cancel could still cancel the same execution here. This PR closes that bypass for the cancel verbs:revokeExecutionConstraintSetsplusrequirePermissionConstraints). It falls back to the creator branch only on a 403. Store errors propagate.delete:purchaseswithout cancel-own can still delete. Cancel-own Constraints apply only when the principal actually being evaluated holds cancel-own.sessionHoldsVerbprobes the key's effective permissions, which already require the owner's grant as well. A delete-only key keeps working, and a probe error refuses the request; it never falls back.update-anyanddeletebranches are unchanged and tracked by sec(api): planned-purchase DELETE ignores the granting permission's Constraints #961.errNotFound.wrapConstraintDeniedis kept on the cancel-any path for consistency. DELETE planned has no token fall-through, so the wrap is inert there; a comment says so.Behaviour change for API keys: on this route, a key session's cancel-any gate now follows the key. A delete-only key whose owner holds cancel-any can no longer cancel other users' executions through the owner's privilege. This tightens access and matches #975 and #957.
Tests
Added to
handler_purchases_cancel_constraints_test.go:TestDeletePlannedPurchase_APIKeyCancelOwnFollowsTheKey: a delete-only key with owner cancel-own allowed, an out-of-scope key denied, an in-scope key allowed.TestDeletePlannedPurchase_APIKeyVerbProbeErrorRefuses.TestDeletePlannedPurchase_ConstraintStoreErrorDoesNotFallBack.The existing router_660 creator-with-delete test now stubs the new probe. Its assertion is unchanged.
Mutants, all killed:
Verification (local, macOS)
go test -race -count=1 ./internal/api/... ./internal/auth/... ./internal/server/...passes (4893 tests at ecbf996;./internal/apipasses at bbad6eb).Review
🤖 Generated with Claude Code
Summary by CodeRabbit