Files
sendico/api/edge/callbacks/internal/secrets/service.go
2026-02-28 10:10:26 +01:00

225 lines
5.0 KiB
Go

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()
}