Files
HoneyandClaude Opus 5 31a027c93a feat(aether): initial commit – self-hosted Cloud, Phasen 1–7
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>
2026-08-24 09:25:36 +02:00

208 lines
5.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}