TEN-13/LIC-07: Dienst-Token fuer interne Admin-Endpunkte, Loopback-Standard, CORS-Ursprungsliste, Proxy in den Admin-Oberflaechen (QA-15 F-01..F-03)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-22 00:49:27 +02:00
1 parent c5fddad8a5
commit 9aa69d3801
17 files changed
+476 -60

No files matched your search

+6 -5
View File
@@ -8,6 +8,8 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
"gitea.perlbach24.de/scripte/nexarch/internal/config"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
@@ -18,6 +20,8 @@ func main() {
log.Fatalf("config: %v", err)
}
adminToken := svcauth.MustToken("NEXARCH_CORE_ADMIN_TOKEN")
ctx := context.Background()
registryPool, err := db.Connect(ctx, cfg.RegistryDSN)
@@ -53,13 +57,10 @@ func main() {
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
mux.HandleFunc("/internal/tenants/onboard", onboardingHandler.Onboard)
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
registerInternalRoutes(mux, adminToken, tenantHandler.CreateTenant, onboardingHandler.Onboard, userHandler.CreateSuperadmin)
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
if err := httpharden.ListenAndServe(cfg.ListenAddr, mux); err != nil {
log.Fatalf("server: %v", err)
}
}
+17
View File
@@ -0,0 +1,17 @@
package main
import (
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
)
// registerInternalRoutes haengt die internen Admin-Pfade an (QA-15 F-01 /
// TEN-13): nur mit Dienst-Token; ohne NEXARCH_CORE_ADMIN_TOKEN startet der
// Dienst nicht. Echte Superadmin-Sitzungen (API-01/IAM-02) loesen das Token
// spaeter ab.
func registerInternalRoutes(mux *http.ServeMux, token string, createTenant, onboard, createSuperadmin http.HandlerFunc) {
mux.HandleFunc("/internal/tenants", svcauth.Require(token, createTenant))
mux.HandleFunc("/internal/tenants/onboard", svcauth.Require(token, onboard))
mux.HandleFunc("/internal/superadmins", svcauth.Require(token, createSuperadmin))
}
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
const testToken = "core-admin-token-0123456789"
func newTestMux(reached *int) *http.ServeMux {
h := func(w http.ResponseWriter, r *http.Request) { *reached++; w.WriteHeader(http.StatusCreated) }
mux := http.NewServeMux()
registerInternalRoutes(mux, testToken, h, h, h)
return mux
}
func TestInternalRoutes_RequireServiceToken(t *testing.T) {
for _, path := range []string{"/internal/tenants", "/internal/tenants/onboard", "/internal/superadmins"} {
reached := 0
mux := newTestMux(&reached)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, path, nil))
if rec.Code != http.StatusUnauthorized || reached != 0 {
t.Fatalf("%s ohne Token: code=%d handler=%d", path, rec.Code, reached)
}
req := httptest.NewRequest(http.MethodPost, path, nil)
req.Header.Set("X-Service-Token", "falsches-token-0123456789")
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized || reached != 0 {
t.Fatalf("%s mit falschem Token: code=%d handler=%d", path, rec.Code, reached)
}
req = httptest.NewRequest(http.MethodPost, path, nil)
req.Header.Set("X-Service-Token", testToken)
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated || reached != 1 {
t.Fatalf("%s mit Token: code=%d handler=%d", path, rec.Code, reached)
}
}
}
+8 -20
View File
@@ -19,7 +19,9 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/adminapi"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
"gitea.perlbach24.de/scripte/nexarch/internal/license"
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
"gitea.perlbach24.de/scripte/nexarch/internal/usage"
)
@@ -39,9 +41,12 @@ func main() {
addr := os.Getenv("NEXARCH_LICADMIN_LISTEN_ADDR")
if addr == "" {
addr = ":8081"
addr = "127.0.0.1:8081"
}
adminToken := svcauth.MustToken("NEXARCH_LICADMIN_ADMIN_TOKEN")
corsOrigins := svcauth.OriginsFromEnv("NEXARCH_LICADMIN_CORS_ORIGINS")
ctx := context.Background()
pool, err := db.Connect(ctx, dsn)
if err != nil {
@@ -56,26 +61,9 @@ func main() {
handler := adminapi.NewHandler(licenseStore, flagStore, flagService, usageStore)
mux := http.NewServeMux()
mux.HandleFunc("/admin/overview", withCORS(handler.OverviewHandler))
mux.HandleFunc("/admin/flags/toggle", withCORS(handler.ToggleFlagHandler))
registerAdminRoutes(mux, adminToken, corsOrigins, handler.OverviewHandler, handler.ToggleFlagHandler)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
log.Printf("licadmin-devserver listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
// withCORS erlaubt lokale Entwicklung des Next.js-Frontends auf einem
// anderen Port als das Backend-API — ausschliesslich fuer diesen
// Entwicklungsserver, keine Produktionshaertung.
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, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
log.Fatal(httpharden.ListenAndServe(addr, mux))
}
+14
View File
@@ -0,0 +1,14 @@
package main
import (
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
)
// registerAdminRoutes schuetzt die Lizenz-/Flag-Administration mit Dienst-Token
// und expliziter CORS-Ursprungsliste (QA-15 F-02 / LIC-07).
func registerAdminRoutes(mux *http.ServeMux, token string, origins []string, overview, toggle http.HandlerFunc) {
mux.HandleFunc("/admin/overview", svcauth.Protect(token, origins, "GET, OPTIONS", overview))
mux.HandleFunc("/admin/flags/toggle", svcauth.Protect(token, origins, "POST, OPTIONS", toggle))
}
+47
View File
@@ -0,0 +1,47 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
const testToken = "licadmin-token-0123456789"
func TestAdminRoutes_RequireTokenAndRestrictCORS(t *testing.T) {
reached := 0
h := func(w http.ResponseWriter, r *http.Request) { reached++; w.WriteHeader(http.StatusOK) }
mux := http.NewServeMux()
registerAdminRoutes(mux, testToken, []string{"https://admin.example.test"}, h, h)
for _, path := range []string{"/admin/overview", "/admin/flags/toggle"} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, path, nil))
if rec.Code != http.StatusUnauthorized || reached != 0 {
t.Fatalf("%s ohne Token: code=%d handler=%d", path, rec.Code, reached)
}
req := httptest.NewRequest(http.MethodPost, path, nil)
req.Header.Set("X-Service-Token", testToken)
req.Header.Set("Origin", "https://evil.example.test")
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s mit Token: %d", path, rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("%s: fremder Ursprung bekam CORS-Freigabe %q", path, got)
}
req = httptest.NewRequest(http.MethodOptions, path, nil)
req.Header.Set("Origin", "https://admin.example.test")
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://admin.example.test" {
t.Fatalf("%s: erlaubter Ursprung fehlt: %q", path, got)
}
if rec.Header().Get("Access-Control-Allow-Origin") == "*" {
t.Fatal("Platzhalter-Ursprung")
}
}
}
+8 -19
View File
@@ -14,6 +14,8 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/httpharden"
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/tenantadmin"
"gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings"
@@ -27,9 +29,12 @@ func main() {
}
addr := os.Getenv("NEXARCH_TENANTADMIN_LISTEN_ADDR")
if addr == "" {
addr = ":8082"
addr = "127.0.0.1:8082"
}
adminToken := svcauth.MustToken("NEXARCH_TENANTADMIN_ADMIN_TOKEN")
corsOrigins := svcauth.OriginsFromEnv("NEXARCH_TENANTADMIN_CORS_ORIGINS")
ctx := context.Background()
pool, err := db.Connect(ctx, dsn)
if err != nil {
@@ -47,25 +52,9 @@ func main() {
handler := tenantadmin.NewHandler(registry, lifecycle, settingsStore, superadmins)
mux := http.NewServeMux()
mux.HandleFunc("/admin/tenants", withCORS(handler.ListTenantsHandler))
mux.HandleFunc("/admin/tenants/detail", withCORS(handler.TenantDetailHandler))
mux.HandleFunc("/admin/tenants/settings", withCORS(handler.UpdateSettingsHandler))
mux.HandleFunc("/admin/tenants/lifecycle", withCORS(handler.LifecycleActionHandler))
registerAdminRoutes(mux, adminToken, corsOrigins, handler.ListTenantsHandler, handler.TenantDetailHandler, handler.UpdateSettingsHandler, handler.LifecycleActionHandler)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
log.Printf("tenantadmin-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, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
log.Fatal(httpharden.ListenAndServe(addr, mux))
}
+18
View File
@@ -0,0 +1,18 @@
package main
import (
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/svcauth"
)
// registerAdminRoutes schuetzt die Mandantenverwaltung mit Dienst-Token und
// expliziter CORS-Ursprungsliste (QA-15 F-03 / LIC-07). Der Parameter
// `superadmin` bleibt die vom authentifizierten Aufrufer behauptete Kennung
// (Audit-Akteur); eine Sitzungs-basierte Superadmin-Anmeldung ist Folgearbeit.
func registerAdminRoutes(mux *http.ServeMux, token string, origins []string, list, detail, settings, lifecycle http.HandlerFunc) {
mux.HandleFunc("/admin/tenants", svcauth.Protect(token, origins, "GET, OPTIONS", list))
mux.HandleFunc("/admin/tenants/detail", svcauth.Protect(token, origins, "GET, OPTIONS", detail))
mux.HandleFunc("/admin/tenants/settings", svcauth.Protect(token, origins, "POST, OPTIONS", settings))
mux.HandleFunc("/admin/tenants/lifecycle", svcauth.Protect(token, origins, "POST, OPTIONS", lifecycle))
}
+35
View File
@@ -0,0 +1,35 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
const testToken = "tenantadmin-token-0123456789"
func TestAdminRoutes_RequireTokenAndIgnoreSuperadminParameterWithoutIt(t *testing.T) {
reached := 0
h := func(w http.ResponseWriter, r *http.Request) { reached++; w.WriteHeader(http.StatusOK) }
mux := http.NewServeMux()
registerAdminRoutes(mux, testToken, nil, h, h, h, h)
for _, path := range []string{"/admin/tenants", "/admin/tenants/detail", "/admin/tenants/settings", "/admin/tenants/lifecycle"} {
// Bekannte Superadmin-Kennung allein reicht nicht.
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path+"?superadmin=00000000-0000-0000-0000-000000000001", nil))
if rec.Code != http.StatusUnauthorized || reached != 0 {
t.Fatalf("%s nur mit superadmin-Parameter: code=%d handler=%d", path, rec.Code, reached)
}
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Service-Token", testToken)
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s mit Token: %d", path, rec.Code)
}
if rec.Header().Get("Access-Control-Allow-Origin") != "" {
t.Fatalf("%s: ohne Ursprungsliste darf es keine CORS-Freigabe geben", path)
}
}
}
+11 -1
View File
@@ -101,6 +101,10 @@ if [ ! -f "$ENV_FILE" ]; then
fi
AUDIT_ADMIN_TOKEN="$(openssl rand -hex 32)"
# QA-15 F-01..F-03: Dienst-Token fuer interne Admin-Endpunkte (ohne sie starten die Dienste nicht).
CORE_ADMIN_TOKEN="$(openssl rand -hex 32)"
LICADMIN_ADMIN_TOKEN="$(openssl rand -hex 32)"
TENANTADMIN_ADMIN_TOKEN="$(openssl rand -hex 32)"
# Ed25519-Schluesselpaar fuer die Lizenzsignatur (LIC-01/LIC-04):
# licadmin-devserver prueft Lizenzen nur mit dem OEFFENTLICHEN Schluessel,
@@ -125,7 +129,13 @@ NEXARCH_REGISTRY_DSN=postgresql://${PG_REGISTRY_ROLE}:${PG_REGISTRY_PASSWORD}@lo
NEXARCH_ADMIN_DSN=postgresql://${PG_ROLE}:${PG_PASSWORD}@localhost:5432/postgres?sslmode=disable
NEXARCH_TENANT_DSN_TEMPLATE=postgresql://${PG_ROLE}:${PG_PASSWORD}@localhost:5432/%s?sslmode=disable
NEXARCH_TENANT_SCHEMA_PATH=${INSTALL_DIR}/migrations/tenant
NEXARCH_LISTEN_ADDR=:8080
NEXARCH_LISTEN_ADDR=127.0.0.1:8080
NEXARCH_CORE_ADMIN_TOKEN=${CORE_ADMIN_TOKEN}
NEXARCH_LICADMIN_ADMIN_TOKEN=${LICADMIN_ADMIN_TOKEN}
NEXARCH_TENANTADMIN_ADMIN_TOKEN=${TENANTADMIN_ADMIN_TOKEN}
# Kommagetrennte, explizite Ursprungslisten fuer die Verwaltungsoberflaeche (kein *):
# NEXARCH_LICADMIN_CORS_ORIGINS=https://admin.example.test
# NEXARCH_TENANTADMIN_CORS_ORIGINS=https://admin.example.test
NEXARCH_AUDIT_ADMIN_TOKEN=${AUDIT_ADMIN_TOKEN}
NEXARCH_LICENSE_PUBLIC_KEY=${LICENSE_PUBLIC_KEY}
EOF
+1 -1
View File
@@ -47,7 +47,7 @@ func Load() (Config, error) {
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
if addr == "" {
addr = ":8080"
addr = "127.0.0.1:8080"
}
return Config{
+91
View File
@@ -0,0 +1,91 @@
// Package svcauth schuetzt interne Admin-Endpunkte mit einem Dienst-Token
// (Header X-Service-Token, wie policyapi/notifyapi/AUD-02) und liefert eine
// CORS-Freigabe mit expliziter Ursprungsliste statt "*" (QA-15 F-01..F-03).
package svcauth
import (
"crypto/sha256"
"crypto/subtle"
"log"
"net/http"
"os"
"strings"
)
const (
Header = "X-Service-Token"
MinTokenLength = 16
)
// MustToken liest das Token aus der Umgebung und beendet den Prozess, wenn es
// fehlt oder zu kurz ist (fail closed: kein Start ohne Auth-Konfiguration).
func MustToken(envName string) string {
v := os.Getenv(envName)
if len(v) < MinTokenLength {
log.Fatalf("%s muss gesetzt sein und mindestens %d Zeichen lang sein", envName, MinTokenLength)
}
return v
}
func equal(a, b string) bool {
ha := sha256.Sum256([]byte(a))
hb := sha256.Sum256([]byte(b))
return subtle.ConstantTimeCompare(ha[:], hb[:]) == 1
}
// Require verlangt das Dienst-Token; ohne oder mit falschem Token 401.
func Require(token string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if token == "" || !equal(r.Header.Get(Header), token) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":{"code":"unauthenticated","message":"gueltiges X-Service-Token erforderlich"}}`))
return
}
next(w, r)
}
}
// OriginsFromEnv liest eine kommagetrennte Ursprungsliste; "*" wird abgelehnt.
func OriginsFromEnv(envName string) []string {
var out []string
for _, o := range strings.Split(os.Getenv(envName), ",") {
o = strings.TrimSpace(o)
if o == "" {
continue
}
if o == "*" {
log.Fatalf("%s: Platzhalter * ist nicht erlaubt, Ursprung explizit angeben", envName)
}
out = append(out, o)
}
return out
}
// CORS setzt Freigabe-Header nur fuer Urspruenge aus der Liste. Ohne Liste
// gibt es keine CORS-Header. Preflight (OPTIONS) wird ohne Token beantwortet.
func CORS(origins []string, methods string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" {
w.Header().Add("Vary", "Origin")
for _, o := range origins {
if o == origin {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", methods)
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, "+Header)
break
}
}
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next(w, r)
}
}
// Protect kombiniert CORS (aussen) und Token-Pruefung (innen).
func Protect(token string, origins []string, methods string, next http.HandlerFunc) http.HandlerFunc {
return CORS(origins, methods, Require(token, next))
}
+75
View File
@@ -0,0 +1,75 @@
package svcauth
import (
"net/http"
"net/http/httptest"
"testing"
)
const tok = "0123456789abcdef-token"
func ok(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }
func do(h http.HandlerFunc, method, token, origin string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, "/x", nil)
if token != "" {
req.Header.Set(Header, token)
}
if origin != "" {
req.Header.Set("Origin", origin)
}
rec := httptest.NewRecorder()
h(rec, req)
return rec
}
func TestRequire(t *testing.T) {
h := Require(tok, ok)
if c := do(h, "GET", "", "").Code; c != 401 {
t.Fatalf("ohne Token: %d", c)
}
if c := do(h, "GET", "falsch-falsch-falsch", "").Code; c != 401 {
t.Fatalf("falsches Token: %d", c)
}
if c := do(h, "GET", tok, "").Code; c != 200 {
t.Fatalf("richtiges Token: %d", c)
}
}
func TestRequire_EmptyConfiguredTokenNeverAuthenticates(t *testing.T) {
h := Require("", ok)
if c := do(h, "GET", "", "").Code; c != 401 {
t.Fatalf("leeres Konfig-Token darf nie durchlassen: %d", c)
}
}
func TestCORS_OnlyListedOrigins(t *testing.T) {
h := Protect(tok, []string{"https://admin.example.test"}, "GET, POST, OPTIONS", ok)
rec := do(h, "GET", tok, "https://admin.example.test")
if rec.Header().Get("Access-Control-Allow-Origin") != "https://admin.example.test" {
t.Fatal("erlaubter Ursprung fehlt")
}
rec = do(h, "GET", tok, "https://evil.example.test")
if rec.Header().Get("Access-Control-Allow-Origin") != "" {
t.Fatal("fremder Ursprung darf keine CORS-Freigabe bekommen")
}
rec = do(Protect(tok, nil, "GET", ok), "GET", tok, "https://admin.example.test")
if rec.Header().Get("Access-Control-Allow-Origin") != "" {
t.Fatal("ohne Liste keine CORS-Header")
}
}
func TestPreflightWithoutToken(t *testing.T) {
h := Protect(tok, []string{"https://admin.example.test"}, "GET", ok)
if c := do(h, "OPTIONS", "", "https://admin.example.test").Code; c != 204 {
t.Fatalf("preflight: %d", c)
}
}
func TestOriginsFromEnv(t *testing.T) {
t.Setenv("O", " https://a.test , ,https://b.test")
got := OriginsFromEnv("O")
if len(got) != 2 || got[0] != "https://a.test" || got[1] != "https://b.test" {
t.Fatalf("%v", got)
}
}
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
// QA-15 F-02/F-03: Der Browser haelt kein Dienst-Token. Dieser serverseitige
// Proxy leitet nur /admin/*-Pfade an das Backend weiter und ergaenzt das Token
// aus der Server-Umgebung (nie NEXT_PUBLIC_). Die Oberflaeche selbst muss nur
// fuer berechtigte Administrierende erreichbar sein (Reverse Proxy mit
// Anmeldung oder Loopback), bis die Superadmin-Sitzungsanmeldung existiert.
export const dynamic = "force-dynamic";
const MAX_BODY_BYTES = 1 << 20;
async function forward(
req: NextRequest,
{ params }: { params: { path: string[] } }
): Promise<NextResponse> {
const upstream = process.env.LICADMIN_API_URL;
const token = process.env.LICADMIN_ADMIN_TOKEN;
if (!upstream || !token) {
return NextResponse.json({ error: "Proxy nicht konfiguriert" }, { status: 503 });
}
const path = params.path.join("/");
if (!path.startsWith("admin/") || path.split("/").some((s) => s === ".." || s === ".")) {
return NextResponse.json({ error: "nicht gefunden" }, { status: 404 });
}
if (Number(req.headers.get("content-length") ?? "0") > MAX_BODY_BYTES) {
return NextResponse.json({ error: "Anfrage zu gross" }, { status: 413 });
}
const url = `${upstream.replace(/\/$/, "")}/${path}${req.nextUrl.search}`;
const init: RequestInit = {
method: req.method,
headers: {
"X-Service-Token": token,
"Content-Type": req.headers.get("content-type") ?? "application/json",
},
cache: "no-store",
};
if (req.method !== "GET" && req.method !== "HEAD") {
init.body = await req.text();
}
const res = await fetch(url, init);
return new NextResponse(await res.text(), {
status: res.status,
headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" },
});
}
export { forward as GET, forward as POST };
+3 -7
View File
@@ -24,14 +24,10 @@ export type Overview = {
usage: UsageOverview[];
};
// Standard: serverseitiger Proxy (app/api/proxy), der das Dienst-Token ergaenzt.
// NEXT_PUBLIC_ADMIN_API_URL kann fuer Tests/Sonderfaelle auf ein Backend zeigen.
function apiBase(): string {
const base = process.env.NEXT_PUBLIC_ADMIN_API_URL;
if (!base) {
throw new Error(
"NEXT_PUBLIC_ADMIN_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"
);
}
return base;
return process.env.NEXT_PUBLIC_ADMIN_API_URL || "/api/proxy";
}
export async function fetchOverview(tenantId: string): Promise<Overview> {
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
// QA-15 F-02/F-03: Der Browser haelt kein Dienst-Token. Dieser serverseitige
// Proxy leitet nur /admin/*-Pfade an das Backend weiter und ergaenzt das Token
// aus der Server-Umgebung (nie NEXT_PUBLIC_). Die Oberflaeche selbst muss nur
// fuer berechtigte Administrierende erreichbar sein (Reverse Proxy mit
// Anmeldung oder Loopback), bis die Superadmin-Sitzungsanmeldung existiert.
export const dynamic = "force-dynamic";
const MAX_BODY_BYTES = 1 << 20;
async function forward(
req: NextRequest,
{ params }: { params: { path: string[] } }
): Promise<NextResponse> {
const upstream = process.env.TENANTADMIN_API_URL;
const token = process.env.TENANTADMIN_ADMIN_TOKEN;
if (!upstream || !token) {
return NextResponse.json({ error: "Proxy nicht konfiguriert" }, { status: 503 });
}
const path = params.path.join("/");
if (!path.startsWith("admin/") || path.split("/").some((s) => s === ".." || s === ".")) {
return NextResponse.json({ error: "nicht gefunden" }, { status: 404 });
}
if (Number(req.headers.get("content-length") ?? "0") > MAX_BODY_BYTES) {
return NextResponse.json({ error: "Anfrage zu gross" }, { status: 413 });
}
const url = `${upstream.replace(/\/$/, "")}/${path}${req.nextUrl.search}`;
const init: RequestInit = {
method: req.method,
headers: {
"X-Service-Token": token,
"Content-Type": req.headers.get("content-type") ?? "application/json",
},
cache: "no-store",
};
if (req.method !== "GET" && req.method !== "HEAD") {
init.body = await req.text();
}
const res = await fetch(url, init);
return new NextResponse(await res.text(), {
status: res.status,
headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" },
});
}
export { forward as GET, forward as POST };
+3 -7
View File
@@ -26,14 +26,10 @@ export type TenantDetail = {
settings: Settings;
};
// Standard: serverseitiger Proxy (app/api/proxy), der das Dienst-Token ergaenzt.
// NEXT_PUBLIC_TENANTADMIN_API_URL kann fuer Tests/Sonderfaelle auf ein Backend zeigen.
function apiBase(): string {
const base = process.env.NEXT_PUBLIC_TENANTADMIN_API_URL;
if (!base) {
throw new Error(
"NEXT_PUBLIC_TENANTADMIN_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"
);
}
return base;
return process.env.NEXT_PUBLIC_TENANTADMIN_API_URL || "/api/proxy";
}
async function handle<T>(res: Response): Promise<T> {