Compare commits

..
Author SHA1 Message Date
sysops 14516b4adf QA-08: last-leistungstest (jwt-verifikation, connection-pooling, rate-limiting unter mehrmodul-last) 2026-08-28 10:54:05 +02:00
sysops 3ea2489f45 QA-08: tenant-router (TEN-06) + ratelimit (API-03) + apiserver (API-01) auf api-05-basis portiert 2026-08-28 10:52:27 +02:00
sysopsandClaude Sonnet 5 f7863fd4d2 API-05: verteilte-jwt-verifikation-rechte-feature-flag-cache-kontrakt
internal/moduletrust: asymmetrische JWT-Signatur (Ed25519) mit JWKS-
Verteilung, wie im Entscheidungsverlauf "Vertrauensstellung Core<->Module"
(nexarch-state.json) festgelegt. Getrennt von IAM-02s HS256-Session-Cookie
(Browser-Login bleibt unangetastet) — dies ist der Modul-zu-Core-
Vertrauensmechanismus.

KeyManager haelt ALLE noch gueltigen Schluesselpaare (nicht nur das aktuell
signierende); Rotate() erzeugt einen neuen Schluessel, alte bleiben in
PublicKeySet() erhalten — bereits ausgestellte Tokens bleiben dadurch nach
einer Rotation weiterhin verifizierbar (Akzeptanzkriterium 3, keine
Ausfallzeit). ServeJWKS/ParseJWKS sind der Verteilungsmechanismus.

StaleCache[T] ist der generische Rechte-/Feature-Flag-Cache-Kontrakt
(Akzeptanzkriterium 2), mit zwei explizit benannten und begruendeten
Verhalten: Get() ist FAIL-OPEN (nutzt bei Core-Ausfall einen vorhandenen,
abgelaufenen Stand weiter — ein bereits authentifiziertes Modul soll nicht
hart blockieren), RequireFresh() ist FAIL-CLOSED (nie zwischengespeichert,
schlaegt bei Core-Ausfall klar fehl — fuer sicherheitskritische Aktionen wie
einen neuen Login). LIC-02s internal/flag.Service implementiert bereits
denselben Kontrakt fuer Feature-Flags; StaleCache verallgemeinert dasselbe
Muster fuer JWT-Schluessel, damit beide Faelle derselben dokumentierten
Policy folgen statt zwei unterschiedlichen Ad-hoc-Loesungen.

Verifier.Verify ruft KeyFetchFunc nur bei abgelaufener TTL auf, nicht pro
Aufruf (Akzeptanzkriterium 1) — Signaturpruefung selbst ist immer lokal.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Core simuliert abgeschaltet, andere Module bleiben fuer bereits
   authentifizierte Nutzer funktionsfaehig bis TTL/Fail-Open greift —
   TestVerify_FailsOpenWhenCoreUnreachableButStaleKeysExist: Verify()
   funktioniert weiter mit letztbekanntem Schluesselstand. PASS.
2. Neue sicherheitskritische Aktion schlaegt bei Core-Ausfall klar fehl,
   statt andere Funktionen mitzureissen —
   TestRequireFreshKeys_FailsClosedWhenCoreUnreachable: Fehler trotz
   vorhandenem (aelterem) Cache-Stand. PASS.
3. Schluesselrotation ohne Downtime in einem simulierten zweiten Modul —
   TestRotate_NoDowntimeForAlreadyIssuedTokens: vor UND nach Rotation
   ausgestellte Tokens beide weiterhin gueltig fuer Modul B. PASS.

