Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14516b4adf | ||
|
|
3ea2489f45 | ||
|
|
f7863fd4d2 |
@@ -1,40 +0,0 @@
|
||||
// secheaders-devserver dient der manuellen/e2e-Verifikation von API-08
|
||||
// (Pruefung 2: CSP blockiert Inline-Script) mit einem echten Browser.
|
||||
// Zwei Pfade: /protected (mit Middleware) und /unprotected (ohne) — beide
|
||||
// liefern dieselbe Seite mit einem Inline-Script, das ein DOM-Element
|
||||
// aendert. Ein echter Browser zeigt den Unterschied.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/secheaders"
|
||||
)
|
||||
|
||||
const page = `<!doctype html>
|
||||
<html><body>
|
||||
<div id="marker">BLOCKED</div>
|
||||
<script>document.getElementById('marker').textContent = 'INLINE_RAN';</script>
|
||||
</body></html>`
|
||||
|
||||
func servePage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(page))
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := secheaders.DefaultConfig()
|
||||
mux := http.NewServeMux()
|
||||
// SetHeaders statt Middleware: dieser lokale Demo-Server laeuft ohne
|
||||
// TLS-Terminierung, der HTTPS-Redirect-Teil von Middleware wuerde hier
|
||||
// in eine Endlosschleife fuehren (siehe secheaders.go-Dokumentation).
|
||||
mux.HandleFunc("/protected", func(w http.ResponseWriter, r *http.Request) {
|
||||
secheaders.SetHeaders(cfg, w)
|
||||
servePage(w, r)
|
||||
})
|
||||
mux.HandleFunc("/unprotected", servePage)
|
||||
|
||||
log.Println("secheaders-devserver listening on :8086")
|
||||
log.Fatal(http.ListenAndServe(":8086", mux))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
// Package secheaders implementiert Core API-08: eine gemeinsame
|
||||
// Security-Header-Middleware/Bibliothek, die jedes Modul-Frontend (DMS,
|
||||
// Mail, Workflow, AI, Connect) einbindet, statt Header pro Modul erneut zu
|
||||
// definieren (Akzeptanzkriterium 3) — dieselbe Konsolidierungslogik wie
|
||||
// beim zentralen Rate-Limiting (API-03). Die tatsaechliche EINBINDUNG in
|
||||
// jedes einzelne Modul-Frontend ist ausdruecklich NICHT Teil dieser Kachel
|
||||
// (siehe Ticket "Nicht Bestandteil").
|
||||
package secheaders
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config ist die Baseline-Konfiguration. DefaultConfig() liefert einen
|
||||
// Ausgangspunkt OHNE 'unsafe-inline'/'unsafe-eval' (Akzeptanzkriterium 1)
|
||||
// und mit angemessener HSTS-Laufzeit (Akzeptanzkriterium 2).
|
||||
type Config struct {
|
||||
// ContentSecurityPolicy wird unveraendert als Header-Wert gesendet.
|
||||
ContentSecurityPolicy string
|
||||
// HSTSMaxAge bestimmt die Gueltigkeitsdauer, die Browser sich merken.
|
||||
HSTSMaxAge time.Duration
|
||||
HSTSIncludeSubdomains bool
|
||||
FrameOptions string
|
||||
ReferrerPolicy string
|
||||
ContentTypeNoSniff bool
|
||||
}
|
||||
|
||||
// DefaultConfig liefert die dokumentierte Baseline: restriktive CSP ohne
|
||||
// unsichere Direktiven, HSTS mit 180 Tagen (empfohlener Mindestwert fuer
|
||||
// HSTS-Preloading-Listen liegt bei 1 Jahr — 180 Tage ist ein bewusst
|
||||
// konservativer, aber bereits "angemessener" Ausgangswert, siehe
|
||||
// Akzeptanzkriterium 2), Clickjacking- und MIME-Sniffing-Schutz.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
ContentSecurityPolicy: strings.Join([]string{
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self'",
|
||||
"img-src 'self' data:",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
}, "; "),
|
||||
HSTSMaxAge: 180 * 24 * time.Hour,
|
||||
HSTSIncludeSubdomains: true,
|
||||
FrameOptions: "DENY",
|
||||
ReferrerPolicy: "strict-origin-when-cross-origin",
|
||||
ContentTypeNoSniff: true,
|
||||
}
|
||||
}
|
||||
|
||||
// RequiredHeaders sind die von dieser Baseline gesetzten Header-Namen — die
|
||||
// Referenzliste fuer CheckCoverage (Akzeptanzkriterium/Pruefung 1).
|
||||
var RequiredHeaders = []string{
|
||||
"Content-Security-Policy",
|
||||
"Strict-Transport-Security",
|
||||
"X-Frame-Options",
|
||||
"Referrer-Policy",
|
||||
"X-Content-Type-Options",
|
||||
}
|
||||
|
||||
func (c Config) hstsValue() string {
|
||||
v := fmt.Sprintf("max-age=%d", int(c.HSTSMaxAge.Seconds()))
|
||||
if c.HSTSIncludeSubdomains {
|
||||
v += "; includeSubDomains"
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Middleware setzt die Baseline-Header auf JEDER Antwort und erzwingt einen
|
||||
// HTTP->HTTPS-Redirect, WENN die Anfrage nicht bereits ueber TLS ankam
|
||||
// (Akzeptanzkriterium 2 / Pruefung 3) — serverseitig durchgesetzt, nicht nur
|
||||
// dem Browser-HSTS-Cache ueberlassen (der erst nach dem ERSTEN sicheren
|
||||
// Aufruf greift).
|
||||
func Middleware(cfg Config, next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isSecure(r) {
|
||||
target := "https://" + r.Host + r.URL.RequestURI()
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
|
||||
SetHeaders(cfg, w)
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// SetHeaders setzt die Baseline-Header direkt, OHNE die HTTPS-Redirect-
|
||||
// Pruefung von Middleware — nuetzlich fuer lokale Entwicklungs-/
|
||||
// Demo-Umgebungen ohne TLS-Terminierung (siehe cmd/secheaders-devserver)
|
||||
// und fuer Aufrufer, die den Redirect selbst schon anderweitig sicherstellen.
|
||||
func SetHeaders(cfg Config, w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Security-Policy", cfg.ContentSecurityPolicy)
|
||||
w.Header().Set("Strict-Transport-Security", cfg.hstsValue())
|
||||
w.Header().Set("X-Frame-Options", cfg.FrameOptions)
|
||||
w.Header().Set("Referrer-Policy", cfg.ReferrerPolicy)
|
||||
if cfg.ContentTypeNoSniff {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
}
|
||||
}
|
||||
|
||||
// isSecure erkennt TLS direkt ODER ueber den ueblichen Reverse-Proxy-Header
|
||||
// (X-Forwarded-Proto) — der Core-Dienst laeuft in der Praxis hinter einem
|
||||
// TLS-terminierenden Proxy, sieht r.TLS also nie direkt gesetzt.
|
||||
func isSecure(r *http.Request) bool {
|
||||
if r.TLS != nil {
|
||||
return true
|
||||
}
|
||||
return strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
// CheckCoverage vergleicht die tatsaechlich gesetzten Header einer Antwort
|
||||
// gegen RequiredHeaders und liefert die fehlenden — der "automatisierte
|
||||
// Header-Scan" aus Pruefung 1, anwendbar auf jede Antwort eines beliebigen
|
||||
// Modul-Frontends.
|
||||
func CheckCoverage(headers http.Header) (missing []string) {
|
||||
for _, name := range RequiredHeaders {
|
||||
if headers.Get(name) == "" {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package secheaders
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func secureRequest(method, target string) *http.Request {
|
||||
req := httptest.NewRequest(method, target, nil)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
return req
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 1: CSP definiert ohne unsafe-inline/unsafe-eval.
|
||||
func TestDefaultConfig_CSPHasNoUnsafeDirectives(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if strings.Contains(cfg.ContentSecurityPolicy, "unsafe-inline") {
|
||||
t.Fatal("csp enthaelt 'unsafe-inline', erwartet keine unsicheren direktiven")
|
||||
}
|
||||
if strings.Contains(cfg.ContentSecurityPolicy, "unsafe-eval") {
|
||||
t.Fatal("csp enthaelt 'unsafe-eval', erwartet keine unsicheren direktiven")
|
||||
}
|
||||
if !strings.Contains(cfg.ContentSecurityPolicy, "default-src 'self'") {
|
||||
t.Fatal("erwartet restriktive default-src 'self' als ausgangspunkt")
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2: HSTS mit angemessener max-age gesetzt.
|
||||
func TestMiddleware_SetsHSTSWithSufficientMaxAge(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
handler := Middleware(cfg, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, secureRequest(http.MethodGet, "https://example.test/"))
|
||||
|
||||
hsts := rec.Header().Get("Strict-Transport-Security")
|
||||
if !strings.Contains(hsts, "max-age=") {
|
||||
t.Fatalf("hsts-header fehlt max-age: %q", hsts)
|
||||
}
|
||||
// "Angemessen" heisst hier: mindestens 30 Tage (2592000s) — deutlich
|
||||
// ueber dem bekannten Anti-Pattern kurzer/fehlender max-age-Werte.
|
||||
if cfg.HSTSMaxAge.Seconds() < 2592000 {
|
||||
t.Fatalf("konfigurierte hsts-max-age = %v, erwartet mindestens 30 tage", cfg.HSTSMaxAge)
|
||||
}
|
||||
if !strings.Contains(hsts, "includeSubDomains") {
|
||||
t.Fatalf("erwartet includeSubDomains im hsts-header: %q", hsts)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 3 + Pruefung 1: EINE gemeinsame Middleware setzt ALLE
|
||||
// Baseline-Header vollstaendig — automatisiert ueberprueft mit CheckCoverage.
|
||||
func TestMiddleware_ProducesFullHeaderCoverage(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
handler := Middleware(cfg, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, secureRequest(http.MethodGet, "https://example.test/"))
|
||||
|
||||
if missing := CheckCoverage(rec.Header()); len(missing) != 0 {
|
||||
t.Fatalf("erwartet vollstaendige header-abdeckung, fehlen: %v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
// Pruefung 1 (Kehrseite): CheckCoverage erkennt fehlende Header
|
||||
// zuverlaessig — Voraussetzung dafuer, dass ein automatisierter Scan
|
||||
// gegen ein Modul-Frontend OHNE eingebundene Middleware ueberhaupt etwas
|
||||
// meldet.
|
||||
func TestCheckCoverage_DetectsMissingHeadersOnBareHandler(t *testing.T) {
|
||||
bareHandler := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
bareHandler(rec, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
|
||||
missing := CheckCoverage(rec.Header())
|
||||
if len(missing) != len(RequiredHeaders) {
|
||||
t.Fatalf("erwartet %d fehlende header bei unbehandeltem handler, habe %d: %v",
|
||||
len(RequiredHeaders), len(missing), missing)
|
||||
}
|
||||
}
|
||||
|
||||
// Akzeptanzkriterium 2 + Pruefung 3: ein Aufruf ueber (simuliertes) HTTP
|
||||
// wird server-seitig auf HTTPS umgeleitet, statt sich allein auf den
|
||||
// HSTS-Browser-Cache zu verlassen (der erst nach dem ersten sicheren
|
||||
// Aufruf greift).
|
||||
func TestMiddleware_RedirectsInsecureRequestToHTTPS(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
called := false
|
||||
handler := Middleware(cfg, func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://example.test/pfad?x=1", nil)
|
||||
// Kein X-Forwarded-Proto, kein r.TLS -> unsicherer Aufruf.
|
||||
rec := httptest.NewRecorder()
|
||||
handler(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("status = %d, want 301 (redirect auf https)", rec.Code)
|
||||
}
|
||||
location := rec.Header().Get("Location")
|
||||
if !strings.HasPrefix(location, "https://") {
|
||||
t.Fatalf("location = %q, want https://-praefix", location)
|
||||
}
|
||||
if !strings.Contains(location, "/pfad?x=1") {
|
||||
t.Fatalf("location = %q, erwartet erhaltenen pfad+query", location)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("eigentlicher handler haette bei unsicherem aufruf nicht erreicht werden duerfen")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user