Skip to content

fix(api): scope Azure exchangeable-RI listing to session's allowed accounts - #1711

Merged
cristim merged 3 commits into
mainfrom
fix/1656-scope-azure-ri-exchange-list
Aug 5, 2026
Merged

cristim merged 3 commits into
mainfrom
fix/1656-scope-azure-ri-exchange-list

Conversation

@cristim

@cristim cristim commented Aug 3, 2026 •

Copy link
Copy Markdown
Member

Summary

listExchangeableAzureRIs (GET /api/ri-exchange/azure-instances) enumerated every reservation the deployment's Azure credential could read across the whole tenant, regardless of the caller's allowed_accounts scope. Any session holding view:purchases could see every subscription's exchangeable reservations, including subscriptions outside their own scope.

This narrows the GET listing to match the scoping the two POST siblings on the same resource (getAzureCompatibleOfferings, executeAzureExchange) already enforce:

  • ?subscription_id= supplied: requireAzureSubscriptionScope gates whether the session may use that subscription at all, returning the same generic errNotFound (404, not 403) the POST siblings use, so a scoped caller cannot probe which subscriptions exist. The listing is then narrowed to that subscription's own rows via BillingScopeID (filterAzureReservationsBySubscription) -- the same discriminator requireAzureSourceOwnership already keys on for issue Azure RI exchange: source reservations are never scoped to the authorized subscription #1527.
  • No subscription_id: the listing is narrowed to whatever the session's allowed_accounts scope covers (filterAzureReservationsByScope), mirroring filterDashboardRecommendations in handler_dashboard.go. Unrestricted/admin sessions pass through unchanged.

Rows that can't be attributed to a registered CloudAccount (no BillingScopeID, or one that matches no registered account) are dropped rather than kept, so an unresolvable scope never degrades to "show everything" -- and the two possible empty-result cases (a scoped caller with zero reservations of their own vs. one whose rows are unattributable) stay indistinguishable.

filterAzureReservationsByScope was split into azureScopeIndex + filterReservationsByScopeIndex to stay under the project's gocyclo threshold (-over 10); the combined function measured 11.

Sibling convention matched

Follows the exact pattern already established for the POST siblings on this resource (requireAzureSubscriptionScope, requireAzureSourceOwnership, filterDashboardRecommendations): fail-closed scope resolution, indistinguishable 404 denials, and counts-only logging (never reservation or billing-scope identifiers).

Test plan

Added to internal/api/handler_ri_exchange_test.go:

  • TestListExchangeableAzureRIs_ScopedFiltersToOwnSubscription -- a session scoped to subscription A, no subscription_id filter, against a fixture holding reservations in A and B: only A's reservation comes back, B's identifiers never appear in the response.
  • TestListExchangeableAzureRIs_ScopedEmptyCasesIndistinguishable -- compares a scoped caller whose own subscription has zero reservations against one whose only visible row is unattributable; both render the identical empty list.
  • TestListExchangeableAzureRIs_SubscriptionIDOutOfScope -- ?subscription_id= naming a subscription the session isn't scoped to returns the same errNotFound shape as the POST siblings.

Confirmed the three new tests fail against the pre-fix handler (stashed just the handler change, kept the tests) and pass after:

Pre-fix (handler reverted, tests unchanged):

--- FAIL: TestListExchangeableAzureRIs_ScopedFiltersToOwnSubscription (0.00s)
    Error: "[...]" should have 1 item(s), but has 2
--- FAIL: TestListExchangeableAzureRIs_ScopedEmptyCasesIndistinguishable (0.00s)
    Error: Should be empty, but was [{ res-b ... }]
    Error: Should be empty, but was [{ res-orphan ... }]
--- FAIL: TestListExchangeableAzureRIs_SubscriptionIDOutOfScope (0.00s)
    Error: An error is expected but got nil.
FAIL	github.com/LeanerCloud/CUDly/internal/api	3.237s