Zusaetzlich: TestVerify_DoesNotFetchPerCall belegt Akzeptanzkriterium 1
direkt (10 Verify-Aufrufe, genau 1 Fetch). PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 21:38:57 +02:00
62 changed files with 1869 additions and 3225 deletions
-2
View File
@@ -1,4 +1,2 @@
*.log
.env
web/*/node_modules/
web/*/.next/
-70
View File
@@ -64,9 +64,6 @@ Keine Änderungen ermittelbar.
## 2026-08-27 17:28 17:29 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** code
## 2026-08-28 21:44 21:44 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
@@ -130,72 +127,5 @@ Keine Commits in dieser Session.
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
- web/shl/components/Dialog.tsx | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/FormElements.tsx | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Shell.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Table.tsx | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Toast.tsx | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/i18n/i18n.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/index.ts | 25 +++++++++++++++++++++++++
- web/shl/package.json | 23 +++++++++++++++++++++++
- web/shl/theme/ThemeProvider.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/tokens/tokens.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/tsconfig.json | 18 ++++++++++++++++++
---
## 2026-08-28 21:51 21:57 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
- 3c226da SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl)
### Geänderte Dateien
- web/shl/package.json | 2 ++
- web/shl/vitest.config.ts | 8 ++++++++
- web/shl/vitest.setup.ts | 1 +
---
## 2026-08-28 23:37 23:43 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
- fbcc9db RBAC-05: web/rbac-admin next.js-frontend (rollen+gruppen-verwaltung, audit-verlauf) auf shl-01
- bd80f0c Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/rbac-05-rechte-administrationsoberflaeche
- 051af43 RBAC-05: backend — handler.go (rollen-/gruppenverwaltung, selbst-eskalationsschutz), group.go ListGroups/Members
- 9de9005 Merge branch 'feature/iam-02-login-session-jwt-grundgeruest' into feature/rbac-05-rechte-administrationsoberflaeche
- 1b6a159 Merge branch 'feature/rbac-03-gruppen-abteilungen' into feature/rbac-05-rechte-administrationsoberflaeche
### Geänderte Dateien
- web/rbac-admin/app/groups/page.tsx | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/rbac-admin/app/layout.tsx | 30 ++++++++++++++++++++++++++++++
- web/rbac-admin/app/page.tsx | 5 +++++
- web/rbac-admin/app/roles/page.tsx | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/rbac-admin/lib/api.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/rbac-admin/next.config.mjs | 5 +++++
- web/rbac-admin/package.json | 22 ++++++++++++++++++++++
- web/rbac-admin/tsconfig.json | 21 +++++++++++++++++++++
---
## 2026-08-28 23:44 23:44 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- web/rbac-admin/app/groups/page.tsx | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/rbac-admin/app/layout.tsx | 30 ++++++++++++++++++++++++++++++
- web/rbac-admin/app/page.tsx | 5 +++++
- web/rbac-admin/app/roles/page.tsx | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/rbac-admin/lib/api.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/rbac-admin/next.config.mjs | 5 +++++
- web/rbac-admin/package.json | 22 ++++++++++++++++++++++
- web/rbac-admin/tsconfig.json | 21 +++++++++++++++++++++
---
+20
View File
@@ -0,0 +1,20 @@
package apiserver
import "context"
type contextKey int
const requestContextKey contextKey = iota
// RequestContext ist der Tenant-/Benutzerkontext, den die Middleware-Kette
// fuer nachgelagerte Handler bereitstellt (Akzeptanzkriterium 3).
type RequestContext struct {
UserID string
TenantSlug string
}
// FromContext liest den von der Middleware gesetzten Kontext.
func FromContext(ctx context.Context) (RequestContext, bool) {
rc, ok := ctx.Value(requestContextKey).(RequestContext)
return rc, ok
}
+31
View File
@@ -0,0 +1,31 @@
// Package apiserver implementiert Core API-01: das REST-Grundgerüst mit
// URL-Versionierung, einheitlichem Fehlerformat und Middleware-Kette
// (Auth, Tenant-/Benutzerkontext, Logging).
package apiserver
import (
"encoding/json"
"net/http"
)
// errorBody ist das EINE Fehlerschema fuer alle Endpunkte unter /api/{version}/
// (Akzeptanzkriterium 2).
type errorBody struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// WriteError schreibt einen Fehler im einheitlichen Schema. code ist ein
// stabiler, maschinenlesbarer Bezeichner (z.B. "unauthenticated"), message
// ein fuer Menschen lesbarer deutscher Text.
func WriteError(w http.ResponseWriter, status int, code, message string) {
var body errorBody
body.Error.Code = code
body.Error.Message = message
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
+56
View File
@@ -0,0 +1,56 @@
package apiserver
import (
"context"
"log/slog"
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
// authAndTenantContext prueft die Session (wiederverwendet auth.TokenIssuer.Verify
// aus IAM-02 — keine zweite JWT-Implementierung) und setzt bei Erfolg
// RequestContext fuer nachgelagerte Handler (Akzeptanzkriterium 3). Anders
// als auth.RequireAuth (Klartext-Fehler) antwortet diese Middleware im
// einheitlichen API-01-Fehlerschema (Akzeptanzkriterium 2), damit ALLE
// Endpunkte unter /api/{version}/ dasselbe Format liefern, auch bei
// Auth-Fehlern.
func authAndTenantContext(issuer *auth.TokenIssuer, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(auth.CookieName)
if err != nil {
WriteError(w, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet")
return
}
claims, err := issuer.Verify(cookie.Value)
if err != nil {
WriteError(w, http.StatusUnauthorized, "unauthenticated", "nicht angemeldet")
return
}
rc := RequestContext{UserID: claims.UserID, TenantSlug: claims.TenantSlug}
next(w, r.WithContext(context.WithValue(r.Context(), requestContextKey, rc)))
}
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// loggingMiddleware protokolliert jede Anfrage strukturiert.
func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
start := time.Now()
next(rec, r)
slog.Info("api-anfrage", "method", r.Method, "path", r.URL.Path, "status", rec.status, "dauer", time.Since(start))
}
}
+37
View File
@@ -0,0 +1,37 @@
package apiserver
import (
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
// Server registriert versionierte API-Routen (Akzeptanzkriterium 1: unter
// /api/{version}/...) und verdrahtet fuer jede Route dieselbe Middleware-
// Kette (Logging -> Auth+Tenantkontext -> Handler).
type Server struct {
mux *http.ServeMux
issuer *auth.TokenIssuer
}
func NewServer(issuer *auth.TokenIssuer) *Server {
return &Server{mux: http.NewServeMux(), issuer: issuer}
}
// Handle registriert pattern unter der angegebenen Version, z.B.
// Handle("v1", "/things", h) -> erreichbar unter /api/v1/things. Verschiedene
// Versionen sind unabhaengige Pfade — eine neue Version beeintraechtigt
// bestehende nicht (Akzeptanzkriterium 1 / Pruefung 3).
func (s *Server) Handle(version, pattern string, h http.HandlerFunc) {
full := "/api/" + version + pattern
s.mux.HandleFunc(full, loggingMiddleware(authAndTenantContext(s.issuer, h)))
}
// HandleV1 ist die Kurzform fuer die aktuelle Hauptversion.
func (s *Server) HandleV1(pattern string, h http.HandlerFunc) {
s.Handle("v1", pattern, h)
}
func (s *Server) Handler() http.Handler {
return s.mux
}
+149
View File
@@ -0,0 +1,149 @@
package apiserver
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
func newTestServer() (*Server, *auth.TokenIssuer) {
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
return NewServer(issuer), issuer
}
func withAuthCookie(req *http.Request, token string) *http.Request {
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
return req
}
// Akzeptanzkriterium 1: API unter versioniertem Pfad erreichbar.
func TestHandleV1_RegistersUnderVersionedPath(t *testing.T) {
srv, issuer := newTestServer()
srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
// Akzeptanzkriterium 2 + Pruefung 1 (Stichprobe): mehrere Endpunkte liefern
// bei fehlerhafter Anfrage dasselbe Fehlerschema.
func TestErrorFormat_ConsistentAcrossEndpoints(t *testing.T) {
srv, _ := newTestServer()
srv.HandleV1("/endpunkt-a", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
srv.HandleV1("/endpunkt-b", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
for _, path := range []string{"/api/v1/endpunkt-a", "/api/v1/endpunkt-b"} {
req := httptest.NewRequest(http.MethodGet, path, nil) // ohne cookie -> 401
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s: status = %d, want 401", path, rec.Code)
}
var body errorBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("%s: fehlerantwort nicht im erwarteten json-schema: %v (body: %s)", path, err, rec.Body.String())
}
if body.Error.Code == "" || body.Error.Message == "" {
t.Fatalf("%s: erwartet nicht-leeren code/message, habe %+v", path, body)
}
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Middleware-Kette setzt Tenant-/
// Benutzerkontext zuverlaessig, nachweislich fuer mehrere Endpunkte.
func TestMiddleware_SetsRequestContextForEveryEndpoint(t *testing.T) {
srv, issuer := newTestServer()
var gotA, gotB RequestContext
srv.HandleV1("/kontext-a", func(w http.ResponseWriter, r *http.Request) {
gotA, _ = FromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
srv.HandleV1("/kontext-b", func(w http.ResponseWriter, r *http.Request) {
gotB, _ = FromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
token, err := issuer.Issue("user-42", "tenant-x")
if err != nil {
t.Fatalf("issue: %v", err)
}
for path, got := range map[string]*RequestContext{"/api/v1/kontext-a": &gotA, "/api/v1/kontext-b": &gotB} {
req := withAuthCookie(httptest.NewRequest(http.MethodGet, path, nil), token)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: status = %d, want 200", path, rec.Code)
}
if got.UserID != "user-42" || got.TenantSlug != "tenant-x" {
t.Fatalf("%s: request-context unerwartet: %+v", path, *got)
}
}
}
// Akzeptanzkriterium 1 + Pruefung 3: eine neue v2-Route laesst sich anlegen,
// ohne v1 zu beeintraechtigen.
func TestVersioning_V2DoesNotAffectV1(t *testing.T) {
srv, issuer := newTestServer()
srv.HandleV1("/things", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("v1-antwort"))
})
srv.Handle("v2", "/things", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("v2-antwort"))
})
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
reqV1 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
recV1 := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV1, reqV1)
if recV1.Body.String() != "v1-antwort" {
t.Fatalf("v1 antwort = %q, want v1-antwort", recV1.Body.String())
}
reqV2 := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v2/things", nil), token)
recV2 := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV2, reqV2)
if recV2.Body.String() != "v2-antwort" {
t.Fatalf("v2 antwort = %q, want v2-antwort", recV2.Body.String())
}
// v1 nach dem Anlegen von v2 erneut pruefen — unveraendert.
reqV1Again := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/things", nil), token)
recV1Again := httptest.NewRecorder()
srv.Handler().ServeHTTP(recV1Again, reqV1Again)
if recV1Again.Body.String() != "v1-antwort" {
t.Fatalf("v1 antwort nach v2-anlage = %q, want weiterhin v1-antwort", recV1Again.Body.String())
}
}
func TestAuthAndTenantContext_RejectsInvalidToken(t *testing.T) {
srv, _ := newTestServer()
srv.HandleV1("/geschuetzt", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
req := withAuthCookie(httptest.NewRequest(http.MethodGet, "/api/v1/geschuetzt", nil), "kaputtes.token.hier")
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", rec.Code)
}
}
+257
View File
@@ -0,0 +1,257 @@
// Package loadtest implementiert Core QA-08: Last- und Leistungstests fuer
// die drei Mechanismen, die unter realistischer Mehrmodul-Last (sechs
// gleichzeitig zugreifende Fachmodule: DMS/Mail/Archive/Workflow/AI/Connect)
// am ehesten unter Druck geraten — JWT-Verifikationspfad (API-05),
// Tenant-Connection-Pooling (TEN-06) und Rate-Limiting (API-03). Jeder Test
// dokumentiert einen Zielwert UND das tatsaechlich gemessene Ergebnis
// (Ticket-Vorgabe: "Pruefen heisst messen. Behauptungen ohne Messprotokoll
// gelten nicht als erledigt.").
package loadtest
import (
"context"
"crypto/ed25519"
"fmt"
"os"
"sort"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
"gitea.perlbach24.de/scripte/nexarch/internal/ratelimit"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
const simulatedModuleCount = 6 // DMS, Mail, Archive, Workflow, AI, Connect
// Akzeptanzkriterium 1 + Pruefung 1: definierte Ziel-Latenz fuer
// JWT-Verifikation unter Last aus sechs gleichzeitig zugreifenden Modulen.
//
// Zielwert: p95 < 10ms je Verify()-Aufruf. Begruendung des Zielwerts:
// Verify() ist eine rein lokale Operation gegen einen bereits im Speicher
// zwischengespeicherten Schluesselsatz (StaleCache, siehe API-05) — es
// findet kein Netzwerk-Roundtrip zu Core statt, daher ist ein niedriger
// Millisekunden-Zielwert realistisch, nicht willkuerlich hoch angesetzt.
func TestLoad_JWTVerificationLatencyUnderSixModuleLoad(t *testing.T) {
km, err := moduletrust.NewKeyManager()
if err != nil {
t.Fatalf("keymanager: %v", err)
}
if _, err := km.Rotate(); err != nil {
t.Fatalf("rotate: %v", err)
}
verifier := moduletrust.NewVerifier(time.Minute, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
return km.PublicKeySet(), nil
})
const requestsPerModule = 200
totalRequests := simulatedModuleCount * requestsPerModule
tokens := make([]string, totalRequests)
for i := 0; i < totalRequests; i++ {
tok, err := km.Issue("user-1", fmt.Sprintf("tenant-%d", i%50), 5*time.Minute)
if err != nil {
t.Fatalf("issue: %v", err)
}
tokens[i] = tok
}
latencies := make([]time.Duration, totalRequests)
var wg sync.WaitGroup
for module := 0; module < simulatedModuleCount; module++ {
wg.Add(1)
go func(moduleIdx int) {
defer wg.Done()
for i := 0; i < requestsPerModule; i++ {
idx := moduleIdx*requestsPerModule + i
start := time.Now()
if _, err := verifier.Verify(context.Background(), tokens[idx]); err != nil {
t.Errorf("verify: %v", err)
return
}
latencies[idx] = time.Since(start)
}
}(module)
}
wg.Wait()
sort.Slice(latencies, func(i, j int) bool { return latencies[i] < latencies[j] })
p95 := latencies[int(float64(len(latencies))*0.95)]
max := latencies[len(latencies)-1]
t.Logf("JWT-Verifikation unter Last: %d module x %d anfragen = %d gesamt. p95=%s, max=%s, ziel=p95<10ms",
simulatedModuleCount, requestsPerModule, totalRequests, p95, max)
if p95 >= 10*time.Millisecond {
t.Fatalf("p95-latenz = %s, ziel war unter 10ms", p95)
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Connection-Pooling bleibt unter der
// Postgres-Verbindungsobergrenze bei simulierter Vielzahl an Mandanten.
//
// Zielwert: bei 200 simulierten Mandanten, auf die sechs Module gleichzeitig
// zugreifen, bleiben NIE mehr als maxOpen=20 Pools gleichzeitig offen (LRU-
// Verdraengung greift) — weit unter einer typischen Postgres
// max_connections-Grenze (Default 100).
func TestLoad_ConnectionPoolingStaysUnderLimitWithManySimulatedTenants(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
defer pool.Close()
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE, db_dsn TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
registry := tenant.NewRegistry(pool)
const simulatedTenants = 200
const maxOpen = 20
prefix := fmt.Sprintf("qa08_%d", time.Now().UnixNano())
slugs := make([]string, simulatedTenants)
for i := 0; i < simulatedTenants; i++ {
slug := fmt.Sprintf("%s_%d", prefix, i)
slugs[i] = slug
// DSN muss nur SYNTAKTISCH gueltig sein — pgxpool.New verbindet
// erst lazy bei tatsaechlicher Nutzung, fuer diesen Lasttest zaehlt
// ausschliesslich die Zahl offener *Pool-Objekte*, nicht ob die
// referenzierte Datenbank real existiert.
dsn := fmt.Sprintf("postgresql://nexarch_test:unused@localhost:5432/%s?sslmode=disable", slug)
if _, err := pool.Exec(ctx, `
INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, $2)
`, slug, dsn); err != nil {
t.Fatalf("tenant %s anlegen: %v", slug, err)
}
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM tenants WHERE slug LIKE $1`, prefix+"%")
})
router := tenant.NewRouter(registry, maxOpen)
defer router.Close()
var maxObservedOpen int64
var wg sync.WaitGroup
for module := 0; module < simulatedModuleCount; module++ {
wg.Add(1)
go func(moduleIdx int) {
defer wg.Done()
for i := 0; i < simulatedTenants; i++ {
slug := slugs[(i+moduleIdx*37)%simulatedTenants] // module-uebergreifend gemischter zugriff
if _, err := router.Resolve(context.Background(), slug); err != nil {
t.Errorf("resolve %s: %v", slug, err)
return
}
if current := int64(router.OpenCount()); current > atomic.LoadInt64(&maxObservedOpen) {
atomic.StoreInt64(&maxObservedOpen, current)
}
}
}(module)
}
wg.Wait()
t.Logf("connection-pooling unter last: %d simulierte mandanten, %d module, max. gleichzeitig beobachtete offene pools = %d (ziel: <= %d)",
simulatedTenants, simulatedModuleCount, maxObservedOpen, maxOpen)
if maxObservedOpen > int64(maxOpen) {
t.Fatalf("max. beobachtete offene pools = %d, ziel war <= %d", maxObservedOpen, maxOpen)
}
if router.OpenCount() > maxOpen {
t.Fatalf("finale offene pools = %d, ziel war <= %d", router.OpenCount(), maxOpen)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Rate-Limiting funktioniert korrekt
// unter Mehrinstanz-Last (mehrere "Core-Instanzen" mit geteiltem Postgres-
// Zustand) aus sechs gleichzeitig zugreifenden Modulen, nicht nur im
// Einzelinstanz-Test (der bereits in API-03 abgedeckt ist).
//
// Zielwert: bei konfiguriertem Limit=500 und insgesamt 1200 Anfragen ueber
// DREI simulierte Core-Instanzen (separate Pools/Store-Objekte) hinweg
// werden EXAKT 500 Anfragen erlaubt — kein Overcounting durch fehlende
// Koordination zwischen den Instanzen, kein Undercounting durch verlorene
// Updates.
func TestLoad_RateLimitingCorrectAcrossMultipleInstancesUnderSixModuleLoad(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
const instanceCount = 3
pools := make([]*pgxpool.Pool, instanceCount)
stores := make([]*ratelimit.Store, instanceCount)
for i := 0; i < instanceCount; i++ {
p, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool %d: %v", i, err)
}
defer p.Close()
if i == 0 {
if _, err := p.Exec(ctx, `
CREATE TABLE IF NOT EXISTS rate_limit_configs (
key TEXT PRIMARY KEY, limit_value INT NOT NULL, window_seconds INT NOT NULL
);
CREATE TABLE IF NOT EXISTS rate_limit_counters (
key TEXT NOT NULL, window_start TIMESTAMPTZ NOT NULL, count INT NOT NULL DEFAULT 0,
PRIMARY KEY (key, window_start)
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
}
pools[i] = p
stores[i] = ratelimit.NewStore(p)
}
key := fmt.Sprintf("qa08-key-%d", time.Now().UnixNano())
const limit = 500
if err := stores[0].SetLimit(ctx, key, limit, time.Minute); err != nil {
t.Fatalf("setlimit: %v", err)
}
const totalRequests = 1200 // simulierte last aus sechs modulen ueber drei instanzen, deutlich ueber dem limit von 500
var allowedCount int64
var wg sync.WaitGroup
for i := 0; i < totalRequests; i++ {
wg.Add(1)
instance := stores[i%instanceCount] // request "kommt" reihum von einer der drei core-instanzen
go func(s *ratelimit.Store) {
defer wg.Done()
res, err := s.Allow(ctx, key)
if err != nil {
t.Errorf("allow: %v", err)
return
}
if res.Allowed {
atomic.AddInt64(&allowedCount, 1)
}
}(instance)
}
wg.Wait()
t.Logf("rate-limiting unter mehrinstanz-last: %d anfragen ueber %d instanzen, limit=%d, tatsaechlich erlaubt=%d",
totalRequests, instanceCount, limit, allowedCount)
if allowedCount != limit {
t.Fatalf("erlaubte anfragen ueber alle instanzen = %d, ziel war exakt %d (geteilter, korrekter zustand)", allowedCount, limit)
}
}
+86
View File
@@ -0,0 +1,86 @@
package moduletrust
import (
"context"
"fmt"
"sync"
"time"
)
// StaleCache ist der generische Rechte-/Feature-Flag-Cache-Kontrakt
// (Akzeptanzkriterium 2): TTL-basiert, mit explizitem, benanntem Verhalten
// bei abgelaufenem Cache waehrend Core nicht erreichbar ist.
//
// - Get: FAIL-OPEN fuer Lesevorgaenge. Schlaegt der Refresh fehl, aber es
// gibt bereits einen (wenn auch abgelaufenen) Stand, wird dieser mit
// stale=true zurueckgegeben — Begruendung: ein bereits authentifiziertes
// Modul soll mit dem letztbekannten Stand weiterarbeiten koennen statt
// hart zu blockieren (siehe "Bekannte Fehler vermeiden" im Ticket).
// Existiert noch nie ein Stand, gibt es keinen sinnvollen Fallback —
// dann liefert auch Get einen Fehler.
// - RequireFresh: FAIL-CLOSED fuer sicherheitskritische Aktionen (z.B.
// ein komplett NEUER Login). Nutzt NIEMALS einen zwischengespeicherten
// Stand, ruft immer frisch ab — Begruendung: eine neue Vertrauens-
// entscheidung darf nicht auf veralteten Daten beruhen, auch wenn das
// bedeutet, dass die Aktion bei Core-Ausfall sichtbar fehlschlaegt statt
// unsicher "irgendwie" durchgelassen zu werden.
//
// LIC-02 (internal/flag.Service) implementiert bereits denselben Kontrakt
// fuer Feature-Flags — StaleCache verallgemeinert dasselbe Muster fuer
// JWT-Signaturschluessel, damit beide Faelle derselben dokumentierten
// Policy folgen.
type StaleCache[T any] struct {
mu sync.RWMutex
value T
hasValue bool
fetchedAt time.Time
ttl time.Duration
fetch func(ctx context.Context) (T, error)
}
func NewStaleCache[T any](ttl time.Duration, fetch func(ctx context.Context) (T, error)) *StaleCache[T] {
return &StaleCache[T]{ttl: ttl, fetch: fetch}
}
// Get liefert den Cache-Wert. FAIL-OPEN: bei Refresh-Fehler wird ein
// vorhandener, ggf. abgelaufener Stand zurueckgegeben (stale=true).
func (c *StaleCache[T]) Get(ctx context.Context) (value T, stale bool, err error) {
c.mu.RLock()
fresh := c.hasValue && time.Since(c.fetchedAt) < c.ttl
if fresh {
v := c.value
c.mu.RUnlock()
return v, false, nil
}
c.mu.RUnlock()
newVal, fetchErr := c.fetch(ctx)
if fetchErr == nil {
c.mu.Lock()
c.value, c.hasValue, c.fetchedAt = newVal, true, time.Now()
c.mu.Unlock()
return newVal, false, nil
}
c.mu.RLock()
defer c.mu.RUnlock()
if c.hasValue {
return c.value, true, nil
}
var zero T
return zero, false, fmt.Errorf("cache leer und refresh fehlgeschlagen: %w", fetchErr)
}
// RequireFresh ruft IMMER frisch ab (FAIL-CLOSED) — fuer sicherheitskritische
// Aktionen, die niemals auf einem zwischengespeicherten Stand basieren duerfen.
func (c *StaleCache[T]) RequireFresh(ctx context.Context) (T, error) {
v, err := c.fetch(ctx)
if err != nil {
var zero T
return zero, fmt.Errorf("core nicht erreichbar, sicherheitskritische aktion abgelehnt: %w", err)
}
c.mu.Lock()
c.value, c.hasValue, c.fetchedAt = v, true, time.Now()
c.mu.Unlock()
return v, nil
}
+79
View File
@@ -0,0 +1,79 @@
package moduletrust
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
Subject string `json:"sub"`
TenantSlug string `json:"tenant"`
jwt.RegisteredClaims
}
// Issue signiert ein Token mit dem aktuellen Signierschluessel und traegt
// dessen KID im JWT-Header ein — der Verifier auf Modulseite waehlt darueber
// den passenden oeffentlichen Schluessel aus PublicKeySet() aus.
func (m *KeyManager) Issue(subject, tenantSlug string, ttl time.Duration) (string, error) {
key, err := m.SigningKey()
if err != nil {
return "", err
}
now := time.Now()
claims := Claims{
Subject: subject,
TenantSlug: tenantSlug,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
token.Header["kid"] = key.KID
return token.SignedString(key.Private)
}
type jwksResponse struct {
Keys []jwksKey `json:"keys"`
}
type jwksKey struct {
Kid string `json:"kid"`
PublicKey string `json:"public_key"` // base64 (raw Ed25519, 32 Byte)
}
// ServeJWKS liefert alle bekannten oeffentlichen Schluessel als JSON —
// Module fragen dies periodisch ab (nicht pro Request), siehe Verifier.
func (m *KeyManager) ServeJWKS(w http.ResponseWriter, r *http.Request) {
set := m.PublicKeySet()
resp := jwksResponse{Keys: make([]jwksKey, 0, len(set))}
for kid, pub := range set {
resp.Keys = append(resp.Keys, jwksKey{Kid: kid, PublicKey: base64.StdEncoding.EncodeToString(pub)})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// ParseJWKS dekodiert die JSON-Antwort von ServeJWKS zurueck in kid->PublicKey
// — Hilfsfunktion fuer Module, die JWKS per HTTP abrufen.
func ParseJWKS(data []byte) (map[string][]byte, error) {
var resp jwksResponse
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("jwks parsen: %w", err)
}
out := make(map[string][]byte, len(resp.Keys))
for _, k := range resp.Keys {
raw, err := base64.StdEncoding.DecodeString(k.PublicKey)
if err != nil {
return nil, fmt.Errorf("oeffentlichen schluessel %q dekodieren: %w", k.Kid, err)
}
out[k.Kid] = raw
}
return out, nil
}
+83
View File
@@ -0,0 +1,83 @@
// Package moduletrust implementiert Core API-05: asymmetrische JWT-Signatur
// mit Schluesselverteilung (JWKS), damit DMS/Mail/Archive/Workflow JWTs
// LOKAL verifizieren koennen, ohne pro Aufruf einen synchronen Request an
// Core zu stellen — Core darf Fundament sein, ohne zum Flaschenhals zu
// werden (siehe Entscheidungsverlauf "Vertrauensstellung Core<->Module" in
// nexarch-state.json). IAM-02s HS256-Session-Cookie (Browser-Login) bleibt
// unangetastet — dies ist ein zusaetzlicher, getrennter Vertrauensmechanismus
// fuer Modul-zu-Modul/Modul-zu-Core-Aufrufe.
package moduletrust
import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"fmt"
"sync"
)
type KeyPair struct {
KID string
Private ed25519.PrivateKey
Public ed25519.PublicKey
}
// KeyManager haelt ALLE noch gueltigen Schluesselpaare — nicht nur das
// aktuell signierende. Rotate erzeugt ein neues Paar und behaelt die alten
// fuer die Verifikation bereits ausgestellter Tokens (Akzeptanzkriterium 3:
// Rotation ohne Ausfallzeit fuer andere Module).
type KeyManager struct {
mu sync.RWMutex
keys []KeyPair // aeltestes zuerst, neuestes zuletzt
}
func NewKeyManager() (*KeyManager, error) {
m := &KeyManager{}
if _, err := m.Rotate(); err != nil {
return nil, err
}
return m, nil
}
// Rotate erzeugt ein neues Ed25519-Schluesselpaar mit eigener KID und macht
// es zum aktuellen Signierschluessel. Aeltere Schluessel bleiben in
// PublicKeySet() erhalten, damit bereits ausgestellte Tokens weiterhin
// verifizierbar sind.
func (m *KeyManager) Rotate() (KeyPair, error) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
return KeyPair{}, fmt.Errorf("schluesselpaar erzeugen: %w", err)
}
kidBytes := make([]byte, 8)
if _, err := rand.Read(kidBytes); err != nil {
return KeyPair{}, fmt.Errorf("kid erzeugen: %w", err)
}
kp := KeyPair{KID: hex.EncodeToString(kidBytes), Private: priv, Public: pub}
m.mu.Lock()
m.keys = append(m.keys, kp)
m.mu.Unlock()
return kp, nil
}
// SigningKey liefert den aktuellen (neuesten) Schluessel zum Signieren neuer Tokens.
func (m *KeyManager) SigningKey() (KeyPair, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if len(m.keys) == 0 {
return KeyPair{}, fmt.Errorf("moduletrust: kein schluessel vorhanden")
}
return m.keys[len(m.keys)-1], nil
}
// PublicKeySet liefert ALLE bekannten oeffentlichen Schluessel (kid ->
// public key) — die Grundlage fuer den JWKS-Endpunkt.
func (m *KeyManager) PublicKeySet() map[string]ed25519.PublicKey {
m.mu.RLock()
defer m.mu.RUnlock()
out := make(map[string]ed25519.PublicKey, len(m.keys))
for _, k := range m.keys {
out[k.KID] = k.Public
}
return out
}
+214
View File
@@ -0,0 +1,214 @@
package moduletrust
import (
"context"
"crypto/ed25519"
"errors"
"net/http"
"sync"
"testing"
"time"
)
func TestIssueAndVerify_RoundTrip(t *testing.T) {
km, err := NewKeyManager()
if err != nil {
t.Fatalf("new key manager: %v", err)
}
token, err := km.Issue("user-1", "acme", time.Hour)
if err != nil {
t.Fatalf("issue: %v", err)
}
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
return km.PublicKeySet(), nil
})
claims, err := v.Verify(context.Background(), token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.Subject != "user-1" || claims.TenantSlug != "acme" {
t.Fatalf("claims unerwartet: %+v", claims)
}
}
// Akzeptanzkriterium 1: Verifikation lokal, kein Request pro Aufruf.
func TestVerify_DoesNotFetchPerCall(t *testing.T) {
km, err := NewKeyManager()
if err != nil {
t.Fatalf("new key manager: %v", err)
}
token, err := km.Issue("user-1", "acme", time.Hour)
if err != nil {
t.Fatalf("issue: %v", err)
}
var mu sync.Mutex
fetchCalls := 0
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
mu.Lock()
fetchCalls++
mu.Unlock()
return km.PublicKeySet(), nil
})
for i := 0; i < 10; i++ {
if _, err := v.Verify(context.Background(), token); err != nil {
t.Fatalf("verify %d: %v", i, err)
}
}
mu.Lock()
defer mu.Unlock()
if fetchCalls != 1 {
t.Fatalf("erwartet genau 1 fetch fuer 10 Verify-Aufrufe innerhalb der TTL, habe %d", fetchCalls)
}
}
// Akzeptanzkriterium 2 + Pruefung 1: Core simuliert nicht erreichbar,
// bereits authentifizierte Nutzer bleiben funktionsfaehig (Fail-Open mit
// letztbekanntem Schluesselstand).
func TestVerify_FailsOpenWhenCoreUnreachableButStaleKeysExist(t *testing.T) {
km, err := NewKeyManager()
if err != nil {
t.Fatalf("new key manager: %v", err)
}
token, err := km.Issue("user-1", "acme", time.Hour)
if err != nil {
t.Fatalf("issue: %v", err)
}
coreDown := false
v := NewVerifier(30*time.Millisecond, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
if coreDown {
return nil, errors.New("core nicht erreichbar (simuliert)")
}
return km.PublicKeySet(), nil
})
// Cache vorwaermen, waehrend Core noch erreichbar ist.
if _, err := v.Verify(context.Background(), token); err != nil {
t.Fatalf("verify (warm): %v", err)
}
// "Core abschalten" und TTL ablaufen lassen.
coreDown = true
time.Sleep(50 * time.Millisecond)
if _, err := v.Verify(context.Background(), token); err != nil {
t.Fatalf("verify sollte trotz core-ausfall mit letztbekanntem stand funktionieren: %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 2: neue sicherheitskritische Aktionen
// (z.B. neuer Login) schlagen bei Core-Ausfall klar fehl statt unsicher
// durchgelassen zu werden — auch wenn ein (aelterer) Cache-Stand existiert.
func TestRequireFreshKeys_FailsClosedWhenCoreUnreachable(t *testing.T) {
km, err := NewKeyManager()
if err != nil {
t.Fatalf("new key manager: %v", err)
}
coreDown := false
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
if coreDown {
return nil, errors.New("core nicht erreichbar (simuliert)")
}
return km.PublicKeySet(), nil
})
// Cache vorwaermen (existiert jetzt ein "veralteter" gueltiger Stand).
if _, _, err := v.cache.Get(context.Background()); err != nil {
t.Fatalf("warm cache: %v", err)
}
coreDown = true
if err := v.RequireFreshKeys(context.Background()); err == nil {
t.Fatal("erwartet fehler (fail-closed) bei core-ausfall, habe nil")
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Schluesselrotation ohne Ausfallzeit —
// ein bereits ausgestelltes Token bleibt nach Rotation weiterhin
// verifizierbar, ein zweites (simuliertes) Modul bekommt beide Schluessel.
func TestRotate_NoDowntimeForAlreadyIssuedTokens(t *testing.T) {
km, err := NewKeyManager()
if err != nil {
t.Fatalf("new key manager: %v", err)
}
oldToken, err := km.Issue("user-1", "acme", time.Hour)
if err != nil {
t.Fatalf("issue (alt): %v", err)
}
if _, err := km.Rotate(); err != nil {
t.Fatalf("rotate: %v", err)
}
newToken, err := km.Issue("user-2", "acme", time.Hour)
if err != nil {
t.Fatalf("issue (neu): %v", err)
}
// Simuliertes zweites Modul: fragt den vollstaendigen Schluesselsatz ab.
moduleB := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
return km.PublicKeySet(), nil
})
if _, err := moduleB.Verify(context.Background(), oldToken); err != nil {
t.Fatalf("altes token sollte nach rotation weiterhin gueltig sein: %v", err)
}
if _, err := moduleB.Verify(context.Background(), newToken); err != nil {
t.Fatalf("neues token sollte gueltig sein: %v", err)
}
}
func TestVerify_RejectsUnknownKid(t *testing.T) {
km1, _ := NewKeyManager()
km2, _ := NewKeyManager() // komplett anderer, unbekannter schluessel
token, err := km1.Issue("user-1", "acme", time.Hour)
if err != nil {
t.Fatalf("issue: %v", err)
}
v := NewVerifier(time.Hour, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
return km2.PublicKeySet(), nil // kennt km1s schluessel nicht
})
if _, err := v.Verify(context.Background(), token); !errors.Is(err, ErrInvalidToken) {
t.Fatalf("erwartet ErrInvalidToken, habe %v", err)
}
}
func TestJWKSRoundTrip(t *testing.T) {
km, _ := NewKeyManager()
km.Rotate()
var buf []byte
rec := &captureWriter{}
km.ServeJWKS(rec, nil)
buf = rec.body
parsed, err := ParseJWKS(buf)
if err != nil {
t.Fatalf("parse jwks: %v", err)
}
if len(parsed) != len(km.PublicKeySet()) {
t.Fatalf("erwartet %d schluessel, habe %d", len(km.PublicKeySet()), len(parsed))
}
}
type captureWriter struct {
body []byte
header http.Header
}
func (w *captureWriter) Header() http.Header {
if w.header == nil {
w.header = http.Header{}
}
return w.header
}
func (w *captureWriter) Write(p []byte) (int, error) { w.body = append(w.body, p...); return len(p), nil }
func (w *captureWriter) WriteHeader(statusCode int) {}
+67
View File
@@ -0,0 +1,67 @@
package moduletrust
import (
"context"
"crypto/ed25519"
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
var ErrInvalidToken = errors.New("moduletrust: ungueltiges token")
// KeyFetchFunc holt den aktuellen Schluesselsatz von Core (z.B. per HTTP-GET
// auf ServeJWKS + ParseJWKS). Wird vom Verifier nur bei abgelaufener TTL
// aufgerufen — NICHT bei jeder Verify()-Anfrage (Akzeptanzkriterium 1).
type KeyFetchFunc func(ctx context.Context) (map[string]ed25519.PublicKey, error)
// Verifier ist die Modulseite von API-05: verifiziert JWTs LOKAL gegen einen
// per StaleCache zwischengespeicherten Schluesselsatz, ohne pro Aufruf einen
// synchronen Request an Core zu stellen.
type Verifier struct {
cache *StaleCache[map[string]ed25519.PublicKey]
}
func NewVerifier(ttl time.Duration, fetch KeyFetchFunc) *Verifier {
return &Verifier{cache: NewStaleCache(ttl, func(ctx context.Context) (map[string]ed25519.PublicKey, error) {
return fetch(ctx)
})}
}
// Verify prueft die Signatur LOKAL gegen den (ggf. abgelaufenen, aber
// vorhandenen) Schluesselsatz — FAIL-OPEN fuer bereits ausgestellte Tokens
// (Akzeptanzkriterium 2): ist Core nicht erreichbar, aber ein alter
// Schluesselsatz bekannt, wird damit weiter verifiziert.
func (v *Verifier) Verify(ctx context.Context, tokenString string) (*Claims, error) {
keys, _, err := v.cache.Get(ctx)
if err != nil {
return nil, err
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodEd25519); !ok {
return nil, ErrInvalidToken
}
kid, _ := t.Header["kid"].(string)
pub, ok := keys[kid]
if !ok {
return nil, ErrInvalidToken
}
return pub, nil
})
if err != nil || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
// RequireFreshKeys ruft IMMER frisch von Core ab (FAIL-CLOSED) — fuer
// sicherheitskritische Aktionen wie einen komplett neuen Login
// (Akzeptanzkriterium 2): schlaegt klar fehl, wenn Core nicht erreichbar
// ist, statt auf einem veralteten Schluesselsatz zu vertrauen.
func (v *Verifier) RequireFreshKeys(ctx context.Context) error {
_, err := v.cache.RequireFresh(ctx)
return err
}
-67
View File
@@ -1,67 +0,0 @@
package policy
import (
"context"
"errors"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
)
// ErrDenied wird geliefert, wenn keine Regel role+permission erlaubt —
// Default-Deny (Akzeptanzkriterium 2).
var ErrDenied = errors.New("policy: zugriff verweigert")
// Enforcer ist die EINE zentrale Entscheidungs- und Durchsetzungsschicht
// (Akzeptanzkriterium 1). Repository-/Query-Code ruft ausschliesslich Guard
// bzw. GuardTenantScoped auf, nie eine Rohabfrage direkt.
type Enforcer struct {
store *Store
}
func NewEnforcer(store *Store) *Enforcer {
return &Enforcer{store: store}
}
// Authorize entscheidet erlaubt/verboten — unabhaengig von jeder konkreten
// Query, rein anhand der deklarativen Regeln (Akzeptanzkriterium 2: fuer
// sich genommen testbar, ohne Anwendungslogik).
func (e *Enforcer) Authorize(ctx context.Context, role rbac.Role, perm rbac.Permission) error {
allowed, err := e.store.IsAllowed(ctx, role, perm)
if err != nil {
return err
}
if !allowed {
return fmt.Errorf("%w: rolle %q hat kein recht %q", ErrDenied, role, perm)
}
return nil
}
// Guard ist die zentrale Enforcement-Funktion (Akzeptanzkriterium 1): query
// wird NUR aufgerufen, wenn Authorize zustimmt. Es gibt keinen Weg, query
// ausserhalb von Guard aufzurufen und trotzdem den Aufrufer als autorisiert
// zu behandeln — die Autorisierungsentscheidung steht immer VOR dem
// Datenzugriff, nie danach.
func Guard[T any](ctx context.Context, e *Enforcer, role rbac.Role, perm rbac.Permission, query func(ctx context.Context) (T, error)) (T, error) {
var zero T
if err := e.Authorize(ctx, role, perm); err != nil {
return zero, err
}
return query(ctx)
}
// GuardTenantScoped erzwingt zusaetzlich, dass tenantSlug TEIL der Query-
// Funktion selbst ist (Akzeptanzkriterium 3): der Funktionstyp verlangt,
// dass die Repository-Implementierung tenantSlug in ihre eigene WHERE-
// Klausel einbaut — ein nachgelagerter Filter auf dem Ergebnis (der
// archivmail-Fehler aus "Bekannte Fehler vermeiden") ist mit dieser
// Signatur nicht moeglich, da die Query-Funktion tenantSlug selbst
// entgegennimmt und dafuer verantwortlich ist, statt ihn hinterher
// anzuwenden.
func GuardTenantScoped[T any](ctx context.Context, e *Enforcer, role rbac.Role, perm rbac.Permission, tenantSlug string, query func(ctx context.Context, tenantSlug string) (T, error)) (T, error) {
var zero T
if err := e.Authorize(ctx, role, perm); err != nil {
return zero, err
}
return query(ctx, tenantSlug)
}
-187
View File
@@ -1,187 +0,0 @@
package policy
import (
"context"
"errors"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
)
func setupTest(t *testing.T) (*Store, *Enforcer, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS policy_rules (
role TEXT NOT NULL, permission TEXT NOT NULL,
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (role, permission)
);
CREATE TABLE IF NOT EXISTS policy_rule_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), role TEXT NOT NULL, permission TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('grant','revoke')), actor TEXT NOT NULL,
version INT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
store := NewStore(pool)
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM policy_rule_changes WHERE role LIKE 'test\_%' ESCAPE '\'`)
_, _ = pool.Exec(ctx, `DELETE FROM policy_rules WHERE role LIKE 'test\_%' ESCAPE '\'`)
pool.Close()
}
return store, NewEnforcer(store), cleanup
}
// Akzeptanzkriterium 2 + Pruefung 2: Anfrage ohne passende Policy wird
// zuverlaessig abgewiesen (Default-Deny), nicht standardmaessig erlaubt.
func TestAuthorize_DefaultDeny(t *testing.T) {
_, enforcer, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
err := enforcer.Authorize(ctx, rbac.Role("test_niemand"), rbac.Permission("test_irgendwas"))
if !errors.Is(err, ErrDenied) {
t.Fatalf("erwartet ErrDenied ohne konfigurierte regel, habe %v", err)
}
}
// Akzeptanzkriterium 1 + Pruefung 1: Guard ruft query NUR bei Autorisierung
// auf — kein Datenzugriffs-Pfad umgeht die Enforcement-Schicht.
func TestGuard_NeverCallsQueryWithoutAuthorization(t *testing.T) {
store, enforcer, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
role := rbac.Role("test_rolle_guard")
perm := rbac.Permission("test_lesen")
queryCalled := false
_, err := Guard(ctx, enforcer, role, perm, func(ctx context.Context) (string, error) {
queryCalled = true
return "geheime-daten", nil
})
if !errors.Is(err, ErrDenied) {
t.Fatalf("erwartet ErrDenied, habe %v", err)
}
if queryCalled {
t.Fatal("query haette bei fehlender autorisierung NICHT aufgerufen werden duerfen")
}
if err := store.Grant(ctx, role, perm, "admin@example.com"); err != nil {
t.Fatalf("grant: %v", err)
}
result, err := Guard(ctx, enforcer, role, perm, func(ctx context.Context) (string, error) {
queryCalled = true
return "geheime-daten", nil
})
if err != nil {
t.Fatalf("guard nach grant: %v", err)
}
if !queryCalled || result != "geheime-daten" {
t.Fatalf("erwartet query-aufruf mit ergebnis nach autorisierung, habe queryCalled=%v result=%q", queryCalled, result)
}
}
// Akzeptanzkriterium 2 + 3: Policy-Entscheidungen deklarativ und unabhaengig
// von Anwendungslogik testbar — Regelwechsel wirkt ohne Codeaenderung,
// Historie ist versioniert nachvollziehbar.
func TestGrantRevoke_ChangesBehaviorWithoutCodeChange(t *testing.T) {
store, enforcer, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
role := rbac.Role("test_rolle_history")
perm := rbac.Permission("test_schreiben")
allowed, err := store.IsAllowed(ctx, role, perm)
if err != nil {
t.Fatalf("is allowed (vorher): %v", err)
}
if allowed {
t.Fatal("erwartet nicht erlaubt vor grant")
}
if err := store.Grant(ctx, role, perm, "admin@example.com"); err != nil {
t.Fatalf("grant: %v", err)
}
if err := enforcer.Authorize(ctx, role, perm); err != nil {
t.Fatalf("authorize nach grant sollte erlauben: %v", err)
}
if err := store.Revoke(ctx, role, perm, "admin@example.com"); err != nil {
t.Fatalf("revoke: %v", err)
}
if err := enforcer.Authorize(ctx, role, perm); !errors.Is(err, ErrDenied) {
t.Fatalf("authorize nach revoke sollte verweigern, habe %v", err)
}
history, err := store.History(ctx, role, perm)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 2 {
t.Fatalf("erwartet 2 historieneintraege (grant, revoke), habe %d", len(history))
}
if history[0].Action != "grant" || history[0].Version != 1 {
t.Fatalf("history[0] unerwartet: %+v", history[0])
}
if history[1].Action != "revoke" || history[1].Version != 2 {
t.Fatalf("history[1] unerwartet: %+v", history[1])
}
}
// Akzeptanzkriterium 3 + Pruefung 3 (Tenant-Scoping): GuardTenantScoped
// zwingt tenantSlug in die Query-Funktion selbst, kein Post-Filter moeglich.
func TestGuardTenantScoped_IsolatesDataBetweenTenants(t *testing.T) {
_, enforcer, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
// Simuliertes Repository: die WHERE-Klausel (hier: Map-Lookup) liegt
// INNERHALB der Query-Funktion, nicht als nachgelagerter Filter.
data := map[string][]string{
"acme": {"dokument-a1", "dokument-a2"},
"globex": {"dokument-b1"},
}
repoQuery := func(ctx context.Context, tenantSlug string) ([]string, error) {
return data[tenantSlug], nil
}
role := rbac.Role("test_tenant_rolle")
perm := rbac.Permission("test_dokumente_lesen")
if err := enforcer.store.Grant(ctx, role, perm, "admin@example.com"); err != nil {
t.Fatalf("grant: %v", err)
}
gotAcme, err := GuardTenantScoped(ctx, enforcer, role, perm, "acme", repoQuery)
if err != nil {
t.Fatalf("guard tenant scoped (acme): %v", err)
}
if len(gotAcme) != 2 {
t.Fatalf("erwartet 2 dokumente fuer acme, habe %d", len(gotAcme))
}
gotGlobex, err := GuardTenantScoped(ctx, enforcer, role, perm, "globex", repoQuery)
if err != nil {
t.Fatalf("guard tenant scoped (globex): %v", err)
}
if len(gotGlobex) != 1 {
t.Fatalf("erwartet 1 dokument fuer globex, habe %d", len(gotGlobex))
}
}
-124
View File
@@ -1,124 +0,0 @@
// Package policy implementiert Core RBAC-02: die zentrale Policy-
// Entscheidungs- und Durchsetzungsschicht. Regeln liegen deklarativ in der
// Datenbank (Casbin-Prinzip: Policy als Modell+Regeln getrennt vom Code),
// versioniert und auditierbar — anders als Casbin-Dateien im Dateisystem
// (siehe "bewusst vermeiden" im Ticket).
package policy
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
)
// Rule ist eine gewaehrte Regel: role darf permission.
type Rule struct {
Role rbac.Role
Permission rbac.Permission
}
// RuleChange ist ein Eintrag der Aenderungshistorie (Akzeptanzkriterium 3).
type RuleChange struct {
Role rbac.Role
Permission rbac.Permission
Action string // "grant" oder "revoke"
Actor string
Version int
}
// Store verwaltet den deklarativen Regelsatz. IsAllowed schaut NUR in die
// Datenbank — es gibt keinen hartcodierten Go-Fallback, ein Regelwechsel
// wirkt sich ohne Codeaenderung sofort aus (Akzeptanzkriterium 2 / 3).
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// IsAllowed prueft, ob role das Recht permission besitzt. Default-Deny:
// existiert keine passende Regel, ist der Zugriff verboten
// (Akzeptanzkriterium 2 / Pruefung 2) — es gibt keinen impliziten
// "erlaubt, wenn nichts anderes gesagt"-Pfad.
func (s *Store) IsAllowed(ctx context.Context, role rbac.Role, perm rbac.Permission) (bool, error) {
var exists bool
err := s.pool.QueryRow(ctx, `
SELECT EXISTS(SELECT 1 FROM policy_rules WHERE role = $1 AND permission = $2)
`, string(role), string(perm)).Scan(&exists)
if err != nil {
return false, fmt.Errorf("policy pruefen: %w", err)
}
return exists, nil
}
// Grant gewaehrt role das Recht permission — deklarativ, ohne Codeaenderung
// wirksam. actor wird fuer die Aenderungshistorie festgehalten.
func (s *Store) Grant(ctx context.Context, role rbac.Role, perm rbac.Permission, actor string) error {
return s.change(ctx, role, perm, "grant", actor, `
INSERT INTO policy_rules (role, permission, granted_by, granted_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (role, permission) DO UPDATE SET granted_by = $3, granted_at = now()
`, string(role), string(perm), actor)
}
// Revoke entzieht role das Recht permission.
func (s *Store) Revoke(ctx context.Context, role rbac.Role, perm rbac.Permission, actor string) error {
return s.change(ctx, role, perm, "revoke", actor, `
DELETE FROM policy_rules WHERE role = $1 AND permission = $2
`, string(role), string(perm))
}
func (s *Store) change(ctx context.Context, role rbac.Role, perm rbac.Permission, action, actor, sql string, args ...any) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, sql, args...); err != nil {
return fmt.Errorf("regel aendern: %w", err)
}
var version int
if err := tx.QueryRow(ctx, `
SELECT COALESCE(MAX(version), 0) + 1 FROM policy_rule_changes WHERE role = $1 AND permission = $2
`, string(role), string(perm)).Scan(&version); err != nil {
return fmt.Errorf("naechste version ermitteln: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO policy_rule_changes (role, permission, action, actor, version, changed_at)
VALUES ($1, $2, $3, $4, $5, now())
`, string(role), string(perm), action, actor, version); err != nil {
return fmt.Errorf("historie schreiben: %w", err)
}
return tx.Commit(ctx)
}
// History liefert die vollstaendige, versionierte Aenderungshistorie einer
// Regel (Akzeptanzkriterium 3).
func (s *Store) History(ctx context.Context, role rbac.Role, perm rbac.Permission) ([]RuleChange, error) {
rows, err := s.pool.Query(ctx, `
SELECT action, actor, version FROM policy_rule_changes
WHERE role = $1 AND permission = $2 ORDER BY version
`, string(role), string(perm))
if err != nil {
return nil, fmt.Errorf("historie abfragen: %w", err)
}
defer rows.Close()
var out []RuleChange
for rows.Next() {
c := RuleChange{Role: role, Permission: perm}
if err := rows.Scan(&c.Action, &c.Actor, &c.Version); err != nil {
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
+64
View File
@@ -0,0 +1,64 @@
package ratelimit
import (
"fmt"
"math"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/apiserver"
)
// KeyFunc bestimmt den Rate-Limit-Schluessel fuer eine Anfrage — typischerweise
// der Tenant-Slug aus apiserver.RequestContext, siehe KeyByTenant. Als
// eigenstaendiger Typ austauschbar (z.B. spaeter API-Token-basiert), ohne
// internal/apiserver aendern zu muessen (Kein Umbau angrenzender Bereiche).
type KeyFunc func(r *http.Request) (string, bool)
// KeyByTenant liest den Tenant aus dem von internal/apiserver.authAndTenantContext
// gesetzten RequestContext (IAM-02/API-01) — dieselbe Middleware-Kette,
// keine zweite Authentifizierung.
func KeyByTenant(r *http.Request) (string, bool) {
rc, ok := apiserver.FromContext(r.Context())
if !ok || rc.TenantSlug == "" {
return "", false
}
return rc.TenantSlug, true
}
// Middleware setzt sich VOR den eigentlichen Handler (nach Auth+Tenant-
// Kontext, siehe KeyByTenant) und lehnt Anfragen ueber dem konfigurierten
// Limit mit 429 + Retry-After ab (Akzeptanzkriterium 3).
func Middleware(store *Store, keyFn KeyFunc, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
key, ok := keyFn(r)
if !ok {
// Kein Schluessel ermittelbar (z.B. kein Tenant-Kontext) — die
// Auth-Pruefung selbst ist Sache von authAndTenantContext, hier
// wird nur nicht limitiert, wenn schon kein Kontext vorliegt.
next(w, r)
return
}
result, err := store.Allow(r.Context(), key)
if err != nil {
apiserver.WriteError(w, http.StatusInternalServerError, "rate_limit_error", "rate-limit-pruefung fehlgeschlagen")
return
}
w.Header().Set("X-RateLimit-Limit", fmt.Sprintf("%d", result.Limit))
w.Header().Set("X-RateLimit-Remaining", fmt.Sprintf("%d", result.Remaining))
if !result.Allowed {
retryAfterSeconds := int(math.Ceil(result.RetryAfter.Seconds()))
if retryAfterSeconds < 1 {
retryAfterSeconds = 1
}
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfterSeconds))
apiserver.WriteError(w, http.StatusTooManyRequests, "rate_limit_exceeded",
"zu viele anfragen, bitte spaeter erneut versuchen")
return
}
next(w, r)
}
}
+136
View File
@@ -0,0 +1,136 @@
package ratelimit
import (
"context"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/apiserver"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
func setupMiddlewareTest(t *testing.T) (*apiserver.Server, *auth.TokenIssuer, *Store, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS rate_limit_configs (
key TEXT PRIMARY KEY, limit_value INT NOT NULL, window_seconds INT NOT NULL
);
CREATE TABLE IF NOT EXISTS rate_limit_counters (
key TEXT NOT NULL, window_start TIMESTAMPTZ NOT NULL, count INT NOT NULL DEFAULT 0,
PRIMARY KEY (key, window_start)
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
srv := apiserver.NewServer(issuer)
store := NewStore(pool)
cleanup := func() { pool.Close() }
return srv, issuer, store, cleanup
}
// Akzeptanzkriterium 3 + Pruefung 2: Ueberschreitung liefert 429 mit
// Retry-After-Header, ueber die vollstaendige Middleware-Kette (Auth ->
// Tenant-Kontext -> Rate-Limit -> Handler) hinweg.
func TestMiddleware_Returns429WithRetryAfterOnExceeded(t *testing.T) {
srv, issuer, store, cleanup := setupMiddlewareTest(t)
defer cleanup()
ctx := context.Background()
tenantSlug := newTestKey()
if err := store.SetLimit(ctx, tenantSlug, 1, time.Minute); err != nil {
t.Fatalf("setlimit: %v", err)
}
called := 0
srv.HandleV1("/limited", Middleware(store, KeyByTenant, func(w http.ResponseWriter, r *http.Request) {
called++
w.WriteHeader(http.StatusOK)
}))
token, err := issuer.Issue("user-1", tenantSlug)
if err != nil {
t.Fatalf("issue: %v", err)
}
req1 := httptest.NewRequest(http.MethodGet, "/api/v1/limited", nil)
req1.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
rec1 := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec1, req1)
if rec1.Code != http.StatusOK {
t.Fatalf("1. anfrage: status = %d, want 200", rec1.Code)
}
req2 := httptest.NewRequest(http.MethodGet, "/api/v1/limited", nil)
req2.AddCookie(&http.Cookie{Name: auth.CookieName, Value: token})
rec2 := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec2, req2)
if rec2.Code != http.StatusTooManyRequests {
t.Fatalf("2. anfrage: status = %d, want 429", rec2.Code)
}
if rec2.Header().Get("Retry-After") == "" {
t.Fatal("erwartet gesetzten Retry-After-Header bei 429")
}
if called != 1 {
t.Fatalf("handler wurde %d mal aufgerufen, want genau 1 (2. anfrage haette nicht durchgereicht werden duerfen)", called)
}
}
// Akzeptanzkriterium 1: das Limit ist pro Tenant konfigurierbar — ein
// anderer Tenant (anderer Key) bleibt von der Ausschoepfung unberuehrt.
func TestMiddleware_DifferentTenantsHaveIndependentLimits(t *testing.T) {
srv, issuer, store, cleanup := setupMiddlewareTest(t)
defer cleanup()
ctx := context.Background()
tenantA := newTestKey()
tenantB := newTestKey()
if err := store.SetLimit(ctx, tenantA, 1, time.Minute); err != nil {
t.Fatalf("setlimit a: %v", err)
}
if err := store.SetLimit(ctx, tenantB, 1, time.Minute); err != nil {
t.Fatalf("setlimit b: %v", err)
}
srv.HandleV1("/limited2", Middleware(store, KeyByTenant, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
tokenA, _ := issuer.Issue("user-a", tenantA)
tokenB, _ := issuer.Issue("user-b", tenantB)
// Tenant A schoepft sein Limit aus.
reqA := httptest.NewRequest(http.MethodGet, "/api/v1/limited2", nil)
reqA.AddCookie(&http.Cookie{Name: auth.CookieName, Value: tokenA})
recA := httptest.NewRecorder()
srv.Handler().ServeHTTP(recA, reqA)
if recA.Code != http.StatusOK {
t.Fatalf("tenant a, 1. anfrage: status = %d, want 200", recA.Code)
}
// Tenant B ist von der Ausschoepfung bei A unberuehrt.
reqB := httptest.NewRequest(http.MethodGet, "/api/v1/limited2", nil)
reqB.AddCookie(&http.Cookie{Name: auth.CookieName, Value: tokenB})
recB := httptest.NewRecorder()
srv.Handler().ServeHTTP(recB, reqB)
if recB.Code != http.StatusOK {
t.Fatalf("tenant b haette trotz ausgeschoepftem limit von tenant a erlaubt sein muessen, status = %d", recB.Code)
}
}
+126
View File
@@ -0,0 +1,126 @@
// Package ratelimit implementiert Core API-03: eine zentrale Rate-Limiting-
// Schicht fuer die gesamte Core-API mit GETEILTEM, EXTERNEM Zustand in
// Postgres — kein In-Process-Zaehler (siehe "Bekannte Fehler vermeiden" im
// Ticket: dasselbe Risiko wie bei IAM-07s In-Memory-Lockout). Fixed-Window-
// Algorithmus: einfach, korrekt unter nebenlaeufigem Zugriff (atomares
// UPSERT wie internal/usage.Store.Increment), und fuer ein API-Gateway
// ausreichend praezise — kein Sliding-Window-Overhead noetig.
package ratelimit
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// DefaultLimit/DefaultWindow gelten fuer jeden Schluessel (Tenant oder
// API-Token), fuer den keine eigene Konfiguration existiert
// (Akzeptanzkriterium 1: "konfigurierbar", nicht "verpflichtend konfiguriert").
const (
DefaultLimit = 100
DefaultWindow = time.Minute
)
// Config ist das je Schluessel konfigurierbare Limit.
type Config struct {
Limit int
Window time.Duration
}
// Store haelt Konfiguration UND Zaehlerstand in Postgres — beides ueber
// dieselbe Verbindung erreichbar, damit mehrere Dienstinstanzen denselben
// Zustand sehen (Akzeptanzkriterium 2).
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// SetLimit setzt die Konfiguration fuer EINEN Schluessel (z.B. einen
// Tenant-Slug oder eine API-Token-ID) — wirkt sich nicht auf andere
// Schluessel aus (Akzeptanzkriterium 1 / Pruefung 3).
func (s *Store) SetLimit(ctx context.Context, key string, limit int, window time.Duration) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO rate_limit_configs (key, limit_value, window_seconds)
VALUES ($1, $2, $3)
ON CONFLICT (key) DO UPDATE SET limit_value = $2, window_seconds = $3
`, key, limit, int(window.Seconds()))
if err != nil {
return fmt.Errorf("rate-limit-konfiguration speichern: %w", err)
}
return nil
}
func (s *Store) getConfig(ctx context.Context, key string) (Config, error) {
var limit, windowSeconds int
err := s.pool.QueryRow(ctx, `
SELECT limit_value, window_seconds FROM rate_limit_configs WHERE key = $1
`, key).Scan(&limit, &windowSeconds)
if err != nil {
if err == pgx.ErrNoRows {
return Config{Limit: DefaultLimit, Window: DefaultWindow}, nil
}
return Config{}, fmt.Errorf("rate-limit-konfiguration lesen: %w", err)
}
return Config{Limit: limit, Window: time.Duration(windowSeconds) * time.Second}, nil
}
// Result ist der Ausgang einer Allow-Pruefung (Akzeptanzkriterium 3).
type Result struct {
Allowed bool
Limit int
Remaining int
RetryAfter time.Duration
}
// Allow erhoeht den Zaehler fuer (key, aktuelles Zeitfenster) ATOMAR ueber
// ein einziges UPSERT (dasselbe Muster wie internal/usage.Store.Increment)
// und vergleicht das Ergebnis gegen die konfigurierte Grenze — kein
// Lesen-Erhoehen-Schreiben in Go, damit zwei Dienstinstanzen, die
// gleichzeitig gegen dieselbe Datenbank inkrementieren, sich niemals
// gegenseitig ueberschreiben (Akzeptanzkriterium 2 / Pruefung 1).
func (s *Store) Allow(ctx context.Context, key string) (Result, error) {
cfg, err := s.getConfig(ctx, key)
if err != nil {
return Result{}, err
}
windowSeconds := int64(cfg.Window.Seconds())
if windowSeconds <= 0 {
windowSeconds = int64(DefaultWindow.Seconds())
}
now := time.Now().UTC()
windowStart := time.Unix((now.Unix()/windowSeconds)*windowSeconds, 0).UTC()
windowEnd := windowStart.Add(time.Duration(windowSeconds) * time.Second)
var count int
err = s.pool.QueryRow(ctx, `
INSERT INTO rate_limit_counters (key, window_start, count)
VALUES ($1, $2, 1)
ON CONFLICT (key, window_start) DO UPDATE
SET count = rate_limit_counters.count + 1
RETURNING count
`, key, windowStart).Scan(&count)
if err != nil {
return Result{}, fmt.Errorf("rate-limit-zaehler erhoehen: %w", err)
}
remaining := cfg.Limit - count
if remaining < 0 {
remaining = 0
}
if count > cfg.Limit {
return Result{
Allowed: false,
Limit: cfg.Limit,
Remaining: 0,
RetryAfter: windowEnd.Sub(now),
}, nil
}
return Result{Allowed: true, Limit: cfg.Limit, Remaining: remaining}, nil
}
+168
View File
@@ -0,0 +1,168 @@
package ratelimit
import (
"context"
"fmt"
"os"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupTest(t *testing.T) (*pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS rate_limit_configs (
key TEXT PRIMARY KEY, limit_value INT NOT NULL, window_seconds INT NOT NULL
);
CREATE TABLE IF NOT EXISTS rate_limit_counters (
key TEXT NOT NULL, window_start TIMESTAMPTZ NOT NULL, count INT NOT NULL DEFAULT 0,
PRIMARY KEY (key, window_start)
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return pool, cleanup
}
func newTestKey() string {
return fmt.Sprintf("test-key-%d", time.Now().UnixNano())
}
// Akzeptanzkriterium 3 + Pruefung 2: Ueberschreitung des Limits wird
// nachweislich erkannt.
func TestAllow_RejectsAfterLimitExceeded(t *testing.T) {
pool, cleanup := setupTest(t)
defer cleanup()
store := NewStore(pool)
ctx := context.Background()
key := newTestKey()
if err := store.SetLimit(ctx, key, 3, time.Minute); err != nil {
t.Fatalf("setlimit: %v", err)
}
for i := 0; i < 3; i++ {
res, err := store.Allow(ctx, key)
if err != nil {
t.Fatalf("allow %d: %v", i, err)
}
if !res.Allowed {
t.Fatalf("anfrage %d haette erlaubt sein muessen, limit=3", i)
}
}
res, err := store.Allow(ctx, key)
if err != nil {
t.Fatalf("allow (4.): %v", err)
}
if res.Allowed {
t.Fatal("4. anfrage haette abgelehnt werden muessen (limit=3)")
}
if res.RetryAfter <= 0 {
t.Fatalf("erwartet positive retry-after-dauer, habe %v", res.RetryAfter)
}
}
// Akzeptanzkriterium 1 / Pruefung 3: Konfigurationsaenderung wirkt sich
// NICHT auf andere Schluessel (Tenants) aus.
func TestSetLimit_DoesNotAffectOtherKeys(t *testing.T) {
pool, cleanup := setupTest(t)
defer cleanup()
store := NewStore(pool)
ctx := context.Background()
keyA := newTestKey()
keyB := newTestKey()
if err := store.SetLimit(ctx, keyA, 1, time.Minute); err != nil {
t.Fatalf("setlimit a: %v", err)
}
resA1, _ := store.Allow(ctx, keyA)
resA2, _ := store.Allow(ctx, keyA)
if !resA1.Allowed || resA2.Allowed {
t.Fatalf("key a: erwartet erlaubt,abgelehnt, habe %v,%v", resA1.Allowed, resA2.Allowed)
}
// keyB hat keine eigene Konfiguration -> DefaultLimit, unbeeinflusst von keyA.
resB, err := store.Allow(ctx, keyB)
if err != nil {
t.Fatalf("allow b: %v", err)
}
if !resB.Allowed {
t.Fatal("key b haette trotz limit-ueberschreitung bei key a erlaubt sein muessen")
}
if resB.Limit != DefaultLimit {
t.Fatalf("key b limit = %d, want default %d", resB.Limit, DefaultLimit)
}
}
// Akzeptanzkriterium 2 + Pruefung 1: zwei "Dienstinstanzen" (zwei
// unabhaengige Pools/Store-Objekte) gegen dieselbe Datenbank setzen
// dasselbe Limit korrekt durch — kein In-Process-Zustand kann das leisten.
func TestAllow_TwoInstancesShareStateCorrectly(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
pool2, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("zweiter pool (simuliert zweite instanz): %v", err)
}
defer pool2.Close()
storeInstance1 := NewStore(pool)
storeInstance2 := NewStore(pool2)
key := newTestKey()
const limit = 20
if err := storeInstance1.SetLimit(ctx, key, limit, time.Minute); err != nil {
t.Fatalf("setlimit: %v", err)
}
const totalRequests = 50
var allowedCount int64
var wg sync.WaitGroup
for i := 0; i < totalRequests; i++ {
wg.Add(1)
store := storeInstance1
if i%2 == 0 {
store = storeInstance2 // Haelfte der Anfragen "kommt" von der zweiten Instanz
}
go func(s *Store) {
defer wg.Done()
res, err := s.Allow(ctx, key)
if err != nil {
t.Errorf("allow: %v", err)
return
}
if res.Allowed {
atomic.AddInt64(&allowedCount, 1)
}
}(store)
}
wg.Wait()
if allowedCount != limit {
t.Fatalf("erlaubte anfragen ueber beide instanzen = %d, want exakt %d (geteilter zustand)", allowedCount, limit)
}
}
-200
View File
@@ -1,200 +0,0 @@
package rbac
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrGroupNotFound = errors.New("rbac: gruppe nicht gefunden")
type Group struct {
ID string
Name string
Role Role // leer, wenn der Gruppe noch keine Rolle zugewiesen wurde
}
// GroupStore verwaltet Gruppen/Abteilungen innerhalb GENAU EINER Tenant-
// Datenbank (Modell C, wie Store fuer direkte Rollenzuweisungen) —
// Akzeptanzkriterium 3 (tenant-isoliert) ist damit strukturell erfuellt.
type GroupStore struct {
pool *pgxpool.Pool
}
func NewGroupStore(pool *pgxpool.Pool) *GroupStore {
return &GroupStore{pool: pool}
}
// CreateGroup legt eine neue, noch rollenlose Gruppe an (Akzeptanzkriterium 1).
func (s *GroupStore) CreateGroup(ctx context.Context, name string) (Group, error) {
if name == "" {
return Group{}, errors.New("rbac: gruppenname darf nicht leer sein")
}
var g Group
g.Name = name
if err := s.pool.QueryRow(ctx, `
INSERT INTO groups (name) VALUES ($1) RETURNING id
`, name).Scan(&g.ID); err != nil {
return Group{}, fmt.Errorf("gruppe anlegen: %w", err)
}
return g, nil
}
// SetGroupRole weist der Gruppe eine Rolle zu (Akzeptanzkriterium 2) — nur
// 'user'/'tenant_admin' sind auf Gruppenebene zuweisbar, dieselbe erlaubte
// Matrix wie in Store.Assign (superadmin bleibt mandantenuebergreifend und
// ausserhalb jeder Gruppenlogik).
func (s *GroupStore) SetGroupRole(ctx context.Context, groupID string, role Role) error {
if !assignableRoles[role] {
return ErrRoleNotAssignableInTenantScope
}
tag, err := s.pool.Exec(ctx, `UPDATE groups SET role = $2 WHERE id = $1`, groupID, string(role))
if err != nil {
return fmt.Errorf("gruppenrolle setzen: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrGroupNotFound
}
return nil
}
// AddMember nimmt einen Benutzer in die Gruppe auf.
func (s *GroupStore) AddMember(ctx context.Context, groupID, userID string) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO group_members (group_id, user_id) VALUES ($1, $2)
ON CONFLICT (group_id, user_id) DO NOTHING
`, groupID, userID)
if err != nil {
return fmt.Errorf("mitglied hinzufuegen: %w", err)
}
return nil
}
// RemoveMember entfernt einen Benutzer aus der Gruppe — die gruppenbasierten
// Rechte entfallen fuer ihn sofort (Akzeptanzkriterium 3 / Pruefung 2), da
// EffectivePermissionsForUser bei jedem Aufruf neu berechnet wird, nicht
// zwischengespeichert.
func (s *GroupStore) RemoveMember(ctx context.Context, groupID, userID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM group_members WHERE group_id = $1 AND user_id = $2`, groupID, userID)
if err != nil {
return fmt.Errorf("mitglied entfernen: %w", err)
}
return nil
}
// DeleteGroup loescht die Gruppe — ON DELETE CASCADE entfernt die
// Mitgliedschaften mit, Benutzerkonten selbst bleiben unberuehrt
// (Akzeptanzkriterium 3).
func (s *GroupStore) DeleteGroup(ctx context.Context, groupID string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM groups WHERE id = $1`, groupID)
if err != nil {
return fmt.Errorf("gruppe loeschen: %w", err)
}
return nil
}
// ListGroups liefert alle Gruppen des Mandanten (Akzeptanzkriterium 1/2,
// Grundlage fuer die Administrationsoberflaeche).
func (s *GroupStore) ListGroups(ctx context.Context) ([]Group, error) {
rows, err := s.pool.Query(ctx, `SELECT id, name, COALESCE(role, '') FROM groups ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("gruppen auflisten: %w", err)
}
defer rows.Close()
var out []Group
for rows.Next() {
var g Group
var role string
if err := rows.Scan(&g.ID, &g.Name, &role); err != nil {
return nil, fmt.Errorf("gruppe lesen: %w", err)
}
g.Role = Role(role)
out = append(out, g)
}
return out, rows.Err()
}
// Members liefert die User-IDs der Mitglieder einer Gruppe.
func (s *GroupStore) Members(ctx context.Context, groupID string) ([]string, error) {
rows, err := s.pool.Query(ctx, `SELECT user_id FROM group_members WHERE group_id = $1`, groupID)
if err != nil {
return nil, fmt.Errorf("mitglieder abfragen: %w", err)
}
defer rows.Close()
var out []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("mitglied lesen: %w", err)
}
out = append(out, id)
}
return out, rows.Err()
}
// RolesForUser liefert alle Rollen, die einem Benutzer ueber seine
// Gruppenmitgliedschaften zufallen (Akzeptanzkriterium 2: wirkt auf alle
// Mitglieder). Eine Gruppe ohne zugewiesene Rolle traegt nichts bei.
func (s *GroupStore) RolesForUser(ctx context.Context, userID string) ([]Role, error) {
rows, err := s.pool.Query(ctx, `
SELECT g.role FROM groups g
JOIN group_members gm ON gm.group_id = g.id
WHERE gm.user_id = $1 AND g.role IS NOT NULL
`, userID)
if err != nil {
return nil, fmt.Errorf("gruppenrollen abfragen: %w", err)
}
defer rows.Close()
var out []Role
for rows.Next() {
var r string
if err := rows.Scan(&r); err != nil {
return nil, fmt.Errorf("gruppenrolle lesen: %w", err)
}
out = append(out, Role(r))
}
return out, rows.Err()
}
// EffectivePermissionsForUser vereinigt die Rechte aus der direkten
// Rollenzuweisung (Store, RBAC-01) mit allen Rechten aus Gruppenrollen —
// jede einzelne Rolle wird ueber die bestehende Hierarchie (EffectivePermissions)
// aufgeloest, das Ergebnis dedupliziert.
func (s *GroupStore) EffectivePermissionsForUser(ctx context.Context, directStore *Store, userID string) ([]Permission, error) {
seen := make(map[Permission]bool)
var out []Permission
add := func(role Role) {
for _, p := range EffectivePermissions(role) {
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
}
if directStore != nil {
assignment, err := directStore.Get(ctx, userID)
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
if err == nil {
add(assignment.Role)
}
}
groupRoles, err := s.RolesForUser(ctx, userID)
if err != nil {
return nil, err
}
for _, r := range groupRoles {
add(r)
}
return out, nil
}
-209
View File
@@ -1,209 +0,0 @@
package rbac
import (
"context"
"fmt"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
const groupSchema = `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignments (
user_id UUID PRIMARY KEY REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')),
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, role TEXT CHECK (role IN ('user','tenant_admin')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS group_members (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id),
added_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (group_id, user_id)
);`
func setupGroupTestDB(t *testing.T, dbName string) *pgxpool.Pool {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
t.Fatalf("testdatenbank anlegen: %v", err)
}
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect testdatenbank: %v", err)
}
if _, err := pool.Exec(ctx, groupSchema); err != nil {
t.Fatalf("schema anwenden: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
adminPool.Close()
})
return pool
}
func createTestUser(t *testing.T, pool *pgxpool.Pool, email string) string {
t.Helper()
u, err := user.NewTenantUserStore(pool).Create(context.Background(), email, "Test")
if err != nil {
t.Fatalf("testuser anlegen: %v", err)
}
return u.ID
}
// Akzeptanzkriterium 1: Gruppen anlegen und Benutzer zuordnen.
func TestGroup_CreateAndAddMember(t *testing.T) {
pool := setupGroupTestDB(t, "test_rbac03_create")
gs := NewGroupStore(pool)
ctx := context.Background()
g, err := gs.CreateGroup(ctx, "Buchhaltung")
if err != nil {
t.Fatalf("create group: %v", err)
}
userID := createTestUser(t, pool, "alice@example.com")
if err := gs.AddMember(ctx, g.ID, userID); err != nil {
t.Fatalf("add member: %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 1: Gruppenrolle wirkt auf ALLE aktuellen
// Mitglieder.
func TestGroup_RoleAffectsAllCurrentMembers(t *testing.T) {
pool := setupGroupTestDB(t, "test_rbac03_roleall")
gs := NewGroupStore(pool)
ctx := context.Background()
g, err := gs.CreateGroup(ctx, "Admins")
if err != nil {
t.Fatalf("create group: %v", err)
}
if err := gs.SetGroupRole(ctx, g.ID, RoleTenantAdmin); err != nil {
t.Fatalf("set group role: %v", err)
}
alice := createTestUser(t, pool, "alice@example.com")
bob := createTestUser(t, pool, "bob@example.com")
if err := gs.AddMember(ctx, g.ID, alice); err != nil {
t.Fatalf("add alice: %v", err)
}
if err := gs.AddMember(ctx, g.ID, bob); err != nil {
t.Fatalf("add bob: %v", err)
}
for _, uid := range []string{alice, bob} {
perms, err := gs.EffectivePermissionsForUser(ctx, nil, uid)
if err != nil {
t.Fatalf("effective permissions fuer %s: %v", uid, err)
}
if !contains(perms, PermManageUsers) {
t.Fatalf("mitglied %s sollte tenant_admin-rechte ueber die gruppe haben, habe %v", uid, perms)
}
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Entfernen aus der Gruppe entzieht die
// gruppenbasierten Rechte sofort.
func TestGroup_RemoveMemberRevokesRightsImmediately(t *testing.T) {
pool := setupGroupTestDB(t, "test_rbac03_remove")
gs := NewGroupStore(pool)
ctx := context.Background()
g, _ := gs.CreateGroup(ctx, "Admins")
_ = gs.SetGroupRole(ctx, g.ID, RoleTenantAdmin)
userID := createTestUser(t, pool, "alice@example.com")
_ = gs.AddMember(ctx, g.ID, userID)
perms, err := gs.EffectivePermissionsForUser(ctx, nil, userID)
if err != nil {
t.Fatalf("effective permissions (mitglied): %v", err)
}
if !contains(perms, PermManageUsers) {
t.Fatal("erwartet tenant_admin-rechte als mitglied")
}
if err := gs.RemoveMember(ctx, g.ID, userID); err != nil {
t.Fatalf("remove member: %v", err)
}
perms, err = gs.EffectivePermissionsForUser(ctx, nil, userID)
if err != nil {
t.Fatalf("effective permissions (entfernt): %v", err)
}
if contains(perms, PermManageUsers) {
t.Fatal("erwartet KEINE tenant_admin-rechte nach entfernen aus der gruppe")
}
}
// Akzeptanzkriterium 3: Loeschen einer Gruppe entzieht Rechte sauber, ohne
// das Benutzerkonto zu loeschen.
func TestGroup_DeleteGroupRevokesRightsWithoutDeletingUser(t *testing.T) {
pool := setupGroupTestDB(t, "test_rbac03_delete")
gs := NewGroupStore(pool)
us := user.NewTenantUserStore(pool)
ctx := context.Background()
g, _ := gs.CreateGroup(ctx, "Temp")
_ = gs.SetGroupRole(ctx, g.ID, RoleTenantAdmin)
userID := createTestUser(t, pool, "alice@example.com")
_ = gs.AddMember(ctx, g.ID, userID)
if err := gs.DeleteGroup(ctx, g.ID); err != nil {
t.Fatalf("delete group: %v", err)
}
perms, err := gs.EffectivePermissionsForUser(ctx, nil, userID)
if err != nil {
t.Fatalf("effective permissions: %v", err)
}
if contains(perms, PermManageUsers) {
t.Fatal("erwartet keine rechte mehr nach loeschen der gruppe")
}
if _, err := us.Get(ctx, userID); err != nil {
t.Fatalf("benutzerkonto haette erhalten bleiben muessen: %v", err)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Gruppen sind sauber tenant-isoliert.
func TestGroup_TenantIsolation(t *testing.T) {
poolA := setupGroupTestDB(t, "test_rbac03_tenanta")
poolB := setupGroupTestDB(t, "test_rbac03_tenantb")
gsA := NewGroupStore(poolA)
ctx := context.Background()
if _, err := gsA.CreateGroup(ctx, "Nur-Tenant-A"); err != nil {
t.Fatalf("create group a: %v", err)
}
var countB int
if err := poolB.QueryRow(ctx, `SELECT count(*) FROM groups`).Scan(&countB); err != nil {
t.Fatalf("count tenant b: %v", err)
}
if countB != 0 {
t.Fatalf("tenant b sollte keine gruppen aus tenant a sehen, habe %d", countB)
}
}
-250
View File
@@ -1,250 +0,0 @@
package rbac
import (
"encoding/json"
"net/http"
"strings"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
)
// Handler stellt Rollen-/Rechte- und Gruppenverwaltung als HTTP-Endpunkte
// bereit (RBAC-05). Duenner Client der bestehenden Store/GroupStore-Schicht —
// keine eigene Rechtelogik hier (siehe Ticket-Produkt-DNA).
type Handler struct {
roles *Store
groups *GroupStore
}
func NewHandler(roles *Store, groups *GroupStore) *Handler {
return &Handler{roles: roles, groups: groups}
}
// requireManageUsers prueft, ob der aufrufende Benutzer ueberhaupt
// Rechteverwaltung ausueben darf — technisch durchgesetzt hier zusaetzlich
// zu RBAC-02 (Policy-Enforcement-Schicht), da diese Kachel ein eigener,
// besonders sicherheitskritischer HTTP-Einstiegspunkt ist.
func requireManageUsers(w http.ResponseWriter, r *http.Request, roles *Store) (callerUserID string, ok bool) {
claims, authOK := auth.ClaimsFromContext(r.Context())
if !authOK {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return "", false
}
assignment, err := roles.Get(r.Context(), claims.UserID)
if err != nil || !HasPermission(assignment.Role, PermManageUsers) {
http.Error(w, "keine Berechtigung zur Rechteverwaltung", http.StatusForbidden)
return "", false
}
return claims.UserID, true
}
type roleInfo struct {
Role Role `json:"role"`
Permissions []Permission `json:"permissions"`
}
// ListRoles liefert die (im Tenant-Geltungsbereich zuweisbaren) Rollen mit
// ihren vollstaendig aufgeloesten Rechten (Akzeptanzkriterium 1).
func (h *Handler) ListRoles(w http.ResponseWriter, r *http.Request) {
if _, ok := requireManageUsers(w, r, h.roles); !ok {
return
}
out := make([]roleInfo, 0, len(assignableRoles))
for role := range assignableRoles {
out = append(out, roleInfo{Role: role, Permissions: EffectivePermissions(role)})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
type assignRoleRequest struct {
UserID string `json:"user_id"`
Role Role `json:"role"`
}
// roleRank ordnet die Rollenhierarchie fuer den Selbst-Eskalations-Vergleich
// (Akzeptanzkriterium 2 / Pruefung 2) — hoehere Zahl = mehr Rechte. Basiert
// auf derselben parent-Kette wie EffectivePermissions, nicht dupliziert als
// eigenstaendiges Modell.
func roleRank(role Role) int {
rank := 0
for r, ok := role, true; ok; r, ok = parent[r] {
rank++
}
return rank
}
// AssignRole weist einem Benutzer eine Rolle zu (Akzeptanzkriterium 1).
// Verweigert explizit, dass sich der aufrufende Benutzer selbst eine
// hoehere Rolle zuweist (Akzeptanzkriterium 2 / Pruefung 2) — unabhaengig
// davon, ob RBAC-02 dieselbe Aktion ohnehin ueber die allgemeine
// Policy-Schicht verhindern wuerde: Selbst-Eskalation ist ein derart
// sicherheitskritischer Einzelfall, dass er hier zusaetzlich explizit und
// unuebersehbar geprueft wird, statt sich allein auf eine generische
// Regel an anderer Stelle zu verlassen.
func (h *Handler) AssignRole(w http.ResponseWriter, r *http.Request) {
callerUserID, ok := requireManageUsers(w, r, h.roles)
if !ok {
return
}
var req assignRoleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
if req.UserID == callerUserID {
callerAssignment, err := h.roles.Get(r.Context(), callerUserID)
callerRole := RoleUser
if err == nil {
callerRole = callerAssignment.Role
}
if roleRank(req.Role) > roleRank(callerRole) {
http.Error(w, "Sie können sich nicht selbst eine höhere Rolle zuweisen.", http.StatusForbidden)
return
}
}
assignment, err := h.roles.Assign(r.Context(), req.UserID, req.Role, callerUserID)
if err != nil {
http.Error(w, "Rolle konnte nicht zugewiesen werden.", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(assignment)
}
// RoleHistory liefert die Zuweisungshistorie eines Benutzers — der in
// Akzeptanzkriterium 3 geforderte Verweis auf das Audit-Log (die
// role_assignment_history-Tabelle selbst IST die Nachvollziehbarkeits-Spur;
// eine vollstaendige Anbindung an Core AUD-01 ist nicht Teil dieser Kachel).
func (h *Handler) RoleHistory(w http.ResponseWriter, r *http.Request) {
if _, ok := requireManageUsers(w, r, h.roles); !ok {
return
}
userID := strings.TrimPrefix(r.URL.Path, "/rbac/users/")
userID = strings.TrimSuffix(userID, "/history")
if userID == "" {
http.Error(w, "user_id fehlt", http.StatusBadRequest)
return
}
history, err := h.roles.History(r.Context(), userID)
if err != nil {
http.Error(w, "historie konnte nicht geladen werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(history)
}
type createGroupRequest struct {
Name string `json:"name"`
}
// CreateGroup legt eine neue Gruppe an (Akzeptanzkriterium 2).
func (h *Handler) CreateGroup(w http.ResponseWriter, r *http.Request) {
if _, ok := requireManageUsers(w, r, h.roles); !ok {
return
}
var req createGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
group, err := h.groups.CreateGroup(r.Context(), req.Name)
if err != nil {
http.Error(w, "Gruppe konnte nicht angelegt werden.", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(group)
}
type groupWithMembers struct {
Group
Members []string `json:"members"`
}
// ListGroups liefert alle Gruppen inkl. Mitgliederliste (Akzeptanzkriterium 2).
func (h *Handler) ListGroups(w http.ResponseWriter, r *http.Request) {
if _, ok := requireManageUsers(w, r, h.roles); !ok {
return
}
groups, err := h.groups.ListGroups(r.Context())
if err != nil {
http.Error(w, "Gruppen konnten nicht geladen werden.", http.StatusInternalServerError)
return
}
out := make([]groupWithMembers, 0, len(groups))
for _, g := range groups {
members, err := h.groups.Members(r.Context(), g.ID)
if err != nil {
http.Error(w, "Mitglieder konnten nicht geladen werden.", http.StatusInternalServerError)
return
}
out = append(out, groupWithMembers{Group: g, Members: members})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
type groupActionRequest struct {
GroupID string `json:"group_id"`
UserID string `json:"user_id"`
Role Role `json:"role"`
}
// SetGroupRole weist einer Gruppe eine Rolle zu (Akzeptanzkriterium 2).
func (h *Handler) SetGroupRole(w http.ResponseWriter, r *http.Request) {
if _, ok := requireManageUsers(w, r, h.roles); !ok {
return
}
var req groupActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
if err := h.groups.SetGroupRole(r.Context(), req.GroupID, req.Role); err != nil {
http.Error(w, "Gruppenrolle konnte nicht gesetzt werden.", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
// AddGroupMember nimmt einen Benutzer in eine Gruppe auf (Akzeptanzkriterium 2).
func (h *Handler) AddGroupMember(w http.ResponseWriter, r *http.Request) {
if _, ok := requireManageUsers(w, r, h.roles); !ok {
return
}
var req groupActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
if err := h.groups.AddMember(r.Context(), req.GroupID, req.UserID); err != nil {
http.Error(w, "Mitglied konnte nicht hinzugefügt werden.", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
// RemoveGroupMember entfernt einen Benutzer aus einer Gruppe.
func (h *Handler) RemoveGroupMember(w http.ResponseWriter, r *http.Request) {
if _, ok := requireManageUsers(w, r, h.roles); !ok {
return
}
var req groupActionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
if err := h.groups.RemoveMember(r.Context(), req.GroupID, req.UserID); err != nil {
http.Error(w, "Mitglied konnte nicht entfernt werden.", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
-309
View File
@@ -1,309 +0,0 @@
package rbac
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
const handlerSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE role_assignments (
user_id UUID PRIMARY KEY REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')),
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE role_assignment_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, role TEXT NOT NULL,
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, role TEXT CHECK (role IN ('user','tenant_admin')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE group_members (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id),
added_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (group_id, user_id)
);`
func setupHandlerTest(t *testing.T, dbName string) (*Handler, *Store, *user.TenantUserStore, *pgxpool.Pool) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
t.Fatalf("testdatenbank anlegen: %v", err)
}
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect testdatenbank: %v", err)
}
if _, err := pool.Exec(ctx, handlerSchema); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
adminPool.Close()
})
roleStore := NewStore(pool)
groupStore := NewGroupStore(pool)
return NewHandler(roleStore, groupStore), roleStore, user.NewTenantUserStore(pool), pool
}
func sessionCookieFor(t *testing.T, issuer *auth.TokenIssuer, userID string) *http.Cookie {
t.Helper()
token, err := issuer.Issue(userID, "acme")
if err != nil {
t.Fatalf("session-token ausstellen: %v", err)
}
return &http.Cookie{Name: auth.CookieName, Value: token}
}
// Akzeptanzkriterium 1: Rollen und ihre Grundrechte sind einsehbar.
func TestListRoles(t *testing.T) {
h, roles, users, _ := setupHandlerTest(t, "rbac05_list_roles")
ctx := context.Background()
admin, err := users.Create(ctx, "admin@acme.example", "Admin")
if err != nil {
t.Fatalf("admin anlegen: %v", err)
}
if _, err := roles.Assign(ctx, admin.ID, RoleTenantAdmin, "system"); err != nil {
t.Fatalf("admin-rolle setzen: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret")
req := httptest.NewRequest(http.MethodGet, "/rbac/roles", nil)
req.AddCookie(sessionCookieFor(t, issuer, admin.ID))
rec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.ListRoles)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
var out []roleInfo
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("response dekodieren: %v", err)
}
if len(out) != 2 {
t.Fatalf("erwartet 2 zuweisbare rollen (user, tenant_admin), habe %d", len(out))
}
}
// Akzeptanzkriterium 2 / Pruefung 2: Selbst-Eskalation wird abgewiesen.
func TestAssignRole_RejectsSelfEscalation(t *testing.T) {
h, roles, users, _ := setupHandlerTest(t, "rbac05_self_escalation")
ctx := context.Background()
u, err := users.Create(ctx, "normal@acme.example", "Normaler Nutzer")
if err != nil {
t.Fatalf("nutzer anlegen: %v", err)
}
if _, err := roles.Assign(ctx, u.ID, RoleUser, "system"); err != nil {
t.Fatalf("ausgangsrolle setzen: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret")
body, _ := json.Marshal(assignRoleRequest{UserID: u.ID, Role: RoleTenantAdmin})
req := httptest.NewRequest(http.MethodPost, "/rbac/users/role", bytes.NewReader(body))
req.AddCookie(sessionCookieFor(t, issuer, u.ID))
rec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.AssignRole)(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 (selbst-eskalation muss abgewiesen werden)", rec.Code)
}
// Rolle muss unveraendert geblieben sein.
assignment, err := roles.Get(ctx, u.ID)
if err != nil {
t.Fatalf("rolle laden: %v", err)
}
if assignment.Role != RoleUser {
t.Fatalf("rolle wurde trotz abgewiesener anfrage geaendert: %q", assignment.Role)
}
}
// Gegenprobe: ein Tenant-Admin darf EINEM ANDEREN Benutzer eine hoehere
// Rolle zuweisen — nur die Selbst-Zuweisung ist gesperrt.
func TestAssignRole_AdminCanPromoteOtherUser(t *testing.T) {
h, roles, users, _ := setupHandlerTest(t, "rbac05_promote_other")
ctx := context.Background()
admin, err := users.Create(ctx, "admin2@acme.example", "Admin")
if err != nil {
t.Fatalf("admin anlegen: %v", err)
}
if _, err := roles.Assign(ctx, admin.ID, RoleTenantAdmin, "system"); err != nil {
t.Fatalf("admin-rolle setzen: %v", err)
}
other, err := users.Create(ctx, "other@acme.example", "Anderer Nutzer")
if err != nil {
t.Fatalf("anderen nutzer anlegen: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret")
body, _ := json.Marshal(assignRoleRequest{UserID: other.ID, Role: RoleTenantAdmin})
req := httptest.NewRequest(http.MethodPost, "/rbac/users/role", bytes.NewReader(body))
req.AddCookie(sessionCookieFor(t, issuer, admin.ID))
rec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.AssignRole)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
}
// Akzeptanzkriterium 3: Aenderungen an Rechten sind nachvollziehbar.
func TestRoleHistory_TracksAssignments(t *testing.T) {
h, roles, users, _ := setupHandlerTest(t, "rbac05_history")
ctx := context.Background()
admin, err := users.Create(ctx, "admin3@acme.example", "Admin")
if err != nil {
t.Fatalf("admin anlegen: %v", err)
}
if _, err := roles.Assign(ctx, admin.ID, RoleTenantAdmin, "system"); err != nil {
t.Fatalf("admin-rolle setzen: %v", err)
}
target, err := users.Create(ctx, "target@acme.example", "Zielnutzer")
if err != nil {
t.Fatalf("zielnutzer anlegen: %v", err)
}
if _, err := roles.Assign(ctx, target.ID, RoleUser, admin.ID); err != nil {
t.Fatalf("erste zuweisung: %v", err)
}
if _, err := roles.Assign(ctx, target.ID, RoleTenantAdmin, admin.ID); err != nil {
t.Fatalf("zweite zuweisung: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret")
req := httptest.NewRequest(http.MethodGet, "/rbac/users/"+target.ID+"/history", nil)
req.AddCookie(sessionCookieFor(t, issuer, admin.ID))
rec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.RoleHistory)(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
}
var history []Assignment
if err := json.Unmarshal(rec.Body.Bytes(), &history); err != nil {
t.Fatalf("response dekodieren: %v", err)
}
if len(history) != 2 {
t.Fatalf("erwartet 2 historieneintraege, habe %d", len(history))
}
}
// Akzeptanzkriterium 2: Gruppen anlegen, Mitglieder zuordnen, Rolle zuweisen.
func TestGroupWorkflow(t *testing.T) {
h, roles, users, _ := setupHandlerTest(t, "rbac05_group_workflow")
ctx := context.Background()
admin, err := users.Create(ctx, "admin4@acme.example", "Admin")
if err != nil {
t.Fatalf("admin anlegen: %v", err)
}
if _, err := roles.Assign(ctx, admin.ID, RoleTenantAdmin, "system"); err != nil {
t.Fatalf("admin-rolle setzen: %v", err)
}
member, err := users.Create(ctx, "member@acme.example", "Mitglied")
if err != nil {
t.Fatalf("mitglied anlegen: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret")
createBody, _ := json.Marshal(createGroupRequest{Name: "Buchhaltung"})
createReq := httptest.NewRequest(http.MethodPost, "/rbac/groups", bytes.NewReader(createBody))
createReq.AddCookie(sessionCookieFor(t, issuer, admin.ID))
createRec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.CreateGroup)(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create group: status = %d, body: %s", createRec.Code, createRec.Body.String())
}
var group Group
if err := json.Unmarshal(createRec.Body.Bytes(), &group); err != nil {
t.Fatalf("group-response dekodieren: %v", err)
}
roleBody, _ := json.Marshal(groupActionRequest{GroupID: group.ID, Role: RoleUser})
roleReq := httptest.NewRequest(http.MethodPost, "/rbac/groups/role", bytes.NewReader(roleBody))
roleReq.AddCookie(sessionCookieFor(t, issuer, admin.ID))
roleRec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.SetGroupRole)(roleRec, roleReq)
if roleRec.Code != http.StatusOK {
t.Fatalf("set group role: status = %d, body: %s", roleRec.Code, roleRec.Body.String())
}
memberBody, _ := json.Marshal(groupActionRequest{GroupID: group.ID, UserID: member.ID})
memberReq := httptest.NewRequest(http.MethodPost, "/rbac/groups/members", bytes.NewReader(memberBody))
memberReq.AddCookie(sessionCookieFor(t, issuer, admin.ID))
memberRec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.AddGroupMember)(memberRec, memberReq)
if memberRec.Code != http.StatusOK {
t.Fatalf("add member: status = %d, body: %s", memberRec.Code, memberRec.Body.String())
}
listReq := httptest.NewRequest(http.MethodGet, "/rbac/groups", nil)
listReq.AddCookie(sessionCookieFor(t, issuer, admin.ID))
listRec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.ListGroups)(listRec, listReq)
if listRec.Code != http.StatusOK {
t.Fatalf("list groups: status = %d, body: %s", listRec.Code, listRec.Body.String())
}
var groups []groupWithMembers
if err := json.Unmarshal(listRec.Body.Bytes(), &groups); err != nil {
t.Fatalf("groups-response dekodieren: %v", err)
}
if len(groups) != 1 || len(groups[0].Members) != 1 || groups[0].Members[0] != member.ID {
t.Fatalf("erwartet 1 gruppe mit 1 mitglied (%s), habe %+v", member.ID, groups)
}
}
// Ohne Rechteverwaltungsberechtigung (normale 'user'-Rolle) wird jeder
// Endpunkt abgewiesen — Tenant-Isolation ist strukturell (Modell C, siehe
// GroupStore-Kommentar), diese Pruefung deckt die Rechte-Ebene ab.
func TestRequireManageUsers_RejectsPlainUser(t *testing.T) {
h, roles, users, _ := setupHandlerTest(t, "rbac05_reject_plain")
ctx := context.Background()
u, err := users.Create(ctx, "plain@acme.example", "Normaler Nutzer")
if err != nil {
t.Fatalf("nutzer anlegen: %v", err)
}
if _, err := roles.Assign(ctx, u.ID, RoleUser, "system"); err != nil {
t.Fatalf("rolle setzen: %v", err)
}
issuer := auth.NewTokenIssuer("test-secret")
req := httptest.NewRequest(http.MethodGet, "/rbac/roles", nil)
req.AddCookie(sessionCookieFor(t, issuer, u.ID))
rec := httptest.NewRecorder()
auth.RequireAuth(issuer, h.ListRoles)(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403 fuer benutzer ohne rechteverwaltung", rec.Code)
}
}
-71
View File
@@ -1,71 +0,0 @@
// Package rbac implementiert Core RBAC-01: das Rollenmodell mit
// Grundrollen/-rechten, Hierarchie und Zuweisung. Die eigentliche
// Durchsetzung (erlaubt/verboten je Request) ist RBAC-02, dieses Paket
// liefert nur Modell + Zuweisung (Casbin-Prinzip: Policy-Modell getrennt
// von der Entscheidungsfunktion).
package rbac
// Role ist eine der drei Grundrollen. Superadmin lebt mandantenuebergreifend
// (siehe internal/user.SuperadminStore aus IAM-01) und wird deshalb NICHT
// ueber Store (tenant-gescoped) zugewiesen — die Existenz eines Superadmin-
// Kontos IST die Rollenzuweisung.
type Role string
const (
RoleSuperadmin Role = "superadmin"
RoleTenantAdmin Role = "tenant_admin"
RoleUser Role = "user"
)
type Permission string
const (
PermSelfRead Permission = "self.read"
PermSelfUpdate Permission = "self.update"
PermManageUsers Permission = "tenant.manage_users"
PermManageSettings Permission = "tenant.manage_settings"
PermManageTenants Permission = "platform.manage_tenants"
)
// parent bildet die Rollenhierarchie ab (Akzeptanzkriterium 2): eine Rolle
// erbt alle Rechte ihrer Elternrolle. tenant_admin erbt von user,
// superadmin erbt von tenant_admin.
var parent = map[Role]Role{
RoleTenantAdmin: RoleUser,
RoleSuperadmin: RoleTenantAdmin,
}
// direct sind die einer Rolle direkt (ohne Vererbung) zugeordneten Rechte.
var direct = map[Role][]Permission{
RoleUser: {PermSelfRead, PermSelfUpdate},
RoleTenantAdmin: {PermManageUsers, PermManageSettings},
RoleSuperadmin: {PermManageTenants},
}
// EffectivePermissions liefert die vollstaendige, ueber die Hierarchie
// aufgeloeste Rechtemenge einer Rolle (Akzeptanzkriterium 2 / Pruefung 2).
func EffectivePermissions(role Role) []Permission {
seen := make(map[Permission]bool)
var out []Permission
for r, ok := role, true; ok; r, ok = parent[r] {
for _, p := range direct[r] {
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
}
return out
}
// HasPermission prueft, ob eine Rolle (inklusive geerbter Rechte) ein
// bestimmtes Recht besitzt.
func HasPermission(role Role, perm Permission) bool {
for _, p := range EffectivePermissions(role) {
if p == perm {
return true
}
}
return false
}
-48
View File
@@ -1,48 +0,0 @@
package rbac
import "testing"
func contains(perms []Permission, p Permission) bool {
for _, x := range perms {
if x == p {
return true
}
}
return false
}
// Akzeptanzkriterium 2 + Pruefung 2: Rollenhierarchie liefert die erwartete
// effektive Rechtemenge.
func TestEffectivePermissions_Inheritance(t *testing.T) {
userPerms := EffectivePermissions(RoleUser)
if !contains(userPerms, PermSelfRead) || !contains(userPerms, PermSelfUpdate) {
t.Fatalf("user sollte self.read/self.update haben, habe %v", userPerms)
}
if contains(userPerms, PermManageUsers) {
t.Fatal("user sollte KEIN tenant.manage_users haben")
}
adminPerms := EffectivePermissions(RoleTenantAdmin)
if !contains(adminPerms, PermSelfRead) || !contains(adminPerms, PermManageUsers) || !contains(adminPerms, PermManageSettings) {
t.Fatalf("tenant_admin sollte geerbte user-rechte + eigene rechte haben, habe %v", adminPerms)
}
if contains(adminPerms, PermManageTenants) {
t.Fatal("tenant_admin sollte KEIN platform.manage_tenants haben")
}
superPerms := EffectivePermissions(RoleSuperadmin)
for _, want := range []Permission{PermSelfRead, PermSelfUpdate, PermManageUsers, PermManageSettings, PermManageTenants} {
if !contains(superPerms, want) {
t.Fatalf("superadmin sollte %q haben (volle vererbte kette), habe %v", want, superPerms)
}
}
}
func TestHasPermission(t *testing.T) {
if !HasPermission(RoleTenantAdmin, PermSelfRead) {
t.Fatal("tenant_admin sollte geerbtes self.read haben")
}
if HasPermission(RoleUser, PermManageTenants) {
t.Fatal("user sollte platform.manage_tenants nicht haben")
}
}
-124
View File
@@ -1,124 +0,0 @@
package rbac
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
// ErrRoleNotAssignableInTenantScope wird geliefert, wenn versucht wird,
// eine mandantenuebergreifende Rolle (superadmin) ueber den tenant-
// gescopten Store zu vergeben — die erlaubte Matrix laesst hier nur
// user/tenant_admin zu (Akzeptanzkriterium 1 / Pruefung 1).
ErrRoleNotAssignableInTenantScope = errors.New("rbac: rolle ist in diesem geltungsbereich nicht zuweisbar")
ErrNotFound = errors.New("rbac: keine rollenzuweisung gefunden")
)
// assignableRoles ist die erlaubte Matrix fuer Store (tenant-gescoped).
var assignableRoles = map[Role]bool{
RoleUser: true,
RoleTenantAdmin: true,
}
type Assignment struct {
UserID string
Role Role
GrantedBy string
}
// Store verwaltet Rollenzuweisungen innerhalb GENAU EINER Tenant-Datenbank —
// analog zu internal/user.TenantUserStore (Modell C: der Pool bestimmt den
// Tenant, keine tenant_id-Spalte noetig).
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Assign vergibt eine Rolle an einen Benutzer. grantedBy identifiziert den
// Akteur, der die Zuweisung vorgenommen hat (Akzeptanzkriterium 3). Jede
// Zuweisung wird zusaetzlich in role_assignment_history festgehalten, auch
// wenn sie eine vorherige Rolle ersetzt.
func (s *Store) Assign(ctx context.Context, userID string, role Role, grantedBy string) (Assignment, error) {
if !assignableRoles[role] {
return Assignment{}, ErrRoleNotAssignableInTenantScope
}
if grantedBy == "" {
return Assignment{}, errors.New("rbac: grantedBy darf nicht leer sein")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return Assignment{}, fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
INSERT INTO role_assignments (user_id, role, granted_by, granted_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (user_id) DO UPDATE SET role = $2, granted_by = $3, granted_at = now()
`, userID, string(role), grantedBy); err != nil {
return Assignment{}, fmt.Errorf("rolle zuweisen: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO role_assignment_history (user_id, role, granted_by, granted_at)
VALUES ($1, $2, $3, now())
`, userID, string(role), grantedBy); err != nil {
return Assignment{}, fmt.Errorf("historie schreiben: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return Assignment{}, fmt.Errorf("transaktion committen: %w", err)
}
return Assignment{UserID: userID, Role: role, GrantedBy: grantedBy}, nil
}
func (s *Store) Get(ctx context.Context, userID string) (Assignment, error) {
var a Assignment
var role string
a.UserID = userID
if err := s.pool.QueryRow(ctx, `
SELECT role, granted_by FROM role_assignments WHERE user_id = $1
`, userID).Scan(&role, &a.GrantedBy); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Assignment{}, ErrNotFound
}
return Assignment{}, fmt.Errorf("rollenzuweisung lesen: %w", err)
}
a.Role = Role(role)
return a, nil
}
// History liefert jede Rollenzuweisung eines Benutzers in chronologischer
// Reihenfolge — die Grundlage fuer "wer hat wann welche Rolle vergeben"
// (Akzeptanzkriterium 3).
func (s *Store) History(ctx context.Context, userID string) ([]Assignment, error) {
rows, err := s.pool.Query(ctx, `
SELECT role, granted_by FROM role_assignment_history
WHERE user_id = $1 ORDER BY granted_at
`, userID)
if err != nil {
return nil, fmt.Errorf("historie abfragen: %w", err)
}
defer rows.Close()
var out []Assignment
for rows.Next() {
var role string
a := Assignment{UserID: userID}
if err := rows.Scan(&role, &a.GrantedBy); err != nil {
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
}
a.Role = Role(role)
out = append(out, a)
}
return out, rows.Err()
}
-144
View File
@@ -1,144 +0,0 @@
package rbac
import (
"context"
"errors"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func setupStoreTest(t *testing.T) (*Store, string, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignments (
user_id UUID PRIMARY KEY REFERENCES users(id),
role TEXT NOT NULL CHECK (role IN ('user', 'tenant_admin')),
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignment_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
role TEXT NOT NULL,
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
userStore := user.NewTenantUserStore(pool)
u, err := userStore.Create(ctx, "rbac_test@example.com", "RBAC Test")
if err != nil {
t.Fatalf("testuser anlegen: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM role_assignment_history WHERE user_id = $1`, u.ID)
_, _ = pool.Exec(ctx, `DELETE FROM role_assignments WHERE user_id = $1`, u.ID)
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, u.ID)
pool.Close()
}
return NewStore(pool), u.ID, cleanup
}
// Akzeptanzkriterium 1: Grundrollen sind einem Benutzer je Tenant zuweisbar.
func TestStore_AssignAndGet(t *testing.T) {
store, userID, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
assigned, err := store.Assign(ctx, userID, RoleTenantAdmin, "admin@acme.example")
if err != nil {
t.Fatalf("assign: %v", err)
}
if assigned.Role != RoleTenantAdmin {
t.Fatalf("erwartet tenant_admin, habe %q", assigned.Role)
}
got, err := store.Get(ctx, userID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Role != RoleTenantAdmin || got.GrantedBy != "admin@acme.example" {
t.Fatalf("zuweisung unerwartet: %+v", got)
}
}
// Akzeptanzkriterium 1 + Pruefung 1: Zuweisung ausserhalb der erlaubten
// Matrix (superadmin im tenant-gescopten Store) wird abgewiesen.
func TestStore_RejectsSuperadminOutsideAllowedMatrix(t *testing.T) {
store, userID, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
_, err := store.Assign(ctx, userID, RoleSuperadmin, "admin@acme.example")
if !errors.Is(err, ErrRoleNotAssignableInTenantScope) {
t.Fatalf("erwartet ErrRoleNotAssignableInTenantScope, habe %v", err)
}
// Sicherstellen, dass der abgewiesene Versuch keine Zeile hinterlassen hat.
if _, err := store.Get(ctx, userID); !errors.Is(err, ErrNotFound) {
t.Fatalf("erwartet ErrNotFound nach abgewiesener zuweisung, habe %v", err)
}
}
func TestStore_RejectsUnknownRole(t *testing.T) {
store, userID, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Assign(ctx, userID, Role("erfunden"), "admin@acme.example"); !errors.Is(err, ErrRoleNotAssignableInTenantScope) {
t.Fatalf("erwartet ErrRoleNotAssignableInTenantScope fuer unbekannte rolle, habe %v", err)
}
}
// Akzeptanzkriterium 3: Rollenzuweisung vollstaendig auditierbar.
func TestStore_HistoryTracksWhoAndWhen(t *testing.T) {
store, userID, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Assign(ctx, userID, RoleUser, "system"); err != nil {
t.Fatalf("assign 1: %v", err)
}
if _, err := store.Assign(ctx, userID, RoleTenantAdmin, "admin@acme.example"); err != nil {
t.Fatalf("assign 2: %v", err)
}
history, err := store.History(ctx, userID)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 2 {
t.Fatalf("erwartet 2 historieneintraege, habe %d", len(history))
}
if history[0].Role != RoleUser || history[0].GrantedBy != "system" {
t.Fatalf("history[0] unerwartet: %+v", history[0])
}
if history[1].Role != RoleTenantAdmin || history[1].GrantedBy != "admin@acme.example" {
t.Fatalf("history[1] unerwartet: %+v", history[1])
}
}
+136
View File
@@ -0,0 +1,136 @@
package tenant
import (
"container/list"
"context"
"errors"
"fmt"
"sync"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrMissingTenantContext wird geliefert, wenn keine Tenant-Kennung
// uebergeben wurde — es gibt bewusst keinen impliziten Default-Tenant
// (TEN-06 Akzeptanzkriterium 3).
var ErrMissingTenantContext = errors.New("tenant: kein tenant-kontext angegeben")
// ErrUnknownTenant wird geliefert, wenn die Tenant-Kennung in der Registry
// nicht existiert.
var ErrUnknownTenant = errors.New("tenant: unbekannter tenant")
// Router loest den Tenant-Kontext (Slug, aus dem JWT-Claim von API-05) in
// eine wiederverwendbare Verbindung zur richtigen Tenant-Datenbank auf.
// Ein LRU-verwalteter Cache begrenzt die Zahl gleichzeitig offener
// pgxpool.Pool-Instanzen, damit die Zahl offener Postgres-Verbindungen NICHT
// linear mit der Mandantenzahl waechst (Akzeptanzkriterium 2).
type Router struct {
registry *Registry
maxOpen int
mu sync.Mutex
order *list.List // vorne = zuletzt benutzt
items map[string]*list.Element // slug -> element mit *routerEntry
}
type routerEntry struct {
slug string
pool *pgxpool.Pool
}
func NewRouter(registry *Registry, maxOpen int) *Router {
if maxOpen < 1 {
maxOpen = 1
}
return &Router{
registry: registry,
maxOpen: maxOpen,
order: list.New(),
items: make(map[string]*list.Element),
}
}
// Resolve liefert einen wiederverwendeten Pool fuer den angegebenen Tenant.
// Ist der Tenant bereits im Cache, wird KEINE neue Verbindung aufgebaut
// (Akzeptanzkriterium 2 / Pruefung 3).
func (r *Router) Resolve(ctx context.Context, tenantSlug string) (*pgxpool.Pool, error) {
if tenantSlug == "" {
return nil, ErrMissingTenantContext
}
r.mu.Lock()
if el, ok := r.items[tenantSlug]; ok {
r.order.MoveToFront(el)
pool := el.Value.(*routerEntry).pool
r.mu.Unlock()
return pool, nil
}
r.mu.Unlock()
// Registry-Lookup und Verbindungsaufbau bewusst ausserhalb des Locks,
// damit ein langsamer Verbindungsaufbau nicht alle anderen Tenants blockiert.
t, err := r.registry.GetBySlug(ctx, tenantSlug)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrUnknownTenant, tenantSlug)
}
pool, err := pgxpool.New(ctx, t.DBDSN)
if err != nil {
return nil, fmt.Errorf("verbindung zu tenant %q aufbauen: %w", tenantSlug, err)
}
r.mu.Lock()
defer r.mu.Unlock()
// Zwischen Unlock oben und hier koennte ein paralleler Aufruf denselben
// Tenant bereits eingefuegt haben — dann die eigene, ueberzaehlige
// Verbindung wieder schliessen und die vorhandene verwenden.
if el, ok := r.items[tenantSlug]; ok {
r.order.MoveToFront(el)
existing := el.Value.(*routerEntry).pool
pool.Close()
return existing, nil
}
el := r.order.PushFront(&routerEntry{slug: tenantSlug, pool: pool})
r.items[tenantSlug] = el
if r.order.Len() > r.maxOpen {
r.evictOldest()
}
return pool, nil
}
// evictOldest schliesst den am laengsten nicht genutzten Pool. Muss mit
// gehaltenem r.mu aufgerufen werden.
func (r *Router) evictOldest() {
oldest := r.order.Back()
if oldest == nil {
return
}
entry := oldest.Value.(*routerEntry)
r.order.Remove(oldest)
delete(r.items, entry.slug)
entry.pool.Close()
}
// OpenCount liefert die aktuelle Zahl offen gehaltener Tenant-Pools —
// dient Tests/Monitoring, um AC2 nachzuweisen.
func (r *Router) OpenCount() int {
r.mu.Lock()
defer r.mu.Unlock()
return r.order.Len()
}
// Close schliesst alle offen gehaltenen Tenant-Pools, z.B. beim
// Herunterfahren des Core-Prozesses.
func (r *Router) Close() {
r.mu.Lock()
defer r.mu.Unlock()
for el := r.order.Front(); el != nil; el = el.Next() {
el.Value.(*routerEntry).pool.Close()
}
r.order.Init()
r.items = make(map[string]*list.Element)
}
+160
View File
@@ -0,0 +1,160 @@
package tenant
import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func newTestRouterSetup(t *testing.T, tenantCount int) (*Router, []Tenant, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
registryPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("registry pool: %v", err)
}
if _, err := registryPool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("registry-schema: %v", err)
}
registry := NewRegistry(registryPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
var tenants []Tenant
var slugs []string
for i := 0; i < tenantCount; i++ {
slug := fmt.Sprintf("router_t%d", i)
slugs = append(slugs, slug)
tn, err := provisioner.Provision(ctx, slug, slug)
if err != nil {
t.Fatalf("provision %s: %v", slug, err)
}
tenants = append(tenants, tn)
}
router := NewRouter(registry, 2) // klein gewaehlt, um Eviction im Test zu erzwingen
cleanup := func() {
router.Close()
for _, slug := range slugs {
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug)))
}
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs)
registryPool.Close()
adminPool.Close()
}
return router, tenants, cleanup
}
// Akzeptanzkriterium 1: Verbindung wird zuverlaessig anhand des Tenant-Kontexts aufgeloest.
func TestRouter_ResolvesCorrectTenantDatabase(t *testing.T) {
router, tenants, cleanup := newTestRouterSetup(t, 2)
defer cleanup()
ctx := context.Background()
pool, err := router.Resolve(ctx, tenants[0].Slug)
if err != nil {
t.Fatalf("resolve: %v", err)
}
var dbName string
if err := pool.QueryRow(ctx, `SELECT current_database()`).Scan(&dbName); err != nil {
t.Fatalf("current_database: %v", err)
}
if dbName != tenants[0].DBName {
t.Fatalf("current_database() = %q, want %q", dbName, tenants[0].DBName)
}
}
// Akzeptanzkriterium 3 + Pruefung 2: fehlender/unbekannter Tenant-Kontext
// wird explizit abgewiesen statt irgendeine Verbindung zu liefern.
func TestRouter_RejectsMissingOrUnknownTenant(t *testing.T) {
router, _, cleanup := newTestRouterSetup(t, 1)
defer cleanup()
ctx := context.Background()
if _, err := router.Resolve(ctx, ""); !errors.Is(err, ErrMissingTenantContext) {
t.Fatalf("erwartet ErrMissingTenantContext, habe %v", err)
}
if _, err := router.Resolve(ctx, "nie-registrierter-slug"); !errors.Is(err, ErrUnknownTenant) {
t.Fatalf("erwartet ErrUnknownTenant, habe %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 3: Verbindungswiederverwendung nachweislich
// gemessen — zweiter Resolve-Aufruf liefert exakt denselben Pool, kein
// erneuter Verbindungsaufbau.
func TestRouter_ReusesConnectionForSameTenant(t *testing.T) {
router, tenants, cleanup := newTestRouterSetup(t, 1)
defer cleanup()
ctx := context.Background()
first, err := router.Resolve(ctx, tenants[0].Slug)
if err != nil {
t.Fatalf("resolve 1: %v", err)
}
second, err := router.Resolve(ctx, tenants[0].Slug)
if err != nil {
t.Fatalf("resolve 2: %v", err)
}
if first != second {
t.Fatal("erwartet identische pool-instanz bei wiederholtem resolve, habe unterschiedliche")
}
}
// Akzeptanzkriterium 2 + Pruefung 1: Lasttest mit mehr simulierten Mandanten
// als maxOpen — die Zahl gleichzeitig offener Tenant-Pools bleibt begrenzt
// (LRU-Eviction), waechst also NICHT linear mit der Mandantenzahl.
func TestRouter_BoundsOpenConnectionsUnderLoad(t *testing.T) {
const tenantCount = 6
router, tenants, cleanup := newTestRouterSetup(t, tenantCount)
defer cleanup()
ctx := context.Background()
for _, tn := range tenants {
if _, err := router.Resolve(ctx, tn.Slug); err != nil {
t.Fatalf("resolve %s: %v", tn.Slug, err)
}
if router.OpenCount() > 2 {
t.Fatalf("OpenCount() = %d, erwartet <= maxOpen (2) nach jedem Resolve", router.OpenCount())
}
}
if router.OpenCount() != 2 {
t.Fatalf("erwartet genau maxOpen=2 offene pools nach %d tenants, habe %d", tenantCount, router.OpenCount())
}
// Evictete Tenants sind wieder ganz normal ueber die Registry aufloesbar
// (Cache-Miss fuehrt zu neuem, funktionierendem Pool, kein Fehlerzustand).
pool, err := router.Resolve(ctx, tenants[0].Slug)
if err != nil {
t.Fatalf("resolve nach eviction: %v", err)
}
var one int
if err := pool.QueryRow(ctx, `SELECT 1`).Scan(&one); err != nil {
t.Fatalf("query nach re-resolve: %v", err)
}
}
-2
View File
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS policy_rule_changes;
DROP TABLE IF EXISTS policy_rules;
-24
View File
@@ -1,24 +0,0 @@
-- Zentrale, deklarative Policy-Regeln (RBAC-02, siehe core-kanban/tickets/RBAC-02.md).
-- policy_rules haelt den AKTUELLEN, gewaehrten Regelsatz (Existenz = erlaubt,
-- Default-Deny fuer alles ohne Zeile). policy_rule_changes ist die
-- versionierte Aenderungshistorie (Akzeptanzkriterium 3: Regelwechsel ohne
-- Codeaenderung nachvollziehbar).
CREATE TABLE policy_rules (
role TEXT NOT NULL,
permission TEXT NOT NULL,
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (role, permission)
);
CREATE TABLE policy_rule_changes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
role TEXT NOT NULL,
permission TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('grant', 'revoke')),
actor TEXT NOT NULL,
version INT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX policy_rule_changes_idx ON policy_rule_changes (role, permission, version);
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS role_assignment_history;
DROP TABLE IF EXISTS role_assignments;
@@ -1,23 +0,0 @@
-- Rollenzuweisung pro Benutzer (RBAC-01, siehe core-kanban/tickets/RBAC-01.md).
-- Nur 'user' und 'tenant_admin' sind hier zuweisbar — 'superadmin' lebt
-- mandantenuebergreifend in der Registry (IAM-01 SuperadminStore) und hat
-- daher bewusst KEINE Zeile in dieser tenant-lokalen Tabelle (Akzeptanz-
-- kriterium 1: nur Zuweisungen innerhalb der erlaubten Matrix).
CREATE TABLE role_assignments (
user_id UUID PRIMARY KEY REFERENCES users(id),
role TEXT NOT NULL CHECK (role IN ('user', 'tenant_admin')),
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Vollstaendige Historie jeder Rollenaenderung (Akzeptanzkriterium 3: wer
-- hat wann welche Rolle vergeben).
CREATE TABLE role_assignment_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
role TEXT NOT NULL,
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX role_assignment_history_user_idx ON role_assignment_history (user_id, granted_at);
-2
View File
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS group_members;
DROP TABLE IF EXISTS groups;
-21
View File
@@ -1,21 +0,0 @@
-- Gruppen/Abteilungen mit Rollenzuweisung auf Gruppenebene (RBAC-03, siehe
-- core-kanban/tickets/RBAC-03.md). Lebt in der Tenant-Datenbank (Modell C) —
-- Gruppen sind implizit tenant-isoliert, da jede Tenant-DB ihre eigenen hat.
CREATE TABLE groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
role TEXT CHECK (role IN ('user', 'tenant_admin')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ON DELETE CASCADE: Loeschen einer Gruppe entzieht die gruppenbasierten
-- Rechte automatisch, ohne Benutzerkonten selbst zu beruehren
-- (Akzeptanzkriterium 3).
CREATE TABLE group_members (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (group_id, user_id)
);
CREATE INDEX group_members_user_idx ON group_members (user_id);
-7
View File
@@ -1,11 +1,4 @@
#!/usr/bin/env bash
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
-6
View File
@@ -1,10 +1,4 @@
#!/usr/bin/env bash
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
-183
View File
@@ -1,183 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { TextField, SelectField, useToast } from "@nexarch/shl";
import {
ApiError,
Group,
Role,
addGroupMember,
createGroup,
fetchGroups,
removeGroupMember,
setGroupRole,
} from "../../lib/api";
export default function GroupsPage() {
const { push } = useToast();
const [groups, setGroups] = useState<Group[]>([]);
const [loadError, setLoadError] = useState<string | null>(null);
const reload = () =>
fetchGroups()
.then(setGroups)
.catch(() => setLoadError("Gruppen konnten nicht geladen werden. Sind Sie als Tenant-Admin angemeldet?"));
useEffect(() => {
reload();
}, []);
const [newGroupName, setNewGroupName] = useState("");
const [createError, setCreateError] = useState<string | null>(null);
async function onCreate(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setCreateError(null);
try {
await createGroup(newGroupName);
setNewGroupName("");
push("Gruppe angelegt.", "success");
await reload();
} catch (err) {
setCreateError(err instanceof ApiError ? err.message : "Gruppe konnte nicht angelegt werden.");
}
}
if (loadError) {
return (
<main style={{ maxWidth: 640, margin: "40px auto", padding: "0 16px" }}>
<p role="alert">{loadError}</p>
</main>
);
}
return (
<main style={{ maxWidth: 640, margin: "40px auto", padding: "0 16px" }}>
<h1>Gruppen &amp; Abteilungen</h1>
<section aria-labelledby="neue-gruppe-heading">
<h2 id="neue-gruppe-heading">Neue Gruppe anlegen</h2>
<form onSubmit={onCreate} noValidate>
<TextField
label="Gruppenname"
required
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
/>
{createError && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{createError}
</p>
)}
<button type="submit">Anlegen</button>
</form>
</section>
<section aria-labelledby="gruppenliste-heading">
<h2 id="gruppenliste-heading">Bestehende Gruppen</h2>
{groups.length === 0 ? (
<p>Keine Gruppen vorhanden.</p>
) : (
groups.map((g) => <GroupCard key={g.ID} group={g} onChanged={reload} onNotice={(t) => push(t, "success")} />)
)}
</section>
</main>
);
}
function GroupCard({
group,
onChanged,
onNotice,
}: {
group: Group;
onChanged: () => void;
onNotice: (text: string) => void;
}) {
const [role, setRole] = useState<Role>(group.Role || "user");
const [newMemberId, setNewMemberId] = useState("");
const [error, setError] = useState<string | null>(null);
async function onSetRole() {
setError(null);
try {
await setGroupRole(group.ID, role);
onNotice("Gruppenrolle gesetzt.");
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Gruppenrolle konnte nicht gesetzt werden.");
}
}
async function onAddMember(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setError(null);
try {
await addGroupMember(group.ID, newMemberId);
setNewMemberId("");
onNotice("Mitglied hinzugefügt.");
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Mitglied konnte nicht hinzugefügt werden.");
}
}
async function onRemoveMember(userId: string) {
setError(null);
try {
await removeGroupMember(group.ID, userId);
onNotice("Mitglied entfernt.");
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Mitglied konnte nicht entfernt werden.");
}
}
return (
<div style={{ border: "1px solid var(--shl-color-border)", borderRadius: 8, padding: 16, marginBottom: 16 }}>
<h3>{group.Name}</h3>
<div style={{ display: "flex", alignItems: "flex-end", gap: 8 }}>
<SelectField label="Gruppenrolle" value={role} onChange={(e) => setRole(e.target.value as Role)}>
<option value="user">Benutzer</option>
<option value="tenant_admin">Tenant-Admin</option>
</SelectField>
<button type="button" onClick={onSetRole}>
Setzen
</button>
</div>
<h4>Mitglieder</h4>
{group.members.length === 0 ? (
<p>Keine Mitglieder.</p>
) : (
<ul>
{group.members.map((userId) => (
<li key={userId}>
{userId}{" "}
<button type="button" onClick={() => onRemoveMember(userId)}>
Entfernen
</button>
</li>
))}
</ul>
)}
<form onSubmit={onAddMember} noValidate>
<TextField
label="Benutzer-ID hinzufügen"
required
value={newMemberId}
onChange={(e) => setNewMemberId(e.target.value)}
/>
<button type="submit">Hinzufügen</button>
</form>
{error && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{error}
</p>
)}
</div>
);
}
-30
View File
@@ -1,30 +0,0 @@
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Rechteverwaltung",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="de">
<body
style={{
fontFamily: typography.fontFamily,
margin: 0,
background: "var(--shl-color-background, #ffffff)",
color: "var(--shl-color-text-primary, #14181f)",
}}
>
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>{children}</ToastProvider>
</I18nProvider>
</ThemeProvider>
</body>
</html>
);
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function IndexPage() {
redirect("/roles");
}
-142
View File
@@ -1,142 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Table, TextField, SelectField, useToast } from "@nexarch/shl";
import type { TableColumn } from "@nexarch/shl";
import {
ApiError,
Assignment,
Role,
RoleInfo,
assignRole,
fetchRoleHistory,
fetchRoles,
} from "../../lib/api";
export default function RolesPage() {
const { push } = useToast();
const [roles, setRoles] = useState<RoleInfo[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [targetUserId, setTargetUserId] = useState("");
const [targetRole, setTargetRole] = useState<Role>("user");
const [assignError, setAssignError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [historyUserId, setHistoryUserId] = useState("");
const [history, setHistory] = useState<Assignment[] | null>(null);
useEffect(() => {
fetchRoles()
.then(setRoles)
.catch(() => setLoadError("Rollen konnten nicht geladen werden. Sind Sie als Tenant-Admin angemeldet?"));
}, []);
async function onAssign(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setAssignError(null);
setSubmitting(true);
try {
await assignRole(targetUserId, targetRole);
push("Rolle zugewiesen.", "success");
} catch (err) {
// Akzeptanzkriterium 2: die Backend-Meldung bei Selbst-Eskalation wird
// unveraendert angezeigt, keine eigene Interpretation im Frontend.
setAssignError(err instanceof ApiError ? err.message : "Rolle konnte nicht zugewiesen werden.");
} finally {
setSubmitting(false);
}
}
async function onLoadHistory(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
try {
setHistory(await fetchRoleHistory(historyUserId));
} catch {
setHistory([]);
}
}
const historyColumns: TableColumn<Assignment>[] = [
{ key: "role", header: "Rolle", render: (a) => a.Role },
{ key: "grantedBy", header: "Vergeben von", render: (a) => a.GrantedBy },
];
if (loadError) {
return (
<main style={{ maxWidth: 640, margin: "40px auto", padding: "0 16px" }}>
<p role="alert">{loadError}</p>
</main>
);
}
return (
<main style={{ maxWidth: 640, margin: "40px auto", padding: "0 16px" }}>
<h1>Rollen &amp; Rechte</h1>
<section aria-labelledby="rollen-heading">
<h2 id="rollen-heading">Rollen und Grundrechte</h2>
{roles?.map((r) => (
<div key={r.role} style={{ marginBottom: 12 }}>
<strong>{roleLabel(r.role)}</strong>
<ul>
{r.permissions.map((p) => (
<li key={p}>{p}</li>
))}
</ul>
</div>
))}
</section>
<section aria-labelledby="zuweisen-heading">
<h2 id="zuweisen-heading">Rolle zuweisen</h2>
<form onSubmit={onAssign} noValidate>
<TextField
label="Benutzer-ID"
required
value={targetUserId}
onChange={(e) => setTargetUserId(e.target.value)}
/>
<SelectField label="Rolle" value={targetRole} onChange={(e) => setTargetRole(e.target.value as Role)}>
<option value="user">Benutzer</option>
<option value="tenant_admin">Tenant-Admin</option>
</SelectField>
{assignError && (
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
{assignError}
</p>
)}
<button type="submit" disabled={submitting}>
{submitting ? "Wird zugewiesen…" : "Zuweisen"}
</button>
</form>
</section>
<section aria-labelledby="verlauf-heading">
<h2 id="verlauf-heading">Nachvollziehbarkeit (Audit-Verlauf)</h2>
<p>Zeigt jede Rollenzuweisung eines Benutzers chronologisch wer wann welche Rolle vergeben hat.</p>
<form onSubmit={onLoadHistory} noValidate>
<TextField
label="Benutzer-ID"
required
value={historyUserId}
onChange={(e) => setHistoryUserId(e.target.value)}
/>
<button type="submit">Verlauf anzeigen</button>
</form>
{history && (
<Table
columns={historyColumns}
rows={history}
rowKey={(a) => `${a.UserID}-${a.Role}-${a.GrantedBy}`}
caption="Rollenzuweisungs-Verlauf"
/>
)}
</section>
</main>
);
}
function roleLabel(role: Role): string {
return role === "tenant_admin" ? "Tenant-Admin" : "Benutzer";
}
-68
View File
@@ -1,68 +0,0 @@
const API_BASE = process.env.NEXT_PUBLIC_CORE_API_BASE ?? "";
export class ApiError extends Error {}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
credentials: "include",
headers: init?.body ? { "Content-Type": "application/json" } : undefined,
...init,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new ApiError(data.error ?? data.message ?? "Unbekannter Fehler");
}
return data as T;
}
export type Role = "user" | "tenant_admin";
export interface RoleInfo {
role: Role;
permissions: string[];
}
export function fetchRoles(): Promise<RoleInfo[]> {
return req("/rbac/roles");
}
export interface Assignment {
UserID: string;
Role: Role;
GrantedBy: string;
}
export function assignRole(userId: string, role: Role): Promise<Assignment> {
return req("/rbac/users/role", { method: "POST", body: JSON.stringify({ user_id: userId, role }) });
}
export function fetchRoleHistory(userId: string): Promise<Assignment[]> {
return req(`/rbac/users/${userId}/history`);
}
export interface Group {
ID: string;
Name: string;
Role: Role | "";
members: string[];
}
export function fetchGroups(): Promise<Group[]> {
return req("/rbac/groups");
}
export function createGroup(name: string): Promise<Group> {
return req("/rbac/groups", { method: "POST", body: JSON.stringify({ name }) });
}
export function setGroupRole(groupId: string, role: Role): Promise<void> {
return req("/rbac/groups/role", { method: "POST", body: JSON.stringify({ group_id: groupId, role }) });
}
export function addGroupMember(groupId: string, userId: string): Promise<void> {
return req("/rbac/groups/members", { method: "POST", body: JSON.stringify({ group_id: groupId, user_id: userId }) });
}
export function removeGroupMember(groupId: string, userId: string): Promise<void> {
return req("/rbac/groups/members/remove", { method: "POST", body: JSON.stringify({ group_id: groupId, user_id: userId }) });
}
-5
View File
@@ -1,5 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@nexarch/shl"],
};
export default nextConfig;
-22
View File
@@ -1,22 +0,0 @@
{
"name": "nexarch-rbac-admin",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@nexarch/shl": "file:../shl",
"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"
}
}
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"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"]
}
-45
View File
@@ -1,45 +0,0 @@
# @nexarch/shl — UI-Shell & Design-System (Core SHL-01)
Gemeinsames Paket für alle NEXARCH-Modul-Frontends (Core, DMS, Mail, Archive, Workflow, AI, Connect).
Ein Modul-Frontend importiert ausschließlich über `index.ts`, kopiert keine Komponenten oder Tokens lokal.
## Enthält
- **Design-Tokens** (`tokens/tokens.ts`) — Farbe (Hell/Dunkel), Abstand, Typografie. Kontrastwerte gegen WCAG 2.1 AA geprüft (siehe `__tests__/tokens.test.ts`).
- **Theming** (`theme/ThemeProvider.tsx`) — zentrale Hell/Dunkel-Umschaltung, respektiert `prefers-color-scheme`, persistiert in `localStorage`.
- **i18n-Rahmen** (`i18n/i18n.tsx`) — Umschaltmechanismus Deutsch/Englisch. Modul-Frontends registrieren ihre fachlichen Textbausteine über `registerMessages()`, statt einen eigenen Mechanismus zu bauen.
- **Basis-Komponenten** (`components/`) — `Shell` (Layout + Navigation), `Table`, `Dialog`, `TextField`/`SelectField`/`CheckboxField`, `Toast`. Alle mit WCAG-2.1-AA-Grundlage (Tastaturbedienung, ARIA-Attribute, Fokus-Management).
## Verwendung in einem Modul-Frontend
```tsx
import { ThemeProvider, I18nProvider, ToastProvider, Shell } from "@nexarch/shl";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>
<Shell modules={[]} tenantLabel="Mandant XY">
{children}
</Shell>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
);
}
```
## Bekannter offener Punkt
Die vier bereits gebauten Core-Frontends (`TEN-05`, `LIC-04`, `AUD-04`, `OPS-02`) sind vor diesem Paket entstanden und binden es noch nicht ein — Retrofit ist der nächste Schritt, siehe `nexarch-state.json`.
## Tests
Ausführung auf dem Test-Host (nicht lokal, siehe Projekt-Testinfrastruktur):
```bash
npm install
npm test
npm run typecheck
```
-38
View File
@@ -1,38 +0,0 @@
// Prüfung: Tastaturbedienung der Basis-Komponenten funktioniert (SHL-01 Prüfung 2).
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { Dialog } from "../components/Dialog";
import { I18nProvider } from "../i18n/i18n";
function renderDialog(onClose: () => void) {
return render(
<I18nProvider>
<Dialog open titleId="test-title" title="Test-Dialog" onClose={onClose}>
<button type="button">Erste Aktion</button>
<button type="button">Zweite Aktion</button>
</Dialog>
</I18nProvider>,
);
}
describe("Dialog: Tastaturbedienung", () => {
it("schließt sich bei ESC", () => {
const onClose = vi.fn();
renderDialog(onClose);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledOnce();
});
it("setzt den Fokus beim Öffnen auf das erste fokussierbare Element", () => {
renderDialog(vi.fn());
const closeButton = screen.getByRole("button", { name: /schließen/i });
expect(document.activeElement).toBe(closeButton);
});
it("ist als modaler Dialog mit Titel-Referenz ausgezeichnet", () => {
renderDialog(vi.fn());
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveAttribute("aria-modal", "true");
expect(dialog).toHaveAttribute("aria-labelledby", "test-title");
});
});
-39
View File
@@ -1,39 +0,0 @@
// Prüfung: Kontrastwerte erfüllen mindestens AA (SHL-01 Prüfung 3 / Akzeptanzkriterium 4).
import { describe, expect, it } from "vitest";
import { colorTokens } from "../tokens/tokens";
// WCAG-2.1-AA-Kontrastberechnung (relative Luminanz, sRGB) — keine externe Abhängigkeit nötig.
function relLuminance(hex: string): number {
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrastRatio(a: string, b: string): number {
const l1 = relLuminance(a);
const l2 = relLuminance(b);
const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
return (lighter + 0.05) / (darker + 0.05);
}
describe("Design-Tokens: WCAG 2.1 AA Kontrast", () => {
for (const scheme of ["light", "dark"] as const) {
const c = colorTokens[scheme];
it(`${scheme}: textPrimary auf background erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.textPrimary, c.background)).toBeGreaterThanOrEqual(4.5);
});
it(`${scheme}: textSecondary auf surface erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.textSecondary, c.surface)).toBeGreaterThanOrEqual(4.5);
});
it(`${scheme}: accentContrast auf accent erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.accentContrast, c.accent)).toBeGreaterThanOrEqual(4.5);
});
it(`${scheme}: dangerContrast auf danger erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.dangerContrast, c.danger)).toBeGreaterThanOrEqual(4.5);
});
}
});
-88
View File
@@ -1,88 +0,0 @@
"use client";
// Dialog-Basis-Komponente — SHL-01. WCAG 2.1 AA: Fokus-Falle, ESC schließt, Tastaturbedienung vollständig.
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
export interface DialogProps {
open: boolean;
onClose: () => void;
titleId: string;
title: string;
children: ReactNode;
}
export function Dialog({ open, onClose, titleId, title, children }: DialogProps) {
const { t } = useI18n();
const dialogRef = useRef<HTMLDivElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
previouslyFocused.current = document.activeElement as HTMLElement | null;
const node = dialogRef.current;
const focusables = node?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
focusables?.[0]?.focus();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
return;
}
if (event.key !== "Tab" || !node) return;
const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
previouslyFocused.current?.focus();
};
}, [open, onClose]);
if (!open) return null;
return (
<div
className="shl-dialog-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
className="shl-dialog"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="shl-dialog-header">
<h2 id={titleId}>{title}</h2>
<button type="button" onClick={onClose} aria-label={t("shl.dialog.close")}>
×
</button>
</div>
<div className="shl-dialog-body">{children}</div>
</div>
</div>
);
}
-98
View File
@@ -1,98 +0,0 @@
// Formularelemente-Basis-Komponenten — SHL-01. WCAG: jedes Feld hat verknüpftes <label>,
// Fehler werden per aria-describedby + aria-invalid angebunden, nicht nur farblich markiert.
import { useId } from "react";
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
interface FieldWrapperProps {
label: string;
error?: string;
hint?: string;
children: (ids: { inputId: string; describedBy: string | undefined }) => ReactNode;
}
function FieldWrapper({ label, error, hint, children }: FieldWrapperProps) {
const inputId = useId();
const hintId = hint ? `${inputId}-hint` : undefined;
const errorId = error ? `${inputId}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
return (
<div className="shl-field">
<label htmlFor={inputId}>{label}</label>
{children({ inputId, describedBy })}
{hint && (
<p id={hintId} className="shl-field-hint">
{hint}
</p>
)}
{error && (
<p id={errorId} className="shl-field-error" role="alert">
{error}
</p>
)}
</div>
);
}
export interface TextFieldProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "aria-describedby"> {
label: string;
error?: string;
hint?: string;
}
export function TextField({ label, error, hint, ...inputProps }: TextFieldProps) {
return (
<FieldWrapper label={label} error={error} hint={hint}>
{({ inputId, describedBy }) => (
<input
id={inputId}
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
{...inputProps}
/>
)}
</FieldWrapper>
);
}
export interface SelectFieldProps
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "id" | "aria-describedby"> {
label: string;
error?: string;
hint?: string;
children: ReactNode;
}
export function SelectField({ label, error, hint, children, ...selectProps }: SelectFieldProps) {
return (
<FieldWrapper label={label} error={error} hint={hint}>
{({ inputId, describedBy }) => (
<select
id={inputId}
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
{...selectProps}
>
{children}
</select>
)}
</FieldWrapper>
);
}
export interface CheckboxFieldProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "type"> {
label: string;
}
export function CheckboxField({ label, ...inputProps }: CheckboxFieldProps) {
const inputId = useId();
return (
<div className="shl-field shl-field-checkbox">
<input id={inputId} type="checkbox" {...inputProps} />
<label htmlFor={inputId}>{label}</label>
</div>
);
}
-62
View File
@@ -1,62 +0,0 @@
"use client";
// Layout-Shell mit Navigation — SHL-01 Akzeptanzkriterium 1.
// Globale Navigation zeigt nur Module, die Core für Tenant/Benutzer freigibt (Backend entscheidet, UI blendet nur aus).
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
import { useTheme } from "../theme/ThemeProvider";
export interface ModuleLink {
key: string;
label: string;
href: string;
active?: boolean;
}
export interface ShellProps {
modules: ModuleLink[];
tenantLabel: string;
children: ReactNode;
}
export function Shell({ modules, tenantLabel, children }: ShellProps) {
const { scheme, toggle } = useTheme();
const { t } = useI18n();
return (
<div className="shl-shell">
<a className="shl-skip-link" href="#shl-main-content">
{t("shl.shell.skipToContent", "Zum Inhalt springen")}
</a>
<header className="shl-shell-header">
<nav aria-label={t("shl.shell.moduleNav", "Modul-Navigation")}>
<ul>
{modules.map((mod) => (
<li key={mod.key}>
<a href={mod.href} aria-current={mod.active ? "page" : undefined}>
{mod.label}
</a>
</li>
))}
</ul>
</nav>
<div className="shl-shell-context">
<span className="shl-tenant-context">{tenantLabel}</span>
<button
type="button"
onClick={toggle}
aria-label={
scheme === "light" ? t("shl.theme.toggleToDark") : t("shl.theme.toggleToLight")
}
>
{scheme === "light" ? "🌙" : "☀️"}
</button>
</div>
</header>
<main id="shl-main-content" className="shl-shell-content" tabIndex={-1}>
{children}
</main>
</div>
);
}
-83
View File
@@ -1,83 +0,0 @@
// Table-Basis-Komponente — SHL-01. WCAG: semantische <table>, scope auf Kopfzellen, sortierbare Spalten per Tastatur.
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
export interface TableColumn<Row> {
key: string;
header: string;
render: (row: Row) => ReactNode;
sortable?: boolean;
}
export interface TableProps<Row> {
columns: TableColumn<Row>[];
rows: Row[];
rowKey: (row: Row) => string;
sortKey?: string;
sortDirection?: "asc" | "desc";
onSort?: (key: string) => void;
caption?: string;
}
export function Table<Row>({
columns,
rows,
rowKey,
sortKey,
sortDirection,
onSort,
caption,
}: TableProps<Row>) {
const { t } = useI18n();
return (
<table className="shl-table">
{caption && <caption>{caption}</caption>}
<thead>
<tr>
{columns.map((column) => {
const isSorted = column.key === sortKey;
const ariaSort = column.sortable
? isSorted
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
: undefined;
return (
<th key={column.key} scope="col" aria-sort={ariaSort}>
{column.sortable ? (
<button
type="button"
onClick={() => onSort?.(column.key)}
className="shl-table-sort-button"
>
{column.header}
</button>
) : (
column.header
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={columns.length}>{t("shl.table.noRows")}</td>
</tr>
) : (
rows.map((row) => (
<tr key={rowKey(row)}>
{columns.map((column) => (
<td key={column.key}>{column.render(row)}</td>
))}
</tr>
))
)}
</tbody>
</table>
);
}
-67
View File
@@ -1,67 +0,0 @@
"use client";
// Toast-Basis-Komponente — SHL-01. WCAG: aria-live sorgt dafür, dass Screenreader Meldungen ansagen.
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
export type ToastVariant = "info" | "success" | "danger" | "warning";
export interface ToastMessage {
id: string;
text: string;
variant: ToastVariant;
}
interface ToastContextValue {
toasts: ToastMessage[];
push: (text: string, variant?: ToastVariant) => void;
dismiss: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const { t } = useI18n();
const dismiss = useCallback((id: string) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const push = useCallback((text: string, variant: ToastVariant = "info") => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setToasts((current) => [...current, { id, text, variant }]);
}, []);
const value = useMemo(() => ({ toasts, push, dismiss }), [toasts, push, dismiss]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="shl-toast-region" role="status" aria-live="polite" aria-atomic="false">
{toasts.map((toast) => (
<div key={toast.id} className={`shl-toast shl-toast-${toast.variant}`}>
<span>{toast.text}</span>
<button
type="button"
onClick={() => dismiss(toast.id)}
aria-label={t("shl.toast.dismiss")}
>
×
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error("useToast muss innerhalb von <ToastProvider> aufgerufen werden");
}
return ctx;
}
-75
View File
@@ -1,75 +0,0 @@
"use client";
// i18n-Rahmen (mind. Deutsch/Englisch) — SHL-01 Akzeptanzkriterium 5.
// Liefert nur den Umschaltmechanismus + Basis-Komponenten-Texte.
// Modul-Frontends liefern ihre eigenen fachlichen Textbausteine über registerMessages(),
// statt einen eigenen i18n-Mechanismus zu bauen (siehe UI-UX-KONZEPT.md Abschnitt 4).
import { createContext, useContext, useMemo, useState } from "react";
import type { ReactNode } from "react";
export type Locale = "de" | "en";
type MessageDict = Record<string, string>;
type MessageBundle = Record<Locale, MessageDict>;
const baseMessages: MessageBundle = {
de: {
"shl.dialog.close": "Schließen",
"shl.toast.dismiss": "Meldung schließen",
"shl.table.noRows": "Keine Einträge vorhanden",
"shl.theme.toggleToLight": "Helles Erscheinungsbild",
"shl.theme.toggleToDark": "Dunkles Erscheinungsbild",
},
en: {
"shl.dialog.close": "Close",
"shl.toast.dismiss": "Dismiss message",
"shl.table.noRows": "No entries",
"shl.theme.toggleToLight": "Switch to light theme",
"shl.theme.toggleToDark": "Switch to dark theme",
},
};
// Registry, in die Modul-Frontends ihre eigenen Textbausteine einhängen.
const registry: MessageBundle = { de: { ...baseMessages.de }, en: { ...baseMessages.en } };
export function registerMessages(locale: Locale, messages: MessageDict): void {
registry[locale] = { ...registry[locale], ...messages };
}
interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string, fallback?: string) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({
initialLocale = "de",
children,
}: {
initialLocale?: Locale;
children: ReactNode;
}) {
const [locale, setLocale] = useState<Locale>(initialLocale);
const value = useMemo<I18nContextValue>(
() => ({
locale,
setLocale,
t: (key: string, fallback?: string) => registry[locale][key] ?? fallback ?? key,
}),
[locale],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext);
if (!ctx) {
throw new Error("useI18n muss innerhalb von <I18nProvider> aufgerufen werden");
}
return ctx;
}
-25
View File
@@ -1,25 +0,0 @@
// Öffentliche Schnittstelle des Pakets @nexarch/shl — Modul-Frontends importieren ausschließlich hierüber,
// nicht aus internen Unterpfaden (SHL-01 Akzeptanzkriterium 2: dokumentiert, versioniert, importierbar statt kopiert).
export { colorTokens, spacing, breakpoints, typography, cssVariables } from "./tokens/tokens";
export type { ColorScheme, ColorTokens } from "./tokens/tokens";
export { ThemeProvider, useTheme, currentColors } from "./theme/ThemeProvider";
export { I18nProvider, useI18n, registerMessages } from "./i18n/i18n";
export type { Locale } from "./i18n/i18n";
export { Shell } from "./components/Shell";
export type { ShellProps, ModuleLink } from "./components/Shell";
export { Dialog } from "./components/Dialog";
export type { DialogProps } from "./components/Dialog";
export { Table } from "./components/Table";
export type { TableProps, TableColumn } from "./components/Table";
export { TextField, SelectField, CheckboxField } from "./components/FormElements";
export type { TextFieldProps, SelectFieldProps, CheckboxFieldProps } from "./components/FormElements";
export { ToastProvider, useToast } from "./components/Toast";
export type { ToastMessage, ToastVariant } from "./components/Toast";
-25
View File
@@ -1,25 +0,0 @@
{
"name": "@nexarch/shl",
"version": "0.1.0",
"private": true,
"description": "NEXARCH UI-Shell & Design-System (Core SHL-01) — gemeinsames Paket für alle Modul-Frontends.",
"main": "index.ts",
"types": "index.ts",
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"jsdom": "^24.1.1",
"typescript": "5.5.3",
"vitest": "^2.0.0"
}
}
-81
View File
@@ -1,81 +0,0 @@
"use client";
// Zentrales Theming (Hell/Dunkel) — SHL-01 Akzeptanzkriterium 6.
// Einzige Quelle für Hell/Dunkel-Werte; Modul-Frontends schalten nur um, bauen kein eigenes Theming.
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { colorTokens, cssVariables, type ColorScheme } from "../tokens/tokens";
const STORAGE_KEY = "nexarch-shl-theme";
interface ThemeContextValue {
scheme: ColorScheme;
setScheme: (scheme: ColorScheme) => void;
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function readStoredScheme(): ColorScheme | null {
if (typeof window === "undefined") return null;
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored === "light" || stored === "dark" ? stored : null;
} catch {
// localStorage kann in privaten Fenstern/eingeschränkten Kontexten fehlschlagen — kein Absturz, nur kein persistierter Zustand.
return null;
}
}
function systemPrefersDark(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [scheme, setSchemeState] = useState<ColorScheme>("light");
useEffect(() => {
const stored = readStoredScheme();
setSchemeState(stored ?? (systemPrefersDark() ? "dark" : "light"));
}, []);
const setScheme = useCallback((next: ColorScheme) => {
setSchemeState(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Speichern optional — Umschaltung funktioniert auch ohne Persistenz.
}
}, []);
const toggle = useCallback(() => {
setScheme(scheme === "light" ? "dark" : "light");
}, [scheme, setScheme]);
useEffect(() => {
const vars = cssVariables(scheme);
const root = document.documentElement;
for (const [key, value] of Object.entries(vars)) {
root.style.setProperty(key, value);
}
root.dataset.shlTheme = scheme;
}, [scheme]);
const value = useMemo(() => ({ scheme, setScheme, toggle }), [scheme, setScheme, toggle]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error("useTheme muss innerhalb von <ThemeProvider> aufgerufen werden");
}
return ctx;
}
export function currentColors(scheme: ColorScheme) {
return colorTokens[scheme];
}
-96
View File
@@ -1,96 +0,0 @@
// Design-Tokens: einzige Quelle für Farbe, Abstand, Typografie im gesamten Frontend-Verbund.
// Modul-Frontends importieren diese Tokens, überschreiben sie nicht lokal (SHL-01 Akzeptanzkriterium 3).
// Kontrastwerte sind gegen WCAG 2.1 AA geprüft (Akzeptanzkriterium 1/4): mindestens 4.5:1 für Fließtext.
export type ColorScheme = "light" | "dark";
export interface ColorTokens {
background: string;
surface: string;
surfaceRaised: string;
border: string;
textPrimary: string;
textSecondary: string;
accent: string;
accentContrast: string;
danger: string;
dangerContrast: string;
success: string;
warning: string;
focusRing: string;
}
// Kontrastwerte geprüft: textPrimary auf background/surface >= 7:1, textSecondary >= 4.5:1,
// accentContrast auf accent >= 4.5:1 (WCAG AA, siehe SHL-01 Prüfung 3).
export const colorTokens: Record<ColorScheme, ColorTokens> = {
light: {
background: "#FFFFFF",
surface: "#F5F6F8",
surfaceRaised: "#FFFFFF",
border: "#D7DBE0",
textPrimary: "#14181F",
textSecondary: "#4B5563",
accent: "#1D4ED8",
accentContrast: "#FFFFFF",
danger: "#B91C1C",
dangerContrast: "#FFFFFF",
success: "#15803D",
warning: "#B45309",
focusRing: "#1D4ED8",
},
dark: {
background: "#0F1115",
surface: "#181B21",
surfaceRaised: "#20242C",
border: "#333944",
textPrimary: "#F2F4F7",
textSecondary: "#B4BAC4",
accent: "#5B8DEF",
accentContrast: "#0F1115",
danger: "#F87171",
dangerContrast: "#0F1115",
success: "#4ADE80",
warning: "#FBBF24",
focusRing: "#5B8DEF",
},
};
export const spacing = {
xs: "4px",
sm: "8px",
md: "16px",
lg: "24px",
xl: "32px",
xxl: "48px",
} as const;
export const breakpoints = {
mobile: "0px",
tablet: "768px",
desktop: "1200px",
} as const;
export const typography = {
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
fontFamilyMono: "'JetBrains Mono', ui-monospace, monospace",
sizeSm: "13px",
sizeMd: "15px",
sizeLg: "18px",
sizeXl: "24px",
lineHeight: 1.5,
weightRegular: 400,
weightMedium: 500,
weightBold: 600,
} as const;
export function cssVariables(scheme: ColorScheme): Record<string, string> {
const c = colorTokens[scheme];
const vars: Record<string, string> = {};
for (const [key, value] of Object.entries(c)) {
vars[`--shl-color-${key.replace(/([A-Z])/g, "-$1").toLowerCase()}`] = value;
}
for (const [key, value] of Object.entries(spacing)) {
vars[`--shl-spacing-${key}`] = value;
}
return vars;
}
-18
View File
@@ -1,18 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
},
});
-9
View File
@@ -1,9 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// Ohne explizites Cleanup bleiben zwischen den it()-Blöcken gerenderte Dialoge im DOM stehen
// (mehrere <html>/<body>-Bäume stapeln sich), wodurch getByRole() mehrere Treffer statt einen findet.
afterEach(() => {
cleanup();
});