munin/internal/store/audit.go
Thomas Adamek 466f1e40af Workspaces, Änderungshistorie, Papierkorb, Nutzungsübersicht, Hilfe
Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6,
5.1–5.4 (Details in AUSBAUSTUFEN.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:44:52 +02:00

211 lines
7.2 KiB
Go

package store
import (
"context"
"fmt"
"log"
"strings"
)
// Aktionen, die im Protokoll auftauchen. Als Konstanten, damit Auswertungen
// nicht an Tippfehlern scheitern.
const (
ActionEntryWrite = "entry_write"
// ActionEntryDelete heißt seit dem Papierkorb (migrations/005): "in den
// Papierkorb gelegt". Der Name bleibt, damit ältere Protokollzeilen und
// neue gleich ausgewertet werden — auch vorher stand er für den Wunsch,
// einen Eintrag loszuwerden.
ActionEntryDelete = "entry_delete"
ActionEntryRestore = "entry_restore" // aus dem Papierkorb zurückgeholt
ActionEntryPurge = "entry_purge" // endgültig gelöscht, nicht rückgängig zu machen
ActionLoginSuccess = "login_success"
ActionAuthFailure = "auth_failure"
ActionProjectCreated = "project_created"
ActionProjectDeleted = "project_deleted"
ActionProjectUpdated = "project_updated"
ActionKeyCreated = "key_created"
ActionKeyRevoked = "key_revoked"
)
// Feste Werte für das Feld Detail bei ActionEntryWrite. Feste Wörter statt
// freiem Text, damit sich später zählen lässt, wie viele Schreibvorgänge neu
// angelegt und wie viele aktualisiert haben (AUSBAUSTUFEN.md 4.6). Die
// Sammelbearbeitung schreibt eigenen Text ("Sammelbearbeitung: …") — sie
// ändert immer nur vorhandene Einträge. Dasselbe gilt für die
// Wiederherstellung einer alten Fassung (EntryRestoreDetail).
const (
DetailEntryCreated = "angelegt"
DetailEntryUpdated = "aktualisiert"
)
// EntryRestoreDetail beschreibt im Protokoll, dass ein Schreibvorgang eine
// alte Fassung zurückgeholt hat — und welche.
//
// Die Zeichenkette beginnt immer mit "wiederhergestellt", damit sie sich später
// für die Nutzungsübersicht (AUSBAUSTUFEN.md 4.6) genauso zählen lässt wie die
// festen Werte oben, etwa mit `detail LIKE 'wiederhergestellt%'`. Eine
// Wiederherstellung ist fachlich immer eine Aktualisierung: Der Eintrag
// existiert ja schon, sonst gäbe es keine alte Fassung.
func EntryRestoreDetail(vonFassung int) string {
// fmt.Sprintf setzt die Zahl in den Text ein — %d steht für eine ganze Zahl.
return fmt.Sprintf("wiederhergestellt: Fassung %d", vonFassung)
}
// EntryWriteDetail sagt anhand des gespeicherten Eintrags, ob der
// Schreibvorgang ihn angelegt oder aktualisiert hat.
//
// Maßgeblich ist die Fassung NACH dem Schreiben: PutEntry vergibt beim
// Anlegen immer Fassung 1 und zählt bei jeder Änderung hoch. Anders als eine
// Nachfrage vor dem Schreiben kann das nicht danebenliegen, wenn zwei
// gleichzeitig denselben Bezeichner schreiben.
func EntryWriteDetail(e Entry) string {
if e.Version == 1 {
return DetailEntryCreated
}
return DetailEntryUpdated
}
// Herkunft eines protokollierten Vorgangs.
const (
SourceWeb = "web"
SourceMCP = "mcp"
SourceSystem = "system"
)
// LogAudit schreibt ein Ereignis ins Protokoll.
//
// Ein Fehler beim Protokollieren lässt den eigentlichen Vorgang nicht scheitern —
// er wird nur ins Serverlog geschrieben. Das Protokoll ist eine Hilfe zur
// Nachvollziehbarkeit, kein Teil des kritischen Pfads: Wer einen Eintrag
// erfolgreich gespeichert hat, soll keine Fehlermeldung sehen, nur weil die
// Protokollzeile nicht geschrieben werden konnte.
func (s *Store) LogAudit(ctx context.Context, e AuditEvent) {
if e.ID == "" {
e.ID = NewID()
}
_, err := s.schreiben.ExecContext(ctx,
`INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.ID, NowString(), e.ProjectID, e.APIKeyID, e.Actor, e.Action, e.EntrySlug, e.Source, e.IP, e.Detail,
)
if err != nil {
log.Printf("Audit-Log: Ereignis %q konnte nicht geschrieben werden: %v", e.Action, err)
}
}
// AuditFilter schränkt ListAudit ein.
type AuditFilter struct {
ProjectID string // leer = alle Projekte
Limit int
}
const defaultAuditLimit = 200
// ListAudit gibt die jüngsten Protokolleinträge zurück, neueste zuerst.
func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]AuditEvent, error) {
limit := f.Limit
if limit <= 0 {
limit = defaultAuditLimit
}
abfrage := `SELECT id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail
FROM audit_events`
werte := []any{}
if f.ProjectID != "" {
abfrage += ` WHERE project_id = ?`
werte = append(werte, f.ProjectID)
}
abfrage += ` ORDER BY ts DESC LIMIT ?`
werte = append(werte, limit)
rows, err := s.lesen.QueryContext(ctx, abfrage, werte...)
if err != nil {
return nil, fmt.Errorf("Audit-Log konnte nicht gelesen werden: %w", err)
}
defer rows.Close()
var ereignisse []AuditEvent
for rows.Next() {
var e AuditEvent
var ts string
if err := rows.Scan(&e.ID, &ts, &e.ProjectID, &e.APIKeyID, &e.Actor,
&e.Action, &e.EntrySlug, &e.Source, &e.IP, &e.Detail); err != nil {
return nil, err
}
e.Timestamp = parseTime(ts)
ereignisse = append(ereignisse, e)
}
return ereignisse, rows.Err()
}
// PruneAudit löscht Protokolleinträge, die älter als die angegebene Grenze sind.
//
// # Warum das nötig ist
//
// Das Protokoll wächst mit jedem Zugriff und wird nie kleiner. Bei Agenten, die
// im Minutentakt schreiben, sind das schnell Millionen Zeilen.
//
// Es gibt außerdem einen datenschutzrechtlichen Grund: Das Protokoll enthält
// IP-Adressen und Key-Namen, aus denen sich Personen ableiten lassen. Solche
// Daten dürfen nicht unbegrenzt aufbewahrt werden — eine Aufbewahrungsfrist ist
// keine Kür, sondern die Voraussetzung dafür, das Protokoll überhaupt führen zu dürfen.
//
// Übergeben wird die Grenze als Zeitstempel im Datenbankformat; alles davor fällt weg.
func (s *Store) PruneAudit(ctx context.Context, aelterAls string) (int64, error) {
res, err := s.schreiben.ExecContext(ctx, `DELETE FROM audit_events WHERE ts < ?`, aelterAls)
if err != nil {
return 0, fmt.Errorf("Audit-Log konnte nicht aufgeräumt werden: %w", err)
}
return res.RowsAffected()
}
// ProjectNames gibt eine Zuordnung von Projekt-ID zu Anzeigename zurück.
// Wird in der Audit-Ansicht gebraucht, damit dort nicht rohe IDs stehen.
func (s *Store) ProjectNames(ctx context.Context, workspaceID string) (map[string]string, error) {
projekte, err := s.ListProjects(ctx, workspaceID)
if err != nil {
return nil, err
}
namen := make(map[string]string, len(projekte))
for _, p := range projekte {
namen[p.ID] = p.Name
}
return namen, nil
}
// KeyNames gibt eine Zuordnung von Key-ID zu Key-Name zurück — analog zu
// ProjectNames, damit die Audit-Ansicht "tom-cline" statt einer ID zeigt.
func (s *Store) KeyNames(ctx context.Context, projectIDs []string) (map[string]string, error) {
if len(projectIDs) == 0 {
return map[string]string{}, nil
}
platzhalter := strings.TrimSuffix(strings.Repeat("?,", len(projectIDs)), ",")
werte := make([]any, len(projectIDs))
for i, id := range projectIDs {
werte[i] = id
}
rows, err := s.lesen.QueryContext(ctx,
`SELECT id, name FROM api_keys WHERE project_id IN (`+platzhalter+`)`, werte...)
if err != nil {
return nil, fmt.Errorf("Key-Namen konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
namen := make(map[string]string)
for rows.Next() {
var id, name string
if err := rows.Scan(&id, &name); err != nil {
return nil, err
}
namen[id] = name
}
return namen, rows.Err()
}