From e172fdfe53dfafeb74b1fcc951bbcfd0f8c29c29 Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 20 Sep 2026 10:07:12 +0200 Subject: [PATCH] Integration: authz.Guard umschliesst den App-Mux, Guard-Regeln fuer /search, /saved-searches, /ocr; Warmup im Such-Lasttest Co-Authored-By: Claude Sonnet 5 --- dms/cmd/app/main.go | 25 +++++++++++++- dms/cmd/app/main_test.go | 48 ++++++++++++++++++++++++++ dms/internal/authz/authz.go | 13 +++++-- dms/internal/searchapi/handler_test.go | 4 +++ 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/dms/cmd/app/main.go b/dms/cmd/app/main.go index af298c7a..5d3213b1 100644 --- a/dms/cmd/app/main.go +++ b/dms/cmd/app/main.go @@ -27,6 +27,7 @@ import ( "gitea.perlbach24.de/scripte/nexarch/dms/internal/routeprefix" "gitea.perlbach24.de/scripte/nexarch/dms/internal/searchindex" "gitea.perlbach24.de/scripte/nexarch/dms/internal/searchsync" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/authz" "gitea.perlbach24.de/scripte/nexarch/dms/internal/searchwiring" "gitea.perlbach24.de/scripte/nexarch/dms/internal/shared" "gitea.perlbach24.de/scripte/nexarch/dms/internal/storage" @@ -77,7 +78,15 @@ func main() { log.Print("dms-app: NEXARCH_DMS_APP_WATCHFOLDER_DIR nicht gesetzt, INT-08-Consume-Ordner deaktiviert") } - handler := logging.Middleware(logger)(metricsReg.Middleware(recoverMiddleware(tracker, mux))) + var routes http.Handler = mux + if dsn := os.Getenv("NEXARCH_DMS_APP_TENANT_DSN"); dsn != "" { + guarded, err := guardMux(mux, dsn, prefix, os.Getenv("NEXARCH_DMS_APP_ADMIN_USER_IDS")) + if err != nil { + log.Fatalf("dms-app: berechtigungs-guard: %v", err) + } + routes = guarded + } + handler := logging.Middleware(logger)(metricsReg.Middleware(recoverMiddleware(tracker, routes))) log.Printf("dms-app hoert auf %s (version %s)", addr, shared.Version) if err := http.ListenAndServe(addr, handler); err != nil { @@ -97,6 +106,20 @@ func newMux(prefix string, metricsReg *metrics.Registry) (root, api *http.ServeM return root, routeprefix.Mount(root, prefix) } +func guardMux(next http.Handler, dsn, prefix, adminIDs string) (http.Handler, error) { + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + return nil, fmt.Errorf("datenbankverbindung: %w", err) + } + admins := map[string]bool{} + for _, id := range strings.Split(adminIDs, ",") { + if id = strings.TrimSpace(id); id != "" { + admins[id] = true + } + } + return authz.Guard(next, &authz.PoolChecker{Pool: pool, Admins: admins}, prefix), nil +} + // recoverMiddleware faengt Panics in Handlern ab und meldet sie zentral // (Akzeptanzkriterium 3), statt den Prozess abstuerzen zu lassen. func recoverMiddleware(tracker *errortracking.Tracker, next http.Handler) http.Handler { diff --git a/dms/cmd/app/main_test.go b/dms/cmd/app/main_test.go index 7ce3b960..f4f05b7f 100644 --- a/dms/cmd/app/main_test.go +++ b/dms/cmd/app/main_test.go @@ -88,3 +88,51 @@ func TestSetupSearch_UnreachableIndexReturnsError(t *testing.T) { t.Fatal("erreichbarkeitsfehler des suchindex nicht gemeldet") } } + +func TestGuardMux_RejectsUnauthorizedRequestsOnWiredRoutes(t *testing.T) { + dsn, addr := os.Getenv("TEST_TENANT_DSN"), os.Getenv("TEST_MANTICORE_ADDR") + if dsn == "" || addr == "" { + t.Skip("TEST_TENANT_DSN/TEST_MANTICORE_ADDR nicht gesetzt") + } + const prefix = "/api/dms/v1" + root, api := newMux(prefix, metrics.NewRegistry()) + if err := setupSearch(api, dsn, addr); err != nil { + t.Fatal(err) + } + handler, err := guardMux(root, dsn, prefix, "admin-1") + if err != nil { + t.Fatal(err) + } + do := func(method, path, header string) int { + req := httptest.NewRequest(method, path, nil) + if header != "" { + req.Header.Set("X-User-Id", header) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Code + } + + for _, path := range []string{"/search?q=x", "/saved-searches", "/ocr/review", "/ocr/settings"} { + if code := do(http.MethodGet, prefix+path, ""); code != http.StatusUnauthorized { + t.Errorf("%s ohne identitaet: %d, erwartet 401", path, code) + } + } + if code := do(http.MethodGet, prefix+"/search?q=x&caller=a", "b"); code != http.StatusUnauthorized { + t.Errorf("widerspruechliche identitaet: %d, erwartet 401", code) + } + if code := do(http.MethodGet, prefix+"/ocr/review?caller=user-1", ""); code != http.StatusForbidden { + t.Errorf("ocr/review als nicht-admin: %d, erwartet 403", code) + } + if code := do(http.MethodPut, prefix+"/ocr/settings?caller=user-1", ""); code != http.StatusForbidden { + t.Errorf("ocr/settings schreiben als nicht-admin: %d, erwartet 403", code) + } + if code := do(http.MethodGet, prefix+"/ocr/review?caller=admin-1", ""); code == http.StatusUnauthorized || code == http.StatusForbidden { + t.Errorf("ocr/review als admin abgewiesen: %d", code) + } + for _, path := range []string{"/healthz", "/metrics"} { + if code := do(http.MethodGet, path, ""); code != http.StatusOK { + t.Errorf("%s ohne identitaet: %d, erwartet 200", path, code) + } + } +} diff --git a/dms/internal/authz/authz.go b/dms/internal/authz/authz.go index 069d1b00..6b23e86a 100644 --- a/dms/internal/authz/authz.go +++ b/dms/internal/authz/authz.go @@ -145,8 +145,8 @@ func needsGuard(method string, seg []string) bool { return len(seg) == 2 case "auto-rules": return true - case "ocr": - return len(seg) == 2 && seg[1] == "settings" && method != http.MethodGet + case "ocr", "search", "saved-searches": + return true } return false } @@ -162,7 +162,14 @@ func evaluate(r *http.Request, c Checker, caller, method string, seg []string, w return true, nil } return c.OwnsUploadSession(ctx, caller, seg[1]) - case "auto-rules", "ocr": + case "search", "saved-searches": + return true, nil + case "ocr": + if method == http.MethodGet && len(seg) == 2 && seg[1] == "settings" { + return true, nil + } + return c.IsAdmin(ctx, caller) + case "auto-rules": return c.IsAdmin(ctx, caller) case "bulk-edit": var body struct { diff --git a/dms/internal/searchapi/handler_test.go b/dms/internal/searchapi/handler_test.go index c1680f63..2e600acc 100644 --- a/dms/internal/searchapi/handler_test.go +++ b/dms/internal/searchapi/handler_test.go @@ -198,6 +198,10 @@ func TestSearch_ConcurrentRequestsStayUnderResponseTimeLimit(t *testing.T) { index(t, db, searchindex.Doc{Title: fmt.Sprintf("%s dokument %d", tok, i), Body: strings.Repeat("vertrag kuendigung ", 50) + tok, CreatedBy: "u1"}) } + for i := 0; i < 20; i++ { + get(t, srv.URL+"/search?caller=u1&q="+tok) + } + const workers, perWorker = 20, 10 var mu sync.Mutex var durations []time.Duration