Skip to content

fix(api/azure): RI exchange execute has no submit-time idempotency; a client retry double-spends #1642

Description

@cristim

Summary

The Azure RI exchange execute endpoint (POST /api/ri-exchange/azure-instances/exchange) has no submit-time idempotency. A client retry after a timeout can commit the same exchange twice, spending roughly twice the intended amount while each individual request stays inside MaxPurchaseAmount.

ExecuteExchange's doc comment presents Azure's CalculateExchange session ID as the replay-protection mechanism. It is not one as used here: executeAzureExchange deliberately mints a fresh session on every request (internal/api/handler_ri_exchange.go, the client.CalculateExchange(...) re-quote before commit) precisely so a client-supplied or stale session can never bypass the guardrails. The consequence is that two POSTs of the same logical exchange carry two different session IDs, so Azure's own replay protection never fires. There is no request-level dedupe either, unlike the purchase path's purchaseIdempotencyKey (internal/api/handler_purchases.go, refs #644 / #643).

Concrete scenario

  1. Source reservation has quantity 10. Caller POSTs an exchange of 4 of them.
  2. The Azure LRO is polled with PollUntilDone and takes minutes.
  3. The client times out and retries the identical request.
  4. The second request re-quotes against the now-quantity-6 reservation, passes every guardrail (ownership, region, currency, cap), and commits a second exchange of 4 plus a second target purchase.
  5. Net effect: twice the intended spend, each half individually under the cap.

Compounding factor: a ctx cancellation mid-ExecuteExchange is mapped to a flat 500 "exchange execution failed" (mapAzureExchangeError), even though BeginPost may already have submitted the operation. That is exactly the response that provokes the retry in step 3.

Fix direction

  • Fingerprint the request over (creator, subscription_id, sorted sources incl. quantity, sorted targets, cap, currency) and dedupe on it, mirroring purchaseIdempotencyKey.
  • Per the feat(mcp): CUDly MCP server for RI/SP/CUD purchases across AWS, Azure, GCP #1495 precedent, the fingerprint MUST fold in subscription_id: ListExchangeableReservations is tenant-wide, so a scope-blind token can alias across subscriptions.
  • A post-submit cancellation should surface as "submitted, outcome unknown - verify in the portal", not a flat failure that invites a retry.

Scope note

The AWS executeExchange handler in the same file has the same absence, so this is a pattern gap across both providers, not a regression introduced by #1515 / PR for issue #596. Fixing it should cover both paths.

Found during adversarial review of PR #1515 (Azure RI exchange execute). Out of scope for that PR, which fixed the Regions-constraint source gap.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions