Skip to content

fix(purchase): re-check purchase constraints on the claimed row before buying - #1036

Merged
cristim merged 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-415
Oct 18, 2026
Merged

cristim merged 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-415

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator

Root cause

Every synchronous execute entry point checks the caller's permission Constraints (MaxPurchaseAmount, Providers, Services, Regions, AccountIDs) against a row read by GetExecutionByID. It then calls the purchase funnel, transitionApproveAndExecute, which buys the row its claim CAS (claimRunning → TransitionExecutionStatus ... RETURNING) returns. Nothing re-checked that returned row. SavePurchaseExecution is an unconditional full-row upsert, so a repricing or edit that lands between the check and the CAS gets bought without being checked against the cap. Confirmed on d7eb3b0d, the same shape as in the maintainer's reading at 68e0eaa.

Fail-before proof: the new Postgres regression below, run with the funnel's re-check disabled, has runPlannedPurchase return success for a row repriced from $300 to $5,000 under a $1,000 MaxPurchaseAmount cap. The same happens on the session approve path.

Fix (option (a): one funnel, fails closed)

  • purchase.ClaimedRowCheck (func(ctx, claimed *config.PurchaseExecution) error) is threaded through ApproveExecution, ApproveAndExecute, DirectExecute and RunPlannedPurchaseNow, which are all on the shared PurchaseManagerInterface.
  • transitionApproveAndExecute runs the check (in the new claimAndCheck) on the CAS-returned row right after the claim and before any audit stamp or provider call.
  • Each handler entry point passes a check that re-runs the same Constraints it already ran before the CAS (internal/api/claimed_row_check.go). The pre-CAS check stays, so an over-cap request on an unchanged row is still refused with the row untouched.
  • A refusal maps to the client error the check returned: 403 for a Constraints denial, 400 for a zero-commitment batch. If the check cannot be evaluated (auth backend error), the response is 500. Either way the body says nothing was bought, and the details carry status: failed. ErrAuditLoss keeps its precedence.

Entry points

Entry point Pre-CAS check Funnel Post-claim check passed
Run now (runPlannedPurchase) enforcePurchaseConstraints(execute) RunPlannedPurchaseNow same, on claimed row
Session approve (approvePurchaseViaSession, incl. approve-any/approve-own) authorizeSessionApprove ApproveAndExecute same, on claimed row
Email-link approve (approveViaToken) approveConstraintsForSession (incl. plan step cap) ApproveExecution → ApproveAndExecute same, on claimed row
Direct execute (executePurchase, execute_mode=direct) execute + authorizeSessionExecuteDirect DirectExecute same, on claimed row (creator taken from the row)
SQS approve worker (handleApproveMessage) none (token + actor, no session Constraints) ApproveExecution nil, unchanged
Retry (retryPurchase) enforcePurchaseConstraints(execute) on the failed row none (mints a pending successor from the checked struct) not affected: nothing is bought here; the successor goes through approve
RI exchange approve no Constraints on approve; executes the pre-CAS record it authorized n/a not affected (see out of scope)

Failure-state semantics (needs owner sign-off)

The maintainer's comment left this open ("revert to pending/paused vs mark failed with an audit note"). This PR takes the choice that matches the existing post-claim refusals: claimAndExecute's step cap re-check (failClaimedBeforeSubmission) and preExecutionRefusal (stale Azure pricing, ladder, armed re-drive). A refused row ends:

  • status = failed, no_submission = true, error = "purchase refused after claim: ... no longer passes the authorization checks: "
  • a plan row's suppressions are released in the same transaction (proven not submitted)
  • no approved_by, executed_at or pre_approval_skip_reason stamped (the check runs before them); transitioned_by records who claimed it
  • no revocation token minted and no executed email sent
  • recoverable through the normal Retry, which re-checks Constraints on the successor

Trade-off to flag: one capped caller whose run loses this race now fails the row, where a revert would have left it pending for another approver. Reverting would need a new running → pending/notified/paused transition with no existing precedent. Fail-closed was the safer default. A transient auth backend error during the re-check also fails the row, which is fail-closed.

