GOV-07: Unabhängige Tenant-Validierung bei KI-Objekttyp-Registrierung durch DMS/Mail #238

Merged
patrick merged 1 commits from feature/gov-07-unabh-ngige-tenant-validierung-bei-ki-objekttyp-registrierung-durch-dms-mail into main 2026-09-28 02:50:30 +02:00
7 changed files with 316 additions and 37 deletions

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
# GOV-07 — Prüfprotokoll: Unabhängige Tenant-Validierung bei KI-Objekttyp-Registrierung durch DMS/Mail
Datum: 2026-09-28
Testhost: root@192.168.1.204 (Wegwerf-Postgres `gov07_test`, eigener Rolle/DB, kein Bezug zu `dms_dev`/`archivdms`)
## Ausgangslage geprüft
- `ai/internal/moduletype` (GOV-01): `Registry.Register(ctx, moduleName, objectType, fields)` nahm den registrierenden Modulnamen entgegen, aber keinerlei Tenant-Kontext — die einzige Mandantentrennung bestand implizit über `module_name`/`object_type`, ohne dass irgendeine Instanz prüfte, welchem Tenant eine Registrierung tatsächlich zuzuordnen ist. Ein kompromittiertes/fehlkonfiguriertes DMS/Mail hätte also mit beliebigem, unverifiziertem Tenant-Bezug KI-Funktionen (Klassifizierung, Extraktion, semantische Suche) auslösen können — exakt die in Archive `RET-12` (Commits `300f107`, `e7f5eed`) und Workflow-Analogie beschriebene Senken-Lücke.
- Etabliertes Muster übernommen: Archive `RET-12` fügt `archive/internal/tenantregistryclient` (schlanker HTTP-Client gegen Core TEN-01, Service-Token) sowie ein `TenantVerifier`-Interface im Registrierungspaket hinzu, das vor jedem `INSERT` fail-closed gegen die Core-Tenant-Registry prüft. DMS hat mit `dms/internal/tenantregistryclient` denselben Client bereits für sein eigenes Modul repliziert. GOV-07 überträgt dasselbe Muster 1:1 auf `ai/internal/moduletype`, da AI (wie Archive und DMS) ein eigenes Go-Modul ist und Core-Pakete nicht importieren kann.
## Umsetzung
- Neues Paket `ai/internal/tenantregistryclient` (Kopie des Archive-/DMS-Musters): `Client.VerifyTenant(ctx, tenantSlug)` fragt Core TEN-01 per HTTP (`GET /tenants/{slug}`, `X-Service-Token`-Header) ab; 200 = gültig, 404 = ungültig, alles andere = Fehler (fail-closed, niemals stillschweigend gültig).
- `ai/internal/moduletype.Registry` bekommt ein neues Feld `Verifier TenantVerifier` (Interface, damit Tests ohne echten Core-Dienst laufen).
- `Register` verlangt jetzt zusätzlich `tenantSlug` als erstes Argument und ruft `Verifier.VerifyTenant` auf, bevor der Datenbank-Insert erfolgt:
- Verifikationsfehler (Core nicht erreichbar/Transportfehler) → Registrierung abgelehnt, protokolliert, `moduletype: tenant-verifikation nicht moeglich: %w` (fail-closed).
- Tenant nicht in der Core-Registry → Registrierung abgelehnt, protokolliert, `ErrUnknownTenant`.
- Gültiger Tenant → Registrierung läuft wie zuvor (AK3: unveränderter Ablauf für korrekte Registrierungen).
- `Get`/`ListByModule` um `tenantSlug` erweitert, damit Objekttypen strikt pro Tenant isoliert bleiben (keine Vermischung über `module_name`/`object_type` hinweg zwischen Tenants).
- Migration `ai/migrations/0005_module_objecttypes_tenant_slug.{up,down}.sql`: neue Spalte `tenant_slug` in `module_objecttypes`, Unique-Constraint von `(module_name, object_type)` auf `(tenant_slug, module_name, object_type)` erweitert.
- Kein Aufrufer von `moduletype.Register`/`Get`/`ListByModule` existierte bisher außerhalb des Pakets selbst (kein HTTP-Handler in `ai/cmd/ai-app` verdrahtet GOV-01 bislang) — GOV-07 bereitet die Schnittstelle vor (wie im Ticket unter "Nicht Bestandteil" gefordert), verdrahtet aber keinen neuen Endpunkt; das bleibt GOV-01-Scope.
## Akzeptanzkriterien
| # | Kriterium | Ergebnis |
|---|---|---|
| 1 | Jede Objekttyp-/Feld-Registrierung aus DMS/Mail wird gegen die Core-Registry auf Tenant-Gültigkeit geprüft, bevor sie übernommen wird | Erfüllt — `Registry.Register` ruft `Verifier.VerifyTenant` vor jedem `INSERT`/`UPDATE` auf; nachgewiesen durch `TestRegister_ValidExistingTenant_Accepted` |
| 2 | Eine Registrierung mit einem nicht existierenden oder falschem Tenant-Kontext wird abgelehnt und protokolliert | Erfüllt — `ErrUnknownTenant` + `log.Printf`-Protokollierung vor Ablehnung; nachgewiesen durch `TestRegister_ForeignOrFakeTenant_Rejected` (adversarialer Test: fremder, nicht besessener Tenant) |
| 3 | Bestehende, korrekte Registrierungsabläufe aus GOV-01 funktionieren unverändert | Erfüllt — alle bestehenden GOV-01-Tests (`TestRegister_TestModule_CanRegisterAndFetch`, `TestRegister_WithoutCodeChange_ViaConfigOnly`, `TestGet_UnregisteredType_Rejected`, `TestTwoModules_RegisterInParallel`, `TestRegister_IdempotentUpdate`, `TestRegister_EmptyFields_Rejected`) laufen unverändert grün mit gültigem Tenant-Kontext |
## Prüfungen vor der Abnahme
1. **Test: Registrierung mit gültigem, existierendem Tenant-Kontext wird übernommen.**
Zielwert: HTTP-äquivalenter Erfolg, Eintrag in `module_objecttypes` vorhanden.
Ergebnis: erreicht — `TestRegister_ValidExistingTenant_Accepted` PASS.
2. **Test: Registrierung mit manipuliertem/fremdem Tenant-Kontext wird abgelehnt (adversarial).**
Zielwert: `ErrUnknownTenant`, kein Datenbankeintrag, Protokollzeile.
Ergebnis: erreicht — `TestRegister_ForeignOrFakeTenant_Rejected` PASS (Tenant `fremder-mandant-den-dms-nicht-besitzt`, den DMS beansprucht, aber den Core nicht führt, wird abgelehnt; `Get` bestätigt danach `ErrNotRegistered`). Zusätzlich `TestRegister_TenantVerificationError_FailsClosed` PASS (Verifikationsfehler selbst führt ebenfalls zur Ablehnung, nicht zur stillschweigenden Annahme).
3. **Regressionstest: normaler DMS-/Mail-Registrierungsablauf bleibt funktionsfähig.**
Zielwert: alle GOV-01-Bestandstests weiterhin grün, gesamtes `ai`-Modul ohne Regression.
Ergebnis: erreicht —
```
TEST_TENANT_DSN=postgres://gov07_test:***@localhost:5432/gov07_test?sslmode=disable \
go test ./internal/moduletype/... ./internal/tenantregistryclient/... -v
```
9/9 Tests in `moduletype` PASS, 3/3 in `tenantregistryclient` PASS. Zusätzlich vollständiger Modul-Lauf:
```
go vet ./... -> keine Befunde
TEST_TENANT_DSN=... go test ./... -p 1 -> alle Pakete ok, keine Regression
```
## Entscheidungen
- Unique-Constraint-Erweiterung auf `(tenant_slug, module_name, object_type)` statt Beibehaltung von `(module_name, object_type)`: notwendig, damit zwei verschiedene Tenants unabhängig voneinander denselben `object_type` für dasselbe Modul registrieren können, ohne sich zu kollidieren — sonst würde Tenant B durch Tenant As Registrierung blockiert.
- `TenantVerifier` als Interface (nicht konkreter Client-Typ) im `moduletype`-Paket: identisch zum Archive-/DMS-Muster, ermöglicht Tests ohne echten Core-Dienst.
- Kein neuer HTTP-Handler/kein Wiring in `ai/cmd/ai-app`: GOV-01 hat bislang keinen Registrierungs-Endpunkt verdrahtet (nur das Paket selbst existiert, unit-getestet). GOV-07 erweitert daher nur die bestehende Paket-Schnittstelle um die Tenant-Prüfung — Verdrahtung eines Endpunkts ist laut Ticket ausdrücklich GOV-01-Scope ("Nicht Bestandteil dieser Kachel").
+56 -23
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
@@ -17,6 +18,10 @@ import (
// ErrNotRegistered signalisiert einen nicht registrierten Objekttyp (AK3).
var ErrNotRegistered = errors.New("moduletype: objekttyp nicht registriert")
// ErrUnknownTenant signalisiert einen Tenant-Kontext, den Core TEN-01 nicht
// fuehrt (GOV-07 AK2) - Registrierung wird abgelehnt statt uebernommen.
var ErrUnknownTenant = errors.New("moduletype: unbekannter tenant-kontext")
// Field beschreibt ein KI-nutzbares Feld eines Objekttyps.
type Field struct {
Name string `json:"name"`
@@ -25,64 +30,92 @@ type Field struct {
// ObjectType ist ein registrierter, KI-fähiger Objekttyp eines Moduls.
type ObjectType struct {
TenantSlug string
ModuleName string
ObjectType string
Fields []Field
}
// Registry verwaltet die Modul-Objekttyp-Registrierungen.
type Registry struct {
Pool *pgxpool.Pool
// TenantVerifier prueft einen von DMS/Mail mitgelieferten Tenant-Kontext
// unabhaengig gegen die Core-Tenant-Registry (TEN-01) - GOV-07: AI vertraut
// dabei nicht der Angabe des registrierenden Moduls selbst. Interface statt
// konkretem Client, damit Tests ohne echten Core-Dienst laufen (siehe
// tenantregistryclient.Client fuer die echte Umsetzung).
type TenantVerifier interface {
VerifyTenant(ctx context.Context, tenantSlug string) (bool, error)
}
// Register registriert (oder aktualisiert) einen Objekttyp für moduleName.
// Erneutes Register mit denselben Feldern ist idempotent.
func (r *Registry) Register(ctx context.Context, moduleName, objectType string, fields []Field) error {
// Registry verwaltet die Modul-Objekttyp-Registrierungen.
type Registry struct {
Pool *pgxpool.Pool
Verifier TenantVerifier
}
// Register registriert (oder aktualisiert) einen Objekttyp für moduleName
// unter tenantSlug. GOV-07: tenantSlug wird zuerst gegen die Core-Tenant-
// Registry verifiziert - ein unbekannter/fehlerhafter Tenant-Kontext wird
// abgelehnt (ErrUnknownTenant) und protokolliert; ein Verifikationsfehler
// selbst fuehrt fail-closed zur Ablehnung, nie zur stillschweigenden
// Annahme. Erneutes Register mit denselben Feldern ist idempotent.
func (r *Registry) Register(ctx context.Context, tenantSlug, moduleName, objectType string, fields []Field) error {
tenantSlug = strings.TrimSpace(tenantSlug)
moduleName = strings.TrimSpace(moduleName)
objectType = strings.TrimSpace(objectType)
if moduleName == "" || objectType == "" {
return fmt.Errorf("moduletype: moduleName und objectType dürfen nicht leer sein")
if tenantSlug == "" || moduleName == "" || objectType == "" {
return fmt.Errorf("moduletype: tenantSlug, moduleName und objectType dürfen nicht leer sein")
}
if len(fields) == 0 {
return fmt.Errorf("moduletype: mindestens ein Feld erforderlich")
}
valid, err := r.Verifier.VerifyTenant(ctx, tenantSlug)
if err != nil {
log.Printf("moduletype: tenant-verifikation fehlgeschlagen fuer modul=%s tenant=%s objekttyp=%s: %v", moduleName, tenantSlug, objectType, err)
return fmt.Errorf("moduletype: tenant-verifikation nicht moeglich: %w", err)
}
if !valid {
log.Printf("moduletype: registrierung abgelehnt - unbekannter tenant-kontext modul=%s tenant=%s objekttyp=%s", moduleName, tenantSlug, objectType)
return fmt.Errorf("%w: %s", ErrUnknownTenant, tenantSlug)
}
payload, err := json.Marshal(fields)
if err != nil {
return fmt.Errorf("moduletype: felder kodieren: %w", err)
}
_, err = r.Pool.Exec(ctx, `
INSERT INTO module_objecttypes (module_name, object_type, fields)
VALUES ($1, $2, $3)
ON CONFLICT (module_name, object_type) DO UPDATE SET fields = EXCLUDED.fields
`, moduleName, objectType, payload)
INSERT INTO module_objecttypes (tenant_slug, module_name, object_type, fields)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tenant_slug, module_name, object_type) DO UPDATE SET fields = EXCLUDED.fields
`, tenantSlug, moduleName, objectType, payload)
if err != nil {
return fmt.Errorf("moduletype: registrieren: %w", err)
}
return nil
}
// Get liefert die Registrierung für moduleName/objectType oder
// Get liefert die Registrierung für tenantSlug/moduleName/objectType oder
// ErrNotRegistered, wenn keine existiert (AK3: saubere Abweisung).
func (r *Registry) Get(ctx context.Context, moduleName, objectType string) (ObjectType, error) {
func (r *Registry) Get(ctx context.Context, tenantSlug, moduleName, objectType string) (ObjectType, error) {
var payload []byte
err := r.Pool.QueryRow(ctx, `
SELECT fields FROM module_objecttypes WHERE module_name = $1 AND object_type = $2
`, moduleName, objectType).Scan(&payload)
SELECT fields FROM module_objecttypes WHERE tenant_slug = $1 AND module_name = $2 AND object_type = $3
`, tenantSlug, moduleName, objectType).Scan(&payload)
if err != nil {
return ObjectType{}, fmt.Errorf("%w: %s/%s", ErrNotRegistered, moduleName, objectType)
return ObjectType{}, fmt.Errorf("%w: %s/%s/%s", ErrNotRegistered, tenantSlug, moduleName, objectType)
}
var fields []Field
if err := json.Unmarshal(payload, &fields); err != nil {
return ObjectType{}, fmt.Errorf("moduletype: felder dekodieren: %w", err)
}
return ObjectType{ModuleName: moduleName, ObjectType: objectType, Fields: fields}, nil
return ObjectType{TenantSlug: tenantSlug, ModuleName: moduleName, ObjectType: objectType, Fields: fields}, nil
}
// ListByModule liefert alle für moduleName registrierten Objekttypen.
func (r *Registry) ListByModule(ctx context.Context, moduleName string) ([]ObjectType, error) {
// ListByModule liefert alle für tenantSlug/moduleName registrierten
// Objekttypen.
func (r *Registry) ListByModule(ctx context.Context, tenantSlug, moduleName string) ([]ObjectType, error) {
rows, err := r.Pool.Query(ctx, `
SELECT object_type, fields FROM module_objecttypes WHERE module_name = $1 ORDER BY object_type
`, moduleName)
SELECT object_type, fields FROM module_objecttypes WHERE tenant_slug = $1 AND module_name = $2 ORDER BY object_type
`, tenantSlug, moduleName)
if err != nil {
return nil, fmt.Errorf("moduletype: liste laden: %w", err)
}
@@ -99,7 +132,7 @@ func (r *Registry) ListByModule(ctx context.Context, moduleName string) ([]Objec
if err := json.Unmarshal(payload, &fields); err != nil {
return nil, fmt.Errorf("moduletype: felder dekodieren: %w", err)
}
out = append(out, ObjectType{ModuleName: moduleName, ObjectType: objectType, Fields: fields})
out = append(out, ObjectType{TenantSlug: tenantSlug, ModuleName: moduleName, ObjectType: objectType, Fields: fields})
}
return out, rows.Err()
}
+89 -14
View File
@@ -11,7 +11,26 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
// fakeVerifier simuliert Core TEN-01 fuer die Registry-Tests, ohne einen
// echten Core-Dienst zu benoetigen.
type fakeVerifier struct {
validTenants map[string]bool
err error
}
func (f fakeVerifier) VerifyTenant(_ context.Context, tenantSlug string) (bool, error) {
if f.err != nil {
return false, f.err
}
return f.validTenants[tenantSlug], nil
}
func newRegistry(t *testing.T) *Registry {
t.Helper()
return newRegistryWithVerifier(t, fakeVerifier{validTenants: map[string]bool{"mandant-a": true, "mandant-b": true}})
}
func newRegistryWithVerifier(t *testing.T, verifier TenantVerifier) *Registry {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
@@ -29,7 +48,7 @@ func newRegistry(t *testing.T) *Registry {
if _, err := migrate.Up(context.Background(), pool, migrations); err != nil {
t.Fatalf("migrationen anwenden: %v", err)
}
return &Registry{Pool: pool}
return &Registry{Pool: pool, Verifier: verifier}
}
// TestRegister_TestModule_CanRegisterAndFetch deckt AK1 ab: ein Modul kann
@@ -39,7 +58,7 @@ func TestRegister_TestModule_CanRegisterAndFetch(t *testing.T) {
r := newRegistry(t)
ctx := context.Background()
err := r.Register(ctx, "dms", "document", []Field{
err := r.Register(ctx, "mandant-a", "dms", "document", []Field{
{Name: "title", Kind: "text"},
{Name: "created_at", Kind: "timestamp"},
})
@@ -47,7 +66,7 @@ func TestRegister_TestModule_CanRegisterAndFetch(t *testing.T) {
t.Fatalf("register: %v", err)
}
got, err := r.Get(ctx, "dms", "document")
got, err := r.Get(ctx, "mandant-a", "dms", "document")
if err != nil {
t.Fatalf("get: %v", err)
}
@@ -66,7 +85,7 @@ func TestRegister_WithoutCodeChange_ViaConfigOnly(t *testing.T) {
r := newRegistry(t)
ctx := context.Background()
err := r.Register(ctx, "mail", "thread", []Field{
err := r.Register(ctx, "mandant-a", "mail", "thread", []Field{
{Name: "subject", Kind: "text"},
{Name: "participant_count", Kind: "integer"},
})
@@ -74,7 +93,7 @@ func TestRegister_WithoutCodeChange_ViaConfigOnly(t *testing.T) {
t.Fatalf("register: %v", err)
}
got, err := r.Get(ctx, "mail", "thread")
got, err := r.Get(ctx, "mandant-a", "mail", "thread")
if err != nil {
t.Fatalf("get: %v", err)
}
@@ -90,7 +109,7 @@ func TestGet_UnregisteredType_Rejected(t *testing.T) {
r := newRegistry(t)
ctx := context.Background()
_, err := r.Get(ctx, "dms", "unknown_type_xyz")
_, err := r.Get(ctx, "mandant-a", "dms", "unknown_type_xyz")
if !errors.Is(err, ErrNotRegistered) {
t.Fatalf("erwartete ErrNotRegistered, bekam: %v", err)
}
@@ -103,18 +122,18 @@ func TestTwoModules_RegisterInParallel(t *testing.T) {
r := newRegistry(t)
ctx := context.Background()
if err := r.Register(ctx, "dms", "document", []Field{{Name: "title", Kind: "text"}}); err != nil {
if err := r.Register(ctx, "mandant-a", "dms", "document", []Field{{Name: "title", Kind: "text"}}); err != nil {
t.Fatalf("dms register: %v", err)
}
if err := r.Register(ctx, "mail", "email", []Field{{Name: "subject", Kind: "text"}}); err != nil {
if err := r.Register(ctx, "mandant-a", "mail", "email", []Field{{Name: "subject", Kind: "text"}}); err != nil {
t.Fatalf("mail register: %v", err)
}
dmsTypes, err := r.ListByModule(ctx, "dms")
dmsTypes, err := r.ListByModule(ctx, "mandant-a", "dms")
if err != nil {
t.Fatalf("dms liste: %v", err)
}
mailTypes, err := r.ListByModule(ctx, "mail")
mailTypes, err := r.ListByModule(ctx, "mandant-a", "mail")
if err != nil {
t.Fatalf("mail liste: %v", err)
}
@@ -133,13 +152,13 @@ func TestRegister_IdempotentUpdate(t *testing.T) {
r := newRegistry(t)
ctx := context.Background()
if err := r.Register(ctx, "dms", "document", []Field{{Name: "title", Kind: "text"}}); err != nil {
if err := r.Register(ctx, "mandant-a", "dms", "document", []Field{{Name: "title", Kind: "text"}}); err != nil {
t.Fatalf("erste registrierung: %v", err)
}
if err := r.Register(ctx, "dms", "document", []Field{{Name: "title", Kind: "text"}, {Name: "tags", Kind: "array"}}); err != nil {
if err := r.Register(ctx, "mandant-a", "dms", "document", []Field{{Name: "title", Kind: "text"}, {Name: "tags", Kind: "array"}}); err != nil {
t.Fatalf("zweite registrierung: %v", err)
}
got, err := r.Get(ctx, "dms", "document")
got, err := r.Get(ctx, "mandant-a", "dms", "document")
if err != nil {
t.Fatalf("get: %v", err)
}
@@ -154,8 +173,64 @@ func TestRegister_EmptyFields_Rejected(t *testing.T) {
r := newRegistry(t)
ctx := context.Background()
err := r.Register(ctx, "dms", "empty_type", nil)
err := r.Register(ctx, "mandant-a", "dms", "empty_type", nil)
if err == nil {
t.Fatal("erwartete fehler bei leerer felderliste")
}
}
// TestRegister_ValidExistingTenant_Accepted ist GOV-07 Prüfung 1: eine
// Registrierung mit gültigem, existierendem Tenant-Kontext wird übernommen.
func TestRegister_ValidExistingTenant_Accepted(t *testing.T) {
r := newRegistry(t)
ctx := context.Background()
if err := r.Register(ctx, "mandant-b", "dms", "document", []Field{{Name: "title", Kind: "text"}}); err != nil {
t.Fatalf("register: %v", err)
}
got, err := r.Get(ctx, "mandant-b", "dms", "document")
if err != nil {
t.Fatalf("get: %v", err)
}
if got.TenantSlug != "mandant-b" {
t.Fatalf("tenant_slug nicht korrekt: %+v", got)
}
}
// TestRegister_ForeignOrFakeTenant_Rejected ist die geforderte adversariale
// Prüfung (GOV-07 Prüfung 2): eine Registrierung, die einen Tenant
// beansprucht, den sie nicht besitzt (nicht in der Core-Registry gefuehrt),
// wird abgelehnt statt uebernommen.
func TestRegister_ForeignOrFakeTenant_Rejected(t *testing.T) {
r := newRegistry(t)
ctx := context.Background()
err := r.Register(ctx, "fremder-mandant-den-dms-nicht-besitzt", "dms", "document", []Field{{Name: "title", Kind: "text"}})
if !errors.Is(err, ErrUnknownTenant) {
t.Fatalf("erwartete ErrUnknownTenant, bekam: %v", err)
}
if _, err := r.Get(ctx, "fremder-mandant-den-dms-nicht-besitzt", "dms", "document"); !errors.Is(err, ErrNotRegistered) {
t.Fatalf("abgelehnte registrierung wurde dennoch uebernommen: %v", err)
}
}
// TestRegister_TenantVerificationError_FailsClosed stellt sicher, dass ein
// Transportfehler bei der Core-Registry-Abfrage NICHT stillschweigend als
// gueltig behandelt wird (fail-closed statt fail-open).
func TestRegister_TenantVerificationError_FailsClosed(t *testing.T) {
r := newRegistryWithVerifier(t, fakeVerifier{err: errors.New("core ten-01 nicht erreichbar")})
ctx := context.Background()
err := r.Register(ctx, "mandant-a", "dms", "document", []Field{{Name: "title", Kind: "text"}})
if err == nil {
t.Fatal("erwartete fehler bei tenant-verifikationsfehler (fail-closed)")
}
if errors.Is(err, ErrUnknownTenant) {
t.Fatal("verifikationsfehler sollte nicht als ErrUnknownTenant klassifiziert werden")
}
if _, getErr := r.Get(ctx, "mandant-a", "dms", "document"); !errors.Is(getErr, ErrNotRegistered) {
t.Fatalf("registrierung trotz verifikationsfehler uebernommen: %v", getErr)
}
}
@@ -0,0 +1,47 @@
// Package tenantregistryclient ist ein schlanker HTTP-Client fuer Core
// TEN-01 (Tenant-Registry); AI ist ein eigenes Go-Modul und kann Cores
// Pakete nicht importieren. Gleiches Muster wie archive/internal/
// tenantregistryclient und dms/internal/tenantregistryclient (RET-12).
package tenantregistryclient
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
)
type Client struct {
BaseURL string
ServiceToken string
HTTPClient *http.Client
}
func New(baseURL, serviceToken string) *Client {
return &Client{BaseURL: baseURL, ServiceToken: serviceToken, HTTPClient: &http.Client{Timeout: 10 * time.Second}}
}
// VerifyTenant fragt Core, ob tenantSlug als Tenant gefuehrt wird. Transport-
// und Serverfehler kommen als Fehler zurueck, nie als "gueltig": der Aufrufer
// muss fail-closed reagieren.
func (c *Client) VerifyTenant(ctx context.Context, tenantSlug string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/tenants/"+url.PathEscape(tenantSlug), nil)
if err != nil {
return false, fmt.Errorf("tenantregistryclient: request bauen: %w", err)
}
req.Header.Set("X-Service-Token", c.ServiceToken)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return false, fmt.Errorf("tenantregistryclient: aufruf fehlgeschlagen: %w", err)
}
defer func() { _ = resp.Body.Close() }()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
default:
return false, fmt.Errorf("tenantregistryclient: unerwarteter status %d", resp.StatusCode)
}
}
@@ -0,0 +1,59 @@
package tenantregistryclient
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestVerifyTenant_ExistingTenant(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/tenants/mandant-a" {
t.Errorf("pfad = %q, want /tenants/mandant-a", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
c := New(server.URL, "test-token")
ok, err := c.VerifyTenant(context.Background(), "mandant-a")
if err != nil {
t.Fatalf("verifytenant: %v", err)
}
if !ok {
t.Fatal("erwartet gueltigen tenant, habe false")
}
}
func TestVerifyTenant_UnknownTenant(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
c := New(server.URL, "test-token")
ok, err := c.VerifyTenant(context.Background(), "unbekannt")
if err != nil {
t.Fatalf("verifytenant: %v", err)
}
if ok {
t.Fatal("erwartet ungueltigen tenant, habe true")
}
}
func TestVerifyTenant_ServerErrorIsFailClosed(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
c := New(server.URL, "test-token")
ok, err := c.VerifyTenant(context.Background(), "mandant-a")
if err == nil {
t.Fatal("erwartet fehler bei 500-antwort der tenant-registry")
}
if ok {
t.Fatal("erwartet false bei fehler (fail-closed)")
}
}
@@ -0,0 +1,3 @@
ALTER TABLE module_objecttypes DROP CONSTRAINT module_objecttypes_tenant_module_objecttype_key;
ALTER TABLE module_objecttypes ADD CONSTRAINT module_objecttypes_module_name_object_type_key UNIQUE (module_name, object_type);
ALTER TABLE module_objecttypes DROP COLUMN tenant_slug;
@@ -0,0 +1,4 @@
ALTER TABLE module_objecttypes ADD COLUMN tenant_slug TEXT NOT NULL DEFAULT '';
ALTER TABLE module_objecttypes ALTER COLUMN tenant_slug DROP DEFAULT;
ALTER TABLE module_objecttypes DROP CONSTRAINT module_objecttypes_module_name_object_type_key;
ALTER TABLE module_objecttypes ADD CONSTRAINT module_objecttypes_tenant_module_objecttype_key UNIQUE (tenant_slug, module_name, object_type);