Skip to content

fix(frontend): RI-exchange account chip sends CloudAccount UUID where AWS and Azure handlers expect the provider account ID #162

Open

Description

@cristim

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.

Activity

  1. changed the title [-]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[/+] on Aug 3, 2026
  2. cristim commented on Aug 3, 2026

    @cristim
    MemberAuthor

    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 both loadConvertibleRIs (AWS, :109) and loadExchangeableAzureRIs (Azure, :100) from the same CloudAccount UUID. The AWS handler expects an account number — TestListConvertibleRIs_AccountScopeMismatchReturnsEmpty sends account_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) returns AccountID / SubscriptionID / ProjectID per provider, so external_id is 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:

    • Changing the chip value globally breaks six other consumers. state.getCurrentAccountIDs() feeds dashboard.ts, history.ts, inventory.ts, plans.ts, recommendations.ts, riexchange.ts and modules/savings-history.ts. Six send it as account_ids matched against cloud_account_id, a CloudAccount UUID field, and are correct as-is.
    • Resolving at the call site needs new state. No UUID -> external_id lookup exists in the frontend. populateAccountOptions fetches listAccountsMinimal() (which does carry external_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.

  3. added and removed on Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions