Skip to content

Commit 0bb1ffb

Browse files
committed
Add auto option that falls back to pgpass on keychain failures (e.g. not installed)
1 parent 44f60eb commit 0bb1ffb

File tree

4 files changed

+277
-6
lines changed

4 files changed

+277
-6
lines changed

internal/tiger/common/connection.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ func GetPassword(service api.Service, role string) (string, error) {
100100
return "", fmt.Errorf("no password found in keyring for this service")
101101
case *PgpassStorage:
102102
return "", fmt.Errorf("no password found in ~/.pgpass for this service")
103+
case *AutoFallbackStorage:
104+
return "", fmt.Errorf("no password found for this service (checked keyring and ~/.pgpass)")
103105
default:
104106
return "", fmt.Errorf("failed to retrieve password: %w", err)
105107
}

internal/tiger/common/password_storage.go

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,82 @@ func (n *NoStorage) GetStorageResult(err error, password string) PasswordStorage
330330
}
331331
}
332332

333+
// AutoFallbackStorage tries keyring first, falls back to pgpass on any error
334+
type AutoFallbackStorage struct {
335+
keyring *KeyringStorage
336+
pgpass *PgpassStorage
337+
lastMethod string // tracks which method was used for GetStorageResult
338+
}
339+
340+
func (a *AutoFallbackStorage) Save(service api.Service, password string, role string) error {
341+
// Try keyring first
342+
if err := a.keyring.Save(service, password, role); err == nil {
343+
a.lastMethod = "keyring"
344+
return nil
345+
}
346+
// Any keyring error -> fall back to pgpass
347+
if err := a.pgpass.Save(service, password, role); err != nil {
348+
a.lastMethod = "auto"
349+
return err
350+
}
351+
a.lastMethod = "pgpass"
352+
return nil
353+
}
354+
355+
func (a *AutoFallbackStorage) Get(service api.Service, role string) (string, error) {
356+
// Try keyring first
357+
if password, err := a.keyring.Get(service, role); err == nil {
358+
return password, nil
359+
}
360+
// Any keyring error -> try pgpass
361+
return a.pgpass.Get(service, role)
362+
}
363+
364+
func (a *AutoFallbackStorage) Remove(service api.Service, role string) error {
365+
// Try to remove from both (best effort)
366+
keyringErr := a.keyring.Remove(service, role)
367+
pgpassErr := a.pgpass.Remove(service, role)
368+
369+
// Success if removed from at least one location
370+
if keyringErr == nil || pgpassErr == nil {
371+
return nil
372+
}
373+
// If both failed, return a combined error
374+
return fmt.Errorf("keyring: %v, pgpass: %v", keyringErr, pgpassErr)
375+
}
376+
377+
func (a *AutoFallbackStorage) GetStorageResult(err error, password string) PasswordStorageResult {
378+
if err != nil {
379+
return PasswordStorageResult{
380+
Success: false,
381+
Method: a.lastMethod,
382+
Message: fmt.Sprintf("Failed to save password: %s", sanitizeErrorMessage(err, password)),
383+
}
384+
}
385+
386+
// Return method-specific success message
387+
switch a.lastMethod {
388+
case "keyring":
389+
return PasswordStorageResult{
390+
Success: true,
391+
Method: "keyring",
392+
Message: "Password saved to system keyring for automatic authentication",
393+
}
394+
case "pgpass":
395+
return PasswordStorageResult{
396+
Success: true,
397+
Method: "pgpass",
398+
Message: "Password saved to ~/.pgpass for automatic authentication",
399+
}
400+
default:
401+
return PasswordStorageResult{
402+
Success: true,
403+
Method: "auto",
404+
Message: "Password saved for automatic authentication",
405+
}
406+
}
407+
}
408+
333409
// GetPasswordStorage returns the appropriate PasswordStorage implementation based on configuration
334410
func GetPasswordStorage() PasswordStorage {
335411
storageMethod := viper.GetString("password_storage")
@@ -340,8 +416,17 @@ func GetPasswordStorage() PasswordStorage {
340416
return &PgpassStorage{}
341417
case "none":
342418
return &NoStorage{}
419+
case "auto":
420+
return &AutoFallbackStorage{
421+
keyring: &KeyringStorage{},
422+
pgpass: &PgpassStorage{},
423+
}
343424
default:
344-
return &KeyringStorage{} // Default to keyring
425+
// Default to auto for best compatibility across environments
426+
return &AutoFallbackStorage{
427+
keyring: &KeyringStorage{},
428+
pgpass: &PgpassStorage{},
429+
}
345430
}
346431
}
347432

internal/tiger/common/password_storage_test.go

