diff --git a/analyzer/malysis_factory.go b/analyzer/malysis_factory.go index 4182480..5ccbdc5 100644 --- a/analyzer/malysis_factory.go +++ b/analyzer/malysis_factory.go @@ -15,7 +15,9 @@ type credentialsResolver func() (*cloud.Credentials, func() error, error) // the unauthenticated community analyzer if the API rejects the credentials. // When no credentials are available, it returns the community analyzer. func NewMalysisAnalyzer(config MalysisQueryAnalyzerConfig) (PackageVersionAnalyzer, error) { - return newMalysisAnalyzer(config, cloudauth.ResolveCredentials) + return newMalysisAnalyzer(config, func() (*cloud.Credentials, func() error, error) { + return cloudauth.ResolveCredentials() + }) } func newMalysisAnalyzer(config MalysisQueryAnalyzerConfig, diff --git a/cmd/cloud/login.go b/cmd/cloud/login.go index ea4fb1b..00d153d 100644 --- a/cmd/cloud/login.go +++ b/cmd/cloud/login.go @@ -9,7 +9,10 @@ import ( "github.com/spf13/cobra" ) -var loginFromEnv bool +var ( + loginFromEnv bool + loginInsecureFileStore bool +) func newLoginCommand() *cobra.Command { cmd := &cobra.Command{ @@ -20,6 +23,8 @@ func newLoginCommand() *cobra.Command { cmd.Flags().BoolVar(&loginFromEnv, "from-env", false, "Read credentials from SAFEDEP_API_KEY and SAFEDEP_TENANT_ID environment variables") + cmd.Flags().BoolVar(&loginInsecureFileStore, "insecure-file-store", false, + "Store credentials in a plaintext file when no OS keychain is available (headless Linux, containers)") return cmd } @@ -89,13 +94,18 @@ func runLogin(cmd *cobra.Command, args []string) error { } } - store, err := cloud.NewKeychainCredentialStore() + var opts []cloud.KeychainOption + if loginInsecureFileStore { + opts = append(opts, cloud.WithInsecureFileFallback()) + } + + store, err := cloud.NewKeychainCredentialStore(opts...) if err != nil { ui.ErrorExit(usefulerror.NewUsefulError(). Wrap(err). WithCode(errcodes.Lifecycle). WithHumanError("Failed to initialize credential store"). - WithHelp("Your system may not support secure credential storage")) + WithHelp("No OS keychain is available. Re-run with --insecure-file-store to use plaintext file storage, or set SAFEDEP_API_KEY and SAFEDEP_TENANT_ID environment variables")) } defer func() { if err := store.Close(); err != nil { @@ -111,6 +121,10 @@ func runLogin(cmd *cobra.Command, args []string) error { WithHelp("Your system may not support secure credential storage")) } - ui.Successf("Credentials saved securely") + if loginInsecureFileStore { + ui.Successf("Credentials saved") + } else { + ui.Successf("Credentials saved securely") + } return nil } diff --git a/cmd/cloud/logout.go b/cmd/cloud/logout.go index c24dab4..065d248 100644 --- a/cmd/cloud/logout.go +++ b/cmd/cloud/logout.go @@ -18,7 +18,7 @@ func newLogoutCommand() *cobra.Command { } func runLogout(cmd *cobra.Command, args []string) error { - store, err := cloud.NewKeychainCredentialStore() + store, err := cloud.NewKeychainCredentialStore(cloud.WithInsecureFileFallback()) if err != nil { ui.ErrorExit(usefulerror.NewUsefulError(). Wrap(err). diff --git a/cmd/setup/info.go b/cmd/setup/info.go index bee13df..5219eec 100644 --- a/cmd/setup/info.go +++ b/cmd/setup/info.go @@ -227,7 +227,7 @@ func describeCloudCredentials() string { } func tryResolveKeychainCredentials() (string, bool) { - resolver, err := cloud.NewKeychainCredentialResolver(cloud.CredentialTypeAPIKey) + resolver, err := cloud.NewKeychainCredentialResolver(cloud.CredentialTypeAPIKey, cloud.WithInsecureFileFallback()) if err != nil { log.Debugf("keychain credential resolver unavailable: %v", err) return "", false diff --git a/internal/cloudauth/resolver.go b/internal/cloudauth/resolver.go index 60d8f09..b48650b 100644 --- a/internal/cloudauth/resolver.go +++ b/internal/cloudauth/resolver.go @@ -16,13 +16,20 @@ import ( // credentials and a close function that releases keychain resources. The // close function is always non-nil and safe to call regardless of the error. // +// The insecure file fallback is always enabled for reading so credentials +// stored via `pmg cloud login --insecure-file-store` on systems without an +// OS keychain (headless Linux, containers) resolve without extra flags. On +// systems with a working keychain the file provider is never constructed. +// // An error is returned when no credentials are available, which callers can // use to decide whether authenticated cloud features should be enabled. -func ResolveCredentials() (*cloud.Credentials, func() error, error) { +func ResolveCredentials(opts ...cloud.KeychainOption) (*cloud.Credentials, func() error, error) { var resolvers []cloud.CredentialResolver var keychainResolver cloud.CloseableCredentialResolver - keychainResolver, err := cloud.NewKeychainCredentialResolver(cloud.CredentialTypeAPIKey) + keychainOpts := append([]cloud.KeychainOption{cloud.WithInsecureFileFallback()}, opts...) + + keychainResolver, err := cloud.NewKeychainCredentialResolver(cloud.CredentialTypeAPIKey, keychainOpts...) if err != nil { log.Debugf("Keychain credential resolver not available, skipping: %v", err) } else { diff --git a/internal/cloudauth/resolver_test.go b/internal/cloudauth/resolver_test.go new file mode 100644 index 0000000..4dcc3ea --- /dev/null +++ b/internal/cloudauth/resolver_test.go @@ -0,0 +1,71 @@ +package cloudauth + +import ( + "testing" + + "github.com/safedep/dry/cloud" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testKeychainOptions(t *testing.T) []cloud.KeychainOption { + t.Helper() + return []cloud.KeychainOption{ + cloud.WithAppName("safedep-test-" + t.Name()), + cloud.WithInsecureFileFallbackPath(t.TempDir() + "/creds.json"), + } +} + +func TestResolveCredentialsFromKeychainStore(t *testing.T) { + t.Setenv("SAFEDEP_API_KEY", "") + t.Setenv("SAFEDEP_TENANT_ID", "") + + opts := testKeychainOptions(t) + + store, err := cloud.NewKeychainCredentialStore(opts...) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, store.Clear()) + require.NoError(t, store.Close()) + }) + + require.NoError(t, store.SaveAPIKeyCredential("sk-test-key", "tenant-123")) + + creds, closeFn, err := ResolveCredentials(opts...) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, closeFn()) }) + + apiKey, err := creds.GetAPIKey() + require.NoError(t, err) + assert.Equal(t, "sk-test-key", apiKey) + + tenant, err := creds.GetTenantDomain() + require.NoError(t, err) + assert.Equal(t, "tenant-123", tenant) +} + +func TestResolveCredentialsFallsBackToEnv(t *testing.T) { + t.Setenv("SAFEDEP_API_KEY", "env-key") + t.Setenv("SAFEDEP_TENANT_ID", "env-tenant") + + creds, closeFn, err := ResolveCredentials(testKeychainOptions(t)...) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, closeFn()) }) + + apiKey, err := creds.GetAPIKey() + require.NoError(t, err) + assert.Equal(t, "env-key", apiKey) + + tenant, err := creds.GetTenantDomain() + require.NoError(t, err) + assert.Equal(t, "env-tenant", tenant) +} + +func TestResolveCredentialsNoCredentials(t *testing.T) { + t.Setenv("SAFEDEP_API_KEY", "") + t.Setenv("SAFEDEP_TENANT_ID", "") + + _, closeFn, err := ResolveCredentials(testKeychainOptions(t)...) + require.Error(t, err) + require.NoError(t, closeFn()) +}