0.27.0 — Infisical hardening: runtime refresh, audit logging, CLI verify/audit, startup verification, SSH host key verification, interface consolidation
This commit is contained in:
@@ -1,10 +1,9 @@
|
||||
// Package secrets abstracts secret retrieval across backends (SOPS, Infisical).
|
||||
// Phase 5: SOPS → Infisical migration with SOPS DR fallback.
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -14,24 +13,23 @@ var ErrBackendUnavailable = errors.New("secret backend unavailable")
|
||||
|
||||
// Backend is the interface for retrieving and storing secrets.
|
||||
type Backend interface {
|
||||
// Get retrieves a secret value by path/key.
|
||||
Get(ctx context.Context, key string) (string, error)
|
||||
// List returns all secret keys available in this backend.
|
||||
List(ctx context.Context) ([]string, error)
|
||||
// Set stores a secret value. Used during migration.
|
||||
Set(ctx context.Context, key string, value string) error
|
||||
// Name returns a human-readable backend identifier.
|
||||
Name() string
|
||||
}
|
||||
|
||||
// Manager holds a primary and fallback backend. If the primary fails,
|
||||
// it falls back to the secondary.
|
||||
// it falls back to the secondary. Supports periodic background refresh
|
||||
// of cached secrets from the primary backend.
|
||||
type Manager struct {
|
||||
primary Backend
|
||||
fallback Backend
|
||||
cache map[string]cachedSecret
|
||||
mu sync.RWMutex
|
||||
cacheTTL time.Duration
|
||||
primary Backend
|
||||
fallback Backend
|
||||
cache map[string]cachedSecret
|
||||
mu sync.RWMutex
|
||||
cacheTTL time.Duration
|
||||
refreshMu sync.Mutex
|
||||
lastRefresh time.Time
|
||||
}
|
||||
|
||||
type cachedSecret struct {
|
||||
@@ -39,21 +37,97 @@ type cachedSecret struct {
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// ManagerRefreshInterval controls how often cached secrets are re-fetched
|
||||
// from the primary backend in the background. Zero disables background refresh.
|
||||
var ManagerRefreshInterval = 5 * time.Minute
|
||||
|
||||
// NewManager creates a secret manager with primary and fallback backends.
|
||||
func NewManager(primary, fallback Backend) *Manager {
|
||||
return &Manager{
|
||||
primary: primary,
|
||||
fallback: fallback,
|
||||
cache: make(map[string]cachedSecret),
|
||||
cacheTTL: 5 * time.Minute,
|
||||
primary: primary,
|
||||
fallback: fallback,
|
||||
cache: make(map[string]cachedSecret),
|
||||
cacheTTL: 5 * time.Minute,
|
||||
lastRefresh: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// StartRefreshLoop starts a background goroutine that periodically refreshes
|
||||
// cached secrets from the primary backend. Call from the server's main
|
||||
// goroutine. The loop runs until ctx is cancelled.
|
||||
func (m *Manager) StartRefreshLoop(ctx context.Context) {
|
||||
if ManagerRefreshInterval <= 0 {
|
||||
return
|
||||
}
|
||||
slog.Info("secrets: background refresh loop started",
|
||||
"interval", ManagerRefreshInterval)
|
||||
ticker := time.NewTicker(ManagerRefreshInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("secrets: refresh loop stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.refreshAll(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// refreshAll re-fetches all cached secrets from the primary backend.
|
||||
// Keys not found in the primary are left cached (they may be in fallback).
|
||||
// Logs a summary line.
|
||||
func (m *Manager) refreshAll(ctx context.Context) {
|
||||
m.refreshMu.Lock()
|
||||
defer m.refreshMu.Unlock()
|
||||
|
||||
if m.primary == nil {
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
keys := make([]string, 0, len(m.cache))
|
||||
for k := range m.cache {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
refreshed, stale, failed := 0, 0, 0
|
||||
for _, key := range keys {
|
||||
val, err := m.primary.Get(ctx, key)
|
||||
if err != nil {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
m.mu.RLock()
|
||||
cached, ok := m.cache[key]
|
||||
m.mu.RUnlock()
|
||||
if !ok || val != cached.value {
|
||||
m.mu.Lock()
|
||||
m.cache[key] = cachedSecret{value: val, expiresAt: time.Now().Add(m.cacheTTL)}
|
||||
m.mu.Unlock()
|
||||
refreshed++
|
||||
} else {
|
||||
stale++
|
||||
}
|
||||
}
|
||||
|
||||
m.lastRefresh = time.Now()
|
||||
slog.Info("secrets: background refresh complete",
|
||||
"refreshed", refreshed, "stale", stale, "failed", failed,
|
||||
"cached", len(keys))
|
||||
}
|
||||
|
||||
// Get retrieves a secret from primary, falling back to secondary on error.
|
||||
func (m *Manager) Get(ctx context.Context, key string) (string, error) {
|
||||
m.mu.RLock()
|
||||
if cached, ok := m.cache[key]; ok && time.Now().Before(cached.expiresAt) {
|
||||
m.mu.RUnlock()
|
||||
slog.Debug("secret: cache hit", "key", key)
|
||||
return cached.value, nil
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
@@ -63,16 +137,20 @@ func (m *Manager) Get(ctx context.Context, key string) (string, error) {
|
||||
m.mu.Lock()
|
||||
m.cache[key] = cachedSecret{value: val, expiresAt: time.Now().Add(m.cacheTTL)}
|
||||
m.mu.Unlock()
|
||||
slog.Debug("secret: fetched from primary", "key", key)
|
||||
return val, nil
|
||||
}
|
||||
|
||||
if m.fallback != nil {
|
||||
val, fallbackErr := m.fallback.Get(ctx, key)
|
||||
if fallbackErr == nil {
|
||||
slog.Warn("secret: primary failed, using fallback",
|
||||
"key", key, "primary_error", err)
|
||||
return val, nil
|
||||
}
|
||||
}
|
||||
|
||||
slog.Warn("secret: not found in any backend", "key", key, "error", err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -85,11 +163,17 @@ func (m *Manager) List(ctx context.Context) ([]string, error) {
|
||||
return keys, err
|
||||
}
|
||||
|
||||
// Set stores a secret in the primary backend (used during migration).
|
||||
// Set stores a secret in the primary backend and invalidates the cache.
|
||||
func (m *Manager) Set(ctx context.Context, key string, value string) error {
|
||||
m.InvalidateCache()
|
||||
return m.primary.Set(ctx, key, value)
|
||||
}
|
||||
|
||||
// Name returns the primary backend name.
|
||||
func (m *Manager) Name() string {
|
||||
return m.primary.Name()
|
||||
}
|
||||
|
||||
// PrimaryName returns the name of the primary backend.
|
||||
func (m *Manager) PrimaryName() string {
|
||||
return m.primary.Name()
|
||||
@@ -101,3 +185,14 @@ func (m *Manager) InvalidateCache() {
|
||||
m.cache = make(map[string]cachedSecret)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// LastRefresh returns the timestamp of the last background refresh.
|
||||
func (m *Manager) LastRefresh() time.Time {
|
||||
return m.lastRefresh
|
||||
}
|
||||
|
||||
// secretOverlay maps an Infisical key to a config setter function.
|
||||
type secretOverlay struct {
|
||||
infisicalKey string
|
||||
apply func(value string)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user