Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98b3bcdf43 | ||
|
|
5f3eb16fae | ||
|
|
c8f72c30f3 | ||
|
|
7c53a099c7 | ||
|
|
25168a18db | ||
|
|
ac48935261 | ||
|
|
0620baa993 | ||
|
|
eed73eca8f | ||
|
|
d447869246 | ||
|
|
d63db93a4c |
@@ -130,29 +130,6 @@ Keine Commits in dieser Session.
|
|||||||
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
|
||||||
- internal/db/db.go | 11 +++++++++++
|
- internal/db/db.go | 11 +++++++++++
|
||||||
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
- migrations/0001_tenant_registry.sql | 10 ++++++++++
|
||||||
|
|
||||||
---
|
|
||||||
## 2026-08-27 17:53 – 17:57 (4m)
|
|
||||||
**Beschreibung:** Claude Code Session
|
|
||||||
**Projekt:** code
|
|
||||||
|
|
||||||
### Commits
|
|
||||||
- e479330 IAM-01: benutzer-datenmodell-crud
|
|
||||||
|
|
||||||
### Geänderte Dateien
|
|
||||||
- cmd/core/main.go | 11 ++++++++++-
|
|
||||||
- go.mod | 9 +++++++++
|
|
||||||
- go.sum | 28 ++++++++++++++++++++++++++++
|
|
||||||
- internal/user/handler.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/user/store_test.go | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/user/superadmin_store.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/user/tenant_store.go | 128 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/user/user.go | 42 ++++++++++++++++++++++++++++++++++++++++++
|
|
||||||
- internal/user/user_test.go | 24 ++++++++++++++++++++++++
|
|
||||||
- migrations/0002_superadmins.down.sql | 1 +
|
|
||||||
- migrations/0002_superadmins.up.sql | 14 ++++++++++++++
|
|
||||||
- migrations/tenant/0001_users.down.sql | 1 +
|
|
||||||
- migrations/tenant/0001_users.up.sql | 16 ++++++++++++++++
|
|
||||||
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
|
- web/shl/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++
|
||||||
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
|
- web/shl/__tests__/Dialog.test.tsx | 38 ++++++++++++++++++++++++++++++++++++++
|
||||||
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
|
- web/shl/__tests__/tokens.test.ts | 39 +++++++++++++++++++++++++++++++++++++++
|
||||||
|
|||||||
+1
-13
@@ -8,7 +8,6 @@ import (
|
|||||||
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
"gitea.perlbach24.de/scripte/nexarch/internal/config"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -34,24 +33,13 @@ func main() {
|
|||||||
registry := tenant.NewRegistry(registryPool)
|
registry := tenant.NewRegistry(registryPool)
|
||||||
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
|
||||||
tenantHandler := tenant.NewHandler(provisioner)
|
tenantHandler := tenant.NewHandler(provisioner)
|
||||||
onboardingService := tenant.NewOnboardingService(provisioner, cfg.TenantSchemaPath)
|
|
||||||
onboardingHandler := tenant.NewOnboardingHandler(onboardingService)
|
|
||||||
|
|
||||||
// Superadmin-Konten leben mandantenuebergreifend in der Registry-DB.
|
|
||||||
// Tenant-User-CRUD (user.TenantUserStore) braucht Connection-Routing pro
|
|
||||||
// Mandant (TEN-06, noch nicht gebaut) und wird hier bewusst noch nicht
|
|
||||||
// verdrahtet — Package ist bereits eigenstaendig nutzbar/testbar.
|
|
||||||
superadmins := user.NewSuperadminStore(registryPool)
|
|
||||||
userHandler := user.NewHandler(nil, superadmins)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
})
|
})
|
||||||
// Vorlaeufige Pfade ohne Versionierung/Auth — werden mit API-01/IAM-02 abgeloest.
|
// Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 abgeloest.
|
||||||
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
|
||||||
mux.HandleFunc("/internal/tenants/onboard", onboardingHandler.Onboard)
|
|
||||||
mux.HandleFunc("/internal/superadmins", userHandler.CreateSuperadmin)
|
|
||||||
|
|
||||||
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
|
||||||
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// licadmin-devserver stellt das LIC-04-Backend-API (internal/adminapi) fuer
|
||||||
|
// die Next.js-Verwaltungsoberflaeche bereit. Getrennt von cmd/core, da die
|
||||||
|
// Verdrahtung von license.Validator (Ed25519-Public-Key) in die zentrale
|
||||||
|
// Server-Topologie erst mit API-01/IAM-01 (Auth vor internen Endpunkten)
|
||||||
|
// sinnvoll ist — dieser Server dient der Entwicklung/dem Betrieb der
|
||||||
|
// Verwaltungsoberflaeche gegen eine echte Datenbank, ohne cmd/core
|
||||||
|
// anzufassen (Kein Umbau angrenzender Bereiche).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/adminapi"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/license"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/usage"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
|
||||||
|
}
|
||||||
|
pubKeyB64 := os.Getenv("NEXARCH_LICENSE_PUBLIC_KEY")
|
||||||
|
if pubKeyB64 == "" {
|
||||||
|
log.Fatal("NEXARCH_LICENSE_PUBLIC_KEY nicht gesetzt (base64, raw-url-encoding)")
|
||||||
|
}
|
||||||
|
pubKeyRaw, err := base64.RawURLEncoding.DecodeString(pubKeyB64)
|
||||||
|
if err != nil || len(pubKeyRaw) != ed25519.PublicKeySize {
|
||||||
|
log.Fatalf("NEXARCH_LICENSE_PUBLIC_KEY ungueltig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := os.Getenv("NEXARCH_LICADMIN_LISTEN_ADDR")
|
||||||
|
if addr == "" {
|
||||||
|
addr = ":8081"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := db.Connect(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("db: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
licenseStore := license.NewStore(pool, license.NewValidator(ed25519.PublicKey(pubKeyRaw)))
|
||||||
|
flagStore := flag.NewStore(pool)
|
||||||
|
flagService := flag.NewService(flagStore, 5*time.Second)
|
||||||
|
usageStore := usage.NewStore(pool)
|
||||||
|
handler := adminapi.NewHandler(licenseStore, flagStore, flagService, usageStore)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/admin/overview", withCORS(handler.OverviewHandler))
|
||||||
|
mux.HandleFunc("/admin/flags/toggle", withCORS(handler.ToggleFlagHandler))
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||||
|
|
||||||
|
log.Printf("licadmin-devserver listening on %s", addr)
|
||||||
|
log.Fatal(http.ListenAndServe(addr, mux))
|
||||||
|
}
|
||||||
|
|
||||||
|
// withCORS erlaubt lokale Entwicklung des Next.js-Frontends auf einem
|
||||||
|
// anderen Port als das Backend-API — ausschliesslich fuer diesen
|
||||||
|
// Entwicklungsserver, keine Produktionshaertung.
|
||||||
|
func withCORS(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// Package adminapi implementiert Core LIC-04: das Backend-API fuer die
|
||||||
|
// Lizenz-/Modul-Verwaltungsoberflaeche. Das Paket enthaelt KEINE eigene
|
||||||
|
// Lizenz-/Flag-/Quota-Logik, sondern ist ein duenner Vermittler ueber
|
||||||
|
// internal/license, internal/flag und internal/usage (Ticket-Vorgabe:
|
||||||
|
// "Verwaltungsoberflaeche als duenner Client ... ohne eigene Lizenzlogik").
|
||||||
|
package adminapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/license"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/usage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UsageMetrics sind die Metriken, die auf der Uebersichtsseite je Tenant
|
||||||
|
// dargestellt werden (Akzeptanzkriterium 3). Neue Metriken hinzuzufuegen
|
||||||
|
// beruehrt kein bestehendes Verhalten von internal/usage.
|
||||||
|
var UsageMetrics = []string{"users", "storage_bytes", "api_calls"}
|
||||||
|
|
||||||
|
// FlagOverview ist ein Flag zusammen mit der fuer den Tenant ausgewerteten
|
||||||
|
// Berechtigung, es umzuschalten (Akzeptanzkriterium 2).
|
||||||
|
type FlagOverview struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Licensed bool `json:"licensed"`
|
||||||
|
DenyGrund string `json:"deny_grund,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageOverview ist eine Metrik mit Zaehlerstand, Quota und Status
|
||||||
|
// (Akzeptanzkriterium 3).
|
||||||
|
type UsageOverview struct {
|
||||||
|
Metric string `json:"metric"`
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
Limit int64 `json:"limit"`
|
||||||
|
Status usage.Status `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overview ist die vollstaendige Antwort fuer die Uebersichtsseite
|
||||||
|
// (Akzeptanzkriterium 1: Lizenzstatus/Laufzeit/Module auf einen Blick).
|
||||||
|
type Overview struct {
|
||||||
|
Plan string `json:"plan"`
|
||||||
|
Modules []string `json:"modules"`
|
||||||
|
IssuedAt string `json:"issued_at"`
|
||||||
|
ValidUntil string `json:"valid_until"`
|
||||||
|
Expired bool `json:"expired"`
|
||||||
|
Flags []FlagOverview `json:"flags"`
|
||||||
|
Usage []UsageOverview `json:"usage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrFlagNotLicensed wird geliefert, wenn ein Administrator versucht, ein
|
||||||
|
// Flag zu aktivieren, dessen zugehoeriges Modul die Lizenz nicht umfasst
|
||||||
|
// (Akzeptanzkriterium 2 / Pruefung 1).
|
||||||
|
var ErrFlagNotLicensed = errors.New("adminapi: modul ist durch die aktuelle lizenz nicht freigeschaltet")
|
||||||
|
|
||||||
|
// Handler buendelt die drei Vorbedingungs-Dienste hinter einem gemeinsamen
|
||||||
|
// HTTP-API fuer das Frontend.
|
||||||
|
type Handler struct {
|
||||||
|
licenseStore *license.Store
|
||||||
|
flagStore *flag.Store
|
||||||
|
flagService *flag.Service
|
||||||
|
usageStore *usage.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(licenseStore *license.Store, flagStore *flag.Store, flagService *flag.Service, usageStore *usage.Store) *Handler {
|
||||||
|
return &Handler{licenseStore: licenseStore, flagStore: flagStore, flagService: flagService, usageStore: usageStore}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isModuleLicensed prueft, ob ein Flag-Schluessel als lizenziertes Modul
|
||||||
|
// gilt. Konvention: ein Flag-Key der Form "module:<name>" ist an das
|
||||||
|
// gleichnamige Modul in Payload.Modules gebunden; alle anderen Flag-Keys
|
||||||
|
// gelten als lizenzunabhaengige, rein funktionale Schalter.
|
||||||
|
func isModuleLicensed(key string, modules []string) (moduleName string, isModuleFlag bool, licensed bool) {
|
||||||
|
const prefix = "module:"
|
||||||
|
if !strings.HasPrefix(key, prefix) {
|
||||||
|
return "", false, true
|
||||||
|
}
|
||||||
|
moduleName = strings.TrimPrefix(key, prefix)
|
||||||
|
for _, m := range modules {
|
||||||
|
if m == moduleName {
|
||||||
|
return moduleName, true, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moduleName, true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overview liefert die kombinierte Uebersicht fuer einen Tenant
|
||||||
|
// (Akzeptanzkriterium 1 + 3).
|
||||||
|
func (h *Handler) Overview(ctx context.Context, tenantID string) (Overview, error) {
|
||||||
|
lic, err := h.licenseStore.Status(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return Overview{}, fmt.Errorf("lizenzstatus lesen: %w", err)
|
||||||
|
}
|
||||||
|
_, expiredErr := h.licenseStore.RequireActive(ctx, tenantID)
|
||||||
|
expired := errors.Is(expiredErr, license.ErrLicenseExpired)
|
||||||
|
|
||||||
|
flags, err := h.flagStore.List(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return Overview{}, fmt.Errorf("flags lesen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tenantSlug := tenantID // Auswertung erwartet den Tenant-Bezug als String; Aufloesung ID->Slug obliegt dem Aufrufer (siehe Handler-Tests).
|
||||||
|
flagOverviews := make([]FlagOverview, 0, len(flags))
|
||||||
|
for _, f := range flags {
|
||||||
|
_, _, licensed := isModuleLicensed(f.Key, lic.Modules)
|
||||||
|
fo := FlagOverview{
|
||||||
|
Key: f.Key,
|
||||||
|
Enabled: h.flagService.IsEnabled(ctx, tenantSlug, f.Key),
|
||||||
|
Licensed: licensed,
|
||||||
|
}
|
||||||
|
if !licensed {
|
||||||
|
fo.DenyGrund = "Modul ist im aktuellen Lizenzplan nicht enthalten"
|
||||||
|
}
|
||||||
|
flagOverviews = append(flagOverviews, fo)
|
||||||
|
}
|
||||||
|
|
||||||
|
usageOverviews := make([]UsageOverview, 0, len(UsageMetrics))
|
||||||
|
for _, metric := range UsageMetrics {
|
||||||
|
value, limit, status, err := h.usageStore.Check(ctx, tenantID, metric)
|
||||||
|
if err != nil {
|
||||||
|
return Overview{}, fmt.Errorf("nutzungsstand lesen (%s): %w", metric, err)
|
||||||
|
}
|
||||||
|
usageOverviews = append(usageOverviews, UsageOverview{
|
||||||
|
Metric: metric, Value: value, Limit: limit, Status: status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Overview{
|
||||||
|
Plan: lic.Plan,
|
||||||
|
Modules: lic.Modules,
|
||||||
|
IssuedAt: lic.IssuedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
ValidUntil: lic.ValidUntil.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
|
Expired: expired,
|
||||||
|
Flags: flagOverviews,
|
||||||
|
Usage: usageOverviews,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleFlag schaltet ein Flag global an/aus, NACHDEM geprueft wurde, dass
|
||||||
|
// das zugehoerige Modul lizenziert ist (Akzeptanzkriterium 2 / Pruefung 1).
|
||||||
|
// Ein nicht lizenziertes Modul wird mit ErrFlagNotLicensed abgelehnt, bevor
|
||||||
|
// irgendein Schreibzugriff erfolgt.
|
||||||
|
func (h *Handler) ToggleFlag(ctx context.Context, tenantID, key string, enabled bool) error {
|
||||||
|
lic, err := h.licenseStore.Status(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("lizenzstatus lesen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
if _, _, licensed := isModuleLicensed(key, lic.Modules); !licensed {
|
||||||
|
return ErrFlagNotLicensed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := h.flagStore.Get(ctx, key)
|
||||||
|
if err != nil && !errors.Is(err, flag.ErrNotFound) {
|
||||||
|
return fmt.Errorf("flag lesen: %w", err)
|
||||||
|
}
|
||||||
|
existing.Key = key
|
||||||
|
existing.Enabled = enabled
|
||||||
|
|
||||||
|
if err := h.flagStore.Set(ctx, existing); err != nil {
|
||||||
|
return fmt.Errorf("flag speichern: %w", err)
|
||||||
|
}
|
||||||
|
h.flagService.Invalidate(key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTTP-Bindung ---
|
||||||
|
|
||||||
|
func (h *Handler) OverviewHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tenantID := r.URL.Query().Get("tenant")
|
||||||
|
if tenantID == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "tenant-parameter fehlt")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
overview, err := h.Overview(r.Context(), tenantID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, overview)
|
||||||
|
}
|
||||||
|
|
||||||
|
type toggleRequest struct {
|
||||||
|
Tenant string `json:"tenant"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ToggleFlagHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req toggleRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Tenant == "" || req.Key == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "tenant und key sind erforderlich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.ToggleFlag(r.Context(), req.Tenant, req.Key, req.Enabled)
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
case errors.Is(err, ErrFlagNotLicensed):
|
||||||
|
writeError(w, http.StatusForbidden, ErrFlagNotLicensed.Error())
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, message string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": message})
|
||||||
|
}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
package adminapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/license"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/usage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testEnv struct {
|
||||||
|
handler *Handler
|
||||||
|
tenantID string
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTest(t *testing.T) (testEnv, func()) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
db_name TEXT NOT NULL UNIQUE,
|
||||||
|
db_dsn TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_licenses (
|
||||||
|
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
|
||||||
|
plan TEXT NOT NULL,
|
||||||
|
modules TEXT[] NOT NULL,
|
||||||
|
issued_at TIMESTAMPTZ NOT NULL,
|
||||||
|
valid_until TIMESTAMPTZ NOT NULL,
|
||||||
|
raw_key TEXT NOT NULL,
|
||||||
|
installed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS feature_flags (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
rollout_percentage INT NOT NULL DEFAULT 0,
|
||||||
|
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS usage_counters (
|
||||||
|
tenant_id UUID NOT NULL, metric TEXT NOT NULL, value BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (tenant_id, metric)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS usage_quotas (
|
||||||
|
tenant_id UUID NOT NULL, metric TEXT NOT NULL, limit_value BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (tenant_id, metric)
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slug := fmt.Sprintf("adminapi_test_%d", time.Now().UnixNano())
|
||||||
|
var tenantID string
|
||||||
|
if err := pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO tenants (slug, name, db_name, db_dsn)
|
||||||
|
VALUES ($1, 'Adminapi Test', $1, 'unused') RETURNING id
|
||||||
|
`, slug).Scan(&tenantID); err != nil {
|
||||||
|
t.Fatalf("test-tenant anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub, priv, err := ed25519.GenerateKey(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("schluesselpaar: %v", err)
|
||||||
|
}
|
||||||
|
issuer := license.NewIssuer(priv)
|
||||||
|
licenseStore := license.NewStore(pool, license.NewValidator(pub))
|
||||||
|
|
||||||
|
payload := license.Payload{
|
||||||
|
TenantSlug: slug,
|
||||||
|
Plan: "professional",
|
||||||
|
Modules: []string{"dms", "mail"},
|
||||||
|
IssuedAt: time.Now().Truncate(time.Second),
|
||||||
|
ValidUntil: time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second),
|
||||||
|
}
|
||||||
|
key, err := issuer.Issue(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lizenz ausstellen: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := licenseStore.Install(ctx, tenantID, key); err != nil {
|
||||||
|
t.Fatalf("lizenz installieren: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
flagStore := flag.NewStore(pool)
|
||||||
|
flagService := flag.NewService(flagStore, time.Millisecond) // sehr kurze TTL, damit Tests nicht auf Cache-Ablauf warten muessen
|
||||||
|
usageStore := usage.NewStore(pool)
|
||||||
|
|
||||||
|
handler := NewHandler(licenseStore, flagStore, flagService, usageStore)
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM usage_quotas WHERE tenant_id = $1`, tenantID)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM usage_counters WHERE tenant_id = $1`, tenantID)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM tenant_licenses WHERE tenant_id = $1`)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, tenantID)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'adminapi\_test\_%' ESCAPE '\'`)
|
||||||
|
pool.Close()
|
||||||
|
}
|
||||||
|
return testEnv{handler: handler, tenantID: tenantID, pool: pool}, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1: Lizenzstatus, Laufzeit und Module sind auf einen
|
||||||
|
// Blick abfragbar.
|
||||||
|
func TestOverview_ShowsLicenseStatusAndModules(t *testing.T) {
|
||||||
|
env, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
overview, err := env.handler.Overview(ctx, env.tenantID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("overview: %v", err)
|
||||||
|
}
|
||||||
|
if overview.Plan != "professional" {
|
||||||
|
t.Fatalf("plan = %q, want professional", overview.Plan)
|
||||||
|
}
|
||||||
|
if len(overview.Modules) != 2 {
|
||||||
|
t.Fatalf("module = %v, want 2 eintraege", overview.Modules)
|
||||||
|
}
|
||||||
|
if overview.Expired {
|
||||||
|
t.Fatal("lizenz sollte nicht abgelaufen sein")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 1: Umschalten eines nicht lizenzierten
|
||||||
|
// Features wird verhindert und verstaendlich begruendet.
|
||||||
|
func TestToggleFlag_RejectsUnlicensedModule(t *testing.T) {
|
||||||
|
env, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
err := env.handler.ToggleFlag(ctx, env.tenantID, "module:archive", true)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("erwartet fehler beim aktivieren eines nicht lizenzierten moduls")
|
||||||
|
}
|
||||||
|
if err.Error() == "" {
|
||||||
|
t.Fatal("fehlermeldung sollte verstaendlich sein, nicht leer")
|
||||||
|
}
|
||||||
|
t.Logf("erhaltene begruendung: %v", err)
|
||||||
|
|
||||||
|
// Sicherstellen, dass NICHTS geschrieben wurde: Flag existiert weiterhin nicht.
|
||||||
|
if _, ferr := flag.NewStore(env.pool).Get(ctx, "module:archive"); ferr == nil {
|
||||||
|
t.Fatal("flag haette wegen fehlender lizenz nicht angelegt werden duerfen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2: lizenziertes Modul laesst sich umschalten.
|
||||||
|
func TestToggleFlag_AllowsLicensedModule(t *testing.T) {
|
||||||
|
env, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := env.handler.ToggleFlag(ctx, env.tenantID, "module:dms", true); err != nil {
|
||||||
|
t.Fatalf("erwartet erfolg fuer lizenziertes modul, habe: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
overview, err := env.handler.Overview(ctx, env.tenantID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("overview: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, f := range overview.Flags {
|
||||||
|
if f.Key == "module:dms" {
|
||||||
|
found = true
|
||||||
|
if !f.Enabled || !f.Licensed {
|
||||||
|
t.Fatalf("flag-status unerwartet: %+v", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("erwartet 'module:dms' in der uebersicht nach dem umschalten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 2: Nutzungsstand stimmt mit den
|
||||||
|
// Backend-Zaehlern ueberein.
|
||||||
|
func TestOverview_UsageMatchesBackendCounters(t *testing.T) {
|
||||||
|
env, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
usageStore := usage.NewStore(env.pool)
|
||||||
|
if err := usageStore.Increment(ctx, env.tenantID, "users", 7); err != nil {
|
||||||
|
t.Fatalf("increment: %v", err)
|
||||||
|
}
|
||||||
|
if err := usageStore.SetQuota(ctx, env.tenantID, "users", 10); err != nil {
|
||||||
|
t.Fatalf("setquota: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
overview, err := env.handler.Overview(ctx, env.tenantID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("overview: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var got *UsageOverview
|
||||||
|
for i := range overview.Usage {
|
||||||
|
if overview.Usage[i].Metric == "users" {
|
||||||
|
got = &overview.Usage[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("erwartet metrik 'users' in der uebersicht")
|
||||||
|
}
|
||||||
|
if got.Value != 7 || got.Limit != 10 || got.Status != usage.StatusOK {
|
||||||
|
t.Fatalf("nutzungsstand stimmt nicht mit backend-zaehler ueberein: %+v (want value=7 limit=10 status=ok, da 7/10=70%% unter der 80%%-warnschwelle liegt)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 / Pruefung 1 auf HTTP-Ebene: der Endpunkt liefert
|
||||||
|
// 403 mit verstaendlicher Fehlermeldung, kein interner Serverfehler.
|
||||||
|
func TestToggleFlagHandler_HTTPForbiddenOnUnlicensed(t *testing.T) {
|
||||||
|
env, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
body, _ := json.Marshal(toggleRequest{Tenant: env.tenantID, Key: "module:archive", Enabled: true})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/admin/flags/toggle", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
env.handler.ToggleFlagHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403", rec.Code)
|
||||||
|
}
|
||||||
|
var resp map[string]string
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("body parsen: %v", err)
|
||||||
|
}
|
||||||
|
if resp["error"] == "" {
|
||||||
|
t.Fatal("erwartet verstaendliche fehlermeldung im response-body")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverviewHandler_HTTPReturnsCombinedData(t *testing.T) {
|
||||||
|
env, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/admin/overview?tenant="+env.tenantID, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
env.handler.OverviewHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
var overview Overview
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &overview); err != nil {
|
||||||
|
t.Fatalf("body parsen: %v", err)
|
||||||
|
}
|
||||||
|
if overview.Plan != "professional" {
|
||||||
|
t.Fatalf("plan = %q, want professional", overview.Plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,9 +19,6 @@ type Config struct {
|
|||||||
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
|
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
|
||||||
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
|
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
|
||||||
TenantDSNTemplate string
|
TenantDSNTemplate string
|
||||||
// TenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der
|
|
||||||
// TEN-02 eine frisch provisionierte Tenant-Datenbank initialisiert.
|
|
||||||
TenantSchemaPath string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
@@ -40,11 +37,6 @@ func Load() (Config, error) {
|
|||||||
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
|
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
schemaPath := os.Getenv("NEXARCH_TENANT_SCHEMA_PATH")
|
|
||||||
if schemaPath == "" {
|
|
||||||
return Config{}, fmt.Errorf("NEXARCH_TENANT_SCHEMA_PATH not set")
|
|
||||||
}
|
|
||||||
|
|
||||||
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
|
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
|
||||||
if addr == "" {
|
if addr == "" {
|
||||||
addr = ":8080"
|
addr = ":8080"
|
||||||
@@ -55,6 +47,5 @@ func Load() (Config, error) {
|
|||||||
RegistryDSN: dsn,
|
RegistryDSN: dsn,
|
||||||
AdminDSN: adminDSN,
|
AdminDSN: adminDSN,
|
||||||
TenantDSNTemplate: dsnTemplate,
|
TenantDSNTemplate: dsnTemplate,
|
||||||
TenantSchemaPath: schemaPath,
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// Package flag implementiert Core LIC-02: einen Feature-Flag-Dienst mit
|
||||||
|
// Strategien (global an/aus, Prozentsatz, Tenant-Zielgruppe) als Kernfunktion
|
||||||
|
// des Core-Dienstes selbst — keine zusaetzliche Infrastruktur (Unleash-Server
|
||||||
|
// + eigene DB), siehe "bewusst vermeiden" im LIC-02-Ticket.
|
||||||
|
package flag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("flag: nicht gefunden")
|
||||||
|
|
||||||
|
// Flag ist die zentrale Definition — Auswertung (Evaluate) ist bewusst davon
|
||||||
|
// getrennt (Unleash-Prinzip: Flag-Verwaltung vs. Flag-Auswertung).
|
||||||
|
type Flag struct {
|
||||||
|
Key string
|
||||||
|
Enabled bool
|
||||||
|
RolloutPercentage int
|
||||||
|
TargetTenantSlugs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store ist die Verwaltungsseite (Admin): Flags definieren/lesen.
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Set(ctx context.Context, f Flag) error {
|
||||||
|
if f.TargetTenantSlugs == nil {
|
||||||
|
f.TargetTenantSlugs = []string{} // pgx uebertraegt ein nil-Slice sonst als SQL NULL statt leerem Array.
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO feature_flags (key, enabled, rollout_percentage, target_tenant_slugs, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, now())
|
||||||
|
ON CONFLICT (key) DO UPDATE SET
|
||||||
|
enabled = $2, rollout_percentage = $3, target_tenant_slugs = $4, updated_at = now()
|
||||||
|
`, f.Key, f.Enabled, f.RolloutPercentage, f.TargetTenantSlugs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("flag speichern: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List liefert alle definierten Flags (Admin-Uebersicht, siehe LIC-04) —
|
||||||
|
// rein additiv, aendert nichts am bestehenden Verhalten von Set/Get.
|
||||||
|
func (s *Store) List(ctx context.Context) ([]Flag, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT key, enabled, rollout_percentage, target_tenant_slugs
|
||||||
|
FROM feature_flags ORDER BY key
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("flags auflisten: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Flag
|
||||||
|
for rows.Next() {
|
||||||
|
var f Flag
|
||||||
|
if err := rows.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil {
|
||||||
|
return nil, fmt.Errorf("flag-zeile lesen: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Get(ctx context.Context, key string) (Flag, error) {
|
||||||
|
var f Flag
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT key, enabled, rollout_percentage, target_tenant_slugs
|
||||||
|
FROM feature_flags WHERE key = $1
|
||||||
|
`, key)
|
||||||
|
if err := row.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Flag{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Flag{}, fmt.Errorf("flag lesen: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluate wendet die Strategien in fester Reihenfolge an: globaler
|
||||||
|
// An/Aus-Schalter zuerst, dann Tenant-Zielgruppe, dann Prozentsatz-Rollout.
|
||||||
|
// Ein unbekannter/nicht getroffener Fall ergibt false — Fail-Safe-Default,
|
||||||
|
// kein Feature wird versehentlich aktiv.
|
||||||
|
func evaluate(f Flag, tenantSlug string) bool {
|
||||||
|
if f.Enabled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, target := range f.TargetTenantSlugs {
|
||||||
|
if target == tenantSlug {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if f.RolloutPercentage > 0 {
|
||||||
|
h := fnv.New32a()
|
||||||
|
_, _ = h.Write([]byte(f.Key + "|" + tenantSlug))
|
||||||
|
return int(h.Sum32()%100) < f.RolloutPercentage
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package flag
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestEvaluate_GlobalEnabled(t *testing.T) {
|
||||||
|
f := Flag{Key: "k", Enabled: true}
|
||||||
|
if !evaluate(f, "irgendein-tenant") {
|
||||||
|
t.Fatal("global aktiviertes flag sollte fuer jeden tenant true liefern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie.
|
||||||
|
func TestEvaluate_TargetTenantStrategy(t *testing.T) {
|
||||||
|
f := Flag{Key: "k", Enabled: false, TargetTenantSlugs: []string{"acme"}}
|
||||||
|
if !evaluate(f, "acme") {
|
||||||
|
t.Fatal("erwartet true fuer tenant in zielgruppe")
|
||||||
|
}
|
||||||
|
if evaluate(f, "globex") {
|
||||||
|
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_RolloutPercentageBoundaries(t *testing.T) {
|
||||||
|
full := Flag{Key: "k", RolloutPercentage: 100}
|
||||||
|
if !evaluate(full, "beliebiger-tenant-1") || !evaluate(full, "beliebiger-tenant-2") {
|
||||||
|
t.Fatal("100% rollout sollte immer true liefern")
|
||||||
|
}
|
||||||
|
|
||||||
|
none := Flag{Key: "k", RolloutPercentage: 0}
|
||||||
|
if evaluate(none, "beliebiger-tenant") {
|
||||||
|
t.Fatal("0% rollout ohne enabled/zielgruppe sollte false liefern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluate_RolloutIsDeterministicPerTenant(t *testing.T) {
|
||||||
|
f := Flag{Key: "k", RolloutPercentage: 50}
|
||||||
|
first := evaluate(f, "stabiler-tenant")
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
if evaluate(f, "stabiler-tenant") != first {
|
||||||
|
t.Fatal("rollout-auswertung sollte fuer denselben tenant/key stabil sein")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package flag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit
|
||||||
|
// (Akzeptanzkriterium 2/3): eine Aenderung wirkt spaetestens nach dieser
|
||||||
|
// Zeit auf allen Core-Instanzen, ohne dass ein Dienst neu gestartet werden
|
||||||
|
// muss (Akzeptanzkriterium 3).
|
||||||
|
const DefaultCacheTTL = 5 * time.Second
|
||||||
|
|
||||||
|
type cacheEntry struct {
|
||||||
|
flag Flag
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service ist die Auswertungsseite (SDK/Client-Analogon zu Unleash) mit
|
||||||
|
// lokalem TTL-Cache. Bewusst getrennt von Store (Verwaltung).
|
||||||
|
type Service struct {
|
||||||
|
store *Store
|
||||||
|
ttl time.Duration
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
cache map[string]cacheEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(store *Store, ttl time.Duration) *Service {
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = DefaultCacheTTL
|
||||||
|
}
|
||||||
|
return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEnabled wertet ein Flag fuer einen Tenant aus. Liefert IMMER einen
|
||||||
|
// bool ohne Fehlerwert — ein nicht erreichbarer Flag-Dienst darf abhaengige
|
||||||
|
// Aufrufer nicht zum Absturz bringen oder zu Fehlerbehandlungscode zwingen,
|
||||||
|
// der leicht vergessen wird (Akzeptanzkriterium 3 / Pruefung 3: dokumentiertes
|
||||||
|
// Fallback-Verhalten = false, ggf. aus dem zuletzt bekannten Zwischenspeicher).
|
||||||
|
func (s *Service) IsEnabled(ctx context.Context, tenantSlug, key string) bool {
|
||||||
|
f, ok := s.resolve(ctx, key)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return evaluate(f, tenantSlug)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolve(ctx context.Context, key string) (Flag, bool) {
|
||||||
|
s.mu.RLock()
|
||||||
|
entry, exists := s.cache[key]
|
||||||
|
fresh := exists && time.Now().Before(entry.expiresAt)
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if fresh {
|
||||||
|
return entry.flag, true
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := s.store.Get(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
if exists {
|
||||||
|
slog.Warn("feature-flag-dienst nicht erreichbar, nutze zwischengespeicherten stand",
|
||||||
|
"flag_key", key, "error", err)
|
||||||
|
return entry.flag, true
|
||||||
|
}
|
||||||
|
slog.Warn("feature-flag-dienst nicht erreichbar, kein zwischengespeicherter stand vorhanden, fallback: deaktiviert",
|
||||||
|
"flag_key", key, "error", err)
|
||||||
|
return Flag{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cache[key] = cacheEntry{flag: f, expiresAt: time.Now().Add(s.ttl)}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return f, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate erzwingt beim naechsten IsEnabled-Aufruf ein sofortiges Neuladen
|
||||||
|
// aus der Datenbank statt auf den TTL-Ablauf zu warten — wird nach Store.Set
|
||||||
|
// auf derselben Instanz aufgerufen, damit der Schreiber die eigene Aenderung
|
||||||
|
// ohne Wartezeit sieht. Andere Core-Instanzen sehen sie spaetestens nach
|
||||||
|
// DefaultCacheTTL (siehe Akzeptanzkriterium 3).
|
||||||
|
func (s *Service) Invalidate(key string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.cache, key)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package flag
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupFlagStoreTest(t *testing.T) (*Store, func()) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS feature_flags (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
rollout_percentage INT NOT NULL DEFAULT 0,
|
||||||
|
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'test\_%' ESCAPE '\'`)
|
||||||
|
pool.Close()
|
||||||
|
}
|
||||||
|
return NewStore(pool), cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie liefert im Test
|
||||||
|
// die erwartete Auswertung.
|
||||||
|
func TestService_TargetTenantStrategy(t *testing.T) {
|
||||||
|
store, cleanup := setupFlagStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := store.Set(ctx, Flag{Key: "test_target_flag", TargetTenantSlugs: []string{"acme"}}); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
svc := NewService(store, time.Hour)
|
||||||
|
|
||||||
|
if !svc.IsEnabled(ctx, "acme", "test_target_flag") {
|
||||||
|
t.Fatal("erwartet true fuer tenant in zielgruppe")
|
||||||
|
}
|
||||||
|
if svc.IsEnabled(ctx, "globex", "test_target_flag") {
|
||||||
|
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + 3 + Pruefung 1: Flag-Aenderung wirkt innerhalb der
|
||||||
|
// dokumentierten Cache-Invalidierungszeit, automatisiert gemessen.
|
||||||
|
func TestService_CacheInvalidationTiming(t *testing.T) {
|
||||||
|
store, cleanup := setupFlagStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const ttl = 150 * time.Millisecond
|
||||||
|
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: false}); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
svc := NewService(store, ttl)
|
||||||
|
|
||||||
|
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
|
||||||
|
t.Fatal("erwartet false vor der aenderung")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aenderung "auf einer anderen instanz" simulieren: direkt ueber den
|
||||||
|
// Store, ohne svc.Invalidate aufzurufen.
|
||||||
|
changedAt := time.Now()
|
||||||
|
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: true}); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sofort danach sollte der Cache noch den alten Stand liefern.
|
||||||
|
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
|
||||||
|
t.Fatal("cache haette den alten (false) stand liefern sollen, direkt nach der aenderung")
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := changedAt.Add(ttl + 100*time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
|
||||||
|
elapsed := time.Since(changedAt)
|
||||||
|
t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", elapsed, ttl)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_InvalidateForcesImmediateRefresh(t *testing.T) {
|
||||||
|
store, cleanup := setupFlagStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: false}); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
svc := NewService(store, time.Hour) // lange TTL, damit Invalidate den unterschied macht
|
||||||
|
_ = svc.IsEnabled(ctx, "acme", "test_invalidate_flag")
|
||||||
|
|
||||||
|
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: true}); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
svc.Invalidate("test_invalidate_flag")
|
||||||
|
|
||||||
|
if !svc.IsEnabled(ctx, "acme", "test_invalidate_flag") {
|
||||||
|
t.Fatal("erwartet sofort sichtbaren neuen stand nach Invalidate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3: Ausfall des Flag-Dienstes fuehrt zu
|
||||||
|
// dokumentiertem Fallback-Verhalten, nicht zum Absturz.
|
||||||
|
func TestService_FallsBackOnStoreFailure(t *testing.T) {
|
||||||
|
store, cleanup := setupFlagStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := store.Set(ctx, Flag{Key: "test_fallback_flag", Enabled: true}); err != nil {
|
||||||
|
t.Fatalf("set: %v", err)
|
||||||
|
}
|
||||||
|
svc := NewService(store, time.Hour)
|
||||||
|
|
||||||
|
// Cache vorwaermen, waehrend die DB noch erreichbar ist.
|
||||||
|
if !svc.IsEnabled(ctx, "acme", "test_fallback_flag") {
|
||||||
|
t.Fatal("erwartet true bei funktionierender db")
|
||||||
|
}
|
||||||
|
|
||||||
|
brokenPool, err := pgxpool.New(ctx, "postgresql://nonexistent-host-fuer-test:5432/x?connect_timeout=1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("broken pool erstellen (sollte nicht sofort verbinden): %v", err)
|
||||||
|
}
|
||||||
|
brokenStore := NewStore(brokenPool)
|
||||||
|
|
||||||
|
svcWithCache := NewService(brokenStore, time.Nanosecond) // TTL sofort abgelaufen, erzwingt reload-versuch
|
||||||
|
svcWithCache.mu.Lock()
|
||||||
|
svcWithCache.cache["test_fallback_flag"] = cacheEntry{
|
||||||
|
flag: Flag{Key: "test_fallback_flag", Enabled: true},
|
||||||
|
expiresAt: time.Now().Add(-time.Hour), // bereits abgelaufen
|
||||||
|
}
|
||||||
|
svcWithCache.mu.Unlock()
|
||||||
|
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("IsEnabled hat gepanict statt einen fallback zu liefern: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if !svcWithCache.IsEnabled(ctx, "acme", "test_fallback_flag") {
|
||||||
|
t.Fatal("erwartet fallback auf zwischengespeicherten (true) stand bei db-ausfall")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Voellig frischer Dienst ohne jeglichen cache + kaputte db -> sicherer
|
||||||
|
// default false, kein absturz.
|
||||||
|
freshSvc := NewService(brokenStore, time.Hour)
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("IsEnabled hat gepanict: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if freshSvc.IsEnabled(ctx, "acme", "test_fallback_flag") {
|
||||||
|
t.Fatal("erwartet fail-safe false ohne cache und mit kaputter db")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Package license implementiert Core LIC-01: Lizenzmodell je Tenant (Plan,
|
||||||
|
// Modul-Umfang, Laufzeit) und die kryptographische Pruefung signierter
|
||||||
|
// Lizenzschluessel. Feature-Flag-AUSWERTUNG zur Laufzeit (LIC-02) und die
|
||||||
|
// Verwaltungsoberflaeche (LIC-04) sind ausdruecklich nicht Teil dieses Pakets
|
||||||
|
// — hier geht es nur um Ausstellung/Validierung/Persistenz (Unleash-Vorbild:
|
||||||
|
// klare Trennung Flag-Verwaltung vs. Flag-Auswertung).
|
||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidSignature = errors.New("license: signatur ungueltig")
|
||||||
|
ErrMalformedKey = errors.New("license: lizenzschluessel hat ungueltiges format")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Payload ist der signierte Lizenzinhalt (Akzeptanzkriterium 3: Plan,
|
||||||
|
// Modul-Liste, Laufzeit).
|
||||||
|
type Payload struct {
|
||||||
|
TenantSlug string `json:"tenant_slug"`
|
||||||
|
Plan string `json:"plan"`
|
||||||
|
Modules []string `json:"modules"`
|
||||||
|
IssuedAt time.Time `json:"issued_at"`
|
||||||
|
ValidUntil time.Time `json:"valid_until"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issuer stellt signierte Lizenzschluessel aus. Haelt den PRIVATEN
|
||||||
|
// Ed25519-Schluessel — lebt in der Praxis beim Lizenzgeber, nicht im
|
||||||
|
// laufenden Core-Prozess (der nur den Validator mit dem oeffentlichen
|
||||||
|
// Schluessel braucht).
|
||||||
|
type Issuer struct {
|
||||||
|
priv ed25519.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIssuer(priv ed25519.PrivateKey) *Issuer {
|
||||||
|
return &Issuer{priv: priv}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue liefert den Lizenzschluessel im Format base64(payload-json) "." base64(signatur).
|
||||||
|
func (i *Issuer) Issue(payload Payload) (string, error) {
|
||||||
|
raw, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("payload serialisieren: %w", err)
|
||||||
|
}
|
||||||
|
sig := ed25519.Sign(i.priv, raw)
|
||||||
|
|
||||||
|
return base64.RawURLEncoding.EncodeToString(raw) + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validator prueft Lizenzschluessel gegen den OEFFENTLICHEN Ed25519-Schluessel
|
||||||
|
// — das ist alles, was der laufende Core-Prozess kennen muss.
|
||||||
|
type Validator struct {
|
||||||
|
pub ed25519.PublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewValidator(pub ed25519.PublicKey) *Validator {
|
||||||
|
return &Validator{pub: pub}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse prueft die Signatur (Akzeptanzkriterium 1 / Pruefung 1) und liefert
|
||||||
|
// bei Erfolg den entschluesselten Payload. Ein manipulierter Schluessel wird
|
||||||
|
// hier zuverlaessig erkannt, unabhaengig davon, ob die Laufzeit noch gueltig
|
||||||
|
// waere — Signaturpruefung und Ablaufpruefung sind bewusst getrennt
|
||||||
|
// (Signatur bei Einspielen, Ablauf bei jeder Nutzung, siehe Store.RequireActive).
|
||||||
|
func (v *Validator) Parse(key string) (Payload, error) {
|
||||||
|
rawPart, sigPart, ok := splitOnce(key, '.')
|
||||||
|
if !ok {
|
||||||
|
return Payload{}, ErrMalformedKey
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(rawPart)
|
||||||
|
if err != nil {
|
||||||
|
return Payload{}, ErrMalformedKey
|
||||||
|
}
|
||||||
|
sig, err := base64.RawURLEncoding.DecodeString(sigPart)
|
||||||
|
if err != nil {
|
||||||
|
return Payload{}, ErrMalformedKey
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ed25519.Verify(v.pub, raw, sig) {
|
||||||
|
return Payload{}, ErrInvalidSignature
|
||||||
|
}
|
||||||
|
|
||||||
|
var p Payload
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
// Signatur war gueltig, aber Payload nicht mehr parsebar — sollte bei
|
||||||
|
// unveraenderten Schluesseln nie vorkommen, trotzdem kein Panic.
|
||||||
|
return Payload{}, fmt.Errorf("%w: payload nicht lesbar", ErrMalformedKey)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitOnce(s string, sep byte) (before, after string, ok bool) {
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] == sep {
|
||||||
|
return s[:i], s[i+1:], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testKeyPair(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||||
|
t.Helper()
|
||||||
|
pub, priv, err := ed25519.GenerateKey(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("schluesselpaar erzeugen: %v", err)
|
||||||
|
}
|
||||||
|
return pub, priv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIssueAndParse_RoundTrip(t *testing.T) {
|
||||||
|
pub, priv := testKeyPair(t)
|
||||||
|
issuer := NewIssuer(priv)
|
||||||
|
validator := NewValidator(pub)
|
||||||
|
|
||||||
|
payload := Payload{
|
||||||
|
TenantSlug: "acme",
|
||||||
|
Plan: "pro",
|
||||||
|
Modules: []string{"dms", "mail"},
|
||||||
|
IssuedAt: time.Now().Truncate(time.Second),
|
||||||
|
ValidUntil: time.Now().Add(365 * 24 * time.Hour).Truncate(time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := issuer.Issue(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := validator.Parse(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse: %v", err)
|
||||||
|
}
|
||||||
|
if got.TenantSlug != payload.TenantSlug || got.Plan != payload.Plan || len(got.Modules) != 2 {
|
||||||
|
t.Fatalf("payload nach parse unerwartet: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 1: manipulierter Schluessel wird zuverlaessig erkannt.
|
||||||
|
func TestParse_RejectsTamperedKey(t *testing.T) {
|
||||||
|
pub, priv := testKeyPair(t)
|
||||||
|
issuer := NewIssuer(priv)
|
||||||
|
validator := NewValidator(pub)
|
||||||
|
|
||||||
|
key, err := issuer.Issue(Payload{TenantSlug: "acme", Plan: "pro", ValidUntil: time.Now().Add(time.Hour)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein Zeichen im signierten Teil aendern.
|
||||||
|
tampered := []byte(key)
|
||||||
|
changed := false
|
||||||
|
for i, c := range tampered {
|
||||||
|
if c != '.' {
|
||||||
|
if c == 'A' {
|
||||||
|
tampered[i] = 'B'
|
||||||
|
} else {
|
||||||
|
tampered[i] = 'A'
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("testaufbau fehlerhaft: nichts zum manipulieren gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := validator.Parse(string(tampered)); !errors.Is(err, ErrInvalidSignature) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidSignature, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_RejectsWrongKeyPair(t *testing.T) {
|
||||||
|
_, priv := testKeyPair(t)
|
||||||
|
otherPub, _ := testKeyPair(t)
|
||||||
|
|
||||||
|
issuer := NewIssuer(priv)
|
||||||
|
validator := NewValidator(otherPub) // falscher oeffentlicher Schluessel
|
||||||
|
|
||||||
|
key, err := issuer.Issue(Payload{TenantSlug: "acme", Plan: "pro"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := validator.Parse(key); !errors.Is(err, ErrInvalidSignature) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidSignature, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse_RejectsMalformedKey(t *testing.T) {
|
||||||
|
pub, _ := testKeyPair(t)
|
||||||
|
validator := NewValidator(pub)
|
||||||
|
|
||||||
|
cases := []string{"", "keine-punkt-trennung", "!!!.!!!"}
|
||||||
|
for _, c := range cases {
|
||||||
|
if _, err := validator.Parse(c); !errors.Is(err, ErrMalformedKey) {
|
||||||
|
t.Fatalf("Parse(%q): erwartet ErrMalformedKey, habe %v", c, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNoLicense = errors.New("license: kein lizenzdatensatz fuer diesen tenant")
|
||||||
|
ErrLicenseExpired = errors.New("license: lizenz abgelaufen")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store persistiert den Lizenzumfang je Tenant in der Control-Plane-Registry
|
||||||
|
// (siehe internal/tenant.Registry — dieselbe Datenbank, aber ein eigener,
|
||||||
|
// unabhaengiger Store, um internal/tenant nicht um lizenzfremde Belange zu
|
||||||
|
// erweitern).
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
validator *Validator
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool, validator *Validator) *Store {
|
||||||
|
return &Store{pool: pool, validator: validator}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install prueft die Signatur des Lizenzschluessels (Akzeptanzkriterium 1)
|
||||||
|
// und ersetzt den bisherigen Lizenzdatensatz des Tenants vollstaendig. Ein
|
||||||
|
// bereits abgelaufener, aber korrekt signierter Schluessel wird trotzdem
|
||||||
|
// gespeichert — der Ablauf wird erst bei der Nutzung (RequireActive)
|
||||||
|
// bewertet, nicht beim Einspielen.
|
||||||
|
func (s *Store) Install(ctx context.Context, tenantID, licenseKey string) (Payload, error) {
|
||||||
|
payload, err := s.validator.Parse(licenseKey)
|
||||||
|
if err != nil {
|
||||||
|
return Payload{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO tenant_licenses (tenant_id, plan, modules, issued_at, valid_until, raw_key, installed_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, now())
|
||||||
|
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||||
|
plan = $2, modules = $3, issued_at = $4, valid_until = $5, raw_key = $6, installed_at = now()
|
||||||
|
`, tenantID, payload.Plan, payload.Modules, payload.IssuedAt, payload.ValidUntil, licenseKey)
|
||||||
|
if err != nil {
|
||||||
|
return Payload{}, fmt.Errorf("lizenz speichern: %w", err)
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) get(ctx context.Context, tenantID string) (Payload, error) {
|
||||||
|
var p Payload
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT plan, modules, issued_at, valid_until
|
||||||
|
FROM tenant_licenses WHERE tenant_id = $1
|
||||||
|
`, tenantID)
|
||||||
|
if err := row.Scan(&p.Plan, &p.Modules, &p.IssuedAt, &p.ValidUntil); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return Payload{}, ErrNoLicense
|
||||||
|
}
|
||||||
|
return Payload{}, fmt.Errorf("lizenz lesen: %w", err)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status liefert den persistierten Lizenzumfang unabhaengig vom Ablauf
|
||||||
|
// (Akzeptanzkriterium 3: Plan, Modul-Liste, Laufzeit abfragbar).
|
||||||
|
func (s *Store) Status(ctx context.Context, tenantID string) (Payload, error) {
|
||||||
|
return s.get(ctx, tenantID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireActive liefert den Lizenzumfang NUR, wenn die Lizenz noch nicht
|
||||||
|
// abgelaufen ist — sonst ErrLicenseExpired statt eines harten Fehlers/Panics
|
||||||
|
// (Akzeptanzkriterium 2: definierter eingeschraenkter Zustand). Aufrufende
|
||||||
|
// Module (LIC-02/03) entscheiden, was "eingeschraenkt" konkret bedeutet.
|
||||||
|
func (s *Store) RequireActive(ctx context.Context, tenantID string) (Payload, error) {
|
||||||
|
p, err := s.get(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return Payload{}, err
|
||||||
|
}
|
||||||
|
if time.Now().After(p.ValidUntil) {
|
||||||
|
return Payload{}, ErrLicenseExpired
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ed25519"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupStoreTest(t *testing.T) (*Store, *Issuer, string, func()) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS tenants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
db_name TEXT NOT NULL UNIQUE,
|
||||||
|
db_dsn TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS tenant_licenses (
|
||||||
|
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
|
||||||
|
plan TEXT NOT NULL,
|
||||||
|
modules TEXT[] NOT NULL,
|
||||||
|
issued_at TIMESTAMPTZ NOT NULL,
|
||||||
|
valid_until TIMESTAMPTZ NOT NULL,
|
||||||
|
raw_key TEXT NOT NULL,
|
||||||
|
installed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tenantID string
|
||||||
|
if err := pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO tenants (slug, name, db_name, db_dsn)
|
||||||
|
VALUES ('lic_test_tenant', 'Lic Test', 'tenant_lic_test', 'unused')
|
||||||
|
RETURNING id
|
||||||
|
`).Scan(&tenantID); err != nil {
|
||||||
|
t.Fatalf("test-tenant anlegen: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub, priv, err := ed25519.GenerateKey(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("schluesselpaar: %v", err)
|
||||||
|
}
|
||||||
|
issuer := NewIssuer(priv)
|
||||||
|
store := NewStore(pool, NewValidator(pub))
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM tenant_licenses WHERE tenant_id = $1`, tenantID)
|
||||||
|
_, _ = pool.Exec(ctx, `DELETE FROM tenants WHERE id = $1`, tenantID)
|
||||||
|
pool.Close()
|
||||||
|
}
|
||||||
|
return store, issuer, tenantID, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3: Lizenzumfang persistiert und abfragbar.
|
||||||
|
func TestStore_InstallAndStatus(t *testing.T) {
|
||||||
|
store, issuer, tenantID, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
payload := Payload{
|
||||||
|
TenantSlug: "lic_test_tenant",
|
||||||
|
Plan: "enterprise",
|
||||||
|
Modules: []string{"dms", "mail", "archive"},
|
||||||
|
IssuedAt: time.Now().Truncate(time.Second),
|
||||||
|
ValidUntil: time.Now().Add(30 * 24 * time.Hour).Truncate(time.Second),
|
||||||
|
}
|
||||||
|
key, err := issuer.Issue(payload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.Install(ctx, tenantID, key); err != nil {
|
||||||
|
t.Fatalf("install: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err := store.Status(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("status: %v", err)
|
||||||
|
}
|
||||||
|
if status.Plan != "enterprise" || len(status.Modules) != 3 {
|
||||||
|
t.Fatalf("status unerwartet: %+v", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_InstallRejectsInvalidSignature(t *testing.T) {
|
||||||
|
store, _, tenantID, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, otherPriv, _ := ed25519.GenerateKey(nil)
|
||||||
|
foreignIssuer := NewIssuer(otherPriv) // signiert mit falschem schluessel
|
||||||
|
|
||||||
|
key, err := foreignIssuer.Issue(Payload{TenantSlug: "lic_test_tenant", Plan: "pro", ValidUntil: time.Now().Add(time.Hour)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := store.Install(ctx, tenantID, key); !errors.Is(err, ErrInvalidSignature) {
|
||||||
|
t.Fatalf("erwartet ErrInvalidSignature, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2: abgelaufene Lizenz fuehrt zu definiertem
|
||||||
|
// eingeschraenktem Zustand (ErrLicenseExpired), nicht zu einem Absturz.
|
||||||
|
func TestStore_RequireActive_DetectsExpiry(t *testing.T) {
|
||||||
|
store, issuer, tenantID, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
expired := Payload{
|
||||||
|
TenantSlug: "lic_test_tenant",
|
||||||
|
Plan: "pro",
|
||||||
|
Modules: []string{"dms"},
|
||||||
|
IssuedAt: time.Now().Add(-48 * time.Hour),
|
||||||
|
ValidUntil: time.Now().Add(-24 * time.Hour), // bereits abgelaufen
|
||||||
|
}
|
||||||
|
key, err := issuer.Issue(expired)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("issue: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einspielen einer bereits abgelaufenen, aber korrekt signierten Lizenz
|
||||||
|
// muss funktionieren (Ablauf wird erst bei Nutzung bewertet).
|
||||||
|
if _, err := store.Install(ctx, tenantID, key); err != nil {
|
||||||
|
t.Fatalf("install sollte trotz ablauf funktionieren: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Fatalf("RequireActive hat gepanict statt einen fehler zu liefern: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if _, err := store.RequireActive(ctx, tenantID); !errors.Is(err, ErrLicenseExpired) {
|
||||||
|
t.Fatalf("erwartet ErrLicenseExpired, habe %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Aber der Umfang bleibt weiterhin abfragbar (Status, im Unterschied zu RequireActive).
|
||||||
|
status, err := store.Status(ctx, tenantID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("status sollte trotz ablauf funktionieren: %v", err)
|
||||||
|
}
|
||||||
|
if status.Plan != "pro" {
|
||||||
|
t.Fatalf("status unerwartet: %+v", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStore_RequireActive_NoLicense(t *testing.T) {
|
||||||
|
store, _, tenantID, cleanup := setupStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := store.RequireActive(ctx, tenantID); !errors.Is(err, ErrNoLicense) {
|
||||||
|
t.Fatalf("erwartet ErrNoLicense, habe %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,7 @@ package tenant
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler ist eine schlanke Vorbereitung der Schnittstelle fuer API-01
|
// 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)
|
w.WriteHeader(http.StatusCreated)
|
||||||
_ = json.NewEncoder(w).Encode(t)
|
_ = 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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,17 +2,11 @@ package tenant
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"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
|
// Provisioner legt fuer jeden neuen Mandanten eine vollstaendig isolierte
|
||||||
// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry
|
// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry
|
||||||
// (Akzeptanzkriterium 2). Zwei Mandanten-Datenbanken sind danach auf
|
// (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
|
// CREATE DATABASE erlaubt keine Parameter-Platzhalter; slug ist durch
|
||||||
// ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen.
|
// ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen.
|
||||||
if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
|
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)
|
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 {
|
if err != nil {
|
||||||
_ = tx.Rollback(ctx)
|
_ = tx.Rollback(ctx)
|
||||||
p.rollbackDatabase(ctx, dbName)
|
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
|
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) {
|
func (p *Provisioner) rollbackDatabase(ctx context.Context, dbName string) {
|
||||||
_, _ = p.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
_, _ = 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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) {
|
func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
|
||||||
// previous_status/deletion_scheduled_at werden mitgelesen, damit TEN-04
|
var t Tenant
|
||||||
// (internal/tenant/lifecycle.go) den vollstaendigen Lebenszyklus-Zustand
|
|
||||||
// ueber GetBySlug ansehen kann, statt eine eigene Abfrage zu duplizieren.
|
|
||||||
row := r.pool.QueryRow(ctx, `
|
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
|
FROM tenants WHERE slug = $1
|
||||||
`, slug)
|
`, slug)
|
||||||
|
|
||||||
t, err := scanTenantWithLifecycle(row)
|
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
|
||||||
if err != nil {
|
|
||||||
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
|
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
|
||||||
}
|
}
|
||||||
return t, nil
|
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) {
|
func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
|
||||||
rows, err := r.pool.Query(ctx, `
|
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
|
FROM tenants ORDER BY created_at
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,8 +59,8 @@ func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
|
|||||||
|
|
||||||
var out []Tenant
|
var out []Tenant
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
t, err := scanTenantWithLifecycle(rows)
|
var t Tenant
|
||||||
if err != nil {
|
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)
|
return nil, fmt.Errorf("tenant lesen: %w", err)
|
||||||
}
|
}
|
||||||
out = append(out, t)
|
out = append(out, t)
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ type Status string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
StatusActive Status = "active"
|
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 {
|
type Tenant struct {
|
||||||
@@ -26,11 +22,6 @@ type Tenant struct {
|
|||||||
DBDSN string
|
DBDSN string
|
||||||
Status Status
|
Status Status
|
||||||
CreatedAt time.Time
|
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
|
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package usage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AggregateFunc berechnet/aktualisiert Zaehlerstaende aus einer autoritativen
|
||||||
|
// Quelle (z.B. "zaehle Zeilen in einer Modul-Tabelle") — die konkrete Quelle
|
||||||
|
// haengt vom jeweiligen Modul ab und ist nicht Teil dieser Kachel. Das
|
||||||
|
// Aggregations-Grundgerüst selbst (periodischer Trigger) ist es.
|
||||||
|
type AggregateFunc func(ctx context.Context) error
|
||||||
|
|
||||||
|
// RunPeriodicAggregation ruft aggregate in festen Abstaenden auf, bis ctx
|
||||||
|
// beendet wird — dieselbe In-Prozess-Worker-Goroutine-Konvention wie
|
||||||
|
// internal/tenant.Lifecycle.RunSweeper (Akzeptanzkriterium 1: "periodisch
|
||||||
|
// aggregiert").
|
||||||
|
func RunPeriodicAggregation(ctx context.Context, interval time.Duration, aggregate AggregateFunc) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := aggregate(ctx); err != nil {
|
||||||
|
slog.Error("nutzungszaehler-aggregation fehlgeschlagen", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
// Package usage implementiert Core LIC-03: Nutzungszaehler je Tenant
|
||||||
|
// (Benutzeranzahl, Speicherverbrauch, API-Aufrufe, ...) und die Pruefung
|
||||||
|
// gegen konfigurierte Quotas. Quotas sind Konfiguration (Tabellenzeile), kein
|
||||||
|
// Hardcode — Zitadel/Unleash-Vorbild.
|
||||||
|
package usage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNoQuota = errors.New("usage: keine quota fuer diese metrik konfiguriert")
|
||||||
|
|
||||||
|
// Status ist die definierte Reaktion einer Quota-Pruefung (Akzeptanzkriterium 2).
|
||||||
|
type Status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusOK Status = "ok"
|
||||||
|
StatusWarning Status = "warning" // Schwelle (80%) erreicht, aber noch nicht ueberschritten
|
||||||
|
StatusExceeded Status = "exceeded" // Quota ueberschritten — neue Ressourcen sollten gesperrt werden
|
||||||
|
)
|
||||||
|
|
||||||
|
// warningThreshold liegt bei 80% der Quota.
|
||||||
|
const warningThreshold = 0.8
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment erhoeht einen Zaehler ATOMAR ueber ein einziges SQL-Statement
|
||||||
|
// (UPSERT mit value = value + delta) statt Read-Modify-Write in Go — das
|
||||||
|
// haelt Zaehlerstaende bei parallelen Schreibzugriffen konsistent
|
||||||
|
// (Akzeptanzkriterium 1 / Pruefung 2), ohne eine Anwendungs-Transaktion mit
|
||||||
|
// Lock zu brauchen.
|
||||||
|
func (s *Store) Increment(ctx context.Context, tenantID, metric string, delta int64) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO usage_counters (tenant_id, metric, value, updated_at)
|
||||||
|
VALUES ($1, $2, $3, now())
|
||||||
|
ON CONFLICT (tenant_id, metric) DO UPDATE
|
||||||
|
SET value = usage_counters.value + $3, updated_at = now()
|
||||||
|
`, tenantID, metric, delta)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("zaehler erhoehen: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get liefert den aktuellen Zaehlerstand — 0, wenn noch nie erhoeht wurde.
|
||||||
|
// Der Wert ist strikt tenant-gescoped (Akzeptanzkriterium 3 / Pruefung 3).
|
||||||
|
func (s *Store) Get(ctx context.Context, tenantID, metric string) (int64, error) {
|
||||||
|
var value int64
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT value FROM usage_counters WHERE tenant_id = $1 AND metric = $2
|
||||||
|
`, tenantID, metric).Scan(&value)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("zaehler lesen: %w", err)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetQuota legt die Obergrenze fuer (tenantID, metric) fest — Konfiguration,
|
||||||
|
// kein Hardcode.
|
||||||
|
func (s *Store) SetQuota(ctx context.Context, tenantID, metric string, limit int64) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO usage_quotas (tenant_id, metric, limit_value)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (tenant_id, metric) DO UPDATE SET limit_value = $3
|
||||||
|
`, tenantID, metric, limit)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("quota setzen: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetQuota(ctx context.Context, tenantID, metric string) (int64, error) {
|
||||||
|
var limit int64
|
||||||
|
err := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT limit_value FROM usage_quotas WHERE tenant_id = $1 AND metric = $2
|
||||||
|
`, tenantID, metric).Scan(&limit)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, ErrNoQuota
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("quota lesen: %w", err)
|
||||||
|
}
|
||||||
|
return limit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check liefert Zaehlerstand, konfigurierte Quota und die daraus abgeleitete
|
||||||
|
// Reaktion (Akzeptanzkriterium 2 / Pruefung 1). Ist keine Quota konfiguriert,
|
||||||
|
// gilt die Metrik als unbegrenzt (StatusOK).
|
||||||
|
func (s *Store) Check(ctx context.Context, tenantID, metric string) (value, limit int64, status Status, err error) {
|
||||||
|
value, err = s.Get(ctx, tenantID, metric)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, err = s.GetQuota(ctx, tenantID, metric)
|
||||||
|
if errors.Is(err, ErrNoQuota) {
|
||||||
|
return value, 0, StatusOK, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case value > limit:
|
||||||
|
return value, limit, StatusExceeded, nil
|
||||||
|
case limit > 0 && float64(value) >= warningThreshold*float64(limit):
|
||||||
|
return value, limit, StatusWarning, nil
|
||||||
|
default:
|
||||||
|
return value, limit, StatusOK, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reaction wird aufgerufen, wenn Check einen Nicht-OK-Status liefert
|
||||||
|
// (Akzeptanzkriterium 2: "definierte Reaktion").
|
||||||
|
type Reaction func(ctx context.Context, tenantID, metric string, value, limit int64, status Status)
|
||||||
|
|
||||||
|
// Enforce fuehrt Check aus und ruft react auf, wenn der Status nicht OK ist —
|
||||||
|
// die konkrete "Sperre neuer Ressourcen"/Benachrichtigung liegt beim
|
||||||
|
// Aufrufer (z.B. TEN-02 vor dem Anlegen eines neuen Benutzers), Enforce
|
||||||
|
// garantiert nur, dass die Reaktion zuverlaessig ausgeloest wird.
|
||||||
|
func (s *Store) Enforce(ctx context.Context, tenantID, metric string, react Reaction) (Status, error) {
|
||||||
|
value, limit, status, err := s.Check(ctx, tenantID, metric)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if status != StatusOK && react != nil {
|
||||||
|
react(ctx, tenantID, metric, value, limit, status)
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package usage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTest(t *testing.T) (*Store, func()) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS usage_counters (
|
||||||
|
tenant_id UUID NOT NULL, metric TEXT NOT NULL, value BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (tenant_id, metric)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS usage_quotas (
|
||||||
|
tenant_id UUID NOT NULL, metric TEXT NOT NULL, limit_value BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (tenant_id, metric)
|
||||||
|
);
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() { pool.Close() }
|
||||||
|
return NewStore(pool), cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTenantID() string {
|
||||||
|
return fmt.Sprintf("00000000-0000-0000-0000-%012d", time.Now().UnixNano()%1e12)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1 + Pruefung 2: Aggregationsjob liefert bei parallelen
|
||||||
|
// Schreibzugriffen konsistente Zaehlerstaende.
|
||||||
|
func TestIncrement_ConsistentUnderConcurrentWrites(t *testing.T) {
|
||||||
|
store, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
tenant := newTenantID()
|
||||||
|
|
||||||
|
const goroutines = 50
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < goroutines; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if err := store.Increment(ctx, tenant, "api_calls", 1); err != nil {
|
||||||
|
t.Errorf("increment: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
value, err := store.Get(ctx, tenant, "api_calls")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if value != goroutines {
|
||||||
|
t.Fatalf("erwartet %d, habe %d (hinweis auf lost update unter nebenlaeufigkeit)", goroutines, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3 + Pruefung 3: Zaehlerstand eines Tenants beeinflusst
|
||||||
|
// nicht den eines anderen.
|
||||||
|
func TestIncrement_IsolatedBetweenTenants(t *testing.T) {
|
||||||
|
store, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
tenantA, tenantB := newTenantID(), newTenantID()
|
||||||
|
|
||||||
|
if err := store.Increment(ctx, tenantA, "users", 5); err != nil {
|
||||||
|
t.Fatalf("increment a: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.Increment(ctx, tenantB, "users", 1); err != nil {
|
||||||
|
t.Fatalf("increment b: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
valA, err := store.Get(ctx, tenantA, "users")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get a: %v", err)
|
||||||
|
}
|
||||||
|
valB, err := store.Get(ctx, tenantB, "users")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get b: %v", err)
|
||||||
|
}
|
||||||
|
if valA != 5 || valB != 1 {
|
||||||
|
t.Fatalf("erwartet a=5 b=1, habe a=%d b=%d", valA, valB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 1: Quota-Ueberschreitung wird automatisiert
|
||||||
|
// erkannt und die definierte Reaktion ausgeloest.
|
||||||
|
func TestEnforce_TriggersReactionOnExceeded(t *testing.T) {
|
||||||
|
store, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
tenant := newTenantID()
|
||||||
|
|
||||||
|
if err := store.SetQuota(ctx, tenant, "users", 10); err != nil {
|
||||||
|
t.Fatalf("set quota: %v", err)
|
||||||
|
}
|
||||||
|
if err := store.Increment(ctx, tenant, "users", 11); err != nil {
|
||||||
|
t.Fatalf("increment: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var reacted bool
|
||||||
|
var gotStatus Status
|
||||||
|
status, err := store.Enforce(ctx, tenant, "users", func(ctx context.Context, tenantID, metric string, value, limit int64, status Status) {
|
||||||
|
reacted = true
|
||||||
|
gotStatus = status
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("enforce: %v", err)
|
||||||
|
}
|
||||||
|
if status != StatusExceeded {
|
||||||
|
t.Fatalf("erwartet StatusExceeded, habe %q", status)
|
||||||
|
}
|
||||||
|
if !reacted || gotStatus != StatusExceeded {
|
||||||
|
t.Fatal("erwartet ausgeloeste reaktion mit StatusExceeded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheck_WarningThresholdAndOK(t *testing.T) {
|
||||||
|
store, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
tenant := newTenantID()
|
||||||
|
|
||||||
|
if err := store.SetQuota(ctx, tenant, "storage_mb", 100); err != nil {
|
||||||
|
t.Fatalf("set quota: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Increment(ctx, tenant, "storage_mb", 50); err != nil {
|
||||||
|
t.Fatalf("increment: %v", err)
|
||||||
|
}
|
||||||
|
_, _, status, err := store.Check(ctx, tenant, "storage_mb")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("check: %v", err)
|
||||||
|
}
|
||||||
|
if status != StatusOK {
|
||||||
|
t.Fatalf("bei 50%% erwartet StatusOK, habe %q", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.Increment(ctx, tenant, "storage_mb", 35); err != nil { // insgesamt 85%
|
||||||
|
t.Fatalf("increment: %v", err)
|
||||||
|
}
|
||||||
|
_, _, status, err = store.Check(ctx, tenant, "storage_mb")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("check: %v", err)
|
||||||
|
}
|
||||||
|
if status != StatusWarning {
|
||||||
|
t.Fatalf("bei 85%% erwartet StatusWarning, habe %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheck_NoQuotaMeansUnlimited(t *testing.T) {
|
||||||
|
store, cleanup := setupTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
tenant := newTenantID()
|
||||||
|
|
||||||
|
if err := store.Increment(ctx, tenant, "api_calls", 1_000_000); err != nil {
|
||||||
|
t.Fatalf("increment: %v", err)
|
||||||
|
}
|
||||||
|
_, _, status, err := store.Check(ctx, tenant, "api_calls")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("check: %v", err)
|
||||||
|
}
|
||||||
|
if status != StatusOK {
|
||||||
|
t.Fatalf("ohne konfigurierte quota erwartet StatusOK, habe %q", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPeriodicAggregation_CallsRepeatedly(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
calls := 0
|
||||||
|
RunPeriodicAggregation(ctx, 20*time.Millisecond, func(ctx context.Context) error {
|
||||||
|
mu.Lock()
|
||||||
|
calls++
|
||||||
|
mu.Unlock()
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if calls < 3 {
|
||||||
|
t.Fatalf("erwartet mehrfache aufrufe innerhalb von 120ms bei 20ms interval, habe %d", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handler stellt die CRUD-API fuer Benutzerkonten bereit (IAM-01-Auftrag).
|
|
||||||
// Auth/Sessions (IAM-02) und Rollen (RBAC-01) sind ausdruecklich nicht Teil
|
|
||||||
// dieser Kachel und daher hier noch nicht angebunden.
|
|
||||||
type Handler struct {
|
|
||||||
users *TenantUserStore
|
|
||||||
superadmins *SuperadminStore
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewHandler(users *TenantUserStore, superadmins *SuperadminStore) *Handler {
|
|
||||||
return &Handler{users: users, superadmins: superadmins}
|
|
||||||
}
|
|
||||||
|
|
||||||
type createUserRequest struct {
|
|
||||||
Email string `json:"email"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req createUserRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := h.users.Create(r.Context(), req.Email, req.Name)
|
|
||||||
writeUserResult(w, u, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateSuperadmin legt ein mandantenuebergreifendes Superadmin-Konto an —
|
|
||||||
// bewusst ein eigener Endpunkt statt eines Tenant-Parameters mit Null-Wert.
|
|
||||||
func (h *Handler) CreateSuperadmin(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req createUserRequest
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
||||||
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := h.superadmins.Create(r.Context(), req.Email, req.Name)
|
|
||||||
writeUserResult(w, u, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeUserResult(w http.ResponseWriter, u User, err error) {
|
|
||||||
if err != nil {
|
|
||||||
switch {
|
|
||||||
case errors.Is(err, ErrInvalidEmail), errors.Is(err, ErrEmailTaken):
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
case errors.Is(err, ErrNotFound):
|
|
||||||
http.Error(w, err.Error(), http.StatusNotFound)
|
|
||||||
default:
|
|
||||||
http.Error(w, "benutzer konnte nicht verarbeitet werden", http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
_ = json.NewEncoder(w).Encode(u)
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// setupTestDB legt eine frische, isolierte Testdatenbank an, wendet die
|
|
||||||
// uebergebene Migration an und liefert einen verbundenen Pool. Wird ohne
|
|
||||||
// TEST_ADMIN_DSN uebersprungen — siehe internal/tenant/provisioner_test.go
|
|
||||||
// fuer dasselbe Muster.
|
|
||||||
func setupTestDB(t *testing.T, dbName, schemaSQL string) *pgxpool.Pool {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
||||||
if adminDSN == "" {
|
|
||||||
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
||||||
}
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
adminPool, err := pgxpool.New(ctx, adminDSN)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("admin pool: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
|
||||||
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
|
|
||||||
t.Fatalf("testdatenbank anlegen: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
|
|
||||||
pool, err := pgxpool.New(ctx, dsn)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("connect testdatenbank: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := pool.Exec(ctx, schemaSQL); err != nil {
|
|
||||||
t.Fatalf("schema anwenden: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Cleanup(func() {
|
|
||||||
pool.Close()
|
|
||||||
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
|
||||||
adminPool.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
return pool
|
|
||||||
}
|
|
||||||
|
|
||||||
const usersSchema = `
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
CREATE TABLE users (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);`
|
|
||||||
|
|
||||||
const superadminsSchema = `
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
CREATE TABLE superadmins (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);`
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1 + 3, Pruefung 1 (inkl. Negativfall doppelte E-Mail).
|
|
||||||
func TestTenantUserStore_CRUD(t *testing.T) {
|
|
||||||
pool := setupTestDB(t, "test_iam01_users", usersSchema)
|
|
||||||
store := NewTenantUserStore(pool)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
created, err := store.Create(ctx, "alice@example.com", "Alice")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create: %v", err)
|
|
||||||
}
|
|
||||||
if created.Status != StatusActive {
|
|
||||||
t.Fatalf("erwartet status active, hat %q", created.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := store.Get(ctx, created.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get: %v", err)
|
|
||||||
}
|
|
||||||
if got.Email != "alice@example.com" {
|
|
||||||
t.Fatalf("get email = %q", got.Email)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := store.Update(ctx, created.ID, "", "Alice A.")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("update: %v", err)
|
|
||||||
}
|
|
||||||
if updated.Name != "Alice A." || updated.Email != "alice@example.com" {
|
|
||||||
t.Fatalf("update ergebnis unerwartet: %+v", updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
list, err := store.List(ctx)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("list: %v", err)
|
|
||||||
}
|
|
||||||
if len(list) != 1 {
|
|
||||||
t.Fatalf("erwartet 1 benutzer, habe %d", len(list))
|
|
||||||
}
|
|
||||||
|
|
||||||
deactivated, err := store.Deactivate(ctx, created.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("deactivate: %v", err)
|
|
||||||
}
|
|
||||||
if deactivated.Status != StatusInactive {
|
|
||||||
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Negativfall: doppelte E-Mail-Adresse.
|
|
||||||
if _, err := store.Create(ctx, "second@example.com", "Bob"); err != nil {
|
|
||||||
t.Fatalf("create second: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := store.Create(ctx, "second@example.com", "Bob Zwei"); !errors.Is(err, ErrEmailTaken) {
|
|
||||||
t.Fatalf("erwartet ErrEmailTaken, habe %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Negativfall: fehlender Benutzer.
|
|
||||||
if _, err := store.Get(ctx, created.ID+"-nicht-vorhanden"); err == nil {
|
|
||||||
t.Fatalf("erwartet fehler bei unbekannter/ungueltiger id")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2 + Pruefung 2: Superadmin-Anlage ohne Tenant-Kontext.
|
|
||||||
// SuperadminStore.Create hat keinen Tenant-Parameter — es gibt syntaktisch
|
|
||||||
// keine Moeglichkeit, hier versehentlich einen Tenant-Sonderfall zu vergessen.
|
|
||||||
func TestSuperadminStore_CreateWithoutTenantContext(t *testing.T) {
|
|
||||||
pool := setupTestDB(t, "test_iam01_superadmins", superadminsSchema)
|
|
||||||
store := NewSuperadminStore(pool)
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
created, err := store.Create(ctx, "root@nexarch.internal", "Root")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create superadmin: %v", err)
|
|
||||||
}
|
|
||||||
if created.Status != StatusActive {
|
|
||||||
t.Fatalf("erwartet status active, hat %q", created.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := store.Get(ctx, created.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get: %v", err)
|
|
||||||
}
|
|
||||||
if got.Email != "root@nexarch.internal" {
|
|
||||||
t.Fatalf("get email = %q", got.Email)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := store.Create(ctx, "root@nexarch.internal", "Root Zwei"); !errors.Is(err, ErrEmailTaken) {
|
|
||||||
t.Fatalf("erwartet ErrEmailTaken (globale eindeutigkeit), habe %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
deactivated, err := store.Deactivate(ctx, created.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("deactivate: %v", err)
|
|
||||||
}
|
|
||||||
if deactivated.Status != StatusInactive {
|
|
||||||
t.Fatalf("erwartet status inactive, hat %q", deactivated.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SuperadminStore verwaltet mandantenuebergreifende Superadmin-Konten in der
|
|
||||||
// Control-Plane-Registry (siehe internal/tenant.Registry). Superadmin-ohne-
|
|
||||||
// Tenant ist dadurch ein eigener Typ statt eines Sonderfalls von User/
|
|
||||||
// TenantUserStore — es gibt keinen Tenant-Parameter, den man weglassen
|
|
||||||
// koennte (IAM-01, "ohne Sonderbehandlung im Code").
|
|
||||||
type SuperadminStore struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSuperadminStore(pool *pgxpool.Pool) *SuperadminStore {
|
|
||||||
return &SuperadminStore{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SuperadminStore) Create(ctx context.Context, email, name string) (User, error) {
|
|
||||||
if err := ValidateEmail(email); err != nil {
|
|
||||||
return User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var u User
|
|
||||||
u.Email, u.Name, u.Status = email, name, StatusActive
|
|
||||||
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO superadmins (email, name, status)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
RETURNING id, created_at, updated_at
|
|
||||||
`, u.Email, u.Name, u.Status)
|
|
||||||
|
|
||||||
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
||||||
return User{}, mapWriteErr(err)
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SuperadminStore) Get(ctx context.Context, id string) (User, error) {
|
|
||||||
return scanUser(s.pool.QueryRow(ctx, `
|
|
||||||
SELECT id, email, name, status, created_at, updated_at
|
|
||||||
FROM superadmins WHERE id = $1
|
|
||||||
`, id))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SuperadminStore) List(ctx context.Context) ([]User, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
|
||||||
SELECT id, email, name, status, created_at, updated_at
|
|
||||||
FROM superadmins ORDER BY created_at
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("superadmins auflisten: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []User
|
|
||||||
for rows.Next() {
|
|
||||||
var u User
|
|
||||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("superadmin lesen: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, u)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SuperadminStore) Deactivate(ctx context.Context, id string) (User, error) {
|
|
||||||
return scanUser(s.pool.QueryRow(ctx, `
|
|
||||||
UPDATE superadmins SET status = $2, updated_at = now()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, email, name, status, created_at, updated_at
|
|
||||||
`, id, StatusInactive))
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TenantUserStore verwaltet Benutzer innerhalb GENAU EINER Tenant-Datenbank.
|
|
||||||
// Welcher Mandant gemeint ist, ergibt sich ausschliesslich aus dem
|
|
||||||
// uebergebenen Pool — es gibt keine tenant_id-Spalte (siehe migrations/tenant/0001_users.up.sql).
|
|
||||||
type TenantUserStore struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTenantUserStore(pool *pgxpool.Pool) *TenantUserStore {
|
|
||||||
return &TenantUserStore{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TenantUserStore) Create(ctx context.Context, email, name string) (User, error) {
|
|
||||||
if err := ValidateEmail(email); err != nil {
|
|
||||||
return User{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var u User
|
|
||||||
u.Email, u.Name, u.Status = email, name, StatusActive
|
|
||||||
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
INSERT INTO users (email, name, status)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
RETURNING id, created_at, updated_at
|
|
||||||
`, u.Email, u.Name, u.Status)
|
|
||||||
|
|
||||||
if err := row.Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
||||||
return User{}, mapWriteErr(err)
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TenantUserStore) Get(ctx context.Context, id string) (User, error) {
|
|
||||||
return scanUser(s.pool.QueryRow(ctx, `
|
|
||||||
SELECT id, email, name, status, created_at, updated_at
|
|
||||||
FROM users WHERE id = $1
|
|
||||||
`, id))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TenantUserStore) List(ctx context.Context) ([]User, error) {
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
|
||||||
SELECT id, email, name, status, created_at, updated_at
|
|
||||||
FROM users ORDER BY created_at
|
|
||||||
`)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("benutzer auflisten: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var out []User
|
|
||||||
for rows.Next() {
|
|
||||||
var u User
|
|
||||||
if err := rows.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("benutzer lesen: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, u)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update aendert Name und E-Mail. Eine leere email/name laesst das jeweilige
|
|
||||||
// Feld unveraendert.
|
|
||||||
func (s *TenantUserStore) Update(ctx context.Context, id, email, name string) (User, error) {
|
|
||||||
if email != "" {
|
|
||||||
if err := ValidateEmail(email); err != nil {
|
|
||||||
return User{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
row := s.pool.QueryRow(ctx, `
|
|
||||||
UPDATE users
|
|
||||||
SET email = COALESCE(NULLIF($2, ''), email),
|
|
||||||
name = COALESCE(NULLIF($3, ''), name),
|
|
||||||
updated_at = now()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, email, name, status, created_at, updated_at
|
|
||||||
`, id, email, name)
|
|
||||||
|
|
||||||
u, err := scanUser(row)
|
|
||||||
if err != nil {
|
|
||||||
return User{}, mapWriteErr(err)
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deactivate setzt den Benutzer auf inaktiv statt ihn zu loeschen.
|
|
||||||
func (s *TenantUserStore) Deactivate(ctx context.Context, id string) (User, error) {
|
|
||||||
return scanUser(s.pool.QueryRow(ctx, `
|
|
||||||
UPDATE users SET status = $2, updated_at = now()
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING id, email, name, status, created_at, updated_at
|
|
||||||
`, id, StatusInactive))
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanUser(row pgx.Row) (User, error) {
|
|
||||||
var u User
|
|
||||||
if err := row.Scan(&u.ID, &u.Email, &u.Name, &u.Status, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return User{}, ErrNotFound
|
|
||||||
}
|
|
||||||
return User{}, fmt.Errorf("benutzer lesen: %w", err)
|
|
||||||
}
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapWriteErr uebersetzt den Unique-Constraint-Verstoss der E-Mail-Spalte in
|
|
||||||
// einen sprechenden Fehler statt der rohen Postgres-Fehlermeldung.
|
|
||||||
func mapWriteErr(err error) error {
|
|
||||||
var pgErr *pgconn.PgError
|
|
||||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
|
||||||
return ErrEmailTaken
|
|
||||||
}
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
return fmt.Errorf("benutzer schreiben: %w", err)
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
// Package user implementiert Core IAM-01: das Benutzer-Datenmodell und die
|
|
||||||
// CRUD-Operationen. Tenant-Zugehoerigkeit ist ueber die Zieldatenbank
|
|
||||||
// gegeben (Modell C, siehe internal/tenant) — Superadmin-Konten leben
|
|
||||||
// dagegen mandantenuebergreifend in der Registry und sind ueber
|
|
||||||
// SuperadminStore als eigener, First-Class-Typ modelliert, nicht als
|
|
||||||
// tenant_id-NULL-Sonderfall in User.
|
|
||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"regexp"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Status string
|
|
||||||
|
|
||||||
const (
|
|
||||||
StatusActive Status = "active"
|
|
||||||
StatusInactive Status = "inactive"
|
|
||||||
)
|
|
||||||
|
|
||||||
type User struct {
|
|
||||||
ID string
|
|
||||||
Email string
|
|
||||||
Name string
|
|
||||||
Status Status
|
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
|
|
||||||
|
|
||||||
var ErrInvalidEmail = errors.New("user: ungueltige E-Mail-Adresse")
|
|
||||||
var ErrEmailTaken = errors.New("user: E-Mail-Adresse bereits vergeben")
|
|
||||||
var ErrNotFound = errors.New("user: nicht gefunden")
|
|
||||||
|
|
||||||
func ValidateEmail(email string) error {
|
|
||||||
if !emailPattern.MatchString(email) {
|
|
||||||
return ErrInvalidEmail
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestValidateEmail(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
email string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{"a@b.de", false},
|
|
||||||
{"a.b+c@sub.example.com", false},
|
|
||||||
{"", true},
|
|
||||||
{"keine-email", true},
|
|
||||||
{"a@b", true},
|
|
||||||
{"@b.de", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range cases {
|
|
||||||
err := ValidateEmail(c.email)
|
|
||||||
if (err != nil) != c.wantErr {
|
|
||||||
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v", c.email, err, c.wantErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS superadmins;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
-- Superadmin-Konten arbeiten mandantenuebergreifend und leben deshalb in der
|
|
||||||
-- Control-Plane-Registry (siehe TEN-01), nicht in einer Tenant-Datenbank.
|
|
||||||
-- Das bildet "Superadmin ohne Tenant" strukturell als First-Class-Zustand ab,
|
|
||||||
-- statt ihn als Sonderfall in der Tenant-users-Tabelle zu behandeln
|
|
||||||
-- (IAM-01, siehe core-kanban/tickets/IAM-01.md — bekannte Fehler vermeiden).
|
|
||||||
-- E-Mail-Eindeutigkeit ist hier global, da die Registry-DB einmalig existiert.
|
|
||||||
CREATE TABLE superadmins (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE tenants DROP COLUMN previous_status;
|
|
||||||
ALTER TABLE tenants DROP COLUMN deletion_scheduled_at;
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS tenant_settings_history;
|
|
||||||
DROP TABLE IF EXISTS tenant_settings;
|
|
||||||
@@ -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 @@
|
|||||||
|
DROP TABLE IF EXISTS tenant_licenses;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Lizenzumfang pro Mandant (LIC-01, siehe core-kanban/tickets/LIC-01.md).
|
||||||
|
-- Genau ein Lizenzdatensatz pro Tenant (tenant_id PK) — ein neues Einspielen
|
||||||
|
-- ersetzt den vorherigen Datensatz vollstaendig statt eine Historie zu fuehren.
|
||||||
|
CREATE TABLE tenant_licenses (
|
||||||
|
tenant_id UUID PRIMARY KEY REFERENCES tenants(id),
|
||||||
|
plan TEXT NOT NULL,
|
||||||
|
modules TEXT[] NOT NULL,
|
||||||
|
issued_at TIMESTAMPTZ NOT NULL,
|
||||||
|
valid_until TIMESTAMPTZ NOT NULL,
|
||||||
|
raw_key TEXT NOT NULL,
|
||||||
|
installed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS usage_quotas;
|
||||||
|
DROP TABLE IF EXISTS usage_counters;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Nutzungszaehler & Quotas je Tenant (LIC-03, siehe core-kanban/tickets/LIC-03.md).
|
||||||
|
CREATE TABLE usage_counters (
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
metric TEXT NOT NULL,
|
||||||
|
value BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (tenant_id, metric)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE usage_quotas (
|
||||||
|
tenant_id UUID NOT NULL,
|
||||||
|
metric TEXT NOT NULL,
|
||||||
|
limit_value BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (tenant_id, metric)
|
||||||
|
);
|
||||||
@@ -1 +0,0 @@
|
|||||||
DROP TABLE IF EXISTS users;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
-- Benutzer-Datenmodell (IAM-01, siehe core-kanban/tickets/IAM-01.md).
|
|
||||||
-- Diese Migration laeuft in der DB EINES Mandanten (Modell C, siehe TEN-01) —
|
|
||||||
-- die Tenant-Zugehoerigkeit ist implizit durch die Datenbankverbindung
|
|
||||||
-- gegeben, es gibt daher bewusst KEINE tenant_id-Spalte.
|
|
||||||
-- E-Mail-Eindeutigkeit ist hier tenant-scoped: der UNIQUE-Constraint gilt
|
|
||||||
-- nur innerhalb dieser einen Tenant-Datenbank.
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
|
|
||||||
CREATE TABLE users (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
||||||
);
|
|
||||||
@@ -13,8 +13,6 @@ ROLE="nexarch_test"
|
|||||||
|
|
||||||
export PGPASSWORD="$PASS"
|
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;"
|
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 '\'")
|
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
|
import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "NEXARCH Mandantenverwaltung",
|
title: "NEXARCH Lizenz- und Modulverwaltung",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { fetchOverview, toggleFlag, type Overview } from "@/lib/api";
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
|
ok: "In Ordnung",
|
||||||
|
warning: "Nahe am Limit",
|
||||||
|
exceeded: "Limit überschritten",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
ok: "#2e7d32",
|
||||||
|
warning: "#ed6c02",
|
||||||
|
exceeded: "#c62828",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [tenantId, setTenantId] = useState("");
|
||||||
|
const [overview, setOverview] = useState<Overview | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [toggleError, setToggleError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!tenantId.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await fetchOverview(tenantId.trim());
|
||||||
|
setOverview(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message ?? "Unbekannter Fehler");
|
||||||
|
setOverview(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggle(key: string, next: boolean) {
|
||||||
|
setToggleError(null);
|
||||||
|
try {
|
||||||
|
await toggleFlag(tenantId.trim(), key, next);
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
setToggleError(e.message ?? "Umschalten fehlgeschlagen");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 800, margin: "0 auto", padding: "2rem 1rem" }}>
|
||||||
|
<h1>Lizenz- und Modulverwaltung</h1>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", marginBottom: "1.5rem" }}>
|
||||||
|
<input
|
||||||
|
value={tenantId}
|
||||||
|
onChange={(e) => setTenantId(e.target.value)}
|
||||||
|
placeholder="Tenant-ID eingeben"
|
||||||
|
style={{ flex: 1, padding: "0.5rem", fontSize: "1rem" }}
|
||||||
|
/>
|
||||||
|
<button onClick={load} disabled={loading} style={{ padding: "0.5rem 1rem" }}>
|
||||||
|
{loading ? "Lädt…" : "Anzeigen"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: "#c62828" }} role="alert">
|
||||||
|
Fehler: {error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{overview && (
|
||||||
|
<>
|
||||||
|
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginBottom: "1.5rem" }}>
|
||||||
|
<h2>Lizenzstatus</h2>
|
||||||
|
<p>
|
||||||
|
<strong>Plan:</strong> {overview.plan}{" "}
|
||||||
|
{overview.expired && (
|
||||||
|
<span style={{ color: "#c62828", fontWeight: "bold" }}>
|
||||||
|
(abgelaufen)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Gültig bis:</strong>{" "}
|
||||||
|
{new Date(overview.valid_until).toLocaleDateString("de-DE")}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Freigeschaltete Module:</strong>{" "}
|
||||||
|
{overview.modules.length > 0 ? overview.modules.join(", ") : "keine"}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ background: "white", padding: "1rem", borderRadius: 8, marginBottom: "1.5rem" }}>
|
||||||
|
<h2>Feature-Flags</h2>
|
||||||
|
{toggleError && (
|
||||||
|
<p style={{ color: "#c62828" }} role="alert">
|
||||||
|
{toggleError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{overview.flags.length === 0 && <p>Keine Feature-Flags definiert.</p>}
|
||||||
|
<ul style={{ listStyle: "none", padding: 0 }}>
|
||||||
|
{overview.flags.map((f) => (
|
||||||
|
<li
|
||||||
|
key={f.key}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "0.5rem 0",
|
||||||
|
borderBottom: "1px solid #eee",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div>{f.key}</div>
|
||||||
|
{!f.licensed && f.deny_grund && (
|
||||||
|
<div style={{ fontSize: "0.85rem", color: "#c62828" }}>
|
||||||
|
{f.deny_grund}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={f.enabled}
|
||||||
|
disabled={!f.licensed && !f.enabled}
|
||||||
|
onChange={(e) => onToggle(f.key, e.target.checked)}
|
||||||
|
/>{" "}
|
||||||
|
aktiv
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ background: "white", padding: "1rem", borderRadius: 8 }}>
|
||||||
|
<h2>Nutzungsstand</h2>
|
||||||
|
{overview.usage.map((u) => {
|
||||||
|
const pct = u.limit > 0 ? Math.min(100, (u.value / u.limit) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div key={u.metric} style={{ marginBottom: "1rem" }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between" }}>
|
||||||
|
<span>{u.metric}</span>
|
||||||
|
<span style={{ color: STATUS_COLOR[u.status] }}>
|
||||||
|
{u.value} / {u.limit > 0 ? u.limit : "unbegrenzt"} —{" "}
|
||||||
|
{STATUS_LABEL[u.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#eee",
|
||||||
|
borderRadius: 4,
|
||||||
|
height: 8,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${u.limit > 0 ? pct : 0}%`,
|
||||||
|
background: STATUS_COLOR[u.status],
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Duenner Client fuer das LIC-04-Backend-API (internal/adminapi) — enthaelt
|
||||||
|
// keinerlei eigene Lizenz-/Flag-Logik, nur Datenabruf und -weitergabe.
|
||||||
|
export type FlagOverview = {
|
||||||
|
key: string;
|
||||||
|
enabled: boolean;
|
||||||
|
licensed: boolean;
|
||||||
|
deny_grund?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UsageOverview = {
|
||||||
|
metric: string;
|
||||||
|
value: number;
|
||||||
|
limit: number;
|
||||||
|
status: "ok" | "warning" | "exceeded";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Overview = {
|
||||||
|
plan: string;
|
||||||
|
modules: string[];
|
||||||
|
issued_at: string;
|
||||||
|
valid_until: string;
|
||||||
|
expired: boolean;
|
||||||
|
flags: FlagOverview[];
|
||||||
|
usage: UsageOverview[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function apiBase(): string {
|
||||||
|
const base = process.env.NEXT_PUBLIC_ADMIN_API_URL;
|
||||||
|
if (!base) {
|
||||||
|
throw new Error(
|
||||||
|
"NEXT_PUBLIC_ADMIN_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchOverview(tenantId: string): Promise<Overview> {
|
||||||
|
const res = await fetch(
|
||||||
|
`${apiBase()}/admin/overview?tenant=${encodeURIComponent(tenantId)}`,
|
||||||
|
{ cache: "no-store" }
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Anfrage fehlgeschlagen (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleFlag(
|
||||||
|
tenantId: string,
|
||||||
|
key: string,
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(`${apiBase()}/admin/flags/toggle`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ tenant: tenantId, key, enabled }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Umschalten fehlgeschlagen (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "nexarch-tenant-admin",
|
"name": "nexarch-lic-admin",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user