diff --git a/cmd/list/affected.go b/cmd/list/affected.go index b8af1bbd5e..5821403319 100644 --- a/cmd/list/affected.go +++ b/cmd/list/affected.go @@ -5,6 +5,7 @@ import ( "github.com/spf13/viper" e "github.com/cloudposse/atmos/internal/exec" + cfg "github.com/cloudposse/atmos/pkg/config" "github.com/cloudposse/atmos/pkg/flags" "github.com/cloudposse/atmos/pkg/flags/global" "github.com/cloudposse/atmos/pkg/list" @@ -39,6 +40,9 @@ type AffectedOptions struct { ProcessTemplates bool ProcessFunctions bool Skip []string + + // Auth flags. + IdentityName string } // affectedCmd lists affected Atmos components and stacks. @@ -59,6 +63,15 @@ var affectedCmd = &cobra.Command{ return err } + // Read identity from flag (inherited from listCmd PersistentFlags) or env var. + var identityName string + if cmd.Flags().Changed(cfg.IdentityFlagName) { + identityName, _ = cmd.Flags().GetString(cfg.IdentityFlagName) + } else { + identityName = v.GetString(cfg.IdentityFlagName) + } + identityName = cfg.NormalizeIdentityValue(identityName) + opts := &AffectedOptions{ Flags: flags.ParseGlobalFlags(cmd, v), Format: v.GetString("format"), @@ -77,6 +90,7 @@ var affectedCmd = &cobra.Command{ ProcessTemplates: v.GetBool("process-templates"), ProcessFunctions: v.GetBool("process-functions"), Skip: v.GetStringSlice("skip"), + IdentityName: identityName, } return executeListAffectedCmd(cmd, args, opts) @@ -147,5 +161,6 @@ func executeListAffectedCmd(cmd *cobra.Command, args []string, opts *AffectedOpt ProcessFunctions: opts.ProcessFunctions, Skip: opts.Skip, ExcludeLocked: opts.ExcludeLocked, + IdentityName: opts.IdentityName, }) } diff --git a/cmd/list/affected_test.go b/cmd/list/affected_test.go index 07996244e5..90850dc8d0 100644 --- a/cmd/list/affected_test.go +++ b/cmd/list/affected_test.go @@ -48,6 +48,7 @@ func TestAffectedOptions(t *testing.T) { ProcessTemplates: true, ProcessFunctions: true, Skip: []string{"component1", "component2"}, + IdentityName: "admin-account", } assert.Equal(t, "json", opts.Format) @@ -66,6 +67,7 @@ func TestAffectedOptions(t *testing.T) { assert.True(t, opts.ProcessTemplates) assert.True(t, opts.ProcessFunctions) assert.Equal(t, []string{"component1", "component2"}, opts.Skip) + assert.Equal(t, "admin-account", opts.IdentityName) } // TestAffectedOptions_Defaults tests default values in AffectedOptions. @@ -88,6 +90,76 @@ func TestAffectedOptions_Defaults(t *testing.T) { assert.False(t, opts.ProcessTemplates) assert.False(t, opts.ProcessFunctions) assert.Empty(t, opts.Skip) + assert.Empty(t, opts.IdentityName) +} + +// TestAffectedIdentityFlagParsing tests the identity flag/viper precedence logic. +func TestAffectedIdentityFlagParsing(t *testing.T) { + tests := []struct { + name string + setupCmd func() *cobra.Command + setupViper func() + expectedName string + }{ + { + name: "identity from flag takes precedence", + setupCmd: func() *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().StringP("identity", "i", "", "Identity") + _ = cmd.Flags().Set("identity", "flag-identity") + return cmd + }, + setupViper: func() { + viper.Reset() + viper.Set("identity", "viper-identity") + }, + expectedName: "flag-identity", + }, + { + name: "identity from viper when flag not changed", + setupCmd: func() *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().StringP("identity", "i", "", "Identity") + // Flag not set — Changed() returns false. + return cmd + }, + setupViper: func() { + viper.Reset() + viper.Set("identity", "viper-identity") + }, + expectedName: "viper-identity", + }, + { + name: "empty when neither flag nor viper set", + setupCmd: func() *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().StringP("identity", "i", "", "Identity") + return cmd + }, + setupViper: func() { + viper.Reset() + }, + expectedName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := tt.setupCmd() + tt.setupViper() + v := viper.GetViper() + + // Replicate the logic from affectedCmd.RunE. + var identityName string + if cmd.Flags().Changed("identity") { + identityName, _ = cmd.Flags().GetString("identity") + } else { + identityName = v.GetString("identity") + } + + assert.Equal(t, tt.expectedName, identityName) + }) + } } // TestAffectedOptions_GitOptions tests the git-related options. diff --git a/docs/fixes/2026-03-25-describe-affected-auth-identity-not-used.md b/docs/fixes/2026-03-25-describe-affected-auth-identity-not-used.md new file mode 100644 index 0000000000..7ba3d3cbde --- /dev/null +++ b/docs/fixes/2026-03-25-describe-affected-auth-identity-not-used.md @@ -0,0 +1,179 @@ +# Fix: Auth identity not used for backend state reads in describe affected + +**Date:** 2026-03-25 + +## Problem + +When running `atmos list affected --ref refs/heads/main` (or `atmos describe affected`), the command +fails to authenticate to the S3 state bucket even though the user is authenticated via `atmos auth` +with a valid identity. The same identity works correctly with `atmos tf plan`. + +### Error + +```text +Failed to read Terraform state file from the S3 bucket + error="operation error S3: GetObject, get identity: get credentials: + failed to refresh cached credentials, no EC2 IMDS role found, + operation error ec2imds: GetMetadata, request canceled, context deadline exceeded" +``` + +### Debug Output Pattern + +```text +DEBU Component has auth config with default identity, creating component-specific AuthManager component=mycomponent stack=mystack +DEBU Authentication chain discovered identity=account-admin chainLength=2 chain="[my-sso account-admin]" +DEBU Successfully loaded credentials from identity storage identity=account-admin +DEBU Created component-specific AuthManager component=mycomponent stack=mystack +DEBU Using standard AWS SDK credential resolution (no auth context provided) ← BUG +DEBU Failed to read Terraform state file from the S3 bucket error="...context deadline exceeded" +``` + +## Root Cause + +Four independent bugs: + +### Bug 1: AuthManager not threaded through describe affected + +`DescribeAffectedCmdArgs.AuthManager` is created from the `--identity` flag but never passed through +the execution chain to `ExecuteDescribeStacks()`. All intermediate functions (`executeDescribeAffected`, +the three helper functions, `addDependentsToAffected`) lacked an `authManager` parameter and passed +`nil` to `ExecuteDescribeStacks()`. + +**Call chain (before fix):** + +``` +DescribeAffectedCmdArgs.AuthManager (has value) + → Execute() calls helpers WITHOUT passing AuthManager + → executeDescribeAffected() has NO authManager parameter + → ExecuteDescribeStacks(..., nil) ← dropped here +``` + +### Bug 2: GetTerraformState ignores resolved AuthManager credentials for backend read + +Even when `resolveAuthManagerForNestedComponent()` successfully creates a component-specific +AuthManager (visible in debug logs), the actual S3 backend read used the original `authContext` +parameter (nil) instead of extracting credentials from the resolved AuthManager. + +**Code (before fix):** + +```go +// terraform_state_utils.go +resolvedAuthMgr, _ := resolveAuthManagerForNestedComponent(...) // ✓ valid AuthManager +componentSections, _ := ExecuteDescribeComponent(... AuthManager: resolvedAuthMgr ...) // ✓ used +backend, _ := tb.GetTerraformBackend(atmosConfig, &componentSections, authContext) // ✗ nil authContext! +``` + +### Bug 3: No per-component identity resolution in ExecuteDescribeStacks + +`ExecuteDescribeStacks` applied a single `authManager` to all components via `propagateAuth()`. +Components with their own `auth:` section defining different identities were not resolved +individually during stack description. + +## Fix + +### Bug 1 Fix + +Added `authManager auth.AuthManager` parameter to the entire describe affected call chain: + +- `executeDescribeAffected()` in `describe_affected_utils.go` +- `ExecuteDescribeAffectedWithTargetRefClone()` in `describe_affected_helpers.go` +- `ExecuteDescribeAffectedWithTargetRefCheckout()` in `describe_affected_helpers.go` +- `ExecuteDescribeAffectedWithTargetRepoPath()` in `describe_affected_helpers.go` +- `addDependentsToAffected()` in `describe_affected_utils_2.go` +- Function type fields in `describeAffectedExec` struct +- `Execute()` method passes `a.AuthManager` to all calls +- `terraform_affected_graph.go` passes `args.AuthManager` +- `terraform_affected.go` passes `args.AuthManager` + +### Bug 2 Fix + +In `GetTerraformState()` (`terraform_state_utils.go`), after resolving the component-specific +AuthManager, extract its AuthContext and use it for the backend read: + +```go +resolvedAuthContext := authContext +if resolvedAuthMgr != nil { + if si := resolvedAuthMgr.GetStackInfo(); si != nil && si.AuthContext != nil { + resolvedAuthContext = si.AuthContext + } +} +backend, err := tb.GetTerraformBackend(atmosConfig, &componentSections, resolvedAuthContext) +``` + +### Bug 3 Fix + +In `processComponentEntry()` (`describe_stacks_component_processor.go`), resolve per-component +auth when YAML functions will be processed. Uses the component section data already in-hand +(no extra `ExecuteDescribeComponent` call): + +```go +componentAuthManager := p.authManager +if p.processYamlFunctions { + authSection, hasAuth := componentSection[cfg.AuthSectionName].(map[string]any) + if hasAuth && hasDefaultIdentity(authSection) { + resolved, err := createComponentAuthManager(...) + if err == nil && resolved != nil { + componentAuthManager = resolved + } + } +} +propagateAuth(&info, componentAuthManager) +``` + +Gated behind `processYamlFunctions` to avoid unnecessary auth resolution when functions aren't +being processed (the only consumer of auth context in this path). + +### Bug 4: `list affected` never reads `--identity` flag or creates AuthManager + +The `--identity` / `-i` flag IS registered on `listCmd` as a PersistentFlag (inherited by all +subcommands including `list affected`). However, `cmd/list/affected.go` never read the flag +and never created an AuthManager. All three code paths in `executeAffectedLogic()` passed `nil` +for authManager. + +**Why `atmos auth shell` worked but `-i` didn't:** +- `atmos auth shell` sets `ATMOS_IDENTITY` env var, picked up by viper fallback +- `-i admin-account` requires the command handler to read the flag and create an AuthManager + +### Bug 4 Fix + +Read the `--identity` flag in the `list affected` RunE handler (`cmd/list/affected.go`) and +pass the identity name through to `ExecuteListAffectedCmd` (`pkg/list/list_affected.go`), +which creates an AuthManager after config initialization and passes it to all three +`ExecuteDescribeAffectedWith*` helper functions. + +```go +// cmd/list/affected.go - read identity flag in RunE +var identityName string +if cmd.Flags().Changed(cfg.IdentityFlagName) { + identityName, _ = cmd.Flags().GetString(cfg.IdentityFlagName) +} else { + identityName = v.GetString(cfg.IdentityFlagName) +} +identityName = cfg.NormalizeIdentityValue(identityName) + +// pkg/list/list_affected.go - create AuthManager after config init +authManager, err := auth.CreateAndAuthenticateManagerWithAtmosConfig( + opts.IdentityName, &atmosConfig.Auth, cfg.IdentityFlagSelectValue, &atmosConfig, +) +``` + +## Files Changed + +- `internal/exec/describe_affected.go` — struct types, Execute() +- `internal/exec/describe_affected_helpers.go` — 3 helper function signatures +- `internal/exec/describe_affected_utils.go` — executeDescribeAffected() signature +- `internal/exec/describe_affected_utils_2.go` — addDependentsToAffected() +- `internal/exec/terraform_affected.go` — pass AuthManager to helpers +- `internal/exec/terraform_affected_graph.go` — pass AuthManager +- `internal/exec/terraform_state_utils.go` — use resolved AuthContext for backend read +- `internal/exec/describe_stacks_component_processor.go` — per-component identity resolution +- `internal/exec/atlantis_generate_repo_config.go` — pass nil (no auth context) +- `pkg/ai/tools/atmos/describe_affected.go` — pass nil +- `pkg/list/list_affected.go` — add IdentityName field, create AuthManager, pass to helpers +- `cmd/list/affected.go` — read identity flag, pass IdentityName through opts +- Test files updated for new signatures + +## Related + +- `docs/fixes/nested-terraform-state-auth-context-propagation.md` — original nested auth fix +- `docs/fixes/2026-03-03-yaml-functions-auth-multi-component.md` — multi-component auth fix diff --git a/internal/exec/atlantis_generate_repo_config.go b/internal/exec/atlantis_generate_repo_config.go index 5dcb2be708..44fccfb3d1 100644 --- a/internal/exec/atlantis_generate_repo_config.go +++ b/internal/exec/atlantis_generate_repo_config.go @@ -163,6 +163,7 @@ func ExecuteAtlantisGenerateRepoConfigAffectedOnly( true, nil, false, + nil, ) } else if cloneTargetRef { affected, _, _, _, err = ExecuteDescribeAffectedWithTargetRefClone( @@ -178,6 +179,7 @@ func ExecuteAtlantisGenerateRepoConfigAffectedOnly( true, nil, false, + nil, ) } else { affected, _, _, _, err = ExecuteDescribeAffectedWithTargetRefCheckout( @@ -191,6 +193,7 @@ func ExecuteAtlantisGenerateRepoConfigAffectedOnly( true, nil, false, + nil, ) } diff --git a/internal/exec/describe_affected.go b/internal/exec/describe_affected.go index 7a4a94e3d7..18c78daa97 100644 --- a/internal/exec/describe_affected.go +++ b/internal/exec/describe_affected.go @@ -71,6 +71,7 @@ type describeAffectedExec struct { processYamlFunctions bool, skip []string, excludeLocked bool, + authManager auth.AuthManager, ) ([]schema.Affected, *plumbing.Reference, *plumbing.Reference, string, error) executeDescribeAffectedWithTargetRefClone func( atmosConfig *schema.AtmosConfiguration, @@ -85,6 +86,7 @@ type describeAffectedExec struct { processYamlFunctions bool, skip []string, excludeLocked bool, + authManager auth.AuthManager, ) ([]schema.Affected, *plumbing.Reference, *plumbing.Reference, string, error) executeDescribeAffectedWithTargetRefCheckout func( atmosConfig *schema.AtmosConfiguration, @@ -97,6 +99,7 @@ type describeAffectedExec struct { processYamlFunctions bool, skip []string, excludeLocked bool, + authManager auth.AuthManager, ) ([]schema.Affected, *plumbing.Reference, *plumbing.Reference, string, error) addDependentsToAffected func( atmosConfig *schema.AtmosConfiguration, @@ -106,6 +109,7 @@ type describeAffectedExec struct { processYamlFunctions bool, skip []string, onlyInStack string, + authManager auth.AuthManager, ) error printOrWriteToFile func( atmosConfig *schema.AtmosConfiguration, @@ -247,6 +251,7 @@ func (d *describeAffectedExec) Execute(a *DescribeAffectedCmdArgs) error { a.ProcessYamlFunctions, a.Skip, a.ExcludeLocked, + a.AuthManager, ) case a.CloneTargetRef: affected, headHead, baseHead, repoUrl, err = d.executeDescribeAffectedWithTargetRefClone( @@ -262,6 +267,7 @@ func (d *describeAffectedExec) Execute(a *DescribeAffectedCmdArgs) error { a.ProcessYamlFunctions, a.Skip, a.ExcludeLocked, + a.AuthManager, ) default: affected, headHead, baseHead, repoUrl, err = d.executeDescribeAffectedWithTargetRefCheckout( @@ -275,6 +281,7 @@ func (d *describeAffectedExec) Execute(a *DescribeAffectedCmdArgs) error { a.ProcessYamlFunctions, a.Skip, a.ExcludeLocked, + a.AuthManager, ) } if err != nil { @@ -283,7 +290,7 @@ func (d *describeAffectedExec) Execute(a *DescribeAffectedCmdArgs) error { // Add dependent components and stacks for each affected component. if len(affected) > 0 && a.IncludeDependents { - err = d.addDependentsToAffected(a.CLIConfig, &affected, a.IncludeSettings, a.ProcessTemplates, a.ProcessYamlFunctions, a.Skip, a.Stack) + err = d.addDependentsToAffected(a.CLIConfig, &affected, a.IncludeSettings, a.ProcessTemplates, a.ProcessYamlFunctions, a.Skip, a.Stack, a.AuthManager) if err != nil { return err } diff --git a/internal/exec/describe_affected_helpers.go b/internal/exec/describe_affected_helpers.go index 777585e8db..94cb55f56a 100644 --- a/internal/exec/describe_affected_helpers.go +++ b/internal/exec/describe_affected_helpers.go @@ -9,6 +9,7 @@ import ( "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/transport/ssh" + "github.com/cloudposse/atmos/pkg/auth" g "github.com/cloudposse/atmos/pkg/git" log "github.com/cloudposse/atmos/pkg/logger" "github.com/cloudposse/atmos/pkg/perf" @@ -38,6 +39,7 @@ func ExecuteDescribeAffectedWithTargetRefClone( processYamlFunctions bool, skip []string, excludeLocked bool, + authManager auth.AuthManager, ) ([]schema.Affected, *plumbing.Reference, *plumbing.Reference, string, error) { defer perf.Track(atmosConfig, "exec.ExecuteDescribeAffectedWithTargetRefClone")() @@ -161,6 +163,7 @@ func ExecuteDescribeAffectedWithTargetRefClone( processYamlFunctions, skip, excludeLocked, + authManager, ) if err != nil { return nil, nil, nil, "", err @@ -195,6 +198,7 @@ func ExecuteDescribeAffectedWithTargetRefCheckout( processYamlFunctions bool, skip []string, excludeLocked bool, + authManager auth.AuthManager, ) ([]schema.Affected, *plumbing.Reference, *plumbing.Reference, string, error) { defer perf.Track(atmosConfig, "exec.ExecuteDescribeAffectedWithTargetRefCheckout")() @@ -256,6 +260,7 @@ func ExecuteDescribeAffectedWithTargetRefCheckout( processYamlFunctions, skip, excludeLocked, + authManager, ) if err != nil { return nil, nil, nil, "", err @@ -282,6 +287,7 @@ func ExecuteDescribeAffectedWithTargetRepoPath( processYamlFunctions bool, skip []string, excludeLocked bool, + authManager auth.AuthManager, ) ([]schema.Affected, *plumbing.Reference, *plumbing.Reference, string, error) { defer perf.Track(atmosConfig, "exec.ExecuteDescribeAffectedWithTargetRepoPath")() @@ -326,6 +332,7 @@ func ExecuteDescribeAffectedWithTargetRepoPath( processYamlFunctions, skip, excludeLocked, + authManager, ) if err != nil { return nil, nil, nil, "", err diff --git a/internal/exec/describe_affected_test.go b/internal/exec/describe_affected_test.go index 9f6cadb04a..85809604e2 100644 --- a/internal/exec/describe_affected_test.go +++ b/internal/exec/describe_affected_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "github.com/cloudposse/atmos/pkg/auth" cfg "github.com/cloudposse/atmos/pkg/config" "github.com/cloudposse/atmos/pkg/pager" "github.com/cloudposse/atmos/pkg/schema" @@ -115,15 +116,15 @@ func TestDescribeAffected(t *testing.T) { return false } - d.executeDescribeAffectedWithTargetRepoPath = func(atmosConfig *schema.AtmosConfiguration, targetRefPath string, includeSpaceliftAdminStacks, includeSettings bool, stack string, processTemplates, processYamlFunctions bool, skip []string, excludeLocked bool) ([]schema.Affected, *plumbing.Reference, *plumbing.Reference, string, error) { + d.executeDescribeAffectedWithTargetRepoPath = func(atmosConfig *schema.AtmosConfiguration, targetRefPath string, includeSpaceliftAdminStacks, includeSettings bool, stack string, processTemplates, processYamlFunctions bool, skip []string, excludeLocked bool, authManager auth.AuthManager) ([]schema.Affected, *plumbing.Reference, *plumbing.Reference, string, error) { return []schema.Affected{}, nil, nil, "", nil } - d.executeDescribeAffectedWithTargetRefClone = func(atmosConfig *schema.AtmosConfiguration, ref, sha, sshKeyPath, sshKeyPassword string, includeSpaceliftAdminStacks, includeSettings bool, stack string, processTemplates, processYamlFunctions bool, skip []string, excludeLocked bool) ([]schema.Affected, *plumbing.Reference, *plumbing.Reference, string, error) { + d.executeDescribeAffectedWithTargetRefClone = func(atmosConfig *schema.AtmosConfiguration, ref, sha, sshKeyPath, sshKeyPassword string, includeSpaceliftAdminStacks, includeSettings bool, stack string, processTemplates, processYamlFunctions bool, skip []string, excludeLocked bool, authManager auth.AuthManager) ([]schema.Affected, *plumbing.Reference, *plumbing.Reference, string, error) { return []schema.Affected{}, nil, nil, "", nil } - d.executeDescribeAffectedWithTargetRefCheckout = func(atmosConfig *schema.AtmosConfiguration, ref, sha string, includeSpaceliftAdminStacks, includeSettings bool, stack string, processTemplates, processYamlFunctions bool, skip []string, excludeLocked bool) ([]schema.Affected, *plumbing.Reference, *plumbing.Reference, string, error) { + d.executeDescribeAffectedWithTargetRefCheckout = func(atmosConfig *schema.AtmosConfiguration, ref, sha string, includeSpaceliftAdminStacks, includeSettings bool, stack string, processTemplates, processYamlFunctions bool, skip []string, excludeLocked bool, authManager auth.AuthManager) ([]schema.Affected, *plumbing.Reference, *plumbing.Reference, string, error) { return []schema.Affected{ { Stack: "test-stack", @@ -132,7 +133,7 @@ func TestDescribeAffected(t *testing.T) { } d.atmosConfig = &schema.AtmosConfiguration{} - d.addDependentsToAffected = func(atmosConfig *schema.AtmosConfiguration, affected *[]schema.Affected, includeSettings bool, processTemplates bool, processFunctions bool, skip []string, onlyInStack string) error { + d.addDependentsToAffected = func(atmosConfig *schema.AtmosConfiguration, affected *[]schema.Affected, includeSettings bool, processTemplates bool, processFunctions bool, skip []string, onlyInStack string, authManager auth.AuthManager) error { return nil } d.printOrWriteToFile = func(atmosConfig *schema.AtmosConfiguration, format, file string, data any) error { @@ -232,6 +233,7 @@ func TestExecuteDescribeAffectedWithTargetRepoPath(t *testing.T) { false, nil, false, + nil, ) assert.Nil(t, err) @@ -315,6 +317,7 @@ func TestDescribeAffectedWithTemplatesAndFunctions(t *testing.T) { true, nil, false, + nil, ) require.NoError(t, err) // Order-agnostic equality on struct slices. @@ -337,6 +340,7 @@ func TestDescribeAffectedWithoutTemplatesAndFunctions(t *testing.T) { false, nil, false, + nil, ) require.NoError(t, err) // Order-agnostic equality on struct slices. @@ -394,6 +398,7 @@ func TestDescribeAffectedWithExcludeLocked(t *testing.T) { true, nil, true, + nil, ) require.NoError(t, err) // Order-agnostic equality on struct slices. @@ -573,6 +578,7 @@ func TestDescribeAffectedWithDependents(t *testing.T) { true, nil, false, + nil, ) require.NoError(t, err) err = addDependentsToAffected( @@ -583,6 +589,7 @@ func TestDescribeAffectedWithDependents(t *testing.T) { true, nil, "", + nil, ) require.NoError(t, err) // Order-agnostic equality on struct slices. @@ -712,6 +719,7 @@ func TestDescribeAffectedWithDependentsWithoutTemplates(t *testing.T) { false, nil, false, + nil, ) require.NoError(t, err) err = addDependentsToAffected( @@ -722,6 +730,7 @@ func TestDescribeAffectedWithDependentsWithoutTemplates(t *testing.T) { false, nil, "", + nil, ) require.NoError(t, err) // Order-agnostic equality on struct slices. @@ -903,6 +912,7 @@ func TestDescribeAffectedWithDependentsFilteredByStack(t *testing.T) { true, nil, false, + nil, ) require.NoError(t, err) err = addDependentsToAffected( @@ -912,7 +922,8 @@ func TestDescribeAffectedWithDependentsFilteredByStack(t *testing.T) { true, true, nil, - onlyInStack, // Filter dependents to only show those in "ue1-network" stack. + onlyInStack, // Filter dependents to only show those in "ue1-network" stack., + nil, ) require.NoError(t, err) // Order-agnostic equality on struct slices. @@ -1012,6 +1023,7 @@ func TestDescribeAffectedWithDisabledDependents(t *testing.T) { true, nil, false, + nil, ) require.NoError(t, err) err = addDependentsToAffected( @@ -1021,7 +1033,8 @@ func TestDescribeAffectedWithDisabledDependents(t *testing.T) { true, true, nil, - onlyInStack, // Filter dependents to only show those in "uw2-network" stack. + onlyInStack, // Filter dependents to only show those in "uw2-network" stack., + nil, ) require.NoError(t, err) // Order-agnostic equality on struct slices. @@ -1057,6 +1070,7 @@ func TestDescribeAffectedWithDependentsStackFilterYamlFunctions(t *testing.T) { true, nil, false, + nil, ) require.NoError(t, err) @@ -1069,6 +1083,7 @@ func TestDescribeAffectedWithDependentsStackFilterYamlFunctions(t *testing.T) { true, nil, onlyInStack, + nil, ) require.NoError(t, err) @@ -1214,6 +1229,7 @@ func TestDescribeAffectedNewComponentInBase(t *testing.T) { false, // processYamlFunctions - disable to avoid !terraform.state issues nil, false, + nil, ) // The test should pass - new components in BASE should be handled gracefully. @@ -1267,6 +1283,7 @@ func TestDescribeAffectedNewComponentInBaseWithYamlFunctions(t *testing.T) { true, // processYamlFunctions - this triggers the bug nil, false, + nil, ) // FIXED BEHAVIOR: The fix passes atmosConfig through the YAML function chain to // ExecuteDescribeComponent, so component lookups use BASE paths correctly. @@ -1332,6 +1349,7 @@ func TestDescribeAffectedSourceVersionChange(t *testing.T) { false, // processYamlFunctions - don't need YAML functions for this test nil, false, + nil, ) // Check if there was an error. require.NoError(t, err) @@ -1413,6 +1431,7 @@ func TestDescribeAffectedDeletedComponentDetection(t *testing.T) { false, // processYamlFunctions - don't need YAML functions for this test nil, false, + nil, ) require.NoError(t, err) @@ -1514,6 +1533,7 @@ func TestDescribeAffectedDeletedComponentFiltering(t *testing.T) { false, nil, false, + nil, ) require.NoError(t, err) @@ -1548,6 +1568,7 @@ func TestDescribeAffectedDeletedComponentWithDependents(t *testing.T) { false, // processYamlFunctions nil, false, + nil, ) require.NoError(t, err) @@ -1572,6 +1593,7 @@ func TestDescribeAffectedDeletedComponentWithDependents(t *testing.T) { false, nil, "", + nil, ) require.NoError(t, err, "addDependentsToAffected should not crash on deleted components") diff --git a/internal/exec/describe_affected_utils.go b/internal/exec/describe_affected_utils.go index 4c25654e06..f2034342f4 100644 --- a/internal/exec/describe_affected_utils.go +++ b/internal/exec/describe_affected_utils.go @@ -8,6 +8,7 @@ import ( "github.com/go-git/go-git/v5/plumbing" errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/pkg/auth" cfg "github.com/cloudposse/atmos/pkg/config" log "github.com/cloudposse/atmos/pkg/logger" "github.com/cloudposse/atmos/pkg/schema" @@ -27,6 +28,7 @@ func executeDescribeAffected( processYamlFunctions bool, skip []string, excludeLocked bool, + authManager auth.AuthManager, ) ([]schema.Affected, *plumbing.Reference, *plumbing.Reference, error) { localRepoHead, err := localRepo.Head() if err != nil { @@ -52,7 +54,7 @@ func executeDescribeAffected( processYamlFunctions, false, skip, - nil, // AuthManager passed from describe affected command layer + authManager, ) if err != nil { return nil, nil, nil, err @@ -152,7 +154,7 @@ func executeDescribeAffected( processYamlFunctions, false, skip, - nil, // AuthManager passed from describe affected command layer + authManager, ) if err != nil { return nil, nil, nil, err diff --git a/internal/exec/describe_affected_utils_2.go b/internal/exec/describe_affected_utils_2.go index 50ba0a2a2c..1516109e66 100644 --- a/internal/exec/describe_affected_utils_2.go +++ b/internal/exec/describe_affected_utils_2.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform-config-inspect/tfconfig" errUtils "github.com/cloudposse/atmos/errors" + "github.com/cloudposse/atmos/pkg/auth" cfg "github.com/cloudposse/atmos/pkg/config" "github.com/cloudposse/atmos/pkg/schema" u "github.com/cloudposse/atmos/pkg/utils" @@ -540,6 +541,7 @@ func addDependentsToAffected( processYamlFunctions bool, skip []string, onlyInStack string, + authManager auth.AuthManager, ) error { // Resolve all stacks once and build a reverse dependency index — these are the expensive // operations (~1s for large infras). Previously ExecuteDescribeStacks was called inside @@ -557,7 +559,7 @@ func addDependentsToAffected( processYamlFunctions, false, skip, - nil, + authManager, ) if err != nil { return err diff --git a/internal/exec/describe_affected_utils_test.go b/internal/exec/describe_affected_utils_test.go index a1c4dfbc7c..1525e6f2a9 100644 --- a/internal/exec/describe_affected_utils_test.go +++ b/internal/exec/describe_affected_utils_test.go @@ -893,6 +893,7 @@ func TestExecuteDescribeAffected(t *testing.T) { tc.processYamlFunctions, tc.skip, false, + nil, ) if tc.expectedErr != "" { @@ -1234,6 +1235,7 @@ func TestExecuteDescribeAffectedLocalRepoHeadError(t *testing.T) { false, nil, false, + nil, ) assert.Error(t, err) @@ -1270,6 +1272,7 @@ func TestExecuteDescribeAffectedRemoteRepoHeadError(t *testing.T) { false, nil, false, + nil, ) assert.Error(t, err) diff --git a/internal/exec/describe_stacks_authmanager_propagation_test.go b/internal/exec/describe_stacks_authmanager_propagation_test.go index e9a94df4a6..9afea0fa65 100644 --- a/internal/exec/describe_stacks_authmanager_propagation_test.go +++ b/internal/exec/describe_stacks_authmanager_propagation_test.go @@ -199,3 +199,48 @@ func TestDescribeStacksAuthManagerWithNilAuthContext(t *testing.T) { require.NoError(t, err, "Should handle nil AuthContext gracefully") require.NotNil(t, stacksMap) } + +// TestDescribeStacksAuthManager_NoPerComponentAuthWhenYamlFunctionsDisabled verifies that +// per-component auth resolution is skipped when processYamlFunctions=false. +// This covers the `if p.processYamlFunctions` conditional in processComponentEntry. +func TestDescribeStacksAuthManager_NoPerComponentAuthWhenYamlFunctionsDisabled(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockAuthManager := types.NewMockAuthManager(ctrl) + // GetStackInfo is called for auth propagation (not per-component resolution). + mockAuthManager.EXPECT(). + GetStackInfo(). + Return(&schema.ConfigAndStacksInfo{ + AuthContext: &schema.AuthContext{ + AWS: &schema.AWSAuthContext{Profile: "parent-identity"}, + }, + }). + AnyTimes() + + workDir := "../../tests/fixtures/scenarios/authmanager-propagation" + t.Chdir(workDir) + t.Setenv("ATMOS_CLI_CONFIG_PATH", ".") + + atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, true) + require.NoError(t, err) + + // processYamlFunctions=false → no per-component auth resolution. + stacksMap, err := ExecuteDescribeStacks( + &atmosConfig, + "", + nil, + nil, + nil, + false, + false, + false, // processYamlFunctions disabled — no per-component auth resolution. + false, + nil, + mockAuthManager, + ) + + require.NoError(t, err, "Should succeed with processYamlFunctions=false") + require.NotNil(t, stacksMap) + assert.NotEmpty(t, stacksMap) +} diff --git a/internal/exec/describe_stacks_component_processor.go b/internal/exec/describe_stacks_component_processor.go index a5db0ff540..c27e894c91 100644 --- a/internal/exec/describe_stacks_component_processor.go +++ b/internal/exec/describe_stacks_component_processor.go @@ -225,7 +225,6 @@ func (p *describeStacksProcessor) processComponentEntry( //nolint:gocognit,reviv } info := buildConfigAndStacksInfo(componentName, stackFileName, stackManifestName, secs) - propagateAuth(&info, p.authManager) // Ensure the component key is present in the info's ComponentSection. if comp, ok := info.ComponentSection[cfg.ComponentSectionName].(string); !ok || comp == "" { @@ -241,6 +240,20 @@ func (p *describeStacksProcessor) processComponentEntry( //nolint:gocognit,reviv } info.Context = resolvedContext + // Resolve per-component auth when YAML functions will be processed (the only consumer of auth context). + // This enables each component to use its own identity for !terraform.state reads. + componentAuthManager := p.authManager + if p.processYamlFunctions { + authSection, hasAuth := componentSection[cfg.AuthSectionName].(map[string]any) + if hasAuth && hasDefaultIdentity(authSection) { + resolved, createErr := createComponentAuthManager(p.atmosConfig, componentSection, componentName, stackName, p.authManager) + if createErr == nil && resolved != nil { + componentAuthManager = resolved + } + } + } + propagateAuth(&info, componentAuthManager) + // Filter: skip this component if it does not belong to the requested stack. if shouldFilterByStack(p.filterByStack, stackFileName, stackName) { return nil diff --git a/internal/exec/terraform_affected.go b/internal/exec/terraform_affected.go index 26a5251ea9..4654973196 100644 --- a/internal/exec/terraform_affected.go +++ b/internal/exec/terraform_affected.go @@ -29,6 +29,7 @@ func getAffectedComponents(args *DescribeAffectedCmdArgs) ([]schema.Affected, er args.ProcessYamlFunctions, args.Skip, args.ExcludeLocked, + args.AuthManager, ) return affectedList, err case args.CloneTargetRef: @@ -45,6 +46,7 @@ func getAffectedComponents(args *DescribeAffectedCmdArgs) ([]schema.Affected, er args.ProcessYamlFunctions, args.Skip, args.ExcludeLocked, + args.AuthManager, ) return affectedList, err default: @@ -59,6 +61,7 @@ func getAffectedComponents(args *DescribeAffectedCmdArgs) ([]schema.Affected, er args.ProcessYamlFunctions, args.Skip, args.ExcludeLocked, + args.AuthManager, ) return affectedList, err } @@ -90,6 +93,7 @@ func ExecuteTerraformAffected(args *DescribeAffectedCmdArgs, info *schema.Config args.ProcessYamlFunctions, args.Skip, "", + args.AuthManager, ) if err != nil { return err diff --git a/internal/exec/terraform_affected_graph.go b/internal/exec/terraform_affected_graph.go index 1f23e381e8..1701a1d4e7 100644 --- a/internal/exec/terraform_affected_graph.go +++ b/internal/exec/terraform_affected_graph.go @@ -67,6 +67,7 @@ func getAffectedWithRepoPath(args *DescribeAffectedCmdArgs) ([]schema.Affected, args.ProcessYamlFunctions, args.Skip, args.ExcludeLocked, + args.AuthManager, ) return affectedList, err } @@ -86,6 +87,7 @@ func getAffectedWithClone(args *DescribeAffectedCmdArgs) ([]schema.Affected, err args.ProcessYamlFunctions, args.Skip, args.ExcludeLocked, + args.AuthManager, ) return affectedList, err } @@ -103,6 +105,7 @@ func getAffectedWithCheckout(args *DescribeAffectedCmdArgs) ([]schema.Affected, args.ProcessYamlFunctions, args.Skip, args.ExcludeLocked, + args.AuthManager, ) return affectedList, err } @@ -140,7 +143,7 @@ func buildFilteredDependencyGraph( args.ProcessYamlFunctions, false, args.Skip, - nil, // authManager + args.AuthManager, ) if err != nil { return nil, fmt.Errorf("error describing stacks: %w", err) diff --git a/internal/exec/terraform_state_utils.go b/internal/exec/terraform_state_utils.go index e94e097104..77cb887415 100644 --- a/internal/exec/terraform_state_utils.go +++ b/internal/exec/terraform_state_utils.go @@ -87,6 +87,16 @@ func GetTerraformState( resolvedAuthMgr = parentAuthMgr } + // Derive the effective AuthContext for backend reads. + // If we resolved a component-specific AuthManager, use its AuthContext instead of the + // passed-in one (which may be nil when the parent didn't propagate auth). + resolvedAuthContext := authContext + if resolvedAuthMgr != nil { + if si := resolvedAuthMgr.GetStackInfo(); si != nil && si.AuthContext != nil { + resolvedAuthContext = si.AuthContext + } + } + componentSections, err := ExecuteDescribeComponent(&ExecuteDescribeComponentParams{ AtmosConfig: atmosConfig, Component: component, @@ -120,8 +130,8 @@ func GetTerraformState( return result, nil } - // Read Terraform backend. - backend, err := tb.GetTerraformBackend(atmosConfig, &componentSections, authContext) + // Read Terraform backend using resolved auth context. + backend, err := tb.GetTerraformBackend(atmosConfig, &componentSections, resolvedAuthContext) if err != nil { er := fmt.Errorf("%w for component `%s` in stack `%s`\nin YAML function: `%s`\n%v", errUtils.ErrReadTerraformState, component, stack, yamlFunc, err) return nil, er diff --git a/pkg/ai/tools/atmos/describe_affected.go b/pkg/ai/tools/atmos/describe_affected.go index c69df3e033..1dc472ecb9 100644 --- a/pkg/ai/tools/atmos/describe_affected.go +++ b/pkg/ai/tools/atmos/describe_affected.go @@ -78,6 +78,7 @@ func (t *DescribeAffectedTool) Execute(ctx context.Context, params map[string]in true, // processYamlFunctions []string{}, // skip false, // excludeLocked + nil, // authManager ) if err != nil { return &tools.Result{ diff --git a/pkg/describe/describe_affected_test.go b/pkg/describe/describe_affected_test.go index cfff183969..2bf5adacf4 100644 --- a/pkg/describe/describe_affected_test.go +++ b/pkg/describe/describe_affected_test.go @@ -47,6 +47,7 @@ func TestDescribeAffectedWithTargetRefClone(t *testing.T) { true, nil, false, + nil, // authManager ) assert.Nil(t, err) @@ -91,6 +92,7 @@ func TestDescribeAffectedWithTargetRepoPath(t *testing.T) { true, nil, false, + nil, // authManager ) assert.Nil(t, err) diff --git a/pkg/list/list_affected.go b/pkg/list/list_affected.go index 5736306f68..c04c3360c8 100644 --- a/pkg/list/list_affected.go +++ b/pkg/list/list_affected.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" e "github.com/cloudposse/atmos/internal/exec" + "github.com/cloudposse/atmos/pkg/auth" cfg "github.com/cloudposse/atmos/pkg/config" "github.com/cloudposse/atmos/pkg/list/column" "github.com/cloudposse/atmos/pkg/list/extract" @@ -69,6 +70,9 @@ type AffectedCommandOptions struct { ProcessFunctions bool Skip []string ExcludeLocked bool + + // Auth options. + IdentityName string // Identity name from --identity flag or ATMOS_IDENTITY env var. } // ExecuteListAffectedCmd executes the list affected command. @@ -83,6 +87,14 @@ func ExecuteListAffectedCmd(opts *AffectedCommandOptions) error { return fmt.Errorf("failed to initialize config: %w", err) } + // Create AuthManager from identity flag if provided. + authManager, err := auth.CreateAndAuthenticateManagerWithAtmosConfig( + opts.IdentityName, &atmosConfig.Auth, cfg.IdentityFlagSelectValue, &atmosConfig, + ) + if err != nil { + return err + } + // Get format flag. formatFlag, err := opts.Cmd.Flags().GetString("format") if err != nil { @@ -101,7 +113,7 @@ func ExecuteListAffectedCmd(opts *AffectedCommandOptions) error { "Comparing", func() (string, error) { var innerErr error - result, innerErr = getAffectedComponents(&atmosConfig, opts) + result, innerErr = getAffectedComponents(&atmosConfig, opts, authManager) if innerErr != nil { return "", innerErr } @@ -166,10 +178,10 @@ type affectedResult struct { } // getAffectedComponents calls the existing describe affected logic. -func getAffectedComponents(atmosConfig *schema.AtmosConfiguration, opts *AffectedCommandOptions) (*affectedResult, error) { +func getAffectedComponents(atmosConfig *schema.AtmosConfiguration, opts *AffectedCommandOptions, authManager auth.AuthManager) (*affectedResult, error) { defer perf.Track(atmosConfig, "list.getAffectedComponents")() - logicResult, err := executeAffectedLogic(atmosConfig, opts) + logicResult, err := executeAffectedLogic(atmosConfig, opts, authManager) if err != nil { return nil, err } @@ -190,7 +202,7 @@ type affectedLogicResult struct { } // executeAffectedLogic calls the appropriate describe affected function based on options. -func executeAffectedLogic(atmosConfig *schema.AtmosConfiguration, opts *AffectedCommandOptions) (*affectedLogicResult, error) { +func executeAffectedLogic(atmosConfig *schema.AtmosConfiguration, opts *AffectedCommandOptions, authManager auth.AuthManager) (*affectedLogicResult, error) { includeSettings := true switch { @@ -205,6 +217,7 @@ func executeAffectedLogic(atmosConfig *schema.AtmosConfiguration, opts *Affected opts.ProcessFunctions, opts.Skip, opts.ExcludeLocked, + authManager, ) if err != nil { return nil, err @@ -224,6 +237,7 @@ func executeAffectedLogic(atmosConfig *schema.AtmosConfiguration, opts *Affected opts.ProcessFunctions, opts.Skip, opts.ExcludeLocked, + authManager, ) if err != nil { return nil, err @@ -241,6 +255,7 @@ func executeAffectedLogic(atmosConfig *schema.AtmosConfiguration, opts *Affected opts.ProcessFunctions, opts.Skip, opts.ExcludeLocked, + authManager, ) if err != nil { return nil, err diff --git a/pkg/list/list_affected_test.go b/pkg/list/list_affected_test.go index 2c35787828..38bf97d8e3 100644 --- a/pkg/list/list_affected_test.go +++ b/pkg/list/list_affected_test.go @@ -935,3 +935,23 @@ func TestBuildAffectedSorters_DeletedSort(t *testing.T) { }) } } + +// TestAffectedCommandOptions_IdentityName verifies the IdentityName field is preserved. +func TestAffectedCommandOptions_IdentityName(t *testing.T) { + tests := []struct { + name string + identityName string + }{ + {name: "empty identity", identityName: ""}, + {name: "explicit identity", identityName: "admin-account"}, + {name: "disabled identity", identityName: "none"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &AffectedCommandOptions{ + IdentityName: tt.identityName, + } + assert.Equal(t, tt.identityName, opts.IdentityName) + }) + } +} diff --git a/tests/describe_affected_include_test.go b/tests/describe_affected_include_test.go index f50f896004..098633a667 100644 --- a/tests/describe_affected_include_test.go +++ b/tests/describe_affected_include_test.go @@ -89,6 +89,7 @@ func TestDescribeAffectedWithInclude(t *testing.T) { true, // processYamlFunctions: true - this triggers !include processing. nil, false, + nil, // authManager ) require.NoError(t, err, "describe affected should not fail when stacks use !include") @@ -122,6 +123,7 @@ func TestDescribeAffectedWithInclude(t *testing.T) { true, nil, false, + nil, // authManager ) require.NoError(t, err) @@ -165,6 +167,7 @@ func TestDescribeAffectedWithIncludeSelfComparison(t *testing.T) { true, // processYamlFunctions: true. nil, false, + nil, // authManager ) require.NoError(t, err,