feat(audit): DegradingSink + durable buffer + loki/ntfy + reconcile (#54)
The classification-aware I5 audit path (§4.4): - DegradingSink.Reserve: central up → AuditCentral; central down + confidential → refuse (no buffer); central down + internal/public + buffer writable → AuditBuffered; central down + buffer unwritable → floor refuse. Record executes the reserved outcome and, when buffered, fires an ntfy alert. - FileBuffer: durable JSONL buffer that survives process restart; Confirm rewrites the file without a record, so a buffered record is cleared ONLY after its central write is confirmed. - LokiCentral: /ready probe + /loki/api/v1/push (full audit entry as the structured line). NtfyNotifier: degraded-state alerts; token only in the auth header, never logged (regression-tested). - Reconcile + StartReconcile: replay buffered records to central on recovery, confirm-then-clear per record; a failed push keeps the record buffered (no loss). SlogSink updated to the two-phase shape (always central, never fails) — the default when no loki endpoint is set. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Reconcile replays locally-buffered audit records to the central sink
|
||||
// when it is reachable again. A record is removed from the buffer ONLY
|
||||
// after its central write is confirmed, so a crash mid-reconcile re-plays
|
||||
// rather than loses. Returns the number of records reconciled.
|
||||
//
|
||||
// A no-op (0, nil) when the central sink is still unreachable or the
|
||||
// buffer is empty.
|
||||
func Reconcile(ctx context.Context, central Central, buffer Buffer, notifier Notifier) (int, error) {
|
||||
if err := central.Ready(ctx); err != nil {
|
||||
return 0, nil // still down; try again next tick
|
||||
}
|
||||
pending, err := buffer.Pending()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read buffer: %w", err)
|
||||
}
|
||||
reconciled := 0
|
||||
for _, rec := range pending {
|
||||
if err := central.Push(ctx, rec.Entry); err != nil {
|
||||
// Central went away mid-drain; stop and keep the rest buffered.
|
||||
break
|
||||
}
|
||||
if err := buffer.Confirm(rec.ID); err != nil {
|
||||
return reconciled, fmt.Errorf("confirm buffered record %s: %w", rec.ID, err)
|
||||
}
|
||||
reconciled++
|
||||
}
|
||||
if reconciled > 0 && notifier != nil {
|
||||
_ = notifier.Notify(ctx, fmt.Sprintf("reconciled %d buffered capture audit record(s) to loki", reconciled))
|
||||
}
|
||||
return reconciled, nil
|
||||
}
|
||||
|
||||
// StartReconcile runs Reconcile on a ticker until ctx is cancelled. It is
|
||||
// the recovery half of the degrade-and-buffer path; pair it with a
|
||||
// DegradingSink sharing the same buffer + central.
|
||||
func StartReconcile(ctx context.Context, central Central, buffer Buffer, notifier Notifier, interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
interval = time.Minute
|
||||
}
|
||||
go func() {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if n, err := Reconcile(ctx, central, buffer, notifier); err != nil {
|
||||
slog.Warn("audit reconcile failed", "err", err)
|
||||
} else if n > 0 {
|
||||
slog.Info("audit reconcile", "reconciled", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user