API-22: Modul-Endpunkt-Discovery #247
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user