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>
This commit is contained in:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,207 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user