Skip to content

fix(api): sign no-cloud-account marketplace rows as their registered AWS account - #1039

Merged
cristim merged 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-560
Oct 16, 2026
Merged

cristim merged 1 commit into
LeanerCloud:mainfrom
IAmJSD:fix/issue-560

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator

Root cause

loadMarketplaceAWSConfig sent every row with a NULL/blank cloud_account_id straight to loadMarketplaceHostAWSConfig (#1031). That signs with the host's ambient credentials only when the row's account_id passes credentials.VerifyHostAccount, and returns a 400 for anything else. It never checked whether the 12-digit account_id belonged to a registered cloud account. purchase_history.cloud_account_id has no backfill (migration 000011) and is ON DELETE SET NULL, so legacy rows owned by a registered member account could not be listed or canceled from the marketplace.

Fix

This is the owner-suggested fix from the issue, built on top of #1031.

Closes #560

Verification

All of this is unit/mock/fixture evidence. The STS responses are recorded XML fixtures served by an in-process http.Client transport, credentials are static fakes, and no real AWS API was called. Gap: nothing ran a real List/Cancel against a real legacy row in a multi-account deployment.

Tests were run with AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, because this machine's default ~/.aws/config profile is invalid. This only affects local runs.

New tests are in internal/api/handler_marketplace_registered_account_test.go:

  • TestLoadMarketplaceAWSConfig_NoCloudAccountResolvesRegisteredAccount (nil/empty cloud_account_id): signs with the member account's key. No STS call, no GetCloudAccount.
  • ..._NoCloudAccountRegisteredRoleAccountAssumesRole: a registered role_arn account assumes its own role.
  • ..._NoCloudAccountRegisteredRoleWithoutARNRefused: 400 ErrNotHostAccount, no credentials.
  • ..._NoCloudAccountUnresolvedFallsBackToHostCheck: covers unregistered member, same external id on Azure only, ambiguous AWS twins and a different registered account (all 400 with no credentials), plus an unregistered host (host credentials). This pins fix(api): sign no-cloud-account marketplace rows as the host only for the host account #1031's behavior, so it passes both before and after the fix.
  • ..._NoCloudAccountLookupErrorFailsClosed: non-client error, no credentials, no STS call.
  • TestMarketplaceHandlers_NoCloudAccountRegisteredRow (list and cancel end to end): the EC2 client is built with the member account's key.

Fail before (production change stashed, new tests kept):

$ git stash push internal/api/handler_marketplace.go
$ go test ./internal/api/ -count=1 -run 'NoCloudAccount|Marketplace'
--- FAIL: TestLoadMarketplaceAWSConfig_NoCloudAccountResolvesRegisteredAccount
--- FAIL: TestLoadMarketplaceAWSConfig_NoCloudAccountRegisteredRoleAccountAssumesRole
--- FAIL: TestLoadMarketplaceAWSConfig_NoCloudAccountRegisteredRoleWithoutARNRefused
--- FAIL: TestLoadMarketplaceAWSConfig_NoCloudAccountLookupErrorFailsClosed
--- FAIL: TestMarketplaceHandlers_NoCloudAccountRegisteredRow
FAIL	github.com/LeanerCloud/cloud-commitments-platform/internal/api

Pass after:

$ go build ./...
$ go vet ./internal/api/
$ go test ./internal/api/ -count=1                      # ok (whole package)
$ go test -race ./internal/api/ -count=1 -run 'NoCloudAccount|Marketplace'   # ok
$ go test ./internal/mocks/ -count=1                    # ok (repo-wide mock guards)
$ golangci-lint run --allow-parallel-runners ./internal/api/   # v2.10.1: 0 issues
$ gocyclo -over 10 internal/api/handler_marketplace.go # silent
$ gofmt -l internal/api/                                 # clean

git-secrets: git secrets --scan over all tracked files plus the new file, in a fresh git init temp repo with an isolated HOME and scripts/setup-git-secrets.sh, was clean. scripts/test-git-secrets-allowlist.sh reported 42 PASS, 0 FAIL. Fixtures use the account-id placeholders already in this test package (111111111111, 222222222222), plus 333333333333.

Not run locally: gosec (not installed here), and the repo-wide go test -short -race ./... hook (skipped for disk reasons; CI runs it).

Mutation notes: each of these changes fails at least one new test:

  • falling back to host credentials on a ListCloudAccounts error
  • taking the first match instead of the unique one (ambiguous case)
  • ignoring the provider (Azure-only case)
  • skipping the lookup entirely

Out of scope / notes

Maintainer: please mirror the issue's triage labels onto this PR: priority/p2, severity/medium, triaged, urgency/this-quarter, impact/few, effort/s, type/bug.

🤖 Generated with Claude Code

…AWS account

A purchase row with a NULL cloud_account_id (legacy rows, or rows whose
account was deleted) was signed with the host's credentials only when its
AWS account id was the host account, and refused otherwise, even when that
id belonged to a registered member account. Resolve the id by (aws,
account_id) through uniqueAccountByExternalID, as revoke (LeanerCloud#547) and RI
exchange history (LeanerCloud#553) do, and sign with that account's own credentials
through the same resolver as a row with a cloud account. An unregistered
or ambiguous id keeps the host-only check from LeanerCloud#1031; a failed account
lookup is an error, never a fallback to the host's credentials.

Closes LeanerCloud#560

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 2 billable files and costs up to $0.50.

Or wait 58 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 6f62da95-4a0a-4918-bc00-fb777555262d
📥 Commits

Reviewing files that changed from the base of the PR and between 4e42a35 and 086ae2f.

📒 Files selected for processing: 2
  • internal/api/handler_marketplace.go
  • internal/api/handler_marketplace_registered_account_test.go
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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

@cristim cristim added priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged urgency/this-quarter Within the quarter impact/few Limited audience effort/s Hours type/bug Defect labels Oct 16, 2026
@cristim

cristim commented Oct 16, 2026

Copy link
Copy Markdown
Member

Independent review verdict: CLEAN at 086ae2f

Reviewer: fresh Claude agent (Sonnet, high effort), not the author, cold brief, acting as a security reviewer. No credentials and no AWS calls. The PR body was treated as a claim. Coordinator: claude-cc-reviewer.

Blocking findings: none.

Interplay with #1031: #1031 (merged as 4e42a35) is the direct parent of this head, and git merge-tree with current main is clean. This PR extends #1031 without contradicting it. Combined behaviour:

  • Registered AWS account: a single registered AWS account matching the row's account_id signs as that account (handler_marketplace.go:127 → marketplaceAccountAWSConfig).
  • No match or ambiguous: this falls back to fix(api): sign no-cloud-account marketplace rows as the host only for the host account #1031's host check (loadMarketplaceHostAWSConfig), which uses host credentials only if the ID verifies as the host account. Otherwise it returns 400.
  • Blank account_id: the host check refuses it.
  • ListCloudAccounts error: returned at :123-125, with no credentials used. It fails closed.

Where the account comes from:

  • row.AccountID comes from the purchase_history DB row, not the request. The request carries only the purchase UUID and the price schedule.
  • uniqueAccountByExternalID (handler_purchases_revoke.go:516) requires provider aws and an exact external-ID match, and returns nil on two or more matches.
  • authorizeSessionSell (handler_marketplace.go:404-419) lets a row with no cloud account through only for sell-any callers with AllowsAll scope. So registered-account signing is reachable only by unrestricted callers.
  • The credentials come from the same resolver as the cloud-account path. A role_arn account with no ARN is refused unless it is the host.

Notes (non-blocking):

  • The resolved account's enabled flag is not checked. That matches the existing GetCloudAccount path.
  • Nothing ran against real AWS, as the body says.

What was attacked and how (AWS_CONFIG_FILE/AWS_SHARED_CREDENTIALS_FILE=/dev/null):

  • go vet ./internal/api/... is clean. go test -race -count=1 ./internal/api/... passes 3583.
  • Fail-before: with the handler reverted to fix(api): sign no-cloud-account marketplace rows as the host only for the host account #1031, 5 new tests fail (resolve registered account, role assumption, role without ARN refused, lookup error fails closed, list).
  • Mutation probes, both killed:
    • falling back to the host on a lookup error;
    • using the first AWS account instead of the unique match.

Gate:

@cristim

cristim commented Oct 16, 2026

Copy link
Copy Markdown
Member

Gate record for 086ae2f (origin/main CLAUDE.md "Review gate"):

Marking ready (owner-authorized) and merging at 086ae2f with --match-head-commit.

@cristim
cristim marked this pull request as ready for review October 16, 2026 23:24
@cristim
cristim merged commit 70a6457 into LeanerCloud:main Oct 16, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(api): marketplace rows with a NULL cloud_account_id use the host's ambient credentials

2 participants