From 3b96d8ef41c2f889a7f00ca89f5aff441d8c0459 Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 00:10:48 +0200 Subject: [PATCH] AUD-04: backend-authorizer + dev-server + next.js audit-log-ansicht --- cmd/auditlog-devserver/main.go | 69 +++++++++++ internal/auditadmin/authorize.go | 23 ++++ internal/auditadmin/authorize_test.go | 33 ++++++ web/audit-log/app/layout.tsx | 17 +++ web/audit-log/app/page.tsx | 159 ++++++++++++++++++++++++++ web/audit-log/lib/api.ts | 63 ++++++++++ web/audit-log/next.config.mjs | 3 + web/audit-log/package.json | 21 ++++ web/audit-log/tsconfig.json | 21 ++++ 9 files changed, 409 insertions(+) create mode 100644 cmd/auditlog-devserver/main.go create mode 100644 internal/auditadmin/authorize.go create mode 100644 internal/auditadmin/authorize_test.go create mode 100644 web/audit-log/app/layout.tsx create mode 100644 web/audit-log/app/page.tsx create mode 100644 web/audit-log/lib/api.ts create mode 100644 web/audit-log/next.config.mjs create mode 100644 web/audit-log/package.json create mode 100644 web/audit-log/tsconfig.json diff --git a/cmd/auditlog-devserver/main.go b/cmd/auditlog-devserver/main.go new file mode 100644 index 0000000..c7c94be --- /dev/null +++ b/cmd/auditlog-devserver/main.go @@ -0,0 +1,69 @@ +// auditlog-devserver stellt AUD-03s ExportHandler (internal/audit) fuer die +// Next.js-Audit-Log-Ansicht (AUD-04) bereit. Getrennt von cmd/core aus +// demselben Grund wie die anderen *-devserver (siehe LIC-04/TEN-05): echte +// Auth/RBAC ist noch nicht in die zentrale Server-Topologie verdrahtet. +// +// Authorize wird hier mit einem geteilten Admin-Token ueber +// crypto/subtle.ConstantTimeCompare umgesetzt — demselben Timing-safe-Muster +// wie internal/audit.timingsafe (AUD-02), NICHT ueber eine neue +// Rollen-/Rechteschicht, da diese Kachel ausdruecklich nur von AUD-03 +// abhaengt und keine Rechteverwaltung duplizieren soll. +package main + +import ( + "context" + "log" + "net/http" + "os" + + "gitea.perlbach24.de/scripte/nexarch/internal/audit" + "gitea.perlbach24.de/scripte/nexarch/internal/auditadmin" + "gitea.perlbach24.de/scripte/nexarch/internal/db" +) + +func main() { + dsn := os.Getenv("NEXARCH_REGISTRY_DSN") + if dsn == "" { + log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt") + } + adminToken := os.Getenv("NEXARCH_AUDIT_ADMIN_TOKEN") + if adminToken == "" { + log.Fatal("NEXARCH_AUDIT_ADMIN_TOKEN nicht gesetzt") + } + addr := os.Getenv("NEXARCH_AUDITLOG_LISTEN_ADDR") + if addr == "" { + addr = ":8083" + } + + ctx := context.Background() + pool, err := db.Connect(ctx, dsn) + if err != nil { + log.Fatalf("db: %v", err) + } + defer pool.Close() + + auditLog := audit.NewLog(pool) + tokenAuthorize := auditadmin.NewTokenAuthorizer(adminToken) + authorize := func(_ context.Context, caller string) bool { return tokenAuthorize(caller) } + handler := audit.NewExportHandler(auditLog, authorize) + + mux := http.NewServeMux() + mux.HandleFunc("/audit/export", withCORS(handler.Export)) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + + log.Printf("auditlog-devserver listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, mux)) +} + +func withCORS(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + next(w, r) + } +} diff --git a/internal/auditadmin/authorize.go b/internal/auditadmin/authorize.go new file mode 100644 index 0000000..c0eb59c --- /dev/null +++ b/internal/auditadmin/authorize.go @@ -0,0 +1,23 @@ +// Package auditadmin implementiert Core AUD-04: die Berechtigungspruefung +// fuer die Audit-Log-Ansicht. Enthaelt bewusst KEINE eigene Filter-/ +// Export-Logik — die Ansicht ist "reiner Konsument der Export-API" (AUD-03, +// internal/audit.ExportHandler); dieses Paket liefert nur die +// audit.Authorize-Implementierung, die der Entwicklungs-/Testserver +// (cmd/auditlog-devserver) einhaengt. +package auditadmin + +import "crypto/subtle" + +// NewTokenAuthorizer liefert eine audit.Authorize-Funktion, die den +// aufrufenden "caller"-Wert timing-safe gegen ein geteiltes Admin-Token +// vergleicht — dasselbe Muster wie internal/audit.timingsafe (AUD-02), +// NICHT ueber eine neue Rollen-/Rechteschicht, da AUD-04 ausdruecklich nur +// von AUD-03 abhaengt. +func NewTokenAuthorizer(adminToken string) func(caller string) bool { + return func(caller string) bool { + if caller == "" || adminToken == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(caller), []byte(adminToken)) == 1 + } +} diff --git a/internal/auditadmin/authorize_test.go b/internal/auditadmin/authorize_test.go new file mode 100644 index 0000000..b1f5e91 --- /dev/null +++ b/internal/auditadmin/authorize_test.go @@ -0,0 +1,33 @@ +package auditadmin + +import "testing" + +// Grundlage fuer Akzeptanzkriterium 1/2: die Oberflaeche darf Daten und den +// Export nur bei korrektem Admin-Token abrufen. +func TestNewTokenAuthorizer_AcceptsCorrectToken(t *testing.T) { + authorize := NewTokenAuthorizer("geheimes-token") + if !authorize("geheimes-token") { + t.Fatal("erwartet true fuer korrektes token") + } +} + +func TestNewTokenAuthorizer_RejectsWrongToken(t *testing.T) { + authorize := NewTokenAuthorizer("geheimes-token") + if authorize("falsches-token") { + t.Fatal("erwartet false fuer falsches token") + } +} + +func TestNewTokenAuthorizer_RejectsEmptyCaller(t *testing.T) { + authorize := NewTokenAuthorizer("geheimes-token") + if authorize("") { + t.Fatal("erwartet false fuer leeren aufrufer") + } +} + +func TestNewTokenAuthorizer_RejectsWhenNoTokenConfigured(t *testing.T) { + authorize := NewTokenAuthorizer("") + if authorize("irgendwas") { + t.Fatal("erwartet false, wenn kein admin-token konfiguriert ist (fail-safe-default)") + } +} diff --git a/web/audit-log/app/layout.tsx b/web/audit-log/app/layout.tsx new file mode 100644 index 0000000..817c017 --- /dev/null +++ b/web/audit-log/app/layout.tsx @@ -0,0 +1,17 @@ +export const metadata = { + title: "NEXARCH Audit-Log", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/web/audit-log/app/page.tsx b/web/audit-log/app/page.tsx new file mode 100644 index 0000000..8eb7625 --- /dev/null +++ b/web/audit-log/app/page.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState } from "react"; +import { fetchRecords, exportURL, type AuditRecord, type AuditFilter } from "@/lib/api"; + +const EMPTY_FILTER: AuditFilter = { tenant: "", actor: "", action: "", from: "", to: "" }; + +export default function Page() { + const [caller, setCaller] = useState(""); + const [filter, setFilter] = useState(EMPTY_FILTER); + const [records, setRecords] = useState(null); + const [selected, setSelected] = useState(null); + const [error, setError] = useState(null); + + async function onSearch() { + setError(null); + setSelected(null); + try { + const data = await fetchRecords(caller.trim(), filter); + setRecords(data); + } catch (e: any) { + setError(e.message ?? "Unbekannter Fehler"); + setRecords(null); + } + } + + function onExport(format: "csv" | "json") { + const url = exportURL(caller.trim(), filter, format); + // Echter Datei-Download ueber Browser-Navigation, KEIN erneutes Parsen + // im Frontend (Akzeptanzkriterium 2: Export aus AUD-03 direkt ausloesbar). + window.open(url, "_blank"); + } + + return ( +
+

