Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions examples/.env.templates
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
API_KEY=sk_live_1234567890abcdef
APP_NAME=MyAwesomeApp

PG_USER=user
PG_PASS=password
PG_HOST=localhost
PG_DATABASE=mydb
15 changes: 15 additions & 0 deletions examples/.sstart.yml.templates
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Templates Usage Example
# This demonstrates using templates: It allows you to build a secret as a
# composite of multiple secrets from the source provider

inherit: false

providers:
- kind: dotenv
path: .env.templates
keys:
API_KEY: API_SECRET_KEY
APP_NAME: ==
templates: # Use classic Go text/template syntax
PG_DSN: |
postgresql://{{ .Env.PG_USER }}:{{ .Env.PG_PASS }}@{{ .Env.PG_HOST }}:5432/{{ .Env.PG_DATABASE }}
Comment on lines +10 to +15
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a weird feeling about this. The keys field is src -> dst, meanwhile templates is dst <- src1+src2 :/

But templates reversing is impossible, and reversing keys breaks the backward compatibility.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why having separate attributes for the templates ? I guess it should be easier to directly apply it to the keys. It would be similar to expandConfigTemplates

providers:
  - kind: dotenv
    path: .env.templates
    keys:
      PG_DSN: postgresql://{{ .PG_USER }}:{{ .PG_PASS }}@{{ .PG_HOST }}:5432/{{ .PG_DATABASE }}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but then we need to fix the behavior of keys. Previously the idea is to have advanced mapping under the keys so that user can rename the key upon load. This also could be used to choose the keys to load.

When I saw my example, now it's ambiguous. Is it load everything plus PG_DSN, or it only contains PG_DSN. Based on the current behaviour, it will be only PG_DSN. 🤔

1 change: 0 additions & 1 deletion internal/app/runner_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,3 @@ func registerSignals(sigChan chan os.Signal) {
// Register for interrupt and terminate signals
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
}

1 change: 0 additions & 1 deletion internal/app/runner_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,3 @@ func registerSignals(sigChan chan os.Signal) {
// On Windows, only os.Interrupt (Ctrl+C) is available
signal.Notify(sigChan, os.Interrupt)
}

4 changes: 1 addition & 3 deletions internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@ import (
"github.com/spf13/cobra"
)

var (
runProviders []string
)
var runProviders []string

var runCmd = &cobra.Command{
Use: "run [flags] -- <command> [args...]",
Expand Down
1 change: 0 additions & 1 deletion internal/cli/sh.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,3 @@ Example:
func init() {
rootCmd.AddCommand(shCmd)
}

29 changes: 23 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,27 @@ package config
import (
"fmt"
"os"
"text/template"

"gopkg.in/yaml.v3"
)

// Config represents the main configuration structure
type Config struct {
Inherit bool `yaml:"inherit"` // Whether to inherit system environment variables (default: true)
Inherit bool `yaml:"inherit"` // Whether to inherit system environment variables (default: true)
Providers []ProviderConfig `yaml:"providers"`
}

// ProviderConfig represents a single provider configuration
// 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 []*template.Template `yaml:"templates,omitempty"` // Optional templates mappings (target_key: str(Go template))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note for me: fix the comment, it's not a map

Env EnvVars `yaml:"env,omitempty"`
}

// UnmarshalYAML implements custom YAML unmarshaling to capture provider-specific fields
Expand Down Expand Up @@ -53,6 +55,21 @@ func (p *ProviderConfig) UnmarshalYAML(unmarshal func(interface{}) error) error
delete(raw, "keys")
}

if templates, ok := raw["templates"].(map[string]interface{}); ok {
for k, v := range templates {
str, ok := v.(string)
if !ok {
return fmt.Errorf("invalid template format")
}
tmpl := template.New(k)
if _, err := tmpl.Parse(str); err != nil {
return err
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note for me: use fmt.Errorf

}
p.Templates = append(p.Templates, tmpl)
}
delete(raw, "templates")
}

