feat(PROJ-17): Admin Dashboard Systemauslastung immer anzeigen
- Systemauslastungs-Sektion wird immer gerendert (nicht nur bei Erfolg) - Fehlermeldung wenn /api/admin/system/stats nicht erreichbar ist - Feature-Status auf In Review gesetzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const (
|
||||
EventLogin = "login"
|
||||
EventLogout = "logout"
|
||||
EventSearch = "search"
|
||||
EventMailView = "mail_view"
|
||||
EventImport = "import"
|
||||
EventExport = "export"
|
||||
EventUserMgmt = "user_mgmt"
|
||||
)
|
||||
|
||||
// Entry is a single audit log record.
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
EventType string `json:"event_type"`
|
||||
Username string `json:"username"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Query string `json:"query"`
|
||||
MailID string `json:"mail_id"`
|
||||
Success bool `json:"success"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// QueryFilter specifies filtering options for audit log queries.
|
||||
type QueryFilter struct {
|
||||
Username string
|
||||
EventType string
|
||||
MailID string
|
||||
From *time.Time
|
||||
To *time.Time
|
||||
PageSize int
|
||||
Page int
|
||||
}
|
||||
|
||||
// Logger is a PostgreSQL-backed, append-only audit log.
|
||||
type Logger struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// New connects to PostgreSQL using the given DSN and initialises the schema.
|
||||
// logDir is reserved for future flat-file logging.
|
||||
func New(dsn, logDir string, logger *slog.Logger) (*Logger, error) {
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("audit: connect: %w", err)
|
||||
}
|
||||
|
||||
_, err = pool.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
username VARCHAR(255) NOT NULL DEFAULT '',
|
||||
ip_address VARCHAR(45) NOT NULL DEFAULT '',
|
||||
query TEXT NOT NULL DEFAULT '',
|
||||
mail_id VARCHAR(255) NOT NULL DEFAULT '',
|
||||
success BOOLEAN NOT NULL DEFAULT true,
|
||||
detail TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("audit: create schema: %w", err)
|
||||
}
|
||||
|
||||
return &Logger{pool: pool, logger: logger}, nil
|
||||
}
|
||||
|
||||
// Log appends an entry to the audit log. Errors are logged but not returned.
|
||||
func (l *Logger) Log(entry Entry) {
|
||||
ts := entry.Timestamp
|
||||
if ts.IsZero() {
|
||||
ts = time.Now().UTC()
|
||||
}
|
||||
ctx := context.Background()
|
||||
_, err := l.pool.Exec(ctx,
|
||||
`INSERT INTO audit_log (timestamp, event_type, username, ip_address, query, mail_id, success, detail)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
ts.UTC(),
|
||||
entry.EventType,
|
||||
entry.Username,
|
||||
entry.IPAddress,
|
||||
entry.Query,
|
||||
entry.MailID,
|
||||
entry.Success,
|
||||
entry.Detail,
|
||||
)
|
||||
if err != nil {
|
||||
l.logger.Error("audit: insert failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Query retrieves audit entries matching the given filter, returning the
|
||||
// matched entries, the total count (ignoring pagination), and any error.
|
||||
func (l *Logger) Query(filter QueryFilter) ([]Entry, int, error) {
|
||||
pageSize := filter.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 50
|
||||
}
|
||||
|
||||
where, args := buildWhere(filter)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Count total
|
||||
countSQL := "SELECT COUNT(*) FROM audit_log" + where
|
||||
var total int
|
||||
if err := l.pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("audit: count: %w", err)
|
||||
}
|
||||
|
||||
offset := filter.Page * pageSize
|
||||
// Append limit and offset as next positional args
|
||||
limitArg := len(args) + 1
|
||||
offsetArg := len(args) + 2
|
||||
querySQL := fmt.Sprintf(
|
||||
"SELECT id, timestamp, event_type, username, ip_address, query, mail_id, success, detail FROM audit_log%s ORDER BY timestamp DESC LIMIT $%d OFFSET $%d",
|
||||
where, limitArg, offsetArg,
|
||||
)
|
||||
allArgs := append(args, pageSize, offset)
|
||||
|
||||
rows, err := l.pool.Query(ctx, querySQL, allArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("audit: query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []Entry
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
if err := rows.Scan(&e.ID, &e.Timestamp, &e.EventType, &e.Username, &e.IPAddress, &e.Query, &e.MailID, &e.Success, &e.Detail); err != nil {
|
||||
return nil, 0, fmt.Errorf("audit: scan: %w", err)
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, total, rows.Err()
|
||||
}
|
||||
|
||||
// Close closes the audit connection pool.
|
||||
func (l *Logger) Close() error {
|
||||
l.pool.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildWhere constructs a SQL WHERE clause from QueryFilter fields using
|
||||
// positional parameters ($1, $2, ...) for PostgreSQL.
|
||||
func buildWhere(f QueryFilter) (string, []interface{}) {
|
||||
var clauses []string
|
||||
var args []interface{}
|
||||
n := 1
|
||||
|
||||
if f.Username != "" {
|
||||
clauses = append(clauses, fmt.Sprintf("username = $%d", n))
|
||||
args = append(args, f.Username)
|
||||
n++
|
||||
}
|
||||
if f.EventType != "" {
|
||||
clauses = append(clauses, fmt.Sprintf("event_type = $%d", n))
|
||||
args = append(args, f.EventType)
|
||||
n++
|
||||
}
|
||||
if f.MailID != "" {
|
||||
clauses = append(clauses, fmt.Sprintf("mail_id = $%d", n))
|
||||
args = append(args, f.MailID)
|
||||
n++
|
||||
}
|
||||
if f.From != nil {
|
||||
clauses = append(clauses, fmt.Sprintf("timestamp >= $%d", n))
|
||||
args = append(args, f.From.UTC())
|
||||
n++
|
||||
}
|
||||
if f.To != nil {
|
||||
clauses = append(clauses, fmt.Sprintf("timestamp <= $%d", n))
|
||||
args = append(args, f.To.UTC())
|
||||
n++
|
||||
}
|
||||
|
||||
if len(clauses) == 0 {
|
||||
return "", args
|
||||
}
|
||||
return " WHERE " + strings.Join(clauses, " AND "), args
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package audit_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/archivmail/internal/audit"
|
||||
)
|
||||
|
||||
func newTestAudit(t *testing.T) *audit.Logger {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping (needs PostgreSQL)")
|
||||
}
|
||||
schema := "autest_" + strings.ToLower(strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
// truncate schema name to 63 chars (PostgreSQL limit)
|
||||
if len(schema) > 63 {
|
||||
schema = schema[:63]
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
conn, err := pgx.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
conn.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+schema)
|
||||
conn.Close(ctx)
|
||||
|
||||
sep := "?"
|
||||
if strings.Contains(dsn, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
schemaDSN := dsn + sep + "search_path=" + schema
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Discard, nil))
|
||||
l, err := audit.New(schemaDSN, t.TempDir(), logger)
|
||||
if err != nil {
|
||||
t.Fatalf("audit.New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
l.Close()
|
||||
conn2, _ := pgx.Connect(context.Background(), dsn)
|
||||
if conn2 != nil {
|
||||
conn2.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE")
|
||||
conn2.Close(context.Background())
|
||||
}
|
||||
})
|
||||
return l
|
||||
}
|
||||
|
||||
func TestLogAndQuery(t *testing.T) {
|
||||
l := newTestAudit(t)
|
||||
|
||||
l.Log(audit.Entry{
|
||||
EventType: audit.EventLogin,
|
||||
Username: "alice",
|
||||
IPAddress: "192.168.1.1",
|
||||
Success: true,
|
||||
})
|
||||
l.Log(audit.Entry{
|
||||
EventType: audit.EventSearch,
|
||||
Username: "alice",
|
||||
IPAddress: "192.168.1.1",
|
||||
Query: "invoice",
|
||||
Success: true,
|
||||
})
|
||||
l.Log(audit.Entry{
|
||||
EventType: audit.EventLogin,
|
||||
Username: "bob",
|
||||
IPAddress: "10.0.0.1",
|
||||
Success: false,
|
||||
Detail: "wrong password",
|
||||
})
|
||||
|
||||
all, total, err := l.Query(audit.QueryFilter{PageSize: 50})
|
||||
if err != nil {
|
||||
t.Fatalf("Query all: %v", err)
|
||||
}
|
||||
if total != 3 {
|
||||
t.Errorf("expected 3 entries, got %d", total)
|
||||
}
|
||||
_ = all
|
||||
}
|
||||
|
||||
func TestQueryByUsername(t *testing.T) {
|
||||
l := newTestAudit(t)
|
||||
|
||||
l.Log(audit.Entry{EventType: audit.EventLogin, Username: "alice", Success: true})
|
||||
l.Log(audit.Entry{EventType: audit.EventSearch, Username: "alice", Success: true})
|
||||
l.Log(audit.Entry{EventType: audit.EventLogin, Username: "bob", Success: true})
|
||||
|
||||
entries, total, _ := l.Query(audit.QueryFilter{Username: "alice", PageSize: 50})
|
||||
if total != 2 {
|
||||
t.Errorf("alice: expected 2 entries, got %d", total)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Username != "alice" {
|
||||
t.Errorf("got entry for user %q in alice filter", e.Username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryByEventType(t *testing.T) {
|
||||
l := newTestAudit(t)
|
||||
|
||||
l.Log(audit.Entry{EventType: audit.EventLogin, Username: "alice", Success: true})
|
||||
l.Log(audit.Entry{EventType: audit.EventSearch, Username: "alice", Success: true})
|
||||
l.Log(audit.Entry{EventType: audit.EventMailView, Username: "alice", MailID: "abc123", Success: true})
|
||||
|
||||
_, total, _ := l.Query(audit.QueryFilter{EventType: audit.EventSearch, PageSize: 50})
|
||||
if total != 1 {
|
||||
t.Errorf("search event filter: expected 1, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryByMailID(t *testing.T) {
|
||||
l := newTestAudit(t)
|
||||
|
||||
l.Log(audit.Entry{EventType: audit.EventMailView, Username: "alice", MailID: "mail-001", Success: true})
|
||||
l.Log(audit.Entry{EventType: audit.EventMailView, Username: "bob", MailID: "mail-001", Success: true})
|
||||
l.Log(audit.Entry{EventType: audit.EventMailView, Username: "alice", MailID: "mail-002", Success: true})
|
||||
|
||||
_, total, _ := l.Query(audit.QueryFilter{MailID: "mail-001", PageSize: 50})
|
||||
if total != 2 {
|
||||
t.Errorf("mailID filter: expected 2, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryDateRange(t *testing.T) {
|
||||
l := newTestAudit(t)
|
||||
|
||||
l.Log(audit.Entry{EventType: audit.EventLogin, Username: "alice", Success: true})
|
||||
l.Log(audit.Entry{EventType: audit.EventLogin, Username: "bob", Success: true})
|
||||
|
||||
// Query with future date range — should return 0
|
||||
future := time.Now().Add(24 * time.Hour)
|
||||
futureEnd := time.Now().Add(48 * time.Hour)
|
||||
_, total, _ := l.Query(audit.QueryFilter{From: &future, To: &futureEnd, PageSize: 50})
|
||||
if total != 0 {
|
||||
t.Errorf("future date range should return 0, got %d", total)
|
||||
}
|
||||
|
||||
// Query with past-to-now range — should return all
|
||||
past := time.Now().Add(-1 * time.Minute)
|
||||
now := time.Now().Add(1 * time.Minute)
|
||||
_, total, _ = l.Query(audit.QueryFilter{From: &past, To: &now, PageSize: 50})
|
||||
if total != 2 {
|
||||
t.Errorf("current date range should return 2, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryPagination(t *testing.T) {
|
||||
l := newTestAudit(t)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
l.Log(audit.Entry{EventType: audit.EventSearch, Username: "alice", Success: true})
|
||||
}
|
||||
|
||||
page0, total, _ := l.Query(audit.QueryFilter{PageSize: 4, Page: 0})
|
||||
_, _, _ = l.Query(audit.QueryFilter{PageSize: 4, Page: 1})
|
||||
page2, _, _ := l.Query(audit.QueryFilter{PageSize: 4, Page: 2})
|
||||
|
||||
if total != 10 {
|
||||
t.Errorf("total = %d, want 10", total)
|
||||
}
|
||||
if len(page0) != 4 {
|
||||
t.Errorf("page 0 len = %d, want 4", len(page0))
|
||||
}
|
||||
if len(page2) != 2 {
|
||||
t.Errorf("page 2 len = %d, want 2", len(page2))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user