Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA. Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb, Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming, Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office (Collabora/OnlyOffice). Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort) sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
208 lines
5.8 KiB
Go
208 lines
5.8 KiB
Go
// Package audit protokolliert sicherheitsrelevante Vorgänge.
|
||
//
|
||
// Leitgedanke: Ein Audit-Eintrag ist nur so viel wert, wie er nach einem
|
||
// Vorfall noch aussagt. Deshalb werden Namen mitgeschrieben statt nur IDs, und
|
||
// das Protokollieren darf die eigentliche Aktion niemals scheitern lassen –
|
||
// ein kaputtes Log ist ärgerlich, eine deswegen abgebrochene Anmeldung wäre
|
||
// schlimmer.
|
||
package audit
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
"github.com/rs/zerolog/log"
|
||
)
|
||
|
||
// Actions – als Konstanten, damit Filter und Schreiber nicht auseinanderlaufen.
|
||
const (
|
||
ActionLogin = "auth.login"
|
||
ActionLoginFailed = "auth.login_failed"
|
||
ActionLogout = "auth.logout"
|
||
ActionPasswordChange = "auth.password_change"
|
||
|
||
ActionAppPasswordCreate = "app_password.create"
|
||
ActionAppPasswordRevoke = "app_password.revoke"
|
||
|
||
ActionUserCreate = "admin.user_create"
|
||
ActionUserUpdate = "admin.user_update"
|
||
ActionUserDelete = "admin.user_delete"
|
||
ActionUserPasswordReset = "admin.user_password_reset"
|
||
|
||
ActionGroupCreate = "admin.group_create"
|
||
ActionGroupUpdate = "admin.group_update"
|
||
ActionGroupDelete = "admin.group_delete"
|
||
ActionGroupMemberAdd = "admin.group_member_add"
|
||
ActionGroupMemberRemove = "admin.group_member_remove"
|
||
|
||
ActionSettingsUpdate = "admin.settings_update"
|
||
|
||
ActionGroupFolderCreate = "admin.group_folder_create"
|
||
ActionGroupFolderDelete = "admin.group_folder_delete"
|
||
|
||
ActionShareCreate = "share.create"
|
||
ActionShareDelete = "share.delete"
|
||
ActionShareGrant = "share.grant"
|
||
ActionShareRevoke = "share.revoke"
|
||
|
||
ActionFileDelete = "file.delete"
|
||
ActionTrashEmpty = "file.trash_empty"
|
||
ActionHardDelete = "file.hard_delete"
|
||
ActionFileRestore = "file.restore"
|
||
|
||
ActionGarbageCollect = "system.gc"
|
||
)
|
||
|
||
// Entry ist ein Eintrag, wie ihn die Admin-Ansicht liest.
|
||
type Entry struct {
|
||
ID uuid.UUID `json:"id"`
|
||
ActorID *uuid.UUID `json:"actor_id"`
|
||
ActorName string `json:"actor_name"`
|
||
Action string `json:"action"`
|
||
TargetType *string `json:"target_type"`
|
||
TargetID *string `json:"target_id"`
|
||
TargetName *string `json:"target_name"`
|
||
IP *string `json:"ip"`
|
||
Detail *string `json:"detail"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
}
|
||
|
||
// Event beschreibt einen zu protokollierenden Vorgang.
|
||
type Event struct {
|
||
ActorID *uuid.UUID
|
||
ActorName string
|
||
Action string
|
||
TargetType string
|
||
TargetID string
|
||
TargetName string
|
||
IP string
|
||
Detail string
|
||
}
|
||
|
||
type Logger struct {
|
||
db *pgxpool.Pool
|
||
}
|
||
|
||
func NewLogger(db *pgxpool.Pool) *Logger {
|
||
return &Logger{db: db}
|
||
}
|
||
|
||
// Log schreibt einen Eintrag. Fehler werden geloggt, nicht zurückgegeben: der
|
||
// Aufrufer soll seine Aktion nicht abbrechen, nur weil das Protokoll klemmt.
|
||
//
|
||
// Bewusst mit eigenem Kontext: bei einem abgebrochenen Request (Client weg,
|
||
// Timeout) ist der Vorgang trotzdem passiert und gehört ins Protokoll.
|
||
func (l *Logger) Log(ctx context.Context, e Event) {
|
||
if l == nil || l.db == nil {
|
||
return
|
||
}
|
||
|
||
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||
defer cancel()
|
||
|
||
_, err := l.db.Exec(writeCtx,
|
||
`INSERT INTO audit_log
|
||
(actor_id, actor_name, action, target_type, target_id, target_name, ip, detail)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||
e.ActorID, e.ActorName, e.Action,
|
||
nullIfEmpty(e.TargetType), nullIfEmpty(e.TargetID), nullIfEmpty(e.TargetName),
|
||
nullIfEmpty(e.IP), nullIfEmpty(e.Detail),
|
||
)
|
||
if err != nil {
|
||
log.Ctx(ctx).Error().Err(err).
|
||
Str("action", e.Action).
|
||
Str("actor", e.ActorName).
|
||
Msg("audit: could not write entry")
|
||
}
|
||
}
|
||
|
||
// Filter für die Admin-Ansicht.
|
||
type Filter struct {
|
||
Action string
|
||
ActorID *uuid.UUID
|
||
Limit int
|
||
Offset int
|
||
}
|
||
|
||
const (
|
||
defaultLimit = 100
|
||
maxLimit = 500
|
||
)
|
||
|
||
// List liefert Einträge, neueste zuerst, plus die Gesamtzahl für die
|
||
// Blätterfunktion.
|
||
func (l *Logger) List(ctx context.Context, f Filter) ([]Entry, int, error) {
|
||
limit := f.Limit
|
||
if limit <= 0 {
|
||
limit = defaultLimit
|
||
}
|
||
if limit > maxLimit {
|
||
limit = maxLimit
|
||
}
|
||
|
||
var total int
|
||
if err := l.db.QueryRow(ctx,
|
||
`SELECT count(*) FROM audit_log
|
||
WHERE ($1 = '' OR action = $1)
|
||
AND ($2::uuid IS NULL OR actor_id = $2)`,
|
||
f.Action, f.ActorID,
|
||
).Scan(&total); err != nil {
|
||
return nil, 0, fmt.Errorf("count audit entries: %w", err)
|
||
}
|
||
|
||
rows, err := l.db.Query(ctx,
|
||
`SELECT id, actor_id, actor_name, action, target_type, target_id,
|
||
target_name, ip, detail, created_at
|
||
FROM audit_log
|
||
WHERE ($1 = '' OR action = $1)
|
||
AND ($2::uuid IS NULL OR actor_id = $2)
|
||
ORDER BY created_at DESC
|
||
LIMIT $3 OFFSET $4`,
|
||
f.Action, f.ActorID, limit, f.Offset)
|
||
if err != nil {
|
||
return nil, 0, fmt.Errorf("query audit entries: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
entries := []Entry{}
|
||
for rows.Next() {
|
||
var e Entry
|
||
if err := rows.Scan(&e.ID, &e.ActorID, &e.ActorName, &e.Action,
|
||
&e.TargetType, &e.TargetID, &e.TargetName, &e.IP, &e.Detail, &e.CreatedAt); err != nil {
|
||
return nil, 0, fmt.Errorf("scan audit entry: %w", err)
|
||
}
|
||
entries = append(entries, e)
|
||
}
|
||
return entries, total, rows.Err()
|
||
}
|
||
|
||
// Actions listet die tatsächlich vorkommenden Aktionen – füttert das
|
||
// Filter-Dropdown, damit dort nichts steht, was es im Protokoll gar nicht gibt.
|
||
func (l *Logger) Actions(ctx context.Context) ([]string, error) {
|
||
rows, err := l.db.Query(ctx, `SELECT DISTINCT action FROM audit_log ORDER BY action`)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("query audit actions: %w", err)
|
||
}
|
||
defer rows.Close()
|
||
|
||
out := []string{}
|
||
for rows.Next() {
|
||
var a string
|
||
if err := rows.Scan(&a); err != nil {
|
||
return nil, fmt.Errorf("scan audit action: %w", err)
|
||
}
|
||
out = append(out, a)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func nullIfEmpty(s string) *string {
|
||
if s == "" {
|
||
return nil
|
||
}
|
||
return &s
|
||
}
|