Post-fix:

--- PASS: TestListExchangeableAzureRIs_ScopedFiltersToOwnSubscription (0.00s)
--- PASS: TestListExchangeableAzureRIs_ScopedEmptyCasesIndistinguishable (0.00s)
--- PASS: TestListExchangeableAzureRIs_SubscriptionIDOutOfScope (0.00s)
PASS
ok  	github.com/LeanerCloud/CUDly/internal/api	1.478s

Gates run locally:

  • go build ./...
  • go vet ./...
  • go test ./... (full suite, all packages pass)
  • gocyclo -over 10 -ignore "_test\.go" . -- clean (0 issues after the split)
  • golangci-lint run at the CI-pinned v2.10.1 (not the locally-installed 2.11.4) -- 0 issues.

Closes #1656

Summary by CodeRabbit

  • Bug Fixes

    • Azure reservation listings are now restricted to subscriptions and billing scopes the requester is authorized to access.
    • Unattributed or unregistered reservations are excluded from scoped results.
    • Out-of-scope subscription requests return a generic not-found response, improving access-control consistency.
  • Tests

    • Added coverage for scoped filtering, empty results, and unauthorized subscription requests.

@cristim cristim added triaged Item has been triaged priority/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint effort/s Hours type/security Security finding labels Aug 3, 2026
@coderabbitai

coderabbitai Bot commented Aug 3, 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: 2 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: 8cb83db7-1af9-49e8-9f99-d9cecc49e3ff

📥 Commits

Reviewing files that changed from the base of the PR and between 5526aab and cbd12d9.

📒 Files selected for processing (2)
  • internal/api/handler_ri_exchange.go
  • internal/api/handler_ri_exchange_test.go
📝 Walkthrough

Walkthrough

The Azure exchangeable-reservation listing now enforces subscription scope and filters tenant-wide results by registered billing scopes. Reservations without resolvable scopes are excluded. Regression tests cover scoped, empty, and out-of-scope responses.

Changes

Azure RI exchange authorization

Layer / File(s) Summary
Validate requested subscription scope
internal/api/handler_ri_exchange.go, internal/api/handler_ri_exchange_test.go
The handler validates explicit subscription_id values against the session’s allowed accounts and returns errNotFound for out-of-scope subscriptions before contacting Azure.
Filter reservations by billing scope
internal/api/handler_ri_exchange.go, internal/api/handler_ri_exchange_test.go
Requested listings match BillingScopeID. Tenant-wide listings resolve registered Azure accounts and retain only allowed accounts. Missing or unknown scopes are excluded. Tests cover filtering and indistinguishable empty responses.

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

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant listExchangeableAzureRIs
  participant SessionScope
  participant AzureClient
  participant AzureAccountRegistry

  Caller->>listExchangeableAzureRIs: Request Azure exchangeable reservations
  alt subscription_id supplied
    listExchangeableAzureRIs->>SessionScope: Validate subscription scope
    alt subscription is out of scope
      listExchangeableAzureRIs-->>Caller: errNotFound
    else subscription is allowed
      listExchangeableAzureRIs->>AzureClient: List reservations
      AzureClient-->>listExchangeableAzureRIs: Reservations
      listExchangeableAzureRIs-->>Caller: Matching BillingScopeID rows
    end
  else subscription_id omitted
    listExchangeableAzureRIs->>AzureClient: List tenant-wide reservations
    AzureClient-->>listExchangeableAzureRIs: Reservations
    listExchangeableAzureRIs->>AzureAccountRegistry: Resolve BillingScopeID values
    AzureAccountRegistry-->>listExchangeableAzureRIs: Registered account scopes
    listExchangeableAzureRIs-->>Caller: Reservations matching allowed accounts
  end
Loading

Possibly related issues

  • #1715: Both changes address Azure RI-exchange subscription scoping and account/subscription identifier resolution.

Possibly related PRs

