Skip to content

fix(auth): bind MaxPurchaseAmount to total commitment, enforce on retry (follow-up to #1210) - #1477

Merged
cristim merged 1 commit into
mainfrom
fix/1210-max-purchase-amount-total-commitment
Jul 22, 2026
Merged

cristim merged 1 commit into
mainfrom
fix/1210-max-purchase-amount-total-commitment

Conversation

@cristim

@cristim cristim commented Jul 21, 2026 •

Copy link
Copy Markdown
Member

Summary

Adversarial code-review follow-up to #1210 (SEC-01, issue #1141). Two live evasion bugs against the MaxPurchaseAmount permission constraint, confirmed against current main:

  1. Basis was upfront-cost-only, not total commitment. purchaseConstraintSets summed only rec.UpfrontCost across the batch. A no-upfront (or partial-upfront) RI/Savings Plan has UpfrontCost at or near zero, so the constraint always passed regardless of the real committed spend (monthly_cost * term). A user capped at e.g. $1,000 could submit an arbitrarily large no-upfront purchase through the ordinary UI, with entirely honest data, and evade the cap.

    Fix: purchaseConstraintSets now sums each rec's total commitment via a new recTotalCommitment helper: UpfrontCost + MonthlyCost * (Term * 12). Term is years (AWS/Azure/GCP standard, matches the *12 convention already used in exchange_lookup.go); UpfrontCost/MonthlyCost are both batch totals for the rec's Count (not per-instance), so they sum directly with no rescaling.

    Also added:

    • requireNonZeroCommitment: a batch whose computed total commitment is exactly $0 is now rejected (400) rather than silently read as "unconstrained" (MaxPurchaseAmount==0 means uncapped to the matcher) -- per this repo's no-silent-fallback-on-money-paths convention.
    • A validatePurchaseRecommendation guard rejecting a negative monthly_cost, since the new formula would otherwise let a negative value offset/mask a real upfront cost and reopen the same evasion.
  2. retryPurchase bypassed the constraint gate entirely. requirePermissionConstraints had exactly two call sites on main (executePurchase, ri-exchange execute); the retry path never called it. A retry-any/retry-own session could relaunch a previously-failed purchase whose recommendations exceed their own execute:purchases Constraints, and a permission tightened after the original submission never applied to a replay.

    Fix: extracted a shared enforcePurchaseConstraints(ctx, session, recs) helper (builds the constraint sets, runs the zero-commitment guard, then requirePermissionConstraints) and call it from both validateExecutePurchaseRequest and retryPurchase, so both paths check identical Constraints.

Known follow-up (documented, not fixed here)

The constraint check still compares against client-supplied upfront_cost/monthly_cost in the request JSON; it does not re-price or re-quote against the provider at execution time. A fully malicious client (as opposed to the honest-UI-data scenario this PR closes) could still fabricate a near-zero monthly_cost to approximate the old evasion. Fully closing that requires binding the checked amount to a server-side quote before purchase, which touches the AWS/Azure/GCP purchase client code broadly -- out of scope for this focused fix per the reviewing team's guidance. Left as a documented gap; happy to file a tracking issue if wanted.

Testing

  • go build ./... -- exit 0
  • go vet ./... -- exit 0
  • go test ./internal/api/... ./internal/auth/... -- exit 0 (2461 tests)
  • golangci-lint v2.10.1 (CI-pinned, matches ci.yml) on internal/api -- 0 issues
  • gocyclo -over 10 -ignore "_test\.go" on touched files -- exit 0 (clean)
  • go test ./... (full repo): did not complete in this environment due to resource contention with a concurrent build in a sibling worktree (process repeatedly died mid-run across 3 attempts, no failure output, just never reached completion). Not run to green; flagging rather than claiming it passed.

Regression tests

  • TestHandler_executePurchase_NoUpfrontBatch_TotalCommitmentEnforced: a no-upfront batch ($0 upfront, $600/mo, 3yr term = $21,600 real commitment). Verified to fail pre-fix (constraint set carried MaxPurchaseAmount=0, mock expectation for 21600.0 never matched) and pass post-fix.
  • TestHandler_retryPurchase_PermissionConstraintsDenied: retry of a failed batch whose total commitment ($5,000) exceeds the retrying session's cap. Verified to fail pre-fix (panicked on an unexpected SavePurchaseExecution call -- no constraint check ever ran) and pass post-fix (403, no execution persisted).

Closes/references #1210.

Summary by CodeRabbit

  • Bug Fixes
    • Retry failed purchases now re-check execute:purchases permission constraints for the retrying session before saving.
    • Purchase maximums now reflect total committed spend per recommendation (upfront plus recurring over the full term), not just upfront cost.
    • Purchase recommendation validation now rejects negative monthly costs (while allowing nil or zero monthly costs).
    • Requests with exactly zero total commitment are no longer treated as uncapped.
    • Added additional safeguards and regression coverage to prevent over-limit or malformed purchases from being retried or submitted.

@cristim cristim added triaged Item has been triaged priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens urgency/now Drop other things impact/all-users Affects every user effort/m Days type/security Security finding labels Jul 21, 2026
@cristim

cristim commented Jul 21, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 16 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c8fe7940-288b-48b1-bc0a-a236b88370e4

📥 Commits

Reviewing files that changed from the base of the PR and between e7f9585 and 5355179.

📒 Files selected for processing (4)
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_guards_test.go
  • internal/api/handler_purchases_test.go
  • internal/api/validation.go
📝 Walkthrough

Walkthrough

Purchase validation now rejects negative recurring costs, constraint calculations include full commitment, and both fresh execution and retry flows enforce purchase constraints before persistence.

Changes

Purchase constraint enforcement

Layer / File(s) Summary
Commitment calculation and request validation
internal/api/handler_purchases.go, internal/api/validation.go, internal/api/handler_purchases_guards_test.go, internal/api/handler_purchases_test.go
Negative monthly costs are rejected, while purchase constraint caps use upfront plus recurring commitment over the term.
Retry constraint gate and regression coverage
internal/api/handler_purchases.go, internal/api/handler_purchases_test.go
Retries re-evaluate execute:purchases constraints before saving successor executions, with updated fixtures and denial tests.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • LeanerCloud/CUDly#1210: Both changes enforce execute:purchases permission constraints during purchase execution, including the MaxPurchaseAmount calculation.
  • LeanerCloud/CUDly#1454: Both changes strengthen constraint enforcement on purchase execution paths.

Sequence Diagram(s)

sequenceDiagram
  participant retryPurchase
  participant enforcePurchaseConstraints
  participant HasPermissionForConstraintsAPI
  participant SavePurchaseExecution
  retryPurchase->>enforcePurchaseConstraints: failed execution recommendations
  enforcePurchaseConstraints->>HasPermissionForConstraintsAPI: total commitment constraints
  HasPermissionForConstraintsAPI-->>enforcePurchaseConstraints: allow or deny
  enforcePurchaseConstraints-->>retryPurchase: constraint result
  retryPurchase->>SavePurchaseExecution: persist successor execution
Loading

Suggested labels: type/bug

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: binding MaxPurchaseAmount to total commitment and applying the constraint on retry.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1210-max-purchase-amount-total-commitment

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

@cristim
cristim force-pushed the fix/1210-max-purchase-amount-total-commitment branch from 9a62a6b to e7f9585 Compare July 22, 2026 20:42
@cristim

cristim commented Jul 22, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

purchaseConstraintSets capped MaxPurchaseAmount against a batch's total
upfront cost only. A no-upfront or partial-upfront commitment's real
cost is the recurring monthly charge over the term, not the (possibly
zero) upfront alone, so an honest no-upfront purchase could evade an
otherwise-binding cap. Fix the basis to the batch's total commitment
(upfront plus monthly_cost * term_months, recTotalCommitment) and
reject a batch whose computed total is exactly zero rather than
silently reading MaxPurchaseAmount==0 as "unconstrained"
(requireNonZeroCommitment). Also reject a negative monthly_cost at the
request boundary, since it would otherwise offset the recurring leg
and reopen the same evasion.

retryPurchase never consulted the execute:purchases permission
Constraints at all, so a retry-any/retry-own session could replay
another user's over-cap failed purchase, and a permission tightened
after the original submission never applied to a replay. Route both
the direct-execute and retry paths through a new shared
enforcePurchaseConstraints helper so they check identical Constraints.

Adversarial review follow-up to #1210 (SEC-01, issue #1141).
@cristim
cristim force-pushed the fix/1210-max-purchase-amount-total-commitment branch from e7f9585 to 5355179 Compare July 22, 2026 21:25
@cristim
cristim merged commit 602774f into main Jul 22, 2026
19 checks passed
@cristim
cristim deleted the fix/1210-max-purchase-amount-total-commitment branch July 27, 2026 11:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/all-users Affects every user priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens triaged Item has been triaged type/security Security finding urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant