Skip to content

fix(credentials): require host identity for role_arn accounts without an ARN - #494

Merged
cristim merged 2 commits into
mainfrom
fix/role-arn-host-credential-fallback
Oct 5, 2026
Merged

cristim merged 2 commits into
mainfrom
fix/role-arn-host-credential-fallback

Conversation

@cristim

@cristim cristim commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

What

Items 3 and 4 of #402. Items 1 and 2 (API-key revocation) landed in #492, so this PR closes #402.

Item 3: role_arn with no ARN on a non-host account. An AWS account with aws_auth_mode=role_arn and an empty aws_role_arn is the "self account" shape: CUDly serves it with the host's own ambient credentials. Nothing checked that the account was the host. A tenant account configured as role_arn with the ARN left out got:

  • recommendation collection against the host account's resources, filed under the tenant account's UUID (scheduler.collectAWSForAccount ambient branch), and
  • the host's ambient credentials in purchase execution and the commitment-options probe (credentials.resolveRoleARNProvider).

The fix adds credentials.VerifyHostAccount. It compares the account's external_id with the host's sts:GetCallerIdentity account and returns the typed credentials.ErrNotHostAccount on a mismatch, an STS error, a missing STS client, or an empty identity. Both ambient paths call it before they hand out ambient credentials. Callers wire AmbientSTS (the STS client already built from the same awsCfg as the ambient provider) in purchase/execution.go and server/app.go. The scheduler reuses its existing stsClient. The per-account error goes through the existing fan-out failure reporting.

Item 4. SetupAdminRequest.password now says "Base64-encoded password", as LoginRequest.password already did. PasswordResetConfirm.new_password had the same gap (its handler also calls decodeBase64Password), so it gets the same note. No generated client or frontend contract mirrors these schemas.

Not in this PR

  • Account create/update validation (validateAWSAuthMode) still accepts role_arn with no ARN for any external_id. The runtime check is the guard that fails closed, because existing rows skip validation. A create-time rejection is a possible follow-up.
  • POST /api/accounts/:id/test (awsAmbientCredResult) still reports "ambient credentials (CUDly host account)" for this shape without probing. It is a status message and no credentials are used, but it is misleading for a non-host account.

How verified

Toolchain: GOTOOLCHAIN=go1.26.6 GOWORK=off GOFLAGS='-p=2 -count=1' AWS_EC2_METADATA_DISABLED=true

  • go build ./..., go vet ./..., go vet -tags integration ./...: clean
  • go test ./internal/credentials/ ./internal/scheduler/ ./internal/purchase/ ./internal/server/ ./internal/api/: 3484 passed
  • go test -tags integration ./internal/scheduler/ -run Completeness (Postgres testcontainer): 25 passed
  • golangci-lint run on the touched packages: no issues

Regression tests (all fixture/mock-based):

  • TestScheduler_CollectAWSForAccount_RoleARNWithoutARNOnNonHostFailsClosed: a tenant account (external_id 222222222222) with the host on 999999999999, an STS error, or no STS client must return ErrNotHostAccount and never call CreateAndValidateProvider(ctx, "aws", nil).
  • TestResolveAWSCredentialProvider_SelfAccount_NonHostFailsClosed: covers mismatch, empty external_id, empty host identity (so "" == "" cannot pass), STS error, and a nil client.
  • TestResolveAWSProvider_RoleARNWithoutARNOnNonHostFailsClosed: covers the purchase path, for both the non-host case (rejected) and the host case (ambient credentials returned).
  • TestOpenAPIPasswordFieldsDocumentBase64: covers the spec.

Failing before the fix: with only the two VerifyHostAccount call sites disabled (scheduler.go, resolver.go):

--- FAIL: TestResolveAWSCredentialProvider_SelfAccount_NonHostFailsClosed
    Error: Expected error with "credentials: aws_role_arn is empty but the account is not confirmed as the CUDly host account" in chain but got nil.
--- FAIL: TestScheduler_CollectAWSForAccount_RoleARNWithoutARNOnNonHostFailsClosed
    mock: I don't know what to return because the method call was unexpected.   (the host-credential provider was requested)
--- FAIL: TestResolveAWSProvider_RoleARNWithoutARNOnNonHostFailsClosed
    Error: Expected error with "credentials: aws_role_arn is empty but ..." in chain but got nil.

With the openapi.yaml change stashed:

--- FAIL: TestOpenAPIPasswordFieldsDocumentBase64
    Error: "" does not contain "Base64-encoded"   Messages: SetupAdminRequest.password
    Error: "" does not contain "Base64-encoded"   Messages: PasswordResetConfirm.new_password

Mutation check: changing the external_id comparison in VerifyHostAccount to if false && ... fails the credentials and scheduler regression tests. Reverted.

Not verified against a real AWS account. The STS identity comparison is exercised only through fakes.

… an ARN

An AWS account with aws_auth_mode=role_arn and an empty aws_role_arn is
treated as the CUDly host account and served with the host's own ambient
credentials. Nothing checked that the account really was the host, so an
operator who picked role_arn on a tenant account and left the ARN out got
collection (and purchase execution) against the host account's resources,
filed under the tenant account's UUID.

Add credentials.VerifyHostAccount, which compares the account's external_id
with the host's sts:GetCallerIdentity account and returns the typed
ErrNotHostAccount on a mismatch, an STS failure, a missing STS client or an
empty identity. The scheduler's ambient branch and resolveRoleARNProvider
(used by purchase execution and the commitment-options probe) call it
before handing out ambient credentials.

Refs #402
The setup-admin and password-reset-confirm handlers base64-decode the
password like login does, but the OpenAPI schemas did not say so. Add the
same "Base64-encoded" description login carries, plus a spec test that
checks every such field.

Refs #402
@coderabbitai

coderabbitai Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 11 billable files and costs up to $2.75.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 49 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 60 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: a1e24fe7-4227-443c-9fab-98c9adb0afb1
📥 Commits

Reviewing files that changed from the base of the PR and between d384d47 and b07241c.

📒 Files selected for processing (11)
  • internal/api/openapi.yaml
  • internal/api/openapi_password_encoding_test.go
  • internal/credentials/host_identity.go
  • internal/credentials/resolver.go
  • internal/credentials/resolver_test.go
  • internal/purchase/coverage_extra_test.go
  • internal/purchase/execution.go
  • internal/scheduler/recommendation_completeness_integration_test.go
  • internal/scheduler/scheduler.go
  • internal/scheduler/scheduler_test.go
  • internal/server/app.go
  • Autopilot · 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/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint impact/few Limited audience effort/m Days type/security Security finding triaged Item has been triaged labels Oct 5, 2026
@cristim
cristim merged commit a1fe220 into main Oct 5, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/few Limited audience 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(auth): API-key revocation durability, role_arn host-identity check, openapi base64 note (follow-ups to #392/#393/#396)

1 participant