diff --git a/ai/cmd/ai-app/main.go b/ai/cmd/ai-app/main.go index 5960d337..64f5321f 100644 --- a/ai/cmd/ai-app/main.go +++ b/ai/cmd/ai-app/main.go @@ -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) diff --git a/ai/internal/moduletypeapi/moduletypeapi.go b/ai/internal/moduletypeapi/moduletypeapi.go new file mode 100644 index 00000000..687254e5 --- /dev/null +++ b/ai/internal/moduletypeapi/moduletypeapi.go @@ -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}) +} diff --git a/ai/internal/moduletypeapi/moduletypeapi_test.go b/ai/internal/moduletypeapi/moduletypeapi_test.go new file mode 100644 index 00000000..f7cdb5e1 --- /dev/null +++ b/ai/internal/moduletypeapi/moduletypeapi_test.go @@ -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) + } +}