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() }