package secrets import ( "context" "os" "strings" "sync" "time" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/vault/kv" "go.uber.org/zap" ) const ( defaultVaultField = "value" vaultRefPrefix = "vault:" ) type cacheEntry struct { value string expiresAt time.Time } type provider struct { logger mlogger.Logger static map[string]string ttl time.Duration vault kv.Client vaultEnabled bool vaultDefField string mu sync.RWMutex cache map[string]cacheEntry } func newProvider(opts Options) (Provider, error) { logger := opts.Logger if logger == nil { logger = zap.NewNop() } static := map[string]string{} for k, v := range opts.Static { key := strings.TrimSpace(k) if key == "" { continue } static[key] = v } vaultField := strings.TrimSpace(opts.Vault.DefaultField) if vaultField == "" { vaultField = defaultVaultField } var vaultClient kv.Client vaultEnabled := false hasVaultConfig := strings.TrimSpace(opts.Vault.Config.Address) != "" || strings.TrimSpace(opts.Vault.Config.TokenEnv) != "" || strings.TrimSpace(opts.Vault.Config.MountPath) != "" if hasVaultConfig { client, err := kv.New(kv.Options{ Logger: logger.Named("vault"), Config: opts.Vault.Config, Component: "callbacks secrets", }) if err != nil { return nil, err } vaultClient = client vaultEnabled = true } return &provider{ logger: logger.Named("secrets"), static: static, ttl: opts.CacheTTL, vault: vaultClient, vaultEnabled: vaultEnabled, vaultDefField: vaultField, cache: map[string]cacheEntry{}, }, nil } func (p *provider) GetSecret(ctx context.Context, ref string) (string, error) { key := strings.TrimSpace(ref) if key == "" { return "", merrors.InvalidArgument("secret reference is required", "secret_ref") } if ctx == nil { ctx = context.Background() } if value, ok := p.fromCache(key); ok { return value, nil } value, err := p.resolve(ctx, key) if err != nil { return "", err } if strings.TrimSpace(value) == "" { return "", merrors.NoData("secret reference resolved to empty value") } p.toCache(key, value) return value, nil } func (p *provider) resolve(ctx context.Context, key string) (string, error) { if value, ok := p.static[key]; ok { return value, nil } if strings.HasPrefix(key, "env:") { envKey := strings.TrimSpace(strings.TrimPrefix(key, "env:")) if envKey == "" { return "", merrors.InvalidArgument("secret env reference is invalid", "secret_ref") } value := strings.TrimSpace(os.Getenv(envKey)) if value == "" { return "", merrors.NoData("secret env variable not set: " + envKey) } return value, nil } if strings.HasPrefix(strings.ToLower(key), vaultRefPrefix) && !p.vaultEnabled { return "", merrors.InvalidArgument("vault secret reference provided but vault is not configured", "secret_ref") } if p.vaultEnabled { value, resolved, err := p.resolveVault(ctx, key) if err != nil { return "", err } if resolved { return value, nil } } return "", merrors.NoData("secret reference not found: " + key) } func (p *provider) resolveVault(ctx context.Context, ref string) (string, bool, error) { path, field, resolved, err := parseVaultRef(ref, p.vaultDefField) if err != nil { return "", false, err } if !resolved { return "", false, nil } value, err := p.vault.GetString(ctx, path, field) if err != nil { p.logger.Warn("Failed to resolve vault secret", zap.String("path", path), zap.String("field", field), zap.Error(err)) return "", true, err } return value, true, nil } func parseVaultRef(ref, defaultField string) (string, string, bool, error) { raw := strings.TrimSpace(ref) lowered := strings.ToLower(raw) explicit := false if strings.HasPrefix(lowered, vaultRefPrefix) { explicit = true raw = strings.TrimSpace(raw[len(vaultRefPrefix):]) } if !explicit && !strings.Contains(raw, "/") && !strings.Contains(raw, "#") { return "", "", false, nil } field := strings.TrimSpace(defaultField) if field == "" { field = defaultVaultField } if idx := strings.Index(raw, "#"); idx >= 0 { field = strings.TrimSpace(raw[idx+1:]) raw = strings.TrimSpace(raw[:idx]) if field == "" { return "", "", false, merrors.InvalidArgument("vault secret field is required", "secret_ref") } } path := strings.Trim(strings.TrimSpace(raw), "/") if path == "" { return "", "", false, merrors.InvalidArgument("vault secret path is required", "secret_ref") } return path, field, true, nil } func (p *provider) fromCache(key string) (string, bool) { if p.ttl <= 0 { return "", false } p.mu.RLock() entry, ok := p.cache[key] p.mu.RUnlock() if !ok { return "", false } if time.Now().After(entry.expiresAt) { p.mu.Lock() delete(p.cache, key) p.mu.Unlock() return "", false } return entry.value, true } func (p *provider) toCache(key, value string) { if p.ttl <= 0 { return } p.mu.Lock() p.cache[key] = cacheEntry{ value: value, expiresAt: time.Now().Add(p.ttl), } p.mu.Unlock() }