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>
66 lines
1.9 KiB
Go
66 lines
1.9 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|