Merge main in feat/connect-bearer
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Successful in 1m0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 3s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 28s
CI / Web (dms-browser) (push) Failing after 1m27s
CI / Web (pdf-viewer) (push) Failing after 1m13s
CI / Web (shl) (push) Failing after 17s
CI / Core-Modul (Build/Vet/Test) (pull_request) Successful in 1m3s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 4s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 18s
CI / Web (dms-browser) (pull_request) Failing after 1m38s
CI / Web (pdf-viewer) (pull_request) Failing after 1m6s
CI / Web (shl) (pull_request) Failing after 11s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 1m0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 26m46s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 51s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 1m51s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 26m10s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 25m41s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5.5 committed 2026-10-28 23:30:01 +01:00
commit 1de1da433f
20 files changed
+722 -59

No files matched your search

+40 -15
View File
@@ -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
policyDisabled bool
}
// policyClient liefert nil nur mit NEXARCH_AI_APP_POLICY_DISABLED=1.
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,19 @@ 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"),
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")
}
if cfg.dsn == "" {
return config{}, errors.New("ai-app: NEXARCH_AI_APP_TENANT_DSN ist erforderlich")
@@ -98,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
}
@@ -129,7 +150,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: Rechtepruefung ai.search.read deaktiviert (NEXARCH_AI_APP_POLICY_DISABLED=1)")
}
semanticsearchapi.NewServer(searchSvc, cfg.serviceToken).WithPolicy(policy).RegisterRoutes(mux)
chunkStore := vectorindex.New(pool)
docchatapi.NewServer(
+42 -1
View File
@@ -44,10 +44,36 @@ func TestLoadConfig_MissingRequiredVars(t *testing.T) {
}
}
func TestLoadConfig_Defaults(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",
"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 {
@@ -184,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)
}
}
@@ -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 {
@@ -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)
}
}
+23 -2
View File
@@ -16,6 +16,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/connect/internal/bearerauth"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/connectorsetupapi"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/credential"
@@ -37,6 +38,24 @@ func (f fixedKEK) TenantKEK(ctx context.Context, tenantSlug string) ([]byte, err
return f.kek, nil
}
// 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 == "" {
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")
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)
@@ -72,11 +91,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,
@@ -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,58 @@ 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).
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, roleOf(r), 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 {
@@ -218,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"
}
@@ -0,0 +1,139 @@
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)
}
}
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)
}
}
+6
View File
@@ -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 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)
- 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`).
@@ -0,0 +1 @@
DELETE FROM policy_rules WHERE granted_by = 'migration:0029_task_setup_policy_rules';
@@ -0,0 +1,25 @@
-- Issue #369: workflow/internal/taskapi und connect/internal/connectorsetupapi
-- hatten keine Rechte-Schluessel; Muster wie 0018_module_policy_rules.
-- 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'),
('user', 'workflow.task.write'),
('tenant_admin', 'workflow.task.read'),
('tenant_admin', 'workflow.task.write'),
('user', 'connect.setup.read'),
('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;
+3
View File
@@ -17,6 +17,9 @@ export const MODULE_ICONS: Record<string, ReactNode> = {
routing_rules: svg(<><path d="M4 6h6l4 6h6M4 18h6l4-6" /><path d="m17 9 3 3-3 3" /></>),
ai: svg(<><path d="m12 3 1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z" /><path d="M19 15l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z" /></>),
ai_extraction: svg(<><path d="M5 4h14v6H5zM5 14h6v6H5z" /><path d="m14 17 2 2 4-4" /></>),
ai_search_chat: svg(<><circle cx="10" cy="10" r="6" /><path d="m14.5 14.5 5 5" /><path d="M8 10h4" /></>),
workflow_tasks: svg(<><rect x="3" y="4" width="5" height="16" rx="1" /><rect x="10" y="4" width="5" height="10" rx="1" /><rect x="17" y="4" width="4" height="7" rx="1" /></>),
connect_setup: svg(<><path d="M9 7H7a4 4 0 0 0 0 8h2M15 7h2a4 4 0 0 1 0 8h-2" /><circle cx="12" cy="11" r="2" /><path d="M12 13v5" /></>),
connect: svg(<><path d="M9 7H7a4 4 0 0 0 0 8h2M15 7h2a4 4 0 0 1 0 8h-2M8 11h8" /></>),
connect_datev: svg(<><ellipse cx="12" cy="6" rx="7" ry="3" /><path d="M5 6v6c0 1.7 3.1 3 7 3s7-1.3 7-3V6M5 12v6c0 1.7 3.1 3 7 3s7-1.3 7-3v-6" /></>),
tenant_admin: svg(<><rect x="4" y="3" width="10" height="18" rx="1" /><path d="M14 9h6v12h-6M8 7h2M8 11h2M8 15h2" /></>),
+6 -1
View File
@@ -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",
+12
View File
@@ -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",
+19 -2
View File
@@ -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,24 @@ 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 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 == "" {
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"))
}
// 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
+7 -1
View File
@@ -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)
+4
View File
@@ -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 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).
+6 -1
View File
@@ -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)
})
}
@@ -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())
}
}
+54 -25
View File
@@ -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,68 @@ 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).
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, roleOf(r), 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 +90,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 {
@@ -96,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"
}
@@ -0,0 +1,137 @@
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())
}
}
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)
}
}