Integration: authz.Guard umschliesst den App-Mux, Guard-Regeln fuer /search, /saved-searches, /ocr; Warmup im Such-Lasttest
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-20 10:07:12 +02:00
1 parent 56b2c22346
commit e172fdfe53
4 files changed
+86 -4

No files matched your search

+24 -1
View File
@@ -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 {
+48
View File
@@ -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)
}
}
}
+10 -3
View File
@@ -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 {
+4
View File
@@ -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