diff --git a/cmd/audit_stop_2103_test.go b/cmd/audit_stop_2103_test.go index bab5c0ca0..7d66f60e9 100644 --- a/cmd/audit_stop_2103_test.go +++ b/cmd/audit_stop_2103_test.go @@ -43,7 +43,7 @@ func failAuditWrites(t *testing.T) { func auditCfg(t *testing.T) Config { t.Helper() dir := t.TempDir() - return Config{AuditLog: filepath.Join(dir, "audit.jsonl"), CSVOutput: filepath.Join(dir, "report.csv")} + return Config{AuditLog: filepath.Join(dir, "audit.jsonl"), CSVOutput: filepath.Join(dir, "report.csv"), CallerAccountID: defaultTestAccount} } func TestExecuteAndReport_AuditWriteFailureStopsFurtherPurchases(t *testing.T) { diff --git a/cmd/cancellation_test.go b/cmd/cancellation_test.go index edb67e668..043ccd6b0 100644 --- a/cmd/cancellation_test.go +++ b/cmd/cancellation_test.go @@ -112,7 +112,7 @@ func TestExecuteAndReport_CancelDuringPurchaseFinishesInFlightAndReportsWhatRan( slowDelay(t) out := captureAppLog(t) dir := t.TempDir() - cfg := Config{AuditLog: filepath.Join(dir, "audit.jsonl"), CSVOutput: filepath.Join(dir, "report.csv")} + cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(dir, "audit.jsonl"), CSVOutput: filepath.Join(dir, "report.csv")} ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -153,7 +153,7 @@ func TestExecuteAndReport_ErroredPurchaseOnInterruptSaysVerifyInAWS(t *testing.T slowDelay(t) out := captureAppLog(t) dir := t.TempDir() - cfg := Config{AuditLog: filepath.Join(dir, "audit.jsonl"), CSVOutput: filepath.Join(dir, "report.csv")} + cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(dir, "audit.jsonl"), CSVOutput: filepath.Join(dir, "report.csv")} ctx, cancel := context.WithCancel(context.Background()) defer cancel() h := &hookTransport{} @@ -176,7 +176,7 @@ func TestExecuteAndReport_ErroredPurchaseOnInterruptSaysVerifyInAWS(t *testing.T func TestProcessPurchaseLoop_AuditRecordWrittenBeforeDelay(t *testing.T) { slowDelay(t) captureAppLog(t) - cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} + cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} ctx, cancel := context.WithCancel(context.Background()) defer cancel() recs := []common.Recommendation{validEC2Rec(), validEC2Rec()} @@ -207,7 +207,7 @@ func TestProcessPurchaseLoop_AuditRecordWrittenBeforeDelay(t *testing.T) { func TestExecutePurchasePipeline_AuditRecordWrittenBeforeDelay(t *testing.T) { slowDelay(t) captureAppLog(t) - cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} + cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} ctx, cancel := context.WithCancel(context.Background()) defer cancel() h := &hookTransport{handle: func(r *http.Request) (int, string) { @@ -283,7 +283,7 @@ func TestInvocationContext_FurtherSigintsAreAbsorbed(t *testing.T) { func TestProcessPurchaseLoop_CancelDuringPurchaseDoesNotCancelTheInFlightCall(t *testing.T) { slowDelay(t) captureAppLog(t) - cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} + cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} ctx, cancel := context.WithCancel(context.Background()) defer cancel() recs := []common.Recommendation{validEC2Rec(), validEC2Rec()} diff --git a/cmd/ec2_zonal_az_test.go b/cmd/ec2_zonal_az_test.go index 7d6a46df5..abb8d4207 100644 --- a/cmd/ec2_zonal_az_test.go +++ b/cmd/ec2_zonal_az_test.go @@ -62,7 +62,7 @@ func purchaseEC2WithRecorder(t *testing.T, rec common.Recommendation) (common.Pu Credentials: aws.AnonymousCredentials{}, Retryer: func() aws.Retryer { return aws.NopRetryer{} }, } - cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} + cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} result, _ := purchaseSingleRec(context.Background(), awsCfg, rec, 1, false, cfg) return result, recorder } diff --git a/cmd/main.go b/cmd/main.go index 8a25aa9a8..eb77a29be 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -37,6 +37,10 @@ const ( // Config holds all configuration for the RI helper tool. type Config struct { + // CallerAccountID is the account the credentials belong to (STS + // GetCallerIdentity), set at run start. Purchases are refused for + // recommendations owned by any other account. + CallerAccountID string AuditLog string CSVInput string IdempotencyWindow string diff --git a/cmd/main_test.go b/cmd/main_test.go index 87dc3ada0..edc7d6a0e 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -14,6 +14,29 @@ import ( "github.com/stretchr/testify/assert" ) +// defaultTestAccount is the account the stubbed credentials belong to. +const defaultTestAccount = "111111111111" + +// wantPurchaseOutcome is the Success and Error report columns for a dry-run +// row owned by account: the stubbed credentials belong to defaultTestAccount, +// and rows of any other account are refused. +func wantPurchaseOutcome(account string) (success, errMsg string) { + if account == defaultTestAccount { + return "true", "" + } + return "false", fmt.Sprintf("recommendation is for account %s but the credentials are for account %s; cross-account purchases are not supported, so run with credentials for account %s", account, defaultTestAccount, account) +} + +// useCallerAccount gives toolCfg the stubbed credentials' account for one test, +// for tests that call the purchase loops directly instead of through an entry +// point (which resolves the account itself). +func useCallerAccount(t *testing.T) { + t.Helper() + prev := toolCfg.CallerAccountID + toolCfg.CallerAccountID = defaultTestAccount + t.Cleanup(func() { toolCfg.CallerAccountID = prev }) +} + // TestMain overrides toolCfg.AuditLog's cobra-registered default // ("./cudly-audit.jsonl", relative to the process working directory) before // any test in this package runs. Since #1609, processPurchaseLoop (shared by @@ -31,6 +54,9 @@ func TestMain(m *testing.M) { engineVersionFetcher = func(context.Context, Config, bool) (engineVersionData, error) { return engineVersionData{}, nil } + callerAccountFetcher = func(context.Context, aws.Config) (string, error) { + return defaultTestAccount, nil + } code := m.Run() _ = os.Remove(toolCfg.AuditLog) os.Exit(code) diff --git a/cmd/multi_service.go b/cmd/multi_service.go index 1636cf2aa..780370edd 100644 --- a/cmd/multi_service.go +++ b/cmd/multi_service.go @@ -16,6 +16,7 @@ import ( "github.com/LeanerCloud/cloud-commitments-go/providers/aws/recommendations" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/google/uuid" ) @@ -239,6 +240,10 @@ func exitIfInterrupted(ctx context.Context, phase string) { // runToolMultiService within the cyclomatic-complexity limit. func runPurchaseAndReport(ctx context.Context, awsCfg aws.Config, scoredResult scorer.ScoredResult, isDryRun bool, cfg Config, drops *common.DropSummary) error { runID := uuid.New().String() + cfg, err := withCallerAccount(ctx, awsCfg, cfg) + if err != nil { + return err + } if !confirmPurchaseRun(scoredResult.Passed, isDryRun) { printDropSummary(drops) AppLogger.Printf("\nāŒ Purchase canceled.\n") @@ -303,6 +308,32 @@ func printDropSummary(drops *common.DropSummary) { } } +// callerAccountFetcher is the seam tests replace so they never reach STS. +var callerAccountFetcher = fetchCallerAccount + +func fetchCallerAccount(ctx context.Context, awsCfg aws.Config) (string, error) { + out, err := sts.NewFromConfig(awsCfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) + if err != nil { + return "", err + } + if aws.ToString(out.Account) == "" { + return "", errors.New("GetCallerIdentity returned no account") + } + return aws.ToString(out.Account), nil +} + +// withCallerAccount returns cfg with CallerAccountID resolved, so the purchase +// preconditions can refuse recommendations that belong to another account. It +// runs on dry runs too, so a dry run predicts what a real run will refuse. +func withCallerAccount(ctx context.Context, awsCfg aws.Config, cfg Config) (Config, error) { + account, err := callerAccountFetcher(ctx, awsCfg) + if err != nil { + return cfg, fmt.Errorf("cannot determine the AWS account of the credentials (needed to check purchases): %w", err) + } + cfg.CallerAccountID = account + return cfg, nil +} + // loadAWSConfig builds an aws.Config from the tool config. func loadAWSConfig(ctx context.Context, cfg Config) (aws.Config, error) { var opts []func(*awsconfig.LoadOptions) error @@ -579,7 +610,7 @@ func purchaseAuditStatus(result common.PurchaseResult) string { func purchaseSingleRec(ctx context.Context, awsCfg aws.Config, rec common.Recommendation, index int, isDryRun bool, cfg Config) (purchaseResult common.PurchaseResult, auditStatus string) { label := purchaseRegionLabel(rec, rec.Region) AppLogger.Printf(" [%d] %s %s %s (count=%d)\n", index, rec.Service, label, rec.ResourceType, rec.Count) - if err := validatePurchasePreconditions(rec, rec.Region); err != nil { + if err := validatePurchasePreconditions(rec, rec.Region, cfg.CallerAccountID); err != nil { AppLogger.Printf(" āŒ cannot purchase: %v\n", err) return preconditionFailure(rec, err, isDryRun), "error" } @@ -722,6 +753,10 @@ func runToolFromCSV(ctx context.Context, cfg Config) error { return nil } + cfg, err = withCallerAccount(ctx, awsCfg, cfg) + if err != nil { + return err + } return runCSVPurchases(ctx, awsCfg, recs, isDryRun, cfg, runID) } @@ -991,7 +1026,7 @@ func processPurchaseLoop(ctx context.Context, recs []common.Recommendation, regi // runOnePurchase validates and then dry-runs or executes one CSV-path // recommendation. attempted reports whether a real purchase call was made. func runOnePurchase(ctx context.Context, rec common.Recommendation, label, region string, index int, isDryRun bool, serviceClient provider.ServiceClient, cfg Config) (result common.PurchaseResult, status string, attempted bool) { - if err := validatePurchasePreconditions(rec, region); err != nil { + if err := validatePurchasePreconditions(rec, region, cfg.CallerAccountID); err != nil { return preconditionFailure(rec, err, isDryRun), "error", false } if isDryRun { diff --git a/cmd/multi_service_test.go b/cmd/multi_service_test.go index ed5cef8aa..fadc4c585 100644 --- a/cmd/multi_service_test.go +++ b/cmd/multi_service_test.go @@ -207,6 +207,7 @@ func TestProcessService_EdgeCases(t *testing.T) { // TestProcessServiceWithMocks tests the processService function using mocks. func TestProcessServiceWithMocks(t *testing.T) { + useCallerAccount(t) ctx := context.Background() awsCfg := aws.Config{Region: "us-east-1"} @@ -1258,6 +1259,7 @@ func TestWriteMultiServiceCSVReport_MultipleResults(t *testing.T) { // ==================== Additional ProcessPurchaseLoop Tests ==================== func TestProcessPurchaseLoopPurchaseFailure(t *testing.T) { + useCallerAccount(t) ctx := context.Background() origCfg := toolCfg defer func() { toolCfg = origCfg }() @@ -1308,6 +1310,7 @@ func TestProcessPurchaseLoopEmptyRecommendations(t *testing.T) { } func TestProcessServicePurchasesUserCancellation(t *testing.T) { + useCallerAccount(t) ctx := context.Background() origCfg := toolCfg defer func() { toolCfg = origCfg }() @@ -1340,6 +1343,7 @@ func TestProcessServicePurchasesUserCancellation(t *testing.T) { } func TestProcessServicePurchasesDryRunMultiple(t *testing.T) { + useCallerAccount(t) ctx := context.Background() origCfg := toolCfg defer func() { toolCfg = origCfg }() @@ -1412,6 +1416,7 @@ func TestExecutePurchaseWithEmptyPurchaseID(t *testing.T) { } func TestProcessPurchaseLoopDryRun(t *testing.T) { + useCallerAccount(t) ctx := context.Background() // Save original values origCfg := toolCfg @@ -1447,6 +1452,7 @@ func TestProcessPurchaseLoopDryRun(t *testing.T) { } func TestProcessPurchaseLoopActualPurchase(t *testing.T) { + useCallerAccount(t) ctx := context.Background() // Save original values origCfg := toolCfg @@ -1492,6 +1498,7 @@ func TestProcessPurchaseLoopActualPurchase(t *testing.T) { } func TestProcessPurchaseLoopWithConfirmation(t *testing.T) { + useCallerAccount(t) ctx := context.Background() // Save original values origCfg := toolCfg @@ -1693,8 +1700,8 @@ func TestRunToolFromCSV(t *testing.T) { // silently ignored, so every row loaded with Count=0 and the whole run // processed nothing while the NotPanics assertion stayed green. csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account -rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,123456789012 -elasticache,us-west-2,cache.t3.micro,redis,1,1yr,All Upfront,123456789012 +rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,111111111111 +elasticache,us-west-2,cache.t3.micro,redis,1,1yr,All Upfront,111111111111 `) tests := []struct { @@ -1761,7 +1768,7 @@ func TestRunToolFromCSV_DuplicateCheckFailureRefusesToPurchase(t *testing.T) { isolateAWSEnv(t) csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account -rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,123456789012 +rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,111111111111 `) reportPath := filepath.Join(t.TempDir(), "report.csv") auditPath := filepath.Join(t.TempDir(), "audit.jsonl") @@ -1910,7 +1917,7 @@ func TestRunToolFromCSV_WritesAuditRecordsOnDryRun(t *testing.T) { isolateAWSEnv(t) csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account -rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,123456789012 +rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,111111111111 `) auditPath := filepath.Join(t.TempDir(), "audit.jsonl") @@ -1952,6 +1959,7 @@ rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,123456789012 // TestProcessPurchaseLoopActualPurchase already uses to test this loop's // purchase behavior without live AWS credentials. func TestProcessPurchaseLoop_WritesAuditRecordForRealPurchase(t *testing.T) { + useCallerAccount(t) tests := []struct { result common.PurchaseResult name string @@ -2017,7 +2025,7 @@ func TestRunToolFromCSV_ChecksAuditLogWritability(t *testing.T) { defer func() { toolCfg = origCfg }() csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account -rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,123456789012 +rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,111111111111 `) toolCfg.CSVInput = csvPath @@ -2052,7 +2060,7 @@ func TestRunToolFromCSV_ConfirmsOnceBeforeAnyRegion(t *testing.T) { t.Cleanup(func() { log.SetOutput(oldWriter) }) csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account -rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,123456789012 +rds,us-east-1,db.t3.small,postgres,2,1yr,All Upfront,111111111111 rds,us-west-2,db.t3.medium,postgres,3,1yr,All Upfront,123456789012 `) reportPath := filepath.Join(t.TempDir(), "report.csv") diff --git a/cmd/purchase_account_guard_test.go b/cmd/purchase_account_guard_test.go new file mode 100644 index 000000000..b9dbb51b7 --- /dev/null +++ b/cmd/purchase_account_guard_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + testCallerAccount = "444444444444" + otherAccount = "555555555555" +) + +func TestCheckPurchaseAccount(t *testing.T) { + rec := common.Recommendation{Service: common.ServiceRDS, Region: "us-east-1", Account: testCallerAccount} + + require.NoError(t, checkPurchaseAccount(rec, testCallerAccount)) + + rec.Account = "" + require.NoError(t, checkPurchaseAccount(rec, testCallerAccount), "a row with no account has nothing to compare") + + rec.Account = otherAccount + err := checkPurchaseAccount(rec, testCallerAccount) + require.Error(t, err) + assert.Contains(t, err.Error(), otherAccount) + assert.Contains(t, err.Error(), testCallerAccount) + + rec.Account = testCallerAccount + require.Error(t, checkPurchaseAccount(rec, ""), "an unresolved caller account must not disable the check") +} + +func TestPurchaseSingleRec_RefusesOtherAccountsRecommendation(t *testing.T) { + captureAppLog(t) + cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl"), CallerAccountID: testCallerAccount} + rec := validEC2Rec() + rec.Account = otherAccount + + for _, dry := range []bool{true, false} { + // A real run must refuse before it builds a service client: aws.Config{} + // has no credentials, so reaching the purchase call would fail + // differently, not with the account message. + result, status := purchaseSingleRec(context.Background(), aws.Config{}, rec, 1, dry, cfg) + assert.False(t, result.Success, "dryRun=%v", dry) + require.Error(t, result.Error, "dryRun=%v", dry) + assert.Contains(t, result.Error.Error(), "cross-account purchases are not supported", "dryRun=%v", dry) + assert.Equal(t, "error", status) + } +} + +func TestProcessPurchaseLoop_RefusesOtherAccountsRecommendationWithoutPurchasing(t *testing.T) { + captureAppLog(t) + cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl"), CallerAccountID: testCallerAccount} + other := validEC2Rec() + other.Account = otherAccount + own := validEC2Rec() + own.Account = testCallerAccount + client := &MockServiceClient{} + client.On("PurchaseCommitment", mock.Anything, mock.MatchedBy(func(r common.Recommendation) bool { return r.Account == testCallerAccount }), mock.Anything). + Return(common.PurchaseResult{Success: true, CommitmentID: "ri-own"}, nil).Once() + + results, err := processPurchaseLoop(context.Background(), []common.Recommendation{other, own}, "us-east-1", false, client, cfg, "run-1") + + require.NoError(t, err) + require.Len(t, results, 2) + assert.False(t, results[0].Success) + require.Error(t, results[0].Error) + assert.Contains(t, results[0].Error.Error(), otherAccount) + assert.True(t, results[1].Success) + client.AssertNumberOfCalls(t, "PurchaseCommitment", 1) +} + +func TestWithCallerAccount(t *testing.T) { + orig := callerAccountFetcher + t.Cleanup(func() { callerAccountFetcher = orig }) + + callerAccountFetcher = func(context.Context, aws.Config) (string, error) { return "444444444444", nil } + cfg, err := withCallerAccount(context.Background(), aws.Config{}, Config{}) + require.NoError(t, err) + assert.Equal(t, "444444444444", cfg.CallerAccountID) + + callerAccountFetcher = func(context.Context, aws.Config) (string, error) { return "", errors.New("expired token") } + _, err = withCallerAccount(context.Background(), aws.Config{}, Config{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "expired token") +} diff --git a/cmd/purchase_confirmation_test.go b/cmd/purchase_confirmation_test.go index d0c5062a6..079adfed1 100644 --- a/cmd/purchase_confirmation_test.go +++ b/cmd/purchase_confirmation_test.go @@ -72,7 +72,7 @@ func TestRunPurchaseAndReportRequiresTerminal(t *testing.T) { for _, dryRun := range []bool{false, true} { t.Run(fmt.Sprintf("%s/dryRun=%t", service, dryRun), func(t *testing.T) { dir := t.TempDir() - cfg := Config{AuditLog: filepath.Join(dir, "audit.jsonl"), CSVOutput: filepath.Join(dir, "report.csv")} + cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(dir, "audit.jsonl"), CSVOutput: filepath.Join(dir, "report.csv")} recs := []common.Recommendation{ {Service: service, Region: "us-east-1", ResourceType: "test-instance", Count: 2, EstimatedSavings: 50}, {Service: service, Region: "us-west-2", ResourceType: "test-instance", Count: 3, EstimatedSavings: 75}, diff --git a/cmd/purchase_safeguards_2121_test.go b/cmd/purchase_safeguards_2121_test.go index efe474628..3489b2cec 100644 --- a/cmd/purchase_safeguards_2121_test.go +++ b/cmd/purchase_safeguards_2121_test.go @@ -244,14 +244,14 @@ func TestExecutePurchasePipeline_InterruptsFailClosed_2121(t *testing.T) { auditPath := filepath.Join(t.TempDir(), "audit.jsonl") shutdownRequested.Store(true) defer shutdownRequested.Store(false) - results, _ := executePurchasePipeline(ctx, aws.Config{}, recs, true, "run-2121-int", Config{AuditLog: auditPath}) + results, _ := executePurchasePipeline(ctx, aws.Config{}, recs, true, "run-2121-int", Config{CallerAccountID: defaultTestAccount, AuditLog: auditPath}) assert.Empty(t, results, "a requested shutdown must stop the pipeline before the first purchase") _, err := os.Stat(auditPath) assert.True(t, os.IsNotExist(err), "no audit records without purchase attempts") }) t.Run("uninterrupted dry run audits every rec as skipped", func(t *testing.T) { auditPath := filepath.Join(t.TempDir(), "audit.jsonl") - results, _ := executePurchasePipeline(ctx, aws.Config{}, recs, true, "run-2121-dry", Config{AuditLog: auditPath}) + results, _ := executePurchasePipeline(ctx, aws.Config{}, recs, true, "run-2121-dry", Config{CallerAccountID: defaultTestAccount, AuditLog: auditPath}) require.Len(t, results, len(recs)) for _, result := range results { assert.True(t, result.Success) diff --git a/cmd/rds_family_expiry_command_test.go b/cmd/rds_family_expiry_command_test.go index 77ef1d4cc..a82cecddc 100644 --- a/cmd/rds_family_expiry_command_test.go +++ b/cmd/rds_family_expiry_command_test.go @@ -256,11 +256,12 @@ func assertRDSExpiryCSV(t *testing.T, output, name, logs string) { continue } require.True(t, present, "scenario=%s rec %d missing; rows=%v\nlogs=%s", name, i, rows, logs) + wantSuccess, wantErr := wantPurchaseOutcome(columns["Account"]) want := map[string]string{ "Service": "rds", "Region": "us-east-1", "ResourceType": rec.class, "Term": "1yr", "PaymentOption": "partial-upfront", "Count": strconv.Itoa(count), "ExistingCoverage": fmt.Sprintf("%.1f", s.existing), "UpfrontPayment": fmt.Sprintf("%.2f", float64(count)*100), "EstimatedSavings": fmt.Sprintf("%.2f", float64(count)*100), - "RecurringMonthlyCost": fmt.Sprintf("%.2f", float64(count)*10), "Success": "true", "Error": "", + "RecurringMonthlyCost": fmt.Sprintf("%.2f", float64(count)*10), "Success": wantSuccess, "Error": wantErr, } for field, value := range want { require.Equal(t, value, columns[field], "scenario=%s rec=%d field=%s (parent count %d)", name, i, field, s.parent[i]) diff --git a/cmd/reservation_expiry_command_test.go b/cmd/reservation_expiry_command_test.go index 837475ecc..7ddd009cc 100644 --- a/cmd/reservation_expiry_command_test.go +++ b/cmd/reservation_expiry_command_test.go @@ -97,13 +97,14 @@ func assertReservationExpiryCSV(t *testing.T, output, name, logs string) { seen[account] = true index := slices.Index([]string{"111111111111", "222222222222", "333333333333"}[:count], account) require.NotEqual(t, -1, index, "unexpected account %s", account) + wantSuccess, wantErr := wantPurchaseOutcome(account) want := map[string]string{ "Service": "ec2", "Region": "us-east-1", "ResourceType": "m5.large", "Term": "1yr", "PaymentOption": "partial-upfront", "Count": strconv.Itoa(s.count), "RecommendedCount": "30", "AccountName": fmt.Sprintf("expiry-account-%d", index+1), "Instances": fmt.Sprintf("%.1f", avg), "CoveredInstances": fmt.Sprintf("%.1f", avg*s.existing/100), "ExistingCoverage": fmt.Sprintf("%.1f", s.existing), "ProjectedCoverage": fmt.Sprintf("%.1f", s.existing+float64(s.count)*100/avg), "UpfrontPayment": fmt.Sprintf("%.2f", float64(s.count)*100), "EstimatedSavings": fmt.Sprintf("%.2f", float64(s.count)*100), - "RecurringMonthlyCost": fmt.Sprintf("%.2f", float64(s.count)*10), "Success": "true", "Error": "", + "RecurringMonthlyCost": fmt.Sprintf("%.2f", float64(s.count)*10), "Success": wantSuccess, "Error": wantErr, } for field, value := range want { require.Equal(t, value, columns[field], "scenario=%s account=%s field=%s", name, account, field) diff --git a/cmd/savings_plan_purchase.go b/cmd/savings_plan_purchase.go index 80eab2452..1f795f7d8 100644 --- a/cmd/savings_plan_purchase.go +++ b/cmd/savings_plan_purchase.go @@ -92,7 +92,10 @@ func purchaseRegionLabel(rec common.Recommendation, groupRegion string) string { // what the library would reject for a missing or malformed field; it does not // call any API. region is the one the client will be built from: the // recommendation's own on the main path, the CSV group's on the CSV path. -func validatePurchasePreconditions(rec common.Recommendation, region string) error { +func validatePurchasePreconditions(rec common.Recommendation, region, callerAccount string) error { + if err := checkPurchaseAccount(rec, callerAccount); err != nil { + return err + } if _, err := clientRegionFor(rec.Service, region); err != nil { return err } @@ -111,6 +114,23 @@ func validatePurchasePreconditions(rec common.Recommendation, region string) err return nil } +// checkPurchaseAccount refuses a recommendation owned by an account other than +// the one the credentials belong to. Cost Explorer returns linked-account +// recommendations to a payer, but a purchase call buys in the caller's own +// account, so buying one would commit the caller's account to another +// account's usage. An unresolved caller account refuses too, so the check +// cannot be skipped. A recommendation with no account (CSV rows may omit it) +// carries nothing to compare. +func checkPurchaseAccount(rec common.Recommendation, callerAccount string) error { + if callerAccount == "" { + return fmt.Errorf("cannot purchase %s %s: the AWS account of the credentials is unknown", rec.Service, rec.ResourceType) + } + if rec.Account != "" && rec.Account != callerAccount { + return fmt.Errorf("recommendation is for account %s but the credentials are for account %s; cross-account purchases are not supported, so run with credentials for account %s", rec.Account, callerAccount, rec.Account) + } + return nil +} + func validateSavingsPlanPreconditions(rec common.Recommendation) error { d, ok := rec.Details.(*common.SavingsPlanDetails) if !ok || d == nil { diff --git a/cmd/savings_plan_purchase_test.go b/cmd/savings_plan_purchase_test.go index fcb28beea..3ee9ae850 100644 --- a/cmd/savings_plan_purchase_test.go +++ b/cmd/savings_plan_purchase_test.go @@ -169,7 +169,7 @@ func parserShapedSP() common.Recommendation { func TestPurchaseSingleRec_SavingsPlanFromParserReachesAPIInSPRegion(t *testing.T) { awsCfg, stub := stubAWSConfig(t) - cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} + cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} result, status := purchaseSingleRec(context.Background(), awsCfg, parserShapedSP(), 1, false, cfg) @@ -183,7 +183,7 @@ func TestPurchaseSingleRec_SavingsPlanFromParserReachesAPIInSPRegion(t *testing. func TestProcessCSVRegionPurchases_SavingsPlanRowWithEmptyRegionAndProvider(t *testing.T) { awsCfg, stub := stubAWSConfig(t) - cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} + cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} // The real loader shape: no Provider, no Region (group key ""). rec := common.Recommendation{ Service: common.ServiceSavingsPlansCompute, Count: 1, Account: "111111111111", Term: "1yr", PaymentOption: "no-upfront", @@ -262,7 +262,7 @@ func TestValidatePurchasePreconditions(t *testing.T) { "rds": {Service: common.ServiceRDS, Region: "us-east-1"}, } for name, rec := range ok { - assert.NoError(t, validatePurchasePreconditions(rec, rec.Region), name) + assert.NoError(t, validatePurchasePreconditions(rec, rec.Region, defaultTestAccount), name) } bad := map[string]common.Recommendation{ "sp nil details": mut(parserShapedSP(), func(r *common.Recommendation) { r.Details = nil }), @@ -282,12 +282,12 @@ func TestValidatePurchasePreconditions(t *testing.T) { "rds no region": {Service: common.ServiceRDS}, } for name, rec := range bad { - assert.Error(t, validatePurchasePreconditions(rec, rec.Region), name) + assert.Error(t, validatePurchasePreconditions(rec, rec.Region, defaultTestAccount), name) } } func TestPurchaseSingleRec_DryRunReportsPreconditionFailure(t *testing.T) { - cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} + cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} broken := parserShapedSP() broken.Details = nil @@ -303,7 +303,7 @@ func TestPurchaseSingleRec_DryRunReportsPreconditionFailure(t *testing.T) { } func TestProcessPurchaseLoop_DryRunReportsPreconditionFailureOnCSVShapedRows(t *testing.T) { - cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} + cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} noDetailsSP := common.Recommendation{Service: common.ServiceSavingsPlansCompute, Count: 1} csvEC2 := validEC2Rec() csvEC2.Details = &common.ComputeDetails{InstanceType: "m5.large", Platform: "Linux/UNIX"} // loader leaves Tenancy/Scope blank @@ -351,7 +351,7 @@ func TestCSVRoundTrip_SavingsPlanAndEC2DetailsSurviveWriteThenRead(t *testing.T) assert.Equal(t, ec2.Details, byService[ec2.Service].Details) assert.Equal(t, rds.Details, byService[rds.Service].Details) for _, r := range out { - assert.NoError(t, validatePurchasePreconditions(r, r.Region), "%s", r.Service) + assert.NoError(t, validatePurchasePreconditions(r, r.Region, defaultTestAccount), "%s", r.Service) } } @@ -384,7 +384,7 @@ func TestCSVReader_MalformedHourlyCommitmentRejected(t *testing.T) { func TestProcessCSVRegionPurchases_SavingsPlanRowFromWrittenCSV(t *testing.T) { awsCfg, stub := stubAWSConfig(t) - cfg := Config{AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} + cfg := Config{CallerAccountID: defaultTestAccount, AuditLog: filepath.Join(t.TempDir(), "audit.jsonl")} path := filepath.Join(t.TempDir(), "report.csv") sp := parserShapedSP() require.NoError(t, writeMultiServiceCSVReport([]common.PurchaseResult{{Recommendation: sp, Timestamp: time.Now()}}, path)) diff --git a/go.mod b/go.mod index 9f915c246..41f8ee0c6 100644 --- a/go.mod +++ b/go.mod @@ -88,6 +88,7 @@ require ( github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261006205158-7ff8c1aee1bb github.com/aws/aws-sdk-go-v2/service/organizations v1.45.3 github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.40.3 + github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 github.com/google/uuid v1.6.0 github.com/microsoftgraph/msgraph-sdk-go v1.99.0 golang.org/x/term v0.46.0 @@ -107,7 +108,6 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.33.17 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect