AUD-04: backend-authorizer + dev-server + next.js audit-log-ansicht
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const metadata = {
|
||||||
|
title: "NEXARCH Audit-Log",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="de">
|
||||||
|
<body style={{ fontFamily: "system-ui, sans-serif", margin: 0, background: "#f5f6f8" }}>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<AuditFilter>(EMPTY_FILTER);
|
||||||
|
const [records, setRecords] = useState<AuditRecord[] | null>(null);
|
||||||
|
const [selected, setSelected] = useState<AuditRecord | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<main style={{ maxWidth: 1000, margin: "0 auto", padding: "2rem 1rem" }}>
|
||||||
|
<h1>Audit-Log</h1>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gap: "0.5rem", gridTemplateColumns: "repeat(3, 1fr)", marginBottom: "1rem" }}>
|
||||||
|
<input
|
||||||
|
value={caller}
|
||||||
|
onChange={(e) => setCaller(e.target.value)}
|
||||||
|
placeholder="Admin-Token (Berechtigung)"
|
||||||
|
style={{ padding: "0.5rem", gridColumn: "span 3" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={filter.tenant}
|
||||||
|
onChange={(e) => setFilter({ ...filter, tenant: e.target.value })}
|
||||||
|
placeholder="Tenant"
|
||||||
|
style={{ padding: "0.5rem" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={filter.actor}
|
||||||
|
onChange={(e) => setFilter({ ...filter, actor: e.target.value })}
|
||||||
|
placeholder="Akteur"
|
||||||
|
style={{ padding: "0.5rem" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={filter.action}
|
||||||
|
onChange={(e) => setFilter({ ...filter, action: e.target.value })}
|
||||||
|
placeholder="Aktion"
|
||||||
|
style={{ padding: "0.5rem" }}
|
||||||
|
/>
|
||||||
|
<label>
|
||||||
|
Von
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={filter.from}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilter({ ...filter, from: e.target.value ? new Date(e.target.value).toISOString() : "" })
|
||||||
|
}
|
||||||
|
style={{ width: "100%", padding: "0.4rem" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Bis
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={filter.to}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilter({ ...filter, to: e.target.value ? new Date(e.target.value).toISOString() : "" })
|
||||||
|
}
|
||||||
|
style={{ width: "100%", padding: "0.4rem" }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem" }}>
|
||||||
|
<button onClick={onSearch}>Suchen</button>
|
||||||
|
<button onClick={() => onExport("csv")}>Export als CSV</button>
|
||||||
|
<button onClick={() => onExport("json")}>Export als JSON</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: "#c62828" }} role="alert">
|
||||||
|
Fehler: {error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{records && (
|
||||||
|
<table style={{ width: "100%", borderCollapse: "collapse", background: "white" }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ textAlign: "left", borderBottom: "2px solid #ddd" }}>
|
||||||
|
<th style={{ padding: "0.5rem" }}>Zeitpunkt</th>
|
||||||
|
<th style={{ padding: "0.5rem" }}>Tenant</th>
|
||||||
|
<th style={{ padding: "0.5rem" }}>Akteur</th>
|
||||||
|
<th style={{ padding: "0.5rem" }}>Aktion</th>
|
||||||
|
<th style={{ padding: "0.5rem" }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{records.map((r, i) => (
|
||||||
|
<tr key={i} style={{ borderBottom: "1px solid #eee" }}>
|
||||||
|
<td style={{ padding: "0.5rem" }}>{new Date(r.occurred_at).toLocaleString("de-DE")}</td>
|
||||||
|
<td style={{ padding: "0.5rem" }}>{r.tenant_slug}</td>
|
||||||
|
<td style={{ padding: "0.5rem" }}>{r.actor}</td>
|
||||||
|
<td style={{ padding: "0.5rem" }}>{r.action}</td>
|
||||||
|
<td style={{ padding: "0.5rem" }}>
|
||||||
|
<button onClick={() => setSelected(r)}>Details</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{records.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} style={{ padding: "0.5rem" }}>
|
||||||
|
Keine Einträge gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginTop: "1.5rem" }}>
|
||||||
|
<h2>Eintrag-Details</h2>
|
||||||
|
<dl>
|
||||||
|
<dt>Zeitpunkt</dt>
|
||||||
|
<dd>{new Date(selected.occurred_at).toLocaleString("de-DE")}</dd>
|
||||||
|
<dt>Tenant</dt>
|
||||||
|
<dd>{selected.tenant_slug}</dd>
|
||||||
|
<dt>Akteur</dt>
|
||||||
|
<dd>{selected.actor}</dd>
|
||||||
|
<dt>Aktion</dt>
|
||||||
|
<dd>{selected.action}</dd>
|
||||||
|
<dt>Ziel</dt>
|
||||||
|
<dd>{selected.target}</dd>
|
||||||
|
<dt>Metadaten</dt>
|
||||||
|
<dd>
|
||||||
|
<pre style={{ whiteSpace: "pre-wrap", background: "#f5f6f8", padding: "0.5rem" }}>
|
||||||
|
{JSON.stringify(selected.metadata, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
<button onClick={() => setSelected(null)}>Schließen</button>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<AuditRecord[]> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
export default nextConfig;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user