if env, ok := raw["env"].(map[string]interface{}); ok {
p.Env = make(EnvVars)
for k, v := range env {
Expand Down
14 changes: 7 additions & 7 deletions internal/config/config_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ providers:
// Create temporary YAML file
tmpDir := t.TempDir()
yamlFile := filepath.Join(tmpDir, "test.yml")
if err := os.WriteFile(yamlFile, []byte(tt.yamlContent), 0644); err != nil {
if err := os.WriteFile(yamlFile, []byte(tt.yamlContent), 0o644); err != nil {
t.Fatalf("Failed to create test YAML file: %v", err)
}

Expand Down Expand Up @@ -286,7 +286,7 @@ providers:
// Create temporary YAML file
tmpDir := t.TempDir()
yamlFile := filepath.Join(tmpDir, "test.yml")
if err := os.WriteFile(yamlFile, []byte(tt.yamlContent), 0644); err != nil {
if err := os.WriteFile(yamlFile, []byte(tt.yamlContent), 0o644); err != nil {
t.Fatalf("Failed to create test YAML file: %v", err)
}

Expand Down Expand Up @@ -336,7 +336,7 @@ providers:

tmpDir := t.TempDir()
yamlFile := filepath.Join(tmpDir, "test.yml")
if err := os.WriteFile(yamlFile, []byte(yamlContent), 0644); err != nil {
if err := os.WriteFile(yamlFile, []byte(yamlContent), 0o644); err != nil {
t.Fatalf("Failed to create test YAML file: %v", err)
}

Expand Down Expand Up @@ -416,7 +416,7 @@ providers:

tmpDir := t.TempDir()
yamlFile := filepath.Join(tmpDir, "test.yml")
if err := os.WriteFile(yamlFile, []byte(yamlContent), 0644); err != nil {
if err := os.WriteFile(yamlFile, []byte(yamlContent), 0o644); err != nil {
t.Fatalf("Failed to create test YAML file: %v", err)
}

Expand Down Expand Up @@ -473,7 +473,7 @@ providers:

tmpDir := t.TempDir()
yamlFile := filepath.Join(tmpDir, "test.yml")
if err := os.WriteFile(yamlFile, []byte(yamlContent), 0644); err != nil {
if err := os.WriteFile(yamlFile, []byte(yamlContent), 0o644); err != nil {
t.Fatalf("Failed to create test YAML file: %v", err)
}

Expand Down Expand Up @@ -614,7 +614,7 @@ providers:
// Create temporary YAML file
tmpDir := t.TempDir()
yamlFile := filepath.Join(tmpDir, "test.yml")
if err := os.WriteFile(yamlFile, []byte(tt.yamlContent), 0644); err != nil {
if err := os.WriteFile(yamlFile, []byte(tt.yamlContent), 0o644); err != nil {
t.Fatalf("Failed to create test YAML file: %v", err)
}

Expand Down Expand Up @@ -643,7 +643,7 @@ 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)
_, err = prov.Fetch(ctx, providerCfg.ID, providerCfg.Config)

if (err != nil) != tt.expectParseErr {
t.Errorf("Expected parse error: %v, got error: %v", tt.expectParseErr, err)
Expand Down
21 changes: 2 additions & 19 deletions internal/provider/aws/secretsmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ 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(ctx context.Context, mapID string, config map[string]interface{}) ([]provider.KeyValue, error) {
// Convert map to strongly typed config struct
cfg, err := parseConfig(config)
if err != nil {
Expand Down Expand Up @@ -87,26 +87,9 @@ func (p *SecretsManagerProvider) Fetch(ctx context.Context, mapID string, config
// Map keys according to configuration
kvs := make([]provider.KeyValue, 0)
for k, v := range secretData {
targetKey := k

// Check if there's a specific mapping
if mappedKey, exists := keys[k]; exists {
if mappedKey == "==" {
targetKey = k // Keep same name
} else {
targetKey = mappedKey
}
} else if len(keys) == 0 {
// No keys specified means map everything
targetKey = k
} else {
// Skip keys not in the mapping
continue
}

value := fmt.Sprintf("%v", v)
kvs = append(kvs, provider.KeyValue{
Key: targetKey,
Key: k,
Value: value,
})
}
Expand Down
7 changes: 3 additions & 4 deletions internal/provider/aws/secretsmanager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import (

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
Expand Down Expand Up @@ -130,7 +130,7 @@ 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)
_, err := provider.Fetch(ctx, "test-map", tt.config)

if (err != nil) != tt.wantErr {
t.Errorf("SecretsManagerProvider.Fetch() error = %v, wantErr %v", err, tt.wantErr)
Expand Down Expand Up @@ -261,4 +261,3 @@ func containsSubstring(s, substr string) bool {
}
return false
}

21 changes: 2 additions & 19 deletions internal/provider/azurekeyvault/azurekeyvault.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ 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(ctx context.Context, mapID string, config map[string]interface{}) ([]provider.KeyValue, error) {
// Convert map to strongly typed config struct
cfg, err := parseConfig(config)
if err != nil {
Expand Down Expand Up @@ -100,26 +100,9 @@ func (p *AzureKeyVaultProvider) Fetch(ctx context.Context, mapID string, config
// Map keys according to configuration
kvs := make([]provider.KeyValue, 0)
for k, v := range secretData {
targetKey := k

// Check if there's a specific mapping
if mappedKey, exists := keys[k]; exists {
if mappedKey == "==" {
targetKey = k // Keep same name
} else {
targetKey = mappedKey
}
} else if len(keys) == 0 {
// No keys specified means map everything
targetKey = k
} else {
// Skip keys not in the mapping
continue
}

value := fmt.Sprintf("%v", v)
kvs = append(kvs, provider.KeyValue{
Key: targetKey,
Key: k,
Value: value,
})
}
Expand Down
29 changes: 6 additions & 23 deletions internal/provider/bitwarden/bitwarden.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ 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(ctx context.Context, mapID string, config map[string]interface{}) ([]provider.KeyValue, error) {
// Convert map to strongly typed config struct
cfg, err := parseConfig(config)
if err != nil {
Expand Down Expand Up @@ -218,7 +218,7 @@ func (p *BitwardenProvider) Fetch(ctx context.Context, mapID string, config map[
case "both":
// Parse both notes and fields, with fields taking precedence
secretData = make(map[string]interface{})

// First, parse notes as JSON (if available)
if item.Notes != "" {
var noteData map[string]interface{}
Expand All @@ -229,14 +229,14 @@ func (p *BitwardenProvider) Fetch(ctx context.Context, mapID string, config map[
}
}
}

// Then, add fields (which will override any duplicate keys from notes)
for _, field := range item.Fields {
if field.Type == 0 || field.Type == 1 { // Text or Hidden
secretData[field.Name] = field.Value
}
}

// Also include login credentials if available
if item.Login != nil {
if item.Login.Username != "" {
Expand All @@ -246,7 +246,7 @@ func (p *BitwardenProvider) Fetch(ctx context.Context, mapID string, config map[
secretData["password"] = item.Login.Password
}
}

if len(secretData) == 0 {
return nil, fmt.Errorf("bitwarden item '%s' has no fields or notes for 'both' format", cfg.ItemID)
}
Expand Down Expand Up @@ -283,26 +283,9 @@ func (p *BitwardenProvider) Fetch(ctx context.Context, mapID string, config map[
// Map keys according to configuration
kvs := make([]provider.KeyValue, 0)
for k, v := range secretData {
targetKey := k

// Check if there's a specific mapping
if mappedKey, exists := keys[k]; exists {
if mappedKey == "==" {
targetKey = k // Keep same name
} else {
targetKey = mappedKey
}
} else if len(keys) == 0 {
// No keys specified means map everything
targetKey = k
} else {
// Skip keys not in the mapping
continue
}

value := fmt.Sprintf("%v", v)
kvs = append(kvs, provider.KeyValue{
Key: targetKey,
Key: k,
Value: value,
})
}
Expand Down
22 changes: 2 additions & 20 deletions internal/provider/bitwarden/bitwarden_sm.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,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(ctx context.Context, mapID string, config map[string]interface{}) ([]provider.KeyValue, error) {
// Convert map to strongly typed config struct
cfg, err := parseSMConfig(config)
if err != nil {
Expand Down Expand Up @@ -124,26 +124,9 @@ func (p *BitwardenSMProvider) Fetch(ctx context.Context, mapID string, config ma
// Map keys according to configuration
kvs := make([]provider.KeyValue, 0)
for k, v := range secretData {
targetKey := k

// Check if there's a specific mapping
if mappedKey, exists := keys[k]; exists {
if mappedKey == "==" {
targetKey = k // Keep same name
} else {
targetKey = mappedKey
}
} else if len(keys) == 0 {
// No keys specified means map everything
targetKey = k
} else {
// Skip keys not in the mapping
continue
}

value := fmt.Sprintf("%v", v)
kvs = append(kvs, provider.KeyValue{
Key: targetKey,
Key: k,
Value: value,
})
}
Expand Down Expand Up @@ -194,7 +177,6 @@ func (p *BitwardenSMProvider) ensureClient(serverURL, accessToken string) error
return nil
}


// parseSMConfig converts a map[string]interface{} to BitwardenSMConfig
func parseSMConfig(config map[string]interface{}) (*BitwardenSMConfig, error) {
// Use JSON marshaling/unmarshaling for clean conversion
Expand Down
Loading