diff --git a/cmd/auth_console_test.go b/cmd/auth_console_test.go index 4e45bd6ef1..a9ecacfbf6 100644 --- a/cmd/auth_console_test.go +++ b/cmd/auth_console_test.go @@ -804,6 +804,14 @@ func (m *mockAuthManagerForProvider) GetIntegration(integrationName string) (*sc return nil, errUtils.ErrNotImplemented } +func (m *mockAuthManagerForProvider) ResolvePrincipalSetting(identityName, key string) (interface{}, bool) { + return nil, false +} + +func (m *mockAuthManagerForProvider) ResolveProviderConfig(identityName string) (*schema.Provider, bool) { + return nil, false +} + // mockAuthManagerForIdentity implements minimal AuthManager for testing resolveIdentityName. // Only GetDefaultIdentity is implemented - other methods return ErrNotImplemented // because they are not needed by TestResolveIdentityName. @@ -911,6 +919,14 @@ func (m *mockAuthManagerForIdentity) GetIntegration(integrationName string) (*sc return nil, errUtils.ErrNotImplemented } +func (m *mockAuthManagerForIdentity) ResolvePrincipalSetting(identityName, key string) (interface{}, bool) { + return nil, false +} + +func (m *mockAuthManagerForIdentity) ResolveProviderConfig(identityName string) (*schema.Provider, bool) { + return nil, false +} + func TestResolveConsoleDuration(t *testing.T) { _ = NewTestKit(t) diff --git a/cmd/auth_integration_test.go b/cmd/auth_integration_test.go index c40089593a..17bf2f2683 100644 --- a/cmd/auth_integration_test.go +++ b/cmd/auth_integration_test.go @@ -190,11 +190,8 @@ func TestAuthCommandCompletion(t *testing.T) { // Call the completion function. completions, directive := completionFunc(authEnvCmd, []string{}, "") - // Verify we get the expected formats. - assert.Equal(t, 3, len(completions)) - assert.Contains(t, completions, "json") - assert.Contains(t, completions, "bash") - assert.Contains(t, completions, "dotenv") + // Verify we get the expected formats (must match SupportedFormats in auth_env.go). + assert.ElementsMatch(t, SupportedFormats, completions) assert.Equal(t, 4, int(directive)) // ShellCompDirectiveNoFileComp }) diff --git a/docs/prd/aws-auth-file-isolation.md b/docs/prd/aws-auth-file-isolation.md index 7c0d781b5a..c8af5ea7f8 100644 --- a/docs/prd/aws-auth-file-isolation.md +++ b/docs/prd/aws-auth-file-isolation.md @@ -101,10 +101,18 @@ output = json - Purpose: Selects which profile section to use within the credentials/config files - Note: Profile name matches identity name for consistency -**`AWS_REGION`** - Region override (optional) +**`AWS_REGION`** - Region from identity/provider configuration - Example: `us-east-1` -- Purpose: Overrides region from config file -- Supports component-level overrides via stack inheritance +- Purpose: Sets region for AWS SDK operations +- Exported by: `atmos auth env` (when region is explicitly configured) +- Also available via: `!env AWS_REGION` in stack configurations +- Note: Only exported when region is explicitly configured in identity or provider; no default fallback + +**`AWS_DEFAULT_REGION`** - Same as AWS_REGION (for SDK compatibility) +- Example: `us-east-1` +- Purpose: Fallback region for older AWS SDKs/tools +- Exported by: `atmos auth env` (when region is explicitly configured) +- Note: Set to same value as AWS_REGION for compatibility with legacy tools ### Conflicting Variables Cleared @@ -229,12 +237,21 @@ Logic flow: 1. Create copy of input environment (doesn't mutate input) 2. Clear conflicting AWS credential env vars 3. Set `AWS_SHARED_CREDENTIALS_FILE`, `AWS_CONFIG_FILE`, `AWS_PROFILE` -4. Set `AWS_REGION` if provided +4. Set `AWS_REGION` and `AWS_DEFAULT_REGION` if region is explicitly configured 5. Set `AWS_EC2_METADATA_DISABLED=true` 6. Return new map **Key Feature:** Returns NEW map instead of mutating input for safety and testability. +**Identity `Environment()` Method:** + +The `Environment()` method on AWS identities returns environment variables for `atmos auth env`: +- Always returns: `AWS_SHARED_CREDENTIALS_FILE`, `AWS_CONFIG_FILE`, `AWS_PROFILE` +- Conditionally returns: `AWS_REGION`, `AWS_DEFAULT_REGION` (only when region is explicitly configured) +- Does NOT use default fallback region - exports only explicitly configured values + +This enables users to reference `!env AWS_REGION` in stack configurations after sourcing auth environment variables. + ### Auth Context Schema (`pkg/schema/schema.go`) ```go @@ -539,3 +556,4 @@ AWS authentication successfully implements the universal pattern: | Date | Version | Changes | |------|---------|---------| | 2025-01-XX | 1.0 | Initial AWS implementation PRD created to document existing implementation | +| 2025-01-12 | 1.1 | Added AWS_REGION and AWS_DEFAULT_REGION export via `atmos auth env` when region is explicitly configured | diff --git a/errors/errors.go b/errors/errors.go index 9c4a6a5773..a27b271961 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -555,6 +555,7 @@ var ( ErrCopyFile = errors.New("failed to copy file") ErrCreateDirectory = errors.New("failed to create directory") ErrOpenFile = errors.New("failed to open file") + ErrWriteFile = errors.New("failed to write to file") ErrStatFile = errors.New("failed to stat file") ErrRemoveDirectory = errors.New("failed to remove directory") ErrSetPermissions = errors.New("failed to set permissions") diff --git a/internal/exec/packer.go b/internal/exec/packer.go index ff0e20f0e2..8d32aa4edb 100644 --- a/internal/exec/packer.go +++ b/internal/exec/packer.go @@ -158,7 +158,6 @@ func ExecutePacker( } // Check if the component is locked (`metadata.locked` is set to true). - // For Packer, only `build` modifies external resources. if info.ComponentIsLocked && info.SubCommand == "build" { return fmt.Errorf("%w: component '%s' cannot be modified (metadata.locked: true)", errUtils.ErrLockedComponentCantBeProvisioned, diff --git a/internal/exec/packer_output.go b/internal/exec/packer_output.go index ed1857ff99..92aaf8257a 100644 --- a/internal/exec/packer_output.go +++ b/internal/exec/packer_output.go @@ -77,7 +77,7 @@ func ExecutePackerOutput( manifestPath := filepath.Join(componentPath, manifestFilename) if !u.FileExists(manifestPath) { - return nil, fmt.Errorf("%w: '%s' does not exist - it is generated by Packer when executing 'atmos packer build' and the manifest filename is specified in the 'manifest_file_name' variable of the Atmos component", + return nil, fmt.Errorf("%w: '%s' (generated by 'atmos packer build', filename configured via 'manifest_file_name' variable)", errUtils.ErrMissingPackerManifest, filepath.Join(atmosConfig.Components.Packer.BasePath, info.ComponentFolderPrefix, info.FinalComponent, manifestFilename), ) diff --git a/internal/exec/terraform_output_authcontext_wrapper_test.go b/internal/exec/terraform_output_authcontext_wrapper_test.go index 1fcbe2f854..fdaa92d2c3 100644 --- a/internal/exec/terraform_output_authcontext_wrapper_test.go +++ b/internal/exec/terraform_output_authcontext_wrapper_test.go @@ -52,3 +52,49 @@ func TestAuthContextWrapperGetStackInfo(t *testing.T) { assert.Equal(t, "test-identity", stackInfo.AuthContext.AWS.Profile) assert.Equal(t, "us-west-2", stackInfo.AuthContext.AWS.Region) } + +// TestAuthContextWrapperResolvePrincipalSetting verifies ResolvePrincipalSetting returns nil, false. +// The wrapper doesn't have access to identity/provider configuration, only auth context. +func TestAuthContextWrapperResolvePrincipalSetting(t *testing.T) { + authContext := &schema.AuthContext{ + AWS: &schema.AWSAuthContext{ + Profile: "test-identity", + Region: "us-west-2", + }, + } + + wrapper := newAuthContextWrapper(authContext) + + // ResolvePrincipalSetting should always return nil, false for the wrapper. + // It only propagates existing auth context, not full identity configuration. + val, found := wrapper.ResolvePrincipalSetting("any-identity", "region") + assert.Nil(t, val, "ResolvePrincipalSetting should return nil") + assert.False(t, found, "ResolvePrincipalSetting should return false") + + val, found = wrapper.ResolvePrincipalSetting("test-identity", "any-key") + assert.Nil(t, val, "ResolvePrincipalSetting should return nil for any key") + assert.False(t, found, "ResolvePrincipalSetting should return false for any key") +} + +// TestAuthContextWrapperResolveProviderConfig verifies ResolveProviderConfig returns nil, false. +// The wrapper doesn't have access to provider configuration. +func TestAuthContextWrapperResolveProviderConfig(t *testing.T) { + authContext := &schema.AuthContext{ + AWS: &schema.AWSAuthContext{ + Profile: "test-identity", + Region: "us-west-2", + }, + } + + wrapper := newAuthContextWrapper(authContext) + + // ResolveProviderConfig should always return nil, false for the wrapper. + // It only propagates existing auth context, not provider configuration. + provider, found := wrapper.ResolveProviderConfig("any-identity") + assert.Nil(t, provider, "ResolveProviderConfig should return nil") + assert.False(t, found, "ResolveProviderConfig should return false") + + provider, found = wrapper.ResolveProviderConfig("test-identity") + assert.Nil(t, provider, "ResolveProviderConfig should return nil for any identity") + assert.False(t, found, "ResolveProviderConfig should return false for any identity") +} diff --git a/internal/exec/terraform_output_utils.go b/internal/exec/terraform_output_utils.go index bca58ef5af..06ae46df32 100644 --- a/internal/exec/terraform_output_utils.go +++ b/internal/exec/terraform_output_utils.go @@ -160,6 +160,22 @@ func (a *authContextWrapper) GetIntegration(integrationName string) (*schema.Int panic("authContextWrapper.GetIntegration should not be called") } +func (a *authContextWrapper) ResolvePrincipalSetting(identityName, key string) (interface{}, bool) { + defer perf.Track(nil, "exec.authContextWrapper.ResolvePrincipalSetting")() + + // Return false - this wrapper doesn't have access to identity/provider configuration. + // It only propagates existing auth context for nested component resolution. + return nil, false +} + +func (a *authContextWrapper) ResolveProviderConfig(identityName string) (*schema.Provider, bool) { + defer perf.Track(nil, "exec.authContextWrapper.ResolveProviderConfig")() + + // Return false - this wrapper doesn't have access to provider configuration. + // It only propagates existing auth context for nested component resolution. + return nil, false +} + // newAuthContextWrapper creates an AuthManager wrapper that returns the given AuthContext. func newAuthContextWrapper(authContext *schema.AuthContext) *authContextWrapper { if authContext == nil { diff --git a/pkg/auth/hooks_test.go b/pkg/auth/hooks_test.go index fea2d8a13e..b55a8563be 100644 --- a/pkg/auth/hooks_test.go +++ b/pkg/auth/hooks_test.go @@ -119,6 +119,14 @@ func (s *stubAuthManager) GetIntegration(integrationName string) (*schema.Integr return nil, nil } +func (s *stubAuthManager) ResolvePrincipalSetting(identityName, key string) (interface{}, bool) { + return nil, false +} + +func (s *stubAuthManager) ResolveProviderConfig(identityName string) (*schema.Provider, bool) { + return nil, false +} + func TestGetConfigLogLevels(t *testing.T) { tests := []struct { name string diff --git a/pkg/auth/identities/aws/assume_role.go b/pkg/auth/identities/aws/assume_role.go index 44380577e3..ee49b0dd37 100644 --- a/pkg/auth/identities/aws/assume_role.go +++ b/pkg/auth/identities/aws/assume_role.go @@ -379,6 +379,13 @@ func (i *assumeRoleIdentity) Environment() (map[string]string, error) { env[envVar.Key] = envVar.Value } + // Resolve region through identity chain inheritance. + // First checks identity principal, then parent identities, then provider. + if region := i.resolveRegion(); region != "" { + env["AWS_REGION"] = region + env["AWS_DEFAULT_REGION"] = region + } + // Add environment variables from identity config. for _, envVar := range i.config.Env { env[envVar.Key] = envVar.Value @@ -413,8 +420,8 @@ func (i *assumeRoleIdentity) PrepareEnvironment(ctx context.Context, environ map credentialsFile := awsFileManager.GetCredentialsPath(providerName) configFile := awsFileManager.GetConfigPath(providerName) - // Get region from identity if available. - region := i.region + // Resolve region through identity chain inheritance. + region := i.resolveRegion() // Use shared AWS environment preparation helper. return awsCloud.PrepareEnvironment(environ, i.name, credentialsFile, configFile, region), nil @@ -467,6 +474,36 @@ func (i *assumeRoleIdentity) getRootProviderFromVia() (string, error) { return "", fmt.Errorf("%w: cannot determine root provider for identity %q before authentication", errUtils.ErrInvalidAuthConfig, i.name) } +// resolveRegion resolves the AWS region by traversing the identity chain. +// First checks identity chain for region setting, then falls back to provider's region. +// This uses the manager's generic chain resolution methods to support inheritance. +func (i *assumeRoleIdentity) resolveRegion() string { + // If manager is not available, fall back to direct config check or cached region. + if i.manager == nil { + if i.region != "" { + return i.region + } + if region, ok := i.config.Principal["region"].(string); ok && region != "" { + return region + } + return "" + } + + // First check identity chain for region setting. + if val, ok := i.manager.ResolvePrincipalSetting(i.name, "region"); ok { + if region, ok := val.(string); ok && region != "" { + return region + } + } + + // Fall back to provider's region. + if provider, ok := i.manager.ResolveProviderConfig(i.name); ok { + return provider.Region + } + + return "" +} + // SetManagerAndProvider sets the manager and root provider name on the identity. // This is used when loading cached credentials to allow the identity to resolve provider information. func (i *assumeRoleIdentity) SetManagerAndProvider(manager types.AuthManager, rootProviderName string) { diff --git a/pkg/auth/identities/aws/assume_role_test.go b/pkg/auth/identities/aws/assume_role_test.go index 1da6ed61c4..6f1a695b1f 100644 --- a/pkg/auth/identities/aws/assume_role_test.go +++ b/pkg/auth/identities/aws/assume_role_test.go @@ -84,6 +84,44 @@ func TestAssumeRoleIdentity_Environment(t *testing.T) { assert.Equal(t, "role", env["AWS_PROFILE"]) } +func TestAssumeRoleIdentity_Environment_WithRegion(t *testing.T) { + // When region is set on the identity, Environment should include AWS_REGION and AWS_DEFAULT_REGION. + i := &assumeRoleIdentity{ + name: "role", + region: "eu-west-1", + config: &schema.Identity{ + Kind: "aws/assume-role", + Principal: map[string]any{"assume_role": "arn:aws:iam::123:role/x"}, + Via: &schema.IdentityVia{Provider: "test-provider"}, + }, + } + env, err := i.Environment() + assert.NoError(t, err) + // Should include region vars when explicitly configured. + assert.Equal(t, "eu-west-1", env["AWS_REGION"]) + assert.Equal(t, "eu-west-1", env["AWS_DEFAULT_REGION"]) +} + +func TestAssumeRoleIdentity_Environment_WithoutRegion(t *testing.T) { + // When region is NOT set, Environment should NOT include AWS_REGION (no default fallback). + i := &assumeRoleIdentity{ + name: "role", + region: "", // No region set + config: &schema.Identity{ + Kind: "aws/assume-role", + Principal: map[string]any{"assume_role": "arn:aws:iam::123:role/x"}, + Via: &schema.IdentityVia{Provider: "test-provider"}, + }, + } + env, err := i.Environment() + assert.NoError(t, err) + // Should NOT include region vars when not explicitly configured. + _, hasRegion := env["AWS_REGION"] + _, hasDefaultRegion := env["AWS_DEFAULT_REGION"] + assert.False(t, hasRegion, "AWS_REGION should not be set when region is not explicitly configured") + assert.False(t, hasDefaultRegion, "AWS_DEFAULT_REGION should not be set when region is not explicitly configured") +} + func TestAssumeRoleIdentity_BuildAssumeRoleInput(t *testing.T) { // External ID and duration should be set when provided. i := &assumeRoleIdentity{name: "role", config: &schema.Identity{ @@ -1066,6 +1104,236 @@ func TestAssumeRoleIdentity_assumeRoleWithWebIdentity_IgnoresAmbientCredentials( assert.NotContains(t, err.Error(), "i/o timeout") } +func TestAssumeRoleIdentity_resolveRegion_ManagerNil_CachedRegion(t *testing.T) { + // When manager is nil and i.region is set, should return cached region. + i := &assumeRoleIdentity{ + name: "test-role", + region: "eu-west-1", + config: &schema.Identity{ + Kind: "aws/assume-role", + Principal: map[string]any{"assume_role": "arn:aws:iam::123:role/x"}, + }, + } + + region := i.resolveRegion() + assert.Equal(t, "eu-west-1", region) +} + +func TestAssumeRoleIdentity_resolveRegion_ManagerNil_FromPrincipal(t *testing.T) { + // When manager is nil and i.region is empty, should check config.Principal["region"]. + i := &assumeRoleIdentity{ + name: "test-role", + region: "", // No cached region. + config: &schema.Identity{ + Kind: "aws/assume-role", + Principal: map[string]any{ + "assume_role": "arn:aws:iam::123:role/x", + "region": "ap-southeast-1", + }, + }, + } + + region := i.resolveRegion() + assert.Equal(t, "ap-southeast-1", region) +} + +func TestAssumeRoleIdentity_resolveRegion_ManagerNil_NoRegion(t *testing.T) { + // When manager is nil and no region configured, should return empty string. + i := &assumeRoleIdentity{ + name: "test-role", + region: "", + config: &schema.Identity{ + Kind: "aws/assume-role", + Principal: map[string]any{"assume_role": "arn:aws:iam::123:role/x"}, + }, + } + + region := i.resolveRegion() + assert.Equal(t, "", region) +} + +func TestAssumeRoleIdentity_resolveRegion_WithManager_FromPrincipalSetting(t *testing.T) { + // When manager exists and ResolvePrincipalSetting returns region, use it. + mockManager := &mockResolveAuthManager{ + principalSettings: map[string]map[string]interface{}{ + "test-role": {"region": "us-west-2"}, + }, + } + + i := &assumeRoleIdentity{ + name: "test-role", + manager: mockManager, + config: &schema.Identity{ + Kind: "aws/assume-role", + Principal: map[string]any{"assume_role": "arn:aws:iam::123:role/x"}, + }, + } + + region := i.resolveRegion() + assert.Equal(t, "us-west-2", region) +} + +func TestAssumeRoleIdentity_resolveRegion_WithManager_FromProviderConfig(t *testing.T) { + // When manager exists, ResolvePrincipalSetting returns empty, use provider's region. + mockManager := &mockResolveAuthManager{ + principalSettings: map[string]map[string]interface{}{}, + providerConfigs: map[string]*schema.Provider{ + "test-role": {Region: "ca-central-1"}, + }, + } + + i := &assumeRoleIdentity{ + name: "test-role", + manager: mockManager, + config: &schema.Identity{ + Kind: "aws/assume-role", + Principal: map[string]any{"assume_role": "arn:aws:iam::123:role/x"}, + }, + } + + region := i.resolveRegion() + assert.Equal(t, "ca-central-1", region) +} + +func TestAssumeRoleIdentity_resolveRegion_WithManager_NeitherHasRegion(t *testing.T) { + // When manager exists but neither principal nor provider has region. + mockManager := &mockResolveAuthManager{ + principalSettings: map[string]map[string]interface{}{}, + providerConfigs: map[string]*schema.Provider{}, + } + + i := &assumeRoleIdentity{ + name: "test-role", + manager: mockManager, + config: &schema.Identity{ + Kind: "aws/assume-role", + Principal: map[string]any{"assume_role": "arn:aws:iam::123:role/x"}, + }, + } + + region := i.resolveRegion() + assert.Equal(t, "", region) +} + +func TestAssumeRoleIdentity_resolveRegion_WithManager_NonStringPrincipalSetting(t *testing.T) { + // When ResolvePrincipalSetting returns non-string value, skip it. + mockManager := &mockResolveAuthManager{ + principalSettings: map[string]map[string]interface{}{ + "test-role": {"region": 12345}, // Non-string value. + }, + providerConfigs: map[string]*schema.Provider{ + "test-role": {Region: "fallback-region"}, + }, + } + + i := &assumeRoleIdentity{ + name: "test-role", + manager: mockManager, + config: &schema.Identity{ + Kind: "aws/assume-role", + Principal: map[string]any{"assume_role": "arn:aws:iam::123:role/x"}, + }, + } + + region := i.resolveRegion() + assert.Equal(t, "fallback-region", region) +} + +func TestAssumeRoleIdentity_resolveRegion_WithManager_EmptyStringPrincipalSetting(t *testing.T) { + // When ResolvePrincipalSetting returns empty string, fall back to provider. + mockManager := &mockResolveAuthManager{ + principalSettings: map[string]map[string]interface{}{ + "test-role": {"region": ""}, // Empty string. + }, + providerConfigs: map[string]*schema.Provider{ + "test-role": {Region: "fallback-region"}, + }, + } + + i := &assumeRoleIdentity{ + name: "test-role", + manager: mockManager, + config: &schema.Identity{ + Kind: "aws/assume-role", + Principal: map[string]any{"assume_role": "arn:aws:iam::123:role/x"}, + }, + } + + region := i.resolveRegion() + assert.Equal(t, "fallback-region", region) +} + +// mockResolveAuthManager implements types.AuthManager for testing resolveRegion. +type mockResolveAuthManager struct { + principalSettings map[string]map[string]interface{} + providerConfigs map[string]*schema.Provider +} + +func (m *mockResolveAuthManager) ResolvePrincipalSetting(identityName, key string) (interface{}, bool) { + if settings, ok := m.principalSettings[identityName]; ok { + if val, ok := settings[key]; ok { + return val, true + } + } + return nil, false +} + +func (m *mockResolveAuthManager) ResolveProviderConfig(identityName string) (*schema.Provider, bool) { + if provider, ok := m.providerConfigs[identityName]; ok { + return provider, true + } + return nil, false +} + +// Implement other AuthManager methods as no-ops for the mock. +func (m *mockResolveAuthManager) GetProviderForIdentity(_ string) string { return "" } + +func (m *mockResolveAuthManager) GetCachedCredentials(_ context.Context, _ string) (*types.WhoamiInfo, error) { + return nil, nil +} + +func (m *mockResolveAuthManager) Authenticate(_ context.Context, _ string) (*types.WhoamiInfo, error) { + return nil, nil +} + +func (m *mockResolveAuthManager) AuthenticateProvider(_ context.Context, _ string) (*types.WhoamiInfo, error) { + return nil, nil +} + +func (m *mockResolveAuthManager) Whoami(_ context.Context, _ string) (*types.WhoamiInfo, error) { + return nil, nil +} +func (m *mockResolveAuthManager) Validate() error { return nil } +func (m *mockResolveAuthManager) GetDefaultIdentity(_ bool) (string, error) { return "", nil } +func (m *mockResolveAuthManager) ListIdentities() []string { return nil } +func (m *mockResolveAuthManager) GetFilesDisplayPath(_ string) string { return "" } +func (m *mockResolveAuthManager) GetProviderKindForIdentity(_ string) (string, error) { return "", nil } +func (m *mockResolveAuthManager) GetChain() []string { return nil } +func (m *mockResolveAuthManager) GetStackInfo() *schema.ConfigAndStacksInfo { return nil } +func (m *mockResolveAuthManager) ListProviders() []string { return nil } +func (m *mockResolveAuthManager) GetIdentities() map[string]schema.Identity { return nil } +func (m *mockResolveAuthManager) GetProviders() map[string]schema.Provider { return nil } +func (m *mockResolveAuthManager) Logout(_ context.Context, _ string, _ bool) error { return nil } +func (m *mockResolveAuthManager) LogoutProvider(_ context.Context, _ string, _ bool) error { + return nil +} +func (m *mockResolveAuthManager) LogoutAll(_ context.Context, _ bool) error { return nil } +func (m *mockResolveAuthManager) GetEnvironmentVariables(_ string) (map[string]string, error) { + return nil, nil +} + +func (m *mockResolveAuthManager) PrepareShellEnvironment(_ context.Context, _ string, _ []string) ([]string, error) { + return nil, nil +} + +func (m *mockResolveAuthManager) ExecuteIdentityIntegrations(_ context.Context, _ string) error { + return nil +} +func (m *mockResolveAuthManager) ExecuteIntegration(_ context.Context, _ string) error { return nil } +func (m *mockResolveAuthManager) GetIntegration(_ string) (*schema.Integration, error) { + return nil, nil +} + func TestAssumeRoleIdentity_WebIdentityVsStandardAssumeRole_DifferentCredentialHandling(t *testing.T) { // This test documents the difference between standard AssumeRole and AssumeRoleWithWebIdentity // credential handling. Standard AssumeRole REQUIRES base AWS credentials, while web identity diff --git a/pkg/auth/identities/aws/assume_root.go b/pkg/auth/identities/aws/assume_root.go index fa42ff3ccd..cbfca81147 100644 --- a/pkg/auth/identities/aws/assume_root.go +++ b/pkg/auth/identities/aws/assume_root.go @@ -307,6 +307,13 @@ func (i *assumeRootIdentity) Environment() (map[string]string, error) { env[envVar.Key] = envVar.Value } + // Resolve region through identity chain inheritance. + // First checks identity principal, then parent identities, then provider. + if region := i.resolveRegion(); region != "" { + env["AWS_REGION"] = region + env["AWS_DEFAULT_REGION"] = region + } + // Add environment variables from identity config. for _, envVar := range i.config.Env { env[envVar.Key] = envVar.Value @@ -339,8 +346,8 @@ func (i *assumeRootIdentity) PrepareEnvironment(ctx context.Context, environ map credentialsFile := awsFileManager.GetCredentialsPath(providerName) configFile := awsFileManager.GetConfigPath(providerName) - // Get region from identity if available. - region := i.region + // Resolve region through identity chain inheritance. + region := i.resolveRegion() // Use shared AWS environment preparation helper. return awsCloud.PrepareEnvironment(environ, i.name, credentialsFile, configFile, region), nil @@ -386,6 +393,36 @@ func (i *assumeRootIdentity) getRootProviderFromVia() (string, error) { return "", fmt.Errorf("%w: cannot determine root provider for identity %q before authentication", errUtils.ErrInvalidAuthConfig, i.name) } +// resolveRegion resolves the AWS region by traversing the identity chain. +// First checks identity chain for region setting, then falls back to provider's region. +// This uses the manager's generic chain resolution methods to support inheritance. +func (i *assumeRootIdentity) resolveRegion() string { + // If manager is not available, fall back to direct config check or cached region. + if i.manager == nil { + if i.region != "" { + return i.region + } + if region, ok := i.config.Principal["region"].(string); ok && region != "" { + return region + } + return "" + } + + // First check identity chain for region setting. + if val, ok := i.manager.ResolvePrincipalSetting(i.name, "region"); ok { + if region, ok := val.(string); ok && region != "" { + return region + } + } + + // Fall back to provider's region. + if provider, ok := i.manager.ResolveProviderConfig(i.name); ok { + return provider.Region + } + + return "" +} + // SetManagerAndProvider sets the manager and root provider name on the identity. func (i *assumeRootIdentity) SetManagerAndProvider(manager types.AuthManager, rootProviderName string) { i.manager = manager diff --git a/pkg/auth/identities/aws/assume_root_test.go b/pkg/auth/identities/aws/assume_root_test.go index 8e1f3ae572..09d0eac65d 100644 --- a/pkg/auth/identities/aws/assume_root_test.go +++ b/pkg/auth/identities/aws/assume_root_test.go @@ -446,6 +446,52 @@ func TestAssumeRootIdentity_Environment(t *testing.T) { assert.Equal(t, "test-root", env["AWS_PROFILE"]) } +func TestAssumeRootIdentity_Environment_WithRegion(t *testing.T) { + // When region is set on the identity, Environment should include AWS_REGION and AWS_DEFAULT_REGION. + i := &assumeRootIdentity{ + name: "test-root", + region: "ca-central-1", + config: &schema.Identity{ + Kind: "aws/assume-root", + Principal: map[string]any{ + "target_principal": "123456789012", + "task_policy_arn": "arn:aws:iam::aws:policy/root-task/IAMAuditRootUserCredentials", + }, + Via: &schema.IdentityVia{Provider: "test-provider"}, + }, + } + + env, err := i.Environment() + assert.NoError(t, err) + // Should include region vars when explicitly configured. + assert.Equal(t, "ca-central-1", env["AWS_REGION"]) + assert.Equal(t, "ca-central-1", env["AWS_DEFAULT_REGION"]) +} + +func TestAssumeRootIdentity_Environment_WithoutRegion(t *testing.T) { + // When region is NOT set, Environment should NOT include AWS_REGION (no default fallback). + i := &assumeRootIdentity{ + name: "test-root", + region: "", // No region set + config: &schema.Identity{ + Kind: "aws/assume-root", + Principal: map[string]any{ + "target_principal": "123456789012", + "task_policy_arn": "arn:aws:iam::aws:policy/root-task/IAMAuditRootUserCredentials", + }, + Via: &schema.IdentityVia{Provider: "test-provider"}, + }, + } + + env, err := i.Environment() + assert.NoError(t, err) + // Should NOT include region vars when not explicitly configured. + _, hasRegion := env["AWS_REGION"] + _, hasDefaultRegion := env["AWS_DEFAULT_REGION"] + assert.False(t, hasRegion, "AWS_REGION should not be set when region is not explicitly configured") + assert.False(t, hasDefaultRegion, "AWS_DEFAULT_REGION should not be set when region is not explicitly configured") +} + func TestAssumeRootIdentity_PostAuthenticate(t *testing.T) { t.Setenv("HOME", t.TempDir()) @@ -1423,6 +1469,14 @@ func (m *mockAuthManager) GetIntegration(_ string) (*schema.Integration, error) return nil, nil } +func (m *mockAuthManager) ResolvePrincipalSetting(_ string, _ string) (interface{}, bool) { + return nil, false +} + +func (m *mockAuthManager) ResolveProviderConfig(_ string) (*schema.Provider, bool) { + return nil, false +} + func TestAssumeRootIdentity_CredentialsExist_ProviderResolutionError(t *testing.T) { // Test when we can't resolve the provider name. i := &assumeRootIdentity{ @@ -1612,6 +1666,188 @@ func TestAssumeRootIdentity_Environment_WithEnvFromConfig(t *testing.T) { assert.Equal(t, "value2", env["CUSTOM_VAR2"]) } +func TestAssumeRootIdentity_resolveRegion_ManagerNil_CachedRegion(t *testing.T) { + // When manager is nil and i.region is set, should return cached region. + i := &assumeRootIdentity{ + name: "test-root", + region: "eu-west-1", + config: &schema.Identity{ + Kind: "aws/assume-root", + Principal: map[string]any{ + "target_principal": "123456789012", + "task_policy_arn": "arn:aws:iam::aws:policy/root-task/IAMAuditRootUserCredentials", + }, + }, + } + + region := i.resolveRegion() + assert.Equal(t, "eu-west-1", region) +} + +func TestAssumeRootIdentity_resolveRegion_ManagerNil_FromPrincipal(t *testing.T) { + // When manager is nil and i.region is empty, should check config.Principal["region"]. + i := &assumeRootIdentity{ + name: "test-root", + region: "", // No cached region. + config: &schema.Identity{ + Kind: "aws/assume-root", + Principal: map[string]any{ + "target_principal": "123456789012", + "task_policy_arn": "arn:aws:iam::aws:policy/root-task/IAMAuditRootUserCredentials", + "region": "ap-southeast-1", + }, + }, + } + + region := i.resolveRegion() + assert.Equal(t, "ap-southeast-1", region) +} + +func TestAssumeRootIdentity_resolveRegion_ManagerNil_NoRegion(t *testing.T) { + // When manager is nil and no region configured, should return empty string. + i := &assumeRootIdentity{ + name: "test-root", + region: "", + config: &schema.Identity{ + Kind: "aws/assume-root", + Principal: map[string]any{ + "target_principal": "123456789012", + "task_policy_arn": "arn:aws:iam::aws:policy/root-task/IAMAuditRootUserCredentials", + }, + }, + } + + region := i.resolveRegion() + assert.Equal(t, "", region) +} + +func TestAssumeRootIdentity_resolveRegion_WithManager_FromPrincipalSetting(t *testing.T) { + // When manager exists and ResolvePrincipalSetting returns region, use it. + // Note: mockResolveAuthManager is defined in assume_role_test.go. + mockManager := &mockResolveAuthManager{ + principalSettings: map[string]map[string]interface{}{ + "test-root": {"region": "us-west-2"}, + }, + } + + i := &assumeRootIdentity{ + name: "test-root", + manager: mockManager, + config: &schema.Identity{ + Kind: "aws/assume-root", + Principal: map[string]any{ + "target_principal": "123456789012", + "task_policy_arn": "arn:aws:iam::aws:policy/root-task/IAMAuditRootUserCredentials", + }, + }, + } + + region := i.resolveRegion() + assert.Equal(t, "us-west-2", region) +} + +func TestAssumeRootIdentity_resolveRegion_WithManager_FromProviderConfig(t *testing.T) { + // When manager exists, ResolvePrincipalSetting returns empty, use provider's region. + mockManager := &mockResolveAuthManager{ + principalSettings: map[string]map[string]interface{}{}, + providerConfigs: map[string]*schema.Provider{ + "test-root": {Region: "ca-central-1"}, + }, + } + + i := &assumeRootIdentity{ + name: "test-root", + manager: mockManager, + config: &schema.Identity{ + Kind: "aws/assume-root", + Principal: map[string]any{ + "target_principal": "123456789012", + "task_policy_arn": "arn:aws:iam::aws:policy/root-task/IAMAuditRootUserCredentials", + }, + }, + } + + region := i.resolveRegion() + assert.Equal(t, "ca-central-1", region) +} + +func TestAssumeRootIdentity_resolveRegion_WithManager_NeitherHasRegion(t *testing.T) { + // When manager exists but neither principal nor provider has region. + mockManager := &mockResolveAuthManager{ + principalSettings: map[string]map[string]interface{}{}, + providerConfigs: map[string]*schema.Provider{}, + } + + i := &assumeRootIdentity{ + name: "test-root", + manager: mockManager, + config: &schema.Identity{ + Kind: "aws/assume-root", + Principal: map[string]any{ + "target_principal": "123456789012", + "task_policy_arn": "arn:aws:iam::aws:policy/root-task/IAMAuditRootUserCredentials", + }, + }, + } + + region := i.resolveRegion() + assert.Equal(t, "", region) +} + +func TestAssumeRootIdentity_resolveRegion_WithManager_NonStringPrincipalSetting(t *testing.T) { + // When ResolvePrincipalSetting returns non-string value, skip it. + mockManager := &mockResolveAuthManager{ + principalSettings: map[string]map[string]interface{}{ + "test-root": {"region": 12345}, // Non-string value. + }, + providerConfigs: map[string]*schema.Provider{ + "test-root": {Region: "fallback-region"}, + }, + } + + i := &assumeRootIdentity{ + name: "test-root", + manager: mockManager, + config: &schema.Identity{ + Kind: "aws/assume-root", + Principal: map[string]any{ + "target_principal": "123456789012", + "task_policy_arn": "arn:aws:iam::aws:policy/root-task/IAMAuditRootUserCredentials", + }, + }, + } + + region := i.resolveRegion() + assert.Equal(t, "fallback-region", region) +} + +func TestAssumeRootIdentity_resolveRegion_WithManager_EmptyStringPrincipalSetting(t *testing.T) { + // When ResolvePrincipalSetting returns empty string, fall back to provider. + mockManager := &mockResolveAuthManager{ + principalSettings: map[string]map[string]interface{}{ + "test-root": {"region": ""}, // Empty string. + }, + providerConfigs: map[string]*schema.Provider{ + "test-root": {Region: "fallback-region"}, + }, + } + + i := &assumeRootIdentity{ + name: "test-root", + manager: mockManager, + config: &schema.Identity{ + Kind: "aws/assume-root", + Principal: map[string]any{ + "target_principal": "123456789012", + "task_policy_arn": "arn:aws:iam::aws:policy/root-task/IAMAuditRootUserCredentials", + }, + }, + } + + region := i.resolveRegion() + assert.Equal(t, "fallback-region", region) +} + func TestAssumeRootIdentity_Logout_ViaProvider(t *testing.T) { tmpDir := t.TempDir() t.Setenv("ATMOS_XDG_CONFIG_HOME", tmpDir) diff --git a/pkg/auth/identities/aws/permission_set.go b/pkg/auth/identities/aws/permission_set.go index d1e8a7ed42..901f46aff6 100644 --- a/pkg/auth/identities/aws/permission_set.go +++ b/pkg/auth/identities/aws/permission_set.go @@ -186,6 +186,13 @@ func (i *permissionSetIdentity) Environment() (map[string]string, error) { } } + // Resolve region through identity chain inheritance. + // First checks identity principal, then parent identities, then provider. + if region := i.resolveRegion(); region != "" { + env["AWS_REGION"] = region + env["AWS_DEFAULT_REGION"] = region + } + // Add environment variables from identity config. for _, envVar := range i.config.Env { env[envVar.Key] = envVar.Value @@ -220,13 +227,8 @@ func (i *permissionSetIdentity) PrepareEnvironment(ctx context.Context, environ credentialsFile := awsFileManager.GetCredentialsPath(providerName) configFile := awsFileManager.GetConfigPath(providerName) - // Get region from identity config if available. - region := "" - if i.config.Principal != nil { - if r, ok := i.config.Principal["region"].(string); ok { - region = r - } - } + // Resolve region through identity chain inheritance. + region := i.resolveRegion() // Use shared AWS environment preparation helper. return awsCloud.PrepareEnvironment(environ, i.name, credentialsFile, configFile, region), nil @@ -254,6 +256,33 @@ func (i *permissionSetIdentity) resolveRootProviderName() (string, error) { return i.getRootProviderFromVia() } +// resolveRegion resolves the AWS region by traversing the identity chain. +// First checks identity chain for region setting, then falls back to provider's region. +// This uses the manager's generic chain resolution methods to support inheritance. +func (i *permissionSetIdentity) resolveRegion() string { + // If manager is not available, fall back to direct config check. + if i.manager == nil { + if region, ok := i.config.Principal["region"].(string); ok && region != "" { + return region + } + return "" + } + + // First check identity chain for region setting. + if val, ok := i.manager.ResolvePrincipalSetting(i.name, "region"); ok { + if region, ok := val.(string); ok && region != "" { + return region + } + } + + // Fall back to provider's region. + if provider, ok := i.manager.ResolveProviderConfig(i.name); ok { + return provider.Region + } + + return "" +} + // getRootProviderFromVia gets the root provider name using available information. // This is used when manager is not available (e.g., LoadCredentials before PostAuthenticate). // Only returns the cached value from PostAuthenticate. Does NOT fall back to via.provider diff --git a/pkg/auth/identities/aws/permission_set_extended_test.go b/pkg/auth/identities/aws/permission_set_extended_test.go index ef77512b64..e07b2d9c70 100644 --- a/pkg/auth/identities/aws/permission_set_extended_test.go +++ b/pkg/auth/identities/aws/permission_set_extended_test.go @@ -34,6 +34,144 @@ func TestPermissionSetIdentity_Environment_NoProvider(t *testing.T) { assert.Equal(t, "custom_value", env["CUSTOM_VAR"]) } +func TestPermissionSetIdentity_Environment_WithRegion(t *testing.T) { + // When region is explicitly configured in principal, Environment should include AWS_REGION and AWS_DEFAULT_REGION. + identity, err := NewPermissionSetIdentity("test-ps", &schema.Identity{ + Kind: "aws/permission-set", + Principal: map[string]interface{}{ + "name": "DevAccess", + "account": map[string]interface{}{"id": "123456789012"}, + "region": "ap-southeast-1", + }, + }) + require.NoError(t, err) + + env, err := identity.Environment() + assert.NoError(t, err) + // Should include region vars when explicitly configured. + assert.Equal(t, "ap-southeast-1", env["AWS_REGION"]) + assert.Equal(t, "ap-southeast-1", env["AWS_DEFAULT_REGION"]) +} + +func TestPermissionSetIdentity_Environment_WithoutRegion(t *testing.T) { + // When region is NOT configured, Environment should NOT include AWS_REGION (no default fallback). + identity, err := NewPermissionSetIdentity("test-ps", &schema.Identity{ + Kind: "aws/permission-set", + Principal: map[string]interface{}{ + "name": "DevAccess", + "account": map[string]interface{}{"id": "123456789012"}, + }, + }) + require.NoError(t, err) + + env, err := identity.Environment() + assert.NoError(t, err) + // Should NOT include region vars when not explicitly configured. + _, hasRegion := env["AWS_REGION"] + _, hasDefaultRegion := env["AWS_DEFAULT_REGION"] + assert.False(t, hasRegion, "AWS_REGION should not be set when region is not explicitly configured") + assert.False(t, hasDefaultRegion, "AWS_DEFAULT_REGION should not be set when region is not explicitly configured") +} + +// mockManagerForRegion implements the manager interface methods needed for region resolution. +type mockManagerForRegion struct { + types.AuthManager + principalSettings map[string]map[string]interface{} + providerConfig *schema.Provider + providerName string +} + +func (m *mockManagerForRegion) GetProviderForIdentity(_ string) string { + return m.providerName +} + +func (m *mockManagerForRegion) ResolvePrincipalSetting(identityName, key string) (interface{}, bool) { + if m.principalSettings != nil { + if settings, ok := m.principalSettings[identityName]; ok { + if val, ok := settings[key]; ok { + return val, true + } + } + } + return nil, false +} + +func (m *mockManagerForRegion) ResolveProviderConfig(_ string) (*schema.Provider, bool) { + if m.providerConfig != nil { + return m.providerConfig, true + } + return nil, false +} + +func TestPermissionSetIdentity_Environment_InheritsProviderRegion(t *testing.T) { + // When identity has NO region but provider HAS region, identity should inherit provider's region. + // This is the user's specific problem scenario: region only at provider level. + identity, err := NewPermissionSetIdentity("test-ps", &schema.Identity{ + Kind: "aws/permission-set", + Principal: map[string]interface{}{ + "name": "DevAccess", + "account": map[string]interface{}{"id": "123456789012"}, + // NOTE: No "region" field here - that's the key to this test. + }, + }) + require.NoError(t, err) + + // Create mock manager that returns provider with region. + mockMgr := &mockManagerForRegion{ + providerName: "aws-sso", + principalSettings: nil, // No region in identity chain. + providerConfig: &schema.Provider{ + Region: "us-west-2", // Provider has region. + }, + } + + // Set the manager on the identity. + psIdentity := identity.(*permissionSetIdentity) + psIdentity.manager = mockMgr + + env, err := identity.Environment() + assert.NoError(t, err) + + // Should inherit region from provider. + assert.Equal(t, "us-west-2", env["AWS_REGION"], "AWS_REGION should inherit from provider when not set on identity") + assert.Equal(t, "us-west-2", env["AWS_DEFAULT_REGION"], "AWS_DEFAULT_REGION should inherit from provider when not set on identity") +} + +func TestPermissionSetIdentity_Environment_IdentityRegionOverridesProvider(t *testing.T) { + // When identity HAS region AND provider HAS region, identity's region should win. + identity, err := NewPermissionSetIdentity("test-ps", &schema.Identity{ + Kind: "aws/permission-set", + Principal: map[string]interface{}{ + "name": "DevAccess", + "account": map[string]interface{}{"id": "123456789012"}, + "region": "eu-west-1", // Identity has region. + }, + }) + require.NoError(t, err) + + // Create mock manager that returns both identity and provider region. + mockMgr := &mockManagerForRegion{ + providerName: "aws-sso", + principalSettings: map[string]map[string]interface{}{ + "test-ps": {"region": "eu-west-1"}, // Identity has region in chain. + }, + providerConfig: &schema.Provider{ + Region: "us-west-2", // Provider also has region. + }, + } + + // Set the manager on the identity. + psIdentity := identity.(*permissionSetIdentity) + psIdentity.manager = mockMgr + + env, err := identity.Environment() + assert.NoError(t, err) + + // Identity region should take precedence over provider region. + assert.Equal(t, "eu-west-1", env["AWS_REGION"], "AWS_REGION should use identity's region, not provider's") + assert.Equal(t, "eu-west-1", env["AWS_DEFAULT_REGION"], "AWS_DEFAULT_REGION should use identity's region, not provider's") +} + func TestPermissionSetIdentity_PrepareEnvironment_NoProvider(t *testing.T) { identity, err := NewPermissionSetIdentity("test-ps", &schema.Identity{ Kind: "aws/permission-set", diff --git a/pkg/auth/identities/aws/user.go b/pkg/auth/identities/aws/user.go index 3fcda1a386..63259a6f23 100644 --- a/pkg/auth/identities/aws/user.go +++ b/pkg/auth/identities/aws/user.go @@ -656,6 +656,13 @@ func (i *userIdentity) Environment() (map[string]string, error) { env[envVar.Key] = envVar.Value } + // Include region ONLY if explicitly configured (not default fallback). + // This enables users to reference AWS_REGION via !env in stack configurations. + if r, ok := i.config.Credentials["region"].(string); ok && r != "" { + env["AWS_REGION"] = r + env["AWS_DEFAULT_REGION"] = r + } + // Add environment variables from identity config. for _, envVar := range i.config.Env { env[envVar.Key] = envVar.Value diff --git a/pkg/auth/identities/aws/user_test.go b/pkg/auth/identities/aws/user_test.go index f48df9c996..ec369185a5 100644 --- a/pkg/auth/identities/aws/user_test.go +++ b/pkg/auth/identities/aws/user_test.go @@ -55,6 +55,35 @@ func TestUserIdentity_Environment(t *testing.T) { assert.Equal(t, "BAR", env["FOO"]) } +func TestUserIdentity_Environment_WithRegion(t *testing.T) { + // When region is explicitly configured, Environment should include AWS_REGION and AWS_DEFAULT_REGION. + id, err := NewUserIdentity("dev", &schema.Identity{ + Kind: "aws/user", + Credentials: map[string]any{"region": "us-west-2"}, + }) + require.NoError(t, err) + env, err := id.Environment() + require.NoError(t, err) + + // Should include region vars when explicitly configured. + assert.Equal(t, "us-west-2", env["AWS_REGION"]) + assert.Equal(t, "us-west-2", env["AWS_DEFAULT_REGION"]) +} + +func TestUserIdentity_Environment_WithoutRegion(t *testing.T) { + // When region is NOT configured, Environment should NOT include AWS_REGION (no default fallback). + id, err := NewUserIdentity("dev", &schema.Identity{Kind: "aws/user"}) + require.NoError(t, err) + env, err := id.Environment() + require.NoError(t, err) + + // Should NOT include region vars when not explicitly configured. + _, hasRegion := env["AWS_REGION"] + _, hasDefaultRegion := env["AWS_DEFAULT_REGION"] + assert.False(t, hasRegion, "AWS_REGION should not be set when region is not explicitly configured") + assert.False(t, hasDefaultRegion, "AWS_DEFAULT_REGION should not be set when region is not explicitly configured") +} + func TestIsStandaloneAWSUserChain(t *testing.T) { // Not standalone when multiple elements. assert.False(t, IsStandaloneAWSUserChain([]string{"p", "dev"}, map[string]schema.Identity{"dev": {Kind: "aws/user"}})) diff --git a/pkg/auth/manager.go b/pkg/auth/manager.go index a1f5b34bb1..fd93632cee 100644 --- a/pkg/auth/manager.go +++ b/pkg/auth/manager.go @@ -713,3 +713,56 @@ func (m *manager) GetProviders() map[string]schema.Provider { func (m *manager) GetConfig() *schema.ConfigAndStacksInfo { return m.stackInfo } + +// ResolvePrincipalSetting traverses the identity chain and returns the first +// non-empty value for the given key in Principal configuration. +// The chain is traversed from the target identity backwards through parent identities. +// This is a provider-agnostic mechanism for inheriting settings through the chain. +func (m *manager) ResolvePrincipalSetting(identityName, key string) (interface{}, bool) { + defer perf.Track(nil, "auth.Manager.ResolvePrincipalSetting")() + + chain, err := m.buildAuthenticationChain(identityName) + if err != nil || len(chain) == 0 { + return nil, false + } + + // Walk chain backwards: current identity β†’ parents (skip index 0 which is provider). + for i := len(chain) - 1; i >= 1; i-- { + identity, exists := m.config.Identities[chain[i]] + if !exists { + continue + } + val, ok := identity.Principal[key] + if !ok || val == nil { + continue + } + // Use type assertion to safely handle string values. + // For strings, skip empty values to allow inheritance from parent. + // For non-string types (maps, etc.), any non-nil value is valid. + if s, isString := val.(string); isString { + if s == "" { + continue + } + return s, true + } + return val, true + } + return nil, false +} + +// ResolveProviderConfig returns the provider configuration at the root of +// the identity's authentication chain. +// This allows identities to access provider-level settings without knowing +// the specific provider name. +func (m *manager) ResolveProviderConfig(identityName string) (*schema.Provider, bool) { + defer perf.Track(nil, "auth.Manager.ResolveProviderConfig")() + + providerName := m.GetProviderForIdentity(identityName) + if providerName == "" { + return nil, false + } + if provider, exists := m.config.Providers[providerName]; exists { + return &provider, true + } + return nil, false +} diff --git a/pkg/auth/manager_test.go b/pkg/auth/manager_test.go index a482b45eba..505b5b127a 100644 --- a/pkg/auth/manager_test.go +++ b/pkg/auth/manager_test.go @@ -1960,3 +1960,187 @@ func TestManager_AuthenticateProvider_AuthenticationFailure(t *testing.T) { assert.Error(t, err) assert.ErrorIs(t, err, errUtils.ErrAuthenticationFailed) } + +func TestManager_ResolvePrincipalSetting(t *testing.T) { + tests := []struct { + name string + identities map[string]schema.Identity + targetIdentity string + key string + expectedValue interface{} + expectedFound bool + }{ + { + name: "returns value from current identity", + identities: map[string]schema.Identity{ + "dev": { + Kind: "aws/permission-set", + Via: &schema.IdentityVia{Provider: "sso"}, + Principal: map[string]any{"region": "us-west-2"}, + }, + }, + targetIdentity: "dev", + key: "region", + expectedValue: "us-west-2", + expectedFound: true, + }, + { + name: "returns value from parent identity when not set on current", + identities: map[string]schema.Identity{ + "parent": { + Kind: "aws/permission-set", + Via: &schema.IdentityVia{Provider: "sso"}, + Principal: map[string]any{"region": "eu-west-1"}, + }, + "child": { + Kind: "aws/assume-role", + Via: &schema.IdentityVia{Identity: "parent"}, + Principal: map[string]any{"assume_role": "arn:aws:iam::123:role/test"}, + }, + }, + targetIdentity: "child", + key: "region", + expectedValue: "eu-west-1", + expectedFound: true, + }, + { + name: "current identity overrides parent", + identities: map[string]schema.Identity{ + "parent": { + Kind: "aws/permission-set", + Via: &schema.IdentityVia{Provider: "sso"}, + Principal: map[string]any{"region": "eu-west-1"}, + }, + "child": { + Kind: "aws/assume-role", + Via: &schema.IdentityVia{Identity: "parent"}, + Principal: map[string]any{"assume_role": "arn:aws:iam::123:role/test", "region": "ap-south-1"}, + }, + }, + targetIdentity: "child", + key: "region", + expectedValue: "ap-south-1", + expectedFound: true, + }, + { + name: "returns false when key not found in chain", + identities: map[string]schema.Identity{ + "dev": { + Kind: "aws/permission-set", + Via: &schema.IdentityVia{Provider: "sso"}, + Principal: map[string]any{"name": "dev"}, + }, + }, + targetIdentity: "dev", + key: "region", + expectedValue: nil, + expectedFound: false, + }, + { + name: "returns false for non-existent identity", + identities: map[string]schema.Identity{}, + targetIdentity: "nonexistent", + key: "region", + expectedValue: nil, + expectedFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authConfig := &schema.AuthConfig{ + Providers: map[string]schema.Provider{ + "sso": {Kind: "aws/iam-identity-center", Region: "us-east-1", StartURL: "https://example.awsapps.com/start"}, + }, + Identities: tt.identities, + } + + m := &manager{ + config: authConfig, + providers: make(map[string]types.Provider), + identities: make(map[string]types.Identity), + credentialStore: &testStore{}, + } + + val, found := m.ResolvePrincipalSetting(tt.targetIdentity, tt.key) + assert.Equal(t, tt.expectedFound, found) + assert.Equal(t, tt.expectedValue, val) + }) + } +} + +func TestManager_ResolveProviderConfig(t *testing.T) { + tests := []struct { + name string + providers map[string]schema.Provider + identities map[string]schema.Identity + targetIdentity string + expectedRegion string + expectedFound bool + }{ + { + name: "returns provider for direct identity", + providers: map[string]schema.Provider{ + "sso": {Kind: "aws/iam-identity-center", Region: "us-west-2", StartURL: "https://example.awsapps.com/start"}, + }, + identities: map[string]schema.Identity{ + "dev": { + Kind: "aws/permission-set", + Via: &schema.IdentityVia{Provider: "sso"}, + }, + }, + targetIdentity: "dev", + expectedRegion: "us-west-2", + expectedFound: true, + }, + { + name: "returns provider for chained identity", + providers: map[string]schema.Provider{ + "sso": {Kind: "aws/iam-identity-center", Region: "eu-central-1", StartURL: "https://example.awsapps.com/start"}, + }, + identities: map[string]schema.Identity{ + "parent": { + Kind: "aws/permission-set", + Via: &schema.IdentityVia{Provider: "sso"}, + }, + "child": { + Kind: "aws/assume-role", + Via: &schema.IdentityVia{Identity: "parent"}, + }, + }, + targetIdentity: "child", + expectedRegion: "eu-central-1", + expectedFound: true, + }, + { + name: "returns false for non-existent identity", + providers: map[string]schema.Provider{}, + identities: map[string]schema.Identity{}, + targetIdentity: "nonexistent", + expectedRegion: "", + expectedFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + authConfig := &schema.AuthConfig{ + Providers: tt.providers, + Identities: tt.identities, + } + + m := &manager{ + config: authConfig, + providers: make(map[string]types.Provider), + identities: make(map[string]types.Identity), + credentialStore: &testStore{}, + } + + provider, found := m.ResolveProviderConfig(tt.targetIdentity) + assert.Equal(t, tt.expectedFound, found) + if found { + assert.Equal(t, tt.expectedRegion, provider.Region) + } + }) + } +} diff --git a/pkg/auth/providers/aws/saml_test.go b/pkg/auth/providers/aws/saml_test.go index 22420e9899..26a1f8ba5d 100644 --- a/pkg/auth/providers/aws/saml_test.go +++ b/pkg/auth/providers/aws/saml_test.go @@ -188,6 +188,14 @@ func (s stubSamlMgr) GetIntegration(string) (*schema.Integration, error) { return nil, nil } +func (s stubSamlMgr) ResolvePrincipalSetting(string, string) (interface{}, bool) { + return nil, false +} + +func (s stubSamlMgr) ResolveProviderConfig(string) (*schema.Provider, bool) { + return nil, false +} + func TestSAMLProvider_PreAuthenticate(t *testing.T) { p, err := NewSAMLProvider("p", &schema.Provider{Kind: "aws/saml", URL: "https://idp.example.com/saml", Region: "us-east-1"}) require.NoError(t, err) diff --git a/pkg/auth/types/interfaces.go b/pkg/auth/types/interfaces.go index f7d32a5e20..8c9677de5b 100644 --- a/pkg/auth/types/interfaces.go +++ b/pkg/auth/types/interfaces.go @@ -292,6 +292,20 @@ type AuthManager interface { // GetIntegration returns the integration config by name. GetIntegration(integrationName string) (*schema.Integration, error) + + // ResolvePrincipalSetting traverses the identity chain and returns the first + // non-empty value for the given key in Principal configuration. + // The chain is traversed from the target identity backwards through parent identities. + // This is a provider-agnostic mechanism for inheriting settings through the chain. + // Returns the value and true if found, nil and false otherwise. + ResolvePrincipalSetting(identityName, key string) (interface{}, bool) + + // ResolveProviderConfig returns the provider configuration at the root of + // the identity's authentication chain. + // This allows identities to access provider-level settings without knowing + // the specific provider name. + // Returns the provider config and true if found, nil and false otherwise. + ResolveProviderConfig(identityName string) (*schema.Provider, bool) } // CredentialStore defines the interface for storing and retrieving credentials. diff --git a/pkg/auth/types/mock_interfaces.go b/pkg/auth/types/mock_interfaces.go index d8df533bee..db12ee096e 100644 --- a/pkg/auth/types/mock_interfaces.go +++ b/pkg/auth/types/mock_interfaces.go @@ -736,6 +736,36 @@ func (mr *MockAuthManagerMockRecorder) PrepareShellEnvironment(ctx, identityName return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PrepareShellEnvironment", reflect.TypeOf((*MockAuthManager)(nil).PrepareShellEnvironment), ctx, identityName, currentEnv) } +// ResolvePrincipalSetting mocks base method. +func (m *MockAuthManager) ResolvePrincipalSetting(identityName, key string) (any, bool) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolvePrincipalSetting", identityName, key) + ret0, _ := ret[0].(any) + ret1, _ := ret[1].(bool) + return ret0, ret1 +} + +// ResolvePrincipalSetting indicates an expected call of ResolvePrincipalSetting. +func (mr *MockAuthManagerMockRecorder) ResolvePrincipalSetting(identityName, key any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolvePrincipalSetting", reflect.TypeOf((*MockAuthManager)(nil).ResolvePrincipalSetting), identityName, key) +} + +// ResolveProviderConfig mocks base method. +func (m *MockAuthManager) ResolveProviderConfig(identityName string) (*schema.Provider, bool) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ResolveProviderConfig", identityName) + ret0, _ := ret[0].(*schema.Provider) + ret1, _ := ret[1].(bool) + return ret0, ret1 +} + +// ResolveProviderConfig indicates an expected call of ResolveProviderConfig. +func (mr *MockAuthManagerMockRecorder) ResolveProviderConfig(identityName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResolveProviderConfig", reflect.TypeOf((*MockAuthManager)(nil).ResolveProviderConfig), identityName) +} + // Validate mocks base method. func (m *MockAuthManager) Validate() error { m.ctrl.T.Helper() diff --git a/tests/fixtures/scenarios/yaml-functions-in-lists/stacks/test-yaml-functions.yaml b/tests/fixtures/scenarios/yaml-functions-in-lists/stacks/test-yaml-functions.yaml index 0b665889ba..6ae2256e14 100644 --- a/tests/fixtures/scenarios/yaml-functions-in-lists/stacks/test-yaml-functions.yaml +++ b/tests/fixtures/scenarios/yaml-functions-in-lists/stacks/test-yaml-functions.yaml @@ -74,6 +74,10 @@ components: - !terraform.state test-component-2 test test_string - !env TEST_ENV_VAR + # Test case for auth env region export feature + # When region is exported via 'atmos auth env', it should be accessible via !env AWS_REGION + aws_region: !env AWS_REGION + # Test case 4: Map with YAML functions function_map_results: from_component_1: !terraform.output test-component-1 test test_string diff --git a/tests/yaml_functions_integration_test.go b/tests/yaml_functions_integration_test.go index 78a62e7196..0634af62e0 100644 --- a/tests/yaml_functions_integration_test.go +++ b/tests/yaml_functions_integration_test.go @@ -18,11 +18,40 @@ func TestYAMLFunctionsInLists(t *testing.T) { t.Chdir("./fixtures/scenarios/yaml-functions-in-lists") t.Setenv("TEST_ENV_VAR", "test-env-value") + // Set AWS_REGION to test the auth env region export feature + // This simulates what 'atmos auth env' would export when region is configured + t.Setenv("AWS_REGION", "us-west-2") atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, true) require.NoError(t, err) require.NotNil(t, atmosConfig) + t.Run("stack config with AWS_REGION env reference loads successfully", func(t *testing.T) { + // This test verifies that stack configurations can reference AWS_REGION + // using !env AWS_REGION without causing parsing errors. + // Note: !env functions are resolved at execution time (terraform apply), + // not during describe. This test ensures the stack with !env AWS_REGION loads. + componentName := "test-yaml-functions-in-lists" + stack := "test" + + componentSection, err := e.ExecuteDescribeComponent( + &e.ExecuteDescribeComponentParams{ + Component: componentName, + Stack: stack, + }, + ) + + require.NoError(t, err) + require.NotNil(t, componentSection) + + vars, ok := componentSection["vars"].(map[string]interface{}) + require.True(t, ok, "vars should be a map") + + // Verify aws_region key exists in the stack config + // The value will be !env AWS_REGION (deferred until execution) + assert.Contains(t, vars, "aws_region", "aws_region should be present in vars") + }) + t.Run("loads stack with yaml functions in lists", func(t *testing.T) { // Test that we can describe a component that uses YAML functions in lists componentName := "test-yaml-functions-in-lists" diff --git a/website/blog/2026-01-12-auth-env-region-export.mdx b/website/blog/2026-01-12-auth-env-region-export.mdx new file mode 100644 index 0000000000..3154efb619 --- /dev/null +++ b/website/blog/2026-01-12-auth-env-region-export.mdx @@ -0,0 +1,55 @@ +--- +slug: auth-env-region-export +title: "AWS Region Now Exported by atmos auth env" +authors: [osterman] +tags: [enhancement] +--- + +The `atmos auth env` command now exports `AWS_REGION` and `AWS_DEFAULT_REGION` +when region is configured in your identity settings. + + + +## What Changed + +The `atmos auth env` command exports AWS credentials to your shell for use with +**external tools** (AWS CLI, direct terraform runs, etc.). Previously it only +exported credential file paths: +- `AWS_SHARED_CREDENTIALS_FILE` +- `AWS_CONFIG_FILE` +- `AWS_PROFILE` + +Now it also exports region when configured: +- `AWS_REGION` +- `AWS_DEFAULT_REGION` + +## When You Need This + +Use `atmos auth env` when you want to run external tools that need AWS credentials +exported to the shell: + +```bash +# Export auth environment for external tools +eval $(atmos auth env --identity my-identity) + +# Now external tools have access to AWS credentials and region +aws s3 ls +terraform plan # running terraform directly, not via atmos +``` + +## When You Don't Need This + +For atmos commands (`atmos terraform plan`, `atmos terraform apply`, etc.), +region is **automatically injected** - no sourcing required. The `!env AWS_REGION` +YAML function works automatically in stack configurations when using atmos commands. + +## Important Notes + +- Region is only exported when **explicitly configured** in your identity or provider +- No default region is assumed - if you don't configure region, it won't be exported +- This is purely additive and doesn't break existing scripts + +## Get Involved + +Found an issue or have a feature request? +[Open an issue on GitHub](https://github.com/cloudposse/atmos/issues). diff --git a/website/docs/cli/commands/auth/auth-login.mdx b/website/docs/cli/commands/auth/auth-login.mdx index 26d8745e2f..6838e34aec 100644 --- a/website/docs/cli/commands/auth/auth-login.mdx +++ b/website/docs/cli/commands/auth/auth-login.mdx @@ -98,9 +98,13 @@ The interactive selector displays all configured identities with arrow key navig - Configure a default identity in your `atmos.yaml` - Explicitly specify the identity using `--identity ` or environment variable -## Integrations (ECR, EKS) +## Integrations (ECR) -When you authenticate with an identity, Atmos automatically triggers any **integrations** linked to that identity (when `auto_provision` is enabled, which is the default). Integrations provide client-only credential materializations for services like ECR and EKS. +When you authenticate with an identity, Atmos automatically triggers any **integrations** linked to that identity (when `auto_provision` is enabled, which is the default). Integrations provide client-only credential materializations for services like ECR. + +:::note EKS Integration Coming Soon +EKS integration is planned but not yet implemented. Currently only ECR integrations are supported. +::: ```yaml auth: diff --git a/website/docs/cli/commands/auth/console.mdx b/website/docs/cli/commands/auth/console.mdx index f13e188b2f..3c2b843fc2 100644 --- a/website/docs/cli/commands/auth/console.mdx +++ b/website/docs/cli/commands/auth/console.mdx @@ -234,9 +234,9 @@ For AWS identities, Atmos uses the [AWS Federation Endpoint](https://docs.aws.am Console signin tokens are valid for 15 minutes and should be treated as sensitive. Never share console URLs or paste them in logs or chat applications. ::: -### Azure and GCP (Coming Soon) +### GCP (Coming Soon) -Support for Azure Portal and Google Cloud Console is planned for future releases. The command structure will remain the same across all providers. +Support for Google Cloud Console is planned for future releases. The command structure will remain the same across all providers. ## Provider Support @@ -244,7 +244,7 @@ Support for Azure Portal and Google Cloud Console is planned for future releases |----------|--------|-------| | AWS (IAM Identity Center) | βœ… Supported | Full support with federation endpoint | | AWS (SAML) | βœ… Supported | Full support with federation endpoint | -| Azure | 🚧 Planned | Coming in future release | +| Azure | βœ… Supported | Opens Azure Portal with configured subscription | | GCP | 🚧 Planned | Coming in future release | ## Common Use Cases @@ -303,7 +303,7 @@ atmos auth console --print-only | xclip # Linux **Problem**: The authenticated identity's provider doesn't support console access yet. -**Solution**: Check the Provider Support table above. Azure and GCP support is coming soon. +**Solution**: Check the Provider Support table above. Azure is supported; GCP support is planned for a future release. ## Configuration diff --git a/website/docs/cli/commands/profile/profile-list.mdx b/website/docs/cli/commands/profile/profile-list.mdx index c5d3e54a8c..7ee4259a4a 100644 --- a/website/docs/cli/commands/profile/profile-list.mdx +++ b/website/docs/cli/commands/profile/profile-list.mdx @@ -33,7 +33,6 @@ Profiles are discovered from multiple locations in precedence order: The output includes: - Profile name -- Profile type (inline or directory) - Source location - Description (if available) @@ -60,14 +59,14 @@ atmos profile list **Example output:** ``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ NAME β”‚ TYPE β”‚ SOURCE β”‚ DESCRIPTION β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ developer β”‚ directory β”‚ profiles/developer β”‚ Development environment β”‚ -β”‚ ci β”‚ directory β”‚ profiles/ci β”‚ CI/CD pipeline settings β”‚ -β”‚ production β”‚ inline β”‚ atmos.yaml β”‚ Production configuration β”‚ -β”‚ debug β”‚ directory β”‚ ~/.config/atmos/profiles β”‚ Debug logging enabled β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ NAME β”‚ SOURCE β”‚ DESCRIPTION β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ developer β”‚ profiles/developer β”‚ Development environment β”‚ +β”‚ ci β”‚ profiles/ci β”‚ CI/CD pipeline settings β”‚ +β”‚ production β”‚ profiles/production β”‚ Production configuration β”‚ +β”‚ debug β”‚ ~/.config/atmos/profiles/debug β”‚ Debug logging enabled β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` ### Output as JSON @@ -83,13 +82,11 @@ atmos profile list --format json [ { "name": "developer", - "type": "directory", "source": "profiles/developer", "description": "Development environment" }, { "name": "ci", - "type": "directory", "source": "profiles/ci", "description": "CI/CD pipeline settings" } @@ -107,11 +104,9 @@ atmos profile list --format yaml ```yaml - name: developer - type: directory source: profiles/developer description: Development environment - name: ci - type: directory source: profiles/ci description: CI/CD pipeline settings ``` diff --git a/website/docs/cli/commands/profile/profile-show.mdx b/website/docs/cli/commands/profile/profile-show.mdx index 19aa2831bf..85536da9cc 100644 --- a/website/docs/cli/commands/profile/profile-show.mdx +++ b/website/docs/cli/commands/profile/profile-show.mdx @@ -36,7 +36,7 @@ atmos profile show [flags] Show detailed information about a specific configuration profile. The output includes: -- **Profile location and type**: Where the profile is defined and whether it's inline or directory-based +- **Profile location**: Where the profile directory is located - **Metadata**: Name, description, version, and tags - **Configuration files**: List of YAML files that make up the profile - **Usage instructions**: How to activate the profile @@ -68,7 +68,6 @@ Profile: developer ══════════════════════════════════════════════════════════════════════════════ Location: profiles/developer -Type: directory Description: Development environment with debug logging Metadata @@ -99,7 +98,6 @@ atmos profile show developer --format json ```json { "name": "developer", - "type": "directory", "source": "profiles/developer", "description": "Development environment with debug logging", "version": "1.0.0", @@ -122,7 +120,6 @@ atmos profile show developer --format yaml ```yaml name: developer -type: directory source: profiles/developer description: Development environment with debug logging version: "1.0.0" @@ -135,37 +132,6 @@ files: - auth.yaml ``` -### Show Inline Profile - -```bash -# Show an inline profile defined in atmos.yaml -atmos profile show production -``` - -**Example output:** - -``` -Profile: production -══════════════════════════════════════════════════════════════════════════════ - -Location: atmos.yaml -Type: inline -Description: Production configuration - -Configuration -──────────────────────────────────────────────────────────────────────────────── - logs: - level: Warning - settings: - terminal: - color: false - -Usage -──────────────────────────────────────────────────────────────────────────────── - atmos --profile production - ATMOS_PROFILE=production atmos -``` - ## Error Handling ### Profile Not Found diff --git a/website/docs/cli/commands/profile/usage.mdx b/website/docs/cli/commands/profile/usage.mdx index 59f5646b73..3dca860c7c 100644 --- a/website/docs/cli/commands/profile/usage.mdx +++ b/website/docs/cli/commands/profile/usage.mdx @@ -37,35 +37,10 @@ Atmos discovers profiles from multiple locations in the following precedence ord 3. **XDG user directory**: `~/.config/atmos/profiles/` (or `$XDG_CONFIG_HOME/atmos/profiles/`) 4. **Project directory**: `profiles/` in your project root -Each profile can be either: -- An inline definition in `atmos.yaml` -- A separate directory containing `atmos.yaml` and other configuration files +Each profile is a directory containing `atmos.yaml` and other configuration files. ## Profile Structure -### Inline Profiles (in atmos.yaml) - -```yaml -# atmos.yaml -profiles: - developer: - logs: - level: Debug - settings: - terminal: - max_width: 120 - - ci: - logs: - level: Info - settings: - terminal: - color: false - pager: false -``` - -### Directory-Based Profiles - ``` profiles/ β”œβ”€β”€ developer/ diff --git a/website/docs/cli/configuration/auth/identities.mdx b/website/docs/cli/configuration/auth/identities.mdx index 6890f1159c..f0849eb2b9 100644 --- a/website/docs/cli/configuration/auth/identities.mdx +++ b/website/docs/cli/configuration/auth/identities.mdx @@ -93,6 +93,53 @@ auth:
Optional. Session name for CloudTrail auditing.
+## AWS Assume Root (Organizations) + +For centralized root access in AWS Organizations using sts:AssumeRoot. This allows privileged operations on member accounts without enabling root user credentials. + + +```yaml +auth: + identities: + root-audit: + kind: aws/assume-root + via: + identity: admin-base # Chain through an existing identity + principal: + target_principal: "123456789012" # 12-digit member account ID + task_policy_arn: arn:aws:iam::aws:policy/root-task/IAMAuditRootUserCredentials + duration: 15m # Optional (max 15m for AssumeRoot) +``` + + +
+
`kind`
+
**Required.** Must be `aws/assume-root`.
+ +
`via.identity`
+
**Required.** Name of another identity to chain from (must provide AWS credentials).
+ +
`principal.target_principal`
+
**Required.** 12-digit AWS account ID of the member account to assume root on.
+ +
`principal.task_policy_arn`
+
**Required.** AWS-managed root task policy ARN. See supported policies below.
+ +
`principal.duration`
+
Optional. Session duration (max 15 minutes for AssumeRoot).
+
+ +**Supported task policies:** +- `arn:aws:iam::aws:policy/root-task/IAMAuditRootUserCredentials` +- `arn:aws:iam::aws:policy/root-task/IAMCreateRootUserPassword` +- `arn:aws:iam::aws:policy/root-task/IAMDeleteRootUserCredentials` +- `arn:aws:iam::aws:policy/root-task/S3UnlockBucketPolicy` +- `arn:aws:iam::aws:policy/root-task/SQSUnlockQueuePolicy` + +:::note +AssumeRoot requires AWS Organizations with centralized root access enabled and appropriate permissions on the base identity. +::: + ## AWS User (Break-glass) For static IAM user credentials, typically used for emergency access. @@ -132,6 +179,44 @@ auth: Instead of storing credentials in configuration, use `atmos auth user configure` to store them securely in the system keyring. ::: +## Azure Subscription + +For targeting a specific Azure subscription using credentials from an Azure provider. + + +```yaml +auth: + identities: + dev-subscription: + kind: azure/subscription + via: + provider: azure-cli # Reference to an Azure provider + principal: + subscription_id: "12345678-1234-1234-1234-123456789012" + location: eastus # Optional: default location + resource_group: my-rg # Optional: default resource group +``` + + +
+
`kind`
+
**Required.** Must be `azure/subscription`.
+ +
`via.provider`
+
**Required.** Name of an Azure provider to obtain credentials from.
+ +
`principal.subscription_id`
+
**Required.** Azure subscription ID to target.
+ +
`principal.location`
+
Optional. Default Azure region for resources.
+ +
`principal.resource_group`
+
Optional. Default resource group for resources.
+
+ +The Azure subscription identity inherits credentials from the provider and sets subscription-specific environment variables (`AZURE_SUBSCRIPTION_ID`, `ARM_SUBSCRIPTION_ID`, etc.) for Terraform and other Azure tools. + ## Identity Chaining Identity chaining allows you to create complex authentication flows where one identity is used to obtain another. @@ -253,57 +338,72 @@ Only one identity should be marked as default. If multiple identities have `defa ## Using Profiles for Role-Based Access -Use [Atmos profiles](/cli/configuration/profiles) to define different identity configurations for various team roles: +Use [Atmos profiles](/cli/configuration/profiles) to define different identity configurations for various team roles. Each profile is a **directory** containing YAML configuration files. - +**Profile structure:** +``` +profiles/ +β”œβ”€β”€ developer/ +β”‚ └── auth.yaml # Developer identity config +β”œβ”€β”€ platform/ +β”‚ └── auth.yaml # Platform engineer identity config +└── ci/ + └── auth.yaml # CI/CD identity config +``` + + +```yaml +# Profile for developers - limited to dev/staging +auth: + identities: + dev-access: + kind: aws/permission-set + default: true + via: + provider: company-sso + principal: + name: DeveloperAccess + account: + name: development +``` + + + +```yaml +# Profile for DevOps/Platform engineers - cross-account access +auth: + identities: + platform-admin: + kind: aws/permission-set + default: true + via: + provider: company-sso + principal: + name: AdminAccess + account: + name: core-identity + + prod-deploy: + kind: aws/assume-role + via: + identity: platform-admin + principal: + assume_role: arn:aws:iam::999999999999:role/ProductionDeploy +``` + + + ```yaml -profiles: - # Profile for developers - limited to dev/staging - developer: - auth: - identities: - dev-access: - kind: aws/permission-set - default: true - via: - provider: company-sso - principal: - name: DeveloperAccess - account: - name: development - - # Profile for DevOps/Platform engineers - cross-account access - platform: - auth: - identities: - platform-admin: - kind: aws/permission-set - default: true - via: - provider: company-sso - principal: - name: AdminAccess - account: - name: core-identity - - prod-deploy: - kind: aws/assume-role - via: - identity: platform-admin - principal: - assume_role: arn:aws:iam::999999999999:role/ProductionDeploy - - # Profile for CI/CD pipelines - ci: - auth: - identities: - github-deploy: - kind: aws/assume-role - default: true - via: - provider: github-oidc - principal: - assume_role: arn:aws:iam::123456789012:role/GitHubActionsRole +# Profile for CI/CD pipelines +auth: + identities: + github-deploy: + kind: aws/assume-role + default: true + via: + provider: github-oidc + principal: + assume_role: arn:aws:iam::123456789012:role/GitHubActionsRole ``` diff --git a/website/docs/cli/configuration/auth/index.mdx b/website/docs/cli/configuration/auth/index.mdx index 8d9b6c5127..fac42cb793 100644 --- a/website/docs/cli/configuration/auth/index.mdx +++ b/website/docs/cli/configuration/auth/index.mdx @@ -179,57 +179,69 @@ auth: ## Using Profiles -Use [Atmos profiles](/cli/configuration/profiles) to define different authentication configurations for various use cases: +Use [Atmos profiles](/cli/configuration/profiles) to define different authentication configurations for various use cases. Each profile is a **directory** containing YAML files. - +**Profile structure:** +``` +profiles/ +β”œβ”€β”€ developer/ +β”‚ └── auth.yaml # Developer auth config +β”œβ”€β”€ ci/ +β”‚ └── auth.yaml # CI/CD auth config +└── platform/ + └── auth.yaml # Platform engineer auth config +``` + + +```yaml +auth: + identities: + dev-access: + kind: aws/permission-set + default: true + via: + provider: company-sso + principal: + name: DeveloperAccess + account: + name: development +``` + + + +```yaml +auth: + providers: + github-oidc: + kind: github/oidc + region: us-east-1 + identities: + deploy: + kind: aws/assume-role + default: true + via: + provider: github-oidc + principal: + assume_role: arn:aws:iam::123456789012:role/GitHubActionsRole +``` + + + ```yaml -profiles: - # Profile for developers - dev: - auth: - identities: - dev-access: - kind: aws/permission-set - default: true - via: - provider: company-sso - principal: - name: DeveloperAccess - account: - name: development - - # Profile for CI/CD - ci: - auth: - providers: - github-oidc: - kind: github/oidc - region: us-east-1 - identities: - deploy: - kind: aws/assume-role - default: true - via: - provider: github-oidc - principal: - assume_role: arn:aws:iam::123456789012:role/GitHubActionsRole - - # Profile for platform engineers - platform: - auth: - providers: - company-sso: - kind: aws/iam-identity-center - region: us-east-1 - start_url: https://company.awsapps.com/start - session: - duration: 8h +auth: + providers: + company-sso: + kind: aws/iam-identity-center + region: us-east-1 + start_url: https://company.awsapps.com/start + session: + duration: 8h ``` ```bash # Activate a profile -atmos --profile dev terraform plan myapp -s dev +atmos --profile developer terraform plan myapp -s dev ATMOS_PROFILE=ci atmos terraform apply myapp -s prod ``` diff --git a/website/docs/cli/configuration/auth/providers.mdx b/website/docs/cli/configuration/auth/providers.mdx index 4512458377..5be6cd9098 100644 --- a/website/docs/cli/configuration/auth/providers.mdx +++ b/website/docs/cli/configuration/auth/providers.mdx @@ -35,9 +35,6 @@ auth: # Automatically discover all accounts and permission sets auto_provision_identities: true - # Optional: include AWS tags from permission sets as labels - include_tags: true - # Optional session configuration session: duration: 4h # Credential lifetime @@ -60,9 +57,6 @@ auth:
`auto_provision_identities`
Optional. When `true`, Atmos automatically discovers all AWS accounts and permission sets assigned to the user and creates identities for them. This eliminates the need to manually configure each identity. Default: `false`.
-
`include_tags`
-
Optional. When `true` (and `auto_provision_identities` is enabled), Atmos retrieves AWS tags from permission sets and includes them as labels on the auto-provisioned identities. Requires additional IAM permissions. Default: `false`.
-
`session.duration`
Optional. Session duration for CLI credentials (e.g., `1h`, `4h`). Default varies by provider.
@@ -101,42 +95,6 @@ These permissions are required for automatic identity provisioning to work: - `sso:ListAccounts` - Enumerates all AWS accounts the user can access - `sso:ListAccountRoles` - Lists available permission sets (roles) for each account -#### Tag/Label Support (Optional) - -If you enable tag discovery (`include_tags: true` in provider configuration), additional permissions are required: - -```json -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "sso:ListAccounts", - "sso:ListAccountRoles" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": [ - "sso:ListInstances", - "sso:DescribePermissionSet", - "sso:ListPermissionSets", - "sso:ListTagsForResource" - ], - "Resource": "*" - } - ] -} -``` - -**Additional APIs Used:** -- `sso:ListInstances` - Finds the SSO instance ARN -- `sso:DescribePermissionSet` - Gets permission set details -- `sso:ListPermissionSets` - Finds permission set ARNs -- `sso:ListTagsForResource` - Retrieves AWS tags from permission sets - #### How to Apply Permissions These permissions should be attached to the IAM Identity Center permission set that users authenticate with, or to the IAM user/role if using static credentials. Without these permissions, identity provisioning will fail gracefully and fall back to manually configured identities. @@ -155,7 +113,6 @@ auth: kind: aws/saml region: us-east-1 url: https://company.okta.com/app/amazon_aws/abc123/sso/saml - idp_arn: arn:aws:iam::123456789012:saml-provider/okta-saml driver: Browser # Browser, GoogleApps, Okta, or ADFS ```
@@ -170,9 +127,6 @@ auth:
`url`
**Required.** SAML SSO URL from your identity provider.
-
`idp_arn`
-
**Required.** ARN of the SAML provider in IAM.
-
`driver`
Optional. Authentication method: `Browser` (default, requires Playwright), `GoogleApps`, `Okta`, or `ADFS`.
@@ -227,7 +181,6 @@ auth: kind: aws/saml region: us-east-1 url: https://company.okta.com/app/amazon_aws/abc123/sso/saml - idp_arn: arn:aws:iam::123456789012:saml-provider/okta # GitHub OIDC for CI/CD github-oidc: diff --git a/website/docs/cli/configuration/profiles.mdx b/website/docs/cli/configuration/profiles.mdx index 421e148407..4f87d8ecc9 100644 --- a/website/docs/cli/configuration/profiles.mdx +++ b/website/docs/cli/configuration/profiles.mdx @@ -18,7 +18,7 @@ Profiles allow you to define named sets of configuration overrides that can be a - Define environment-specific configuration overrides - Switch contexts with a single flag or environment variable -- Support for inline profiles in `atmos.yaml` or directory-based profiles +- Define profiles as directories containing configuration files - Multiple profiles can be activated and merged together diff --git a/website/src/data/roadmap.js b/website/src/data/roadmap.js index 0d51f9dc71..cd0b2c54e5 100644 --- a/website/src/data/roadmap.js +++ b/website/src/data/roadmap.js @@ -138,7 +138,7 @@ export const roadmapConfig = { tagline: 'Replace a dozen auth tools with one identity layer', description: 'The way humans login with SSO is different from how automation systems authenticate with OIDC. Yet most teams implement this with fragmented approaches. Atmos brings authentication into the core with native support for identity profiles configurable by runtime.', - progress: 83, + progress: 85, status: 'in-progress', milestones: [ { label: 'Added `atmos auth` command framework', status: 'shipped', quarter: 'q2-2025', docs: '/cli/commands/auth/usage', changelog: 'introducing-atmos-auth', version: 'v1.196.0', description: 'Unified command for managing authentication across cloud providers and CI systems.', benefits: 'One command replaces aws-vault, saml2aws, gcloud auth, and azure login. Credentials are managed consistently across all providers.' }, @@ -147,6 +147,7 @@ export const roadmapConfig = { { label: 'Assume Root capability', status: 'shipped', quarter: 'q4-2025', docs: '/cli/configuration/auth/providers', changelog: 'aws-assume-root-identity', description: 'Centralized root access management for organizations that need controlled root-level operations.', benefits: 'Auditable root access for break-glass scenarios. No shared root credentials or password managers.' }, { label: 'GitHub OIDC', status: 'shipped', quarter: 'q3-2025', docs: '/cli/configuration/auth/providers', changelog: 'introducing-atmos-auth', version: 'v1.196.0', description: 'Native GitHub Actions OIDC integration for secure, secretless CI/CD authentication to AWS.', benefits: 'No AWS access keys in GitHub secrets. Credentials are scoped to the repository and workflow.' }, { label: 'Azure AD / Workload Identity Federation', status: 'shipped', quarter: 'q3-2025', docs: '/cli/configuration/auth/providers', changelog: 'azure-authentication-support', version: 'v1.199.0', description: 'Authenticate to Azure using Entra ID (Azure AD) with support for Workload Identity Federation for CI/CD.', benefits: 'Multi-cloud teams use the same auth patterns for AWS and Azure without separate tooling.' }, + { label: 'Azure Portal console access', status: 'shipped', quarter: 'q4-2025', pr: 1894, docs: '/cli/commands/auth/console', changelog: 'azure-authentication-support', description: 'Open Azure Portal in your browser using authenticated Atmos identity credentials.', benefits: 'Access Azure Portal with one command. No manual login or credential copying required.' }, { label: 'SAML Provider', status: 'shipped', quarter: 'q3-2025', docs: '/cli/configuration/auth/providers', changelog: 'introducing-atmos-auth', version: 'v1.196.0', description: 'Enterprise SAML-based authentication for organizations using identity providers like Okta or OneLogin.', benefits: 'Enterprises can enforce their existing IdP policies without custom scripts or third-party tools.' }, { label: 'Keyring backends (system, file, memory)', status: 'shipped', quarter: 'q3-2025', docs: '/cli/configuration/auth/keyring', changelog: 'flexible-keyring-backends', version: 'v1.196.0', description: 'Flexible credential storage with system keychain integration, encrypted file storage, or in-memory sessions.', benefits: 'Credentials are stored securely based on your environmentβ€”macOS Keychain, Linux secret-tool, or encrypted files.' }, { label: 'Component-level auth identities', status: 'shipped', quarter: 'q3-2025', docs: '/cli/configuration/auth/identities', changelog: 'authentication-for-workflows-and-custom-commands', version: 'v1.197.0', description: 'Define different AWS identities per component, enabling multi-account deployments from a single workflow.', benefits: 'Deploy to prod, staging, and dev accounts in a single workflow without switching credentials.' }, @@ -157,6 +158,7 @@ export const roadmapConfig = { { label: 'Seamless first login with provider fallback', status: 'shipped', quarter: 'q4-2025', pr: 1918, changelog: 'auth-login-provider-fallback', description: 'Automatic provider fallback when no identities are configured, enabling seamless first-time login with auto_provision_identities.', benefits: 'Just run atmos auth login on first use. No need to know about --provider flag.' }, { label: 'Automatic EKS kubeconfig tied to identities', status: 'in-progress', quarter: 'q4-2025', pr: 1884, description: 'Automatic kubeconfig generation for EKS clusters using Atmos-managed AWS credentials.', benefits: 'No aws eks update-kubeconfig commands. Kubectl works immediately after Atmos auth.' }, { label: 'Automatic ECR authentication tied to identities', status: 'shipped', quarter: 'q4-2025', pr: 1859, docs: '/tutorials/ecr-authentication', changelog: 'ecr-authentication-integration', description: 'Native ECR login for container image operations without external tooling.', benefits: 'Docker push/pull to ECR works without aws ecr get-login-password or external credential helpers.' }, + { label: 'AWS_REGION and AWS_DEFAULT_REGION export from `atmos auth env`', status: 'shipped', quarter: 'q1-2026', pr: 1955, docs: '/cli/commands/auth/env', changelog: 'auth-env-region-export', description: 'Export AWS_REGION and AWS_DEFAULT_REGION environment variables from atmos auth env when region is configured in the identity.', benefits: 'External tools like Terraform and AWS CLI automatically use the correct region without additional configuration.' }, { label: 'GCP Workload Identity', status: 'planned', quarter: 'q1-2026', description: 'Google Cloud authentication using Workload Identity Federation for secretless CI/CD.', benefits: 'GCP deployments use the same secretless CI/CD pattern as AWS OIDC.' }, { label: 'Native Okta Authentication (Device Code Flow)', status: 'planned', quarter: 'q1-2026', prd: 'okta-auth-identity', description: 'Native Okta authentication using OAuth 2.0 Device Authorization Grant. Enables Okta as a central IdP for AWS, Azure, and GCP federation, plus direct Okta API access for Terraform.', benefits: 'Use Okta as your single identity hub. Authenticate once with Okta and federate to any cloud. No browser automation or SAML complexity.' }, { label: 'Support for `atmos auth` with GitHub Apps', status: 'planned', quarter: 'q1-2026', pr: 1683, description: 'GitHub App authentication for fine-grained repository access and elevated rate limits.', benefits: 'Higher API rate limits and granular permissions for automation that interacts with GitHub.' },