- 3.7: MCP-Werkzeug "related" liefert Nachbarn im Verweisgraphen in beide
Richtungen, bis zur zweiten Reihe (store.RelatedEntries).
- 3.5: Einträge, die seit über einem Jahr unverändert sind, tragen in
recall/get/list/related "stale": true; get und recall geben einen
Hinweis, die Eintragsseite eine Marke. instructions erklären das Feld.
- 3.2: Import von Markdown-Dateien als ZIP unter /p/{projekt}/import
(Rolle admin), mit Vorschau, überspringen/überschreiben, Erkennung von
Unverändertem und Papierkorb. Neue Einträge behalten Autor, Herkunft,
Prüfvermerk und Zeitstempel aus dem Export.
Nebenbei behoben: Das 1-MB-Body-Limit für Formulare lag über dem ganzen
Mux und begrenzte damit auch /mcp auf 1 MB statt MAX_API_BODY_BYTES.
LimitBodies wählt die Grenze jetzt einmal außen je Route.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
217 lines
7.5 KiB
Go
217 lines
7.5 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
|
|
|
|
// 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"
|
|
|
|
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()
|
|
}
|