Suggested labels: impact/many

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly states that Azure exchangeable-reservation listings are scoped to the session's allowed accounts.
Linked Issues check ✅ Passed [#1656] The changes enforce subscription authorization, filter reservations by account scope, fail closed, preserve admin behavior, and add regression tests.
Out of Scope Changes check ✅ Passed The implementation, tests, documentation updates, and result normalization directly support the linked issue objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1656-scope-azure-ri-exchange-list

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

@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 3, 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 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 (1)
internal/api/handler_ri_exchange_test.go (1)

951-967: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert that ListExchangeableReservations is not called.

ownsAzureSource registers this method with .Maybe(), so AssertExpectations passes whether it is called or not. Add opsClient.AssertNotCalled(t, "ListExchangeableReservations") after the assertions.

🤖 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 951 - 967, The test
should explicitly verify that the out-of-scope request never invokes
ListExchangeableReservations. In the test around listExchangeableAzureRIs, add
opsClient.AssertNotCalled(t, "ListExchangeableReservations") after the existing
error assertions, while retaining the current ownsAzureSource and cleanup
behavior.
🤖 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 951-967: The test should explicitly verify that the out-of-scope
request never invokes ListExchangeableReservations. In the test around
listExchangeableAzureRIs, add opsClient.AssertNotCalled(t,
"ListExchangeableReservations") after the existing error assertions, while
retaining the current ownsAzureSource and cleanup behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: dc762f90-97e7-4a04-89d1-c079fcde3ccc

📥 Commits

Reviewing files that changed from the base of the PR and between 02702a1 and b76d66f.

📒 Files selected for processing (2)
  • internal/api/handler_ri_exchange.go
  • internal/api/handler_ri_exchange_test.go

@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Adversarial review — PR #1711 @ b76d66ff4

Independent review. I read the committed diff (git diff origin/main...origin/fix/1656-scope-azure-ri-exchange-list) in a detached worktree at the PR head, not the implementer's tree, and ran every gate myself.

Verdict: no security hole found in the fix. The anti-enumeration posture holds on every axis I attacked — denials are byte-identical, the gate runs before the expensive call, every empty case renders identically, and the ?subscription_id= path is strictly tighter than the no-parameter path. But the PR ships ~55 lines of filtering that is unreachable in production, proven only by a test that injects azureExchangeFactory; the one filter that is reachable has no test at all; and it converts a silently-broken UI state into a 404 error banner for scoped users. I would want F1 and F2 addressed before merge.


Gates (run by me, on b76d66ff4)

Gate Result
go build ./... exit 0
go vet ./... exit 0
go test ./... exit 0, 41 packages
go test ./internal/api/ 2028 passed
golangci-lint run --timeout=10m ./... @ v2.10.1 (CI-pinned; binary version asserted before use) exit 0, 0 issues.
gocyclo -over 10 -ignore "_test\.go" ./internal/api clean (no output)

Findings

F1 — Medium. The UI sends a CloudAccount UUID as subscription_id; the new gate turns that into a 404 for every scoped user

The topbar account chip's option value is the CloudAccount UUID, not the Azure subscription ID:

  • frontend/src/topbar-filters.ts:107 — value: a.id
  • internal/api/handler_accounts.go:180 — AccountSummary{ID: acct.ID, ...} (UUID), with ExternalID carried as a separate field
  • frontend/src/riexchange.ts:65 → :303 — accountIDs[0] is passed straight to api.listExchangeableAzureRIs(accountID)
  • frontend/src/api/riexchange.ts:41 — placed into ?subscription_id=

Before this PR, buildAzureExchangeClient looked that UUID up as an external_id, found no row, and returned the graceful empty 200. After this PR, handler_ri_exchange.go:282-286 runs requireAzureSubscriptionScope first, which resolves the same UUID to no account and returns errNotFound → the Azure RI-Exchange tab renders Failed to load Azure reservations: not found for every restricted session that selects an account chip. Admin sessions are unaffected (the scope check short-circuits on IsUnrestrictedAccess).

Verified by execution, not by reading: a throwaway test driving listExchangeableAzureRIs through the real buildAzureExchangeClient (no factory injection) with subscription_id = a UUID returned errNotFound for a scoped session and an empty 200 for an unrestricted one.

The 404 is the correct security posture given the handler's contract — the real defect is the frontend sending the wrong identifier, and the listing was already returning empty for chip-selected accounts before this PR. So this changes silently-broken into loudly-broken. That is arguably an improvement, but it is a user-visible behaviour change this PR does not mention. Suggested: file a follow-up issue for the FE/BE identifier contract (a.id vs a.external_id on this one call) and note the transition in the PR body.

F2 — Medium. The bulk of the new code is production-unreachable; the reachable filter is untested

azureExchangeFactory is declared at internal/api/handler.go:87 and assigned only in tests (grep over internal/ cmd/ excluding _test.go finds no writer). So in production, with no subscription_id, buildAzureExchangeClient calls GetCloudAccountByExternalID(ctx, "azure", ""), whose query is WHERE ca.provider = $1 AND ca.external_id = $2 (internal/config/store_postgres.go:3056), matches no row, and the handler returns the empty short-circuit at handler_ri_exchange.go:292-297 — before the listing call and therefore before the filter.

That makes filterAzureReservationsByScope (:356), azureScopeIndex (:390) and filterReservationsByScopeIndex (:406) unreachable in production. Meanwhile filterAzureReservationsBySubscription (:328) — the only filter on the path production actually takes — has zero coverage: no test names it, and TestListExchangeableAzureRIs_SubscriptionIDOutOfScope stops at the scope gate without ever reaching it.

Verified by execution: the same throwaway test, with a ListCloudAccountsFn tripwire, showed the no-subscription_id path never called ListCloudAccounts — and the mock's GetAllowedAccountsAPI expectation went unmet, i.e. filterAzureReservationsByScope was never entered.

The doc comment at :259-262 describes the no-parameter branch as the live behaviour. This is exactly the class of claim ("comment asserting cross-file behaviour") that reviews here have been weakest on.

Suggested fix:

  1. Add a handler-level test for the in-scope ?subscription_id= path asserting only that subscription's rows come back — that is the filter protecting production.
  2. Either state in the doc comment that the no-parameter branch is defence-in-depth for the external_id = '' account edge case, or confirm the intended reachable surface with the issue author.

F3 — Low. buildAzureExchangeClient's doc states a conjunction the code does not implement

handler_ri_exchange.go:196-198: "subscriptionID is empty AND no Azure accounts are registered at all". There is no such conjunction — an empty subscriptionID yields a nil client unconditionally, whatever is registered. Pre-existing, but this PR restates it verbatim at :271-273, where it now carries weight (it is what makes the no-parameter branch look reachable). Suggested: "subscriptionID is empty: no account can be resolved, so the caller returns an empty list."

F4 — Low. Two different comparison methods for the same discriminator, and a case-collision in the index

filterAzureReservationsBySubscription:332 uses strings.EqualFold; azureScopeIndex:396 / filterReservationsByScopeIndex:409 use a strings.ToLower-keyed map. They agree for ASCII. They diverge on the Turkish dotted I — strings.ToLower("İ") == "i" but strings.EqualFold("İ", "i") == false — and the ToLower path is the more permissive of the two, so an account whose ExternalID carried such a character would index a scope EqualFold would reject. Azure subscription IDs are GUIDs, so this is not reachable from Azure-sourced data; ExternalID is operator-supplied. Not a live hole.

Separately: cloud_accounts declares UNIQUE(provider, external_id) (migration 000011_cloud_accounts.up.sql:38 — verified, not taken from the comment), and Postgres compares that case-sensitively, so two rows differing only in case can coexist. azureScopeIndex collapses them and last-write-wins; if the surviving entry is not the one in allowed_accounts, the user's own rows are dropped. Fails closed, so it is an availability nit rather than a leak.

Suggested fix, which removes both: mirror filterDashboardRecommendations more closely — iterate the accounts, keep the ones auth.MatchesAccount passes, and build a map[string]struct{} of allowed lower-cased scopes. Same cyclomatic cost, no collision semantics, one comparison rule.

F5 — Nit. Duplicated comment

The "Counts, not the dropped reservation/billing-scope identifiers, are safe to log…" paragraph appears twice: in the doc comment (:351-355) and inline (:372-374).

F6 — Nit (optional). provider := "azure" at :365

common.ProviderAzure is used in this same file at :1050. That said, exchange_lookup.go:187 and handler_accounts.go:1508 both use the bare literal for exactly this filter, so the new code matches local convention. Flagging only for completeness.

F7 — Nit. Admin passthrough can emit null

:362 returns the client's slice unchanged for unrestricted sessions, which can be nil → {"reservations": null}, while every other path emits []. Admin-only, and the frontend does resp.reservations ?? [].

F8 — Not this PR, but it is what is red

Security Scanning fails at npm audit --audit-level=high: brace-expansion GHSA-rgw5-rvv9-x895 and fast-uri GHSA-7p8r-x3mc-p8w7. That step's exit 1 aborted the job before gosec and Trivy wrote their SARIF, which is why the visible errors are the two Path does not exist: *.sarif uploads. The branch is 0 behind origin/main, so these advisories are on main too (main's last green ci.yml run predates them). Needs its own npm audit fix PR; nothing here to change.


