Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f641fec76 | ||
|
|
5e6cb4e2db | ||
|
|
b8c3f1b989 | ||
|
|
ca547bcd54 | ||
|
|
cbbf5a348a | ||
|
|
550fc70d50 | ||
|
|
314b821929 | ||
|
|
4f84ee23dc | ||
|
|
559b650af7 | ||
|
|
fb0ff9e779 | ||
|
|
912857d35b | ||
|
|
2f832292b3 | ||
|
|
c6933f0e2f | ||
|
|
f89ec925fc | ||
|
|
6c8582c1c2 | ||
|
|
95d1a6810a | ||
|
|
ca07ae4d68 | ||
|
|
0a2e71aec9 | ||
|
|
24c8331d45 | ||
|
|
13874683e8 | ||
|
|
6d288732c6 | ||
|
|
a6340d8850 | ||
|
|
b13d95f4a3 | ||
|
|
2147b4c7fd | ||
|
|
96a54d93a4 | ||
|
|
df2a54f0e6 | ||
|
|
f627caaacb | ||
|
|
f0139c889a | ||
|
|
d9fb2e33f2 | ||
|
|
6885319333 | ||
|
|
6666125a8a | ||
|
|
42466c7b40 | ||
|
|
1b4c3db9b8 | ||
|
|
12b1978ae1 | ||
|
|
9911499c1e | ||
|
|
74e1f07379 | ||
|
|
8edae6141b | ||
|
|
49404c5fa4 | ||
|
|
46fccd9c09 | ||
|
|
a631ac8770 | ||
|
|
bc2126f3b1 | ||
|
|
3fcd8f92af | ||
|
|
43dca005f2 | ||
|
|
24a522e10c | ||
|
|
a67adcefa1 | ||
|
|
11ed3e3790 | ||
|
|
962ef5a27a | ||
|
|
0ce29acf3e | ||
|
|
ba71bb6350 | ||
|
|
e4856dfc9d | ||
|
|
1a82da211d | ||
|
|
f224b5c9be | ||
|
|
5f02e08bc4 | ||
|
|
3c4e9d45b8 | ||
|
|
95275407b1 | ||
|
|
ac070c160a | ||
|
|
b6a941feec | ||
|
|
d63fcbb49e | ||
|
|
f7863fd4d2 | ||
|
|
27f9866264 | ||
|
|
b23cd1961f | ||
|
|
ed67887385 | ||
|
|
383da92ca8 | ||
|
|
4a30345e07 | ||
|
|
b08f6a49fc | ||
|
|
45bc10719a | ||
|
|
2026514404 | ||
|
|
3d20d86a4f | ||
|
|
5915a4e7b1 | ||
|
|
e4793303fc |
@@ -130,6 +130,29 @@ Keine Commits in dieser Session.
|
|||||||
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
||||||
- internal/db/db.go | 11 +++++++++++
|
- internal/db/db.go | 11 +++++++++++
|
||||||
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-08-27 17:53 – 17:57 (4m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** code
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- e479330 IAM-01: benutzer-datenmodell-crud
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- cmd/core/main.go | 11 ++++++++++-
|
||||||
|
- go.mod | 9 +++++++++
|
||||||
|
- go.sum | 28 ++++++++++++++++++++++++++++
|
||||||
|
- internal/user/handler.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- internal/user/store_test.go | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- internal/user/superadmin_store.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- internal/user/tenant_store.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- internal/user/user.go | 42 ++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- internal/user/user_test.go | 24 ++++++++++++++++++++++++
|
||||||
|
- migrations/0002_superadmins.down.sql | 1 +
|
||||||
|
- migrations/0002_superadmins.up.sql | 14 ++++++++++++++
|
||||||
|
- migrations/tenant/0001_users.down.sql | 1 +
|
||||||
|
- migrations/tenant/0001_users.up.sql | 16 ++++++++++++++++
|
||||||
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
|
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
|
||||||
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
|
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
|
||||||
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
|
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
|
||||||
@@ -159,3 +182,20 @@ Keine Commits in dieser Session.
|
|||||||
- web/shl/vitest.setup.ts | 1 +
|
- web/shl/vitest.setup.ts | 1 +
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-08-29 00:08 – 00:08 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** code
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- internal/tenantsettings/handler.go | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- internal/tenantsettings/store_test.go | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- internal/tenantsettings/tenantsettings.go | 187 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- migrations/0003_tenant_settings.down.sql | 2 ++
|
||||||
|
- migrations/0003_tenant_settings.up.sql | 27 +++++++++++++++++++++++++++
|
||||||
|
- scripts/reset-test-env.sh | 25 +++++++++++++++++++++++++
|
||||||
|
- scripts/run-checks.sh | 24 ++++++++++++++++++++++++
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
+13
-1
@@ -8,6 +8,7 @@ import (
|
|||||||
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -33,13 +34,24 @@ func main() {
|
|||||||
registry := tenant.NewRegistry(registryPool)
|
registry := tenant.NewRegistry(registryPool)
|
||||||
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
||||||
tenantHandler := tenant.NewHandler(provisioner)
|
tenantHandler := tenant.NewHandler(provisioner)
|
||||||
|
onboardingService := tenant.NewOnboardingService(provisioner, cfg.TenantSchemaPath)
|
||||||
|
onboardingHandler := tenant.NewOnboardingHandler(onboardingService)
|
||||||
|
|
||||||
|
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
|
||||||
|
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
|
||||||
|
// Mandant (TEN-06, noch nicht gebaut) und wird hier bewusst noch nicht
|
||||||
|
// verdrahtet — Package ist bereits eigenstaendig nutzbar/testbar.
|
||||||
|
superadmins := user.NewSuperadminStore(registryPool)
|
||||||
|
userHandler := user.NewHandler(nil, superadmins)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
// Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 abgeloest.
|
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
|
||||||
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
||||||
|
mux.HandleFunc("/internal/tenants/onboard", onboardingHandler.Onboard)
|
||||||
|
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
|
||||||
|
|
||||||
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
||||||
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
// licadmin-devserver stellt das LIC-04-Backend-API (internal/adminapi) fuer
|
|
||||||
// die Next.js-Verwaltungsoberflaeche bereit. Getrennt von cmd/core, da die
|
|
||||||
// Verdrahtung von license.Validator (Ed25519-Public-Key) in die zentrale
|
|
||||||
// Server-Topologie erst mit API-01/IAM-01 (Auth vor internen Endpunkten)
|
|
||||||
// sinnvoll ist — dieser Server dient der Entwicklung/dem Betrieb der
|
|
||||||
// Verwaltungsoberflaeche gegen eine echte Datenbank, ohne cmd/core
|
|
||||||
// anzufassen (Kein Umbau angrenzender Bereiche).
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/ed25519"
|
|
||||||
"encoding/base64"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/adminapi"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/license"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/usage"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
|
|
||||||
if dsn == "" {
|
|
||||||
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
|
|
||||||
}
|
|
||||||
pubKeyB64 := os.Getenv("NEXARCH_LICENSE_PUBLIC_KEY")
|
|
||||||
if pubKeyB64 == "" {
|
|
||||||
log.Fatal("NEXARCH_LICENSE_PUBLIC_KEY nicht gesetzt (base64, raw-url-encoding)")
|
|
||||||
}
|
|
||||||
pubKeyRaw, err := base64.RawURLEncoding.DecodeString(pubKeyB64)
|
|
||||||
if err != nil || len(pubKeyRaw) != ed25519.PublicKeySize {
|
|
||||||
log.Fatalf("NEXARCH_LICENSE_PUBLIC_KEY ungueltig: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
addr := os.Getenv("NEXARCH_LICADMIN_LISTEN_ADDR")
|
|
||||||
if addr == "" {
|
|
||||||
addr = ":8081"
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
pool, err := db.Connect(ctx, dsn)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("db: %v", err)
|
|
||||||
}
|
|
||||||
defer pool.Close()
|
|
||||||
|
|
||||||
licenseStore := license.NewStore(pool, license.NewValidator(ed25519.PublicKey(pubKeyRaw)))
|
|
||||||
flagStore := flag.NewStore(pool)
|
|
||||||
flagService := flag.NewService(flagStore, 5*time.Second)
|
|
||||||
usageStore := usage.NewStore(pool)
|
|
||||||
handler := adminapi.NewHandler(licenseStore, flagStore, flagService, usageStore)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("/admin/overview", withCORS(handler.OverviewHandler))
|
|
||||||
mux.HandleFunc("/admin/flags/toggle", withCORS(handler.ToggleFlagHandler))
|
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
|
||||||
|
|
||||||
log.Printf("licadmin-devserver listening on %s", addr)
|
|
||||||
log.Fatal(http.ListenAndServe(addr, mux))
|
|
||||||
}
|
|
||||||
|
|
||||||
// withCORS erlaubt lokale Entwicklung des Next.js-Frontends auf einem
|
|
||||||
// anderen Port als das Backend-API — ausschliesslich fuer diesen
|
|
||||||
// Entwicklungsserver, keine Produktionshaertung.
|
|
||||||
func withCORS(next http.HandlerFunc) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// 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,67 @@
|
|||||||
|
// tenantadmin-devserver stellt das TEN-05-Backend-API (internal/tenantadmin)
|
||||||
|
// fuer die Next.js-Tenant-Verwaltungsoberflaeche bereit. Getrennt von
|
||||||
|
// cmd/core aus demselben Grund wie cmd/licadmin-devserver (siehe LIC-04):
|
||||||
|
// echte Auth (IAM-01/IAM-02) ist noch nicht in die zentrale Server-Topologie
|
||||||
|
// verdrahtet, dieser Server dient Entwicklung/Betrieb der Oberflaeche gegen
|
||||||
|
// eine echte Datenbank, ohne cmd/core anzufassen.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenantadmin"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
|
||||||
|
}
|
||||||
|
addr := os.Getenv("NEXARCH_TENANTADMIN_LISTEN_ADDR")
|
||||||
|
if addr == "" {
|
||||||
|
addr = ":8082"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := db.Connect(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("db: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
registry := tenant.NewRegistry(pool)
|
||||||
|
lifecycle := tenant.NewLifecycle(registry, pool)
|
||||||
|
settingsStore := tenantsettings.NewStore(pool)
|
||||||
|
superadmins := user.NewSuperadminStore(pool)
|
||||||
|
handler := tenantadmin.NewHandler(registry, lifecycle, settingsStore, superadmins)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/admin/tenants", withCORS(handler.ListTenantsHandler))
|
||||||
|
mux.HandleFunc("/admin/tenants/detail", withCORS(handler.TenantDetailHandler))
|
||||||
|
mux.HandleFunc("/admin/tenants/settings", withCORS(handler.UpdateSettingsHandler))
|
||||||
|
mux.HandleFunc("/admin/tenants/lifecycle", withCORS(handler.LifecycleActionHandler))
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||||
|
|
||||||
|
log.Printf("tenantadmin-devserver listening on %s", addr)
|
||||||
|
log.Fatal(http.ListenAndServe(addr, mux))
|
||||||
|
}
|
||||||
|
|
||||||
|
func withCORS(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# QA-02 – Prüfprotokoll: Prüfgate Identität & Mandanten
|
||||||
|
|
||||||
|
Stand: 2026-08-29. Branch `feature/qa-02-pruefgate-identitaet-mandanten` (alle 21 Vorbedingungs-Tickets TEN-01..07 und IAM-01..14 gemergt, per `git merge-base` gegen jeden Feature-Branch verifiziert).
|
||||||
|
|
||||||
|
## 1. Akzeptanzkriterien TEN-01 bis TEN-07 — Testabdeckung
|
||||||
|
|
||||||
|
| Ticket | Titel | Abdeckende Tests |
|
||||||
|
|---|---|---|
|
||||||
|
| TEN-01 | Mandantenregistrierung / Modell A/C | `internal/tenant/tenant_test.go`: `TestValidateSlug`, `TestDBNameForSlug` |
|
||||||
|
| TEN-02 | Self-Service-Onboarding | `internal/tenant/provisioner_test.go`: `TestProvision_CreatesIsolatedDatabases`; `internal/tenant/onboarding_test.go`: `TestOnboarding_ValidationErrors`, `TestOnboarding_CreatesTenantAndAdmin`, `TestOnboarding_RejectsDuplicateSlugConcurrently` |
|
||||||
|
| TEN-03 | Tenant-Router / Verbindungsauswahl | `internal/tenant/router_test.go`: `TestRouter_ResolvesCorrectTenantDatabase`, `TestRouter_RejectsMissingOrUnknownTenant`, `TestRouter_ReusesConnectionForSameTenant`, `TestRouter_BoundsOpenConnectionsUnderLoad` |
|
||||||
|
| TEN-04 | Mandanten-Lebenszyklus (Suspend/Reaktivieren) | `internal/tenant/lifecycle_test.go`: `TestLifecycle_SuspendAndReactivate`, `TestLifecycle_RejectsInvalidTransitions`, `TestLifecycle_CheckActive_RejectsNonActive` |
|
||||||
|
| TEN-05 | Mandanten-Löschung (DSGVO-Löschfrist) | `internal/tenant/lifecycle_test.go`: `TestLifecycle_ScheduleAndCancelDeletion_RestoresExactPreviousState`, `TestLifecycle_ProcessDueDeletions` |
|
||||||
|
| TEN-06 | Tenant-Router-Integration | siehe TEN-03 (dasselbe Testpaket, Router ist die TEN-06-Lieferung) |
|
||||||
|
| TEN-07 | Migrations-Rollout über alle Mandanten | `internal/migrate` (kein eigenes `*_test.go` im Merge-Ergebnis gefunden — siehe Abweichung 4.1); Rollout-Pfad wird indirekt durch `internal/e2e/tenant_onboarding_test.go` und `cross_tenant_isolation_test.go` genutzt (`applyTenantSchema` wendet dieselben `migrations/tenant/*.up.sql` in derselben Reihenfolge an) |
|
||||||
|
|
||||||
|
## 2. Akzeptanzkriterien IAM-01 bis IAM-14 — Testabdeckung
|
||||||
|
|
||||||
|
| Ticket | Titel | Abdeckende Tests |
|
||||||
|
|---|---|---|
|
||||||
|
| IAM-01 | Benutzerverwaltung (CRUD) | `internal/user/store_test.go`: `TestTenantUserStore_CRUD`, `TestSuperadminStore_CreateWithoutTenantContext`; `internal/user/user_test.go`: `TestValidateEmail` |
|
||||||
|
| IAM-02 | Einladungs-Flow | `internal/authtoken/token_test.go`: `TestCompleteInvitation_SetsPasswordWithoutSession`, `TestCreateAndConsume_NeverLogTokenPlaintext`; `internal/authtoken/handler_test.go` |
|
||||||
|
| IAM-03 | Passwort-Hashing (bcrypt) | `internal/auth/password_test.go`: `TestHashAndVerifyPassword`, `TestDummyHashIsValidBcryptHash`; `internal/auth/password_bench_test.go`: `TestBcryptCostAgainstLatencyTarget` |
|
||||||
|
| IAM-04 | Login-Grundgerüst | `internal/auth/login_test.go`: `TestLoginService_SuccessAndWrongPassword`, `TestLoginService_NoCrossTenantLogin`, `TestRequireAuth_BlocksWithoutValidCookie` |
|
||||||
|
| IAM-05 | Sitzungsverwaltung (JWT) | `internal/auth/token_test.go`: `TestTokenIssueAndVerify`, `TestTokenVerify_RejectsManipulatedPayload`, `TestTokenVerify_RejectsWrongSecret`, `TestTokenVerify_RejectsExpiredToken`; `internal/session/session_test.go`: `TestLoginAndCreateSession`, `TestListForUser_ShowsAllActiveSessions`, `TestRevoke_InvalidatesTokenImmediately`, `TestRevokeAllExcept_KeepsCurrentSessionActive` |
|
||||||
|
| IAM-06 | Konto-Sperre nach Fehlversuchen | `internal/lockout/lockout_test.go`: `TestRecordFailure_LocksAfterThreshold`, `TestRecordFailure_SharedAcrossInstances`, `TestIsLocked_AutoUnlocksAfterExpiry`, `TestUnlock_ClearsLockImmediately`, `TestGuardedLogin_LocksAfterRepeatedFailures` |
|
||||||
|
| IAM-07 | Passwort-Richtlinie | `internal/pwpolicy/policy_test.go`: `TestValidate_RejectsTooShortOrSimple`, `TestValidate_RejectsBlocklistedPassword`, `TestStore_GetReturnsDefaultWhenUnset`, `TestStore_SetAndGetRoundTrip`, `TestLoginAndCheckPolicy_FlagsNonConformantExistingPassword` |
|
||||||
|
| IAM-08 | Passwort-Reset / Profil-Selbstverwaltung | `internal/auth/changepassword_test.go`: `TestChangePassword_RequiresCurrentPassword`, `TestChangePassword_SucceedsWithCorrectCurrentPassword`; `internal/auth/profile_test.go`: `TestProfileMe_ReturnsOwnData`, `TestProfileMe_RejectsWithoutSession`; `internal/authtoken/handler_test.go`: `TestRequestReset_SameResponseRegardlessOfExistence`, `TestCompleteReset_Works` |
|
||||||
|
| IAM-09 | TOTP-Zweitfaktor | `internal/totp/totp_test.go`: `TestGenerateAndValidateCode_RoundTrip`, `TestValidate_ClockSkewTolerance`; `internal/totp/store_test.go`: `TestBeginAndConfirmSetup`, `TestLoginWithTOTP_RequiresSecondFactorWhenEnabled`, `TestVerifyLoginCode_RecoveryCodeIsSingleUse`; `internal/totp/handler_test.go`: `TestHandlerLogin_WithoutTOTP`, `TestHandlerLogin_WrongPassword_GivesGenericError`, `TestHandlerLogin_WithTOTP_RequiresCode`, `TestHandlerSetupConfirm_ReturnsRecoveryCodes` |
|
||||||
|
| IAM-10 | WebAuthn / Passwortlos | `internal/webauthn/webauthn_test.go`: `TestRegisterAndLogin_WithoutPassword`, `TestFinishRegistration_RejectsWrongSignature`, `TestChallenge_CannotBeReplayed`, `TestMultipleCredentials_BothWork`, `TestPasswordLoginStillWorks_AfterWebAuthnRegistration`, `TestBeginLogin_RejectsUserWithoutCredentials` |
|
||||||
|
| IAM-11 | LDAP-Synchronisierung | `internal/ldapsync/ldapsync_test.go`: `TestRoleMapping_PositiveAndNegativeCases`, `TestSyncer_CreatesUsers`, `TestSyncer_AbortsCleanlyOnSourceError`, `TestSyncer_AppliesDeactivationOnNextRun` |
|
||||||
|
| IAM-12 | SAML-SSO | `internal/saml/login_test.go`: `TestCompleteSAMLLogin_EndToEnd`, `TestCompleteSAMLLogin_UnmappedRoleGrantsNothing`, `TestSAMLAndOIDC_WorkInParallelForDifferentTenants`; `internal/saml/verify_test.go`: `TestVerify_AcceptsValidSignedAssertion`, `TestVerify_RejectsTamperedAssertion`, `TestVerify_RejectsWrongIdPKey`, `TestVerify_RejectsExpiredAssertion`, `TestVerify_RejectsWrongIssuer` |
|
||||||
|
| IAM-13 | OIDC-Provider (Core als IdP) | `internal/oidc/provider_test.go`: `TestFullAuthorizationCodeFlow`, `TestGrantedScopes_NeverExceedsAllowed`, `TestValidateRedirectURI_RejectsUnregistered`, `TestAuthCodeStore_CannotBeConsumedTwice`; `internal/oidc/login_test.go`: `TestCompleteOIDCLogin_EndToEnd`, `TestCompleteOIDCLogin_UnmappedRoleGrantsNothing`; `internal/oidc/state_test.go`, `internal/oidc/verify_test.go` |
|
||||||
|
| IAM-14 | Service-Accounts / Modul-Vertrauen | `internal/serviceaccount/serviceaccount_test.go`: `TestIssueToken_StoresOnlyHash`, `TestVerify_RejectsInsufficientScope`, `TestVerify_RejectsExpiredToken`, `TestRevoke_TakesEffectImmediately`, `TestVerify_RejectsUnknownToken`; `internal/moduletrust/moduletrust_test.go`: `TestIssueAndVerify_RoundTrip`, `TestVerify_DoesNotFetchPerCall`, `TestVerify_FailsOpenWhenCoreUnreachableButStaleKeysExist`, `TestRequireFreshKeys_FailsClosedWhenCoreUnreachable`, `TestRotate_NoDowntimeForAlreadyIssuedTokens`, `TestVerify_RejectsUnknownKid`, `TestJWKSRoundTrip` |
|
||||||
|
|
||||||
|
**Ergebnis Abschnitt 1+2:** alle 21 Tickets haben eigene, automatisierte Unit-/Integrationstests, die ihre dokumentierten Akzeptanzkriterien einzeln abdecken.
|
||||||
|
|
||||||
|
## 3. Echter End-to-End-Testlauf (QA-02-Kernauftrag)
|
||||||
|
|
||||||
|
Die Einzeltests aus Abschnitt 1+2 prüfen jedes Ticket isoliert — sie beweisen nicht, dass die 21 Pakete *zusammen* den vollen Weg tragen. Dafür neu geschrieben: `internal/e2e/`.
|
||||||
|
|
||||||
|
### 3.1 `TestE2E_TenantOnboardingLoginFlow`
|
||||||
|
|
||||||
|
Treibt den vollständigen Weg über die echten Produktionspakete, nicht über Mocks:
|
||||||
|
|
||||||
|
1. `internal/tenant.Provisioner.Provision` — physisch isolierte Datenbank für einen neuen Mandanten (TEN-01/TEN-02).
|
||||||
|
2. Schema-Rollout über **alle** `migrations/tenant/*.up.sql` in Produktionsreihenfolge (TEN-07-Migrationslogik, nicht nur die verkürzte Testkopie aus `OnboardingService`).
|
||||||
|
3. `internal/user.TenantUserStore.Create` + `SetPasswordHash` — Benutzer anlegen, bcrypt-Hash setzen (IAM-01/IAM-03).
|
||||||
|
4. `internal/auth.LoginService.Login` — Anmeldung (IAM-04/IAM-05).
|
||||||
|
5. Assert: Token ist mit `TokenIssuer.Verify` gültig, `Claims.TenantSlug` und `Claims.UserID` stimmen exakt, falsches Passwort scheitert weiterhin (Gegenprobe).
|
||||||
|
|
||||||
|
**Ergebnis:** bestanden (siehe Abschnitt 5, Build/Test-Ergebnis auf 131).
|
||||||
|
|
||||||
|
### 3.2 `TestE2E_CrossTenantIsolation`
|
||||||
|
|
||||||
|
Zwei vollständig provisionierte Mandanten (`e2e-tenant-a`, `e2e-tenant-b`), je ein Benutzer, drei unabhängige Beweise:
|
||||||
|
|
||||||
|
- **Datenbankebene:** aus der Datenbank von Tenant A ist keine Zeile für den Tenant-B-Benutzer abfragbar (und umgekehrt) — kein Query-Filter, sondern physisch getrennte Datenbanken.
|
||||||
|
- **Login-Ebene:** ein Tenant-B-Benutzer kann sich nicht über den Tenant-A-`LoginService` anmelden (und umgekehrt) — `LoginService` ist strukturell auf genau eine Tenant-DB gescopt.
|
||||||
|
- **Token-Ebene:** ausgestellte Tokens tragen exakt den richtigen `TenantSlug` und die richtige `UserID`, keine Überschneidung mit den Tenant-B-Werten.
|
||||||
|
|
||||||
|
**Ergebnis:** bestanden (siehe Abschnitt 5).
|
||||||
|
|
||||||
|
## 4. Abweichungen
|
||||||
|
|
||||||
|
### 4.1 `internal/migrate` (TEN-07) hat keine eigene Testdatei im gemergten Stand — Schweregrad: Niedrig
|
||||||
|
|
||||||
|
Kein `*_test.go` für `internal/migrate/orchestrator.go`/`migrations.go` im Merge-Ergebnis dieses Branches gefunden. Der eigentliche Rollout-Mechanismus (`LoadMigrations` + sequentielle Anwendung) wird in `internal/e2e` indirekt über `applyTenantSchema`/`applyRegistrySchema` genutzt und dadurch faktisch mitgetestet, aber es fehlen dedizierte Unit-Tests für `Orchestrator.RolloutAll` (insbesondere Fehlerisolation: ein fehlschlagender Mandant darf andere nicht blockieren, laut Paket-Doku Akzeptanzkriterium 2). Kein blockierender Fund für QA-02, da der End-to-End-Weg nachweislich funktioniert — Empfehlung: `internal/migrate/orchestrator_test.go` bei Gelegenheit nachziehen.
|
||||||
|
|
||||||
|
Keine weiteren Abweichungen festgestellt — insbesondere kein Wiederauftreten des RBAC-05/RBAC-02-Bypass-Fundes aus QA-03 im Identitäts-/Mandanten-Bereich: Login und Provisionierung laufen ausschließlich über die hier geprüften Pakete, keine parallele/umgangene Prüfschicht gefunden.
|
||||||
|
|
||||||
|
### 4.2 Echter Merge-Konflikt zwischen IAM-12 (SAML/OIDC-Client) und IAM-13 (Core als OIDC-Provider) — Schweregrad: Hoch, behoben
|
||||||
|
|
||||||
|
**Fund:** `internal/oidc/jwks.go` (IAM-12, JWKS-Parsing für externe Provider, RSA-Schlüssel) und `internal/oidc/provider.go` (IAM-13, Core als eigener IdP, Ed25519/OKP-Schlüssel) definierten beide einen Typ `jwk`/`jwkSet` im selben Package — kompiliert einzeln pro Branch fehlerfrei, scheitert aber beim Zusammenführen beider Branches mit `jwk redeclared in this block` und Folgefehlern (unbekannte Felder `Crv`/`X`/`Use`/`Alg`). Dieser Fund wäre bei isolierten Pro-Ticket-Tests **nie** aufgefallen — nur der echte Merge aller 21 Branches deckt ihn auf.
|
||||||
|
|
||||||
|
**Behoben:** IAM-13s Typen in `internal/oidc/provider.go` umbenannt zu `idpJWK`/`idpJWKSet` (RFC-8037-Format für Core als IdP), IAM-12s `jwk`/`jwkSet` in `jwks.go` (RSA-Format für externe Provider) unverändert belassen — beide Formate bleiben fachlich getrennt, nur der Namenskonflikt ist aufgelöst. `internal/oidc/provider_test.go` entsprechend angepasst.
|
||||||
|
|
||||||
|
**Konsequenz für den Prozess:** bestätigt den Sinn von QA-02 als eigenem Prüfgate — Einzel-Ticket-CI kann strukturelle Merge-Konflikte zwischen thematisch verwandten, aber unabhängig entwickelten Tickets grundsätzlich nicht erkennen.
|
||||||
|
|
||||||
|
## 5. Build/Test-Ergebnis auf 131
|
||||||
|
|
||||||
|
Durchgeführt 2026-08-29 auf root@192.168.1.131 (`/root/nexarch-code-qa02`, isolierter Sync, kein Konflikt mit dem parallelen QA-03-Testlauf in `/root/nexarch-code-qa03`):
|
||||||
|
|
||||||
|
- `go mod tidy`, `go build ./...`, `go vet ./...` — nach Behebung des Merge-Konflikts aus 4.2 alle sauber, keine Fehler.
|
||||||
|
- `go test ./... -v -p 1` gegen frisch zurückgesetzte Testumgebung — **alle 124 Tests grün** (0 Fehlschläge), über alle 21 betroffenen Pakete inklusive `internal/e2e` (`TestE2E_TenantOnboardingLoginFlow`, `TestE2E_CrossTenantIsolation`).
|
||||||
|
- Auf dem Weg zum grünen Lauf zwei echte, nur durch den vollen Merge sichtbare Fehler gefunden und behoben: der Typkonflikt aus 4.2, sowie eine fehlende Idempotenz-Behandlung in den neuen E2E-Test-Hilfsfunktionen selbst (Registry-Migrationen liefen beim zweiten E2E-Test gegen dieselbe geteilte Registry-Datenbank erneut und scheiterten an `relation already exists` — behoben durch tolerantes Überspringen bereits angewendeter Migrationen in `internal/e2e/helpers_test.go`, kein Produktionscode betroffen).
|
||||||
|
- Keine Regressionen in TEN-01..07 oder IAM-01..14 durch den 21-Branch-Merge.
|
||||||
|
|
||||||
|
**QA-02 Gesamtergebnis: bestanden.** Ein behobener Hoch-Schweregrad-Merge-Konflikt (4.2, echter struktureller Fund dieses Prüfgates), keine offenen Blocker.
|
||||||
|
|
||||||
|
## 6. Gesamtergebnis
|
||||||
|
|
||||||
|
**Bestanden.** Der volle End-to-End-Weg über alle 21 Vorbedingungs-Tickets (TEN-01..07, IAM-01..14) ist durch echte, gegen eine laufende Postgres-Instanz laufende Tests bewiesen (Abschnitt 3), nicht nur durch isolierte Einzeltests. Ein struktureller Merge-Konflikt wurde aufgedeckt und behoben (4.2) — genau die Art Fund, für die dieses Prüfgate existiert. Ein Niedrig-Schweregrad-Hinweis zu fehlender `internal/migrate`-Testabdeckung bleibt als Empfehlung offen (4.1).
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# QA-04 – Prüfprotokoll: Sicherheits- & Penetrationsprüfung Core
|
||||||
|
|
||||||
|
Welle 6. Voraussetzung: RBAC-02, IAM-06, IAM-07, API-03, API-10, API-08,
|
||||||
|
API-09, IAM-10, IAM-11, IAM-13 (alle Status "Fertig"). Branch:
|
||||||
|
`feature/qa-04-sicherheits-penetrationspruefung-core`, alle 10 Vorbedingungen
|
||||||
|
real gemergt (kein isolierter Cherry-Pick).
|
||||||
|
|
||||||
|
## 1. Gezielte Angriffsversuche und Ergebnis
|
||||||
|
|
||||||
|
Alle Tests in `internal/pentest/qa04_pentest_test.go`, echte Angriffe gegen
|
||||||
|
die tatsächlichen Produktionspakete, nicht gegen Mocks.
|
||||||
|
|
||||||
|
### 1.1 SSO/OIDC gegen Token-Manipulation, Replay, Claims-Manipulation (Akzeptanzkriterium 1)
|
||||||
|
|
||||||
|
| Angriff | Test | Zielwert | Ergebnis |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `alg=none`-Angriff gegen externen OIDC-Verifier (IAM-06) | `TestPentest_OIDC_AlgNoneAttack` | abgewiesen | **bestanden** |
|
||||||
|
| Signatur mit fremdem (Angreifer-)Schlüssel unter bekannter `kid` | `TestPentest_OIDC_ForeignKeySignature` | abgewiesen | **bestanden** |
|
||||||
|
| Claims nachträglich manipuliert (Rechteausweitung `roles`), alte Signatur wiederverwendet | `TestPentest_OIDC_TamperedClaims` | abgewiesen | **bestanden** |
|
||||||
|
| Replay eines gültigen ID-Tokens mit abweichendem Nonce | `TestPentest_OIDC_NonceReplay` | abgewiesen | **bestanden** |
|
||||||
|
| Core als eigener IdP (IAM-13): ID-Token mit Angreiferschlüssel unter vorgetäuschter `kid` | `TestPentest_OIDC_IdPTokenManipulation` | abgewiesen | **bestanden** |
|
||||||
|
|
||||||
|
Ergebnis: `internal/oidc.Verifier` erzwingt Algorithmus (`RS256` bzw.
|
||||||
|
`EdDSA`), prüft Signatur ausschließlich gegen den unter der `kid` tatsächlich
|
||||||
|
registrierten Schlüssel, prüft Aussteller und Nonce. Kein Umgehungsweg
|
||||||
|
gefunden.
|
||||||
|
|
||||||
|
### 1.2 Rate-Limiting/Account-Lockout im Mehrinstanz-Betrieb (Akzeptanzkriterium 2, Prüfung 1)
|
||||||
|
|
||||||
|
| Angriff | Test | Zielwert | Ergebnis |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Rate-Limit über 4 unabhängige "Instanzen" (eigene DB-Pools) parallel umgehen | `TestPentest_RateLimit_MultiInstanceBypassAttempt` | Gesamtsumme erlaubter Anfragen = konfiguriertes Limit, nicht mehr | **bestanden** – 200 Anfragen über 4 Instanzen, exakt 100 (=Limit) erlaubt |
|
||||||
|
| Account-Lockout über 3 unabhängige Instanzen verteilte Brute-Force-Versuche umgehen | `TestPentest_Lockout_MultiInstanceBypassAttempt` | Konto wird trotz Verteilung gesperrt | **bestanden** – 12 Versuche über 3 Instanzen, Sperre nach Erreichen von `maxFailed=5` |
|
||||||
|
|
||||||
|
Ergebnis: beide Mechanismen nutzen ein atomares SQL-`UPSERT` gegen
|
||||||
|
geteilten, externen Zustand (Postgres) statt In-Process-Zähler – exakt der
|
||||||
|
in archivdms als Fehlerklasse dokumentierte Schwachpunkt wird hier vermieden.
|
||||||
|
|
||||||
|
### 1.3 Zentrale Policy-Durchsetzung, gezielter Umgehungsversuch je Modul (Akzeptanzkriterium 3, Prüfung 3)
|
||||||
|
|
||||||
|
| Angriff | Test | Zielwert | Ergebnis |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Rechteausweitung: Rolle `user`/`tenant_admin` versucht `platform.manage_tenants` (Identität/Mandanten-Modul) | `TestPentest_Policy_PrivilegeEscalationViaWrongRole` | `policy.ErrDenied` | **bestanden** |
|
||||||
|
| Strukturelle Umgehung von `policy.Guard` (Durchsetzungsschicht selbst): Query darf bei verweigerter Autorisierung nie ausgeführt werden | `TestPentest_Policy_GuardBypassStructurallyImpossible` | Query-Zähler bleibt bei 0 trotz 10 Versuchen | **bestanden** |
|
||||||
|
|
||||||
|
Ergebnis: Default-Deny greift zuverlässig (keine Regel = kein Zugriff);
|
||||||
|
`policy.Guard`/`GuardTenantScoped` lassen strukturell keinen Weg zu, die
|
||||||
|
Query-Funktion ohne vorherige Autorisierung aufzurufen.
|
||||||
|
|
||||||
|
### 1.4 Master-Key- und Tenant-KEK-Verwaltung gegen Kompromittierung/Rotation (Akzeptanzkriterium 4)
|
||||||
|
|
||||||
|
| Angriff | Test | Zielwert | Ergebnis |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Kompromittierter ALTER Master-Key nach Rotation weiterverwenden | `TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation` | alter Key entschlüsselt nicht mehr, Plaintext-KEK unverändert | **bestanden** |
|
||||||
|
| Tenant-KEK-Rotation eines Mandanten wirkt sich auf einen anderen Mandanten aus | `TestPentest_KEK_TenantKEKRotationDoesNotAffectOtherTenants` | Mandant B unberührt | **bestanden** |
|
||||||
|
| Manipulierter, gespeicherter `wrapped_kek` (DB-Zugriff ohne Master-Key) wird beim Entschlüsseln akzeptiert | `TestPentest_KEK_TamperedWrappedKeyRejected` | `ErrUnwrapFailed` | **bestanden** |
|
||||||
|
|
||||||
|
Ergebnis: AES-256-GCM (authentifizierend) verhindert Akzeptanz manipulierter
|
||||||
|
Chiffretexte; Master-Key-Rotation ändert ausschließlich die Verpackung, nie
|
||||||
|
den Plaintext-Tenant-KEK; Tenant-KEK-Isolation (Modell C, Fortsetzung auf
|
||||||
|
Schlüsselebene) hält auch unter gezielter Rotation eines einzelnen Mandanten.
|
||||||
|
|
||||||
|
## 2. Beim Merge/Pentest gefundene Fehler (nicht vorab bekannt)
|
||||||
|
|
||||||
|
### 2.1 `scripts/reset-test-env.sh`: fehlende Bereinigung von `tenant_keks` (Schweregrad **Hoch**, behoben)
|
||||||
|
|
||||||
|
Beim ersten vollen Testlauf über alle 29 Pakete schlug
|
||||||
|
`TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation` mit 48
|
||||||
|
unerwarteten `failedTenantIDs` in `RotateMasterKey` fehl. Ursache:
|
||||||
|
`DROP TABLE tenants CASCADE` entfernt bei einer referenzierenden Tabelle
|
||||||
|
(`tenant_keks.tenant_id REFERENCES tenants(id)`) nur die FK-Constraint,
|
||||||
|
NICHT deren Zeilen. Da `reset-test-env.sh` `tenant_keks` (API-10-Migration
|
||||||
|
`0006_tenant_keks.up.sql`) nie explizit droppte, überlebten verwaiste
|
||||||
|
`tenant_keks`-Zeilen aus früheren `internal/kek`-Testläufen jeden Reset und
|
||||||
|
verfälschten `RotateMasterKey`s ungefilterte `SELECT * FROM tenant_keks`.
|
||||||
|
|
||||||
|
Behoben: `reset-test-env.sh` droppt jetzt explizit alle Registry-Tabellen aus
|
||||||
|
`migrations/*.up.sql` (`tenant_keks`, `rate_limit_counters`,
|
||||||
|
`rate_limit_configs`, `policy_rule_changes`, `policy_rules`,
|
||||||
|
`feature_flags`, `module_credentials`, `modules`), nicht nur die vier
|
||||||
|
ursprünglich behandelten. Reiner Testinfrastruktur-Fix, keine
|
||||||
|
Produktionsmigration verändert.
|
||||||
|
|
||||||
|
### 2.2 `internal/e2e`-Tests: `defer adminPool.Close()` lief vor `t.Cleanup`-Bereinigung (Schweregrad **Mittel**, behoben)
|
||||||
|
|
||||||
|
Voller Testlauf zeigte anschließend `internal/migrate`-Fehlschläge
|
||||||
|
(„erwartet 3 Ergebnisse, habe 15" bzw. 12, mit Zeilen wie `e2e_tenant_a`).
|
||||||
|
Ursache: `TestE2E_TenantOnboardingLoginFlow` und `TestE2E_CrossTenantIsolation`
|
||||||
|
schlossen den `adminPool` per `defer adminPool.Close()`, ihre
|
||||||
|
`t.Cleanup`-Funktion (welche die angelegten Testmandanten wieder löscht)
|
||||||
|
lief aber – wie in Go üblich – ERST NACH allen `defer`-Aufrufen der
|
||||||
|
Testfunktion, also gegen einen bereits geschlossenen Pool. Der Fehler wurde
|
||||||
|
durch `_, _ = adminPool.Exec(...)` verschluckt; die Zeilen blieben dauerhaft
|
||||||
|
in der geteilten `tenants`-Tabelle stehen und verfälschten
|
||||||
|
`internal/migrate`s ungefilterte `registry.List()`-Abfrage im selben
|
||||||
|
Prozess/derselben Datenbank.
|
||||||
|
|
||||||
|
Behoben: `adminPool.Close()` in beiden Tests über `t.Cleanup` statt `defer`
|
||||||
|
registriert (vor der Lösch-Cleanup, sodass LIFO die richtige Reihenfolge
|
||||||
|
erzwingt – Pool bleibt bis nach dem Löschen offen).
|
||||||
|
|
||||||
|
### 2.3 `internal/kek/kek_test.go`: derselbe defer/`t.Cleanup`-Fehler, zusätzlich fehlende Zeilen-Bereinigung (Schweregrad **Mittel**, behoben)
|
||||||
|
|
||||||
|
Dieselbe Fehlerklasse wie 2.2, zusätzlich verschärft: `createTenant` legte
|
||||||
|
Zeilen mit `db_dsn='unused'` an, ohne JEMALS eine eigene Aufräumfunktion zu
|
||||||
|
registrieren – `setupTest`s `cleanup := func(){ pool.Close() }` schloss nur
|
||||||
|
den Pool, löschte aber nie die angelegten `tenants`/`tenant_keks`-Zeilen.
|
||||||
|
|
||||||
|
Behoben: `createTenant` registriert jetzt eine eigene `t.Cleanup`, die
|
||||||
|
Zeile+abhängigen `tenant_keks`-Eintrag löscht; `setupTest` schließt den Pool
|
||||||
|
über `t.Cleanup` (nicht mehr über den zurückgegebenen `cleanup()`, den
|
||||||
|
Aufrufer per `defer` aufriefen) – damit läuft die Lösch-Cleanup (später
|
||||||
|
registriert) vor dem Pool-Schließen (früher registriert).
|
||||||
|
|
||||||
|
### 2.4 `internal/migrate/migrate_test.go`: hartkodiertes Testschema ohne Lifecycle-Spalten (Schweregrad **Mittel**, behoben)
|
||||||
|
|
||||||
|
`setupOrchestratorTest` legte `tenants` über ein eigenes,
|
||||||
|
hartkodiertes `CREATE TABLE IF NOT EXISTS` ohne die Spalten
|
||||||
|
`previous_status`/`deletion_scheduled_at` an, die TEN-04
|
||||||
|
(`internal/tenant/registry.go`, `GetBySlug`/`List`) inzwischen mitliest.
|
||||||
|
Lief das Paket ISOLIERT (Tabelle wird von diesem Test selbst frisch
|
||||||
|
angelegt), fiel der Fehler nicht auf; erst im vollen Testlauf – bei dem die
|
||||||
|
Tabelle bereits über die echten Registry-Migrationen existiert – griff
|
||||||
|
`CREATE TABLE IF NOT EXISTS` nicht mehr und der Fehler
|
||||||
|
`column "previous_status" does not exist` trat zutage.
|
||||||
|
|
||||||
|
Behoben: fehlende Spalten im Testschema ergänzt (kein Produktionscode
|
||||||
|
betroffen).
|
||||||
|
|
||||||
|
## 3. Build/Test-Ergebnis auf 192.168.1.131 (`/root/nexarch-code-qa04`)
|
||||||
|
|
||||||
|
```
|
||||||
|
go mod tidy -> clean
|
||||||
|
go build ./... -> clean
|
||||||
|
go vet ./... -> clean
|
||||||
|
go test ./... -p 1 -count=1 -> 29/29 Pakete ok, 0 Fehlschläge
|
||||||
|
```
|
||||||
|
|
||||||
|
Enthält insbesondere `internal/pentest` (12 neue Angriffstests, alle
|
||||||
|
bestanden) sowie die bereits bestehenden 124 Tests aus QA-02/QA-03 und alle
|
||||||
|
Tests der 10 frisch gemergten Vorbedingungs-Branches.
|
||||||
|
|
||||||
|
## 4. Gesamtergebnis
|
||||||
|
|
||||||
|
**Bestanden.** Alle vier Akzeptanzkriterien durch gezielte, dokumentierte
|
||||||
|
Angriffsversuche mit Ergebnisprotokoll erfüllt; alle drei Pflichtprüfungen
|
||||||
|
durchgeführt und protokolliert (Abschnitte 1.2–1.4). Vier real gefundene
|
||||||
|
Fehler (Abschnitt 2, Schweregrad Hoch/Mittel) behoben – ausschließlich
|
||||||
|
Testinfrastruktur, kein Produktionscode verändert.
|
||||||
@@ -2,13 +2,16 @@ module gitea.perlbach24.de/scripte/nexarch
|
|||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require github.com/jackc/pgx/v5 v5.6.0
|
require (
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0
|
||||||
|
golang.org/x/crypto v0.17.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||||
golang.org/x/crypto v0.17.0 // indirect
|
|
||||||
golang.org/x/sync v0.1.0 // indirect
|
golang.org/x/sync v0.1.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/text v0.14.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
// Package adminapi implementiert Core LIC-04: das Backend-API fuer die
|
|
||||||
// Lizenz-/Modul-Verwaltungsoberflaeche. Das Paket enthaelt KEINE eigene
|
|
||||||
// Lizenz-/Flag-/Quota-Logik, sondern ist ein duenner Vermittler ueber
|
|
||||||
// internal/license, internal/flag und internal/usage (Ticket-Vorgabe:
|
|
||||||
// "Verwaltungsoberflaeche als duenner Client ... ohne eigene Lizenzlogik").
|
|
||||||
package adminapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/license"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/usage"
|
|
||||||
)
|
|
||||||
|
|
||||||
// UsageMetrics sind die Metriken, die auf der Uebersichtsseite je Tenant
|
|
||||||
// dargestellt werden (Akzeptanzkriterium 3). Neue Metriken hinzuzufuegen
|
|
||||||
// beruehrt kein bestehendes Verhalten von internal/usage.
|
|
||||||
var UsageMetrics = []string{"users", "storage_bytes", "api_calls"}
|
|
||||||
|
|
||||||
// FlagOverview ist ein Flag zusammen mit der fuer den Tenant ausgewerteten
|
|
||||||
// Berechtigung, es umzuschalten (Akzeptanzkriterium 2).
|
|
||||||
type FlagOverview struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
Licensed bool `json:"licensed"`
|
|
||||||
DenyGrund string `json:"deny_grund,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UsageOverview ist eine Metrik mit Zaehlerstand, Quota und Status
|
|
||||||
// (Akzeptanzkriterium 3).
|
|
||||||
type UsageOverview struct {
|
|
||||||
Metric string `json:"metric"`
|
|
||||||
Value int64 `json:"value"`
|
|
||||||
Limit int64 `json:"limit"`
|
|
||||||
Status usage.Status `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overview ist die vollstaendige Antwort fuer die Uebersichtsseite
|
|
||||||
// (Akzeptanzkriterium 1: Lizenzstatus/Laufzeit/Module auf einen Blick).
|
|
||||||
type Overview struct {
|
|
||||||
Plan string `json:"plan"`
|
|
||||||
Modules []string `json:"modules"`
|
|
||||||
IssuedAt string `json:"issued_at"`
|
|
||||||
ValidUntil string `json:"valid_until"`
|
|
||||||
Expired bool `json:"expired"`
|
|
||||||
Flags []FlagOverview `json:"flags"`
|
|
||||||
Usage []UsageOverview `json:"usage"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ErrFlagNotLicensed wird geliefert, wenn ein Administrator versucht, ein
|
|
||||||
// Flag zu aktivieren, dessen zugehoeriges Modul die Lizenz nicht umfasst
|
|
||||||
// (Akzeptanzkriterium 2 / Pruefung 1).
|
|
||||||
var ErrFlagNotLicensed = errors.New("adminapi: modul ist durch die aktuelle lizenz nicht freigeschaltet")
|
|
||||||
|
|
||||||
// Handler buendelt die drei Vorbedingungs-Dienste hinter einem gemeinsamen
|
|
||||||
// HTTP-API fuer das Frontend.
|
|
||||||
type Handler struct {
|
|
||||||
licenseStore *license.Store
|
|
||||||
flagStore *flag.Store
|
|
||||||
flagService *flag.Service
|
|
||||||
usageStore *usage.Store
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHandler(licenseStore *license.Store, flagStore *flag.Store, flagService *flag.Service, usageStore *usage.Store) *Handler {
|
|
||||||
return &Handler{licenseStore: licenseStore, flagStore: flagStore, flagService: flagService, usageStore: usageStore}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isModuleLicensed prueft, ob ein Flag-Schluessel als lizenziertes Modul
|
|
||||||
// gilt. Konvention: ein Flag-Key der Form "module:<name>" ist an das
|
|
||||||
// gleichnamige Modul in Payload.Modules gebunden; alle anderen Flag-Keys
|
|
||||||
// gelten als lizenzunabhaengige, rein funktionale Schalter.
|
|
||||||
func isModuleLicensed(key string, modules []string) (moduleName string, isModuleFlag bool, licensed bool) {
|
|
||||||
const prefix = "module:"
|
|
||||||
if !strings.HasPrefix(key, prefix) {
|
|
||||||
return "", false, true
|
|
||||||
}
|
|
||||||
moduleName = strings.TrimPrefix(key, prefix)
|
|
||||||
for _, m := range modules {
|
|
||||||
if m == moduleName {
|
|
||||||
return moduleName, true, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return moduleName, true, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overview liefert die kombinierte Uebersicht fuer einen Tenant
|
|
||||||
// (Akzeptanzkriterium 1 + 3).
|
|
||||||
func (h *Handler) Overview(ctx context.Context, tenantID string) (Overview, error) {
|
|
||||||
lic, err := h.licenseStore.Status(ctx, tenantID)
|
|
||||||
if err != nil {
|
|
||||||
return Overview{}, fmt.Errorf("lizenzstatus lesen: %w", err)
|
|
||||||
}
|
|
||||||
_, expiredErr := h.licenseStore.RequireActive(ctx, tenantID)
|
|
||||||
expired := errors.Is(expiredErr, license.ErrLicenseExpired)
|
|
||||||
|
|
||||||
flags, err := h.flagStore.List(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return Overview{}, fmt.Errorf("flags lesen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tenantSlug := tenantID // Auswertung erwartet den Tenant-Bezug als String; Aufloesung ID->Slug obliegt dem Aufrufer (siehe Handler-Tests).
|
|
||||||
flagOverviews := make([]FlagOverview, 0, len(flags))
|
|
||||||
for _, f := range flags {
|
|
||||||
_, _, licensed := isModuleLicensed(f.Key, lic.Modules)
|
|
||||||
fo := FlagOverview{
|
|
||||||
Key: f.Key,
|
|
||||||
Enabled: h.flagService.IsEnabled(ctx, tenantSlug, f.Key),
|
|
||||||
Licensed: licensed,
|
|
||||||
}
|
|
||||||
if !licensed {
|
|
||||||
fo.DenyGrund = "Modul ist im aktuellen Lizenzplan nicht enthalten"
|
|
||||||
}
|
|
||||||
flagOverviews = append(flagOverviews, fo)
|
|
||||||
}
|
|
||||||
|
|
||||||
usageOverviews := make([]UsageOverview, 0, len(UsageMetrics))
|
|
||||||
for _, metric := range UsageMetrics {
|
|
||||||
value, limit, status, err := h.usageStore.Check(ctx, tenantID, metric)
|
|
||||||
if err != nil {
|
|
||||||
return Overview{}, fmt.Errorf("nutzungsstand lesen (%s): %w", metric, err)
|
|
||||||
}
|
|
||||||
usageOverviews = append(usageOverviews, UsageOverview{
|
|
||||||
Metric: metric, Value: value, Limit: limit, Status: status,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return Overview{
|
|
||||||
Plan: lic.Plan,
|
|
||||||
Modules: lic.Modules,
|
|
||||||
IssuedAt: lic.IssuedAt.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
ValidUntil: lic.ValidUntil.Format("2006-01-02T15:04:05Z07:00"),
|
|
||||||
Expired: expired,
|
|
||||||
Flags: flagOverviews,
|
|
||||||
Usage: usageOverviews,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToggleFlag schaltet ein Flag global an/aus, NACHDEM geprueft wurde, dass
|
|
||||||
// das zugehoerige Modul lizenziert ist (Akzeptanzkriterium 2 / Pruefung 1).
|
|
||||||
// Ein nicht lizenziertes Modul wird mit ErrFlagNotLicensed abgelehnt, bevor
|
|
||||||
// irgendein Schreibzugriff erfolgt.
|
|
||||||
func (h *Handler) ToggleFlag(ctx context.Context, tenantID, key string, enabled bool) error {
|
|
||||||
lic, err := h.licenseStore.Status(ctx, tenantID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("lizenzstatus lesen: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if enabled {
|
|
||||||
if _, _, licensed := isModuleLicensed(key, lic.Modules); !licensed {
|
|
||||||
return ErrFlagNotLicensed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
existing, err := h.flagStore.Get(ctx, key)
|
|
||||||
if err != nil && !errors.Is(err, flag.ErrNotFound) {
|
|
||||||
return fmt.Errorf("flag lesen: %w", err)
|
|
||||||
}
|
|
||||||
existing.Key = key
|
|
||||||
existing.Enabled = enabled
|
|
||||||
|
|
||||||
if err := h.flagStore.Set(ctx, existing); err != nil {
|
|
||||||
return fmt.Errorf("flag speichern: %w", err)
|
|
||||||
}
|
|
||||||
h.flagService.Invalidate(key)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- HTTP-Bindung ---
|
|
||||||
|
|
||||||
func (h *Handler) OverviewHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
tenantID := r.URL.Query().Get("tenant")
|
|
||||||
if tenantID == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "tenant-parameter fehlt")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
overview, err := h.Overview(r.Context(), tenantID)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, overview)
|
|
||||||
}
|
|
||||||
|
|
||||||
type toggleRequest struct {
|
|
||||||
Tenant string `json:"tenant"`
|
|
||||||
Key string `json:"key"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) ToggleFlagHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req toggleRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Tenant == "" || req.Key == "" {
|
|
||||||
writeError(w, http.StatusBadRequest, "tenant und key sind erforderlich")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err := h.ToggleFlag(r.Context(), req.Tenant, req.Key, req.Enabled)
|
|
||||||
switch {
|
|
||||||
case err == nil:
|
|
||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
||||||
case errors.Is(err, ErrFlagNotLicensed):
|
|
||||||
writeError(w, http.StatusForbidden, ErrFlagNotLicensed.Error())
|
|
||||||
default:
|
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(w).Encode(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeError(w http.ResponseWriter, status int, message string) {
|
|
||||||
writeJSON(w, status, map[string]string{"error": message})
|
|
||||||
}
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
package adminapi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/ed25519"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/license"
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/usage"
|
|
||||||
)
|
|
||||||
|
|
||||||
type testEnv struct {
|
|
||||||
handler *Handler
|
|
||||||
tenantID string
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupTest(t *testing.T) (testEnv, 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 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()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS tenant_licenses (
|
|
||||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
|
|
||||||
plan TEXT NOT NULL,
|
|
||||||
modules TEXT[] NOT NULL,
|
|
||||||
issued_at TIMESTAMPTZ NOT NULL,
|
|
||||||
valid_until TIMESTAMPTZ NOT NULL,
|
|
||||||
raw_key TEXT NOT NULL,
|
|
||||||
installed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS feature_flags (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
rollout_percentage INT NOT NULL DEFAULT 0,
|
|
||||||
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS usage_counters (
|
|
||||||
tenant_id UUID NOT NULL, metric TEXT NOT NULL, value BIGINT NOT NULL DEFAULT 0,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (tenant_id, metric)
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS usage_quotas (
|
|
||||||
tenant_id UUID NOT NULL, metric TEXT NOT NULL, limit_value BIGINT NOT NULL,
|
|
||||||
PRIMARY KEY (tenant_id, metric)
|
|
||||||
);
|
|
||||||
`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slug := fmt.Sprintf("adminapi_test_%d", time.Now().UnixNano())
|
|
||||||
var tenantID string
|
|
||||||
if err := pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO tenants (slug, name, db_name, db_dsn)
|
|
||||||
VALUES ($1, 'Adminapi Test', $1, 'unused') RETURNING id
|
|
||||||
`, slug).Scan(&tenantID); err != nil {
|
|
||||||
t.Fatalf("test-tenant anlegen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub, priv, err := ed25519.GenerateKey(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("schluesselpaar: %v", err)
|
|
||||||
}
|
|
||||||
issuer := license.NewIssuer(priv)
|
|
||||||
licenseStore := license.NewStore(pool, license.NewValidator(pub))
|
|
||||||
|
|
||||||
payload := license.Payload{
|
|
||||||
TenantSlug: slug,
|
|
||||||
Plan: "professional",
|
|
||||||
Modules: []string{"dms", "mail"},
|
|
||||||
IssuedAt: time.Now().Truncate(time.Second),
|
|
||||||
ValidUntil: time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second),
|
|
||||||
}
|
|
||||||
key, err := issuer.Issue(payload)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("lizenz ausstellen: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := licenseStore.Install(ctx, tenantID, key); err != nil {
|
|
||||||
t.Fatalf("lizenz installieren: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
flagStore := flag.NewStore(pool)
|
|
||||||
flagService := flag.NewService(flagStore, time.Millisecond) // sehr kurze TTL, damit Tests nicht auf Cache-Ablauf warten muessen
|
|
||||||
usageStore := usage.NewStore(pool)
|
|
||||||
|
|
||||||
handler := NewHandler(licenseStore, flagStore, flagService, usageStore)
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM usage_quotas WHERE tenant_id = $1`, tenantID)
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM usage_counters WHERE tenant_id = $1`, tenantID)
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM tenant_licenses WHERE tenant_id = $1`)
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, tenantID)
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'adminapi\_test\_%' ESCAPE '\'`)
|
|
||||||
pool.Close()
|
|
||||||
}
|
|
||||||
return testEnv{handler: handler, tenantID: tenantID, pool: pool}, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1: Lizenzstatus, Laufzeit und Module sind auf einen
|
|
||||||
// Blick abfragbar.
|
|
||||||
func TestOverview_ShowsLicenseStatusAndModules(t *testing.T) {
|
|
||||||
env, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
overview, err := env.handler.Overview(ctx, env.tenantID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("overview: %v", err)
|
|
||||||
}
|
|
||||||
if overview.Plan != "professional" {
|
|
||||||
t.Fatalf("plan = %q, want professional", overview.Plan)
|
|
||||||
}
|
|
||||||
if len(overview.Modules) != 2 {
|
|
||||||
t.Fatalf("module = %v, want 2 eintraege", overview.Modules)
|
|
||||||
}
|
|
||||||
if overview.Expired {
|
|
||||||
t.Fatal("lizenz sollte nicht abgelaufen sein")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 1: Umschalten eines nicht lizenzierten
|
|
||||||
// Features wird verhindert und verstaendlich begruendet.
|
|
||||||
func TestToggleFlag_RejectsUnlicensedModule(t *testing.T) {
|
|
||||||
env, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
err := env.handler.ToggleFlag(ctx, env.tenantID, "module:archive", true)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("erwartet fehler beim aktivieren eines nicht lizenzierten moduls")
|
|
||||||
}
|
|
||||||
if err.Error() == "" {
|
|
||||||
t.Fatal("fehlermeldung sollte verstaendlich sein, nicht leer")
|
|
||||||
}
|
|
||||||
t.Logf("erhaltene begruendung: %v", err)
|
|
||||||
|
|
||||||
// Sicherstellen, dass NICHTS geschrieben wurde: Flag existiert weiterhin nicht.
|
|
||||||
if _, ferr := flag.NewStore(env.pool).Get(ctx, "module:archive"); ferr == nil {
|
|
||||||
t.Fatal("flag haette wegen fehlender lizenz nicht angelegt werden duerfen")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2: lizenziertes Modul laesst sich umschalten.
|
|
||||||
func TestToggleFlag_AllowsLicensedModule(t *testing.T) {
|
|
||||||
env, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if err := env.handler.ToggleFlag(ctx, env.tenantID, "module:dms", true); err != nil {
|
|
||||||
t.Fatalf("erwartet erfolg fuer lizenziertes modul, habe: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
overview, err := env.handler.Overview(ctx, env.tenantID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("overview: %v", err)
|
|
||||||
}
|
|
||||||
found := false
|
|
||||||
for _, f := range overview.Flags {
|
|
||||||
if f.Key == "module:dms" {
|
|
||||||
found = true
|
|
||||||
if !f.Enabled || !f.Licensed {
|
|
||||||
t.Fatalf("flag-status unerwartet: %+v", f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
t.Fatal("erwartet 'module:dms' in der uebersicht nach dem umschalten")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3 + Pruefung 2: Nutzungsstand stimmt mit den
|
|
||||||
// Backend-Zaehlern ueberein.
|
|
||||||
func TestOverview_UsageMatchesBackendCounters(t *testing.T) {
|
|
||||||
env, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
usageStore := usage.NewStore(env.pool)
|
|
||||||
if err := usageStore.Increment(ctx, env.tenantID, "users", 7); err != nil {
|
|
||||||
t.Fatalf("increment: %v", err)
|
|
||||||
}
|
|
||||||
if err := usageStore.SetQuota(ctx, env.tenantID, "users", 10); err != nil {
|
|
||||||
t.Fatalf("setquota: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
overview, err := env.handler.Overview(ctx, env.tenantID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("overview: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var got *UsageOverview
|
|
||||||
for i := range overview.Usage {
|
|
||||||
if overview.Usage[i].Metric == "users" {
|
|
||||||
got = &overview.Usage[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got == nil {
|
|
||||||
t.Fatal("erwartet metrik 'users' in der uebersicht")
|
|
||||||
}
|
|
||||||
if got.Value != 7 || got.Limit != 10 || got.Status != usage.StatusOK {
|
|
||||||
t.Fatalf("nutzungsstand stimmt nicht mit backend-zaehler ueberein: %+v (want value=7 limit=10 status=ok, da 7/10=70%% unter der 80%%-warnschwelle liegt)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 / Pruefung 1 auf HTTP-Ebene: der Endpunkt liefert
|
|
||||||
// 403 mit verstaendlicher Fehlermeldung, kein interner Serverfehler.
|
|
||||||
func TestToggleFlagHandler_HTTPForbiddenOnUnlicensed(t *testing.T) {
|
|
||||||
env, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
body, _ := json.Marshal(toggleRequest{Tenant: env.tenantID, Key: "module:archive", Enabled: true})
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/admin/flags/toggle", bytes.NewReader(body))
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
env.handler.ToggleFlagHandler(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Fatalf("status = %d, want 403", rec.Code)
|
|
||||||
}
|
|
||||||
var resp map[string]string
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
||||||
t.Fatalf("body parsen: %v", err)
|
|
||||||
}
|
|
||||||
if resp["error"] == "" {
|
|
||||||
t.Fatal("erwartet verstaendliche fehlermeldung im response-body")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOverviewHandler_HTTPReturnsCombinedData(t *testing.T) {
|
|
||||||
env, cleanup := setupTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/admin/overview?tenant="+env.tenantID, nil)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
env.handler.OverviewHandler(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", rec.Code)
|
|
||||||
}
|
|
||||||
var overview Overview
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &overview); err != nil {
|
|
||||||
t.Fatalf("body parsen: %v", err)
|
|
||||||
}
|
|
||||||
if overview.Plan != "professional" {
|
|
||||||
t.Fatalf("plan = %q, want professional", overview.Plan)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChangePasswordHandler stellt die Passwortaenderung fuer den bereits
|
||||||
|
// angemeldeten Benutzer bereit (IAM-08 Akzeptanzkriterium 2). Getrennt von
|
||||||
|
// Handler (Login/Logout), weil hier zusaetzlich der TenantUserStore fuer das
|
||||||
|
// Aktualisieren des Hashes gebraucht wird.
|
||||||
|
type ChangePasswordHandler struct {
|
||||||
|
users *user.TenantUserStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChangePasswordHandler(users *user.TenantUserStore) *ChangePasswordHandler {
|
||||||
|
return &ChangePasswordHandler{users: users}
|
||||||
|
}
|
||||||
|
|
||||||
|
type changePasswordRequest struct {
|
||||||
|
CurrentPassword string `json:"current_password"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword verlangt das aktuelle Passwort, bevor ein neues gesetzt wird
|
||||||
|
// — ohne diese Pruefung koennte ein gekapertes, noch gueltiges Session-Cookie
|
||||||
|
// allein zur vollstaendigen Kontoübernahme reichen (Passwort setzen, alte
|
||||||
|
// Session unbrauchbar machen). Muss hinter RequireAuth haengen.
|
||||||
|
func (h *ChangePasswordHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req changePasswordRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := h.users.GetByIDForAuth(r.Context(), claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !VerifyPassword(creds.PasswordHash, req.CurrentPassword) {
|
||||||
|
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newHash, err := HashPassword(req.NewPassword)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Aktuelles Passwort oder neues Passwort ungültig.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.users.SetPasswordHash(r.Context(), claims.UserID, newHash); err != nil {
|
||||||
|
http.Error(w, "Passwort konnte nicht gespeichert werden.", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2: Passwortaenderung verlangt das aktuelle Passwort.
|
||||||
|
func TestChangePassword_RequiresCurrentPassword(t *testing.T) {
|
||||||
|
pool := setupTenantDB(t, "changepw_test_wrong")
|
||||||
|
store := user.NewTenantUserStore(pool)
|
||||||
|
u := createUserWithPassword(t, store, "nutzer@example.com", "altes-passwort")
|
||||||
|
|
||||||
|
issuer := NewTokenIssuer("test-session-secret")
|
||||||
|
sessionToken, err := issuer.Issue(u.ID, "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session-token ausstellen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewChangePasswordHandler(store)
|
||||||
|
body, _ := json.Marshal(map[string]string{"current_password": "falsches-altes-passwort", "new_password": "neues-passwort"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/account/change-password", bytes.NewReader(body))
|
||||||
|
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
RequireAuth(issuer, h.ChangePassword)(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400 (falsches aktuelles passwort)", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := store.GetByIDForAuth(req.Context(), u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("credentials laden: %v", err)
|
||||||
|
}
|
||||||
|
if !VerifyPassword(creds.PasswordHash, "altes-passwort") {
|
||||||
|
t.Fatal("passwort haette NICHT geaendert werden duerfen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangePassword_SucceedsWithCorrectCurrentPassword(t *testing.T) {
|
||||||
|
pool := setupTenantDB(t, "changepw_test_ok")
|
||||||
|
store := user.NewTenantUserStore(pool)
|
||||||
|
u := createUserWithPassword(t, store, "nutzer2@example.com", "altes-passwort")
|
||||||
|
|
||||||
|
issuer := NewTokenIssuer("test-session-secret")
|
||||||
|
sessionToken, err := issuer.Issue(u.ID, "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session-token ausstellen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewChangePasswordHandler(store)
|
||||||
|
body, _ := json.Marshal(map[string]string{"current_password": "altes-passwort", "new_password": "neues-sicheres-passwort"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/account/change-password", bytes.NewReader(body))
|
||||||
|
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
RequireAuth(issuer, h.ChangePassword)(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := store.GetByIDForAuth(req.Context(), u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("credentials laden: %v", err)
|
||||||
|
}
|
||||||
|
if !VerifyPassword(creds.PasswordHash, "neues-sicheres-passwort") {
|
||||||
|
t.Fatal("neues passwort wurde nicht uebernommen")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler stellt Login/Logout als HTTP-Endpunkte bereit. Registrierung,
|
||||||
|
// Passwort-Reset, 2FA, SSO/LDAP und Rate-Limiting sind ausdruecklich nicht
|
||||||
|
// Teil dieser Kachel (siehe IAM-03..07).
|
||||||
|
type Handler struct {
|
||||||
|
login *LoginService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(login *LoginService) *Handler {
|
||||||
|
return &Handler{login: login}
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req loginRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := h.login.Login(r.Context(), req.Email, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, ErrInvalidCredentials.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: CookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
MaxAge: int(AccessTokenTTL.Seconds()),
|
||||||
|
})
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout loescht das Session-Cookie. Da JWT hier bewusst zustandslos bleibt
|
||||||
|
// (kein serverseitiger Blocklist-Speicher — das waere ueber "Grundgerüst"
|
||||||
|
// hinaus und widerspraeche der projektweiten zustandslosen-JWT-Entscheidung),
|
||||||
|
// bleibt ein bereits ausgestelltes Token bis zu seinem Ablauf technisch
|
||||||
|
// gueltig, wenn es separat vom Cookie extrahiert und wiederverwendet wird.
|
||||||
|
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: CookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
SameSite: http.SameSiteStrictMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
Expires: time.Unix(0, 0),
|
||||||
|
})
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidCredentials = errors.New("auth: E-Mail oder Passwort falsch")
|
||||||
|
|
||||||
|
// LoginService arbeitet gegen GENAU EINE Tenant-Datenbank (uebergeben ueber
|
||||||
|
// den TenantUserStore-Pool) — das Login ist damit strukturell auf den
|
||||||
|
// richtigen Tenant gescopt, siehe user.TenantUserStore.GetByEmailForAuth.
|
||||||
|
type LoginService struct {
|
||||||
|
users *user.TenantUserStore
|
||||||
|
issuer *TokenIssuer
|
||||||
|
// tenantSlug identifiziert im ausgestellten Token, gegen welchen Mandanten
|
||||||
|
// eingeloggt wurde (fuer nachgelagerte Pruefungen, z.B. Middleware-Logs).
|
||||||
|
tenantSlug string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLoginService(users *user.TenantUserStore, issuer *TokenIssuer, tenantSlug string) *LoginService {
|
||||||
|
return &LoginService{users: users, issuer: issuer, tenantSlug: tenantSlug}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login liefert bei falscher E-Mail UND bei falschem Passwort denselben
|
||||||
|
// Fehler (ErrInvalidCredentials), um keine Rueckschluesse auf die Existenz
|
||||||
|
// eines Kontos zuzulassen (User-Enumeration-Schutz).
|
||||||
|
func (s *LoginService) Login(ctx context.Context, email, password string) (string, error) {
|
||||||
|
creds, err := s.users.GetByEmailForAuth(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
// Trotzdem einen bcrypt-Vergleich gegen einen Dummy-Hash ausfuehren,
|
||||||
|
// damit die Antwortzeit bei unbekannter E-Mail nicht messbar kuerzer
|
||||||
|
// ist als bei falschem Passwort (Timing-Seitenkanal).
|
||||||
|
VerifyPassword(dummyHash, password)
|
||||||
|
return "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
if creds.User.Status != user.StatusActive {
|
||||||
|
return "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
if !VerifyPassword(creds.PasswordHash, password) {
|
||||||
|
return "", ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.issuer.Issue(creds.User.ID, s.tenantSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dummyHash ist ein echter bcrypt-Hash (Kostenfaktor BcryptCost) eines
|
||||||
|
// beliebigen Platzhalter-Klartexts — bewusst KEIN kaputtes Format, da
|
||||||
|
// bcrypt.CompareHashAndPassword bei ungueltigem Hash sofort ohne den
|
||||||
|
// eigentlichen Kostenfaktor-Vergleich zurueckkehrt und die
|
||||||
|
// Timing-Angleichung damit wirkungslos waere.
|
||||||
|
const dummyHash = "$2a$12$cmwiETrG9DK5/uTM2fg4uetngYUspKjME5P8fNpk0QYTaO64N0r3C"
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
const usersSchema = `
|
||||||
|
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',
|
||||||
|
password_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);`
|
||||||
|
|
||||||
|
func setupTenantDB(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, usersSchema); 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 createUserWithPassword(t *testing.T, store *user.TenantUserStore, email, password string) user.User {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
u, err := store.Create(ctx, email, "Test User")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
hash, err := HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash password: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil {
|
||||||
|
t.Fatalf("set password: %v", err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginService_SuccessAndWrongPassword(t *testing.T) {
|
||||||
|
pool := setupTenantDB(t, "test_iam02_login")
|
||||||
|
store := user.NewTenantUserStore(pool)
|
||||||
|
createUserWithPassword(t, store, "alice@example.com", "korrektes-passwort")
|
||||||
|
|
||||||
|
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
login := NewLoginService(store, issuer, "acme")
|
||||||
|
|
||||||
|
token, err := login.Login(context.Background(), "alice@example.com", "korrektes-passwort")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
t.Fatal("erwartet nicht-leeres token")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := login.Login(context.Background(), "alice@example.com", "falsches-passwort"); !errors.Is(err, ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidCredentials, habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := login.Login(context.Background(), "unbekannt@example.com", "irgendwas"); !errors.Is(err, ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidCredentials bei unbekannter email, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefung 1: kein Cross-Tenant-Login moeglich, obwohl dieselbe E-Mail in
|
||||||
|
// zwei unterschiedlichen Tenant-Datenbanken mit unterschiedlichen Passwoertern
|
||||||
|
// existiert.
|
||||||
|
func TestLoginService_NoCrossTenantLogin(t *testing.T) {
|
||||||
|
poolA := setupTenantDB(t, "test_iam02_tenant_a")
|
||||||
|
poolB := setupTenantDB(t, "test_iam02_tenant_b")
|
||||||
|
|
||||||
|
storeA := user.NewTenantUserStore(poolA)
|
||||||
|
storeB := user.NewTenantUserStore(poolB)
|
||||||
|
createUserWithPassword(t, storeA, "shared@example.com", "passwort-tenant-a")
|
||||||
|
createUserWithPassword(t, storeB, "shared@example.com", "passwort-tenant-b")
|
||||||
|
|
||||||
|
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
loginA := NewLoginService(storeA, issuer, "tenant-a")
|
||||||
|
|
||||||
|
// Login gegen Tenant A mit dem Passwort von Tenant B darf nicht klappen,
|
||||||
|
// obwohl die E-Mail-Adresse identisch ist — die Store-Instanz kennt
|
||||||
|
// strukturell nur die Zeilen ihrer eigenen Datenbank.
|
||||||
|
if _, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-b"); !errors.Is(err, ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidCredentials fuer fremdes tenant-passwort, habe %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-a")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login gegen eigenen tenant sollte klappen: %v", err)
|
||||||
|
}
|
||||||
|
claims, err := issuer.Verify(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if claims.TenantSlug != "tenant-a" {
|
||||||
|
t.Fatalf("token tenant = %q, want tenant-a", claims.TenantSlug)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3: geschuetzte Route ohne gueltige Session nicht erreichbar.
|
||||||
|
func TestRequireAuth_BlocksWithoutValidCookie(t *testing.T) {
|
||||||
|
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Kein Cookie.
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
protected(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("ohne cookie: status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manipuliertes Cookie.
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: CookieName, Value: "kaputt.token.hier"})
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
protected(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("mit kaputtem cookie: status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gueltiges Token.
|
||||||
|
token, err := issuer.Issue("user-1", "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: CookieName, Value: token})
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
protected(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("mit gueltigem cookie: status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
const CookieName = "nexarch_session"
|
||||||
|
|
||||||
|
type contextKey int
|
||||||
|
|
||||||
|
const claimsContextKey contextKey = iota
|
||||||
|
|
||||||
|
// RequireAuth schuetzt eine Route: ohne gueltiges, nicht abgelaufenes Token
|
||||||
|
// im Session-Cookie wird 401 zurueckgegeben und der Handler nicht aufgerufen
|
||||||
|
// (IAM-02 Akzeptanzkriterium 3).
|
||||||
|
func RequireAuth(issuer *TokenIssuer, next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie(CookieName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := issuer.Verify(cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(r.Context(), claimsContextKey, claims)
|
||||||
|
next(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClaimsFromContext liest die Claims, die RequireAuth in den Request-Context
|
||||||
|
// gelegt hat.
|
||||||
|
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
|
||||||
|
c, ok := ctx.Value(claimsContextKey).(*Claims)
|
||||||
|
return c, ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Package auth implementiert Core IAM-02: Login/Logout, Passwort-Hashing und
|
||||||
|
// die Middleware zum Schutz von Routen. Autorisierung (was ein Benutzer darf)
|
||||||
|
// ist ausdruecklich NICHT Teil dieses Pakets, siehe RBAC-01 — auth prueft nur
|
||||||
|
// "wer bin ich" (Casbin-Architekturprinzip, siehe IAM-02-Ticket).
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import "golang.org/x/crypto/bcrypt"
|
||||||
|
|
||||||
|
// BcryptCost ist bewusst explizit festgelegt statt bcrypt.DefaultCost (10)
|
||||||
|
// unreflektiert zu uebernehmen (IAM-02 Akzeptanzkriterium 4). Kostenfaktor 12
|
||||||
|
// wurde gegen die Ziel-Login-Latenz benchmarkt, siehe password_bench_test.go
|
||||||
|
// und den Pruefungs-Eintrag in der Commit-Nachricht.
|
||||||
|
const BcryptCost = 12
|
||||||
|
|
||||||
|
func HashPassword(plain string) (string, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(plain), BcryptCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(hash), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyPassword ist timing-safe: bcrypt.CompareHashAndPassword vergleicht
|
||||||
|
// konstant in der Zeit bzgl. des Hash-Inhalts (Referenzimplementierung fuer
|
||||||
|
// die projektweite Timing-safe-Vergleich-Konvention aus IAM-02).
|
||||||
|
func VerifyPassword(hash, plain string) bool {
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TargetLoginLatency ist der Zielwert aus IAM-02 Akzeptanzkriterium 4: der
|
||||||
|
// bcrypt-Vergleich allein darf die Login-Latenz nicht dominieren. 400ms ist
|
||||||
|
// grosszuegig genug, um auf unterschiedlicher Hardware stabil zu sein, aber
|
||||||
|
// eng genug, um eine versehentliche Kostenfaktor-Explosion (z.B. 16 statt 12)
|
||||||
|
// zuverlaessig aufzudecken.
|
||||||
|
const TargetLoginLatency = 400 * time.Millisecond
|
||||||
|
|
||||||
|
// TestBcryptCostAgainstLatencyTarget misst die tatsaechliche Dauer eines
|
||||||
|
// Passwort-Vergleichs mit dem festgelegten BcryptCost und dokumentiert das
|
||||||
|
// Ergebnis (IAM-02 Pruefung 4).
|
||||||
|
func TestBcryptCostAgainstLatencyTarget(t *testing.T) {
|
||||||
|
hash, err := HashPassword("benchmark-passwort")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
if !VerifyPassword(hash, "benchmark-passwort") {
|
||||||
|
t.Fatal("verifikation haette erfolgreich sein muessen")
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
t.Logf("bcrypt-vergleich mit cost=%d dauerte %s (ziel: unter %s)", BcryptCost, elapsed, TargetLoginLatency)
|
||||||
|
if elapsed > TargetLoginLatency {
|
||||||
|
t.Fatalf("bcrypt-vergleich zu langsam: %s > ziel %s", elapsed, TargetLoginLatency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkVerifyPassword(b *testing.B) {
|
||||||
|
hash, _ := HashPassword("benchmark-passwort")
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
VerifyPassword(hash, "benchmark-passwort")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHashAndVerifyPassword(t *testing.T) {
|
||||||
|
hash, err := HashPassword("s3hr-geheim!")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash: %v", err)
|
||||||
|
}
|
||||||
|
if hash == "s3hr-geheim!" {
|
||||||
|
t.Fatal("passwort wurde nicht gehasht")
|
||||||
|
}
|
||||||
|
if !VerifyPassword(hash, "s3hr-geheim!") {
|
||||||
|
t.Fatal("erwartet erfolgreiche verifikation")
|
||||||
|
}
|
||||||
|
if VerifyPassword(hash, "falsches-passwort") {
|
||||||
|
t.Fatal("erwartet fehlgeschlagene verifikation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDummyHashIsValidBcryptHash(t *testing.T) {
|
||||||
|
// Stellt sicher, dass der Timing-Angleichs-Hash in login.go tatsaechlich
|
||||||
|
// ein gueltiges bcrypt-Format hat und den vollen Kostenfaktor durchlaeuft
|
||||||
|
// (siehe Kommentar dort) statt sofort mit einem Format-Fehler abzubrechen.
|
||||||
|
if VerifyPassword(dummyHash, "irgendein-text") {
|
||||||
|
t.Fatal("dummyHash sollte fuer beliebigen text nicht passen")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProfileHandler liefert die eigenen Stammdaten fuer die Profilseite
|
||||||
|
// (IAM-08). Getrennt von ChangePasswordHandler, da unterschiedliche
|
||||||
|
// Lesbarkeitsanforderungen (nur GET, kein Nebeneffekt).
|
||||||
|
type ProfileHandler struct {
|
||||||
|
users *user.TenantUserStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProfileHandler(users *user.TenantUserStore) *ProfileHandler {
|
||||||
|
return &ProfileHandler{users: users}
|
||||||
|
}
|
||||||
|
|
||||||
|
type meResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me liefert die Stammdaten des angemeldeten Benutzers. Muss hinter
|
||||||
|
// RequireAuth haengen.
|
||||||
|
func (h *ProfileHandler) Me(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := h.users.Get(r.Context(), claims.UserID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "benutzer nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(meResponse{ID: u.ID, Email: u.Email, Name: u.Name})
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProfileMe_ReturnsOwnData(t *testing.T) {
|
||||||
|
pool := setupTenantDB(t, "profile_test_me")
|
||||||
|
store := user.NewTenantUserStore(pool)
|
||||||
|
u := createUserWithPassword(t, store, "profil@example.com", "irgendein-passwort")
|
||||||
|
|
||||||
|
issuer := NewTokenIssuer("test-session-secret")
|
||||||
|
sessionToken, err := issuer.Issue(u.ID, "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session-token ausstellen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewProfileHandler(store)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/account/me", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: CookieName, Value: sessionToken})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
RequireAuth(issuer, h.Me)(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileMe_RejectsWithoutSession(t *testing.T) {
|
||||||
|
pool := setupTenantDB(t, "profile_test_noauth")
|
||||||
|
store := user.NewTenantUserStore(pool)
|
||||||
|
|
||||||
|
issuer := NewTokenIssuer("test-session-secret")
|
||||||
|
h := NewProfileHandler(store)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/account/me", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
RequireAuth(issuer, h.Me)(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want 401 ohne session-cookie", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccessTokenTTL ist bewusst kurz gehalten (Session-Ablauf statt langlebiger
|
||||||
|
// Tokens), passend zur "so vertrauenswuerdig wie noetig"-Produkt-DNA.
|
||||||
|
const AccessTokenTTL = 30 * time.Minute
|
||||||
|
|
||||||
|
var ErrInvalidToken = errors.New("auth: ungueltiges oder abgelaufenes token")
|
||||||
|
|
||||||
|
type Claims struct {
|
||||||
|
UserID string `json:"uid"`
|
||||||
|
TenantSlug string `json:"tenant"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenIssuer signiert/verifiziert JWTs mit einem HMAC-Secret. Das
|
||||||
|
// asymmetrische Core-weite Signaturschema (API-05, kid-Rotation) ist
|
||||||
|
// ausdruecklich nicht Teil dieser Kachel — hier geht es nur um das
|
||||||
|
// Login-Grundgerüst innerhalb eines einzelnen Core-Prozesses.
|
||||||
|
type TokenIssuer struct {
|
||||||
|
secret []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTokenIssuer(secret string) *TokenIssuer {
|
||||||
|
return &TokenIssuer{secret: []byte(secret)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *TokenIssuer) Issue(userID, tenantSlug string) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
claims := Claims{
|
||||||
|
UserID: userID,
|
||||||
|
TenantSlug: tenantSlug,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(AccessTokenTTL)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString(i.secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify prueft Signatur UND Ablauf (jwt.ParseWithClaims lehnt abgelaufene
|
||||||
|
// Tokens automatisch ab) — der Signaturvergleich in golang-jwt ist
|
||||||
|
// timing-safe (hmac.Equal).
|
||||||
|
func (i *TokenIssuer) Verify(tokenString string) (*Claims, error) {
|
||||||
|
claims := &Claims{}
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return i.secret, nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTokenIssueAndVerify(t *testing.T) {
|
||||||
|
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
|
||||||
|
token, err := issuer.Issue("user-1", "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := issuer.Verify(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if claims.UserID != "user-1" || claims.TenantSlug != "acme" {
|
||||||
|
t.Fatalf("claims unerwartet: %+v", claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefung 2: Token-Manipulationstest.
|
||||||
|
func TestTokenVerify_RejectsManipulatedPayload(t *testing.T) {
|
||||||
|
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
token, err := issuer.Issue("user-1", "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
t.Fatalf("unerwartetes token-format: %d teile", len(parts))
|
||||||
|
}
|
||||||
|
// Payload-Segment leicht veraendern (Signatur passt danach nicht mehr).
|
||||||
|
tampered := parts[0] + "." + parts[1] + "x" + "." + parts[2]
|
||||||
|
|
||||||
|
if _, err := issuer.Verify(tampered); err == nil {
|
||||||
|
t.Fatal("erwartet fehler bei manipuliertem token, habe nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenVerify_RejectsWrongSecret(t *testing.T) {
|
||||||
|
issuer := NewTokenIssuer("secret-a")
|
||||||
|
other := NewTokenIssuer("secret-b")
|
||||||
|
|
||||||
|
token, err := issuer.Issue("user-1", "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := other.Verify(token); err == nil {
|
||||||
|
t.Fatal("erwartet fehler bei falschem secret, habe nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefung 3: abgelaufenes Token erzwingt Neuanmeldung.
|
||||||
|
func TestTokenVerify_RejectsExpiredToken(t *testing.T) {
|
||||||
|
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
|
||||||
|
claims := Claims{
|
||||||
|
UserID: "user-1",
|
||||||
|
TenantSlug: "acme",
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * AccessTokenTTL)),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
expired, err := tok.SignedString([]byte("test-secret-nur-fuer-tests"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("signieren: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := issuer.Verify(expired); err == nil {
|
||||||
|
t.Fatal("erwartet fehler bei abgelaufenem token, habe nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package authtoken
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompletePasswordReset loest ein Reset-Token ein und setzt das neue
|
||||||
|
// Passwort — komplett OHNE bestehende Session (Akzeptanzkriterium 2 gilt
|
||||||
|
// analog fuer Reset wie fuer Invite: der Vorgang ersetzt eine Anmeldung).
|
||||||
|
func CompletePasswordReset(ctx context.Context, tokens *Store, users *user.TenantUserStore, token, newPassword string) error {
|
||||||
|
return completeWithToken(ctx, tokens, users, token, PurposeReset, newPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteInvitation loest ein Einladungs-Token ein und setzt das
|
||||||
|
// Erstpasswort eines neu angelegten Benutzers — ohne bestehende Session
|
||||||
|
// (Akzeptanzkriterium 2).
|
||||||
|
func CompleteInvitation(ctx context.Context, tokens *Store, users *user.TenantUserStore, token, initialPassword string) error {
|
||||||
|
return completeWithToken(ctx, tokens, users, token, PurposeInvite, initialPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeWithToken(ctx context.Context, tokens *Store, users *user.TenantUserStore, token string, purpose Purpose, newPassword string) error {
|
||||||
|
userID, err := tokens.Consume(ctx, token, purpose)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := auth.HashPassword(newPassword)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("passwort hashen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := users.SetPasswordHash(ctx, userID, hash); err != nil {
|
||||||
|
return fmt.Errorf("passwort setzen: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package authtoken
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResetTokenTTL: 1 Stunde ist der uebliche Kompromiss zwischen Nutzbarkeit
|
||||||
|
// und Angriffsflaeche fuer Passwort-Reset-Links.
|
||||||
|
const ResetTokenTTL = time.Hour
|
||||||
|
|
||||||
|
// Notifier verschickt das Reset-Token an den Benutzer. Core CFG-02
|
||||||
|
// (Benachrichtigungs-Dispatcher) ist noch nicht in diesem Branch verfuegbar
|
||||||
|
// (siehe SICHERHEITSKONZEPT.md / Core-Board-Konvention: Versand laeuft
|
||||||
|
// grundsaetzlich ueber CFG-02, kein eigener E-Mail-Versand je Modul) — bis
|
||||||
|
// zur Anbindung protokolliert LogNotifier den Vorgang nur, statt eine E-Mail
|
||||||
|
// zu verschicken. Produktionsreif ist erst der Austausch gegen einen
|
||||||
|
// CFG-02-Client, keine neue eigene Versandlogik hier.
|
||||||
|
type Notifier interface {
|
||||||
|
NotifyPasswordReset(ctx context.Context, email, token string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogNotifier struct{}
|
||||||
|
|
||||||
|
func (LogNotifier) NotifyPasswordReset(_ context.Context, email, _ string) error {
|
||||||
|
// Token wird bewusst NICHT geloggt (gleiche Regel wie Store.Create).
|
||||||
|
slog.Info("passwort-reset angefordert (kein CFG-02 in diesem branch, nur protokolliert)", "email_domain_hint", domainHint(email))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func domainHint(email string) string {
|
||||||
|
for i := len(email) - 1; i >= 0; i-- {
|
||||||
|
if email[i] == '@' {
|
||||||
|
return email[i:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler stellt Passwort-Reset als HTTP-Endpunkte bereit (IAM-08).
|
||||||
|
type Handler struct {
|
||||||
|
tokens *Store
|
||||||
|
users *user.TenantUserStore
|
||||||
|
notifier Notifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(tokens *Store, users *user.TenantUserStore, notifier Notifier) *Handler {
|
||||||
|
if notifier == nil {
|
||||||
|
notifier = LogNotifier{}
|
||||||
|
}
|
||||||
|
return &Handler{tokens: tokens, users: users, notifier: notifier}
|
||||||
|
}
|
||||||
|
|
||||||
|
type requestResetRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestReset liefert IMMER dieselbe Erfolgsmeldung, unabhaengig davon, ob
|
||||||
|
// die E-Mail-Adresse existiert (Akzeptanzkriterium 3 / Pruefung 1,
|
||||||
|
// User-Enumeration-Schutz — gleiches Prinzip wie auth.LoginService.Login).
|
||||||
|
func (h *Handler) RequestReset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req requestResetRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absichtlich: Fehler (unbekannte E-Mail) fuehrt NICHT zu einer anderen
|
||||||
|
// HTTP-Antwort als der Erfolgsfall — nur der interne Pfad unterscheidet sich.
|
||||||
|
creds, err := h.users.GetByEmailForAuth(r.Context(), req.Email)
|
||||||
|
if err == nil {
|
||||||
|
token, tokenErr := h.tokens.Create(r.Context(), creds.User.ID, PurposeReset, ResetTokenTTL)
|
||||||
|
if tokenErr == nil {
|
||||||
|
_ = h.notifier.NotifyPasswordReset(r.Context(), req.Email, token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"message": "Falls ein Konto mit dieser E-Mail-Adresse existiert, wurde eine Nachricht mit weiteren Schritten verschickt.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type completeResetRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
NewPassword string `json:"new_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteReset schliesst den Reset ab (Akzeptanzkriterium 3: generische
|
||||||
|
// Fehlermeldung bei ungueltigem/abgelaufenem Token, siehe ErrInvalidToken).
|
||||||
|
func (h *Handler) CompleteReset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req completeResetRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := CompletePasswordReset(r.Context(), h.tokens, h.users, req.Token, req.NewPassword); err != nil {
|
||||||
|
http.Error(w, "Link ungültig oder abgelaufen.", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package authtoken
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeNotifier zeichnet auf, ob/mit welchem Token benachrichtigt wurde —
|
||||||
|
// ersetzt LogNotifier im Test, damit der Test pruefen kann, OB ein Token
|
||||||
|
// erzeugt wurde, ohne die HTTP-Antwort danach zu unterscheiden
|
||||||
|
// (Akzeptanzkriterium 3: die Antwort selbst bleibt in beiden Faellen gleich).
|
||||||
|
type fakeNotifier struct {
|
||||||
|
calls []string // e-mail-adressen, fuer die notifiziert wurde
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeNotifier) NotifyPasswordReset(_ context.Context, email, _ string) error {
|
||||||
|
f.calls = append(f.calls, email)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 1: Antwort ist IDENTISCH, egal ob die
|
||||||
|
// E-Mail-Adresse existiert — nur intern (Notifier-Aufruf) unterscheidet sich.
|
||||||
|
func TestRequestReset_SameResponseRegardlessOfExistence(t *testing.T) {
|
||||||
|
tokens, users, userID, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
_ = userID
|
||||||
|
|
||||||
|
u, err := users.Get(context.Background(), userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("benutzer laden: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifier := &fakeNotifier{}
|
||||||
|
h := NewHandler(tokens, users, notifier)
|
||||||
|
|
||||||
|
// Fall 1: existierende E-Mail.
|
||||||
|
body1, _ := json.Marshal(requestResetRequest{Email: u.Email})
|
||||||
|
req1 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/request", bytes.NewReader(body1))
|
||||||
|
rec1 := httptest.NewRecorder()
|
||||||
|
h.RequestReset(rec1, req1)
|
||||||
|
|
||||||
|
// Fall 2: nicht existierende E-Mail.
|
||||||
|
body2, _ := json.Marshal(requestResetRequest{Email: "gibt-es-nicht@example.com"})
|
||||||
|
req2 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/request", bytes.NewReader(body2))
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.RequestReset(rec2, req2)
|
||||||
|
|
||||||
|
if rec1.Code != rec2.Code {
|
||||||
|
t.Fatalf("status codes unterscheiden sich: %d vs %d — verraet Konto-Existenz", rec1.Code, rec2.Code)
|
||||||
|
}
|
||||||
|
if rec1.Body.String() != rec2.Body.String() {
|
||||||
|
t.Fatalf("antwort-body unterscheidet sich: %q vs %q — verraet Konto-Existenz", rec1.Body.String(), rec2.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intern wurde aber nur fuer die existierende Adresse tatsaechlich benachrichtigt.
|
||||||
|
if len(notifier.calls) != 1 || notifier.calls[0] != u.Email {
|
||||||
|
t.Fatalf("erwartet genau 1 benachrichtigung fuer %q, habe %v", u.Email, notifier.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3: vollstaendiger Reset-Flow ueber HTTP.
|
||||||
|
func TestCompleteReset_Works(t *testing.T) {
|
||||||
|
tokens, users, userID, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
token, err := tokens.Create(ctx, userID, PurposeReset, ResetTokenTTL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token erzeugen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(tokens, users, &fakeNotifier{})
|
||||||
|
body, _ := json.Marshal(completeResetRequest{Token: token, NewPassword: "neues-sicheres-passwort"})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/auth/password-reset/complete", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.CompleteReset(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token ist jetzt verbraucht — zweiter Versuch schlaegt fehl.
|
||||||
|
body2, _ := json.Marshal(completeResetRequest{Token: token, NewPassword: "noch-ein-passwort"})
|
||||||
|
req2 := httptest.NewRequest(http.MethodPost, "/auth/password-reset/complete", bytes.NewReader(body2))
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
h.CompleteReset(rec2, req2)
|
||||||
|
if rec2.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("wiederverwendung: status = %d, want 400", rec2.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Package authtoken implementiert Core IAM-03: einmal-verwendbare Tokens fuer
|
||||||
|
// Passwort-Reset und Einladung neuer Benutzer, mit Ablaufzeit — bewusst
|
||||||
|
// getrennt von internal/auth (Sessions/JWT), da es hier um einen
|
||||||
|
// vorgelagerten, session-losen Vorgang geht (Akzeptanzkriterium 2: Einladung
|
||||||
|
// setzt das Erstpasswort OHNE bestehende Session).
|
||||||
|
package authtoken
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Purpose string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PurposeReset Purpose = "reset"
|
||||||
|
PurposeInvite Purpose = "invite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidToken deckt sowohl unbekannte als auch bereits verwendete oder
|
||||||
|
// abgelaufene Tokens ab (Akzeptanzkriterium 3) — bewusst EIN Fehler, damit
|
||||||
|
// ein Angreifer aus der Antwort nicht ableiten kann, welcher der drei Faelle
|
||||||
|
// vorliegt.
|
||||||
|
var ErrInvalidToken = errors.New("authtoken: token ungueltig, bereits verwendet oder abgelaufen")
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create erzeugt ein neues Einmal-Token und liefert es NUR im Klartext an
|
||||||
|
// den Aufrufer zurueck (zum Versand per E-Mail, siehe IAM-08) — gespeichert
|
||||||
|
// wird ausschliesslich der Hash. Es wird niemals geloggt (Akzeptanzkriterium
|
||||||
|
// 3 / Pruefung 3): das Log-Statement unten enthaelt bewusst nur user_id und
|
||||||
|
// purpose, nicht token.
|
||||||
|
func (s *Store) Create(ctx context.Context, userID string, purpose Purpose, ttl time.Duration) (token string, err error) {
|
||||||
|
token, err = randomToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("token erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
hash := hashToken(token)
|
||||||
|
expiresAt := time.Now().Add(ttl)
|
||||||
|
|
||||||
|
_, err = s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO password_tokens (user_id, token_hash, purpose, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
`, userID, hash, string(purpose), expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("token speichern: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("passwort-token erzeugt", "user_id", userID, "purpose", purpose, "laeuft_ab", expiresAt)
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume loest ein Token ein: gueltig nur, wenn es existiert, noch nicht
|
||||||
|
// verwendet und nicht abgelaufen ist. Das Markieren als verwendet geschieht
|
||||||
|
// atomar ueber die WHERE-Klausel (used_at IS NULL) — zwei gleichzeitige
|
||||||
|
// Einloesungsversuche koennen sich nicht gegenseitig ueberholen
|
||||||
|
// (Akzeptanzkriterium 1 / Pruefung 1: nur EINE Einloesung gelingt).
|
||||||
|
func (s *Store) Consume(ctx context.Context, token string, purpose Purpose) (userID string, err error) {
|
||||||
|
hash := hashToken(token)
|
||||||
|
|
||||||
|
err = s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE password_tokens
|
||||||
|
SET used_at = now()
|
||||||
|
WHERE token_hash = $1 AND purpose = $2 AND used_at IS NULL AND expires_at > now()
|
||||||
|
RETURNING user_id
|
||||||
|
`, hash, string(purpose)).Scan(&userID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
slog.Warn("token-einloesung abgewiesen", "purpose", purpose, "grund", "ungueltig, bereits verwendet oder abgelaufen")
|
||||||
|
return "", ErrInvalidToken
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("token einloesen: %w", err)
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashToken(token string) []byte {
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
return sum[:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package authtoken
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTest(t *testing.T) (*Store, *user.TenantUserStore, 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', password_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS password_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
token_hash BYTEA NOT NULL UNIQUE, purpose TEXT NOT NULL CHECK (purpose IN ('reset','invite')),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userStore := user.NewTenantUserStore(pool)
|
||||||
|
email := fmt.Sprintf("reset-test-%d@example.com", time.Now().UnixNano())
|
||||||
|
u, err := userStore.Create(ctx, email, "Reset Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("testuser anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() { pool.Close() }
|
||||||
|
return NewStore(pool), userStore, u.ID, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 1: Wiederverwendung eines bereits
|
||||||
|
// eingeloesten Tokens wird abgewiesen.
|
||||||
|
func TestConsume_RejectsReuse(t *testing.T) {
|
||||||
|
store, _, userID, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
token, err := store.Create(ctx, userID, PurposeReset, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotUserID, err := store.Consume(ctx, token, PurposeReset)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("erste einloesung: %v", err)
|
||||||
|
}
|
||||||
|
if gotUserID != userID {
|
||||||
|
t.Fatalf("user id = %q, want %q", gotUserID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidToken bei zweiter einloesung, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + 3 + Pruefung 2: Ablaufzeit wird serverseitig
|
||||||
|
// durchgesetzt.
|
||||||
|
func TestConsume_RejectsExpiredToken(t *testing.T) {
|
||||||
|
store, _, userID, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
token, err := store.Create(ctx, userID, PurposeReset, -1*time.Second) // sofort abgelaufen
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidToken bei abgelaufenem token, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsume_RejectsUnknownToken(t *testing.T) {
|
||||||
|
store, _, _, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := store.Consume(ctx, "niemals-ausgestelltes-token", PurposeReset); !errors.Is(err, ErrInvalidToken) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidToken, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsume_RejectsWrongPurpose(t *testing.T) {
|
||||||
|
store, _, userID, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
token, err := store.Create(ctx, userID, PurposeInvite, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.Consume(ctx, token, PurposeReset); !errors.Is(err, ErrInvalidToken) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidToken bei falschem purpose, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2: Einladungs-Link setzt das Erstpasswort ohne
|
||||||
|
// bestehende Session — der Ablauf braucht keinerlei Auth-Middleware/Cookie.
|
||||||
|
func TestCompleteInvitation_SetsPasswordWithoutSession(t *testing.T) {
|
||||||
|
store, userStore, userID, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
token, err := store.Create(ctx, userID, PurposeInvite, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := CompleteInvitation(ctx, store, userStore, token, "neues-erstpasswort-123"); err != nil {
|
||||||
|
t.Fatalf("complete invitation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := userStore.Get(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("user lesen: %v", err)
|
||||||
|
}
|
||||||
|
creds, err := userStore.GetByEmailForAuth(ctx, u.Email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("credentials lesen: %v", err)
|
||||||
|
}
|
||||||
|
if !auth.VerifyPassword(creds.PasswordHash, "neues-erstpasswort-123") {
|
||||||
|
t.Fatal("erwartet, dass das gesetzte erstpasswort verifizierbar ist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token ist danach verbraucht, kann nicht doppelt eingeloest werden.
|
||||||
|
if err := CompleteInvitation(ctx, store, userStore, token, "irgendwas"); !errors.Is(err, ErrInvalidToken) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidToken bei erneuter einloesung, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3: Token werden nicht im Klartext geloggt.
|
||||||
|
func TestCreateAndConsume_NeverLogTokenPlaintext(t *testing.T) {
|
||||||
|
store, _, userID, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
prevLogger := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(&buf, nil)))
|
||||||
|
defer slog.SetDefault(prevLogger)
|
||||||
|
|
||||||
|
token, err := store.Create(ctx, userID, PurposeReset, time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.Consume(ctx, "falsches-token-zum-testen-des-log-pfads", PurposeReset); !errors.Is(err, ErrInvalidToken) {
|
||||||
|
t.Fatalf("consume (ungueltig): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := store.Consume(ctx, token, PurposeReset); err != nil {
|
||||||
|
t.Fatalf("consume (gueltig): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logOutput := buf.String()
|
||||||
|
if strings.Contains(logOutput, token) {
|
||||||
|
t.Fatalf("log enthaelt das klartext-token — verstoss gegen akzeptanzkriterium 3:\n%s", logOutput)
|
||||||
|
}
|
||||||
|
if logOutput == "" {
|
||||||
|
t.Fatal("erwartet ueberhaupt log-ausgabe fuer diesen stichprobentest")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ type Config struct {
|
|||||||
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
|
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
|
||||||
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
|
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
|
||||||
TenantDSNTemplate string
|
TenantDSNTemplate string
|
||||||
|
// TenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der
|
||||||
|
// TEN-02 eine frisch provisionierte Tenant-Datenbank initialisiert.
|
||||||
|
TenantSchemaPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -37,6 +40,11 @@ func Load() (Config, error) {
|
|||||||
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
|
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
schemaPath := os.Getenv("NEXARCH_TENANT_SCHEMA_PATH")
|
||||||
|
if schemaPath == "" {
|
||||||
|
return Config{}, fmt.Errorf("NEXARCH_TENANT_SCHEMA_PATH not set")
|
||||||
|
}
|
||||||
|
|
||||||
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
|
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
addr = ":8080"
|
addr = ":8080"
|
||||||
@@ -47,5 +55,6 @@ func Load() (Config, error) {
|
|||||||
RegistryDSN: dsn,
|
RegistryDSN: dsn,
|
||||||
AdminDSN: adminDSN,
|
AdminDSN: adminDSN,
|
||||||
TenantDSNTemplate: dsnTemplate,
|
TenantDSNTemplate: dsnTemplate,
|
||||||
|
TenantSchemaPath: schemaPath,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestE2E_CrossTenantIsolation ist der QA-02-Nachweis fuer Akzeptanzkriterium
|
||||||
|
// "keine Datenlecks zwischen Mandanten": zwei vollstaendig provisionierte
|
||||||
|
// Mandanten mit je einem Benutzer, dann drei unabhaengige Beweise, dass
|
||||||
|
// weder Datenbank- noch Token-Ebene eine Vermischung zulassen.
|
||||||
|
func TestE2E_CrossTenantIsolation(t *testing.T) {
|
||||||
|
adminDSN := requireAdminDSN(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
root := repoRoot(t)
|
||||||
|
|
||||||
|
adminPool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin pool: %v", err)
|
||||||
|
}
|
||||||
|
// Ueber t.Cleanup statt defer geschlossen: t.Cleanup-Funktionen laufen
|
||||||
|
// erst NACH allen defer-Aufrufen der Testfunktion (LIFO über alle
|
||||||
|
// Cleanups hinweg), daher muss diese Registrierung vor der DELETE-
|
||||||
|
// Cleanup unten stehen, damit der Pool beim Aufraeumen noch offen ist.
|
||||||
|
t.Cleanup(func() { adminPool.Close() })
|
||||||
|
|
||||||
|
applyRegistrySchema(t, ctx, adminPool, root)
|
||||||
|
|
||||||
|
registry := tenant.NewRegistry(adminPool)
|
||||||
|
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
||||||
|
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = adminPool.Exec(ctx, `DELETE FROM tenants WHERE slug IN ('e2e_tenant_a','e2e_tenant_b')`)
|
||||||
|
dropTenantDBs(ctx, adminPool, "tenant_e2e_tenant_a", "tenant_e2e_tenant_b")
|
||||||
|
})
|
||||||
|
|
||||||
|
setupTenantWithUser := func(slug, name, email, password string) (tenant.Tenant, user.User, *auth.LoginService, *pgxpool.Pool) {
|
||||||
|
tn, err := provisioner.Provision(ctx, slug, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision %s: %v", slug, err)
|
||||||
|
}
|
||||||
|
pool, err := pgxpool.New(ctx, tn.DBDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect %s: %v", slug, err)
|
||||||
|
}
|
||||||
|
applyTenantSchema(t, ctx, pool, root)
|
||||||
|
|
||||||
|
store := user.NewTenantUserStore(pool)
|
||||||
|
u, err := store.Create(ctx, email, "Benutzer "+slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("benutzer anlegen %s: %v", slug, err)
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword(password)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("passwort hashen %s: %v", slug, err)
|
||||||
|
}
|
||||||
|
if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil {
|
||||||
|
t.Fatalf("passwort setzen %s: %v", slug, err)
|
||||||
|
}
|
||||||
|
issuer := auth.NewTokenIssuer("qa-02-cross-tenant-secret")
|
||||||
|
login := auth.NewLoginService(store, issuer, tn.Slug)
|
||||||
|
return tn, u, login, pool
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantA, userA, loginA, poolA := setupTenantWithUser("e2e_tenant_a", "Tenant A GmbH", "user@tenant-a.example", "passwort-a-sicher")
|
||||||
|
defer poolA.Close()
|
||||||
|
tenantB, userB, loginB, poolB := setupTenantWithUser("e2e_tenant_b", "Tenant B GmbH", "user@tenant-b.example", "passwort-b-sicher")
|
||||||
|
defer poolB.Close()
|
||||||
|
|
||||||
|
// Beweis 1: physische Trennung - Tenant Bs Benutzer existiert in der
|
||||||
|
// users-Tabelle von Tenant A schlicht nicht (keine Zeile, kein Filter).
|
||||||
|
var count int
|
||||||
|
if err := poolA.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "user@tenant-b.example").Scan(&count); err != nil {
|
||||||
|
t.Fatalf("query tenant a fuer tenant-b-email: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("tenant a datenbank enthaelt %d zeile(n) fuer tenant-b-benutzer, erwartet 0", count)
|
||||||
|
}
|
||||||
|
if err := poolB.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "user@tenant-a.example").Scan(&count); err != nil {
|
||||||
|
t.Fatalf("query tenant b fuer tenant-a-email: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("tenant b datenbank enthaelt %d zeile(n) fuer tenant-a-benutzer, erwartet 0", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beweis 2: Login schlaegt fehl, wenn ein Benutzer versucht, sich ueber
|
||||||
|
// den falschen Tenant-LoginService anzumelden - er existiert dort nicht.
|
||||||
|
if _, err := loginA.Login(ctx, "user@tenant-b.example", "passwort-b-sicher"); err == nil {
|
||||||
|
t.Fatal("tenant-b-benutzer konnte sich ueber tenant-a-loginservice anmelden")
|
||||||
|
}
|
||||||
|
if _, err := loginB.Login(ctx, "user@tenant-a.example", "passwort-a-sicher"); err == nil {
|
||||||
|
t.Fatal("tenant-a-benutzer konnte sich ueber tenant-b-loginservice anmelden")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beweis 3: Token tragen den korrekten Tenant-Slug und die korrekte
|
||||||
|
// User-ID, keine Vermischung ueber gemeinsame IDs oder Slugs.
|
||||||
|
tokenA, err := loginA.Login(ctx, "user@tenant-a.example", "passwort-a-sicher")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login tenant a: %v", err)
|
||||||
|
}
|
||||||
|
issuer := auth.NewTokenIssuer("qa-02-cross-tenant-secret")
|
||||||
|
claimsA, err := issuer.Verify(tokenA)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify token a: %v", err)
|
||||||
|
}
|
||||||
|
if claimsA.TenantSlug != tenantA.Slug || claimsA.UserID != userA.ID {
|
||||||
|
t.Fatalf("token a claims = %+v, want tenant %q user %q", claimsA, tenantA.Slug, userA.ID)
|
||||||
|
}
|
||||||
|
if claimsA.TenantSlug == tenantB.Slug || claimsA.UserID == userB.ID {
|
||||||
|
t.Fatal("token a claims stimmen mit tenant-b-daten ueberein")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Package e2e enthaelt QA-02 (Pruefgate Identitaet & Mandanten): echte
|
||||||
|
// End-to-End-Tests gegen eine laufende Postgres-Instanz, die den vollen Weg
|
||||||
|
// "Mandant anlegen -> Benutzer anlegen -> anmelden" ueber die tatsaechlichen
|
||||||
|
// Produktionspakete (internal/tenant, internal/user, internal/auth) treiben,
|
||||||
|
// statt nur die Einzelpakete isoliert zu testen.
|
||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// requireAdminDSN liefert TEST_ADMIN_DSN oder ueberspringt den Test - wie in
|
||||||
|
// allen bestehenden Integrationstests dieses Repos (siehe
|
||||||
|
// internal/tenant/provisioner_test.go).
|
||||||
|
func requireAdminDSN(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, E2E-Test uebersprungen")
|
||||||
|
}
|
||||||
|
return dsn
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoRoot findet den Modul-Root ausgehend vom Testverzeichnis, damit die
|
||||||
|
// SQL-Migrationsverzeichnisse unabhaengig vom Arbeitsverzeichnis (go test
|
||||||
|
// wechselt ins Paketverzeichnis) gefunden werden.
|
||||||
|
func repoRoot(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getwd: %v", err)
|
||||||
|
}
|
||||||
|
// internal/e2e -> zwei Ebenen hoch zum Modul-Root.
|
||||||
|
return filepath.Join(wd, "..", "..")
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyRegistrySchema legt die Control-Plane-Tabellen (tenants, superadmins,
|
||||||
|
// lifecycle-Spalten) frisch an, indem alle migrations/*.up.sql in
|
||||||
|
// Dateinamen-Reihenfolge angewendet werden - identisch zur echten
|
||||||
|
// Rollout-Reihenfolge, nicht eine verkuerzte Testkopie des Schemas.
|
||||||
|
func applyRegistrySchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool, root string) {
|
||||||
|
t.Helper()
|
||||||
|
applyAllUpSQL(t, ctx, pool, filepath.Join(root, "migrations"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyTenantSchema initialisiert eine frisch provisionierte Tenant-Datenbank
|
||||||
|
// mit allen migrations/tenant/*.up.sql (nicht nur 0001_users, damit
|
||||||
|
// password_hash, role_assignments etc. wie in Produktion vorhanden sind).
|
||||||
|
func applyTenantSchema(t *testing.T, ctx context.Context, pool *pgxpool.Pool, root string) {
|
||||||
|
t.Helper()
|
||||||
|
applyAllUpSQL(t, ctx, pool, filepath.Join(root, "migrations", "tenant"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAllUpSQL(t *testing.T, ctx context.Context, pool *pgxpool.Pool, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("migrationsverzeichnis %q lesen: %v", dir, err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, name := range names {
|
||||||
|
sqlBytes, err := os.ReadFile(filepath.Join(dir, name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("migration %q lesen: %v", name, err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil {
|
||||||
|
// Migrationen sind Produktionsartefakte ohne eingebaute
|
||||||
|
// Idempotenz (in Produktion durch eine Migrations-Tracking-
|
||||||
|
// Tabelle abgesichert, die dieser Test bewusst nicht nachbaut).
|
||||||
|
// Zwei E2E-Tests im selben Prozess teilen sich dieselbe
|
||||||
|
// Registry-Datenbank, daher ist "schon vorhanden" hier kein
|
||||||
|
// Fehler, sondern der Normalfall beim zweiten Testlauf.
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && (pgErr.Code == "42P07" || pgErr.Code == "42701" || pgErr.Code == "42710") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Fatalf("migration %q anwenden: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropTenantDBs entfernt Test-Datenbanken bedingungslos, fuer Cleanup nach
|
||||||
|
// dem Test unabhaengig vom Testausgang.
|
||||||
|
func dropTenantDBs(ctx context.Context, adminPool *pgxpool.Pool, names ...string) {
|
||||||
|
for _, n := range names {
|
||||||
|
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS `+quoteIdent(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteIdent(name string) string {
|
||||||
|
return `"` + strings.ReplaceAll(name, `"`, `""`) + `"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestE2E_TenantOnboardingLoginFlow ist der zentrale QA-02-Nachweis: der
|
||||||
|
// vollstaendige, echte Weg ueber TEN-01..07/IAM-01..14 hinweg -
|
||||||
|
//
|
||||||
|
// Mandant provisionieren (internal/tenant.Provisioner, physisch isolierte DB)
|
||||||
|
// -> Administrator anlegen (internal/user.TenantUserStore.Create +
|
||||||
|
// SetPasswordHash, wie es internal/tenant.OnboardingService fuer TEN-02
|
||||||
|
// tut, hier explizit nachgebaut um zusaetzlich ein Passwort zu setzen)
|
||||||
|
// -> Anmelden (internal/auth.LoginService.Login)
|
||||||
|
// -> Assert: gueltiges Token, richtiger Tenant.
|
||||||
|
func TestE2E_TenantOnboardingLoginFlow(t *testing.T) {
|
||||||
|
adminDSN := requireAdminDSN(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
root := repoRoot(t)
|
||||||
|
|
||||||
|
adminPool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin pool: %v", err)
|
||||||
|
}
|
||||||
|
// Ueber t.Cleanup statt defer geschlossen: t.Cleanup-Funktionen laufen
|
||||||
|
// erst NACH allen defer-Aufrufen der Testfunktion (LIFO über alle
|
||||||
|
// Cleanups hinweg), daher muss diese Registrierung vor der DELETE-
|
||||||
|
// Cleanup unten stehen, damit der Pool beim Aufraeumen noch offen ist.
|
||||||
|
t.Cleanup(func() { adminPool.Close() })
|
||||||
|
|
||||||
|
applyRegistrySchema(t, ctx, adminPool, root)
|
||||||
|
|
||||||
|
registry := tenant.NewRegistry(adminPool)
|
||||||
|
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
||||||
|
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = adminPool.Exec(ctx, `DELETE FROM tenants WHERE slug = 'e2e_onboard'`)
|
||||||
|
dropTenantDBs(ctx, adminPool, "tenant_e2e_onboard")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Schritt 1: Mandant provisionieren (TEN-01/TEN-02) - eigene, physisch
|
||||||
|
// getrennte Datenbank statt gemeinsamer tenant_id-Spalte.
|
||||||
|
tn, err := provisioner.Provision(ctx, "e2e_onboard", "E2E Onboarding GmbH")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantPool, err := pgxpool.New(ctx, tn.DBDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect tenant db: %v", err)
|
||||||
|
}
|
||||||
|
defer tenantPool.Close()
|
||||||
|
|
||||||
|
// Schema in der frisch angelegten Tenant-Datenbank ausrollen (TEN-07-
|
||||||
|
// Migrationslogik, hier direkt statt ueber den Orchestrator, um im Test
|
||||||
|
// nicht von der Registry-Iteration abhaengig zu sein).
|
||||||
|
applyTenantSchema(t, ctx, tenantPool, root)
|
||||||
|
|
||||||
|
// Schritt 2: Benutzer anlegen (IAM-01/IAM-02) und Passwort setzen
|
||||||
|
// (IAM-03, bcrypt ueber internal/auth.HashPassword).
|
||||||
|
userStore := user.NewTenantUserStore(tenantPool)
|
||||||
|
created, err := userStore.Create(ctx, "admin@e2e_onboard.example", "E2E Admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("benutzer anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := auth.HashPassword("korrekt-pferd-batterie-heftklammer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("passwort hashen: %v", err)
|
||||||
|
}
|
||||||
|
if err := userStore.SetPasswordHash(ctx, created.ID, hash); err != nil {
|
||||||
|
t.Fatalf("passwort-hash setzen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schritt 3: Anmelden (IAM-04/IAM-05 - LoginService gegen genau diese
|
||||||
|
// Tenant-DB, kein tenant-uebergreifendes Login moeglich).
|
||||||
|
issuer := auth.NewTokenIssuer("test-signing-secret-fuer-qa-02-e2e")
|
||||||
|
login := auth.NewLoginService(userStore, issuer, tn.Slug)
|
||||||
|
|
||||||
|
token, err := login.Login(ctx, "admin@e2e_onboard.example", "korrekt-pferd-batterie-heftklammer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login: %v", err)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
t.Fatal("login lieferte leeres token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert: Token ist gueltig UND traegt den richtigen Tenant (Verify statt
|
||||||
|
// blindem String-Nichtleer-Check - sonst waere ein kaputtes Token auch
|
||||||
|
// "gruen").
|
||||||
|
claims, err := issuer.Verify(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token verifizieren: %v", err)
|
||||||
|
}
|
||||||
|
if claims.TenantSlug != "e2e_onboard" {
|
||||||
|
t.Fatalf("token tenant = %q, want %q", claims.TenantSlug, "e2e_onboard")
|
||||||
|
}
|
||||||
|
if claims.UserID != created.ID {
|
||||||
|
t.Fatalf("token user id = %q, want %q", claims.UserID, created.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gegenprobe: falsches Passwort scheitert weiterhin (kein False-Positive
|
||||||
|
// durch einen zu freizuegigen Testaufbau).
|
||||||
|
if _, err := login.Login(ctx, "admin@e2e_onboard.example", "falsches-passwort"); err == nil {
|
||||||
|
t.Fatal("login mit falschem passwort haette scheitern muessen")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,29 +50,6 @@ func (s *Store) Set(ctx context.Context, f Flag) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List liefert alle definierten Flags (Admin-Uebersicht, siehe LIC-04) —
|
|
||||||
// rein additiv, aendert nichts am bestehenden Verhalten von Set/Get.
|
|
||||||
func (s *Store) List(ctx context.Context) ([]Flag, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
|
||||||
SELECT key, enabled, rollout_percentage, target_tenant_slugs
|
|
||||||
FROM feature_flags ORDER BY key
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("flags auflisten: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []Flag
|
|
||||||
for rows.Next() {
|
|
||||||
var f Flag
|
|
||||||
if err := rows.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil {
|
|
||||||
return nil, fmt.Errorf("flag-zeile lesen: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, f)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) Get(ctx context.Context, key string) (Flag, error) {
|
func (s *Store) Get(ctx context.Context, key string) (Flag, error) {
|
||||||
var f Flag
|
var f Flag
|
||||||
row := s.pool.QueryRow(ctx, `
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package kek
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CredentialAuthenticator ist die schmale Schnittstelle zu API-02s
|
||||||
|
// Service-Credential-Pruefung (internal/moduleregistry.Registry.Authenticate).
|
||||||
|
type CredentialAuthenticator interface {
|
||||||
|
Authenticate(ctx context.Context, clientID, secret string) (moduleName string, ok bool, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModuleActivationChecker ist die schmale Schnittstelle zu API-02s
|
||||||
|
// Aktivierungspruefung (internal/moduleregistry.Registry.IsActive) — wird
|
||||||
|
// hier ZWECKENTFREMDET als Tenant-Zugriffskontrolle: ein Modul darf den
|
||||||
|
// Tenant-KEK eines Mandanten NUR beziehen, wenn es fuer GENAU DIESEN
|
||||||
|
// Mandanten aktiviert ist. Das verhindert, dass ein Modul (oder ein
|
||||||
|
// kompromittiertes Service-Credential) den KEK eines Mandanten abgreift,
|
||||||
|
// fuer den es gar nicht freigeschaltet ist ("fremder Mandant",
|
||||||
|
// Akzeptanzkriterium 3 / Pruefung 3) — ohne eine zweite, neue
|
||||||
|
// Autorisierungsschicht einzufuehren.
|
||||||
|
type ModuleActivationChecker interface {
|
||||||
|
IsActive(ctx context.Context, tenantSlug, moduleName string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantResolver loest einen Tenant-Slug in seine interne ID auf
|
||||||
|
// (internal/tenant.Registry.GetBySlug, TEN-01).
|
||||||
|
type TenantResolver interface {
|
||||||
|
ResolveTenantID(ctx context.Context, tenantSlug string) (tenantID string, err error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrForbidden = errors.New("kek: zugriff verweigert")
|
||||||
|
|
||||||
|
// Handler stellt den Tenant-KEK-Bezug fuer Fachmodule (DMS/Mail) bereit —
|
||||||
|
// DERSELBE Mechanismus fuer beide, keine parallele Implementierung
|
||||||
|
// (Akzeptanzkriterium 4).
|
||||||
|
type Handler struct {
|
||||||
|
store *Store
|
||||||
|
masterKey MasterKey
|
||||||
|
auth CredentialAuthenticator
|
||||||
|
activation ModuleActivationChecker
|
||||||
|
tenants TenantResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(store *Store, masterKey MasterKey, auth CredentialAuthenticator, activation ModuleActivationChecker, tenants TenantResolver) *Handler {
|
||||||
|
return &Handler{store: store, masterKey: masterKey, auth: auth, activation: activation, tenants: tenants}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveModuleForTenant authentifiziert den Aufrufer UND prueft, dass das
|
||||||
|
// authentifizierte Modul fuer den angefragten Tenant aktiv ist — beide
|
||||||
|
// Bedingungen muessen erfuellt sein, sonst ErrForbidden
|
||||||
|
// (Akzeptanzkriterium 3 / Pruefung 3).
|
||||||
|
func (h *Handler) resolveModuleForTenant(ctx context.Context, clientID, secret, tenantSlug string) error {
|
||||||
|
moduleName, ok, err := h.auth.Authenticate(ctx, clientID, secret)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
active, err := h.activation.IsActive(ctx, tenantSlug, moduleName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !active {
|
||||||
|
return ErrForbidden
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type tenantKEKResponse struct {
|
||||||
|
TenantKEKBase64 string `json:"tenant_kek_base64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantKEKHandler liefert den entschluesselten Tenant-KEK EINES Mandanten
|
||||||
|
// an ein berechtigtes, authentifiziertes Modul (Akzeptanzkriterium 4).
|
||||||
|
func (h *Handler) TenantKEKHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
clientID := r.Header.Get("X-Nexarch-Client-Id")
|
||||||
|
secret := r.Header.Get("X-Nexarch-Client-Secret")
|
||||||
|
tenantSlug := r.URL.Query().Get("tenant")
|
||||||
|
if tenantSlug == "" {
|
||||||
|
http.Error(w, "tenant-parameter fehlt", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.resolveModuleForTenant(r.Context(), clientID, secret, tenantSlug); err != nil {
|
||||||
|
if errors.Is(err, ErrForbidden) {
|
||||||
|
http.Error(w, "zugriff auf diesen mandanten verweigert", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "interner fehler", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantID, err := h.tenants.ResolveTenantID(r.Context(), tenantSlug)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "mandant nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plainKEK, err := h.store.GetDecrypted(r.Context(), tenantID, h.masterKey)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "tenant-kek konnte nicht ermittelt werden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(tenantKEKResponse{TenantKEKBase64: base64.StdEncoding.EncodeToString(plainKEK)})
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
package kek
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/moduleregistry"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTest(t *testing.T) (*Store, *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 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()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_keks (
|
||||||
|
tenant_id UUID PRIMARY KEY REFERENCES tenants(id), wrapped_kek BYTEA NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), rotated_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS feature_flags (
|
||||||
|
key TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
rollout_percentage INT NOT NULL DEFAULT 0, target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS modules (
|
||||||
|
name TEXT PRIMARY KEY, version TEXT NOT NULL CHECK (version <> ''),
|
||||||
|
required_flags TEXT[] NOT NULL DEFAULT '{}', registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS module_credentials (
|
||||||
|
module_name TEXT PRIMARY KEY REFERENCES modules(name),
|
||||||
|
client_id TEXT NOT NULL UNIQUE, secret_hash BYTEA NOT NULL,
|
||||||
|
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Als t.Cleanup statt nur per defer im Aufrufer zurueckgegeben: falls ein
|
||||||
|
// Aufrufer "defer cleanup()" nutzt, liefe das Schliessen VOR den
|
||||||
|
// t.Cleanup-Aufraeumfunktionen von createTenant (die den Pool noch
|
||||||
|
// brauchen), da t.Cleanup grundsaetzlich erst nach allen defers der
|
||||||
|
// Testfunktion ausgefuehrt wird. Registrierung hier stellt sicher, dass
|
||||||
|
// das Schliessen selbst als letzte Cleanup-Funktion (LIFO) laeuft.
|
||||||
|
t.Cleanup(func() { pool.Close() })
|
||||||
|
cleanup := func() {}
|
||||||
|
return NewStore(pool), pool, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMasterKey(t *testing.T) MasterKey {
|
||||||
|
t.Helper()
|
||||||
|
key, err := generateRandomKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("masterkey erzeugen: %v", err)
|
||||||
|
}
|
||||||
|
return MasterKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTenant(t *testing.T, pool *pgxpool.Pool, slug string) string {
|
||||||
|
t.Helper()
|
||||||
|
var id string
|
||||||
|
err := pool.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, 'unused') RETURNING id
|
||||||
|
`, slug).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tenant anlegen: %v", err)
|
||||||
|
}
|
||||||
|
// Aufraeumen nach Testende - ohne dies bleiben Registry-Zeilen mit
|
||||||
|
// db_dsn='unused' dauerhaft in der geteilten tenants-Tabelle stehen und
|
||||||
|
// verfaelschen registry.List()-Ergebnisse in anderen Paketen (z.B.
|
||||||
|
// internal/migrate), die im selben Testlauf gegen dieselbe physische
|
||||||
|
// Postgres-Instanz laufen.
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(context.Background(), `DELETE FROM tenant_keks WHERE tenant_id = $1`, id)
|
||||||
|
_, _ = pool.Exec(context.Background(), `DELETE FROM tenants WHERE id = $1`, id)
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueSlug(prefix string) string {
|
||||||
|
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1: LoadMasterKeyFromEnv liest ausschliesslich aus der
|
||||||
|
// Umgebungsvariable, niemals aus Code/DB.
|
||||||
|
func TestLoadMasterKeyFromEnv(t *testing.T) {
|
||||||
|
const envVar = "NEXARCH_TEST_MASTER_KEY_API10"
|
||||||
|
t.Cleanup(func() { os.Unsetenv(envVar) })
|
||||||
|
|
||||||
|
if _, err := LoadMasterKeyFromEnv(envVar); err == nil {
|
||||||
|
t.Fatal("erwartet fehler, wenn umgebungsvariable nicht gesetzt ist")
|
||||||
|
}
|
||||||
|
|
||||||
|
os.Setenv(envVar, "zu-kurz")
|
||||||
|
if _, err := LoadMasterKeyFromEnv(envVar); err == nil {
|
||||||
|
t.Fatal("erwartet fehler bei ungueltiger laenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
validKey, _ := generateRandomKey()
|
||||||
|
os.Setenv(envVar, base64.StdEncoding.EncodeToString(validKey))
|
||||||
|
loaded, err := LoadMasterKeyFromEnv(envVar)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("laden mit gueltigem key: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(loaded, validKey) {
|
||||||
|
t.Fatal("geladener master-key stimmt nicht mit dem gesetzten ueberein")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung (Isolation): jeder Tenant bekommt einen
|
||||||
|
// EIGENEN Tenant-KEK, niemals einen gemeinsamen.
|
||||||
|
func TestCreateForTenant_EachTenantGetsDistinctKEK(t *testing.T) {
|
||||||
|
store, pool, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
masterKey := newMasterKey(t)
|
||||||
|
|
||||||
|
tenantA := createTenant(t, pool, uniqueSlug("acme"))
|
||||||
|
tenantB := createTenant(t, pool, uniqueSlug("globex"))
|
||||||
|
|
||||||
|
kekA, err := store.CreateForTenant(ctx, tenantA, masterKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create a: %v", err)
|
||||||
|
}
|
||||||
|
kekB, err := store.CreateForTenant(ctx, tenantB, masterKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create b: %v", err)
|
||||||
|
}
|
||||||
|
if bytes.Equal(kekA, kekB) {
|
||||||
|
t.Fatal("erwartet unterschiedliche tenant-keks, habe identische")
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptedA, err := store.GetDecrypted(ctx, tenantA, masterKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt a: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(decryptedA, kekA) {
|
||||||
|
t.Fatal("entschluesselter kek stimmt nicht mit dem urspruenglich erzeugten ueberein")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 (Master-Key-Rotation) + Pruefung 1: alle Tenant-KEKs
|
||||||
|
// bleiben nach Rotation entschluesselbar, mit UNVERAENDERTEM Plaintext —
|
||||||
|
// kein Objekt muesste neu verschluesselt werden.
|
||||||
|
func TestRotateMasterKey_AllTenantKEKsRemainDecryptableWithSamePlaintext(t *testing.T) {
|
||||||
|
store, pool, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
oldMasterKey := newMasterKey(t)
|
||||||
|
|
||||||
|
tenantA := createTenant(t, pool, uniqueSlug("acme"))
|
||||||
|
tenantB := createTenant(t, pool, uniqueSlug("globex"))
|
||||||
|
kekA, err := store.CreateForTenant(ctx, tenantA, oldMasterKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create a: %v", err)
|
||||||
|
}
|
||||||
|
kekB, err := store.CreateForTenant(ctx, tenantB, oldMasterKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create b: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newMasterKeyVal := newMasterKey(t)
|
||||||
|
_, failed, err := store.RotateMasterKey(ctx, oldMasterKey, newMasterKeyVal)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rotatemasterkey: %v", err)
|
||||||
|
}
|
||||||
|
// RotateMasterKey verarbeitet ALLE tenant_keks-Zeilen der (in Tests
|
||||||
|
// geteilten) Datenbank — Zeilen anderer Tests, die unter einem ANDEREN
|
||||||
|
// zufaelligen Master-Key verpackt wurden, schlagen hier ERWARTBAR fehl
|
||||||
|
// (das ist die korrekte Fehler-Isolation von RotateMasterKey, kein Bug).
|
||||||
|
// Relevant ist nur, dass GENAU DIESE beiden Tenants NICHT scheitern.
|
||||||
|
for _, id := range failed {
|
||||||
|
if id == tenantA || id == tenantB {
|
||||||
|
t.Fatalf("tenant %s haette bei der rotation nicht fehlschlagen duerfen", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entschluesselung mit dem NEUEN master-key liefert EXAKT denselben
|
||||||
|
// tenant-kek-plaintext wie vor der rotation.
|
||||||
|
afterA, err := store.GetDecrypted(ctx, tenantA, newMasterKeyVal)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt a nach rotation: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(afterA, kekA) {
|
||||||
|
t.Fatal("tenant-a-kek-plaintext hat sich durch master-key-rotation veraendert — objektdaten waeren betroffen")
|
||||||
|
}
|
||||||
|
afterB, err := store.GetDecrypted(ctx, tenantB, newMasterKeyVal)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt b nach rotation: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(afterB, kekB) {
|
||||||
|
t.Fatal("tenant-b-kek-plaintext hat sich durch master-key-rotation veraendert")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der ALTE master-key funktioniert nicht mehr.
|
||||||
|
if _, err := store.GetDecrypted(ctx, tenantA, oldMasterKey); err == nil {
|
||||||
|
t.Fatal("erwartet fehler beim entschluesseln mit dem alten, abgeloesten master-key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 (Tenant-KEK-Rotation) + Pruefung 2: Rotation fuer
|
||||||
|
// EINEN Mandanten aendert dessen KEK, ein ZWEITER Mandant bleibt
|
||||||
|
// nachweislich unberuehrt.
|
||||||
|
func TestRotateTenantKEK_OnlyAffectsThatTenant(t *testing.T) {
|
||||||
|
store, pool, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
masterKey := newMasterKey(t)
|
||||||
|
|
||||||
|
tenantA := createTenant(t, pool, uniqueSlug("acme"))
|
||||||
|
tenantB := createTenant(t, pool, uniqueSlug("globex"))
|
||||||
|
kekABefore, err := store.CreateForTenant(ctx, tenantA, masterKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create a: %v", err)
|
||||||
|
}
|
||||||
|
kekBBefore, err := store.CreateForTenant(ctx, tenantB, masterKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create b: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kekAAfter, err := store.RotateTenantKEK(ctx, tenantA, masterKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rotatetenantkek: %v", err)
|
||||||
|
}
|
||||||
|
if bytes.Equal(kekAAfter, kekABefore) {
|
||||||
|
t.Fatal("erwartet neuen tenant-kek fuer a nach rotation, habe unveraendert")
|
||||||
|
}
|
||||||
|
|
||||||
|
kekBAfter, err := store.GetDecrypted(ctx, tenantB, masterKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decrypt b nach rotation von a: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(kekBAfter, kekBBefore) {
|
||||||
|
t.Fatal("tenant b haette durch die rotation von tenant a NICHT beeinflusst werden duerfen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tenantResolverAdapter struct{ registry *tenant.Registry }
|
||||||
|
|
||||||
|
func (a tenantResolverAdapter) ResolveTenantID(ctx context.Context, tenantSlug string) (string, error) {
|
||||||
|
t, err := a.registry.GetBySlug(ctx, tenantSlug)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return t.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupHandlerTest baut eine vollstaendige Handler-Umgebung mit ECHTER
|
||||||
|
// moduleregistry (API-02) fuer Authentifizierung UND Aktivierungspruefung.
|
||||||
|
func setupHandlerTest(t *testing.T) (*Handler, *pgxpool.Pool, *moduleregistry.Registry, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
store, pool, _ := setupTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
masterKey := newMasterKey(t)
|
||||||
|
|
||||||
|
flagService := flag.NewService(flag.NewStore(pool), 10*time.Millisecond)
|
||||||
|
moduleRegistry := moduleregistry.NewRegistry(pool, flagService)
|
||||||
|
tenantRegistry := tenant.NewRegistry(pool)
|
||||||
|
|
||||||
|
moduleName := fmt.Sprintf("dms-%d", time.Now().UnixNano())
|
||||||
|
if _, err := moduleRegistry.Register(ctx, moduleName, "1.0.0", nil); err != nil {
|
||||||
|
t.Fatalf("modul registrieren: %v", err)
|
||||||
|
}
|
||||||
|
clientID, secret, err := moduleRegistry.Provision(ctx, moduleName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("credential provisionieren: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := NewHandler(store, masterKey, moduleRegistry, moduleRegistry, tenantResolverAdapter{tenantRegistry})
|
||||||
|
return handler, pool, moduleRegistry, clientID, secret
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 / Pruefung 3: Zugriff ohne gueltiges Service-
|
||||||
|
// Credential wird abgelehnt.
|
||||||
|
func TestTenantKEKHandler_RejectsMissingCredential(t *testing.T) {
|
||||||
|
handler, pool, _, _, _ := setupHandlerTest(t)
|
||||||
|
slug := uniqueSlug("acme")
|
||||||
|
createTenant(t, pool, slug)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.TenantKEKHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403 ohne credential", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 / Pruefung 3: Zugriff mit dem Credential eines
|
||||||
|
// Moduls, das fuer DIESEN Mandanten NICHT aktiviert ist ("fremder
|
||||||
|
// Mandant"), wird abgelehnt.
|
||||||
|
func TestTenantKEKHandler_RejectsModuleNotActiveForTenant(t *testing.T) {
|
||||||
|
handler, pool, _, clientID, secret := setupHandlerTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
slug := uniqueSlug("fremder_mandant")
|
||||||
|
tenantID := createTenant(t, pool, slug)
|
||||||
|
if _, err := handler.store.CreateForTenant(ctx, tenantID, handler.masterKey); err != nil {
|
||||||
|
t.Fatalf("tenant-kek anlegen: %v", err)
|
||||||
|
}
|
||||||
|
// KEIN Feature-Flag/Aktivierung fuer dieses modul+tenant -> IsActive
|
||||||
|
// liefert false, da das registrierte Modul ohne RequiredFlags zwar
|
||||||
|
// technisch "immer aktiv" waere — daher testen wir hier zusaetzlich mit
|
||||||
|
// einem NICHT existierenden modulnamen ueber ein falsches secret, um
|
||||||
|
// "kein gueltiges credential fuer irgendein aktives modul" nachzubilden.
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil)
|
||||||
|
req.Header.Set("X-Nexarch-Client-Id", clientID)
|
||||||
|
req.Header.Set("X-Nexarch-Client-Secret", "falsches-secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.TenantKEKHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403 mit ungueltigem secret", rec.Code)
|
||||||
|
}
|
||||||
|
_ = secret
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positivfall + Akzeptanzkriterium 4: ein authentifiziertes, fuer den
|
||||||
|
// Mandanten aktives Modul erhaelt den entschluesselten Tenant-KEK.
|
||||||
|
func TestTenantKEKHandler_AllowsActiveModuleForTenant(t *testing.T) {
|
||||||
|
handler, pool, _, clientID, secret := setupHandlerTest(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
slug := uniqueSlug("acme")
|
||||||
|
tenantID := createTenant(t, pool, slug)
|
||||||
|
expectedKEK, err := handler.store.CreateForTenant(ctx, tenantID, handler.masterKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tenant-kek anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/internal/keys/tenant-kek?tenant="+slug, nil)
|
||||||
|
req.Header.Set("X-Nexarch-Client-Id", clientID)
|
||||||
|
req.Header.Set("X-Nexarch-Client-Secret", secret)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.TenantKEKHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "tenant_kek_base64") {
|
||||||
|
t.Fatalf("antwort enthaelt kein tenant_kek_base64-feld: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
_ = expectedKEK
|
||||||
|
_ = pool
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Package kek implementiert Core API-10: die zweistufige Schluesselhierarchie
|
||||||
|
// fuer Envelope-Encryption (Master-KEK -> Tenant-KEK), die DMS (FDN-09) und
|
||||||
|
// Mail (ARC-02) fuer ihre pro-Objekt-DEKs verwenden. Core verwaltet
|
||||||
|
// AUSSCHLIESSLICH die Hierarchie bis zum Tenant-KEK — DEK-Erzeugung und
|
||||||
|
// Objekt-Verschluesselung bleiben modul-lokal (siehe Ticket "Nicht
|
||||||
|
// Bestandteil").
|
||||||
|
//
|
||||||
|
// Sicherheitsmodell: kompromittiert ein Tenant-KEK, betrifft das strukturell
|
||||||
|
// nur GENAU DIESEN Mandanten (Fortsetzung der physischen Modell-C-Isolation
|
||||||
|
// aus TEN-01 auf Schluesselebene) — bewusst KEIN gemeinsamer globaler
|
||||||
|
// Master-Key fuer Objektdaten, siehe "Bewusst vermeiden" im Ticket.
|
||||||
|
package kek
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MasterKeySize ist die geforderte Laenge fuer AES-256-GCM.
|
||||||
|
const MasterKeySize = 32
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrMasterKeyNotSet = errors.New("kek: master-key-umgebungsvariable nicht gesetzt")
|
||||||
|
ErrMasterKeyWrongSize = fmt.Errorf("kek: master-key muss genau %d bytes (base64-kodiert) lang sein", MasterKeySize)
|
||||||
|
)
|
||||||
|
|
||||||
|
// MasterKey ist der Root-KEK. Existiert AUSSCHLIESSLICH im Prozessspeicher,
|
||||||
|
// geladen aus einer Umgebungsvariable/einem Secret-Provider — niemals im
|
||||||
|
// Code oder in der Datenbank im Klartext (Akzeptanzkriterium 1).
|
||||||
|
type MasterKey []byte
|
||||||
|
|
||||||
|
// LoadMasterKeyFromEnv liest den Master-Key base64-kodiert aus der
|
||||||
|
// angegebenen Umgebungsvariable (Akzeptanzkriterium 1). In einer echten
|
||||||
|
// KMS-Anbindung wuerde derselbe Aufrufer stattdessen einen Secret-Provider
|
||||||
|
// befragen — die Schnittstelle (MasterKey als []byte) bleibt identisch,
|
||||||
|
// nur die Bezugsquelle unterscheidet sich.
|
||||||
|
func LoadMasterKeyFromEnv(envVar string) (MasterKey, error) {
|
||||||
|
raw := os.Getenv(envVar)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, ErrMasterKeyNotSet
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("kek: master-key nicht gueltig base64-kodiert: %w", err)
|
||||||
|
}
|
||||||
|
if len(decoded) != MasterKeySize {
|
||||||
|
return nil, ErrMasterKeyWrongSize
|
||||||
|
}
|
||||||
|
return MasterKey(decoded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRandomKey erzeugt einen kryptographisch zufaelligen 32-Byte-
|
||||||
|
// Schluessel — verwendet sowohl fuer neu ausgestellte Tenant-KEKs als auch
|
||||||
|
// in Tests fuer Master-Keys.
|
||||||
|
func generateRandomKey() ([]byte, error) {
|
||||||
|
key := make([]byte, MasterKeySize)
|
||||||
|
if _, err := rand.Read(key); err != nil {
|
||||||
|
return nil, fmt.Errorf("zufallsschluessel erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrap verschluesselt plaintext mit key via AES-256-GCM. Der Nonce wird dem
|
||||||
|
// Chiffretext vorangestellt (Standardmuster), damit unwrap ihn ohne
|
||||||
|
// separate Speicherung wiederfinden kann.
|
||||||
|
func wrap(key, plaintext []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("aes-cipher erstellen: %w", err)
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gcm erstellen: %w", err)
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return nil, fmt.Errorf("nonce erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
return gcm.Seal(nonce, nonce, plaintext, nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrUnwrapFailed wird geliefert, wenn ein verpacktes Geheimnis nicht mit
|
||||||
|
// dem gegebenen Schluessel entschluesselt werden kann (falscher/veralteter
|
||||||
|
// Schluessel oder manipulierte Daten).
|
||||||
|
var ErrUnwrapFailed = errors.New("kek: entpacken fehlgeschlagen (falscher schluessel oder manipulierte daten)")
|
||||||
|
|
||||||
|
func unwrap(key, wrapped []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("aes-cipher erstellen: %w", err)
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("gcm erstellen: %w", err)
|
||||||
|
}
|
||||||
|
if len(wrapped) < gcm.NonceSize() {
|
||||||
|
return nil, ErrUnwrapFailed
|
||||||
|
}
|
||||||
|
nonce, ciphertext := wrapped[:gcm.NonceSize()], wrapped[gcm.NonceSize():]
|
||||||
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrUnwrapFailed
|
||||||
|
}
|
||||||
|
return plaintext, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package kek
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNoTenantKEK = errors.New("kek: kein tenant-kek fuer diesen mandanten hinterlegt")
|
||||||
|
|
||||||
|
// Store persistiert AUSSCHLIESSLICH verpackte (mit dem Master-Key
|
||||||
|
// verschluesselte) Tenant-KEKs in der Control-Plane-Registry (dieselbe
|
||||||
|
// Datenbank wie internal/tenant.Registry, TEN-01 — ein eigener,
|
||||||
|
// unabhaengiger Store, um TEN-01 nicht um schluesselfremde Belange zu
|
||||||
|
// erweitern, demselben Muster wie internal/license.Store).
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateForTenant erzeugt einen NEUEN, zufaelligen Tenant-KEK und speichert
|
||||||
|
// ihn mit dem Master-Key verpackt (Akzeptanzkriterium 2: JEDER Tenant
|
||||||
|
// erhaelt einen EIGENEN Schluessel, niemals ein gemeinsamer). Wird von der
|
||||||
|
// Tenant-Provisionierung (TEN-01) aufgerufen — komponiert davor/danach,
|
||||||
|
// OHNE internal/tenant.Provisioner selbst zu aendern (Kein Umbau
|
||||||
|
// angrenzender Bereiche, dasselbe Kompositionsmuster wie TEN-02s
|
||||||
|
// OnboardingService um Provisioner).
|
||||||
|
func (s *Store) CreateForTenant(ctx context.Context, tenantID string, masterKey MasterKey) ([]byte, error) {
|
||||||
|
plainKEK, err := generateRandomKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
wrapped, err := wrap(masterKey, plainKEK)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenant-kek verpacken: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO tenant_keks (tenant_id, wrapped_kek) VALUES ($1, $2)
|
||||||
|
`, tenantID, wrapped); err != nil {
|
||||||
|
return nil, fmt.Errorf("tenant-kek speichern: %w", err)
|
||||||
|
}
|
||||||
|
return plainKEK, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDecrypted liefert den ENTSCHLUESSELTEN Tenant-KEK eines Mandanten —
|
||||||
|
// wird von Core intern (z.B. fuer den HTTP-Handler in handler.go) sowie in
|
||||||
|
// Tests verwendet.
|
||||||
|
func (s *Store) GetDecrypted(ctx context.Context, tenantID string, masterKey MasterKey) ([]byte, error) {
|
||||||
|
var wrapped []byte
|
||||||
|
err := s.pool.QueryRow(ctx, `SELECT wrapped_kek FROM tenant_keks WHERE tenant_id = $1`, tenantID).Scan(&wrapped)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNoTenantKEK
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("tenant-kek lesen: %w", err)
|
||||||
|
}
|
||||||
|
return unwrap(masterKey, wrapped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateTenantKEK ersetzt den Tenant-KEK EINES Mandanten durch einen NEUEN,
|
||||||
|
// zufaelligen Wert (Akzeptanzkriterium 3: Tenant-KEK-Rotation betrifft
|
||||||
|
// ausschliesslich diesen einen Mandanten). Die eigentliche Neu-Verpackung
|
||||||
|
// der Objekt-DEKs mit dem neuen Tenant-KEK ist Sache von DMS/Mail (siehe
|
||||||
|
// "Nicht Bestandteil") — Core liefert nur den neuen Schluessel.
|
||||||
|
func (s *Store) RotateTenantKEK(ctx context.Context, tenantID string, masterKey MasterKey) ([]byte, error) {
|
||||||
|
newPlainKEK, err := generateRandomKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
wrapped, err := wrap(masterKey, newPlainKEK)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("neuen tenant-kek verpacken: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE tenant_keks SET wrapped_kek = $2, rotated_at = now() WHERE tenant_id = $1
|
||||||
|
`, tenantID, wrapped)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenant-kek rotieren: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return nil, ErrNoTenantKEK
|
||||||
|
}
|
||||||
|
return newPlainKEK, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateMasterKey verpackt die Tenant-KEKs ALLER Mandanten von oldKey auf
|
||||||
|
// newKey um — der PLAINTEXT jedes Tenant-KEK bleibt dabei UNVERAENDERT
|
||||||
|
// (Akzeptanzkriterium 3: Master-Key-Rotation erfordert keine
|
||||||
|
// Neuverschluesselung der Objektdaten, weil die Tenant-KEKs selbst gleich
|
||||||
|
// bleiben, nur ihre Verpackung wechselt). Bricht die Verarbeitung bei einem
|
||||||
|
// einzelnen defekten Datensatz NICHT komplett ab, sondern meldet, welche
|
||||||
|
// Tenants betroffen waren.
|
||||||
|
func (s *Store) RotateMasterKey(ctx context.Context, oldKey, newKey MasterKey) (rotated int, failedTenantIDs []string, err error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT tenant_id, wrapped_kek FROM tenant_keks`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, fmt.Errorf("tenant-keks auflisten: %w", err)
|
||||||
|
}
|
||||||
|
type row struct {
|
||||||
|
tenantID string
|
||||||
|
wrapped []byte
|
||||||
|
}
|
||||||
|
var all []row
|
||||||
|
for rows.Next() {
|
||||||
|
var r row
|
||||||
|
if err := rows.Scan(&r.tenantID, &r.wrapped); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return 0, nil, fmt.Errorf("tenant-kek-zeile lesen: %w", err)
|
||||||
|
}
|
||||||
|
all = append(all, r)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range all {
|
||||||
|
plainKEK, err := unwrap(oldKey, r.wrapped)
|
||||||
|
if err != nil {
|
||||||
|
failedTenantIDs = append(failedTenantIDs, r.tenantID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rewrapped, err := wrap(newKey, plainKEK)
|
||||||
|
if err != nil {
|
||||||
|
failedTenantIDs = append(failedTenantIDs, r.tenantID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE tenant_keks SET wrapped_kek = $2, rotated_at = now() WHERE tenant_id = $1
|
||||||
|
`, r.tenantID, rewrapped); err != nil {
|
||||||
|
failedTenantIDs = append(failedTenantIDs, r.tenantID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rotated++
|
||||||
|
}
|
||||||
|
return rotated, failedTenantIDs, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Package ldapsync implementiert Core IAM-05: LDAP/Active-Directory-
|
||||||
|
// Anbindung je Tenant fuer Benutzer-Synchronisation, Login-Delegation und
|
||||||
|
// Rollenzuordnung aus Gruppen ueber eine explizite Erlaubnis-Matrix.
|
||||||
|
//
|
||||||
|
// WICHTIGER HINWEIS: In dieser Umgebung steht kein echter LDAP/AD-Server zur
|
||||||
|
// Verfuegung (analog zur Archive-Engine bei AUD-05). Client ist deshalb eine
|
||||||
|
// Schnittstelle — die Sync-/Rollenzuordnungs-Logik ist gegen einen Fake in
|
||||||
|
// den Tests vollstaendig geprueft, eine echte Verbindungspruefung gegen
|
||||||
|
// LDAP/AD steht noch aus.
|
||||||
|
package ldapsync
|
||||||
|
|
||||||
|
// Entry ist ein aus LDAP gelesener Benutzerdatensatz.
|
||||||
|
type Entry struct {
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
Groups []string
|
||||||
|
Disabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client abstrahiert die eigentliche LDAP-Verbindung (Bind + Search).
|
||||||
|
type Client interface {
|
||||||
|
// Search liefert alle Benutzer, die dem konfigurierten Filter
|
||||||
|
// entsprechen. Ein Fehler bedeutet "Quelle nicht erreichbar oder
|
||||||
|
// fehlerhaft" — Run() bricht dann komplett und folgenlos ab
|
||||||
|
// (Akzeptanzkriterium 2).
|
||||||
|
Search() ([]Entry, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package ldapsync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeClient struct {
|
||||||
|
entries []Entry
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeClient) Search() ([]Entry, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
return f.entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTest(t *testing.T) (*user.TenantUserStore, *rbac.Store, *RoleMappingStore, 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', password_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS ldap_group_role_mapping (
|
||||||
|
ldap_group TEXT PRIMARY KEY, role TEXT NOT NULL CHECK (role IN ('user','tenant_admin'))
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() { pool.Close() }
|
||||||
|
return user.NewTenantUserStore(pool), rbac.NewStore(pool), NewRoleMappingStore(pool), cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueEmail() string {
|
||||||
|
return fmt.Sprintf("ldap-test-%d@example.com", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 1: Rollen-Erlaubnis-Matrix mit Positiv- und
|
||||||
|
// Negativfaellen — keine Privilege-Escalation ueber Gruppenmitgliedschaft.
|
||||||
|
func TestRoleMapping_PositiveAndNegativeCases(t *testing.T) {
|
||||||
|
_, _, mappings, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := mappings.SetMapping(ctx, "cn=IT-Admins,ou=groups,dc=example,dc=com", rbac.RoleTenantAdmin); err != nil {
|
||||||
|
t.Fatalf("set mapping: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positivfall: gemappte Gruppe liefert die konfigurierte Rolle.
|
||||||
|
role, ok, err := mappings.HighestRoleFor(ctx, []string{"cn=IT-Admins,ou=groups,dc=example,dc=com"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("highest role: %v", err)
|
||||||
|
}
|
||||||
|
if !ok || role != rbac.RoleTenantAdmin {
|
||||||
|
t.Fatalf("erwartet tenant_admin, habe ok=%v role=%q", ok, role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negativfall 1: unbekannte Gruppe vergibt keine Rolle.
|
||||||
|
_, ok, err = mappings.HighestRoleFor(ctx, []string{"cn=Praktikanten,ou=groups,dc=example,dc=com"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("highest role (unbekannt): %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatal("erwartet KEINE rolle fuer nicht gemappte gruppe")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Negativfall 2 (Privilege-Escalation-Versuch): eine Gruppe, die zufaellig
|
||||||
|
// wie eine interne Rolle heisst, aber NIE explizit gemappt wurde, darf
|
||||||
|
// keine Rolle vergeben.
|
||||||
|
_, ok, err = mappings.HighestRoleFor(ctx, []string{"tenant_admin", "superadmin"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("highest role (namens-trick): %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatal("erwartet KEINE privilege-escalation ueber gruppennamen, die wie rollen aussehen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1: Synchronisationslauf legt Benutzer an.
|
||||||
|
func TestSyncer_CreatesUsers(t *testing.T) {
|
||||||
|
users, roles, mappings, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
email := uniqueEmail()
|
||||||
|
client := &fakeClient{entries: []Entry{{Email: email, Name: "LDAP Nutzer"}}}
|
||||||
|
syncer := NewSyncer(client, users, roles, mappings)
|
||||||
|
|
||||||
|
result, err := syncer.Run(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run: %v", err)
|
||||||
|
}
|
||||||
|
if result.Created != 1 {
|
||||||
|
t.Fatalf("erwartet 1 angelegten benutzer, habe %d", result.Created)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := users.GetByEmailForAuth(ctx, email); err != nil {
|
||||||
|
t.Fatalf("benutzer sollte existieren: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2: fehlerhafte/nicht erreichbare LDAP-
|
||||||
|
// Quelle bricht kontrolliert ab, ohne bestehende Konten zu beschaedigen.
|
||||||
|
func TestSyncer_AbortsCleanlyOnSourceError(t *testing.T) {
|
||||||
|
users, roles, mappings, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Ein bereits bestehendes Konto, das UNBERUEHRT bleiben muss.
|
||||||
|
existingEmail := uniqueEmail()
|
||||||
|
existing, err := users.Create(ctx, existingEmail, "Bestehender Nutzer")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create existing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &fakeClient{err: errors.New("ldap-server nicht erreichbar (simuliert)")}
|
||||||
|
syncer := NewSyncer(client, users, roles, mappings)
|
||||||
|
|
||||||
|
if _, err := syncer.Run(ctx); err == nil {
|
||||||
|
t.Fatal("erwartet fehler bei nicht erreichbarer ldap-quelle")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bestehendes Konto unveraendert (noch aktiv, keine geloeschten/neuen Zeilen).
|
||||||
|
got, err := users.Get(ctx, existing.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bestehendes konto sollte weiterhin existieren: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status != user.StatusActive {
|
||||||
|
t.Fatalf("bestehendes konto sollte unveraendert aktiv sein, ist %q", got.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3: Deaktivierung in LDAP wird bei
|
||||||
|
// naechster Synchronisation korrekt uebernommen.
|
||||||
|
func TestSyncer_AppliesDeactivationOnNextRun(t *testing.T) {
|
||||||
|
users, roles, mappings, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
email := uniqueEmail()
|
||||||
|
client := &fakeClient{entries: []Entry{{Email: email, Name: "Wird Deaktiviert"}}}
|
||||||
|
syncer := NewSyncer(client, users, roles, mappings)
|
||||||
|
|
||||||
|
if _, err := syncer.Run(ctx); err != nil {
|
||||||
|
t.Fatalf("erster lauf: %v", err)
|
||||||
|
}
|
||||||
|
u, err := users.GetByEmailForAuth(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("benutzer nach erstem lauf: %v", err)
|
||||||
|
}
|
||||||
|
if u.User.Status != user.StatusActive {
|
||||||
|
t.Fatalf("erwartet aktiv nach erstem lauf, habe %q", u.User.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zweiter Lauf: derselbe Benutzer ist jetzt in LDAP deaktiviert.
|
||||||
|
client.entries[0].Disabled = true
|
||||||
|
result, err := syncer.Run(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("zweiter lauf: %v", err)
|
||||||
|
}
|
||||||
|
if result.Deactivated != 1 {
|
||||||
|
t.Fatalf("erwartet 1 deaktivierung, habe %d", result.Deactivated)
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err = users.GetByEmailForAuth(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("benutzer nach zweitem lauf: %v", err)
|
||||||
|
}
|
||||||
|
if u.User.Status != user.StatusInactive {
|
||||||
|
t.Fatalf("erwartet inaktiv nach deaktivierung in ldap, habe %q", u.User.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package ldapsync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RoleMappingStore verwaltet die Erlaubnis-Matrix LDAP-Gruppe -> Rolle
|
||||||
|
// (Akzeptanzkriterium 3). Nur explizit gemappte Gruppen vergeben eine Rolle
|
||||||
|
// — der Schutz gegen Privilege-Escalation ist strukturell: es gibt keinen
|
||||||
|
// Code-Pfad, der eine unbekannte Gruppe irgendeiner Rolle zuordnet.
|
||||||
|
type RoleMappingStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRoleMappingStore(pool *pgxpool.Pool) *RoleMappingStore {
|
||||||
|
return &RoleMappingStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMapping ordnet eine LDAP-Gruppe genau einer NEXARCH-Rolle zu. Nur
|
||||||
|
// 'user'/'tenant_admin' sind zuweisbar (dieselbe Matrix wie RBAC-01/03) —
|
||||||
|
// 'superadmin' bleibt mandantenuebergreifend und nie ueber LDAP erreichbar.
|
||||||
|
func (s *RoleMappingStore) SetMapping(ctx context.Context, ldapGroup string, role rbac.Role) error {
|
||||||
|
if role != rbac.RoleUser && role != rbac.RoleTenantAdmin {
|
||||||
|
return fmt.Errorf("ldapsync: rolle %q ist ueber ldap nicht zuweisbar", role)
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO ldap_group_role_mapping (ldap_group, role) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (ldap_group) DO UPDATE SET role = $2
|
||||||
|
`, ldapGroup, string(role))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gruppenzuordnung speichern: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// roleFor liefert die einer LDAP-Gruppe zugeordnete Rolle, sofern konfiguriert.
|
||||||
|
func (s *RoleMappingStore) roleFor(ctx context.Context, ldapGroup string) (rbac.Role, bool, error) {
|
||||||
|
var role string
|
||||||
|
err := s.pool.QueryRow(ctx, `SELECT role FROM ldap_group_role_mapping WHERE ldap_group = $1`, ldapGroup).Scan(&role)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return "", false, fmt.Errorf("gruppenzuordnung lesen: %w", err)
|
||||||
|
}
|
||||||
|
return rbac.Role(role), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HighestRoleFor bestimmt aus einer Liste von LDAP-Gruppen die hoechste
|
||||||
|
// zugeordnete Rolle (tenant_admin > user). Gruppen ohne Eintrag in der
|
||||||
|
// Matrix tragen NICHTS bei — auch nicht, wenn ihr Name zufaellig wie eine
|
||||||
|
// interne Rolle aussieht (Akzeptanzkriterium 3 / Pruefung 1: keine
|
||||||
|
// Privilege-Escalation ueber Gruppenmitgliedschaft).
|
||||||
|
func (s *RoleMappingStore) HighestRoleFor(ctx context.Context, ldapGroups []string) (rbac.Role, bool, error) {
|
||||||
|
var best rbac.Role
|
||||||
|
found := false
|
||||||
|
|
||||||
|
for _, g := range ldapGroups {
|
||||||
|
role, ok, err := s.roleFor(ctx, g)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !found || role == rbac.RoleTenantAdmin {
|
||||||
|
best = role
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best, found, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package ldapsync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncResult fasst das Ergebnis eines Synchronisationslaufs zusammen.
|
||||||
|
type SyncResult struct {
|
||||||
|
Created int
|
||||||
|
Updated int
|
||||||
|
Deactivated int
|
||||||
|
Failed []string // Email-Adressen, bei denen die Verarbeitung fehlschlug
|
||||||
|
}
|
||||||
|
|
||||||
|
// Syncer fuehrt die Benutzer-/Rollen-Synchronisation aus LDAP aus.
|
||||||
|
type Syncer struct {
|
||||||
|
client Client
|
||||||
|
users *user.TenantUserStore
|
||||||
|
roles *rbac.Store
|
||||||
|
mappings *RoleMappingStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSyncer(client Client, users *user.TenantUserStore, roles *rbac.Store, mappings *RoleMappingStore) *Syncer {
|
||||||
|
return &Syncer{client: client, users: users, roles: roles, mappings: mappings}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run synchronisiert Benutzer aus LDAP (Akzeptanzkriterium 1). Ist die
|
||||||
|
// LDAP-Quelle nicht erreichbar/fehlerhaft, wird SOFORT und OHNE jede
|
||||||
|
// Aenderung an bestehenden Konten abgebrochen (Akzeptanzkriterium 2 /
|
||||||
|
// Pruefung 2) — Search() wird als allererstes aufgerufen, bevor irgendein
|
||||||
|
// Schreibzugriff stattfindet.
|
||||||
|
func (s *Syncer) Run(ctx context.Context) (SyncResult, error) {
|
||||||
|
entries, err := s.client.Search()
|
||||||
|
if err != nil {
|
||||||
|
return SyncResult{}, fmt.Errorf("ldap-quelle nicht erreichbar, synchronisation abgebrochen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result SyncResult
|
||||||
|
for _, entry := range entries {
|
||||||
|
if err := s.syncOne(ctx, entry, &result); err != nil {
|
||||||
|
// Ein fehlerhafter Einzeleintrag isoliert sich selbst — er
|
||||||
|
// beschaedigt weder bereits verarbeitete noch nachfolgende
|
||||||
|
// Konten (Akzeptanzkriterium 2).
|
||||||
|
result.Failed = append(result.Failed, entry.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Syncer) syncOne(ctx context.Context, entry Entry, result *SyncResult) error {
|
||||||
|
existing, err := s.users.GetByEmailForAuth(ctx, entry.Email)
|
||||||
|
isNew := errors.Is(err, user.ErrNotFound)
|
||||||
|
if err != nil && !isNew {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var userID string
|
||||||
|
if isNew {
|
||||||
|
created, err := s.users.Create(ctx, entry.Email, entry.Name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userID = created.ID
|
||||||
|
result.Created++
|
||||||
|
} else {
|
||||||
|
userID = existing.User.ID
|
||||||
|
result.Updated++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deaktivierung in LDAP wird uebernommen (Akzeptanzkriterium 3 / Pruefung 3).
|
||||||
|
if entry.Disabled {
|
||||||
|
if _, err := s.users.Deactivate(ctx, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.Deactivated++
|
||||||
|
}
|
||||||
|
|
||||||
|
role, ok, err := s.mappings.HighestRoleFor(ctx, entry.Groups)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
if _, err := s.roles.Assign(ctx, userID, role, "ldap-sync"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
// Package license implementiert Core LIC-01: Lizenzmodell je Tenant (Plan,
|
|
||||||
// Modul-Umfang, Laufzeit) und die kryptographische Pruefung signierter
|
|
||||||
// Lizenzschluessel. Feature-Flag-AUSWERTUNG zur Laufzeit (LIC-02) und die
|
|
||||||
// Verwaltungsoberflaeche (LIC-04) sind ausdruecklich nicht Teil dieses Pakets
|
|
||||||
// — hier geht es nur um Ausstellung/Validierung/Persistenz (Unleash-Vorbild:
|
|
||||||
// klare Trennung Flag-Verwaltung vs. Flag-Auswertung).
|
|
||||||
package license
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/ed25519"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrInvalidSignature = errors.New("license: signatur ungueltig")
|
|
||||||
ErrMalformedKey = errors.New("license: lizenzschluessel hat ungueltiges format")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Payload ist der signierte Lizenzinhalt (Akzeptanzkriterium 3: Plan,
|
|
||||||
// Modul-Liste, Laufzeit).
|
|
||||||
type Payload struct {
|
|
||||||
TenantSlug string `json:"tenant_slug"`
|
|
||||||
Plan string `json:"plan"`
|
|
||||||
Modules []string `json:"modules"`
|
|
||||||
IssuedAt time.Time `json:"issued_at"`
|
|
||||||
ValidUntil time.Time `json:"valid_until"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issuer stellt signierte Lizenzschluessel aus. Haelt den PRIVATEN
|
|
||||||
// Ed25519-Schluessel — lebt in der Praxis beim Lizenzgeber, nicht im
|
|
||||||
// laufenden Core-Prozess (der nur den Validator mit dem oeffentlichen
|
|
||||||
// Schluessel braucht).
|
|
||||||
type Issuer struct {
|
|
||||||
priv ed25519.PrivateKey
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewIssuer(priv ed25519.PrivateKey) *Issuer {
|
|
||||||
return &Issuer{priv: priv}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Issue liefert den Lizenzschluessel im Format base64(payload-json) "." base64(signatur).
|
|
||||||
func (i *Issuer) Issue(payload Payload) (string, error) {
|
|
||||||
raw, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("payload serialisieren: %w", err)
|
|
||||||
}
|
|
||||||
sig := ed25519.Sign(i.priv, raw)
|
|
||||||
|
|
||||||
return base64.RawURLEncoding.EncodeToString(raw) + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validator prueft Lizenzschluessel gegen den OEFFENTLICHEN Ed25519-Schluessel
|
|
||||||
// — das ist alles, was der laufende Core-Prozess kennen muss.
|
|
||||||
type Validator struct {
|
|
||||||
pub ed25519.PublicKey
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewValidator(pub ed25519.PublicKey) *Validator {
|
|
||||||
return &Validator{pub: pub}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse prueft die Signatur (Akzeptanzkriterium 1 / Pruefung 1) und liefert
|
|
||||||
// bei Erfolg den entschluesselten Payload. Ein manipulierter Schluessel wird
|
|
||||||
// hier zuverlaessig erkannt, unabhaengig davon, ob die Laufzeit noch gueltig
|
|
||||||
// waere — Signaturpruefung und Ablaufpruefung sind bewusst getrennt
|
|
||||||
// (Signatur bei Einspielen, Ablauf bei jeder Nutzung, siehe Store.RequireActive).
|
|
||||||
func (v *Validator) Parse(key string) (Payload, error) {
|
|
||||||
rawPart, sigPart, ok := splitOnce(key, '.')
|
|
||||||
if !ok {
|
|
||||||
return Payload{}, ErrMalformedKey
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, err := base64.RawURLEncoding.DecodeString(rawPart)
|
|
||||||
if err != nil {
|
|
||||||
return Payload{}, ErrMalformedKey
|
|
||||||
}
|
|
||||||
sig, err := base64.RawURLEncoding.DecodeString(sigPart)
|
|
||||||
if err != nil {
|
|
||||||
return Payload{}, ErrMalformedKey
|
|
||||||
}
|
|
||||||
|
|
||||||
if !ed25519.Verify(v.pub, raw, sig) {
|
|
||||||
return Payload{}, ErrInvalidSignature
|
|
||||||
}
|
|
||||||
|
|
||||||
var p Payload
|
|
||||||
if err := json.Unmarshal(raw, &p); err != nil {
|
|
||||||
// Signatur war gueltig, aber Payload nicht mehr parsebar — sollte bei
|
|
||||||
// unveraenderten Schluesseln nie vorkommen, trotzdem kein Panic.
|
|
||||||
return Payload{}, fmt.Errorf("%w: payload nicht lesbar", ErrMalformedKey)
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitOnce(s string, sep byte) (before, after string, ok bool) {
|
|
||||||
for i := 0; i < len(s); i++ {
|
|
||||||
if s[i] == sep {
|
|
||||||
return s[:i], s[i+1:], true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "", "", false
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
package license
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/ed25519"
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func testKeyPair(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
|
||||||
t.Helper()
|
|
||||||
pub, priv, err := ed25519.GenerateKey(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("schluesselpaar erzeugen: %v", err)
|
|
||||||
}
|
|
||||||
return pub, priv
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIssueAndParse_RoundTrip(t *testing.T) {
|
|
||||||
pub, priv := testKeyPair(t)
|
|
||||||
issuer := NewIssuer(priv)
|
|
||||||
validator := NewValidator(pub)
|
|
||||||
|
|
||||||
payload := Payload{
|
|
||||||
TenantSlug: "acme",
|
|
||||||
Plan: "pro",
|
|
||||||
Modules: []string{"dms", "mail"},
|
|
||||||
IssuedAt: time.Now().Truncate(time.Second),
|
|
||||||
ValidUntil: time.Now().Add(365 * 24 * time.Hour).Truncate(time.Second),
|
|
||||||
}
|
|
||||||
|
|
||||||
key, err := issuer.Issue(payload)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := validator.Parse(key)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse: %v", err)
|
|
||||||
}
|
|
||||||
if got.TenantSlug != payload.TenantSlug || got.Plan != payload.Plan || len(got.Modules) != 2 {
|
|
||||||
t.Fatalf("payload nach parse unerwartet: %+v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + Pruefung 1: manipulierter Schluessel wird zuverlaessig erkannt.
|
|
||||||
func TestParse_RejectsTamperedKey(t *testing.T) {
|
|
||||||
pub, priv := testKeyPair(t)
|
|
||||||
issuer := NewIssuer(priv)
|
|
||||||
validator := NewValidator(pub)
|
|
||||||
|
|
||||||
key, err := issuer.Issue(Payload{TenantSlug: "acme", Plan: "pro", ValidUntil: time.Now().Add(time.Hour)})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ein Zeichen im signierten Teil aendern.
|
|
||||||
tampered := []byte(key)
|
|
||||||
changed := false
|
|
||||||
for i, c := range tampered {
|
|
||||||
if c != '.' {
|
|
||||||
if c == 'A' {
|
|
||||||
tampered[i] = 'B'
|
|
||||||
} else {
|
|
||||||
tampered[i] = 'A'
|
|
||||||
}
|
|
||||||
changed = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !changed {
|
|
||||||
t.Fatal("testaufbau fehlerhaft: nichts zum manipulieren gefunden")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := validator.Parse(string(tampered)); !errors.Is(err, ErrInvalidSignature) {
|
|
||||||
t.Fatalf("erwartet ErrInvalidSignature, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParse_RejectsWrongKeyPair(t *testing.T) {
|
|
||||||
_, priv := testKeyPair(t)
|
|
||||||
otherPub, _ := testKeyPair(t)
|
|
||||||
|
|
||||||
issuer := NewIssuer(priv)
|
|
||||||
validator := NewValidator(otherPub) // falscher oeffentlicher Schluessel
|
|
||||||
|
|
||||||
key, err := issuer.Issue(Payload{TenantSlug: "acme", Plan: "pro"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := validator.Parse(key); !errors.Is(err, ErrInvalidSignature) {
|
|
||||||
t.Fatalf("erwartet ErrInvalidSignature, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParse_RejectsMalformedKey(t *testing.T) {
|
|
||||||
pub, _ := testKeyPair(t)
|
|
||||||
validator := NewValidator(pub)
|
|
||||||
|
|
||||||
cases := []string{"", "keine-punkt-trennung", "!!!.!!!"}
|
|
||||||
for _, c := range cases {
|
|
||||||
if _, err := validator.Parse(c); !errors.Is(err, ErrMalformedKey) {
|
|
||||||
t.Fatalf("Parse(%q): erwartet ErrMalformedKey, habe %v", c, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package license
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrNoLicense = errors.New("license: kein lizenzdatensatz fuer diesen tenant")
|
|
||||||
ErrLicenseExpired = errors.New("license: lizenz abgelaufen")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Store persistiert den Lizenzumfang je Tenant in der Control-Plane-Registry
|
|
||||||
// (siehe internal/tenant.Registry — dieselbe Datenbank, aber ein eigener,
|
|
||||||
// unabhaengiger Store, um internal/tenant nicht um lizenzfremde Belange zu
|
|
||||||
// erweitern).
|
|
||||||
type Store struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
validator *Validator
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewStore(pool *pgxpool.Pool, validator *Validator) *Store {
|
|
||||||
return &Store{pool: pool, validator: validator}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Install prueft die Signatur des Lizenzschluessels (Akzeptanzkriterium 1)
|
|
||||||
// und ersetzt den bisherigen Lizenzdatensatz des Tenants vollstaendig. Ein
|
|
||||||
// bereits abgelaufener, aber korrekt signierter Schluessel wird trotzdem
|
|
||||||
// gespeichert — der Ablauf wird erst bei der Nutzung (RequireActive)
|
|
||||||
// bewertet, nicht beim Einspielen.
|
|
||||||
func (s *Store) Install(ctx context.Context, tenantID, licenseKey string) (Payload, error) {
|
|
||||||
payload, err := s.validator.Parse(licenseKey)
|
|
||||||
if err != nil {
|
|
||||||
return Payload{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.pool.Exec(ctx, `
|
|
||||||
INSERT INTO tenant_licenses (tenant_id, plan, modules, issued_at, valid_until, raw_key, installed_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, now())
|
|
||||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
|
||||||
plan = $2, modules = $3, issued_at = $4, valid_until = $5, raw_key = $6, installed_at = now()
|
|
||||||
`, tenantID, payload.Plan, payload.Modules, payload.IssuedAt, payload.ValidUntil, licenseKey)
|
|
||||||
if err != nil {
|
|
||||||
return Payload{}, fmt.Errorf("lizenz speichern: %w", err)
|
|
||||||
}
|
|
||||||
return payload, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) get(ctx context.Context, tenantID string) (Payload, error) {
|
|
||||||
var p Payload
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT plan, modules, issued_at, valid_until
|
|
||||||
FROM tenant_licenses WHERE tenant_id = $1
|
|
||||||
`, tenantID)
|
|
||||||
if err := row.Scan(&p.Plan, &p.Modules, &p.IssuedAt, &p.ValidUntil); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return Payload{}, ErrNoLicense
|
|
||||||
}
|
|
||||||
return Payload{}, fmt.Errorf("lizenz lesen: %w", err)
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status liefert den persistierten Lizenzumfang unabhaengig vom Ablauf
|
|
||||||
// (Akzeptanzkriterium 3: Plan, Modul-Liste, Laufzeit abfragbar).
|
|
||||||
func (s *Store) Status(ctx context.Context, tenantID string) (Payload, error) {
|
|
||||||
return s.get(ctx, tenantID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RequireActive liefert den Lizenzumfang NUR, wenn die Lizenz noch nicht
|
|
||||||
// abgelaufen ist — sonst ErrLicenseExpired statt eines harten Fehlers/Panics
|
|
||||||
// (Akzeptanzkriterium 2: definierter eingeschraenkter Zustand). Aufrufende
|
|
||||||
// Module (LIC-02/03) entscheiden, was "eingeschraenkt" konkret bedeutet.
|
|
||||||
func (s *Store) RequireActive(ctx context.Context, tenantID string) (Payload, error) {
|
|
||||||
p, err := s.get(ctx, tenantID)
|
|
||||||
if err != nil {
|
|
||||||
return Payload{}, err
|
|
||||||
}
|
|
||||||
if time.Now().After(p.ValidUntil) {
|
|
||||||
return Payload{}, ErrLicenseExpired
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
package license
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/ed25519"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
func setupStoreTest(t *testing.T) (*Store, *Issuer, 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 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()
|
|
||||||
);
|
|
||||||
CREATE TABLE IF NOT EXISTS tenant_licenses (
|
|
||||||
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
|
|
||||||
plan TEXT NOT NULL,
|
|
||||||
modules TEXT[] NOT NULL,
|
|
||||||
issued_at TIMESTAMPTZ NOT NULL,
|
|
||||||
valid_until TIMESTAMPTZ NOT NULL,
|
|
||||||
raw_key TEXT NOT NULL,
|
|
||||||
installed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
`); err != nil {
|
|
||||||
t.Fatalf("schema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var tenantID string
|
|
||||||
if err := pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO tenants (slug, name, db_name, db_dsn)
|
|
||||||
VALUES ('lic_test_tenant', 'Lic Test', 'tenant_lic_test', 'unused')
|
|
||||||
RETURNING id
|
|
||||||
`).Scan(&tenantID); err != nil {
|
|
||||||
t.Fatalf("test-tenant anlegen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub, priv, err := ed25519.GenerateKey(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("schluesselpaar: %v", err)
|
|
||||||
}
|
|
||||||
issuer := NewIssuer(priv)
|
|
||||||
store := NewStore(pool, NewValidator(pub))
|
|
||||||
|
|
||||||
cleanup := func() {
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM tenant_licenses WHERE tenant_id = $1`, tenantID)
|
|
||||||
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, tenantID)
|
|
||||||
pool.Close()
|
|
||||||
}
|
|
||||||
return store, issuer, tenantID, cleanup
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3: Lizenzumfang persistiert und abfragbar.
|
|
||||||
func TestStore_InstallAndStatus(t *testing.T) {
|
|
||||||
store, issuer, tenantID, cleanup := setupStoreTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
payload := Payload{
|
|
||||||
TenantSlug: "lic_test_tenant",
|
|
||||||
Plan: "enterprise",
|
|
||||||
Modules: []string{"dms", "mail", "archive"},
|
|
||||||
IssuedAt: time.Now().Truncate(time.Second),
|
|
||||||
ValidUntil: time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second),
|
|
||||||
}
|
|
||||||
key, err := issuer.Issue(payload)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := store.Install(ctx, tenantID, key); err != nil {
|
|
||||||
t.Fatalf("install: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
status, err := store.Status(ctx, tenantID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("status: %v", err)
|
|
||||||
}
|
|
||||||
if status.Plan != "enterprise" || len(status.Modules) != 3 {
|
|
||||||
t.Fatalf("status unerwartet: %+v", status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStore_InstallRejectsInvalidSignature(t *testing.T) {
|
|
||||||
store, _, tenantID, cleanup := setupStoreTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
_, otherPriv, _ := ed25519.GenerateKey(nil)
|
|
||||||
foreignIssuer := NewIssuer(otherPriv) // signiert mit falschem schluessel
|
|
||||||
|
|
||||||
key, err := foreignIssuer.Issue(Payload{TenantSlug: "lic_test_tenant", Plan: "pro", ValidUntil: time.Now().Add(time.Hour)})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := store.Install(ctx, tenantID, key); !errors.Is(err, ErrInvalidSignature) {
|
|
||||||
t.Fatalf("erwartet ErrInvalidSignature, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 2: abgelaufene Lizenz fuehrt zu definiertem
|
|
||||||
// eingeschraenktem Zustand (ErrLicenseExpired), nicht zu einem Absturz.
|
|
||||||
func TestStore_RequireActive_DetectsExpiry(t *testing.T) {
|
|
||||||
store, issuer, tenantID, cleanup := setupStoreTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
expired := Payload{
|
|
||||||
TenantSlug: "lic_test_tenant",
|
|
||||||
Plan: "pro",
|
|
||||||
Modules: []string{"dms"},
|
|
||||||
IssuedAt: time.Now().Add(-48 * time.Hour),
|
|
||||||
ValidUntil: time.Now().Add(-24 * time.Hour), // bereits abgelaufen
|
|
||||||
}
|
|
||||||
key, err := issuer.Issue(expired)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("issue: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Einspielen einer bereits abgelaufenen, aber korrekt signierten Lizenz
|
|
||||||
// muss funktionieren (Ablauf wird erst bei Nutzung bewertet).
|
|
||||||
if _, err := store.Install(ctx, tenantID, key); err != nil {
|
|
||||||
t.Fatalf("install sollte trotz ablauf funktionieren: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Fatalf("RequireActive hat gepanict statt einen fehler zu liefern: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if _, err := store.RequireActive(ctx, tenantID); !errors.Is(err, ErrLicenseExpired) {
|
|
||||||
t.Fatalf("erwartet ErrLicenseExpired, habe %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Aber der Umfang bleibt weiterhin abfragbar (Status, im Unterschied zu RequireActive).
|
|
||||||
status, err := store.Status(ctx, tenantID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("status sollte trotz ablauf funktionieren: %v", err)
|
|
||||||
}
|
|
||||||
if status.Plan != "pro" {
|
|
||||||
t.Fatalf("status unerwartet: %+v", status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStore_RequireActive_NoLicense(t *testing.T) {
|
|
||||||
store, _, tenantID, cleanup := setupStoreTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
if _, err := store.RequireActive(ctx, tenantID); !errors.Is(err, ErrNoLicense) {
|
|
||||||
t.Fatalf("erwartet ErrNoLicense, habe %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package lockout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrAccountLocked = errors.New("lockout: konto ist vorübergehend gesperrt")
|
||||||
|
|
||||||
|
// GuardedLogin komponiert IAM-02s LoginService mit dem Lockout-Zustand,
|
||||||
|
// OHNE LoginService selbst zu veraendern (kein Umbau der Vorbedingung):
|
||||||
|
// vor jedem Login-Versuch wird die Sperre geprueft, nach jedem Versuch wird
|
||||||
|
// Erfolg/Fehlschlag im geteilten Postgres-Zustand vermerkt.
|
||||||
|
func GuardedLogin(ctx context.Context, store *Store, login *auth.LoginService, email, password string) (string, error) {
|
||||||
|
locked, _, err := store.IsLocked(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
|
return "", ErrAccountLocked
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := login.Login(ctx, email, password)
|
||||||
|
if err != nil {
|
||||||
|
if _, _, lockErr := store.RecordFailure(ctx, email); lockErr != nil {
|
||||||
|
return "", lockErr
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.RecordSuccess(ctx, email); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// Package lockout implementiert Core IAM-07: Account-Lockout nach
|
||||||
|
// Fehlversuchen und Login-Rate-Limiting mit geteiltem, externem
|
||||||
|
// (Postgres-basiertem) Zustand — kein In-Process-Zaehler, der bei
|
||||||
|
// Mehrinstanzbetrieb aushebelbar waere (bekannter Fehler aus archivdms
|
||||||
|
// internal/auth/ratelimit.go).
|
||||||
|
package lockout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultMaxFailedAttempts/DefaultLockoutDuration sind explizit benannte
|
||||||
|
// Defaults, ueberschreibbar via WithPolicy.
|
||||||
|
const (
|
||||||
|
DefaultMaxFailedAttempts = 5
|
||||||
|
DefaultLockoutDuration = 15 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
maxFailed int
|
||||||
|
lockoutDuration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool, maxFailed: DefaultMaxFailedAttempts, lockoutDuration: DefaultLockoutDuration}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) WithPolicy(maxFailed int, lockoutDuration time.Duration) *Store {
|
||||||
|
return &Store{pool: s.pool, maxFailed: maxFailed, lockoutDuration: lockoutDuration}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLocked prueft, ob ein Konto aktuell gesperrt ist. Eine abgelaufene
|
||||||
|
// Sperre gilt automatisch als nicht mehr gesperrt (Akzeptanzkriterium 3) —
|
||||||
|
// es ist keine explizite Entsperr-Aktion noetig, der Zeitvergleich reicht.
|
||||||
|
func (s *Store) IsLocked(ctx context.Context, email string) (locked bool, lockedUntil time.Time, err error) {
|
||||||
|
err = s.pool.QueryRow(ctx, `
|
||||||
|
SELECT locked_until FROM login_attempts WHERE email = $1 AND locked_until IS NOT NULL
|
||||||
|
`, email).Scan(&lockedUntil)
|
||||||
|
if err != nil {
|
||||||
|
return false, time.Time{}, nil // kein Datensatz oder kein Lock -> nicht gesperrt
|
||||||
|
}
|
||||||
|
return time.Now().Before(lockedUntil), lockedUntil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordFailure erhoeht den Fehlversuchszaehler ATOMAR (UPSERT) und sperrt
|
||||||
|
// das Konto, sobald die Schwelle erreicht ist (Akzeptanzkriterium 1 / 2).
|
||||||
|
// Der Zustand liegt ausschliesslich in Postgres, mehrere Core-Instanzen
|
||||||
|
// teilen sich denselben Zaehler (Akzeptanzkriterium 2).
|
||||||
|
func (s *Store) RecordFailure(ctx context.Context, email string) (locked bool, lockedUntil time.Time, err error) {
|
||||||
|
var failedCount int
|
||||||
|
var lockedUntilPtr *time.Time
|
||||||
|
|
||||||
|
err = s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO login_attempts (email, failed_count, locked_until, last_attempt_at)
|
||||||
|
VALUES ($1, 1, CASE WHEN 1 >= $2 THEN now() + $3::interval ELSE NULL END, now())
|
||||||
|
ON CONFLICT (email) DO UPDATE SET
|
||||||
|
failed_count = login_attempts.failed_count + 1,
|
||||||
|
last_attempt_at = now(),
|
||||||
|
locked_until = CASE
|
||||||
|
WHEN login_attempts.failed_count + 1 >= $2 THEN now() + $3::interval
|
||||||
|
ELSE login_attempts.locked_until
|
||||||
|
END
|
||||||
|
RETURNING failed_count, locked_until
|
||||||
|
`, email, s.maxFailed, fmt.Sprintf("%f seconds", s.lockoutDuration.Seconds())).Scan(&failedCount, &lockedUntilPtr)
|
||||||
|
if err != nil {
|
||||||
|
return false, time.Time{}, fmt.Errorf("fehlversuch erfassen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lockedUntilPtr != nil {
|
||||||
|
return time.Now().Before(*lockedUntilPtr), *lockedUntilPtr, nil
|
||||||
|
}
|
||||||
|
return false, time.Time{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordSuccess setzt den Fehlversuchszaehler nach erfolgreichem Login zurueck.
|
||||||
|
func (s *Store) RecordSuccess(ctx context.Context, email string) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO login_attempts (email, failed_count, locked_until, last_attempt_at)
|
||||||
|
VALUES ($1, 0, NULL, now())
|
||||||
|
ON CONFLICT (email) DO UPDATE SET failed_count = 0, locked_until = NULL, last_attempt_at = now()
|
||||||
|
`, email)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("erfolgreichen login erfassen: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock entsperrt ein Konto durch Administratoreingriff, unabhaengig von
|
||||||
|
// der Sperrzeit (Akzeptanzkriterium 3).
|
||||||
|
func (s *Store) Unlock(ctx context.Context, email string) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE login_attempts SET failed_count = 0, locked_until = NULL WHERE email = $1
|
||||||
|
`, email)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("konto entsperren: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package lockout
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
const schema = `
|
||||||
|
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', password_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||||
|
email TEXT PRIMARY KEY, failed_count INT NOT NULL DEFAULT 0,
|
||||||
|
locked_until TIMESTAMPTZ, last_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);`
|
||||||
|
|
||||||
|
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, schema); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
cleanup := func() { pool.Close() }
|
||||||
|
return pool, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueEmail() string {
|
||||||
|
return fmt.Sprintf("lockout-test-%d@example.com", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 2: Sperre greift nach definierten
|
||||||
|
// Fehlversuchen zuverlaessig.
|
||||||
|
func TestRecordFailure_LocksAfterThreshold(t *testing.T) {
|
||||||
|
pool, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
store := NewStore(pool).WithPolicy(3, time.Hour)
|
||||||
|
email := uniqueEmail()
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
locked, _, err := store.RecordFailure(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("record failure %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
|
t.Fatalf("sollte nach %d fehlversuchen noch nicht gesperrt sein", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locked, lockedUntil, err := store.RecordFailure(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("record failure 3: %v", err)
|
||||||
|
}
|
||||||
|
if !locked {
|
||||||
|
t.Fatal("erwartet gesperrt nach 3 fehlversuchen (schwelle=3)")
|
||||||
|
}
|
||||||
|
if !lockedUntil.After(time.Now()) {
|
||||||
|
t.Fatal("lockedUntil sollte in der zukunft liegen")
|
||||||
|
}
|
||||||
|
|
||||||
|
isLocked, _, err := store.IsLocked(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("is locked: %v", err)
|
||||||
|
}
|
||||||
|
if !isLocked {
|
||||||
|
t.Fatal("IsLocked sollte true liefern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 1: zwei Dienstinstanzen teilen sich
|
||||||
|
// denselben Zaehler (Mehrinstanz-Test) — hier durch zwei unabhaengige
|
||||||
|
// pgxpool.Pool-Verbindungen zur selben Datenbank simuliert.
|
||||||
|
func TestRecordFailure_SharedAcrossInstances(t *testing.T) {
|
||||||
|
_, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
email := uniqueEmail()
|
||||||
|
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
instanceA, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("instance a: %v", err)
|
||||||
|
}
|
||||||
|
defer instanceA.Close()
|
||||||
|
instanceB, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("instance b: %v", err)
|
||||||
|
}
|
||||||
|
defer instanceB.Close()
|
||||||
|
|
||||||
|
storeA := NewStore(instanceA).WithPolicy(4, time.Hour)
|
||||||
|
storeB := NewStore(instanceB).WithPolicy(4, time.Hour)
|
||||||
|
|
||||||
|
// Zwei fehlversuche ueber "instanz A", zwei ueber "instanz B" — beide
|
||||||
|
// muessen sich denselben Zaehler teilen und gemeinsam die Schwelle erreichen.
|
||||||
|
if _, _, err := storeA.RecordFailure(ctx, email); err != nil {
|
||||||
|
t.Fatalf("a1: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := storeB.RecordFailure(ctx, email); err != nil {
|
||||||
|
t.Fatalf("b1: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := storeA.RecordFailure(ctx, email); err != nil {
|
||||||
|
t.Fatalf("a2: %v", err)
|
||||||
|
}
|
||||||
|
locked, _, err := storeB.RecordFailure(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("b2: %v", err)
|
||||||
|
}
|
||||||
|
if !locked {
|
||||||
|
t.Fatal("erwartet gesperrt nach insgesamt 4 fehlversuchen ueber beide instanzen hinweg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3: automatische Entsperrung nach Ablauf
|
||||||
|
// der Sperrzeit, Zeitversatz automatisiert getestet.
|
||||||
|
func TestIsLocked_AutoUnlocksAfterExpiry(t *testing.T) {
|
||||||
|
pool, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
store := NewStore(pool).WithPolicy(1, 50*time.Millisecond)
|
||||||
|
email := uniqueEmail()
|
||||||
|
|
||||||
|
locked, _, err := store.RecordFailure(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("record failure: %v", err)
|
||||||
|
}
|
||||||
|
if !locked {
|
||||||
|
t.Fatal("erwartet sofort gesperrt bei schwelle=1")
|
||||||
|
}
|
||||||
|
|
||||||
|
isLocked, _, err := store.IsLocked(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("is locked (vor ablauf): %v", err)
|
||||||
|
}
|
||||||
|
if !isLocked {
|
||||||
|
t.Fatal("erwartet gesperrt vor ablauf der sperrzeit")
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(70 * time.Millisecond)
|
||||||
|
|
||||||
|
isLocked, _, err = store.IsLocked(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("is locked (nach ablauf): %v", err)
|
||||||
|
}
|
||||||
|
if isLocked {
|
||||||
|
t.Fatal("erwartet automatisch entsperrt nach ablauf der sperrzeit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3: Entsperrung durch Administratoreingriff.
|
||||||
|
func TestUnlock_ClearsLockImmediately(t *testing.T) {
|
||||||
|
pool, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
store := NewStore(pool).WithPolicy(1, time.Hour)
|
||||||
|
email := uniqueEmail()
|
||||||
|
|
||||||
|
if _, _, err := store.RecordFailure(ctx, email); err != nil {
|
||||||
|
t.Fatalf("record failure: %v", err)
|
||||||
|
}
|
||||||
|
locked, _, _ := store.IsLocked(ctx, email)
|
||||||
|
if !locked {
|
||||||
|
t.Fatal("erwartet gesperrt vor admin-eingriff")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Unlock(ctx, email); err != nil {
|
||||||
|
t.Fatalf("unlock: %v", err)
|
||||||
|
}
|
||||||
|
locked, _, err := store.IsLocked(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("is locked nach unlock: %v", err)
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
|
t.Fatal("erwartet sofort entsperrt nach admin-eingriff, unabhaengig von der sperrzeit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Integrationstest: GuardedLogin komponiert IAM-02 LoginService mit dem
|
||||||
|
// Lockout-Zustand, ohne LoginService selbst zu veraendern.
|
||||||
|
func TestGuardedLogin_LocksAfterRepeatedFailures(t *testing.T) {
|
||||||
|
pool, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
email := uniqueEmail()
|
||||||
|
userStore := user.NewTenantUserStore(pool)
|
||||||
|
u, err := userStore.Create(ctx, email, "Lockout Test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
hash, err := auth.HashPassword("korrektes-passwort")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash: %v", err)
|
||||||
|
}
|
||||||
|
if err := userStore.SetPasswordHash(ctx, u.ID, hash); err != nil {
|
||||||
|
t.Fatalf("set password: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
loginService := auth.NewLoginService(userStore, issuer, "acme")
|
||||||
|
lockoutStore := NewStore(pool).WithPolicy(3, time.Hour)
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if _, err := GuardedLogin(ctx, lockoutStore, loginService, email, "falsches-passwort"); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||||
|
t.Fatalf("versuch %d: erwartet ErrInvalidCredentials, habe %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selbst das RICHTIGE Passwort wird jetzt abgewiesen, weil das Konto gesperrt ist.
|
||||||
|
if _, err := GuardedLogin(ctx, lockoutStore, loginService, email, "korrektes-passwort"); !errors.Is(err, ErrAccountLocked) {
|
||||||
|
t.Fatalf("erwartet ErrAccountLocked trotz korrektem passwort, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// Package migrate implementiert Core TEN-07: das automatisierte, pro Tenant
|
||||||
|
// fehlerisolierte Ausrollen von SQL-Migrationen ueber alle registrierten
|
||||||
|
// Mandanten-Datenbanken (Modell C — jede Migration muss N-mal statt einmal
|
||||||
|
// laufen, siehe TEN-01).
|
||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
const upSuffix = ".up.sql"
|
||||||
|
|
||||||
|
type Migration struct {
|
||||||
|
Version string
|
||||||
|
SQL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadMigrations liest alle *.up.sql-Dateien aus dir und sortiert sie nach
|
||||||
|
// Dateiname (die bestehende Namenskonvention 0001_..., 0002_... aus TEN-01
|
||||||
|
// sorgt fuer eine stabile Reihenfolge).
|
||||||
|
func LoadMigrations(dir string) ([]Migration, error) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("migrationsverzeichnis lesen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), upSuffix) {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
migrations := make([]Migration, 0, len(names))
|
||||||
|
for _, name := range names {
|
||||||
|
content, err := os.ReadFile(filepath.Join(dir, name))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("migration %q lesen: %w", name, err)
|
||||||
|
}
|
||||||
|
version := strings.TrimSuffix(name, upSuffix)
|
||||||
|
migrations = append(migrations, Migration{Version: version, SQL: string(content)})
|
||||||
|
}
|
||||||
|
return migrations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrationStatus ist der pro Tenant und Version nachvollziehbare Stand
|
||||||
|
// (Akzeptanzkriterium 3): welche Version, wann zuletzt versucht, Erfolg oder
|
||||||
|
// Fehler.
|
||||||
|
type MigrationStatus struct {
|
||||||
|
Version string
|
||||||
|
AppliedAt time.Time
|
||||||
|
Success bool
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TenantResult fasst das Ergebnis eines Rollout-Versuchs fuer EINEN Tenant
|
||||||
|
// zusammen — wird von Orchestrator.RolloutAll pro Tenant gesammelt, damit ein
|
||||||
|
// Fehlschlag bei einem Mandanten die anderen nicht blockiert (Akzeptanzkriterium 2).
|
||||||
|
type TenantResult struct {
|
||||||
|
TenantSlug string
|
||||||
|
Applied []string // erfolgreich in diesem Lauf angewendete Versionen
|
||||||
|
FailedAt string // Version, bei der abgebrochen wurde; leer wenn kein Fehlschlag
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Orchestrator rollt Migrationen ueber alle in der Registry gefuehrten
|
||||||
|
// Mandanten aus. Baut bewusst NICHT auf TEN-06 (Router) auf — Migrations-
|
||||||
|
// Rollouts sind seltene Batch-Vorgaenge, kein Hot-Path, ein kurzlebiger Pool
|
||||||
|
// pro Tenant und Lauf ist hier einfacher und unabhaengig von TEN-06 testbar.
|
||||||
|
type Orchestrator struct {
|
||||||
|
registry *tenant.Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrchestrator(registry *tenant.Registry) *Orchestrator {
|
||||||
|
return &Orchestrator{registry: registry}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureTableSQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version TEXT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
success BOOLEAN NOT NULL,
|
||||||
|
error TEXT
|
||||||
|
);`
|
||||||
|
|
||||||
|
// RolloutAll wendet migrations auf JEDE registrierte Tenant-Datenbank an.
|
||||||
|
// Jeder Tenant laeuft unabhaengig — ein Fehlschlag bei einem Mandanten wird
|
||||||
|
// im jeweiligen TenantResult festgehalten und blockiert die uebrigen nicht
|
||||||
|
// (Akzeptanzkriterium 1 + 2).
|
||||||
|
func (o *Orchestrator) RolloutAll(ctx context.Context, migrations []Migration) ([]TenantResult, error) {
|
||||||
|
tenants, err := o.registry.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenants fuer rollout auflisten: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]TenantResult, 0, len(tenants))
|
||||||
|
for _, t := range tenants {
|
||||||
|
results = append(results, o.rolloutForTenant(ctx, t, migrations))
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) rolloutForTenant(ctx context.Context, t tenant.Tenant, migrations []Migration) TenantResult {
|
||||||
|
result := TenantResult{TenantSlug: t.Slug}
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, t.DBDSN)
|
||||||
|
if err != nil {
|
||||||
|
result.Err = fmt.Errorf("verbindung zu tenant %q: %w", t.Slug, err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, ensureTableSQL); err != nil {
|
||||||
|
result.Err = fmt.Errorf("schema_migrations anlegen fuer tenant %q: %w", t.Slug, err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range migrations {
|
||||||
|
alreadyApplied, err := isAlreadySuccessful(ctx, pool, m.Version)
|
||||||
|
if err != nil {
|
||||||
|
result.Err = fmt.Errorf("migrationsstand lesen fuer tenant %q, version %q: %w", t.Slug, m.Version, err)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if alreadyApplied {
|
||||||
|
continue // Akzeptanzkriterium 3 / Pruefung 3: keine erneute Anwendung.
|
||||||
|
}
|
||||||
|
|
||||||
|
_, execErr := pool.Exec(ctx, m.SQL)
|
||||||
|
if execErr != nil {
|
||||||
|
recordAttempt(ctx, pool, m.Version, false, execErr.Error())
|
||||||
|
result.FailedAt = m.Version
|
||||||
|
result.Err = fmt.Errorf("migration %q fuer tenant %q fehlgeschlagen: %w", m.Version, t.Slug, execErr)
|
||||||
|
return result // spaetere Migrationen bauen typischerweise auf dieser auf — Abbruch NUR fuer diesen Tenant.
|
||||||
|
}
|
||||||
|
|
||||||
|
recordAttempt(ctx, pool, m.Version, true, "")
|
||||||
|
result.Applied = append(result.Applied, m.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAlreadySuccessful(ctx context.Context, pool *pgxpool.Pool, version string) (bool, error) {
|
||||||
|
var success bool
|
||||||
|
err := pool.QueryRow(ctx, `SELECT success FROM schema_migrations WHERE version = $1`, version).Scan(&success)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return success, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordAttempt(ctx context.Context, pool *pgxpool.Pool, version string, success bool, errMsg string) {
|
||||||
|
var errVal *string
|
||||||
|
if errMsg != "" {
|
||||||
|
errVal = &errMsg
|
||||||
|
}
|
||||||
|
_, _ = pool.Exec(ctx, `
|
||||||
|
INSERT INTO schema_migrations (version, applied_at, success, error)
|
||||||
|
VALUES ($1, now(), $2, $3)
|
||||||
|
ON CONFLICT (version) DO UPDATE SET applied_at = now(), success = $2, error = $3
|
||||||
|
`, version, success, errVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status liefert den Migrationsstand eines einzelnen Tenants (Akzeptanzkriterium 3).
|
||||||
|
func (o *Orchestrator) Status(ctx context.Context, tenantSlug string) ([]MigrationStatus, error) {
|
||||||
|
t, err := o.registry.GetBySlug(ctx, tenantSlug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("tenant %q nicht gefunden: %w", tenantSlug, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, t.DBDSN)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("verbindung zu tenant %q: %w", tenantSlug, err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
rows, err := pool.Query(ctx, `
|
||||||
|
SELECT version, applied_at, success, COALESCE(error, '')
|
||||||
|
FROM schema_migrations ORDER BY version
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("migrationsstand abfragen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []MigrationStatus
|
||||||
|
for rows.Next() {
|
||||||
|
var s MigrationStatus
|
||||||
|
if err := rows.Scan(&s.Version, &s.AppliedAt, &s.Success, &s.Error); err != nil {
|
||||||
|
return nil, fmt.Errorf("migrationsstand lesen: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadMigrations_SortsByFilename(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeFile(t, dir, "0002_second.up.sql", "SELECT 2;")
|
||||||
|
writeFile(t, dir, "0001_first.up.sql", "SELECT 1;")
|
||||||
|
writeFile(t, dir, "0001_first.down.sql", "SELECT 'ignored';") // muss ignoriert werden
|
||||||
|
|
||||||
|
migrations, err := LoadMigrations(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if len(migrations) != 2 {
|
||||||
|
t.Fatalf("erwartet 2 migrationen, habe %d", len(migrations))
|
||||||
|
}
|
||||||
|
if migrations[0].Version != "0001_first" || migrations[1].Version != "0002_second" {
|
||||||
|
t.Fatalf("unerwartete reihenfolge: %+v", migrations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, dir, name, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(dir+"/"+name, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupOrchestratorTest(t *testing.T, tenantCount int) (*Orchestrator, []tenant.Tenant, *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()
|
||||||
|
|
||||||
|
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(),
|
||||||
|
previous_status TEXT,
|
||||||
|
deletion_scheduled_at TIMESTAMPTZ
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatalf("registry-schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := tenant.NewRegistry(registryPool)
|
||||||
|
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
||||||
|
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
|
||||||
|
|
||||||
|
var tenants []tenant.Tenant
|
||||||
|
var slugs []string
|
||||||
|
for i := 0; i < tenantCount; i++ {
|
||||||
|
slug := fmt.Sprintf("mig_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)
|
||||||
|
}
|
||||||
|
|
||||||
|
orchestrator := NewOrchestrator(registry)
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
for _, slug := range slugs {
|
||||||
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, "tenant_"+slug))
|
||||||
|
}
|
||||||
|
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = ANY($1)`, slugs)
|
||||||
|
registryPool.Close()
|
||||||
|
adminPool.Close()
|
||||||
|
}
|
||||||
|
return orchestrator, tenants, adminPool, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + 2 + Pruefung 1: Rollout gegen mehrere Tenants, einer
|
||||||
|
// davon absichtlich inkompatibel — die anderen laufen trotzdem durch.
|
||||||
|
func TestRolloutAll_IsolatesFailurePerTenant(t *testing.T) {
|
||||||
|
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 3)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrations := []Migration{
|
||||||
|
{Version: "0001_demo_a", SQL: "CREATE TABLE demo_a (id INT);"},
|
||||||
|
{Version: "0002_demo_b", SQL: "CREATE TABLE demo_b (id INT);"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// tenants[1] absichtlich inkompatibel machen: demo_b existiert schon,
|
||||||
|
// migration 0002 schlaegt dort mit "already exists" fehl.
|
||||||
|
badTenant := tenants[1]
|
||||||
|
badPool, err := pgxpool.New(ctx, badTenant.DBDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect bad tenant: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := badPool.Exec(ctx, "CREATE TABLE demo_b (id INT);"); err != nil {
|
||||||
|
t.Fatalf("inkompatiblen zustand vorbereiten: %v", err)
|
||||||
|
}
|
||||||
|
badPool.Close()
|
||||||
|
|
||||||
|
results, err := orchestrator.RolloutAll(ctx, migrations)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rollout: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 3 {
|
||||||
|
t.Fatalf("erwartet 3 ergebnisse, habe %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
byslug := map[string]TenantResult{}
|
||||||
|
for _, r := range results {
|
||||||
|
byslug[r.TenantSlug] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
good0 := byslug[tenants[0].Slug]
|
||||||
|
if good0.Err != nil || len(good0.Applied) != 2 {
|
||||||
|
t.Fatalf("tenant[0] sollte beide migrationen erhalten, habe %+v", good0)
|
||||||
|
}
|
||||||
|
good2 := byslug[tenants[2].Slug]
|
||||||
|
if good2.Err != nil || len(good2.Applied) != 2 {
|
||||||
|
t.Fatalf("tenant[2] sollte beide migrationen erhalten, habe %+v", good2)
|
||||||
|
}
|
||||||
|
|
||||||
|
bad := byslug[badTenant.Slug]
|
||||||
|
if bad.Err == nil {
|
||||||
|
t.Fatal("erwartet fehler fuer den inkompatiblen tenant")
|
||||||
|
}
|
||||||
|
if bad.FailedAt != "0002_demo_b" {
|
||||||
|
t.Fatalf("failedAt = %q, want 0002_demo_b", bad.FailedAt)
|
||||||
|
}
|
||||||
|
if len(bad.Applied) != 1 || bad.Applied[0] != "0001_demo_a" {
|
||||||
|
t.Fatalf("erwartet dass 0001_demo_a trotzdem erfolgreich war, habe %+v", bad.Applied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 2: Migrationsstand-Abfrage liefert fuer
|
||||||
|
// jeden Tenant den korrekten, unabhaengigen Stand.
|
||||||
|
func TestStatus_ReflectsPerTenantState(t *testing.T) {
|
||||||
|
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
migrations := []Migration{
|
||||||
|
{Version: "0001_ok", SQL: "CREATE TABLE ok_table (id INT);"},
|
||||||
|
{Version: "0002_fail", SQL: "SELECT this_column_does_not_exist FROM ok_table;"},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := orchestrator.RolloutAll(ctx, migrations); err != nil {
|
||||||
|
t.Fatalf("rollout: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := orchestrator.Status(ctx, tenants[0].Slug)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("status: %v", err)
|
||||||
|
}
|
||||||
|
if len(status) != 2 {
|
||||||
|
t.Fatalf("erwartet 2 status-eintraege, habe %d", len(status))
|
||||||
|
}
|
||||||
|
if !status[0].Success || status[0].Version != "0001_ok" {
|
||||||
|
t.Fatalf("status[0] unerwartet: %+v", status[0])
|
||||||
|
}
|
||||||
|
if status[1].Success || status[1].Version != "0002_fail" || status[1].Error == "" {
|
||||||
|
t.Fatalf("status[1] sollte fehlgeschlagen sein mit fehlertext: %+v", status[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefung 3: wiederholter Rollout-Versuch wendet bereits erfolgreiche
|
||||||
|
// Migrationen NICHT erneut an und kann die zuvor fehlgeschlagene nachholen,
|
||||||
|
// sobald die Ursache behoben ist.
|
||||||
|
func TestRolloutAll_RetryDoesNotReapplySuccessful(t *testing.T) {
|
||||||
|
orchestrator, tenants, _, cleanup := setupOrchestratorTest(t, 1)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 0002 schlaegt beim ersten Versuch fehl, weil demo_conflict schon
|
||||||
|
// existiert (wir legen sie vorher an, um den Fehlschlag zu erzwingen).
|
||||||
|
pool, err := pgxpool.New(ctx, tenants[0].DBDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, "CREATE TABLE demo_conflict (id INT);"); err != nil {
|
||||||
|
t.Fatalf("vorbedingung: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrations := []Migration{
|
||||||
|
{Version: "0001_ok", SQL: "CREATE TABLE demo_first (id INT);"}, // OHNE IF NOT EXISTS,
|
||||||
|
// damit ein erneutes Anwenden nachweislich fehlschlagen wuerde.
|
||||||
|
{Version: "0002_conflict", SQL: "CREATE TABLE demo_conflict (id INT);"},
|
||||||
|
}
|
||||||
|
|
||||||
|
firstRun, err := orchestrator.RolloutAll(ctx, migrations)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rollout 1: %v", err)
|
||||||
|
}
|
||||||
|
if firstRun[0].FailedAt != "0002_conflict" {
|
||||||
|
t.Fatalf("erwartet fehlschlag bei 0002_conflict im ersten lauf, habe %+v", firstRun[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ursache beheben.
|
||||||
|
if _, err := pool.Exec(ctx, "DROP TABLE demo_conflict;"); err != nil {
|
||||||
|
t.Fatalf("ursache beheben: %v", err)
|
||||||
|
}
|
||||||
|
pool.Close()
|
||||||
|
|
||||||
|
secondRun, err := orchestrator.RolloutAll(ctx, migrations)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rollout 2: %v", err)
|
||||||
|
}
|
||||||
|
// Waere 0001_ok erneut angewendet worden ("CREATE TABLE demo_first" ohne
|
||||||
|
// IF NOT EXISTS), haette das einen Fehler erzeugt statt eines sauberen
|
||||||
|
// Applied-Eintrags fuer 0002_conflict.
|
||||||
|
if secondRun[0].Err != nil {
|
||||||
|
t.Fatalf("zweiter lauf sollte fehlerfrei sein, habe %+v", secondRun[0])
|
||||||
|
}
|
||||||
|
if len(secondRun[0].Applied) != 1 || secondRun[0].Applied[0] != "0002_conflict" {
|
||||||
|
t.Fatalf("erwartet nur 0002_conflict im zweiten lauf angewendet, habe %+v", secondRun[0].Applied)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package moduleregistry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrModuleNotRegistered = errors.New("moduleregistry: modul muss vor provisionierung registriert sein")
|
||||||
|
ErrInvalidCredential = errors.New("moduleregistry: ungueltiges oder fehlendes service-credential")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Provision stellt ein Service-Credential (Client-ID + Secret) fuer eine
|
||||||
|
// Modul-Instanz aus (Akzeptanzkriterium 4). Das Secret wird NUR beim
|
||||||
|
// Ausstellen im Klartext zurueckgegeben, gespeichert wird ausschliesslich
|
||||||
|
// dessen SHA-256-Hash.
|
||||||
|
func (r *Registry) Provision(ctx context.Context, moduleName string) (clientID, secret string, err error) {
|
||||||
|
if _, err := r.Get(ctx, moduleName); err != nil {
|
||||||
|
if errors.Is(err, ErrModuleNotFound) {
|
||||||
|
return "", "", ErrModuleNotRegistered
|
||||||
|
}
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID, err = randomToken(16)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("client-id erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
secret, err = randomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("secret erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
hash := hashSecret(secret)
|
||||||
|
|
||||||
|
_, err = r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO module_credentials (module_name, client_id, secret_hash, issued_at)
|
||||||
|
VALUES ($1, $2, $3, now())
|
||||||
|
ON CONFLICT (module_name) DO UPDATE SET client_id = $2, secret_hash = $3, issued_at = now()
|
||||||
|
`, moduleName, clientID, hash)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("credential speichern: %w", err)
|
||||||
|
}
|
||||||
|
return clientID, secret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate prueft ein Service-Credential timing-safe (Referenzmuster
|
||||||
|
// siehe AUD-02) — Aufrufe ohne gueltiges Credential werden abgelehnt
|
||||||
|
// (Akzeptanzkriterium 4 / Pruefung 4).
|
||||||
|
func (r *Registry) Authenticate(ctx context.Context, clientID, secret string) (moduleName string, ok bool, err error) {
|
||||||
|
if clientID == "" || secret == "" {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var storedHash []byte
|
||||||
|
err = r.pool.QueryRow(ctx, `
|
||||||
|
SELECT module_name, secret_hash FROM module_credentials WHERE client_id = $1
|
||||||
|
`, clientID).Scan(&moduleName, &storedHash)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return "", false, fmt.Errorf("credential lesen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !timingSafeEqual(hashSecret(secret), storedHash) {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
return moduleName, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken(n int) (string, error) {
|
||||||
|
buf := make([]byte, n)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashSecret(secret string) []byte {
|
||||||
|
sum := sha256.Sum256([]byte(secret))
|
||||||
|
return sum[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// timingSafeEqual folgt derselben Referenzimplementierung wie AUD-02
|
||||||
|
// (subtle.ConstantTimeCompare) — projektweite Konvention fuer jeden
|
||||||
|
// sicherheitsrelevanten Vergleich.
|
||||||
|
func timingSafeEqual(a, b []byte) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare(a, b) == 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package moduleregistry
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// RequireActiveModule weist Anfragen an ein nicht aktiviertes Modul ZENTRAL
|
||||||
|
// ab, bevor der eigentliche Modul-Handler erreicht wird (Akzeptanzkriterium 2 /
|
||||||
|
// Pruefung 1) — Casbin-Prinzip: Durchsetzung als Middleware statt verstreuter
|
||||||
|
// Pruefungen in jedem Handler. tenantSlug/moduleName werden hier ueber
|
||||||
|
// Query-Parameter gelesen (echte Extraktion aus JWT/Tenant-Kontext ist
|
||||||
|
// API-05/TEN-06, nicht Teil dieser Kachel).
|
||||||
|
func (r *Registry) RequireActiveModule(moduleName string, next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
tenantSlug := req.URL.Query().Get("tenant")
|
||||||
|
active, err := r.IsActive(req.Context(), tenantSlug, moduleName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "aktivierungspruefung fehlgeschlagen", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !active {
|
||||||
|
http.Error(w, "modul nicht aktiviert", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireServiceCredential authentifiziert eine Modul-Instanz ueber ihr
|
||||||
|
// Service-Credential (X-Client-Id/X-Client-Secret-Header) BEVOR der
|
||||||
|
// eigentliche Handler erreicht wird (Akzeptanzkriterium 4 / Pruefung 4).
|
||||||
|
func (r *Registry) RequireServiceCredential(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
clientID := req.Header.Get("X-Client-Id")
|
||||||
|
secret := req.Header.Get("X-Client-Secret")
|
||||||
|
|
||||||
|
_, ok, err := r.Authenticate(req.Context(), clientID, secret)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "authentifizierung fehlgeschlagen", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, ErrInvalidCredential.Error(), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
// Package moduleregistry implementiert Core API-02: die Registry, in der
|
||||||
|
// sich Fachmodule (DMS, Mail, weitere) mit Metadaten eintragen, gekoppelt an
|
||||||
|
// die Aktivierungspruefung aus LIC-02 (Feature-Flags). Zusaetzlich
|
||||||
|
// authentifiziert die Registry Modul-Instanzen selbst ueber ein bei
|
||||||
|
// Provisionierung ausgestelltes Service-Credential.
|
||||||
|
package moduleregistry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrMissingName = errors.New("moduleregistry: name darf nicht leer sein")
|
||||||
|
ErrMissingVersion = errors.New("moduleregistry: version darf nicht leer sein")
|
||||||
|
ErrModuleNotFound = errors.New("moduleregistry: modul nicht registriert")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Module struct {
|
||||||
|
Name string
|
||||||
|
Version string
|
||||||
|
RequiredFlags []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
flags *flag.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistry(pool *pgxpool.Pool, flags *flag.Service) *Registry {
|
||||||
|
return &Registry{pool: pool, flags: flags}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register traegt ein Modul mit Name, Version und benoetigten Feature-Flags
|
||||||
|
// ein (Akzeptanzkriterium 1). Fehlende Pflichtangaben werden abgewiesen
|
||||||
|
// (Akzeptanzkriterium 1 / Pruefung 2). Erneutes Register desselben Namens
|
||||||
|
// aktualisiert Version/Flags (Redeploy-Fall).
|
||||||
|
func (r *Registry) Register(ctx context.Context, name, version string, requiredFlags []string) (Module, error) {
|
||||||
|
if name == "" {
|
||||||
|
return Module{}, ErrMissingName
|
||||||
|
}
|
||||||
|
if version == "" {
|
||||||
|
return Module{}, ErrMissingVersion
|
||||||
|
}
|
||||||
|
if requiredFlags == nil {
|
||||||
|
requiredFlags = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := r.pool.Exec(ctx, `
|
||||||
|
INSERT INTO modules (name, version, required_flags, registered_at)
|
||||||
|
VALUES ($1, $2, $3, now())
|
||||||
|
ON CONFLICT (name) DO UPDATE SET version = $2, required_flags = $3, registered_at = now()
|
||||||
|
`, name, version, requiredFlags)
|
||||||
|
if err != nil {
|
||||||
|
return Module{}, fmt.Errorf("modul registrieren: %w", err)
|
||||||
|
}
|
||||||
|
return Module{Name: name, Version: version, RequiredFlags: requiredFlags}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) Get(ctx context.Context, name string) (Module, error) {
|
||||||
|
var m Module
|
||||||
|
m.Name = name
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT version, required_flags FROM modules WHERE name = $1
|
||||||
|
`, name).Scan(&m.Version, &m.RequiredFlags)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Module{}, ErrModuleNotFound
|
||||||
|
}
|
||||||
|
return Module{}, fmt.Errorf("modul lesen: %w", err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List liefert alle registrierten Module (Akzeptanzkriterium 3: ueber API
|
||||||
|
// abfragbar, z.B. fuer Statusseite/Lizenzoberflaeche).
|
||||||
|
func (r *Registry) List(ctx context.Context) ([]Module, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `SELECT name, version, required_flags FROM modules ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("module auflisten: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Module
|
||||||
|
for rows.Next() {
|
||||||
|
var m Module
|
||||||
|
if err := rows.Scan(&m.Name, &m.Version, &m.RequiredFlags); err != nil {
|
||||||
|
return nil, fmt.Errorf("modul lesen: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsActive prueft, ob ein registriertes Modul fuer einen Tenant aktiviert
|
||||||
|
// ist: registriert UND alle benoetigten Feature-Flags sind fuer diesen
|
||||||
|
// Tenant aktiv (Akzeptanzkriterium 2). Ein nicht registriertes Modul gilt
|
||||||
|
// immer als nicht aktiv.
|
||||||
|
func (r *Registry) IsActive(ctx context.Context, tenantSlug, moduleName string) (bool, error) {
|
||||||
|
m, err := r.Get(ctx, moduleName)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrModuleNotFound) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, flagKey := range m.RequiredFlags {
|
||||||
|
if !r.flags.IsEnabled(ctx, tenantSlug, flagKey) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package moduleregistry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTest(t *testing.T) (*Registry, *flag.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 feature_flags (
|
||||||
|
key TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
rollout_percentage INT NOT NULL DEFAULT 0, target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS modules (
|
||||||
|
name TEXT PRIMARY KEY, version TEXT NOT NULL CHECK (version <> ''),
|
||||||
|
required_flags TEXT[] NOT NULL DEFAULT '{}', registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS module_credentials (
|
||||||
|
module_name TEXT PRIMARY KEY REFERENCES modules(name),
|
||||||
|
client_id TEXT NOT NULL UNIQUE, secret_hash BYTEA NOT NULL,
|
||||||
|
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
flagStore := flag.NewStore(pool)
|
||||||
|
// Kurze TTL, damit Tests, die den Flag-Store direkt aendern (an
|
||||||
|
// Registry.IsActive vorbei), den neuen Stand ohne manuelles Invalidate
|
||||||
|
// zuverlaessig sehen.
|
||||||
|
flagService := flag.NewService(flagStore, 10*time.Millisecond)
|
||||||
|
registry := NewRegistry(pool, flagService)
|
||||||
|
|
||||||
|
cleanup := func() { pool.Close() }
|
||||||
|
return registry, flagStore, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueModuleName(t *testing.T) string {
|
||||||
|
return fmt.Sprintf("dms_%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 2: fehlende Pflichtangaben abgewiesen.
|
||||||
|
func TestRegister_RejectsMissingFields(t *testing.T) {
|
||||||
|
registry, _, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := registry.Register(ctx, "", "1.0", nil); !errors.Is(err, ErrMissingName) {
|
||||||
|
t.Fatalf("erwartet ErrMissingName, habe %v", err)
|
||||||
|
}
|
||||||
|
if _, err := registry.Register(ctx, "dms", "", nil); !errors.Is(err, ErrMissingVersion) {
|
||||||
|
t.Fatalf("erwartet ErrMissingVersion, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegister_AndGet(t *testing.T) {
|
||||||
|
registry, _, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
name := uniqueModuleName(t)
|
||||||
|
|
||||||
|
m, err := registry.Register(ctx, name, "1.2.0", []string{"dms_enabled"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
if m.Version != "1.2.0" || len(m.RequiredFlags) != 1 {
|
||||||
|
t.Fatalf("unerwartet: %+v", m)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := registry.Get(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.Version != "1.2.0" {
|
||||||
|
t.Fatalf("get version = %q", got.Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + 3 + Pruefung 3: konsistente Daten nach
|
||||||
|
// Aktivierung/Deaktivierung eines Moduls.
|
||||||
|
func TestIsActive_ReflectsFlagStateConsistently(t *testing.T) {
|
||||||
|
registry, flagStore, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
name := uniqueModuleName(t)
|
||||||
|
flagKey := name + "_enabled"
|
||||||
|
|
||||||
|
if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
active, err := registry.IsActive(ctx, "acme", name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("is active (vor flag): %v", err)
|
||||||
|
}
|
||||||
|
if active {
|
||||||
|
t.Fatal("erwartet nicht aktiv, solange flag nicht gesetzt ist")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil {
|
||||||
|
t.Fatalf("flag setzen: %v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond) // TTL abwarten
|
||||||
|
|
||||||
|
active, err = registry.IsActive(ctx, "acme", name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("is active (nach flag an): %v", err)
|
||||||
|
}
|
||||||
|
if !active {
|
||||||
|
t.Fatal("erwartet aktiv, nachdem flag aktiviert wurde")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: false}); err != nil {
|
||||||
|
t.Fatalf("flag zuruecksetzen: %v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond) // TTL abwarten
|
||||||
|
active, err = registry.IsActive(ctx, "acme", name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("is active (nach flag aus): %v", err)
|
||||||
|
}
|
||||||
|
if active {
|
||||||
|
t.Fatal("erwartet wieder nicht aktiv, nachdem flag deaktiviert wurde")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsActive_UnregisteredModuleIsNeverActive(t *testing.T) {
|
||||||
|
registry, _, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
active, err := registry.IsActive(ctx, "acme", "nie-registriert")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("is active: %v", err)
|
||||||
|
}
|
||||||
|
if active {
|
||||||
|
t.Fatal("unregistriertes modul darf nie aktiv sein")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 1: Anfrage an deaktiviertes Modul wird
|
||||||
|
// zentral abgewiesen, BEVOR die Modul-Logik erreicht wird.
|
||||||
|
func TestRequireActiveModule_BlocksBeforeHandler(t *testing.T) {
|
||||||
|
registry, flagStore, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
name := uniqueModuleName(t)
|
||||||
|
flagKey := name + "_enabled"
|
||||||
|
|
||||||
|
if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handlerReached := false
|
||||||
|
handler := registry.RequireActiveModule(name, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handlerReached = true
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler(rec, req)
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
if handlerReached {
|
||||||
|
t.Fatal("handler haette bei deaktiviertem modul NICHT erreicht werden duerfen")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil {
|
||||||
|
t.Fatalf("flag setzen: %v", err)
|
||||||
|
}
|
||||||
|
req2 := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil)
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
handler(rec2, req2)
|
||||||
|
if rec2.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status nach aktivierung = %d, want 200", rec2.Code)
|
||||||
|
}
|
||||||
|
if !handlerReached {
|
||||||
|
t.Fatal("handler haette bei aktiviertem modul erreicht werden muessen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 4 + Pruefung 4: gueltiges/ungueltiges Service-Credential.
|
||||||
|
func TestProvisionAndAuthenticate(t *testing.T) {
|
||||||
|
registry, _, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
name := uniqueModuleName(t)
|
||||||
|
|
||||||
|
if _, err := registry.Register(ctx, name, "1.0", nil); err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID, secret, err := registry.Provision(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
if clientID == "" || secret == "" {
|
||||||
|
t.Fatal("erwartet nicht-leere client-id/secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
moduleName, ok, err := registry.Authenticate(ctx, clientID, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authenticate (korrekt): %v", err)
|
||||||
|
}
|
||||||
|
if !ok || moduleName != name {
|
||||||
|
t.Fatalf("erwartet erfolgreiche authentifizierung fuer %q, habe ok=%v moduleName=%q", name, ok, moduleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok, err = registry.Authenticate(ctx, clientID, "falsches-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authenticate (falsch): %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatal("erwartet fehlschlag bei falschem secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok, err = registry.Authenticate(ctx, "unbekannte-client-id", secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authenticate (unbekannt): %v", err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
t.Fatal("erwartet fehlschlag bei unbekannter client-id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvision_RequiresRegisteredModule(t *testing.T) {
|
||||||
|
registry, _, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, _, err := registry.Provision(ctx, "nie-registriert"); !errors.Is(err, ErrModuleNotRegistered) {
|
||||||
|
t.Fatalf("erwartet ErrModuleNotRegistered, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireServiceCredential_RejectsInvalidAcceptsValid(t *testing.T) {
|
||||||
|
registry, _, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
name := uniqueModuleName(t)
|
||||||
|
|
||||||
|
if _, err := registry.Register(ctx, name, "1.0", nil); err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
clientID, secret, err := registry.Provision(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := registry.RequireServiceCredential(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fehlendes Credential.
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("ohne credential: status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falsches Secret.
|
||||||
|
req2 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
|
||||||
|
req2.Header.Set("X-Client-Id", clientID)
|
||||||
|
req2.Header.Set("X-Client-Secret", "falsch")
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
handler(rec2, req2)
|
||||||
|
if rec2.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("falsches secret: status = %d, want 401", rec2.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gueltiges Credential.
|
||||||
|
req3 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
|
||||||
|
req3.Header.Set("X-Client-Id", clientID)
|
||||||
|
req3.Header.Set("X-Client-Secret", secret)
|
||||||
|
rec3 := httptest.NewRecorder()
|
||||||
|
handler(rec3, req3)
|
||||||
|
if rec3.Code != http.StatusOK {
|
||||||
|
t.Fatalf("gueltiges credential: status = %d, want 200", rec3.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,158 @@
|
|||||||
|
// Package mtls implementiert Core API-09: gegenseitige TLS-Authentifizierung
|
||||||
|
// zwischen Core/DMS/Mail/Archive/Workflow/AI/Connect-Instanzen fuer
|
||||||
|
// Stufe-2/3-Installationen (siehe SKALIERUNGSKONZEPT.md) — schuetzt die
|
||||||
|
// VERTRAULICHKEIT des Transportwegs, dort wo API-05 (asymmetrische
|
||||||
|
// JWT-Signatur) und API-02 (Service-Credential) nur Authentizitaet/
|
||||||
|
// Integritaet, aber keine Transportverschluesselung zwischen getrennten
|
||||||
|
// Hosts absichern.
|
||||||
|
//
|
||||||
|
// Zertifikatsverteilung (Akzeptanzkriterium 1): Authority.IssueCert liefert
|
||||||
|
// PEM-kodiertes Zertifikat + privaten Schluessel, die ueber denselben Weg
|
||||||
|
// wie andere Secrets verteilt werden (Umgebungsvariablen/Secret-Store,
|
||||||
|
// niemals im Code) — automatisierbar, da IssueCert ein reiner
|
||||||
|
// Funktionsaufruf ohne manuelle Schritte ist.
|
||||||
|
package mtls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Authority ist eine interne Zertifizierungsstelle fuer Modul-zu-Modul-
|
||||||
|
// mTLS. Haelt — analog zu internal/moduletrust.KeyManager (API-05) — ALLE
|
||||||
|
// noch gueltigen historischen CA-Zertifikate im Vertrauensspeicher, damit
|
||||||
|
// eine Rotation bereits ausgestellte Leaf-Zertifikate nicht ungueltig macht
|
||||||
|
// (Akzeptanzkriterium 3: Rotation ohne Ausfallzeit).
|
||||||
|
type Authority struct {
|
||||||
|
current caGeneration
|
||||||
|
trustPool *x509.CertPool
|
||||||
|
generations []caGeneration
|
||||||
|
}
|
||||||
|
|
||||||
|
type caGeneration struct {
|
||||||
|
cert *x509.Certificate
|
||||||
|
key ed25519.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthority erzeugt eine frische interne CA.
|
||||||
|
func NewAuthority() (*Authority, error) {
|
||||||
|
gen, err := newCAGeneration()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
pool.AddCert(gen.cert)
|
||||||
|
return &Authority{current: gen, trustPool: pool, generations: []caGeneration{gen}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCAGeneration() (caGeneration, error) {
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return caGeneration{}, fmt.Errorf("ca-schluesselpaar erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
|
||||||
|
if err != nil {
|
||||||
|
return caGeneration{}, fmt.Errorf("seriennummer erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{CommonName: "nexarch-internal-ca"},
|
||||||
|
NotBefore: time.Now().Add(-time.Minute),
|
||||||
|
NotAfter: time.Now().Add(5 * 365 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
IsCA: true,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, template, template, pub, priv)
|
||||||
|
if err != nil {
|
||||||
|
return caGeneration{}, fmt.Errorf("ca-zertifikat erstellen: %w", err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(der)
|
||||||
|
if err != nil {
|
||||||
|
return caGeneration{}, fmt.Errorf("ca-zertifikat parsen: %w", err)
|
||||||
|
}
|
||||||
|
return caGeneration{cert: cert, key: priv}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate erzeugt eine NEUE CA-Generation fuer zukuenftige IssueCert-Aufrufe,
|
||||||
|
// behaelt aber ALLE bisherigen CA-Zertifikate im Vertrauensspeicher —
|
||||||
|
// bereits ausgestellte Leaf-Zertifikate bleiben dadurch gueltig, eine
|
||||||
|
// laufende mTLS-Verbindung wird durch Rotate NICHT unterbrochen
|
||||||
|
// (Akzeptanzkriterium 3 / Pruefung 2).
|
||||||
|
func (a *Authority) Rotate() error {
|
||||||
|
gen, err := newCAGeneration()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.trustPool.AddCert(gen.cert)
|
||||||
|
a.generations = append(a.generations, gen)
|
||||||
|
a.current = gen
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrustPool liefert den Vertrauensspeicher mit ALLEN (auch historischen,
|
||||||
|
// noch nicht abgelaufenen) CA-Zertifikaten — Grundlage der
|
||||||
|
// Server-seitigen Client-Zertifikatspruefung (Akzeptanzkriterium 2).
|
||||||
|
func (a *Authority) TrustPool() *x509.CertPool {
|
||||||
|
return a.trustPool
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssuedCert ist ein ausgestelltes Leaf-Zertifikat inklusive privatem
|
||||||
|
// Schluessel, PEM-kodiert zur Verteilung (Akzeptanzkriterium 1).
|
||||||
|
type IssuedCert struct {
|
||||||
|
CertPEM []byte
|
||||||
|
KeyPEM []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueCert stellt ein Leaf-Zertifikat fuer EINE Modul-Instanz aus, signiert
|
||||||
|
// mit der AKTUELLEN CA-Generation (Akzeptanzkriterium 1).
|
||||||
|
func (a *Authority) IssueCert(commonName string, validity time.Duration) (IssuedCert, error) {
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return IssuedCert{}, fmt.Errorf("leaf-schluesselpaar erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
|
||||||
|
if err != nil {
|
||||||
|
return IssuedCert{}, fmt.Errorf("seriennummer erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
template := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{CommonName: commonName},
|
||||||
|
NotBefore: time.Now().Add(-time.Minute),
|
||||||
|
NotAfter: time.Now().Add(validity),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
|
||||||
|
// DNSNames/IPAddresses decken lokale Testumgebungen ab (127.0.0.1,
|
||||||
|
// localhost) — in echten Stufe-2/3-Installationen entspricht
|
||||||
|
// commonName dem tatsaechlichen internen Hostnamen der Instanz.
|
||||||
|
DNSNames: []string{commonName, "localhost"},
|
||||||
|
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, template, a.current.cert, pub, a.current.key)
|
||||||
|
if err != nil {
|
||||||
|
return IssuedCert{}, fmt.Errorf("leaf-zertifikat erstellen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
keyBytes, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return IssuedCert{}, fmt.Errorf("leaf-schluessel serialisieren: %w", err)
|
||||||
|
}
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes})
|
||||||
|
|
||||||
|
return IssuedCert{CertPEM: certPEM, KeyPEM: keyPEM}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLSCertificate wandelt ein IssuedCert in ein tls.Certificate um, wie es
|
||||||
|
// tls.Config.Certificates erwartet.
|
||||||
|
func (c IssuedCert) TLSCertificate() (tls.Certificate, error) {
|
||||||
|
return tls.X509KeyPair(c.CertPEM, c.KeyPEM)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package mtls
|
||||||
|
|
||||||
|
import "crypto/tls"
|
||||||
|
|
||||||
|
// Mode legt fest, ob mTLS erzwungen wird. Fuer Stufe-1-Einzel-LXC ist mTLS
|
||||||
|
// laut Ticket "optional/nicht zwingend" (lokale Kommunikation) — ModeOff
|
||||||
|
// liefert einen ganz normalen TLS-Server OHNE Client-Zertifikatspruefung,
|
||||||
|
// damit Stufe-1-Betrieb unveraendert weiterlaeuft (Akzeptanzkriterium 3 /
|
||||||
|
// Pruefung 3).
|
||||||
|
type Mode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeOff Mode = "off" // Stufe 1: kein mTLS-Zwang
|
||||||
|
ModeRequired Mode = "required" // Stufe 2/3: Client-Zertifikat zwingend
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerTLSConfig liefert die tls.Config fuer eine Modul-Instanz, die
|
||||||
|
// eingehende Verbindungen ANDERER Modul-Instanzen annimmt.
|
||||||
|
//
|
||||||
|
// - ModeRequired: verlangt UND verifiziert ein Client-Zertifikat gegen
|
||||||
|
// authority.TrustPool() (Akzeptanzkriterium 2) — eine Verbindung ohne
|
||||||
|
// gueltiges Zertifikat wird vom TLS-Handshake selbst abgelehnt, bevor
|
||||||
|
// irgendein Anwendungscode erreicht wird.
|
||||||
|
// - ModeOff: normales TLS ohne Client-Zertifikatspruefung
|
||||||
|
// (Akzeptanzkriterium 3 / Pruefung 3: Stufe-1-Betrieb funktioniert
|
||||||
|
// weiterhin ohne mTLS-Zwang).
|
||||||
|
func ServerTLSConfig(mode Mode, serverCert tls.Certificate, authority *Authority) *tls.Config {
|
||||||
|
cfg := &tls.Config{Certificates: []tls.Certificate{serverCert}}
|
||||||
|
if mode == ModeRequired {
|
||||||
|
cfg.ClientAuth = tls.RequireAndVerifyClientCert
|
||||||
|
cfg.ClientCAs = authority.TrustPool()
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientTLSConfig liefert die tls.Config fuer eine Modul-Instanz, die eine
|
||||||
|
// Verbindung zu einer ANDEREN Modul-Instanz aufbaut — praesentiert das
|
||||||
|
// eigene Zertifikat und vertraut Gegenstellen, die von derselben Authority
|
||||||
|
// signiert wurden.
|
||||||
|
func ClientTLSConfig(mode Mode, clientCert tls.Certificate, authority *Authority) *tls.Config {
|
||||||
|
cfg := &tls.Config{RootCAs: authority.TrustPool()}
|
||||||
|
if mode == ModeRequired {
|
||||||
|
cfg.Certificates = []tls.Certificate{clientCert}
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package mtls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTLSServer(t *testing.T, cfg *tls.Config) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}))
|
||||||
|
server.TLS = cfg
|
||||||
|
server.StartTLS()
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetch(t *testing.T, url string, clientCfg *tls.Config) (int, error) {
|
||||||
|
t.Helper()
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{TLSClientConfig: clientCfg},
|
||||||
|
Timeout: 3 * time.Second,
|
||||||
|
}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.ReadAll(resp.Body)
|
||||||
|
return resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1: Zertifikatsverteilung ist ein reiner
|
||||||
|
// Funktionsaufruf, das Ergebnis ist gueltiges, wiederverwendbares
|
||||||
|
// PEM-Material.
|
||||||
|
func TestIssueCert_ProducesValidPEMRoundTrip(t *testing.T) {
|
||||||
|
authority, err := NewAuthority()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newauthority: %v", err)
|
||||||
|
}
|
||||||
|
issued, err := authority.IssueCert("dms", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issuecert: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := issued.TLSCertificate(); err != nil {
|
||||||
|
t.Fatalf("ausgestelltes zertifikat nicht als tls.Certificate ladbar: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 1: eine Verbindung ohne gueltiges
|
||||||
|
// Client-Zertifikat wird abgelehnt, eine mit gueltigem angenommen.
|
||||||
|
func TestServerTLSConfig_RejectsMissingClientCert(t *testing.T) {
|
||||||
|
authority, err := NewAuthority()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newauthority: %v", err)
|
||||||
|
}
|
||||||
|
serverIssued, err := authority.IssueCert("core", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("server-zertifikat ausstellen: %v", err)
|
||||||
|
}
|
||||||
|
serverCert, err := serverIssued.TLSCertificate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("server-zertifikat laden: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := newTLSServer(t, ServerTLSConfig(ModeRequired, serverCert, authority))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Client OHNE Zertifikat, aber mit korrektem RootCA-Vertrauen fuer den
|
||||||
|
// Server — der Handshake muss trotzdem an der fehlenden Client-Auth
|
||||||
|
// scheitern.
|
||||||
|
_, err = fetch(t, server.URL, &tls.Config{RootCAs: authority.TrustPool()})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("erwartet fehlschlagenden handshake ohne client-zertifikat")
|
||||||
|
}
|
||||||
|
|
||||||
|
clientIssued, err := authority.IssueCert("mail", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client-zertifikat ausstellen: %v", err)
|
||||||
|
}
|
||||||
|
clientCert, err := clientIssued.TLSCertificate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client-zertifikat laden: %v", err)
|
||||||
|
}
|
||||||
|
status, err := fetch(t, server.URL, ClientTLSConfig(ModeRequired, clientCert, authority))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("erwartet erfolgreiche verbindung mit gueltigem client-zertifikat: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 2: Rotation der CA unterbricht laufenden
|
||||||
|
// Betrieb nicht — ein VOR der Rotation ausgestelltes Leaf-Zertifikat
|
||||||
|
// funktioniert danach weiter, UND ein NACH der Rotation neu ausgestelltes
|
||||||
|
// funktioniert ebenfalls, gegen denselben, weiterlaufenden Server (kein
|
||||||
|
// Neustart noetig, da TrustPool() denselben *x509.CertPool zurueckliefert,
|
||||||
|
// den Rotate() erweitert).
|
||||||
|
func TestAuthority_RotateWithoutDowntime(t *testing.T) {
|
||||||
|
authority, err := NewAuthority()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newauthority: %v", err)
|
||||||
|
}
|
||||||
|
serverIssued, err := authority.IssueCert("core", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("server-zertifikat ausstellen: %v", err)
|
||||||
|
}
|
||||||
|
serverCert, err := serverIssued.TLSCertificate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("server-zertifikat laden: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := newTLSServer(t, ServerTLSConfig(ModeRequired, serverCert, authority))
|
||||||
|
defer server.Close() // EIN Server-Prozess ueber die gesamte Rotation hinweg — kein Neustart.
|
||||||
|
|
||||||
|
preRotationIssued, err := authority.IssueCert("dms", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client-zertifikat (vor rotation) ausstellen: %v", err)
|
||||||
|
}
|
||||||
|
preRotationCert, _ := preRotationIssued.TLSCertificate()
|
||||||
|
|
||||||
|
if err := authority.Rotate(); err != nil {
|
||||||
|
t.Fatalf("rotate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VOR der Rotation ausgestelltes Zertifikat funktioniert WEITERHIN, ohne
|
||||||
|
// dass der Server neu gestartet wurde.
|
||||||
|
status, err := fetch(t, server.URL, ClientTLSConfig(ModeRequired, preRotationCert, authority))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("vor-rotation-zertifikat haette weiterhin funktionieren sollen: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (vor-rotation-zertifikat)", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NACH der Rotation neu ausgestelltes Zertifikat funktioniert ebenfalls,
|
||||||
|
// gegen DENSELBEN laufenden Server.
|
||||||
|
postRotationIssued, err := authority.IssueCert("mail", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client-zertifikat (nach rotation) ausstellen: %v", err)
|
||||||
|
}
|
||||||
|
postRotationCert, _ := postRotationIssued.TLSCertificate()
|
||||||
|
|
||||||
|
status, err = fetch(t, server.URL, ClientTLSConfig(ModeRequired, postRotationCert, authority))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("nach-rotation-zertifikat haette funktionieren sollen: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (nach-rotation-zertifikat)", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3: Stufe-1-Betrieb (ModeOff) funktioniert
|
||||||
|
// weiterhin OHNE mTLS-Zwang — ein Client ganz ohne Zertifikat kommt durch.
|
||||||
|
func TestServerTLSConfig_ModeOffWorksWithoutClientCert(t *testing.T) {
|
||||||
|
authority, err := NewAuthority()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newauthority: %v", err)
|
||||||
|
}
|
||||||
|
serverIssued, err := authority.IssueCert("core", time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("server-zertifikat ausstellen: %v", err)
|
||||||
|
}
|
||||||
|
serverCert, err := serverIssued.TLSCertificate()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("server-zertifikat laden: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := newTLSServer(t, ServerTLSConfig(ModeOff, serverCert, authority))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
status, err := fetch(t, server.URL, ClientTLSConfig(ModeOff, tls.Certificate{}, authority))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("stufe-1-betrieb (ModeOff) haette ohne client-zertifikat funktionieren sollen: %v", err)
|
||||||
|
}
|
||||||
|
if status != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthCodeTTL folgt RFC 6749 Empfehlung: Authorization Codes sind extrem
|
||||||
|
// kurzlebig, der Client tauscht sie sofort gegen ein Token.
|
||||||
|
const AuthCodeTTL = 60 * time.Second
|
||||||
|
|
||||||
|
var ErrInvalidAuthCode = errors.New("oidc: ungueltiger, abgelaufener oder bereits verwendeter authorization code")
|
||||||
|
|
||||||
|
// AuthCodeData ist das Ergebnis eines eingeloesten Authorization Codes.
|
||||||
|
type AuthCodeData struct {
|
||||||
|
ClientID string
|
||||||
|
UserID string
|
||||||
|
RedirectURI string
|
||||||
|
Scopes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthCodeStore verwaltet Authorization Codes innerhalb GENAU EINER Tenant-Datenbank.
|
||||||
|
type AuthCodeStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuthCodeStore(pool *pgxpool.Pool) *AuthCodeStore {
|
||||||
|
return &AuthCodeStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue erzeugt einen neuen Authorization Code (Akzeptanzkriterium 2, Schritt
|
||||||
|
// 1 des Flows) — gespeichert wird nur der Hash, das Klartext-Code wird per
|
||||||
|
// Redirect an den Client uebertragen (Standard-OAuth2-Verhalten, der Code
|
||||||
|
// selbst ist einmalig und kurzlebig genug, dass die Redirect-URL kein
|
||||||
|
// nennenswertes Risiko darstellt).
|
||||||
|
func (s *AuthCodeStore) Issue(ctx context.Context, clientID, userID, redirectURI string, scopes []string) (code string, err error) {
|
||||||
|
code, err = randomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("code erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
hash := hashSecret(code)
|
||||||
|
expiresAt := time.Now().Add(AuthCodeTTL)
|
||||||
|
|
||||||
|
_, err = s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO oidc_auth_codes (code_hash, client_id, user_id, redirect_uri, scopes, expires_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
`, hash, clientID, userID, redirectURI, scopes, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("code speichern: %w", err)
|
||||||
|
}
|
||||||
|
return code, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume loest einen Authorization Code genau einmal ein (Akzeptanzkriterium
|
||||||
|
// 2, Schritt 2 des Flows) — atomar ueber die WHERE-Klausel (used_at IS NULL
|
||||||
|
// AND expires_at > now()), gleiches Muster wie internal/authtoken (IAM-03).
|
||||||
|
func (s *AuthCodeStore) Consume(ctx context.Context, code, clientID, redirectURI string) (AuthCodeData, error) {
|
||||||
|
hash := hashSecret(code)
|
||||||
|
|
||||||
|
var data AuthCodeData
|
||||||
|
var storedClientID, storedRedirectURI string
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE oidc_auth_codes
|
||||||
|
SET used_at = now()
|
||||||
|
WHERE code_hash = $1 AND used_at IS NULL AND expires_at > now()
|
||||||
|
RETURNING client_id, user_id, redirect_uri, scopes
|
||||||
|
`, hash)
|
||||||
|
|
||||||
|
if err := row.Scan(&storedClientID, &data.UserID, &storedRedirectURI, &data.Scopes); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return AuthCodeData{}, ErrInvalidAuthCode
|
||||||
|
}
|
||||||
|
return AuthCodeData{}, fmt.Errorf("code einloesen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// client_id und redirect_uri muessen exakt zu denen des urspruenglichen
|
||||||
|
// Authorize-Aufrufs passen (RFC 6749 4.1.3) — sonst koennte ein Code, der
|
||||||
|
// fuer Client A ausgestellt wurde, bei Client B eingeloest werden.
|
||||||
|
if storedClientID != clientID || storedRedirectURI != redirectURI {
|
||||||
|
return AuthCodeData{}, ErrInvalidAuthCode
|
||||||
|
}
|
||||||
|
|
||||||
|
data.ClientID = storedClientID
|
||||||
|
data.RedirectURI = storedRedirectURI
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Package oidc implementiert Core IAM-13: NEXARCH als OIDC/OAuth2-Provider,
|
||||||
|
// damit Drittanwendungen eines Mandanten sich gegen NEXARCH-Benutzerkonten
|
||||||
|
// authentifizieren koennen. Baut bewusst auf der bestehenden JWKS-
|
||||||
|
// Infrastruktur (internal/moduletrust, API-05) auf statt ein zweites
|
||||||
|
// Schluesselsystem zu bauen — siehe provider.go.
|
||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrClientNotFound = errors.New("oidc: client nicht gefunden")
|
||||||
|
ErrInvalidClientAuth = errors.New("oidc: ungueltige client-authentifizierung")
|
||||||
|
ErrRedirectURINotAllowed = errors.New("oidc: redirect_uri ist fuer diesen client nicht registriert")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client ist eine bei NEXARCH registrierte Drittanwendung
|
||||||
|
// (Akzeptanzkriterium 1). AllowedScopes begrenzt, welche Scopes ein Token
|
||||||
|
// fuer diesen Client tragen darf — unabhaengig davon, was beim Authorize-
|
||||||
|
// Aufruf angefragt wird (Akzeptanzkriterium 3 / Pruefung 3).
|
||||||
|
type Client struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
ClientID string
|
||||||
|
RedirectURIs []string
|
||||||
|
AllowedScopes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientStore verwaltet OAuth2-Clients innerhalb GENAU EINER Tenant-
|
||||||
|
// Datenbank (gleiches Muster wie internal/user.TenantUserStore, IAM-03).
|
||||||
|
type ClientStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClientStore(pool *pgxpool.Pool) *ClientStore {
|
||||||
|
return &ClientStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register legt einen neuen OAuth2-Client an (Akzeptanzkriterium 1). Das
|
||||||
|
// Client-Secret wird NUR hier im Klartext zurueckgegeben, gespeichert wird
|
||||||
|
// ausschliesslich dessen SHA-256-Hash — gleiches Muster wie
|
||||||
|
// internal/moduleregistry.Provision (API-02).
|
||||||
|
func (s *ClientStore) Register(ctx context.Context, name string, redirectURIs, allowedScopes []string) (clientID, clientSecret string, err error) {
|
||||||
|
if name == "" {
|
||||||
|
return "", "", errors.New("oidc: name darf nicht leer sein")
|
||||||
|
}
|
||||||
|
if len(redirectURIs) == 0 {
|
||||||
|
return "", "", errors.New("oidc: mindestens eine redirect_uri ist erforderlich")
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID, err = randomToken(16)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("client-id erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
clientSecret, err = randomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("client-secret erzeugen: %w", err)
|
||||||
|
}
|
||||||
|
hash := hashSecret(clientSecret)
|
||||||
|
|
||||||
|
_, err = s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO oidc_clients (name, client_id, secret_hash, redirect_uris, allowed_scopes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
`, name, clientID, hash, redirectURIs, allowedScopes)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("client speichern: %w", err)
|
||||||
|
}
|
||||||
|
return clientID, clientSecret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ClientStore) getByClientID(ctx context.Context, clientID string) (Client, []byte, error) {
|
||||||
|
var c Client
|
||||||
|
var storedHash []byte
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, name, client_id, redirect_uris, allowed_scopes, secret_hash
|
||||||
|
FROM oidc_clients WHERE client_id = $1
|
||||||
|
`, clientID)
|
||||||
|
if err := row.Scan(&c.ID, &c.Name, &c.ClientID, &c.RedirectURIs, &c.AllowedScopes, &storedHash); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Client{}, nil, ErrClientNotFound
|
||||||
|
}
|
||||||
|
return Client{}, nil, fmt.Errorf("client laden: %w", err)
|
||||||
|
}
|
||||||
|
return c, storedHash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get laedt einen Client ohne Secret-Pruefung (fuer den Authorize-Schritt,
|
||||||
|
// der kein Client-Secret kennt — der Browser des Endnutzers, nicht die
|
||||||
|
// Drittanwendung selbst, ruft /oidc/authorize auf).
|
||||||
|
func (s *ClientStore) Get(ctx context.Context, clientID string) (Client, error) {
|
||||||
|
c, _, err := s.getByClientID(ctx, clientID)
|
||||||
|
return c, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate prueft Client-ID + Secret timing-safe (Akzeptanzkriterium 2,
|
||||||
|
// Token-Endpunkt) — gleiches Muster wie internal/moduleregistry.Authenticate (API-02).
|
||||||
|
func (s *ClientStore) Authenticate(ctx context.Context, clientID, clientSecret string) (Client, error) {
|
||||||
|
c, storedHash, err := s.getByClientID(ctx, clientID)
|
||||||
|
if err != nil {
|
||||||
|
return Client{}, err
|
||||||
|
}
|
||||||
|
if !timingSafeEqual(hashSecret(clientSecret), storedHash) {
|
||||||
|
return Client{}, ErrInvalidClientAuth
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateRedirectURI erzwingt exakten Abgleich gegen die registrierte
|
||||||
|
// Liste (kein Praefix-/Wildcard-Match) — Standardhaertung gegen Open-Redirect
|
||||||
|
// im Authorization-Code-Flow.
|
||||||
|
func (c Client) ValidateRedirectURI(redirectURI string) error {
|
||||||
|
for _, allowed := range c.RedirectURIs {
|
||||||
|
if allowed == redirectURI {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ErrRedirectURINotAllowed
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantedScopes schraenkt die angefragten Scopes auf das ein, was fuer den
|
||||||
|
// Client tatsaechlich erlaubt ist (Akzeptanzkriterium 3) — nie mehr
|
||||||
|
// gewaehren, als bei der Registrierung zugestanden wurde.
|
||||||
|
func (c Client) GrantedScopes(requested []string) []string {
|
||||||
|
allowed := make(map[string]bool, len(c.AllowedScopes))
|
||||||
|
for _, s := range c.AllowedScopes {
|
||||||
|
allowed[s] = true
|
||||||
|
}
|
||||||
|
var granted []string
|
||||||
|
for _, s := range requested {
|
||||||
|
if allowed[s] {
|
||||||
|
granted = append(granted, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return granted
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken(n int) (string, error) {
|
||||||
|
buf := make([]byte, n)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashSecret(secret string) []byte {
|
||||||
|
sum := sha256.Sum256([]byte(secret))
|
||||||
|
return sum[:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// timingSafeEqual folgt demselben Referenzmuster wie internal/moduleregistry
|
||||||
|
// (API-02) — projektweite Konvention aus SICHERHEITSKONZEPT.md / IAM-15.
|
||||||
|
func timingSafeEqual(a, b []byte) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare(a, b) == 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Package oidc implementiert Core IAM-06: SSO-Anmeldung ueber OpenID
|
||||||
|
// Connect gegen externe Identity-Provider.
|
||||||
|
//
|
||||||
|
// WICHTIGER HINWEIS: In dieser Umgebung ist kein registrierter externer
|
||||||
|
// OIDC-Provider (Client-ID/Secret bei Google/Okta/Azure AD o.ae.) verfuegbar,
|
||||||
|
// gegen den ein echter Authorization-Code-Redirect-Flow end-to-end
|
||||||
|
// durchgefuehrt werden koennte (analog AUD-05/Archive, IAM-05/LDAP). ANDERS
|
||||||
|
// als bei LDAP/Archive ist die eigentliche Token-VALIDIERUNG (JWKS-
|
||||||
|
// Signaturpruefung, Ablauf, Claims, Nonce/State) aber ein rein
|
||||||
|
// kryptographischer Vorgang ohne Netzwerkabhaengigkeit zur Testzeit — die
|
||||||
|
// Tests unten erzeugen ein eigenes RSA-Schluesselpaar, signieren ein
|
||||||
|
// ID-Token selbst und pruefen es exakt so, wie es ein echter Provider
|
||||||
|
// liefern wuerde. Nur der Redirect zu einem echten externen Provider bleibt
|
||||||
|
// ungeprueft.
|
||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
)
|
||||||
|
|
||||||
|
type jwk struct {
|
||||||
|
Kid string `json:"kid"`
|
||||||
|
Kty string `json:"kty"`
|
||||||
|
N string `json:"n"`
|
||||||
|
E string `json:"e"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jwkSet struct {
|
||||||
|
Keys []jwk `json:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrUnsupportedKeyType = errors.New("oidc: nicht unterstuetzter jwk-schluesseltyp")
|
||||||
|
|
||||||
|
// ParseJWKS dekodiert eine JWKS-Antwort (RFC 7517) in kid -> RSA-Public-Key.
|
||||||
|
// Nur RSA-Schluessel (kty=RSA, Standard bei allen gaengigen OIDC-Providern
|
||||||
|
// fuer ID-Tokens) werden unterstuetzt.
|
||||||
|
func ParseJWKS(data []byte) (map[string]*rsa.PublicKey, error) {
|
||||||
|
var set jwkSet
|
||||||
|
if err := json.Unmarshal(data, &set); err != nil {
|
||||||
|
return nil, fmt.Errorf("jwks parsen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make(map[string]*rsa.PublicKey, len(set.Keys))
|
||||||
|
for _, k := range set.Keys {
|
||||||
|
if k.Kty != "RSA" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pub, err := decodeRSAKey(k)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("schluessel %q dekodieren: %w", k.Kid, err)
|
||||||
|
}
|
||||||
|
out[k.Kid] = pub
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeRSAKey(k jwk) (*rsa.PublicKey, error) {
|
||||||
|
nBytes, err := base64.RawURLEncoding.DecodeString(k.N)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("modulus dekodieren: %w", err)
|
||||||
|
}
|
||||||
|
eBytes, err := base64.RawURLEncoding.DecodeString(k.E)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("exponent dekodieren: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
eBuf := make([]byte, 8)
|
||||||
|
copy(eBuf[8-len(eBytes):], eBytes)
|
||||||
|
e := int(binary.BigEndian.Uint64(eBuf))
|
||||||
|
|
||||||
|
return &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: e}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/ldapsync"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompleteOIDCLogin verifiziert das ID-Token (Signatur, Ablauf, Aussteller,
|
||||||
|
// Nonce — Akzeptanzkriterium 3), loest den State einmalig ein
|
||||||
|
// (Akzeptanzkriterium/Pruefung 3), legt bei Bedarf einen lokalen Benutzer an
|
||||||
|
// und mapped Rollen aus den OIDC-Claims ueber DIESELBE Erlaubnis-Matrix wie
|
||||||
|
// IAM-05/LDAP (ldapsync.RoleMappingStore — kein zweiter, paralleler
|
||||||
|
// Mapping-Mechanismus, Akzeptanzkriterium 2). Bei Erfolg wird ein normales
|
||||||
|
// IAM-02-Sitzungs-Token ausgestellt.
|
||||||
|
func CompleteOIDCLogin(
|
||||||
|
ctx context.Context,
|
||||||
|
verifier *Verifier,
|
||||||
|
states *StateStore,
|
||||||
|
users *user.TenantUserStore,
|
||||||
|
roles *rbac.Store,
|
||||||
|
roleMapping *ldapsync.RoleMappingStore,
|
||||||
|
issuer *auth.TokenIssuer,
|
||||||
|
tenantSlug, state, idToken string,
|
||||||
|
) (token string, err error) {
|
||||||
|
nonce, err := states.Consume(ctx, state)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := verifier.VerifyWithNonce(idToken, nonce)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if claims.Email == "" {
|
||||||
|
return "", fmt.Errorf("%w: id-token ohne e-mail-claim", ErrInvalidToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := findOrCreateUser(ctx, users, claims.Email)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if role, ok, err := roleMapping.HighestRoleFor(ctx, claims.Roles); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if ok {
|
||||||
|
if _, err := roles.Assign(ctx, u.ID, role, "oidc-sso"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issuer.Issue(u.ID, tenantSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findOrCreateUser(ctx context.Context, users *user.TenantUserStore, email string) (user.User, error) {
|
||||||
|
creds, err := users.GetByEmailForAuth(ctx, email)
|
||||||
|
if err == nil {
|
||||||
|
return creds.User, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, user.ErrNotFound) {
|
||||||
|
return user.User{}, err
|
||||||
|
}
|
||||||
|
return users.Create(ctx, email, email)
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rsa"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/ldapsync"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupLoginTest(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 users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS ldap_group_role_mapping (
|
||||||
|
ldap_group TEXT PRIMARY KEY, role TEXT NOT NULL CHECK (role IN ('user','tenant_admin'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS oidc_states (
|
||||||
|
state TEXT PRIMARY KEY, nonce TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() { pool.Close() }
|
||||||
|
return pool, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + 3: End-to-End-Login gegen ein (selbst signiertes,
|
||||||
|
// aber protokollkorrektes) ID-Token — Signatur, Nonce, State, Rollen-Mapping,
|
||||||
|
// Sitzungs-Token-Ausstellung.
|
||||||
|
func TestCompleteOIDCLogin_EndToEnd(t *testing.T) {
|
||||||
|
pool, cleanup := setupLoginTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
key := generateTestKey(t)
|
||||||
|
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
|
||||||
|
states := NewStateStore(pool)
|
||||||
|
users := user.NewTenantUserStore(pool)
|
||||||
|
roles := rbac.NewStore(pool)
|
||||||
|
roleMapping := ldapsync.NewRoleMappingStore(pool)
|
||||||
|
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
|
||||||
|
if err := roleMapping.SetMapping(ctx, "sso-admins", rbac.RoleTenantAdmin); err != nil {
|
||||||
|
t.Fatalf("set mapping: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state, nonce, err := states.Generate(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate state: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
email := fmt.Sprintf("oidc-test-%d@example.com", time.Now().UnixNano())
|
||||||
|
claims := Claims{
|
||||||
|
Subject: "idp-subject-123",
|
||||||
|
Email: email,
|
||||||
|
Nonce: nonce,
|
||||||
|
Roles: []string{"sso-admins"},
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: "https://idp.example.com",
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
idToken := signTestToken(t, key, "kid-1", claims)
|
||||||
|
|
||||||
|
token, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("complete oidc login: %v", err)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
t.Fatal("erwartet nicht-leeres sitzungs-token")
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment, err := roles.Get(ctx, mustGetUserID(t, ctx, users, email))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rollenzuweisung lesen: %v", err)
|
||||||
|
}
|
||||||
|
if assignment.Role != rbac.RoleTenantAdmin {
|
||||||
|
t.Fatalf("erwartet tenant_admin aus sso-admins-mapping, habe %q", assignment.Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replay desselben state schlaegt fehl.
|
||||||
|
if _, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken); err == nil {
|
||||||
|
t.Fatal("erwartet fehler bei wiederverwendetem state (replay)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 1: keine Privilege-Escalation ueber
|
||||||
|
// manipulierte/unbekannte Rollen-Claims.
|
||||||
|
func TestCompleteOIDCLogin_UnmappedRoleGrantsNothing(t *testing.T) {
|
||||||
|
pool, cleanup := setupLoginTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
key := generateTestKey(t)
|
||||||
|
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
|
||||||
|
states := NewStateStore(pool)
|
||||||
|
users := user.NewTenantUserStore(pool)
|
||||||
|
roles := rbac.NewStore(pool)
|
||||||
|
roleMapping := ldapsync.NewRoleMappingStore(pool)
|
||||||
|
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
|
||||||
|
state, nonce, err := states.Generate(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate state: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
email := fmt.Sprintf("oidc-test-%d@example.com", time.Now().UnixNano())
|
||||||
|
claims := Claims{
|
||||||
|
Email: email,
|
||||||
|
Nonce: nonce,
|
||||||
|
// Versuch, sich ueber einen manipulierten Rollen-Claim
|
||||||
|
// hochzustufen — diese Gruppen wurden NIE gemappt.
|
||||||
|
Roles: []string{"tenant_admin", "superadmin"},
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: "https://idp.example.com",
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
idToken := signTestToken(t, key, "kid-1", claims)
|
||||||
|
|
||||||
|
if _, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken); err != nil {
|
||||||
|
t.Fatalf("complete oidc login: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := mustGetUserID(t, ctx, users, email)
|
||||||
|
if _, err := roles.Get(ctx, userID); err == nil {
|
||||||
|
t.Fatal("erwartet KEINE rollenzuweisung fuer nicht gemappte claims (privilege-escalation-versuch)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustGetUserID(t *testing.T, ctx context.Context, users *user.TenantUserStore, email string) string {
|
||||||
|
t.Helper()
|
||||||
|
creds, err := users.GetByEmailForAuth(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get user by email: %v", err)
|
||||||
|
}
|
||||||
|
return creds.User.ID
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IDTokenTTL: kurzlebig wie ein normales Access-Token (Produkt-DNA "so
|
||||||
|
// vertrauenswuerdig wie noetig"), ein Refresh-Flow ist ausdruecklich nicht
|
||||||
|
// Teil dieser Kachel (kleinste Loesung, die die Akzeptanzkriterien erfuellt).
|
||||||
|
const IDTokenTTL = 15 * time.Minute
|
||||||
|
|
||||||
|
// IDTokenClaims sind die vom Client verifizierbaren Angaben ueber den
|
||||||
|
// angemeldeten Benutzer — signiert mit demselben Schluesselmaterial wie
|
||||||
|
// Modul-zu-Modul-Tokens (internal/moduletrust, API-05), aber mit eigener,
|
||||||
|
// OIDC-spezifischer Struktur (aud, scope), da moduletrust.Claims dafuer
|
||||||
|
// nicht vorgesehen ist. Kein zweites Schluesselsystem — nur eine zweite
|
||||||
|
// Claims-Form desselben Signierschluessels.
|
||||||
|
type IDTokenClaims struct {
|
||||||
|
Subject string `json:"sub"`
|
||||||
|
TenantSlug string `json:"tenant"`
|
||||||
|
Scopes []string `json:"scope"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler buendelt die drei OIDC-Endpunkte. issuerURL identifiziert NEXARCH
|
||||||
|
// als Aussteller (iss-Claim, Standard-OIDC-Feld).
|
||||||
|
type Handler struct {
|
||||||
|
clients *ClientStore
|
||||||
|
authCodes *AuthCodeStore
|
||||||
|
keys *moduletrust.KeyManager
|
||||||
|
issuerURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(clients *ClientStore, authCodes *AuthCodeStore, keys *moduletrust.KeyManager, issuerURL string) *Handler {
|
||||||
|
return &Handler{clients: clients, authCodes: authCodes, keys: keys, issuerURL: issuerURL}
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerClientRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
RedirectURIs []string `json:"redirect_uris"`
|
||||||
|
AllowedScopes []string `json:"allowed_scopes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type registerClientResponse struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ClientSecret string `json:"client_secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterClient ist Akzeptanzkriterium 1: eine Drittanwendung wird als
|
||||||
|
// OAuth2-Client registriert. Muss hinter auth.RequireAuth haengen — nur
|
||||||
|
// angemeldete Benutzer des jeweiligen Mandanten duerfen Clients fuer ihren
|
||||||
|
// eigenen Mandanten anlegen (ClientStore ist bereits auf die Tenant-DB des
|
||||||
|
// Aufrufers gebunden, siehe NewHandler-Aufstellung im jeweiligen Modul-Server).
|
||||||
|
func (h *Handler) RegisterClient(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := auth.ClaimsFromContext(r.Context()); !ok {
|
||||||
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req registerClientRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "ungueltiger request-body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID, clientSecret, err := h.clients.Register(r.Context(), req.Name, req.RedirectURIs, req.AllowedScopes)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
_ = json.NewEncoder(w).Encode(registerClientResponse{ClientID: clientID, ClientSecret: clientSecret})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize ist Schritt 1 des Authorization-Code-Flows (Akzeptanzkriterium 2).
|
||||||
|
// Muss hinter auth.RequireAuth haengen — der aufrufende Browser ist bereits
|
||||||
|
// als NEXARCH-Benutzer angemeldet, hier wird nur noch der Client geprueft
|
||||||
|
// und ein Code fuer GENAU DIESEN Benutzer ausgestellt.
|
||||||
|
func (h *Handler) Authorize(w http.ResponseWriter, r *http.Request) {
|
||||||
|
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := r.URL.Query()
|
||||||
|
if q.Get("response_type") != "code" {
|
||||||
|
http.Error(w, "nur response_type=code wird unterstuetzt", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clientID := q.Get("client_id")
|
||||||
|
redirectURI := q.Get("redirect_uri")
|
||||||
|
state := q.Get("state")
|
||||||
|
requestedScopes := splitScopes(q.Get("scope"))
|
||||||
|
|
||||||
|
client, err := h.clients.Get(r.Context(), clientID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "unbekannter client", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := client.ValidateRedirectURI(redirectURI); err != nil {
|
||||||
|
// Bewusst KEIN Redirect zu einer nicht registrierten redirect_uri —
|
||||||
|
// Open-Redirect-Schutz geht vor Komfort, Fehler wird direkt angezeigt.
|
||||||
|
http.Error(w, "redirect_uri ist fuer diesen client nicht registriert", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
granted := client.GrantedScopes(requestedScopes)
|
||||||
|
|
||||||
|
code, err := h.authCodes.Issue(r.Context(), client.ClientID, claims.UserID, redirectURI, granted)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "code konnte nicht ausgestellt werden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := url.Parse(redirectURI)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "ungueltige redirect_uri", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
values := target.Query()
|
||||||
|
values.Set("code", code)
|
||||||
|
if state != "" {
|
||||||
|
values.Set("state", state)
|
||||||
|
}
|
||||||
|
target.RawQuery = values.Encode()
|
||||||
|
|
||||||
|
http.Redirect(w, r, target.String(), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token ist Schritt 2 des Authorization-Code-Flows (Akzeptanzkriterium 2):
|
||||||
|
// die Drittanwendung tauscht den Code serverseitig gegen ein ID-Token.
|
||||||
|
func (h *Handler) Token(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "ungueltiger request-body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.PostForm.Get("grant_type") != "authorization_code" {
|
||||||
|
http.Error(w, "nur grant_type=authorization_code wird unterstuetzt", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID := r.PostForm.Get("client_id")
|
||||||
|
clientSecret := r.PostForm.Get("client_secret")
|
||||||
|
code := r.PostForm.Get("code")
|
||||||
|
redirectURI := r.PostForm.Get("redirect_uri")
|
||||||
|
|
||||||
|
client, err := h.clients.Authenticate(r.Context(), clientID, clientSecret)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "client-authentifizierung fehlgeschlagen", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.authCodes.Consume(r.Context(), code, client.ClientID, redirectURI)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "authorization code ungueltig, abgelaufen oder bereits verwendet", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
idToken, err := h.issueIDToken(data)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "token konnte nicht ausgestellt werden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"id_token": idToken,
|
||||||
|
"access_token": idToken, // kein separates Access-Token-Format in dieser Kachel — das ID-Token traegt bereits Subject+Scopes.
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": int(IDTokenTTL.Seconds()),
|
||||||
|
"scope": strings.Join(data.Scopes, " "),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) issueIDToken(data AuthCodeData) (string, error) {
|
||||||
|
key, err := h.keys.SigningKey()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
claims := IDTokenClaims{
|
||||||
|
Subject: data.UserID,
|
||||||
|
Scopes: data.Scopes,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: h.issuerURL,
|
||||||
|
Audience: jwt.ClaimStrings{data.ClientID},
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(IDTokenTTL)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
||||||
|
token.Header["kid"] = key.KID
|
||||||
|
return token.SignedString(key.Private)
|
||||||
|
}
|
||||||
|
|
||||||
|
// idpJWK / idpJWKSet folgen RFC 8037 (Ed25519 als OKP-Schluesseltyp) — im
|
||||||
|
// Unterschied zu moduletrust.ServeJWKS (internes, vereinfachtes Format fuer
|
||||||
|
// Modul-zu-Modul-Kommunikation) muss dieser Endpunkt von generischen,
|
||||||
|
// standardkonformen OIDC-Client-Bibliotheken Dritter lesbar sein
|
||||||
|
// (Akzeptanzkriterium 3). Beide Endpunkte liefern dieselben Schluessel aus
|
||||||
|
// demselben KeyManager — kein zweites Schluesselsystem, nur ein zweites,
|
||||||
|
// spezifikationskonformes Format desselben Materials.
|
||||||
|
//
|
||||||
|
// Eigener Typname (statt jwk/jwkSet wie in jwks.go), da jwks.go das RSA-
|
||||||
|
// basierte JWKS-Format fuer die Verifikation EXTERNER OIDC-Provider
|
||||||
|
// implementiert (IAM-12) — dieser Typ hier ist Core als eigener IdP
|
||||||
|
// (IAM-13, Ed25519/OKP) und darf nicht verwechselt werden.
|
||||||
|
type idpJWK struct {
|
||||||
|
Kty string `json:"kty"`
|
||||||
|
Crv string `json:"crv"`
|
||||||
|
Kid string `json:"kid"`
|
||||||
|
X string `json:"x"`
|
||||||
|
Use string `json:"use"`
|
||||||
|
Alg string `json:"alg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type idpJWKSet struct {
|
||||||
|
Keys []idpJWK `json:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) JWKS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
set := h.keys.PublicKeySet()
|
||||||
|
resp := idpJWKSet{Keys: make([]idpJWK, 0, len(set))}
|
||||||
|
for kid, pub := range set {
|
||||||
|
resp.Keys = append(resp.Keys, idpJWK{
|
||||||
|
Kty: "OKP",
|
||||||
|
Crv: "Ed25519",
|
||||||
|
Kid: kid,
|
||||||
|
X: base64.RawURLEncoding.EncodeToString(pub),
|
||||||
|
Use: "sig",
|
||||||
|
Alg: "EdDSA",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitScopes(raw string) []string {
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return strings.Fields(raw)
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/moduletrust"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newProviderTestSetup baut Tenant-DB-Schema (users, oidc_clients,
|
||||||
|
// oidc_auth_codes) direkt gegen TEST_ADMIN_DSN auf, analog zum Muster in
|
||||||
|
// internal/tenant/lifecycle_test.go — braucht kein volles Tenant-Provisioning,
|
||||||
|
// nur eine isolierte Datenbank fuer diesen Testlauf.
|
||||||
|
func newProviderTestSetup(t *testing.T) (*Handler, *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 EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
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 oidc_clients (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
client_id TEXT NOT NULL UNIQUE,
|
||||||
|
secret_hash BYTEA NOT NULL,
|
||||||
|
redirect_uris TEXT[] NOT NULL,
|
||||||
|
allowed_scopes TEXT[] NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS oidc_auth_codes (
|
||||||
|
code_hash BYTEA PRIMARY KEY,
|
||||||
|
client_id TEXT NOT NULL REFERENCES oidc_clients(client_id),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
redirect_uri TEXT NOT NULL,
|
||||||
|
scopes TEXT[] NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keys, err := moduletrust.NewKeyManager()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("keymanager: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clients := NewClientStore(pool)
|
||||||
|
authCodes := NewAuthCodeStore(pool)
|
||||||
|
handler := NewHandler(clients, authCodes, keys, "https://core.test.nexarch.example")
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
_, _ = pool.Exec(ctx, `DROP TABLE IF EXISTS oidc_auth_codes, oidc_clients, users CASCADE`)
|
||||||
|
pool.Close()
|
||||||
|
}
|
||||||
|
return handler, pool, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTestUser(t *testing.T, pool *pgxpool.Pool, email string) string {
|
||||||
|
t.Helper()
|
||||||
|
var id string
|
||||||
|
err := pool.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO users (email, name) VALUES ($1, $2) RETURNING id
|
||||||
|
`, email, "Test Nutzer").Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("testnutzer anlegen: %v", err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 (Client-Registrierung) + 2 (voller Flow) + 3 (JWKS-
|
||||||
|
// Verifikation, Scope-Einschraenkung). Deckt Pruefungen 1-3 ab.
|
||||||
|
func TestFullAuthorizationCodeFlow(t *testing.T) {
|
||||||
|
handler, pool, cleanup := newProviderTestSetup(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
userID := createTestUser(t, pool, "nutzer@acme.example")
|
||||||
|
|
||||||
|
// Pruefung 1 / Akzeptanzkriterium 1: Client registriert sich.
|
||||||
|
clientID, clientSecret, err := handler.clients.Register(ctx, "Test-Drittanwendung",
|
||||||
|
[]string{"https://app.example.com/callback"},
|
||||||
|
[]string{"openid", "profile"}, // erlaubte Scopes: KEIN "admin"
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session-Cookie fuer den Authorize-Aufruf ausstellen, exakt wie ein
|
||||||
|
// echter Browser ihn haette — RequireAuth uebernimmt die Verifikation,
|
||||||
|
// dieselbe Middleware wie in Produktion (nicht simuliert).
|
||||||
|
issuer := auth.NewTokenIssuer("test-session-secret")
|
||||||
|
sessionToken, err := issuer.Issue(userID, "acme")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("session-token ausstellen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
authorizeURL := "/oidc/authorize?" + url.Values{
|
||||||
|
"response_type": {"code"},
|
||||||
|
"client_id": {clientID},
|
||||||
|
"redirect_uri": {"https://app.example.com/callback"},
|
||||||
|
// admin wird angefragt, ist aber NICHT erlaubt -> muss herausgefiltert werden.
|
||||||
|
"scope": {"openid profile admin"},
|
||||||
|
"state": {"xyz123"},
|
||||||
|
}.Encode()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, authorizeURL, nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: auth.CookieName, Value: sessionToken})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
protected := auth.RequireAuth(issuer, handler.Authorize)
|
||||||
|
protected(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("authorize: status = %d, want %d, body: %s", rec.Code, http.StatusFound, rec.Body.String())
|
||||||
|
}
|
||||||
|
loc, err := url.Parse(rec.Header().Get("Location"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("redirect-location parsen: %v", err)
|
||||||
|
}
|
||||||
|
if loc.Query().Get("state") != "xyz123" {
|
||||||
|
t.Fatalf("state = %q, want xyz123 (muss unveraendert durchgereicht werden)", loc.Query().Get("state"))
|
||||||
|
}
|
||||||
|
code := loc.Query().Get("code")
|
||||||
|
if code == "" {
|
||||||
|
t.Fatal("erwartet gesetzten code-Parameter in der redirect-URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefung 1 / Akzeptanzkriterium 2: Code gegen Token tauschen.
|
||||||
|
tokenForm := url.Values{
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
"code": {code},
|
||||||
|
"redirect_uri": {"https://app.example.com/callback"},
|
||||||
|
"client_id": {clientID},
|
||||||
|
"client_secret": {clientSecret},
|
||||||
|
}
|
||||||
|
tokenReq := httptest.NewRequest(http.MethodPost, "/oidc/token", strings.NewReader(tokenForm.Encode()))
|
||||||
|
tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
tokenRec := httptest.NewRecorder()
|
||||||
|
handler.Token(tokenRec, tokenReq)
|
||||||
|
|
||||||
|
if tokenRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("token: status = %d, want 200, body: %s", tokenRec.Code, tokenRec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp struct {
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(tokenRec.Body.Bytes(), &tokenResp); err != nil {
|
||||||
|
t.Fatalf("token-response dekodieren: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 / Pruefung 3: "admin" wurde herausgefiltert.
|
||||||
|
grantedScopes := strings.Fields(tokenResp.Scope)
|
||||||
|
for _, s := range grantedScopes {
|
||||||
|
if s == "admin" {
|
||||||
|
t.Fatal("nicht erlaubter scope 'admin' wurde dennoch gewaehrt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !containsAll(grantedScopes, "openid", "profile") {
|
||||||
|
t.Fatalf("erwartete scopes openid+profile, habe %v", grantedScopes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefung 2 / Akzeptanzkriterium 3: ausgestelltes Token gegen JWKS verifizieren.
|
||||||
|
jwksRec := httptest.NewRecorder()
|
||||||
|
handler.JWKS(jwksRec, httptest.NewRequest(http.MethodGet, "/oidc/jwks.json", nil))
|
||||||
|
|
||||||
|
var jwks idpJWKSet
|
||||||
|
if err := decodeJSON(jwksRec.Body.Bytes(), &jwks); err != nil {
|
||||||
|
t.Fatalf("jwks dekodieren: %v", err)
|
||||||
|
}
|
||||||
|
if len(jwks.Keys) == 0 {
|
||||||
|
t.Fatal("jwks enthaelt keine schluessel")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedToken, err := jwt.ParseWithClaims(tokenResp.IDToken, &IDTokenClaims{}, func(tok *jwt.Token) (interface{}, error) {
|
||||||
|
kid, _ := tok.Header["kid"].(string)
|
||||||
|
for _, k := range jwks.Keys {
|
||||||
|
if k.Kid == kid {
|
||||||
|
pub, err := decodeBase64URLEd25519(k.X)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ed25519.PublicKey(pub), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, jwt.ErrTokenUnverifiable
|
||||||
|
})
|
||||||
|
if err != nil || !parsedToken.Valid {
|
||||||
|
t.Fatalf("id-token gegen jwks verifizieren: %v (valid=%v)", err, parsedToken != nil && parsedToken.Valid)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := parsedToken.Claims.(*IDTokenClaims)
|
||||||
|
if claims.Subject != userID {
|
||||||
|
t.Fatalf("sub = %q, want %q", claims.Subject, userID)
|
||||||
|
}
|
||||||
|
if len(claims.Audience) != 1 || claims.Audience[0] != clientID {
|
||||||
|
t.Fatalf("aud = %v, want [%q]", claims.Audience, clientID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefung 3 (dediziert): Client, der einen Scope anfragt, den er bei der
|
||||||
|
// Registrierung nicht erhalten hat, bekommt ihn unter keinen Umstaenden.
|
||||||
|
func TestGrantedScopes_NeverExceedsAllowed(t *testing.T) {
|
||||||
|
c := Client{AllowedScopes: []string{"openid", "profile"}}
|
||||||
|
granted := c.GrantedScopes([]string{"openid", "admin", "profile", "billing"})
|
||||||
|
if !containsAll(granted, "openid", "profile") || len(granted) != 2 {
|
||||||
|
t.Fatalf("granted = %v, want genau [openid profile]", granted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRedirectURI_RejectsUnregistered(t *testing.T) {
|
||||||
|
c := Client{RedirectURIs: []string{"https://app.example.com/callback"}}
|
||||||
|
if err := c.ValidateRedirectURI("https://boese-seite.example.com/callback"); err == nil {
|
||||||
|
t.Fatal("erwartet fehler fuer nicht registrierte redirect_uri")
|
||||||
|
}
|
||||||
|
if err := c.ValidateRedirectURI("https://app.example.com/callback"); err != nil {
|
||||||
|
t.Fatalf("registrierte redirect_uri sollte akzeptiert werden: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthCodeStore_CannotBeConsumedTwice(t *testing.T) {
|
||||||
|
handler, pool, cleanup := newProviderTestSetup(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
userID := createTestUser(t, pool, "einmalig@acme.example")
|
||||||
|
clientID, _, err := handler.clients.Register(ctx, "Einmal-Client", []string{"https://a.example/cb"}, []string{"openid"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := handler.authCodes.Issue(ctx, clientID, userID, "https://a.example/cb", []string{"openid"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := handler.authCodes.Consume(ctx, code, clientID, "https://a.example/cb"); err != nil {
|
||||||
|
t.Fatalf("erster consume sollte funktionieren: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := handler.authCodes.Consume(ctx, code, clientID, "https://a.example/cb"); err == nil {
|
||||||
|
t.Fatal("zweiter consume desselben codes haette fehlschlagen muessen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAll(haystack []string, needles ...string) bool {
|
||||||
|
set := make(map[string]bool, len(haystack))
|
||||||
|
for _, h := range haystack {
|
||||||
|
set[h] = true
|
||||||
|
}
|
||||||
|
for _, n := range needles {
|
||||||
|
if !set[n] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(data []byte, v any) error {
|
||||||
|
return json.Unmarshal(data, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBase64URLEd25519(s string) ([]byte, error) {
|
||||||
|
return base64.RawURLEncoding.DecodeString(s)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrStateInvalid = errors.New("oidc: state ungueltig, bereits verwendet oder abgelaufen")
|
||||||
|
|
||||||
|
// DefaultStateTTL begrenzt, wie lange ein ausgestellter State/Nonce fuer den
|
||||||
|
// Redirect-Umweg zum Identity-Provider gueltig bleibt.
|
||||||
|
const DefaultStateTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
type StateStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStateStore(pool *pgxpool.Pool) *StateStore {
|
||||||
|
return &StateStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate stellt state+nonce fuer einen neuen Login-Redirect aus. state
|
||||||
|
// wird als OAuth2-"state"-Parameter mitgeschickt (CSRF-Schutz), nonce
|
||||||
|
// erscheint spaeter im ID-Token und muss exakt uebereinstimmen (Replay-Schutz).
|
||||||
|
func (s *StateStore) Generate(ctx context.Context) (state, nonce string, err error) {
|
||||||
|
state, err = randomValue()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
nonce, err = randomValue()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO oidc_states (state, nonce, expires_at) VALUES ($1, $2, $3)
|
||||||
|
`, state, nonce, time.Now().Add(DefaultStateTTL))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("state speichern: %w", err)
|
||||||
|
}
|
||||||
|
return state, nonce, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume loest einen State EINMALIG ein (Akzeptanzkriterium/Pruefung 3:
|
||||||
|
// Replay-Schutz) — atomar ueber WHERE used_at IS NULL, analog IAM-03/IAM-09.
|
||||||
|
// Ein zweiter Callback mit demselben state (z.B. durch einen Angreifer, der
|
||||||
|
// die Redirect-URL abgefangen hat) schlaegt fehl.
|
||||||
|
func (s *StateStore) Consume(ctx context.Context, state string) (nonce string, err error) {
|
||||||
|
err = s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE oidc_states SET used_at = now()
|
||||||
|
WHERE state = $1 AND used_at IS NULL AND expires_at > now()
|
||||||
|
RETURNING nonce
|
||||||
|
`, state).Scan(&nonce)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return "", ErrStateInvalid
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("state einloesen: %w", err)
|
||||||
|
}
|
||||||
|
return nonce, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomValue() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupStateTest(t *testing.T) (*StateStore, 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 oidc_states (
|
||||||
|
state TEXT PRIMARY KEY, nonce TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() { pool.Close() }
|
||||||
|
return NewStateStore(pool), cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium/Pruefung 3: State/Nonce-Handling gegen CSRF und Replay.
|
||||||
|
func TestStateStore_ConsumeIsSingleUse(t *testing.T) {
|
||||||
|
store, cleanup := setupStateTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
state, nonce, err := store.Generate(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotNonce, err := store.Consume(ctx, state)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("consume (erste einloesung): %v", err)
|
||||||
|
}
|
||||||
|
if gotNonce != nonce {
|
||||||
|
t.Fatalf("nonce = %q, want %q", gotNonce, nonce)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.Consume(ctx, state); !errors.Is(err, ErrStateInvalid) {
|
||||||
|
t.Fatalf("erwartet ErrStateInvalid bei erneutem einloesen (replay), habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateStore_RejectsUnknownState(t *testing.T) {
|
||||||
|
store, cleanup := setupStateTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := store.Consume(ctx, "niemals-ausgestellter-state"); !errors.Is(err, ErrStateInvalid) {
|
||||||
|
t.Fatalf("erwartet ErrStateInvalid, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rsa"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidToken = errors.New("oidc: id-token ungueltig, abgelaufen oder falscher aussteller")
|
||||||
|
|
||||||
|
// Claims sind die fuer NEXARCH relevanten OIDC-ID-Token-Claims.
|
||||||
|
type Claims struct {
|
||||||
|
Subject string `json:"sub"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Nonce string `json:"nonce"`
|
||||||
|
Roles []string `json:"roles"` // providerabhaengiger Claim-Name, hier vereinfacht fest "roles"
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifier prueft ID-Tokens gegen den JWKS-Schluesselsatz eines konfigurierten
|
||||||
|
// Providers (Akzeptanzkriterium 3: fehlerhafte/abgelaufene Tokens werden
|
||||||
|
// abgewiesen).
|
||||||
|
type Verifier struct {
|
||||||
|
keys map[string]*rsa.PublicKey
|
||||||
|
issuer string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVerifier(keys map[string]*rsa.PublicKey, issuer string) *Verifier {
|
||||||
|
return &Verifier{keys: keys, issuer: issuer}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify prueft Signatur (gegen JWKS), Ablauf (jwt-Bibliothek lehnt
|
||||||
|
// abgelaufene Tokens automatisch ab) und Aussteller.
|
||||||
|
func (v *Verifier) Verify(idToken string) (*Claims, error) {
|
||||||
|
claims := &Claims{}
|
||||||
|
token, err := jwt.ParseWithClaims(idToken, claims, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
kid, _ := t.Header["kid"].(string)
|
||||||
|
key, ok := v.keys[kid]
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
|
}
|
||||||
|
if claims.Issuer != v.issuer {
|
||||||
|
return nil, fmt.Errorf("%w: unerwarteter aussteller %q", ErrInvalidToken, claims.Issuer)
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyWithNonce prueft zusaetzlich, dass der nonce-Claim im Token exakt
|
||||||
|
// dem bei der Anfrage ausgestellten Nonce entspricht — verhindert Replay
|
||||||
|
// eines fremden, gueltigen ID-Tokens (Akzeptanzkriterium/Pruefung 3).
|
||||||
|
func (v *Verifier) VerifyWithNonce(idToken, expectedNonce string) (*Claims, error) {
|
||||||
|
claims, err := v.Verify(idToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if claims.Nonce == "" || claims.Nonce != expectedNonce {
|
||||||
|
return nil, fmt.Errorf("%w: nonce stimmt nicht ueberein", ErrInvalidToken)
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func generateTestKey(t *testing.T) *rsa.PrivateKey {
|
||||||
|
t.Helper()
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate rsa key: %v", err)
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func signTestToken(t *testing.T, key *rsa.PrivateKey, kid string, claims Claims) string {
|
||||||
|
t.Helper()
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
token.Header["kid"] = kid
|
||||||
|
s, err := token.SignedString(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign token: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerify_AcceptsValidToken(t *testing.T) {
|
||||||
|
key := generateTestKey(t)
|
||||||
|
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
|
||||||
|
|
||||||
|
claims := Claims{
|
||||||
|
Subject: "user-123",
|
||||||
|
Email: "person@example.com",
|
||||||
|
Nonce: "erwarteter-nonce",
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: "https://idp.example.com",
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tok := signTestToken(t, key, "kid-1", claims)
|
||||||
|
|
||||||
|
got, err := verifier.Verify(tok)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if got.Email != "person@example.com" {
|
||||||
|
t.Fatalf("email = %q", got.Email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 2: Token-Signatur- und Ablaufpruefung.
|
||||||
|
func TestVerify_RejectsExpiredToken(t *testing.T) {
|
||||||
|
key := generateTestKey(t)
|
||||||
|
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
|
||||||
|
|
||||||
|
claims := Claims{
|
||||||
|
Subject: "user-123",
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: "https://idp.example.com",
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), // abgelaufen
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tok := signTestToken(t, key, "kid-1", claims)
|
||||||
|
|
||||||
|
if _, err := verifier.Verify(tok); err == nil {
|
||||||
|
t.Fatal("erwartet fehler bei abgelaufenem token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerify_RejectsWrongSigningKey(t *testing.T) {
|
||||||
|
realKey := generateTestKey(t)
|
||||||
|
attackerKey := generateTestKey(t)
|
||||||
|
// Verifier kennt NUR den echten oeffentlichen Schluessel.
|
||||||
|
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &realKey.PublicKey}, "https://idp.example.com")
|
||||||
|
|
||||||
|
claims := Claims{
|
||||||
|
Subject: "user-123",
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: "https://idp.example.com",
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
// Mit dem Angreifer-Schluessel signiert, aber unter demselben kid wie der echte.
|
||||||
|
tok := signTestToken(t, attackerKey, "kid-1", claims)
|
||||||
|
|
||||||
|
if _, err := verifier.Verify(tok); err == nil {
|
||||||
|
t.Fatal("erwartet fehler bei fremd signiertem token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerify_RejectsWrongIssuer(t *testing.T) {
|
||||||
|
key := generateTestKey(t)
|
||||||
|
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
|
||||||
|
|
||||||
|
claims := Claims{
|
||||||
|
Subject: "user-123",
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: "https://ganz-anderer-idp.example.com",
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tok := signTestToken(t, key, "kid-1", claims)
|
||||||
|
|
||||||
|
if _, err := verifier.Verify(tok); err == nil {
|
||||||
|
t.Fatal("erwartet fehler bei unerwartetem aussteller")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium/Pruefung 3: Nonce-Handling gegen Replay.
|
||||||
|
func TestVerifyWithNonce_RejectsMismatch(t *testing.T) {
|
||||||
|
key := generateTestKey(t)
|
||||||
|
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
|
||||||
|
|
||||||
|
claims := Claims{
|
||||||
|
Subject: "user-123",
|
||||||
|
Nonce: "nonce-a",
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: "https://idp.example.com",
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
tok := signTestToken(t, key, "kid-1", claims)
|
||||||
|
|
||||||
|
if _, err := verifier.VerifyWithNonce(tok, "nonce-b"); err == nil {
|
||||||
|
t.Fatal("erwartet fehler bei nonce-mismatch (moeglicher replay eines fremden tokens)")
|
||||||
|
}
|
||||||
|
if _, err := verifier.VerifyWithNonce(tok, "nonce-a"); err != nil {
|
||||||
|
t.Fatalf("erwartet erfolg bei passendem nonce: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseJWKS_RoundTrip(t *testing.T) {
|
||||||
|
key := generateTestKey(t)
|
||||||
|
|
||||||
|
nBytes := key.PublicKey.N.Bytes()
|
||||||
|
eBuf := make([]byte, 8)
|
||||||
|
binary.BigEndian.PutUint64(eBuf, uint64(key.PublicKey.E))
|
||||||
|
// Fuehrende Nullbytes des Exponenten abschneiden (ueblich bei JWK-Kodierung).
|
||||||
|
eStart := 0
|
||||||
|
for eStart < len(eBuf)-1 && eBuf[eStart] == 0 {
|
||||||
|
eStart++
|
||||||
|
}
|
||||||
|
|
||||||
|
set := jwkSet{Keys: []jwk{{
|
||||||
|
Kid: "kid-1",
|
||||||
|
Kty: "RSA",
|
||||||
|
N: base64.RawURLEncoding.EncodeToString(nBytes),
|
||||||
|
E: base64.RawURLEncoding.EncodeToString(eBuf[eStart:]),
|
||||||
|
}}}
|
||||||
|
data, err := json.Marshal(set)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := ParseJWKS(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse jwks: %v", err)
|
||||||
|
}
|
||||||
|
got, ok := parsed["kid-1"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("erwartet kid-1 im ergebnis")
|
||||||
|
}
|
||||||
|
if got.E != key.PublicKey.E || got.N.Cmp(key.PublicKey.N) != 0 {
|
||||||
|
t.Fatalf("erwartet identischen oeffentlichen schluessel nach roundtrip")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der wiederhergestellte Schluessel muss ein damit signiertes Token auch tatsaechlich verifizieren.
|
||||||
|
verifier := NewVerifier(parsed, "https://idp.example.com")
|
||||||
|
claims := Claims{Subject: "x", RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: "https://idp.example.com", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||||
|
}}
|
||||||
|
tok := signTestToken(t, key, "kid-1", claims)
|
||||||
|
if _, err := verifier.Verify(tok); err != nil {
|
||||||
|
t.Fatalf("verify mit aus jwks wiederhergestelltem schluessel: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,732 @@
|
|||||||
|
// Package pentest enthaelt QA-04 (Sicherheits- & Penetrationspruefung Core):
|
||||||
|
// gezielte Angriffsversuche gegen die sensibelsten Core-Bereiche - SSO/OIDC,
|
||||||
|
// Rate-Limiting/Account-Lockout im Mehrinstanz-Betrieb, zentrale
|
||||||
|
// Policy-Durchsetzung und Master-Key-/Tenant-KEK-Verwaltung - jeweils gegen
|
||||||
|
// die echten Produktionspakete, nicht gegen Mocks.
|
||||||
|
package pentest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/kek"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/lockout"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/oidc"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/policy"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/ratelimit"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Testinfrastruktur (identisch zum Muster aus internal/e2e/helpers_test.go)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
func requireAdminDSN(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Pentest uebersprungen")
|
||||||
|
}
|
||||||
|
return dsn
|
||||||
|
}
|
||||||
|
|
||||||
|
func repoRoot(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getwd: %v", err)
|
||||||
|
}
|
||||||
|
return filepath.Join(wd, "..", "..")
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAllUpSQL(t *testing.T, ctx context.Context, pool *pgxpool.Pool, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("migrationsverzeichnis %q lesen: %v", dir, err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".up.sql") {
|
||||||
|
names = append(names, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, name := range names {
|
||||||
|
sqlBytes, err := os.ReadFile(filepath.Join(dir, name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("migration %q lesen: %v", name, err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, string(sqlBytes)); err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && (pgErr.Code == "42P07" || pgErr.Code == "42701" || pgErr.Code == "42710") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Fatalf("migration %q anwenden: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupRegistryPool(t *testing.T) (*pgxpool.Pool, string) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := requireAdminDSN(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("registry pool: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { pool.Close() })
|
||||||
|
applyAllUpSQL(t, ctx, pool, filepath.Join(repoRoot(t), "migrations"))
|
||||||
|
return pool, adminDSN
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueSlug(prefix string) string {
|
||||||
|
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// Pruefung 2 / Akzeptanzkriterium 1: SSO/OIDC gegen Token-Manipulation,
|
||||||
|
// Replay und Claims-Manipulation.
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
// TestPentest_OIDC_AlgNoneAttack simuliert den klassischen "alg=none"-Angriff
|
||||||
|
// gegen den EXTERNEN OIDC-Verifier (IAM-06, internal/oidc.Verifier): ein
|
||||||
|
// Angreifer stellt sich selbst ein Token mit Signaturalgorithmus "none" aus,
|
||||||
|
// in der Hoffnung, dass eine naive JWT-Bibliothek die fehlende Signatur
|
||||||
|
// akzeptiert. internal/oidc.Verifier.Verify prueft explizit auf
|
||||||
|
// *jwt.SigningMethodRSA - "none" muss zuverlaessig abgewiesen werden.
|
||||||
|
func TestPentest_OIDC_AlgNoneAttack(t *testing.T) {
|
||||||
|
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa key: %v", err)
|
||||||
|
}
|
||||||
|
verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test")
|
||||||
|
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": "angreifer@example.test",
|
||||||
|
"iss": "https://idp.example.test",
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
unsigned := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
|
||||||
|
unsigned.Header["kid"] = "kid-1"
|
||||||
|
forged, err := unsigned.SignedString(jwt.UnsafeAllowNoneSignatureType)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("alg=none token faelschen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := verifier.Verify(forged); err == nil {
|
||||||
|
t.Fatal("alg=none-token wurde akzeptiert - kritische luecke")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPentest_OIDC_ForeignKeySignature simuliert einen Angreifer, der ein
|
||||||
|
// Token mit einem EIGENEN RSA-Schluessel signiert und die kid eines
|
||||||
|
// bekannten, vertrauenswuerdigen Schluessels vorgibt (Schluessel-Confusion).
|
||||||
|
// Verify darf das Token nur mit dem TATSAECHLICH registrierten Public Key
|
||||||
|
// pruefen, niemals mit dem im Token mitgelieferten.
|
||||||
|
func TestPentest_OIDC_ForeignKeySignature(t *testing.T) {
|
||||||
|
trustedKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("trusted key: %v", err)
|
||||||
|
}
|
||||||
|
attackerKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("attacker key: %v", err)
|
||||||
|
}
|
||||||
|
verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"trusted-kid": &trustedKey.PublicKey}, "https://idp.example.test")
|
||||||
|
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": "angreifer@example.test",
|
||||||
|
"iss": "https://idp.example.test",
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
forgedToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
forgedToken.Header["kid"] = "trusted-kid" // gibt vor, mit dem bekannten Schluessel signiert zu sein
|
||||||
|
forged, err := forgedToken.SignedString(attackerKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token mit fremdem schluessel signieren: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := verifier.Verify(forged); err == nil {
|
||||||
|
t.Fatal("mit fremdem schluessel signiertes token wurde akzeptiert - kritische luecke")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPentest_OIDC_TamperedClaims prueft, dass eine nachtraegliche
|
||||||
|
// Claims-Manipulation (z.B. sub oder roles aendern, ohne neu zu signieren)
|
||||||
|
// zuverlaessig an der Signaturpruefung scheitert.
|
||||||
|
func TestPentest_OIDC_TamperedClaims(t *testing.T) {
|
||||||
|
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa key: %v", err)
|
||||||
|
}
|
||||||
|
verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test")
|
||||||
|
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": "normaler-benutzer@example.test",
|
||||||
|
"iss": "https://idp.example.test",
|
||||||
|
"roles": []string{"user"},
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
valid := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
valid.Header["kid"] = "kid-1"
|
||||||
|
signed, err := valid.SignedString(rsaKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token signieren: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gegenprobe: das unveraenderte, echte Token wird akzeptiert.
|
||||||
|
if _, err := verifier.Verify(signed); err != nil {
|
||||||
|
t.Fatalf("unveraendertes, echtes token wurde abgewiesen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claims-Manipulation: Payload-Segment durch eine Version mit
|
||||||
|
// erhoehter Rolle ersetzen, Signatur-Segment unveraendert lassen.
|
||||||
|
parts := strings.Split(signed, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
t.Fatalf("unerwartetes token-format: %d segmente", len(parts))
|
||||||
|
}
|
||||||
|
tamperedClaims := jwt.MapClaims{
|
||||||
|
"sub": "normaler-benutzer@example.test",
|
||||||
|
"iss": "https://idp.example.test",
|
||||||
|
"roles": []string{"superadmin"}, // Rechteausweitung
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
tamperedHeader := jwt.NewWithClaims(jwt.SigningMethodRS256, tamperedClaims)
|
||||||
|
tamperedHeader.Header["kid"] = "kid-1"
|
||||||
|
tamperedUnsigned, err := tamperedHeader.SigningString()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("manipulierten payload kodieren: %v", err)
|
||||||
|
}
|
||||||
|
tamperedParts := strings.Split(tamperedUnsigned, ".")
|
||||||
|
forged := tamperedParts[0] + "." + tamperedParts[1] + "." + parts[2] // fremder payload + ALTE signatur
|
||||||
|
|
||||||
|
if _, err := verifier.Verify(forged); err == nil {
|
||||||
|
t.Fatal("token mit manipulierten claims (rechteausweitung) wurde akzeptiert - kritische luecke")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPentest_OIDC_NonceReplay ist Pruefung 2 (Replay-Schutz): ein gueltiges,
|
||||||
|
// korrekt signiertes ID-Token eines FRUEHEREN Logins darf nicht fuer einen
|
||||||
|
// neuen Login-Vorgang mit einem anderen erwarteten Nonce wiederverwendet
|
||||||
|
// werden koennen.
|
||||||
|
func TestPentest_OIDC_NonceReplay(t *testing.T) {
|
||||||
|
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa key: %v", err)
|
||||||
|
}
|
||||||
|
verifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &rsaKey.PublicKey}, "https://idp.example.test")
|
||||||
|
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": "benutzer@example.test",
|
||||||
|
"iss": "https://idp.example.test",
|
||||||
|
"nonce": "nonce-aus-login-versuch-1",
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
valid := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
valid.Header["kid"] = "kid-1"
|
||||||
|
signed, err := valid.SignedString(rsaKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token signieren: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der urspruengliche Login-Versuch (passender Nonce) ist gueltig.
|
||||||
|
if _, err := verifier.VerifyWithNonce(signed, "nonce-aus-login-versuch-1"); err != nil {
|
||||||
|
t.Fatalf("token mit korrektem nonce wurde abgewiesen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Angreifer faengt das Token ab und spielt es bei einem NEUEN
|
||||||
|
// Login-Versuch (mit eigenem, anderem erwarteten Nonce) erneut ein.
|
||||||
|
if _, err := verifier.VerifyWithNonce(signed, "nonce-aus-login-versuch-2"); err == nil {
|
||||||
|
t.Fatal("token-replay mit abweichendem nonce wurde akzeptiert - kritische luecke")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPentest_OIDC_IdPTokenManipulation prueft den Weg, bei dem NEXARCH
|
||||||
|
// selbst als OIDC-Provider fuer Drittanwendungen auftritt (IAM-13,
|
||||||
|
// internal/oidc.Handler.JWKS + issueIDToken, Ed25519/EdDSA). Ein Angreifer
|
||||||
|
// versucht, ein selbst signiertes ID-Token gegen den echten, aus dem
|
||||||
|
// JWKS-Endpunkt gelesenen Schluessel zu verifizieren.
|
||||||
|
func TestPentest_OIDC_IdPTokenManipulation(t *testing.T) {
|
||||||
|
genuinePub, genuinePriv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("echtes schluesselpaar erzeugen: %v", err)
|
||||||
|
}
|
||||||
|
attackerPub, attackerPriv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("angreifer-schluesselpaar erzeugen: %v", err)
|
||||||
|
}
|
||||||
|
_ = attackerPub
|
||||||
|
|
||||||
|
claims := oidc.IDTokenClaims{
|
||||||
|
Subject: "opfer-user-id",
|
||||||
|
TenantSlug: "opfer-tenant",
|
||||||
|
Scopes: []string{"openid"},
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: "https://core.nexarch.example",
|
||||||
|
Audience: jwt.ClaimStrings{"drittanwendung-client-id"},
|
||||||
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
forged := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
||||||
|
forged.Header["kid"] = "genuine-kid" // gibt vor, mit dem echten Schluessel signiert zu sein
|
||||||
|
forgedSigned, err := forged.SignedString(attackerPriv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("token mit angreiferschluessel signieren: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifikation exakt wie eine Drittanwendung sie gegen den echten,
|
||||||
|
// aus dem JWKS-Endpunkt bezogenen Schluessel durchfuehren wuerde.
|
||||||
|
verify := func(tokenString string, kid2key map[string]ed25519.PublicKey) error {
|
||||||
|
parsed, err := jwt.Parse(tokenString, func(tk *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := tk.Method.(*jwt.SigningMethodEd25519); !ok {
|
||||||
|
return nil, fmt.Errorf("unerwarteter algorithmus: %v", tk.Header["alg"])
|
||||||
|
}
|
||||||
|
kid, _ := tk.Header["kid"].(string)
|
||||||
|
pub, ok := kid2key[kid]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unbekannte kid %q", kid)
|
||||||
|
}
|
||||||
|
return pub, nil
|
||||||
|
})
|
||||||
|
if err != nil || !parsed.Valid {
|
||||||
|
return fmt.Errorf("ungueltig: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
trustedKeys := map[string]ed25519.PublicKey{"genuine-kid": genuinePub}
|
||||||
|
|
||||||
|
if err := verify(forgedSigned, trustedKeys); err == nil {
|
||||||
|
t.Fatal("mit angreiferschluessel signiertes idp-token wurde akzeptiert - kritische luecke")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gegenprobe: ein echtes, mit dem tatsaechlichen Schluessel signiertes
|
||||||
|
// Token muss weiterhin akzeptiert werden (keine False-Positive-Abwehr).
|
||||||
|
genuine := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
||||||
|
genuine.Header["kid"] = "genuine-kid"
|
||||||
|
genuineSigned, err := genuine.SignedString(genuinePriv)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("echtes token signieren: %v", err)
|
||||||
|
}
|
||||||
|
if err := verify(genuineSigned, trustedKeys); err != nil {
|
||||||
|
t.Fatalf("echtes, korrekt signiertes idp-token wurde abgewiesen: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// Pruefung 1 / Akzeptanzkriterium 2: Rate-Limiting/Account-Lockout im
|
||||||
|
// Mehrinstanz-Betrieb nachweislich nicht umgehbar.
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
// TestPentest_RateLimit_MultiInstanceBypassAttempt simuliert einen
|
||||||
|
// Angreifer, der versucht, das Rate-Limit zu umgehen, indem er seine
|
||||||
|
// Anfragen ueber MEHRERE Core-Instanzen (hier: mehrere unabhaengige
|
||||||
|
// pgxpool.Pool-Verbindungen zur selben Postgres-Instanz, wie es getrennte
|
||||||
|
// Core-Prozesse waeren) verteilt, in der Annahme, jede Instanz fuehre einen
|
||||||
|
// eigenen In-Process-Zaehler. Da ratelimit.Store atomar gegen dieselbe
|
||||||
|
// geteilte Tabelle arbeitet (API-03), darf die Gesamtsumme der erlaubten
|
||||||
|
// Anfragen ueber ALLE "Instanzen" hinweg das konfigurierte Limit nicht
|
||||||
|
// uebersteigen.
|
||||||
|
func TestPentest_RateLimit_MultiInstanceBypassAttempt(t *testing.T) {
|
||||||
|
_, adminDSN := setupRegistryPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
key := uniqueSlug("pentest-ratelimit")
|
||||||
|
|
||||||
|
const instanceCount = 4
|
||||||
|
const requestsPerInstance = 50
|
||||||
|
const limit = 100
|
||||||
|
|
||||||
|
// Eine eigene Konfiguration ueber EINE der "Instanzen" gesetzt - muss
|
||||||
|
// fuer alle anderen sofort sichtbar sein (geteilter Zustand).
|
||||||
|
configPool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("config pool: %v", err)
|
||||||
|
}
|
||||||
|
defer configPool.Close()
|
||||||
|
if err := ratelimit.NewStore(configPool).SetLimit(ctx, key, limit, time.Minute); err != nil {
|
||||||
|
t.Fatalf("limit setzen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowed int64
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < instanceCount; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
// Jede "Instanz" hat einen EIGENEN Pool - simuliert getrennte
|
||||||
|
// Prozesse, die sich keinen In-Memory-Zustand teilen.
|
||||||
|
instancePool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("instanz-pool: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer instancePool.Close()
|
||||||
|
store := ratelimit.NewStore(instancePool)
|
||||||
|
|
||||||
|
for j := 0; j < requestsPerInstance; j++ {
|
||||||
|
res, err := store.Allow(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("allow: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if res.Allowed {
|
||||||
|
atomic.AddInt64(&allowed, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
totalRequests := int64(instanceCount * requestsPerInstance)
|
||||||
|
if allowed > limit {
|
||||||
|
t.Fatalf("mehrinstanz-umgehungsversuch erfolgreich: %d von %d anfragen ueber %d instanzen erlaubt, konfiguriertes limit war %d",
|
||||||
|
allowed, totalRequests, instanceCount, limit)
|
||||||
|
}
|
||||||
|
if allowed != limit {
|
||||||
|
t.Fatalf("erwartet exakt %d erlaubte anfragen (limit ausgeschoepft, nicht mehr/weniger), habe %d", limit, allowed)
|
||||||
|
}
|
||||||
|
t.Logf("mehrinstanz-umgehungsversuch protokolliert erfolglos: %d instanzen, %d anfragen gesamt, exakt %d (=limit) erlaubt", instanceCount, totalRequests, allowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPentest_Lockout_MultiInstanceBypassAttempt simuliert denselben
|
||||||
|
// Umgehungsversuch gegen den Account-Lockout (IAM-07): ein Angreifer verteilt
|
||||||
|
// Brute-Force-Loginversuche fuer EIN Konto ueber mehrere Instanzen, um zu
|
||||||
|
// verhindern, dass eine einzelne Instanz die Schwelle erreicht.
|
||||||
|
func TestPentest_Lockout_MultiInstanceBypassAttempt(t *testing.T) {
|
||||||
|
registryPool, adminDSN := setupRegistryPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
|
||||||
|
registry := tenant.NewRegistry(registryPool)
|
||||||
|
provisioner := tenant.NewProvisioner(registryPool, registry, dsnTemplate)
|
||||||
|
|
||||||
|
slug := uniqueSlug("pentest_lockout")
|
||||||
|
tn, err := provisioner.Provision(ctx, slug, "Pentest Lockout GmbH")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provision: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
|
||||||
|
_, _ = registryPool.Exec(ctx, `DROP DATABASE IF EXISTS "tenant_`+slug+`"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
tenantPool, err := pgxpool.New(ctx, tn.DBDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tenant pool: %v", err)
|
||||||
|
}
|
||||||
|
defer tenantPool.Close()
|
||||||
|
applyAllUpSQL(t, ctx, tenantPool, filepath.Join(repoRoot(t), "migrations", "tenant"))
|
||||||
|
|
||||||
|
const email = "opfer@pentest.example"
|
||||||
|
const maxFailed = lockout.DefaultMaxFailedAttempts
|
||||||
|
const instanceCount = 3
|
||||||
|
const attemptsPerInstance = 4 // 3*4=12 > maxFailed(5), muesste ohne umgehung frueh sperren
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
var lockedAt []int // Reihenfolge, in der jede "Instanz" eine Sperre beobachtet hat
|
||||||
|
for i := 0; i < instanceCount; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
instanceID := i
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
instancePool, err := pgxpool.New(ctx, tn.DBDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("instanz-pool: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer instancePool.Close()
|
||||||
|
store := lockout.NewStore(instancePool)
|
||||||
|
|
||||||
|
for j := 0; j < attemptsPerInstance; j++ {
|
||||||
|
locked, _, err := store.RecordFailure(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("fehlversuch erfassen: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
|
mu.Lock()
|
||||||
|
lockedAt = append(lockedAt, instanceID)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if len(lockedAt) == 0 {
|
||||||
|
t.Fatal("mehrinstanz-umgehungsversuch erfolgreich: konto wurde trotz insgesamt mehr als maxFailed fehlversuchen ueber mehrere instanzen NICHT gesperrt")
|
||||||
|
}
|
||||||
|
|
||||||
|
locked, _, err := lockout.NewStore(tenantPool).IsLocked(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sperrstatus pruefen: %v", err)
|
||||||
|
}
|
||||||
|
if !locked {
|
||||||
|
t.Fatal("konto ist nach ueberschreiten der schwelle ueber mehrere instanzen hinweg nicht als gesperrt markiert")
|
||||||
|
}
|
||||||
|
t.Logf("mehrinstanz-umgehungsversuch protokolliert erfolglos: %d instanzen x %d versuche, konto nach ueberschreiten von maxFailed=%d gesperrt", instanceCount, attemptsPerInstance, maxFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// Pruefung 3 / Akzeptanzkriterium 3: zentrale Policy-Durchsetzung gegen
|
||||||
|
// mindestens einen gezielten Umgehungsversuch je Modul.
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
// TestPentest_Policy_PrivilegeEscalationViaWrongRole ist der
|
||||||
|
// Umgehungsversuch fuer den Identitaets-/Mandantenbereich (TEN/IAM-Module):
|
||||||
|
// ein normaler Benutzer versucht, ueber den zentralen Enforcer (RBAC-02) ein
|
||||||
|
// Recht zu nutzen, das ausschliesslich Superadmin vorbehalten ist
|
||||||
|
// (PermManageTenants). Default-Deny muss greifen.
|
||||||
|
func TestPentest_Policy_PrivilegeEscalationViaWrongRole(t *testing.T) {
|
||||||
|
registryPool, _ := setupRegistryPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
store := policy.NewStore(registryPool)
|
||||||
|
enforcer := policy.NewEnforcer(store)
|
||||||
|
|
||||||
|
// Nur die Basisrechte gewaehren, wie es die reale Policy vorsieht -
|
||||||
|
// PermManageTenants bewusst NICHT an user oder tenant_admin vergeben.
|
||||||
|
if err := store.Grant(ctx, rbac.RoleUser, rbac.PermSelfRead, "pentest-setup"); err != nil {
|
||||||
|
t.Fatalf("basisrecht gewaehren: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = store.Revoke(ctx, rbac.RoleUser, rbac.PermSelfRead, "pentest-cleanup")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Angriff: ein Benutzer mit Rolle "user" versucht, eine
|
||||||
|
// mandantenverwaltende Aktion auszufuehren.
|
||||||
|
err := enforcer.Authorize(ctx, rbac.RoleUser, rbac.PermManageTenants)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("privilege-escalation erfolgreich: rolle 'user' durfte platform.manage_tenants ausfuehren")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, policy.ErrDenied) {
|
||||||
|
t.Fatalf("erwartete policy.ErrDenied, habe: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Angriff Variante 2: dieselbe Aktion fuer tenant_admin, der ebenfalls
|
||||||
|
// nicht ueber PermManageTenants verfuegt.
|
||||||
|
if err := enforcer.Authorize(ctx, rbac.RoleTenantAdmin, rbac.PermManageTenants); !errors.Is(err, policy.ErrDenied) {
|
||||||
|
t.Fatalf("privilege-escalation ueber tenant_admin: erwartete policy.ErrDenied, habe: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPentest_Policy_GuardBypassStructurallyImpossible ist der
|
||||||
|
// Umgehungsversuch gegen die Durchsetzungsschicht selbst (Akzeptanzkriterium
|
||||||
|
// 1): policy.Guard ruft die uebergebene Query-Funktion NUR bei Erfolg von
|
||||||
|
// Authorize auf. Dieser Test beweist das Verhalten aktiv, indem ein
|
||||||
|
// Zaehler in der Query-Funktion inkrementiert wird - bei einer verweigerten
|
||||||
|
// Autorisierung darf der Zaehler NIEMALS erhoeht werden, unabhaengig davon,
|
||||||
|
// wie oft Guard aufgerufen wird.
|
||||||
|
func TestPentest_Policy_GuardBypassStructurallyImpossible(t *testing.T) {
|
||||||
|
registryPool, _ := setupRegistryPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
store := policy.NewStore(registryPool)
|
||||||
|
enforcer := policy.NewEnforcer(store)
|
||||||
|
// Bewusst KEINE Regel gewaehrt - default-deny.
|
||||||
|
|
||||||
|
var dataAccessed int32
|
||||||
|
query := func(ctx context.Context) (string, error) {
|
||||||
|
atomic.AddInt32(&dataAccessed, 1)
|
||||||
|
return "geheime mandantendaten", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
_, err := policy.Guard(ctx, enforcer, rbac.RoleUser, rbac.PermManageSettings, query)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("guard hat query trotz fehlender berechtigung ausgefuehrt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if atomic.LoadInt32(&dataAccessed) != 0 {
|
||||||
|
t.Fatalf("umgehungsversuch erfolgreich: query wurde %d mal trotz verweigerter autorisierung ausgefuehrt", dataAccessed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =======================================================================
|
||||||
|
// Akzeptanzkriterium 4: Master-Key- und Tenant-KEK-Verwaltung (API-10)
|
||||||
|
// gegen Schluesselkompromittierung und Rotation geprueft.
|
||||||
|
// =======================================================================
|
||||||
|
|
||||||
|
// TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation prueft: nach
|
||||||
|
// einer Master-Key-Rotation kann der ALTE (potenziell kompromittierte)
|
||||||
|
// Master-Key die Tenant-KEKs nicht mehr entschluesseln - kompromittiert ein
|
||||||
|
// Angreifer den alten Master-Key NACH der Rotation, ist der Zugriff bereits
|
||||||
|
// verwehrt.
|
||||||
|
func TestPentest_KEK_CompromisedMasterKeyCannotDecryptAfterRotation(t *testing.T) {
|
||||||
|
oldMasterKey := randomMasterKey(t)
|
||||||
|
newMasterKey := randomMasterKey(t)
|
||||||
|
|
||||||
|
store, pool := setupKEKStore(t)
|
||||||
|
tenantID := insertTenantRow(t, pool, uniqueSlug("pentest_kek"))
|
||||||
|
|
||||||
|
plainKEK, err := store.CreateForTenant(context.Background(), tenantID, oldMasterKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tenant-kek anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rotated, failed, err := store.RotateMasterKey(context.Background(), oldMasterKey, newMasterKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("master-key-rotation: %v", err)
|
||||||
|
}
|
||||||
|
if rotated != 1 || len(failed) != 0 {
|
||||||
|
t.Fatalf("rotation unerwartet: rotated=%d failed=%v", rotated, failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Angriff: der (kompromittierte) ALTE Master-Key wird nach der Rotation
|
||||||
|
// verwendet, um den Tenant-KEK zu entschluesseln.
|
||||||
|
if _, err := store.GetDecrypted(context.Background(), tenantID, oldMasterKey); err == nil {
|
||||||
|
t.Fatal("kompromittierter alter master-key konnte tenant-kek nach rotation weiterhin entschluesseln - kritische luecke")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gegenprobe: der neue Master-Key liefert weiterhin denselben Plaintext-
|
||||||
|
// Tenant-KEK (Akzeptanzkriterium 3: Rotation aendert nur die Verpackung).
|
||||||
|
decrypted, err := store.GetDecrypted(context.Background(), tenantID, newMasterKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("neuer master-key konnte tenant-kek nicht entschluesseln: %v", err)
|
||||||
|
}
|
||||||
|
if string(decrypted) != string(plainKEK) {
|
||||||
|
t.Fatal("tenant-kek-plaintext hat sich durch master-key-rotation veraendert - widerspricht akzeptanzkriterium 3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPentest_KEK_TenantKEKRotationDoesNotAffectOtherTenants ist der
|
||||||
|
// Umgehungs-/Kompromittierungstest fuer Tenant-KEK-Rotation: kompromittiert
|
||||||
|
// ein Angreifer den Tenant-KEK von Mandant A und dieser wird rotiert, darf
|
||||||
|
// das keinerlei Auswirkung auf Mandant B haben (Fortsetzung der physischen
|
||||||
|
// Modell-C-Isolation auf Schluesselebene).
|
||||||
|
func TestPentest_KEK_TenantKEKRotationDoesNotAffectOtherTenants(t *testing.T) {
|
||||||
|
masterKey := randomMasterKey(t)
|
||||||
|
store, pool := setupKEKStore(t)
|
||||||
|
|
||||||
|
tenantA := insertTenantRow(t, pool, uniqueSlug("pentest_kek_a"))
|
||||||
|
tenantB := insertTenantRow(t, pool, uniqueSlug("pentest_kek_b"))
|
||||||
|
|
||||||
|
if _, err := store.CreateForTenant(context.Background(), tenantA, masterKey); err != nil {
|
||||||
|
t.Fatalf("tenant a kek anlegen: %v", err)
|
||||||
|
}
|
||||||
|
kekBBefore, err := store.CreateForTenant(context.Background(), tenantB, masterKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tenant b kek anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant As KEK wird rotiert (z.B. weil kompromittiert).
|
||||||
|
if _, err := store.RotateTenantKEK(context.Background(), tenantA, masterKey); err != nil {
|
||||||
|
t.Fatalf("tenant a rotation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kekBAfter, err := store.GetDecrypted(context.Background(), tenantB, masterKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tenant b kek nach fremder rotation nicht mehr lesbar: %v", err)
|
||||||
|
}
|
||||||
|
if string(kekBAfter) != string(kekBBefore) {
|
||||||
|
t.Fatal("tenant bs kek hat sich durch rotation von tenant a veraendert - isolationsverletzung")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPentest_KEK_TamperedWrappedKeyRejected prueft, dass eine Manipulation
|
||||||
|
// des in der Datenbank gespeicherten, verpackten Tenant-KEK (z.B. durch
|
||||||
|
// einen Angreifer mit Datenbankzugriff, aber ohne Master-Key) beim
|
||||||
|
// Entschluesseln zuverlaessig als ungueltig erkannt wird - AES-GCM ist
|
||||||
|
// authentifizierend, ein manipulierter Chiffretext darf niemals einen
|
||||||
|
// (falschen) Plaintext zurueckliefern.
|
||||||
|
func TestPentest_KEK_TamperedWrappedKeyRejected(t *testing.T) {
|
||||||
|
masterKey := randomMasterKey(t)
|
||||||
|
store, pool := setupKEKStore(t)
|
||||||
|
tenantID := insertTenantRow(t, pool, uniqueSlug("pentest_kek_tamper"))
|
||||||
|
|
||||||
|
if _, err := store.CreateForTenant(context.Background(), tenantID, masterKey); err != nil {
|
||||||
|
t.Fatalf("tenant-kek anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Angriff: ein Byte im gespeicherten, verpackten Schluessel wird
|
||||||
|
// direkt in der Datenbank veraendert (kein Master-Key noetig fuer diesen
|
||||||
|
// Schritt).
|
||||||
|
tag, err := pool.Exec(context.Background(), `
|
||||||
|
UPDATE tenant_keks SET wrapped_kek = set_byte(wrapped_kek, 20, get_byte(wrapped_kek, 20) # 255)
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
`, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("wrapped_kek manipulieren: %v", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() != 1 {
|
||||||
|
t.Fatalf("erwartet 1 manipulierte zeile, habe %d", tag.RowsAffected())
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.GetDecrypted(context.Background(), tenantID, masterKey); !errors.Is(err, kek.ErrUnwrapFailed) {
|
||||||
|
t.Fatalf("manipulierter wrapped_kek wurde nicht als ErrUnwrapFailed erkannt: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// KEK-Testhilfsfunktionen (eigenes Minimalschema wie in internal/kek/kek_test.go,
|
||||||
|
// mit derselben Aufraeum-Disziplin - siehe QA-04-Befund zu
|
||||||
|
// t.Cleanup/defer-Reihenfolge).
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
func randomMasterKey(t *testing.T) kek.MasterKey {
|
||||||
|
t.Helper()
|
||||||
|
buf := make([]byte, kek.MasterKeySize)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
t.Fatalf("master-key erzeugen: %v", err)
|
||||||
|
}
|
||||||
|
return kek.MasterKey(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupKEKStore(t *testing.T) (*kek.Store, *pgxpool.Pool) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := requireAdminDSN(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
// Ueber t.Cleanup registriert (nicht defer), damit spaeter via t.Cleanup
|
||||||
|
// registrierte Aufraeumfunktionen (insertTenantRow) den Pool beim
|
||||||
|
// Ausfuehren noch offen vorfinden - siehe QA-04-Befund in
|
||||||
|
// internal/kek/kek_test.go zur defer/t.Cleanup-Reihenfolge.
|
||||||
|
t.Cleanup(func() { pool.Close() })
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_keks (
|
||||||
|
tenant_id UUID PRIMARY KEY REFERENCES tenants(id), wrapped_kek BYTEA NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), rotated_at TIMESTAMPTZ
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatalf("tenant_keks-schema: %v", err)
|
||||||
|
}
|
||||||
|
return kek.NewStore(pool), pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertTenantRow(t *testing.T, pool *pgxpool.Pool, slug string) string {
|
||||||
|
t.Helper()
|
||||||
|
var id string
|
||||||
|
err := pool.QueryRow(context.Background(), `
|
||||||
|
INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ($1, $1, $1, 'unused') RETURNING id
|
||||||
|
`, slug).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tenant-zeile anlegen: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_, _ = pool.Exec(context.Background(), `DELETE FROM tenant_keks WHERE tenant_id = $1`, id)
|
||||||
|
_, _ = pool.Exec(context.Background(), `DELETE FROM tenants WHERE id = $1`, id)
|
||||||
|
})
|
||||||
|
return id
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// 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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package pwpolicy
|
||||||
|
|
||||||
|
// commonPasswords ist eine kleine, eingebettete Sperrliste bekannt
|
||||||
|
// haeufig verwendeter/kompromittierter Passwoerter (Akzeptanzkriterium 2).
|
||||||
|
// Vergleich erfolgt case-insensitive (siehe Validate).
|
||||||
|
var commonPasswords = map[string]bool{
|
||||||
|
"123456": true,
|
||||||
|
"password": true,
|
||||||
|
"12345678": true,
|
||||||
|
"qwerty": true,
|
||||||
|
"123456789": true,
|
||||||
|
"12345": true,
|
||||||
|
"1234": true,
|
||||||
|
"111111": true,
|
||||||
|
"1234567": true,
|
||||||
|
"dragon": true,
|
||||||
|
"123123": true,
|
||||||
|
"baseball": true,
|
||||||
|
"iloveyou": true,
|
||||||
|
"trustno1": true,
|
||||||
|
"1234567890": true,
|
||||||
|
"sunshine": true,
|
||||||
|
"master": true,
|
||||||
|
"welcome": true,
|
||||||
|
"admin": true,
|
||||||
|
"letmein": true,
|
||||||
|
"login": true,
|
||||||
|
"passw0rd": true,
|
||||||
|
"starwars": true,
|
||||||
|
"abc123": true,
|
||||||
|
"password1": true,
|
||||||
|
"qwerty123": true,
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package pwpolicy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoginAndCheckPolicy komponiert IAM-02s LoginService mit der Passwort-
|
||||||
|
// Richtlinienpruefung, OHNE LoginService selbst zu veraendern. Ein
|
||||||
|
// bestehender Benutzer mit einem alten, nicht mehr konformen Passwort kann
|
||||||
|
// sich weiterhin einloggen (Login schlaegt NICHT fehl) — er wird lediglich
|
||||||
|
// zur Aenderung aufgefordert (Akzeptanzkriterium 3: kein rueckwirkendes
|
||||||
|
// Aussperren). Die Pruefung ist nur HIER moeglich, da nur beim Login das
|
||||||
|
// Klartext-Passwort kurzzeitig vorliegt — der gespeicherte bcrypt-Hash laesst
|
||||||
|
// sich nicht rueckwirkend gegen eine neue Richtlinie pruefen.
|
||||||
|
func LoginAndCheckPolicy(ctx context.Context, policies *Store, login *auth.LoginService, email, password string) (token string, mustChangePassword bool, err error) {
|
||||||
|
token, err = login.Login(ctx, email, password)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
policy, err := policies.Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
mustChangePassword = Validate(policy, password) != nil
|
||||||
|
return token, mustChangePassword, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Package pwpolicy implementiert Core IAM-14: konfigurierbare Passwort-
|
||||||
|
// Komplexitätsanforderungen pro Tenant — eine schmale Regelschicht VOR dem
|
||||||
|
// bcrypt-Hashing aus IAM-02, kein eigenes Policy-Framework.
|
||||||
|
package pwpolicy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Policy sind die Mindestanforderungen (Akzeptanzkriterium 1).
|
||||||
|
type Policy struct {
|
||||||
|
MinLength int
|
||||||
|
RequireUpper bool
|
||||||
|
RequireLower bool
|
||||||
|
RequireDigit bool
|
||||||
|
RequireSpecial bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultPolicy gilt, solange ein Tenant keine eigene Konfiguration gesetzt hat.
|
||||||
|
func DefaultPolicy() Policy {
|
||||||
|
return Policy{MinLength: 12, RequireUpper: true, RequireLower: true, RequireDigit: true, RequireSpecial: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrPasswordTooWeak = errors.New("pwpolicy: passwort erfuellt die richtlinie nicht")
|
||||||
|
|
||||||
|
// Validate prueft ein Passwort gegen policy UND die Sperrliste
|
||||||
|
// (Akzeptanzkriterium 2). Liefert bei Verstoss ErrPasswordTooWeak,
|
||||||
|
// gewrappt mit einer fuer Menschen lesbaren Begruendung.
|
||||||
|
func Validate(policy Policy, password string) error {
|
||||||
|
if commonPasswords[strings.ToLower(password)] {
|
||||||
|
return fmt.Errorf("%w: passwort steht auf der sperrliste haeufig verwendeter passwoerter", ErrPasswordTooWeak)
|
||||||
|
}
|
||||||
|
if len(password) < policy.MinLength {
|
||||||
|
return fmt.Errorf("%w: mindestens %d zeichen erforderlich", ErrPasswordTooWeak, policy.MinLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasUpper, hasLower, hasDigit, hasSpecial bool
|
||||||
|
for _, r := range password {
|
||||||
|
switch {
|
||||||
|
case unicode.IsUpper(r):
|
||||||
|
hasUpper = true
|
||||||
|
case unicode.IsLower(r):
|
||||||
|
hasLower = true
|
||||||
|
case unicode.IsDigit(r):
|
||||||
|
hasDigit = true
|
||||||
|
case unicode.IsPunct(r) || unicode.IsSymbol(r):
|
||||||
|
hasSpecial = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if policy.RequireUpper && !hasUpper {
|
||||||
|
return fmt.Errorf("%w: mindestens ein grossbuchstabe erforderlich", ErrPasswordTooWeak)
|
||||||
|
}
|
||||||
|
if policy.RequireLower && !hasLower {
|
||||||
|
return fmt.Errorf("%w: mindestens ein kleinbuchstabe erforderlich", ErrPasswordTooWeak)
|
||||||
|
}
|
||||||
|
if policy.RequireDigit && !hasDigit {
|
||||||
|
return fmt.Errorf("%w: mindestens eine ziffer erforderlich", ErrPasswordTooWeak)
|
||||||
|
}
|
||||||
|
if policy.RequireSpecial && !hasSpecial {
|
||||||
|
return fmt.Errorf("%w: mindestens ein sonderzeichen erforderlich", ErrPasswordTooWeak)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store verwaltet die pro-Tenant konfigurierte Richtlinie (eine Zeile je
|
||||||
|
// Tenant-Datenbank, Modell C).
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get liefert die konfigurierte Richtlinie, oder DefaultPolicy() wenn noch
|
||||||
|
// keine gesetzt wurde.
|
||||||
|
func (s *Store) Get(ctx context.Context) (Policy, error) {
|
||||||
|
var p Policy
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT min_length, require_upper, require_lower, require_digit, require_special
|
||||||
|
FROM password_policy WHERE id = true
|
||||||
|
`).Scan(&p.MinLength, &p.RequireUpper, &p.RequireLower, &p.RequireDigit, &p.RequireSpecial)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return DefaultPolicy(), nil
|
||||||
|
}
|
||||||
|
return Policy{}, fmt.Errorf("richtlinie lesen: %w", err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set legt die Richtlinie fuer diesen Tenant fest (Akzeptanzkriterium 1).
|
||||||
|
func (s *Store) Set(ctx context.Context, p Policy) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO password_policy (id, min_length, require_upper, require_lower, require_digit, require_special, updated_at)
|
||||||
|
VALUES (true, $1, $2, $3, $4, $5, now())
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
min_length = $1, require_upper = $2, require_lower = $3, require_digit = $4, require_special = $5, updated_at = now()
|
||||||
|
`, p.MinLength, p.RequireUpper, p.RequireLower, p.RequireDigit, p.RequireSpecial)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("richtlinie speichern: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package pwpolicy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 1: zu kurzes/zu einfaches Passwort abgelehnt.
|
||||||
|
func TestValidate_RejectsTooShortOrSimple(t *testing.T) {
|
||||||
|
policy := DefaultPolicy()
|
||||||
|
|
||||||
|
if err := Validate(policy, "kurz1A"); !errors.Is(err, ErrPasswordTooWeak) {
|
||||||
|
t.Fatalf("erwartet ErrPasswordTooWeak fuer zu kurzes passwort, habe %v", err)
|
||||||
|
}
|
||||||
|
if err := Validate(policy, "alleskleingeschriebenundlang123"); !errors.Is(err, ErrPasswordTooWeak) {
|
||||||
|
t.Fatalf("erwartet ErrPasswordTooWeak ohne grossbuchstaben, habe %v", err)
|
||||||
|
}
|
||||||
|
if err := Validate(policy, "GutesPasswort2026!"); err != nil {
|
||||||
|
t.Fatalf("erwartet gueltig, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2: Passwort aus Sperrliste abgelehnt.
|
||||||
|
func TestValidate_RejectsBlocklistedPassword(t *testing.T) {
|
||||||
|
policy := Policy{MinLength: 4} // absichtlich schwach, damit NUR die sperrliste greift
|
||||||
|
|
||||||
|
if err := Validate(policy, "password"); !errors.Is(err, ErrPasswordTooWeak) {
|
||||||
|
t.Fatalf("erwartet ErrPasswordTooWeak fuer sperrlisten-passwort, habe %v", err)
|
||||||
|
}
|
||||||
|
if err := Validate(policy, "PASSWORD"); !errors.Is(err, ErrPasswordTooWeak) {
|
||||||
|
t.Fatalf("erwartet case-insensitive treffer auf der sperrliste, habe %v", err)
|
||||||
|
}
|
||||||
|
if err := Validate(policy, "ein-eher-unueblicher-satz"); err != nil {
|
||||||
|
t.Fatalf("nicht-sperrlisten-passwort sollte hier gueltig sein: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTest(t *testing.T) (*Store, *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 users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS password_policy (
|
||||||
|
id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id), min_length INT NOT NULL DEFAULT 12,
|
||||||
|
require_upper BOOLEAN NOT NULL DEFAULT true, require_lower BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
require_digit BOOLEAN NOT NULL DEFAULT true, require_special BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton-Tabelle: vor jedem Test leeren, falls ein vorheriger Lauf
|
||||||
|
// (in dieser geteilten Registry-Tabelle) eine Zeile hinterlassen hat.
|
||||||
|
if _, err := pool.Exec(ctx, `DELETE FROM password_policy`); err != nil {
|
||||||
|
t.Fatalf("vorab-bereinigung: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM password_policy`)
|
||||||
|
pool.Close()
|
||||||
|
}
|
||||||
|
return NewStore(pool), pool, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_GetReturnsDefaultWhenUnset(t *testing.T) {
|
||||||
|
store, _, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
got, err := store.Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got != DefaultPolicy() {
|
||||||
|
t.Fatalf("erwartet default policy, habe %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_SetAndGetRoundTrip(t *testing.T) {
|
||||||
|
store, _, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
custom := Policy{MinLength: 16, RequireUpper: true, RequireLower: true, RequireDigit: true, RequireSpecial: true}
|
||||||
|
if err := store.Set(ctx, custom); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
got, err := store.Get(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got != custom {
|
||||||
|
t.Fatalf("erwartet %+v, habe %+v", custom, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3: bestehender Nutzer mit altem,
|
||||||
|
// nicht-konformem Passwort kann sich noch einloggen, wird aber zur
|
||||||
|
// Aenderung aufgefordert — kein rueckwirkendes Aussperren.
|
||||||
|
func TestLoginAndCheckPolicy_FlagsNonConformantExistingPassword(t *testing.T) {
|
||||||
|
store, pool, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
email := fmt.Sprintf("pwpolicy-test-%d@example.com", time.Now().UnixNano())
|
||||||
|
userStore := user.NewTenantUserStore(pool)
|
||||||
|
u, err := userStore.Create(ctx, email, "Legacy User")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Altes Passwort, das VOR der heutigen Richtlinie gesetzt wurde — erfuellt
|
||||||
|
// die aktuelle DefaultPolicy() nicht (kein Grossbuchstabe, zu kurz).
|
||||||
|
oldPassword := "altespasswort"
|
||||||
|
hash, err := auth.HashPassword(oldPassword)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash: %v", err)
|
||||||
|
}
|
||||||
|
if err := userStore.SetPasswordHash(ctx, u.ID, hash); err != nil {
|
||||||
|
t.Fatalf("set password hash: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
loginService := auth.NewLoginService(userStore, issuer, "acme")
|
||||||
|
|
||||||
|
token, mustChange, err := LoginAndCheckPolicy(ctx, store, loginService, email, oldPassword)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login sollte trotz schwachem altpasswort gelingen: %v", err)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
t.Fatal("erwartet gueltiges token")
|
||||||
|
}
|
||||||
|
if !mustChange {
|
||||||
|
t.Fatal("erwartet mustChangePassword=true fuer nicht-konformes altpasswort")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Konformes Passwort -> kein Aenderungszwang.
|
||||||
|
strongPassword := "EinStarkesPasswort2026!"
|
||||||
|
hash2, err := auth.HashPassword(strongPassword)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash 2: %v", err)
|
||||||
|
}
|
||||||
|
if err := userStore.SetPasswordHash(ctx, u.ID, hash2); err != nil {
|
||||||
|
t.Fatalf("set password hash 2: %v", err)
|
||||||
|
}
|
||||||
|
_, mustChange, err = LoginAndCheckPolicy(ctx, store, loginService, email, strongPassword)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("login mit starkem passwort: %v", err)
|
||||||
|
}
|
||||||
|
if mustChange {
|
||||||
|
t.Fatal("erwartet mustChangePassword=false fuer konformes passwort")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Package saml implementiert Core IAM-11: SAML 2.0 als zusaetzlicher
|
||||||
|
// Anmeldeweg neben OIDC (IAM-06).
|
||||||
|
//
|
||||||
|
// WICHTIGER HINWEIS: Die Signaturpruefung hier verifiziert eine RSA-SHA256-
|
||||||
|
// Signatur ueber die deterministisch (re-)marshalte Assertion — das deckt
|
||||||
|
// dieselbe Sicherheitseigenschaft ab wie XML-DSig (nur ein gueltig
|
||||||
|
// signiertes Assertion eines vertrauten IdP wird akzeptiert), implementiert
|
||||||
|
// aber NICHT die vollstaendige W3C-Exclusive-XML-Canonicalization (C14N) mit
|
||||||
|
// all ihren Randfaellen (Namespace-Normalisierung, Attribut-Reihenfolge
|
||||||
|
// etc.), die produktive SAML-Bibliotheken brauchen, um mit echten
|
||||||
|
// Enterprise-IdPs (ADFS, Okta, Azure AD) byteidentisch zu sein. Das Ticket
|
||||||
|
// erlaubt ausdruecklich einen "simulierten IdP" fuer die Pruefungen — dieser
|
||||||
|
// Simulator und der Verifier hier verwenden folgerichtig dieselbe
|
||||||
|
// deterministische Kodierung, echte Interop mit einem Drittprodukt-IdP ist
|
||||||
|
// nicht Teil dieser Kachel.
|
||||||
|
package saml
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type attributeXML struct {
|
||||||
|
Name string `xml:"Name,attr"`
|
||||||
|
Values []string `xml:"AttributeValue"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type conditionsXML struct {
|
||||||
|
NotBefore string `xml:"NotBefore,attr"`
|
||||||
|
NotOnOrAfter string `xml:"NotOnOrAfter,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssertionXML ist die (vereinfachte) SAML-Assertion.
|
||||||
|
type AssertionXML struct {
|
||||||
|
XMLName xml.Name `xml:"Assertion"`
|
||||||
|
Issuer string `xml:"Issuer"`
|
||||||
|
NameID string `xml:"Subject>NameID"`
|
||||||
|
Conditions conditionsXML `xml:"Conditions"`
|
||||||
|
Attributes []attributeXML `xml:"AttributeStatement>Attribute"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponseXML umhuellt die Assertion mit der Signatur ueber deren kanonische
|
||||||
|
// Bytes (siehe Paket-Dokumentation).
|
||||||
|
type ResponseXML struct {
|
||||||
|
XMLName xml.Name `xml:"Response"`
|
||||||
|
Assertion AssertionXML `xml:"Assertion"`
|
||||||
|
SignatureValue string `xml:"SignatureValue"` // base64
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanonicalAssertionBytes liefert die deterministischen Bytes, ueber die
|
||||||
|
// signiert/verifiziert wird.
|
||||||
|
func CanonicalAssertionBytes(a AssertionXML) ([]byte, error) {
|
||||||
|
b, err := xml.Marshal(a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("assertion serialisieren: %w", err)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attribute liefert die Werte eines benannten SAML-Attributs (z.B. "roles"),
|
||||||
|
// oder nil wenn nicht vorhanden.
|
||||||
|
func (a AssertionXML) Attribute(name string) []string {
|
||||||
|
for _, attr := range a.Attributes {
|
||||||
|
if attr.Name == name {
|
||||||
|
return attr.Values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithinValidity prueft die Conditions/NotBefore/NotOnOrAfter der Assertion
|
||||||
|
// gegen die aktuelle Zeit.
|
||||||
|
func (a AssertionXML) WithinValidity(now time.Time) error {
|
||||||
|
notBefore, err := time.Parse(time.RFC3339, a.Conditions.NotBefore)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("notbefore parsen: %w", err)
|
||||||
|
}
|
||||||
|
notOnOrAfter, err := time.Parse(time.RFC3339, a.Conditions.NotOnOrAfter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("notonorafter parsen: %w", err)
|
||||||
|
}
|
||||||
|
if now.Before(notBefore) {
|
||||||
|
return ErrNotYetValid
|
||||||
|
}
|
||||||
|
if !now.Before(notOnOrAfter) {
|
||||||
|
return ErrExpired
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package saml
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/ldapsync"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompleteSAMLLogin verifiziert die SAML-Response, bildet die Assertion auf
|
||||||
|
// das bestehende Benutzer-/Rollenmodell ab (Akzeptanzkriterium 3) und
|
||||||
|
// stellt bei Erfolg ein IAM-02-Sitzungs-Token aus. Rollen-Mapping laeuft
|
||||||
|
// ueber DIESELBE Erlaubnis-Matrix wie IAM-05/LDAP und IAM-06/OIDC
|
||||||
|
// (ldapsync.RoleMappingStore) — kein dritter paralleler Mapping-Mechanismus.
|
||||||
|
func CompleteSAMLLogin(
|
||||||
|
ctx context.Context,
|
||||||
|
verifier *Verifier,
|
||||||
|
users *user.TenantUserStore,
|
||||||
|
roles *rbac.Store,
|
||||||
|
roleMapping *ldapsync.RoleMappingStore,
|
||||||
|
issuer *auth.TokenIssuer,
|
||||||
|
tenantSlug, rawSAMLResponse string,
|
||||||
|
) (token string, err error) {
|
||||||
|
resp, err := Parse(rawSAMLResponse)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
assertion, err := verifier.Verify(resp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if assertion.NameID == "" {
|
||||||
|
return "", fmt.Errorf("%w: assertion ohne subject-nameid", ErrInvalidSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := findOrCreateUser(ctx, users, assertion.NameID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
roleAttrs := assertion.Attribute("roles")
|
||||||
|
if role, ok, err := roleMapping.HighestRoleFor(ctx, roleAttrs); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if ok {
|
||||||
|
if _, err := roles.Assign(ctx, u.ID, role, "saml-sso"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issuer.Issue(u.ID, tenantSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findOrCreateUser(ctx context.Context, users *user.TenantUserStore, email string) (user.User, error) {
|
||||||
|
creds, err := users.GetByEmailForAuth(ctx, email)
|
||||||
|
if err == nil {
|
||||||
|
return creds.User, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, user.ErrNotFound) {
|
||||||
|
return user.User{}, err
|
||||||
|
}
|
||||||
|
return users.Create(ctx, email, email)
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package saml
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rsa"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/ldapsync"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/oidc"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
const schema = `
|
||||||
|
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', password_hash TEXT NOT NULL DEFAULT '',
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS ldap_group_role_mapping (
|
||||||
|
ldap_group TEXT PRIMARY KEY, role TEXT NOT NULL CHECK (role IN ('user','tenant_admin'))
|
||||||
|
);`
|
||||||
|
|
||||||
|
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, schema); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
cleanup := func() { pool.Close() }
|
||||||
|
return pool, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 1: End-to-End-Test gegen simulierten SAML-IdP.
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3: Rollenzuordnung aus SAML-Attributen korrekt.
|
||||||
|
func TestCompleteSAMLLogin_EndToEnd(t *testing.T) {
|
||||||
|
pool, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
idpKey := generateTestIdPKey(t)
|
||||||
|
verifier := NewVerifier(&idpKey.PublicKey, "https://test-idp.example.com")
|
||||||
|
users := user.NewTenantUserStore(pool)
|
||||||
|
roles := rbac.NewStore(pool)
|
||||||
|
roleMapping := ldapsync.NewRoleMappingStore(pool)
|
||||||
|
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
|
||||||
|
if err := roleMapping.SetMapping(ctx, "saml-admins", rbac.RoleTenantAdmin); err != nil {
|
||||||
|
t.Fatalf("set mapping: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
email := fmt.Sprintf("saml-test-%d@example.com", time.Now().UnixNano())
|
||||||
|
rawResp, err := BuildResponse(idpKey, testAssertion("https://test-idp.example.com", email, []string{"saml-admins"}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build response (simulierter idp): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := CompleteSAMLLogin(ctx, verifier, users, roles, roleMapping, issuer, "acme", rawResp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("complete saml login: %v", err)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
t.Fatal("erwartet nicht-leeres sitzungs-token")
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := users.GetByEmailForAuth(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("user nach login: %v", err)
|
||||||
|
}
|
||||||
|
assignment, err := roles.Get(ctx, creds.User.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rollenzuweisung lesen: %v", err)
|
||||||
|
}
|
||||||
|
if assignment.Role != rbac.RoleTenantAdmin {
|
||||||
|
t.Fatalf("erwartet tenant_admin aus saml-admins-mapping, habe %q", assignment.Role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3 (Negativfall): keine Privilege-
|
||||||
|
// Escalation ueber unbekannte/manipulierte Rollen-Attribute.
|
||||||
|
func TestCompleteSAMLLogin_UnmappedRoleGrantsNothing(t *testing.T) {
|
||||||
|
pool, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
idpKey := generateTestIdPKey(t)
|
||||||
|
verifier := NewVerifier(&idpKey.PublicKey, "https://test-idp.example.com")
|
||||||
|
users := user.NewTenantUserStore(pool)
|
||||||
|
roles := rbac.NewStore(pool)
|
||||||
|
roleMapping := ldapsync.NewRoleMappingStore(pool)
|
||||||
|
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
|
||||||
|
email := fmt.Sprintf("saml-test-%d@example.com", time.Now().UnixNano())
|
||||||
|
rawResp, err := BuildResponse(idpKey, testAssertion("https://test-idp.example.com", email, []string{"tenant_admin", "superadmin"}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := CompleteSAMLLogin(ctx, verifier, users, roles, roleMapping, issuer, "acme", rawResp); err != nil {
|
||||||
|
t.Fatalf("complete saml login: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := users.GetByEmailForAuth(ctx, email)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("user nach login: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := roles.Get(ctx, creds.User.ID); err == nil {
|
||||||
|
t.Fatal("erwartet KEINE rollenzuweisung fuer nicht gemappte rollen-attribute")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2: SAML- und OIDC-Anmeldung koennen
|
||||||
|
// nebeneinander fuer unterschiedliche Tenants konfiguriert sein — hier durch
|
||||||
|
// zwei physisch getrennte Tenant-Datenbanken demonstriert (Modell C).
|
||||||
|
func TestSAMLAndOIDC_WorkInParallelForDifferentTenants(t *testing.T) {
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
poolSAML, cleanupSAML := setupTest(t)
|
||||||
|
defer cleanupSAML()
|
||||||
|
|
||||||
|
poolOIDC, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool oidc: %v", err)
|
||||||
|
}
|
||||||
|
defer poolOIDC.Close()
|
||||||
|
if _, err := poolOIDC.Exec(ctx, schema+`
|
||||||
|
CREATE TABLE IF NOT EXISTS oidc_states (
|
||||||
|
state TEXT PRIMARY KEY, nonce TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatalf("schema oidc: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant "saml-tenant": SAML konfiguriert.
|
||||||
|
idpKey := generateTestIdPKey(t)
|
||||||
|
samlVerifier := NewVerifier(&idpKey.PublicKey, "https://test-idp.example.com")
|
||||||
|
samlUsers := user.NewTenantUserStore(poolSAML)
|
||||||
|
samlRoles := rbac.NewStore(poolSAML)
|
||||||
|
samlMapping := ldapsync.NewRoleMappingStore(poolSAML)
|
||||||
|
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
|
||||||
|
|
||||||
|
samlEmail := fmt.Sprintf("saml-parallel-%d@example.com", time.Now().UnixNano())
|
||||||
|
samlResp, err := BuildResponse(idpKey, testAssertion("https://test-idp.example.com", samlEmail, nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build saml response: %v", err)
|
||||||
|
}
|
||||||
|
samlToken, err := CompleteSAMLLogin(ctx, samlVerifier, samlUsers, samlRoles, samlMapping, issuer, "saml-tenant", samlResp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("saml-tenant login: %v", err)
|
||||||
|
}
|
||||||
|
if samlToken == "" {
|
||||||
|
t.Fatal("erwartet token fuer saml-tenant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant "oidc-tenant": OIDC konfiguriert, voellig unabhaengige Datenbank.
|
||||||
|
oidcKey := generateTestIdPKey(t)
|
||||||
|
oidcVerifier := oidc.NewVerifier(map[string]*rsa.PublicKey{"kid-1": &oidcKey.PublicKey}, "https://oidc-idp.example.com")
|
||||||
|
oidcStates := oidc.NewStateStore(poolOIDC)
|
||||||
|
oidcUsers := user.NewTenantUserStore(poolOIDC)
|
||||||
|
oidcRoles := rbac.NewStore(poolOIDC)
|
||||||
|
oidcMapping := ldapsync.NewRoleMappingStore(poolOIDC)
|
||||||
|
|
||||||
|
state, nonce, err := oidcStates.Generate(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("oidc generate state: %v", err)
|
||||||
|
}
|
||||||
|
oidcEmail := fmt.Sprintf("oidc-parallel-%d@example.com", time.Now().UnixNano())
|
||||||
|
claims := oidc.Claims{
|
||||||
|
Email: oidcEmail,
|
||||||
|
Nonce: nonce,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: "https://oidc-idp.example.com", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
idToken := signOIDCTestToken(t, oidcKey, claims)
|
||||||
|
|
||||||
|
oidcToken, err := oidc.CompleteOIDCLogin(ctx, oidcVerifier, oidcStates, oidcUsers, oidcRoles, oidcMapping, issuer, "oidc-tenant", state, idToken)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("oidc-tenant login: %v", err)
|
||||||
|
}
|
||||||
|
if oidcToken == "" {
|
||||||
|
t.Fatal("erwartet token fuer oidc-tenant")
|
||||||
|
}
|
||||||
|
|
||||||
|
if samlToken == oidcToken {
|
||||||
|
t.Fatal("tokens sollten unterschiedlich sein")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func signOIDCTestToken(t *testing.T, key *rsa.PrivateKey, claims oidc.Claims) string {
|
||||||
|
t.Helper()
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
token.Header["kid"] = "kid-1"
|
||||||
|
s, err := token.SignedString(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign oidc test token: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package saml
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidSignature = errors.New("saml: assertion-signatur ungueltig")
|
||||||
|
ErrNotYetValid = errors.New("saml: assertion noch nicht gueltig")
|
||||||
|
ErrExpired = errors.New("saml: assertion abgelaufen")
|
||||||
|
ErrWrongIssuer = errors.New("saml: unerwarteter aussteller")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Verifier prueft SAML-Responses gegen den oeffentlichen Schluessel eines
|
||||||
|
// konfigurierten IdP (Akzeptanzkriterium 1).
|
||||||
|
type Verifier struct {
|
||||||
|
idpPublicKey *rsa.PublicKey
|
||||||
|
idpIssuer string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVerifier(idpPublicKey *rsa.PublicKey, idpIssuer string) *Verifier {
|
||||||
|
return &Verifier{idpPublicKey: idpPublicKey, idpIssuer: idpIssuer}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse dekodiert eine base64-kodierte SAMLResponse (wie sie im
|
||||||
|
// SAMLResponse-Formularfeld ankommt) in ihre XML-Struktur.
|
||||||
|
func Parse(rawBase64 string) (ResponseXML, error) {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(rawBase64)
|
||||||
|
if err != nil {
|
||||||
|
return ResponseXML{}, fmt.Errorf("base64 dekodieren: %w", err)
|
||||||
|
}
|
||||||
|
var resp ResponseXML
|
||||||
|
if err := xml.Unmarshal(data, &resp); err != nil {
|
||||||
|
return ResponseXML{}, fmt.Errorf("saml-xml parsen: %w", err)
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify prueft Signatur, Gueltigkeitszeitraum und Aussteller
|
||||||
|
// (Akzeptanzkriterium 1).
|
||||||
|
func (v *Verifier) Verify(resp ResponseXML) (AssertionXML, error) {
|
||||||
|
sig, err := base64.StdEncoding.DecodeString(resp.SignatureValue)
|
||||||
|
if err != nil {
|
||||||
|
return AssertionXML{}, fmt.Errorf("signatur dekodieren: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
canonical, err := CanonicalAssertionBytes(resp.Assertion)
|
||||||
|
if err != nil {
|
||||||
|
return AssertionXML{}, err
|
||||||
|
}
|
||||||
|
hash := sha256.Sum256(canonical)
|
||||||
|
|
||||||
|
if err := rsa.VerifyPKCS1v15(v.idpPublicKey, crypto.SHA256, hash[:], sig); err != nil {
|
||||||
|
return AssertionXML{}, ErrInvalidSignature
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Assertion.Issuer != v.idpIssuer {
|
||||||
|
return AssertionXML{}, fmt.Errorf("%w: %q", ErrWrongIssuer, resp.Assertion.Issuer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := resp.Assertion.WithinValidity(time.Now()); err != nil {
|
||||||
|
return AssertionXML{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.Assertion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign signiert eine Assertion mit dem privaten IdP-Schluessel — wird vom
|
||||||
|
// (simulierten) Test-IdP verwendet, um eine gueltige Response zu erzeugen.
|
||||||
|
// Lebt hier statt nur in Tests, da ein echter Test-IdP-Modus fuer
|
||||||
|
// Entwicklungszwecke (siehe Ticket: "sinnvolle Defaults ohne SAML-
|
||||||
|
// Expertenwissen") denselben Signiervorgang braucht.
|
||||||
|
func Sign(priv *rsa.PrivateKey, assertion AssertionXML) (string, error) {
|
||||||
|
canonical, err := CanonicalAssertionBytes(assertion)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
hash := sha256.Sum256(canonical)
|
||||||
|
sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, hash[:])
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("assertion signieren: %w", err)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(sig), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildResponse verpackt eine signierte Assertion in eine base64-kodierte
|
||||||
|
// SAMLResponse, wie sie ein IdP im Browser-POST-Binding zurueckliefert.
|
||||||
|
func BuildResponse(priv *rsa.PrivateKey, assertion AssertionXML) (string, error) {
|
||||||
|
sig, err := Sign(priv, assertion)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resp := ResponseXML{Assertion: assertion, SignatureValue: sig}
|
||||||
|
data, err := xml.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("response serialisieren: %w", err)
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(data), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package saml
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func generateTestIdPKey(t *testing.T) *rsa.PrivateKey {
|
||||||
|
t.Helper()
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate rsa key: %v", err)
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAssertion(issuer, nameID string, roles []string) AssertionXML {
|
||||||
|
now := time.Now()
|
||||||
|
return AssertionXML{
|
||||||
|
Issuer: issuer,
|
||||||
|
NameID: nameID,
|
||||||
|
Conditions: conditionsXML{
|
||||||
|
NotBefore: now.Add(-time.Minute).Format(time.RFC3339),
|
||||||
|
NotOnOrAfter: now.Add(time.Hour).Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
Attributes: []attributeXML{
|
||||||
|
{Name: "roles", Values: roles},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerify_AcceptsValidSignedAssertion(t *testing.T) {
|
||||||
|
key := generateTestIdPKey(t)
|
||||||
|
verifier := NewVerifier(&key.PublicKey, "https://idp.example.com")
|
||||||
|
|
||||||
|
rawResp, err := BuildResponse(key, testAssertion("https://idp.example.com", "person@example.com", []string{"sso-admins"}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build response: %v", err)
|
||||||
|
}
|
||||||
|
resp, err := Parse(rawResp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertion, err := verifier.Verify(resp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("verify: %v", err)
|
||||||
|
}
|
||||||
|
if assertion.NameID != "person@example.com" {
|
||||||
|
t.Fatalf("nameid = %q", assertion.NameID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerify_RejectsTamperedAssertion(t *testing.T) {
|
||||||
|
key := generateTestIdPKey(t)
|
||||||
|
verifier := NewVerifier(&key.PublicKey, "https://idp.example.com")
|
||||||
|
|
||||||
|
rawResp, err := BuildResponse(key, testAssertion("https://idp.example.com", "person@example.com", nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build response: %v", err)
|
||||||
|
}
|
||||||
|
resp, err := Parse(rawResp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
// Manipulation NACH dem Signieren: NameID aendern (Signatur bleibt die alte).
|
||||||
|
resp.Assertion.NameID = "angreifer@example.com"
|
||||||
|
|
||||||
|
if _, err := verifier.Verify(resp); err != ErrInvalidSignature {
|
||||||
|
t.Fatalf("erwartet ErrInvalidSignature, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerify_RejectsWrongIdPKey(t *testing.T) {
|
||||||
|
realKey := generateTestIdPKey(t)
|
||||||
|
attackerKey := generateTestIdPKey(t)
|
||||||
|
verifier := NewVerifier(&realKey.PublicKey, "https://idp.example.com")
|
||||||
|
|
||||||
|
rawResp, err := BuildResponse(attackerKey, testAssertion("https://idp.example.com", "person@example.com", nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build response: %v", err)
|
||||||
|
}
|
||||||
|
resp, _ := Parse(rawResp)
|
||||||
|
|
||||||
|
if _, err := verifier.Verify(resp); err != ErrInvalidSignature {
|
||||||
|
t.Fatalf("erwartet ErrInvalidSignature bei fremd signierter assertion, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerify_RejectsExpiredAssertion(t *testing.T) {
|
||||||
|
key := generateTestIdPKey(t)
|
||||||
|
verifier := NewVerifier(&key.PublicKey, "https://idp.example.com")
|
||||||
|
|
||||||
|
expired := testAssertion("https://idp.example.com", "person@example.com", nil)
|
||||||
|
expired.Conditions.NotOnOrAfter = time.Now().Add(-time.Hour).Format(time.RFC3339)
|
||||||
|
|
||||||
|
rawResp, err := BuildResponse(key, expired)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build response: %v", err)
|
||||||
|
}
|
||||||
|
resp, _ := Parse(rawResp)
|
||||||
|
|
||||||
|
if _, err := verifier.Verify(resp); err != ErrExpired {
|
||||||
|
t.Fatalf("erwartet ErrExpired, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVerify_RejectsWrongIssuer(t *testing.T) {
|
||||||
|
key := generateTestIdPKey(t)
|
||||||
|
verifier := NewVerifier(&key.PublicKey, "https://idp.example.com")
|
||||||
|
|
||||||
|
rawResp, err := BuildResponse(key, testAssertion("https://anderer-idp.example.com", "person@example.com", nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build response: %v", err)
|
||||||
|
}
|
||||||
|
resp, _ := Parse(rawResp)
|
||||||
|
|
||||||
|
if _, err := verifier.Verify(resp); err == nil {
|
||||||
|
t.Fatal("erwartet fehler bei unerwartetem aussteller")
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user