Skip to content

fix(purchase): refuse recommendations owned by another account - #2196

Merged
cristim merged 2 commits into
mainfrom
fix/2191-purchase-account-guard-v2
Oct 13, 2026
Merged

cristim merged 2 commits into
mainfrom
fix/2191-purchase-account-guard-v2

Conversation

@cristim

@cristim cristim commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Closes #2191

Replaces #2193 (rebuilt from a clean branch with synthetic account IDs only).

What

Recommendations are fetched with linked-account scope, so a payer run gets rows for other accounts, but PurchaseCommitment buys in the caller's own account. Nothing compared rec.Account with the credentials' account, so a purchase would commit the payer account to a member account's usage.

  • withCallerAccount resolves the account with STS GetCallerIdentity into Config.CallerAccountID. It runs in both purchase entry points (runPurchaseAndReport, runToolFromCSV), on dry runs too, and fails the run if the account cannot be resolved.
  • validatePurchasePreconditions (main pipeline and CSV loop) rejects a recommendation whose Account differs from the caller's, and fails closed when the caller account is unknown. The row is recorded as a failed result and audit entry; other rows proceed. A row with an empty account (CSV may omit it) has nothing to compare.
  • Cross-account purchase is not supported by the current design, so this refuses rather than assuming a role. cli#2131 (inventory) is untouched.
  • go.mod: service/sts moves from indirect to direct.

Tests (mocks only; no purchase API is called)