Hypotheses tested and cleared

  • Is the filter an oracle? No. requireAzureSubscriptionScope:792 treats account == nil and !MatchesAccount(...) identically, returning the same errNotFound sentinel (handler_router.go:19-26, Error() == "not found", 404) with no distinguishing text, and both cost exactly one GetCloudAccountByExternalID. The gate runs at :282, before buildAzureExchangeClient and the tenant-wide ARM listing, so there is no timing signal from the listing either.
  • Do the two empty cases look identical? Yes. Every non-admin return path emits a non-nil empty slice — make([]T, 0, len(...)) at :330 and :407, and the literal at :296 — so all three empty cases serialize as {"reservations":[]}. A scoped caller cannot separate "my subscriptions have zero exchangeable reservations" from "no row could be attributed".
  • Fail-closed? Yes. getAllowedAccounts and ListCloudAccounts errors are both wrapped and returned; no fall-through to an unfiltered listing. No ("", nil) resolver anywhere on this path. h.auth == nil cannot reach getAllowedAccounts here because requirePermission fails first — asserted by the pre-existing TestListExchangeableAzureRIs_RequiresPermission.
  • Does empty allowed_accounts mean "all"? Yes, via auth.IsUnrestrictedAccess (internal/auth/types.go:157-178) — the repo-wide, explicitly documented 03-L5 fail-open backward-compat default, identical to what requireAzureSubscriptionScope, filterDashboardRecommendations and listAccountsMinimal already do. Not this PR's to change. One wording note: the doc comment says "Unrestricted/admin sessions see everything", which hides that "no allowed_accounts configured" also lands in that bucket.
  • Is IsUnrestrictedAccess the right admin predicate? Yes — the same predicate the POST siblings on this resource use, so no class of session is treated more broadly here than there.
  • Reach parity. The ?subscription_id= path enforces strictly more: rows ⊆ {that subscription}, and that subscription ∈ allowed_accounts by the gate. The same subscriptionID != "" condition drives both the gate (:282) and the row filter (:304), so the two axes cannot desynchronize.
  • Logging. :376 logs only dropped and len(reservations). No reservation IDs, no billing scopes, no account identifiers.
  • Do the tests fail pre-fix? Yes — verified by execution, not from the report. With git checkout origin/main -- internal/api/handler_ri_exchange.go and the new tests in place, all three fail: length 2 vs the required 1 (res-b present); Should be empty, but was [{res-b …}]; An error is expected but got nil. Restored afterwards; worktree clean.
  • Reuse / proportionality. Mirrors filterDashboardRecommendations (handler_dashboard.go:169) rather than inventing a third pattern. No new scoping abstraction, no mode enum, no config knob.

