package secrets import ( "context" "errors" "log/slog" "sync" "time" ) var ErrNotFound = errors.New("secret not found") var ErrBackendUnavailable = errors.New("secret backend unavailable") // Backend is the interface for retrieving and storing secrets. type Backend interface { Get(ctx context.Context, key string) (string, error) List(ctx context.Context) ([]string, error) Set(ctx context.Context, key string, value string) error Name() string } // Manager holds a primary and fallback backend. If the primary fails, // 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 refreshMu sync.Mutex lastRefresh time.Time } type cachedSecret struct { value string 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, 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() val, err := m.primary.Get(ctx, key) if err == nil { 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 } // List returns all keys from the primary backend. func (m *Manager) List(ctx context.Context) ([]string, error) { keys, err := m.primary.List(ctx) if err != nil && m.fallback != nil { return m.fallback.List(ctx) } return keys, err } // 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() } // InvalidateCache clears cached secrets. func (m *Manager) InvalidateCache() { m.mu.Lock() 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) }