Compare commits

..
Author SHA1 Message Date
sysopsandClaude Sonnet 5 e4856dfc9d IAM-06: sso-anmeldung-ueber-oidc
internal/oidc: JWKS-Parser (RSA-Schluessel, RFC 7517) + Verifier prueft
ID-Tokens gegen den geparsten Schluesselsatz (Signatur, Ablauf ueber die
jwt-Bibliothek, Aussteller) — Akzeptanzkriterium 3. VerifyWithNonce prueft
zusaetzlich, dass der nonce-Claim exakt dem beim Redirect ausgestellten
Nonce entspricht (Replay-Schutz).

StateStore ist der CSRF-/Replay-Schutz (Akzeptanzkriterium/Pruefung 3):
Generate stellt state+nonce aus, Consume loest den state ATOMAR und EINMALIG
ein (WHERE used_at IS NULL, analog IAM-03/IAM-09-Muster) — ein abgefangener
und wiederverwendeter Redirect-Callback schlaegt fehl.

CompleteOIDCLogin mappt Rollen aus OIDC-Rollen-Claims ueber DIESELBE
Erlaubnis-Matrix wie IAM-05/LDAP (ldapsync.RoleMappingStore.HighestRoleFor,
keine zweite parallele Implementierung — Akzeptanzkriterium 2) und stellt
bei Erfolg ein normales IAM-02-Sitzungs-Token aus. Lokaler Login (IAM-02
LoginService) bleibt vollstaendig unangetastet und damit als Fallback nutzbar.

WICHTIGER HINWEIS: kein registrierter externer OIDC-Provider (Google/Okta/
Azure AD) in dieser Umgebung verfuegbar fuer einen echten Authorization-
Code-Redirect (analog IAM-05/AUD-05). ANDERS als dort ist die eigentliche
Token-Validierung aber rein kryptographisch und ohne Netzwerkabhaengigkeit
zur Testzeit vollstaendig echt geprueft: Tests erzeugen ein eigenes
RSA-Schluesselpaar, signieren ID-Tokens selbst und verifizieren sie exakt
wie bei einem echten Provider. Nur der Live-Redirect zu einem realen
externen IdP bleibt ungeprueft.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Rollen-Erlaubnis-Matrix fuer OIDC-Claims automatisiert getestet (keine
   Privilege-Escalation) — TestCompleteOIDCLogin_UnmappedRoleGrantsNothing:
   Claims mit "tenant_admin"/"superadmin" als Rollen-Strings, die NIE gemappt
   wurden, vergeben keine Rolle. PASS.
2. Token-Signatur- und Ablaufpruefung gegen JWKS automatisiert getestet —
   TestVerify_RejectsExpiredToken, TestVerify_RejectsWrongSigningKey,
   TestVerify_RejectsWrongIssuer, TestParseJWKS_RoundTrip. PASS.
3. State/Nonce-Handling gegen CSRF und Replay geprueft —
   TestStateStore_ConsumeIsSingleUse (State-Replay abgewiesen),
   TestVerifyWithNonce_RejectsMismatch (Nonce-Mismatch abgewiesen). PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:53:42 +02:00
sysopsandClaude Sonnet 5 1a82da211d IAM-05: ldap-active-directory-anbindung
internal/ldapsync: Client ist eine Schnittstelle (Search() liefert Entry-
Liste) — in dieser Umgebung steht kein echter LDAP/AD-Server zur Verfuegung
(analog AUD-05/Archive), daher ist die Sync-/Rollenzuordnungs-Logik
vollstaendig gegen einen Fake getestet, eine echte Verbindungspruefung gegen
LDAP/AD steht noch aus.

RoleMappingStore (Akzeptanzkriterium 3): explizite Erlaubnis-Matrix
LDAP-Gruppe -> Rolle. HighestRoleFor vergibt NUR fuer explizit gemappte
Gruppen eine Rolle — eine unbekannte Gruppe, auch wenn ihr Name zufaellig
wie eine interne Rolle aussieht ("tenant_admin", "superadmin"), traegt
strukturell nichts bei (kein Code-Pfad, der eine ungemappte Gruppe je einer
Rolle zuordnet) — behebt die aus archivmail bekannte Privilege-Escalation-
Fehlerklasse von Grund auf statt nachtraeglich zu haerten.

Syncer.Run ruft Search() als ALLERERSTES auf; schlaegt es fehl, wird ohne
jede Aenderung an bestehenden Konten abgebrochen (Akzeptanzkriterium 2).
Pro Eintrag isolierte Fehler landen in SyncResult.Failed, ohne andere
Eintraege zu beeintraechtigen. Deaktivierung in LDAP wird als
users.Deactivate uebernommen (Akzeptanzkriterium 3). ldap_config speichert
bewusst nur den NAMEN einer Umgebungsvariable fuer das Bind-Passwort, nie
das Passwort selbst.

internal/rbac (RBAC-01) wurde 1:1 aus dem rbac-01-Branch uebernommen (git
show aus derselben Repo-Historie) — IAM-05 haengt an RBAC-01 fuer die
Rollenzuweisung, beide Boards leben aber auf getrennten, noch nicht
gemergten Feature-Branches ohne gemeinsame Historie.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Rollen-Erlaubnis-Matrix mit Positiv- und Negativfaellen automatisiert
   getestet (keine Privilege-Escalation) — TestRoleMapping_PositiveAndNegativeCases:
   gemappte Gruppe liefert Rolle, unbekannte Gruppe und rollen-aehnlich
   benannte, aber nie gemappte Gruppen liefern keine. PASS.
2. Synchronisationslauf mit fehlerhafter/nicht erreichbarer LDAP-Quelle
   bricht kontrolliert ab, ohne bestehende Konten zu beschaedigen —
   TestSyncer_AbortsCleanlyOnSourceError: bestehendes Konto bleibt nach
   fehlgeschlagenem Lauf unveraendert aktiv. PASS.
3. Deaktivierung eines Benutzers in LDAP wird bei naechster Synchronisation
   korrekt uebernommen — TestSyncer_AppliesDeactivationOnNextRun: erster
   Lauf legt aktiven Benutzer an, zweiter Lauf mit Disabled=true setzt ihn
   auf inaktiv. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 22:39:10 +02:00
sysopsandClaude Sonnet 5 3d20d86a4f IAM-02: login-session-jwt-grundgeruest
internal/auth: Login/Logout ueber httpOnly/Secure/SameSite=Strict-Cookie mit
HS256-JWT (30min TTL), bcrypt-Passwort-Hashing (Cost 12, explizit begruendet
und benchmarkt statt DefaultCost uebernommen), RequireAuth-Middleware fuer
geschuetzte Routen. LoginService ist strukturell auf einen Tenant gescopt
(nutzt user.TenantUserStore, dessen Pool = eine Tenant-DB — derselbe
Mechanismus wie in TEN-01/TEN-02), liefert bei falscher E-Mail und falschem
Passwort denselben Fehler (User-Enumeration-Schutz) inkl. Dummy-bcrypt-
Vergleich gegen Timing-Seitenkanal bei unbekannter E-Mail.

user.TenantUserStore erweitert um SetPasswordHash/GetByEmailForAuth
(password_hash bleibt ausserhalb des regulaeren User-Typs/JSON-Pfads).
Migration 0002 fuegt password_hash-Spalte hinzu (Default '', da IAM-01
User ohne Passwort anlegt).

Login-Handler ist wie IAM-01/TEN-02 aus denselben Gruenden (Tenant-
Connection-Routing = TEN-06, noch nicht gebaut) nicht in cmd/core/main.go
verdrahtet — Package ist eigenstaendig nutzbar/getestet.

Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS):
1. Login-Query tenant-gescopt — TestLoginService_NoCrossTenantLogin: gleiche
   E-Mail in zwei Tenant-DBs mit unterschiedlichem Passwort, Login gegen
   Tenant A mit Tenant-B-Passwort schlaegt fehl. PASS.
2. Session-Fixation/Token-Manipulation — TestTokenVerify_RejectsManipulatedPayload
   und TestTokenVerify_RejectsWrongSecret: manipuliertes/falsch signiertes
   Token wird abgelehnt. PASS.
3. Abgelaufenes Token erzwingt Neuanmeldung — TestTokenVerify_RejectsExpiredToken
   und TestRequireAuth_BlocksWithoutValidCookie. PASS.
4. Login-Latenz mit Kostenfaktor 12 gemessen: 294ms (Ziel < 400ms) —
   TestBcryptCostAgainstLatencyTarget. PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 18:26:00 +02:00
