From 52dd30a2ebfbd6cd9daa3f0225be38daa8a7a611 Mon Sep 17 00:00:00 2001 From: dirathea Date: Fri, 26 Dec 2025 21:53:45 +0100 Subject: [PATCH 01/10] feat: template provider for composing secrets --- CONFIGURATION.md | 27 + internal/cli/root.go | 1 + internal/config/config.go | 32 +- internal/provider/aws/secretsmanager.go | 3 +- internal/provider/aws/secretsmanager_test.go | 10 +- .../provider/azurekeyvault/azurekeyvault.go | 3 +- internal/provider/bitwarden/bitwarden.go | 3 +- internal/provider/bitwarden/bitwarden_sm.go | 3 +- internal/provider/doppler/doppler.go | 4 +- internal/provider/dotenv/dotenv.go | 3 +- internal/provider/dotenv/dotenv_test.go | 12 +- internal/provider/gcsm/gcsm.go | 3 +- internal/provider/gcsm/gcsm_test.go | 5 +- internal/provider/infisical/infisical.go | 3 +- internal/provider/interface.go | 24 +- internal/provider/onepassword/onepassword.go | 3 +- internal/provider/template/template.go | 132 +++++ internal/provider/vault/vault.go | 3 +- internal/provider/vault/vault_test.go | 8 +- internal/secrets/collector.go | 39 +- internal/secrets/ctx.go | 76 +++ tests/end2end/config_test.go | 5 +- tests/end2end/template_test.go | 537 ++++++++++++++++++ 23 files changed, 903 insertions(+), 36 deletions(-) create mode 100644 internal/provider/template/template.go create mode 100644 internal/secrets/ctx.go create mode 100644 tests/end2end/template_test.go diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 34f517d..fa03e18 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -590,6 +590,33 @@ You can also use simple environment variable expansion with `${VAR}` or `$VAR` s path: ${HOME}/.config/myapp/.env ``` +## Template Providers + +Sometimes we need a secret in different form, for example, when we have PG_USERNAME, PG_PASSWORD, PG_HOST, our apps might asked for PG_URI that basically constructed by forming `pgsql://${PG_USERNAME}:${PG_PASSWORD}@${PG_HOST}`. To do that, we can utilize a special `template` provider: + +```yaml +providers: +# Assume this providers has PG_HOST secret +- kind: aws_secretsmanager + id: aws_generic + secret_id: rds/credentials +# This secrets returns PG_USERNAME and PG_PASSWORD +- kind: aws_secretsmanager + id: aws_prod + secret_id: rds/prod/credentials +- kind: template + # list down all providers as dependencies + uses: + - aws_prod + - aws_generic + templates: + # We can construct the URI by referring them here. + # Pay attention to the dot notation. The format is similar to helm yaml template engine. + PG_URI: pgsql://{{.aws_prod.PG_USERNAME}}:{{.aws_prod.PG_PASSWORD}}@{{.aws_generic.PG_HOST}} +``` + +utilizing template provider, you can refer the previous secrets using `{{..}}` to be used for secret builder here. + ## Multiple Providers Each provider loads from a single source. To load multiple secrets from the same provider type, create multiple provider instances: diff --git a/internal/cli/root.go b/internal/cli/root.go index 9ff8efd..f36a864 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -11,6 +11,7 @@ import ( _ "github.com/dirathea/sstart/internal/provider/gcsm" _ "github.com/dirathea/sstart/internal/provider/infisical" _ "github.com/dirathea/sstart/internal/provider/onepassword" + _ "github.com/dirathea/sstart/internal/provider/template" _ "github.com/dirathea/sstart/internal/provider/vault" "github.com/dirathea/sstart/internal/app" "github.com/dirathea/sstart/internal/config" diff --git a/internal/config/config.go b/internal/config/config.go index ed609de..687f7df 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -89,11 +89,13 @@ func (o *OIDCConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { // Each provider loads from a single source. To load multiple secrets from the same provider type, // configure multiple provider instances with the same 'kind' but different 'id' values. type ProviderConfig struct { - Kind string `yaml:"kind"` - ID string `yaml:"id,omitempty"` // Optional: defaults to 'kind'. Required if multiple providers share the same kind - Config map[string]interface{} `yaml:"-"` // Provider-specific configuration (e.g., path, region, endpoint, etc.) - Keys map[string]string `yaml:"keys,omitempty"` // Optional key mappings (source_key: target_key, or "==" to keep same name) - Env EnvVars `yaml:"env,omitempty"` + Kind string `yaml:"kind"` + ID string `yaml:"id,omitempty"` // Optional: defaults to 'kind'. Required if multiple providers share the same kind + Config map[string]interface{} `yaml:"-"` // Provider-specific configuration (e.g., path, region, endpoint, etc.) + Keys map[string]string `yaml:"keys,omitempty"` // Optional key mappings (source_key: target_key, or "==" to keep same name) + Templates map[string]string `yaml:"templates,omitempty"` // Optional templates for template provider (target_key: template_expression) + Env EnvVars `yaml:"env,omitempty"` + Uses []string `yaml:"uses,omitempty"` // Optional list of provider IDs to depend on } // UnmarshalYAML implements custom YAML unmarshaling to capture provider-specific fields @@ -125,6 +127,16 @@ func (p *ProviderConfig) UnmarshalYAML(unmarshal func(interface{}) error) error delete(raw, "keys") } + if templates, ok := raw["templates"].(map[string]interface{}); ok { + p.Templates = make(map[string]string) + for k, v := range templates { + if str, ok := v.(string); ok { + p.Templates[k] = str + } + } + delete(raw, "templates") + } + if env, ok := raw["env"].(map[string]interface{}); ok { p.Env = make(EnvVars) for k, v := range env { @@ -135,6 +147,16 @@ func (p *ProviderConfig) UnmarshalYAML(unmarshal func(interface{}) error) error delete(raw, "env") } + if uses, ok := raw["uses"].([]interface{}); ok { + p.Uses = make([]string, 0, len(uses)) + for _, v := range uses { + if str, ok := v.(string); ok { + p.Uses = append(p.Uses, str) + } + } + delete(raw, "uses") + } + // Everything else goes into Config p.Config = raw if p.Config == nil { diff --git a/internal/provider/aws/secretsmanager.go b/internal/provider/aws/secretsmanager.go index 087bc5a..fa5f05c 100644 --- a/internal/provider/aws/secretsmanager.go +++ b/internal/provider/aws/secretsmanager.go @@ -42,7 +42,8 @@ func (p *SecretsManagerProvider) Name() string { } // Fetch fetches secrets from AWS Secrets Manager -func (p *SecretsManagerProvider) Fetch(ctx context.Context, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { +func (p *SecretsManagerProvider) Fetch(secretContext provider.SecretContext, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { + ctx := secretContext.Ctx // Convert map to strongly typed config struct cfg, err := parseConfig(config) if err != nil { diff --git a/internal/provider/aws/secretsmanager_test.go b/internal/provider/aws/secretsmanager_test.go index e247d9c..35694e3 100644 --- a/internal/provider/aws/secretsmanager_test.go +++ b/internal/provider/aws/secretsmanager_test.go @@ -3,12 +3,14 @@ package aws import ( "context" "testing" + + "github.com/dirathea/sstart/internal/secrets" ) func TestParseConfig(t *testing.T) { tests := []struct { - name string - config map[string]interface{} + name string + config map[string]interface{} wantSecretID string wantRegion string wantEndpoint string @@ -130,7 +132,8 @@ func TestSecretsManagerProvider_Fetch_ConfigValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() - _, err := provider.Fetch(ctx, "test-map", tt.config, nil) + secretContext := secrets.NewEmptySecretContext(ctx) + _, err := provider.Fetch(secretContext, "test-map", tt.config, nil) if (err != nil) != tt.wantErr { t.Errorf("SecretsManagerProvider.Fetch() error = %v, wantErr %v", err, tt.wantErr) @@ -261,4 +264,3 @@ func containsSubstring(s, substr string) bool { } return false } - diff --git a/internal/provider/azurekeyvault/azurekeyvault.go b/internal/provider/azurekeyvault/azurekeyvault.go index 0caabbd..b086483 100644 --- a/internal/provider/azurekeyvault/azurekeyvault.go +++ b/internal/provider/azurekeyvault/azurekeyvault.go @@ -43,7 +43,8 @@ func (p *AzureKeyVaultProvider) Name() string { } // Fetch fetches secrets from Azure Key Vault -func (p *AzureKeyVaultProvider) Fetch(ctx context.Context, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { +func (p *AzureKeyVaultProvider) Fetch(secretContext provider.SecretContext, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { + ctx := secretContext.Ctx // Convert map to strongly typed config struct cfg, err := parseConfig(config) if err != nil { diff --git a/internal/provider/bitwarden/bitwarden.go b/internal/provider/bitwarden/bitwarden.go index d71b171..fa133c7 100644 --- a/internal/provider/bitwarden/bitwarden.go +++ b/internal/provider/bitwarden/bitwarden.go @@ -96,7 +96,8 @@ func (p *BitwardenProvider) Name() string { } // Fetch fetches secrets from personal Bitwarden vault using REST API -func (p *BitwardenProvider) Fetch(ctx context.Context, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { +func (p *BitwardenProvider) Fetch(secretContext provider.SecretContext, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { + ctx := secretContext.Ctx // Convert map to strongly typed config struct cfg, err := parseConfig(config) if err != nil { diff --git a/internal/provider/bitwarden/bitwarden_sm.go b/internal/provider/bitwarden/bitwarden_sm.go index 68ac0dc..abb842b 100644 --- a/internal/provider/bitwarden/bitwarden_sm.go +++ b/internal/provider/bitwarden/bitwarden_sm.go @@ -1,7 +1,6 @@ package bitwarden import ( - "context" "encoding/json" "fmt" "strings" @@ -41,7 +40,7 @@ func (p *BitwardenSMProvider) Name() string { // Fetch fetches all secrets from a Bitwarden Secret Manager project // Only Key-Value pairs are extracted from secrets. Note fields are ignored. -func (p *BitwardenSMProvider) Fetch(ctx context.Context, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { +func (p *BitwardenSMProvider) Fetch(secretContext provider.SecretContext, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { // Convert map to strongly typed config struct cfg, err := parseSMConfig(config) if err != nil { diff --git a/internal/provider/doppler/doppler.go b/internal/provider/doppler/doppler.go index 9c6ade6..d95a353 100644 --- a/internal/provider/doppler/doppler.go +++ b/internal/provider/doppler/doppler.go @@ -1,7 +1,6 @@ package doppler import ( - "context" "encoding/json" "fmt" "io" @@ -58,7 +57,8 @@ func (p *DopplerProvider) Name() string { } // Fetch fetches secrets from Doppler -func (p *DopplerProvider) Fetch(ctx context.Context, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { +func (p *DopplerProvider) Fetch(secretContext provider.SecretContext, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { + ctx := secretContext.Ctx // Parse and validate configuration cfg, err := validateConfig(config) if err != nil { diff --git a/internal/provider/dotenv/dotenv.go b/internal/provider/dotenv/dotenv.go index 61ed31b..4d22a14 100644 --- a/internal/provider/dotenv/dotenv.go +++ b/internal/provider/dotenv/dotenv.go @@ -1,7 +1,6 @@ package dotenv import ( - "context" "fmt" "os" @@ -24,7 +23,7 @@ func (p *DotEnvProvider) Name() string { } // Fetch fetches secrets from a .env file -func (p *DotEnvProvider) Fetch(ctx context.Context, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { +func (p *DotEnvProvider) Fetch(secretContext provider.SecretContext, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { // Extract path from config path, ok := config["path"].(string) if !ok || path == "" { diff --git a/internal/provider/dotenv/dotenv_test.go b/internal/provider/dotenv/dotenv_test.go index e472e6c..c06ff10 100644 --- a/internal/provider/dotenv/dotenv_test.go +++ b/internal/provider/dotenv/dotenv_test.go @@ -5,6 +5,9 @@ import ( "os" "path/filepath" "testing" + + prov "github.com/dirathea/sstart/internal/provider" + "github.com/dirathea/sstart/internal/secrets" ) func TestDotEnvProvider_Name(t *testing.T) { @@ -60,7 +63,8 @@ func TestDotEnvProvider_Fetch_ConfigValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() - _, err := provider.Fetch(ctx, "test-map", tt.config, nil) + secretContext := secrets.NewEmptySecretContext(ctx) + _, err := provider.Fetch(secretContext, "test-map", tt.config, nil) if (err != nil) != tt.wantErr { t.Errorf("DotEnvProvider.Fetch() error = %v, wantErr %v", err, tt.wantErr) @@ -165,9 +169,10 @@ SECRET_VALUE=my-secret-value } ctx := context.Background() + secretContext := secrets.NewSecretContext(ctx, make(prov.ProviderSecretsMap), nil) // Test fetching all keys (empty keys map) - result, err := provider.Fetch(ctx, "test-map", config, nil) + result, err := provider.Fetch(secretContext, "test-map", config, nil) if err != nil { t.Fatalf("DotEnvProvider.Fetch() error = %v", err) } @@ -217,7 +222,8 @@ OTHER_VALUE=should-not-appear } ctx := context.Background() - result, err := provider.Fetch(ctx, "test-map", config, keys) + secretContext := secrets.NewSecretContext(ctx, make(prov.ProviderSecretsMap), nil) + result, err := provider.Fetch(secretContext, "test-map", config, keys) if err != nil { t.Fatalf("DotEnvProvider.Fetch() error = %v", err) } diff --git a/internal/provider/gcsm/gcsm.go b/internal/provider/gcsm/gcsm.go index 23e1cc6..aea5047 100644 --- a/internal/provider/gcsm/gcsm.go +++ b/internal/provider/gcsm/gcsm.go @@ -44,7 +44,8 @@ func (p *GCSMProvider) Name() string { } // Fetch fetches secrets from Google Cloud Secret Manager -func (p *GCSMProvider) Fetch(ctx context.Context, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { +func (p *GCSMProvider) Fetch(secretContext provider.SecretContext, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { + ctx := secretContext.Ctx // Convert map to strongly typed config struct cfg, err := parseConfig(config) if err != nil { diff --git a/internal/provider/gcsm/gcsm_test.go b/internal/provider/gcsm/gcsm_test.go index 25d2457..62947e1 100644 --- a/internal/provider/gcsm/gcsm_test.go +++ b/internal/provider/gcsm/gcsm_test.go @@ -3,6 +3,8 @@ package gcsm import ( "context" "testing" + + "github.com/dirathea/sstart/internal/secrets" ) func TestParseConfig(t *testing.T) { @@ -161,7 +163,8 @@ func TestGCSMProvider_Fetch_ConfigValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() - _, err := provider.Fetch(ctx, "test-map", tt.config, nil) + secretContext := secrets.NewEmptySecretContext(ctx) + _, err := provider.Fetch(secretContext, "test-map", tt.config, nil) if (err != nil) != tt.wantErr { t.Errorf("GCSMProvider.Fetch() error = %v, wantErr %v", err, tt.wantErr) diff --git a/internal/provider/infisical/infisical.go b/internal/provider/infisical/infisical.go index 5942424..b40db5b 100644 --- a/internal/provider/infisical/infisical.go +++ b/internal/provider/infisical/infisical.go @@ -43,7 +43,8 @@ func (p *InfisicalProvider) Name() string { } // Fetch fetches secrets from Infisical -func (p *InfisicalProvider) Fetch(ctx context.Context, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { +func (p *InfisicalProvider) Fetch(secretContext provider.SecretContext, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { + ctx := secretContext.Ctx // Convert map to strongly typed config struct cfg, err := parseConfig(config) if err != nil { diff --git a/internal/provider/interface.go b/internal/provider/interface.go index ed109b3..1859e22 100644 --- a/internal/provider/interface.go +++ b/internal/provider/interface.go @@ -6,12 +6,33 @@ import ( "fmt" ) +// Secrets represents a collection of secret key-value pairs +type Secrets map[string]string + +// ProviderSecretsMap represents secrets organized by provider ID +type ProviderSecretsMap map[string]Secrets + // KeyValue represents a secret key-value pair type KeyValue struct { Key string Value string } +// SecretsResolver provides access to secrets from other providers +// This interface allows providers to access secrets without creating an import cycle +type SecretsResolver interface { + // Get returns secrets for a specific provider ID + Get(id string) map[string]string + // Map returns all provider secrets as a map + Map() map[string]map[string]string +} + +// SecretContext provides context and resolver access to providers +type SecretContext struct { + Ctx context.Context + SecretsResolver SecretsResolver +} + // Provider is the interface that all secret providers must implement type Provider interface { // Name returns the name of the provider @@ -19,7 +40,7 @@ type Provider interface { // Fetch fetches secrets from the provider based on the configuration // config contains provider-specific configuration fields (e.g., path, region, endpoint, etc.) - Fetch(ctx context.Context, mapID string, config map[string]interface{}, keys map[string]string) ([]KeyValue, error) + Fetch(secretContext SecretContext, mapID string, config map[string]interface{}, keys map[string]string) ([]KeyValue, error) } // Registry holds all registered providers @@ -47,4 +68,3 @@ func List() []string { } return kinds } - diff --git a/internal/provider/onepassword/onepassword.go b/internal/provider/onepassword/onepassword.go index 376d708..6fc139d 100644 --- a/internal/provider/onepassword/onepassword.go +++ b/internal/provider/onepassword/onepassword.go @@ -44,7 +44,8 @@ func (p *OnePasswordProvider) Name() string { } // Fetch fetches secrets from 1Password -func (p *OnePasswordProvider) Fetch(ctx context.Context, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { +func (p *OnePasswordProvider) Fetch(secretContext provider.SecretContext, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { + ctx := secretContext.Ctx // Convert map to strongly typed config struct cfg, err := parseConfig(config) if err != nil { diff --git a/internal/provider/template/template.go b/internal/provider/template/template.go new file mode 100644 index 0000000..8d274c9 --- /dev/null +++ b/internal/provider/template/template.go @@ -0,0 +1,132 @@ +package template + +import ( + "bytes" + "fmt" + "regexp" + "text/template" + + "github.com/dirathea/sstart/internal/provider" +) + +// TemplateProvider implements the provider interface for template-based secret manipulation +type TemplateProvider struct{} + +func init() { + provider.Register("template", func() provider.Provider { + return &TemplateProvider{} + }) +} + +// Name returns the provider name +func (p *TemplateProvider) Name() string { + return "template" +} + +// Fetch fetches secrets by resolving template expressions +// The templates map contains template expressions using dot notation: PG_URI: pgsql://{{.aws_prod.PG_USERNAME}}:{{.aws_prod.PG_PASSWORD}}@{{.aws_generic.PG_HOST}} +func (p *TemplateProvider) Fetch(secretContext provider.SecretContext, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { + // Get SecretsResolver from secretContext + resolver := secretContext.SecretsResolver + + // Get templates from config + templatesRaw, ok := config["templates"] + if !ok { + return nil, fmt.Errorf("template provider requires 'templates' field with template expressions") + } + + templates, ok := templatesRaw.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("template provider 'templates' field must be a map") + } + + if len(templates) == 0 { + return nil, fmt.Errorf("template provider requires 'templates' field with template expressions") + } + + // Get available provider IDs from resolver for validation + availableProviders := resolver.Map() + availableProviderIDs := make(map[string]bool) + for providerID := range availableProviders { + availableProviderIDs[providerID] = true + } + + // Resolve each template expression + kvs := make([]provider.KeyValue, 0, len(templates)) + for targetKey, templateExprRaw := range templates { + templateExpr, ok := templateExprRaw.(string) + if !ok { + return nil, fmt.Errorf("template expression for key '%s' must be a string", targetKey) + } + + // Validate that all referenced providers are available in the resolver + referencedProviders := p.extractProviderReferences(templateExpr) + for _, providerID := range referencedProviders { + if !availableProviderIDs[providerID] { + return nil, fmt.Errorf("template for key '%s' references provider '%s' which is not available (not in 'uses' list or provider not found)", targetKey, providerID) + } + } + + resolvedValue, err := p.resolveTemplate(templateExpr, resolver) + if err != nil { + return nil, fmt.Errorf("failed to resolve template for key '%s': %w", targetKey, err) + } + kvs = append(kvs, provider.KeyValue{ + Key: targetKey, + Value: resolvedValue, + }) + } + + return kvs, nil +} + +// resolveTemplate resolves a template expression using Go's text/template package +// Template syntax: {{.provider_id.secret_key}} (dot notation, similar to Helm templates) +// Example: {{.aws_prod.PG_USERNAME}} or {{.aws_generic.PG_HOST}} +func (p *TemplateProvider) resolveTemplate(templateStr string, resolver provider.SecretsResolver) (string, error) { + // Build template data structure from resolver + // Structure: { "provider_id": { "secret_key": "value", ... }, ... } + templateData := make(map[string]map[string]string) + providerSecrets := resolver.Map() + for providerID, secrets := range providerSecrets { + templateData[providerID] = secrets + } + + // Parse the template + tmpl, err := template.New("secret_template").Parse(templateStr) + if err != nil { + return "", fmt.Errorf("failed to parse template: %w", err) + } + + // Execute the template with the data structure + var buf bytes.Buffer + if err := tmpl.Execute(&buf, templateData); err != nil { + return "", fmt.Errorf("failed to execute template: %w", err) + } + + return buf.String(), nil +} + +// extractProviderReferences extracts all provider IDs referenced in a template string +// Template syntax: {{.provider_id.secret_key}} - extracts "provider_id" +func (p *TemplateProvider) extractProviderReferences(templateStr string) []string { + // Regex to match {{.provider_id.secret_key}} pattern + // Matches: {{.provider_id.secret_key}} or {{.provider_id.secret_key}} with optional whitespace + re := regexp.MustCompile(`\{\{\s*\.([a-zA-Z0-9_-]+)\.`) + matches := re.FindAllStringSubmatch(templateStr, -1) + + providerIDs := make(map[string]bool) + for _, match := range matches { + if len(match) > 1 { + providerIDs[match[1]] = true + } + } + + // Convert map to slice + result := make([]string, 0, len(providerIDs)) + for providerID := range providerIDs { + result = append(result, providerID) + } + + return result +} diff --git a/internal/provider/vault/vault.go b/internal/provider/vault/vault.go index a38089d..734fb66 100644 --- a/internal/provider/vault/vault.go +++ b/internal/provider/vault/vault.go @@ -68,7 +68,8 @@ func (p *VaultProvider) Name() string { } // Fetch fetches secrets from HashiCorp Vault -func (p *VaultProvider) Fetch(ctx context.Context, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { +func (p *VaultProvider) Fetch(secretContext provider.SecretContext, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { + ctx := secretContext.Ctx // Convert map to strongly typed config struct cfg, err := parseConfig(config) if err != nil { diff --git a/internal/provider/vault/vault_test.go b/internal/provider/vault/vault_test.go index 1e33c31..23b5281 100644 --- a/internal/provider/vault/vault_test.go +++ b/internal/provider/vault/vault_test.go @@ -3,6 +3,8 @@ package vault import ( "context" "testing" + + "github.com/dirathea/sstart/internal/secrets" ) func TestParseConfigWithAuthOptions(t *testing.T) { @@ -161,7 +163,8 @@ func TestVaultProvider_Fetch_OIDCAuthValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() - _, err := provider.Fetch(ctx, "test-map", tt.config, nil) + secretContext := secrets.NewEmptySecretContext(ctx) + _, err := provider.Fetch(secretContext, "test-map", tt.config, nil) if (err != nil) != tt.wantErr { t.Errorf("VaultProvider.Fetch() error = %v, wantErr %v", err, tt.wantErr) @@ -331,7 +334,8 @@ func TestVaultProvider_Fetch_ConfigValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() - _, err := provider.Fetch(ctx, "test-map", tt.config, nil) + secretContext := secrets.NewEmptySecretContext(ctx) + _, err := provider.Fetch(secretContext, "test-map", tt.config, nil) if (err != nil) != tt.wantErr { t.Errorf("VaultProvider.Fetch() error = %v, wantErr %v", err, tt.wantErr) diff --git a/internal/secrets/collector.go b/internal/secrets/collector.go index 27237d6..4dd524f 100644 --- a/internal/secrets/collector.go +++ b/internal/secrets/collector.go @@ -59,8 +59,10 @@ func NewCollector(cfg *config.Config, opts ...CollectorOption) *Collector { } // Collect fetches secrets from all providers and combines them -func (c *Collector) Collect(ctx context.Context, providerIDs []string) (map[string]string, error) { - secrets := make(map[string]string) +func (c *Collector) Collect(ctx context.Context, providerIDs []string) (provider.Secrets, error) { + secrets := make(provider.Secrets) + // Track secrets by provider ID for template providers + providerSecrets := make(provider.ProviderSecretsMap) // Authenticate with SSO if configured if err := c.authenticateSSO(ctx); err != nil { @@ -93,12 +95,41 @@ func (c *Collector) Collect(ctx context.Context, providerIDs []string) (map[stri // Inject SSO tokens into provider config if available c.injectTokensIntoConfig(expandedConfig) + // Inject templates field for template provider + if len(providerCfg.Templates) > 0 { + // Convert Templates map to map[string]interface{} for config + templatesMap := make(map[string]interface{}) + for k, v := range providerCfg.Templates { + templatesMap[k] = v + } + expandedConfig["templates"] = templatesMap + } + + // Create SecretContext with resolver for providers + // Providers can optionally use SecretsResolver to access secrets from other providers + // This follows the principle of least privilege - providers only access secrets they explicitly request + // If 'uses' is specified, create a filtered resolver that only includes secrets from allowed providers + // If 'uses' is not specified, pass an empty resolver (no access to other providers' secrets) + var secretContext provider.SecretContext + if len(providerCfg.Uses) > 0 { + secretContext = NewSecretContext(ctx, providerSecrets, providerCfg.Uses) + } else { + // Pass empty provider secrets map when 'uses' is not defined + secretContext = NewEmptySecretContext(ctx) + } + // Fetch secrets from this provider's single source - kvs, err := prov.Fetch(ctx, providerCfg.ID, expandedConfig, providerCfg.Keys) + kvs, err := prov.Fetch(secretContext, providerCfg.ID, expandedConfig, providerCfg.Keys) if err != nil { return nil, fmt.Errorf("failed to fetch from provider '%s': %w", providerID, err) } + // Store secrets by provider ID for resolver + providerSecrets[providerID] = make(provider.Secrets) + for _, kv := range kvs { + providerSecrets[providerID][kv.Key] = kv.Value + } + // Merge secrets (later providers override earlier ones) for _, kv := range kvs { secrets[kv.Key] = kv.Value @@ -222,7 +253,7 @@ func expandTemplate(template string) string { } // Redact redacts secrets from text -func Redact(text string, secrets map[string]string) string { +func Redact(text string, secrets provider.Secrets) string { result := text for _, value := range secrets { if len(value) > 0 { diff --git a/internal/secrets/ctx.go b/internal/secrets/ctx.go new file mode 100644 index 0000000..74415bf --- /dev/null +++ b/internal/secrets/ctx.go @@ -0,0 +1,76 @@ +package secrets + +import ( + "context" + + "github.com/dirathea/sstart/internal/provider" +) + +type SecretsResolver struct { + providerSecrets provider.ProviderSecretsMap +} + +func (receiver SecretsResolver) Get(id string) map[string]string { + return receiver.providerSecrets[id] +} + +func (receiver SecretsResolver) Map() map[string]map[string]string { + result := make(map[string]map[string]string) + for pid, psecrets := range receiver.providerSecrets { + result[pid] = psecrets + } + return result +} + +// SetResolver creates a filtered SecretsResolver that only includes secrets from allowed provider IDs +// If allowedProviderIDs is empty or nil, returns an empty resolver (no access to any secrets) +// This is used for security best practices - providers can only access secrets from explicitly allowed providers +func SetResolver(providerSecrets provider.ProviderSecretsMap, allowedProviderIDs []string) provider.SecretsResolver { + // If no allowed provider IDs specified, return empty resolver + if len(allowedProviderIDs) == 0 { + return SecretsResolver{ + providerSecrets: make(provider.ProviderSecretsMap), + } + } + + // Create a map of allowed provider IDs for fast lookup + allowedMap := make(map[string]bool) + for _, id := range allowedProviderIDs { + allowedMap[id] = true + } + + // Create a filtered copy + providerSecretsCopy := make(provider.ProviderSecretsMap) + for pid, psecrets := range providerSecrets { + // Only include secrets from allowed providers + if allowedMap[pid] { + secretsCopy := make(provider.Secrets) + for k, v := range psecrets { + secretsCopy[k] = v + } + providerSecretsCopy[pid] = secretsCopy + } + } + + return SecretsResolver{ + providerSecrets: providerSecretsCopy, + } +} + +func NewEmptySecretContext(ctx context.Context) provider.SecretContext { + return provider.SecretContext{ + Ctx: ctx, + SecretsResolver: SecretsResolver{ + providerSecrets: make(provider.ProviderSecretsMap), + }, + } +} + +// NewSecretContext creates a SecretContext with a filtered resolver that only includes secrets from allowed provider IDs +// If allowedProviderIDs is empty or nil, the resolver will be empty (no access to any secrets) +func NewSecretContext(ctx context.Context, providerSecrets provider.ProviderSecretsMap, allowedProviderIDs []string) provider.SecretContext { + return provider.SecretContext{ + Ctx: ctx, + SecretsResolver: SetResolver(providerSecrets, allowedProviderIDs), + } +} diff --git a/tests/end2end/config_test.go b/tests/end2end/config_test.go index 2c05d92..9ec203c 100644 --- a/tests/end2end/config_test.go +++ b/tests/end2end/config_test.go @@ -12,6 +12,7 @@ import ( _ "github.com/dirathea/sstart/internal/provider/aws" _ "github.com/dirathea/sstart/internal/provider/dotenv" _ "github.com/dirathea/sstart/internal/provider/vault" + "github.com/dirathea/sstart/internal/secrets" ) // TestE2E_Config_AllProviders tests the full flow from YAML config to provider config parsing @@ -649,7 +650,8 @@ providers: // Try to Fetch (will fail for missing connections/credentials, but config parsing should work) ctx := context.Background() - _, err = prov.Fetch(ctx, providerCfg.ID, providerCfg.Config, providerCfg.Keys) + secretContext := secrets.NewEmptySecretContext(ctx) + _, err = prov.Fetch(secretContext, providerCfg.ID, providerCfg.Config, providerCfg.Keys) if (err != nil) != tt.expectParseErr { t.Errorf("Expected parse error: %v, got error: %v", tt.expectParseErr, err) @@ -944,4 +946,3 @@ sso: }) } } - diff --git a/tests/end2end/template_test.go b/tests/end2end/template_test.go new file mode 100644 index 0000000..6afc1a4 --- /dev/null +++ b/tests/end2end/template_test.go @@ -0,0 +1,537 @@ +package end2end + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/dirathea/sstart/internal/config" + _ "github.com/dirathea/sstart/internal/provider/aws" + _ "github.com/dirathea/sstart/internal/provider/template" + "github.com/dirathea/sstart/internal/secrets" +) + +// TestE2E_TemplateProvider tests the template provider functionality +// It sets up multiple AWS Secrets Manager secrets and uses a template provider +// to construct a new secret from them +func TestE2E_TemplateProvider(t *testing.T) { + ctx := context.Background() + + // Setup LocalStack container + localstack := SetupLocalStack(ctx, t) + defer func() { + if err := localstack.Cleanup(); err != nil { + t.Errorf("Failed to terminate localstack container: %v", err) + } + }() + + // Set up first AWS secret with PG_HOST + secretName1 := "test/template/host" + secretData1 := map[string]string{ + "PG_HOST": "db.example.com", + } + SetupAWSSecret(ctx, t, localstack, secretName1, secretData1) + + // Set up second AWS secret with PG_USERNAME and PG_PASSWORD + secretName2 := "test/template/credentials" + secretData2 := map[string]string{ + "PG_USERNAME": "myuser", + "PG_PASSWORD": "mypassword", + } + SetupAWSSecret(ctx, t, localstack, secretName2, secretData2) + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, ".sstart.yml") + + configYAML := fmt.Sprintf(` +providers: + - kind: aws_secretsmanager + id: aws_generic + secret_id: %s + region: us-east-1 + endpoint: %s + keys: + PG_HOST: PG_HOST + + - kind: aws_secretsmanager + id: aws_prod + secret_id: %s + region: us-east-1 + endpoint: %s + keys: + PG_USERNAME: PG_USERNAME + PG_PASSWORD: PG_PASSWORD + + - kind: template + uses: + - aws_prod + - aws_generic + templates: + PG_URI: pgsql://{{.aws_prod.PG_USERNAME}}:{{.aws_prod.PG_PASSWORD}}@{{.aws_generic.PG_HOST}} +`, secretName1, localstack.Endpoint, secretName2, localstack.Endpoint) + + if err := os.WriteFile(configFile, []byte(configYAML), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Load config + cfg, err := config.Load(configFile) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Create collector + collector := secrets.NewCollector(cfg) + + // Collect secrets from all providers + collectedSecrets, err := collector.Collect(ctx, nil) + if err != nil { + t.Fatalf("Failed to collect secrets: %v", err) + } + + // Verify original secrets are present + expectedSecrets := map[string]string{ + "PG_HOST": "db.example.com", + "PG_USERNAME": "myuser", + "PG_PASSWORD": "mypassword", + "PG_URI": "pgsql://myuser:mypassword@db.example.com", + } + + for key, expectedValue := range expectedSecrets { + actualValue, exists := collectedSecrets[key] + if !exists { + t.Errorf("Expected secret '%s' not found", key) + continue + } + if actualValue != expectedValue { + t.Errorf("Secret '%s': expected '%s', got '%s'", key, expectedValue, actualValue) + } + } + + t.Logf("Successfully tested template provider with %d secrets", len(collectedSecrets)) +} + +// TestE2E_TemplateProvider_Complex tests a more complex template with multiple references +func TestE2E_TemplateProvider_Complex(t *testing.T) { + ctx := context.Background() + + // Setup LocalStack container + localstack := SetupLocalStack(ctx, t) + defer func() { + if err := localstack.Cleanup(); err != nil { + t.Errorf("Failed to terminate localstack container: %v", err) + } + }() + + // Set up AWS secrets + secretName1 := "test/template/api" + secretData1 := map[string]string{ + "API_KEY": "secret-api-key-123", + "API_SECRET": "secret-api-secret-456", + } + SetupAWSSecret(ctx, t, localstack, secretName1, secretData1) + + secretName2 := "test/template/db" + secretData2 := map[string]string{ + "DB_HOST": "localhost", + "DB_PORT": "5432", + } + SetupAWSSecret(ctx, t, localstack, secretName2, secretData2) + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, ".sstart.yml") + + configYAML := fmt.Sprintf(` +providers: + - kind: aws_secretsmanager + id: api_secrets + secret_id: %s + region: us-east-1 + endpoint: %s + + - kind: aws_secretsmanager + id: db_config + secret_id: %s + region: us-east-1 + endpoint: %s + + - kind: template + uses: + - api_secrets + - db_config + templates: + API_CONFIG: api_key={{.api_secrets.API_KEY}}&api_secret={{.api_secrets.API_SECRET}} + DB_URL: postgresql://{{.db_config.DB_HOST}}:{{.db_config.DB_PORT}}/mydb +`, secretName1, localstack.Endpoint, secretName2, localstack.Endpoint) + + if err := os.WriteFile(configFile, []byte(configYAML), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Load config + cfg, err := config.Load(configFile) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Create collector + collector := secrets.NewCollector(cfg) + + // Collect secrets from all providers + collectedSecrets, err := collector.Collect(ctx, nil) + if err != nil { + t.Fatalf("Failed to collect secrets: %v", err) + } + + // Verify template secrets + expectedAPIConfig := "api_key=secret-api-key-123&api_secret=secret-api-secret-456" + expectedDBURL := "postgresql://localhost:5432/mydb" + + if collectedSecrets["API_CONFIG"] != expectedAPIConfig { + t.Errorf("API_CONFIG: expected '%s', got '%s'", expectedAPIConfig, collectedSecrets["API_CONFIG"]) + } + + if collectedSecrets["DB_URL"] != expectedDBURL { + t.Errorf("DB_URL: expected '%s', got '%s'", expectedDBURL, collectedSecrets["DB_URL"]) + } + + t.Logf("Successfully tested complex template provider") +} + +// TestE2E_TemplateProvider_EndToEnd tests template provider with actual command execution +func TestE2E_TemplateProvider_EndToEnd(t *testing.T) { + ctx := context.Background() + + // Setup LocalStack container + localstack := SetupLocalStack(ctx, t) + defer func() { + if err := localstack.Cleanup(); err != nil { + t.Errorf("Failed to terminate localstack container: %v", err) + } + }() + + // Set up AWS secrets + secretName1 := "test/template/e2e/host" + secretData1 := map[string]string{ + "PG_HOST": "production.db.example.com", + } + SetupAWSSecret(ctx, t, localstack, secretName1, secretData1) + + secretName2 := "test/template/e2e/creds" + secretData2 := map[string]string{ + "PG_USERNAME": "produser", + "PG_PASSWORD": "prodpass123", + } + SetupAWSSecret(ctx, t, localstack, secretName2, secretData2) + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, ".sstart.yml") + + configYAML := fmt.Sprintf(` +providers: + - kind: aws_secretsmanager + id: aws_generic + secret_id: %s + region: us-east-1 + endpoint: %s + keys: + PG_HOST: PG_HOST + + - kind: aws_secretsmanager + id: aws_prod + secret_id: %s + region: us-east-1 + endpoint: %s + keys: + PG_USERNAME: PG_USERNAME + PG_PASSWORD: PG_PASSWORD + + - kind: template + uses: + - aws_prod + - aws_generic + templates: + PG_URI: pgsql://{{.aws_prod.PG_USERNAME}}:{{.aws_prod.PG_PASSWORD}}@{{.aws_generic.PG_HOST}} +`, secretName1, localstack.Endpoint, secretName2, localstack.Endpoint) + + if err := os.WriteFile(configFile, []byte(configYAML), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + // Create a test script that verifies the template secret + testScript := filepath.Join(tmpDir, "test_template.sh") + scriptContent := `#!/bin/sh +# Verify template-generated secret +if [ "$PG_URI" != "pgsql://produser:prodpass123@production.db.example.com" ]; then + echo "ERROR: PG_URI mismatch. Expected: pgsql://produser:prodpass123@production.db.example.com, Got: $PG_URI" + exit 1 +fi + +# Verify original secrets are also present +if [ "$PG_HOST" != "production.db.example.com" ]; then + echo "ERROR: PG_HOST mismatch. Expected: production.db.example.com, Got: $PG_HOST" + exit 1 +fi + +if [ "$PG_USERNAME" != "produser" ]; then + echo "ERROR: PG_USERNAME mismatch. Expected: produser, Got: $PG_USERNAME" + exit 1 +fi + +if [ "$PG_PASSWORD" != "prodpass123" ]; then + echo "ERROR: PG_PASSWORD mismatch. Expected: prodpass123, Got: $PG_PASSWORD" + exit 1 +fi + +echo "SUCCESS: Template provider works correctly" +exit 0 +` + + if err := os.WriteFile(testScript, []byte(scriptContent), 0755); err != nil { + t.Fatalf("Failed to write test script: %v", err) + } + + // Build sstart binary + sstartBinary := filepath.Join(tmpDir, "sstart") + projectRoot := getProjectRoot(t) + cmdPath := filepath.Join(projectRoot, "cmd", "sstart") + buildCmd := exec.CommandContext(ctx, "go", "build", "-o", sstartBinary, cmdPath) + buildCmd.Dir = projectRoot + if err := buildCmd.Run(); err != nil { + t.Fatalf("Failed to build sstart binary: %v", err) + } + + // Run sstart with the test script + runCmd := exec.CommandContext(ctx, sstartBinary, "--config", configFile, "run", "--", testScript) + runCmd.Dir = tmpDir + output, err := runCmd.CombinedOutput() + if err != nil { + t.Fatalf("Failed to run sstart command: %v\nOutput: %s", err, output) + } + + if !strings.Contains(string(output), "SUCCESS") { + t.Errorf("Test script failed. Output: %s", output) + } +} + +// TestE2E_TemplateProvider_ErrorHandling tests error handling for missing references +func TestE2E_TemplateProvider_ErrorHandling(t *testing.T) { + ctx := context.Background() + + // Setup LocalStack container + localstack := SetupLocalStack(ctx, t) + defer func() { + if err := localstack.Cleanup(); err != nil { + t.Errorf("Failed to terminate localstack container: %v", err) + } + }() + + // Set up AWS secret + secretName := "test/template/error" + secretData := map[string]string{ + "EXISTING_KEY": "existing-value", + } + SetupAWSSecret(ctx, t, localstack, secretName, secretData) + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, ".sstart.yml") + + // Test case 1: Template provider references non-existent provider + configYAML1 := fmt.Sprintf(` +providers: + - kind: aws_secretsmanager + id: aws_existing + secret_id: %s + region: us-east-1 + endpoint: %s + + - kind: template + uses: + - nonexistent_provider + templates: + TEST_KEY: "{{.nonexistent_provider.SOME_KEY}}" +`, secretName, localstack.Endpoint) + + if err := os.WriteFile(configFile, []byte(configYAML1), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + cfg, err := config.Load(configFile) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + collector := secrets.NewCollector(cfg) + _, err = collector.Collect(ctx, nil) + if err == nil { + t.Error("Expected error for non-existent provider reference, got nil") + } else if !strings.Contains(err.Error(), "not available") && !strings.Contains(err.Error(), "nonexistent_provider") { + t.Errorf("Expected error about provider not available, got: %v", err) + } + + // Test case 2: Template provider references non-existent secret key + configYAML2 := fmt.Sprintf(` +providers: + - kind: aws_secretsmanager + id: aws_existing + secret_id: %s + region: us-east-1 + endpoint: %s + + - kind: template + uses: + - aws_existing + templates: + TEST_KEY: "{{.aws_existing.NONEXISTENT_KEY}}" +`, secretName, localstack.Endpoint) + + if err := os.WriteFile(configFile, []byte(configYAML2), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + cfg, err = config.Load(configFile) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + collector = secrets.NewCollector(cfg) + _, err = collector.Collect(ctx, nil) + if err == nil { + t.Error("Expected error for non-existent secret key reference, got nil") + } else if !strings.Contains(err.Error(), "secret key 'NONEXISTENT_KEY' not found") { + t.Errorf("Expected error about secret key not found, got: %v", err) + } + + t.Logf("Successfully tested template provider error handling") +} + +// TestE2E_TemplateProvider_WithoutUses tests that template provider cannot access secrets when 'uses' is not specified +func TestE2E_TemplateProvider_WithoutUses(t *testing.T) { + ctx := context.Background() + + // Setup LocalStack container + localstack := SetupLocalStack(ctx, t) + defer func() { + if err := localstack.Cleanup(); err != nil { + t.Errorf("Failed to terminate localstack container: %v", err) + } + }() + + // Set up AWS secret + secretName := "test/template/without-uses" + secretData := map[string]string{ + "SECRET_KEY": "secret-value", + } + SetupAWSSecret(ctx, t, localstack, secretName, secretData) + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, ".sstart.yml") + + // Template provider without 'uses' should not be able to access other providers' secrets + configYAML := fmt.Sprintf(` +providers: + - kind: aws_secretsmanager + id: aws_secret + secret_id: %s + region: us-east-1 + endpoint: %s + + - kind: template + templates: + TEST_KEY: "{{.aws_secret.SECRET_KEY}}" +`, secretName, localstack.Endpoint) + + if err := os.WriteFile(configFile, []byte(configYAML), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + cfg, err := config.Load(configFile) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + collector := secrets.NewCollector(cfg) + _, err = collector.Collect(ctx, nil) + if err == nil { + t.Error("Expected error when template provider references provider without 'uses' specified, got nil") + } else if !strings.Contains(err.Error(), "not available") && !strings.Contains(err.Error(), "not in 'uses' list") { + t.Errorf("Expected error about provider not available (not in 'uses' list), got: %v", err) + } + + t.Logf("Successfully tested template provider security: cannot access secrets without 'uses'") +} + +// TestE2E_TemplateProvider_UsesNotInList tests that template provider cannot access providers not in 'uses' list +func TestE2E_TemplateProvider_UsesNotInList(t *testing.T) { + ctx := context.Background() + + // Setup LocalStack container + localstack := SetupLocalStack(ctx, t) + defer func() { + if err := localstack.Cleanup(); err != nil { + t.Errorf("Failed to terminate localstack container: %v", err) + } + }() + + // Set up two AWS secrets + secretName1 := "test/template/uses1" + secretData1 := map[string]string{ + "KEY1": "value1", + } + SetupAWSSecret(ctx, t, localstack, secretName1, secretData1) + + secretName2 := "test/template/uses2" + secretData2 := map[string]string{ + "KEY2": "value2", + } + SetupAWSSecret(ctx, t, localstack, secretName2, secretData2) + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, ".sstart.yml") + + // Template provider with 'uses' that only includes aws_secret1, but tries to access aws_secret2 + configYAML := fmt.Sprintf(` +providers: + - kind: aws_secretsmanager + id: aws_secret1 + secret_id: %s + region: us-east-1 + endpoint: %s + + - kind: aws_secretsmanager + id: aws_secret2 + secret_id: %s + region: us-east-1 + endpoint: %s + + - kind: template + uses: + - aws_secret1 + templates: + TEST_KEY: "{{.aws_secret1.KEY1}} and {{.aws_secret2.KEY2}}" +`, secretName1, localstack.Endpoint, secretName2, localstack.Endpoint) + + if err := os.WriteFile(configFile, []byte(configYAML), 0644); err != nil { + t.Fatalf("Failed to write config file: %v", err) + } + + cfg, err := config.Load(configFile) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + collector := secrets.NewCollector(cfg) + _, err = collector.Collect(ctx, nil) + if err == nil { + t.Error("Expected error when template provider references provider not in 'uses' list, got nil") + } else if !strings.Contains(err.Error(), "not available") && !strings.Contains(err.Error(), "not in 'uses' list") { + t.Errorf("Expected error about provider not available (not in 'uses' list), got: %v", err) + } + + t.Logf("Successfully tested template provider security: cannot access providers not in 'uses' list") +} + From 51a2bfc2afbfe42e12acdb33eed9d11dcfc79a80 Mon Sep 17 00:00:00 2001 From: dirathea Date: Fri, 26 Dec 2025 22:07:41 +0100 Subject: [PATCH 02/10] fix: end2end test for template provider --- tests/end2end/template_test.go | 61 ++++++++++------------------------ 1 file changed, 17 insertions(+), 44 deletions(-) diff --git a/tests/end2end/template_test.go b/tests/end2end/template_test.go index 6afc1a4..94e0bea 100644 --- a/tests/end2end/template_test.go +++ b/tests/end2end/template_test.go @@ -340,6 +340,16 @@ func TestE2E_TemplateProvider_ErrorHandling(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, ".sstart.yml") + // Build sstart binary + sstartBinary := filepath.Join(tmpDir, "sstart") + projectRoot := getProjectRoot(t) + cmdPath := filepath.Join(projectRoot, "cmd", "sstart") + buildCmd := exec.CommandContext(ctx, "go", "build", "-o", sstartBinary, cmdPath) + buildCmd.Dir = projectRoot + if err := buildCmd.Run(); err != nil { + t.Fatalf("Failed to build sstart binary: %v", err) + } + // Test case 1: Template provider references non-existent provider configYAML1 := fmt.Sprintf(` providers: @@ -360,50 +370,14 @@ providers: t.Fatalf("Failed to write config file: %v", err) } - cfg, err := config.Load(configFile) - if err != nil { - t.Fatalf("Failed to load config: %v", err) - } - - collector := secrets.NewCollector(cfg) - _, err = collector.Collect(ctx, nil) - if err == nil { - t.Error("Expected error for non-existent provider reference, got nil") - } else if !strings.Contains(err.Error(), "not available") && !strings.Contains(err.Error(), "nonexistent_provider") { - t.Errorf("Expected error about provider not available, got: %v", err) - } - - // Test case 2: Template provider references non-existent secret key - configYAML2 := fmt.Sprintf(` -providers: - - kind: aws_secretsmanager - id: aws_existing - secret_id: %s - region: us-east-1 - endpoint: %s - - - kind: template - uses: - - aws_existing - templates: - TEST_KEY: "{{.aws_existing.NONEXISTENT_KEY}}" -`, secretName, localstack.Endpoint) - - if err := os.WriteFile(configFile, []byte(configYAML2), 0644); err != nil { - t.Fatalf("Failed to write config file: %v", err) - } - - cfg, err = config.Load(configFile) - if err != nil { - t.Fatalf("Failed to load config: %v", err) - } - - collector = secrets.NewCollector(cfg) - _, err = collector.Collect(ctx, nil) + // Run sstart with a simple command - should exit with non-zero status + runCmd := exec.CommandContext(ctx, sstartBinary, "--config", configFile, "run", "--", "echo", "test") + runCmd.Dir = tmpDir + err := runCmd.Run() if err == nil { - t.Error("Expected error for non-existent secret key reference, got nil") - } else if !strings.Contains(err.Error(), "secret key 'NONEXISTENT_KEY' not found") { - t.Errorf("Expected error about secret key not found, got: %v", err) + t.Error("Expected non-zero exit status for non-existent provider reference, got zero") + } else if exitError, ok := err.(*exec.ExitError); !ok || exitError.ExitCode() == 0 { + t.Errorf("Expected non-zero exit status, got: %v", err) } t.Logf("Successfully tested template provider error handling") @@ -534,4 +508,3 @@ providers: t.Logf("Successfully tested template provider security: cannot access providers not in 'uses' list") } - From 4ba02495bd207bc2272a9d69f25ac5560ff52c05 Mon Sep 17 00:00:00 2001 From: dirathea Date: Fri, 26 Dec 2025 22:18:04 +0100 Subject: [PATCH 03/10] fix: unused attributes --- internal/config/config.go | 23 ++++++----------------- internal/secrets/collector.go | 10 ---------- 2 files changed, 6 insertions(+), 27 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 687f7df..9fd3aa3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -89,13 +89,12 @@ func (o *OIDCConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { // Each provider loads from a single source. To load multiple secrets from the same provider type, // configure multiple provider instances with the same 'kind' but different 'id' values. type ProviderConfig struct { - Kind string `yaml:"kind"` - ID string `yaml:"id,omitempty"` // Optional: defaults to 'kind'. Required if multiple providers share the same kind - Config map[string]interface{} `yaml:"-"` // Provider-specific configuration (e.g., path, region, endpoint, etc.) - Keys map[string]string `yaml:"keys,omitempty"` // Optional key mappings (source_key: target_key, or "==" to keep same name) - Templates map[string]string `yaml:"templates,omitempty"` // Optional templates for template provider (target_key: template_expression) - Env EnvVars `yaml:"env,omitempty"` - Uses []string `yaml:"uses,omitempty"` // Optional list of provider IDs to depend on + Kind string `yaml:"kind"` + ID string `yaml:"id,omitempty"` // Optional: defaults to 'kind'. Required if multiple providers share the same kind + Config map[string]interface{} `yaml:"-"` // Provider-specific configuration (e.g., path, region, endpoint, etc.) + Keys map[string]string `yaml:"keys,omitempty"` // Optional key mappings (source_key: target_key, or "==" to keep same name) + Env EnvVars `yaml:"env,omitempty"` + Uses []string `yaml:"uses,omitempty"` // Optional list of provider IDs to depend on } // UnmarshalYAML implements custom YAML unmarshaling to capture provider-specific fields @@ -127,16 +126,6 @@ func (p *ProviderConfig) UnmarshalYAML(unmarshal func(interface{}) error) error delete(raw, "keys") } - if templates, ok := raw["templates"].(map[string]interface{}); ok { - p.Templates = make(map[string]string) - for k, v := range templates { - if str, ok := v.(string); ok { - p.Templates[k] = str - } - } - delete(raw, "templates") - } - if env, ok := raw["env"].(map[string]interface{}); ok { p.Env = make(EnvVars) for k, v := range env { diff --git a/internal/secrets/collector.go b/internal/secrets/collector.go index 4dd524f..cbbb52c 100644 --- a/internal/secrets/collector.go +++ b/internal/secrets/collector.go @@ -95,16 +95,6 @@ func (c *Collector) Collect(ctx context.Context, providerIDs []string) (provider // Inject SSO tokens into provider config if available c.injectTokensIntoConfig(expandedConfig) - // Inject templates field for template provider - if len(providerCfg.Templates) > 0 { - // Convert Templates map to map[string]interface{} for config - templatesMap := make(map[string]interface{}) - for k, v := range providerCfg.Templates { - templatesMap[k] = v - } - expandedConfig["templates"] = templatesMap - } - // Create SecretContext with resolver for providers // Providers can optionally use SecretsResolver to access secrets from other providers // This follows the principle of least privilege - providers only access secrets they explicitly request From b866423071300c158bea9a268adb442ba3f303ee Mon Sep 17 00:00:00 2001 From: dirathea Date: Sat, 27 Dec 2025 21:23:24 +0100 Subject: [PATCH 04/10] fix: provider refactoring --- internal/provider/template/template.go | 94 +++++++++----------------- tests/end2end/template_test.go | 44 +++++++++--- 2 files changed, 66 insertions(+), 72 deletions(-) diff --git a/internal/provider/template/template.go b/internal/provider/template/template.go index 8d274c9..476023d 100644 --- a/internal/provider/template/template.go +++ b/internal/provider/template/template.go @@ -2,13 +2,19 @@ package template import ( "bytes" + "encoding/json" "fmt" - "regexp" "text/template" "github.com/dirathea/sstart/internal/provider" ) +// TemplateConfig represents the configuration for template provider +type TemplateConfig struct { + // Templates is a map of template expressions using dot notation: PG_URI: pgsql://{{.aws_prod.PG_USERNAME}}:{{.aws_prod.PG_PASSWORD}}@{{.aws_generic.PG_HOST}} + Templates map[string]string `yaml:"templates"` +} + // TemplateProvider implements the provider interface for template-based secret manipulation type TemplateProvider struct{} @@ -23,50 +29,40 @@ func (p *TemplateProvider) Name() string { return "template" } +// parseConfig converts a map[string]interface{} to TemplateConfig +func parseConfig(config map[string]interface{}) (*TemplateConfig, error) { + // Use JSON marshaling/unmarshaling for clean conversion + jsonData, err := json.Marshal(config) + if err != nil { + return nil, fmt.Errorf("failed to marshal config: %w", err) + } + + var cfg TemplateConfig + if err := json.Unmarshal(jsonData, &cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + return &cfg, nil +} + // Fetch fetches secrets by resolving template expressions // The templates map contains template expressions using dot notation: PG_URI: pgsql://{{.aws_prod.PG_USERNAME}}:{{.aws_prod.PG_PASSWORD}}@{{.aws_generic.PG_HOST}} func (p *TemplateProvider) Fetch(secretContext provider.SecretContext, mapID string, config map[string]interface{}, keys map[string]string) ([]provider.KeyValue, error) { // Get SecretsResolver from secretContext resolver := secretContext.SecretsResolver - - // Get templates from config - templatesRaw, ok := config["templates"] - if !ok { - return nil, fmt.Errorf("template provider requires 'templates' field with template expressions") - } - - templates, ok := templatesRaw.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("template provider 'templates' field must be a map") + cfg, err := parseConfig(config) + if err != nil { + return nil, fmt.Errorf("invalid template configuration: %w", err) } - if len(templates) == 0 { + // Get templates from config + if len(cfg.Templates) == 0 { return nil, fmt.Errorf("template provider requires 'templates' field with template expressions") } - // Get available provider IDs from resolver for validation - availableProviders := resolver.Map() - availableProviderIDs := make(map[string]bool) - for providerID := range availableProviders { - availableProviderIDs[providerID] = true - } - // Resolve each template expression - kvs := make([]provider.KeyValue, 0, len(templates)) - for targetKey, templateExprRaw := range templates { - templateExpr, ok := templateExprRaw.(string) - if !ok { - return nil, fmt.Errorf("template expression for key '%s' must be a string", targetKey) - } - - // Validate that all referenced providers are available in the resolver - referencedProviders := p.extractProviderReferences(templateExpr) - for _, providerID := range referencedProviders { - if !availableProviderIDs[providerID] { - return nil, fmt.Errorf("template for key '%s' references provider '%s' which is not available (not in 'uses' list or provider not found)", targetKey, providerID) - } - } - + kvs := make([]provider.KeyValue, 0, len(cfg.Templates)) + for targetKey, templateExpr := range cfg.Templates { resolvedValue, err := p.resolveTemplate(templateExpr, resolver) if err != nil { return nil, fmt.Errorf("failed to resolve template for key '%s': %w", targetKey, err) @@ -86,11 +82,7 @@ func (p *TemplateProvider) Fetch(secretContext provider.SecretContext, mapID str func (p *TemplateProvider) resolveTemplate(templateStr string, resolver provider.SecretsResolver) (string, error) { // Build template data structure from resolver // Structure: { "provider_id": { "secret_key": "value", ... }, ... } - templateData := make(map[string]map[string]string) providerSecrets := resolver.Map() - for providerID, secrets := range providerSecrets { - templateData[providerID] = secrets - } // Parse the template tmpl, err := template.New("secret_template").Parse(templateStr) @@ -100,33 +92,9 @@ func (p *TemplateProvider) resolveTemplate(templateStr string, resolver provider // Execute the template with the data structure var buf bytes.Buffer - if err := tmpl.Execute(&buf, templateData); err != nil { + if err := tmpl.Execute(&buf, providerSecrets); err != nil { return "", fmt.Errorf("failed to execute template: %w", err) } return buf.String(), nil } - -// extractProviderReferences extracts all provider IDs referenced in a template string -// Template syntax: {{.provider_id.secret_key}} - extracts "provider_id" -func (p *TemplateProvider) extractProviderReferences(templateStr string) []string { - // Regex to match {{.provider_id.secret_key}} pattern - // Matches: {{.provider_id.secret_key}} or {{.provider_id.secret_key}} with optional whitespace - re := regexp.MustCompile(`\{\{\s*\.([a-zA-Z0-9_-]+)\.`) - matches := re.FindAllStringSubmatch(templateStr, -1) - - providerIDs := make(map[string]bool) - for _, match := range matches { - if len(match) > 1 { - providerIDs[match[1]] = true - } - } - - // Convert map to slice - result := make([]string, 0, len(providerIDs)) - for providerID := range providerIDs { - result = append(result, providerID) - } - - return result -} diff --git a/tests/end2end/template_test.go b/tests/end2end/template_test.go index 94e0bea..3479d5a 100644 --- a/tests/end2end/template_test.go +++ b/tests/end2end/template_test.go @@ -493,18 +493,44 @@ providers: t.Fatalf("Failed to write config file: %v", err) } - cfg, err := config.Load(configFile) + // Build sstart binary + sstartBinary := filepath.Join(tmpDir, "sstart") + projectRoot := getProjectRoot(t) + cmdPath := filepath.Join(projectRoot, "cmd", "sstart") + buildCmd := exec.CommandContext(ctx, "go", "build", "-o", sstartBinary, cmdPath) + buildCmd.Dir = projectRoot + if err := buildCmd.Run(); err != nil { + t.Fatalf("Failed to build sstart binary: %v", err) + } + + // Create a test script that verifies the template resolves with empty value for provider not in 'uses' + testScript := filepath.Join(tmpDir, "test_template.sh") + scriptContent := `#!/bin/sh +# Verify template resolves: aws_secret1.KEY1 should be "value1", aws_secret2.KEY2 should be empty () +if [ "$TEST_KEY" != "value1 and " ]; then + echo "ERROR: TEST_KEY mismatch. Expected: 'value1 and ', Got: '$TEST_KEY'" + exit 1 +fi + +echo "SUCCESS: Template provider correctly resolves with empty value for provider not in 'uses' list" +exit 0 +` + + if err := os.WriteFile(testScript, []byte(scriptContent), 0755); err != nil { + t.Fatalf("Failed to write test script: %v", err) + } + + // Run sstart with the test script - should succeed with empty value for provider not in 'uses' + runCmd := exec.CommandContext(ctx, sstartBinary, "--config", configFile, "run", "--", testScript) + runCmd.Dir = tmpDir + output, err := runCmd.CombinedOutput() if err != nil { - t.Fatalf("Failed to load config: %v", err) + t.Fatalf("Failed to run sstart command: %v\nOutput: %s", err, output) } - collector := secrets.NewCollector(cfg) - _, err = collector.Collect(ctx, nil) - if err == nil { - t.Error("Expected error when template provider references provider not in 'uses' list, got nil") - } else if !strings.Contains(err.Error(), "not available") && !strings.Contains(err.Error(), "not in 'uses' list") { - t.Errorf("Expected error about provider not available (not in 'uses' list), got: %v", err) + if !strings.Contains(string(output), "SUCCESS") { + t.Errorf("Test script failed. Output: %s", output) } - t.Logf("Successfully tested template provider security: cannot access providers not in 'uses' list") + t.Logf("Successfully tested template provider: providers not in 'uses' list resolve to empty values") } From a0fbc212142003e014eab29539fa059dad169209 Mon Sep 17 00:00:00 2001 From: dirathea Date: Sat, 27 Dec 2025 21:33:53 +0100 Subject: [PATCH 05/10] fix: end2end test --- tests/end2end/template_test.go | 77 +++++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 16 deletions(-) diff --git a/tests/end2end/template_test.go b/tests/end2end/template_test.go index 3479d5a..849fdbe 100644 --- a/tests/end2end/template_test.go +++ b/tests/end2end/template_test.go @@ -370,14 +370,33 @@ providers: t.Fatalf("Failed to write config file: %v", err) } - // Run sstart with a simple command - should exit with non-zero status - runCmd := exec.CommandContext(ctx, sstartBinary, "--config", configFile, "run", "--", "echo", "test") + // Create a test script that verifies the template resolves with empty value for non-existent provider + testScript := filepath.Join(tmpDir, "test_template.sh") + scriptContent := `#!/bin/sh +# Verify template resolves to for non-existent provider +if [ "$TEST_KEY" != "" ]; then + echo "ERROR: TEST_KEY mismatch. Expected: '', Got: '$TEST_KEY'" + exit 1 +fi + +echo "SUCCESS: Template provider correctly resolves with empty value for non-existent provider" +exit 0 +` + + if err := os.WriteFile(testScript, []byte(scriptContent), 0755); err != nil { + t.Fatalf("Failed to write test script: %v", err) + } + + // Run sstart with the test script - should succeed with empty value for non-existent provider + runCmd := exec.CommandContext(ctx, sstartBinary, "--config", configFile, "run", "--", testScript) runCmd.Dir = tmpDir - err := runCmd.Run() - if err == nil { - t.Error("Expected non-zero exit status for non-existent provider reference, got zero") - } else if exitError, ok := err.(*exec.ExitError); !ok || exitError.ExitCode() == 0 { - t.Errorf("Expected non-zero exit status, got: %v", err) + output, err := runCmd.CombinedOutput() + if err != nil { + t.Fatalf("Failed to run sstart command: %v\nOutput: %s", err, output) + } + + if !strings.Contains(string(output), "SUCCESS") { + t.Errorf("Test script failed. Output: %s", output) } t.Logf("Successfully tested template provider error handling") @@ -405,7 +424,7 @@ func TestE2E_TemplateProvider_WithoutUses(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, ".sstart.yml") - // Template provider without 'uses' should not be able to access other providers' secrets + // Template provider without 'uses' should resolve to empty value when accessing other providers' secrets configYAML := fmt.Sprintf(` providers: - kind: aws_secretsmanager @@ -423,17 +442,43 @@ providers: t.Fatalf("Failed to write config file: %v", err) } - cfg, err := config.Load(configFile) + // Build sstart binary + sstartBinary := filepath.Join(tmpDir, "sstart") + projectRoot := getProjectRoot(t) + cmdPath := filepath.Join(projectRoot, "cmd", "sstart") + buildCmd := exec.CommandContext(ctx, "go", "build", "-o", sstartBinary, cmdPath) + buildCmd.Dir = projectRoot + if err := buildCmd.Run(); err != nil { + t.Fatalf("Failed to build sstart binary: %v", err) + } + + // Create a test script that verifies the template resolves with empty value when 'uses' is not specified + testScript := filepath.Join(tmpDir, "test_template.sh") + scriptContent := `#!/bin/sh +# Verify template resolves to when 'uses' is not specified +if [ "$TEST_KEY" != "" ]; then + echo "ERROR: TEST_KEY mismatch. Expected: '', Got: '$TEST_KEY'" + exit 1 +fi + +echo "SUCCESS: Template provider correctly resolves with empty value when 'uses' is not specified" +exit 0 +` + + if err := os.WriteFile(testScript, []byte(scriptContent), 0755); err != nil { + t.Fatalf("Failed to write test script: %v", err) + } + + // Run sstart with the test script - should succeed with empty value when 'uses' is not specified + runCmd := exec.CommandContext(ctx, sstartBinary, "--config", configFile, "run", "--", testScript) + runCmd.Dir = tmpDir + output, err := runCmd.CombinedOutput() if err != nil { - t.Fatalf("Failed to load config: %v", err) + t.Fatalf("Failed to run sstart command: %v\nOutput: %s", err, output) } - collector := secrets.NewCollector(cfg) - _, err = collector.Collect(ctx, nil) - if err == nil { - t.Error("Expected error when template provider references provider without 'uses' specified, got nil") - } else if !strings.Contains(err.Error(), "not available") && !strings.Contains(err.Error(), "not in 'uses' list") { - t.Errorf("Expected error about provider not available (not in 'uses' list), got: %v", err) + if !strings.Contains(string(output), "SUCCESS") { + t.Errorf("Test script failed. Output: %s", output) } t.Logf("Successfully tested template provider security: cannot access secrets without 'uses'") From ae09984b0687de69d735d068313ddd534e2b3a6a Mon Sep 17 00:00:00 2001 From: dirathea Date: Sat, 27 Dec 2025 21:48:28 +0100 Subject: [PATCH 06/10] fix: use environment to protect end2end test --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c81465f..df0ecde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: test: name: Run End-to-End Tests runs-on: ubuntu-latest + environment: end2end steps: - name: Checkout code uses: actions/checkout@v4 @@ -29,7 +30,7 @@ jobs: env: INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET }} INFISICAL_UNIVERSAL_AUTH_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID }} - INFISICAL_SITE_URL: ${{ secrets.INFISICAL_SITE_URL }} + INFISICAL_SITE_URL: https://eu.infisical.com with: config: | providers: From d770fd3ec82872dba4e30f8aa02c9c2a66acc10f Mon Sep 17 00:00:00 2001 From: dirathea Date: Sat, 27 Dec 2025 22:13:06 +0100 Subject: [PATCH 07/10] feat: split test between short and full test --- .github/workflows/ci.yml | 142 ++------------------------- .github/workflows/end2end.yml | 76 ++++++++++++++ tests/end2end/bitwarden_sm_test.go | 3 + tests/end2end/bitwarden_test.go | 9 ++ tests/end2end/doppler_test.go | 6 ++ tests/end2end/gcsm_test.go | 6 ++ tests/end2end/infisical_test.go | 12 +++ tests/end2end/multi_provider_test.go | 3 + tests/end2end/onepassword_test.go | 24 +++++ tests/end2end/sso_test.go | 6 ++ 10 files changed, 151 insertions(+), 136 deletions(-) create mode 100644 .github/workflows/end2end.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df0ecde..d30ac85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,8 @@ permissions: jobs: test: - name: Run End-to-End Tests + name: Run short test runs-on: ubuntu-latest - environment: end2end steps: - name: Checkout code uses: actions/checkout@v4 @@ -25,150 +24,21 @@ jobs: with: go-version-file: go.mod - - name: Setup env via sstart - uses: dirathea/setup-sstart-env@main - env: - INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET }} - INFISICAL_UNIVERSAL_AUTH_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID }} - INFISICAL_SITE_URL: https://eu.infisical.com - with: - config: | - providers: - - kind: infisical - project_id: 8aded323-e110-4f48-9c7f-24c275358609 - environment: prod - path: /github - - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v2 - with: - credentials_json: ${{ env.GOOGLE_APPLICATION_CREDENTIALS }} - - - name: Set up Cloud SDK - uses: google-github-actions/setup-gcloud@v2 - - - name: Install Bitwarden CLI - run: | - BW_DOWNLOAD_URL=$(curl -s https://api.github.com/repos/bitwarden/cli/releases/latest | grep '"browser_download_url".*linux.*zip' | head -1 | sed -E 's/.*"([^"]+)".*/\1/') - curl -L "${BW_DOWNLOAD_URL}" -o bw.zip - unzip -q bw.zip - chmod +x bw - sudo mv bw /usr/local/bin/ - rm bw.zip - bw --version - - - name: Determine which tests to run - id: test_filter - uses: actions/github-script@v7 - with: - script: | - try { - const { data: files } = await github.rest.pulls.listFiles({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: context.issue.number, - }); - - const changedFiles = files.map(f => f.filename); - console.log('Changed files:', changedFiles); - - // Map provider directories to test name prefixes - const providerTestMap = { - 'internal/provider/aws/': ['TestE2E_AWSSecretsManager'], - 'internal/provider/azurekeyvault/': ['TestE2E_AzureKeyVault'], - 'internal/provider/bitwarden/': ['TestE2E_Bitwarden', 'TestE2E_BitwardenSM'], - 'internal/provider/doppler/': ['TestE2E_Doppler'], - 'internal/provider/gcsm/': ['TestE2E_GCSM'], - 'internal/provider/infisical/': ['TestE2E_Infisical'], - 'internal/provider/onepassword/': ['TestE2E_OnePassword'], - 'internal/provider/vault/': ['TestE2E_Vault', 'TestE2E_OpenBao'], - 'internal/oidc/': ['TestE2E_SSO'], - }; - - // Core files that require all tests - const corePaths = [ - 'internal/secrets/', - 'internal/config/', - 'internal/app/', - 'internal/cli/', - 'tests/end2end/', - 'cmd/', - ]; - - // Check if any core files changed - const hasCoreChanges = changedFiles.some(file => - corePaths.some(path => file.startsWith(path)) - ); - - if (hasCoreChanges) { - console.log('Core files changed, running all tests'); - core.setOutput('test_filter', ''); - core.setOutput('run_all_tests', 'true'); - core.setOutput('skip_tests', 'false'); - return; - } - - // Find which providers changed - const affectedTests = new Set(); - - for (const file of changedFiles) { - for (const [providerPath, testNames] of Object.entries(providerTestMap)) { - if (file.startsWith(providerPath)) { - testNames.forEach(test => affectedTests.add(test)); - } - } - } - - if (affectedTests.size === 0) { - console.log('No provider changes detected, skipping end2end tests'); - core.setOutput('test_filter', ''); - core.setOutput('run_all_tests', 'false'); - core.setOutput('skip_tests', 'true'); - return; - } - - // Construct test filter regex (matches any of the affected tests) - // Format: TestE2E_(Provider1|Provider2) for go test -run flag - const testFilter = Array.from(affectedTests).join('|'); - console.log('Running selective tests:', testFilter); - core.setOutput('test_filter', testFilter); - core.setOutput('run_all_tests', 'false'); - core.setOutput('skip_tests', 'false'); - } catch (error) { - console.log('Error determining test filter, running all tests:', error.message); - core.setOutput('test_filter', ''); - core.setOutput('run_all_tests', 'true'); - core.setOutput('skip_tests', 'false'); - } - - - name: Run end-to-end tests - if: steps.test_filter.outputs.skip_tests != 'true' + - name: Run tests in short mode env: CGO_LDFLAGS: -lm - # the rest of env supplied by setup-sstart-env run: | - go install gotest.tools/gotestsum - if [ "${{ steps.test_filter.outputs.run_all_tests }}" = "true" ]; then - echo "Running all end-to-end tests" - gotestsum --junitfile test-results.xml --format testname -- ./tests/end2end/... - else - echo "Running selective tests: ${{ steps.test_filter.outputs.test_filter }}" - gotestsum --junitfile test-results.xml --format testname -- -run "${{ steps.test_filter.outputs.test_filter }}" ./tests/end2end/... - fi + echo "Running tests in short mode" + gotestsum --junitfile test-results.xml --format testname -- -short ./tests/end2end/... - name: Publish test results - if: always() && steps.test_filter.outputs.skip_tests != 'true' + if: always() uses: EnricoMi/publish-unit-test-result-action@v2 with: files: test-results.xml - check_name: End-to-End Test Results + check_name: CI Test Results fail_on: 'nothing' comment_mode: off - - - name: Skip end-to-end tests - if: steps.test_filter.outputs.skip_tests == 'true' - run: | - echo "No provider changes detected. Skipping end-to-end tests." build: name: Build diff --git a/.github/workflows/end2end.yml b/.github/workflows/end2end.yml new file mode 100644 index 0000000..16eb2b2 --- /dev/null +++ b/.github/workflows/end2end.yml @@ -0,0 +1,76 @@ +name: End-to-End Tests + +on: + pull_request: + types: [opened, synchronize] + branches: + - main + +permissions: + contents: read + checks: write + pull-requests: write + +jobs: + test: + name: Run End-to-End Tests + runs-on: ubuntu-latest + environment: end2end + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Setup env via sstart + uses: dirathea/setup-sstart-env@main + env: + INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET }} + INFISICAL_UNIVERSAL_AUTH_CLIENT_ID: ${{ secrets.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID }} + INFISICAL_SITE_URL: https://eu.infisical.com + with: + config: | + providers: + - kind: infisical + project_id: 8aded323-e110-4f48-9c7f-24c275358609 + environment: prod + path: /github + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ env.GOOGLE_APPLICATION_CREDENTIALS }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Install Bitwarden CLI + run: | + BW_DOWNLOAD_URL=$(curl -s https://api.github.com/repos/bitwarden/cli/releases/latest | grep '"browser_download_url".*linux.*zip' | head -1 | sed -E 's/.*"([^"]+)".*/\1/') + curl -L "${BW_DOWNLOAD_URL}" -o bw.zip + unzip -q bw.zip + chmod +x bw + sudo mv bw /usr/local/bin/ + rm bw.zip + bw --version + + - name: Run all end-to-end tests + env: + CGO_LDFLAGS: -lm + # the rest of env supplied by setup-sstart-env + run: | + go install gotest.tools/gotestsum + echo "Running all end-to-end tests (including tests that require real services)" + gotestsum --junitfile test-results.xml --format testname -- ./tests/end2end/... + + - name: Publish test results + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: test-results.xml + check_name: End-to-End Test Results + fail_on: 'nothing' + comment_mode: off + diff --git a/tests/end2end/bitwarden_sm_test.go b/tests/end2end/bitwarden_sm_test.go index dda4021..c32b02c 100644 --- a/tests/end2end/bitwarden_sm_test.go +++ b/tests/end2end/bitwarden_sm_test.go @@ -23,6 +23,9 @@ import ( // TestE2E_BitwardenSM tests the Bitwarden Secret Manager provider func TestE2E_BitwardenSM(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real Bitwarden Secret Manager service") + } ctx := context.Background() // Get server URL from environment or use default diff --git a/tests/end2end/bitwarden_test.go b/tests/end2end/bitwarden_test.go index 78a7a19..8b93ba3 100644 --- a/tests/end2end/bitwarden_test.go +++ b/tests/end2end/bitwarden_test.go @@ -20,6 +20,9 @@ import ( // TestE2E_Bitwarden_CLI_FieldsFormat tests the personal Bitwarden provider with fields format func TestE2E_Bitwarden_CLI_FieldsFormat(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real Bitwarden service") + } ctx := context.Background() // Setup Bitwarden CLI (login, unlock, start bw serve) @@ -98,6 +101,9 @@ providers: // TestE2E_Bitwarden_CLI_NoteFormat tests the personal Bitwarden provider with note format func TestE2E_Bitwarden_CLI_NoteFormat(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real Bitwarden service") + } ctx := context.Background() // Setup Bitwarden CLI (login, unlock, start bw serve) @@ -176,6 +182,9 @@ providers: // TestE2E_Bitwarden_CLI_BothFormat tests the personal Bitwarden provider with both format // This tests that fields take precedence over notes when there are duplicate keys func TestE2E_Bitwarden_CLI_BothFormat(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real Bitwarden service") + } ctx := context.Background() // Setup Bitwarden CLI (login, unlock, start bw serve) diff --git a/tests/end2end/doppler_test.go b/tests/end2end/doppler_test.go index 900feaa..938482c 100644 --- a/tests/end2end/doppler_test.go +++ b/tests/end2end/doppler_test.go @@ -21,6 +21,9 @@ import ( // TestE2E_Doppler_WithKeys tests the Doppler provider with key mappings func TestE2E_Doppler_WithKeys(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real Doppler service") + } ctx := context.Background() // Setup Doppler client @@ -113,6 +116,9 @@ providers: // TestE2E_Doppler_NoKeys tests the Doppler provider without key mappings func TestE2E_Doppler_NoKeys(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real Doppler service") + } ctx := context.Background() // Setup Doppler client diff --git a/tests/end2end/gcsm_test.go b/tests/end2end/gcsm_test.go index 1588478..5893d56 100644 --- a/tests/end2end/gcsm_test.go +++ b/tests/end2end/gcsm_test.go @@ -14,6 +14,9 @@ import ( // TestE2E_GCSM_WithKeys tests the GCSM provider using real Google Cloud Secret Manager API with key mappings func TestE2E_GCSM_WithKeys(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real Google Cloud Secret Manager service") + } ctx := context.Background() // Setup GCSM client (uses real API, requires credentials) @@ -98,6 +101,9 @@ providers: // TestE2E_GCSM_NoKeys tests the GCSM provider using real Google Cloud Secret Manager API without key mappings func TestE2E_GCSM_NoKeys(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real Google Cloud Secret Manager service") + } ctx := context.Background() // Setup GCSM client (uses real API, requires credentials) diff --git a/tests/end2end/infisical_test.go b/tests/end2end/infisical_test.go index b2d888a..d94a237 100644 --- a/tests/end2end/infisical_test.go +++ b/tests/end2end/infisical_test.go @@ -22,6 +22,9 @@ import ( // TestE2E_Infisical_WithKeys tests the Infisical provider with key mappings func TestE2E_Infisical_WithKeys(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real Infisical service") + } ctx := context.Background() // Setup Infisical client @@ -114,6 +117,9 @@ providers: // TestE2E_Infisical_NoKeys tests the Infisical provider without key mappings func TestE2E_Infisical_NoKeys(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real Infisical service") + } ctx := context.Background() // Setup Infisical client @@ -202,6 +208,9 @@ providers: // TestE2E_Infisical_WithOptionalParams tests the Infisical provider with optional parameters func TestE2E_Infisical_WithOptionalParams(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real Infisical service") + } ctx := context.Background() // Setup Infisical client @@ -268,6 +277,9 @@ providers: // TestE2E_Infisical_VerifySecretExists tests that the test setup can verify secrets exist func TestE2E_Infisical_VerifySecretExists(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real Infisical service") + } ctx := context.Background() // Setup Infisical client diff --git a/tests/end2end/multi_provider_test.go b/tests/end2end/multi_provider_test.go index cea429a..49a729d 100644 --- a/tests/end2end/multi_provider_test.go +++ b/tests/end2end/multi_provider_test.go @@ -233,6 +233,9 @@ providers: // TestE2E_MultiProvider_All tests all providers together including GCSM func TestE2E_MultiProvider_All(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real Google Cloud Secret Manager service") + } ctx := context.Background() // Setup containers diff --git a/tests/end2end/onepassword_test.go b/tests/end2end/onepassword_test.go index 7099463..e5a8281 100644 --- a/tests/end2end/onepassword_test.go +++ b/tests/end2end/onepassword_test.go @@ -30,6 +30,9 @@ func getKeys(m map[string]string) []string { // TestE2E_OnePassword_SectionField tests fetching a field from a section func TestE2E_OnePassword_SectionField(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real OnePassword service") + } ctx := context.Background() // Setup 1Password client @@ -103,6 +106,9 @@ providers: // TestE2E_OnePassword_TopLevelField tests fetching a top-level field (not in any section) func TestE2E_OnePassword_TopLevelField(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real OnePassword service") + } ctx := context.Background() // Setup 1Password client @@ -173,6 +179,9 @@ providers: // TestE2E_OnePassword_WholeSection tests fetching a whole section from 1Password func TestE2E_OnePassword_WholeSection(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real OnePassword service") + } ctx := context.Background() // Setup 1Password client @@ -257,6 +266,9 @@ providers: // TestE2E_OnePassword_WholeItem_OnlyTopLevelFields tests fetching a whole item that has only top-level fields (no sections) func TestE2E_OnePassword_WholeItem_OnlyTopLevelFields(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real OnePassword service") + } ctx := context.Background() // Setup 1Password client @@ -346,6 +358,9 @@ providers: // TestE2E_OnePassword_WholeItem tests fetching a whole item from 1Password func TestE2E_OnePassword_WholeItem(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real OnePassword service") + } ctx := context.Background() // Setup 1Password client @@ -441,6 +456,9 @@ providers: // TestE2E_OnePassword_WholeItem_NoSectionPrefix tests fetching a whole item without section prefixes func TestE2E_OnePassword_WholeItem_NoSectionPrefix(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real OnePassword service") + } ctx := context.Background() // Setup 1Password client @@ -516,6 +534,9 @@ providers: // TestE2E_OnePassword_WholeSection_NoSectionPrefix tests fetching a whole section without section prefix func TestE2E_OnePassword_WholeSection_NoSectionPrefix(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real OnePassword service") + } ctx := context.Background() // Setup 1Password client @@ -608,6 +629,9 @@ providers: // TestE2E_OnePassword_SectionField_NoSectionPrefix tests fetching a field from a section without section prefix func TestE2E_OnePassword_SectionField_NoSectionPrefix(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real OnePassword service") + } ctx := context.Background() // Setup 1Password client diff --git a/tests/end2end/sso_test.go b/tests/end2end/sso_test.go index ac0b1f7..e8bfba8 100644 --- a/tests/end2end/sso_test.go +++ b/tests/end2end/sso_test.go @@ -141,6 +141,9 @@ func TestE2E_SSO_OIDCClient_TokenStorage(t *testing.T) { // TestE2E_SSO_ClientCredentialsFlow tests the client credentials flow for non-interactive authentication // This test requires a confidential client with client_credentials grant type enabled func TestE2E_SSO_ClientCredentialsFlow(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real OIDC provider") + } ctx := context.Background() // Get SSO configuration from environment @@ -261,6 +264,9 @@ providers: // TestE2E_SSO_ClientCredentialsFlow_WithCustomAuthMount tests client credentials with a custom JWT auth mount path func TestE2E_SSO_ClientCredentialsFlow_WithCustomAuthMount(t *testing.T) { + if testing.Short() { + t.Skip("Skipping test that requires real OIDC provider") + } ctx := context.Background() // Get SSO configuration from environment From a2823457cc3c999c1947dddcbe358485023b8f26 Mon Sep 17 00:00:00 2001 From: dirathea Date: Sat, 27 Dec 2025 22:16:29 +0100 Subject: [PATCH 08/10] fix: gotestsum install --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d30ac85..626f0e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: env: CGO_LDFLAGS: -lm run: | + go install gotest.tools/gotestsum echo "Running tests in short mode" gotestsum --junitfile test-results.xml --format testname -- -short ./tests/end2end/... From bbcddcab97dd8e074aa1a7a4e733b12b3b3a2c63 Mon Sep 17 00:00:00 2001 From: dirathea Date: Sun, 28 Dec 2025 20:47:18 +0100 Subject: [PATCH 09/10] fix: ci test reporting --- .github/workflows/end2end.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/end2end.yml b/.github/workflows/end2end.yml index 16eb2b2..6cd549c 100644 --- a/.github/workflows/end2end.yml +++ b/.github/workflows/end2end.yml @@ -67,6 +67,7 @@ jobs: gotestsum --junitfile test-results.xml --format testname -- ./tests/end2end/... - name: Publish test results + if: always() uses: EnricoMi/publish-unit-test-result-action@v2 with: files: test-results.xml From dd2ae62dde6e8981633e9b2cb96a4d7b6bc77042 Mon Sep 17 00:00:00 2001 From: dirathea Date: Sun, 28 Dec 2025 20:52:05 +0100 Subject: [PATCH 10/10] feat: docs --- CONFIGURATION.md | 110 ++++++++++++++++++++++++++++++++++++++--------- README.md | 35 ++++++++++++--- 2 files changed, 118 insertions(+), 27 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index fa03e18..e232560 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -34,6 +34,7 @@ providers: | `dotenv` | Stable | | `gcloud_secretmanager` | Stable | | `infisical` | Stable | +| `template` | Stable | | `vault` | Stable | ## Provider Configuration @@ -592,30 +593,97 @@ You can also use simple environment variable expansion with `${VAR}` or `$VAR` s ## Template Providers -Sometimes we need a secret in different form, for example, when we have PG_USERNAME, PG_PASSWORD, PG_HOST, our apps might asked for PG_URI that basically constructed by forming `pgsql://${PG_USERNAME}:${PG_PASSWORD}@${PG_HOST}`. To do that, we can utilize a special `template` provider: +The template provider allows you to construct new secrets by combining values from other providers using Go template syntax. This is useful when your application needs secrets in a different format than how they're stored (e.g., building connection URIs from separate credentials). +**Configuration:** +- `uses` (required): List of provider IDs that this template provider depends on. The template provider can only access secrets from providers explicitly listed here (principle of least privilege). +- `templates` (required): Map of output secret keys to template expressions. Each template expression is evaluated using Go's `text/template` package. + +**Template Syntax:** +- Use `{{..}}` to reference secrets from other providers +- The syntax is similar to Helm templates and uses Go's text/template package +- You can use all Go template functions (e.g., `{{if}}`, `{{range}}`, `{{index}}`, etc.) +- Provider IDs and secret keys are case-sensitive + +**Security Model:** +The template provider follows the principle of least privilege: +- Only providers listed in the `uses` field are accessible +- If a provider is not in `uses`, references to it will resolve to empty values +- This ensures templates can only access secrets they explicitly declare as dependencies + +**Provider Order:** +Template providers must be defined after the providers they depend on. Providers are processed in the order they appear in the configuration file, so ensure all source providers are listed before the template provider. + +**Example - Building a Database URI:** +```yaml +providers: + # Fetch database host configuration + - kind: aws_secretsmanager + id: db_config + secret_id: rds/credentials + # Returns: DB_HOST, DB_PORT, DB_NAME + + # Fetch database credentials + - kind: aws_secretsmanager + id: db_creds + secret_id: rds/prod/credentials + # Returns: DB_USER, DB_PASSWORD + + # Build database URI using template provider + - kind: template + uses: + - db_config + - db_creds + templates: + DATABASE_URI: postgresql://{{.db_creds.DB_USER}}:{{.db_creds.DB_PASSWORD}}@{{.db_config.DB_HOST}}:{{.db_config.DB_PORT}}/{{.db_config.DB_NAME}} +``` + +**Example - Multiple Templates:** ```yaml providers: -# Assume this providers has PG_HOST secret -- kind: aws_secretsmanager - id: aws_generic - secret_id: rds/credentials -# This secrets returns PG_USERNAME and PG_PASSWORD -- kind: aws_secretsmanager - id: aws_prod - secret_id: rds/prod/credentials -- kind: template - # list down all providers as dependencies - uses: - - aws_prod - - aws_generic - templates: - # We can construct the URI by referring them here. - # Pay attention to the dot notation. The format is similar to helm yaml template engine. - PG_URI: pgsql://{{.aws_prod.PG_USERNAME}}:{{.aws_prod.PG_PASSWORD}}@{{.aws_generic.PG_HOST}} -``` - -utilizing template provider, you can refer the previous secrets using `{{..}}` to be used for secret builder here. + - kind: aws_secretsmanager + id: api_config + secret_id: api/config + # Returns: API_HOST, API_PORT + + - kind: aws_secretsmanager + id: api_creds + secret_id: api/credentials + # Returns: API_KEY, API_SECRET + + - kind: template + uses: + - api_config + - api_creds + templates: + API_BASE_URL: https://{{.api_config.API_HOST}}:{{.api_config.API_PORT}} + API_AUTH_HEADER: Bearer {{.api_creds.API_KEY}} + API_FULL_URL: https://{{.api_config.API_HOST}}:{{.api_config.API_PORT}}/v1?key={{.api_creds.API_KEY}} +``` + +**Example - Using Template Functions:** +```yaml +providers: + - kind: aws_secretsmanager + id: config + secret_id: app/config + # Returns: ENV (e.g., "production") + + - kind: template + uses: + - config + templates: + # Use conditional logic based on secret values + LOG_LEVEL: {{if eq .config.ENV "production"}}error{{else}}debug{{end}} + # Combine multiple template expressions + APP_ENV: {{.config.ENV}} +``` + +**Error Handling:** +- If a referenced provider ID doesn't exist, the template will fail with an error +- If a referenced secret key doesn't exist in a provider, it will resolve to an empty value +- If `uses` is not specified or empty, all provider references will resolve to empty values +- Template parsing errors will be reported with the specific template expression that failed ## Multiple Providers diff --git a/README.md b/README.md index 39727e6..75e918b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ You define all your required secrets from all your sources in a single, declarat - 🔐 **Multiple Secret Providers**: Support for 1Password, AWS Secrets Manager, Azure Key Vault, Bitwarden, Doppler, HashiCorp Vault, GCP Secret Manager, dotenv files, and more - 🔄 **Combine Secrets**: Merge secrets from multiple providers +- 🧩 **Template Providers**: Construct new secrets by combining values from other providers using Go template syntax (e.g., build database URIs from separate credentials) - 🚀 **Subprocess Execution**: Automatically inject secrets into subprocesses - 🔒 **Secure by Default**: Secrets never appear in shell history or logs - ⚙️ **YAML Configuration**: Easy-to-use configuration file @@ -47,12 +48,6 @@ curl -L https://github.com/dirathea/sstart/releases/latest/download/sstart_Darwi sudo mv sstart /usr/local/bin/ ``` -**Windows:** -```powershell -# Download and extract from https://github.com/dirathea/sstart/releases/latest -# Add sstart.exe to your PATH -``` - **Using a specific version:** Replace `latest` with a version tag (e.g., `v1.0.0`) in the URLs above. @@ -157,6 +152,7 @@ See [CONFIGURATION.md](CONFIGURATION.md) for complete configuration documentatio - Configuration file structure - All supported providers and their options - Authentication methods +- Template providers for constructing secrets from other providers - Template variables - Multiple provider setup - Key mappings @@ -175,6 +171,33 @@ sstart run -- node index.js docker run --rm -it --env-file <(sstart env) node:18-alpine sh ``` +### Using Template Providers + +Construct new secrets by combining values from other providers: + +```yaml +providers: + # Fetch database credentials from AWS Secrets Manager + - kind: aws_secretsmanager + id: db_creds + secret_id: rds/prod/credentials + + # Fetch database host from another source + - kind: aws_secretsmanager + id: db_config + secret_id: rds/config + + # Build database URI using template provider + - kind: template + uses: + - db_creds + - db_config + templates: + DATABASE_URI: postgresql://{{.db_creds.DB_USER}}:{{.db_creds.DB_PASSWORD}}@{{.db_config.DB_HOST}}:{{.db_config.DB_PORT}}/{{.db_config.DB_NAME}} +``` + +Template syntax uses `{{..}}` notation (similar to Helm templates). See [CONFIGURATION.md](CONFIGURATION.md) for more details. + ## Security