Repository navigation
sec(api): bound update-any by its Constraints on planned-purchase run - #1192
Conversation
The run (execute now) route authorized through authorizeExecutionManagement, which lets any update-any holder act on another user's scheduled purchase without evaluating update-any's Constraints. Route it through the constraint-aware helper merged for pause/resume (LeanerCloud#1098), renamed to authorizeBoundedExecutionManagement, and re-run the same decision on the row the claim CAS returns so an edit landing before the claim cannot carry the row out of scope. Closes LeanerCloud#1129 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reached
This review includes 4 billable files and costs up to $1.00. Or wait 59 minutes for your next included review. View limit details
Comment |
|
VERDICT: APPROVE 9a5c4dc Base: origin/main 9fceda3. The fix addresses #1129. Run now goes through Consistency:
Findings (all nits):
Attacked:
|
|
Independent review 2 (claude-fleet, cold): APPROVE 9a5c4dc Findings (none blocking):
Against #1129: the issue holds at base, where run used
Attacked:
Verified locally, with no cloud credentials, under the heavy lock, using the repo's testcontainers fixture:
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
Local verification at
|
|
Marked ready and merged by IAmJSD's agent, at IAmJSD's request, after 2 independent reviews and local verification (see the review and verification comments above, all at |
Root cause
runPlannedPurchase(POST/api/purchases/planned/{id}/run) authorized throughauthorizeExecutionManagement. For a caller who isn't the row's creator, that only checks that update-any:purchases is held. It never evaluates update-any's Constraints. A user with update-any bounded toprovider=awsand an unbounded execute:purchases could therefore run another user's Azure scheduled purchase. #1134 (which closed #1098) fixed the same gap for pause and resume, but run still used the unbounded helper.Fix
authorizeExecutionPauseResume. It's renamed toauthorizeBoundedExecutionManagementbecause it now serves pause, resume and run. Its behavior is unchanged:revokeExecutionConstraintSets).authorizeExecutionManagementcreator check.authorizeUpdateAnyOnRow.authorizeClaimedRowManagementre-runs the same decision on the row that the claim CAS returns.runNowClaimCheckcalls it before the existing execute:purchases Constraints check. Without it, an edit that lands between the handler's last pre-claim read and the claim (the sec(purchase): constraint checks read recs before the CAS; execution buys from the post-CAS row (TOCTOU) #415 window) could move the row out of the bounded update-any's scope, and the row would still be bought.Closes #1129
Related work
authorizePlannedFullScopefor planned DELETE (update-any, then cancel-any, both bounded by Constraints). This PR doesn't touchauthorizePlannedPurchaseCancelorauthorizePlannedFullScope, so the two PRs edit different parts ofhandler_purchases.go. Once both land, the update-any-on-stored-row check is implemented in two places:authorizeUpdateAnyOnRowhere and the update-any step ofauthorizePlannedFullScopein fix(api): bound update-any by its Constraints on planned-purchase DELETE #1097. They should be consolidated into one helper, as the issue asks.Verification
All commands were run from the repo root at the head of this branch. Tests ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/nullbecause this machine's~/.aws/configdefault profile is invalid. The tests make no AWS calls.Unit tests (mock store, real auth matcher via
grantPermissions, realauth.Servicefor API keys):internal/api/handler_purchases_run_update_any_test.goErrorIs). The creator branch doesn't swallow it, and nothing runs.HandleRequest).authorizeClaimedRowManagement: the admin API key, a creator and a non-creator without update-any, a NULL creator, and a probe error.Integration tests (real Postgres via testcontainers, real handler and purchase manager, counting fake provider, no cloud API):
internal/api/handler_purchases_run_update_any_integration_test.goTestRunNow_Postgres_RowMovedOutOfUpdateAnyScopeBeforeClaimIsRefused: a non-creator holds update-any bounded to aws. A second writer runs a rawUPDATEthat moves the row to gcp after the handler's last pre-claim check and before the claim. Expected result: 403 "nothing was bought", zero provider calls, rowfailedwithno_submission, and no purchase_history.TestRunNow_Postgres_InScopeUpdateAnyNonCreatorPurchasesis the control: the same grant on an unmoved row buys once.Fail before, pass after. Production files were reset to
origin/main(75219c0). The test that only compiles against the new helper (TestAuthorizeClaimedRowManagement_WithoutUpdateAny) was removed for this run, and the rest were run:With the fix, all of these pass. A mutation probe was also run: with only the
authorizeClaimedRowManagementcall removed fromrunNowClaimCheck,TestRunPlanned_UpdateAnyBoundRecheckedOnClaimedRowfails.CI-equivalent checks:
Coverage gaps. All of this is unit, mock and local-Postgres evidence. No real cloud account or deployed environment was exercised, and nothing was purchased.
Out of scope
authorizeExecutionManagementis now reached only as the fallback for sessions without update-any. Its doc comment gets one added sentence; it isn't otherwise restructured.revokeExecutionConstraintSets) carry no amount, which matches pause, resume and cancel. The spend cap stays on execute:purchases, as before.authorizePlannedFullScopeis described under "Related work".Could a maintainer mirror the issue's triage labels onto this PR:
priority/p2,severity/medium,urgency/this-quarter,impact/few,effort/s,type/security?🤖 Generated with Claude Code