Skip to content

feat(azure/ri-exchange): find compatible offerings and execute exchange (closes #596) - #1515

Merged
cristim merged 20 commits into
mainfrom
feat/596-azure-ri-exchange-execute
Jul 28, 2026
Merged

feat(azure/ri-exchange): find compatible offerings and execute exchange (closes #596)#1515
cristim merged 20 commits into
mainfrom
feat/596-azure-ri-exchange-execute

Conversation

@cristim

@cristim cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

Implements the remaining two steps of Azure Convertible RI exchange parity with AWS EC2 (closes #596, closes #1527, refs #473): pricing a proposed exchange (compatible offerings) and executing it.

  • POST /api/ri-exchange/azure-instances/compatible-offerings (view:purchases) -- runs armreservations.CalculateExchangeClient.BeginPost against the caller's source reservations and target SKUs, returns the priced candidate offerings plus a cost preview. Read-only, commits nothing.
  • POST /api/ri-exchange/azure-instances/exchange (execute:ri-exchange, deliberately separate from execute:purchases) -- commits the exchange.

Client-layer operations (CalculateExchange, ExecuteExchange) live in providers/azure/services/compute/exchange_operations.go, wrapping the async armreservations LRO APIs with an injected caller-func test seam so no real polling ever runs in tests.

Deviations from the issue text (verified against the armreservations@v1.1.0 module cache)

  • POST, not GET, for compatible-offerings: CalculateExchange prices a proposed purchase and requires a request body (source reservations + target SKUs/regions/quantities); it cannot be expressed as a GET ?reservation_id= query.
  • BeginPost, not BeginDoExchange: that's the actual method name on ExchangeClient in this SDK version.
  • execute:ri-exchange, not manage:purchases: manage:purchases does not exist anywhere in this codebase's permission set (verified across every requirePermission/requirePermissionConstraints call site). execute:ri-exchange is the existing AWS execute gate, already deliberately separate from execute:purchases for the same reason (RI exchanges are financially irreversible).
  • PossibleReservationTermValues() includes P1Y/P3Y/P5Y in this SDK version (the issue and my own initial plan assumed only P1Y/P3Y). The term whitelist is driven entirely from the SDK's own enum rather than a hardcoded list, so this required no code change -- just a corrected assumption, caught by a test that initially used an SDK-valid "invalid" term.

Money-path design: the execute handler never trusts a client-supplied session

CalculateExchange's response carries a SessionID that ExecuteExchange commits verbatim. A design that lets the client pick which prior CalculateExchange session to execute would let stale pricing, a race with a rate change, or a forged session bypass every guardrail below. Instead, executeAzureExchange:

  1. Re-runs CalculateExchange itself against the caller's own sources/targets (server-side re-quote, ignoring anything price-shaped the client might send).
  2. Validates the fresh quote against every guardrail below.
  3. Only then calls ExecuteExchange with the SessionID that fresh call just returned.

Guardrails enforced against the fresh quote (all 422 on violation):

  • Non-empty PolicyErrors blocks execution.
  • A nil NetPayable is refused rather than treated as a free exchange.
  • A currency mismatch between the quote and the caller's requested currency blocks execution.
  • NetPayable exceeding the caller's mandatory max_payment_due cap blocks execution (both figures included in the error).

execute:ri-exchange additionally goes through requirePermissionConstraints (SEC-01, issue #1141): AccountIDs from the resolved CloudAccount (falling back to the unattributedAccountConstraint sentinel so an unregistered subscription still fails closed), Providers/Services fixed to azure/compute, every target region, and MaxPurchaseAmount from the cap.

Every one of these four guardrails has a dedicated test that fails when the guard is removed (verified by hand: temporarily disabling each check locally reproduces a test failure, then reverted -- confirmed no diff remains against the committed code).

Other fail-loud rules: no quantity/term coercion (unknown term -> 400, no P1Y fallback); nil Azure client (unregistered subscription) -> 404, not a graceful empty state (unlike the list endpoint -- these two endpoints were asked to price/execute something specific and cannot silently do nothing); typed SDK enum constants throughout; money fields are pointers end to end so an absent amount is never read as free; 4xx-vs-5xx classification via the existing isAzureClientError.

Commits

  1. feat(azure/compute): CalculateExchange and DoExchange client operations -- provider-layer client + table-driven tests (request-builder wiring, validation, nil-properties/empty-session errors, policy-error extraction, nil-vs-zero money fields, ctx-cancel passthrough).
  2. feat(api): Azure RI exchange compatible-offerings and execute endpoints -- the two handlers, wire types, router entries, openapi.yaml.
  3. test(api): Azure exchange handler coverage -- auth fail-closed, every validation reject, and the four money-path guardrails (also folds in the golangci-lint v2.10.1 findings this branch introduced: two err-shadow fixes matching the existing executeExchange style, two godot fixes, two gocritic nits).

Gate results

Gate Result
cd providers/azure && go build ./... exit 0
cd providers/azure && go test ./services/compute/... -race 95 passed, exit 0
go build ./... (root) exit 0
go vet ./... (root) exit 0
go test ./internal/api/... -race 1911 passed, exit 0
go test ./... (root, full repo) all packages ok, exit 0
gocyclo -over 10 -ignore "_test\.go" . (CI-exact invocation) 0 findings, exit 0
golangci-lint run ./internal/api/... --new-from-rev=ab682d939 (v2.10.1, CI-pinned) 0 issues
golangci-lint run ./services/compute/... --new-from-rev=ab682d939 (v2.10.1, CI-pinned) 0 issues
grep -rn "&http.Client{" providers/azure/ only pre-existing comment mentions, no new bare-client hits

E2E limitation

No Azure sandbox tenant exists for a live end-to-end exchange. Verification is mock-driven (the table above) plus this PR being held for an independent Fable adversarial review and human merge before it ships, per the team's money-path process.

Follow-ups (filed separately, out of scope here)

  • Frontend UI wiring for these two endpoints (explicitly out of scope per the issue).
  • Exchange audit-trail persistence for manual/direct-API executes (parity gap: AWS's manual execute path also doesn't persist a ledger row today).

Closes #596.

Summary by CodeRabbit

  • New Features
    • Added Azure RI reservation exchange support: preview compatible offerings with pricing and outcomes, and execute exchanges with server-side re-quoting.
  • Documentation
    • Updated the API specification with new Azure exchange endpoints, request/response contracts, and a standardized 422 unprocessable-entity response.
  • Bug Fixes
    • Improved permission-constraint enforcement and error messaging consistency for exchange and purchase validations.
  • Tests
    • Added comprehensive coverage for request validation, authorization/constraint checks, money/policy guardrails, Azure error mapping, and end-to-end success/failure scenarios.

Source-reservation ownership scoping (closes #1527)

Added during adversarial review. Every other gate on these endpoints constrains the destination of an exchange: allowed_accounts scope, the execute:ri-exchange AccountIDs constraint, and the derived target billing scope all key off subscription_id. The sources were validated only for a non-empty reservation_id and quantity >= 1.

Azure reservation orders are tenant-scoped, and ListExchangeableReservations enumerates the whole tenant by design ("the Azure Capacity exchange API operates on reservation order IDs which span subscriptions"). So a caller authorized for subscription A could name subscription B's reservation ids, hand B's commitments back, and buy the replacement into A's billing scope -- with Azure RBAC on the reservation order as the only backstop.

Each source must now be billed to the authorized subscription, checked against the reservation's own BillingScopeID (Azure: "Subscription that will be charged for purchasing Reservation", and the scope an exchange refunds it to). This is the right discriminator even for AppliedScopeType: Shared, which governs which subscriptions receive the discount rather than which one paid; an AppliedScopes check would pass for nearly every reservation.

Fails closed throughout -- a source missing from the listing, one Azure reports without a billing scope, and a failed listing call are all refused. Denials are byte-identical whether the reservation belongs to someone else or does not exist, so the gate cannot be used to enumerate reservation ids elsewhere in the tenant. Applied to the pricing endpoint too, where the same gap leaked another subscription's commitment value.

Deployment note: the gate depends on Azure populating billingScopeId in the Reservation_List response. If a tenant's API version omits it, exchanges refuse with a clear message rather than silently permitting -- correct for a money path, but worth confirming against a live tenant before the first production exchange.

Source-region scoping and gate ordering (adversarial review round 2)

An Azure exchange mutates two sets of reservations: the ones handed back (sources) and the ones acquired (targets). Only the targets reached the Regions dimension of the execute:ri-exchange constraint check, so a caller with Constraints{AccountIDs:[acct-A], Regions:["eastus"]} could POST an exchange whose source was acct-A's own westeurope reservation and whose target was in eastus: the region constraint saw only ["eastus"] and passed, the ownership gate above passed (the reservation genuinely is billed to that subscription), and a westeurope commitment the caller was never authorized to touch was consumed and relocated. exchangeRegions now folds every source's own region into the same set, so each region the operation touches must be permitted.

A source whose Region Azure did not report contributes an unknown-region sentinel rather than being dropped -- dropping it would make "we do not know where this is" mean "unconstrained", the fail-open shape fixed in #1495. A permission with no Regions constraint is unaffected.

The gates were then reordered so ownership is checked before the constraint check. With the constraint check first, the two formed an enumeration oracle: a caller scoped to subscription A and permitted only in eastus got a different 403 for an unowned reservation that exists in eastus (constraint passes, ownership refuses) than for one that does not exist or lives elsewhere (constraint refuses first). That difference confirms "this id exists, in one of my permitted regions, in a subscription I am not scoped to" -- precisely what the ownership gate's identical denials were written to withhold. TestExecuteAzureExchange_UnownedSourceDenialsAreIndistinguishable drives the whole handler and asserts both probes return the same body; the pre-existing unit test could not catch it, because it calls the gate directly rather than the path.

Follow-ups filed (not fixed here)

Note for whoever picks up #1643: the sentinel and that issue are coupled. ExchangeableReservation.Region is documented as possibly empty for AppliedScopeType == Shared, and per #1643 every reservation this endpoint creates is Shared -- so if that documented empty-region case is real, a region-scoped caller could be locked out of re-exchanging reservations this very endpoint minted. Likely theoretical (ARM populates location for VM reservations regardless of applied scope, since Shared governs discount application rather than capacity location), but the two should be resolved with each other in mind.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 27, 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 27, 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: 52 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: 17d6e8c3-03aa-4131-989e-6e3e11f7fb76

📥 Commits

Reviewing files that changed from the base of the PR and between 1d01d6d and 849679b.

📒 Files selected for processing (4)
  • internal/api/handler_ri_exchange.go
  • internal/api/handler_ri_exchange_azure_test.go
  • internal/auth/service_group.go
  • internal/auth/service_group_test.go
📝 Walkthrough

Walkthrough

This PR adds Azure Convertible RI exchange pricing and execution through provider LRO operations and authenticated API endpoints. It adds request validation, subscription and source-ownership authorization, permission and payment guardrails, routing, OpenAPI contracts, and comprehensive tests.

Changes

Azure RI Exchange Pricing and Execution

Layer / File(s) Summary
Provider exchange contracts and reservation ownership
providers/azure/services/compute/client.go, providers/azure/services/compute/exchange.go, providers/azure/services/compute/exchange_operations.go, internal/api/handler_ri_exchange.go
Adds exchange models, injectable LRO callers, billing-scope metadata, and the expanded exchange client interface.
Azure exchange operation implementation
providers/azure/services/compute/exchange_operations.go
Validates inputs, builds Azure requests, polls CalculateExchange and ExecuteExchange operations, validates statuses, and extracts previews, offerings, results, policy errors, and nullable money fields.
Provider exchange operation tests
providers/azure/services/compute/exchange_operations_test.go, providers/azure/services/compute/exchange_test.go
Covers provider validation, request wiring, LRO responses, status rules, policy errors, nullable amounts, context propagation, and billing-scope extraction.
API validation, authorization, and execution
internal/api/handler_ri_exchange.go, internal/api/handler.go, internal/api/handler_purchases.go
Adds Azure request and response types, subscription and source-ownership checks, permission constraints, currency and payment guardrails, re-quoting, execution, and error mapping.
Routes and API specification
internal/api/router.go, internal/api/openapi.yaml
Registers authenticated compatible-offerings and exchange routes and documents their request, response, and 422 contracts.
API exchange handler tests
internal/api/handler_ri_exchange_azure_test.go, internal/api/handler_ri_exchange_test.go
Tests validation, authentication, authorization, scope enforcement, anti-enumeration behavior, source ownership, guardrails, error mapping, and successful responses.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related issues

Possibly related PRs

  • LeanerCloud/CUDly#595: Provides the Azure exchange listing and client plumbing extended by these operations.
  • LeanerCloud/CUDly#934: Overlaps with the Azure exchange client construction and per-subscription credential path.
  • LeanerCloud/CUDly#1210: Shares the execution-time permission-constraint enforcement path updated here.

Suggested labels: type/security

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 51.28% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements compatible-offerings and exchange flows, permission gates, and source-reservation scoping as requested.
Out of Scope Changes check ✅ Passed The changes stay focused on Azure RI exchange endpoints, SDK plumbing, authorization, OpenAPI, and tests.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main Azure RI exchange addition: finding compatible offerings and executing exchanges.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/596-azure-ri-exchange-execute

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
internal/api/openapi.yaml (1)

724-731: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the server-side item cap with maxItems: 50.

The handler rejects more than maxAzureExchangeItems (50) sources/targets; the spec currently advertises unbounded arrays, so clients only learn the limit from a 400. Checkov flags the same gap.

📘 Proposed spec change (repeat for both endpoints)
                 sources:
                   type: array
+                  maxItems: 50
                   items:
                     type: object
                 targets:
                   type: array
+                  maxItems: 50
                   items:
                     type: object

Also applies to: 776-783

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/openapi.yaml` around lines 724 - 731, Add maxItems: 50 to both
sources and targets array schemas in the affected OpenAPI endpoint definitions,
matching the server-side maxAzureExchangeItems limit. Apply the constraint
consistently to both occurrences while preserving their existing array item
schemas.

Source: Linters/SAST tools

internal/api/handler_ri_exchange.go (2)

569-588: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard against a nil preview before dereferencing.

checkAzureExchangeMoneyGuardrails dereferences preview unconditionally. The provider returns a non-nil preview on success today, but azureExchangeClient is an injectable interface, so a (nil, nil, nil) return panics on the money path instead of failing closed.

🛡️ Proposed guard
 func checkAzureExchangeMoneyGuardrails(preview *azurecompute.ExchangePreview, maxRat *big.Rat, currency string) error {
+	if preview == nil {
+		return NewClientError(422, "Azure did not return a pricing preview; refusing to execute")
+	}
 	if len(preview.PolicyErrors) > 0 {

Note the same nil preview would also be dereferenced at Line 647 (preview.SessionID) and Lines 660-661.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_ri_exchange.go` around lines 569 - 588, Update
checkAzureExchangeMoneyGuardrails to reject a nil preview before accessing
PolicyErrors, NetPayable, or any other fields, returning an appropriate
client/error response so the exchange fails closed. Also guard the money-path
uses of preview.SessionID and related fields in the surrounding exchange flow to
prevent nil dereferences from an injectable azureExchangeClient.

269-677: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

File now exceeds the 500-line limit.

This addition pushes handler_ri_exchange.go well past 500 lines. Consider splitting the Azure exchange request/response types, validation, and handlers into a dedicated file (e.g. handler_ri_exchange_azure.go) within the same package.

As per coding guidelines: "Follow Domain-Driven Design with bounded contexts, keep files under 500 lines, and use typed interfaces for public APIs."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_ri_exchange.go` around lines 269 - 677,
handler_ri_exchange.go now exceeds the 500-line limit because of the Azure
exchange implementation. Move the Azure-specific request/response types,
validation and conversion helpers, authorization/guardrail functions, error
mapping, and Handler methods such as getAzureCompatibleOfferings and
executeAzureExchange into a dedicated same-package file named
handler_ri_exchange_azure.go, preserving behavior and symbol visibility.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/api/handler_ri_exchange.go`:
- Around line 569-588: Update checkAzureExchangeMoneyGuardrails to reject a nil
preview before accessing PolicyErrors, NetPayable, or any other fields,
returning an appropriate client/error response so the exchange fails closed.
Also guard the money-path uses of preview.SessionID and related fields in the
surrounding exchange flow to prevent nil dereferences from an injectable
azureExchangeClient.
- Around line 269-677: handler_ri_exchange.go now exceeds the 500-line limit
because of the Azure exchange implementation. Move the Azure-specific
request/response types, validation and conversion helpers,
authorization/guardrail functions, error mapping, and Handler methods such as
getAzureCompatibleOfferings and executeAzureExchange into a dedicated
same-package file named handler_ri_exchange_azure.go, preserving behavior and
symbol visibility.

In `@internal/api/openapi.yaml`:
- Around line 724-731: Add maxItems: 50 to both sources and targets array
schemas in the affected OpenAPI endpoint definitions, matching the server-side
maxAzureExchangeItems limit. Apply the constraint consistently to both
occurrences while preserving their existing array item schemas.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6a397985-0e28-4fe2-9e15-3a81825a1f1d

📥 Commits

Reviewing files that changed from the base of the PR and between b83c5d1 and 66c54d5.

📒 Files selected for processing (8)
  • internal/api/handler.go
  • internal/api/handler_ri_exchange.go
  • internal/api/handler_ri_exchange_test.go
  • internal/api/openapi.yaml
  • internal/api/router.go
  • providers/azure/services/compute/client.go
  • providers/azure/services/compute/exchange_operations.go
  • providers/azure/services/compute/exchange_operations_test.go

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 27, 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.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

✅ Ready for human merge (closes #596). Money path.

  • CI: all checks green.
  • CodeRabbit: SUCCESS.
  • Independent Fable money-path adversarial review: CLEAN (2 rounds). The first pass caught a real CONFIRMED-high defect that green CI + CodeRabbit both missed: the MaxPurchaseAmount spend cap is USD-denominated by convention, but the execute handler compared the cap in the request's currency without normalization, weakening the cap for non-USD tenants (e.g. a KWD request could execute well above a USD MaxPurchaseAmount). Fixed by failing closed: a non-USD execute is rejected (403) whenever the granting permission carries a MaxPurchaseAmount constraint (no FX guessing); unconstrained permissions still allow non-USD. The follow-up lint fix (d0ec1043c) then removed a now-always-"execute" param (clean refactor, no behavior change). Re-review confirmed the cap weakening is closed with no new bypass, and re-derived the guard-removal test failures independently.
  • Core money-path guardrails verified clean: execute never trusts a client-supplied session (server re-runs CalculateExchange and executes only the fresh SessionID); cap/policy-errors/nil-NetPayable/currency-mismatch all block (422) before execute; auth fail-closed; typed SDK enums; LRO bounded by request ctx.

E2E limitation (stated in the diff): no Azure sandbox tenant, so verification is mock-driven + this adversarial review + your merge. Follow-ups filed: #1517 (frontend UI wiring), #1518 (exchange audit-trail persistence, pre-existing on AWS+Azure), plus a new small auth-hardening issue for a pre-existing negative-MaxPurchaseAmount matcher quirk.

Held for your merge.

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Recovered stranded work: Azure exchange billing-scope authorization fix

The review agent working this PR was interrupted mid-flight and left this change committed-but-unpushed plus uncommitted on disk. Recovering it here rather than re-implementing.

Finding: targets[].billing_scope_id was a required, caller-supplied field that flowed straight through to the purchase, while authorization was checked against the request's subscription_id. The execute:ri-exchange AccountIDs constraint is evaluated against the CloudAccount registered for subscription_id, so a caller could pass a subscription_id they are authorized for to satisfy the constraint, then name a different subscription's billing scope in targets[] and move the charge outside the account whose permissions were actually verified.

Fix: derive the billing scope from subscription_id, matching every other Azure reservation purchase path in this repo (ComputeClient.buildReservationBody and the database / cache / search / cosmosdb / synapse / managedredis clients all build the scope from their own subscription ID). billing_scope_id becomes optional and is rejected when supplied with anything other than the request subscription's own scope, rather than silently ignored, so a caller who believed they were directing the charge elsewhere is told they were not. Applies to both the offerings and execute endpoints; the openapi schema is updated to match.

Verification: go build ./... clean, go test ./internal/api/ green, go vet ./... clean, gocyclo -over 10 -ignore "_test\.go" . clean, and golangci-lint at the CI-pinned v2.10.1 reports 0 issues on ./internal/api/... (I fixed 4 findings that the interrupted agent had not: 1 govet shadow, 2 gocritic sloppyReassign, 1 misspell).

Note this PR was UNSTABLE before this push; a CI watcher is armed on the new commit and I will report what it finds.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@cristim Acknowledged. I’ll review the recovered billing-scope authorization changes, including the derived-scope invariant, rejection behavior, endpoint consistency, and OpenAPI contract.


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1515

Timestamp: 2026-07-27T15:07:43.800Z
Learning: For Azure RI exchange compatible-offerings and execute endpoints, authorization is bound to the request `subscription_id`. Each target's billing scope must be derived from that subscription; a caller-supplied `billing_scope_id` is optional but must be rejected when it differs from the subscription's own Azure billing scope. This prevents an authorized subscription from being used to charge a different subscription.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ 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 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
internal/api/handler_ri_exchange_test.go (2)

2537-2556: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Doc comment is attached to the wrong test.

The TestCheckAzureExchangeMoneyGuardrails_NilPreviewRefused paragraph (Lines 2537-2540) sits directly above ..._CurrencyCaseInsensitive, so the nil-preview test at Line 2552 is undocumented and go doc/IDE hover shows the wrong rationale.

♻️ Move the comment above its test
-// TestCheckAzureExchangeMoneyGuardrails_NilPreviewRefused pins the
-// defensive nil check: a future azureExchangeClient implementation that
-// mistakenly returns (nil, nil, nil) from CalculateExchange must not panic
-// this handler.
 // TestCheckAzureExchangeMoneyGuardrails_CurrencyCaseInsensitive pins the
 // currency comparison to the same case-insensitive rule
 // checkAzureExecuteConstraints uses for its isUSD test. With an exact-match
 // comparison a request of "usd" took the USD cap path there and was then
 // always rejected here as a mismatch against Azure's "USD", making a
 // well-formed request permanently unexecutable.
 func TestCheckAzureExchangeMoneyGuardrails_CurrencyCaseInsensitive(t *testing.T) {
 	preview := &azurecompute.ExchangePreview{SessionID: "sess-1", NetPayable: toPtr(10.00), NetPayableCurrency: "USD"}
 	require.NoError(t, checkAzureExchangeMoneyGuardrails(preview, big.NewRat(100, 1), "usd"))
 }
 
+// TestCheckAzureExchangeMoneyGuardrails_NilPreviewRefused pins the
+// defensive nil check: a future azureExchangeClient implementation that
+// mistakenly returns (nil, nil, nil) from CalculateExchange must not panic
+// this handler.
 func TestCheckAzureExchangeMoneyGuardrails_NilPreviewRefused(t *testing.T) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_ri_exchange_test.go` around lines 2537 - 2556, Move the
nil-preview defensive-check comment so it directly precedes
TestCheckAzureExchangeMoneyGuardrails_NilPreviewRefused, and keep the
currency-comparison rationale directly above
TestCheckAzureExchangeMoneyGuardrails_CurrencyCaseInsensitive. Do not alter the
test implementations.

1791-1791: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Test file has grown well past the 500-line ceiling.

This file is now ~2650 lines; the Azure exchange suite (offerings, execute, money guardrails, scoping) is cohesive enough to live in its own handler_ri_exchange_azure_test.go in the same package. Deferable, but the file will keep growing with the follow-up frontend/audit work.

As per coding guidelines, "keep files under 500 lines".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_ri_exchange_test.go` at line 1791, Split the cohesive
Azure exchange tests from handler_ri_exchange_test.go into a new
handler_ri_exchange_azure_test.go in the same package, including the offerings,
execute, money guardrails, and scoping suites. Keep shared setup and test
behavior unchanged, and ensure the original file is reduced toward the 500-line
guideline.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/api/handler_ri_exchange_test.go`:
- Around line 2537-2556: Move the nil-preview defensive-check comment so it
directly precedes TestCheckAzureExchangeMoneyGuardrails_NilPreviewRefused, and
keep the currency-comparison rationale directly above
TestCheckAzureExchangeMoneyGuardrails_CurrencyCaseInsensitive. Do not alter the
test implementations.
- Line 1791: Split the cohesive Azure exchange tests from
handler_ri_exchange_test.go into a new handler_ri_exchange_azure_test.go in the
same package, including the offerings, execute, money guardrails, and scoping
suites. Keep shared setup and test behavior unchanged, and ensure the original
file is reduced toward the 500-line guideline.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b1551be3-efd6-4ed0-ab5f-2d36418b6b27

📥 Commits

Reviewing files that changed from the base of the PR and between 66c54d5 and 566536e.

📒 Files selected for processing (7)
  • internal/api/handler.go
  • internal/api/handler_purchases.go
  • internal/api/handler_ri_exchange.go
  • internal/api/handler_ri_exchange_test.go
  • internal/api/openapi.yaml
  • providers/azure/services/compute/exchange_operations.go
  • providers/azure/services/compute/exchange_operations_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/api/openapi.yaml
  • providers/azure/services/compute/exchange_operations.go
  • internal/api/handler_ri_exchange.go

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

Addressed both nitpicks from the latest review in 3de62e1:

  • Misplaced doc comment (handler_ri_exchange_test.go ~2537-2556): the comment describing TestCheckAzureExchangeMoneyGuardrails_NilPreviewRefused sat above TestCheckAzureExchangeMoneyGuardrails_CurrencyCaseInsensitive, stacking both rationales over the wrong test. Moved it to directly precede the test it documents. Neither test body changed.
  • File size (handler_ri_exchange_test.go:1791): moved the Azure compatible-offerings, execute, money-guardrail and subscription-scoping suites into a new handler_ri_exchange_azure_test.go in the same package, so shared setup keeps working unchanged. The original file goes from 2788 to 1788 lines.

Verified nothing was lost in the move: 86 test functions before, 86 after. go build ./..., go vet ./internal/api/, and go test ./internal/api/ all pass, gocyclo -over 10 -ignore "_test\.go" . is clean, and golangci-lint at the CI-pinned v2.10.1 reports 0 issues on ./internal/api/....

Not done, flagging rather than silently expanding scope: handler_ri_exchange_test.go is still 1788 lines, above the 500-line guideline. The remainder is the AWS exchange suite, which is a separate cohesive block from the Azure work this PR is about; splitting it too would put unrelated churn in this PR. Happy to do it here if you'd prefer, or it can be a follow-up.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git show --no-ext-diff --format=fuller --stat 3de62e1
git diff --no-ext-diff 3de62e1^ 3de62e1 -- \
  internal/api/handler_ri_exchange_test.go \
  internal/api/handler_ri_exchange_azure_test.go

printf '\n== Test function declarations ==\n'
rg -n --glob 'handler_ri_exchange{,_azure}_test.go' \
  '^func Test[A-Za-z0-9_]+\s*\(t \*testing\.T\)' internal/api

printf '\n== Package/import structure ==\n'
for f in internal/api/handler_ri_exchange_test.go internal/api/handler_ri_exchange_azure_test.go; do
  if [ -f "$f" ]; then
    echo "-- $f"
    sed -n '1,100p' "$f"
  fi
done

printf '\n== Guardrail test/comment context ==\n'
rg -n -A10 -B12 \
  'TestCheckAzureExchangeMoneyGuardrails_(CurrencyCaseInsensitive|NilPreviewRefused)' \
  internal/api/handler_ri_exchange_azure_test.go internal/api/handler_ri_exchange_test.go || true

Length of output: 110710


🏁 Script executed:

#!/bin/bash
set -euo pipefail

before="$(mktemp)"
after="$(mktemp)"
trap 'rm -f "$before" "$after"' EXIT

git grep -hE '^func Test[A-Za-z0-9_]+[[:space:]]*\(t \*testing\.T\)' 3de62e1^ -- \
  internal/api/handler_ri_exchange_test.go \
  internal/api/handler_ri_exchange_azure_test.go 2>/dev/null \
  | sed -E 's/^func (Test[A-Za-z0-9_]+).*/\1/' | sort -u > "$before"

git grep -hE '^func Test[A-Za-z0-9_]+[[:space:]]*\(t \*testing\.T\)' 3de62e1 -- \
  internal/api/handler_ri_exchange_test.go \
  internal/api/handler_ri_exchange_azure_test.go 2>/dev/null \
  | sed -E 's/^func (Test[A-Za-z0-9_]+).*/\1/' | sort -u > "$after"

printf '== Test declaration counts ==\n'
printf 'before: '; wc -l < "$before"
printf 'after:  '; wc -l < "$after"

printf '\n== Removed test declarations ==\n'
comm -23 "$before" "$after" || true
printf '\n== Added test declarations ==\n'
comm -13 "$before" "$after" || true

printf '\n== Duplicate test declarations after ==\n'
git grep -hE '^func Test[A-Za-z0-9_]+[[:space:]]*\(t \*testing\.T\)' 3de62e1 -- \
  internal/api/handler_ri_exchange_test.go \
  internal/api/handler_ri_exchange_azure_test.go 2>/dev/null \
  | sed -E 's/^func (Test[A-Za-z0-9_]+).*/\1/' | sort | uniq -d

printf '\n== Whitespace errors ==\n'
git diff --check 3de62e1^ 3de62e1

Length of output: 1601


@cristim Reviewed 3de62e1 — no new findings.

The Azure suite move preserves the same-package setup, and all 86 test declarations are retained exactly with no duplicates. The two guardrail comments now each immediately precede the test they explain, and the diff is whitespace-clean.

I agree that splitting the remaining AWS-focused portion of internal/api/handler_ri_exchange_test.go would be unrelated churn here; it is reasonable to defer that cleanup to a focused follow-up. 🐇

@cristim

cristim commented Jul 27, 2026

Copy link
Copy Markdown
Member Author

✅ Re-verified after post-review drift — ready for human merge (closes #596)

After the earlier review, cr-watch agents pushed additional commits (billing-scope derivation, failed-terminal-status refusal, test split). Those were re-reviewed adversarially and found sound — plus one real finding, now fixed:

  • Fixed (commit a62d7545e): authorizeAzureExchangeExecution built the Azure client before the subscription-scope check, giving a scoped caller an enumeration oracle (distinguishable 404 for unregistered vs out-of-scope subscriptions) and resolving credentials for an out-of-scope account before denial. The scope check now runs first (mirroring the offerings endpoint). 3 tests on the real client path assert an indistinguishable denial and no pre-authz credential resolution; each fails when the reorder is reverted (verified). No money exposure existed (scope + constraints always ran before any Azure call).
  • Re-verified sound: billing scope is server-derived (caller's billing_scope_id never reaches the provider; foreign scope → 400); terminal-status is a typed fail-loud allow-list; server-re-quote executes only the fresh SessionID; cap/policy/nil-payable/currency all block before execute; non-USD-with-cap fails closed.

CI green, CodeRabbit SUCCESS. Held for your merge (money path).

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

@cristim Acknowledged — starting a full, non-incremental review of the current PR head.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 32 minutes.

@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@cristim Acknowledged — starting a full, non-incremental review of the current PR head.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 39 minutes.

@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@cristim Acknowledged — starting a full, non-incremental review of the current PR head.

✅ Action performed

Full review finished.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 19 minutes.

@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review (not incremental) now that a window may be open. Triggers are serialised centrally across the queue because CodeRabbit is under an account-level adaptive throttle and parallel pings hold it in backoff.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@cristim Acknowledged — starting a full, non-incremental review of the current PR head.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
internal/api/openapi.yaml (2)

741-757: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Document the 500/502 responses these endpoints actually return.

The handler tests pin failure codes the spec omits: a transient Azure fault maps to 500 (TestGetAzureCompatibleOfferings_TransientErrorIs500) and a source-ownership listing failure maps to 502 (TestExecuteAzureExchange_SourceOwnershipLookupFailureRefuses). Both operations should list them so generated clients handle them.

📝 Suggested addition (apply to both operations)
         '422':
           $ref: '`#/components/responses/UnprocessableEntity`'
+        '500':
+          $ref: '`#/components/responses/InternalServerError`'
+        '502':
+          $ref: '`#/components/responses/BadGateway`'

plus new entries under components.responses mirroring the existing UnprocessableEntity shape.

Same gap applies to /api/ri-exchange/azure-instances/exchange (lines 808-824).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/openapi.yaml` around lines 741 - 757, Add 500 and 502 response
entries to both Azure compatible-offerings and Azure-instances exchange
operations, alongside their existing responses. Define corresponding reusable
entries under components.responses, mirroring the existing UnprocessableEntity
response shape, so generated clients expose both failure statuses.

742-747: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider typing the 200 payloads.

Both success responses are declared as bare type: object, though AzureCompatibleOfferingsResponse / AzureExecuteExchangeResponse are concrete shapes (session id, status, nullable money fields). Reusable schemas here would let the frontend follow-up generate types instead of hand-rolling them, and would document the nullable-vs-zero money semantics the handler is careful about.

As per coding guidelines, "use typed interfaces for public APIs".

Also applies to: 809-814

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/openapi.yaml` around lines 742 - 747, Replace the bare object
schemas for the 200 responses of the Azure compatible offerings and execute
exchange endpoints with reusable schemas matching
AzureCompatibleOfferingsResponse and AzureExecuteExchangeResponse. Define their
concrete fields, including session identifiers, status values, and nullable
money fields, then reference those schemas from both success responses so
generated clients preserve the documented nullable-versus-zero semantics.

Source: Coding guidelines

internal/api/handler_ri_exchange_azure_test.go (1)

1-100: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

File is ~1740 lines, well past the 500-line ceiling.

Natural split points already exist in the section banners: validation/conversion helpers, allowed_accounts scoping + ordering, money-path guardrails, and the #1527 source-ownership suite. Shared fixtures (mockAzureExchangeOpsClient, ownsAzureSource, the body constants) would live in one file the others import from the same package.

As per coding guidelines, "keep files under 500 lines".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_ri_exchange_azure_test.go` around lines 1 - 100, Split
the oversized Azure exchange test file into focused files under 500 lines, using
the existing section banners as boundaries for validation/conversion helpers,
allowed_accounts scoping and ordering, money-path guardrails, and the `#1527`
source-ownership tests. Keep shared fixtures such as mockAzureExchangeOpsClient,
ownsAzureSource, allowAnyAccountScope, body constants, and request builders in
one shared test file so all same-package tests can reuse them.

Source: Coding guidelines

providers/azure/services/compute/exchange_operations.go (1)

1-31: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

New file is 560 lines, over the repo's 500-line ceiling.

Types (CompatibleOffering, ExchangePreview, ExchangeResult, ExchangeTarget) and the caller/LRO plumbing split cleanly from the operation logic — e.g. exchange_types.go + exchange_operations.go — which would also keep validation/extraction helpers easier to locate.

As per coding guidelines, "Follow Domain-Driven Design with bounded contexts, keep files under 500 lines, and use typed interfaces for public APIs."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/services/compute/exchange_operations.go` around lines 1 - 31,
Split the oversized exchange_operations.go file to keep each file under the
repository’s 500-line limit. Move the public exchange data
types—CompatibleOffering, ExchangePreview, ExchangeResult, and
ExchangeTarget—into a focused exchange_types.go file, while retaining the
CalculateExchange, ExecuteExchange, and related operation/validation helpers in
exchange_operations.go.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/api/handler_ri_exchange.go`:
- Around line 817-852: Move the executeAzureExchange documentation block so it
immediately precedes the executeAzureExchange function, and keep
parseAzureExecuteRequest preceded only by its own request-parsing documentation.
Preserve the existing comment text and function behavior.
- Around line 497-511: Update targetLocations to canonicalize each target's
Location, such as by lower-casing it, before checking seen and appending to the
result. Deduplicate and populate Regions using the normalized values while
preserving the existing output behavior.

---

Nitpick comments:
In `@internal/api/handler_ri_exchange_azure_test.go`:
- Around line 1-100: Split the oversized Azure exchange test file into focused
files under 500 lines, using the existing section banners as boundaries for
validation/conversion helpers, allowed_accounts scoping and ordering, money-path
guardrails, and the `#1527` source-ownership tests. Keep shared fixtures such as
mockAzureExchangeOpsClient, ownsAzureSource, allowAnyAccountScope, body
constants, and request builders in one shared test file so all same-package
tests can reuse them.

In `@internal/api/openapi.yaml`:
- Around line 741-757: Add 500 and 502 response entries to both Azure
compatible-offerings and Azure-instances exchange operations, alongside their
existing responses. Define corresponding reusable entries under
components.responses, mirroring the existing UnprocessableEntity response shape,
so generated clients expose both failure statuses.
- Around line 742-747: Replace the bare object schemas for the 200 responses of
the Azure compatible offerings and execute exchange endpoints with reusable
schemas matching AzureCompatibleOfferingsResponse and
AzureExecuteExchangeResponse. Define their concrete fields, including session
identifiers, status values, and nullable money fields, then reference those
schemas from both success responses so generated clients preserve the documented
nullable-versus-zero semantics.

In `@providers/azure/services/compute/exchange_operations.go`:
- Around line 1-31: Split the oversized exchange_operations.go file to keep each
file under the repository’s 500-line limit. Move the public exchange data
types—CompatibleOffering, ExchangePreview, ExchangeResult, and
ExchangeTarget—into a focused exchange_types.go file, while retaining the
CalculateExchange, ExecuteExchange, and related operation/validation helpers in
exchange_operations.go.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bbe0c6d4-6a03-47d1-8a39-658b6e920ca9

📥 Commits

Reviewing files that changed from the base of the PR and between 7b18bfd and 1d01d6d.

📒 Files selected for processing (12)
  • internal/api/handler.go
  • internal/api/handler_purchases.go
  • internal/api/handler_ri_exchange.go
  • internal/api/handler_ri_exchange_azure_test.go
  • internal/api/handler_ri_exchange_test.go
  • internal/api/openapi.yaml
  • internal/api/router.go
  • providers/azure/services/compute/client.go
  • providers/azure/services/compute/exchange.go
  • providers/azure/services/compute/exchange_operations.go
  • providers/azure/services/compute/exchange_operations_test.go
  • providers/azure/services/compute/exchange_test.go

Comment thread internal/api/handler_ri_exchange.go Outdated
Comment thread internal/api/handler_ri_exchange.go Outdated
The Regions dimension of a permission constraint was matched with
containsAny, the same any-overlap rule the other dimensions use. That
rule is safe for AccountIDs, Providers and Services because a request
names exactly one value on each of them, so any-match and all-match
coincide. Regions is the one dimension a single request legitimately
spans several values on: an Azure RI exchange submits every target's
location at once via targetLocations.

The result was an authorization bypass on a money path. A caller
permitted only in eastus could attach a westus target to the exchange;
containsAny found eastus in the permitted set, returned true, and the
irreversible exchange executed for BOTH regions. "Regions limits to
specific regions" cannot mean "limits to requests that mention at least
one permitted region".

matchAllRegionsConstraint now backs that dimension: every requested
region must be permitted, comparison is case- and whitespace-
insensitive, and the empty-list semantics are unchanged (an
unconstrained permission, or a request naming no region, still
matches). Every other dimension keeps containsAny. Single-region
callers (the AWS reshape execute path, purchaseConstraintSets) are
unaffected: for a one-element request all-match and any-match are the
same test, and the case-insensitivity only widens what they match.

Alongside it, targetLocations canonicalizes each location to trimmed
lower case before de-duplicating, so the constraint set names each
target region exactly once instead of demanding a permission for two
spellings of one place ("EastUS", the casing the Azure portal shows,
and "eastus", the casing its APIs return). Target validation now
rejects a whitespace-only location as well, which previously survived
the empty check and would reach the permission check as "", a region
no permission can name.

Regression tests cover the bypass through both matchConstraints and
the real HasPermission entry point, the case and whitespace handling
on both sides, and the targetLocations normalization both directly and
through executeAzureExchange's submitted constraint set. Each fails
against the pre-fix code.

Refs #596
@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Pushed 55f98da — region constraint ALL-match + location canonicalization

Authorization bypass found and fixed (not a CR finding)

matchConstraints matched the Regions dimension with matchStringListConstraints, i.e. containsAny: true if any requested value is in the permitted set.

That rule is correct for AccountIDs, Providers and Services, because a request names exactly one value on each of those, so any-match and all-match coincide. Regions is the one dimension a single request legitimately spans several values on: targetLocations feeds every Azure exchange target's location into one constraint set.

Concretely, a caller permitted only in eastus could attach a westus target. containsAny found eastus in the permitted set, returned true, and the irreversible exchange executed for both regions. "Regions limits to specific regions" cannot mean "limits to requests that mention at least one permitted region".

New auth.matchAllRegionsConstraint backs that dimension: every requested region must be permitted, comparison is case- and whitespace-insensitive, and the empty-list semantics are unchanged (unconstrained permission, or a request naming no region, still matches). Every other dimension keeps containsAny.

Verified no legitimate flow regresses. Every non-test caller that sets a request-side PermissionConstraints.Regions:

Caller Regions Effect
handler_ri_exchange.go checkAzureExecuteConstraints targetLocations(...) — multi-value the path being fixed
handler_ri_exchange.go AWS reshape execute []string{region} — single unchanged; all-match == any-match at n=1
handler_purchases.go purchaseConstraintSets []string{rec.Region} — single unchanged, same reason

(service_api.go touches Regions only on the permission side of the APIPermission conversion; handler_recommendations.go's Regions is a response field, not a constraint.) For a one-element request the two rules are the same test, and the added case-insensitivity only widens what they match, so nothing that passed before fails now.

CR threads addressed

  • targetLocations casing — locations are now canonicalized to trimmed lower case before the dedup, so the constraint set names each target region exactly once instead of demanding a permission for two spellings of one place (EastUS, the casing the Azure portal shows, and eastus, the casing its APIs return).
  • executeAzureExchange doc block — moved off parseAzureExecuteRequest and onto executeAzureExchange, where godoc will attribute it correctly.

Follow-on found while reviewing the above

The targetLocations doc comment justified normalization by "the permission side matches with exact string comparison (auth.containsAny)" — no longer true once the Regions matcher became case-insensitive. Rewrote it to state the actual reason (dedup accuracy, two layers agreeing rather than one resting on the other's leniency) and to record that the raw Location is what reaches Azure, which is safe since Azure resolves either casing to the same region.

That also exposed a real gap: target validation rejected location == "" but not a whitespace-only location, which survived the check and would reach the permission check as "" after trimming — a region no permission can name. Validation is now trim-aware (shared by both the offerings and execute paths).

Nothing dismissed

Tests — each confirmed to FAIL against the pre-fix code

  • TestMatchConstraints_RegionsRequireEveryRequestedRegion (11 cases) — the bypass, single/multi permitted and unpermitted, permission-side and request-side casing, whitespace, both empty-list semantics, AWS-style names. Asserted through matchConstraints, not the new matcher directly, so re-wiring the dimension back to matchStringListConstraints fails these rather than leaving them vacuously green.
  • TestHasPermission_RegionConstraintDeniesUnpermittedTargetRegion — the same bypass through the real HasPermission entry point the handlers call.
  • TestTargetLocations_CanonicalizesCase (6 cases) and TestExecuteAzureExchange_ConstraintRegionsAreLowerCased — normalization directly and through the constraint set executeAzureExchange actually submits.
  • TestValidateAzureOfferingsBody/target_location_is_whitespace_only.

Pre-fix failure was verified by temporarily reverting each production change and re-running, not assumed.

Gates (exit codes)

Gate Exit
go build ./... 0
go vet ./internal/... 0
go test ./... 0
gocyclo -over 10 -ignore "_test\.go" . (CI's exact command) 0
golangci-lint run full repo, v2.10.1 (the version ci.yml pins) 0 — 0 issues.
pre-commit (all hooks, no --no-verify) 0

One note for the record: validateAzureExchangeTargets sits at complexity exactly 10, right on the -over 10 gate boundary. My change swapped one comparison for another so the count is unchanged, but the next added branch in that function will redden pre-commit repo-wide.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Commit metadata and changed files =='
git show --no-ext-diff --format=fuller --stat 55f98da
git diff --check 55f98da^ 55f98da
git diff --no-ext-diff --unified=100 55f98da^ 55f98da -- \
  internal/auth \
  internal/api/handler_ri_exchange.go \
  internal/api/handler_ri_exchange_azure_test.go \
  internal/api/handler_purchases.go \
  internal/api/handler_ri_exchange_test.go

printf '%s\n' '== Shared authorization symbols and all call sites =='
rg -n -C 12 \
  'func .*matchConstraints|func .*matchStringListConstraints|func .*matchAllRegionsConstraint|matchConstraints\(|PermissionConstraints|\.Regions' \
  internal \
  -g '*.go'

printf '%s\n' '== Azure validation, normalization, and constraint construction =='
ast-grep outline internal/api/handler_ri_exchange.go \
  --items all --type function,method,struct \
  --match 'Azure|targetLocations|Constraint|validate' 2>/dev/null || true
rg -n -C 16 \
  'targetLocations|validateAzureExchangeTargets|checkAzureExecuteConstraints|executeAzureExchange|parseAzureExecuteRequest|Location|Regions' \
  internal/api/handler_ri_exchange.go \
  internal/api/handler_ri_exchange_azure_test.go

printf '%s\n' '== Focused authorization and Azure regression tests =='
fd -e go . internal | rg '(auth|permission|handler_ri_exchange).*_test\.go$' | sort
rg -n -C 14 \
  'TestMatchConstraints_RegionsRequireEveryRequestedRegion|TestHasPermission_RegionConstraintDeniesUnpermittedTargetRegion|TestTargetLocations_CanonicalizesCase|TestExecuteAzureExchange_ConstraintRegionsAreLowerCased|whitespace_only' \
  internal -g '*_test.go'

Length of output: 202152


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Exact commit patch: authorization and Azure handler =='
git diff --no-ext-diff --unified=45 55f98da^ 55f98da -- \
  internal/auth/service_group.go \
  internal/auth/service_group_test.go \
  internal/api/handler_ri_exchange.go \
  internal/api/handler_ri_exchange_azure_test.go

printf '%s\n' '== Region constraint types, validation, and persistence boundaries =='
rg -n -C 12 \
  'type PermissionConstraints|Regions[[:space:]]+\[\]string|validate.*[Cc]onstraint|Constraint.*validate|constraints.*valid|PermissionConstraints' \
  internal/auth \
  internal/api \
  -g '*.go'

printf '%s\n' '== Complete new matcher tests and permission evaluation context =='
sed -n '1410,1605p' internal/auth/service_group_test.go
sed -n '210,320p' internal/auth/service_group.go

printf '%s\n' '== Production request-side region construction (excluding tests) =='
rg -n -C 8 \
  'PermissionConstraints\{|Regions:' \
  internal \
  -g '*.go' \
  -g '!**/*_test.go'

printf '%s\n' '== All all-match helper uses and direct matcher tests =='
rg -n -C 8 \
  'matchAllRegionsConstraint|matchStringListConstraints|containsAny' \
  internal/auth \
  -g '*.go'

Length of output: 250680


@cristim Reviewed 55f98da — no new findings.

The shared matcher now correctly requires every requested region to be permitted, while preserving the documented unconstrained/unspecified empty-list behavior. The production call-site check confirms the behavior change is limited to the Azure multi-target case; AWS reshape and purchase paths still submit one region per constraint set.

The Azure path also correctly canonicalizes the authorization-only region set, rejects whitespace-only locations, and retains the original location for Azure. The new tests meaningfully pin the prior bypass, normalization, and handler wiring.


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1515

Timestamp: 2026-07-28T15:55:06.425Z
Learning: In `internal/auth/service_group.go`, `PermissionConstraints.Regions` must use all-match semantics: every request-side region must be included in the permission-side region set. This differs from `AccountIDs`, `Providers`, and `Services`, which retain overlap matching because their current execution callers submit a single value. Region comparisons are case- and whitespace-insensitive; empty-list semantics remain unconstrained/unspecified.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ 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.

@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Re-requesting review: the latest commit on this PR postdates your last review, so that review does not cover the current HEAD.

Fixes landed since then were verified locally (build, tests, vet, gocyclo, and golangci-lint at the CI-pinned version), and CI on this PR is green across all workflows. Please review the current HEAD.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@cristim Acknowledged — reviewing the current PR head, including the commits added since the prior review.

✅ 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.

An Azure exchange mutates two sets of reservations: the ones handed back
(sources) and the ones acquired (targets). Only the targets reached the
Regions dimension of the execute:ri-exchange constraint check, so a
caller permitted solely in eastus could POST an exchange whose source was
their own subscription's westeurope Standard_M128 3-year reservation and
whose target was a single eastus reservation. targetLocations returned
["eastus"], the region constraint passed, requireAzureSourceOwnership
passed (the westeurope reservation IS billed to that subscription, and
that gate keys on BillingScopeID rather than region), and the exchange
committed: a westeurope commitment the caller was never authorized to
touch was consumed and relocated to eastus, irreversibly.

No other gate covered it -- ExchangeableReservation.Region was consulted
by nothing. The AWS analog is safe only because AWS exchanges are
same-region; cross-region is this feature's stated purpose.

exchangeRegions now folds each source reservation's own region into the
same normalized, de-duplicated set as the target locations, so every
region the operation touches must be permitted. Source regions come from
the tenant listing rather than the request body, which names only
reservation ids; the listing is fetched once in
authorizeAzureExchangeExecution and reused by requireAzureSourceOwnership
so both source-side gates judge the same data. That listing now precedes
the constraint check, since the Regions dimension cannot be assembled
without it.

A source whose Region Azure did not report (documented as possible for
AppliedScopeType == Shared) or that is absent from the listing
contributes an unknown-region sentinel instead of being dropped:
dropping it would make "we do not know where this is" mean
"unconstrained", the fail-open shape fixed in #1495. A permission with no
Regions constraint is unaffected, matching unattributedAccountConstraint's
posture on the AccountIDs dimension.

Regression coverage asserts the security property rather than the
constraint set as built: an eastus-only caller is denied a westeurope
source, an unreported source region denies, and the sentinel does not
block an unscoped caller. The first two fail against the pre-fix call
site by reaching CalculateExchange.
@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

An independent adversarial review confirmed the region-matcher fix already on this branch is correct, but found it covers only half the exchange. Pushed 879bc1b to close the other half.

What was wrong

An Azure exchange mutates two sets of reservations: the ones handed back (sources) and the ones acquired (targets). Only the targets reached the Regions dimension of the execute:ri-exchange constraint check.

Concrete scenario: a caller with Constraints{AccountIDs:[acct-A], Regions:["eastus"]} POSTs an exchange whose source is acct-A's own westeurope Standard_M128 3-year reservation and whose target is a single eastus reservation. targetLocations returned ["eastus"], so the region constraint passed. requireAzureSourceOwnership passed too — the westeurope reservation genuinely IS billed to that subscription, and that gate keys on BillingScopeID, not region. The exchange committed: a westeurope commitment the caller was never authorized to touch was consumed and relocated to eastus, irreversibly. ExchangeableReservation.Region was consulted by no gate at all.

This is an authorization-scope gap (unauthorized mutation of a foreign-region resource), not unbounded loss — Azure still requires the new commitment to be worth at least the returned reservation's prorated value, and net cash stays bounded by MaxPurchaseAmount. The AWS analog is safe only because AWS exchanges are same-region; cross-region is precisely what this endpoint exists to do.

The fix

exchangeRegions folds each source reservation's own region into the same normalized, de-duplicated set as the target locations, so every region the operation touches must be permitted. Source regions come from the tenant listing (the request body names only reservation ids), which is now fetched once in authorizeAzureExchangeExecution and reused by requireAzureSourceOwnership, so both source-side gates judge identical data. That listing consequently precedes the constraint check — the Regions dimension cannot be assembled without it.

Empty-region trap: ExchangeableReservation.Region "may be empty for reservations with AppliedScopeType == Shared". Such a source contributes an unknown-region sentinel rather than being dropped — dropping it would make "Azure did not tell us where this is" mean "unconstrained", the exact fail-open shape fixed in #1495. A permission with no Regions constraint is unaffected (matchAllRegionsConstraint treats an empty permission list as no restriction), so Shared-scope reservations stay exchangeable for callers who were never region-scoped. Same posture as unattributedAccountConstraint on the AccountIDs dimension.

Regression coverage

TestExecuteAzureExchange_ConstraintSetPinsAllDimensions could not catch this: it asserts the constraint set as built, so it stayed green with the gap present. The new tests assert the security property instead:

  • TestExecuteAzureExchange_SourceRegionOutsidePermissionRefused — an eastus-only caller is denied a westeurope source.
  • TestExecuteAzureExchange_UnreportedSourceRegionRefused — an unreported source region denies.
  • TestExecuteAzureExchange_UnreportedSourceRegionAllowedWithoutRegionScope — the sentinel does not block an unscoped caller.
  • TestExchangeRegions — the fold itself (case, dedup, missing reservation, blank region).

The first two were verified to FAIL against the pre-fix call site (targetLocations(body.Targets)): the westeurope source sailed past the permission check and reached CalculateExchange, i.e. the money path.

Gates, all from the worktree at this commit: go build ./... 0, go vet ./internal/... 0, go test ./internal/... 0, go test -race ./internal/api/... ./internal/auth/... 0, gocyclo -over 10 (CI's -ignore "_test\.go" filter) 0, golangci-lint run ./internal/... 0 on the CI-pinned v2.10.1.

Follow-ups filed (out of scope here)

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

commit="879bc1b"

printf '%s\n' '== Commit and changed files =='
git show --no-ext-diff --format=fuller --stat "$commit"
git diff --check "${commit}^" "$commit"
git diff --no-ext-diff --unified=90 "${commit}^" "$commit" -- \
  internal/api/handler_ri_exchange.go \
  internal/api/handler_ri_exchange_azure_test.go \
  internal/auth

printf '%s\n' '== Relevant handler structure =='
ast-grep outline internal/api/handler_ri_exchange.go --items all \
  --type function,method,struct \
  --match 'Azure|exchangeRegions|Ownership|Constraint|authorize' 2>/dev/null || true

printf '%s\n' '== Execution authorization and region helper contexts =='
rg -n -C 24 \
  'func \(h \*Handler\) (executeAzureExchange|authorizeAzureExchangeExecution|requireAzureSourceOwnership|checkAzureExecuteConstraints)|func exchangeRegions|unknown-region|targetLocations|ListExchangeableReservations|CalculateExchange|ExecuteExchange' \
  internal/api/handler_ri_exchange.go

printf '%s\n' '== Models used for source-region lookup =='
rg -n -C 12 \
  'type ExchangeableReservation|Region[[:space:]]+string|BillingScopeID' \
  providers/azure/services/compute \
  -g '*.go'

printf '%s\n' '== Focused regression tests =='
rg -n -C 22 \
  'TestExecuteAzureExchange_(SourceRegionOutsidePermissionRefused|UnreportedSourceRegionRefused|UnreportedSourceRegionAllowedWithoutRegionScope)|TestExchangeRegions|exchangeRegions|unknown-region|ConstraintSetPinsAllDimensions' \
  internal/api/handler_ri_exchange_azure_test.go \
  internal/auth \
  -g '*_test.go'

printf '%s\n' '== All production execution region constraint construction =='
rg -n -C 10 \
  'checkAzureExecuteConstraints\(|PermissionConstraints\{|Regions:' \
  internal/api \
  -g '*.go' -g '!**/*_test.go'

Length of output: 207192


@cristim Reviewed 879bc1b — no new findings.

The execution path now correctly lists source reservations before constraint evaluation, derives a normalized union of target and source regions, and reuses that same listing for source-ownership validation. This closes the cross-region source mutation gap without creating a fail-open path for shared-scope reservations whose source region is unreported: unknownRegionConstraint blocks region-scoped permissions, while unconstrained permissions remain unaffected.

The added tests meaningfully pin the security property by ensuring unauthorized or unreported source regions never reach CalculateExchange or ExecuteExchange.

🐇🔎


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1515

Timestamp: 2026-07-28T19:08:55.247Z
Learning: For Azure RI exchange execution in `internal/api/handler_ri_exchange.go`, `PermissionConstraints.Regions` must cover every region mutated by the exchange: normalized target locations and normalized regions of all source reservations from the authoritative tenant listing. A listed source with a blank/missing region must contribute the `unknownRegionConstraint` sentinel so region-scoped permissions fail closed; permissions with no region constraint remain unrestricted.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ 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.

Folding source regions into the Regions dimension put the constraint
check ahead of requireAzureSourceOwnership, and the two together formed
an enumeration oracle. A caller scoped to subscription A and permitted
only in eastus received two distinguishable 403s for a reservation id
they do not own: an id that exists in eastus but is billed to
subscription B cleared the Regions dimension and was refused by the
ownership gate, while an id that does not exist -- or lives in an
unpermitted region -- tripped the sentinel or the foreign region and was
refused by the constraint check with a different message. The difference
confirms "this reservation id exists, in one of my permitted regions, in
a subscription I am not scoped to", which is exactly what
requireAzureSourceOwnership's deliberately identical denials withhold.

Ownership now runs immediately after the tenant listing and before the
constraint check, so the whole path is as indistinguishable as the gate
already was. The listing is in hand at that point, so there is no extra
round trip, and executeAzureExchange no longer needs the listing handed
back to it.

It also sharpens the sentinel: every source reaching exchangeRegions is
known-owned, so unknown-region means only "owned, but Azure reported no
region" rather than doubling as "not yours" or "not real". The
missing-source branch stays as a fail-closed default for any future
caller without that guarantee.

The existing TestRequireAzureSourceOwnership_DenialsAreIndistinguishable
stays green with this bug present, because it calls the gate directly
rather than driving the path. The new test drives the handler and asserts
both probes return the same status and body; it fails against the
previous ordering with the two messages diverging.
@cristim

cristim commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Round-2 review finding addressed in 849679b.

D1 — the gate ordering was an enumeration oracle

Folding the source regions into the Regions dimension (879bc1b) required the tenant listing, which put the constraint check ahead of requireAzureSourceOwnership. Together the two leaked what the ownership gate exists to withhold.

A caller scoped to subscription sub-1 and permitted only in eastus got two distinguishable 403s for a reservation id they do not own:

probe pre-fix response
id exists in eastus, billed to sub-2 exchangeRegions yields ["eastus"] → constraint passes → ownership gate: sources[0].reservation_id is not a reservation billed to subscription "sub-1"...
id does not exist (or is in westeurope) sentinel / foreign region joins the set → constraint check refuses first: permission denied: this request exceeds the constraints configured on your execute permission for ri-exchange

The difference confirms "this reservation id exists, in one of my permitted regions, in a subscription I am not scoped to". requireAzureSourceOwnership's doc comment promises its denials do not distinguish "does not exist" from "belongs to someone else" — still true of the gate, no longer true of the path.

Fix: requireAzureSourceOwnership now runs immediately after listOwnedAzureReservations and before checkAzureExecuteConstraints. The listing is already in hand, so no extra round trip, and authorizeAzureExchangeExecution no longer has to hand the listing back to executeAzureExchange. It also sharpens the sentinel: every source reaching exchangeRegions is known-owned, so unknown-region now means only "owned, but Azure reported no region" rather than doubling as "not yours" / "not real". The missing-source branch stays as a fail-closed default for any future caller without that guarantee.

Test: TestRequireAzureSourceOwnership_DenialsAreIndistinguishable stays green with this bug present — it calls the gate directly rather than driving the path, the same illusory-coverage shape that let the original region gap through. The new TestExecuteAzureExchange_UnownedSourceDenialsAreIndistinguishable drives the handler for both probes and asserts identical status and body. Verified failing against the previous ordering:

--- FAIL: TestExecuteAzureExchange_UnownedSourceDenialsAreIndistinguishable (0.00s)
    Error:  Not equal:
            expected: "sources[0].reservation_id is not a reservation billed to subscription \"sub-1\"; an exchange may only hand back reservations that subscription paid for"
            actual  : "permission denied: this request exceeds the constraints configured on your execute permission for ri-exchange"
    Messages: ...nor by message: differing denials confirm the id exists in a subscription the caller is not scoped to
FAIL	github.com/LeanerCloud/CUDly/internal/api	1.365s

Two existing tests whose subject is a source-side gate now legitimately return before the constraint check ever runs, so they use a newAzureExecuteSourceGateHandler variant that marks that one expectation optional; every other test keeps requiring it.

Gates, all re-run at 849679b

gate exit
go build ./... 0
go vet ./internal/... 0
go test ./internal/... 0
go test -race ./internal/api/... ./internal/auth/... 0
gocyclo -over 10 -ignore "_test\.go" (CI's filter) 0
golangci-lint run ./internal/... 0 — "0 issues", on the CI-pinned v2.10.1, not a newer local build

CI on the previous commit 879bc1b was green across all five workflows; a fresh watcher is armed for this one.

D2 filed, not fixed here

#1656listExchangeableAzureRIs returns the tenant-wide reservation listing behind view:purchases alone, with no requireAzureSubscriptionScope and no filtering against allowed_accounts. It undercuts the anti-enumeration posture this PR builds: both POST siblings enforce subscription scope and keep denials indistinguishable, while the GET on the same resource hands the listing over unfiltered. Azure RBAC bounds it to the deployment's managed estate rather than the whole customer tenant. Verified byte-identical at the merge base, so it is pre-existing, not a regression from this PR. Linked from the PR body along with #1642/#1643/#1644/#1645.

The PR body also now records that the unknown-region sentinel and #1643 are coupled: Region is documented as possibly empty for AppliedScopeType == Shared, and per #1643 every reservation this endpoint mints is Shared — so if that documented case is real, a region-scoped caller could be locked out of re-exchanging reservations this endpoint created. Likely theoretical, but the two should be resolved with each other in mind.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

commit="849679b"

printf '%s\n' '== Commit metadata and patch =='
git show --no-ext-diff --format=fuller --stat "$commit"
git diff --check "${commit}^" "$commit"
git diff --no-ext-diff --unified=100 "${commit}^" "$commit" -- \
  internal/api/handler_ri_exchange.go \
  internal/api/handler_ri_exchange_azure_test.go

printf '%s\n' '== Handler structure =='
ast-grep outline internal/api/handler_ri_exchange.go --items all \
  --type function,method,struct \
  --match 'Azure|authorize|Ownership|exchangeRegions|Constraint' 2>/dev/null || true

printf '%s\n' '== Authorization-ordering and error-path context =='
rg -n -C 30 \
  'func \(h \*Handler\) (executeAzureExchange|authorizeAzureExchangeExecution|listOwnedAzureReservations|requireAzureSourceOwnership|checkAzureExecuteConstraints)|func exchangeRegions|ListExchangeableReservations|requireAzureSubscriptionScope|CalculateExchange|ExecuteExchange|unknownRegionConstraint' \
  internal/api/handler_ri_exchange.go

printf '%s\n' '== Handler-level indistinguishable-denial regression coverage =='
rg -n -C 35 \
  'TestExecuteAzureExchange_UnownedSourceDenialsAreIndistinguishable|newAzureExecuteSourceGateHandler|RequireAzureSourceOwnership_DenialsAreIndistinguishable|SourceRegionOutsidePermissionRefused|UnreportedSourceRegion' \
  internal/api/handler_ri_exchange_azure_test.go

printf '%s\n' '== Call sites and listing implementation/interface =='
rg -n -C 12 \
  '\.authorizeAzureExchangeExecution\s*\(|\.listOwnedAzureReservations\s*\(|\.requireAzureSourceOwnership\s*\(|\.checkAzureExecuteConstraints\s*\(|ListExchangeableReservations\s*\(' \
  internal/api providers/azure/services/compute \
  -g '*.go'

Length of output: 192218


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Current head and reviewed commit =='
git rev-parse HEAD
git show -s --format='%H%n%P%n%s' 849679b
git merge-base --is-ancestor 849679b HEAD && echo '849679b-is-ancestor-of-HEAD=yes'

printf '%s\n' '== Changes since reviewed commit =='
git diff --name-status 849679b..HEAD
git diff --check 849679b..HEAD

printf '%s\n' '== Exact commit changed-file scope =='
git diff-tree --no-commit-id --name-status -r 849679b

printf '%s\n' '== Full-path authorization sequence references =='
rg -n -C 6 \
  'authorizeAzureExchangeExecution|requireAzureSourceOwnership\(owned|checkAzureExecuteConstraints\(ctx.*exchangeRegions|CalculateExchange\(ctx' \
  internal/api/handler_ri_exchange.go

Length of output: 8494


@cristim Reviewed current HEAD 849679b — no new findings.

authorizeAzureExchangeExecution now lists sources, verifies their billing-scope ownership, and only then derives/evaluates the account and region constraints. Consequently, both an absent source ID and a foreign source ID stop at the same ownership denial before region matching can differentiate them. The handler-level regression test correctly verifies identical status and body and confirms neither probe reaches CalculateExchange or ExecuteExchange.

The current HEAD is exactly 849679b; there are no later commits outside this review.


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/1515

Timestamp: 2026-07-28T19:32:05.635Z
Learning: For Azure RI exchange execution in `internal/api/handler_ri_exchange.go`, source ownership must be checked against the authoritative tenant reservation listing before deriving or evaluating `execute:ri-exchange` permission constraints. Otherwise region-constraint outcomes can distinguish a nonexistent source reservation from a real reservation billed to another subscription, creating an enumeration oracle. The handler-level denial behavior—not only `requireAzureSourceOwnership` in isolation—must remain indistinguishable for those cases.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@cristim
cristim merged commit ecb0681 into main Jul 28, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/l Weeks impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

1 participant