From b2bd1c4c1a08cb9e676bd8d32d6f45ef3ea2e608 Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 28 Sep 2026 04:56:41 +0000 Subject: [PATCH] API-22: modul endpunkt discovery Co-Authored-By: Claude Sonnet 5 --- cmd/core/main.go | 14 +++ cmd/core/routes.go | 8 ++ cmd/core/routes_test.go | 20 +++ docs/API-22-PRUEFPROTOKOLL.md | 42 +++++++ internal/config/config.go | 16 ++- internal/moduledirectory/directory.go | 53 ++++++++ internal/moduledirectory/directory_test.go | 134 +++++++++++++++++++++ 7 files changed, 282 insertions(+), 5 deletions(-) create mode 100644 docs/API-22-PRUEFPROTOKOLL.md create mode 100644 internal/moduledirectory/directory.go create mode 100644 internal/moduledirectory/directory_test.go diff --git a/cmd/core/main.go b/cmd/core/main.go index 1f5d3cc1..533ba76d 100644 --- a/cmd/core/main.go +++ b/cmd/core/main.go @@ -9,6 +9,7 @@ import ( "gitea.perlbach24.de/scripte/nexarch/internal/config" "gitea.perlbach24.de/scripte/nexarch/internal/db" "gitea.perlbach24.de/scripte/nexarch/internal/httpharden" + "gitea.perlbach24.de/scripte/nexarch/internal/moduledirectory" "gitea.perlbach24.de/scripte/nexarch/internal/svcauth" "gitea.perlbach24.de/scripte/nexarch/internal/tenant" "gitea.perlbach24.de/scripte/nexarch/internal/tenantimportdir" @@ -62,6 +63,18 @@ func main() { // INT-14: Tenant-Bestaetigung fuer Module (DMS/Archive) ohne Admin-Auth. confirmHandler := tenant.NewConfirmHandler(registry) + // API-22: Modul-Endpunkt-Discovery fuer Clients, die nur die + // Server-Grundadresse kennen (z.B. INT-21 native App). Fehlt die + // Konfiguration, liefert der Endpunkt bewusst eine leere Liste statt den + // Dienststart zu verhindern. + var modules []moduledirectory.Module + if cfg.ModuleDirectoryPath != "" { + modules, err = moduledirectory.LoadFile(cfg.ModuleDirectoryPath) + if err != nil { + log.Fatalf("modulverzeichnis: %v", err) + } + } + mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) @@ -69,6 +82,7 @@ func main() { registerInternalRoutes(mux, adminToken, tenantHandler.CreateTenant, onboardingHandler.Onboard, userHandler.CreateSuperadmin) registerImportDirectoryQueryRoutes(mux, adminToken, importDirHandler.ListForTenantHandler, importDirHandler.ResolveHandler) registerTenantConfirmRoute(mux, adminToken, confirmHandler.Confirm) + registerModuleDirectoryRoute(mux, moduledirectory.Handler(modules)) log.Printf("nexarch-core listening on %s", cfg.ListenAddr) if err := httpharden.ListenAndServe(cfg.ListenAddr, mux); err != nil { diff --git a/cmd/core/routes.go b/cmd/core/routes.go index 8a41df9d..76cef9dd 100644 --- a/cmd/core/routes.go +++ b/cmd/core/routes.go @@ -31,3 +31,11 @@ func registerImportDirectoryQueryRoutes(mux *http.ServeMux, token string, listFo func registerTenantConfirmRoute(mux *http.ServeMux, token string, confirm http.HandlerFunc) { mux.HandleFunc("GET /tenants/{slug}", svcauth.Require(token, confirm)) } + +// registerModuleDirectoryRoute bindet API-22s Discovery-Endpunkt ein: bewusst +// UNAUTHENTIFIZIERT, da ein Client mit nur der Server-Grundadresse (z.B. die +// native Android-App aus INT-21) sonst gar nicht erst herausfinden kann, wo +// die einzelnen Module erreichbar sind. +func registerModuleDirectoryRoute(mux *http.ServeMux, discover http.HandlerFunc) { + mux.HandleFunc("GET /.well-known/nexarch-modules", discover) +} diff --git a/cmd/core/routes_test.go b/cmd/core/routes_test.go index 1000c138..944e5b26 100644 --- a/cmd/core/routes_test.go +++ b/cmd/core/routes_test.go @@ -43,3 +43,23 @@ func TestInternalRoutes_RequireServiceToken(t *testing.T) { } } } + +// API-22 Pruefung: Discovery-Route ist unauthentifiziert erreichbar und +// ruft den uebergebenen Handler auf. +func TestModuleDirectoryRoute_IsUnauthenticated(t *testing.T) { + reached := false + h := func(w http.ResponseWriter, r *http.Request) { + reached = true + w.WriteHeader(http.StatusOK) + } + mux := http.NewServeMux() + registerModuleDirectoryRoute(mux, h) + + req := httptest.NewRequest(http.MethodGet, "/.well-known/nexarch-modules", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK || !reached { + t.Fatalf("code=%d reached=%v", rec.Code, reached) + } +} diff --git a/docs/API-22-PRUEFPROTOKOLL.md b/docs/API-22-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..69a5cc2d --- /dev/null +++ b/docs/API-22-PRUEFPROTOKOLL.md @@ -0,0 +1,42 @@ +# API-22 – Modul-Endpunkt-Discovery – Prüfprotokoll + +Geprüft auf Entwicklungshost 204, Build/Vet/Test lokal im Arbeitsverzeichnis der Ticket-Session. `archivdms` unberührt, Host 131 nicht angefasst. + +## Umsetzung + +- Neues Paket `internal/moduledirectory` (Core): `LoadFile(path)` liest und validiert eine JSON-Konfigurationsdatei (Liste aus `{"key", "base_url"}`), `Handler(modules)` liefert sie unauthentifiziert als JSON aus. +- Response-Feldliste ist auf `key`/`base_url` beschränkt (Akzeptanzkriterium 3) — keine Zugangsdaten, keine internen Pfade, kein Bezug zu `module_credentials`/`Provision`/`Authenticate`. +- `cmd/core/routes.go`: `registerModuleDirectoryRoute` bindet `GET /.well-known/nexarch-modules` ein, bewusst außerhalb von `svcauth.Require` (unauthentifiziert, Ticket-Vorgabe für Clients, die nur die Server-Grundadresse kennen, z.B. INT-21). +- `cmd/core/main.go`: lädt die Konfigurationsdatei über neuen `config.ModuleDirectoryPath` (Env `NEXARCH_MODULE_DIRECTORY_PATH`), optional — fehlt der Pfad, liefert der Endpunkt eine leere Liste statt den Dienststart zu verhindern (keine bestehende Deployment-Konfiguration wird durch diese Kachel gebrochen). +- Keine Änderung an `internal/moduleregistry` (Credentials/Aktivierung bleiben unverändert), keine Änderung an `web/portal` oder INT-21 (Android-App) — beides laut Ticket explizit nicht Bestandteil. + +## Prüfungen + +**Prüfung 1 — Discovery-Aufruf liefert für ein bekanntes Modul die korrekte, tatsächlich erreichbare Adresse:** +- `TestHandler_ReturnsReachableAddress` (`internal/moduledirectory/directory_test.go`): startet einen echten `httptest.Server`, trägt dessen URL als Modul ein, ruft den Discovery-Handler auf, parst die Antwort und führt anschließend einen echten `http.Get` gegen die zurückgegebene `base_url` aus — Roundtrip bestätigt Erreichbarkeit. Bestanden. + +**Prüfung 2 — neues Test-Modul nur über Konfiguration ergänzt, erscheint korrekt im Discovery-Ergebnis:** +- `TestLoadFile_ParsesModules`: JSON-Datei mit einem bestehenden (`dms`) und einem neuen Test-Modul (`neues_testmodul`), rein über die Konfigurationsdatei ergänzt, kein Code geändert. Beide erscheinen korrekt im geladenen Ergebnis. Bestanden. +- `TestModuleDirectoryRoute_IsUnauthenticated` (`cmd/core/routes_test.go`): Route ohne jeden Token erreichbar, Handler wird aufgerufen. Bestanden. + +**Prüfung 3 — kein sicherheitsrelevanter Wert im Antwort-Payload (Positivliste erlaubter Felder):** +- `TestHandler_OnlyAllowlistedFieldsInPayload`: parst die Antwort generisch (`map[string]interface{}`), prüft jedes Feld jedes Moduleintrags gegen die Positivliste `{key, base_url}` und durchsucht den rohen Payload zusätzlich nach den Begriffen `secret`, `client_id`, `client_secret`, `password`, `token`, `internal_path`, `dsn`. Bestanden. +- Zusätzlich: `TestLoadFile_RejectsMissingFields`/`TestLoadFile_RejectsMissingFile` (Robustheit der Konfigurationsquelle). + +## Build/Vet/Test + +- `go build ./...` — grün. +- `go vet ./internal/moduledirectory/... ./cmd/core/...` — grün. +- `go test ./internal/moduledirectory/... ./cmd/core/... -v` — alle 7 Tests bestanden (5 in `moduledirectory`, 2 in `cmd/core`). + +## QA und Entscheidungen + +- **Entscheidung — Konfigurationsquelle:** neue, kleine JSON-Datei server-seitig (Pfad über Env), analog zu `web/portal/config/modules.json` aus SHL-02, aber serverseitig gepflegt statt clientseitig — wie in der Ausgangslage des Tickets vorgeschlagen. `internal/moduleregistry`/`modules`-Tabelle hält keine Adressinformationen und wurde bewusst nicht erweitert, um den Datenbank-Registrierungspfad (API-02) von der reinen Adress-Konfiguration zu trennen. +- **Entscheidung — Pfad optional statt Pflichtfeld:** anders als die übrigen `config.Load()`-Felder ist `NEXARCH_MODULE_DIRECTORY_PATH` optional (leere Liste statt Fatal), damit bestehende Deployments ohne diese neue Datei weiterlaufen, bis eine echte Konfiguration gepflegt ist. +- 0 blockierende Iterationen. + +## Offen + +- `web/portal` (SHL-02) migriert seine statische Konfiguration nicht auf diesen Endpunkt — laut Ticket ausdrücklich ein späteres, nicht ticketiertes Follow-up. +- INT-21 (Android-App) bleibt beim Zwei-Felder-Workaround — laut Ticket ausdrücklich nicht Bestandteil. +- Es existiert noch keine gepflegte Produktions-`modules-directory.json` auf den Zielhosts; das Anlegen/Pflegen dieser Datei ist Deployment-/Betriebsaufgabe, nicht Teil dieser Codeänderung. diff --git a/internal/config/config.go b/internal/config/config.go index 5dcf82a4..e39fef0d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,9 @@ type Config struct { // TenantSchemaPath zeigt auf migrations/tenant/0001_users.up.sql, mit der // TEN-02 eine frisch provisionierte Tenant-Datenbank initialisiert. TenantSchemaPath string + // ModuleDirectoryPath zeigt auf eine JSON-Datei mit den Basis-Adressen der + // Fachmodule (API-22). Leer = Discovery-Endpunkt liefert eine leere Liste. + ModuleDirectoryPath string } func Load() (Config, error) { @@ -50,11 +53,14 @@ func Load() (Config, error) { addr = "127.0.0.1:8080" } + moduleDirectoryPath := os.Getenv("NEXARCH_MODULE_DIRECTORY_PATH") + return Config{ - ListenAddr: addr, - RegistryDSN: dsn, - AdminDSN: adminDSN, - TenantDSNTemplate: dsnTemplate, - TenantSchemaPath: schemaPath, + ListenAddr: addr, + RegistryDSN: dsn, + AdminDSN: adminDSN, + TenantDSNTemplate: dsnTemplate, + TenantSchemaPath: schemaPath, + ModuleDirectoryPath: moduleDirectoryPath, }, nil } diff --git a/internal/moduledirectory/directory.go b/internal/moduledirectory/directory.go new file mode 100644 index 00000000..e3517d74 --- /dev/null +++ b/internal/moduledirectory/directory.go @@ -0,0 +1,53 @@ +// Package moduledirectory implementiert API-22: einen unauthentifizierten +// Discovery-Endpunkt, ueber den ein Client, der nur die Server-Grundadresse +// kennt, die tatsaechlichen Basis-Adressen der Fachmodule ermitteln kann. +// Die Modulliste kommt aus einer serverseitig gepflegten JSON-Datei (analog +// zu web/portal/config/modules.json aus SHL-02, aber server- statt +// clientseitig), damit neue Module ohne Codeaenderung ergaenzt werden koennen. +package moduledirectory + +import ( + "encoding/json" + "fmt" + "net/http" + "os" +) + +// Module ist bewusst auf Key und BaseURL beschraenkt (Akzeptanzkriterium 3): +// keine Zugangsdaten, keine internen Pfade. +type Module struct { + Key string `json:"key"` + BaseURL string `json:"base_url"` +} + +// LoadFile liest und validiert die Modulverzeichnis-Konfiguration. +func LoadFile(path string) ([]Module, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("modulverzeichnis lesen: %w", err) + } + + var modules []Module + if err := json.Unmarshal(data, &modules); err != nil { + return nil, fmt.Errorf("modulverzeichnis parsen: %w", err) + } + for _, m := range modules { + if m.Key == "" || m.BaseURL == "" { + return nil, fmt.Errorf("modulverzeichnis: key und base_url sind fuer jeden eintrag pflicht") + } + } + return modules, nil +} + +type discoveryResponse struct { + Modules []Module `json:"modules"` +} + +// Handler liefert die Modulliste unauthentifiziert aus (Akzeptanzkriterium 1). +func Handler(modules []Module) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(discoveryResponse{Modules: modules}) + } +} diff --git a/internal/moduledirectory/directory_test.go b/internal/moduledirectory/directory_test.go new file mode 100644 index 00000000..ffc0fc51 --- /dev/null +++ b/internal/moduledirectory/directory_test.go @@ -0,0 +1,134 @@ +package moduledirectory + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func writeConfig(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "modules-directory.json") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("config schreiben: %v", err) + } + return path +} + +// Pruefung 2: neues Modul erscheint nur ueber Konfiguration ergaenzt korrekt. +func TestLoadFile_ParsesModules(t *testing.T) { + path := writeConfig(t, `[ + {"key": "dms", "base_url": "http://localhost:3101"}, + {"key": "neues_testmodul", "base_url": "http://localhost:9999"} + ]`) + + modules, err := LoadFile(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(modules) != 2 { + t.Fatalf("erwartet 2 module, habe %d", len(modules)) + } + if modules[1].Key != "neues_testmodul" || modules[1].BaseURL != "http://localhost:9999" { + t.Fatalf("neues modul falsch geladen: %+v", modules[1]) + } +} + +func TestLoadFile_RejectsMissingFields(t *testing.T) { + path := writeConfig(t, `[{"key": "dms"}]`) + if _, err := LoadFile(path); err == nil { + t.Fatal("erwartet fehler bei fehlender base_url") + } +} + +func TestLoadFile_RejectsMissingFile(t *testing.T) { + if _, err := LoadFile("/nicht/vorhanden.json"); err == nil { + t.Fatal("erwartet fehler bei fehlender datei") + } +} + +// Pruefung 1: Discovery-Aufruf liefert fuer ein bekanntes Modul die korrekte, +// tatsaechlich erreichbare Adresse. +func TestHandler_ReturnsReachableAddress(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("modul-antwort")) + })) + defer target.Close() + + modules := []Module{{Key: "testmodul", BaseURL: target.URL}} + handler := Handler(modules) + + req := httptest.NewRequest(http.MethodGet, "/.well-known/nexarch-modules", nil) + rec := httptest.NewRecorder() + handler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var got discoveryResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("antwort parsen: %v", err) + } + if len(got.Modules) != 1 || got.Modules[0].BaseURL != target.URL { + t.Fatalf("unerwartete antwort: %+v", got) + } + + resp, err := http.Get(got.Modules[0].BaseURL) + if err != nil { + t.Fatalf("adresse aus discovery-antwort nicht erreichbar: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("modul-adresse antwortet mit %d", resp.StatusCode) + } +} + +// Pruefung 3: kein sicherheitsrelevanter Wert im Antwort-Payload — Test gegen +// eine Positivliste erlaubter Felder. +func TestHandler_OnlyAllowlistedFieldsInPayload(t *testing.T) { + modules := []Module{{Key: "dms", BaseURL: "http://localhost:3101"}} + handler := Handler(modules) + + req := httptest.NewRequest(http.MethodGet, "/.well-known/nexarch-modules", nil) + rec := httptest.NewRecorder() + handler(rec, req) + + var raw map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &raw); err != nil { + t.Fatalf("antwort parsen: %v", err) + } + rawModules, ok := raw["modules"].([]interface{}) + if !ok || len(rawModules) != 1 { + t.Fatalf("unerwartete struktur: %+v", raw) + } + + allowlist := map[string]bool{"key": true, "base_url": true} + entry := rawModules[0].(map[string]interface{}) + for field := range entry { + if !allowlist[field] { + t.Fatalf("nicht erlaubtes feld im payload: %q", field) + } + } + + forbidden := []string{"secret", "client_id", "client_secret", "password", "token", "internal_path", "dsn"} + body := rec.Body.String() + for _, f := range forbidden { + if jsonContains(body, f) { + t.Fatalf("sicherheitsrelevanter begriff %q im payload gefunden", f) + } + } +} + +func jsonContains(body, needle string) bool { + for i := 0; i+len(needle) <= len(body); i++ { + if body[i:i+len(needle)] == needle { + return true + } + } + return false +} -- 2.47.3