package credentials import ( "container/list" "context" "errors" "sync" "time" "go.uber.org/zap" ) // Manager is the read-side entry point for per-namespace, per-provider // credentials. Provider packages call Manager.Get to load credentials // at push-send time; the LRU+TTL cache eliminates per-call decryption // for the (almost always) cache-hit path. // // Cache invalidation (defense in depth): // // - Immediate (this-gateway): the HTTP handler calls Invalidate(ns, // provider) after PUT/DELETE so the next lookup on THIS gateway // rebuilds from store. // - Bounded staleness (cluster-wide): every cached entry expires // after cacheEntryTTL (30s) and is reloaded from the store on the // next call. Bounds the window during which a config change on // gateway A is invisible to gateway B without requiring a pub/sub // broadcast layer. Same model as pkg/ratelimit. // // Safe for concurrent use. type Manager struct { store Store logger *zap.Logger ttl time.Duration // configurable for tests; defaults to cacheEntryTTL mu sync.Mutex cache map[cacheKey]*list.Element lru *list.List cacheCap int } // cacheKey is (namespace, provider) — the natural primary key. type cacheKey struct { namespace string provider string } // cacheEntry is the LRU node payload. type cacheEntry struct { key cacheKey cred *Credential // nil means "no row" (negative cache) builtAt time.Time } // NewManager constructs a Manager backed by the given store. func NewManager(store Store, logger *zap.Logger) *Manager { if logger == nil { logger = zap.NewNop() } return &Manager{ store: store, logger: logger, ttl: cacheEntryTTL, cache: make(map[cacheKey]*list.Element, defaultCacheCap), lru: list.New(), cacheCap: defaultCacheCap, } } // SetCacheTTL overrides the default cache-entry TTL. Intended for tests // (where 30s is too long to wait) and for operators who want a tighter // propagation window across multi-gateway deployments. A non-positive // argument is a no-op. func (m *Manager) SetCacheTTL(d time.Duration) { if d <= 0 { return } m.mu.Lock() defer m.mu.Unlock() m.ttl = d } // Get returns the credential for (namespace, provider) or (nil, nil) if // no credential is configured. A store error is returned to the caller // — unlike rate limiting (where we fail open under a store error), a // missing push credential MUST surface so the caller doesn't silently // drop a message to a misconfigured provider. func (m *Manager) Get(ctx context.Context, namespace, provider string) (*Credential, error) { if namespace == "" { return nil, ErrInvalidNamespace } if provider == "" { return nil, ErrInvalidProvider } key := cacheKey{namespace: namespace, provider: provider} m.mu.Lock() if el, ok := m.cache[key]; ok { entry := el.Value.(*cacheEntry) if time.Since(entry.builtAt) < m.ttl { m.lru.MoveToFront(el) m.mu.Unlock() return entry.cred, nil } // Expired — drop and fall through to rebuild. m.lru.Remove(el) delete(m.cache, key) } m.mu.Unlock() cred, err := m.store.Get(ctx, namespace, provider) if err != nil && !errors.Is(err, ErrNotFound) { return nil, err } // Store ErrNotFound → cache a negative (nil cred) entry so we don't // hammer rqlite for "namespace doesn't use this provider" on the hot // send path. The TTL still expires the negative entry, so once a // tenant DOES configure the provider, latency to first-effective is // bounded by the TTL. m.mu.Lock() defer m.mu.Unlock() // Recheck under lock — another goroutine may have built one // concurrently. Use it if it's still fresh. if el, ok := m.cache[key]; ok { entry := el.Value.(*cacheEntry) if time.Since(entry.builtAt) < m.ttl { m.lru.MoveToFront(el) return entry.cred, nil } m.lru.Remove(el) delete(m.cache, key) } entry := &cacheEntry{key: key, cred: cred, builtAt: time.Now()} el := m.lru.PushFront(entry) m.cache[key] = el for m.lru.Len() > m.cacheCap { tail := m.lru.Back() if tail == nil { break } m.lru.Remove(tail) delete(m.cache, tail.Value.(*cacheEntry).key) } return cred, nil } // Invalidate evicts the cached entry for (namespace, provider). Called // by the HTTP handler after PUT/DELETE so the next Get reloads from // the store. func (m *Manager) Invalidate(namespace, provider string) { m.mu.Lock() defer m.mu.Unlock() key := cacheKey{namespace: namespace, provider: provider} if el, ok := m.cache[key]; ok { m.lru.Remove(el) delete(m.cache, key) } } // InvalidateNamespace evicts every cached entry for the given namespace, // regardless of provider. Used when a namespace is deleted wholesale or // during an admin "rotate all credentials" operation. func (m *Manager) InvalidateNamespace(namespace string) { m.mu.Lock() defer m.mu.Unlock() for k, el := range m.cache { if k.namespace == namespace { m.lru.Remove(el) delete(m.cache, k) } } } // Store returns the underlying store. Used by the HTTP handlers for // write paths (PUT/DELETE) which go straight to the store and then // Invalidate; reads of cached state remain on the Manager. func (m *Manager) Store() Store { return m.store }