diff --git a/ai/docs/GOV-07-PRUEFPROTOKOLL.md b/ai/docs/GOV-07-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..3b39b9e1 --- /dev/null +++ b/ai/docs/GOV-07-PRUEFPROTOKOLL.md @@ -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"). diff --git a/ai/internal/moduletype/moduletype.go b/ai/internal/moduletype/moduletype.go index 69a3bdad..e6bcfc24 100644 --- a/ai/internal/moduletype/moduletype.go +++ b/ai/internal/moduletype/moduletype.go @@ -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() } diff --git a/ai/internal/moduletype/moduletype_test.go b/ai/internal/moduletype/moduletype_test.go index 3d383808..e54f444d 100644 --- a/ai/internal/moduletype/moduletype_test.go +++ b/ai/internal/moduletype/moduletype_test.go @@ -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) + } +} diff --git a/ai/internal/tenantregistryclient/tenantregistryclient.go b/ai/internal/tenantregistryclient/tenantregistryclient.go new file mode 100644 index 00000000..e768c264 --- /dev/null +++ b/ai/internal/tenantregistryclient/tenantregistryclient.go @@ -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) + } +} diff --git a/ai/internal/tenantregistryclient/tenantregistryclient_test.go b/ai/internal/tenantregistryclient/tenantregistryclient_test.go new file mode 100644 index 00000000..41f9f355 --- /dev/null +++ b/ai/internal/tenantregistryclient/tenantregistryclient_test.go @@ -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)") + } +} diff --git a/ai/migrations/0005_module_objecttypes_tenant_slug.down.sql b/ai/migrations/0005_module_objecttypes_tenant_slug.down.sql new file mode 100644 index 00000000..06f9ce29 --- /dev/null +++ b/ai/migrations/0005_module_objecttypes_tenant_slug.down.sql @@ -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; diff --git a/ai/migrations/0005_module_objecttypes_tenant_slug.up.sql b/ai/migrations/0005_module_objecttypes_tenant_slug.up.sql new file mode 100644 index 00000000..cd2cf164 --- /dev/null +++ b/ai/migrations/0005_module_objecttypes_tenant_slug.up.sql @@ -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);