Tests

  • internal/purchase/claimed_row_check_test.go (mock store, recording provider): for run-now, session approve, direct execute and token approve, a row repriced between the snapshot and the CAS is refused with ErrClaimedRowRefused. The check's own error stays reachable, the check saw the claimed row, the provider is never called, and the saved row is failed with no_submission and no approve/execute stamps. Also covered: the unmodified row still buys exactly once, a check backend error fails closed, a terminal save failure is ErrAuditLoss, a plan row releases its suppressions, and a nil check keeps the old behavior.
  • internal/api/claimed_row_check_test.go: each handler entry point passes a non-nil check that refuses the repriced CAS-returned row with a 403 (real auth matcher) without reaching the funnel's success path. The unmodified row runs. Covers the token path with approve-own cap, approve-any cap, and plan step cap with no verb.
  • internal/api/handler_purchases_claimed_row_integration_test.go (integration tag, real Postgres via testcontainers, real handler and manager, counting fake provider): a second writer reprices the row with a raw UPDATE during the handler's Constraints check, before the CAS. Run-now and session approve both answer 403, the provider has zero calls, the row is failed with no_submission, and there is no purchase_history. Unmodified controls complete with exactly one provider call.
  • One existing fixture adjusted: TestHandler_approvePurchaseViaSession_FourEyesOn_DifferentApproverSucceeds had its CAS mock return a row with no recommendations, a different row from the one checked. That is exactly this bug's shape, and the new check refuses it. The mock now returns the checked recommendations.

Mutation probes, each made and then reverted (all caught):

  • funnel skips the check: purchase tests and both Postgres regressions fail
  • each handler entry point passes nil: its tests fail
  • the ErrClaimedRowRefused mapping removed from executeFailureError / tokenActionError: the 403 tests fail
  • refusal without the terminal save: the row-state tests fail

Verification (local, macOS, at 0497cf7)

All tests ran with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true. Fakes only: no real cloud API calls, no purchases.

  • go build ./..., go vet ./..., go vet -tags integration ./...: clean
  • go test -count=1 per package: ./internal/purchase/..., ./internal/api/..., ./internal/mocks/..., ./internal/server/..., ./internal/testutil/...: ok
  • go test -tags integration -count=1 ./internal/purchase/ and ./internal/api/ (testcontainers Postgres): ok
  • golangci-lint v2.10.1 run ./... (config includes the integration tag): 0 issues
  • gocyclo -over 10 on every changed non-test .go file: none. transitionApproveAndExecute, approvePurchaseViaSession and approveViaToken stay at 10.
  • git-secrets --scan --cached of all changed files in a fresh git init repo with isolated HOME: clean. scripts/test-git-secrets-allowlist.sh: 42 PASS, 0 FAIL.

Not verified: a real cloud account (not in scope; no purchases).

Out of scope / follow-ups

Closes #415

🤖 Generated with Claude Code

…e buying

The execute entry points (run now, session approve, email-link approve,
direct execute) checked permission Constraints against a row read by
GetExecutionByID, but transitionApproveAndExecute buys the row its claim
CAS returns. A repricing or edit landing between the two was bought
unchecked.

Thread a purchase.ClaimedRowCheck through ApproveExecution,
ApproveAndExecute, DirectExecute and RunPlannedPurchaseNow. The funnel runs
it on the CAS-returned row right after the claim, before any audit stamp or
provider call; a refusal fails the row before submission (status failed,
no_submission, plan suppressions released), matching the existing
post-claim step cap and pre-execution refusals. Each handler passes a check
re-running the Constraints it already ran; refusals map to the check's
client error (403 for a Constraints denial). The SQS approve worker passes
nil (no session Constraints).

Closes LeanerCloud#415

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-quarter Within the quarter impact/few Limited audience effort/s Hours type/security Security finding triaged Item has been triaged labels Oct 16, 2026
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 22 billable files and costs up to $5.50.

Or wait 59 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 27e3e96d-14be-4bfd-96df-e96bab3d7027
📥 Commits

Reviewing files that changed from the base of the PR and between 6e80319 and 0497cf7.