Not verified, and why

  • Live Azure behaviour — no tenant available. In particular, whether ARM ever returns a BillingScopeID cased differently from the registered ExternalID. Both filters are case-insensitive, so the conclusion holds either way.
  • Whether any non-UI client (CLI, MCP) calls this endpoint with a real subscription GUID. Grep over the repo found only frontend/src/api/riexchange.ts and internal/api/router.go:306, so F1's blast radius is the web UI.

No merge action taken.

@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Addressed the adversarial review (F1-F8) and the CodeRabbit nitpick. Pushed as 14342ced0 (findings) + 99efcd08a (nitpick).

CodeRabbit nitpick (internal/api/handler_ri_exchange_test.go:951-967): fixed. Added opsClient.AssertNotCalled(t, "ListExchangeableReservations") to TestListExchangeableAzureRIs_SubscriptionIDOutOfScope -- ownsAzureSource registers that method with .Maybe(), so AssertExpectations alone would have passed either way. Now it explicitly proves the scope gate refuses before the tenant-wide listing is ever fetched.

F2 (Medium, required): fixed. Added TestListExchangeableAzureRIs_SubscriptionIDFiltersToOwnRows, which exercises filterAzureReservationsBySubscription -- the one filter production can actually reach -- against a tenant-wide listing mixing rows for the authorized subscription and a different one; only the authorized row survives. Also corrected the doc comments on listExchangeableAzureRIs and buildAzureExchangeClient to state plainly that the no-subscription_id branch (filterAzureReservationsByScope) is defense-in-depth rather than the live production path today, and why: buildAzureExchangeClient resolves subscriptionID via an exact external_id match, so an empty subscriptionID never resolves a registered account and the graceful empty-state branch returns first. Left the code in place, as requested -- it's the seam that makes the branch testable and becomes live if buildAzureExchangeClient changes.