79 changed files with 2021 additions and 3233 deletions
-2
View File
@@ -1,4 +1,2 @@
*.log
.env
web/*/node_modules/
web/*/.next/
-53
View File
@@ -64,9 +64,6 @@ Keine Änderungen ermittelbar.
## 2026-08-27 17:28 17:29 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** code
## 2026-08-28 21:44 21:44 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
Keine Commits in dieser Session.
@@ -132,53 +129,3 @@ Keine Commits in dieser Session.
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:53 17:57 (4m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
- e479330 IAM-01: benutzer-datenmodell-crud
### Geänderte Dateien
- cmd/core/main.go | 11 ++++++++++-
- go.mod | 9 +++++++++
- go.sum | 28 ++++++++++++++++++++++++++++
- internal/user/handler.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/store_test.go | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/superadmin_store.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/tenant_store.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/user/user.go | 42 ++++++++++++++++++++++++++++++++++++++++++
- internal/user/user_test.go | 24 ++++++++++++++++++++++++
- migrations/0002_superadmins.down.sql | 1 +
- migrations/0002_superadmins.up.sql | 14 ++++++++++++++
- migrations/tenant/0001_users.down.sql | 1 +
- migrations/tenant/0001_users.up.sql | 16 ++++++++++++++++
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
- web/shl/components/Dialog.tsx | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/FormElements.tsx | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Shell.tsx | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Table.tsx | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/components/Toast.tsx | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/i18n/i18n.tsx | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/index.ts | 25 +++++++++++++++++++++++++
- web/shl/package.json | 23 +++++++++++++++++++++++
- web/shl/theme/ThemeProvider.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/tokens/tokens.ts | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- web/shl/tsconfig.json | 18 ++++++++++++++++++
---
## 2026-08-28 21:51 21:57 (5m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
- 3c226da SHL-01: fix — vitest jsdom-environment + jest-dom-Setup (3 Dialog-Tests schlugen ohne DOM fehl)
### Geänderte Dateien
- web/shl/package.json | 2 ++
- web/shl/vitest.config.ts | 8 ++++++++
- web/shl/vitest.setup.ts | 1 +
---
-3
View File
@@ -34,8 +34,6 @@ func main() {
registry := tenant.NewRegistry(registryPool)
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
tenantHandler := tenant.NewHandler(provisioner)
onboardingService := tenant.NewOnboardingService(provisioner, cfg.TenantSchemaPath)
onboardingHandler := tenant.NewOnboardingHandler(onboardingService)
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
@@ -50,7 +48,6 @@ func main() {
})
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
mux.HandleFunc("/internal/tenants/onboard", onboardingHandler.Onboard)
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
-67
View File
@@ -1,67 +0,0 @@
// tenantadmin-devserver stellt das TEN-05-Backend-API (internal/tenantadmin)
// fuer die Next.js-Tenant-Verwaltungsoberflaeche bereit. Getrennt von
// cmd/core aus demselben Grund wie cmd/licadmin-devserver (siehe LIC-04):
// echte Auth (IAM-01/IAM-02) ist noch nicht in die zentrale Server-Topologie
// verdrahtet, dieser Server dient Entwicklung/Betrieb der Oberflaeche gegen
// eine echte Datenbank, ohne cmd/core anzufassen.
package main
import (
"context"
"log"
"net/http"
"os"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/tenantadmin"
"gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func main() {
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
if dsn == "" {
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
}
addr := os.Getenv("NEXARCH_TENANTADMIN_LISTEN_ADDR")
if addr == "" {
addr = ":8082"
}
ctx := context.Background()
pool, err := db.Connect(ctx, dsn)
if err != nil {
log.Fatalf("db: %v", err)
}
defer pool.Close()
registry := tenant.NewRegistry(pool)
lifecycle := tenant.NewLifecycle(registry, pool)
settingsStore := tenantsettings.NewStore(pool)
superadmins := user.NewSuperadminStore(pool)
handler := tenantadmin.NewHandler(registry, lifecycle, settingsStore, superadmins)
mux := http.NewServeMux()
mux.HandleFunc("/admin/tenants", withCORS(handler.ListTenantsHandler))
mux.HandleFunc("/admin/tenants/detail", withCORS(handler.TenantDetailHandler))
mux.HandleFunc("/admin/tenants/settings", withCORS(handler.UpdateSettingsHandler))
mux.HandleFunc("/admin/tenants/lifecycle", withCORS(handler.LifecycleActionHandler))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
log.Printf("tenantadmin-devserver listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
func withCORS(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next(w, r)
}
}
+5 -2
View File
@@ -2,13 +2,16 @@ module gitea.perlbach24.de/scripte/nexarch
go 1.22
require github.com/jackc/pgx/v5 v5.6.0
require (
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.6.0
golang.org/x/crypto v0.17.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // 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/text v0.14.0 // indirect
)
+2
View File
@@ -1,6 +1,8 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+67
View File
@@ -0,0 +1,67 @@
package auth
import (
"encoding/json"
"net/http"
"time"
)
// Handler stellt Login/Logout als HTTP-Endpunkte bereit. Registrierung,
// Passwort-Reset, 2FA, SSO/LDAP und Rate-Limiting sind ausdruecklich nicht
// Teil dieser Kachel (siehe IAM-03..07).
type Handler struct {
login *LoginService
}
func NewHandler(login *LoginService) *Handler {
return &Handler{login: login}
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
token, err := h.login.Login(r.Context(), req.Email, req.Password)
if err != nil {
http.Error(w, ErrInvalidCredentials.Error(), http.StatusUnauthorized)
return
}
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(AccessTokenTTL.Seconds()),
})
w.WriteHeader(http.StatusOK)
}
// Logout loescht das Session-Cookie. Da JWT hier bewusst zustandslos bleibt
// (kein serverseitiger Blocklist-Speicher — das waere ueber "Grundgerüst"
// hinaus und widerspraeche der projektweiten zustandslosen-JWT-Entscheidung),
// bleibt ein bereits ausgestelltes Token bis zu seinem Ablauf technisch
// gueltig, wenn es separat vom Cookie extrahiert und wiederverwendet wird.
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
w.WriteHeader(http.StatusOK)
}
+56
View File
@@ -0,0 +1,56 @@
package auth
import (
"context"
"errors"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
var ErrInvalidCredentials = errors.New("auth: E-Mail oder Passwort falsch")
// LoginService arbeitet gegen GENAU EINE Tenant-Datenbank (uebergeben ueber
// den TenantUserStore-Pool) — das Login ist damit strukturell auf den
// richtigen Tenant gescopt, siehe user.TenantUserStore.GetByEmailForAuth.
type LoginService struct {
users *user.TenantUserStore
issuer *TokenIssuer
// tenantSlug identifiziert im ausgestellten Token, gegen welchen Mandanten
// eingeloggt wurde (fuer nachgelagerte Pruefungen, z.B. Middleware-Logs).
tenantSlug string
}
func NewLoginService(users *user.TenantUserStore, issuer *TokenIssuer, tenantSlug string) *LoginService {
return &LoginService{users: users, issuer: issuer, tenantSlug: tenantSlug}
}
// Login liefert bei falscher E-Mail UND bei falschem Passwort denselben
// Fehler (ErrInvalidCredentials), um keine Rueckschluesse auf die Existenz
// eines Kontos zuzulassen (User-Enumeration-Schutz).
func (s *LoginService) Login(ctx context.Context, email, password string) (string, error) {
creds, err := s.users.GetByEmailForAuth(ctx, email)
if err != nil {
// Trotzdem einen bcrypt-Vergleich gegen einen Dummy-Hash ausfuehren,
// damit die Antwortzeit bei unbekannter E-Mail nicht messbar kuerzer
// ist als bei falschem Passwort (Timing-Seitenkanal).
VerifyPassword(dummyHash, password)
return "", ErrInvalidCredentials
}
if creds.User.Status != user.StatusActive {
return "", ErrInvalidCredentials
}
if !VerifyPassword(creds.PasswordHash, password) {
return "", ErrInvalidCredentials
}
return s.issuer.Issue(creds.User.ID, s.tenantSlug)
}
// dummyHash ist ein echter bcrypt-Hash (Kostenfaktor BcryptCost) eines
// beliebigen Platzhalter-Klartexts — bewusst KEIN kaputtes Format, da
// bcrypt.CompareHashAndPassword bei ungueltigem Hash sofort ohne den
// eigentlichen Kostenfaktor-Vergleich zurueckkehrt und die
// Timing-Angleichung damit wirkungslos waere.
const dummyHash = "$2a$12$cmwiETrG9DK5/uTM2fg4uetngYUspKjME5P8fNpk0QYTaO64N0r3C"
+177
View File
@@ -0,0 +1,177 @@
package auth
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
const usersSchema = `
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);`
func setupTenantDB(t *testing.T, dbName string) *pgxpool.Pool {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
t.Fatalf("testdatenbank anlegen: %v", err)
}
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("connect testdatenbank: %v", err)
}
if _, err := pool.Exec(ctx, usersSchema); err != nil {
t.Fatalf("schema anwenden: %v", err)
}
t.Cleanup(func() {
pool.Close()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
adminPool.Close()
})
return pool
}
func createUserWithPassword(t *testing.T, store *user.TenantUserStore, email, password string) user.User {
t.Helper()
ctx := context.Background()
u, err := store.Create(ctx, email, "Test User")
if err != nil {
t.Fatalf("create user: %v", err)
}
hash, err := HashPassword(password)
if err != nil {
t.Fatalf("hash password: %v", err)
}
if err := store.SetPasswordHash(ctx, u.ID, hash); err != nil {
t.Fatalf("set password: %v", err)
}
return u
}
func TestLoginService_SuccessAndWrongPassword(t *testing.T) {
pool := setupTenantDB(t, "test_iam02_login")
store := user.NewTenantUserStore(pool)
createUserWithPassword(t, store, "alice@example.com", "korrektes-passwort")
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
login := NewLoginService(store, issuer, "acme")
token, err := login.Login(context.Background(), "alice@example.com", "korrektes-passwort")
if err != nil {
t.Fatalf("login: %v", err)
}
if token == "" {
t.Fatal("erwartet nicht-leeres token")
}
if _, err := login.Login(context.Background(), "alice@example.com", "falsches-passwort"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials, habe %v", err)
}
if _, err := login.Login(context.Background(), "unbekannt@example.com", "irgendwas"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials bei unbekannter email, habe %v", err)
}
}
// Pruefung 1: kein Cross-Tenant-Login moeglich, obwohl dieselbe E-Mail in
// zwei unterschiedlichen Tenant-Datenbanken mit unterschiedlichen Passwoertern
// existiert.
func TestLoginService_NoCrossTenantLogin(t *testing.T) {
poolA := setupTenantDB(t, "test_iam02_tenant_a")
poolB := setupTenantDB(t, "test_iam02_tenant_b")
storeA := user.NewTenantUserStore(poolA)
storeB := user.NewTenantUserStore(poolB)
createUserWithPassword(t, storeA, "shared@example.com", "passwort-tenant-a")
createUserWithPassword(t, storeB, "shared@example.com", "passwort-tenant-b")
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
loginA := NewLoginService(storeA, issuer, "tenant-a")
// Login gegen Tenant A mit dem Passwort von Tenant B darf nicht klappen,
// obwohl die E-Mail-Adresse identisch ist — die Store-Instanz kennt
// strukturell nur die Zeilen ihrer eigenen Datenbank.
if _, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-b"); !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("erwartet ErrInvalidCredentials fuer fremdes tenant-passwort, habe %v", err)
}
token, err := loginA.Login(context.Background(), "shared@example.com", "passwort-tenant-a")
if err != nil {
t.Fatalf("login gegen eigenen tenant sollte klappen: %v", err)
}
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.TenantSlug != "tenant-a" {
t.Fatalf("token tenant = %q, want tenant-a", claims.TenantSlug)
}
}
// Akzeptanzkriterium 3: geschuetzte Route ohne gueltige Session nicht erreichbar.
func TestRequireAuth_BlocksWithoutValidCookie(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
protected := RequireAuth(issuer, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Kein Cookie.
req := httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
rec := httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ohne cookie: status = %d, want 401", rec.Code)
}
// Manipuliertes Cookie.
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: "kaputt.token.hier"})
rec = httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("mit kaputtem cookie: status = %d, want 401", rec.Code)
}
// Gueltiges Token.
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
req = httptest.NewRequest(http.MethodGet, "/geschuetzt", nil)
req.AddCookie(&http.Cookie{Name: CookieName, Value: token})
rec = httptest.NewRecorder()
protected(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("mit gueltigem cookie: status = %d, want 200", rec.Code)
}
}
+41
View File
@@ -0,0 +1,41 @@
package auth
import (
"context"
"net/http"
)
const CookieName = "nexarch_session"
type contextKey int
const claimsContextKey contextKey = iota
// RequireAuth schuetzt eine Route: ohne gueltiges, nicht abgelaufenes Token
// im Session-Cookie wird 401 zurueckgegeben und der Handler nicht aufgerufen
// (IAM-02 Akzeptanzkriterium 3).
func RequireAuth(issuer *TokenIssuer, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(CookieName)
if err != nil {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
claims, err := issuer.Verify(cookie.Value)
if err != nil {
http.Error(w, "nicht angemeldet", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsContextKey, claims)
next(w, r.WithContext(ctx))
}
}
// ClaimsFromContext liest die Claims, die RequireAuth in den Request-Context
// gelegt hat.
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
c, ok := ctx.Value(claimsContextKey).(*Claims)
return c, ok
}
+28
View File
@@ -0,0 +1,28 @@
// Package auth implementiert Core IAM-02: Login/Logout, Passwort-Hashing und
// die Middleware zum Schutz von Routen. Autorisierung (was ein Benutzer darf)
// ist ausdruecklich NICHT Teil dieses Pakets, siehe RBAC-01 — auth prueft nur
// "wer bin ich" (Casbin-Architekturprinzip, siehe IAM-02-Ticket).
package auth
import "golang.org/x/crypto/bcrypt"
// BcryptCost ist bewusst explizit festgelegt statt bcrypt.DefaultCost (10)
// unreflektiert zu uebernehmen (IAM-02 Akzeptanzkriterium 4). Kostenfaktor 12
// wurde gegen die Ziel-Login-Latenz benchmarkt, siehe password_bench_test.go
// und den Pruefungs-Eintrag in der Commit-Nachricht.
const BcryptCost = 12
func HashPassword(plain string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(plain), BcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
// VerifyPassword ist timing-safe: bcrypt.CompareHashAndPassword vergleicht
// konstant in der Zeit bzgl. des Hash-Inhalts (Referenzimplementierung fuer
// die projektweite Timing-safe-Vergleich-Konvention aus IAM-02).
func VerifyPassword(hash, plain string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
}
+42
View File
@@ -0,0 +1,42 @@
package auth
import (
"testing"
"time"
)
// TargetLoginLatency ist der Zielwert aus IAM-02 Akzeptanzkriterium 4: der
// bcrypt-Vergleich allein darf die Login-Latenz nicht dominieren. 400ms ist
// grosszuegig genug, um auf unterschiedlicher Hardware stabil zu sein, aber
// eng genug, um eine versehentliche Kostenfaktor-Explosion (z.B. 16 statt 12)
// zuverlaessig aufzudecken.
const TargetLoginLatency = 400 * time.Millisecond
// TestBcryptCostAgainstLatencyTarget misst die tatsaechliche Dauer eines
// Passwort-Vergleichs mit dem festgelegten BcryptCost und dokumentiert das
// Ergebnis (IAM-02 Pruefung 4).
func TestBcryptCostAgainstLatencyTarget(t *testing.T) {
hash, err := HashPassword("benchmark-passwort")
if err != nil {
t.Fatalf("hash: %v", err)
}
start := time.Now()
if !VerifyPassword(hash, "benchmark-passwort") {
t.Fatal("verifikation haette erfolgreich sein muessen")
}
elapsed := time.Since(start)
t.Logf("bcrypt-vergleich mit cost=%d dauerte %s (ziel: unter %s)", BcryptCost, elapsed, TargetLoginLatency)
if elapsed > TargetLoginLatency {
t.Fatalf("bcrypt-vergleich zu langsam: %s > ziel %s", elapsed, TargetLoginLatency)
}
}
func BenchmarkVerifyPassword(b *testing.B) {
hash, _ := HashPassword("benchmark-passwort")
b.ResetTimer()
for i := 0; i < b.N; i++ {
VerifyPassword(hash, "benchmark-passwort")
}
}
+28
View File
@@ -0,0 +1,28 @@
package auth
import "testing"
func TestHashAndVerifyPassword(t *testing.T) {
hash, err := HashPassword("s3hr-geheim!")
if err != nil {
t.Fatalf("hash: %v", err)
}
if hash == "s3hr-geheim!" {
t.Fatal("passwort wurde nicht gehasht")
}
if !VerifyPassword(hash, "s3hr-geheim!") {
t.Fatal("erwartet erfolgreiche verifikation")
}
if VerifyPassword(hash, "falsches-passwort") {
t.Fatal("erwartet fehlgeschlagene verifikation")
}
}
func TestDummyHashIsValidBcryptHash(t *testing.T) {
// Stellt sicher, dass der Timing-Angleichs-Hash in login.go tatsaechlich
// ein gueltiges bcrypt-Format hat und den vollen Kostenfaktor durchlaeuft
// (siehe Kommentar dort) statt sofort mit einem Format-Fehler abzubrechen.
if VerifyPassword(dummyHash, "irgendein-text") {
t.Fatal("dummyHash sollte fuer beliebigen text nicht passen")
}
}
+63
View File
@@ -0,0 +1,63 @@
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
// AccessTokenTTL ist bewusst kurz gehalten (Session-Ablauf statt langlebiger
// Tokens), passend zur "so vertrauenswuerdig wie noetig"-Produkt-DNA.
const AccessTokenTTL = 30 * time.Minute
var ErrInvalidToken = errors.New("auth: ungueltiges oder abgelaufenes token")
type Claims struct {
UserID string `json:"uid"`
TenantSlug string `json:"tenant"`
jwt.RegisteredClaims
}
// TokenIssuer signiert/verifiziert JWTs mit einem HMAC-Secret. Das
// asymmetrische Core-weite Signaturschema (API-05, kid-Rotation) ist
// ausdruecklich nicht Teil dieser Kachel — hier geht es nur um das
// Login-Grundgerüst innerhalb eines einzelnen Core-Prozesses.
type TokenIssuer struct {
secret []byte
}
func NewTokenIssuer(secret string) *TokenIssuer {
return &TokenIssuer{secret: []byte(secret)}
}
func (i *TokenIssuer) Issue(userID, tenantSlug string) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
TenantSlug: tenantSlug,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(AccessTokenTTL)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(i.secret)
}
// Verify prueft Signatur UND Ablauf (jwt.ParseWithClaims lehnt abgelaufene
// Tokens automatisch ab) — der Signaturvergleich in golang-jwt ist
// timing-safe (hmac.Equal).
func (i *TokenIssuer) Verify(tokenString string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, ErrInvalidToken
}
return i.secret, nil
})
if err != nil || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
+82
View File
@@ -0,0 +1,82 @@
package auth
import (
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestTokenIssueAndVerify(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
claims, err := issuer.Verify(token)
if err != nil {
t.Fatalf("verify: %v", err)
}
if claims.UserID != "user-1" || claims.TenantSlug != "acme" {
t.Fatalf("claims unerwartet: %+v", claims)
}
}
// Pruefung 2: Token-Manipulationstest.
func TestTokenVerify_RejectsManipulatedPayload(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("unerwartetes token-format: %d teile", len(parts))
}
// Payload-Segment leicht veraendern (Signatur passt danach nicht mehr).
tampered := parts[0] + "." + parts[1] + "x" + "." + parts[2]
if _, err := issuer.Verify(tampered); err == nil {
t.Fatal("erwartet fehler bei manipuliertem token, habe nil")
}
}
func TestTokenVerify_RejectsWrongSecret(t *testing.T) {
issuer := NewTokenIssuer("secret-a")
other := NewTokenIssuer("secret-b")
token, err := issuer.Issue("user-1", "acme")
if err != nil {
t.Fatalf("issue: %v", err)
}
if _, err := other.Verify(token); err == nil {
t.Fatal("erwartet fehler bei falschem secret, habe nil")
}
}
// Pruefung 3: abgelaufenes Token erzwingt Neuanmeldung.
func TestTokenVerify_RejectsExpiredToken(t *testing.T) {
issuer := NewTokenIssuer("test-secret-nur-fuer-tests")
claims := Claims{
UserID: "user-1",
TenantSlug: "acme",
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * AccessTokenTTL)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Minute)),
},
}
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
expired, err := tok.SignedString([]byte("test-secret-nur-fuer-tests"))
if err != nil {
t.Fatalf("signieren: %v", err)
}
if _, err := issuer.Verify(expired); err == nil {
t.Fatal("erwartet fehler bei abgelaufenem token, habe nil")
}
}
-9
View File
@@ -19,9 +19,6 @@ type Config struct {
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
TenantDSNTemplate string
// TenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der
// TEN-02 eine frisch provisionierte Tenant-Datenbank initialisiert.
TenantSchemaPath string
}
func Load() (Config, error) {
@@ -40,11 +37,6 @@ func Load() (Config, error) {
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
}
schemaPath := os.Getenv("NEXARCH_TENANT_SCHEMA_PATH")
if schemaPath == "" {
return Config{}, fmt.Errorf("NEXARCH_TENANT_SCHEMA_PATH not set")
}
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
if addr == "" {
addr = ":8080"
@@ -55,6 +47,5 @@ func Load() (Config, error) {
RegistryDSN: dsn,
AdminDSN: adminDSN,
TenantDSNTemplate: dsnTemplate,
TenantSchemaPath: schemaPath,
}, nil
}
+27
View File
@@ -0,0 +1,27 @@
// Package ldapsync implementiert Core IAM-05: LDAP/Active-Directory-
// Anbindung je Tenant fuer Benutzer-Synchronisation, Login-Delegation und
// Rollenzuordnung aus Gruppen ueber eine explizite Erlaubnis-Matrix.
//
// WICHTIGER HINWEIS: In dieser Umgebung steht kein echter LDAP/AD-Server zur
// Verfuegung (analog zur Archive-Engine bei AUD-05). Client ist deshalb eine
// Schnittstelle — die Sync-/Rollenzuordnungs-Logik ist gegen einen Fake in
// den Tests vollstaendig geprueft, eine echte Verbindungspruefung gegen
// LDAP/AD steht noch aus.
package ldapsync
// Entry ist ein aus LDAP gelesener Benutzerdatensatz.
type Entry struct {
Email string
Name string
Groups []string
Disabled bool
}
// Client abstrahiert die eigentliche LDAP-Verbindung (Bind + Search).
type Client interface {
// Search liefert alle Benutzer, die dem konfigurierten Filter
// entsprechen. Ein Fehler bedeutet "Quelle nicht erreichbar oder
// fehlerhaft" — Run() bricht dann komplett und folgenlos ab
// (Akzeptanzkriterium 2).
Search() ([]Entry, error)
}
+204
View File
@@ -0,0 +1,204 @@
package ldapsync
import (
"context"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
type fakeClient struct {
entries []Entry
err error
}
func (f *fakeClient) Search() ([]Entry, error) {
if f.err != nil {
return nil, f.err
}
return f.entries, nil
}
func setupTest(t *testing.T) (*user.TenantUserStore, *rbac.Store, *RoleMappingStore, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignments (
user_id UUID PRIMARY KEY REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')),
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignment_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, role TEXT NOT NULL,
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ldap_group_role_mapping (
ldap_group TEXT PRIMARY KEY, role TEXT NOT NULL CHECK (role IN ('user','tenant_admin'))
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return user.NewTenantUserStore(pool), rbac.NewStore(pool), NewRoleMappingStore(pool), cleanup
}
func uniqueEmail() string {
return fmt.Sprintf("ldap-test-%d@example.com", time.Now().UnixNano())
}
// Akzeptanzkriterium 3 + Pruefung 1: Rollen-Erlaubnis-Matrix mit Positiv- und
// Negativfaellen — keine Privilege-Escalation ueber Gruppenmitgliedschaft.
func TestRoleMapping_PositiveAndNegativeCases(t *testing.T) {
_, _, mappings, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if err := mappings.SetMapping(ctx, "cn=IT-Admins,ou=groups,dc=example,dc=com", rbac.RoleTenantAdmin); err != nil {
t.Fatalf("set mapping: %v", err)
}
// Positivfall: gemappte Gruppe liefert die konfigurierte Rolle.
role, ok, err := mappings.HighestRoleFor(ctx, []string{"cn=IT-Admins,ou=groups,dc=example,dc=com"})
if err != nil {
t.Fatalf("highest role: %v", err)
}
if !ok || role != rbac.RoleTenantAdmin {
t.Fatalf("erwartet tenant_admin, habe ok=%v role=%q", ok, role)
}
// Negativfall 1: unbekannte Gruppe vergibt keine Rolle.
_, ok, err = mappings.HighestRoleFor(ctx, []string{"cn=Praktikanten,ou=groups,dc=example,dc=com"})
if err != nil {
t.Fatalf("highest role (unbekannt): %v", err)
}
if ok {
t.Fatal("erwartet KEINE rolle fuer nicht gemappte gruppe")
}
// Negativfall 2 (Privilege-Escalation-Versuch): eine Gruppe, die zufaellig
// wie eine interne Rolle heisst, aber NIE explizit gemappt wurde, darf
// keine Rolle vergeben.
_, ok, err = mappings.HighestRoleFor(ctx, []string{"tenant_admin", "superadmin"})
if err != nil {
t.Fatalf("highest role (namens-trick): %v", err)
}
if ok {
t.Fatal("erwartet KEINE privilege-escalation ueber gruppennamen, die wie rollen aussehen")
}
}
// Akzeptanzkriterium 1: Synchronisationslauf legt Benutzer an.
func TestSyncer_CreatesUsers(t *testing.T) {
users, roles, mappings, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
email := uniqueEmail()
client := &fakeClient{entries: []Entry{{Email: email, Name: "LDAP Nutzer"}}}
syncer := NewSyncer(client, users, roles, mappings)
result, err := syncer.Run(ctx)
if err != nil {
t.Fatalf("run: %v", err)
}
if result.Created != 1 {
t.Fatalf("erwartet 1 angelegten benutzer, habe %d", result.Created)
}
if _, err := users.GetByEmailForAuth(ctx, email); err != nil {
t.Fatalf("benutzer sollte existieren: %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 2: fehlerhafte/nicht erreichbare LDAP-
// Quelle bricht kontrolliert ab, ohne bestehende Konten zu beschaedigen.
func TestSyncer_AbortsCleanlyOnSourceError(t *testing.T) {
users, roles, mappings, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
// Ein bereits bestehendes Konto, das UNBERUEHRT bleiben muss.
existingEmail := uniqueEmail()
existing, err := users.Create(ctx, existingEmail, "Bestehender Nutzer")
if err != nil {
t.Fatalf("create existing: %v", err)
}
client := &fakeClient{err: errors.New("ldap-server nicht erreichbar (simuliert)")}
syncer := NewSyncer(client, users, roles, mappings)
if _, err := syncer.Run(ctx); err == nil {
t.Fatal("erwartet fehler bei nicht erreichbarer ldap-quelle")
}
// Bestehendes Konto unveraendert (noch aktiv, keine geloeschten/neuen Zeilen).
got, err := users.Get(ctx, existing.ID)
if err != nil {
t.Fatalf("bestehendes konto sollte weiterhin existieren: %v", err)
}
if got.Status != user.StatusActive {
t.Fatalf("bestehendes konto sollte unveraendert aktiv sein, ist %q", got.Status)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Deaktivierung in LDAP wird bei
// naechster Synchronisation korrekt uebernommen.
func TestSyncer_AppliesDeactivationOnNextRun(t *testing.T) {
users, roles, mappings, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
email := uniqueEmail()
client := &fakeClient{entries: []Entry{{Email: email, Name: "Wird Deaktiviert"}}}
syncer := NewSyncer(client, users, roles, mappings)
if _, err := syncer.Run(ctx); err != nil {
t.Fatalf("erster lauf: %v", err)
}
u, err := users.GetByEmailForAuth(ctx, email)
if err != nil {
t.Fatalf("benutzer nach erstem lauf: %v", err)
}
if u.User.Status != user.StatusActive {
t.Fatalf("erwartet aktiv nach erstem lauf, habe %q", u.User.Status)
}
// Zweiter Lauf: derselbe Benutzer ist jetzt in LDAP deaktiviert.
client.entries[0].Disabled = true
result, err := syncer.Run(ctx)
if err != nil {
t.Fatalf("zweiter lauf: %v", err)
}
if result.Deactivated != 1 {
t.Fatalf("erwartet 1 deaktivierung, habe %d", result.Deactivated)
}
u, err = users.GetByEmailForAuth(ctx, email)
if err != nil {
t.Fatalf("benutzer nach zweitem lauf: %v", err)
}
if u.User.Status != user.StatusInactive {
t.Fatalf("erwartet inaktiv nach deaktivierung in ldap, habe %q", u.User.Status)
}
}
+79
View File
@@ -0,0 +1,79 @@
package ldapsync
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
)
// RoleMappingStore verwaltet die Erlaubnis-Matrix LDAP-Gruppe -> Rolle
// (Akzeptanzkriterium 3). Nur explizit gemappte Gruppen vergeben eine Rolle
// — der Schutz gegen Privilege-Escalation ist strukturell: es gibt keinen
// Code-Pfad, der eine unbekannte Gruppe irgendeiner Rolle zuordnet.
type RoleMappingStore struct {
pool *pgxpool.Pool
}
func NewRoleMappingStore(pool *pgxpool.Pool) *RoleMappingStore {
return &RoleMappingStore{pool: pool}
}
// SetMapping ordnet eine LDAP-Gruppe genau einer NEXARCH-Rolle zu. Nur
// 'user'/'tenant_admin' sind zuweisbar (dieselbe Matrix wie RBAC-01/03) —
// 'superadmin' bleibt mandantenuebergreifend und nie ueber LDAP erreichbar.
func (s *RoleMappingStore) SetMapping(ctx context.Context, ldapGroup string, role rbac.Role) error {
if role != rbac.RoleUser && role != rbac.RoleTenantAdmin {
return fmt.Errorf("ldapsync: rolle %q ist ueber ldap nicht zuweisbar", role)
}
_, err := s.pool.Exec(ctx, `
INSERT INTO ldap_group_role_mapping (ldap_group, role) VALUES ($1, $2)
ON CONFLICT (ldap_group) DO UPDATE SET role = $2
`, ldapGroup, string(role))
if err != nil {
return fmt.Errorf("gruppenzuordnung speichern: %w", err)
}
return nil
}
// roleFor liefert die einer LDAP-Gruppe zugeordnete Rolle, sofern konfiguriert.
func (s *RoleMappingStore) roleFor(ctx context.Context, ldapGroup string) (rbac.Role, bool, error) {
var role string
err := s.pool.QueryRow(ctx, `SELECT role FROM ldap_group_role_mapping WHERE ldap_group = $1`, ldapGroup).Scan(&role)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
return "", false, fmt.Errorf("gruppenzuordnung lesen: %w", err)
}
return rbac.Role(role), true, nil
}
// HighestRoleFor bestimmt aus einer Liste von LDAP-Gruppen die hoechste
// zugeordnete Rolle (tenant_admin > user). Gruppen ohne Eintrag in der
// Matrix tragen NICHTS bei — auch nicht, wenn ihr Name zufaellig wie eine
// interne Rolle aussieht (Akzeptanzkriterium 3 / Pruefung 1: keine
// Privilege-Escalation ueber Gruppenmitgliedschaft).
func (s *RoleMappingStore) HighestRoleFor(ctx context.Context, ldapGroups []string) (rbac.Role, bool, error) {
var best rbac.Role
found := false
for _, g := range ldapGroups {
role, ok, err := s.roleFor(ctx, g)
if err != nil {
return "", false, err
}
if !ok {
continue
}
if !found || role == rbac.RoleTenantAdmin {
best = role
found = true
}
}
return best, found, nil
}
+93
View File
@@ -0,0 +1,93 @@
package ldapsync
import (
"context"
"errors"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// SyncResult fasst das Ergebnis eines Synchronisationslaufs zusammen.
type SyncResult struct {
Created int
Updated int
Deactivated int
Failed []string // Email-Adressen, bei denen die Verarbeitung fehlschlug
}
// Syncer fuehrt die Benutzer-/Rollen-Synchronisation aus LDAP aus.
type Syncer struct {
client Client
users *user.TenantUserStore
roles *rbac.Store
mappings *RoleMappingStore
}
func NewSyncer(client Client, users *user.TenantUserStore, roles *rbac.Store, mappings *RoleMappingStore) *Syncer {
return &Syncer{client: client, users: users, roles: roles, mappings: mappings}
}
// Run synchronisiert Benutzer aus LDAP (Akzeptanzkriterium 1). Ist die
// LDAP-Quelle nicht erreichbar/fehlerhaft, wird SOFORT und OHNE jede
// Aenderung an bestehenden Konten abgebrochen (Akzeptanzkriterium 2 /
// Pruefung 2) — Search() wird als allererstes aufgerufen, bevor irgendein
// Schreibzugriff stattfindet.
func (s *Syncer) Run(ctx context.Context) (SyncResult, error) {
entries, err := s.client.Search()
if err != nil {
return SyncResult{}, fmt.Errorf("ldap-quelle nicht erreichbar, synchronisation abgebrochen: %w", err)
}
var result SyncResult
for _, entry := range entries {
if err := s.syncOne(ctx, entry, &result); err != nil {
// Ein fehlerhafter Einzeleintrag isoliert sich selbst — er
// beschaedigt weder bereits verarbeitete noch nachfolgende
// Konten (Akzeptanzkriterium 2).
result.Failed = append(result.Failed, entry.Email)
}
}
return result, nil
}
func (s *Syncer) syncOne(ctx context.Context, entry Entry, result *SyncResult) error {
existing, err := s.users.GetByEmailForAuth(ctx, entry.Email)
isNew := errors.Is(err, user.ErrNotFound)
if err != nil && !isNew {
return err
}
var userID string
if isNew {
created, err := s.users.Create(ctx, entry.Email, entry.Name)
if err != nil {
return err
}
userID = created.ID
result.Created++
} else {
userID = existing.User.ID
result.Updated++
}
// Deaktivierung in LDAP wird uebernommen (Akzeptanzkriterium 3 / Pruefung 3).
if entry.Disabled {
if _, err := s.users.Deactivate(ctx, userID); err != nil {
return err
}
result.Deactivated++
}
role, ok, err := s.mappings.HighestRoleFor(ctx, entry.Groups)
if err != nil {
return err
}
if ok {
if _, err := s.roles.Assign(ctx, userID, role, "ldap-sync"); err != nil {
return err
}
}
return nil
}
+78
View File
@@ -0,0 +1,78 @@
// Package oidc implementiert Core IAM-06: SSO-Anmeldung ueber OpenID
// Connect gegen externe Identity-Provider.
//
// WICHTIGER HINWEIS: In dieser Umgebung ist kein registrierter externer
// OIDC-Provider (Client-ID/Secret bei Google/Okta/Azure AD o.ae.) verfuegbar,
// gegen den ein echter Authorization-Code-Redirect-Flow end-to-end
// durchgefuehrt werden koennte (analog AUD-05/Archive, IAM-05/LDAP). ANDERS
// als bei LDAP/Archive ist die eigentliche Token-VALIDIERUNG (JWKS-
// Signaturpruefung, Ablauf, Claims, Nonce/State) aber ein rein
// kryptographischer Vorgang ohne Netzwerkabhaengigkeit zur Testzeit — die
// Tests unten erzeugen ein eigenes RSA-Schluesselpaar, signieren ein
// ID-Token selbst und pruefen es exakt so, wie es ein echter Provider
// liefern wuerde. Nur der Redirect zu einem echten externen Provider bleibt
// ungeprueft.
package oidc
import (
"crypto/rsa"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math/big"
)
type jwk struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
}
type jwkSet struct {
Keys []jwk `json:"keys"`
}
var ErrUnsupportedKeyType = errors.New("oidc: nicht unterstuetzter jwk-schluesseltyp")
// ParseJWKS dekodiert eine JWKS-Antwort (RFC 7517) in kid -> RSA-Public-Key.
// Nur RSA-Schluessel (kty=RSA, Standard bei allen gaengigen OIDC-Providern
// fuer ID-Tokens) werden unterstuetzt.
func ParseJWKS(data []byte) (map[string]*rsa.PublicKey, error) {
var set jwkSet
if err := json.Unmarshal(data, &set); err != nil {
return nil, fmt.Errorf("jwks parsen: %w", err)
}
out := make(map[string]*rsa.PublicKey, len(set.Keys))
for _, k := range set.Keys {
if k.Kty != "RSA" {
continue
}
pub, err := decodeRSAKey(k)
if err != nil {
return nil, fmt.Errorf("schluessel %q dekodieren: %w", k.Kid, err)
}
out[k.Kid] = pub
}
return out, nil
}
func decodeRSAKey(k jwk) (*rsa.PublicKey, error) {
nBytes, err := base64.RawURLEncoding.DecodeString(k.N)
if err != nil {
return nil, fmt.Errorf("modulus dekodieren: %w", err)
}
eBytes, err := base64.RawURLEncoding.DecodeString(k.E)
if err != nil {
return nil, fmt.Errorf("exponent dekodieren: %w", err)
}
eBuf := make([]byte, 8)
copy(eBuf[8-len(eBytes):], eBytes)
e := int(binary.BigEndian.Uint64(eBuf))
return &rsa.PublicKey{N: new(big.Int).SetBytes(nBytes), E: e}, nil
}
+69
View File
@@ -0,0 +1,69 @@
package oidc
import (
"context"
"errors"
"fmt"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/ldapsync"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// CompleteOIDCLogin verifiziert das ID-Token (Signatur, Ablauf, Aussteller,
// Nonce — Akzeptanzkriterium 3), loest den State einmalig ein
// (Akzeptanzkriterium/Pruefung 3), legt bei Bedarf einen lokalen Benutzer an
// und mapped Rollen aus den OIDC-Claims ueber DIESELBE Erlaubnis-Matrix wie
// IAM-05/LDAP (ldapsync.RoleMappingStore — kein zweiter, paralleler
// Mapping-Mechanismus, Akzeptanzkriterium 2). Bei Erfolg wird ein normales
// IAM-02-Sitzungs-Token ausgestellt.
func CompleteOIDCLogin(
ctx context.Context,
verifier *Verifier,
states *StateStore,
users *user.TenantUserStore,
roles *rbac.Store,
roleMapping *ldapsync.RoleMappingStore,
issuer *auth.TokenIssuer,
tenantSlug, state, idToken string,
) (token string, err error) {
nonce, err := states.Consume(ctx, state)
if err != nil {
return "", err
}
claims, err := verifier.VerifyWithNonce(idToken, nonce)
if err != nil {
return "", err
}
if claims.Email == "" {
return "", fmt.Errorf("%w: id-token ohne e-mail-claim", ErrInvalidToken)
}
u, err := findOrCreateUser(ctx, users, claims.Email)
if err != nil {
return "", err
}
if role, ok, err := roleMapping.HighestRoleFor(ctx, claims.Roles); err != nil {
return "", err
} else if ok {
if _, err := roles.Assign(ctx, u.ID, role, "oidc-sso"); err != nil {
return "", err
}
}
return issuer.Issue(u.ID, tenantSlug)
}
func findOrCreateUser(ctx context.Context, users *user.TenantUserStore, email string) (user.User, error) {
creds, err := users.GetByEmailForAuth(ctx, email)
if err == nil {
return creds.User, nil
}
if !errors.Is(err, user.ErrNotFound) {
return user.User{}, err
}
return users.Create(ctx, email, email)
}
+172
View File
@@ -0,0 +1,172 @@
package oidc
import (
"context"
"crypto/rsa"
"fmt"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/golang-jwt/jwt/v5"
"gitea.perlbach24.de/scripte/nexarch/internal/auth"
"gitea.perlbach24.de/scripte/nexarch/internal/ldapsync"
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
func setupLoginTest(t *testing.T) (*pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active', password_hash TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignments (
user_id UUID PRIMARY KEY REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')),
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS role_assignment_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, role TEXT NOT NULL,
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS ldap_group_role_mapping (
ldap_group TEXT PRIMARY KEY, role TEXT NOT NULL CHECK (role IN ('user','tenant_admin'))
);
CREATE TABLE IF NOT EXISTS oidc_states (
state TEXT PRIMARY KEY, nonce TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return pool, cleanup
}
// Akzeptanzkriterium 1 + 3: End-to-End-Login gegen ein (selbst signiertes,
// aber protokollkorrektes) ID-Token — Signatur, Nonce, State, Rollen-Mapping,
// Sitzungs-Token-Ausstellung.
func TestCompleteOIDCLogin_EndToEnd(t *testing.T) {
pool, cleanup := setupLoginTest(t)
defer cleanup()
ctx := context.Background()
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
states := NewStateStore(pool)
users := user.NewTenantUserStore(pool)
roles := rbac.NewStore(pool)
roleMapping := ldapsync.NewRoleMappingStore(pool)
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
if err := roleMapping.SetMapping(ctx, "sso-admins", rbac.RoleTenantAdmin); err != nil {
t.Fatalf("set mapping: %v", err)
}
state, nonce, err := states.Generate(ctx)
if err != nil {
t.Fatalf("generate state: %v", err)
}
email := fmt.Sprintf("oidc-test-%d@example.com", time.Now().UnixNano())
claims := Claims{
Subject: "idp-subject-123",
Email: email,
Nonce: nonce,
Roles: []string{"sso-admins"},
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
idToken := signTestToken(t, key, "kid-1", claims)
token, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken)
if err != nil {
t.Fatalf("complete oidc login: %v", err)
}
if token == "" {
t.Fatal("erwartet nicht-leeres sitzungs-token")
}
assignment, err := roles.Get(ctx, mustGetUserID(t, ctx, users, email))
if err != nil {
t.Fatalf("rollenzuweisung lesen: %v", err)
}
if assignment.Role != rbac.RoleTenantAdmin {
t.Fatalf("erwartet tenant_admin aus sso-admins-mapping, habe %q", assignment.Role)
}
// Replay desselben state schlaegt fehl.
if _, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken); err == nil {
t.Fatal("erwartet fehler bei wiederverwendetem state (replay)")
}
}
// Akzeptanzkriterium 2 + Pruefung 1: keine Privilege-Escalation ueber
// manipulierte/unbekannte Rollen-Claims.
func TestCompleteOIDCLogin_UnmappedRoleGrantsNothing(t *testing.T) {
pool, cleanup := setupLoginTest(t)
defer cleanup()
ctx := context.Background()
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
states := NewStateStore(pool)
users := user.NewTenantUserStore(pool)
roles := rbac.NewStore(pool)
roleMapping := ldapsync.NewRoleMappingStore(pool)
issuer := auth.NewTokenIssuer("test-secret-nur-fuer-tests")
state, nonce, err := states.Generate(ctx)
if err != nil {
t.Fatalf("generate state: %v", err)
}
email := fmt.Sprintf("oidc-test-%d@example.com", time.Now().UnixNano())
claims := Claims{
Email: email,
Nonce: nonce,
// Versuch, sich ueber einen manipulierten Rollen-Claim
// hochzustufen — diese Gruppen wurden NIE gemappt.
Roles: []string{"tenant_admin", "superadmin"},
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
idToken := signTestToken(t, key, "kid-1", claims)
if _, err := CompleteOIDCLogin(ctx, verifier, states, users, roles, roleMapping, issuer, "acme", state, idToken); err != nil {
t.Fatalf("complete oidc login: %v", err)
}
userID := mustGetUserID(t, ctx, users, email)
if _, err := roles.Get(ctx, userID); err == nil {
t.Fatal("erwartet KEINE rollenzuweisung fuer nicht gemappte claims (privilege-escalation-versuch)")
}
}
func mustGetUserID(t *testing.T, ctx context.Context, users *user.TenantUserStore, email string) string {
t.Helper()
creds, err := users.GetByEmailForAuth(ctx, email)
if err != nil {
t.Fatalf("get user by email: %v", err)
}
return creds.User.ID
}
+76
View File
@@ -0,0 +1,76 @@
package oidc
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrStateInvalid = errors.New("oidc: state ungueltig, bereits verwendet oder abgelaufen")
// DefaultStateTTL begrenzt, wie lange ein ausgestellter State/Nonce fuer den
// Redirect-Umweg zum Identity-Provider gueltig bleibt.
const DefaultStateTTL = 10 * time.Minute
type StateStore struct {
pool *pgxpool.Pool
}
func NewStateStore(pool *pgxpool.Pool) *StateStore {
return &StateStore{pool: pool}
}
// Generate stellt state+nonce fuer einen neuen Login-Redirect aus. state
// wird als OAuth2-"state"-Parameter mitgeschickt (CSRF-Schutz), nonce
// erscheint spaeter im ID-Token und muss exakt uebereinstimmen (Replay-Schutz).
func (s *StateStore) Generate(ctx context.Context) (state, nonce string, err error) {
state, err = randomValue()
if err != nil {
return "", "", err
}
nonce, err = randomValue()
if err != nil {
return "", "", err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO oidc_states (state, nonce, expires_at) VALUES ($1, $2, $3)
`, state, nonce, time.Now().Add(DefaultStateTTL))
if err != nil {
return "", "", fmt.Errorf("state speichern: %w", err)
}
return state, nonce, nil
}
// Consume loest einen State EINMALIG ein (Akzeptanzkriterium/Pruefung 3:
// Replay-Schutz) — atomar ueber WHERE used_at IS NULL, analog IAM-03/IAM-09.
// Ein zweiter Callback mit demselben state (z.B. durch einen Angreifer, der
// die Redirect-URL abgefangen hat) schlaegt fehl.
func (s *StateStore) Consume(ctx context.Context, state string) (nonce string, err error) {
err = s.pool.QueryRow(ctx, `
UPDATE oidc_states SET used_at = now()
WHERE state = $1 AND used_at IS NULL AND expires_at > now()
RETURNING nonce
`, state).Scan(&nonce)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrStateInvalid
}
return "", fmt.Errorf("state einloesen: %w", err)
}
return nonce, nil
}
func randomValue() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
+68
View File
@@ -0,0 +1,68 @@
package oidc
import (
"context"
"errors"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupStateTest(t *testing.T) (*StateStore, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("pool: %v", err)
}
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS oidc_states (
state TEXT PRIMARY KEY, nonce TEXT NOT NULL, expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return NewStateStore(pool), cleanup
}
// Akzeptanzkriterium/Pruefung 3: State/Nonce-Handling gegen CSRF und Replay.
func TestStateStore_ConsumeIsSingleUse(t *testing.T) {
store, cleanup := setupStateTest(t)
defer cleanup()
ctx := context.Background()
state, nonce, err := store.Generate(ctx)
if err != nil {
t.Fatalf("generate: %v", err)
}
gotNonce, err := store.Consume(ctx, state)
if err != nil {
t.Fatalf("consume (erste einloesung): %v", err)
}
if gotNonce != nonce {
t.Fatalf("nonce = %q, want %q", gotNonce, nonce)
}
if _, err := store.Consume(ctx, state); !errors.Is(err, ErrStateInvalid) {
t.Fatalf("erwartet ErrStateInvalid bei erneutem einloesen (replay), habe %v", err)
}
}
func TestStateStore_RejectsUnknownState(t *testing.T) {
store, cleanup := setupStateTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Consume(ctx, "niemals-ausgestellter-state"); !errors.Is(err, ErrStateInvalid) {
t.Fatalf("erwartet ErrStateInvalid, habe %v", err)
}
}
+70
View File
@@ -0,0 +1,70 @@
package oidc
import (
"crypto/rsa"
"errors"
"fmt"
"github.com/golang-jwt/jwt/v5"
)
var ErrInvalidToken = errors.New("oidc: id-token ungueltig, abgelaufen oder falscher aussteller")
// Claims sind die fuer NEXARCH relevanten OIDC-ID-Token-Claims.
type Claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Nonce string `json:"nonce"`
Roles []string `json:"roles"` // providerabhaengiger Claim-Name, hier vereinfacht fest "roles"
jwt.RegisteredClaims
}
// Verifier prueft ID-Tokens gegen den JWKS-Schluesselsatz eines konfigurierten
// Providers (Akzeptanzkriterium 3: fehlerhafte/abgelaufene Tokens werden
// abgewiesen).
type Verifier struct {
keys map[string]*rsa.PublicKey
issuer string
}
func NewVerifier(keys map[string]*rsa.PublicKey, issuer string) *Verifier {
return &Verifier{keys: keys, issuer: issuer}
}
// Verify prueft Signatur (gegen JWKS), Ablauf (jwt-Bibliothek lehnt
// abgelaufene Tokens automatisch ab) und Aussteller.
func (v *Verifier) Verify(idToken string) (*Claims, error) {
claims := &Claims{}
token, err := jwt.ParseWithClaims(idToken, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, ErrInvalidToken
}
kid, _ := t.Header["kid"].(string)
key, ok := v.keys[kid]
if !ok {
return nil, ErrInvalidToken
}
return key, nil
})
if err != nil || !token.Valid {
return nil, ErrInvalidToken
}
if claims.Issuer != v.issuer {
return nil, fmt.Errorf("%w: unerwarteter aussteller %q", ErrInvalidToken, claims.Issuer)
}
return claims, nil
}
// VerifyWithNonce prueft zusaetzlich, dass der nonce-Claim im Token exakt
// dem bei der Anfrage ausgestellten Nonce entspricht — verhindert Replay
// eines fremden, gueltigen ID-Tokens (Akzeptanzkriterium/Pruefung 3).
func (v *Verifier) VerifyWithNonce(idToken, expectedNonce string) (*Claims, error) {
claims, err := v.Verify(idToken)
if err != nil {
return nil, err
}
if claims.Nonce == "" || claims.Nonce != expectedNonce {
return nil, fmt.Errorf("%w: nonce stimmt nicht ueberein", ErrInvalidToken)
}
return claims, nil
}
+184
View File
@@ -0,0 +1,184 @@
package oidc
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/binary"
"encoding/json"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func generateTestKey(t *testing.T) *rsa.PrivateKey {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate rsa key: %v", err)
}
return key
}
func signTestToken(t *testing.T, key *rsa.PrivateKey, kid string, claims Claims) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = kid
s, err := token.SignedString(key)
if err != nil {
t.Fatalf("sign token: %v", err)
}
return s
}
func TestVerify_AcceptsValidToken(t *testing.T) {
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
claims := Claims{
Subject: "user-123",
Email: "person@example.com",
Nonce: "erwarteter-nonce",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
tok := signTestToken(t, key, "kid-1", claims)
got, err := verifier.Verify(tok)
if err != nil {
t.Fatalf("verify: %v", err)
}
if got.Email != "person@example.com" {
t.Fatalf("email = %q", got.Email)
}
}
// Akzeptanzkriterium 3 + Pruefung 2: Token-Signatur- und Ablaufpruefung.
func TestVerify_RejectsExpiredToken(t *testing.T) {
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
claims := Claims{
Subject: "user-123",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), // abgelaufen
},
}
tok := signTestToken(t, key, "kid-1", claims)
if _, err := verifier.Verify(tok); err == nil {
t.Fatal("erwartet fehler bei abgelaufenem token")
}
}
func TestVerify_RejectsWrongSigningKey(t *testing.T) {
realKey := generateTestKey(t)
attackerKey := generateTestKey(t)
// Verifier kennt NUR den echten oeffentlichen Schluessel.
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &realKey.PublicKey}, "https://idp.example.com")
claims := Claims{
Subject: "user-123",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
// Mit dem Angreifer-Schluessel signiert, aber unter demselben kid wie der echte.
tok := signTestToken(t, attackerKey, "kid-1", claims)
if _, err := verifier.Verify(tok); err == nil {
t.Fatal("erwartet fehler bei fremd signiertem token")
}
}
func TestVerify_RejectsWrongIssuer(t *testing.T) {
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
claims := Claims{
Subject: "user-123",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://ganz-anderer-idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
tok := signTestToken(t, key, "kid-1", claims)
if _, err := verifier.Verify(tok); err == nil {
t.Fatal("erwartet fehler bei unerwartetem aussteller")
}
}
// Akzeptanzkriterium/Pruefung 3: Nonce-Handling gegen Replay.
func TestVerifyWithNonce_RejectsMismatch(t *testing.T) {
key := generateTestKey(t)
verifier := NewVerifier(map[string]*rsa.PublicKey{"kid-1": &key.PublicKey}, "https://idp.example.com")
claims := Claims{
Subject: "user-123",
Nonce: "nonce-a",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com",
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
},
}
tok := signTestToken(t, key, "kid-1", claims)
if _, err := verifier.VerifyWithNonce(tok, "nonce-b"); err == nil {
t.Fatal("erwartet fehler bei nonce-mismatch (moeglicher replay eines fremden tokens)")
}
if _, err := verifier.VerifyWithNonce(tok, "nonce-a"); err != nil {
t.Fatalf("erwartet erfolg bei passendem nonce: %v", err)
}
}
func TestParseJWKS_RoundTrip(t *testing.T) {
key := generateTestKey(t)
nBytes := key.PublicKey.N.Bytes()
eBuf := make([]byte, 8)
binary.BigEndian.PutUint64(eBuf, uint64(key.PublicKey.E))
// Fuehrende Nullbytes des Exponenten abschneiden (ueblich bei JWK-Kodierung).
eStart := 0
for eStart < len(eBuf)-1 && eBuf[eStart] == 0 {
eStart++
}
set := jwkSet{Keys: []jwk{{
Kid: "kid-1",
Kty: "RSA",
N: base64.RawURLEncoding.EncodeToString(nBytes),
E: base64.RawURLEncoding.EncodeToString(eBuf[eStart:]),
}}}
data, err := json.Marshal(set)
if err != nil {
t.Fatalf("marshal: %v", err)
}
parsed, err := ParseJWKS(data)
if err != nil {
t.Fatalf("parse jwks: %v", err)
}
got, ok := parsed["kid-1"]
if !ok {
t.Fatal("erwartet kid-1 im ergebnis")
}
if got.E != key.PublicKey.E || got.N.Cmp(key.PublicKey.N) != 0 {
t.Fatalf("erwartet identischen oeffentlichen schluessel nach roundtrip")
}
// Der wiederhergestellte Schluessel muss ein damit signiertes Token auch tatsaechlich verifizieren.
verifier := NewVerifier(parsed, "https://idp.example.com")
claims := Claims{Subject: "x", RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://idp.example.com", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
}}
tok := signTestToken(t, key, "kid-1", claims)
if _, err := verifier.Verify(tok); err != nil {
t.Fatalf("verify mit aus jwks wiederhergestelltem schluessel: %v", err)
}
}
+71
View File
@@ -0,0 +1,71 @@
// Package rbac implementiert Core RBAC-01: das Rollenmodell mit
// Grundrollen/-rechten, Hierarchie und Zuweisung. Die eigentliche
// Durchsetzung (erlaubt/verboten je Request) ist RBAC-02, dieses Paket
// liefert nur Modell + Zuweisung (Casbin-Prinzip: Policy-Modell getrennt
// von der Entscheidungsfunktion).
package rbac
// Role ist eine der drei Grundrollen. Superadmin lebt mandantenuebergreifend
// (siehe internal/user.SuperadminStore aus IAM-01) und wird deshalb NICHT
// ueber Store (tenant-gescoped) zugewiesen — die Existenz eines Superadmin-
// Kontos IST die Rollenzuweisung.
type Role string
const (
RoleSuperadmin Role = "superadmin"
RoleTenantAdmin Role = "tenant_admin"
RoleUser Role = "user"
)
type Permission string
const (
PermSelfRead Permission = "self.read"
PermSelfUpdate Permission = "self.update"
PermManageUsers Permission = "tenant.manage_users"
PermManageSettings Permission = "tenant.manage_settings"
PermManageTenants Permission = "platform.manage_tenants"
)
// parent bildet die Rollenhierarchie ab (Akzeptanzkriterium 2): eine Rolle
// erbt alle Rechte ihrer Elternrolle. tenant_admin erbt von user,
// superadmin erbt von tenant_admin.
var parent = map[Role]Role{
RoleTenantAdmin: RoleUser,
RoleSuperadmin: RoleTenantAdmin,
}
// direct sind die einer Rolle direkt (ohne Vererbung) zugeordneten Rechte.
var direct = map[Role][]Permission{
RoleUser: {PermSelfRead, PermSelfUpdate},
RoleTenantAdmin: {PermManageUsers, PermManageSettings},
RoleSuperadmin: {PermManageTenants},
}
// EffectivePermissions liefert die vollstaendige, ueber die Hierarchie
// aufgeloeste Rechtemenge einer Rolle (Akzeptanzkriterium 2 / Pruefung 2).
func EffectivePermissions(role Role) []Permission {
seen := make(map[Permission]bool)
var out []Permission
for r, ok := role, true; ok; r, ok = parent[r] {
for _, p := range direct[r] {
if !seen[p] {
seen[p] = true
out = append(out, p)
}
}
}
return out
}
// HasPermission prueft, ob eine Rolle (inklusive geerbter Rechte) ein
// bestimmtes Recht besitzt.
func HasPermission(role Role, perm Permission) bool {
for _, p := range EffectivePermissions(role) {
if p == perm {
return true
}
}
return false
}
+124
View File
@@ -0,0 +1,124 @@
package rbac
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
// ErrRoleNotAssignableInTenantScope wird geliefert, wenn versucht wird,
// eine mandantenuebergreifende Rolle (superadmin) ueber den tenant-
// gescopten Store zu vergeben — die erlaubte Matrix laesst hier nur
// user/tenant_admin zu (Akzeptanzkriterium 1 / Pruefung 1).
ErrRoleNotAssignableInTenantScope = errors.New("rbac: rolle ist in diesem geltungsbereich nicht zuweisbar")
ErrNotFound = errors.New("rbac: keine rollenzuweisung gefunden")
)
// assignableRoles ist die erlaubte Matrix fuer Store (tenant-gescoped).
var assignableRoles = map[Role]bool{
RoleUser: true,
RoleTenantAdmin: true,
}
type Assignment struct {
UserID string
Role Role
GrantedBy string
}
// Store verwaltet Rollenzuweisungen innerhalb GENAU EINER Tenant-Datenbank —
// analog zu internal/user.TenantUserStore (Modell C: der Pool bestimmt den
// Tenant, keine tenant_id-Spalte noetig).
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Assign vergibt eine Rolle an einen Benutzer. grantedBy identifiziert den
// Akteur, der die Zuweisung vorgenommen hat (Akzeptanzkriterium 3). Jede
// Zuweisung wird zusaetzlich in role_assignment_history festgehalten, auch
// wenn sie eine vorherige Rolle ersetzt.
func (s *Store) Assign(ctx context.Context, userID string, role Role, grantedBy string) (Assignment, error) {
if !assignableRoles[role] {
return Assignment{}, ErrRoleNotAssignableInTenantScope
}
if grantedBy == "" {
return Assignment{}, errors.New("rbac: grantedBy darf nicht leer sein")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return Assignment{}, fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
INSERT INTO role_assignments (user_id, role, granted_by, granted_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (user_id) DO UPDATE SET role = $2, granted_by = $3, granted_at = now()
`, userID, string(role), grantedBy); err != nil {
return Assignment{}, fmt.Errorf("rolle zuweisen: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO role_assignment_history (user_id, role, granted_by, granted_at)
VALUES ($1, $2, $3, now())
`, userID, string(role), grantedBy); err != nil {
return Assignment{}, fmt.Errorf("historie schreiben: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return Assignment{}, fmt.Errorf("transaktion committen: %w", err)
}
return Assignment{UserID: userID, Role: role, GrantedBy: grantedBy}, nil
}
func (s *Store) Get(ctx context.Context, userID string) (Assignment, error) {
var a Assignment
var role string
a.UserID = userID
if err := s.pool.QueryRow(ctx, `
SELECT role, granted_by FROM role_assignments WHERE user_id = $1
`, userID).Scan(&role, &a.GrantedBy); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Assignment{}, ErrNotFound
}
return Assignment{}, fmt.Errorf("rollenzuweisung lesen: %w", err)
}
a.Role = Role(role)
return a, nil
}
// History liefert jede Rollenzuweisung eines Benutzers in chronologischer
// Reihenfolge — die Grundlage fuer "wer hat wann welche Rolle vergeben"
// (Akzeptanzkriterium 3).
func (s *Store) History(ctx context.Context, userID string) ([]Assignment, error) {
rows, err := s.pool.Query(ctx, `
SELECT role, granted_by FROM role_assignment_history
WHERE user_id = $1 ORDER BY granted_at
`, userID)
if err != nil {
return nil, fmt.Errorf("historie abfragen: %w", err)
}
defer rows.Close()
var out []Assignment
for rows.Next() {
var role string
a := Assignment{UserID: userID}
if err := rows.Scan(&role, &a.GrantedBy); err != nil {
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
}
a.Role = Role(role)
out = append(out, a)
}
return out, rows.Err()
}
-49
View File
@@ -2,10 +2,7 @@ package tenant
import (
"encoding/json"
"errors"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// Handler ist eine schlanke Vorbereitung der Schnittstelle fuer API-01
@@ -46,49 +43,3 @@ func (h *Handler) CreateTenant(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(t)
}
// OnboardingHandler stellt den Self-Service-Endpunkt aus TEN-02 bereit —
// eigener Handler statt Erweiterung von Handler.CreateTenant, damit die
// TEN-01-Schnittstelle (reines Provisioning ohne Admin-Konto) unveraendert
// bleibt.
type OnboardingHandler struct {
service *OnboardingService
}
func NewOnboardingHandler(s *OnboardingService) *OnboardingHandler {
return &OnboardingHandler{service: s}
}
type onboardRequest struct {
Slug string `json:"slug"`
Name string `json:"name"`
AdminEmail string `json:"admin_email"`
AdminName string `json:"admin_name"`
}
func (h *OnboardingHandler) Onboard(w http.ResponseWriter, r *http.Request) {
var req onboardRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
result, err := h.service.Onboard(r.Context(), req.Slug, req.Name, req.AdminEmail, req.AdminName)
if err != nil {
switch {
case errors.Is(err, ErrInvalidSlug),
errors.Is(err, ErrMissingField),
errors.Is(err, user.ErrInvalidEmail):
http.Error(w, err.Error(), http.StatusBadRequest)
case errors.Is(err, ErrTenantExists):
http.Error(w, err.Error(), http.StatusConflict)
default:
http.Error(w, "mandant konnte nicht angelegt werden", http.StatusInternalServerError)
}
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(result)
}
-206
View File
@@ -1,206 +0,0 @@
package tenant
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var (
ErrTenantNotFound = errors.New("tenant: nicht gefunden")
ErrInvalidTransition = errors.New("tenant: ungueltiger zustandsuebergang")
// ErrTenantNotActive wird von Lifecycle.CheckActive verwendet — bewusst
// EIN Fehler fuer suspendiert/zur-Loeschung-vorgemerkt/geloescht, da der
// Aufrufer (z.B. Login) nur wissen muss "kein Zugriff", nicht welcher der
// Nicht-aktiv-Zustaende genau vorliegt.
ErrTenantNotActive = errors.New("tenant: nicht aktiv")
)
func scanTenantWithLifecycle(row pgx.Row) (Tenant, error) {
var t Tenant
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status,
&t.CreatedAt, &t.PreviousStatus, &t.DeletionScheduledAt); err != nil {
return Tenant{}, err
}
return t, nil
}
// transition fuehrt einen bewachten Zustandsuebergang aus: das UPDATE greift
// nur, wenn der aktuelle Status einer von allowedFrom ist (atomarer
// Check-and-Set, kein Race zwischen Lesen und Schreiben). Greift es nicht,
// wird zwischen "Tenant existiert nicht" und "Uebergang nicht erlaubt"
// unterschieden, damit AC1 ("ungueltige Uebergaenge werden abgewiesen") einen
// sprechenden Fehler liefert statt eines stillen No-Ops.
func (r *Registry) transition(ctx context.Context, slug string, allowedFrom []Status, to Status, previousStatus *string, deletionAt *time.Time) (Tenant, error) {
from := make([]string, len(allowedFrom))
for i, s := range allowedFrom {
from[i] = string(s)
}
row := r.pool.QueryRow(ctx, `
UPDATE tenants
SET status = $2, previous_status = $3, deletion_scheduled_at = $4
WHERE slug = $1 AND status = ANY($5)
RETURNING id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
`, slug, string(to), previousStatus, deletionAt, from)
t, err := scanTenantWithLifecycle(row)
if err == nil {
return t, nil
}
if !errors.Is(err, pgx.ErrNoRows) {
return Tenant{}, fmt.Errorf("zustandsuebergang: %w", err)
}
existing, getErr := r.GetBySlug(ctx, slug)
if getErr != nil {
return Tenant{}, ErrTenantNotFound
}
return Tenant{}, fmt.Errorf("%w: von %q nach %q (aktuell: %q)", ErrInvalidTransition, allowedFrom, to, existing.Status)
}
// Suspend haelt die Daten des Mandanten unveraendert, sperrt aber den Zugriff
// (Akzeptanzkriterium 1) — es findet keine Loeschung/Migration statt.
func (r *Registry) Suspend(ctx context.Context, slug string) (Tenant, error) {
return r.transition(ctx, slug, []Status{StatusActive}, StatusSuspended, nil, nil)
}
// Reactivate stellt den Zustand vor der Suspendierung vollstaendig wieder her
// (Akzeptanzkriterium 2) — da Suspend keine weiteren Daten veraendert, genuegt
// die Rueckkehr nach StatusActive.
func (r *Registry) Reactivate(ctx context.Context, slug string) (Tenant, error) {
return r.transition(ctx, slug, []Status{StatusSuspended}, StatusActive, nil, nil)
}
// ScheduleDeletion merkt den Mandanten zur Loeschung vor und startet die
// Karenzzeit (Akzeptanzkriterium 3). previous_status wird festgehalten, damit
// CancelDeletion exakt dorthin zurueckkehren kann (aktiv ODER suspendiert).
func (r *Registry) ScheduleDeletion(ctx context.Context, slug string, grace time.Duration) (Tenant, error) {
existing, err := r.GetBySlug(ctx, slug)
if err != nil {
return Tenant{}, ErrTenantNotFound
}
prev := string(existing.Status)
deletionAt := time.Now().Add(grace)
return r.transition(ctx, slug, []Status{StatusActive, StatusSuspended}, StatusPendingDeletion, &prev, &deletionAt)
}
// CancelDeletion widerruft eine Loeschvormerkung innerhalb der Karenzzeit und
// stellt exakt den zuvor gesicherten Zustand wieder her.
func (r *Registry) CancelDeletion(ctx context.Context, slug string) (Tenant, error) {
existing, err := r.GetBySlug(ctx, slug)
if err != nil {
return Tenant{}, ErrTenantNotFound
}
if existing.Status != StatusPendingDeletion || existing.PreviousStatus == nil {
return Tenant{}, fmt.Errorf("%w: von %q nach aktiv/suspendiert (aktuell: %q)", ErrInvalidTransition, StatusPendingDeletion, existing.Status)
}
restoreTo := Status(*existing.PreviousStatus)
return r.transition(ctx, slug, []Status{StatusPendingDeletion}, restoreTo, nil, nil)
}
// Lifecycle fuehrt die tatsaechliche, physische Loeschung nach Ablauf der
// Karenzzeit aus (Datenbank-Drop) und stellt die Zugriffsschutz-Pruefung
// bereit. Getrennt von Registry, weil hierfuer zusaetzlich der adminPool
// (fuer DROP DATABASE) noetig ist, siehe internal/tenant.Provisioner.
type Lifecycle struct {
registry *Registry
adminPool *pgxpool.Pool
}
func NewLifecycle(registry *Registry, adminPool *pgxpool.Pool) *Lifecycle {
return &Lifecycle{registry: registry, adminPool: adminPool}
}
// CheckActive verweigert Zugriff fuer jeden Nicht-aktiv-Zustand und loggt den
// Vorgang strukturiert (Akzeptanzkriterium 1 / Pruefung 2).
func (l *Lifecycle) CheckActive(ctx context.Context, slug string) error {
t, err := l.registry.GetBySlug(ctx, slug)
if err != nil {
return ErrTenantNotFound
}
if t.Status != StatusActive {
slog.Warn("zugriff auf nicht-aktiven mandanten verweigert",
"tenant_slug", slug, "tenant_status", t.Status)
return ErrTenantNotActive
}
return nil
}
// ProcessDueDeletions loescht alle Mandanten-Datenbanken, deren Karenzzeit
// abgelaufen ist (Akzeptanzkriterium 3 / Pruefung 3). FOR UPDATE SKIP LOCKED
// folgt der projektweiten Postgres-Jobqueue-Konvention (siehe
// SKALIERUNGSKONZEPT.md) und macht die Funktion sicher fuer mehrere parallel
// laufende Core-Instanzen.
func (l *Lifecycle) ProcessDueDeletions(ctx context.Context) (int, error) {
tx, err := l.registry.pool.Begin(ctx)
if err != nil {
return 0, fmt.Errorf("sweep-transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
rows, err := tx.Query(ctx, `
SELECT id, db_name FROM tenants
WHERE status = $1 AND deletion_scheduled_at <= now()
FOR UPDATE SKIP LOCKED
`, string(StatusPendingDeletion))
if err != nil {
return 0, fmt.Errorf("faellige loeschungen abfragen: %w", err)
}
type due struct{ id, dbName string }
var candidates []due
for rows.Next() {
var d due
if err := rows.Scan(&d.id, &d.dbName); err != nil {
rows.Close()
return 0, fmt.Errorf("faellige loeschung lesen: %w", err)
}
candidates = append(candidates, d)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, err
}
processed := 0
for _, c := range candidates {
if _, err := l.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, c.dbName)); err != nil {
return processed, fmt.Errorf("tenant-datenbank %q loeschen: %w", c.dbName, err)
}
if _, err := tx.Exec(ctx, `
UPDATE tenants SET status = $2, previous_status = NULL, deletion_scheduled_at = NULL
WHERE id = $1
`, c.id, string(StatusDeleted)); err != nil {
return processed, fmt.Errorf("tenant %q als geloescht markieren: %w", c.id, err)
}
processed++
}
if err := tx.Commit(ctx); err != nil {
return 0, fmt.Errorf("sweep-transaktion committen: %w", err)
}
return processed, nil
}
// RunSweeper triggert ProcessDueDeletions periodisch, bis ctx beendet wird —
// die "In-Prozess-Worker-Goroutine" aus der projektweiten Jobqueue-Konvention.
func (l *Lifecycle) RunSweeper(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if _, err := l.ProcessDueDeletions(ctx); err != nil {
slog.Error("tenant-loeschung-sweep fehlgeschlagen", "error", err)
}
}
}
}
-253
View File
@@ -1,253 +0,0 @@
package tenant
import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func newLifecycleTestSetup(t *testing.T) (*Registry, *Lifecycle, *pgxpool.Pool, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
registryPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("registry pool: %v", err)
}
if _, err := registryPool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
previous_status TEXT,
deletion_scheduled_at TIMESTAMPTZ
)`); err != nil {
t.Fatalf("registry-schema: %v", err)
}
registry := NewRegistry(registryPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
lifecycle := NewLifecycle(registry, adminPool)
cleanup := func() {
registryPool.Close()
adminPool.Close()
}
_ = provisioner
return registry, lifecycle, adminPool, cleanup
}
func provisionTestTenant(t *testing.T, registry *Registry, adminPool *pgxpool.Pool, slug string) {
t.Helper()
dsnTemplate := strings.Replace(os.Getenv("TEST_ADMIN_DSN"), "/postgres?", "/%s?", 1)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
if _, err := provisioner.Provision(context.Background(), slug, slug); err != nil {
t.Fatalf("provision %s: %v", slug, err)
}
t.Cleanup(func() {
ctx := context.Background()
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbNameForSlug(slug)))
_, _ = registry.pool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
})
}
// Akzeptanzkriterium 1 (Suspend) + 2 (Reactivate) + Pruefung 1 (Uebergaenge).
func TestLifecycle_SuspendAndReactivate(t *testing.T) {
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
defer cleanup()
provisionTestTenant(t, registry, adminPool, "lc_suspend")
ctx := context.Background()
suspended, err := registry.Suspend(ctx, "lc_suspend")
if err != nil {
t.Fatalf("suspend: %v", err)
}
if suspended.Status != StatusSuspended {
t.Fatalf("status = %q, want suspended", suspended.Status)
}
reactivated, err := registry.Reactivate(ctx, "lc_suspend")
if err != nil {
t.Fatalf("reactivate: %v", err)
}
if reactivated.Status != StatusActive {
t.Fatalf("status = %q, want active", reactivated.Status)
}
}
// Pruefung 1: ungueltige Uebergaenge werden abgewiesen.
func TestLifecycle_RejectsInvalidTransitions(t *testing.T) {
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
defer cleanup()
provisionTestTenant(t, registry, adminPool, "lc_invalid")
ctx := context.Background()
// Reactivate auf einem bereits aktiven Tenant ist kein gueltiger Uebergang.
if _, err := registry.Reactivate(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
}
if _, err := registry.Suspend(ctx, "lc_invalid"); err != nil {
t.Fatalf("suspend: %v", err)
}
// Suspend auf einem bereits suspendierten Tenant ist ebenfalls ungueltig.
if _, err := registry.Suspend(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
}
// CancelDeletion ohne vorherige Loeschvormerkung ist ungueltig.
if _, err := registry.CancelDeletion(ctx, "lc_invalid"); !errors.Is(err, ErrInvalidTransition) {
t.Fatalf("erwartet ErrInvalidTransition, habe %v", err)
}
if _, err := registry.Suspend(ctx, "unbekannter-slug-xyz"); !errors.Is(err, ErrTenantNotFound) {
t.Fatalf("erwartet ErrTenantNotFound, habe %v", err)
}
}
// Akzeptanzkriterium 3: Loeschung zweistufig mit Karenzzeit, innerhalb der
// Frist widerrufbar — sowohl aus 'active' als auch aus 'suspended' heraus,
// mit exakter Wiederherstellung des jeweiligen Vorzustands.
func TestLifecycle_ScheduleAndCancelDeletion_RestoresExactPreviousState(t *testing.T) {
registry, _, adminPool, cleanup := newLifecycleTestSetup(t)
defer cleanup()
provisionTestTenant(t, registry, adminPool, "lc_cancel_active")
provisionTestTenant(t, registry, adminPool, "lc_cancel_suspended")
ctx := context.Background()
// Fall 1: aus 'active' heraus vorgemerkt und widerrufen.
scheduled, err := registry.ScheduleDeletion(ctx, "lc_cancel_active", time.Hour)
if err != nil {
t.Fatalf("schedule deletion: %v", err)
}
if scheduled.Status != StatusPendingDeletion {
t.Fatalf("status = %q, want pending_deletion", scheduled.Status)
}
if scheduled.DeletionScheduledAt == nil {
t.Fatal("erwartet gesetzte deletion_scheduled_at")
}
restored, err := registry.CancelDeletion(ctx, "lc_cancel_active")
if err != nil {
t.Fatalf("cancel deletion: %v", err)
}
if restored.Status != StatusActive {
t.Fatalf("status = %q, want active (vorheriger zustand)", restored.Status)
}
// Fall 2: aus 'suspended' heraus vorgemerkt und widerrufen — muss zu
// 'suspended' zurueckkehren, NICHT zu 'active'.
if _, err := registry.Suspend(ctx, "lc_cancel_suspended"); err != nil {
t.Fatalf("suspend: %v", err)
}
if _, err := registry.ScheduleDeletion(ctx, "lc_cancel_suspended", time.Hour); err != nil {
t.Fatalf("schedule deletion: %v", err)
}
restoredSuspended, err := registry.CancelDeletion(ctx, "lc_cancel_suspended")
if err != nil {
t.Fatalf("cancel deletion: %v", err)
}
if restoredSuspended.Status != StatusSuspended {
t.Fatalf("status = %q, want suspended (vorheriger zustand)", restoredSuspended.Status)
}
}
// Akzeptanzkriterium 1 + Pruefung 2: suspendierter Tenant erzeugt bei jedem
// Zugriffsversuch einen klaren Fehler.
func TestLifecycle_CheckActive_RejectsNonActive(t *testing.T) {
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
defer cleanup()
provisionTestTenant(t, registry, adminPool, "lc_checkactive")
ctx := context.Background()
if err := lifecycle.CheckActive(ctx, "lc_checkactive"); err != nil {
t.Fatalf("aktiver tenant sollte durchgehen, habe %v", err)
}
if _, err := registry.Suspend(ctx, "lc_checkactive"); err != nil {
t.Fatalf("suspend: %v", err)
}
for i := 0; i < 3; i++ {
if err := lifecycle.CheckActive(ctx, "lc_checkactive"); !errors.Is(err, ErrTenantNotActive) {
t.Fatalf("versuch %d: erwartet ErrTenantNotActive, habe %v", i, err)
}
}
if err := lifecycle.CheckActive(ctx, "nie-registriert"); !errors.Is(err, ErrTenantNotFound) {
t.Fatalf("erwartet ErrTenantNotFound, habe %v", err)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Loeschvorgang nach Ablauf der Karenzzeit
// automatisch ausgeloest (hier durch direkten Aufruf von ProcessDueDeletions,
// das RunSweeper periodisch aufruft).
func TestLifecycle_ProcessDueDeletions(t *testing.T) {
registry, lifecycle, adminPool, cleanup := newLifecycleTestSetup(t)
defer cleanup()
provisionTestTenant(t, registry, adminPool, "lc_due")
provisionTestTenant(t, registry, adminPool, "lc_not_due")
ctx := context.Background()
// lc_due: Karenzzeit liegt bereits in der Vergangenheit -> faellig.
if _, err := registry.ScheduleDeletion(ctx, "lc_due", -time.Minute); err != nil {
t.Fatalf("schedule deletion (due): %v", err)
}
// lc_not_due: Karenzzeit liegt weit in der Zukunft -> nicht faellig.
if _, err := registry.ScheduleDeletion(ctx, "lc_not_due", time.Hour); err != nil {
t.Fatalf("schedule deletion (not due): %v", err)
}
processed, err := lifecycle.ProcessDueDeletions(ctx)
if err != nil {
t.Fatalf("process due deletions: %v", err)
}
if processed != 1 {
t.Fatalf("erwartet genau 1 verarbeitete loeschung, habe %d", processed)
}
due, err := registry.GetBySlug(ctx, "lc_due")
if err != nil {
t.Fatalf("get lc_due: %v", err)
}
if due.Status != StatusDeleted {
t.Fatalf("lc_due status = %q, want deleted", due.Status)
}
notDue, err := registry.GetBySlug(ctx, "lc_not_due")
if err != nil {
t.Fatalf("get lc_not_due: %v", err)
}
if notDue.Status != StatusPendingDeletion {
t.Fatalf("lc_not_due status = %q, want pending_deletion (noch nicht faellig)", notDue.Status)
}
// Datenbank von lc_due wurde tatsaechlich physisch entfernt.
var exists bool
if err := adminPool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)`,
dbNameForSlug("lc_due")).Scan(&exists); err != nil {
t.Fatalf("pg_database pruefen: %v", err)
}
if exists {
t.Fatal("erwartet, dass die tenant-datenbank von lc_due geloescht wurde")
}
}
-83
View File
@@ -1,83 +0,0 @@
package tenant
import (
"context"
"errors"
"fmt"
"os"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
var ErrMissingField = errors.New("tenant: pflichtfeld fehlt")
// OnboardingService implementiert TEN-02: Self-Service-Anlage eines neuen
// Mandanten inklusive Ersteinrichtung des Administrator-Kontos in einem
// Vorgang. Schlaegt die Admin-Anlage fehl, wird der bereits provisionierte
// Tenant per Deprovision wieder vollstaendig entfernt (Saga-Kompensation,
// da CREATE DATABASE keine echte cross-database Transaktion erlaubt).
type OnboardingService struct {
provisioner *Provisioner
// tenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der
// die frisch angelegte Tenant-Datenbank initialisiert wird, bevor der
// erste Administrator angelegt werden kann.
tenantSchemaPath string
}
func NewOnboardingService(provisioner *Provisioner, tenantSchemaPath string) *OnboardingService {
return &OnboardingService{provisioner: provisioner, tenantSchemaPath: tenantSchemaPath}
}
type OnboardResult struct {
Tenant Tenant
Admin user.User
}
func (s *OnboardingService) Onboard(ctx context.Context, slug, tenantName, adminEmail, adminName string) (OnboardResult, error) {
if tenantName == "" {
return OnboardResult{}, fmt.Errorf("%w: name", ErrMissingField)
}
if adminName == "" {
return OnboardResult{}, fmt.Errorf("%w: admin-name", ErrMissingField)
}
if err := ValidateSlug(slug); err != nil {
return OnboardResult{}, err
}
if err := user.ValidateEmail(adminEmail); err != nil {
return OnboardResult{}, err
}
t, err := s.provisioner.Provision(ctx, slug, tenantName)
if err != nil {
return OnboardResult{}, err
}
admin, err := s.setupAdmin(ctx, t, adminEmail, adminName)
if err != nil {
_ = s.provisioner.Deprovision(ctx, t)
return OnboardResult{}, err
}
return OnboardResult{Tenant: t, Admin: admin}, nil
}
func (s *OnboardingService) setupAdmin(ctx context.Context, t Tenant, adminEmail, adminName string) (user.User, error) {
pool, err := pgxpool.New(ctx, t.DBDSN)
if err != nil {
return user.User{}, fmt.Errorf("verbindung zur neuen tenant-datenbank: %w", err)
}
defer pool.Close()
schema, err := os.ReadFile(s.tenantSchemaPath)
if err != nil {
return user.User{}, fmt.Errorf("tenant-schema lesen: %w", err)
}
if _, err := pool.Exec(ctx, string(schema)); err != nil {
return user.User{}, fmt.Errorf("tenant-schema anwenden: %w", err)
}
store := user.NewTenantUserStore(pool)
return store.Create(ctx, adminEmail, adminName)
}
-169
View File
@@ -1,169 +0,0 @@
package tenant
import (
"context"
"errors"
"os"
"strings"
"sync"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// schemaPathForTest liefert den Pfad zur echten Migration, damit Tests exakt
// dasselbe Schema anwenden wie die Produktion (keine Zweitkopie des SQL).
const schemaPathForTest = "../../migrations/tenant/0001_users.up.sql"
func TestOnboarding_ValidationErrors(t *testing.T) {
// Reine Eingabevalidierung, braucht keine Datenbank.
svc := NewOnboardingService(nil, schemaPathForTest)
ctx := context.Background()
cases := []struct {
name string
slug string
tenantName string
email string
adminName string
wantErr error
}{
{"leerer name", "acme", "", "a@b.de", "Admin", ErrMissingField},
{"leerer admin-name", "acme", "Acme", "a@b.de", "", ErrMissingField},
{"ungueltiger slug", "Acme!", "Acme", "a@b.de", "Admin", ErrInvalidSlug},
{"ungueltige email", "acme", "Acme", "keine-email", "Admin", user.ErrInvalidEmail},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := svc.Onboard(ctx, c.slug, c.tenantName, c.email, c.adminName)
if !errors.Is(err, c.wantErr) {
t.Fatalf("erwartet %v, habe %v", c.wantErr, err)
}
})
}
}
func newTestOnboardingService(t *testing.T) (*OnboardingService, func()) {
t.Helper()
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
registryPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("registry pool: %v", err)
}
if _, err := registryPool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("registry-schema: %v", err)
}
registry := NewRegistry(registryPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
svc := NewOnboardingService(provisioner, schemaPathForTest)
cleanup := func() {
registryPool.Close()
adminPool.Close()
}
return svc, cleanup
}
// Akzeptanzkriterien 1+2: Mandant + Erst-Admin in einem Vorgang.
func TestOnboarding_CreatesTenantAndAdmin(t *testing.T) {
svc, cleanup := newTestOnboardingService(t)
defer cleanup()
ctx := context.Background()
t.Cleanup(func() {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
p, _ := pgxpool.New(ctx, adminDSN)
defer p.Close()
_, _ = p.Exec(ctx, `DELETE FROM tenants WHERE slug = 'onboardtest'`)
_, _ = p.Exec(ctx, `DROP DATABASE IF EXISTS tenant_onboardtest`)
})
result, err := svc.Onboard(ctx, "onboardtest", "Onboard Test GmbH", "admin@onboardtest.example", "Erste Admin")
if err != nil {
t.Fatalf("onboard: %v", err)
}
if result.Tenant.Slug != "onboardtest" {
t.Fatalf("tenant slug = %q", result.Tenant.Slug)
}
if result.Admin.Email != "admin@onboardtest.example" {
t.Fatalf("admin email = %q", result.Admin.Email)
}
pool, err := pgxpool.New(ctx, result.Tenant.DBDSN)
if err != nil {
t.Fatalf("connect tenant db: %v", err)
}
defer pool.Close()
var count int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE email = $1`, "admin@onboardtest.example").Scan(&count); err != nil {
t.Fatalf("admin in tenant-db pruefen: %v", err)
}
if count != 1 {
t.Fatalf("erwartet genau einen admin in der tenant-db, habe %d", count)
}
}
// Akzeptanzkriterium 3 + Pruefung 1: doppelte Registrierung wird abgewiesen,
// auch bei zwei parallelen Anfragen entsteht kein Doppel-Tenant.
func TestOnboarding_RejectsDuplicateSlugConcurrently(t *testing.T) {
svc, cleanup := newTestOnboardingService(t)
defer cleanup()
ctx := context.Background()
t.Cleanup(func() {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
p, _ := pgxpool.New(ctx, adminDSN)
defer p.Close()
_, _ = p.Exec(ctx, `DELETE FROM tenants WHERE slug = 'dupetest'`)
_, _ = p.Exec(ctx, `DROP DATABASE IF EXISTS tenant_dupetest`)
})
var wg sync.WaitGroup
results := make([]error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, err := svc.Onboard(ctx, "dupetest", "Dupe Test", "admin@dupetest.example", "Admin")
results[i] = err
}(i)
}
wg.Wait()
successCount := 0
for _, err := range results {
if err == nil {
successCount++
} else if !errors.Is(err, ErrTenantExists) {
t.Fatalf("unerwarteter fehler: %v", err)
}
}
if successCount != 1 {
t.Fatalf("erwartet genau einen erfolgreichen onboarding-vorgang, habe %d", successCount)
}
}
-31
View File
@@ -2,17 +2,11 @@ package tenant
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrTenantExists wird zurueckgegeben, wenn Slug oder Datenbankname eines
// Mandanten bereits vergeben sind (TEN-02 Akzeptanzkriterium 3).
var ErrTenantExists = errors.New("tenant: slug bereits vergeben")
// Provisioner legt fuer jeden neuen Mandanten eine vollstaendig isolierte
// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry
// (Akzeptanzkriterium 2). Zwei Mandanten-Datenbanken sind danach auf
@@ -45,14 +39,6 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
// CREATE DATABASE erlaubt keine Parameter-Platzhalter; slug ist durch
// ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen.
if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
var pgErr *pgconn.PgError
// 42P04 (duplicate_database) im Normalfall; bei zwei echt parallelen
// CREATE DATABASE auf denselben Namen liefert Postgres stattdessen den
// internen Unique-Index-Konflikt 23505 auf pg_database — beide Faelle
// bedeuten: der Tenant existiert bereits bzw. wird gerade angelegt.
if errors.As(err, &pgErr) && (pgErr.Code == "42P04" || pgErr.Code == "23505") {
return Tenant{}, ErrTenantExists
}
return Tenant{}, fmt.Errorf("tenant-datenbank anlegen: %w", err)
}
@@ -74,10 +60,6 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
if err != nil {
_ = tx.Rollback(ctx)
p.rollbackDatabase(ctx, dbName)
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" { // unique_violation auf slug
return Tenant{}, ErrTenantExists
}
return Tenant{}, err
}
@@ -94,16 +76,3 @@ func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant,
func (p *Provisioner) rollbackDatabase(ctx context.Context, dbName string) {
_, _ = p.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
}
// Deprovision entfernt einen bereits erfolgreich registrierten Tenant wieder
// vollstaendig (Registry-Eintrag + Datenbank). Wird als Kompensation
// verwendet, wenn ein Schritt NACH Provision fehlschlaegt (z.B. die
// Admin-Account-Anlage in TEN-02) — Provision selbst bleibt dadurch die
// einzige Stelle, die "erfolgreich provisioniert" garantieren muss.
func (p *Provisioner) Deprovision(ctx context.Context, t Tenant) error {
if err := p.registry.Delete(ctx, t.ID); err != nil {
return err
}
p.rollbackDatabase(ctx, t.DBName)
return nil
}
+6 -19
View File
@@ -35,34 +35,21 @@ func (r *Registry) insertTx(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, e
}
func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
// previous_status/deletion_scheduled_at werden mitgelesen, damit TEN-04
// (internal/tenant/lifecycle.go) den vollstaendigen Lebenszyklus-Zustand
// ueber GetBySlug ansehen kann, statt eine eigene Abfrage zu duplizieren.
var t Tenant
row := r.pool.QueryRow(ctx, `
SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
SELECT id, slug, name, db_name, db_dsn, status, created_at
FROM tenants WHERE slug = $1
`, slug)
t, err := scanTenantWithLifecycle(row)
if err != nil {
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
}
return t, nil
}
// Delete entfernt einen Registry-Eintrag. Wird von Provisioner.Deprovision
// als Kompensation verwendet, wenn nach erfolgreicher Registrierung ein
// nachgelagerter Schritt (z.B. Admin-Account-Anlage in TEN-02) fehlschlaegt.
func (r *Registry) Delete(ctx context.Context, id string) error {
if _, err := r.pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, id); err != nil {
return fmt.Errorf("tenant loeschen: %w", err)
}
return nil
}
func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, slug, name, db_name, db_dsn, status, created_at, previous_status, deletion_scheduled_at
SELECT id, slug, name, db_name, db_dsn, status, created_at
FROM tenants ORDER BY created_at
`)
if err != nil {
@@ -72,8 +59,8 @@ func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
var out []Tenant
for rows.Next() {
t, err := scanTenantWithLifecycle(rows)
if err != nil {
var t Tenant
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
return nil, fmt.Errorf("tenant lesen: %w", err)
}
out = append(out, t)
-9
View File
@@ -12,10 +12,6 @@ type Status string
const (
StatusActive Status = "active"
// Lebenszyklus-Zustaende aus TEN-04 (siehe internal/tenant/lifecycle.go).
StatusSuspended Status = "suspended"
StatusPendingDeletion Status = "pending_deletion"
StatusDeleted Status = "deleted"
)
type Tenant struct {
@@ -26,11 +22,6 @@ type Tenant struct {
DBDSN string
Status Status
CreatedAt time.Time
// PreviousStatus und DeletionScheduledAt sind nur waehrend
// StatusPendingDeletion gesetzt (TEN-04) — sie halten fest, in welchen
// Zustand CancelDeletion zurueckkehrt und wann die Karenzzeit ablaeuft.
PreviousStatus *string
DeletionScheduledAt *time.Time
}
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
-298
View File
@@ -1,298 +0,0 @@
// Package tenantadmin implementiert Core TEN-05: das Backend-API fuer die
// Tenant-Verwaltungsoberflaeche. Enthaelt KEINE eigene Provisioning-/
// Lifecycle-/Settings-Logik, sondern ist ein duenner Vermittler ueber
// internal/tenant (TEN-01/TEN-04), internal/tenantsettings (TEN-03) und
// internal/user.SuperadminStore (Berechtigungspruefung) — Ticket-Vorgabe:
// "Verwaltungsoberflaeche im Stil einer schlanken Zitadel-Console".
package tenantadmin
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
// ErrNotSuperadmin wird geliefert, wenn der Aufrufer keine aktive
// Superadmin-Identitaet hat (Akzeptanzkriterium 1 / Pruefung 1: die
// Oberflaeche zeigt Mandanten nur berechtigten Superadmins).
var ErrNotSuperadmin = errors.New("tenantadmin: aufrufer ist kein aktiver superadmin")
// ErrMissingDisplayName wird geliefert, wenn ein Einstellungs-Update ohne
// das Pflichtfeld Anzeigename versucht wird (Akzeptanzkriterium 2 / Pruefung 2).
var ErrMissingDisplayName = errors.New("tenantadmin: anzeigename ist ein pflichtfeld")
type Handler struct {
registry *tenant.Registry
lifecycle *tenant.Lifecycle
settings *tenantsettings.Store
superadmins *user.SuperadminStore
}
func NewHandler(registry *tenant.Registry, lifecycle *tenant.Lifecycle, settings *tenantsettings.Store, superadmins *user.SuperadminStore) *Handler {
return &Handler{registry: registry, lifecycle: lifecycle, settings: settings, superadmins: superadmins}
}
// requireSuperadmin prueft, dass der Aufrufer ein EXISTIERENDER, AKTIVER
// Superadmin ist — es gibt (bewusst, siehe internal/user.SuperadminStore)
// keine Tenant-Scoping-Dimension fuer Superadmins: wer ueberhaupt Zugriff
// hat, sieht alle Mandanten. Alles andere wird abgelehnt, bevor irgendeine
// Mandantendatei gelesen wird (Fail-Safe-Default).
func (h *Handler) requireSuperadmin(ctx context.Context, superadminID string) error {
if superadminID == "" {
return ErrNotSuperadmin
}
admin, err := h.superadmins.Get(ctx, superadminID)
if err != nil {
return ErrNotSuperadmin
}
if admin.Status != user.StatusActive {
return ErrNotSuperadmin
}
return nil
}
// TenantListItem ist die fuer die Uebersichtsliste relevante Projektion
// (Akzeptanzkriterium 1).
type TenantListItem struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Status string `json:"status"`
}
// ListTenants liefert alle Mandanten, optional gefiltert nach Suchbegriff
// (Slug/Name, Teilstring, case-insensitive) und Status — beides serverseitig,
// damit die Oberflaeche nicht selbst ueber unautorisierte Datensaetze
// filtern muss (Akzeptanzkriterium 1: Suche und Filter).
func (h *Handler) ListTenants(ctx context.Context, superadminID, search, statusFilter string) ([]TenantListItem, error) {
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
return nil, err
}
all, err := h.registry.List(ctx)
if err != nil {
return nil, fmt.Errorf("mandanten auflisten: %w", err)
}
search = strings.ToLower(strings.TrimSpace(search))
out := make([]TenantListItem, 0, len(all))
for _, t := range all {
if statusFilter != "" && string(t.Status) != statusFilter {
continue
}
if search != "" && !strings.Contains(strings.ToLower(t.Slug), search) && !strings.Contains(strings.ToLower(t.Name), search) {
continue
}
out = append(out, TenantListItem{ID: t.ID, Slug: t.Slug, Name: t.Name, Status: string(t.Status)})
}
return out, nil
}
// TenantDetail buendelt Stammdaten und Einstellungen fuer die Detailansicht.
type TenantDetail struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Status string `json:"status"`
Settings tenantsettings.Settings `json:"settings"`
}
func (h *Handler) GetTenantDetail(ctx context.Context, superadminID, slug string) (TenantDetail, error) {
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
return TenantDetail{}, err
}
t, err := h.registry.GetBySlug(ctx, slug)
if err != nil {
return TenantDetail{}, fmt.Errorf("mandant laden: %w", err)
}
s, err := h.settings.Get(ctx, t.ID)
if err != nil {
return TenantDetail{}, fmt.Errorf("einstellungen laden: %w", err)
}
return TenantDetail{ID: t.ID, Slug: t.Slug, Name: t.Name, Status: string(t.Status), Settings: s}, nil
}
// SettingsPatch ist die vom Formular gesendete Aenderung. DisplayName ist
// KEIN Zeiger, weil es Pflichtfeld ist (Akzeptanzkriterium 2) — die anderen
// Felder bleiben optional (Zeiger = "unveraendert lassen", siehe
// tenantsettings.Patch).
type SettingsPatch struct {
DisplayName string
LogoURL *string
ColorScheme *string
Timezone *string
Language *string
}
// UpdateSettings validiert das Pflichtfeld Anzeigename, BEVOR irgendein
// Schreibzugriff erfolgt (Akzeptanzkriterium 2 / Pruefung 2: unvollstaendige
// Pflichtfelder werden serverseitig verhindert, nicht nur clientseitig).
func (h *Handler) UpdateSettings(ctx context.Context, superadminID, slug string, patch SettingsPatch) (tenantsettings.Settings, error) {
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
return tenantsettings.Settings{}, err
}
if strings.TrimSpace(patch.DisplayName) == "" {
return tenantsettings.Settings{}, ErrMissingDisplayName
}
t, err := h.registry.GetBySlug(ctx, slug)
if err != nil {
return tenantsettings.Settings{}, fmt.Errorf("mandant laden: %w", err)
}
displayName := patch.DisplayName
return h.settings.Update(ctx, t.ID, tenantsettings.Patch{
DisplayName: &displayName,
LogoURL: patch.LogoURL,
ColorScheme: patch.ColorScheme,
Timezone: patch.Timezone,
Language: patch.Language,
})
}
// LifecycleAction sind die von der Oberflaeche ausloesbaren Aktionen
// (Akzeptanzkriterium 3) — je EIN Wort pro Aktion, damit ein Bestaetigungs-
// dialog im Frontend darauf verzweigen kann, ohne HTTP-Interna zu kennen.
type LifecycleAction string
const (
ActionSuspend LifecycleAction = "suspend"
ActionReactivate LifecycleAction = "reactivate"
ActionScheduleDeletion LifecycleAction = "schedule_deletion"
ActionCancelDeletion LifecycleAction = "cancel_deletion"
)
var ErrUnknownAction = errors.New("tenantadmin: unbekannte lifecycle-aktion")
// DefaultDeletionGracePeriod ist die Karenzzeit, die die Oberflaeche beim
// Ausloesen von ActionScheduleDeletion verwendet — Konfiguration dieses
// Pakets, nicht von internal/tenant (das lifecycle.go generisch mit einer
// uebergebenen Dauer arbeitet, siehe TEN-04).
const DefaultDeletionGracePeriod = 30 * 24 * time.Hour
// PerformLifecycleAction fuehrt EINEN der vier Uebergaenge aus. Ungueltige
// Zustandsuebergaenge (z.B. "suspend" auf einen bereits geloeschten Tenant)
// werden von internal/tenant.Lifecycle selbst mit ErrInvalidTransition
// abgelehnt (siehe TEN-04) — dieses Paket dupliziert diese Pruefung nicht.
func (h *Handler) PerformLifecycleAction(ctx context.Context, superadminID, slug string, action LifecycleAction) (tenant.Tenant, error) {
if err := h.requireSuperadmin(ctx, superadminID); err != nil {
return tenant.Tenant{}, err
}
switch action {
case ActionSuspend:
return h.registry.Suspend(ctx, slug)
case ActionReactivate:
return h.registry.Reactivate(ctx, slug)
case ActionScheduleDeletion:
return h.registry.ScheduleDeletion(ctx, slug, DefaultDeletionGracePeriod)
case ActionCancelDeletion:
return h.registry.CancelDeletion(ctx, slug)
default:
return tenant.Tenant{}, ErrUnknownAction
}
}
// --- HTTP-Bindung ---
//
// Der Aufrufer wird bewusst als expliziter Query-/Body-Parameter
// "superadmin" statt aus einem Auth-Header gelesen — Session-/Token-basierte
// Authentifizierung ist Sache von IAM-01/IAM-02 und wird hier NICHT
// dupliziert (Kein Umbau angrenzender Bereiche); dieser Handler ist ein
// duenner Entwicklungs-/Testzugang, der genau die in requireSuperadmin
// beschriebene Berechtigungspruefung durchsetzt.
func (h *Handler) ListTenantsHandler(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
items, err := h.ListTenants(r.Context(), q.Get("superadmin"), q.Get("search"), q.Get("status"))
writeResult(w, items, err)
}
func (h *Handler) TenantDetailHandler(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
detail, err := h.GetTenantDetail(r.Context(), q.Get("superadmin"), q.Get("slug"))
writeResult(w, detail, err)
}
type settingsRequest struct {
Superadmin string `json:"superadmin"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
LogoURL *string `json:"logo_url"`
ColorScheme *string `json:"color_scheme"`
Timezone *string `json:"timezone"`
Language *string `json:"language"`
}
func (h *Handler) UpdateSettingsHandler(w http.ResponseWriter, r *http.Request) {
var req settingsRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
return
}
result, err := h.UpdateSettings(r.Context(), req.Superadmin, req.Slug, SettingsPatch{
DisplayName: req.DisplayName,
LogoURL: req.LogoURL,
ColorScheme: req.ColorScheme,
Timezone: req.Timezone,
Language: req.Language,
})
writeResult(w, result, err)
}
type lifecycleRequest struct {
Superadmin string `json:"superadmin"`
Slug string `json:"slug"`
Action LifecycleAction `json:"action"`
}
func (h *Handler) LifecycleActionHandler(w http.ResponseWriter, r *http.Request) {
var req lifecycleRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
return
}
result, err := h.PerformLifecycleAction(r.Context(), req.Superadmin, req.Slug, req.Action)
writeResult(w, result, err)
}
func decodeJSON(r *http.Request, v any) error {
return json.NewDecoder(r.Body).Decode(v)
}
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})
}
func writeResult(w http.ResponseWriter, body any, err error) {
if err == nil {
writeJSON(w, http.StatusOK, body)
return
}
switch {
case errors.Is(err, ErrNotSuperadmin):
writeError(w, http.StatusForbidden, err.Error())
case errors.Is(err, ErrMissingDisplayName):
writeError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, ErrUnknownAction), errors.Is(err, tenant.ErrInvalidTransition):
writeError(w, http.StatusBadRequest, err.Error())
case errors.Is(err, tenant.ErrTenantNotFound):
writeError(w, http.StatusNotFound, err.Error())
default:
writeError(w, http.StatusInternalServerError, err.Error())
}
}
-232
View File
@@ -1,232 +0,0 @@
package tenantadmin
import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
"gitea.perlbach24.de/scripte/nexarch/internal/tenantsettings"
"gitea.perlbach24.de/scripte/nexarch/internal/user"
)
type testEnv struct {
handler *Handler
superadminID string
slug string
adminPool *pgxpool.Pool
registry *tenant.Registry
}
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()
registryPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("registry pool: %v", err)
}
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
if _, err := registryPool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE, db_dsn TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), previous_status TEXT, deletion_scheduled_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS tenant_settings (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id), display_name TEXT, logo_url TEXT,
color_scheme TEXT, timezone TEXT, language TEXT, version INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tenant_settings_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL, display_name TEXT,
logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT, version INT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS 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()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
registry := tenant.NewRegistry(registryPool)
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
provisioner := tenant.NewProvisioner(adminPool, registry, dsnTemplate)
lifecycle := tenant.NewLifecycle(registry, adminPool)
settingsStore := tenantsettings.NewStore(registryPool)
superadmins := user.NewSuperadminStore(registryPool)
slug := fmt.Sprintf("tadm_%d", time.Now().UnixNano()%1_000_000_000)
if _, err := provisioner.Provision(ctx, slug, "Test Mandant "+slug); err != nil {
t.Fatalf("provision: %v", err)
}
admin, err := superadmins.Create(ctx, fmt.Sprintf("admin-%d@example.com", time.Now().UnixNano()), "Test Superadmin")
if err != nil {
t.Fatalf("superadmin anlegen: %v", err)
}
handler := NewHandler(registry, lifecycle, settingsStore, superadmins)
cleanup := func() {
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, "tenant_"+slug))
_, _ = registryPool.Exec(ctx, `DELETE FROM tenant_settings_history WHERE tenant_id IN (SELECT id FROM tenants WHERE slug = $1)`, slug)
_, _ = registryPool.Exec(ctx, `DELETE FROM tenant_settings WHERE tenant_id IN (SELECT id FROM tenants WHERE slug = $1)`, slug)
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug = $1`, slug)
_, _ = registryPool.Exec(ctx, `DELETE FROM superadmins WHERE id = $1`, admin.ID)
registryPool.Close()
adminPool.Close()
}
return testEnv{handler: handler, superadminID: admin.ID, slug: slug, adminPool: adminPool, registry: registry}, cleanup
}
// Akzeptanzkriterium 1 + Pruefung 1: nur berechtigte (aktive) Superadmins
// sehen die Mandantenliste ueberhaupt.
func TestListTenants_RejectsNonSuperadmin(t *testing.T) {
env, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
_, err := env.handler.ListTenants(ctx, "irgendeine-nicht-existierende-id", "", "")
if !errors.Is(err, ErrNotSuperadmin) {
t.Fatalf("erwartet ErrNotSuperadmin, habe: %v", err)
}
}
func TestListTenants_ShowsSearchAndStatusFilterResults(t *testing.T) {
env, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
all, err := env.handler.ListTenants(ctx, env.superadminID, "", "")
if err != nil {
t.Fatalf("list: %v", err)
}
found := false
for _, item := range all {
if item.Slug == env.slug {
found = true
if item.Status != "active" {
t.Fatalf("status = %q, want active", item.Status)
}
}
}
if !found {
t.Fatal("erwartet test-tenant in ungefilterter liste")
}
bySearch, err := env.handler.ListTenants(ctx, env.superadminID, env.slug, "")
if err != nil {
t.Fatalf("list mit suche: %v", err)
}
if len(bySearch) != 1 || bySearch[0].Slug != env.slug {
t.Fatalf("suche nach slug lieferte unerwartetes ergebnis: %+v", bySearch)
}
byWrongStatus, err := env.handler.ListTenants(ctx, env.superadminID, env.slug, "suspended")
if err != nil {
t.Fatalf("list mit statusfilter: %v", err)
}
if len(byWrongStatus) != 0 {
t.Fatalf("statusfilter haette test-tenant (status=active) ausfiltern muessen, habe: %+v", byWrongStatus)
}
}
// Akzeptanzkriterium 2 + Pruefung 2: fehlender Pflichtwert (Anzeigename)
// wird serverseitig abgelehnt, bevor etwas gespeichert wird.
func TestUpdateSettings_RejectsMissingDisplayName(t *testing.T) {
env, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
_, err := env.handler.UpdateSettings(ctx, env.superadminID, env.slug, SettingsPatch{DisplayName: " "})
if !errors.Is(err, ErrMissingDisplayName) {
t.Fatalf("erwartet ErrMissingDisplayName, habe: %v", err)
}
}
func TestUpdateSettings_AppliesValidPatch(t *testing.T) {
env, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
updated, err := env.handler.UpdateSettings(ctx, env.superadminID, env.slug, SettingsPatch{DisplayName: "Neuer Name"})
if err != nil {
t.Fatalf("update: %v", err)
}
if updated.DisplayName != "Neuer Name" {
t.Fatalf("displayname = %q, want 'Neuer Name'", updated.DisplayName)
}
detail, err := env.handler.GetTenantDetail(ctx, env.superadminID, env.slug)
if err != nil {
t.Fatalf("detail: %v", err)
}
if detail.Settings.DisplayName != "Neuer Name" {
t.Fatalf("detail zeigt nicht den aktualisierten namen: %+v", detail.Settings)
}
}
// Akzeptanzkriterium 3: Lifecycle-Aktionen sind ausloesbar und wirken sich
// auf den tatsaechlichen Mandantenstatus aus.
func TestPerformLifecycleAction_SuspendAndReactivate(t *testing.T) {
env, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
suspended, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionSuspend)
if err != nil {
t.Fatalf("suspend: %v", err)
}
if suspended.Status != tenant.StatusSuspended {
t.Fatalf("status = %q, want suspended", suspended.Status)
}
reactivated, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionReactivate)
if err != nil {
t.Fatalf("reactivate: %v", err)
}
if reactivated.Status != tenant.StatusActive {
t.Fatalf("status = %q, want active", reactivated.Status)
}
}
func TestPerformLifecycleAction_RejectsInvalidTransition(t *testing.T) {
env, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
// Reaktivieren eines bereits aktiven Tenants ist kein gueltiger Uebergang.
_, err := env.handler.PerformLifecycleAction(ctx, env.superadminID, env.slug, ActionReactivate)
if !errors.Is(err, tenant.ErrInvalidTransition) {
t.Fatalf("erwartet ErrInvalidTransition, habe: %v", err)
}
}
func TestPerformLifecycleAction_RejectsNonSuperadmin(t *testing.T) {
env, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
_, err := env.handler.PerformLifecycleAction(ctx, "keine-berechtigung", env.slug, ActionSuspend)
if !errors.Is(err, ErrNotSuperadmin) {
t.Fatalf("erwartet ErrNotSuperadmin, habe: %v", err)
}
}
-74
View File
@@ -1,74 +0,0 @@
package tenantsettings
import (
"encoding/json"
"net/http"
)
// Handler stellt Lesen/Schreiben der Tenant-Einstellungen als schlanke
// Vorbereitung der Schnittstelle bereit (API-01/TEN-05 sind nicht Teil
// dieser Kachel). tenantID wird hier als Pfad-/Query-Parameter erwartet,
// eine echte Auth-/Berechtigungspruefung kommt erst mit IAM-02/RBAC-01.
type Handler struct {
store *Store
}
func NewHandler(store *Store) *Handler {
return &Handler{store: store}
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
tenantID := r.URL.Query().Get("tenant_id")
if tenantID == "" {
http.Error(w, "tenant_id fehlt", http.StatusBadRequest)
return
}
settings, err := h.store.Get(r.Context(), tenantID)
if err != nil {
http.Error(w, "einstellungen konnten nicht geladen werden", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, settings)
}
type updateRequest struct {
DisplayName *string `json:"display_name"`
LogoURL *string `json:"logo_url"`
ColorScheme *string `json:"color_scheme"`
Timezone *string `json:"timezone"`
Language *string `json:"language"`
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
tenantID := r.URL.Query().Get("tenant_id")
if tenantID == "" {
http.Error(w, "tenant_id fehlt", http.StatusBadRequest)
return
}
var req updateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
settings, err := h.store.Update(r.Context(), tenantID, Patch{
DisplayName: req.DisplayName,
LogoURL: req.LogoURL,
ColorScheme: req.ColorScheme,
Timezone: req.Timezone,
Language: req.Language,
})
if err != nil {
http.Error(w, "einstellungen konnten nicht gespeichert werden", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, settings)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
-156
View File
@@ -1,156 +0,0 @@
package tenantsettings
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupTest(t *testing.T) (*Store, string, 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_settings (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
display_name TEXT, logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT,
version INT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tenant_settings_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL,
display_name TEXT, logo_url TEXT, color_scheme TEXT, timezone TEXT, language TEXT,
version INT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
var tenantA, tenantB string
if err := pool.QueryRow(ctx, `INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ('ts_a', 'A', 'tenant_ts_a', 'unused') RETURNING id`).Scan(&tenantA); err != nil {
t.Fatalf("tenant a anlegen: %v", err)
}
if err := pool.QueryRow(ctx, `INSERT INTO tenants (slug, name, db_name, db_dsn) VALUES ('ts_b', 'B', 'tenant_ts_b', 'unused') RETURNING id`).Scan(&tenantB); err != nil {
t.Fatalf("tenant b anlegen: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM tenant_settings_history WHERE tenant_id IN ($1, $2)`, tenantA, tenantB)
_, _ = pool.Exec(ctx, `DELETE FROM tenant_settings WHERE tenant_id IN ($1, $2)`, tenantA, tenantB)
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id IN ($1, $2)`, tenantA, tenantB)
pool.Close()
}
return NewStore(pool), tenantA, tenantB, cleanup
}
func strp(s string) *string { return &s }
// Akzeptanzkriterium 2 + Pruefung 2: fehlende Werte liefern Defaults, kein Fehler.
func TestGet_UnsetTenantReturnsDefaults(t *testing.T) {
store, tenantA, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
got, err := store.Get(ctx, tenantA)
if err != nil {
t.Fatalf("get: %v", err)
}
want := Defaults()
if got != want {
t.Fatalf("erwartet defaults %+v, habe %+v", want, got)
}
}
// Akzeptanzkriterium 1 + 2: Teil-Update laesst nicht gesetzte Felder auf Default.
func TestUpdate_PartialPatchKeepsOtherFieldsAtDefault(t *testing.T) {
store, tenantA, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
got, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("Acme GmbH")})
if err != nil {
t.Fatalf("update: %v", err)
}
if got.DisplayName != "Acme GmbH" {
t.Fatalf("erwartet 'Acme GmbH', habe %q", got.DisplayName)
}
d := Defaults()
if got.ColorScheme != d.ColorScheme || got.Timezone != d.Timezone || got.Language != d.Language {
t.Fatalf("nicht gesetzte felder haetten auf default bleiben sollen, habe %+v", got)
}
if got.Version != 1 {
t.Fatalf("erwartet version 1, habe %d", got.Version)
}
}
// Akzeptanzkriterium 1 + Pruefung 1: Aenderung eines Tenants wirkt sich
// nicht auf einen anderen aus.
func TestUpdate_IsolatedBetweenTenants(t *testing.T) {
store, tenantA, tenantB, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("Tenant A")}); err != nil {
t.Fatalf("update a: %v", err)
}
gotB, err := store.Get(ctx, tenantB)
if err != nil {
t.Fatalf("get b: %v", err)
}
if gotB != Defaults() {
t.Fatalf("tenant b sollte unberuehrt bei defaults bleiben, habe %+v", gotB)
}
}
// Akzeptanzkriterium 3: Aenderungen versioniert nachvollziehbar.
func TestUpdate_HistoryTracksVersions(t *testing.T) {
store, tenantA, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("v1")}); err != nil {
t.Fatalf("update 1: %v", err)
}
if _, err := store.Update(ctx, tenantA, Patch{DisplayName: strp("v2")}); err != nil {
t.Fatalf("update 2: %v", err)
}
if _, err := store.Update(ctx, tenantA, Patch{ColorScheme: strp("dark")}); err != nil {
t.Fatalf("update 3: %v", err)
}
history, err := store.History(ctx, tenantA)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 3 {
t.Fatalf("erwartet 3 historieneintraege, habe %d", len(history))
}
if history[0].DisplayName != "v1" || history[0].Version != 1 {
t.Fatalf("history[0] unerwartet: %+v", history[0])
}
if history[1].DisplayName != "v2" || history[1].Version != 2 {
t.Fatalf("history[1] unerwartet: %+v", history[1])
}
if history[2].ColorScheme != "dark" || history[2].DisplayName != "v2" || history[2].Version != 3 {
t.Fatalf("history[2] unerwartet (display_name haette aus vorherigem update erhalten bleiben sollen): %+v", history[2])
}
}
-187
View File
@@ -1,187 +0,0 @@
// Package tenantsettings implementiert Core TEN-03: pro-Tenant-Einstellungen
// (Anzeigename, Logo, Farbschema, Zeitzone, Sprache) mit versionierter
// Aenderungshistorie und dokumentierten Systemvoreinstellungen.
package tenantsettings
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Settings ist der vollstaendig AUFGELOESTE Stand (jede leere Einstellung ist
// bereits durch ihren Default ersetzt) — Aufrufer muessen sich nie selbst um
// Systemvoreinstellungen kuemmern (Akzeptanzkriterium 2).
type Settings struct {
DisplayName string
LogoURL string
ColorScheme string
Timezone string
Language string
Version int
}
// Defaults liefert die dokumentierten Systemvoreinstellungen.
func Defaults() Settings {
return Settings{
DisplayName: "",
LogoURL: "",
ColorScheme: "system",
Timezone: "UTC",
Language: "de",
Version: 0,
}
}
// Patch enthaelt nur die Felder, die tatsaechlich geaendert werden sollen —
// nil bedeutet "unveraendert lassen", nicht "auf Default zuruecksetzen".
type Patch struct {
DisplayName *string
LogoURL *string
ColorScheme *string
Timezone *string
Language *string
}
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Get liefert die aufgeloesten Einstellungen eines Tenants. Existiert noch
// kein Datensatz oder sind einzelne Spalten NULL, werden die jeweiligen
// Systemvoreinstellungen eingesetzt (Akzeptanzkriterium 2 / Pruefung 2) —
// niemals ein Fehler wegen fehlender Werte.
func (s *Store) Get(ctx context.Context, tenantID string) (Settings, error) {
var displayName, logoURL, colorScheme, timezone, language *string
var version int
row := s.pool.QueryRow(ctx, `
SELECT display_name, logo_url, color_scheme, timezone, language, version
FROM tenant_settings WHERE tenant_id = $1
`, tenantID)
err := row.Scan(&displayName, &logoURL, &colorScheme, &timezone, &language, &version)
if errors.Is(err, pgx.ErrNoRows) {
return Defaults(), nil
}
if err != nil {
return Settings{}, fmt.Errorf("einstellungen lesen: %w", err)
}
d := Defaults()
return Settings{
DisplayName: orDefault(displayName, d.DisplayName),
LogoURL: orDefault(logoURL, d.LogoURL),
ColorScheme: orDefault(colorScheme, d.ColorScheme),
Timezone: orDefault(timezone, d.Timezone),
Language: orDefault(language, d.Language),
Version: version,
}, nil
}
func orDefault(v *string, def string) string {
if v == nil {
return def
}
return *v
}
// Update wendet patch auf den aktuellen Stand an, erhoeht die Version und
// schreibt Stand + Historieneintrag atomar (Akzeptanzkriterium 3). Nicht in
// patch gesetzte Felder bleiben unveraendert (bzw. weiterhin auf Default,
// wenn sie es vorher schon waren).
func (s *Store) Update(ctx context.Context, tenantID string, patch Patch) (Settings, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return Settings{}, fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage *string
var version int
row := tx.QueryRow(ctx, `
SELECT display_name, logo_url, color_scheme, timezone, language, version
FROM tenant_settings WHERE tenant_id = $1 FOR UPDATE
`, tenantID)
err = row.Scan(&rawDisplayName, &rawLogoURL, &rawColorScheme, &rawTimezone, &rawLanguage, &version)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Settings{}, fmt.Errorf("aktuellen stand lesen: %w", err)
}
if patch.DisplayName != nil {
rawDisplayName = patch.DisplayName
}
if patch.LogoURL != nil {
rawLogoURL = patch.LogoURL
}
if patch.ColorScheme != nil {
rawColorScheme = patch.ColorScheme
}
if patch.Timezone != nil {
rawTimezone = patch.Timezone
}
if patch.Language != nil {
rawLanguage = patch.Language
}
newVersion := version + 1
if _, err := tx.Exec(ctx, `
INSERT INTO tenant_settings (tenant_id, display_name, logo_url, color_scheme, timezone, language, version, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
ON CONFLICT (tenant_id) DO UPDATE SET
display_name = $2, logo_url = $3, color_scheme = $4, timezone = $5, language = $6, version = $7, updated_at = now()
`, tenantID, rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage, newVersion); err != nil {
return Settings{}, fmt.Errorf("einstellungen speichern: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO tenant_settings_history (tenant_id, display_name, logo_url, color_scheme, timezone, language, version, changed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, now())
`, tenantID, rawDisplayName, rawLogoURL, rawColorScheme, rawTimezone, rawLanguage, newVersion); err != nil {
return Settings{}, fmt.Errorf("historie schreiben: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return Settings{}, fmt.Errorf("transaktion committen: %w", err)
}
return s.Get(ctx, tenantID)
}
// History liefert die vollstaendige Versionshistorie eines Tenants
// (Akzeptanzkriterium 3 / Pruefung 3-Vorbereitung).
func (s *Store) History(ctx context.Context, tenantID string) ([]Settings, error) {
rows, err := s.pool.Query(ctx, `
SELECT display_name, logo_url, color_scheme, timezone, language, version
FROM tenant_settings_history WHERE tenant_id = $1 ORDER BY version
`, tenantID)
if err != nil {
return nil, fmt.Errorf("historie abfragen: %w", err)
}
defer rows.Close()
d := Defaults()
var out []Settings
for rows.Next() {
var displayName, logoURL, colorScheme, timezone, language *string
var version int
if err := rows.Scan(&displayName, &logoURL, &colorScheme, &timezone, &language, &version); err != nil {
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
}
out = append(out, Settings{
DisplayName: orDefault(displayName, d.DisplayName),
LogoURL: orDefault(logoURL, d.LogoURL),
ColorScheme: orDefault(colorScheme, d.ColorScheme),
Timezone: orDefault(timezone, d.Timezone),
Language: orDefault(language, d.Language),
Version: version,
})
}
return out, rows.Err()
}
+45
View File
@@ -103,6 +103,51 @@ func (s *TenantUserStore) Deactivate(ctx context.Context, id string) (User, erro
`, 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 {
@@ -1,2 +0,0 @@
ALTER TABLE tenants DROP COLUMN previous_status;
ALTER TABLE tenants DROP COLUMN deletion_scheduled_at;
-6
View File
@@ -1,6 +0,0 @@
-- Lebenszyklus-Zustaende fuer Mandanten (TEN-04, siehe core-kanban/tickets/TEN-04.md).
-- previous_status haelt den Zustand VOR einer Loeschvormerkung, damit
-- CancelDeletion "den vorherigen Zustand vollstaendig wiederherstellt"
-- (aktiv ODER suspendiert), statt hart auf 'active' zurueckzusetzen.
ALTER TABLE tenants ADD COLUMN previous_status TEXT;
ALTER TABLE tenants ADD COLUMN deletion_scheduled_at TIMESTAMPTZ;
-2
View File
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS tenant_settings_history;
DROP TABLE IF EXISTS tenant_settings;
-27
View File
@@ -1,27 +0,0 @@
-- Pro-Tenant-Einstellungen & Branding (TEN-03, siehe core-kanban/tickets/TEN-03.md).
-- Alle Spalten NULLABLE: ein fehlender Wert bedeutet "System-Default
-- verwenden" (Akzeptanzkriterium 2), niemals ein Fehler.
CREATE TABLE tenant_settings (
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
display_name TEXT,
logo_url TEXT,
color_scheme TEXT,
timezone TEXT,
language TEXT,
version INT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE tenant_settings_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
display_name TEXT,
logo_url TEXT,
color_scheme TEXT,
timezone TEXT,
language TEXT,
version INT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX tenant_settings_history_tenant_idx ON tenant_settings_history (tenant_id, version);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS role_assignment_history;
DROP TABLE IF EXISTS role_assignments;
@@ -0,0 +1,23 @@
-- Rollenzuweisung pro Benutzer (RBAC-01, siehe core-kanban/tickets/RBAC-01.md).
-- Nur 'user' und 'tenant_admin' sind hier zuweisbar — 'superadmin' lebt
-- mandantenuebergreifend in der Registry (IAM-01 SuperadminStore) und hat
-- daher bewusst KEINE Zeile in dieser tenant-lokalen Tabelle (Akzeptanz-
-- kriterium 1: nur Zuweisungen innerhalb der erlaubten Matrix).
CREATE TABLE role_assignments (
user_id UUID PRIMARY KEY REFERENCES users(id),
role TEXT NOT NULL CHECK (role IN ('user', 'tenant_admin')),
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Vollstaendige Historie jeder Rollenaenderung (Akzeptanzkriterium 3: wer
-- hat wann welche Rolle vergeben).
CREATE TABLE role_assignment_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
role TEXT NOT NULL,
granted_by TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX role_assignment_history_user_idx ON role_assignment_history (user_id, granted_at);
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN password_hash;
@@ -0,0 +1,3 @@
-- 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 '';
+2
View File
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS ldap_group_role_mapping;
DROP TABLE IF EXISTS ldap_config;
+22
View File
@@ -0,0 +1,22 @@
-- LDAP/Active-Directory-Anbindung (IAM-05, siehe core-kanban/tickets/IAM-05.md).
-- Bind-Credentials liegen NIE in dieser Tabelle im Klartext — bind_password_env
-- nennt nur den Namen einer Umgebungsvariable, aus der das Passwort zur
-- Laufzeit gelesen wird (Projekt-Konvention: Secrets nur ueber Env-Vars).
CREATE TABLE ldap_config (
id BOOLEAN PRIMARY KEY DEFAULT true CHECK (id),
url TEXT NOT NULL,
bind_dn TEXT NOT NULL,
bind_password_env TEXT NOT NULL,
base_dn TEXT NOT NULL,
user_filter TEXT NOT NULL DEFAULT '(objectClass=person)',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Erlaubnis-Matrix LDAP-Gruppe -> NEXARCH-Rolle (Akzeptanzkriterium 3).
-- Eine LDAP-Gruppe OHNE Eintrag hier vergibt KEINE Rolle — kein impliziter
-- Zugriff durch Gruppennamen-Zufall (Privilege-Escalation-Schutz, siehe
-- "Bekannte Fehler vermeiden" im Ticket).
CREATE TABLE ldap_group_role_mapping (
ldap_group TEXT PRIMARY KEY,
role TEXT NOT NULL CHECK (role IN ('user', 'tenant_admin'))
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS oidc_states;
+10
View File
@@ -0,0 +1,10 @@
-- CSRF-/Replay-Schutz fuer OIDC-Login (IAM-06, siehe
-- core-kanban/tickets/IAM-06.md). Einmal-Verwendung erzwungen ueber
-- used_at IS NULL in der Consume-Abfrage, analog IAM-03/IAM-09.
CREATE TABLE oidc_states (
state TEXT PRIMARY KEY,
nonce TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-14
View File
@@ -1,25 +1,11 @@
#!/usr/bin/env bash
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
ROLE="nexarch_test"
export PGPASSWORD="$PASS"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings_history CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenant_settings CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
for db in $dbs; do
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
done
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
-12
View File
@@ -1,24 +1,12 @@
#!/usr/bin/env bash
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
cd "$(dirname "$0")/.."
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
echo "== go build =="
go build ./...
echo "== go vet =="
go vet ./...
echo "== go test (-p 1) =="
go test ./... -p 1 -count=1
-45
View File
@@ -1,45 +0,0 @@
# @nexarch/shl — UI-Shell & Design-System (Core SHL-01)
Gemeinsames Paket für alle NEXARCH-Modul-Frontends (Core, DMS, Mail, Archive, Workflow, AI, Connect).
Ein Modul-Frontend importiert ausschließlich über `index.ts`, kopiert keine Komponenten oder Tokens lokal.
## Enthält
- **Design-Tokens** (`tokens/tokens.ts`) — Farbe (Hell/Dunkel), Abstand, Typografie. Kontrastwerte gegen WCAG 2.1 AA geprüft (siehe `__tests__/tokens.test.ts`).
- **Theming** (`theme/ThemeProvider.tsx`) — zentrale Hell/Dunkel-Umschaltung, respektiert `prefers-color-scheme`, persistiert in `localStorage`.
- **i18n-Rahmen** (`i18n/i18n.tsx`) — Umschaltmechanismus Deutsch/Englisch. Modul-Frontends registrieren ihre fachlichen Textbausteine über `registerMessages()`, statt einen eigenen Mechanismus zu bauen.
- **Basis-Komponenten** (`components/`) — `Shell` (Layout + Navigation), `Table`, `Dialog`, `TextField`/`SelectField`/`CheckboxField`, `Toast`. Alle mit WCAG-2.1-AA-Grundlage (Tastaturbedienung, ARIA-Attribute, Fokus-Management).
## Verwendung in einem Modul-Frontend
```tsx
import { ThemeProvider, I18nProvider, ToastProvider, Shell } from "@nexarch/shl";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider>
<I18nProvider initialLocale="de">
<ToastProvider>
<Shell modules={[]} tenantLabel="Mandant XY">
{children}
</Shell>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
);
}
```
## Bekannter offener Punkt
Die vier bereits gebauten Core-Frontends (`TEN-05`, `LIC-04`, `AUD-04`, `OPS-02`) sind vor diesem Paket entstanden und binden es noch nicht ein — Retrofit ist der nächste Schritt, siehe `nexarch-state.json`.
## Tests
Ausführung auf dem Test-Host (nicht lokal, siehe Projekt-Testinfrastruktur):
```bash
npm install
npm test
npm run typecheck
```
-38
View File
@@ -1,38 +0,0 @@
// Prüfung: Tastaturbedienung der Basis-Komponenten funktioniert (SHL-01 Prüfung 2).
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { Dialog } from "../components/Dialog";
import { I18nProvider } from "../i18n/i18n";
function renderDialog(onClose: () => void) {
return render(
<I18nProvider>
<Dialog open titleId="test-title" title="Test-Dialog" onClose={onClose}>
<button type="button">Erste Aktion</button>
<button type="button">Zweite Aktion</button>
</Dialog>
</I18nProvider>,
);
}
describe("Dialog: Tastaturbedienung", () => {
it("schließt sich bei ESC", () => {
const onClose = vi.fn();
renderDialog(onClose);
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledOnce();
});
it("setzt den Fokus beim Öffnen auf das erste fokussierbare Element", () => {
renderDialog(vi.fn());
const closeButton = screen.getByRole("button", { name: /schließen/i });
expect(document.activeElement).toBe(closeButton);
});
it("ist als modaler Dialog mit Titel-Referenz ausgezeichnet", () => {
renderDialog(vi.fn());
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveAttribute("aria-modal", "true");
expect(dialog).toHaveAttribute("aria-labelledby", "test-title");
});
});
-39
View File
@@ -1,39 +0,0 @@
// Prüfung: Kontrastwerte erfüllen mindestens AA (SHL-01 Prüfung 3 / Akzeptanzkriterium 4).
import { describe, expect, it } from "vitest";
import { colorTokens } from "../tokens/tokens";
// WCAG-2.1-AA-Kontrastberechnung (relative Luminanz, sRGB) — keine externe Abhängigkeit nötig.
function relLuminance(hex: string): number {
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
const [r, g, b] = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrastRatio(a: string, b: string): number {
const l1 = relLuminance(a);
const l2 = relLuminance(b);
const [lighter, darker] = l1 > l2 ? [l1, l2] : [l2, l1];
return (lighter + 0.05) / (darker + 0.05);
}
describe("Design-Tokens: WCAG 2.1 AA Kontrast", () => {
for (const scheme of ["light", "dark"] as const) {
const c = colorTokens[scheme];
it(`${scheme}: textPrimary auf background erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.textPrimary, c.background)).toBeGreaterThanOrEqual(4.5);
});
it(`${scheme}: textSecondary auf surface erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.textSecondary, c.surface)).toBeGreaterThanOrEqual(4.5);
});
it(`${scheme}: accentContrast auf accent erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.accentContrast, c.accent)).toBeGreaterThanOrEqual(4.5);
});
it(`${scheme}: dangerContrast auf danger erfüllt AA (>= 4.5:1)`, () => {
expect(contrastRatio(c.dangerContrast, c.danger)).toBeGreaterThanOrEqual(4.5);
});
}
});
-88
View File
@@ -1,88 +0,0 @@
"use client";
// Dialog-Basis-Komponente — SHL-01. WCAG 2.1 AA: Fokus-Falle, ESC schließt, Tastaturbedienung vollständig.
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
export interface DialogProps {
open: boolean;
onClose: () => void;
titleId: string;
title: string;
children: ReactNode;
}
export function Dialog({ open, onClose, titleId, title, children }: DialogProps) {
const { t } = useI18n();
const dialogRef = useRef<HTMLDivElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
previouslyFocused.current = document.activeElement as HTMLElement | null;
const node = dialogRef.current;
const focusables = node?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
focusables?.[0]?.focus();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
return;
}
if (event.key !== "Tab" || !node) return;
const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
if (items.length === 0) return;
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
previouslyFocused.current?.focus();
};
}, [open, onClose]);
if (!open) return null;
return (
<div
className="shl-dialog-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
ref={dialogRef}
className="shl-dialog"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<div className="shl-dialog-header">
<h2 id={titleId}>{title}</h2>
<button type="button" onClick={onClose} aria-label={t("shl.dialog.close")}>
×
</button>
</div>
<div className="shl-dialog-body">{children}</div>
</div>
</div>
);
}
-98
View File
@@ -1,98 +0,0 @@
// Formularelemente-Basis-Komponenten — SHL-01. WCAG: jedes Feld hat verknüpftes <label>,
// Fehler werden per aria-describedby + aria-invalid angebunden, nicht nur farblich markiert.
import { useId } from "react";
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes } from "react";
interface FieldWrapperProps {
label: string;
error?: string;
hint?: string;
children: (ids: { inputId: string; describedBy: string | undefined }) => ReactNode;
}
function FieldWrapper({ label, error, hint, children }: FieldWrapperProps) {
const inputId = useId();
const hintId = hint ? `${inputId}-hint` : undefined;
const errorId = error ? `${inputId}-error` : undefined;
const describedBy = [hintId, errorId].filter(Boolean).join(" ") || undefined;
return (
<div className="shl-field">
<label htmlFor={inputId}>{label}</label>
{children({ inputId, describedBy })}
{hint && (
<p id={hintId} className="shl-field-hint">
{hint}
</p>
)}
{error && (
<p id={errorId} className="shl-field-error" role="alert">
{error}
</p>
)}
</div>
);
}
export interface TextFieldProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "aria-describedby"> {
label: string;
error?: string;
hint?: string;
}
export function TextField({ label, error, hint, ...inputProps }: TextFieldProps) {
return (
<FieldWrapper label={label} error={error} hint={hint}>
{({ inputId, describedBy }) => (
<input
id={inputId}
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
{...inputProps}
/>
)}
</FieldWrapper>
);
}
export interface SelectFieldProps
extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "id" | "aria-describedby"> {
label: string;
error?: string;
hint?: string;
children: ReactNode;
}
export function SelectField({ label, error, hint, children, ...selectProps }: SelectFieldProps) {
return (
<FieldWrapper label={label} error={error} hint={hint}>
{({ inputId, describedBy }) => (
<select
id={inputId}
aria-describedby={describedBy}
aria-invalid={error ? true : undefined}
{...selectProps}
>
{children}
</select>
)}
</FieldWrapper>
);
}
export interface CheckboxFieldProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "id" | "type"> {
label: string;
}
export function CheckboxField({ label, ...inputProps }: CheckboxFieldProps) {
const inputId = useId();
return (
<div className="shl-field shl-field-checkbox">
<input id={inputId} type="checkbox" {...inputProps} />
<label htmlFor={inputId}>{label}</label>
</div>
);
}
-62
View File
@@ -1,62 +0,0 @@
"use client";
// Layout-Shell mit Navigation — SHL-01 Akzeptanzkriterium 1.
// Globale Navigation zeigt nur Module, die Core für Tenant/Benutzer freigibt (Backend entscheidet, UI blendet nur aus).
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
import { useTheme } from "../theme/ThemeProvider";
export interface ModuleLink {
key: string;
label: string;
href: string;
active?: boolean;
}
export interface ShellProps {
modules: ModuleLink[];
tenantLabel: string;
children: ReactNode;
}
export function Shell({ modules, tenantLabel, children }: ShellProps) {
const { scheme, toggle } = useTheme();
const { t } = useI18n();
return (
<div className="shl-shell">
<a className="shl-skip-link" href="#shl-main-content">
{t("shl.shell.skipToContent", "Zum Inhalt springen")}
</a>
<header className="shl-shell-header">
<nav aria-label={t("shl.shell.moduleNav", "Modul-Navigation")}>
<ul>
{modules.map((mod) => (
<li key={mod.key}>
<a href={mod.href} aria-current={mod.active ? "page" : undefined}>
{mod.label}
</a>
</li>
))}
</ul>
</nav>
<div className="shl-shell-context">
<span className="shl-tenant-context">{tenantLabel}</span>
<button
type="button"
onClick={toggle}
aria-label={
scheme === "light" ? t("shl.theme.toggleToDark") : t("shl.theme.toggleToLight")
}
>
{scheme === "light" ? "🌙" : "☀️"}
</button>
</div>
</header>
<main id="shl-main-content" className="shl-shell-content" tabIndex={-1}>
{children}
</main>
</div>
);
}
-83
View File
@@ -1,83 +0,0 @@
// Table-Basis-Komponente — SHL-01. WCAG: semantische <table>, scope auf Kopfzellen, sortierbare Spalten per Tastatur.
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
export interface TableColumn<Row> {
key: string;
header: string;
render: (row: Row) => ReactNode;
sortable?: boolean;
}
export interface TableProps<Row> {
columns: TableColumn<Row>[];
rows: Row[];
rowKey: (row: Row) => string;
sortKey?: string;
sortDirection?: "asc" | "desc";
onSort?: (key: string) => void;
caption?: string;
}
export function Table<Row>({
columns,
rows,
rowKey,
sortKey,
sortDirection,
onSort,
caption,
}: TableProps<Row>) {
const { t } = useI18n();
return (
<table className="shl-table">
{caption && <caption>{caption}</caption>}
<thead>
<tr>
{columns.map((column) => {
const isSorted = column.key === sortKey;
const ariaSort = column.sortable
? isSorted
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
: undefined;
return (
<th key={column.key} scope="col" aria-sort={ariaSort}>
{column.sortable ? (
<button
type="button"
onClick={() => onSort?.(column.key)}
className="shl-table-sort-button"
>
{column.header}
</button>
) : (
column.header
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={columns.length}>{t("shl.table.noRows")}</td>
</tr>
) : (
rows.map((row) => (
<tr key={rowKey(row)}>
{columns.map((column) => (
<td key={column.key}>{column.render(row)}</td>
))}
</tr>
))
)}
</tbody>
</table>
);
}
-67
View File
@@ -1,67 +0,0 @@
"use client";
// Toast-Basis-Komponente — SHL-01. WCAG: aria-live sorgt dafür, dass Screenreader Meldungen ansagen.
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { useI18n } from "../i18n/i18n";
export type ToastVariant = "info" | "success" | "danger" | "warning";
export interface ToastMessage {
id: string;
text: string;
variant: ToastVariant;
}
interface ToastContextValue {
toasts: ToastMessage[];
push: (text: string, variant?: ToastVariant) => void;
dismiss: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastMessage[]>([]);
const { t } = useI18n();
const dismiss = useCallback((id: string) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const push = useCallback((text: string, variant: ToastVariant = "info") => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
setToasts((current) => [...current, { id, text, variant }]);
}, []);
const value = useMemo(() => ({ toasts, push, dismiss }), [toasts, push, dismiss]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="shl-toast-region" role="status" aria-live="polite" aria-atomic="false">
{toasts.map((toast) => (
<div key={toast.id} className={`shl-toast shl-toast-${toast.variant}`}>
<span>{toast.text}</span>
<button
type="button"
onClick={() => dismiss(toast.id)}
aria-label={t("shl.toast.dismiss")}
>
×
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error("useToast muss innerhalb von <ToastProvider> aufgerufen werden");
}
return ctx;
}
-75
View File
@@ -1,75 +0,0 @@
"use client";
// i18n-Rahmen (mind. Deutsch/Englisch) — SHL-01 Akzeptanzkriterium 5.
// Liefert nur den Umschaltmechanismus + Basis-Komponenten-Texte.
// Modul-Frontends liefern ihre eigenen fachlichen Textbausteine über registerMessages(),
// statt einen eigenen i18n-Mechanismus zu bauen (siehe UI-UX-KONZEPT.md Abschnitt 4).
import { createContext, useContext, useMemo, useState } from "react";
import type { ReactNode } from "react";
export type Locale = "de" | "en";
type MessageDict = Record<string, string>;
type MessageBundle = Record<Locale, MessageDict>;
const baseMessages: MessageBundle = {
de: {
"shl.dialog.close": "Schließen",
"shl.toast.dismiss": "Meldung schließen",
"shl.table.noRows": "Keine Einträge vorhanden",
"shl.theme.toggleToLight": "Helles Erscheinungsbild",
"shl.theme.toggleToDark": "Dunkles Erscheinungsbild",
},
en: {
"shl.dialog.close": "Close",
"shl.toast.dismiss": "Dismiss message",
"shl.table.noRows": "No entries",
"shl.theme.toggleToLight": "Switch to light theme",
"shl.theme.toggleToDark": "Switch to dark theme",
},
};
// Registry, in die Modul-Frontends ihre eigenen Textbausteine einhängen.
const registry: MessageBundle = { de: { ...baseMessages.de }, en: { ...baseMessages.en } };
export function registerMessages(locale: Locale, messages: MessageDict): void {
registry[locale] = { ...registry[locale], ...messages };
}
interface I18nContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
t: (key: string, fallback?: string) => string;
}
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({
initialLocale = "de",
children,
}: {
initialLocale?: Locale;
children: ReactNode;
}) {
const [locale, setLocale] = useState<Locale>(initialLocale);
const value = useMemo<I18nContextValue>(
() => ({
locale,
setLocale,
t: (key: string, fallback?: string) => registry[locale][key] ?? fallback ?? key,
}),
[locale],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext);
if (!ctx) {
throw new Error("useI18n muss innerhalb von <I18nProvider> aufgerufen werden");
}
return ctx;
}
-25
View File
@@ -1,25 +0,0 @@
// Öffentliche Schnittstelle des Pakets @nexarch/shl — Modul-Frontends importieren ausschließlich hierüber,
// nicht aus internen Unterpfaden (SHL-01 Akzeptanzkriterium 2: dokumentiert, versioniert, importierbar statt kopiert).
export { colorTokens, spacing, breakpoints, typography, cssVariables } from "./tokens/tokens";
export type { ColorScheme, ColorTokens } from "./tokens/tokens";
export { ThemeProvider, useTheme, currentColors } from "./theme/ThemeProvider";
export { I18nProvider, useI18n, registerMessages } from "./i18n/i18n";
export type { Locale } from "./i18n/i18n";
export { Shell } from "./components/Shell";
export type { ShellProps, ModuleLink } from "./components/Shell";
export { Dialog } from "./components/Dialog";
export type { DialogProps } from "./components/Dialog";
export { Table } from "./components/Table";
export type { TableProps, TableColumn } from "./components/Table";
export { TextField, SelectField, CheckboxField } from "./components/FormElements";
export type { TextFieldProps, SelectFieldProps, CheckboxFieldProps } from "./components/FormElements";
export { ToastProvider, useToast } from "./components/Toast";
export type { ToastMessage, ToastVariant } from "./components/Toast";
-25
View File
@@ -1,25 +0,0 @@
{
"name": "@nexarch/shl",
"version": "0.1.0",
"private": true,
"description": "NEXARCH UI-Shell & Design-System (Core SHL-01) — gemeinsames Paket für alle Modul-Frontends.",
"main": "index.ts",
"types": "index.ts",
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"jsdom": "^24.1.1",
"typescript": "5.5.3",
"vitest": "^2.0.0"
}
}
-81
View File
@@ -1,81 +0,0 @@
"use client";
// Zentrales Theming (Hell/Dunkel) — SHL-01 Akzeptanzkriterium 6.
// Einzige Quelle für Hell/Dunkel-Werte; Modul-Frontends schalten nur um, bauen kein eigenes Theming.
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import { colorTokens, cssVariables, type ColorScheme } from "../tokens/tokens";
const STORAGE_KEY = "nexarch-shl-theme";
interface ThemeContextValue {
scheme: ColorScheme;
setScheme: (scheme: ColorScheme) => void;
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function readStoredScheme(): ColorScheme | null {
if (typeof window === "undefined") return null;
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored === "light" || stored === "dark" ? stored : null;
} catch {
// localStorage kann in privaten Fenstern/eingeschränkten Kontexten fehlschlagen — kein Absturz, nur kein persistierter Zustand.
return null;
}
}
function systemPrefersDark(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [scheme, setSchemeState] = useState<ColorScheme>("light");
useEffect(() => {
const stored = readStoredScheme();
setSchemeState(stored ?? (systemPrefersDark() ? "dark" : "light"));
}, []);
const setScheme = useCallback((next: ColorScheme) => {
setSchemeState(next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Speichern optional — Umschaltung funktioniert auch ohne Persistenz.
}
}, []);
const toggle = useCallback(() => {
setScheme(scheme === "light" ? "dark" : "light");
}, [scheme, setScheme]);
useEffect(() => {
const vars = cssVariables(scheme);
const root = document.documentElement;
for (const [key, value] of Object.entries(vars)) {
root.style.setProperty(key, value);
}
root.dataset.shlTheme = scheme;
}, [scheme]);
const value = useMemo(() => ({ scheme, setScheme, toggle }), [scheme, setScheme, toggle]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error("useTheme muss innerhalb von <ThemeProvider> aufgerufen werden");
}
return ctx;
}
export function currentColors(scheme: ColorScheme) {
return colorTokens[scheme];
}
-96
View File
@@ -1,96 +0,0 @@
// Design-Tokens: einzige Quelle für Farbe, Abstand, Typografie im gesamten Frontend-Verbund.
// Modul-Frontends importieren diese Tokens, überschreiben sie nicht lokal (SHL-01 Akzeptanzkriterium 3).
// Kontrastwerte sind gegen WCAG 2.1 AA geprüft (Akzeptanzkriterium 1/4): mindestens 4.5:1 für Fließtext.
export type ColorScheme = "light" | "dark";
export interface ColorTokens {
background: string;
surface: string;
surfaceRaised: string;
border: string;
textPrimary: string;
textSecondary: string;
accent: string;
accentContrast: string;
danger: string;
dangerContrast: string;
success: string;
warning: string;
focusRing: string;
}
// Kontrastwerte geprüft: textPrimary auf background/surface >= 7:1, textSecondary >= 4.5:1,
// accentContrast auf accent >= 4.5:1 (WCAG AA, siehe SHL-01 Prüfung 3).
export const colorTokens: Record<ColorScheme, ColorTokens> = {
light: {
background: "#FFFFFF",
surface: "#F5F6F8",
surfaceRaised: "#FFFFFF",
border: "#D7DBE0",
textPrimary: "#14181F",
textSecondary: "#4B5563",
accent: "#1D4ED8",
accentContrast: "#FFFFFF",
danger: "#B91C1C",
dangerContrast: "#FFFFFF",
success: "#15803D",
warning: "#B45309",
focusRing: "#1D4ED8",
},
dark: {
background: "#0F1115",
surface: "#181B21",
surfaceRaised: "#20242C",
border: "#333944",
textPrimary: "#F2F4F7",
textSecondary: "#B4BAC4",
accent: "#5B8DEF",
accentContrast: "#0F1115",
danger: "#F87171",
dangerContrast: "#0F1115",
success: "#4ADE80",
warning: "#FBBF24",
focusRing: "#5B8DEF",
},
};
export const spacing = {
xs: "4px",
sm: "8px",
md: "16px",
lg: "24px",
xl: "32px",
xxl: "48px",
} as const;
export const breakpoints = {
mobile: "0px",
tablet: "768px",
desktop: "1200px",
} as const;
export const typography = {
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
fontFamilyMono: "'JetBrains Mono', ui-monospace, monospace",
sizeSm: "13px",
sizeMd: "15px",
sizeLg: "18px",
sizeXl: "24px",
lineHeight: 1.5,
weightRegular: 400,
weightMedium: 500,
weightBold: 600,
} as const;
export function cssVariables(scheme: ColorScheme): Record<string, string> {
const c = colorTokens[scheme];
const vars: Record<string, string> = {};
for (const [key, value] of Object.entries(c)) {
vars[`--shl-color-${key.replace(/([A-Z])/g, "-$1").toLowerCase()}`] = value;
}
for (const [key, value] of Object.entries(spacing)) {
vars[`--shl-spacing-${key}`] = value;
}
return vars;
}
-18
View File
@@ -1,18 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
},
});
-9
View File
@@ -1,9 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
// Ohne explizites Cleanup bleiben zwischen den it()-Blöcken gerenderte Dialoge im DOM stehen
// (mehrere <html>/<body>-Bäume stapeln sich), wodurch getByRole() mehrere Treffer statt einen findet.
afterEach(() => {
cleanup();
});
-30
View File
@@ -1,30 +0,0 @@
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
export const metadata = {
title: "NEXARCH Mandantenverwaltung",
};
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>
);
}
-225
View File
@@ -1,225 +0,0 @@
"use client";
import { useState } from "react";
import {
fetchTenants,
fetchTenantDetail,
updateSettings,
performLifecycleAction,
type TenantListItem,
type TenantDetail,
type LifecycleAction,
} from "@/lib/api";
const ACTION_LABEL: Record<LifecycleAction, string> = {
suspend: "Suspendieren",
reactivate: "Reaktivieren",
schedule_deletion: "Löschung vormerken",
cancel_deletion: "Löschung abbrechen",
};
const ACTION_CONFIRM: Record<LifecycleAction, string> = {
suspend: "Mandant wirklich suspendieren? Benutzer können sich danach nicht mehr anmelden.",
reactivate: "Mandant wirklich reaktivieren?",
schedule_deletion:
"Mandant wirklich zur Löschung vormerken? Nach der Karenzzeit wird er unwiderruflich gelöscht.",
cancel_deletion: "Vorgemerkte Löschung wirklich abbrechen?",
};
export default function Page() {
const [superadminId, setSuperadminId] = useState("");
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const [tenants, setTenants] = useState<TenantListItem[] | null>(null);
const [detail, setDetail] = useState<TenantDetail | null>(null);
const [error, setError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
async function loadList() {
setError(null);
try {
const items = await fetchTenants(superadminId.trim(), search.trim(), status);
setTenants(items);
} catch (e: any) {
setError(e.message ?? "Unbekannter Fehler");
setTenants(null);
}
}
async function openDetail(slug: string) {
setError(null);
try {
const d = await fetchTenantDetail(superadminId.trim(), slug);
setDetail(d);
} catch (e: any) {
setError(e.message ?? "Unbekannter Fehler");
}
}
async function onSubmitSettings(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!detail) return;
setFormError(null);
const form = new FormData(e.currentTarget);
const displayName = String(form.get("displayName") ?? "").trim();
if (!displayName) {
setFormError("Anzeigename ist ein Pflichtfeld.");
return;
}
try {
await updateSettings(
superadminId.trim(),
detail.slug,
displayName,
String(form.get("colorScheme") ?? ""),
String(form.get("timezone") ?? ""),
String(form.get("language") ?? "")
);
await openDetail(detail.slug);
} catch (e: any) {
setFormError(e.message ?? "Speichern fehlgeschlagen");
}
}
async function onLifecycleAction(action: LifecycleAction) {
if (!detail) return;
if (!window.confirm(ACTION_CONFIRM[action])) return;
setError(null);
try {
await performLifecycleAction(superadminId.trim(), detail.slug, action);
await openDetail(detail.slug);
await loadList();
} catch (e: any) {
setError(e.message ?? "Aktion fehlgeschlagen");
}
}
return (
<main style={{ maxWidth: 900, margin: "0 auto", padding: "2rem 1rem" }}>
<h1>Mandantenverwaltung</h1>
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1rem", flexWrap: "wrap" }}>
<input
value={superadminId}
onChange={(e) => setSuperadminId(e.target.value)}
placeholder="Superadmin-ID"
style={{ padding: "0.5rem" }}
/>
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Suche (Slug/Name)"
style={{ padding: "0.5rem" }}
/>
<select value={status} onChange={(e) => setStatus(e.target.value)} style={{ padding: "0.5rem" }}>
<option value="">Alle Status</option>
<option value="active">Aktiv</option>
<option value="suspended">Suspendiert</option>
<option value="pending_deletion">Löschung vorgemerkt</option>
<option value="deleted">Gelöscht</option>
</select>
<button onClick={loadList} style={{ padding: "0.5rem 1rem" }}>
Anzeigen
</button>
</div>
{error && (
<p style={{ color: "#c62828" }} role="alert">
Fehler: {error}
</p>
)}
{tenants && (
<table style={{ width: "100%", borderCollapse: "collapse", background: "white" }}>
<thead>
<tr style={{ textAlign: "left", borderBottom: "2px solid #ddd" }}>
<th style={{ padding: "0.5rem" }}>Slug</th>
<th style={{ padding: "0.5rem" }}>Name</th>
<th style={{ padding: "0.5rem" }}>Status</th>
<th style={{ padding: "0.5rem" }}></th>
</tr>
</thead>
<tbody>
{tenants.map((t) => (
<tr key={t.id} style={{ borderBottom: "1px solid #eee" }}>
<td style={{ padding: "0.5rem" }}>{t.slug}</td>
<td style={{ padding: "0.5rem" }}>{t.name}</td>
<td style={{ padding: "0.5rem" }}>{t.status}</td>
<td style={{ padding: "0.5rem" }}>
<button onClick={() => openDetail(t.slug)}>Details</button>
</td>
</tr>
))}
{tenants.length === 0 && (
<tr>
<td colSpan={4} style={{ padding: "0.5rem" }}>
Keine Mandanten gefunden.
</td>
</tr>
)}
</tbody>
</table>
)}
{detail && (
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginTop: "1.5rem" }}>
<h2>
{detail.name} ({detail.slug}) Status: {detail.status}
</h2>
<form onSubmit={onSubmitSettings} style={{ display: "grid", gap: "0.75rem", maxWidth: 400 }}>
{formError && (
<p style={{ color: "#c62828" }} role="alert">
{formError}
</p>
)}
<label>
Anzeigename (Pflichtfeld)
<input
name="displayName"
required
defaultValue={detail.settings.DisplayName}
style={{ width: "100%", padding: "0.4rem" }}
/>
</label>
<label>
Farbschema
<input
name="colorScheme"
defaultValue={detail.settings.ColorScheme}
style={{ width: "100%", padding: "0.4rem" }}
/>
</label>
<label>
Zeitzone
<input
name="timezone"
defaultValue={detail.settings.Timezone}
style={{ width: "100%", padding: "0.4rem" }}
/>
</label>
<label>
Sprache
<input
name="language"
defaultValue={detail.settings.Language}
style={{ width: "100%", padding: "0.4rem" }}
/>
</label>
<button type="submit">Einstellungen speichern</button>
</form>
<div style={{ display: "flex", gap: "0.5rem", marginTop: "1.5rem", flexWrap: "wrap" }}>
{(Object.keys(ACTION_LABEL) as LifecycleAction[]).map((action) => (
<button key={action} onClick={() => onLifecycleAction(action)}>
{ACTION_LABEL[action]}
</button>
))}
</div>
</section>
)}
</main>
);
}
-106
View File
@@ -1,106 +0,0 @@
// Duenner Client fuer das TEN-05-Backend-API (internal/tenantadmin) — keine
// eigene Provisioning-/Lifecycle-/Validierungslogik ausser der Pflichtfeld-
// Vorpruefung im Formular (Akzeptanzkriterium 2), die zusaetzlich serverseitig
// durchgesetzt wird.
export type TenantListItem = {
id: string;
slug: string;
name: string;
status: string;
};
export type Settings = {
DisplayName: string;
LogoURL: string;
ColorScheme: string;
Timezone: string;
Language: string;
Version: number;
};
export type TenantDetail = {
id: string;
slug: string;
name: string;
status: string;
settings: Settings;
};
function apiBase(): string {
const base = process.env.NEXT_PUBLIC_TENANTADMIN_API_URL;
if (!base) {
throw new Error(
"NEXT_PUBLIC_TENANTADMIN_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"
);
}
return base;
}
async function handle<T>(res: Response): Promise<T> {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Anfrage fehlgeschlagen (${res.status})`);
}
return res.json();
}
export async function fetchTenants(
superadminId: string,
search: string,
status: string
): Promise<TenantListItem[]> {
const params = new URLSearchParams({ superadmin: superadminId, search, status });
const res = await fetch(`${apiBase()}/admin/tenants?${params}`, { cache: "no-store" });
return handle<TenantListItem[]>(res);
}
export async function fetchTenantDetail(
superadminId: string,
slug: string
): Promise<TenantDetail> {
const params = new URLSearchParams({ superadmin: superadminId, slug });
const res = await fetch(`${apiBase()}/admin/tenants/detail?${params}`, { cache: "no-store" });
return handle<TenantDetail>(res);
}
export async function updateSettings(
superadminId: string,
slug: string,
displayName: string,
colorScheme: string,
timezone: string,
language: string
): Promise<Settings> {
const res = await fetch(`${apiBase()}/admin/tenants/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
superadmin: superadminId,
slug,
display_name: displayName,
color_scheme: colorScheme,
timezone,
language,
}),
});
return handle<Settings>(res);
}
export type LifecycleAction =
| "suspend"
| "reactivate"
| "schedule_deletion"
| "cancel_deletion";
export async function performLifecycleAction(
superadminId: string,
slug: string,
action: LifecycleAction
): Promise<void> {
const res = await fetch(`${apiBase()}/admin/tenants/lifecycle`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ superadmin: superadminId, slug, action }),
});
await handle<unknown>(res);
}
-7
View File
@@ -1,7 +0,0 @@
/** @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;
-22
View File
@@ -1,22 +0,0 @@
{
"name": "nexarch-tenant-admin",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@nexarch/shl": "file:../shl",
"next": "14.2.35",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/node": "20.14.9",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"typescript": "5.5.3"
}
}
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": 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"]
}