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 = "entry_delete" ActionLoginSuccess = "login_success" ActionAuthFailure = "auth_failure" ActionProjectCreated = "project_created" ActionProjectDeleted = "project_deleted" ActionProjectUpdated = "project_updated" ActionKeyCreated = "key_created" ActionKeyRevoked = "key_revoked" ) // 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() }