F1 (Medium, sizing requested): sized, not attempted. It ripples. The account chip's value (a.id, a CloudAccount UUID) is consumed by 7 frontend modules; 6 of them (dashboard, history, inventory, plans, recommendations, savings-history) filter server-side against cloud_account_id, a UUID field, so they need the UUID. Only the two RI-exchange listing calls (AWS account_id and Azure subscription_id) need external_id instead. There's no existing UUID->external_id lookup in the frontend to fix only those two call sites without new state plumbing (a cache populated alongside the chip, plus handling the async race against the accounts fetch). It's also not Azure-specific: the same resolveScope() function feeds the AWS convertible-RI listing with the same wrong identifier, so a real fix should cover both providers, not one, and that's more than a security-PR drive-by. Full writeup went to the PR owner for the tracking issue.

F3 (Low, optional -- taken): fixed. buildAzureExchangeClient's doc no longer claims an "empty AND no accounts registered" conjunction the code never implements; folded into the F2 doc fix since both cited the same claim.

F4 (Low): not changed, per direction -- confirmed settled: EqualFold vs the ToLower-keyed map agree on ASCII, and ARM billing-scope strings / GUIDs can't hit the Turkish-I divergence. Would be churn on a settled diff.

F5/F6 (Nit, duplicated comment): fixed. Removed the inline repeat of the "counts, not identifiers, are safe to log" paragraph; kept the doc-comment copy.

F6/other (Nit, bare "azure" literal): no change -- already matches local convention (exchange_lookup.go:187, handler_accounts.go:1508 both use the bare literal for this filter), confirmed in the finding itself.

