From 1d8b69e6034bfc9111710238682d9115b908e76a Mon Sep 17 00:00:00 2001 From: Roman Volykh Date: Thu, 30 Oct 2025 14:06:28 +0200 Subject: [PATCH] feat: Add AWS SecretsManager profile support --- README.md | 27 +- configs/vui.yaml | 11 + internal/engines/aws/aws_secrets_manager.go | 369 ++++++++ .../engines/aws/aws_secrets_manager_test.go | 792 ++++++++++++++++++ internal/engines/engines_factory.go | 3 + internal/engines/engines_factory_test.go | 15 + sandbox/docker-compose.yml | 4 +- 7 files changed, 1213 insertions(+), 8 deletions(-) create mode 100644 internal/engines/aws/aws_secrets_manager.go create mode 100644 internal/engines/aws/aws_secrets_manager_test.go diff --git a/README.md b/README.md index 8ea3b43..d3856e2 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ ui: theme: "dark" show_hidden_secrets: false -vaults: +profiles: local: address: "http://localhost:8200" auth_method: "token" @@ -82,8 +82,9 @@ For complete example, see [vui.yaml](./configs/vui.yaml) #### LDAP Authentication ```yaml -vaults: +profiles: ldap_vault: + engine: vault address: "https://vault.company.com" auth_method: "ldap" namespace: "production" @@ -94,7 +95,7 @@ vaults: #### AWS IAM Authentication ```yaml -vaults: +profiles: aws_vault: address: "https://vault.company.com" auth_method: "aws" @@ -108,7 +109,7 @@ vaults: #### Kubernetes Authentication ```yaml -vaults: +profiles: k8s_vault: address: "https://vault.company.com" auth_method: "kubernetes" @@ -120,7 +121,7 @@ vaults: #### JWT Authentication ```yaml -vaults: +profiles: jwt_vault: address: "https://vault.company.com" auth_method: "jwt" @@ -132,7 +133,7 @@ vaults: #### Certificate Authentication ```yaml -vaults: +profiles: cert_vault: address: "https://vault.company.com" auth_method: "cert" @@ -143,6 +144,20 @@ vaults: key_path: "/path/to/client.key" ``` +#### AWS SecretsManager + +```yaml +profiles: + aws_secretsmanager: + engine: aws/secretsmanager + auth_method: "aws" + auth_config: + aws_access_key_id: "${AWS_ACCESS_KEY_ID}" + aws_secret_access_key: "${AWS_SECRET_ACCESS_KEY}" + aws_session_token: "${AWS_SESSION_TOKEN}" + aws_region: "us-east-1" +``` + ## Installation ### Download from Release diff --git a/configs/vui.yaml b/configs/vui.yaml index 375da1e..d0e40d5 100644 --- a/configs/vui.yaml +++ b/configs/vui.yaml @@ -91,3 +91,14 @@ profiles: auth_config: azure_role: "vui-azure-role" azure_resource: "TODO" + + aws-secretsmanager: + engine: "aws/secretsmanager" + address: "http://localhost:4566" + auth_method: "aws" + namespace: "" + auth_config: + aws_region: "us-east-1" + aws_access_key_id: "${AWS_ACCESS_KEY_ID}" + aws_secret_access_key: "${AWS_SECRET_ACCESS_KEY}" + aws_session_token: "${AWS_SESSION_TOKEN}" diff --git a/internal/engines/aws/aws_secrets_manager.go b/internal/engines/aws/aws_secrets_manager.go new file mode 100644 index 0000000..56a6e2b --- /dev/null +++ b/internal/engines/aws/aws_secrets_manager.go @@ -0,0 +1,369 @@ +package aws + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/credentials/stscreds" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/secretsmanager" + "github.com/aws/aws-sdk-go/service/secretsmanager/secretsmanageriface" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/rvolykh/vui/internal/config" + "github.com/rvolykh/vui/internal/models" + "github.com/rvolykh/vui/internal/utils" + "github.com/sirupsen/logrus" +) + +type AWSClient struct { + client secretsmanageriface.SecretsManagerAPI + profile *config.Profile + logger *logrus.Logger + region string + address string +} + +func NewAWSSecretsManagerClient(logger *logrus.Logger, profile *config.Profile) (*AWSClient, error) { + region := utils.Coalesce(profile.AuthConfig.AWSRegion, "us-east-1") + + awsConfig := aws.NewConfig().WithRegion(region) + + if profile.Address != "" { + awsConfig.WithEndpoint(profile.Address) + } + + if profile.AuthConfig.AWSAccessKeyID == "" || profile.AuthConfig.AWSSecretAccessKey == "" { + return nil, fmt.Errorf("aws_access_key_id and aws_secret_access_key are required for AWS Secrets Manager authentication") + } + + awsConfig.WithCredentials(credentials.NewStaticCredentials( + profile.AuthConfig.AWSAccessKeyID, + profile.AuthConfig.AWSSecretAccessKey, + profile.AuthConfig.AWSSessionToken, + )) + + sess, err := session.NewSession(awsConfig) + if err != nil { + return nil, fmt.Errorf("failed to create AWS session for static credentials: %w", err) + } + + awsRole := profile.AuthConfig.AWSRole + if awsRole != "" { + awsConfig.WithCredentials(stscreds.NewCredentials(sess, awsRole)) + + sess, err = session.NewSession(awsConfig) + if err != nil { + return nil, fmt.Errorf("failed to create AWS session for assumed role: %w", err) + } + } + + address := profile.Address + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + result, err := sts.New(sess).GetCallerIdentityWithContext(ctx, &sts.GetCallerIdentityInput{}) + if err == nil && result != nil && result.Account != nil { + address = fmt.Sprintf("aws://%s:%s", *result.Account, region) + } else if profile.Address == "" { + address = fmt.Sprintf("secretsmanager.%s.amazonaws.com", region) + } + + return &AWSClient{ + client: secretsmanager.New(sess), + profile: profile, + logger: logger, + region: region, + address: address, + }, nil +} + +func (c *AWSClient) Authenticate() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // For AWS Secrets Manager, we can verify by making a simple API call + _, err := c.client.ListSecretsWithContext(ctx, &secretsmanager.ListSecretsInput{ + MaxResults: aws.Int64(1), + }) + if err != nil { + return fmt.Errorf("failed to authenticate with AWS Secrets Manager: %w", err) + } + + c.logger.Debug("AWS Secrets Manager authentication verified successfully") + return nil +} + +func (c *AWSClient) GetAddress() string { + if c.address == "" { + return c.profile.Address + } + return c.address +} + +func (c *AWSClient) GetStatus(ctx context.Context) (models.ConnectionStatus, error) { + _, err := c.client.ListSecretsWithContext(ctx, &secretsmanager.ListSecretsInput{ + MaxResults: aws.Int64(1), + }) + if err != nil { + return models.ConnectionStatus{ + Status: models.StatusDisconnected, + Address: c.GetAddress(), + LastCheck: time.Now(), + Error: err.Error(), + }, nil + } + + // Get AWS account ID or region info for cluster_id + // For AWS, we can use the region as cluster identifier + return models.ConnectionStatus{ + Status: models.StatusConnected, + Address: c.GetAddress(), + Version: "AWS Secrets Manager", + ClusterID: c.region, + LastCheck: time.Now(), + }, nil +} + +func (c *AWSClient) ListSecrets(path string) ([]*models.SecretNode, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // AWS Secrets Manager doesn't have a hierarchical path structure like Vault + // We'll use prefix matching to simulate directories + input := &secretsmanager.ListSecretsInput{} + + normalizedPath := strings.Trim(path, "/") + + var allSecrets []*secretsmanager.SecretListEntry + err := c.client.ListSecretsPagesWithContext(ctx, input, func(page *secretsmanager.ListSecretsOutput, lastPage bool) bool { + if page.SecretList != nil { + allSecrets = append(allSecrets, page.SecretList...) + } + return !lastPage + }) + if err != nil { + return nil, fmt.Errorf("failed to list secrets: %w", err) + } + + // Filter secrets by path prefix if a path is specified + if normalizedPath != "" { + filteredSecrets := []*secretsmanager.SecretListEntry{} + prefix := normalizedPath + "/" + for _, secret := range allSecrets { + if secret.Name != nil && strings.HasPrefix(*secret.Name, prefix) { + filteredSecrets = append(filteredSecrets, secret) + } + } + allSecrets = filteredSecrets + } + + // Build a tree structure from secret names + // Secrets can have "/" in their names to simulate paths + // We'll return immediate children (both secrets and directories) at the current path level + nodeMap := make(map[string]*models.SecretNode) + + for _, secret := range allSecrets { + if secret.Name == nil { + continue + } + + secretName := *secret.Name + // Remove the prefix if we're filtering by path + var relativePath string + if normalizedPath != "" { + if !strings.HasPrefix(secretName, normalizedPath+"/") { + continue + } + relativePath = strings.TrimPrefix(secretName, normalizedPath+"/") + } else { + relativePath = secretName + } + + // Split into parts to find immediate children + parts := strings.Split(relativePath, "/") + if len(parts) == 0 { + continue + } + + firstPart := parts[0] + fullPath := firstPart + if normalizedPath != "" { + fullPath = normalizedPath + "/" + firstPart + } + + // Check if this is a direct child or a nested path + if len(parts) == 1 { + // This is a direct secret at the current path level + node := &models.SecretNode{ + Name: firstPart, + Path: secretName, // Use full secret name as path + IsSecret: true, + } + + if secret.CreatedDate != nil { + node.Metadata = &models.SecretMetadata{ + CreatedTime: *secret.CreatedDate, + Version: 1, + } + } + + nodeMap[firstPart] = node + } else { + // This is a nested path, create a directory node + if _, exists := nodeMap[firstPart]; !exists { + dirNode := &models.SecretNode{ + Name: firstPart, + Path: fullPath, + IsSecret: false, + Children: []*models.SecretNode{}, + } + nodeMap[firstPart] = dirNode + } + } + } + + // Convert map to slice + result := []*models.SecretNode{} + for _, node := range nodeMap { + result = append(result, node) + } + + return result, nil +} + +func (c *AWSClient) GetSecret(path string) (*models.SecretNode, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // AWS Secrets Manager uses secret name or ARN as identifier + secretName := strings.Trim(path, "/") + + input := &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(secretName), + } + + result, err := c.client.GetSecretValueWithContext(ctx, input) + if err != nil { + return nil, fmt.Errorf("failed to get secret '%s': %w", path, err) + } + + node := &models.SecretNode{ + Name: filepath.Base(secretName), + Path: secretName, + IsSecret: true, + Metadata: &models.SecretMetadata{}, + } + + // Parse secret value (can be JSON string or plain string) + var secretData map[string]any + secretString := "" + if result.SecretString != nil { + secretString = *result.SecretString + } + + // Try to parse as JSON + if err := json.Unmarshal([]byte(secretString), &secretData); err != nil { + // If not JSON, treat as plain string + secretData = map[string]any{ + "value": secretString, + } + } + + node.Data = secretData + + // Get metadata + describeInput := &secretsmanager.DescribeSecretInput{ + SecretId: aws.String(secretName), + } + describeResult, err := c.client.DescribeSecretWithContext(ctx, describeInput) + if err == nil && describeResult != nil { + if describeResult.CreatedDate != nil { + node.Metadata.CreatedTime = *describeResult.CreatedDate + } + // AWS Secrets Manager has versioning but not a simple integer version + // We'll use 1 as default version + node.Metadata.Version = 1 + if describeResult.DeletedDate != nil { + node.Metadata.DeletionTime = *describeResult.DeletedDate + node.Metadata.Destroyed = true + } + } + + return node, nil +} + +func (c *AWSClient) CreateSecret(path string, data map[string]any) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + secretName := strings.Trim(path, "/") + + // Convert data map to JSON string + jsonData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal secret data: %w", err) + } + + input := &secretsmanager.CreateSecretInput{ + Name: aws.String(secretName), + SecretString: aws.String(string(jsonData)), + } + + _, err = c.client.CreateSecretWithContext(ctx, input) + if err != nil { + return fmt.Errorf("failed to create secret '%s': %w", path, err) + } + + c.logger.Infof("Created secret: %s", secretName) + return nil +} + +func (c *AWSClient) UpdateSecret(path string, data map[string]any) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + secretName := strings.Trim(path, "/") + + // Convert data map to JSON string + jsonData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to marshal secret data: %w", err) + } + + input := &secretsmanager.UpdateSecretInput{ + SecretId: aws.String(secretName), + SecretString: aws.String(string(jsonData)), + } + + _, err = c.client.UpdateSecretWithContext(ctx, input) + if err != nil { + return fmt.Errorf("failed to update secret '%s': %w", path, err) + } + + c.logger.Infof("Updated secret: %s", secretName) + return nil +} + +func (c *AWSClient) DeleteSecret(path string) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + secretName := strings.Trim(path, "/") + + input := &secretsmanager.DeleteSecretInput{ + SecretId: aws.String(secretName), + } + + _, err := c.client.DeleteSecretWithContext(ctx, input) + if err != nil { + return fmt.Errorf("failed to delete secret '%s': %w", path, err) + } + + c.logger.Infof("Deleted secret: %s", secretName) + return nil +} diff --git a/internal/engines/aws/aws_secrets_manager_test.go b/internal/engines/aws/aws_secrets_manager_test.go new file mode 100644 index 0000000..1453686 --- /dev/null +++ b/internal/engines/aws/aws_secrets_manager_test.go @@ -0,0 +1,792 @@ +package aws + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/request" + "github.com/aws/aws-sdk-go/service/secretsmanager" + "github.com/aws/aws-sdk-go/service/secretsmanager/secretsmanageriface" + "github.com/rvolykh/vui/internal/config" + "github.com/rvolykh/vui/internal/models" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockSecretsManager is a mock implementation of secretsmanageriface.SecretsManagerAPI +type mockSecretsManager struct { + secretsmanageriface.SecretsManagerAPI + listSecretsOutput *secretsmanager.ListSecretsOutput + listSecretsError error + getSecretValueOutput *secretsmanager.GetSecretValueOutput + getSecretValueError error + describeSecretOutput *secretsmanager.DescribeSecretOutput + describeSecretError error + createSecretOutput *secretsmanager.CreateSecretOutput + createSecretError error + updateSecretOutput *secretsmanager.UpdateSecretOutput + updateSecretError error + deleteSecretOutput *secretsmanager.DeleteSecretOutput + deleteSecretError error +} + +func (m *mockSecretsManager) ListSecretsPagesWithContext(ctx aws.Context, input *secretsmanager.ListSecretsInput, fn func(*secretsmanager.ListSecretsOutput, bool) bool, opts ...request.Option) error { + if m.listSecretsError != nil { + return m.listSecretsError + } + if m.listSecretsOutput != nil { + fn(m.listSecretsOutput, true) + } + return nil +} + +func (m *mockSecretsManager) ListSecretsWithContext(ctx aws.Context, input *secretsmanager.ListSecretsInput, opts ...request.Option) (*secretsmanager.ListSecretsOutput, error) { + if m.listSecretsError != nil { + return nil, m.listSecretsError + } + return m.listSecretsOutput, nil +} + +func (m *mockSecretsManager) GetSecretValueWithContext(ctx aws.Context, input *secretsmanager.GetSecretValueInput, opts ...request.Option) (*secretsmanager.GetSecretValueOutput, error) { + if m.getSecretValueError != nil { + return nil, m.getSecretValueError + } + return m.getSecretValueOutput, nil +} + +func (m *mockSecretsManager) DescribeSecretWithContext(ctx aws.Context, input *secretsmanager.DescribeSecretInput, opts ...request.Option) (*secretsmanager.DescribeSecretOutput, error) { + if m.describeSecretError != nil { + return nil, m.describeSecretError + } + return m.describeSecretOutput, nil +} + +func (m *mockSecretsManager) CreateSecretWithContext(ctx aws.Context, input *secretsmanager.CreateSecretInput, opts ...request.Option) (*secretsmanager.CreateSecretOutput, error) { + if m.createSecretError != nil { + return nil, m.createSecretError + } + return m.createSecretOutput, nil +} + +func (m *mockSecretsManager) UpdateSecretWithContext(ctx aws.Context, input *secretsmanager.UpdateSecretInput, opts ...request.Option) (*secretsmanager.UpdateSecretOutput, error) { + if m.updateSecretError != nil { + return nil, m.updateSecretError + } + return m.updateSecretOutput, nil +} + +func (m *mockSecretsManager) DeleteSecretWithContext(ctx aws.Context, input *secretsmanager.DeleteSecretInput, opts ...request.Option) (*secretsmanager.DeleteSecretOutput, error) { + if m.deleteSecretError != nil { + return nil, m.deleteSecretError + } + return m.deleteSecretOutput, nil +} + +// Helper function to create a test client with a mock secrets manager +func createTestClientWithMock(mockSM secretsmanageriface.SecretsManagerAPI, profile *config.Profile) *AWSClient { + return &AWSClient{ + client: mockSM, + profile: profile, + logger: logrus.New(), + region: "us-east-1", + address: "https://secretsmanager.us-east-1.amazonaws.com", + } +} + +func TestAWSClient_Implements(t *testing.T) { + t.Run("client has all required methods", func(t *testing.T) { + mockSM := &mockSecretsManager{} + profile := &config.Profile{} + client := createTestClientWithMock(mockSM, profile) + + // Verify client implements all required methods + var _ interface { + Authenticate() error + GetAddress() string + GetStatus(context.Context) (models.ConnectionStatus, error) + ListSecrets(string) ([]*models.SecretNode, error) + GetSecret(string) (*models.SecretNode, error) + CreateSecret(string, map[string]any) error + UpdateSecret(string, map[string]any) error + DeleteSecret(string) error + } = client + }) +} + +func TestNewAWSSecretsManagerClient(t *testing.T) { + logger := logrus.New() + logger.SetLevel(logrus.ErrorLevel) + + tests := []struct { + name string + profile *config.Profile + wantError bool + errorContains string + }{ + { + name: "valid profile with credentials", + profile: &config.Profile{ + AuthConfig: config.AuthConfig{ + AWSAccessKeyID: "test-key", + AWSSecretAccessKey: "test-secret", + AWSRegion: "us-west-2", + }, + }, + wantError: false, + }, + { + name: "valid profile with default region", + profile: &config.Profile{ + AuthConfig: config.AuthConfig{ + AWSAccessKeyID: "test-key", + AWSSecretAccessKey: "test-secret", + }, + }, + wantError: false, + }, + { + name: "missing access key", + profile: &config.Profile{ + AuthConfig: config.AuthConfig{ + AWSSecretAccessKey: "test-secret", + }, + }, + wantError: true, + errorContains: "aws_access_key_id", + }, + { + name: "missing secret key", + profile: &config.Profile{ + AuthConfig: config.AuthConfig{ + AWSAccessKeyID: "test-key", + }, + }, + wantError: true, + errorContains: "aws_secret_access_key", + }, + { + name: "with custom endpoint", + profile: &config.Profile{ + Address: "http://localhost:4566", + AuthConfig: config.AuthConfig{ + AWSAccessKeyID: "test-key", + AWSSecretAccessKey: "test-secret", + }, + }, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewAWSSecretsManagerClient(logger, tt.profile) + if tt.wantError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + assert.Nil(t, client) + } else { + // Note: This will fail if AWS credentials are not configured + // For production tests, you'd need actual AWS credentials or skip this test + if err != nil { + t.Skipf("Skipping test: AWS credentials not configured: %v", err) + return + } + require.NoError(t, err) + require.NotNil(t, client) + assert.Equal(t, tt.profile, client.profile) + } + }) + } +} + +func TestAWSClient_Authenticate(t *testing.T) { + tests := []struct { + name string + mockSM *mockSecretsManager + wantError bool + errorContains string + }{ + { + name: "successful authentication", + mockSM: &mockSecretsManager{ + listSecretsOutput: &secretsmanager.ListSecretsOutput{ + SecretList: []*secretsmanager.SecretListEntry{}, + }, + }, + wantError: false, + }, + { + name: "authentication failure", + mockSM: &mockSecretsManager{ + listSecretsError: errors.New("access denied"), + }, + wantError: true, + errorContains: "failed to authenticate", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := createTestClientWithMock(tt.mockSM, &config.Profile{}) + err := client.Authenticate() + if tt.wantError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestAWSClient_GetAddress(t *testing.T) { + tests := []struct { + name string + client *AWSClient + want string + }{ + { + name: "with address set", + client: &AWSClient{ + address: "aws://123456789:us-east-1", + profile: &config.Profile{}, + }, + want: "aws://123456789:us-east-1", + }, + { + name: "without address, falls back to profile", + client: &AWSClient{ + address: "", + profile: &config.Profile{ + Address: "http://localhost:4566", + }, + }, + want: "http://localhost:4566", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.client.GetAddress() + assert.Equal(t, tt.want, got) + }) + } +} + +func TestAWSClient_GetStatus(t *testing.T) { + ctx := context.Background() + testTime := time.Now() + + tests := []struct { + name string + mockSM *mockSecretsManager + wantStatus models.Status + wantError bool + }{ + { + name: "connected", + mockSM: &mockSecretsManager{ + listSecretsOutput: &secretsmanager.ListSecretsOutput{}, + }, + wantStatus: models.StatusConnected, + wantError: false, + }, + { + name: "disconnected", + mockSM: &mockSecretsManager{ + listSecretsError: errors.New("connection failed"), + }, + wantStatus: models.StatusDisconnected, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := createTestClientWithMock(tt.mockSM, &config.Profile{}) + client.region = "us-east-1" + client.address = "https://secretsmanager.us-east-1.amazonaws.com" + + status, err := client.GetStatus(ctx) + if tt.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.wantStatus, status.Status) + if tt.wantStatus == models.StatusConnected { + assert.Equal(t, "us-east-1", status.ClusterID) + assert.Equal(t, "AWS Secrets Manager", status.Version) + } + assert.WithinDuration(t, testTime, status.LastCheck, time.Second) + } + }) + } +} + +func TestAWSClient_ListSecrets(t *testing.T) { + testTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + path string + mockSM *mockSecretsManager + wantError bool + wantCount int + wantSecret bool + wantDir bool + }{ + { + name: "empty list", + path: "", + mockSM: &mockSecretsManager{ + listSecretsOutput: &secretsmanager.ListSecretsOutput{ + SecretList: []*secretsmanager.SecretListEntry{}, + }, + }, + wantError: false, + wantCount: 0, + }, + { + name: "single secret", + path: "", + mockSM: &mockSecretsManager{ + listSecretsOutput: &secretsmanager.ListSecretsOutput{ + SecretList: []*secretsmanager.SecretListEntry{ + { + Name: aws.String("test-secret"), + CreatedDate: &testTime, + }, + }, + }, + }, + wantError: false, + wantCount: 1, + wantSecret: true, + }, + { + name: "nested secrets", + path: "", + mockSM: &mockSecretsManager{ + listSecretsOutput: &secretsmanager.ListSecretsOutput{ + SecretList: []*secretsmanager.SecretListEntry{ + { + Name: aws.String("app/db/password"), + CreatedDate: &testTime, + }, + { + Name: aws.String("app/api/key"), + CreatedDate: &testTime, + }, + }, + }, + }, + wantError: false, + wantCount: 1, // Should return directory "app" + wantDir: true, + }, + { + name: "filtered by path", + path: "app", + mockSM: &mockSecretsManager{ + listSecretsOutput: &secretsmanager.ListSecretsOutput{ + SecretList: []*secretsmanager.SecretListEntry{ + { + Name: aws.String("app/db/password"), + CreatedDate: &testTime, + }, + { + Name: aws.String("other/secret"), + CreatedDate: &testTime, + }, + }, + }, + }, + wantError: false, + wantCount: 1, // Should return directory "db" + }, + { + name: "list error", + path: "", + mockSM: &mockSecretsManager{ + listSecretsError: errors.New("list failed"), + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := createTestClientWithMock(tt.mockSM, &config.Profile{}) + secrets, err := client.ListSecrets(tt.path) + if tt.wantError { + require.Error(t, err) + assert.Nil(t, secrets) + } else { + require.NoError(t, err) + assert.NotNil(t, secrets) // secrets can be empty slice but not nil + assert.Len(t, secrets, tt.wantCount) + if tt.wantSecret && len(secrets) > 0 { + assert.True(t, secrets[0].IsSecret) + } + if tt.wantDir && len(secrets) > 0 { + assert.False(t, secrets[0].IsSecret) + } + } + }) + } +} + +func TestAWSClient_GetSecret(t *testing.T) { + testTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + path string + mockSM *mockSecretsManager + wantError bool + wantJSON bool + wantPlainText bool + errorContains string + }{ + { + name: "JSON secret", + path: "test-secret", + mockSM: &mockSecretsManager{ + getSecretValueOutput: &secretsmanager.GetSecretValueOutput{ + SecretString: aws.String(`{"username":"admin","password":"secret123"}`), + }, + describeSecretOutput: &secretsmanager.DescribeSecretOutput{ + CreatedDate: &testTime, + }, + }, + wantError: false, + wantJSON: true, + }, + { + name: "plain text secret", + path: "test-secret", + mockSM: &mockSecretsManager{ + getSecretValueOutput: &secretsmanager.GetSecretValueOutput{ + SecretString: aws.String("plain-text-secret"), + }, + describeSecretOutput: &secretsmanager.DescribeSecretOutput{ + CreatedDate: &testTime, + }, + }, + wantError: false, + wantPlainText: true, + }, + { + name: "deleted secret", + path: "test-secret", + mockSM: &mockSecretsManager{ + getSecretValueOutput: &secretsmanager.GetSecretValueOutput{ + SecretString: aws.String(`{"key":"value"}`), + }, + describeSecretOutput: &secretsmanager.DescribeSecretOutput{ + CreatedDate: &testTime, + DeletedDate: &testTime, + }, + }, + wantError: false, + }, + { + name: "get secret error", + path: "test-secret", + mockSM: &mockSecretsManager{ + getSecretValueError: errors.New("secret not found"), + }, + wantError: true, + errorContains: "failed to get secret", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := createTestClientWithMock(tt.mockSM, &config.Profile{}) + secret, err := client.GetSecret(tt.path) + if tt.wantError { + require.Error(t, err) + assert.Nil(t, secret) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + require.NotNil(t, secret) + assert.True(t, secret.IsSecret) + assert.Equal(t, tt.path, secret.Path) + if tt.wantJSON { + assert.Contains(t, secret.Data, "username") + assert.Contains(t, secret.Data, "password") + } + if tt.wantPlainText { + assert.Contains(t, secret.Data, "value") + assert.Equal(t, "plain-text-secret", secret.Data["value"]) + } + if tt.mockSM.describeSecretOutput != nil && tt.mockSM.describeSecretOutput.DeletedDate != nil { + assert.True(t, secret.Metadata.Destroyed) + } + } + }) + } +} + +func TestAWSClient_CreateSecret(t *testing.T) { + tests := []struct { + name string + path string + data map[string]any + mockSM *mockSecretsManager + wantError bool + errorContains string + }{ + { + name: "successful creation", + path: "new-secret", + data: map[string]any{ + "key": "value", + }, + mockSM: &mockSecretsManager{ + createSecretOutput: &secretsmanager.CreateSecretOutput{}, + }, + wantError: false, + }, + { + name: "creation error", + path: "new-secret", + data: map[string]any{ + "key": "value", + }, + mockSM: &mockSecretsManager{ + createSecretError: errors.New("secret already exists"), + }, + wantError: true, + errorContains: "failed to create secret", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := createTestClientWithMock(tt.mockSM, &config.Profile{}) + err := client.CreateSecret(tt.path, tt.data) + if tt.wantError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestAWSClient_UpdateSecret(t *testing.T) { + tests := []struct { + name string + path string + data map[string]any + mockSM *mockSecretsManager + wantError bool + errorContains string + }{ + { + name: "successful update", + path: "existing-secret", + data: map[string]any{ + "key": "new-value", + }, + mockSM: &mockSecretsManager{ + updateSecretOutput: &secretsmanager.UpdateSecretOutput{}, + }, + wantError: false, + }, + { + name: "update error", + path: "non-existent-secret", + data: map[string]any{ + "key": "value", + }, + mockSM: &mockSecretsManager{ + updateSecretError: errors.New("secret not found"), + }, + wantError: true, + errorContains: "failed to update secret", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := createTestClientWithMock(tt.mockSM, &config.Profile{}) + err := client.UpdateSecret(tt.path, tt.data) + if tt.wantError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestAWSClient_DeleteSecret(t *testing.T) { + tests := []struct { + name string + path string + mockSM *mockSecretsManager + wantError bool + errorContains string + }{ + { + name: "successful deletion", + path: "secret-to-delete", + mockSM: &mockSecretsManager{ + deleteSecretOutput: &secretsmanager.DeleteSecretOutput{}, + }, + wantError: false, + }, + { + name: "deletion error", + path: "non-existent-secret", + mockSM: &mockSecretsManager{ + deleteSecretError: errors.New("secret not found"), + }, + wantError: true, + errorContains: "failed to delete secret", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := createTestClientWithMock(tt.mockSM, &config.Profile{}) + err := client.DeleteSecret(tt.path) + if tt.wantError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestAWSClient_ListSecrets_PathNormalization(t *testing.T) { + testTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + path string + mockSM *mockSecretsManager + wantCount int + wantPath string + }{ + { + name: "path with leading slash", + path: "/app/db", + mockSM: &mockSecretsManager{ + listSecretsOutput: &secretsmanager.ListSecretsOutput{ + SecretList: []*secretsmanager.SecretListEntry{ + { + Name: aws.String("app/db/password"), + CreatedDate: &testTime, + }, + }, + }, + }, + wantCount: 1, + }, + { + name: "path with trailing slash", + path: "app/db/", + mockSM: &mockSecretsManager{ + listSecretsOutput: &secretsmanager.ListSecretsOutput{ + SecretList: []*secretsmanager.SecretListEntry{ + { + Name: aws.String("app/db/password"), + CreatedDate: &testTime, + }, + }, + }, + }, + wantCount: 1, + }, + { + name: "path with both slashes", + path: "/app/db/", + mockSM: &mockSecretsManager{ + listSecretsOutput: &secretsmanager.ListSecretsOutput{ + SecretList: []*secretsmanager.SecretListEntry{ + { + Name: aws.String("app/db/password"), + CreatedDate: &testTime, + }, + }, + }, + }, + wantCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := createTestClientWithMock(tt.mockSM, &config.Profile{}) + secrets, err := client.ListSecrets(tt.path) + require.NoError(t, err) + assert.Len(t, secrets, tt.wantCount) + }) + } +} + +func TestAWSClient_GetSecret_PathNormalization(t *testing.T) { + testTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + path string + }{ + { + name: "path with leading slash", + path: "/test-secret", + }, + { + name: "path with trailing slash", + path: "test-secret/", + }, + { + name: "path with both slashes", + path: "/test-secret/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockSM := &mockSecretsManager{ + getSecretValueOutput: &secretsmanager.GetSecretValueOutput{ + SecretString: aws.String(`{"key":"value"}`), + }, + describeSecretOutput: &secretsmanager.DescribeSecretOutput{ + CreatedDate: &testTime, + }, + } + client := createTestClientWithMock(mockSM, &config.Profile{}) + secret, err := client.GetSecret(tt.path) + require.NoError(t, err) + require.NotNil(t, secret) + // Path should be normalized (slashes trimmed) + assert.Equal(t, "test-secret", secret.Path) + }) + } +} diff --git a/internal/engines/engines_factory.go b/internal/engines/engines_factory.go index ec69757..c57557a 100644 --- a/internal/engines/engines_factory.go +++ b/internal/engines/engines_factory.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/rvolykh/vui/internal/config" + "github.com/rvolykh/vui/internal/engines/aws" "github.com/rvolykh/vui/internal/engines/vault" "github.com/sirupsen/logrus" ) @@ -22,6 +23,8 @@ func (f *EnginesFactory) SetupEngine(name string, profile *config.Profile) (Secr switch name { case "vault": return vault.NewVaultClient(f.logger, profile) + case "aws/secretsmanager": + return aws.NewAWSSecretsManagerClient(f.logger, profile) default: return nil, fmt.Errorf("unknown engine: %s", name) } diff --git a/internal/engines/engines_factory_test.go b/internal/engines/engines_factory_test.go index f374d77..1286a91 100644 --- a/internal/engines/engines_factory_test.go +++ b/internal/engines/engines_factory_test.go @@ -23,6 +23,21 @@ func TestEnginesFactory_SetupEngine(t *testing.T) { assert.NotNil(t, engine) }) + t.Run("aws_secrets_manager", func(t *testing.T) { + engine, err := factory.SetupEngine("aws/secretsmanager", &config.Profile{ + Engine: "aws/secretsmanager", + Address: "http://localhost:8200", + AuthMethod: "aws", + AuthConfig: config.AuthConfig{ + AWSAccessKeyID: "test-key", + AWSSecretAccessKey: "test-secret", + AWSRegion: "us-west-2", + }, + }) + require.NoError(t, err) + assert.NotNil(t, engine) + }) + t.Run("unknown", func(t *testing.T) { engine, err := factory.SetupEngine("unknown", &config.Profile{ Engine: "unknown", diff --git a/sandbox/docker-compose.yml b/sandbox/docker-compose.yml index 3a7f8d9..18af7ce 100644 --- a/sandbox/docker-compose.yml +++ b/sandbox/docker-compose.yml @@ -33,11 +33,11 @@ services: - ./files/ldap_bootstrap.ldif:/container/service/slapd/assets/config/bootstrap/ldif/50-bootstrap.ldif localstack: - image: localstack/localstack + image: localstack/localstack:4.9.2 ports: - "127.0.0.1:4566:4566" environment: - - SERVICES=sts,iam + - SERVICES=sts,iam,secretsmanager volumes: - "./vol/localstack:/var/lib/localstack"