📒 Files selected for processing: 22
  • internal/api/claimed_row_check.go
  • internal/api/claimed_row_check_test.go
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_claimed_row_integration_test.go
  • internal/api/handler_purchases_test.go
  • internal/api/mocks_test.go
  • internal/api/types.go
  • internal/purchase/approvals.go
  • internal/purchase/approvals_test.go
  • internal/purchase/armed_redrive_test.go
  • internal/purchase/aws_no_token_redrive_test.go
  • internal/purchase/azure_pricing_integration_test.go
  • internal/purchase/azure_pricing_test.go
  • internal/purchase/claim.go
  • internal/purchase/claim_guard_test.go
  • internal/purchase/claimed_row_check_test.go
  • internal/purchase/ladder_execution_recheck_test.go
  • internal/purchase/messages.go
  • internal/purchase/notified_run_now_test.go
  • internal/purchase/single_claim_integration_test.go
  • internal/server/interfaces.go
  • internal/testutil/mocks.go
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Oct 16, 2026

Copy link
Copy Markdown
Member

Independent review verdict: CLEAN at 0497cf7

Reviewer: fresh Claude agent (Sonnet, high effort), not the author, cold brief, acting as a security reviewer focused on the purchase money path. The PR body was treated as a claim. Coordinator: claude-cc-reviewer.

Blocking findings: none. No unapproved-spend bypass was found on the user-triggered execute paths, which are this PR's scope.

Entry points:

Path Re-check on the claimed row before buying Tests
Run-now handler_purchases.go:371 → RunPlannedPurchaseNow → claimAndCheck (claim.go ~:47) yes purchase ClaimedRowCheck, API, Postgres
Session approve :1003 yes (authorizeSessionApprove on the claimed row) same three
Token approve :911 yes (approveConstraintsForSession, including the plan step cap) API
Direct execute :3475 yes (execute constraints and execute-any/own) unit only (see note 1)
SQS approve messages.go:183 none needed: no session, so no caller constraints
claimAndExecute (SQS/cron) manager.go:195 pinned claim plus the existing step-cap recheck, unchanged
Scheduler fireOneDue scheduled_fire.go:81 no caller-constraints concept on this path
claimAndRedrive manager.go:578 no re-check; already the case before this PR (note 2)

Every provider call goes through executeAndFinalize (manager.go:248), and the new check runs before the audit stamp and the buy.

What is re-checked on the claimed row: the permission constraints (max amount as total commitment, provider, service, region, account IDs, non-zero commitment). The approve-any/own verb and creator ownership are re-run live, and the approver's permission is read at claim time. Four-eyes, account existence and amount versioning were not post-claim checks before this PR either.

When the re-check refuses:

  • failClaimedBeforeSubmission (step_cap.go:63) marks the row failed with no_submission, releases suppressions, and maps the error to 403 or 500.
  • No provider call, no approved_by/executed_at stamp and no revocation token.
  • The idempotency path is untouched, and the CAS is unchanged, so the loser of a double claim does nothing.

Notes (non-blocking):

  1. Test gap: replacing authorizeSessionExecuteDirect with return nil in directExecuteClaimCheck (claimed_row_check.go) is not caught by any test. Please add one.
  2. Already present before this PR: claimAndRedrive buys the pre-claim snapshot, and its claim is unpinned. Filed as fix(purchase): re-drive buys the pre-claim snapshot instead of the claimed row #1037.
  3. The failed-vs-reverted row semantics needs owner sign-off, as the PR body says. @cristim

What was attacked and how:

  • go vet ./internal/... is clean. go test -race -count=1 on api, purchase and server passes 4879.
  • -tags integration -v -run 'ClaimedRow|SingleClaim|Claim' on api and purchase against Docker Postgres: 142 PASS, 0 SKIP.
  • Mutation probes:
    • The funnel skipping the check gives 13 unit failures plus the Postgres run-now and session-approve tests.
    • Leaving the row running on refusal fails the four-path test.
    • A no-op four-eyes fails the four-eyes test.
    • Dropping the direct-execute authorization survives (note 1).

Gate:

@IAmJSD

IAmJSD commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

@cristim the Build Docker Image failure on this head is in scripts/test-govulncheck-retry.sh, which this PR doesn't touch: FAIL: persistent transient failure fails after 3 attempts (exit 1 want 1, calls 2 want 3). The same self-test failed earlier today on #963's Docker job, with transient fetch failures then success pass (exit 1 want 0, calls 2 want 3). So the retry-count assertion looks timing-flaky rather than tied to any change. Could you re-run the job? I can open an issue for the flake if that's useful.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/security Security finding urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(purchase): constraint checks read recs before the CAS; execution buys from the post-CAS row (TOCTOU)

2 participants