Audit-Log

+ +
+ setCaller(e.target.value)} + placeholder="Admin-Token (Berechtigung)" + style={{ padding: "0.5rem", gridColumn: "span 3" }} + /> + setFilter({ ...filter, tenant: e.target.value })} + placeholder="Tenant" + style={{ padding: "0.5rem" }} + /> + setFilter({ ...filter, actor: e.target.value })} + placeholder="Akteur" + style={{ padding: "0.5rem" }} + /> + setFilter({ ...filter, action: e.target.value })} + placeholder="Aktion" + style={{ padding: "0.5rem" }} + /> + + +
+ +
+ + + +
+ + {error && ( +

+ Fehler: {error} +

+ )} + + {records && ( + + + + + + + + + + + + {records.map((r, i) => ( + + + + + + + + ))} + {records.length === 0 && ( + + + + )} + +
ZeitpunktTenantAkteurAktion
{new Date(r.occurred_at).toLocaleString("de-DE")}{r.tenant_slug}{r.actor}{r.action} + +
+ Keine Einträge gefunden. +
+ )} + + {selected && ( +
+

Eintrag-Details

+
+
Zeitpunkt
+
{new Date(selected.occurred_at).toLocaleString("de-DE")}
+
Tenant
+
{selected.tenant_slug}
+
Akteur
+
{selected.actor}
+
Aktion
+
{selected.action}
+
Ziel
+
{selected.target}
+
Metadaten
+
+
+                {JSON.stringify(selected.metadata, null, 2)}
+              
+
+
+ +
+ )} +
+ ); +} diff --git a/web/audit-log/lib/api.ts b/web/audit-log/lib/api.ts new file mode 100644 index 0000000..bef57c6 --- /dev/null +++ b/web/audit-log/lib/api.ts @@ -0,0 +1,63 @@ +// Duenner Client der AUD-03-Export-API (internal/audit.ExportHandler) — +// keine eigene Aggregations-/Filterlogik im Frontend (Ticket-Vorgabe: +// "reiner Konsument der Export-API"). Die JSON-Lines-Antwort wird nur +// dekodiert, nicht neu berechnet oder gefiltert. +export type AuditRecord = { + occurred_at: string; + tenant_slug: string; + actor: string; + action: string; + target: string; + metadata: unknown; +}; + +export type AuditFilter = { + tenant: string; + actor: string; + action: string; + from: string; // RFC3339, leer = kein Filter + to: string; +}; + +function apiBase(): string { + const base = process.env.NEXT_PUBLIC_AUDITLOG_API_URL; + if (!base) { + throw new Error( + "NEXT_PUBLIC_AUDITLOG_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)" + ); + } + return base; +} + +function buildParams(caller: string, filter: AuditFilter, format: "json" | "csv"): URLSearchParams { + const params = new URLSearchParams({ caller, format }); + if (filter.tenant) params.set("tenant", filter.tenant); + if (filter.actor) params.set("actor", filter.actor); + if (filter.action) params.set("action", filter.action); + if (filter.from) params.set("from", filter.from); + if (filter.to) params.set("to", filter.to); + return params; +} + +// exportURL liefert die Adresse desselben Endpunkts, den auch die +// Export-Datei-Funktion (Akzeptanzkriterium 2) verwendet — Liste und Export +// sind bewusst DERSELBE API-Aufruf mit unterschiedlichem "format". +export function exportURL(caller: string, filter: AuditFilter, format: "json" | "csv"): string { + return `${apiBase()}/audit/export?${buildParams(caller, filter, format)}`; +} + +// fetchRecords laedt die Liste als JSON-Lines und dekodiert Zeile fuer +// Zeile — dieselben Filterparameter wie ein direkter API-Aufruf +// (Akzeptanzkriterium 1 / Pruefung 1). +export async function fetchRecords(caller: string, filter: AuditFilter): Promise { + const res = await fetch(exportURL(caller, filter, "json"), { cache: "no-store" }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || `Anfrage fehlgeschlagen (${res.status})`); + } + const text = await res.text(); + return text + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => JSON.parse(line) as AuditRecord); +} diff --git a/web/audit-log/next.config.mjs b/web/audit-log/next.config.mjs new file mode 100644 index 0000000..f26ac37 --- /dev/null +++ b/web/audit-log/next.config.mjs @@ -0,0 +1,3 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; +export default nextConfig; diff --git a/web/audit-log/package.json b/web/audit-log/package.json new file mode 100644 index 0000000..94d14f3 --- /dev/null +++ b/web/audit-log/package.json @@ -0,0 +1,21 @@ +{ + "name": "nexarch-audit-log", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "14.2.35", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "20.14.9", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "typescript": "5.5.3" + } +} diff --git a/web/audit-log/tsconfig.json b/web/audit-log/tsconfig.json new file mode 100644 index 0000000..26caf44 --- /dev/null +++ b/web/audit-log/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}