F7 (Nit): fixed. The admin passthrough in filterAzureReservationsByScope now normalizes a nil reservations slice to [] before returning, so an unrestricted session can't emit {"reservations": null} while every other path on this handler emits [].

F8: not this PR (Security Scanning red is #1712, npm advisories, unrelated and pre-existing on main).

Gates re-run after all of the above: go build ./..., go vet ./..., go test ./... (full suite), gocyclo -over 10 -ignore "_test\.go" ., and golangci-lint run at the CI-pinned v2.10.1 -- all clean.

@cristim

cristim commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 3, 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 Aug 5, 2026

Copy link
Copy Markdown
Member Author

Requesting a full review, paced to one request per hour across this repo.

The CodeRabbit quota is per-developer per-organization and adaptive, tightening at the 95th percentile of recent review volume. Every open PR here draws on one budget, so bursting requests both consumes it faster and tightens the limit. Spacing them is what gets verdicts to land.

Full form rather than incremental: this head was pushed while the quota was exhausted, so its automatic review never ran and is not retried retroactively. An incremental request would skip exactly those commits and report clean on a diff it never read.

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

@cristim Full review requested for the current PR head. This review will include commits that the automatic review did not process.


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

Timestamp: 2026-08-05T05:27:02.228Z
Learning: For the LeanerCloud/CUDly repository, pace CodeRabbit review requests at one request per hour across the repository. The review quota is adaptive and shared per developer and organization; burst requests can exhaust the quota and tighten the limit. When a PR head was pushed while quota was exhausted, request a full review because an incremental review skips the unreviewed commits.

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

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.

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

996-999: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case-variant row to cover the case-insensitive match.

filterAzureReservationsBySubscription compares with strings.EqualFold, and azureScopeIndex normalizes with strings.ToLower. No new test exercises a BillingScopeID whose case differs from the registered ExternalID. A regression to an exact == comparison would still pass this suite and would silently drop a caller's own reservations.

Add one row whose billing scope uses different casing.

🧪 Proposed test data change
 	stub := &stubAzureExchangeClient{reservations: []azurecompute.ExchangeableReservation{
 		{ReservationID: "res-own", BillingScopeID: "/subscriptions/sub-1", SKU: "Standard_D2s_v3", Quantity: 1, Region: "eastus"},
+		{ReservationID: "res-own-mixed-case", BillingScopeID: "/Subscriptions/SUB-1", SKU: "Standard_D2s_v3", Quantity: 1, Region: "eastus"},
 		{ReservationID: "res-other", BillingScopeID: "/subscriptions/sub-2", SKU: "Standard_D4s_v3", Quantity: 5, Region: "westeurope"},
 	}}

Update the length assertion at Line 1014 to 2 when you add the row.

🤖 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 996 - 999, Add a third
reservation to the stubAzureExchangeClient data with the same subscription scope
as the registered account but different casing in BillingScopeID, then update
the result-length assertion from 1 to 2. Keep the existing distinct-scope
reservation unchanged so the test verifies case-insensitive matching while still
excluding unrelated reservations.
🤖 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 996-999: Add a third reservation to the stubAzureExchangeClient
data with the same subscription scope as the registered account but different
casing in BillingScopeID, then update the result-length assertion from 1 to 2.
Keep the existing distinct-scope reservation unchanged so the test verifies
case-insensitive matching while still excluding unrelated reservations.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 98d1b4e5-dfd4-4ff1-aa70-293dc17c9862

📥 Commits

Reviewing files that changed from the base of the PR and between 02702a1 and 99efcd0.

📒 Files selected for processing (2)
  • internal/api/handler_ri_exchange.go
  • internal/api/handler_ri_exchange_test.go

cristim added 3 commits August 5, 2026 11:03
…counts

listExchangeableAzureRIs enumerated every reservation the deployment's
Azure credential could read across the whole tenant, regardless of the
caller's allowed_accounts scope. A caller with view:purchases saw every
subscription's reservations, including ones outside their scope.

Narrow the GET listing the same way the POST siblings (compatible-
offerings, execute) already narrow their own scope:
  - subscription_id supplied: requireAzureSubscriptionScope gates access
    to that subscription (errNotFound, matching the POST siblings so a
    scoped caller cannot probe which subscriptions exist), then the
    listing is narrowed to that subscription's own rows via BillingScopeID.
  - No subscription_id: the listing is narrowed to whatever the session's
    allowed_accounts scope covers, mirroring filterDashboardRecommendations.
    Unrestricted/admin sessions pass through unchanged.

Rows with no BillingScopeID, or one matching no registered CloudAccount,
are dropped rather than kept, so an unresolvable scope never degrades to
"show everything" and the two empty-result cases stay indistinguishable.

Split the scope filter into azureScopeIndex and
filterReservationsByScopeIndex to keep filterAzureReservationsByScope
under the project's gocyclo threshold.

Adds regression tests covering: cross-subscription filtering with no
subscription_id, the two indistinguishable-empty cases, and the
out-of-scope subscription_id 404 shape.

Closes #1656
…oping

F2: filterAzureReservationsByScope has no production caller today --
buildAzureExchangeClient resolves subscriptionID via an exact external_id
match, so an empty subscriptionID never resolves a registered account and
the graceful empty-state branch returns before the tenant-wide listing is
ever fetched. Correct the doc comments on listExchangeableAzureRIs and
buildAzureExchangeClient to say so plainly instead of describing the
no-subscription_id branch as live. Add a regression test that exercises
the filter production actually reaches on this path,
filterAzureReservationsBySubscription, against a mix of in-scope and
out-of-scope rows.

F7: normalize a nil reservations slice to [] on the admin passthrough so
an unrestricted session can't emit {"reservations": null} while every
other path on this handler emits [].

Also drops a duplicated log-safety comment (doc comment already states
it) and a stale "AND no accounts registered" conjunction in
buildAzureExchangeClient's doc that the code never implements.
…rvations

ownsAzureSource registers ListExchangeableReservations with .Maybe(), so
AssertExpectations alone passes whether it was called or not. Add an
explicit AssertNotCalled to prove the scope gate refuses the request
before the tenant-wide listing is ever fetched.
@cristim
cristim force-pushed the fix/1656-scope-azure-ri-exchange-list branch from 99efcd0 to cbd12d9 Compare August 5, 2026 09:07
@cristim

cristim commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Rebased onto current main (5526aab) to pick up #1716's fix for the brace-expansion / fast-uri npm advisories that were reddening Security Scanning on the pre-rebase head — a workflow re-run can't pick up a new base since it reuses the same merge commit, so an actual rebase was required.

  • Old head 99efcd08a -> new head cbd12d9cd
  • Diff vs merge-base is byte-for-byte identical before/after the rebase (git diff <old-merge-base> 99efcd08a == git diff <new-merge-base> cbd12d9cd), so the pre-rebase CodeRabbit verdict still applies to this diff — no re-review needed.
  • Rebase was clean, no conflicts.

Verification after rebase:

  • go build ./... — clean
  • go test ./internal/api/... — 2041 passed
  • Targeted scoping tests all pass: TestListExchangeableAzureRIs_ScopedFiltersToOwnSubscription, TestListExchangeableAzureRIs_ScopedEmptyCasesIndistinguishable, TestListExchangeableAzureRIs_SubscriptionIDOutOfScope, TestListExchangeableAzureRIs_SubscriptionIDFiltersToOwnRows
  • go vet ./... — clean
  • gocyclo -over 10 on touched files — no findings
  • golangci-lint v2.10.1 (CI-pinned) on internal/api/... — 0 issues

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

Labels

effort/s Hours priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/security Security finding urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sec(api/azure): listExchangeableAzureRIs returns the tenant-wide reservation listing unscoped

1 participant