package store import ( "context" "fmt" "log" "strings" "time" ) // 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 // ActionEntryImport steht einmal je Import über die Weboberfläche, mit der // Zusammenfassung als Detail (siehe internal/importer). Die einzelnen // Einträge tauchen bewusst nicht als entry_write auf, damit ein // einmaliger Import die Nutzungsübersicht (4.6) nicht verzerrt. ActionEntryImport = "entry_import" // ActionExportDownload: Ein Projekt oder ein ganzer Workspace wurde als // ZIP heruntergeladen. Mit Projekt-ID beim Projekt, ohne beim Workspace. // Ein Download verändert nichts, steht aber im Protokoll, weil er den // gesamten Bestand auf einmal aus dem Haus trägt. ActionExportDownload = "export_download" ActionLoginSuccess = "login_success" ActionAuthFailure = "auth_failure" ActionProjectCreated = "project_created" ActionProjectDeleted = "project_deleted" ActionProjectUpdated = "project_updated" ActionKeyCreated = "key_created" ActionKeyRevoked = "key_revoked" // Workspace-Admin-Zugänge (AUSBAUSTUFEN.md 4.8). Stehen im Protokoll des // betroffenen Workspace, ohne Projekt — sichtbar unter /admin/audit. ActionWorkspaceAdminCreated = "workspace_admin_created" ActionWorkspaceAdminRevoked = "workspace_admin_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) } // DetailCuratedOverridden leitet im Protokoll einen Schreibvorgang ein, mit dem // ein Agent einen kuratierten Eintrag geändert hat — und damit die Markierung // aufgehoben (siehe internal/mcpserver/kuratiert.go). Danach folgt die // Begründung des Agenten. // // Fachlich ist das eine Aktualisierung und zählt in der Nutzungsübersicht auch // so (alles außer "angelegt" und leer). Der eigene Anfang macht es aber // auffindbar: CuratedOverrides sucht mit LIKE danach. const DetailCuratedOverridden = "kuratiert aufgehoben" // maxBegruendung begrenzt die Begründung im Protokoll. Sie soll einordnen, // nicht den Eintrag noch einmal enthalten. const maxBegruendung = 300 // CuratedOverrideDetail baut den Protokolltext für DetailCuratedOverridden. func CuratedOverrideDetail(begruendung string) string { begruendung = strings.TrimSpace(begruendung) // In Runen zählen, nicht in Bytes — sonst könnte der Schnitt mitten in // einem Umlaut landen. if r := []rune(begruendung); len(r) > maxBegruendung { begruendung = string(r[:maxBegruendung]) + "…" } return DetailCuratedOverridden + ": " + begruendung } // 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. Leere Felder schränken nicht ein. type AuditFilter struct { ProjectID string // leer = alle Projekte Limit int // Filter der Protokollseite (AUSBAUSTUFEN.md 5.11): die Fragen, die man bei // einem Vorfall stellt — „Was hat dieser Zugang gestern gemacht?" Action string // ein Vorgang, z.B. ActionEntryDelete Actor string // Name des Zugangs, genau so, wie er im Protokoll steht Since time.Time // ab diesem Zeitpunkt (einschließlich) Until time.Time // vor diesem Zeitpunkt (ausschließlich) } // AuditActions nennt alle Vorgänge mit einer kurzen Beschreibung — für die // Auswahlliste der Protokollseite. In der Reihenfolge, in der sie dort // erscheinen sollen. var AuditActions = []struct{ Action, Label string }{ {ActionEntryWrite, "Eintrag geschrieben"}, {ActionEntryDelete, "in den Papierkorb gelegt"}, {ActionEntryRestore, "aus dem Papierkorb geholt"}, {ActionEntryPurge, "endgültig gelöscht"}, {ActionEntryImport, "Import"}, {ActionExportDownload, "als ZIP heruntergeladen"}, {ActionLoginSuccess, "Anmeldung"}, {ActionAuthFailure, "Anmeldung fehlgeschlagen"}, {ActionProjectCreated, "Projekt angelegt"}, {ActionProjectUpdated, "Projekt geändert"}, {ActionProjectDeleted, "Projekt gelöscht"}, {ActionKeyCreated, "Zugang angelegt"}, {ActionKeyRevoked, "Zugang zurückgezogen"}, {ActionWorkspaceAdminCreated, "Workspace-Admin angelegt"}, {ActionWorkspaceAdminRevoked, "Workspace-Admin zurückgezogen"}, } 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 } // Die Bedingungen werden gesammelt und mit AND verbunden. Werte gehen // immer als Platzhalter in die Abfrage, nie als Text. var bedingungen []string werte := []any{} if f.ProjectID != "" { bedingungen = append(bedingungen, `project_id = ?`) werte = append(werte, f.ProjectID) } if f.Action != "" { bedingungen = append(bedingungen, `action = ?`) werte = append(werte, f.Action) } if f.Actor != "" { bedingungen = append(bedingungen, `actor = ?`) werte = append(werte, f.Actor) } if !f.Since.IsZero() { bedingungen = append(bedingungen, `ts >= ?`) werte = append(werte, formatTime(f.Since)) } if !f.Until.IsZero() { bedingungen = append(bedingungen, `ts < ?`) werte = append(werte, formatTime(f.Until)) } abfrage := `SELECT id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail FROM audit_events` if len(bedingungen) > 0 { abfrage += ` WHERE ` + strings.Join(bedingungen, ` AND `) } 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() } // AuditActors nennt alle Zugangsnamen, die im Protokoll vorkommen, sortiert — // für die Auswahlliste der Protokollseite. Mit projectID nur die eines // Projekts. Höchstens auditActorLimit, damit die Liste bedienbar bleibt. func (s *Store) AuditActors(ctx context.Context, projectID string) ([]string, error) { abfrage := `SELECT DISTINCT actor FROM audit_events WHERE actor <> ''` werte := []any{} if projectID != "" { abfrage += ` AND project_id = ?` werte = append(werte, projectID) } abfrage += ` ORDER BY actor LIMIT ?` werte = append(werte, auditActorLimit) rows, err := s.lesen.QueryContext(ctx, abfrage, werte...) if err != nil { return nil, fmt.Errorf("Zugänge im Protokoll konnten nicht gelesen werden: %w", err) } defer rows.Close() var namen []string for rows.Next() { var n string if err := rows.Scan(&n); err != nil { return nil, err } namen = append(namen, n) } return namen, rows.Err() } // auditActorLimit begrenzt die Auswahlliste der Zugänge. const auditActorLimit = 500 // 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() }