Skip to content

Commit 01541b6

Browse files
ostermanclaudeaknysh
authored
feat: add required field to identities for multi-account Terraform components (#2180)
* feat: support concurrent identities for multi-account Terraform components via auth.needs Add `auth.needs` field to component auth config, enabling components to declare which identities they require. All listed identities are authenticated before Terraform runs, with their profiles written to the shared credentials file. The first identity becomes primary (sets AWS_PROFILE). This fixes CI failures when using multiple AWS provider aliases (e.g., hub-spoke networking) with OIDC, where only one profile was previously written to the credentials file. Changes: - Add Needs field to AuthConfig schema with comprehensive documentation - Update resolveTargetIdentityName() to prioritize auth.needs first entry as primary - Add authenticateAdditionalIdentities() for non-primary identity authentication with non-fatal error handling - Call authenticateAdditionalIdentities() from authenticateAndWriteEnv() after primary auth succeeds - Add 7 test cases covering needs list resolution, CLI override, fallback, success auth, skipping primary, non-fatal errors, and empty needs Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * feat: add auth section to stack manifest JSON schema Add the `auth` definition to the Atmos stack manifest JSON schema, including the new `needs` field, along with `realm`, `providers`, `identities`, and `integrations`. Reference it from `terraform_component_manifest` so IDE autocompletion and schema validation recognize the component-level auth config. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add needs field to all auth JSON schemas with validation tests Update all three copies of the manifest schema to include auth.needs: - pkg/datafetcher/schema (source, already had auth definition) - website/static/schemas (published, add needs/realm/integrations to component_auth) - tests/fixtures/schemas (test fixture, same as website) Add schema validation tests: - TestManifestSchema_AuthDefinitionExists: verify auth definition in embedded schema - TestManifestSchema_AuthNeedsField: verify needs is array of strings - TestManifestSchema_ValidAuthConfig: validate realistic auth configs against schema - TestAuthConfig_Needs: struct-level tests for Needs field - TestAuthConfig_NeedsWithMapstructure: verify Needs works with full AuthConfig Also changed component_auth additionalProperties from false to true to allow fields like logs, keyring, and realm that exist in the Go struct. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add blog post, roadmap milestone, and docs page for auth.needs Add announcement blog post for the auth.needs feature enabling concurrent identity authentication for multi-account Terraform components. Update the roadmap with a shipped milestone and add a dedicated documentation page at /cli/configuration/auth/needs with configuration examples and behavior reference. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: align embedded auth schema with structured definitions and allow slash-delimited identity names The embedded manifest schema had a loose auth definition with unstructured providers/identities, while the website and fixture schemas used rich structured definitions. This aligns all three copies and widens the identity/provider key pattern to accept slash-delimited names like core/network. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: make auth.needs purely additive — default identity stays primary Previously, the first entry in auth.needs became the primary identity, requiring users to re-list the default. Now the default identity is always primary, and needs only lists additional identities. If no default exists, the first needs entry becomes the primary as a fallback. Precedence: --identity CLI flag > default identity > needs[0] > error Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: discriminate sentinel errors in resolveTargetIdentityName GetDefaultIdentity can return multiple error types beyond ErrNoDefaultIdentity (e.g., ErrUserAborted, ErrIdentitySelectionRequiresTTY). Previously all errors fell through to the auth.needs fallback, masking real failures. Now only ErrNoDefaultIdentity triggers the fallback; other errors are returned immediately. Also properly surfaces decodeAuthConfigFromStack errors instead of silently ignoring them, and documents the Azure credential overwrite limitation for multi-identity scenarios. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: replace auth.needs with per-identity required field Move the multi-identity declaration from a centralized `auth.needs` string array on AuthConfig to a `required: true` boolean on each Identity. Required identities are automatically authenticated without prompting. The `required` field is orthogonal to `default`: default sets the primary identity, required means auto-authenticate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add missing ComponentAuthConfig fields and clarify docs examples Add Realm and Integrations fields to ComponentAuthConfig struct to match the JSON schema, preventing silent data loss during unmarshalling. Mark aws/assume-role examples in blog and docs as partial overrides with notes linking to full identity configuration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: harden schema validation tests with safe type assertions and negative case - Convert bare type assertions to two-value form with require.True for clear failure messages instead of cryptic panics - Add negative test case: identity with string "required" field rejected by JSON schema (validates the expectErr path) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com> Co-authored-by: aknysh <andriy.knysh@gmail.com>
1 parent 8e576bf commit 01541b6

File tree

15 files changed

+1043
-23
lines changed

15 files changed

+1043
-23
lines changed

pkg/auth/hooks.go

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package auth
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"strings"
78

@@ -25,7 +26,10 @@ type (
2526
Validator = types.Validator
2627
)
2728

28-
const hookOpTerraformPreHook = "TerraformPreHook"
29+
const (
30+
hookOpTerraformPreHook = "TerraformPreHook"
31+
identityKey = "identity"
32+
)
2933

3034
// TerraformPreHook runs before Terraform commands to set up authentication.
3135
func TerraformPreHook(atmosConfig *schema.AtmosConfiguration, stackInfo *schema.ConfigAndStacksInfo) error {
@@ -86,20 +90,24 @@ func decodeAuthConfigFromStack(stackInfo *schema.ConfigAndStacksInfo) (schema.Au
8690
}
8791

8892
func resolveTargetIdentityName(stackInfo *schema.ConfigAndStacksInfo, authManager types.AuthManager) (string, error) {
93+
// CLI --identity flag takes precedence.
8994
if stackInfo.Identity != "" {
9095
return stackInfo.Identity, nil
9196
}
92-
// Hooks don't have CLI flags, so never force selection here.
97+
98+
// Default identity from config is primary when available.
9399
name, err := authManager.GetDefaultIdentity(false)
94-
if err != nil {
95-
errUtils.CheckErrorAndPrint(errUtils.ErrDefaultIdentity, hookOpTerraformPreHook, "failed to get default identity")
96-
return "", errUtils.ErrDefaultIdentity
100+
if err == nil && name != "" {
101+
return name, nil
97102
}
98-
if name == "" {
99-
errUtils.CheckErrorAndPrint(errUtils.ErrNoDefaultIdentity, hookOpTerraformPreHook, "Use the identity flag or specify an identity as default.")
100-
return "", errUtils.ErrNoDefaultIdentity
103+
if err != nil && !errors.Is(err, errUtils.ErrNoDefaultIdentity) {
104+
return "", err
101105
}
102-
return name, nil
106+
107+
// No default identity found — error out.
108+
// The "required" field is about auto-authentication, not primary selection.
109+
errUtils.CheckErrorAndPrint(errUtils.ErrNoDefaultIdentity, hookOpTerraformPreHook, "Use the identity flag or specify an identity as default.")
110+
return "", errUtils.ErrNoDefaultIdentity
103111
}
104112

105113
// isAuthenticationDisabled checks if authentication has been explicitly disabled.
@@ -115,6 +123,10 @@ func authenticateAndWriteEnv(ctx context.Context, authManager types.AuthManager,
115123
}
116124
log.Debug("Authentication successful", "identity", whoami.Identity, "expiration", whoami.Expiration)
117125

126+
// Authenticate additional required identities so their profiles exist in the shared credentials file.
127+
// This is needed for Terraform components that use multiple AWS provider aliases.
128+
authenticateAdditionalIdentities(ctx, authManager, identityName)
129+
118130
// Convert ComponentEnvSection to env list for PrepareShellEnvironment.
119131
// This includes any component-specific env vars already set in the stack config.
120132
baseEnvList := componentEnvSectionToList(stackInfo.ComponentEnvSection)
@@ -144,6 +156,31 @@ func authenticateAndWriteEnv(ctx context.Context, authManager types.AuthManager,
144156
return nil
145157
}
146158

159+
// authenticateAdditionalIdentities authenticates non-primary identities marked as required.
160+
// Failures are non-fatal: errors are logged as warnings but don't fail the hook.
161+
// This ensures all required profiles exist in the shared credentials file for Terraform
162+
// components that use multiple AWS provider aliases (e.g., hub-spoke networking).
163+
//
164+
// TODO: Azure credentials are keyed by provider name, not identity. If two identities
165+
// share the same Azure provider name, the second will overwrite the first. AWS merges
166+
// profiles into INI sections and GCP isolates by directory, so they handle this correctly.
167+
// Consider adopting a per-identity storage strategy for Azure if multi-identity Azure
168+
// support becomes a requirement.
169+
func authenticateAdditionalIdentities(ctx context.Context, authManager types.AuthManager,
170+
primaryIdentity string,
171+
) {
172+
for name, identity := range authManager.GetIdentities() {
173+
if !identity.Required || strings.EqualFold(name, primaryIdentity) {
174+
continue
175+
}
176+
log.Debug("Authenticating additional required identity", identityKey, name)
177+
if _, err := authManager.Authenticate(ctx, name); err != nil {
178+
log.Warn("Failed to authenticate additional identity (non-fatal)",
179+
identityKey, name, "error", err)
180+
}
181+
}
182+
}
183+
147184
// componentEnvSectionToList converts ComponentEnvSection map to environment variable list.
148185
func componentEnvSectionToList(envSection map[string]any) []string {
149186
var envList []string

pkg/auth/hooks_required_test.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
10+
errUtils "github.com/cloudposse/atmos/errors"
11+
"github.com/cloudposse/atmos/pkg/auth/types"
12+
"github.com/cloudposse/atmos/pkg/schema"
13+
)
14+
15+
// trackingAuthManager extends stubAuthManager with call tracking for required identity tests.
16+
type trackingAuthManager struct {
17+
stubAuthManager
18+
authenticatedIdentities []string
19+
failIdentities map[string]error
20+
}
21+
22+
func (t *trackingAuthManager) Authenticate(ctx context.Context, identityName string) (*types.WhoamiInfo, error) {
23+
t.authenticatedIdentities = append(t.authenticatedIdentities, identityName)
24+
if err, ok := t.failIdentities[identityName]; ok {
25+
return nil, err
26+
}
27+
return t.whoami, nil
28+
}
29+
30+
func TestResolveTargetIdentityName_DefaultWinsOverRequired(t *testing.T) {
31+
stack := &schema.ConfigAndStacksInfo{}
32+
mgr := &stubAuthManager{defaultIdentity: "some-default"}
33+
34+
name, err := resolveTargetIdentityName(stack, mgr)
35+
assert.NoError(t, err)
36+
assert.Equal(t, "some-default", name, "default identity should be primary even when required identities exist")
37+
}
38+
39+
func TestResolveTargetIdentityName_NoDefaultErrorsEvenWithRequired(t *testing.T) {
40+
stack := &schema.ConfigAndStacksInfo{}
41+
mgr := &stubAuthManager{
42+
defaultIdentity: "",
43+
identities: map[string]schema.Identity{
44+
"core-network": {Kind: "aws/assume-role", Required: true},
45+
"plat-prod": {Kind: "aws/assume-role", Required: true},
46+
},
47+
}
48+
49+
_, err := resolveTargetIdentityName(stack, mgr)
50+
assert.ErrorIs(t, err, errUtils.ErrNoDefaultIdentity, "should error when no default, even if required identities exist")
51+
}
52+
53+
func TestResolveTargetIdentityName_CliOverridesDefault(t *testing.T) {
54+
stack := &schema.ConfigAndStacksInfo{Identity: "override-identity"}
55+
mgr := &stubAuthManager{defaultIdentity: "some-default"}
56+
57+
name, err := resolveTargetIdentityName(stack, mgr)
58+
assert.NoError(t, err)
59+
assert.Equal(t, "override-identity", name, "CLI --identity flag should take precedence")
60+
}
61+
62+
func TestResolveTargetIdentityName_NoDefaultNoRequired(t *testing.T) {
63+
stack := &schema.ConfigAndStacksInfo{}
64+
mgr := &stubAuthManager{defaultIdentity: ""}
65+
66+
_, err := resolveTargetIdentityName(stack, mgr)
67+
assert.ErrorIs(t, err, errUtils.ErrNoDefaultIdentity, "should error when no default and no required")
68+
}
69+
70+
func TestAuthenticateAdditionalIdentities_RequiredSuccess(t *testing.T) {
71+
mgr := &trackingAuthManager{
72+
stubAuthManager: stubAuthManager{
73+
whoami: &types.WhoamiInfo{Provider: "p", Identity: "i"},
74+
identities: map[string]schema.Identity{
75+
"primary": {Kind: "aws/assume-role", Required: true},
76+
"secondary": {Kind: "aws/assume-role", Required: true},
77+
"tertiary": {Kind: "aws/assume-role", Required: true},
78+
},
79+
},
80+
}
81+
82+
authenticateAdditionalIdentities(context.Background(), mgr, "primary")
83+
84+
assert.Contains(t, mgr.authenticatedIdentities, "secondary",
85+
"should authenticate required non-primary identities")
86+
assert.Contains(t, mgr.authenticatedIdentities, "tertiary",
87+
"should authenticate required non-primary identities")
88+
assert.NotContains(t, mgr.authenticatedIdentities, "primary",
89+
"primary identity should not be re-authenticated")
90+
}
91+
92+
func TestAuthenticateAdditionalIdentities_SkipsNonRequired(t *testing.T) {
93+
mgr := &trackingAuthManager{
94+
stubAuthManager: stubAuthManager{
95+
whoami: &types.WhoamiInfo{Provider: "p", Identity: "i"},
96+
identities: map[string]schema.Identity{
97+
"primary": {Kind: "aws/assume-role", Required: true},
98+
"optional": {Kind: "aws/assume-role", Required: false},
99+
"also-required": {Kind: "aws/assume-role", Required: true},
100+
},
101+
},
102+
}
103+
104+
authenticateAdditionalIdentities(context.Background(), mgr, "primary")
105+
106+
assert.Contains(t, mgr.authenticatedIdentities, "also-required",
107+
"should authenticate required identities")
108+
assert.NotContains(t, mgr.authenticatedIdentities, "optional",
109+
"should skip non-required identities")
110+
}
111+
112+
func TestAuthenticateAdditionalIdentities_SkipsPrimary(t *testing.T) {
113+
mgr := &trackingAuthManager{
114+
stubAuthManager: stubAuthManager{
115+
whoami: &types.WhoamiInfo{Provider: "p", Identity: "i"},
116+
identities: map[string]schema.Identity{
117+
"primary": {Kind: "aws/assume-role", Required: true},
118+
"secondary": {Kind: "aws/assume-role", Required: true},
119+
},
120+
},
121+
}
122+
123+
authenticateAdditionalIdentities(context.Background(), mgr, "primary")
124+
125+
for _, id := range mgr.authenticatedIdentities {
126+
assert.NotEqual(t, "primary", id, "primary identity should not be re-authenticated")
127+
}
128+
}
129+
130+
func TestAuthenticateAdditionalIdentities_NonFatal(t *testing.T) {
131+
mgr := &trackingAuthManager{
132+
stubAuthManager: stubAuthManager{
133+
whoami: &types.WhoamiInfo{Provider: "p", Identity: "i"},
134+
identities: map[string]schema.Identity{
135+
"primary": {Kind: "aws/assume-role", Required: true},
136+
"fail-id": {Kind: "aws/assume-role", Required: true},
137+
"success-id": {Kind: "aws/assume-role", Required: true},
138+
},
139+
},
140+
failIdentities: map[string]error{
141+
"fail-id": fmt.Errorf("simulated auth failure"),
142+
},
143+
}
144+
145+
// Should not panic or return error — failures are non-fatal.
146+
authenticateAdditionalIdentities(context.Background(), mgr, "primary")
147+
148+
// Both identities should have been attempted despite the failure.
149+
assert.Contains(t, mgr.authenticatedIdentities, "fail-id",
150+
"failed identity should still be attempted")
151+
assert.Contains(t, mgr.authenticatedIdentities, "success-id",
152+
"subsequent identities should be attempted after a failure")
153+
}
154+
155+
func TestAuthenticateAdditionalIdentities_NoRequired(t *testing.T) {
156+
mgr := &trackingAuthManager{
157+
stubAuthManager: stubAuthManager{
158+
whoami: &types.WhoamiInfo{Provider: "p", Identity: "i"},
159+
identities: map[string]schema.Identity{
160+
"identity-a": {Kind: "aws/assume-role"},
161+
"identity-b": {Kind: "aws/assume-role"},
162+
},
163+
},
164+
}
165+
166+
authenticateAdditionalIdentities(context.Background(), mgr, "primary")
167+
168+
assert.Empty(t, mgr.authenticatedIdentities, "no identities should be authenticated when none are required")
169+
}
170+
171+
func TestAuthenticateAdditionalIdentities_EmptyIdentities(t *testing.T) {
172+
mgr := &trackingAuthManager{
173+
stubAuthManager: stubAuthManager{
174+
whoami: &types.WhoamiInfo{Provider: "p", Identity: "i"},
175+
},
176+
}
177+
178+
authenticateAdditionalIdentities(context.Background(), mgr, "primary")
179+
180+
assert.Empty(t, mgr.authenticatedIdentities, "no identities should be authenticated when identities map is empty")
181+
}

pkg/auth/hooks_test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ type stubAuthManager struct {
1919
defaultIdentity string
2020
defaultErr error
2121
whoami *types.WhoamiInfo
22-
envVars map[string]string // Environment variables to return from GetEnvironmentVariables
22+
envVars map[string]string // Environment variables to return from GetEnvironmentVariables.
23+
identities map[string]schema.Identity // Identities to return from GetIdentities.
2324
}
2425

2526
func (s *stubAuthManager) Authenticate(ctx context.Context, identityName string) (*types.WhoamiInfo, error) {
@@ -54,6 +55,9 @@ func (s *stubAuthManager) GetStackInfo() *schema.ConfigAndStacksInfo {
5455
}
5556
func (s *stubAuthManager) ListProviders() []string { return []string{"prov"} }
5657
func (s *stubAuthManager) GetIdentities() map[string]schema.Identity {
58+
if s.identities != nil {
59+
return s.identities
60+
}
5761
return map[string]schema.Identity{}
5862
}
5963

0 commit comments

Comments
 (0)