Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98b3bcdf43 | ||
|
|
bfa5c61db5 | ||
|
|
5f3eb16fae | ||
|
|
c8f72c30f3 | ||
|
|
3c226dab12 | ||
|
|
7c53a099c7 | ||
|
|
25168a18db | ||
|
|
584cefa388 | ||
|
|
00665592f6 | ||
|
|
ac48935261 | ||
|
|
0620baa993 | ||
|
|
eed73eca8f | ||
|
|
d447869246 | ||
|
|
d63db93a4c |
@@ -1,2 +1,4 @@
|
|||||||
*.log
|
*.log
|
||||||
.env
|
.env
|
||||||
|
web/*/node_modules/
|
||||||
|
web/*/.next/
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ Keine Änderungen ermittelbar.
|
|||||||
## 2026-08-27 17:28 – 17:29 (1m)
|
## 2026-08-27 17:28 – 17:29 (1m)
|
||||||
**Beschreibung:** Claude Code Session
|
**Beschreibung:** Claude Code Session
|
||||||
**Projekt:** code
|
**Projekt:** code
|
||||||
|
## 2026-08-28 21:44 – 21:44 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** nexarch
|
||||||
|
|
||||||
### Commits
|
### Commits
|
||||||
Keine Commits in dieser Session.
|
Keine Commits in dieser Session.
|
||||||
@@ -127,48 +130,32 @@ 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 ++++++++++
|
||||||
|
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/components/Dialog.tsx | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/components/FormElements.tsx | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/components/Shell.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/components/Table.tsx | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/components/Toast.tsx | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/i18n/i18n.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/index.ts | 25 +++++++++++++++++++++++++
|
||||||
|
- web/shl/package.json | 23 +++++++++++++++++++++++
|
||||||
|
- web/shl/theme/ThemeProvider.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/tokens/tokens.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
- web/shl/tsconfig.json | 18 ++++++++++++++++++
|
||||||
|
|
||||||
---
|
---
|
||||||
## 2026-08-28 23:20 – 23:26 (6m)
|
## 2026-08-28 21:51 – 21:57 (5m)
|
||||||
**Beschreibung:** Claude Code Session
|
**Beschreibung:** Claude Code Session
|
||||||
**Projekt:** nexarch
|
**Projekt:** nexarch
|
||||||
|
|
||||||
### Commits
|
### Commits
|
||||||
- 42466c7 IAM-13: oidc-provider-fuer-drittanwendungen (client-registrierung, authorization-code-flow, jwks ueber API-05-schluessel)
|
- 3c226da SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl)
|
||||||
|
|
||||||
### Geänderte Dateien
|
### Geänderte Dateien
|
||||||
- internal/oidc/authcode.go | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
- web/shl/package.json | 2 ++
|
||||||
- internal/oidc/client.go | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
- web/shl/vitest.config.ts | 8 ++++++++
|
||||||
- internal/oidc/provider.go | 251 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
- web/shl/vitest.setup.ts | 1 +
|
||||||
- internal/oidc/provider_test.go | 294 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- migrations/tenant/0003_oidc_provider.down.sql | 2 ++
|
|
||||||
- migrations/tenant/0003_oidc_provider.up.sql | 28 +++++++++++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
## 2026-08-28 23:26 – 23:26 (0m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** code
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
Keine Commits in dieser Session.
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- internal/oidc/authcode.go | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/oidc/client.go | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/oidc/provider.go | 251 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/oidc/provider_test.go | 294 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- migrations/tenant/0003_oidc_provider.down.sql | 2 ++
|
|
||||||
- migrations/tenant/0003_oidc_provider.up.sql | 28 +++++++++++++++++++
|
|
||||||
|
|
||||||
---
|
|
||||||
## 2026-08-28 23:26 – 23:26 (0m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** code
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
Keine Commits in dieser Session.
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- DEVLOG.md | 32 ++++++++++++++++++++++++++++++++
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+1
-10
@@ -8,7 +8,6 @@ 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() {
|
||||||
@@ -35,20 +34,12 @@ func main() {
|
|||||||
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
||||||
tenantHandler := tenant.NewHandler(provisioner)
|
tenantHandler := tenant.NewHandler(provisioner)
|
||||||
|
|
||||||
// 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)
|
||||||
})
|
})
|
||||||
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
|
// Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 abgeloest.
|
||||||
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
||||||
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 {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,16 +2,13 @@ module gitea.perlbach24.de/scripte/nexarch
|
|||||||
|
|
||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require (
|
require github.com/jackc/pgx/v5 v5.6.0
|
||||||
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,8 +1,6 @@
|
|||||||
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=
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// 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})
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
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,110 @@
|
|||||||
|
// Package flag implementiert Core LIC-02: einen Feature-Flag-Dienst mit
|
||||||
|
// Strategien (global an/aus, Prozentsatz, Tenant-Zielgruppe) als Kernfunktion
|
||||||
|
// des Core-Dienstes selbst — keine zusaetzliche Infrastruktur (Unleash-Server
|
||||||
|
// + eigene DB), siehe "bewusst vermeiden" im LIC-02-Ticket.
|
||||||
|
package flag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("flag: nicht gefunden")
|
||||||
|
|
||||||
|
// Flag ist die zentrale Definition — Auswertung (Evaluate) ist bewusst davon
|
||||||
|
// getrennt (Unleash-Prinzip: Flag-Verwaltung vs. Flag-Auswertung).
|
||||||
|
type Flag struct {
|
||||||
|
Key string
|
||||||
|
Enabled bool
|
||||||
|
RolloutPercentage int
|
||||||
|
TargetTenantSlugs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store ist die Verwaltungsseite (Admin): Flags definieren/lesen.
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Set(ctx context.Context, f Flag) error {
|
||||||
|
if f.TargetTenantSlugs == nil {
|
||||||
|
f.TargetTenantSlugs = []string{} // pgx uebertraegt ein nil-Slice sonst als SQL NULL statt leerem Array.
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO feature_flags (key, enabled, rollout_percentage, target_tenant_slugs, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, now())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET
|
||||||
|
enabled = $2, rollout_percentage = $3, target_tenant_slugs = $4, updated_at = now()
|
||||||
|
`, f.Key, f.Enabled, f.RolloutPercentage, f.TargetTenantSlugs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("flag speichern: %w", err)
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
var f Flag
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT key, enabled, rollout_percentage, target_tenant_slugs
|
||||||
|
FROM feature_flags WHERE key = $1
|
||||||
|
`, key)
|
||||||
|
if err := row.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Flag{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Flag{}, fmt.Errorf("flag lesen: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluate wendet die Strategien in fester Reihenfolge an: globaler
|
||||||
|
// An/Aus-Schalter zuerst, dann Tenant-Zielgruppe, dann Prozentsatz-Rollout.
|
||||||
|
// Ein unbekannter/nicht getroffener Fall ergibt false — Fail-Safe-Default,
|
||||||
|
// kein Feature wird versehentlich aktiv.
|
||||||
|
func evaluate(f Flag, tenantSlug string) bool {
|
||||||
|
if f.Enabled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, target := range f.TargetTenantSlugs {
|
||||||
|
if target == tenantSlug {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.RolloutPercentage > 0 {
|
||||||
|
h := fnv.New32a()
|
||||||
|
_, _ = h.Write([]byte(f.Key + "|" + tenantSlug))
|
||||||
|
return int(h.Sum32()%100) < f.RolloutPercentage
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package flag
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEvaluate_GlobalEnabled(t *testing.T) {
|
||||||
|
f := Flag{Key: "k", Enabled: true}
|
||||||
|
if !evaluate(f, "irgendein-tenant") {
|
||||||
|
t.Fatal("global aktiviertes flag sollte fuer jeden tenant true liefern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie.
|
||||||
|
func TestEvaluate_TargetTenantStrategy(t *testing.T) {
|
||||||
|
f := Flag{Key: "k", Enabled: false, TargetTenantSlugs: []string{"acme"}}
|
||||||
|
if !evaluate(f, "acme") {
|
||||||
|
t.Fatal("erwartet true fuer tenant in zielgruppe")
|
||||||
|
}
|
||||||
|
if evaluate(f, "globex") {
|
||||||
|
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_RolloutPercentageBoundaries(t *testing.T) {
|
||||||
|
full := Flag{Key: "k", RolloutPercentage: 100}
|
||||||
|
if !evaluate(full, "beliebiger-tenant-1") || !evaluate(full, "beliebiger-tenant-2") {
|
||||||
|
t.Fatal("100% rollout sollte immer true liefern")
|
||||||
|
}
|
||||||
|
|
||||||
|
none := Flag{Key: "k", RolloutPercentage: 0}
|
||||||
|
if evaluate(none, "beliebiger-tenant") {
|
||||||
|
t.Fatal("0% rollout ohne enabled/zielgruppe sollte false liefern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_RolloutIsDeterministicPerTenant(t *testing.T) {
|
||||||
|
f := Flag{Key: "k", RolloutPercentage: 50}
|
||||||
|
first := evaluate(f, "stabiler-tenant")
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if evaluate(f, "stabiler-tenant") != first {
|
||||||
|
t.Fatal("rollout-auswertung sollte fuer denselben tenant/key stabil sein")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package flag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit
|
||||||
|
// (Akzeptanzkriterium 2/3): eine Aenderung wirkt spaetestens nach dieser
|
||||||
|
// Zeit auf allen Core-Instanzen, ohne dass ein Dienst neu gestartet werden
|
||||||
|
// muss (Akzeptanzkriterium 3).
|
||||||
|
const DefaultCacheTTL = 5 * time.Second
|
||||||
|
|
||||||
|
type cacheEntry struct {
|
||||||
|
flag Flag
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service ist die Auswertungsseite (SDK/Client-Analogon zu Unleash) mit
|
||||||
|
// lokalem TTL-Cache. Bewusst getrennt von Store (Verwaltung).
|
||||||
|
type Service struct {
|
||||||
|
store *Store
|
||||||
|
ttl time.Duration
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
cache map[string]cacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(store *Store, ttl time.Duration) *Service {
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = DefaultCacheTTL
|
||||||
|
}
|
||||||
|
return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled wertet ein Flag fuer einen Tenant aus. Liefert IMMER einen
|
||||||
|
// bool ohne Fehlerwert — ein nicht erreichbarer Flag-Dienst darf abhaengige
|
||||||
|
// Aufrufer nicht zum Absturz bringen oder zu Fehlerbehandlungscode zwingen,
|
||||||
|
// der leicht vergessen wird (Akzeptanzkriterium 3 / Pruefung 3: dokumentiertes
|
||||||
|
// Fallback-Verhalten = false, ggf. aus dem zuletzt bekannten Zwischenspeicher).
|
||||||
|
func (s *Service) IsEnabled(ctx context.Context, tenantSlug, key string) bool {
|
||||||
|
f, ok := s.resolve(ctx, key)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return evaluate(f, tenantSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolve(ctx context.Context, key string) (Flag, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
entry, exists := s.cache[key]
|
||||||
|
fresh := exists && time.Now().Before(entry.expiresAt)
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return entry.flag, true
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := s.store.Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
if exists {
|
||||||
|
slog.Warn("feature-flag-dienst nicht erreichbar, nutze zwischengespeicherten stand",
|
||||||
|
"flag_key", key, "error", err)
|
||||||
|
return entry.flag, true
|
||||||
|
}
|
||||||
|
slog.Warn("feature-flag-dienst nicht erreichbar, kein zwischengespeicherter stand vorhanden, fallback: deaktiviert",
|
||||||
|
"flag_key", key, "error", err)
|
||||||
|
return Flag{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cache[key] = cacheEntry{flag: f, expiresAt: time.Now().Add(s.ttl)}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return f, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate erzwingt beim naechsten IsEnabled-Aufruf ein sofortiges Neuladen
|
||||||
|
// aus der Datenbank statt auf den TTL-Ablauf zu warten — wird nach Store.Set
|
||||||
|
// auf derselben Instanz aufgerufen, damit der Schreiber die eigene Aenderung
|
||||||
|
// ohne Wartezeit sieht. Andere Core-Instanzen sehen sie spaetestens nach
|
||||||
|
// DefaultCacheTTL (siehe Akzeptanzkriterium 3).
|
||||||
|
func (s *Service) Invalidate(key string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.cache, key)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package flag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupFlagStoreTest(t *testing.T) (*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()
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'test\_%' ESCAPE '\'`)
|
||||||
|
pool.Close()
|
||||||
|
}
|
||||||
|
return NewStore(pool), cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie liefert im Test
|
||||||
|
// die erwartete Auswertung.
|
||||||
|
func TestService_TargetTenantStrategy(t *testing.T) {
|
||||||
|
store, cleanup := setupFlagStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := store.Set(ctx, Flag{Key: "test_target_flag", TargetTenantSlugs: []string{"acme"}}); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
svc := NewService(store, time.Hour)
|
||||||
|
|
||||||
|
if !svc.IsEnabled(ctx, "acme", "test_target_flag") {
|
||||||
|
t.Fatal("erwartet true fuer tenant in zielgruppe")
|
||||||
|
}
|
||||||
|
if svc.IsEnabled(ctx, "globex", "test_target_flag") {
|
||||||
|
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + 3 + Pruefung 1: Flag-Aenderung wirkt innerhalb der
|
||||||
|
// dokumentierten Cache-Invalidierungszeit, automatisiert gemessen.
|
||||||
|
func TestService_CacheInvalidationTiming(t *testing.T) {
|
||||||
|
store, cleanup := setupFlagStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const ttl = 150 * time.Millisecond
|
||||||
|
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: false}); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
svc := NewService(store, ttl)
|
||||||
|
|
||||||
|
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
|
||||||
|
t.Fatal("erwartet false vor der aenderung")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aenderung "auf einer anderen instanz" simulieren: direkt ueber den
|
||||||
|
// Store, ohne svc.Invalidate aufzurufen.
|
||||||
|
changedAt := time.Now()
|
||||||
|
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: true}); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sofort danach sollte der Cache noch den alten Stand liefern.
|
||||||
|
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
|
||||||
|
t.Fatal("cache haette den alten (false) stand liefern sollen, direkt nach der aenderung")
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := changedAt.Add(ttl + 100*time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
|
||||||
|
elapsed := time.Since(changedAt)
|
||||||
|
t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", elapsed, ttl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_InvalidateForcesImmediateRefresh(t *testing.T) {
|
||||||
|
store, cleanup := setupFlagStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: false}); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
svc := NewService(store, time.Hour) // lange TTL, damit Invalidate den unterschied macht
|
||||||
|
_ = svc.IsEnabled(ctx, "acme", "test_invalidate_flag")
|
||||||
|
|
||||||
|
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: true}); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
svc.Invalidate("test_invalidate_flag")
|
||||||
|
|
||||||
|
if !svc.IsEnabled(ctx, "acme", "test_invalidate_flag") {
|
||||||
|
t.Fatal("erwartet sofort sichtbaren neuen stand nach Invalidate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3: Ausfall des Flag-Dienstes fuehrt zu
|
||||||
|
// dokumentiertem Fallback-Verhalten, nicht zum Absturz.
|
||||||
|
func TestService_FallsBackOnStoreFailure(t *testing.T) {
|
||||||
|
store, cleanup := setupFlagStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := store.Set(ctx, Flag{Key: "test_fallback_flag", Enabled: true}); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
svc := NewService(store, time.Hour)
|
||||||
|
|
||||||
|
// Cache vorwaermen, waehrend die DB noch erreichbar ist.
|
||||||
|
if !svc.IsEnabled(ctx, "acme", "test_fallback_flag") {
|
||||||
|
t.Fatal("erwartet true bei funktionierender db")
|
||||||
|
}
|
||||||
|
|
||||||
|
brokenPool, err := pgxpool.New(ctx, "postgresql://nonexistent-host-fuer-test:5432/x?connect_timeout=1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("broken pool erstellen (sollte nicht sofort verbinden): %v", err)
|
||||||
|
}
|
||||||
|
brokenStore := NewStore(brokenPool)
|
||||||
|
|
||||||
|
svcWithCache := NewService(brokenStore, time.Nanosecond) // TTL sofort abgelaufen, erzwingt reload-versuch
|
||||||
|
svcWithCache.mu.Lock()
|
||||||
|
svcWithCache.cache["test_fallback_flag"] = cacheEntry{
|
||||||
|
flag: Flag{Key: "test_fallback_flag", Enabled: true},
|
||||||
|
expiresAt: time.Now().Add(-time.Hour), // bereits abgelaufen
|
||||||
|
}
|
||||||
|
svcWithCache.mu.Unlock()
|
||||||
|
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("IsEnabled hat gepanict statt einen fallback zu liefern: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if !svcWithCache.IsEnabled(ctx, "acme", "test_fallback_flag") {
|
||||||
|
t.Fatal("erwartet fallback auf zwischengespeicherten (true) stand bei db-ausfall")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Voellig frischer Dienst ohne jeglichen cache + kaputte db -> sicherer
|
||||||
|
// default false, kein absturz.
|
||||||
|
freshSvc := NewService(brokenStore, time.Hour)
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("IsEnabled hat gepanict: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if freshSvc.IsEnabled(ctx, "acme", "test_fallback_flag") {
|
||||||
|
t.Fatal("erwartet fail-safe false ohne cache und mit kaputter db")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
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) {}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// jwk / jwkSet 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.
|
|
||||||
type jwk 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 jwkSet struct {
|
|
||||||
Keys []jwk `json:"keys"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) JWKS(w http.ResponseWriter, r *http.Request) {
|
|
||||||
set := h.keys.PublicKeySet()
|
|
||||||
resp := jwkSet{Keys: make([]jwk, 0, len(set))}
|
|
||||||
for kid, pub := range set {
|
|
||||||
resp.Keys = append(resp.Keys, jwk{
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
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 jwkSet
|
|
||||||
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,32 @@
|
|||||||
|
package usage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AggregateFunc berechnet/aktualisiert Zaehlerstaende aus einer autoritativen
|
||||||
|
// Quelle (z.B. "zaehle Zeilen in einer Modul-Tabelle") — die konkrete Quelle
|
||||||
|
// haengt vom jeweiligen Modul ab und ist nicht Teil dieser Kachel. Das
|
||||||
|
// Aggregations-Grundgerüst selbst (periodischer Trigger) ist es.
|
||||||
|
type AggregateFunc func(ctx context.Context) error
|
||||||
|
|
||||||
|
// RunPeriodicAggregation ruft aggregate in festen Abstaenden auf, bis ctx
|
||||||
|
// beendet wird — dieselbe In-Prozess-Worker-Goroutine-Konvention wie
|
||||||
|
// internal/tenant.Lifecycle.RunSweeper (Akzeptanzkriterium 1: "periodisch
|
||||||
|
// aggregiert").
|
||||||
|
func RunPeriodicAggregation(ctx context.Context, interval time.Duration, aggregate AggregateFunc) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := aggregate(ctx); err != nil {
|
||||||
|
slog.Error("nutzungszaehler-aggregation fehlgeschlagen", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// Package usage implementiert Core LIC-03: Nutzungszaehler je Tenant
|
||||||
|
// (Benutzeranzahl, Speicherverbrauch, API-Aufrufe, ...) und die Pruefung
|
||||||
|
// gegen konfigurierte Quotas. Quotas sind Konfiguration (Tabellenzeile), kein
|
||||||
|
// Hardcode — Zitadel/Unleash-Vorbild.
|
||||||
|
package usage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNoQuota = errors.New("usage: keine quota fuer diese metrik konfiguriert")
|
||||||
|
|
||||||
|
// Status ist die definierte Reaktion einer Quota-Pruefung (Akzeptanzkriterium 2).
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusOK Status = "ok"
|
||||||
|
StatusWarning Status = "warning" // Schwelle (80%) erreicht, aber noch nicht ueberschritten
|
||||||
|
StatusExceeded Status = "exceeded" // Quota ueberschritten — neue Ressourcen sollten gesperrt werden
|
||||||
|
)
|
||||||
|
|
||||||
|
// warningThreshold liegt bei 80% der Quota.
|
||||||
|
const warningThreshold = 0.8
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment erhoeht einen Zaehler ATOMAR ueber ein einziges SQL-Statement
|
||||||
|
// (UPSERT mit value = value + delta) statt Read-Modify-Write in Go — das
|
||||||
|
// haelt Zaehlerstaende bei parallelen Schreibzugriffen konsistent
|
||||||
|
// (Akzeptanzkriterium 1 / Pruefung 2), ohne eine Anwendungs-Transaktion mit
|
||||||
|
// Lock zu brauchen.
|
||||||
|
func (s *Store) Increment(ctx context.Context, tenantID, metric string, delta int64) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO usage_counters (tenant_id, metric, value, updated_at)
|
||||||
|
VALUES ($1, $2, $3, now())
|
||||||
|
ON CONFLICT (tenant_id, metric) DO UPDATE
|
||||||
|
SET value = usage_counters.value + $3, updated_at = now()
|
||||||
|
`, tenantID, metric, delta)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("zaehler erhoehen: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get liefert den aktuellen Zaehlerstand — 0, wenn noch nie erhoeht wurde.
|
||||||
|
// Der Wert ist strikt tenant-gescoped (Akzeptanzkriterium 3 / Pruefung 3).
|
||||||
|
func (s *Store) Get(ctx context.Context, tenantID, metric string) (int64, error) {
|
||||||
|
var value int64
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT value FROM usage_counters WHERE tenant_id = $1 AND metric = $2
|
||||||
|
`, tenantID, metric).Scan(&value)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("zaehler lesen: %w", err)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetQuota legt die Obergrenze fuer (tenantID, metric) fest — Konfiguration,
|
||||||
|
// kein Hardcode.
|
||||||
|
func (s *Store) SetQuota(ctx context.Context, tenantID, metric string, limit int64) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO usage_quotas (tenant_id, metric, limit_value)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (tenant_id, metric) DO UPDATE SET limit_value = $3
|
||||||
|
`, tenantID, metric, limit)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("quota setzen: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetQuota(ctx context.Context, tenantID, metric string) (int64, error) {
|
||||||
|
var limit int64
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT limit_value FROM usage_quotas WHERE tenant_id = $1 AND metric = $2
|
||||||
|
`, tenantID, metric).Scan(&limit)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, ErrNoQuota
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("quota lesen: %w", err)
|
||||||
|
}
|
||||||
|
return limit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check liefert Zaehlerstand, konfigurierte Quota und die daraus abgeleitete
|
||||||
|
// Reaktion (Akzeptanzkriterium 2 / Pruefung 1). Ist keine Quota konfiguriert,
|
||||||
|
// gilt die Metrik als unbegrenzt (StatusOK).
|
||||||
|
func (s *Store) Check(ctx context.Context, tenantID, metric string) (value, limit int64, status Status, err error) {
|
||||||
|
value, err = s.Get(ctx, tenantID, metric)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, err = s.GetQuota(ctx, tenantID, metric)
|
||||||
|
if errors.Is(err, ErrNoQuota) {
|
||||||
|
return value, 0, StatusOK, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case value > limit:
|
||||||
|
return value, limit, StatusExceeded, nil
|
||||||
|
case limit > 0 && float64(value) >= warningThreshold*float64(limit):
|
||||||
|
return value, limit, StatusWarning, nil
|
||||||
|
default:
|
||||||
|
return value, limit, StatusOK, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reaction wird aufgerufen, wenn Check einen Nicht-OK-Status liefert
|
||||||
|
// (Akzeptanzkriterium 2: "definierte Reaktion").
|
||||||
|
type Reaction func(ctx context.Context, tenantID, metric string, value, limit int64, status Status)
|
||||||
|
|
||||||
|
// Enforce fuehrt Check aus und ruft react auf, wenn der Status nicht OK ist —
|
||||||
|
// die konkrete "Sperre neuer Ressourcen"/Benachrichtigung liegt beim
|
||||||
|
// Aufrufer (z.B. TEN-02 vor dem Anlegen eines neuen Benutzers), Enforce
|
||||||
|
// garantiert nur, dass die Reaktion zuverlaessig ausgeloest wird.
|
||||||
|
func (s *Store) Enforce(ctx context.Context, tenantID, metric string, react Reaction) (Status, error) {
|
||||||
|
value, limit, status, err := s.Check(ctx, tenantID, metric)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if status != StatusOK && react != nil {
|
||||||
|
react(ctx, tenantID, metric, value, limit, status)
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package usage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTest(t *testing.T) (*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 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() { pool.Close() }
|
||||||
|
return NewStore(pool), cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTenantID() string {
|
||||||
|
return fmt.Sprintf("00000000-0000-0000-0000-%012d", time.Now().UnixNano()%1e12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 2: Aggregationsjob liefert bei parallelen
|
||||||
|
// Schreibzugriffen konsistente Zaehlerstaende.
|
||||||
|
func TestIncrement_ConsistentUnderConcurrentWrites(t *testing.T) {
|
||||||
|
store, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
tenant := newTenantID()
|
||||||
|
|
||||||
|
const goroutines = 50
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := store.Increment(ctx, tenant, "api_calls", 1); err != nil {
|
||||||
|
t.Errorf("increment: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
value, err := store.Get(ctx, tenant, "api_calls")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if value != goroutines {
|
||||||
|
t.Fatalf("erwartet %d, habe %d (hinweis auf lost update unter nebenlaeufigkeit)", goroutines, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3: Zaehlerstand eines Tenants beeinflusst
|
||||||
|
// nicht den eines anderen.
|
||||||
|
func TestIncrement_IsolatedBetweenTenants(t *testing.T) {
|
||||||
|
store, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
tenantA, tenantB := newTenantID(), newTenantID()
|
||||||
|
|
||||||
|
if err := store.Increment(ctx, tenantA, "users", 5); err != nil {
|
||||||
|
t.Fatalf("increment a: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.Increment(ctx, tenantB, "users", 1); err != nil {
|
||||||
|
t.Fatalf("increment b: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
valA, err := store.Get(ctx, tenantA, "users")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get a: %v", err)
|
||||||
|
}
|
||||||
|
valB, err := store.Get(ctx, tenantB, "users")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get b: %v", err)
|
||||||
|
}
|
||||||
|
if valA != 5 || valB != 1 {
|
||||||
|
t.Fatalf("erwartet a=5 b=1, habe a=%d b=%d", valA, valB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 1: Quota-Ueberschreitung wird automatisiert
|
||||||
|
// erkannt und die definierte Reaktion ausgeloest.
|
||||||
|
func TestEnforce_TriggersReactionOnExceeded(t *testing.T) {
|
||||||
|
store, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
tenant := newTenantID()
|
||||||
|
|
||||||
|
if err := store.SetQuota(ctx, tenant, "users", 10); err != nil {
|
||||||
|
t.Fatalf("set quota: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.Increment(ctx, tenant, "users", 11); err != nil {
|
||||||
|
t.Fatalf("increment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reacted bool
|
||||||
|
var gotStatus Status
|
||||||
|
status, err := store.Enforce(ctx, tenant, "users", func(ctx context.Context, tenantID, metric string, value, limit int64, status Status) {
|
||||||
|
reacted = true
|
||||||
|
gotStatus = status
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("enforce: %v", err)
|
||||||
|
}
|
||||||
|
if status != StatusExceeded {
|
||||||
|
t.Fatalf("erwartet StatusExceeded, habe %q", status)
|
||||||
|
}
|
||||||
|
if !reacted || gotStatus != StatusExceeded {
|
||||||
|
t.Fatal("erwartet ausgeloeste reaktion mit StatusExceeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheck_WarningThresholdAndOK(t *testing.T) {
|
||||||
|
store, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
tenant := newTenantID()
|
||||||
|
|
||||||
|
if err := store.SetQuota(ctx, tenant, "storage_mb", 100); err != nil {
|
||||||
|
t.Fatalf("set quota: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Increment(ctx, tenant, "storage_mb", 50); err != nil {
|
||||||
|
t.Fatalf("increment: %v", err)
|
||||||
|
}
|
||||||
|
_, _, status, err := store.Check(ctx, tenant, "storage_mb")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("check: %v", err)
|
||||||
|
}
|
||||||
|
if status != StatusOK {
|
||||||
|
t.Fatalf("bei 50%% erwartet StatusOK, habe %q", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Increment(ctx, tenant, "storage_mb", 35); err != nil { // insgesamt 85%
|
||||||
|
t.Fatalf("increment: %v", err)
|
||||||
|
}
|
||||||
|
_, _, status, err = store.Check(ctx, tenant, "storage_mb")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("check: %v", err)
|
||||||
|
}
|
||||||
|
if status != StatusWarning {
|
||||||
|
t.Fatalf("bei 85%% erwartet StatusWarning, habe %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheck_NoQuotaMeansUnlimited(t *testing.T) {
|
||||||
|
store, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
tenant := newTenantID()
|
||||||
|
|
||||||
|
if err := store.Increment(ctx, tenant, "api_calls", 1_000_000); err != nil {
|
||||||
|
t.Fatalf("increment: %v", err)
|
||||||
|
}
|
||||||
|
_, _, status, err := store.Check(ctx, tenant, "api_calls")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("check: %v", err)
|
||||||
|
}
|
||||||
|
if status != StatusOK {
|
||||||
|
t.Fatalf("ohne konfigurierte quota erwartet StatusOK, habe %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPeriodicAggregation_CallsRepeatedly(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
calls := 0
|
||||||
|
RunPeriodicAggregation(ctx, 20*time.Millisecond, func(ctx context.Context) error {
|
||||||
|
mu.Lock()
|
||||||
|
calls++
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if calls < 3 {
|
||||||
|
t.Fatalf("erwartet mehrfache aufrufe innerhalb von 120ms bei 20ms interval, habe %d", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handler stellt die CRUD-API fuer Benutzerkonten bereit (IAM-01-Auftrag).
|
|
||||||
// Auth/Sessions (IAM-02) und Rollen (RBAC-01) sind ausdruecklich nicht Teil
|
|
||||||
// dieser Kachel und daher hier noch nicht angebunden.
|
|
||||||
type Handler struct {
|
|
||||||
users *TenantUserStore
|
|
||||||
superadmins *SuperadminStore
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHandler(users *TenantUserStore, superadmins *SuperadminStore) *Handler {
|
|
||||||
return &Handler{users: users, superadmins: superadmins}
|
|
||||||
}
|
|
||||||
|
|
||||||
type createUserRequest struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req createUserRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := h.users.Create(r.Context(), req.Email, req.Name)
|
|
||||||
writeUserResult(w, u, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateSuperadmin legt ein mandantenuebergreifendes Superadmin-Konto an —
|
|
||||||
// bewusst ein eigener Endpunkt statt eines Tenant-Parameters mit Null-Wert.
|
|
||||||
func (h *Handler) CreateSuperadmin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req createUserRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := h.superadmins.Create(r.Context(), req.Email, req.Name)
|
|
||||||
writeUserResult(w, u, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeUserResult(w http.ResponseWriter, u User, err error) {
|
|
||||||
if err != nil {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, ErrInvalidEmail), errors.Is(err, ErrEmailTaken):
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
case errors.Is(err, ErrNotFound):
|
|
||||||
http.Error(w, err.Error(), http.StatusNotFound)
|
|
||||||
default:
|
|
||||||
http.Error(w, "benutzer konnte nicht verarbeitet werden", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_ = json.NewEncoder(w).Encode(u)
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setupTestDB legt eine frische, isolierte Testdatenbank an, wendet die
|
|
||||||
// uebergebene Migration an und liefert einen verbundenen Pool. Wird ohne
|
|
||||||
// TEST_ADMIN_DSN uebersprungen — siehe internal/tenant/provisioner_test.go
|
|
||||||
// fuer dasselbe Muster.
|
|
||||||
func setupTestDB(t *testing.T, dbName, schemaSQL 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, schemaSQL); 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);`
|
|
||||||
|
|
||||||
const superadminsSchema = `
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
CREATE TABLE superadmins (
|
|
||||||
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()
|
|
||||||
);`
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + 3, Pruefung 1 (inkl. Negativfall doppelte E-Mail).
|
|
||||||
func TestTenantUserStore_CRUD(t *testing.T) {
|
|
||||||
pool := setupTestDB(t, "test_iam01_users", usersSchema)
|
|
||||||
store := NewTenantUserStore(pool)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
created, err := store.Create(ctx, "alice@example.com", "Alice")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create: %v", err)
|
|
||||||
}
|
|
||||||
if created.Status != StatusActive {
|
|
||||||
t.Fatalf("erwartet status active, hat %q", created.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := store.Get(ctx, created.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get: %v", err)
|
|
||||||
}
|
|
||||||
if got.Email != "alice@example.com" {
|
|
||||||
t.Fatalf("get email = %q", got.Email)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := store.Update(ctx, created.ID, "", "Alice A.")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("update: %v", err)
|
|
||||||
}
|
|
||||||
if updated.Name != "Alice A." || updated.Email != "alice@example.com" {
|
|
||||||
t.Fatalf("update ergebnis unerwartet: %+v", updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
list, err := store.List(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
if len(list) != 1 {
|
|
||||||
t.Fatalf("erwartet 1 benutzer, habe %d", len(list))
|
|
||||||
}
|
|
||||||
|
|
||||||
deactivated, err := store.Deactivate(ctx, created.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("deactivate: %v", err)
|
|
||||||
}
|
|
||||||
if deactivated.Status != StatusInactive {
|
|
||||||
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Negativfall: doppelte E-Mail-Adresse.
|
|
||||||
if _, err := store.Create(ctx, "second@example.com", "Bob"); err != nil {
|
|
||||||
t.Fatalf("create second: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.Create(ctx, "second@example.com", "Bob Zwei"); !errors.Is(err, ErrEmailTaken) {
|
|
||||||
t.Fatalf("erwartet ErrEmailTaken, habe %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Negativfall: fehlender Benutzer.
|
|
||||||
if _, err := store.Get(ctx, created.ID+"-nicht-vorhanden"); err == nil {
|
|
||||||
t.Fatalf("erwartet fehler bei unbekannter/ungueltiger id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 2: Superadmin-Anlage ohne Tenant-Kontext.
|
|
||||||
// SuperadminStore.Create hat keinen Tenant-Parameter — es gibt syntaktisch
|
|
||||||
// keine Moeglichkeit, hier versehentlich einen Tenant-Sonderfall zu vergessen.
|
|
||||||
func TestSuperadminStore_CreateWithoutTenantContext(t *testing.T) {
|
|
||||||
pool := setupTestDB(t, "test_iam01_superadmins", superadminsSchema)
|
|
||||||
store := NewSuperadminStore(pool)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
created, err := store.Create(ctx, "root@nexarch.internal", "Root")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create superadmin: %v", err)
|
|
||||||
}
|
|
||||||
if created.Status != StatusActive {
|
|
||||||
t.Fatalf("erwartet status active, hat %q", created.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := store.Get(ctx, created.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get: %v", err)
|
|
||||||
}
|
|
||||||
if got.Email != "root@nexarch.internal" {
|
|
||||||
t.Fatalf("get email = %q", got.Email)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := store.Create(ctx, "root@nexarch.internal", "Root Zwei"); !errors.Is(err, ErrEmailTaken) {
|
|
||||||
t.Fatalf("erwartet ErrEmailTaken (globale eindeutigkeit), habe %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
deactivated, err := store.Deactivate(ctx, created.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("deactivate: %v", err)
|
|
||||||
}
|
|
||||||
if deactivated.Status != StatusInactive {
|
|
||||||
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SuperadminStore verwaltet mandantenuebergreifende Superadmin-Konten in der
|
|
||||||
// Control-Plane-Registry (siehe internal/tenant.Registry). Superadmin-ohne-
|
|
||||||
// Tenant ist dadurch ein eigener Typ statt eines Sonderfalls von User/
|
|
||||||
// TenantUserStore — es gibt keinen Tenant-Parameter, den man weglassen
|
|
||||||
// koennte (IAM-01, "ohne Sonderbehandlung im Code").
|
|
||||||
type SuperadminStore struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSuperadminStore(pool *pgxpool.Pool) *SuperadminStore {
|
|
||||||
return &SuperadminStore{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SuperadminStore) Create(ctx context.Context, email, name string) (User, error) {
|
|
||||||
if err := ValidateEmail(email); err != nil {
|
|
||||||
return User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var u User
|
|
||||||
u.Email, u.Name, u.Status = email, name, StatusActive
|
|
||||||
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO superadmins (email, name, status)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
RETURNING id, created_at, updated_at
|
|
||||||
`, u.Email, u.Name, u.Status)
|
|
||||||
|
|
||||||
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
||||||
return User{}, mapWriteErr(err)
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SuperadminStore) Get(ctx context.Context, id string) (User, error) {
|
|
||||||
return scanUser(s.pool.QueryRow(ctx, `
|
|
||||||
SELECT id, email, name, status, created_at, updated_at
|
|
||||||
FROM superadmins WHERE id = $1
|
|
||||||
`, id))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SuperadminStore) List(ctx context.Context) ([]User, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
|
||||||
SELECT id, email, name, status, created_at, updated_at
|
|
||||||
FROM superadmins ORDER BY created_at
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("superadmins auflisten: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []User
|
|
||||||
for rows.Next() {
|
|
||||||
var u User
|
|
||||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("superadmin lesen: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, u)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SuperadminStore) Deactivate(ctx context.Context, id string) (User, error) {
|
|
||||||
return scanUser(s.pool.QueryRow(ctx, `
|
|
||||||
UPDATE superadmins SET status = $2, updated_at = now()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, email, name, status, created_at, updated_at
|
|
||||||
`, id, StatusInactive))
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TenantUserStore verwaltet Benutzer innerhalb GENAU EINER Tenant-Datenbank.
|
|
||||||
// Welcher Mandant gemeint ist, ergibt sich ausschliesslich aus dem
|
|
||||||
// uebergebenen Pool — es gibt keine tenant_id-Spalte (siehe migrations/tenant/0001_users.up.sql).
|
|
||||||
type TenantUserStore struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTenantUserStore(pool *pgxpool.Pool) *TenantUserStore {
|
|
||||||
return &TenantUserStore{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TenantUserStore) Create(ctx context.Context, email, name string) (User, error) {
|
|
||||||
if err := ValidateEmail(email); err != nil {
|
|
||||||
return User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var u User
|
|
||||||
u.Email, u.Name, u.Status = email, name, StatusActive
|
|
||||||
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO users (email, name, status)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
RETURNING id, created_at, updated_at
|
|
||||||
`, u.Email, u.Name, u.Status)
|
|
||||||
|
|
||||||
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
||||||
return User{}, mapWriteErr(err)
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TenantUserStore) Get(ctx context.Context, id string) (User, error) {
|
|
||||||
return scanUser(s.pool.QueryRow(ctx, `
|
|
||||||
SELECT id, email, name, status, created_at, updated_at
|
|
||||||
FROM users WHERE id = $1
|
|
||||||
`, id))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TenantUserStore) List(ctx context.Context) ([]User, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
|
||||||
SELECT id, email, name, status, created_at, updated_at
|
|
||||||
FROM users ORDER BY created_at
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("benutzer auflisten: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []User
|
|
||||||
for rows.Next() {
|
|
||||||
var u User
|
|
||||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("benutzer lesen: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, u)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update aendert Name und E-Mail. Eine leere email/name laesst das jeweilige
|
|
||||||
// Feld unveraendert.
|
|
||||||
func (s *TenantUserStore) Update(ctx context.Context, id, email, name string) (User, error) {
|
|
||||||
if email != "" {
|
|
||||||
if err := ValidateEmail(email); err != nil {
|
|
||||||
return User{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
UPDATE users
|
|
||||||
SET email = COALESCE(NULLIF($2, ''), email),
|
|
||||||
name = COALESCE(NULLIF($3, ''), name),
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, email, name, status, created_at, updated_at
|
|
||||||
`, id, email, name)
|
|
||||||
|
|
||||||
u, err := scanUser(row)
|
|
||||||
if err != nil {
|
|
||||||
return User{}, mapWriteErr(err)
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deactivate setzt den Benutzer auf inaktiv statt ihn zu loeschen.
|
|
||||||
func (s *TenantUserStore) Deactivate(ctx context.Context, id string) (User, error) {
|
|
||||||
return scanUser(s.pool.QueryRow(ctx, `
|
|
||||||
UPDATE users SET status = $2, updated_at = now()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, email, name, status, created_at, updated_at
|
|
||||||
`, id, StatusInactive))
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPasswordHash schreibt einen bereits berechneten bcrypt-Hash (siehe
|
|
||||||
// internal/auth, IAM-02). Der Store selbst kennt kein Klartext-Passwort.
|
|
||||||
func (s *TenantUserStore) SetPasswordHash(ctx context.Context, id, hash string) error {
|
|
||||||
tag, err := s.pool.Exec(ctx, `
|
|
||||||
UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1
|
|
||||||
`, id, hash)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("passwort setzen: %w", err)
|
|
||||||
}
|
|
||||||
if tag.RowsAffected() == 0 {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthCredentials wird ausschliesslich fuer den Login-Pfad (internal/auth)
|
|
||||||
// verwendet und traegt bewusst den password_hash, damit er nicht ueber den
|
|
||||||
// regulaeren User-Typ/JSON-Serialisierungspfad nach aussen dringen kann.
|
|
||||||
type AuthCredentials struct {
|
|
||||||
User User
|
|
||||||
PasswordHash string
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByEmailForAuth liefert Benutzer + Passwort-Hash zu einer E-Mail-Adresse
|
|
||||||
// aus GENAU DIESER Tenant-Datenbank — der Tenant-Scope ergibt sich damit
|
|
||||||
// zwingend aus dem verwendeten Pool, es gibt keine Moeglichkeit, versehentlich
|
|
||||||
// ueber Tenant-Grenzen hinweg zu suchen (bekannter archivmail-Fehler, siehe
|
|
||||||
// IAM-02 "Bekannte Fehler vermeiden").
|
|
||||||
func (s *TenantUserStore) GetByEmailForAuth(ctx context.Context, email string) (AuthCredentials, error) {
|
|
||||||
var c AuthCredentials
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
SELECT id, email, name, status, created_at, updated_at, password_hash
|
|
||||||
FROM users WHERE email = $1
|
|
||||||
`, email)
|
|
||||||
|
|
||||||
if err := row.Scan(&c.User.ID, &c.User.Email, &c.User.Name, &c.User.Status,
|
|
||||||
&c.User.CreatedAt, &c.User.UpdatedAt, &c.PasswordHash); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return AuthCredentials{}, ErrNotFound
|
|
||||||
}
|
|
||||||
return AuthCredentials{}, fmt.Errorf("anmeldedaten lesen: %w", err)
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanUser(row pgx.Row) (User, error) {
|
|
||||||
var u User
|
|
||||||
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return User{}, ErrNotFound
|
|
||||||
}
|
|
||||||
return User{}, fmt.Errorf("benutzer lesen: %w", err)
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapWriteErr uebersetzt den Unique-Constraint-Verstoss der E-Mail-Spalte in
|
|
||||||
// einen sprechenden Fehler statt der rohen Postgres-Fehlermeldung.
|
|
||||||
func mapWriteErr(err error) error {
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
|
||||||
return ErrEmailTaken
|
|
||||||
}
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
return fmt.Errorf("benutzer schreiben: %w", err)
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
// Package user implementiert Core IAM-01: das Benutzer-Datenmodell und die
|
|
||||||
// CRUD-Operationen. Tenant-Zugehoerigkeit ist ueber die Zieldatenbank
|
|
||||||
// gegeben (Modell C, siehe internal/tenant) — Superadmin-Konten leben
|
|
||||||
// dagegen mandantenuebergreifend in der Registry und sind ueber
|
|
||||||
// SuperadminStore als eigener, First-Class-Typ modelliert, nicht als
|
|
||||||
// tenant_id-NULL-Sonderfall in User.
|
|
||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"regexp"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Status string
|
|
||||||
|
|
||||||
const (
|
|
||||||
StatusActive Status = "active"
|
|
||||||
StatusInactive Status = "inactive"
|
|
||||||
)
|
|
||||||
|
|
||||||
type User struct {
|
|
||||||
ID string
|
|
||||||
Email string
|
|
||||||
Name string
|
|
||||||
Status Status
|
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
|
||||||
|
|
||||||
var ErrInvalidEmail = errors.New("user: ungueltige E-Mail-Adresse")
|
|
||||||
var ErrEmailTaken = errors.New("user: E-Mail-Adresse bereits vergeben")
|
|
||||||
var ErrNotFound = errors.New("user: nicht gefunden")
|
|
||||||
|
|
||||||
func ValidateEmail(email string) error {
|
|
||||||
if !emailPattern.MatchString(email) {
|
|
||||||
return ErrInvalidEmail
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestValidateEmail(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
email string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"a@b.de", false},
|
|
||||||
{"a.b+c@sub.example.com", false},
|
|
||||||
{"", true},
|
|
||||||
{"keine-email", true},
|
|
||||||
{"a@b", true},
|
|
||||||
{"@b.de", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
err := ValidateEmail(c.email)
|
|
||||||
if (err != nil) != c.wantErr {
|
|
||||||
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", c.email, err, c.wantErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS superadmins;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
-- Superadmin-Konten arbeiten mandantenuebergreifend und leben deshalb in der
|
|
||||||
-- Control-Plane-Registry (siehe TEN-01), nicht in einer Tenant-Datenbank.
|
|
||||||
-- Das bildet "Superadmin ohne Tenant" strukturell als First-Class-Zustand ab,
|
|
||||||
-- statt ihn als Sonderfall in der Tenant-users-Tabelle zu behandeln
|
|
||||||
-- (IAM-01, siehe core-kanban/tickets/IAM-01.md — bekannte Fehler vermeiden).
|
|
||||||
-- E-Mail-Eindeutigkeit ist hier global, da die Registry-DB einmalig existiert.
|
|
||||||
CREATE TABLE superadmins (
|
|
||||||
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()
|
|
||||||
);
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS tenant_licenses;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Lizenzumfang pro Mandant (LIC-01, siehe core-kanban/tickets/LIC-01.md).
|
||||||
|
-- Genau ein Lizenzdatensatz pro Tenant (tenant_id PK) — ein neues Einspielen
|
||||||
|
-- ersetzt den vorherigen Datensatz vollstaendig statt eine Historie zu fuehren.
|
||||||
|
CREATE TABLE 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()
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS usage_quotas;
|
||||||
|
DROP TABLE IF EXISTS usage_counters;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Nutzungszaehler & Quotas je Tenant (LIC-03, siehe core-kanban/tickets/LIC-03.md).
|
||||||
|
CREATE TABLE 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 usage_quotas (
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
metric TEXT NOT NULL,
|
||||||
|
limit_value BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (tenant_id, metric)
|
||||||
|
);
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS users;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
-- Benutzer-Datenmodell (IAM-01, siehe core-kanban/tickets/IAM-01.md).
|
|
||||||
-- Diese Migration laeuft in der DB EINES Mandanten (Modell C, siehe TEN-01) —
|
|
||||||
-- die Tenant-Zugehoerigkeit ist implizit durch die Datenbankverbindung
|
|
||||||
-- gegeben, es gibt daher bewusst KEINE tenant_id-Spalte.
|
|
||||||
-- E-Mail-Eindeutigkeit ist hier tenant-scoped: der UNIQUE-Constraint gilt
|
|
||||||
-- nur innerhalb dieser einen Tenant-Datenbank.
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
|
|
||||||
CREATE TABLE users (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE users DROP COLUMN password_hash;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
-- Passwort-Hash-Spalte fuer Login (IAM-02, siehe core-kanban/tickets/IAM-02.md).
|
|
||||||
-- Enthaelt AUSSCHLIESSLICH den bcrypt-Hash, niemals das Klartext-Passwort.
|
|
||||||
ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
DROP TABLE oidc_auth_codes;
|
|
||||||
DROP TABLE oidc_clients;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
-- IAM-13: NEXARCH als OIDC-Provider fuer Drittanwendungen eines Mandanten.
|
|
||||||
-- Client-Registrierung und Authorization-Codes leben pro Mandant (nicht in
|
|
||||||
-- der zentralen Registry), weil ein OAuth2-Client konzeptionell zu genau
|
|
||||||
-- einem Kunden gehoert (siehe core-kanban/tickets/IAM-13.md).
|
|
||||||
|
|
||||||
CREATE TABLE 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()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Auth-Codes sind bewusst kurzlebig und einmal verwendbar (RFC 6749 4.1.2):
|
|
||||||
-- used_at IS NULL in der WHERE-Klausel beim Einloesen macht das Konsumieren
|
|
||||||
-- atomar, gleiches Muster wie internal/authtoken (IAM-03).
|
|
||||||
CREATE TABLE 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
|
|
||||||
);
|
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
|
||||||
|
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
|
||||||
|
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
|
||||||
|
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
|
||||||
|
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
|
||||||
|
#
|
||||||
|
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
|
||||||
|
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
|
||||||
|
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
|
||||||
|
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
|
||||||
|
#
|
||||||
|
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "NEXARCH Lizenz- und Modulverwaltung",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="de">
|
||||||
|
<body
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily,
|
||||||
|
margin: 0,
|
||||||
|
background: "var(--shl-color-background, #ffffff)",
|
||||||
|
color: "var(--shl-color-text-primary, #14181f)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeProvider>
|
||||||
|
<I18nProvider initialLocale="de">
|
||||||
|
<ToastProvider>{children}</ToastProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { fetchOverview, toggleFlag, type Overview } from "@/lib/api";
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
ok: "In Ordnung",
|
||||||
|
warning: "Nahe am Limit",
|
||||||
|
exceeded: "Limit überschritten",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
ok: "#2e7d32",
|
||||||
|
warning: "#ed6c02",
|
||||||
|
exceeded: "#c62828",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [tenantId, setTenantId] = useState("");
|
||||||
|
const [overview, setOverview] = useState<Overview | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [toggleError, setToggleError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!tenantId.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await fetchOverview(tenantId.trim());
|
||||||
|
setOverview(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message ?? "Unbekannter Fehler");
|
||||||
|
setOverview(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggle(key: string, next: boolean) {
|
||||||
|
setToggleError(null);
|
||||||
|
try {
|
||||||
|
await toggleFlag(tenantId.trim(), key, next);
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
setToggleError(e.message ?? "Umschalten fehlgeschlagen");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 800, margin: "0 auto", padding: "2rem 1rem" }}>
|
||||||
|
<h1>Lizenz- und Modulverwaltung</h1>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem" }}>
|
||||||
|
<input
|
||||||
|
value={tenantId}
|
||||||
|
onChange={(e) => setTenantId(e.target.value)}
|
||||||
|
placeholder="Tenant-ID eingeben"
|
||||||
|
style={{ flex: 1, padding: "0.5rem", fontSize: "1rem" }}
|
||||||
|
/>
|
||||||
|
<button onClick={load} disabled={loading} style={{ padding: "0.5rem 1rem" }}>
|
||||||
|
{loading ? "Lädt…" : "Anzeigen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: "#c62828" }} role="alert">
|
||||||
|
Fehler: {error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{overview && (
|
||||||
|
<>
|
||||||
|
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginBottom: "1.5rem" }}>
|
||||||
|
<h2>Lizenzstatus</h2>
|
||||||
|
<p>
|
||||||
|
<strong>Plan:</strong> {overview.plan}{" "}
|
||||||
|
{overview.expired && (
|
||||||
|
<span style={{ color: "#c62828", fontWeight: "bold" }}>
|
||||||
|
(abgelaufen)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Gültig bis:</strong>{" "}
|
||||||
|
{new Date(overview.valid_until).toLocaleDateString("de-DE")}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Freigeschaltete Module:</strong>{" "}
|
||||||
|
{overview.modules.length > 0 ? overview.modules.join(", ") : "keine"}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginBottom: "1.5rem" }}>
|
||||||
|
<h2>Feature-Flags</h2>
|
||||||
|
{toggleError && (
|
||||||
|
<p style={{ color: "#c62828" }} role="alert">
|
||||||
|
{toggleError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{overview.flags.length === 0 && <p>Keine Feature-Flags definiert.</p>}
|
||||||
|
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||||
|
{overview.flags.map((f) => (
|
||||||
|
<li
|
||||||
|
key={f.key}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "0.5rem 0",
|
||||||
|
borderBottom: "1px solid #eee",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div>{f.key}</div>
|
||||||
|
{!f.licensed && f.deny_grund && (
|
||||||
|
<div style={{ fontSize: "0.85rem", color: "#c62828" }}>
|
||||||
|
{f.deny_grund}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={f.enabled}
|
||||||
|
disabled={!f.licensed && !f.enabled}
|
||||||
|
onChange={(e) => onToggle(f.key, e.target.checked)}
|
||||||
|
/>{" "}
|
||||||
|
aktiv
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ background: "white", padding: "1rem", borderRadius: 8 }}>
|
||||||
|
<h2>Nutzungsstand</h2>
|
||||||
|
{overview.usage.map((u) => {
|
||||||
|
const pct = u.limit > 0 ? Math.min(100, (u.value / u.limit) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div key={u.metric} style={{ marginBottom: "1rem" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
|
<span>{u.metric}</span>
|
||||||
|
<span style={{ color: STATUS_COLOR[u.status] }}>
|
||||||
|
{u.value} / {u.limit > 0 ? u.limit : "unbegrenzt"} —{" "}
|
||||||
|
{STATUS_LABEL[u.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#eee",
|
||||||
|
borderRadius: 4,
|
||||||
|
height: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${u.limit > 0 ? pct : 0}%`,
|
||||||
|
background: STATUS_COLOR[u.status],
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Duenner Client fuer das LIC-04-Backend-API (internal/adminapi) — enthaelt
|
||||||
|
// keinerlei eigene Lizenz-/Flag-Logik, nur Datenabruf und -weitergabe.
|
||||||
|
export type FlagOverview = {
|
||||||
|
key: string;
|
||||||
|
enabled: boolean;
|
||||||
|
licensed: boolean;
|
||||||
|
deny_grund?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UsageOverview = {
|
||||||
|
metric: string;
|
||||||
|
value: number;
|
||||||
|
limit: number;
|
||||||
|
status: "ok" | "warning" | "exceeded";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Overview = {
|
||||||
|
plan: string;
|
||||||
|
modules: string[];
|
||||||
|
issued_at: string;
|
||||||
|
valid_until: string;
|
||||||
|
expired: boolean;
|
||||||
|
flags: FlagOverview[];
|
||||||
|
usage: UsageOverview[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function apiBase(): string {
|
||||||
|
const base = process.env.NEXT_PUBLIC_ADMIN_API_URL;
|
||||||
|
if (!base) {
|
||||||
|
throw new Error(
|
||||||
|
"NEXT_PUBLIC_ADMIN_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOverview(tenantId: string): Promise<Overview> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${apiBase()}/admin/overview?tenant=${encodeURIComponent(tenantId)}`,
|
||||||
|
{ cache: "no-store" }
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Anfrage fehlgeschlagen (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleFlag(
|
||||||
|
tenantId: string,
|
||||||
|
key: string,
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(`${apiBase()}/admin/flags/toggle`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ tenant: tenantId, key, enabled }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Umschalten fehlgeschlagen (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
// @nexarch/shl liegt als file:-Dependency mit TS-Quellen in node_modules —
|
||||||
|
// Next.js transpiliert node_modules standardmäßig nicht, siehe web/shl/README.md.
|
||||||
|
transpilePackages: ["@nexarch/shl"],
|
||||||
|
};
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "nexarch-lic-admin",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nexarch/shl": "file:../shl",
|
||||||
|
"next": "14.2.35",
|
||||||
|
"react": "18.3.1",
|
||||||
|
"react-dom": "18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "20.14.9",
|
||||||
|
"@types/react": "18.3.3",
|
||||||
|
"@types/react-dom": "18.3.0",
|
||||||
|
"typescript": "5.5.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# @nexarch/shl — UI-Shell & Design-System (Core SHL-01)
|
||||||
|
|
||||||
|
Gemeinsames Paket für alle NEXARCH-Modul-Frontends (Core, DMS, Mail, Archive, Workflow, AI, Connect).
|
||||||
|
Ein Modul-Frontend importiert ausschließlich über `index.ts`, kopiert keine Komponenten oder Tokens lokal.
|
||||||
|
|
||||||
|
## Enthält
|
||||||
|
|
||||||
|
- **Design-Tokens** (`tokens/tokens.ts`) — Farbe (Hell/Dunkel), Abstand, Typografie. Kontrastwerte gegen WCAG 2.1 AA geprüft (siehe `__tests__/tokens.test.ts`).
|
||||||
|
- **Theming** (`theme/ThemeProvider.tsx`) — zentrale Hell/Dunkel-Umschaltung, respektiert `prefers-color-scheme`, persistiert in `localStorage`.
|
||||||
|
- **i18n-Rahmen** (`i18n/i18n.tsx`) — Umschaltmechanismus Deutsch/Englisch. Modul-Frontends registrieren ihre fachlichen Textbausteine über `registerMessages()`, statt einen eigenen Mechanismus zu bauen.
|
||||||
|
- **Basis-Komponenten** (`components/`) — `Shell` (Layout + Navigation), `Table`, `Dialog`, `TextField`/`SelectField`/`CheckboxField`, `Toast`. Alle mit WCAG-2.1-AA-Grundlage (Tastaturbedienung, ARIA-Attribute, Fokus-Management).
|
||||||
|
|
||||||
|
## Verwendung in einem Modul-Frontend
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ThemeProvider, I18nProvider, ToastProvider, Shell } from "@nexarch/shl";
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<I18nProvider initialLocale="de">
|
||||||
|
<ToastProvider>
|
||||||
|
<Shell modules={[]} tenantLabel="Mandant XY">
|
||||||
|
{children}
|
||||||
|
</Shell>
|
||||||
|
</ToastProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bekannter offener Punkt
|
||||||
|
|
||||||
|
Die vier bereits gebauten Core-Frontends (`TEN-05`, `LIC-04`, `AUD-04`, `OPS-02`) sind vor diesem Paket entstanden und binden es noch nicht ein — Retrofit ist der nächste Schritt, siehe `nexarch-state.json`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Ausführung auf dem Test-Host (nicht lokal, siehe Projekt-Testinfrastruktur):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm test
|
||||||
|
npm run typecheck
|
||||||
|
```
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// Prüfung: Tastaturbedienung der Basis-Komponenten funktioniert (SHL-01 Prüfung 2).
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { Dialog } from "../components/Dialog";
|
||||||
|
import { I18nProvider } from "../i18n/i18n";
|
||||||
|
|
||||||
|
function renderDialog(onClose: () => void) {
|
||||||
|
return render(
|
||||||
|
<I18nProvider>
|
||||||
|
<Dialog open titleId="test-title" title="Test-Dialog" onClose={onClose}>
|
||||||
|
<button type="button">Erste Aktion</button>
|
||||||
|
<button type="button">Zweite Aktion</button>
|
||||||
|
</Dialog>
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Dialog: Tastaturbedienung", () => {
|
||||||
|
it("schließt sich bei ESC", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
renderDialog(onClose);
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt den Fokus beim Öffnen auf das erste fokussierbare Element", () => {
|
||||||
|
renderDialog(vi.fn());
|
||||||
|
const closeButton = screen.getByRole("button", { name: /schließen/i });
|
||||||
|
expect(document.activeElement).toBe(closeButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ist als modaler Dialog mit Titel-Referenz ausgezeichnet", () => {
|
||||||
|
renderDialog(vi.fn());
|
||||||
|
const dialog = screen.getByRole("dialog");
|
||||||
|
expect(dialog).toHaveAttribute("aria-modal", "true");
|
||||||
|
expect(dialog).toHaveAttribute("aria-labelledby", "test-title");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Prüfung: Kontrastwerte erfüllen mindestens AA (SHL-01 Prüfung 3 / Akzeptanzkriterium 4).
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { colorTokens } from "../tokens/tokens";
|
||||||
|
|
||||||
|
// WCAG-2.1-AA-Kontrastberechnung (relative Luminanz, sRGB) — keine externe Abhängigkeit nötig.
|
||||||
|
function relLuminance(hex: string): number {
|
||||||
|
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
||||||
|
const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contrastRatio(a: string, b: string): number {
|
||||||
|
const l1 = relLuminance(a);
|
||||||
|
const l2 = relLuminance(b);
|
||||||
|
const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
|
||||||
|
return (lighter + 0.05) / (darker + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Design-Tokens: WCAG 2.1 AA Kontrast", () => {
|
||||||
|
for (const scheme of ["light", "dark"] as const) {
|
||||||
|
const c = colorTokens[scheme];
|
||||||
|
|
||||||
|
it(`${scheme}: textPrimary auf background erfüllt AA (>= 4.5:1)`, () => {
|
||||||
|
expect(contrastRatio(c.textPrimary, c.background)).toBeGreaterThanOrEqual(4.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${scheme}: textSecondary auf surface erfüllt AA (>= 4.5:1)`, () => {
|
||||||
|
expect(contrastRatio(c.textSecondary, c.surface)).toBeGreaterThanOrEqual(4.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${scheme}: accentContrast auf accent erfüllt AA (>= 4.5:1)`, () => {
|
||||||
|
expect(contrastRatio(c.accentContrast, c.accent)).toBeGreaterThanOrEqual(4.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${scheme}: dangerContrast auf danger erfüllt AA (>= 4.5:1)`, () => {
|
||||||
|
expect(contrastRatio(c.dangerContrast, c.danger)).toBeGreaterThanOrEqual(4.5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Dialog-Basis-Komponente — SHL-01. WCAG 2.1 AA: Fokus-Falle, ESC schließt, Tastaturbedienung vollständig.
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useI18n } from "../i18n/i18n";
|
||||||
|
|
||||||
|
const FOCUSABLE_SELECTOR =
|
||||||
|
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||||
|
|
||||||
|
export interface DialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
titleId: string;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dialog({ open, onClose, titleId, title, children }: DialogProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
previouslyFocused.current = document.activeElement as HTMLElement | null;
|
||||||
|
|
||||||
|
const node = dialogRef.current;
|
||||||
|
const focusables = node?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
|
||||||
|
focusables?.[0]?.focus();
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key !== "Tab" || !node) return;
|
||||||
|
|
||||||
|
const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
||||||
|
if (items.length === 0) return;
|
||||||
|
const first = items[0];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
|
||||||
|
if (event.shiftKey && document.activeElement === first) {
|
||||||
|
event.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!event.shiftKey && document.activeElement === last) {
|
||||||
|
event.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
previouslyFocused.current?.focus();
|
||||||
|
};
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="shl-dialog-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.target === event.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
className="shl-dialog"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
>
|
||||||
|
<div className="shl-dialog-header">
|
||||||
|
<h2 id={titleId}>{title}</h2>
|
||||||
|
<button type="button" onClick={onClose} aria-label={t("shl.dialog.close")}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="shl-dialog-body">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Formularelemente-Basis-Komponenten — SHL-01. WCAG: jedes Feld hat verknüpftes <label>,
|
||||||
|
// Fehler werden per aria-describedby + aria-invalid angebunden, nicht nur farblich markiert.
|
||||||
|
|
||||||
|
import { useId } from "react";
|
||||||
|
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
|
||||||
|
|
||||||
|
interface FieldWrapperProps {
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
children: (ids: { inputId: string; describedBy: string | undefined }) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldWrapper({ label, error, hint, children }: FieldWrapperProps) {
|
||||||
|
const inputId = useId();
|
||||||
|
const hintId = hint ? `${inputId}-hint` : undefined;
|
||||||
|
const errorId = error ? `${inputId}-error` : undefined;
|
||||||
|
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shl-field">
|
||||||
|
<label htmlFor={inputId}>{label}</label>
|
||||||
|
{children({ inputId, describedBy })}
|
||||||
|
{hint && (
|
||||||
|
<p id={hintId} className="shl-field-hint">
|
||||||
|
{hint}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p id={errorId} className="shl-field-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextFieldProps
|
||||||
|
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "aria-describedby"> {
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextField({ label, error, hint, ...inputProps }: TextFieldProps) {
|
||||||
|
return (
|
||||||
|
<FieldWrapper label={label} error={error} hint={hint}>
|
||||||
|
{({ inputId, describedBy }) => (
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectFieldProps
|
||||||
|
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "id" | "aria-describedby"> {
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectField({ label, error, hint, children, ...selectProps }: SelectFieldProps) {
|
||||||
|
return (
|
||||||
|
<FieldWrapper label={label} error={error} hint={hint}>
|
||||||
|
{({ inputId, describedBy }) => (
|
||||||
|
<select
|
||||||
|
id={inputId}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
|
{...selectProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</FieldWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckboxFieldProps
|
||||||
|
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "type"> {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckboxField({ label, ...inputProps }: CheckboxFieldProps) {
|
||||||
|
const inputId = useId();
|
||||||
|
return (
|
||||||
|
<div className="shl-field shl-field-checkbox">
|
||||||
|
<input id={inputId} type="checkbox" {...inputProps} />
|
||||||
|
<label htmlFor={inputId}>{label}</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Layout-Shell mit Navigation — SHL-01 Akzeptanzkriterium 1.
|
||||||
|
// Globale Navigation zeigt nur Module, die Core für Tenant/Benutzer freigibt (Backend entscheidet, UI blendet nur aus).
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useI18n } from "../i18n/i18n";
|
||||||
|
import { useTheme } from "../theme/ThemeProvider";
|
||||||
|
|
||||||
|
export interface ModuleLink {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellProps {
|
||||||
|
modules: ModuleLink[];
|
||||||
|
tenantLabel: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Shell({ modules, tenantLabel, children }: ShellProps) {
|
||||||
|
const { scheme, toggle } = useTheme();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shl-shell">
|
||||||
|
<a className="shl-skip-link" href="#shl-main-content">
|
||||||
|
{t("shl.shell.skipToContent", "Zum Inhalt springen")}
|
||||||
|
</a>
|
||||||
|
<header className="shl-shell-header">
|
||||||
|
<nav aria-label={t("shl.shell.moduleNav", "Modul-Navigation")}>
|
||||||
|
<ul>
|
||||||
|
{modules.map((mod) => (
|
||||||
|
<li key={mod.key}>
|
||||||
|
<a href={mod.href} aria-current={mod.active ? "page" : undefined}>
|
||||||
|
{mod.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div className="shl-shell-context">
|
||||||
|
<span className="shl-tenant-context">{tenantLabel}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-label={
|
||||||
|
scheme === "light" ? t("shl.theme.toggleToDark") : t("shl.theme.toggleToLight")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{scheme === "light" ? "🌙" : "☀️"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main id="shl-main-content" className="shl-shell-content" tabIndex={-1}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Table-Basis-Komponente — SHL-01. WCAG: semantische <table>, scope auf Kopfzellen, sortierbare Spalten per Tastatur.
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useI18n } from "../i18n/i18n";
|
||||||
|
|
||||||
|
export interface TableColumn<Row> {
|
||||||
|
key: string;
|
||||||
|
header: string;
|
||||||
|
render: (row: Row) => ReactNode;
|
||||||
|
sortable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableProps<Row> {
|
||||||
|
columns: TableColumn<Row>[];
|
||||||
|
rows: Row[];
|
||||||
|
rowKey: (row: Row) => string;
|
||||||
|
sortKey?: string;
|
||||||
|
sortDirection?: "asc" | "desc";
|
||||||
|
onSort?: (key: string) => void;
|
||||||
|
caption?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Table<Row>({
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
rowKey,
|
||||||
|
sortKey,
|
||||||
|
sortDirection,
|
||||||
|
onSort,
|
||||||
|
caption,
|
||||||
|
}: TableProps<Row>) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="shl-table">
|
||||||
|
{caption && <caption>{caption}</caption>}
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((column) => {
|
||||||
|
const isSorted = column.key === sortKey;
|
||||||
|
const ariaSort = column.sortable
|
||||||
|
? isSorted
|
||||||
|
? sortDirection === "asc"
|
||||||
|
? "ascending"
|
||||||
|
: "descending"
|
||||||
|
: "none"
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<th key={column.key} scope="col" aria-sort={ariaSort}>
|
||||||
|
{column.sortable ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSort?.(column.key)}
|
||||||
|
className="shl-table-sort-button"
|
||||||
|
>
|
||||||
|
{column.header}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
column.header
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length}>{t("shl.table.noRows")}</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<tr key={rowKey(row)}>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<td key={column.key}>{column.render(row)}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Toast-Basis-Komponente — SHL-01. WCAG: aria-live sorgt dafür, dass Screenreader Meldungen ansagen.
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useI18n } from "../i18n/i18n";
|
||||||
|
|
||||||
|
export type ToastVariant = "info" | "success" | "danger" | "warning";
|
||||||
|
|
||||||
|
export interface ToastMessage {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
variant: ToastVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
toasts: ToastMessage[];
|
||||||
|
push: (text: string, variant?: ToastVariant) => void;
|
||||||
|
dismiss: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const dismiss = useCallback((id: string) => {
|
||||||
|
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const push = useCallback((text: string, variant: ToastVariant = "info") => {
|
||||||
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
setToasts((current) => [...current, { id, text, variant }]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ toasts, push, dismiss }), [toasts, push, dismiss]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<div className="shl-toast-region" role="status" aria-live="polite" aria-atomic="false">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<div key={toast.id} className={`shl-toast shl-toast-${toast.variant}`}>
|
||||||
|
<span>{toast.text}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismiss(toast.id)}
|
||||||
|
aria-label={t("shl.toast.dismiss")}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast(): ToastContextValue {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useToast muss innerhalb von <ToastProvider> aufgerufen werden");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// i18n-Rahmen (mind. Deutsch/Englisch) — SHL-01 Akzeptanzkriterium 5.
|
||||||
|
// Liefert nur den Umschaltmechanismus + Basis-Komponenten-Texte.
|
||||||
|
// Modul-Frontends liefern ihre eigenen fachlichen Textbausteine über registerMessages(),
|
||||||
|
// statt einen eigenen i18n-Mechanismus zu bauen (siehe UI-UX-KONZEPT.md Abschnitt 4).
|
||||||
|
|
||||||
|
import { createContext, useContext, useMemo, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type Locale = "de" | "en";
|
||||||
|
|
||||||
|
type MessageDict = Record<string, string>;
|
||||||
|
type MessageBundle = Record<Locale, MessageDict>;
|
||||||
|
|
||||||
|
const baseMessages: MessageBundle = {
|
||||||
|
de: {
|
||||||
|
"shl.dialog.close": "Schließen",
|
||||||
|
"shl.toast.dismiss": "Meldung schließen",
|
||||||
|
"shl.table.noRows": "Keine Einträge vorhanden",
|
||||||
|
"shl.theme.toggleToLight": "Helles Erscheinungsbild",
|
||||||
|
"shl.theme.toggleToDark": "Dunkles Erscheinungsbild",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
"shl.dialog.close": "Close",
|
||||||
|
"shl.toast.dismiss": "Dismiss message",
|
||||||
|
"shl.table.noRows": "No entries",
|
||||||
|
"shl.theme.toggleToLight": "Switch to light theme",
|
||||||
|
"shl.theme.toggleToDark": "Switch to dark theme",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Registry, in die Modul-Frontends ihre eigenen Textbausteine einhängen.
|
||||||
|
const registry: MessageBundle = { de: { ...baseMessages.de }, en: { ...baseMessages.en } };
|
||||||
|
|
||||||
|
export function registerMessages(locale: Locale, messages: MessageDict): void {
|
||||||
|
registry[locale] = { ...registry[locale], ...messages };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface I18nContextValue {
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (locale: Locale) => void;
|
||||||
|
t: (key: string, fallback?: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||||
|
|
||||||
|
export function I18nProvider({
|
||||||
|
initialLocale = "de",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
initialLocale?: Locale;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [locale, setLocale] = useState<Locale>(initialLocale);
|
||||||
|
|
||||||
|
const value = useMemo<I18nContextValue>(
|
||||||
|
() => ({
|
||||||
|
locale,
|
||||||
|
setLocale,
|
||||||
|
t: (key: string, fallback?: string) => registry[locale][key] ?? fallback ?? key,
|
||||||
|
}),
|
||||||
|
[locale],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useI18n(): I18nContextValue {
|
||||||
|
const ctx = useContext(I18nContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useI18n muss innerhalb von <I18nProvider> aufgerufen werden");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Öffentliche Schnittstelle des Pakets @nexarch/shl — Modul-Frontends importieren ausschließlich hierüber,
|
||||||
|
// nicht aus internen Unterpfaden (SHL-01 Akzeptanzkriterium 2: dokumentiert, versioniert, importierbar statt kopiert).
|
||||||
|
|
||||||
|
export { colorTokens, spacing, breakpoints, typography, cssVariables } from "./tokens/tokens";
|
||||||
|
export type { ColorScheme, ColorTokens } from "./tokens/tokens";
|
||||||
|
|
||||||
|
export { ThemeProvider, useTheme, currentColors } from "./theme/ThemeProvider";
|
||||||
|
|
||||||
|
export { I18nProvider, useI18n, registerMessages } from "./i18n/i18n";
|
||||||
|
export type { Locale } from "./i18n/i18n";
|
||||||
|
|
||||||
|
export { Shell } from "./components/Shell";
|
||||||
|
export type { ShellProps, ModuleLink } from "./components/Shell";
|
||||||
|
|
||||||
|
export { Dialog } from "./components/Dialog";
|
||||||
|
export type { DialogProps } from "./components/Dialog";
|
||||||
|
|
||||||
|
export { Table } from "./components/Table";
|
||||||
|
export type { TableProps, TableColumn } from "./components/Table";
|
||||||
|
|
||||||
|
export { TextField, SelectField, CheckboxField } from "./components/FormElements";
|
||||||
|
export type { TextFieldProps, SelectFieldProps, CheckboxFieldProps } from "./components/FormElements";
|
||||||
|
|
||||||
|
export { ToastProvider, useToast } from "./components/Toast";
|
||||||
|
export type { ToastMessage, ToastVariant } from "./components/Toast";
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "@nexarch/shl",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "NEXARCH UI-Shell & Design-System (Core SHL-01) — gemeinsames Paket für alle Modul-Frontends.",
|
||||||
|
"main": "index.ts",
|
||||||
|
"types": "index.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.4.8",
|
||||||
|
"@testing-library/react": "^16.0.0",
|
||||||
|
"@types/react": "18.3.3",
|
||||||
|
"@types/react-dom": "18.3.0",
|
||||||
|
"jsdom": "^24.1.1",
|
||||||
|
"typescript": "5.5.3",
|
||||||
|
"vitest": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Zentrales Theming (Hell/Dunkel) — SHL-01 Akzeptanzkriterium 6.
|
||||||
|
// Einzige Quelle für Hell/Dunkel-Werte; Modul-Frontends schalten nur um, bauen kein eigenes Theming.
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { colorTokens, cssVariables, type ColorScheme } from "../tokens/tokens";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "nexarch-shl-theme";
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
scheme: ColorScheme;
|
||||||
|
setScheme: (scheme: ColorScheme) => void;
|
||||||
|
toggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
function readStoredScheme(): ColorScheme | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored === "light" || stored === "dark" ? stored : null;
|
||||||
|
} catch {
|
||||||
|
// localStorage kann in privaten Fenstern/eingeschränkten Kontexten fehlschlagen — kein Absturz, nur kein persistierter Zustand.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemPrefersDark(): boolean {
|
||||||
|
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||||
|
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [scheme, setSchemeState] = useState<ColorScheme>("light");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = readStoredScheme();
|
||||||
|
setSchemeState(stored ?? (systemPrefersDark() ? "dark" : "light"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setScheme = useCallback((next: ColorScheme) => {
|
||||||
|
setSchemeState(next);
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, next);
|
||||||
|
} catch {
|
||||||
|
// Speichern optional — Umschaltung funktioniert auch ohne Persistenz.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setScheme(scheme === "light" ? "dark" : "light");
|
||||||
|
}, [scheme, setScheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const vars = cssVariables(scheme);
|
||||||
|
const root = document.documentElement;
|
||||||
|
for (const [key, value] of Object.entries(vars)) {
|
||||||
|
root.style.setProperty(key, value);
|
||||||
|
}
|
||||||
|
root.dataset.shlTheme = scheme;
|
||||||
|
}, [scheme]);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ scheme, setScheme, toggle }), [scheme, setScheme, toggle]);
|
||||||
|
|
||||||
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme(): ThemeContextValue {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useTheme muss innerhalb von <ThemeProvider> aufgerufen werden");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentColors(scheme: ColorScheme) {
|
||||||
|
return colorTokens[scheme];
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Design-Tokens: einzige Quelle für Farbe, Abstand, Typografie im gesamten Frontend-Verbund.
|
||||||
|
// Modul-Frontends importieren diese Tokens, überschreiben sie nicht lokal (SHL-01 Akzeptanzkriterium 3).
|
||||||
|
// Kontrastwerte sind gegen WCAG 2.1 AA geprüft (Akzeptanzkriterium 1/4): mindestens 4.5:1 für Fließtext.
|
||||||
|
|
||||||
|
export type ColorScheme = "light" | "dark";
|
||||||
|
|
||||||
|
export interface ColorTokens {
|
||||||
|
background: string;
|
||||||
|
surface: string;
|
||||||
|
surfaceRaised: string;
|
||||||
|
border: string;
|
||||||
|
textPrimary: string;
|
||||||
|
textSecondary: string;
|
||||||
|
accent: string;
|
||||||
|
accentContrast: string;
|
||||||
|
danger: string;
|
||||||
|
dangerContrast: string;
|
||||||
|
success: string;
|
||||||
|
warning: string;
|
||||||
|
focusRing: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kontrastwerte geprüft: textPrimary auf background/surface >= 7:1, textSecondary >= 4.5:1,
|
||||||
|
// accentContrast auf accent >= 4.5:1 (WCAG AA, siehe SHL-01 Prüfung 3).
|
||||||
|
export const colorTokens: Record<ColorScheme, ColorTokens> = {
|
||||||
|
light: {
|
||||||
|
background: "#FFFFFF",
|
||||||
|
surface: "#F5F6F8",
|
||||||
|
surfaceRaised: "#FFFFFF",
|
||||||
|
border: "#D7DBE0",
|
||||||
|
textPrimary: "#14181F",
|
||||||
|
textSecondary: "#4B5563",
|
||||||
|
accent: "#1D4ED8",
|
||||||
|
accentContrast: "#FFFFFF",
|
||||||
|
danger: "#B91C1C",
|
||||||
|
dangerContrast: "#FFFFFF",
|
||||||
|
success: "#15803D",
|
||||||
|
warning: "#B45309",
|
||||||
|
focusRing: "#1D4ED8",
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
background: "#0F1115",
|
||||||
|
surface: "#181B21",
|
||||||
|
surfaceRaised: "#20242C",
|
||||||
|
border: "#333944",
|
||||||
|
textPrimary: "#F2F4F7",
|
||||||
|
textSecondary: "#B4BAC4",
|
||||||
|
accent: "#5B8DEF",
|
||||||
|
accentContrast: "#0F1115",
|
||||||
|
danger: "#F87171",
|
||||||
|
dangerContrast: "#0F1115",
|
||||||
|
success: "#4ADE80",
|
||||||
|
warning: "#FBBF24",
|
||||||
|
focusRing: "#5B8DEF",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const spacing = {
|
||||||
|
xs: "4px",
|
||||||
|
sm: "8px",
|
||||||
|
md: "16px",
|
||||||
|
lg: "24px",
|
||||||
|
xl: "32px",
|
||||||
|
xxl: "48px",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const breakpoints = {
|
||||||
|
mobile: "0px",
|
||||||
|
tablet: "768px",
|
||||||
|
desktop: "1200px",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const typography = {
|
||||||
|
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
|
||||||
|
fontFamilyMono: "'JetBrains Mono', ui-monospace, monospace",
|
||||||
|
sizeSm: "13px",
|
||||||
|
sizeMd: "15px",
|
||||||
|
sizeLg: "18px",
|
||||||
|
sizeXl: "24px",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
weightRegular: 400,
|
||||||
|
weightMedium: 500,
|
||||||
|
weightBold: 600,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function cssVariables(scheme: ColorScheme): Record<string, string> {
|
||||||
|
const c = colorTokens[scheme];
|
||||||
|
const vars: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(c)) {
|
||||||
|
vars[`--shl-color-${key.replace(/([A-Z])/g, "-$1").toLowerCase()}`] = value;
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(spacing)) {
|
||||||
|
vars[`--shl-spacing-${key}`] = value;
|
||||||
|
}
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"declaration": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { afterEach } from "vitest";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
// Ohne explizites Cleanup bleiben zwischen den it()-Blöcken gerenderte Dialoge im DOM stehen
|
||||||
|
// (mehrere <html>/<body>-Bäume stapeln sich), wodurch getByRole() mehrere Treffer statt einen findet.
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user