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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 54 additions & 7 deletions cmd/auth_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
cfg "github.com/cloudposse/atmos/pkg/config"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/profile"
"github.com/cloudposse/atmos/pkg/schema"
)

Expand Down Expand Up @@ -136,7 +137,7 @@ func executeAuthListCommand(cmd *cobra.Command, args []string) error {
}

// Load auth manager.
authManager, err := loadAuthManagerForList()
authManager, atmosConfig, err := loadAuthManagerForList(cmd)
if err != nil {
return err
}
Expand All @@ -151,6 +152,15 @@ func executeAuthListCommand(cmd *cobra.Command, args []string) error {
return err
}

// When no providers or identities are configured, check if profiles exist
// that might contain auth configuration and suggest using --profile.
// Use the unfiltered lists to avoid false positives when filters narrow results to empty.
if len(providers) == 0 && len(identities) == 0 {
if suggestion := suggestProfilesForAuth(atmosConfig); suggestion != nil {
return suggestion
}
}

// Get output format.
format, _ := cmd.Flags().GetString("format")

Expand Down Expand Up @@ -364,19 +374,56 @@ func renderYAML(providers map[string]schema.Provider, identities map[string]sche
return string(data), nil
}

// loadAuthManager loads the auth manager (helper from auth_whoami.go).
func loadAuthManagerForList() (authTypes.AuthManager, error) {
// suggestProfilesForAuth checks if profiles exist and returns a helpful error
// suggesting the user try --profile when no auth providers/identities are configured.
func suggestProfilesForAuth(atmosConfig *schema.AtmosConfiguration) error {
defer perf.Track(atmosConfig, "cmd.suggestProfilesForAuth")()

mgr := profile.NewProfileManager()
profiles, err := mgr.ListProfiles(atmosConfig)
if err != nil || len(profiles) == 0 {
return nil
}

// Collect profile names.
profileNames := make([]string, 0, len(profiles))
for _, p := range profiles {
profileNames = append(profileNames, p.Name)
}

return errUtils.Build(errUtils.ErrAuthNotConfigured).
WithExplanation("No authentication providers or identities are configured in the current configuration").
WithHintf("Available profiles: `%s`", strings.Join(profileNames, "`, `")).
WithHint("Try: `atmos auth list --profile <name>` to load auth configuration from a profile").
WithHint("Run `atmos profile list` for detailed information about each profile").
WithExitCode(1).
Err()
}

// loadAuthManagerForList loads the auth manager and returns the atmos config for profile discovery.
func loadAuthManagerForList(cmd *cobra.Command) (authTypes.AuthManager, *schema.AtmosConfiguration, error) {
defer perf.Track(nil, "cmd.loadAuthManagerForList")()

atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false)
configAndStacksInfo := schema.ConfigAndStacksInfo{}
if bp, _ := cmd.Flags().GetString("base-path"); bp != "" {
configAndStacksInfo.AtmosBasePath = bp
}
if cfgFiles, _ := cmd.Flags().GetStringSlice("config"); len(cfgFiles) > 0 {
configAndStacksInfo.AtmosConfigFilesFromArg = cfgFiles
}
if cfgDirs, _ := cmd.Flags().GetStringSlice("config-path"); len(cfgDirs) > 0 {
configAndStacksInfo.AtmosConfigDirsFromArg = cfgDirs
}

atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, false)
if err != nil {
return nil, fmt.Errorf("%w: failed to load atmos config: %w", errUtils.ErrInvalidAuthConfig, err)
return nil, nil, fmt.Errorf("%w: failed to load atmos config: %w", errUtils.ErrInvalidAuthConfig, err)
}

manager, err := createAuthManager(&atmosConfig.Auth, atmosConfig.CliConfigPath)
if err != nil {
return nil, fmt.Errorf("%w: failed to create auth manager: %w", errUtils.ErrInvalidAuthConfig, err)
return nil, nil, fmt.Errorf("%w: failed to create auth manager: %w", errUtils.ErrInvalidAuthConfig, err)
}

return manager, nil
return manager, &atmosConfig, nil
}
79 changes: 79 additions & 0 deletions cmd/auth_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ package cmd

import (
"os"
"path/filepath"
"strings"
"testing"

cockroachErrors "github.com/cockroachdb/errors"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

errUtils "github.com/cloudposse/atmos/errors"
"github.com/cloudposse/atmos/pkg/schema"
)

Expand Down Expand Up @@ -430,6 +434,81 @@ func TestProvidersFlagCompletion_ReturnsSortedProviders(t *testing.T) {
assert.Equal(t, []string{"apple-provider", "mango-provider", "zebra-provider"}, results)
}

func TestExecuteAuthListCommand_SuggestsProfilesWhenNoAuthConfigured(t *testing.T) {
// Setup: Create a temp dir with minimal atmos.yaml (no auth providers/identities)
// but with profiles that could contain auth config.
tempDir := t.TempDir()
configFile := filepath.Join(tempDir, "atmos.yaml")

configContent := `auth:
realm: test
logs:
level: Debug
`
err := os.WriteFile(configFile, []byte(configContent), 0o600)
require.NoError(t, err)

// Create profile directories (simulating profiles that may contain auth config).
for _, profileName := range []string{"developers", "devops", "superadmin"} {
profileDir := filepath.Join(tempDir, "profiles", profileName)
err := os.MkdirAll(profileDir, 0o755)
require.NoError(t, err)

// Add a minimal atmos.yaml in each profile so it's discovered.
profileConfig := filepath.Join(profileDir, "atmos.yaml")
err = os.WriteFile(profileConfig, []byte("# profile config\n"), 0o600)
require.NoError(t, err)
}

// Set environment to use test config.
t.Chdir(tempDir)

_ = NewTestKit(t)

cmd := createTestAuthListCmd()
cmd.RunE = executeAuthListCommand

// Execute the command - should return an error suggesting profiles.
err = cmd.Execute()

require.Error(t, err)
assert.ErrorIs(t, err, errUtils.ErrAuthNotConfigured)

// Verify hints contain profile names and usage suggestion.
hints := cockroachErrors.GetAllHints(err)
allHints := strings.Join(hints, " ")
assert.Contains(t, allHints, "developers")
assert.Contains(t, allHints, "devops")
assert.Contains(t, allHints, "superadmin")
assert.Contains(t, allHints, "--profile")
}

