package moduletrust import ( "context" "fmt" "sync" "time" ) // StaleCache ist der generische Rechte-/Feature-Flag-Cache-Kontrakt // (Akzeptanzkriterium 2): TTL-basiert, mit explizitem, benanntem Verhalten // bei abgelaufenem Cache waehrend Core nicht erreichbar ist. // // - Get: FAIL-OPEN fuer Lesevorgaenge. Schlaegt der Refresh fehl, aber es // gibt bereits einen (wenn auch abgelaufenen) Stand, wird dieser mit // stale=true zurueckgegeben — Begruendung: ein bereits authentifiziertes // Modul soll mit dem letztbekannten Stand weiterarbeiten koennen statt // hart zu blockieren (siehe "Bekannte Fehler vermeiden" im Ticket). // Existiert noch nie ein Stand, gibt es keinen sinnvollen Fallback — // dann liefert auch Get einen Fehler. // - RequireFresh: FAIL-CLOSED fuer sicherheitskritische Aktionen (z.B. // ein komplett NEUER Login). Nutzt NIEMALS einen zwischengespeicherten // Stand, ruft immer frisch ab — Begruendung: eine neue Vertrauens- // entscheidung darf nicht auf veralteten Daten beruhen, auch wenn das // bedeutet, dass die Aktion bei Core-Ausfall sichtbar fehlschlaegt statt // unsicher "irgendwie" durchgelassen zu werden. // // LIC-02 (internal/flag.Service) implementiert bereits denselben Kontrakt // fuer Feature-Flags — StaleCache verallgemeinert dasselbe Muster fuer // JWT-Signaturschluessel, damit beide Faelle derselben dokumentierten // Policy folgen. type StaleCache[T any] struct { mu sync.RWMutex value T hasValue bool fetchedAt time.Time ttl time.Duration fetch func(ctx context.Context) (T, error) } func NewStaleCache[T any](ttl time.Duration, fetch func(ctx context.Context) (T, error)) *StaleCache[T] { return &StaleCache[T]{ttl: ttl, fetch: fetch} } // Get liefert den Cache-Wert. FAIL-OPEN: bei Refresh-Fehler wird ein // vorhandener, ggf. abgelaufener Stand zurueckgegeben (stale=true). func (c *StaleCache[T]) Get(ctx context.Context) (value T, stale bool, err error) { c.mu.RLock() fresh := c.hasValue && time.Since(c.fetchedAt) < c.ttl if fresh { v := c.value c.mu.RUnlock() return v, false, nil } c.mu.RUnlock() newVal, fetchErr := c.fetch(ctx) if fetchErr == nil { c.mu.Lock() c.value, c.hasValue, c.fetchedAt = newVal, true, time.Now() c.mu.Unlock() return newVal, false, nil } c.mu.RLock() defer c.mu.RUnlock() if c.hasValue { return c.value, true, nil } var zero T return zero, false, fmt.Errorf("cache leer und refresh fehlgeschlagen: %w", fetchErr) } // Invalidate erzwingt beim naechsten Get-Aufruf einen sofortigen Refresh // statt auf den TTL-Ablauf zu warten (API-06, Akzeptanzkriterium 1: ein // Health-Check-getriggerter Wiederanlauf soll den Cache SOFORT aktualisieren, // nicht die reguläre TTL abwarten) — rein additiv, aendert nichts an // Get/RequireFresh (dasselbe Muster wie internal/flag.Service.Invalidate). func (c *StaleCache[T]) Invalidate() { c.mu.Lock() c.hasValue = false c.mu.Unlock() } // RequireFresh ruft IMMER frisch ab (FAIL-CLOSED) — fuer sicherheitskritische // Aktionen, die niemals auf einem zwischengespeicherten Stand basieren duerfen. func (c *StaleCache[T]) RequireFresh(ctx context.Context) (T, error) { v, err := c.fetch(ctx) if err != nil { var zero T return zero, fmt.Errorf("core nicht erreichbar, sicherheitskritische aktion abgelehnt: %w", err) } c.mu.Lock() c.value, c.hasValue, c.fetchedAt = v, true, time.Now() c.mu.Unlock() return v, nil }