INT-09: Nachtrag - AI-seitige GOV-01-HTTP-Schnittstelle (ai/internal/moduletypeapi) aus INT-12 uebernommen

INT-12 (DMS-Variante dieser Integration) ist auf main noch nicht gemerged,
nur auf dem eigenen Feature-Branch vorhanden. Ohne den HTTP-Endpunkt bei AI
gibt es fuer den Mail-seitigen Client aus INT-09 kein Ziel. Unveraendert aus
dem INT-12-Branch uebernommen (Service-Token-Pflicht, Tenant-Verifikation
gegen Core TEN-01/GOV-07 ueber tenantregistryclient, Route nur verdrahtet
bei konfigurierter Core-Basis-URL).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Sonnet 5 committed 2026-10-16 06:39:12 +00:00
1 parent dac7fff504
commit 45541d1577
3 files changed
+236 -10

No files matched your search

+28 -10
View File
@@ -27,6 +27,8 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/extractvalidate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/invoiceextract"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/metadatapushclient"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletype"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletypeapi"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/queue"
@@ -39,6 +41,7 @@ import (
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearchapi"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/summarize"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/summaryapi"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/tenantregistryclient"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/threadsummarize"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex"
)
@@ -49,11 +52,13 @@ const defaultListenAddr = "127.0.0.1:9600"
const defaultModel = "default"
type config struct {
dsn string
serviceToken string
addr string
model string
moduleURLs map[string]string
dsn string
serviceToken string
addr string
model string
moduleURLs map[string]string
coreBaseURL string
coreServiceToken string
}
// parseModuleBaseURLs liest "modul=url,modul2=url2" (EXT-05: je Modul eine
@@ -79,11 +84,13 @@ func parseModuleBaseURLs(raw string) map[string]string {
// Programmabbruchs weiter unten im Code.
func loadConfig(getenv func(string) string) (config, error) {
cfg := config{
dsn: getenv("NEXARCH_AI_APP_TENANT_DSN"),
serviceToken: getenv("NEXARCH_AI_APP_SERVICE_TOKEN"),
addr: getenv("NEXARCH_AI_APP_LISTEN_ADDR"),
model: getenv("NEXARCH_AI_APP_MODEL"),
moduleURLs: parseModuleBaseURLs(getenv("NEXARCH_AI_APP_MODULE_BASE_URLS")),
dsn: getenv("NEXARCH_AI_APP_TENANT_DSN"),
serviceToken: getenv("NEXARCH_AI_APP_SERVICE_TOKEN"),
addr: getenv("NEXARCH_AI_APP_LISTEN_ADDR"),
model: getenv("NEXARCH_AI_APP_MODEL"),
moduleURLs: parseModuleBaseURLs(getenv("NEXARCH_AI_APP_MODULE_BASE_URLS")),
coreBaseURL: getenv("NEXARCH_AI_APP_CORE_BASE_URL"),
coreServiceToken: getenv("NEXARCH_AI_APP_CORE_SERVICE_TOKEN"),
}
if cfg.dsn == "" {
return config{}, errors.New("ai-app: NEXARCH_AI_APP_TENANT_DSN ist erforderlich")
@@ -150,6 +157,17 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri
cfg.serviceToken,
).RegisterRoutes(mux)
// GOV-01/INT-12: Objekttyp-Registrierung durch DMS/Mail nur zulaessig,
// solange Core TEN-01 fuer die Tenant-Verifikation erreichbar konfiguriert
// ist (GOV-07 fail-closed) - ohne Core-Basis-URL bleibt der Endpunkt
// unverdrahtet statt eine Registrierung ohne Verifikation zu erlauben.
if cfg.coreBaseURL != "" {
verifier := tenantregistryclient.New(cfg.coreBaseURL, cfg.coreServiceToken)
moduletypeapi.NewServer(&moduletype.Registry{Pool: pool, Verifier: verifier}, cfg.serviceToken).RegisterRoutes(mux)
} else {
log.Print("ai-app: NEXARCH_AI_APP_CORE_BASE_URL nicht gesetzt, Objekttyp-Registrierung (GOV-01) deaktiviert")
}
quotaStore := quota.NewStore(pool, nil)
q := queue.NewQueue(pool, 5*time.Minute)
q.SetQuotaEnforcer(quotaStore)
@@ -0,0 +1,83 @@
// Package moduletypeapi ist die HTTP-Bindung fuer moduletype (GOV-01):
// DMS/Mail sind eigene Go-Module und koennen moduletype.Registry nicht
// direkt importieren, nur ueber diese Schnittstelle (Muster wie
// extractapi/residencyapi), Service-Token-Pflicht, fail closed.
package moduletypeapi
import (
"encoding/json"
"errors"
"net/http"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletype"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth"
)
type Server struct {
registry *moduletype.Registry
serviceToken string
}
func NewServer(registry *moduletype.Registry, serviceToken string) *Server {
return &Server{registry: registry, serviceToken: serviceToken}
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /module-objecttypes", s.requireAuth(s.handleRegister))
}
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
got := r.Header.Get("X-Service-Token")
if !svcauth.Equal(got, s.serviceToken) {
writeError(w, http.StatusUnauthorized, "service-token fehlt oder ist ungueltig")
return
}
next(w, r)
}
}
type fieldDTO struct {
Name string `json:"name"`
Kind string `json:"kind"`
}
type registerRequest struct {
TenantSlug string `json:"tenant_slug"`
ModuleName string `json:"module_name"`
ObjectType string `json:"object_type"`
Fields []fieldDTO `json:"fields"`
}
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
return
}
fields := make([]moduletype.Field, 0, len(req.Fields))
for _, f := range req.Fields {
fields = append(fields, moduletype.Field{Name: f.Name, Kind: f.Kind})
}
err := s.registry.Register(r.Context(), req.TenantSlug, req.ModuleName, req.ObjectType, fields)
switch {
case err == nil:
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
case errors.Is(err, moduletype.ErrUnknownTenant):
writeError(w, http.StatusForbidden, err.Error())
default:
writeError(w, http.StatusBadRequest, err.Error())
}
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
@@ -0,0 +1,125 @@
package moduletypeapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletype"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
)
type fakeVerifier struct{ validTenants map[string]bool }
func (f fakeVerifier) VerifyTenant(_ context.Context, tenantSlug string) (bool, error) {
return f.validTenants[tenantSlug], nil
}
func newServer(t *testing.T, serviceToken string) *Server {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen")
}
pool := testdb.Pool(t, dsn)
migrationsDir, err := filepath.Abs("../../migrations")
if err != nil {
t.Fatalf("migrationsverzeichnis: %v", err)
}
migrations, err := migrate.Load(migrationsDir)
if err != nil {
t.Fatalf("migrationen laden: %v", err)
}
if _, err := migrate.Up(context.Background(), pool, migrations); err != nil {
t.Fatalf("migrationen anwenden: %v", err)
}
registry := &moduletype.Registry{Pool: pool, Verifier: fakeVerifier{validTenants: map[string]bool{"mandant-int12": true}}}
return NewServer(registry, serviceToken)
}
// TestHandleRegister_ValidRequest_Registered deckt AK1 ab: ein Objekttyp
// samt Feldern wird ueber den HTTP-Endpunkt registriert und erscheint in der
// Registry.
func TestHandleRegister_ValidRequest_Registered(t *testing.T) {
s := newServer(t, "test-token")
mux := http.NewServeMux()
s.RegisterRoutes(mux)
srv := httptest.NewServer(mux)
defer srv.Close()
body, _ := json.Marshal(registerRequest{
TenantSlug: "mandant-int12",
ModuleName: "dms",
ObjectType: "document",
Fields: []fieldDTO{
{Name: "fulltext", Kind: "text"},
{Name: "title", Kind: "text"},
},
})
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/module-objecttypes", bytes.NewReader(body))
req.Header.Set("X-Service-Token", "test-token")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
got, err := s.registry.Get(context.Background(), "mandant-int12", "dms", "document")
if err != nil {
t.Fatalf("get: %v", err)
}
if len(got.Fields) != 2 || got.Fields[0].Name != "fulltext" {
t.Fatalf("unerwartete felder in der registry: %+v", got.Fields)
}
}
// TestHandleRegister_MissingServiceToken_Rejected deckt die Auth-Pflicht ab:
// ohne gueltiges Service-Token kein Zugriff, fail closed.
func TestHandleRegister_MissingServiceToken_Rejected(t *testing.T) {
s := newServer(t, "test-token")
mux := http.NewServeMux()
s.RegisterRoutes(mux)
srv := httptest.NewServer(mux)
defer srv.Close()
body, _ := json.Marshal(registerRequest{TenantSlug: "mandant-int12", ModuleName: "dms", ObjectType: "document", Fields: []fieldDTO{{Name: "title", Kind: "text"}}})
resp, err := http.Post(srv.URL+"/module-objecttypes", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
}
// TestHandleRegister_UnknownTenant_Rejected deckt GOV-07 ab: ein Tenant, den
// Core TEN-01 nicht fuehrt, wird mit 403 abgewiesen statt uebernommen.
func TestHandleRegister_UnknownTenant_Rejected(t *testing.T) {
s := newServer(t, "test-token")
mux := http.NewServeMux()
s.RegisterRoutes(mux)
srv := httptest.NewServer(mux)
defer srv.Close()
body, _ := json.Marshal(registerRequest{TenantSlug: "fremder-mandant", ModuleName: "dms", ObjectType: "document", Fields: []fieldDTO{{Name: "title", Kind: "text"}}})
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/module-objecttypes", bytes.NewReader(body))
req.Header.Set("X-Service-Token", "test-token")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want 403", resp.StatusCode)
}
}