func TestExecuteAuthListCommand_NoErrorWhenNoProfilesExist(t *testing.T) {
// Setup: Create a temp dir with minimal atmos.yaml (no auth, no profiles).
tempDir := t.TempDir()
configFile := filepath.Join(tempDir, "atmos.yaml")

configContent := `auth:
realm: test
`
err := os.WriteFile(configFile, []byte(configContent), 0o600)
require.NoError(t, err)

// No profiles directory created.

t.Chdir(tempDir)

_ = NewTestKit(t)

cmd := createTestAuthListCmd()
cmd.RunE = executeAuthListCommand

// Execute the command - should NOT return an error (no profiles to suggest).
err = cmd.Execute()

assert.NoError(t, err)
}

func TestIdentitiesFlagCompletion_ReturnsSortedIdentities(t *testing.T) {
// Setup: Create a test config file with identities in non-alphabetical order.
tempDir := t.TempDir()
Expand Down
8 changes: 8 additions & 0 deletions pkg/auth/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,14 @@ func (m *manager) Authenticate(ctx context.Context, identityName string) (*types
m.triggerIntegrations(ctx, identityName, finalCreds)
}

// Clean up legacy (pre-realm) keyring and file entries to prevent realm mismatch warnings.
// This runs after successful authentication so legacy credentials remain as a fallback
// if authentication fails.
m.deleteLegacyCredentialFiles()
for _, step := range chain {
m.deleteLegacyKeyringEntry(step)
}

return m.buildWhoamiInfo(identityName, finalCreds), nil
}

Expand Down
2 changes: 2 additions & 0 deletions pkg/auth/manager_whoami.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ func (m *manager) buildWhoamiInfo(identityName string, creds types.ICredentials)
// Note: We keep info.Credentials populated for validation purposes.
// The Credentials field is marked with json:"-" yaml:"-" tags to prevent
// accidental serialization, so there's no security risk in keeping it.
// Clean up legacy (pre-realm) keyring entry to prevent realm mismatch warnings.
m.deleteLegacyKeyringEntry(identityName)
}
} else {
log.Debug("Skipping keyring cache for session tokens in WhoamiInfo", logKeyIdentity, identityName)
Expand Down
57 changes: 57 additions & 0 deletions pkg/auth/realm_mismatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,63 @@ func hasCredentialFiles(awsDir string) bool {
return false
}

// deleteLegacyKeyringEntry removes a pre-realm keyring entry after successful
// authentication. This runs in the post-success path of the Authenticate flow,
// after authenticateChain and PostAuthenticate have completed, so legacy
// credentials remain as a fallback if authentication fails.
func (m *manager) deleteLegacyKeyringEntry(alias string) {
if m.realm.Value == "" || m.credentialStore == nil {
return
}
if err := m.credentialStore.Delete(alias, ""); err != nil {
log.Warn("Failed to delete legacy keyring entry", "alias", alias, "error", err)
} else {
log.Debug("Deleted legacy keyring entry (pre-realm)", "alias", alias)
}
}

// deleteLegacyCredentialFiles removes pre-realm credential files after successful
// authentication. This runs in the post-success path of the Authenticate flow,
// after authenticateChain and PostAuthenticate have completed, so legacy files
// remain as a fallback if authentication fails.
// Scans {baseDir}/aws/{provider}/ for credential and config files and removes them.
// TODO: Refactor to use a provider/store-owned cleanup hook instead of hardcoding
// AWS-specific paths here, keeping the auth manager cloud-agnostic.
func (m *manager) deleteLegacyCredentialFiles() {
if m.realm.Value == "" {
return
}

baseDir := xdg.LookupXDGConfigDir("")
if baseDir == "" {
return
}

awsDir := filepath.Join(baseDir, awsDirNameForMismatch)
providerDirs, err := os.ReadDir(awsDir)
if err != nil {
return
}

for _, provider := range providerDirs {
if !provider.IsDir() {
continue
}
providerDir := filepath.Join(awsDir, provider.Name())
// Remove credential, config, and lock files from legacy (no-realm) path.
for _, filename := range []string{"credentials", "config", "credentials.lock", "config.lock"} {
filePath := filepath.Join(providerDir, filename)
if err := os.Remove(filePath); err == nil {
log.Debug("Deleted legacy credential file (pre-realm)", "path", filePath)
}
}
// Remove provider directory if now empty.
_ = os.Remove(providerDir)
}
// Remove aws directory if now empty.
_ = os.Remove(awsDir)
}

// logRealmMismatchWarning emits a warning about credentials existing under a different realm.
func logRealmMismatchWarning(currentRealm, alternateRealm string) {
currentDisplay := currentRealm
Expand Down
Loading
Loading