Repository navigation
fix(frontend): RI-exchange account chip sends CloudAccount UUID where AWS and Azure handlers expect the provider account ID #162
Description
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 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
- AWS: silently broken today. Picking an account chip on the RI-exchange page returns an empty convertible-RI list with no error. It looks like "you have no convertible RIs".
- Azure: was silently broken the same way, and fix(api): scope Azure exchangeable-RI listing to session's allowed accounts cloud-commitments-cli#1711 turned it into a visible
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 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.
Activity
[-]fix(frontend): account chip sends CloudAccount UUID where the Azure RI-exchange endpoint expects a subscription ID[/-][+]fix(frontend): RI-exchange account chip sends CloudAccount UUID where AWS and Azure handlers expect the provider account ID[/+]cristim commentedon Aug 3, 2026
Rescoped: this is not Azure-specific. I filed it as an Azure issue; that understated it.
resolveScope()(frontend/src/riexchange.ts:62-66) is one function feeding bothloadConvertibleRIs(AWS,:109) andloadExchangeableAzureRIs(Azure,:100) from the same CloudAccount UUID. The AWS handler expects an account number —TestListConvertibleRIs_AccountScopeMismatchReturnsEmptysendsaccount_id: "123456789012"— so AWS RI-exchange account filtering silently returns an empty list today, and has all along. It reads as "you have no convertible RIs" rather than as a bug.sourceIdentity.ExternalID()(internal/api/handler.go:835) returnsAccountID/SubscriptionID/ProjectIDper provider, soexternal_idis right for all three and the UUID is right for none of them at these two call sites.Also recording why the obvious fixes do not work, since both look like one-liners:
state.getCurrentAccountIDs()feedsdashboard.ts,history.ts,inventory.ts,plans.ts,recommendations.ts,riexchange.tsandmodules/savings-history.ts. Six send it asaccount_idsmatched againstcloud_account_id, a CloudAccount UUID field, and are correct as-is.external_idlookup exists in the frontend.populateAccountOptionsfetcheslistAccountsMinimal()(which does carryexternal_id, already used for the option label) and then discards the list.Raising impact/many -> impact/all-users: it is not one provider's listing, it is the account filter on the RI-exchange page for every provider and every user who touches it.
Sized during the LeanerCloud/cloud-commitments-cli#1711 adversarial review. LeanerCloud/cloud-commitments-cli#1711 ships without this fix deliberately — folding new frontend state plumbing into a security PR would be the wrong trade, and the AWS half means the correct fix is broader than that PR's footprint.