fix(purchase): refuse a plan-less execution whose recs span cloud accounts - #2072
Conversation
…ounts A direct-execute or approved web purchase whose selected recommendations carried two different cloud_account_ids never fanned out: the resolver returned a nil provider config for "more than one account" exactly as it did for "no account at all", the factory built a client from the host's ambient credentials, every commitment was bought in the CUDly host account, and history was stamped with the ambient STS identity. A batch mixing attributed and unattributed recs bought the unattributed ones under the attributed account's credentials. SingleCloudAccountIDFromRecs now scans only the selected recs (the recs the money moves for) and returns errAmbiguousAccountScope for the multi-account and mixed shapes; resolveSingleAccountProvider propagates it so every executor entry point (direct, approval, SQS, retry, scheduled fire, cron, reaper re-drive) fails closed before a provider client exists. The web execute endpoint applies the same rule and returns 400 naming the accounts before an execution row is persisted. Nil config stays legitimate at the provider factory: GCP ADC and the ambient single-account deployment depend on it, so the guard lives at the only resolver that can be ambiguous. Closes #1902 Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01Fu9uWjxtDFx5HDKeMRt1jC
The CI Lint job runs golangci-lint with misspell in US locale, and three new comments used honour and behaviour. The exemption list only covers cancelled and initialised in named files, and the pre-commit hook does not run misspell, so this would have failed only on CI. Also corrects executePurchase's doc header, which still described the non-fan-out branch as falling back to ambient credentials. Since #1902 that branch resolves credentials from the recommendations' own cloud account and refuses a batch spanning more than one; only a batch with no attributed recommendations at all reaches ambient credentials. Co-Authored-By: claude-flow <ruv@ruv.net> Claude-Session: https://claude.ai/code/session_01Fu9uWjxtDFx5HDKeMRt1jC
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Essentials Run ID: 📒 Files selected for processing (5)
Included review availability: 3 reviews are currently available. Your included PR review attempts over the past 7 days set your current allowance at 5 reviews per hour. 📝 WalkthroughWalkthroughPlan-less purchases now derive account scope from selected recommendations. Multi-account and mixed-attribution selections return errors before provider creation, execution persistence, notifications, or purchase execution. The API rejects invalid batches with HTTP 400. ChangesPurchase scope validation
Priority: ⬆️ High — Impact reflects high issue severity. Estimated code review effort: 3 (Moderate) | ~20 minutes Severity of issue fixed: High Merge Risk: ⚪ Minimal · up to Plan-less purchases spanning ambiguous account scopes are rejected before execution or persistence, preventing ambient credentials from purchasing in the wrong account. The change is ready to merge. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
What
Closes #1902.
A direct-execute purchase whose selected recommendations spanned two cloud accounts never fanned out.
singleCloudAccountIDFromRecscould not distinguish "no account on any recommendation" from "more than one distinct account", returning an empty result for both, and the caller then built a provider client from the host's ambient credentials. Every commitment for both target accounts was bought in the CUDly host account, and the saved history stamped the ambient identity. The per-recommendation permission check had already passed against the real target accounts, so authorization succeeded for accounts the money never reached.The helper is now exported as
SingleCloudAccountIDFromRecsand returnserrAmbiguousAccountScopefor that shape.resolveSingleAccountProviderpropagates it, so every executor entry point fails closed, andvalidateExecutePurchaseRecommendationsapplies the same rule at the API boundary so a 400 comes back naming the accounts before an execution row is persisted.Three things the issue did not say
Seven entry points share the defect, not one. Direct execute, session approval, email-token approval, the SQS approve and execute messages, retry, the delayed scheduled fire, the cron path and the reaper re-drive all funnel through
executeSingleAccountfor a plan-less row. One guard at the resolver covers all of them; the API guard additionally stops the row being created.A third defective shape. A batch mixing attributed and unattributed recommendations resolved to the attributed account and bought the unattributed one under those credentials. That is now refused too.
The helper scanned the wrong set. It inspected every recommendation on the execution while the purchase only buys the selected ones, so an unselected row from another account could push a legitimate single-account batch onto the ambient path. It now scans selected recommendations only.
Refusal, not fan-out
Plan-less fan-out would need per-account child rows carrying recommendation subsets, because
executeForAccountcopies every recommendation to every account by design, plus history, retry and re-drive support for those children. That is a feature, and this is aneffort/sdefect. The frontend already sends one request per bucket, so the refusal is not reachable through normal use. A follow-up should addcloud_account_idto the frontend's bucket key so it stays that way.The provider factory is deliberately not guarded
The obvious second guard would be to refuse a nil provider config at the factory. That would break real deployments:
resolveGCPProviderreturns a nil config for application default credentials by design, and the ambient single-account deployment relies on the same contract. Verified that after this change a nil config reaches a purchase only from GCP ADC and from a batch whose selected recommendations carry no account at all.Two purchase paths outside
purchase.Managerare unaffected by design: the MCP tool has its own explicit-account refusal, and the CLI runs in ambient local mode.Verification
Both regression tests were observed failing on the pre-fix code, and the failures are the real defect rather than a setup error: the two-account batch dispatched under the host's ambient path, and the mixed batch dispatched both recommendations under the first account's credentials. Both purchases actually completed pre-fix. After the change, neither reaches
CreateAndValidateProvider,PurchaseCommitment,SavePurchaseHistory,GetCloudAccountor the confirmation email, and no recommendation is marked purchased.go build ./...,go vet ./...go test ./internal/purchase/ ./internal/api/go test ./...(full backend suite)gocyclo -over 10on both touched filesA second commit fixes three UK spellings that the CI Lint job's US-locale
misspellrule would have rejected. The pre-commit hook does not runmisspell, so that would have failed only after pushing. It also correctsexecutePurchase's doc header, which still described the non-fan-out branch as falling back to ambient credentials.Note for a maintainer
The API guard and the resolver guard call the same exported function, so the two cannot drift. The 400 names the offending accounts, which is deliberate: the operator needs to know which accounts collided, and the caller already had permission on all of them.
Summary by CodeRabbit