Lines changed: 186 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,8 +295,9 @@ func TestGetPasswordStorage(t *testing.T) {
295295
{"keyring", "keyring", "*common.KeyringStorage"},
296296
{"pgpass", "pgpass", "*common.PgpassStorage"},
297297
{"none", "none", "*common.NoStorage"},
298-
{"default", "", "*common.KeyringStorage"}, // Default case
299-
{"invalid", "invalid", "*common.KeyringStorage"}, // Falls back to default
298+
{"auto", "auto", "*common.AutoFallbackStorage"},
299+
{"default", "", "*common.AutoFallbackStorage"}, // Default case now uses auto
300+
{"invalid", "invalid", "*common.AutoFallbackStorage"}, // Falls back to auto
300301
}
301302

302303
for _, tt := range tests {
@@ -937,3 +938,186 @@ func TestSanitizeErrorMessage(t *testing.T) {
937938
})
938939
}
939940
}
941+
942+
// Test AutoFallbackStorage using pgpass as fallback (since keyring may not be available in CI)
943+
func TestAutoFallbackStorage_Integration(t *testing.T) {
944+
// Set up test service name for keyring
945+
config.SetTestServiceName(t)
946+
947+
// Create a temporary directory for pgpass
948+
tempDir := t.TempDir()
949+
originalHome := os.Getenv("HOME")
950+
os.Setenv("HOME", tempDir)
951+
defer os.Setenv("HOME", originalHome)
952+
953+
storage := &AutoFallbackStorage{
954+
keyring: &KeyringStorage{},
955+
pgpass: &PgpassStorage{},
956+
}
957+
service := createTestService("auto-fallback-test-service")
958+
password := "test-password-auto"
959+
role := "tsdbadmin"
960+
961+
// Test Save - should succeed using either keyring or pgpass
962+
err := storage.Save(service, password, role)
963+
if err != nil {
964+
t.Fatalf("AutoFallbackStorage.Save() failed: %v", err)
965+
}
966+
967+
// Verify lastMethod is set to either keyring or pgpass
968+
if storage.lastMethod != "keyring" && storage.lastMethod != "pgpass" {
969+
t.Errorf("AutoFallbackStorage.lastMethod = %q, want 'keyring' or 'pgpass'", storage.lastMethod)
970+
}
971+
972+
// Test Get - should retrieve the password
973+
retrieved, err := storage.Get(service, role)
974+
if err != nil {
975+
t.Fatalf("AutoFallbackStorage.Get() failed: %v", err)
976+
}
977+
if retrieved != password {
978+
t.Errorf("AutoFallbackStorage.Get() = %q, want %q", retrieved, password)
979+
}
980+
981+
// Test Remove - should succeed
982+
err = storage.Remove(service, role)
983+
if err != nil {
984+
t.Fatalf("AutoFallbackStorage.Remove() failed: %v", err)
985+
}
986+
987+
// Verify password is gone
988+
_, err = storage.Get(service, role)
989+
if err == nil {
990+
t.Error("AutoFallbackStorage.Get() should fail after Remove()")
991+
}
992+
}
993+
994+
// Test AutoFallbackStorage GetStorageResult with keyring success
995+
func TestAutoFallbackStorage_GetStorageResult_KeyringSuccess(t *testing.T) {
996+
storage := &AutoFallbackStorage{
997+
keyring: &KeyringStorage{},
998+
pgpass: &PgpassStorage{},
999+
lastMethod: "keyring",
1000+
}
1001+
testPassword := "test-password"
1002+
1003+
result := storage.GetStorageResult(nil, testPassword)
1004+
if !result.Success {
1005+
t.Errorf("GetStorageResult() success should be true, got: %t", result.Success)
1006+
}
1007+
if result.Method != "keyring" {
1008+
t.Errorf("GetStorageResult() method should be 'keyring', got: %s", result.Method)
1009+
}
1010+
if !strings.Contains(result.Message, "Password saved to system keyring") {
1011+
t.Errorf("GetStorageResult() should mention keyring, got: %s", result.Message)
1012+
}
1013+
if strings.Contains(result.Message, testPassword) {
1014+
t.Error("GetStorageResult() should never include actual passwords")
1015+
}
1016+
}
1017+
1018+
// Test AutoFallbackStorage GetStorageResult with pgpass fallback success
1019+
func TestAutoFallbackStorage_GetStorageResult_PgpassSuccess(t *testing.T) {
1020+
storage := &AutoFallbackStorage{
1021+
keyring: &KeyringStorage{},
1022+
pgpass: &PgpassStorage{},
1023+
lastMethod: "pgpass",
1024+
}
1025+
testPassword := "test-password"
1026+
1027+
result := storage.GetStorageResult(nil, testPassword)
1028+
if !result.Success {
1029+
t.Errorf("GetStorageResult() success should be true, got: %t", result.Success)
1030+
}
1031+
if result.Method != "pgpass" {
1032+
t.Errorf("GetStorageResult() method should be 'pgpass', got: %s", result.Method)
1033+
}
1034+
if !strings.Contains(result.Message, "Password saved to ~/.pgpass") {
1035+
t.Errorf("GetStorageResult() should mention pgpass, got: %s", result.Message)
1036+
}
1037+
if strings.Contains(result.Message, testPassword) {
1038+
t.Error("GetStorageResult() should never include actual passwords")
1039+
}
1040+
}
1041+
1042+
// Test AutoFallbackStorage GetStorageResult with failure
1043+
func TestAutoFallbackStorage_GetStorageResult_Failure(t *testing.T) {
1044+
storage := &AutoFallbackStorage{
1045+
keyring: &KeyringStorage{},
1046+
pgpass: &PgpassStorage{},
1047+
lastMethod: "auto",
1048+
}
1049+
testPassword := "test-password"
1050+
testError := fmt.Errorf("both storage methods failed")
1051+
1052+
result := storage.GetStorageResult(testError, testPassword)
1053+
if result.Success {
1054+
t.Errorf("GetStorageResult() success should be false, got: %t", result.Success)
1055+
}
1056+
if result.Method != "auto" {
1057+
t.Errorf("GetStorageResult() method should be 'auto', got: %s", result.Method)
1058+
}
1059+
if !strings.Contains(result.Message, "Failed to save password") {
1060+
t.Errorf("GetStorageResult() should mention failure, got: %s", result.Message)
1061+
}
1062+
if strings.Contains(result.Message, testPassword) {
1063+
t.Error("GetStorageResult() should never include actual passwords")
1064+
}
1065+
}
1066+
1067+
// Test AutoFallbackStorage Remove succeeds if at least one location succeeds
1068+
func TestAutoFallbackStorage_Remove_PartialSuccess(t *testing.T) {
1069+
// Set up test service name for keyring
1070+
config.SetTestServiceName(t)
1071+
1072+
// Create a temporary directory for pgpass
1073+
tempDir := t.TempDir()
1074+
originalHome := os.Getenv("HOME")
1075+
os.Setenv("HOME", tempDir)
1076+
defer os.Setenv("HOME", originalHome)
1077+
1078+
// Save password only to pgpass (not keyring)
1079+
pgpassStorage := &PgpassStorage{}
1080+
service := createTestService("partial-remove-test")
1081+
password := "test-password"
1082+
role := "tsdbadmin"
1083+
1084+
err := pgpassStorage.Save(service, password, role)
1085+
if err != nil {
1086+
t.Fatalf("PgpassStorage.Save() failed: %v", err)
1087+
}
1088+
1089+
// Now remove using AutoFallbackStorage - should succeed even if keyring fails
1090+
autoStorage := &AutoFallbackStorage{
1091+
keyring: &KeyringStorage{},
1092+
pgpass: &PgpassStorage{},
1093+
}
1094+
1095+
err = autoStorage.Remove(service, role)
1096+
if err != nil {
1097+
t.Errorf("AutoFallbackStorage.Remove() should succeed when at least one location succeeds, got: %v", err)
1098+
}
1099+
}
1100+
1101+
// Security test: Ensure AutoFallbackStorage never includes passwords in messages
1102+
func TestAutoFallbackStorage_SecurityTest_NoPasswordInResults(t *testing.T) {
1103+
testPassword := "super-secret-password-123"
1104+
testError := fmt.Errorf("failed to save %s", testPassword)
1105+
1106+
storage := &AutoFallbackStorage{
1107+
keyring: &KeyringStorage{},
1108+
pgpass: &PgpassStorage{},
1109+
lastMethod: "auto",
1110+
}
1111+
1112+
// Test success case
1113+
successResult := storage.GetStorageResult(nil, testPassword)
1114+
if strings.Contains(successResult.Message, testPassword) {
1115+
t.Errorf("GetStorageResult() success should never include password, got: %s", successResult.Message)
1116+
}
1117+
1118+
// Test error case
1119+
errorResult := storage.GetStorageResult(testError, testPassword)
1120+
if strings.Contains(errorResult.Message, testPassword) {
1121+
t.Errorf("GetStorageResult() error should never include password, got: %s", errorResult.Message)
1122+
}
1123+
}

internal/tiger/config/config.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ const (
6666
DefaultDocsMCPURL = "https://mcp.tigerdata.com/docs"
6767
DefaultGatewayURL = "https://console.cloud.timescale.com/api"
6868
DefaultOutput = "table"
69-
DefaultPasswordStorage = "keyring"
69+
DefaultPasswordStorage = "auto"
7070
DefaultReleasesURL = "https://cli.tigerdata.com"
7171
DefaultVersionCheckInterval = 24 * time.Hour
7272
)
@@ -368,8 +368,8 @@ func (c *Config) UpdateField(key string, value any) (any, error) {
368368
if !ok {
369369
return nil, fmt.Errorf("password_storage must be string, got %T", value)
370370
}
371-
if s != "keyring" && s != "pgpass" && s != "none" {
372-
return nil, fmt.Errorf("invalid password_storage value: %s (must be keyring, pgpass, or none)", s)
371+
if s != "auto" && s != "keyring" && s != "pgpass" && s != "none" {
372+
return nil, fmt.Errorf("invalid password_storage value: %s (must be auto, keyring, pgpass, or none)", s)
373373
}
374374
c.PasswordStorage = s
375375
validated = s

0 commit comments

Comments
 (0)