Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions cmd/list/affected.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -39,6 +40,9 @@ type AffectedOptions struct {
ProcessTemplates bool
ProcessFunctions bool
Skip []string

// Auth flags.
IdentityName string
}

// affectedCmd lists affected Atmos components and stacks.
Expand All @@ -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"),
Expand All @@ -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)
Expand Down Expand Up @@ -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,
})
}
72 changes: 72 additions & 0 deletions cmd/list/affected_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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.
Expand Down
179 changes: 179 additions & 0 deletions docs/fixes/2026-03-25-describe-affected-auth-identity-not-used.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions internal/exec/atlantis_generate_repo_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ func ExecuteAtlantisGenerateRepoConfigAffectedOnly(
true,
nil,
false,
nil,
)
} else if cloneTargetRef {
affected, _, _, _, err = ExecuteDescribeAffectedWithTargetRefClone(
Expand All @@ -178,6 +179,7 @@ func ExecuteAtlantisGenerateRepoConfigAffectedOnly(
true,
nil,
false,
nil,
)
} else {
affected, _, _, _, err = ExecuteDescribeAffectedWithTargetRefCheckout(
Expand All @@ -191,6 +193,7 @@ func ExecuteAtlantisGenerateRepoConfigAffectedOnly(
true,
nil,
false,
nil,
)
}

Expand Down
Loading
Loading