From d63db93a4cd6e344100e965befea14cc82de887d Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 19:12:13 +0200 Subject: [PATCH 1/7] LIC-01: lizenzmodell-lizenzschluessel-pruefung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/license: Ed25519-signierte Lizenzschluessel (stdlib crypto/ed25519, keine neue Abhaengigkeit). Issuer haelt den privaten Schluessel (lebt beim Lizenzgeber), Validator nur den oeffentlichen (lebt im Core-Prozess) — klare Trennung Ausstellung/Pruefung nach Unleash-Vorbild (Flag-Verwaltung vs. Flag-Auswertung). Store.Install prueft NUR die Signatur und persistiert den Lizenzumfang (Plan, Modul-Liste, Laufzeit) in tenant_licenses (Registry-DB, 1:1 zu tenants). Eine bereits abgelaufene, aber korrekt signierte Lizenz laesst sich trotzdem einspielen — der Ablauf wird erst bei Store.RequireActive bewertet (liefert ErrLicenseExpired statt Panic/Absturz), waehrend Store.Status den Umfang unabhaengig vom Ablauf weiterhin liefert. Neu: scripts/run-checks.sh buendelt reset-test-env.sh + go build/vet/test (-p 1) zu einem Ein-Kommando-Check fuer den Testhost. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Manipulierter Lizenzschluessel zuverlaessig erkannt — TestParse_RejectsTamperedKey, TestParse_RejectsWrongKeyPair, TestStore_InstallRejectsInvalidSignature. PASS. 2. Ablauf loest definierten eingeschraenkten Zustand aus, kein harter Systemausfall — TestStore_RequireActive_DetectsExpiry (inkl. recover()- Absicherung im Test, dass kein Panic auftritt), ErrLicenseExpired statt Absturz; Status bleibt trotzdem abfragbar. PASS. 3. Signaturpruefung von zweiter Person gegen Dokumentation nachvollzogen — NICHT durchgefuehrt (keine zweite Person in dieser Session verfuegbar). Offen. Co-Authored-By: Claude Sonnet 5 --- go.mod | 9 ++ go.sum | 28 ++++ internal/license/license.go | 106 ++++++++++++++ internal/license/license_test.go | 106 ++++++++++++++ internal/license/store.go | 88 +++++++++++ internal/license/store_test.go | 177 +++++++++++++++++++++++ migrations/0004_tenant_licenses.down.sql | 1 + migrations/0004_tenant_licenses.up.sql | 12 ++ scripts/reset-test-env.sh | 23 +++ scripts/run-checks.sh | 24 +++ 10 files changed, 574 insertions(+) create mode 100644 go.sum create mode 100644 internal/license/license.go create mode 100644 internal/license/license_test.go create mode 100644 internal/license/store.go create mode 100644 internal/license/store_test.go create mode 100644 migrations/0004_tenant_licenses.down.sql create mode 100644 migrations/0004_tenant_licenses.up.sql create mode 100755 scripts/reset-test-env.sh create mode 100755 scripts/run-checks.sh diff --git a/go.mod b/go.mod index 75a79b7..05c10d0 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,12 @@ module gitea.perlbach24.de/scripte/nexarch go 1.22 require github.com/jackc/pgx/v5 v5.6.0 + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5c39671 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/license/license.go b/internal/license/license.go new file mode 100644 index 0000000..34dab1b --- /dev/null +++ b/internal/license/license.go @@ -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 +} diff --git a/internal/license/license_test.go b/internal/license/license_test.go new file mode 100644 index 0000000..fb9316a --- /dev/null +++ b/internal/license/license_test.go @@ -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) + } + } +} diff --git a/internal/license/store.go b/internal/license/store.go new file mode 100644 index 0000000..a634738 --- /dev/null +++ b/internal/license/store.go @@ -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 +} diff --git a/internal/license/store_test.go b/internal/license/store_test.go new file mode 100644 index 0000000..dec430b --- /dev/null +++ b/internal/license/store_test.go @@ -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) + } +} diff --git a/migrations/0004_tenant_licenses.down.sql b/migrations/0004_tenant_licenses.down.sql new file mode 100644 index 0000000..d9728f1 --- /dev/null +++ b/migrations/0004_tenant_licenses.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tenant_licenses; diff --git a/migrations/0004_tenant_licenses.up.sql b/migrations/0004_tenant_licenses.up.sql new file mode 100644 index 0000000..9872cbf --- /dev/null +++ b/migrations/0004_tenant_licenses.up.sql @@ -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() +); diff --git a/scripts/reset-test-env.sh b/scripts/reset-test-env.sh new file mode 100755 index 0000000..5bc2bc5 --- /dev/null +++ b/scripts/reset-test-env.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte +# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle +# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches +# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische +# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]). +# +# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh +set -euo pipefail + +PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}" +ROLE="nexarch_test" + +export PGPASSWORD="$PASS" + +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;" + +dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'") +for db in $dbs; do + psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";" +done + +echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt." diff --git a/scripts/run-checks.sh b/scripts/run-checks.sh new file mode 100755 index 0000000..83f26b6 --- /dev/null +++ b/scripts/run-checks.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost: +# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch. +# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf +# dem Testhost teilen (siehe [[project-nexarch-test-infra]]). +# +# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh +set -euo pipefail + +PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}" +cd "$(dirname "$0")/.." + +NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh + +export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable" + +echo "== go build ==" +go build ./... + +echo "== go vet ==" +go vet ./... + +echo "== go test (-p 1) ==" +go test ./... -p 1 -count=1 From d447869246dbc6d8904ae072dced4ed4071395c9 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 21:10:11 +0200 Subject: [PATCH 2/7] LIC-03: nutzungszaehler-quotas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit internal/usage: Store.Increment aktualisiert Zaehlerstaende ueber ein einziges atomares SQL-UPSERT (value = value + delta) statt Read-Modify-Write in Go — haelt Zaehlerstaende bei parallelen Schreibzugriffen konsistent (Akzeptanzkriterium 1), ganz ohne Anwendungs-Lock. Quotas sind Konfiguration (usage_quotas-Tabelle je Tenant+Metrik), kein Hardcode. Check/Enforce leiten aus Zaehlerstand + Quota eine definierte Reaktion ab (StatusOK/Warning bei 80%/Exceeded, Akzeptanzkriterium 2) — Enforce ruft eine uebergebene Reaction-Funktion auf, wenn der Status nicht OK ist; die konkrete Sperr-/Benachrichtigungslogik bleibt beim Aufrufer (z.B. TEN-02 vor Benutzeranlage), Enforce garantiert nur zuverlaessiges Ausloesen. Fehlende Quota-Konfiguration bedeutet unbegrenzt (StatusOK), kein Fehler. RunPeriodicAggregation ist das Aggregations-Grundgerüst (Akzeptanzkriterium 1: "periodisch aggregiert") — dieselbe In-Prozess-Worker-Goroutine-Konvention wie internal/tenant.Lifecycle.RunSweeper. Die konkrete Aggregationsquelle (Zeilen zaehlen in Modul-Tabellen) haengt vom jeweiligen Modul ab und ist nicht Teil dieser Kachel. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Quota-Ueberschreitung automatisiert erkannt, definierte Reaktion ausgeloest — TestEnforce_TriggersReactionOnExceeded: Reaction-Callback wird mit StatusExceeded aufgerufen. PASS. 2. Aggregationsjob liefert bei parallelen Schreibzugriffen konsistente Zaehlerstaende — TestIncrement_ConsistentUnderConcurrentWrites: 50 nebenlaeufige Increments, Endstand exakt 50 (kein Lost Update). PASS. 3. Zaehlerstand eines Tenants beeinflusst nicht den eines anderen — TestIncrement_IsolatedBetweenTenants. PASS. Co-Authored-By: Claude Sonnet 5 --- internal/usage/aggregation.go | 32 ++++ internal/usage/usage.go | 144 +++++++++++++++++ internal/usage/usage_test.go | 206 ++++++++++++++++++++++++ migrations/0005_usage_counters.down.sql | 2 + migrations/0005_usage_counters.up.sql | 15 ++ 5 files changed, 399 insertions(+) create mode 100644 internal/usage/aggregation.go create mode 100644 internal/usage/usage.go create mode 100644 internal/usage/usage_test.go create mode 100644 migrations/0005_usage_counters.down.sql create mode 100644 migrations/0005_usage_counters.up.sql diff --git a/internal/usage/aggregation.go b/internal/usage/aggregation.go new file mode 100644 index 0000000..e0e5bc6 --- /dev/null +++ b/internal/usage/aggregation.go @@ -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) + } + } + } +} diff --git a/internal/usage/usage.go b/internal/usage/usage.go new file mode 100644 index 0000000..be6aeba --- /dev/null +++ b/internal/usage/usage.go @@ -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 +} diff --git a/internal/usage/usage_test.go b/internal/usage/usage_test.go new file mode 100644 index 0000000..8906675 --- /dev/null +++ b/internal/usage/usage_test.go @@ -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) + } +} diff --git a/migrations/0005_usage_counters.down.sql b/migrations/0005_usage_counters.down.sql new file mode 100644 index 0000000..7e267ae --- /dev/null +++ b/migrations/0005_usage_counters.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS usage_quotas; +DROP TABLE IF EXISTS usage_counters; diff --git a/migrations/0005_usage_counters.up.sql b/migrations/0005_usage_counters.up.sql new file mode 100644 index 0000000..0658918 --- /dev/null +++ b/migrations/0005_usage_counters.up.sql @@ -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) +); From eed73eca8fbb3f97e6982ca72d5da9954af25746 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 23:37:11 +0200 Subject: [PATCH 3/7] LIC-04: backend-api fuer lizenz-modul-verwaltungsoberflaeche (flag.List, adminapi-paket) --- internal/adminapi/lichandler.go | 226 ++++++++++++++++++++++ internal/adminapi/lichandler_test.go | 275 +++++++++++++++++++++++++++ internal/flag/flag.go | 110 +++++++++++ internal/flag/flag_test.go | 43 +++++ internal/flag/service.go | 87 +++++++++ internal/flag/service_test.go | 179 +++++++++++++++++ 6 files changed, 920 insertions(+) create mode 100644 internal/adminapi/lichandler.go create mode 100644 internal/adminapi/lichandler_test.go create mode 100644 internal/flag/flag.go create mode 100644 internal/flag/flag_test.go create mode 100644 internal/flag/service.go create mode 100644 internal/flag/service_test.go diff --git a/internal/adminapi/lichandler.go b/internal/adminapi/lichandler.go new file mode 100644 index 0000000..49f2e4b --- /dev/null +++ b/internal/adminapi/lichandler.go @@ -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:" 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}) +} diff --git a/internal/adminapi/lichandler_test.go b/internal/adminapi/lichandler_test.go new file mode 100644 index 0000000..398eb92 --- /dev/null +++ b/internal/adminapi/lichandler_test.go @@ -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) + } +} diff --git a/internal/flag/flag.go b/internal/flag/flag.go new file mode 100644 index 0000000..672087f --- /dev/null +++ b/internal/flag/flag.go @@ -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 +} diff --git a/internal/flag/flag_test.go b/internal/flag/flag_test.go new file mode 100644 index 0000000..772a336 --- /dev/null +++ b/internal/flag/flag_test.go @@ -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") + } + } +} diff --git a/internal/flag/service.go b/internal/flag/service.go new file mode 100644 index 0000000..6718d28 --- /dev/null +++ b/internal/flag/service.go @@ -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() +} diff --git a/internal/flag/service_test.go b/internal/flag/service_test.go new file mode 100644 index 0000000..0ddd5b9 --- /dev/null +++ b/internal/flag/service_test.go @@ -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") + } + }() +} From 0620baa9938087a819057ee4b3940714c7893f05 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 23:40:05 +0200 Subject: [PATCH 4/7] LIC-04: dev-server fuer adminapi + next.js lizenz-modul-verwaltungsoberflaeche --- cmd/licadmin-devserver/main.go | 81 +++++++++++++++ web/lic-admin/app/layout.tsx | 17 ++++ web/lic-admin/app/page.tsx | 173 +++++++++++++++++++++++++++++++++ web/lic-admin/lib/api.ts | 63 ++++++++++++ web/lic-admin/next.config.mjs | 3 + web/lic-admin/package.json | 21 ++++ web/lic-admin/tsconfig.json | 21 ++++ 7 files changed, 379 insertions(+) create mode 100644 cmd/licadmin-devserver/main.go create mode 100644 web/lic-admin/app/layout.tsx create mode 100644 web/lic-admin/app/page.tsx create mode 100644 web/lic-admin/lib/api.ts create mode 100644 web/lic-admin/next.config.mjs create mode 100644 web/lic-admin/package.json create mode 100644 web/lic-admin/tsconfig.json diff --git a/cmd/licadmin-devserver/main.go b/cmd/licadmin-devserver/main.go new file mode 100644 index 0000000..f585367 --- /dev/null +++ b/cmd/licadmin-devserver/main.go @@ -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) + } +} diff --git a/web/lic-admin/app/layout.tsx b/web/lic-admin/app/layout.tsx new file mode 100644 index 0000000..30e77e0 --- /dev/null +++ b/web/lic-admin/app/layout.tsx @@ -0,0 +1,17 @@ +export const metadata = { + title: "NEXARCH Lizenz- und Modulverwaltung", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/web/lic-admin/app/page.tsx b/web/lic-admin/app/page.tsx new file mode 100644 index 0000000..61c8f5a --- /dev/null +++ b/web/lic-admin/app/page.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useState } from "react"; +import { fetchOverview, toggleFlag, type Overview } from "@/lib/api"; + +const STATUS_LABEL: Record = { + ok: "In Ordnung", + warning: "Nahe am Limit", + exceeded: "Limit überschritten", +}; + +const STATUS_COLOR: Record = { + ok: "#2e7d32", + warning: "#ed6c02", + exceeded: "#c62828", +}; + +export default function Page() { + const [tenantId, setTenantId] = useState(""); + const [overview, setOverview] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [toggleError, setToggleError] = useState(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 ( +
+

