The account-filter chip carries a CloudAccount UUID, but both RI-exchange listing endpoints interpret their account parameter as the provider's own account identifier (external_id). The values never match, so the endpoints return empty.
This affects AWS and Azure, not just Azure. resolveScope() (frontend/src/riexchange.ts:62-66) is a single function feeding both loaders from the same UUID:
loadConvertibleRIs (AWS, riexchange.ts:109) — the handler expects an AWS account number (TestListConvertibleRIs_AccountScopeMismatchReturnsEmpty sends account_id: "123456789012")
loadExchangeableAzureRIs (Azure, riexchange.ts:100) — the handler expects an Azure subscription ID
sourceIdentity.ExternalID() (internal/api/handler.go:835) returns AccountID for aws, SubscriptionID for azure, ProjectID for gcp — so external_id is the correct value for every provider, and the UUID is correct for none of them here.
Current user-visible behaviour
Why a one-line fix does not exist
Sized during the LeanerCloud/cloud-commitments-cli#1711 review; both obvious approaches fail:
Changing the chip value globally is wrong. state.getCurrentAccountIDs() is consumed by seven non-test source files — dashboard.ts, history.ts, inventory.ts, plans.ts, recommendations.ts, riexchange.ts, modules/savings-history.ts. Six of them send it as account_ids matched against cloud_account_id (a CloudAccount UUID field) and are correct as-is. Swapping topbar-filters.ts:107 from a.id to a.external_id would fix RI-exchange and break the other six.
Resolving at the call site needs new state. There is no UUID -> external_id lookup anywhere in the frontend. populateAccountOptions does fetch api.listAccountsMinimal(), which carries external_id (frontend/src/api/accounts.ts:117-121, already used for the option label), but discards the list after building the chip options. Nothing caches it.
Suggested fix
Introduce a UUID-keyed account cache populated alongside the chip options and exposed for riexchange.ts, then resolve to external_id at the two RI-exchange call sites only. Leave the chip value itself as the UUID, since that is right for the other six consumers.
Handle the async race: resolveScope() can run before the accounts fetch resolves. A miss must not silently degrade to "send the UUID anyway" or "send nothing and show everything" — decide explicitly and fail visibly.
Cover both providers at that one call site. Add a test asserting the value sent as account_id/subscription_id is a provider account identifier and not a CloudAccount UUID; the mismatch survived because both are opaque strings and neither side validates the shape.
Do not change either handler's scoping or 404 posture, which exist to prevent account enumeration.
Found during the adversarial review of LeanerCloud/cloud-commitments-cli#1711; the AWS half was found while sizing the Azure fix.
The account-filter chip carries a CloudAccount UUID, but both RI-exchange listing endpoints interpret their account parameter as the provider's own account identifier (
external_id). The values never match, so the endpoints return empty.This affects AWS and Azure, not just Azure.
resolveScope()(frontend/src/riexchange.ts:62-66) is a single function feeding both loaders from the same UUID:loadConvertibleRIs(AWS,riexchange.ts:109) — the handler expects an AWS account number (TestListConvertibleRIs_AccountScopeMismatchReturnsEmptysendsaccount_id: "123456789012")loadExchangeableAzureRIs(Azure,riexchange.ts:100) — the handler expects an Azure subscription IDsourceIdentity.ExternalID()(internal/api/handler.go:835) returnsAccountIDfor aws,SubscriptionIDfor azure,ProjectIDfor gcp — soexternal_idis the correct value for every provider, and the UUID is correct for none of them here.Current user-visible behaviour
404("Failed to load Azure reservations: not found") for restricted sessions, becauserequireAzureSubscriptionScopenow runs before the client build. Admin sessions are unaffected. fix(api): scope Azure exchangeable-RI listing to session's allowed accounts cloud-commitments-cli#1711 is not the defect — returningerrNotFoundis the correct anti-enumeration posture, matching the POST siblings that return 404 rather than 403 so a scoped caller cannot probe which subscriptions exist. It made a pre-existing mismatch legible.Why a one-line fix does not exist
Sized during the LeanerCloud/cloud-commitments-cli#1711 review; both obvious approaches fail:
Changing the chip value globally is wrong.
state.getCurrentAccountIDs()is consumed by seven non-test source files —dashboard.ts,history.ts,inventory.ts,plans.ts,recommendations.ts,riexchange.ts,modules/savings-history.ts. Six of them send it asaccount_idsmatched againstcloud_account_id(a CloudAccount UUID field) and are correct as-is. Swappingtopbar-filters.ts:107froma.idtoa.external_idwould fix RI-exchange and break the other six.Resolving at the call site needs new state. There is no UUID ->
external_idlookup anywhere in the frontend.populateAccountOptionsdoes fetchapi.listAccountsMinimal(), which carriesexternal_id(frontend/src/api/accounts.ts:117-121, already used for the option label), but discards the list after building the chip options. Nothing caches it.Suggested fix
Introduce a UUID-keyed account cache populated alongside the chip options and exposed for
riexchange.ts, then resolve toexternal_idat the two RI-exchange call sites only. Leave the chip value itself as the UUID, since that is right for the other six consumers.Handle the async race:
resolveScope()can run before the accounts fetch resolves. A miss must not silently degrade to "send the UUID anyway" or "send nothing and show everything" — decide explicitly and fail visibly.Cover both providers at that one call site. Add a test asserting the value sent as
account_id/subscription_idis a provider account identifier and not a CloudAccount UUID; the mismatch survived because both are opaque strings and neither side validates the shape.Do not change either handler's scoping or 404 posture, which exist to prevent account enumeration.
Found during the adversarial review of LeanerCloud/cloud-commitments-cli#1711; the AWS half was found while sizing the Azure fix.