TestCheckPurchaseAccount (including the unknown-caller case), TestPurchaseSingleRec_RefusesOtherAccountsRecommendation (dry and real), TestProcessPurchaseLoop_RefusesOtherAccountsRecommendationWithoutPurchasing (the mock client sees exactly one PurchaseCommitment, for the caller's own row), TestWithCallerAccount. TestMain stubs the STS fetcher with a synthetic account; existing audit-path and CSV tests set or use that account, and the two multi-account end-to-end expiry tests now expect the guard's refusal for rows of other accounts.

Fail before (guard body neutered): the three guard tests fail with "An error is expected but got nil" / "Should be false". After: go test ./cmd passes (29s, skipping TestQueryMajorEngineVersions_ErrorHandling, which hangs locally in this session independent of this change). go vet and the pre-commit hooks pass.

Live dry run (read-only, no --purchase)

Under payer-account credentials, a member-account EC2 row is now refused in the dry run with "recommendation is for account X but the credentials are for account Y; cross-account purchases are not supported ...", and the CSV row has Success=false. Before this change the same row was reported as a successful dry-run purchase.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Purchases are now blocked when the AWS credential account cannot be verified or does not match the recommendation’s account, helping prevent cross-account purchases.
    • Account lookup failures stop purchase processing and are reported as errors.

Cost Explorer returns linked-account recommendations to a payer, but a
purchase call buys in the caller's own account. The purchase path never
compared rec.Account with the credentials' account, so a payer run could
commit the payer account to a linked account's usage.

Both purchase entry points now resolve the caller account with STS
GetCallerIdentity (failing the run if it cannot), and
validatePurchasePreconditions rejects a recommendation whose account
differs, or when the caller account is unknown, on dry runs too.

Refs #2191

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@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/many Affects most users effort/s Hours type/bug Defect labels Oct 13, 2026
@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Warning

Review limit reached

  • Run on-demand review

This review includes 3 billable files and costs up to $0.75.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 23 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 62 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-cli/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 8aff1a80-978a-401a-914d-311a517ec258
📥 Commits

Reviewing files that changed from the base of the PR and between fb5df32 and 8e75ee6.

📒 Files selected for processing: 3
  • cmd/main_test.go
  • cmd/multi_service_test.go
  • cmd/purchase_account_guard_test.go

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-cli/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 30f773b2-665a-4227-851e-c0ec860307a7
📥 Commits

Reviewing files that changed from the base of the PR and between f2275a7 and fb5df32.

📒 Files selected for processing: 15
  • cmd/audit_stop_2103_test.go
  • cmd/cancellation_test.go
  • cmd/ec2_zonal_az_test.go
  • cmd/main.go
  • cmd/main_test.go
  • cmd/multi_service.go
  • cmd/multi_service_test.go
  • cmd/purchase_account_guard_test.go
  • cmd/purchase_confirmation_test.go
  • cmd/purchase_safeguards_2121_test.go
  • cmd/rds_family_expiry_command_test.go
  • cmd/reservation_expiry_command_test.go
  • cmd/savings_plan_purchase.go
  • cmd/savings_plan_purchase_test.go
  • go.mod

Included review availability: This review used your included allowance. 0 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

📝 Walkthrough

The purchase flow now resolves the credential account through STS and checks it against each recommendation before purchase. It rejects unknown caller accounts and recommendations that specify a different account. Tests and fixtures now provide caller-account values and assert cross-account outcomes.

Changes: Purchase account validation

Layer / File(s) Summary
Account-aware purchase validation
cmd/main.go, cmd/savings_plan_purchase.go, cmd/purchase_account_guard_test.go
Config includes CallerAccountID. Purchase preconditions reject an unknown caller account or a recommendation with a different nonempty account ID. New tests cover matching, missing, and mismatched account IDs and refusal in individual and batch purchase flows.
Resolve and pass caller account
cmd/multi_service.go, cmd/purchase_account_guard_test.go, go.mod
The purchase and CSV paths resolve the caller account through STS and set Config.CallerAccountID before proceeding. Lookup failures and empty account IDs return errors. The resolved account is passed to purchase-precondition validation. STS is a direct module requirement.
Update purchase test fixtures
cmd/*_test.go
Existing purchase tests now set the caller account. CSV fixtures and expected purchase outcomes account for recommendations with different account IDs.

Priority: ⬆️ High

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant PurchaseFlow
  participant STS
  participant PurchaseValidation
  participant PurchaseCall
  PurchaseFlow->>STS: GetCallerIdentity
  STS-->>PurchaseFlow: return caller account ID
  PurchaseFlow->>PurchaseValidation: check caller account against recommendation
  PurchaseValidation-->>PurchaseFlow: return validation result
  PurchaseFlow->>PurchaseCall: proceed when validation succeeds
Loading
Merge Risk: ⚪ Minimal · up to fb5df

No actionable merge-blocking risk is identified; the purchase-account guard is ready for normal merge checks.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 36.59% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 41 functions across 14 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: rejecting purchase recommendations owned by another AWS account.
Linked Issues check Passed Issue #2191 requires caller-account resolution before purchase and refusal of recommendations for a different account. The PR resolves the account with STS in both purchase entry points, including dry…
Out of Scope Changes check Passed The changes stay within issue #2191. Production changes add caller-account resolution and purchase validation. Test and fixture changes support those behaviors. The direct STS dependency supports the …
Full details: Docstring Coverage

Explanation

Docstring coverage is 36.59% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 41 functions across 14 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

Drop the global toolCfg.CallerAccountID assignment from TestMain so
deleting the lookup from an entry point fails a test, set the account per
test where the purchase loops are called directly, and fix a lint finding
and the defaultTestAccount placement.

Refs #2191

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@cristim

cristim commented Oct 13, 2026

Copy link
Copy Markdown
Member Author

Independent review (claude-opus-5-5) of 8e75ee6: APPROVE

This re-verifies the PR after my block on fb5df32. The delta is one commit, 8e75ee6, touching cmd/main_test.go, cmd/multi_service_test.go and cmd/purchase_account_guard_test.go, all test files.

Earlier findings

  • The lint trailing newline is fixed, and Lint Code passes.
  • TestMain no longer sets toolCfg.CallerAccountID; the STS stub stays. Tests that call the purchase loops directly use useCallerAccount(t) (8 call sites).
  • The TestMain doc comment is back on TestMain.
  • The commits in origin/main..8e75ee6 contain no real account IDs (grep count 0).

Guard (fails closed)

checkPurchaseAccount refuses when the caller account is empty. Both purchase entry points, runPurchaseAndReport and runToolFromCSV, resolve the account through STS before any purchase. An STS error, or an empty account, stops the run.

Probes, run here at the head

  • Deleting the account lookup in runPurchaseAndReport fails TestRDSFamilyExpiryCommand, TestRecommendationCompletenessCommand and TestReservationExpiryCommand.
  • Deleting it in runToolFromCSV fails TestRunToolFromCSV and TestRunToolFromCSV_WritesAuditRecordsOnDryRun.
  • Re-adding the empty-caller skip branch fails TestCheckPurchaseAccount.
  • gofmt and go vet ./... are clean, go test -count=1 ./... passes, and the git-secrets self-test gives 44 PASS, 0 FAIL.

Live dry run

No --purchase; credentials are for the payer account.

  • EC2 Instance Savings Plans at linked scope: all 3 member-account rows are refused with the cross-account message, and none reaches a dry-run purchase.
  • Replaying that CSV with --input-csv: the same 3 rows are refused.
  • Compute at --sp-account-scope payer: the payer-account row passes to a dry-run purchase.

CI on 8e75ee6 is green: all required checks pass, and gosec and Trivy are skipped.

@cristim
cristim merged commit 83a5aed into main Oct 13, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/many Affects most users priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens triaged Item has been triaged type/bug Defect urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(purchase): refuse to purchase a recommendation owned by a different account than the caller

1 participant