API-22: Modul-Endpunkt-Discovery #247

Merged
patrick merged 1 commits from feature/api-22-modul-endpunkt-discovery into main 2026-09-28 07:06:56 +02:00
7 changed files with 282 additions and 5 deletions

No files matched your search

+14
View File
@@ -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 {
+8
View File
@@ -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)
}
+20
View File
@@ -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)
}
}
+42
View File
@@ -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.
+11 -5
View File
@@ -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
}
+53
View File
@@ -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})
}
}
+134
View File
@@ -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
}