GOV-07: Unabhängige Tenant-Validierung bei KI-Objekttyp-Registrierung durch DMS/Mail #238
No files matched your search
@@ -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").
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user