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
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:
commit
1de1da433f
20 files changed
+722
-59
No files matched your search
+40
-15
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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" /></>),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user