From 0a4c0aaf923bb6d1c5940bfd4dbbf0ef2cc20b5e Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 28 Oct 2026 22:36:43 +0100 Subject: [PATCH 1/2] SEC: RBAC-Pruefung in ai-search, workflow-tasks, connect-setup (#369) - semanticsearchapi: ai.search.read per policyclient (WithPolicy), Tenant aus dem Anfragekoerper, nach Service-Token-Pruefung - taskapi: workflow.task.read (GET /tasks) und workflow.task.write (PATCH status); Paket jetzt in cmd/workflow-app verdrahtet (lag bisher nur im a11y-Fixture) und damit hinter bearerauth - connectorsetupapi: connect.setup.read/write je Route - Migration 0029 (up/down): neue Rechte fuer user und tenant_admin - Wiring: NEXARCH_{AI,WORKFLOW,CONNECT}_APP_POLICY_URL/_POLICY_SERVICE_TOKEN; ohne URL Warnung und keine Pruefung (Uebergang), URL ohne Token Startfehler; connect-app setzt die bestehende statusapi-Pruefung damit ebenfalls scharf - Tests je API: 403 bei Ablehnung, 200 bei Freigabe, 503 bei Ausfall, Policy wird ohne Identitaet/Service-Token nicht befragt - MODULE_REGISTRY: ai_search_chat, workflow_tasks, connect_setup (+ Icons, Portal-Rechteliste) - Doku: BETRIEB-PORTAL.md, ENG-11 Umgebungsvariablen Co-Authored-By: Claude Sonnet 5.5 --- ai/cmd/ai-app/main.go | 51 +++++--- ai/cmd/ai-app/main_test.go | 25 ++++ .../semanticsearchapi/semanticsearchapi.go | 34 ++++- .../semanticsearchapi_policy_test.go | 100 ++++++++++++++ connect/cmd/connect-app/main.go | 22 +++- .../connectorsetupapi/connectorsetupapi.go | 47 ++++++- .../connectorsetupapi_policy_test.go | 123 ++++++++++++++++++ docs/BETRIEB-PORTAL.md | 6 + .../0029_task_setup_policy_rules.down.sql | 1 + .../0029_task_setup_policy_rules.up.sql | 26 ++++ web/portal/app/moduleIcons.tsx | 3 + web/portal/lib/modules.test.ts | 7 +- web/shl/components/ModuleNavShell.tsx | 12 ++ workflow/cmd/workflow-app/main.go | 18 ++- workflow/cmd/workflow-app/main_test.go | 8 +- workflow/docs/ENG-11-PRUEFPROTOKOLL.md | 4 + workflow/internal/taskapi/taskapi.go | 74 +++++++---- .../internal/taskapi/taskapi_policy_test.go | 121 +++++++++++++++++ 18 files changed, 629 insertions(+), 53 deletions(-) create mode 100644 ai/internal/semanticsearchapi/semanticsearchapi_policy_test.go create mode 100644 connect/internal/connectorsetupapi/connectorsetupapi_policy_test.go create mode 100644 migrations/0029_task_setup_policy_rules.down.sql create mode 100644 migrations/0029_task_setup_policy_rules.up.sql create mode 100644 workflow/internal/taskapi/taskapi_policy_test.go diff --git a/ai/cmd/ai-app/main.go b/ai/cmd/ai-app/main.go index 56b97d4f..52787409 100644 --- a/ai/cmd/ai-app/main.go +++ b/ai/cmd/ai-app/main.go @@ -43,6 +43,7 @@ import ( "gitea.perlbach24.de/scripte/nexarch/ai/internal/summaryapi" "gitea.perlbach24.de/scripte/nexarch/ai/internal/threadsummarize" "gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex" + "gitea.perlbach24.de/scripte/nexarch/common/policyclient" "gitea.perlbach24.de/scripte/nexarch/common/tenantregistryclient" ) @@ -52,13 +53,24 @@ const defaultListenAddr = "127.0.0.1:9600" const defaultModel = "default" type config struct { - dsn string - serviceToken string - addr string - model string - moduleURLs map[string]string - coreBaseURL string - coreServiceToken string + dsn string + serviceToken string + addr string + model string + moduleURLs map[string]string + coreBaseURL string + coreServiceToken string + policyURL string + policyServiceToken string +} + +// policyClient liefert nil, solange NEXARCH_AI_APP_POLICY_URL fehlt +// (Uebergang; Pruefung dann deaktiviert, wird beim Start geloggt). +func (c config) policyClient() *policyclient.Client { + if c.policyURL == "" { + return nil + } + return policyclient.New(c.policyURL, c.policyServiceToken) } // parseModuleBaseURLs liest "modul=url,modul2=url2" (EXT-05: je Modul eine @@ -84,13 +96,18 @@ func parseModuleBaseURLs(raw string) map[string]string { // Programmabbruchs weiter unten im Code. func loadConfig(getenv func(string) string) (config, error) { cfg := config{ - dsn: getenv("NEXARCH_AI_APP_TENANT_DSN"), - serviceToken: getenv("NEXARCH_AI_APP_SERVICE_TOKEN"), - addr: getenv("NEXARCH_AI_APP_LISTEN_ADDR"), - model: getenv("NEXARCH_AI_APP_MODEL"), - moduleURLs: parseModuleBaseURLs(getenv("NEXARCH_AI_APP_MODULE_BASE_URLS")), - coreBaseURL: getenv("NEXARCH_AI_APP_CORE_BASE_URL"), - coreServiceToken: getenv("NEXARCH_AI_APP_CORE_SERVICE_TOKEN"), + dsn: getenv("NEXARCH_AI_APP_TENANT_DSN"), + serviceToken: getenv("NEXARCH_AI_APP_SERVICE_TOKEN"), + addr: getenv("NEXARCH_AI_APP_LISTEN_ADDR"), + model: getenv("NEXARCH_AI_APP_MODEL"), + moduleURLs: parseModuleBaseURLs(getenv("NEXARCH_AI_APP_MODULE_BASE_URLS")), + coreBaseURL: getenv("NEXARCH_AI_APP_CORE_BASE_URL"), + coreServiceToken: getenv("NEXARCH_AI_APP_CORE_SERVICE_TOKEN"), + policyURL: getenv("NEXARCH_AI_APP_POLICY_URL"), + policyServiceToken: getenv("NEXARCH_AI_APP_POLICY_SERVICE_TOKEN"), + } + if cfg.policyURL != "" && cfg.policyServiceToken == "" { + return config{}, errors.New("ai-app: NEXARCH_AI_APP_POLICY_SERVICE_TOKEN ist erforderlich, wenn NEXARCH_AI_APP_POLICY_URL gesetzt ist") } if cfg.dsn == "" { return config{}, errors.New("ai-app: NEXARCH_AI_APP_TENANT_DSN ist erforderlich") @@ -129,7 +146,11 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri ).RegisterRoutes(mux) searchSvc := semanticsearch.New(prov, vectorindex.New(pool), cfg.model) - semanticsearchapi.NewServer(searchSvc, cfg.serviceToken).RegisterRoutes(mux) + policy := cfg.policyClient() + if policy == nil { + log.Print("ai-app: WARNUNG: NEXARCH_AI_APP_POLICY_URL nicht gesetzt, Rechtepruefung ai.search.read (Issue #369) deaktiviert") + } + semanticsearchapi.NewServer(searchSvc, cfg.serviceToken).WithPolicy(policy).RegisterRoutes(mux) chunkStore := vectorindex.New(pool) docchatapi.NewServer( diff --git a/ai/cmd/ai-app/main_test.go b/ai/cmd/ai-app/main_test.go index 144dc4d9..a18ca427 100644 --- a/ai/cmd/ai-app/main_test.go +++ b/ai/cmd/ai-app/main_test.go @@ -44,6 +44,31 @@ func TestLoadConfig_MissingRequiredVars(t *testing.T) { } } +// Issue #369: Policy-URL ohne Service-Token darf nicht still ohne +// Rechtepruefung starten. +func TestLoadConfig_PolicyURLRequiresServiceToken(t *testing.T) { + env := map[string]string{ + "NEXARCH_AI_APP_TENANT_DSN": "postgres://x", + "NEXARCH_AI_APP_SERVICE_TOKEN": "tok", + "NEXARCH_AI_APP_POLICY_URL": "http://127.0.0.1:8093", + } + get := func(k string) string { return env[k] } + if _, err := loadConfig(get); err == nil || !strings.Contains(err.Error(), "NEXARCH_AI_APP_POLICY_SERVICE_TOKEN") { + t.Fatalf("erwartete fehler zur fehlenden NEXARCH_AI_APP_POLICY_SERVICE_TOKEN, bekam %v", err) + } + env["NEXARCH_AI_APP_POLICY_SERVICE_TOKEN"] = "policy-tok" + cfg, err := loadConfig(get) + if err != nil { + t.Fatalf("unerwarteter fehler: %v", err) + } + if cfg.policyClient() == nil { + t.Fatal("policy-client muss bei gesetzter URL aktiv sein") + } + if (config{}).policyClient() != nil { + t.Fatal("ohne URL darf kein policy-client entstehen") + } +} + func TestLoadConfig_Defaults(t *testing.T) { env := map[string]string{ "NEXARCH_AI_APP_TENANT_DSN": "postgres://x", diff --git a/ai/internal/semanticsearchapi/semanticsearchapi.go b/ai/internal/semanticsearchapi/semanticsearchapi.go index 7d8db564..e9e41968 100644 --- a/ai/internal/semanticsearchapi/semanticsearchapi.go +++ b/ai/internal/semanticsearchapi/semanticsearchapi.go @@ -1,7 +1,8 @@ // Package semanticsearchapi ist die HTTP-Bindung der semantischen Suche // (SEM-03). Aufrufer ist das anfragende Modul (DMS/Mail), nicht der // Endnutzer direkt — Service-Token-Pflicht wie moduleadapter (Workflow -// ENG-02), fail closed. +// ENG-02), fail closed. Zusaetzlich (Issue #369) Rechtepruefung +// ai.search.read gegen Core fuer den Mandanten aus dem Anfrage-Koerper. package semanticsearchapi import ( @@ -10,18 +11,30 @@ import ( "net/http" "gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch" + "gitea.perlbach24.de/scripte/nexarch/common/policyclient" "gitea.perlbach24.de/scripte/nexarch/common/svcauth" ) +const permissionSearchRead = "ai.search.read" + type Server struct { svc *semanticsearch.Service serviceToken string + policy *policyclient.Client } func NewServer(svc *semanticsearch.Service, serviceToken string) *Server { return &Server{svc: svc, serviceToken: serviceToken} } +// WithPolicy aktiviert die Rechtepruefung gegen Core (POST /authorize). +// Rolle ist fest "user", weil der Modul-zu-Modul-Aufruf keine Nutzerrolle +// transportiert (Uebergang, gleiches Muster wie connect/internal/statusapi). +func (s *Server) WithPolicy(client *policyclient.Client) *Server { + s.policy = client + return s +} + func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /search", s.requireAuth(s.handleSearch)) } @@ -37,6 +50,22 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { } } +func (s *Server) permitted(w http.ResponseWriter, r *http.Request, tenantSlug, permission string) bool { + if s.policy == nil { + return true + } + allowed, err := s.policy.Allowed(r.Context(), tenantSlug, "user", permission) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "berechtigungsdienst nicht erreichbar") + return false + } + if !allowed { + writeError(w, http.StatusForbidden, "keine berechtigung") + return false + } + return true +} + type refRequest struct { SourceType string `json:"source_type"` SourceID string `json:"source_id"` @@ -59,6 +88,9 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper") return } + if !s.permitted(w, r, req.TenantSlug, permissionSearchRead) { + return + } refs := make([]semanticsearch.Ref, 0, len(req.AllowedRefs)) for _, ref := range req.AllowedRefs { diff --git a/ai/internal/semanticsearchapi/semanticsearchapi_policy_test.go b/ai/internal/semanticsearchapi/semanticsearchapi_policy_test.go new file mode 100644 index 00000000..98e72d47 --- /dev/null +++ b/ai/internal/semanticsearchapi/semanticsearchapi_policy_test.go @@ -0,0 +1,100 @@ +package semanticsearchapi_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/common/policyclient" +) + +// Steht fuer Core's POST /authorize-Vertrag ({"allowed": bool}); prueft +// die echte Kette requireAuth -> permitted -> policyclient -> 403/200. +func newFakePolicyAPI(t *testing.T, allow bool, gotBody *map[string]string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/authorize" || r.Header.Get("X-Service-Token") == "" { + t.Fatalf("unerwartete anfrage: %s %s", r.Method, r.URL.Path) + } + if gotBody != nil { + _ = json.NewDecoder(r.Body).Decode(gotBody) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]bool{"allowed": allow}) + })) +} + +func searchRequest(t *testing.T) *http.Request { + t.Helper() + body, _ := json.Marshal(map[string]any{ + "tenant_slug": "acme", + "query": "test", + "top_k": 5, + "allowed_refs": []map[string]string{{"source_type": "dms_document", "source_id": "doc-1"}}, + }) + req := httptest.NewRequest(http.MethodPost, "/search", bytes.NewReader(body)) + req.Header.Set("X-Service-Token", "geheimes-token") + return req +} + +func TestHandleSearch_PolicyDenies_Rejected(t *testing.T) { + var got map[string]string + policyAPI := newFakePolicyAPI(t, false, &got) + defer policyAPI.Close() + mux := http.NewServeMux() + newTestServer().WithPolicy(policyclient.New(policyAPI.URL, "test-token")).RegisterRoutes(mux) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, searchRequest(t)) + if rec.Code != http.StatusForbidden { + t.Fatalf("erwartet 403 ohne berechtigung, bekam %d: %s", rec.Code, rec.Body.String()) + } + if got["permission"] != "ai.search.read" || got["tenant"] != "acme" { + t.Fatalf("falsche policy-anfrage: %+v", got) + } +} + +func TestHandleSearch_PolicyAllows_Passes(t *testing.T) { + policyAPI := newFakePolicyAPI(t, true, nil) + defer policyAPI.Close() + mux := http.NewServeMux() + newTestServer().WithPolicy(policyclient.New(policyAPI.URL, "test-token")).RegisterRoutes(mux) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, searchRequest(t)) + if rec.Code != http.StatusOK { + t.Fatalf("erwartet 200 mit berechtigung, bekam %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestHandleSearch_PolicyUnavailable_FailsClosed(t *testing.T) { + policyAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer policyAPI.Close() + mux := http.NewServeMux() + newTestServer().WithPolicy(policyclient.New(policyAPI.URL, "test-token")).RegisterRoutes(mux) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, searchRequest(t)) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("erwartet 503 bei ausfall des berechtigungsdienstes, bekam %d", rec.Code) + } +} + +func TestHandleSearch_PolicyCheckedOnlyAfterServiceToken(t *testing.T) { + policyAPI := newFakePolicyAPI(t, true, nil) + defer policyAPI.Close() + mux := http.NewServeMux() + newTestServer().WithPolicy(policyclient.New(policyAPI.URL, "test-token")).RegisterRoutes(mux) + + req := searchRequest(t) + req.Header.Set("X-Service-Token", "falsch") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("erwartet 401 bei falschem service-token, bekam %d", rec.Code) + } +} diff --git a/connect/cmd/connect-app/main.go b/connect/cmd/connect-app/main.go index a22b1ba0..4d1b856d 100644 --- a/connect/cmd/connect-app/main.go +++ b/connect/cmd/connect-app/main.go @@ -15,6 +15,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "gitea.perlbach24.de/scripte/nexarch/common/policyclient" "gitea.perlbach24.de/scripte/nexarch/connect/internal/connectorsetupapi" "gitea.perlbach24.de/scripte/nexarch/connect/internal/credential" "gitea.perlbach24.de/scripte/nexarch/connect/internal/crypto" @@ -35,6 +36,21 @@ func (f fixedKEK) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, err return f.kek, nil } +// buildPolicyClient liefert nil, solange NEXARCH_CONNECT_APP_POLICY_URL fehlt +// (Uebergang; Rechtepruefung dann deaktiviert, Warnung im Log). +func buildPolicyClient() *policyclient.Client { + policyURL := os.Getenv("NEXARCH_CONNECT_APP_POLICY_URL") + if policyURL == "" { + log.Print("connect-app: WARNUNG: NEXARCH_CONNECT_APP_POLICY_URL nicht gesetzt, Rechtepruefung connect.status.read/connect.setup.* (Issue #369) deaktiviert") + return nil + } + token := os.Getenv("NEXARCH_CONNECT_APP_POLICY_SERVICE_TOKEN") + if token == "" { + log.Fatal("connect-app: NEXARCH_CONNECT_APP_POLICY_SERVICE_TOKEN ist erforderlich, wenn NEXARCH_CONNECT_APP_POLICY_URL gesetzt ist") + } + return policyclient.New(policyURL, token) +} + func main() { if !killswitch.Enabled() { log.Printf("connect-app: deaktiviert (%s=false), kein Start", killswitch.EnvEnabled) @@ -68,11 +84,13 @@ func main() { mappings := fieldmapping.NewStore(pool) credentials := credential.NewStore(pool, crypto.NewService(fixedKEK{kek: kek})) + policy := buildPolicyClient() + mux := http.NewServeMux() mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) - statusapi.NewServer(syncjob.NewStore(pool)).RegisterRoutes(mux) + statusapi.NewServer(syncjob.NewStore(pool)).WithPolicy(policy).RegisterRoutes(mux) dms2importapi.NewServer(mappings, dms2import.NewStore(pool)).RegisterRoutes(mux) - connectorsetupapi.NewServer(credentials, mappings).RegisterRoutes(mux) + connectorsetupapi.NewServer(credentials, mappings).WithPolicy(policy).RegisterRoutes(mux) srv := &http.Server{ Addr: addr, diff --git a/connect/internal/connectorsetupapi/connectorsetupapi.go b/connect/internal/connectorsetupapi/connectorsetupapi.go index d3bcbf1c..e3463cf2 100644 --- a/connect/internal/connectorsetupapi/connectorsetupapi.go +++ b/connect/internal/connectorsetupapi/connectorsetupapi.go @@ -2,7 +2,8 @@ // setup UI: pick a connector type, store its credentials, edit its field // mapping — entirely through this API, never a config file. Identity comes // from X-User-Id/X-Tenant-Slug, the same transitional pattern -// connect/internal/statusapi already uses. +// connect/internal/statusapi already uses. Issue #369: every route also +// checks connect.setup.read/write against Core (requirePermission). package connectorsetupapi import ( @@ -11,26 +12,60 @@ import ( "net/http" "time" + "gitea.perlbach24.de/scripte/nexarch/common/policyclient" "gitea.perlbach24.de/scripte/nexarch/connect/internal/connectorcatalog" "gitea.perlbach24.de/scripte/nexarch/connect/internal/credential" "gitea.perlbach24.de/scripte/nexarch/connect/internal/fieldmapping" ) +const ( + permissionSetupRead = "connect.setup.read" + permissionSetupWrite = "connect.setup.write" +) + type Server struct { credentials *credential.Store mappings *fieldmapping.Store + policy *policyclient.Client } func NewServer(credentials *credential.Store, mappings *fieldmapping.Store) *Server { return &Server{credentials: credentials, mappings: mappings} } +// WithPolicy aktiviert die Rechtepruefung gegen Core (POST /authorize). +// Rolle ist mangels Rollenkonzept in Connect fest "user" (Uebergang, wie +// statusapi). +func (s *Server) WithPolicy(client *policyclient.Client) *Server { + s.policy = client + return s +} + +type handler func(w http.ResponseWriter, r *http.Request, tenantSlug, userID string) + func (s *Server) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("GET /connectors/catalog", s.requireIdentity(s.handleCatalog)) - mux.HandleFunc("GET /connectors/connections", s.requireIdentity(s.handleListConnections)) - mux.HandleFunc("PUT /connectors/connections/{type}/{instance}", s.requireIdentity(s.handlePutConnection)) - mux.HandleFunc("GET /connectors/connections/{type}/{instance}/mapping", s.requireIdentity(s.handleGetMapping)) - mux.HandleFunc("PUT /connectors/connections/{type}/{instance}/mapping", s.requireIdentity(s.handlePutMapping)) + mux.HandleFunc("GET /connectors/catalog", s.requireIdentity(s.requirePermission(permissionSetupRead, s.handleCatalog))) + mux.HandleFunc("GET /connectors/connections", s.requireIdentity(s.requirePermission(permissionSetupRead, s.handleListConnections))) + mux.HandleFunc("PUT /connectors/connections/{type}/{instance}", s.requireIdentity(s.requirePermission(permissionSetupWrite, s.handlePutConnection))) + mux.HandleFunc("GET /connectors/connections/{type}/{instance}/mapping", s.requireIdentity(s.requirePermission(permissionSetupRead, s.handleGetMapping))) + mux.HandleFunc("PUT /connectors/connections/{type}/{instance}/mapping", s.requireIdentity(s.requirePermission(permissionSetupWrite, s.handlePutMapping))) +} + +func (s *Server) requirePermission(permission string, next handler) handler { + return func(w http.ResponseWriter, r *http.Request, tenantSlug, userID string) { + if s.policy != nil { + allowed, err := s.policy.Allowed(r.Context(), tenantSlug, "user", permission) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "berechtigungsdienst nicht erreichbar") + return + } + if !allowed { + writeError(w, http.StatusForbidden, "keine berechtigung") + return + } + } + next(w, r, tenantSlug, userID) + } } func (s *Server) requireIdentity(next func(w http.ResponseWriter, r *http.Request, tenantSlug, userID string)) http.HandlerFunc { diff --git a/connect/internal/connectorsetupapi/connectorsetupapi_policy_test.go b/connect/internal/connectorsetupapi/connectorsetupapi_policy_test.go new file mode 100644 index 00000000..47dd2fda --- /dev/null +++ b/connect/internal/connectorsetupapi/connectorsetupapi_policy_test.go @@ -0,0 +1,123 @@ +package connectorsetupapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/common/policyclient" +) + +// Steht fuer Core's POST /authorize-Vertrag ({"allowed": bool}); prueft die +// echte Kette requireIdentity -> requirePermission -> policyclient -> +// 403/next. Ohne Datenbank: der Katalog braucht keinen Store, und die +// Ablehnung muss vor jedem Store-Zugriff greifen (Stores bewusst nil). +func newFakePolicyAPI(t *testing.T, allow bool, seen *[]map[string]string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/authorize" || r.Header.Get("X-Service-Token") == "" { + t.Fatalf("unerwartete anfrage: %s %s", r.Method, r.URL.Path) + } + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if seen != nil { + *seen = append(*seen, body) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]bool{"allowed": allow}) + })) +} + +func policyMux(policyURL string) *http.ServeMux { + mux := http.NewServeMux() + NewServer(nil, nil).WithPolicy(policyclient.New(policyURL, "test-token")).RegisterRoutes(mux) + return mux +} + +func TestCatalog_PolicyDenies_Rejected(t *testing.T) { + var seen []map[string]string + policyAPI := newFakePolicyAPI(t, false, &seen) + defer policyAPI.Close() + mux := policyMux(policyAPI.URL) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, authedRequest("GET", "/connectors/catalog", nil)) + if w.Code != http.StatusForbidden { + t.Fatalf("erwartet 403 ohne berechtigung, bekam %d: %s", w.Code, w.Body.String()) + } + if len(seen) != 1 || seen[0]["permission"] != "connect.setup.read" || seen[0]["tenant"] != "acme" { + t.Fatalf("falsche policy-anfrage: %+v", seen) + } +} + +func TestWriteRoutes_PolicyDenies_RejectedBeforeStore(t *testing.T) { + var seen []map[string]string + policyAPI := newFakePolicyAPI(t, false, &seen) + defer policyAPI.Close() + mux := policyMux(policyAPI.URL) + + for _, url := range []string{ + "/connectors/connections/erp/acme-erp-1", + "/connectors/connections/erp/acme-erp-1/mapping", + } { + w := httptest.NewRecorder() + mux.ServeHTTP(w, authedRequest("PUT", url, map[string]any{})) + if w.Code != http.StatusForbidden { + t.Fatalf("PUT %s: erwartet 403, bekam %d: %s", url, w.Code, w.Body.String()) + } + } + for _, url := range []string{"/connectors/connections", "/connectors/connections/erp/acme-erp-1/mapping"} { + w := httptest.NewRecorder() + mux.ServeHTTP(w, authedRequest("GET", url, nil)) + if w.Code != http.StatusForbidden { + t.Fatalf("GET %s: erwartet 403, bekam %d: %s", url, w.Code, w.Body.String()) + } + } + if len(seen) != 4 || seen[0]["permission"] != "connect.setup.write" || seen[1]["permission"] != "connect.setup.write" || + seen[2]["permission"] != "connect.setup.read" || seen[3]["permission"] != "connect.setup.read" { + t.Fatalf("falsche policy-anfragen: %+v", seen) + } +} + +func TestCatalog_PolicyAllows_Passes(t *testing.T) { + policyAPI := newFakePolicyAPI(t, true, nil) + defer policyAPI.Close() + mux := policyMux(policyAPI.URL) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, authedRequest("GET", "/connectors/catalog", nil)) + if w.Code != http.StatusOK { + t.Fatalf("erwartet 200 mit berechtigung, bekam %d: %s", w.Code, w.Body.String()) + } +} + +func TestCatalog_PolicyUnavailable_FailsClosed(t *testing.T) { + policyAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer policyAPI.Close() + mux := policyMux(policyAPI.URL) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, authedRequest("GET", "/connectors/catalog", nil)) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("erwartet 503 bei ausfall des berechtigungsdienstes, bekam %d", w.Code) + } +} + +func TestCatalog_PolicyNotAskedWithoutIdentity(t *testing.T) { + var seen []map[string]string + policyAPI := newFakePolicyAPI(t, true, &seen) + defer policyAPI.Close() + mux := policyMux(policyAPI.URL) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest("GET", "/connectors/catalog", nil)) + if w.Code != http.StatusUnauthorized { + t.Fatalf("erwartet 401 ohne identitaet, bekam %d", w.Code) + } + if len(seen) != 0 { + t.Fatalf("policy darf ohne identitaet nicht befragt werden: %+v", seen) + } +} diff --git a/docs/BETRIEB-PORTAL.md b/docs/BETRIEB-PORTAL.md index 8baeffdd..761a5d98 100644 --- a/docs/BETRIEB-PORTAL.md +++ b/docs/BETRIEB-PORTAL.md @@ -14,6 +14,12 @@ - Offen: Port 3113 (`kennzahlen_dashboard`) hat keinen Dienst. - archivdms-Daten (Dienste `archivdms*`, `/var/archivdms`, `/opt/archivdms`) nicht anfassen. +## Rechtepruefung in ai-app, workflow-app, connect-app (Issue #369) +- Migration `migrations/0029_task_setup_policy_rules.up.sql` (Core-DB, vor dem Binary-Rollout) legt `workflow.task.read/write` und `connect.setup.read/write` fuer `user` und `tenant_admin` an; `ai.search.read` stammt aus 0018. +- Je Dienst zwei Variablen in der Env-Datei: `NEXARCH_AI_APP_POLICY_URL`/`NEXARCH_AI_APP_POLICY_SERVICE_TOKEN`, `NEXARCH_WORKFLOW_APP_POLICY_URL`/`NEXARCH_WORKFLOW_APP_POLICY_SERVICE_TOKEN`, `NEXARCH_CONNECT_APP_POLICY_URL`/`NEXARCH_CONNECT_APP_POLICY_SERVICE_TOKEN` (URL der policy-api, z. B. `http://127.0.0.1:8093`; Token wie `NEXARCH_POLICY_SERVICE_TOKEN` der policy-api, analog `NEXARCH_DMS_APP_POLICY_*`). +- Ohne `*_POLICY_URL` startet der Dienst mit Warnung im Log und ohne Pruefung (Uebergang). Mit URL: Core nicht erreichbar → 503, kein Recht → 403. Rolle ist in allen drei Diensten vorerst fest `user`, weil weder bearerauth noch die Connect-/AI-Aufrufer eine Rolle durchreichen. +- `taskapi` (`GET /tasks`, `PATCH /tasks/{id}/status`) ist seit #369 in `workflow-app` verdrahtet und liegt damit hinter bearerauth. + ## 204: Workflow-Demo und Bearer (Stand 2026-10-28) - Backend: `workflow-app` (Unit `nexarch-portal-live-workflow-app`, `127.0.0.1:9600`) mit eigenem Postgres-Cluster `/opt/nexarch-portal-live/pg-workflow` (Port 55433, DB `workflow_demo`, Login-Rolle `wf_demo` in Gruppe `nexarch_app`, Verlaufstabelle ohne UPDATE/DELETE). Migrationen: alle `workflow/migrations/tenant/*.up.sql` in Reihenfolge. Konfiguration in `env/workflow.env` (Rechte 600, Geheimnisse zufällig): Mandant `demo`, `NEXARCH_WORKFLOW_APP_JWKS_URL=http://127.0.0.1:8199/auth/module-jwks`, Zielmodul `workflow`, kein `ALLOW_HEADER_AUTH`. - Core: `NEXARCH_ACCOUNT_MODULE_AUDIENCES=dms,workflow,portal`. Bei mehr als einer Audience lehnt der Core Token-Anfragen ohne `?module=` ab; alle Frontends mit Sitzung setzen deshalb `NEXT_PUBLIC_TOKEN_MODULE` (Standard `dms`). diff --git a/migrations/0029_task_setup_policy_rules.down.sql b/migrations/0029_task_setup_policy_rules.down.sql new file mode 100644 index 00000000..abac3fa2 --- /dev/null +++ b/migrations/0029_task_setup_policy_rules.down.sql @@ -0,0 +1 @@ +DELETE FROM policy_rules WHERE granted_by = 'migration:0029_task_setup_policy_rules'; diff --git a/migrations/0029_task_setup_policy_rules.up.sql b/migrations/0029_task_setup_policy_rules.up.sql new file mode 100644 index 00000000..e3e54813 --- /dev/null +++ b/migrations/0029_task_setup_policy_rules.up.sql @@ -0,0 +1,26 @@ +-- Issue #369: workflow/internal/taskapi und connect/internal/connectorsetupapi +-- hatten keine Rechte-Schluessel; Muster wie 0018_module_policy_rules. +-- Beide Rollen, weil die Aufrufer (bearerauth/X-User-Id) noch keine Rolle +-- liefern und die Module mit fester Rolle 'user' pruefen (siehe statusapi). +WITH grants(role, permission) AS ( + VALUES + ('user', 'workflow.task.read'), + ('user', 'workflow.task.write'), + ('tenant_admin', 'workflow.task.read'), + ('tenant_admin', 'workflow.task.write'), + + ('user', 'connect.setup.read'), + ('user', 'connect.setup.write'), + ('tenant_admin', 'connect.setup.read'), + ('tenant_admin', 'connect.setup.write') +), +ins AS ( + INSERT INTO policy_rules (role, permission, granted_by) + SELECT role, permission, 'migration:0029_task_setup_policy_rules' FROM grants + ON CONFLICT (role, permission) DO NOTHING + RETURNING role, permission +) +INSERT INTO policy_rule_changes (role, permission, action, actor, version) +SELECT i.role, i.permission, 'grant', 'migration:0029_task_setup_policy_rules', + COALESCE((SELECT MAX(c.version) FROM policy_rule_changes c WHERE c.role = i.role AND c.permission = i.permission), 0) + 1 +FROM ins i; diff --git a/web/portal/app/moduleIcons.tsx b/web/portal/app/moduleIcons.tsx index 96947b10..84d8ef25 100644 --- a/web/portal/app/moduleIcons.tsx +++ b/web/portal/app/moduleIcons.tsx @@ -17,6 +17,9 @@ export const MODULE_ICONS: Record = { routing_rules: svg(<>), ai: svg(<>), ai_extraction: svg(<>), + ai_search_chat: svg(<>), + workflow_tasks: svg(<>), + connect_setup: svg(<>), connect: svg(<>), connect_datev: svg(<>), tenant_admin: svg(<>), diff --git a/web/portal/lib/modules.test.ts b/web/portal/lib/modules.test.ts index 673cc8d4..cdfaeb25 100644 --- a/web/portal/lib/modules.test.ts +++ b/web/portal/lib/modules.test.ts @@ -3,7 +3,8 @@ import { loadModuleRegistry } from "./modules"; // Echte, in Core's policy_rules real existierende Rechte-Schluessel // (DMS: 0011/0015/0016_dms*_policy_rules.up.sql, restliche Module: -// 0018_module_policy_rules.up.sql, Kennzahlen: 0019_kennzahlen_policy_rule.up.sql). +// 0018_module_policy_rules.up.sql, Kennzahlen: 0019_kennzahlen_policy_rule.up.sql, +// Aufgaben/Connect-Setup: 0029_task_setup_policy_rules.up.sql). // Kein erfundener Schluessel darf // hier je wieder durchrutschen (siehe SHL-02-Portal-Vorfall). const KNOWN_REAL_PERMISSIONS = new Set([ @@ -44,12 +45,16 @@ const KNOWN_REAL_PERMISSIONS = new Set([ "workflow.case.read", "workflow.routingrule.read", "workflow.routingrule.write", + "workflow.task.read", + "workflow.task.write", "ai.summary.read", "ai.search.read", "ai.extraction.read", "connect.status.read", "connect.datev.read", "connect.datev.write", + "connect.setup.read", + "connect.setup.write", "core.tenant.read", "core.tenant.admin", "kennzahlen.read", diff --git a/web/shl/components/ModuleNavShell.tsx b/web/shl/components/ModuleNavShell.tsx index f4c6ce25..4a35deb8 100644 --- a/web/shl/components/ModuleNavShell.tsx +++ b/web/shl/components/ModuleNavShell.tsx @@ -61,6 +61,18 @@ export const MODULE_REGISTRY: ModuleRegistryEntry[] = [ key: "ai_extraction", group: "work", label: "Extraktions-Korrektur", href: "http://localhost:3110", description: "Von der KI extrahierte Werte prüfen und korrigieren.", permission: "ai.extraction.read", }, + { + key: "ai_search_chat", group: "work", label: "Suche & Chat", href: "http://localhost:3115", + description: "Semantische Suche und Dokumenten-Chat mit Quellenangaben.", permission: "ai.search.read", + }, + { + key: "workflow_tasks", group: "work", label: "Aufgabenboard", href: "http://localhost:3116", + description: "Aufgaben als Liste und Kanban bearbeiten.", permission: "workflow.task.read", + }, + { + key: "connect_setup", group: "admin", label: "Anbindungen einrichten", href: "http://localhost:3117", + description: "Connectoren anlegen, Zugangsdaten und Feld-Mapping pflegen.", permission: "connect.setup.read", + }, { key: "connect", group: "admin", label: "Externe Anbindungen", href: "http://localhost:3105", description: "Status externer Systemanbindungen (ERP, DATEV, CRM).", permission: "connect.status.read", diff --git a/workflow/cmd/workflow-app/main.go b/workflow/cmd/workflow-app/main.go index 894c83e7..45a1cfcb 100644 --- a/workflow/cmd/workflow-app/main.go +++ b/workflow/cmd/workflow-app/main.go @@ -17,6 +17,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/common/moduleauth" + "gitea.perlbach24.de/scripte/nexarch/common/policyclient" "gitea.perlbach24.de/scripte/nexarch/common/tenantregistryclient" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/approval" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/approvalapi" @@ -36,6 +37,7 @@ import ( "gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingruleapi" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/routingsuggestclient" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/task" + "gitea.perlbach24.de/scripte/nexarch/workflow/internal/taskapi" ) func requireEnv(name string) string { @@ -68,7 +70,7 @@ func main() { verifier, allowHeaderAuth := buildIdentity(tenantSlug) taskStore := task.NewStore(pool) - mux, err := buildMux(pool, taskStore, serviceToken, tenantSlug, tenantRegistryURL, tenantRegistryToken) + mux, err := buildMux(pool, taskStore, serviceToken, tenantSlug, tenantRegistryURL, tenantRegistryToken, buildPolicyClient()) if err != nil { log.Fatalf("workflow-app: %v", err) } @@ -133,7 +135,7 @@ func buildIdentity(tenantSlug string) (*moduleauth.Verifier, bool) { // gemeinsamen ServeMux (Akzeptanzkriterium 1) -- ausgelagert aus main(), // damit ein Test denselben Aufbau real gegen eine Datenbank pruefen kann, // ohne main() zu duplizieren. -func buildMux(pool *pgxpool.Pool, taskStore *task.Store, serviceToken, tenantSlug, tenantRegistryURL, tenantRegistryToken string) (*http.ServeMux, error) { +func buildMux(pool *pgxpool.Pool, taskStore *task.Store, serviceToken, tenantSlug, tenantRegistryURL, tenantRegistryToken string, policy *policyclient.Client) (*http.ServeMux, error) { engine, err := caseobj.NewEngineWithCase(pool, approval.ProcessDefinition) if err != nil { return nil, fmt.Errorf("prozess-engine aufbauen: %w", err) @@ -154,9 +156,21 @@ func buildMux(pool *pgxpool.Pool, taskStore *task.Store, serviceToken, tenantSlu backchannelapi.NewServer(backchannelSvc).RegisterRoutes(mux) routingruleapi.NewServer(routingruleStore, buildRoutingSuggester()).RegisterRoutes(mux) moduleadapter.NewServer(pool, serviceToken, tenantSlug, tenantVerifier).RegisterRoutes(mux) + taskapi.NewServer(taskStore, tenantSlug).WithPolicy(policy).RegisterRoutes(mux) return mux, nil } +// buildPolicyClient liefert nil, solange NEXARCH_WORKFLOW_APP_POLICY_URL fehlt +// (Uebergang; Rechtepruefung dann deaktiviert, Warnung im Log). +func buildPolicyClient() *policyclient.Client { + policyURL := os.Getenv("NEXARCH_WORKFLOW_APP_POLICY_URL") + if policyURL == "" { + log.Print("workflow-app: WARNUNG: NEXARCH_WORKFLOW_APP_POLICY_URL nicht gesetzt, Rechtepruefung workflow.task.read/write (Issue #369) deaktiviert") + return nil + } + return policyclient.New(policyURL, requireEnv("NEXARCH_WORKFLOW_APP_POLICY_SERVICE_TOKEN")) +} + // buildRoutingSuggester verdrahtet RTE-05s Kopplung an AI WFS-02. // NEXARCH_WORKFLOW_APP_AI_ROUTING_SUGGEST_BASE_URL bewusst optional -- // fehlt sie, ist das AI-Modul nicht aktiviert und routingruleapi liefert diff --git a/workflow/cmd/workflow-app/main_test.go b/workflow/cmd/workflow-app/main_test.go index 11121f8e..db682650 100644 --- a/workflow/cmd/workflow-app/main_test.go +++ b/workflow/cmd/workflow-app/main_test.go @@ -64,7 +64,7 @@ func testPool(t *testing.T) *pgxpool.Pool { func TestBuildMux_AllPackagesAnswer(t *testing.T) { pool := testPool(t) taskStore := task.NewStore(pool) - mux, err := buildMux(pool, taskStore, "test-service-token", "mandant-a", "http://127.0.0.1:0", "") + mux, err := buildMux(pool, taskStore, "test-service-token", "mandant-a", "http://127.0.0.1:0", "", nil) if err != nil { t.Fatalf("buildMux: %v", err) } @@ -78,6 +78,7 @@ func TestBuildMux_AllPackagesAnswer(t *testing.T) { } if userID != "" { req.Header.Set("X-User-Id", userID) + req.Header.Set("X-Tenant-Slug", "mandant-a") } if serviceToken != "" { req.Header.Set("X-Service-Token", serviceToken) @@ -119,6 +120,11 @@ func TestBuildMux_AllPackagesAnswer(t *testing.T) { t.Errorf("routingruleapi GET /routing-rules: status = %d, want 200", resp.StatusCode) } + // taskapi (Issue #369): GET /tasks + if resp := do(http.MethodGet, "/tasks", "frank", "", nil); resp.StatusCode != http.StatusOK { + t.Errorf("taskapi GET /tasks: status = %d, want 200", resp.StatusCode) + } + // moduleadapter: GET /registrations if resp := do(http.MethodGet, "/registrations", "", "test-service-token", nil); resp.StatusCode != http.StatusOK { t.Errorf("moduleadapter GET /registrations: status = %d, want 200", resp.StatusCode) diff --git a/workflow/docs/ENG-11-PRUEFPROTOKOLL.md b/workflow/docs/ENG-11-PRUEFPROTOKOLL.md index 89b124e1..4f7ec8ca 100644 --- a/workflow/docs/ENG-11-PRUEFPROTOKOLL.md +++ b/workflow/docs/ENG-11-PRUEFPROTOKOLL.md @@ -43,6 +43,10 @@ Optional: - `NEXARCH_WORKFLOW_APP_NOTIFY_BASE_URL` / `NEXARCH_WORKFLOW_APP_NOTIFY_SERVICE_TOKEN` (TSK-05-Versand; ohne Base-URL läuft der Dienst ohne Eskalationsversand weiter) - `NEXARCH_WORKFLOW_APP_ESCALATION_INTERVAL` (Go-Duration, Default `5m`) +- `NEXARCH_WORKFLOW_APP_POLICY_URL` / `NEXARCH_WORKFLOW_APP_POLICY_SERVICE_TOKEN` + (Issue #369: Rechtepruefung `workflow.task.read`/`workflow.task.write` fuer + `taskapi` gegen Core `POST /authorize`; ohne URL startet der Dienst mit + Warnung ohne Pruefung, URL ohne Token ist ein Startfehler) Keine Zugangsdaten im Code — ausschließlich über Umgebungsvariablen (AC2). diff --git a/workflow/internal/taskapi/taskapi.go b/workflow/internal/taskapi/taskapi.go index de2bcbf3..90ffae49 100644 --- a/workflow/internal/taskapi/taskapi.go +++ b/workflow/internal/taskapi/taskapi.go @@ -4,7 +4,9 @@ // aus X-User-Id, Tenant-Kontext aus X-Tenant-Slug -- gleiches Muster wie // backchannelapi (VOR-04/VOR-06): der Tenant-Kontext des anfragenden Nutzers // muss zur eigenen Instanz passen und optional gegen Core bestaetigt werden, -// bevor Aufgaben zurueckgegeben werden (Akzeptanzkriterium 5). +// bevor Aufgaben zurueckgegeben werden (Akzeptanzkriterium 5). In +// cmd/workflow-app setzt bearerauth beide Header aus dem verifizierten +// Token. Issue #369: zusaetzlich Rechtepruefung workflow.task.read/write. package taskapi import ( @@ -13,45 +15,70 @@ import ( "log" "net/http" + "gitea.perlbach24.de/scripte/nexarch/common/policyclient" "gitea.perlbach24.de/scripte/nexarch/workflow/internal/task" ) +const ( + permissionTaskRead = "workflow.task.read" + permissionTaskWrite = "workflow.task.write" +) + type Server struct { store *task.Store ownTenantSlug string + policy *policyclient.Client } func NewServer(store *task.Store, ownTenantSlug string) *Server { return &Server{store: store, ownTenantSlug: ownTenantSlug} } -func (s *Server) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("GET /tasks", s.handleList) - mux.HandleFunc("PATCH /tasks/{id}/status", s.handleSetStatus) +// WithPolicy aktiviert die Rechtepruefung gegen Core (POST /authorize). +// Rolle ist fest "user": bearerauth reicht nur Subject und Tenant aus den +// Claims durch, keine Rolle (Uebergang, wie connect/internal/statusapi). +func (s *Server) WithPolicy(client *policyclient.Client) *Server { + s.policy = client + return s } -func (s *Server) checkAuth(w http.ResponseWriter, r *http.Request) bool { - if r.Header.Get("X-User-Id") == "" { - writeError(w, http.StatusUnauthorized, "X-User-Id fehlt") - return false +func (s *Server) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /tasks", s.requireAuth(permissionTaskRead, s.handleList)) + mux.HandleFunc("PATCH /tasks/{id}/status", s.requireAuth(permissionTaskWrite, s.handleSetStatus)) +} + +func (s *Server) requireAuth(permission string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-User-Id") == "" { + writeError(w, http.StatusUnauthorized, "X-User-Id fehlt") + return + } + tenantSlug := r.Header.Get("X-Tenant-Slug") + if tenantSlug == "" { + writeError(w, http.StatusUnauthorized, "X-Tenant-Slug fehlt") + return + } + if tenantSlug != s.ownTenantSlug { + log.Printf("taskapi: tenant-kontext abgelehnt (angefragt=%q, instanz=%q)", tenantSlug, s.ownTenantSlug) + writeError(w, http.StatusForbidden, "nicht berechtigt") + return + } + if s.policy != nil { + allowed, err := s.policy.Allowed(r.Context(), tenantSlug, "user", permission) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "berechtigungsdienst nicht erreichbar") + return + } + if !allowed { + writeError(w, http.StatusForbidden, "keine berechtigung") + return + } + } + next(w, r) } - tenantSlug := r.Header.Get("X-Tenant-Slug") - if tenantSlug == "" { - writeError(w, http.StatusUnauthorized, "X-Tenant-Slug fehlt") - return false - } - if tenantSlug != s.ownTenantSlug { - log.Printf("taskapi: tenant-kontext abgelehnt (angefragt=%q, instanz=%q)", tenantSlug, s.ownTenantSlug) - writeError(w, http.StatusForbidden, "nicht berechtigt") - return false - } - return true } func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(w, r) { - return - } tasks, err := s.store.ListAll(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "aufgaben konnten nicht gelesen werden") @@ -65,9 +92,6 @@ type setStatusRequest struct { } func (s *Server) handleSetStatus(w http.ResponseWriter, r *http.Request) { - if !s.checkAuth(w, r) { - return - } id := r.PathValue("id") var req setStatusRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/workflow/internal/taskapi/taskapi_policy_test.go b/workflow/internal/taskapi/taskapi_policy_test.go new file mode 100644 index 00000000..1f7efcfe --- /dev/null +++ b/workflow/internal/taskapi/taskapi_policy_test.go @@ -0,0 +1,121 @@ +package taskapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/common/policyclient" +) + +// Steht fuer Core's POST /authorize-Vertrag ({"allowed": bool}); prueft die +// echte Kette requireAuth -> policyclient -> 403/next, nicht nur einen +// Datenbankeintrag. Die Ablehnungsfaelle brauchen keinen Store (nil), weil +// die Pruefung vor jedem Zugriff greifen muss. +func newFakePolicyAPI(t *testing.T, allow bool, seen *[]map[string]string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/authorize" || r.Header.Get("X-Service-Token") == "" { + t.Fatalf("unerwartete anfrage: %s %s", r.Method, r.URL.Path) + } + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + if seen != nil { + *seen = append(*seen, body) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]bool{"allowed": allow}) + })) +} + +func policyMux(t *testing.T, policyURL string) *http.ServeMux { + t.Helper() + mux := http.NewServeMux() + NewServer(nil, "acme").WithPolicy(policyclient.New(policyURL, "test-token")).RegisterRoutes(mux) + return mux +} + +func authed(req *http.Request) *http.Request { + req.Header.Set("X-User-Id", "u1") + req.Header.Set("X-Tenant-Slug", "acme") + return req +} + +func TestHandleList_PolicyDenies_Rejected(t *testing.T) { + var seen []map[string]string + policyAPI := newFakePolicyAPI(t, false, &seen) + defer policyAPI.Close() + mux := policyMux(t, policyAPI.URL) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, authed(httptest.NewRequest(http.MethodGet, "/tasks", nil))) + if rec.Code != http.StatusForbidden { + t.Fatalf("erwartet 403 ohne berechtigung, bekam %d: %s", rec.Code, rec.Body.String()) + } + if len(seen) != 1 || seen[0]["permission"] != "workflow.task.read" || seen[0]["tenant"] != "acme" { + t.Fatalf("falsche policy-anfrage: %+v", seen) + } +} + +func TestHandleSetStatus_PolicyDenies_Rejected(t *testing.T) { + var seen []map[string]string + policyAPI := newFakePolicyAPI(t, false, &seen) + defer policyAPI.Close() + mux := policyMux(t, policyAPI.URL) + + req := authed(httptest.NewRequest(http.MethodPatch, "/tasks/t-1/status", strings.NewReader(`{"status":"done"}`))) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("erwartet 403 ohne berechtigung, bekam %d: %s", rec.Code, rec.Body.String()) + } + if len(seen) != 1 || seen[0]["permission"] != "workflow.task.write" { + t.Fatalf("falsche policy-anfrage: %+v", seen) + } +} + +func TestHandleList_PolicyUnavailable_FailsClosed(t *testing.T) { + policyAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer policyAPI.Close() + mux := policyMux(t, policyAPI.URL) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, authed(httptest.NewRequest(http.MethodGet, "/tasks", nil))) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("erwartet 503 bei ausfall des berechtigungsdienstes, bekam %d", rec.Code) + } +} + +func TestHandleList_PolicyNotAskedWithoutIdentity(t *testing.T) { + var seen []map[string]string + policyAPI := newFakePolicyAPI(t, true, &seen) + defer policyAPI.Close() + mux := policyMux(t, policyAPI.URL) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("erwartet 401 ohne identitaet, bekam %d", rec.Code) + } + if len(seen) != 0 { + t.Fatalf("policy darf ohne identitaet nicht befragt werden: %+v", seen) + } +} + +func TestHandleList_PolicyAllows_Passes(t *testing.T) { + srv, _ := newTestServer(t) + policyAPI := newFakePolicyAPI(t, true, nil) + defer policyAPI.Close() + mux := http.NewServeMux() + srv.WithPolicy(policyclient.New(policyAPI.URL, "test-token")).RegisterRoutes(mux) + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, authed(httptest.NewRequest(http.MethodGet, "/tasks", nil))) + if rec.Code != http.StatusOK { + t.Fatalf("erwartet 200 mit berechtigung, bekam %d: %s", rec.Code, rec.Body.String()) + } +} From 698f0490f2bcae85d250e0ede616a4ca7f2027e6 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 28 Oct 2026 23:08:57 +0100 Subject: [PATCH 2/2] RBAC #369: Rolle aus dem Token durchreichen, Policy-Pruefung fail-closed workflow bearerauth setzt X-User-Role nur aus den Claims (Client-Header werden entfernt); taskapi und connectorsetupapi fragen mit dieser Rolle, Default user. Migration 0029: connect.setup.write nur fuer tenant_admin. ai-app, workflow-app, connect-app starten ohne POLICY_URL nicht mehr, ausser mit NEXARCH__APP_POLICY_DISABLED=1. Co-Authored-By: Claude Sonnet 5.5 --- ai/cmd/ai-app/main.go | 10 +++++--- ai/cmd/ai-app/main_test.go | 20 ++++++++++++++-- connect/cmd/connect-app/main.go | 9 +++++--- .../connectorsetupapi/connectorsetupapi.go | 11 ++++++--- .../connectorsetupapi_policy_test.go | 16 +++++++++++++ docs/BETRIEB-PORTAL.md | 2 +- .../0029_task_setup_policy_rules.up.sql | 5 ++-- workflow/cmd/workflow-app/main.go | 9 +++++--- workflow/docs/ENG-11-PRUEFPROTOKOLL.md | 4 ++-- workflow/internal/bearerauth/bearerauth.go | 7 +++++- .../internal/bearerauth/bearerauth_test.go | 23 +++++++++++++++---- workflow/internal/taskapi/taskapi.go | 11 ++++++--- .../internal/taskapi/taskapi_policy_test.go | 16 +++++++++++++ 13 files changed, 115 insertions(+), 28 deletions(-) diff --git a/ai/cmd/ai-app/main.go b/ai/cmd/ai-app/main.go index 52787409..c039e182 100644 --- a/ai/cmd/ai-app/main.go +++ b/ai/cmd/ai-app/main.go @@ -62,10 +62,10 @@ type config struct { coreServiceToken string policyURL string policyServiceToken string + policyDisabled bool } -// policyClient liefert nil, solange NEXARCH_AI_APP_POLICY_URL fehlt -// (Uebergang; Pruefung dann deaktiviert, wird beim Start geloggt). +// policyClient liefert nil nur mit NEXARCH_AI_APP_POLICY_DISABLED=1. func (c config) policyClient() *policyclient.Client { if c.policyURL == "" { return nil @@ -105,6 +105,7 @@ func loadConfig(getenv func(string) string) (config, error) { coreServiceToken: getenv("NEXARCH_AI_APP_CORE_SERVICE_TOKEN"), policyURL: getenv("NEXARCH_AI_APP_POLICY_URL"), policyServiceToken: getenv("NEXARCH_AI_APP_POLICY_SERVICE_TOKEN"), + policyDisabled: getenv("NEXARCH_AI_APP_POLICY_DISABLED") == "1", } if cfg.policyURL != "" && cfg.policyServiceToken == "" { return config{}, errors.New("ai-app: NEXARCH_AI_APP_POLICY_SERVICE_TOKEN ist erforderlich, wenn NEXARCH_AI_APP_POLICY_URL gesetzt ist") @@ -115,6 +116,9 @@ func loadConfig(getenv func(string) string) (config, error) { if cfg.serviceToken == "" { return config{}, errors.New("ai-app: NEXARCH_AI_APP_SERVICE_TOKEN ist erforderlich") } + if cfg.policyURL == "" && !cfg.policyDisabled { + return config{}, errors.New("ai-app: NEXARCH_AI_APP_POLICY_URL ist erforderlich (oder NEXARCH_AI_APP_POLICY_DISABLED=1 als Uebergang)") + } if cfg.addr == "" { cfg.addr = defaultListenAddr } @@ -148,7 +152,7 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri searchSvc := semanticsearch.New(prov, vectorindex.New(pool), cfg.model) policy := cfg.policyClient() if policy == nil { - log.Print("ai-app: WARNUNG: NEXARCH_AI_APP_POLICY_URL nicht gesetzt, Rechtepruefung ai.search.read (Issue #369) deaktiviert") + log.Print("ai-app: WARNUNG: Rechtepruefung ai.search.read deaktiviert (NEXARCH_AI_APP_POLICY_DISABLED=1)") } semanticsearchapi.NewServer(searchSvc, cfg.serviceToken).WithPolicy(policy).RegisterRoutes(mux) diff --git a/ai/cmd/ai-app/main_test.go b/ai/cmd/ai-app/main_test.go index a18ca427..84b0dd0f 100644 --- a/ai/cmd/ai-app/main_test.go +++ b/ai/cmd/ai-app/main_test.go @@ -71,8 +71,9 @@ func TestLoadConfig_PolicyURLRequiresServiceToken(t *testing.T) { func TestLoadConfig_Defaults(t *testing.T) { env := map[string]string{ - "NEXARCH_AI_APP_TENANT_DSN": "postgres://x", - "NEXARCH_AI_APP_SERVICE_TOKEN": "tok", + "NEXARCH_AI_APP_TENANT_DSN": "postgres://x", + "NEXARCH_AI_APP_SERVICE_TOKEN": "tok", + "NEXARCH_AI_APP_POLICY_DISABLED": "1", } cfg, err := loadConfig(func(k string) string { return env[k] }) if err != nil { @@ -209,3 +210,18 @@ func TestBuild_QueueWorkerProcessesJobWithQuota(t *testing.T) { t.Errorf("quota-zaehlerstand = %d, want 1 (GOV-04-Verbrauchszaehlung nicht verdrahtet)", value) } } + +func TestLoadConfig_PolicyRequiredUnlessDisabled(t *testing.T) { + env := map[string]string{ + "NEXARCH_AI_APP_TENANT_DSN": "postgres://x", + "NEXARCH_AI_APP_SERVICE_TOKEN": "tok", + } + get := func(k string) string { return env[k] } + if _, err := loadConfig(get); err == nil || !strings.Contains(err.Error(), "NEXARCH_AI_APP_POLICY_URL") { + t.Fatalf("erwartete fehler zur fehlenden NEXARCH_AI_APP_POLICY_URL, bekam %v", err) + } + env["NEXARCH_AI_APP_POLICY_DISABLED"] = "1" + if _, err := loadConfig(get); err != nil { + t.Fatalf("mit POLICY_DISABLED=1 darf der Start nicht scheitern: %v", err) + } +} diff --git a/connect/cmd/connect-app/main.go b/connect/cmd/connect-app/main.go index 4d1b856d..d8e806f4 100644 --- a/connect/cmd/connect-app/main.go +++ b/connect/cmd/connect-app/main.go @@ -36,12 +36,15 @@ func (f fixedKEK) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, err return f.kek, nil } -// buildPolicyClient liefert nil, solange NEXARCH_CONNECT_APP_POLICY_URL fehlt -// (Uebergang; Rechtepruefung dann deaktiviert, Warnung im Log). +// buildPolicyClient liefert nil nur mit NEXARCH_CONNECT_APP_POLICY_DISABLED=1; +// ohne URL und ohne diesen Schalter startet der Dienst nicht. func buildPolicyClient() *policyclient.Client { policyURL := os.Getenv("NEXARCH_CONNECT_APP_POLICY_URL") if policyURL == "" { - log.Print("connect-app: WARNUNG: NEXARCH_CONNECT_APP_POLICY_URL nicht gesetzt, Rechtepruefung connect.status.read/connect.setup.* (Issue #369) deaktiviert") + if os.Getenv("NEXARCH_CONNECT_APP_POLICY_DISABLED") != "1" { + log.Fatal("connect-app: NEXARCH_CONNECT_APP_POLICY_URL fehlt (oder NEXARCH_CONNECT_APP_POLICY_DISABLED=1 als Uebergang setzen)") + } + log.Print("connect-app: WARNUNG: Rechtepruefung connect.status.read/connect.setup.* deaktiviert (NEXARCH_CONNECT_APP_POLICY_DISABLED=1)") return nil } token := os.Getenv("NEXARCH_CONNECT_APP_POLICY_SERVICE_TOKEN") diff --git a/connect/internal/connectorsetupapi/connectorsetupapi.go b/connect/internal/connectorsetupapi/connectorsetupapi.go index e3463cf2..0d4050d6 100644 --- a/connect/internal/connectorsetupapi/connectorsetupapi.go +++ b/connect/internal/connectorsetupapi/connectorsetupapi.go @@ -34,8 +34,6 @@ func NewServer(credentials *credential.Store, mappings *fieldmapping.Store) *Ser } // WithPolicy aktiviert die Rechtepruefung gegen Core (POST /authorize). -// Rolle ist mangels Rollenkonzept in Connect fest "user" (Uebergang, wie -// statusapi). func (s *Server) WithPolicy(client *policyclient.Client) *Server { s.policy = client return s @@ -54,7 +52,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) { func (s *Server) requirePermission(permission string, next handler) handler { return func(w http.ResponseWriter, r *http.Request, tenantSlug, userID string) { if s.policy != nil { - allowed, err := s.policy.Allowed(r.Context(), tenantSlug, "user", permission) + allowed, err := s.policy.Allowed(r.Context(), tenantSlug, roleOf(r), permission) if err != nil { writeError(w, http.StatusServiceUnavailable, "berechtigungsdienst nicht erreichbar") return @@ -253,3 +251,10 @@ func writeJSON(w http.ResponseWriter, status int, v any) { func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } + +func roleOf(r *http.Request) string { + if role := r.Header.Get("X-User-Role"); role != "" { + return role + } + return "user" +} diff --git a/connect/internal/connectorsetupapi/connectorsetupapi_policy_test.go b/connect/internal/connectorsetupapi/connectorsetupapi_policy_test.go index 47dd2fda..afe087cf 100644 --- a/connect/internal/connectorsetupapi/connectorsetupapi_policy_test.go +++ b/connect/internal/connectorsetupapi/connectorsetupapi_policy_test.go @@ -121,3 +121,19 @@ func TestCatalog_PolicyNotAskedWithoutIdentity(t *testing.T) { t.Fatalf("policy darf ohne identitaet nicht befragt werden: %+v", seen) } } + +func TestPolicyAsked_WithRoleFromHeader_DefaultUser(t *testing.T) { + var seen []map[string]string + policyAPI := newFakePolicyAPI(t, false, &seen) + defer policyAPI.Close() + mux := policyMux(policyAPI.URL) + + mux.ServeHTTP(httptest.NewRecorder(), authedRequest("GET", "/connectors/catalog", nil)) + admin := authedRequest("GET", "/connectors/catalog", nil) + admin.Header.Set("X-User-Role", "tenant_admin") + mux.ServeHTTP(httptest.NewRecorder(), admin) + + if len(seen) != 2 || seen[0]["role"] != "user" || seen[1]["role"] != "tenant_admin" { + t.Fatalf("falsche rollen in policy-anfragen: %+v", seen) + } +} diff --git a/docs/BETRIEB-PORTAL.md b/docs/BETRIEB-PORTAL.md index 761a5d98..30f03527 100644 --- a/docs/BETRIEB-PORTAL.md +++ b/docs/BETRIEB-PORTAL.md @@ -17,7 +17,7 @@ ## Rechtepruefung in ai-app, workflow-app, connect-app (Issue #369) - Migration `migrations/0029_task_setup_policy_rules.up.sql` (Core-DB, vor dem Binary-Rollout) legt `workflow.task.read/write` und `connect.setup.read/write` fuer `user` und `tenant_admin` an; `ai.search.read` stammt aus 0018. - Je Dienst zwei Variablen in der Env-Datei: `NEXARCH_AI_APP_POLICY_URL`/`NEXARCH_AI_APP_POLICY_SERVICE_TOKEN`, `NEXARCH_WORKFLOW_APP_POLICY_URL`/`NEXARCH_WORKFLOW_APP_POLICY_SERVICE_TOKEN`, `NEXARCH_CONNECT_APP_POLICY_URL`/`NEXARCH_CONNECT_APP_POLICY_SERVICE_TOKEN` (URL der policy-api, z. B. `http://127.0.0.1:8093`; Token wie `NEXARCH_POLICY_SERVICE_TOKEN` der policy-api, analog `NEXARCH_DMS_APP_POLICY_*`). -- Ohne `*_POLICY_URL` startet der Dienst mit Warnung im Log und ohne Pruefung (Uebergang). Mit URL: Core nicht erreichbar → 503, kein Recht → 403. Rolle ist in allen drei Diensten vorerst fest `user`, weil weder bearerauth noch die Connect-/AI-Aufrufer eine Rolle durchreichen. +- Ohne `*_POLICY_URL` startet der Dienst nicht (fail-closed). Uebergang: `NEXARCH_{AI,WORKFLOW,CONNECT}_APP_POLICY_DISABLED=1` (Warnung im Log, keine Pruefung). Mit URL: Core nicht erreichbar → 503, kein Recht → 403. Rolle: workflow-app und connect-app reichen die Rolle aus dem Token als `X-User-Role` durch (nur aus dem Token, Client-Header werden entfernt); ohne Rolle gilt `user`. `connect.setup.write` hat nur `tenant_admin`. ai-app wird von Modulen mit Service-Token gerufen und fragt mit Rolle `user`. - `taskapi` (`GET /tasks`, `PATCH /tasks/{id}/status`) ist seit #369 in `workflow-app` verdrahtet und liegt damit hinter bearerauth. ## 204: Workflow-Demo und Bearer (Stand 2026-10-28) diff --git a/migrations/0029_task_setup_policy_rules.up.sql b/migrations/0029_task_setup_policy_rules.up.sql index e3e54813..b8fd9230 100644 --- a/migrations/0029_task_setup_policy_rules.up.sql +++ b/migrations/0029_task_setup_policy_rules.up.sql @@ -1,7 +1,7 @@ -- Issue #369: workflow/internal/taskapi und connect/internal/connectorsetupapi -- hatten keine Rechte-Schluessel; Muster wie 0018_module_policy_rules. --- Beide Rollen, weil die Aufrufer (bearerauth/X-User-Id) noch keine Rolle --- liefern und die Module mit fester Rolle 'user' pruefen (siehe statusapi). +-- connect.setup.write (Zugangsdaten, Mapping) nur fuer tenant_admin; die Rolle +-- kommt aus dem Token (X-User-Role), ohne Rolle gilt 'user'. WITH grants(role, permission) AS ( VALUES ('user', 'workflow.task.read'), @@ -10,7 +10,6 @@ WITH grants(role, permission) AS ( ('tenant_admin', 'workflow.task.write'), ('user', 'connect.setup.read'), - ('user', 'connect.setup.write'), ('tenant_admin', 'connect.setup.read'), ('tenant_admin', 'connect.setup.write') ), diff --git a/workflow/cmd/workflow-app/main.go b/workflow/cmd/workflow-app/main.go index 45a1cfcb..e241dc21 100644 --- a/workflow/cmd/workflow-app/main.go +++ b/workflow/cmd/workflow-app/main.go @@ -160,12 +160,15 @@ func buildMux(pool *pgxpool.Pool, taskStore *task.Store, serviceToken, tenantSlu return mux, nil } -// buildPolicyClient liefert nil, solange NEXARCH_WORKFLOW_APP_POLICY_URL fehlt -// (Uebergang; Rechtepruefung dann deaktiviert, Warnung im Log). +// buildPolicyClient liefert nil nur mit NEXARCH_WORKFLOW_APP_POLICY_DISABLED=1; +// ohne URL und ohne diesen Schalter startet der Dienst nicht. func buildPolicyClient() *policyclient.Client { policyURL := os.Getenv("NEXARCH_WORKFLOW_APP_POLICY_URL") if policyURL == "" { - log.Print("workflow-app: WARNUNG: NEXARCH_WORKFLOW_APP_POLICY_URL nicht gesetzt, Rechtepruefung workflow.task.read/write (Issue #369) deaktiviert") + if os.Getenv("NEXARCH_WORKFLOW_APP_POLICY_DISABLED") != "1" { + log.Fatal("workflow-app: NEXARCH_WORKFLOW_APP_POLICY_URL fehlt (oder NEXARCH_WORKFLOW_APP_POLICY_DISABLED=1 als Uebergang setzen)") + } + log.Print("workflow-app: WARNUNG: Rechtepruefung workflow.task.read/write deaktiviert (NEXARCH_WORKFLOW_APP_POLICY_DISABLED=1)") return nil } return policyclient.New(policyURL, requireEnv("NEXARCH_WORKFLOW_APP_POLICY_SERVICE_TOKEN")) diff --git a/workflow/docs/ENG-11-PRUEFPROTOKOLL.md b/workflow/docs/ENG-11-PRUEFPROTOKOLL.md index 4f7ec8ca..9a5875bd 100644 --- a/workflow/docs/ENG-11-PRUEFPROTOKOLL.md +++ b/workflow/docs/ENG-11-PRUEFPROTOKOLL.md @@ -45,8 +45,8 @@ Optional: - `NEXARCH_WORKFLOW_APP_ESCALATION_INTERVAL` (Go-Duration, Default `5m`) - `NEXARCH_WORKFLOW_APP_POLICY_URL` / `NEXARCH_WORKFLOW_APP_POLICY_SERVICE_TOKEN` (Issue #369: Rechtepruefung `workflow.task.read`/`workflow.task.write` fuer - `taskapi` gegen Core `POST /authorize`; ohne URL startet der Dienst mit - Warnung ohne Pruefung, URL ohne Token ist ein Startfehler) + `taskapi` gegen Core `POST /authorize`; ohne URL bricht der Dienst + mit Startfehler ab, ausser `NEXARCH_WORKFLOW_APP_POLICY_DISABLED=1`; URL ohne Token ist ein Startfehler; Rolle aus dem Token als `X-User-Role`) Keine Zugangsdaten im Code — ausschließlich über Umgebungsvariablen (AC2). diff --git a/workflow/internal/bearerauth/bearerauth.go b/workflow/internal/bearerauth/bearerauth.go index e5dd429b..efef3e4b 100644 --- a/workflow/internal/bearerauth/bearerauth.go +++ b/workflow/internal/bearerauth/bearerauth.go @@ -1,7 +1,7 @@ // Package bearerauth setzt die Identitaet der Workflow-APIs aus einem // verifizierten Core-Modul-Token. Die Handler lesen weiter X-User-Id und // X-Tenant-Slug; die Middleware ueberschreibt beide aus den Claims, sodass ein -// Client sie nicht frei setzen kann. +// Client sie nicht frei setzen kann. X-User-Role kommt nur aus dem Token. package bearerauth import ( @@ -16,6 +16,7 @@ import ( const ( headerUser = "X-User-Id" headerTenant = "X-Tenant-Slug" + headerRole = "X-User-Role" ) // Middleware verlangt fuer Anfragen mit Authorization: Bearer ein gueltiges @@ -23,6 +24,7 @@ const ( // allowHeaderAuth ist gesetzt (Uebergang, bis alle Clients Bearer senden). func Middleware(v *moduleauth.Verifier, allowHeaderAuth bool, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Header.Del(headerRole) token, ok := bearer(r) if !ok { if !allowHeaderAuth { @@ -47,6 +49,9 @@ func Middleware(v *moduleauth.Verifier, allowHeaderAuth bool, next http.Handler) } r.Header.Set(headerUser, claims.Subject) r.Header.Set(headerTenant, claims.Tenant) + if claims.Role != "" { + r.Header.Set(headerRole, claims.Role) + } next.ServeHTTP(w, r) }) } diff --git a/workflow/internal/bearerauth/bearerauth_test.go b/workflow/internal/bearerauth/bearerauth_test.go index 1df4724d..685f2fbe 100644 --- a/workflow/internal/bearerauth/bearerauth_test.go +++ b/workflow/internal/bearerauth/bearerauth_test.go @@ -10,7 +10,7 @@ import ( func echo() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(r.Header.Get("X-User-Id") + "|" + r.Header.Get("X-Tenant-Slug"))) + _, _ = w.Write([]byte(r.Header.Get("X-User-Id") + "|" + r.Header.Get("X-Tenant-Slug") + "|" + r.Header.Get("X-User-Role"))) }) } @@ -31,7 +31,7 @@ func TestBearerOverridesClientSuppliedHeaders(t *testing.T) { "Authorization": "Bearer " + iss.Token("u1", "acme", "user", "", ""), "X-User-Id": "fremd", "X-Tenant-Slug": "fremd", }) - if rec.Code != 200 || rec.Body.String() != "u1|acme" { + if rec.Code != 200 || rec.Body.String() != "u1|acme|user" { t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String()) } } @@ -51,10 +51,25 @@ func TestInvalidBearerIs401(t *testing.T) { func TestWithoutBearerHeadersStrippedUnlessAllowed(t *testing.T) { v, _ := moduleauth.NewTestVerifierAndIssuer() hdr := map[string]string{"X-User-Id": "u9", "X-Tenant-Slug": "t9"} - if rec := do(Middleware(v, false, echo()), hdr); rec.Body.String() != "|" { + if rec := do(Middleware(v, false, echo()), hdr); rec.Body.String() != "||" { t.Fatalf("Header muessen entfernt werden, bekam %q", rec.Body.String()) } - if rec := do(Middleware(v, true, echo()), hdr); rec.Body.String() != "u9|t9" { + if rec := do(Middleware(v, true, echo()), hdr); rec.Body.String() != "u9|t9|" { t.Fatalf("Uebergangsmodus muss Header durchlassen, bekam %q", rec.Body.String()) } } + +func TestRoleOnlyFromToken(t *testing.T) { + v, iss := moduleauth.NewTestVerifierAndIssuer() + h := Middleware(v, true, echo()) + rec := do(h, map[string]string{ + "Authorization": "Bearer " + iss.Token("u1", "acme", "tenant_admin", "", ""), + "X-User-Role": "tenant_admin_fremd", + }) + if rec.Body.String() != "u1|acme|tenant_admin" { + t.Fatalf("Rolle muss aus dem Token kommen, bekam %q", rec.Body.String()) + } + if rec := do(h, map[string]string{"X-User-Role": "tenant_admin"}); rec.Body.String() != "||" { + t.Fatalf("Rolle ohne Token muss entfernt werden, bekam %q", rec.Body.String()) + } +} diff --git a/workflow/internal/taskapi/taskapi.go b/workflow/internal/taskapi/taskapi.go index 90ffae49..3f0eab38 100644 --- a/workflow/internal/taskapi/taskapi.go +++ b/workflow/internal/taskapi/taskapi.go @@ -35,8 +35,6 @@ func NewServer(store *task.Store, ownTenantSlug string) *Server { } // WithPolicy aktiviert die Rechtepruefung gegen Core (POST /authorize). -// Rolle ist fest "user": bearerauth reicht nur Subject und Tenant aus den -// Claims durch, keine Rolle (Uebergang, wie connect/internal/statusapi). func (s *Server) WithPolicy(client *policyclient.Client) *Server { s.policy = client return s @@ -64,7 +62,7 @@ func (s *Server) requireAuth(permission string, next http.HandlerFunc) http.Hand return } if s.policy != nil { - allowed, err := s.policy.Allowed(r.Context(), tenantSlug, "user", permission) + allowed, err := s.policy.Allowed(r.Context(), tenantSlug, roleOf(r), permission) if err != nil { writeError(w, http.StatusServiceUnavailable, "berechtigungsdienst nicht erreichbar") return @@ -120,3 +118,10 @@ func writeJSON(w http.ResponseWriter, status int, v any) { func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } + +func roleOf(r *http.Request) string { + if role := r.Header.Get("X-User-Role"); role != "" { + return role + } + return "user" +} diff --git a/workflow/internal/taskapi/taskapi_policy_test.go b/workflow/internal/taskapi/taskapi_policy_test.go index 1f7efcfe..d50a8661 100644 --- a/workflow/internal/taskapi/taskapi_policy_test.go +++ b/workflow/internal/taskapi/taskapi_policy_test.go @@ -119,3 +119,19 @@ func TestHandleList_PolicyAllows_Passes(t *testing.T) { t.Fatalf("erwartet 200 mit berechtigung, bekam %d: %s", rec.Code, rec.Body.String()) } } + +func TestPolicyAsked_WithRoleFromHeader_DefaultUser(t *testing.T) { + var seen []map[string]string + policyAPI := newFakePolicyAPI(t, false, &seen) + defer policyAPI.Close() + mux := policyMux(t, policyAPI.URL) + + mux.ServeHTTP(httptest.NewRecorder(), authed(httptest.NewRequest(http.MethodGet, "/tasks", nil))) + admin := authed(httptest.NewRequest(http.MethodGet, "/tasks", nil)) + admin.Header.Set("X-User-Role", "tenant_admin") + mux.ServeHTTP(httptest.NewRecorder(), admin) + + if len(seen) != 2 || seen[0]["role"] != "user" || seen[1]["role"] != "tenant_admin" { + t.Fatalf("falsche rollen in policy-anfragen: %+v", seen) + } +}