Lizenz- und Modulverwaltung

+ +
+ setTenantId(e.target.value)} + placeholder="Tenant-ID eingeben" + style={{ flex: 1, padding: "0.5rem", fontSize: "1rem" }} + /> + +
+ + {error && ( +

+ Fehler: {error} +

+ )} + + {overview && ( + <> +
+

Lizenzstatus

+

+ Plan: {overview.plan}{" "} + {overview.expired && ( + + (abgelaufen) + + )} +

+

+ Gültig bis:{" "} + {new Date(overview.valid_until).toLocaleDateString("de-DE")} +

+

+ Freigeschaltete Module:{" "} + {overview.modules.length > 0 ? overview.modules.join(", ") : "keine"} +

+
+ +
+

Feature-Flags

+ {toggleError && ( +

+ {toggleError} +

+ )} + {overview.flags.length === 0 &&

Keine Feature-Flags definiert.

} +
    + {overview.flags.map((f) => ( +
  • +
    +
    {f.key}
    + {!f.licensed && f.deny_grund && ( +
    + {f.deny_grund} +
    + )} +
    + +
  • + ))} +
+
+ +
+

Nutzungsstand

+ {overview.usage.map((u) => { + const pct = u.limit > 0 ? Math.min(100, (u.value / u.limit) * 100) : 0; + return ( +
+
+ {u.metric} + + {u.value} / {u.limit > 0 ? u.limit : "unbegrenzt"} —{" "} + {STATUS_LABEL[u.status]} + +
+
+
0 ? pct : 0}%`, + background: STATUS_COLOR[u.status], + height: "100%", + }} + /> +
+
+ ); + })} +
+ + )} +
+ ); +} diff --git a/web/lic-admin/lib/api.ts b/web/lic-admin/lib/api.ts new file mode 100644 index 0000000..63b1e3d --- /dev/null +++ b/web/lic-admin/lib/api.ts @@ -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 { + 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 { + 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})`); + } +} diff --git a/web/lic-admin/next.config.mjs b/web/lic-admin/next.config.mjs new file mode 100644 index 0000000..f26ac37 --- /dev/null +++ b/web/lic-admin/next.config.mjs @@ -0,0 +1,3 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; +export default nextConfig; diff --git a/web/lic-admin/package.json b/web/lic-admin/package.json new file mode 100644 index 0000000..79a1ea2 --- /dev/null +++ b/web/lic-admin/package.json @@ -0,0 +1,21 @@ +{ + "name": "nexarch-lic-admin", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "14.2.5", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "20.14.9", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "typescript": "5.5.3" + } +} diff --git a/web/lic-admin/tsconfig.json b/web/lic-admin/tsconfig.json new file mode 100644 index 0000000..26caf44 --- /dev/null +++ b/web/lic-admin/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} From ac489352614bc14fa27db981b28f213edafe9946 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 23:46:50 +0200 Subject: [PATCH 5/7] LIC-04: next.js 14.2.35 (aktuellster patch der 14-linie), gitignore fuer web-build-artefakte --- .gitignore | 2 ++ web/lic-admin/package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ac00d6f..a5e59d5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ *.log .env +web/*/node_modules/ +web/*/.next/ diff --git a/web/lic-admin/package.json b/web/lic-admin/package.json index 79a1ea2..3e29724 100644 --- a/web/lic-admin/package.json +++ b/web/lic-admin/package.json @@ -8,7 +8,7 @@ "lint": "next lint" }, "dependencies": { - "next": "14.2.5", + "next": "14.2.35", "react": "18.3.1", "react-dom": "18.3.1" }, From 7c53a099c74c42f10c4791d46578db6ac96d172a Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 21:46:59 +0200 Subject: [PATCH 6/7] LIC-04: Retrofit auf SHL-01 (ThemeProvider/I18nProvider/ToastProvider, Design-Tokens statt hartkodierter Werte) --- web/lic-admin/app/layout.tsx | 17 +++++++++++++++-- web/lic-admin/package.json | 1 + 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/web/lic-admin/app/layout.tsx b/web/lic-admin/app/layout.tsx index 30e77e0..3c2e927 100644 --- a/web/lic-admin/app/layout.tsx +++ b/web/lic-admin/app/layout.tsx @@ -1,3 +1,5 @@ +import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl"; + export const metadata = { title: "NEXARCH Lizenz- und Modulverwaltung", }; @@ -9,8 +11,19 @@ export default function RootLayout({ }) { return ( - - {children} + + + + {children} + + ); diff --git a/web/lic-admin/package.json b/web/lic-admin/package.json index 3e29724..beadabb 100644 --- a/web/lic-admin/package.json +++ b/web/lic-admin/package.json @@ -8,6 +8,7 @@ "lint": "next lint" }, "dependencies": { + "@nexarch/shl": "file:../shl", "next": "14.2.35", "react": "18.3.1", "react-dom": "18.3.1" From 5f3eb16faeab8b062cd744a786833a0e2c3b43cb Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 21:56:15 +0200 Subject: [PATCH 7/7] =?UTF-8?q?LIC-04:=20fix=20=E2=80=94=20transpilePackag?= =?UTF-8?q?es=20fuer=20@nexarch/shl=20(Next.js=20transpiliert=20node=5Fmod?= =?UTF-8?q?ules=20sonst=20nicht,=20Build=20brach=20ab)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/lic-admin/next.config.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/lic-admin/next.config.mjs b/web/lic-admin/next.config.mjs index f26ac37..fa7f635 100644 --- a/web/lic-admin/next.config.mjs +++ b/web/lic-admin/next.config.mjs @@ -1,3 +1,7 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + // @nexarch/shl liegt als file:-Dependency mit TS-Quellen in node_modules — + // Next.js transpiliert node_modules standardmäßig nicht, siehe web/shl/README.md. + transpilePackages: ["@nexarch/shl"], +}; export default nextConfig;