munin/internal/store/audit.go

337 lines
12 KiB
Go

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