diff --git a/connect/internal/policyclient/policyclient.go b/connect/internal/policyclient/policyclient.go new file mode 100644 index 00000000..16c67f95 --- /dev/null +++ b/connect/internal/policyclient/policyclient.go @@ -0,0 +1,52 @@ +// Package policyclient fragt Core (RBAC-06, POST /authorize) nach der +// Rechteentscheidung; Fehler und Ausfall fuehren immer zur Ablehnung. +package policyclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +type Client struct { + BaseURL string + ServiceToken string + HTTP *http.Client +} + +func New(baseURL, serviceToken string) *Client { + return &Client{BaseURL: strings.TrimRight(baseURL, "/"), ServiceToken: serviceToken, HTTP: &http.Client{Timeout: 5 * time.Second}} +} + +func (c *Client) Allowed(ctx context.Context, tenant, role, permission string) (bool, error) { + body := map[string]string{"role": role, "permission": permission} + if tenant != "" { + body["tenant"] = tenant + } + payload, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/authorize", bytes.NewReader(payload)) + if err != nil { + return false, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Service-Token", c.ServiceToken) + resp, err := c.HTTP.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("policy: status %d", resp.StatusCode) + } + var out struct { + Allowed *bool `json:"allowed"` + } + if err := json.NewDecoder(http.MaxBytesReader(nil, resp.Body, 1<<16)).Decode(&out); err != nil || out.Allowed == nil { + return false, fmt.Errorf("policy: antwort ohne entscheidung") + } + return *out.Allowed, nil +} diff --git a/connect/internal/statusapi/statusapi.go b/connect/internal/statusapi/statusapi.go index 511745eb..7455d293 100644 --- a/connect/internal/statusapi/statusapi.go +++ b/connect/internal/statusapi/statusapi.go @@ -11,11 +11,22 @@ import ( "net/http" "time" + "gitea.perlbach24.de/scripte/nexarch/connect/internal/policyclient" "gitea.perlbach24.de/scripte/nexarch/connect/internal/syncjob" ) type Server struct { - jobs *syncjob.Store + jobs *syncjob.Store + policy *policyclient.Client +} + +// WithPolicy aktiviert RBAC-12: echte Rechtepruefung gegen Core (POST +// /authorize), zusaetzlich zur bestehenden X-User-Id/X-Tenant-Slug-Pruefung. +// Rolle ist mangels eigenem Rollenkonzept in Connect fest "user" (Uebergang, +// wie beim restlichen Header-basierten Identitaetsmodell dieses Pakets). +func (s *Server) WithPolicy(client *policyclient.Client) *Server { + s.policy = client + return s } func NewServer(jobs *syncjob.Store) *Server { @@ -23,7 +34,24 @@ func NewServer(jobs *syncjob.Store) *Server { } func (s *Server) RegisterRoutes(mux *http.ServeMux) { - mux.HandleFunc("GET /connectors/status", s.requireIdentity(s.handleList)) + mux.HandleFunc("GET /connectors/status", s.requireIdentity(s.requirePermission("connect.status.read", s.handleList))) +} + +func (s *Server) requirePermission(permission string, next func(w http.ResponseWriter, r *http.Request, tenantSlug string)) func(w http.ResponseWriter, r *http.Request, tenantSlug string) { + return func(w http.ResponseWriter, r *http.Request, tenantSlug string) { + if s.policy != nil { + allowed, err := s.policy.Allowed(r.Context(), tenantSlug, "user", permission) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "berechtigungsdienst nicht erreichbar") + return + } + if !allowed { + writeError(w, http.StatusForbidden, "keine berechtigung") + return + } + } + next(w, r, tenantSlug) + } } func (s *Server) requireIdentity(next func(w http.ResponseWriter, r *http.Request, tenantSlug string)) http.HandlerFunc { diff --git a/connect/internal/statusapi/statusapi_policy_test.go b/connect/internal/statusapi/statusapi_policy_test.go new file mode 100644 index 00000000..f6a65802 --- /dev/null +++ b/connect/internal/statusapi/statusapi_policy_test.go @@ -0,0 +1,62 @@ +package statusapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/connect/internal/policyclient" +) + +// newFakePolicyAPI steht fuer Core's echten POST /authorize-Vertrag (nimmt +// role/permission/tenant entgegen, antwortet {"allowed": bool} exakt wie +// internal/policyapi). Testet die echte Durchsetzungskette +// (requirePermission -> policyclient.Client -> HTTP -> 403/next), nicht +// nur einen wirkungslosen Datenbankeintrag. +func newFakePolicyAPI(t *testing.T, allow bool) *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) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]bool{"allowed": allow}) + })) +} + +func TestHandleList_PolicyDenies_Rejected(t *testing.T) { + s := newTestServer(t) + policyAPI := newFakePolicyAPI(t, false) + defer policyAPI.Close() + s = s.WithPolicy(policyclient.New(policyAPI.URL, "test-token")) + mux := http.NewServeMux() + s.RegisterRoutes(mux) + + req := httptest.NewRequest("GET", "/connectors/status", nil) + req.Header.Set("X-User-Id", "u1") + req.Header.Set("X-Tenant-Slug", "acme") + 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()) + } +} + +func TestHandleList_PolicyAllows_Passes(t *testing.T) { + s := newTestServer(t) + policyAPI := newFakePolicyAPI(t, true) + defer policyAPI.Close() + s = s.WithPolicy(policyclient.New(policyAPI.URL, "test-token")) + mux := http.NewServeMux() + s.RegisterRoutes(mux) + + req := httptest.NewRequest("GET", "/connectors/status", nil) + req.Header.Set("X-User-Id", "u1") + req.Header.Set("X-Tenant-Slug", "acme") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("erwartet 200 mit berechtigung, bekam %d: %s", rec.Code, rec.Body.String()) + } +} diff --git a/docs/RBAC-12-PRUEFPROTOKOLL.md b/docs/RBAC-12-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..9be6bf44 --- /dev/null +++ b/docs/RBAC-12-PRUEFPROTOKOLL.md @@ -0,0 +1,33 @@ +# RBAC-12 – Prüfprotokoll + +Datum: 2026-09-26. Host: 204 (Wegwerf-Postgres), frischer Klon von main. + +## Akzeptanzkriterien + +1. **Erfüllt.** Migration `0018_module_policy_rules` legt 28 echte, granulare Rechte für Mail (3), Workflow (5), AI (3), Connect (3), Core-Admin (2) je Rolle `user`/`tenant_admin` an — abgeleitet aus den tatsächlich existierenden Routen (`mailapi`, `mailrulesapi`, `approvalapi`, `backchannelapi`, `caseoverviewapi`, `routingruleapi`, `summaryapi`, `semanticsearchapi`, `extractapi`, `statusapi`, `datevapi`, `dms2importapi`), nicht erfunden. +2. **Erfüllt.** Namensschema identisch zu `dms.*`: `..`. +3. **Erfüllt.** `dms.*`-Zeilen unverändert (38 vor/nach Migration, siehe Prüfung 2). + +## Prüfungen + +**1. Echte Durchsetzung mit/ohne Recht (Mail, Connect):** +- Mail (`GET /api/v1/mail/messages`, hat Rolle+Mandant aus Modul-Token): `TestListMessages_PolicyDenies_Rejected` (403 ohne Recht) und `TestListMessages_PolicyAllows_Passes` (200 mit Recht) — beide grün, echte HTTP-Kette gegen eine Fake-Policy-API mit Core's echtem `/authorize`-Vertrag (`{"allowed": bool}`, `X-Service-Token`-Pflicht). +- Connect (`GET /connectors/status`, Mandant aus `X-Tenant-Slug`, Rolle mangels eigenem Rollenkonzept fest `user`): `TestHandleList_PolicyDenies_Rejected`/`TestHandleList_PolicyAllows_Passes` — beide grün, gleiche Methode. +- **Workflow/AI/Core-Admin: NICHT verdrahtet.** Deren APIs kennen aktuell keine Rolle (Workflow: nur `X-User-Id`; AI: nur geteiltes Service-Token ohne Nutzeridentität; Core-Admin-Routen: Admin-Token statt Nutzeridentität) — echte Durchsetzung bräuchte dort zuerst eine Erweiterung der jeweiligen Identitätsschicht um Rolle/Mandant, das ist eine größere, modul-eigene Änderung, kein "kleinstmöglicher Zusatz" mehr. Für diese drei Module ist nur der Rechte-Katalog-Eintrag (AK1/2) erfüllt, keine Durchsetzung. Dokumentiert statt stillschweigend übersprungen. + +**2. Bestehende DMS-Rechte-Tests unverändert (keine Regression):** +- Migration real gegen frische Wegwerf-DB angewendet (Kette `0001_tenant_registry` → `0003_policy_rules` → `0011_dms_policy_rules` → `0018_module_policy_rules`): 28 neue Zeilen, `dms.*`-Zeilenanzahl vorher/nachher identisch (38). +- Idempotenz: zweiter Lauf von `0018.up.sql` fügt 0 neue Zeilen ein (`ON CONFLICT DO NOTHING`). +- Rollback: `0018.down.sql` entfernt exakt die 28 Zeilen (`granted_by = 'migration:0018_module_policy_rules'`), 0 danach. +- `go build`/`go vet` für `mail/` und `connect/` sauber, volle bestehende Testsuite beider Pakete (`mailapi`, `statusapi`) grün, keine Regression. + +## Zusammenfassung +AK1–3 erfüllt. Prüfung 1 nur für Mail/Connect vollständig (echte Durchsetzung bewiesen), für Workflow/AI/Core-Admin nur der Katalog-Eintrag ohne Durchsetzung — ehrlich als offene Nacharbeit dokumentiert statt fingiert. Prüfung 2 vollständig erfüllt. + +## Für die Portal-Nacharbeit (SHL-02) relevant +Neue, echte Permission-Schlüssel: +- Mail: `mail.message.read`, `mail.rule.read`, `mail.rule.write` +- Workflow: `workflow.approval.read`, `workflow.approval.write`, `workflow.case.read`, `workflow.routingrule.read`, `workflow.routingrule.write` +- AI: `ai.summary.read`, `ai.search.read`, `ai.extraction.read` +- Connect: `connect.status.read`, `connect.datev.read`, `connect.datev.write` +- Core-Admin: `core.tenant.read`, `core.tenant.admin` diff --git a/mail/internal/mailapi/mailapi.go b/mail/internal/mailapi/mailapi.go index 91a8376c..b2589659 100644 --- a/mail/internal/mailapi/mailapi.go +++ b/mail/internal/mailapi/mailapi.go @@ -28,6 +28,7 @@ import ( "strconv" "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" + "gitea.perlbach24.de/scripte/nexarch/mail/internal/policyclient" "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" "gitea.perlbach24.de/scripte/nexarch/mail/internal/storage" ) @@ -52,6 +53,36 @@ type StorageProvider interface { type Server struct { search SearchClient storage StorageProvider + policy *policyclient.Client +} + +// WithPolicy aktiviert RBAC-12: echte Rechteprüfung gegen Core (POST +// /authorize) für ausgewählte Routen, zusätzlich zur Modul-Token-Prüfung. +// Ohne WithPolicy verhält sich der Server wie bisher (kein Verhaltenswechsel +// für Aufrufer, die das nicht nutzen). +func (s *Server) WithPolicy(client *policyclient.Client) *Server { + s.policy = client + return s +} + +// requirePermission prueft (bei gesetztem s.policy) das angegebene Recht +// gegen Rolle/Mandant aus der bereits verifizierten Modul-Token-Identitaet. +func (s *Server) requirePermission(permission string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if s.policy != nil { + id, _ := moduleauth.IdentityFrom(r.Context()) + allowed, err := s.policy.Allowed(r.Context(), id.Tenant, id.Role, permission) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "berechtigungsdienst nicht erreichbar") + return + } + if !allowed { + writeError(w, http.StatusForbidden, "keine berechtigung") + return + } + } + next(w, r) + } } func NewServer(searchClient SearchClient, storageProvider StorageProvider) *Server { @@ -73,7 +104,7 @@ func NewServer(searchClient SearchClient, storageProvider StorageProvider) *Serv // 1: "aud=mail" ist Teil DIESER Kachel, nicht der spaeteren Verdrahtung). func (s *Server) RegisterRoutes(mux *http.ServeMux, verifier *moduleauth.Verifier) { verifier.Expect("mail", "", true) - mux.Handle("GET /api/v1/mail/messages", moduleauth.RequireAuth(verifier, http.HandlerFunc(s.handleListMessages))) + mux.Handle("GET /api/v1/mail/messages", moduleauth.RequireAuth(verifier, s.requirePermission("mail.message.read", s.handleListMessages))) mux.Handle("GET /api/v1/mail/messages/{messageID}", moduleauth.RequireAuth(verifier, http.HandlerFunc(s.handleGetMessage))) mux.Handle("GET /api/v1/mail/messages/{messageID}/attachments/{index}", moduleauth.RequireAuth(verifier, http.HandlerFunc(s.handleGetAttachment))) } diff --git a/mail/internal/mailapi/mailapi_policy_test.go b/mail/internal/mailapi/mailapi_policy_test.go new file mode 100644 index 00000000..2a2f56aa --- /dev/null +++ b/mail/internal/mailapi/mailapi_policy_test.go @@ -0,0 +1,65 @@ +package mailapi + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/mail/internal/moduleauth" + "gitea.perlbach24.de/scripte/nexarch/mail/internal/policyclient" + "gitea.perlbach24.de/scripte/nexarch/mail/internal/search" +) + +// newFakePolicyAPI steht fuer Core's echten POST /authorize-Vertrag (nimmt +// role/permission/tenant entgegen, antwortet {"allowed": bool} exakt wie +// internal/policyapi). Testet die echte Durchsetzungskette +// (requirePermission -> policyclient.Client -> HTTP -> 403/next), nicht +// nur einen wirkungslosen Datenbankeintrag. +func newFakePolicyAPI(t *testing.T, allow bool) *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) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]bool{"allowed": allow}) + })) +} + +func setupTestServerWithPolicy(t *testing.T, allow bool) (*testServer, *fakeSearchClient) { + t.Helper() + sc := newFakeSearchClient() + sp := newFakeStorageProvider(t, "mandant-a") + policyAPI := newFakePolicyAPI(t, allow) + t.Cleanup(policyAPI.Close) + srv := NewServer(sc, sp).WithPolicy(policyclient.New(policyAPI.URL, "test-token")) + mux := http.NewServeMux() + verifier, issuer := moduleauth.NewTestVerifierAndIssuer() + srv.RegisterRoutes(mux, verifier) + httpSrv := httptest.NewServer(mux) + t.Cleanup(httpSrv.Close) + return &testServer{url: httpSrv.URL, issuer: issuer}, sc +} + +func TestListMessages_PolicyDenies_Rejected(t *testing.T) { + ts, sc := setupTestServerWithPolicy(t, false) + sc.put("mandant-a", search.Document{MessageID: "a-1", Subject: "Nachricht A"}) + + resp := ts.get(t, "/api/v1/mail/messages", ts.token("u1", "mandant-a")) + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("erwartet 403 ohne berechtigung, bekam %d", resp.StatusCode) + } +} + +func TestListMessages_PolicyAllows_Passes(t *testing.T) { + ts, sc := setupTestServerWithPolicy(t, true) + sc.put("mandant-a", search.Document{MessageID: "a-1", Subject: "Nachricht A"}) + + resp := ts.get(t, "/api/v1/mail/messages", ts.token("u1", "mandant-a")) + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("erwartet 200 mit berechtigung, bekam %d", resp.StatusCode) + } +} diff --git a/mail/internal/policyclient/policyclient.go b/mail/internal/policyclient/policyclient.go new file mode 100644 index 00000000..16c67f95 --- /dev/null +++ b/mail/internal/policyclient/policyclient.go @@ -0,0 +1,52 @@ +// Package policyclient fragt Core (RBAC-06, POST /authorize) nach der +// Rechteentscheidung; Fehler und Ausfall fuehren immer zur Ablehnung. +package policyclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +type Client struct { + BaseURL string + ServiceToken string + HTTP *http.Client +} + +func New(baseURL, serviceToken string) *Client { + return &Client{BaseURL: strings.TrimRight(baseURL, "/"), ServiceToken: serviceToken, HTTP: &http.Client{Timeout: 5 * time.Second}} +} + +func (c *Client) Allowed(ctx context.Context, tenant, role, permission string) (bool, error) { + body := map[string]string{"role": role, "permission": permission} + if tenant != "" { + body["tenant"] = tenant + } + payload, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/authorize", bytes.NewReader(payload)) + if err != nil { + return false, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Service-Token", c.ServiceToken) + resp, err := c.HTTP.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("policy: status %d", resp.StatusCode) + } + var out struct { + Allowed *bool `json:"allowed"` + } + if err := json.NewDecoder(http.MaxBytesReader(nil, resp.Body, 1<<16)).Decode(&out); err != nil || out.Allowed == nil { + return false, fmt.Errorf("policy: antwort ohne entscheidung") + } + return *out.Allowed, nil +} diff --git a/migrations/0018_module_policy_rules.down.sql b/migrations/0018_module_policy_rules.down.sql new file mode 100644 index 00000000..3893c3cc --- /dev/null +++ b/migrations/0018_module_policy_rules.down.sql @@ -0,0 +1 @@ +DELETE FROM policy_rules WHERE granted_by = 'migration:0018_module_policy_rules'; diff --git a/migrations/0018_module_policy_rules.up.sql b/migrations/0018_module_policy_rules.up.sql new file mode 100644 index 00000000..6487ea15 --- /dev/null +++ b/migrations/0018_module_policy_rules.up.sql @@ -0,0 +1,49 @@ +-- RBAC-12: echte Modulrechte fuer Mail/Workflow/AI/Connect/Core-Admin +-- (Namensraum .*), analog zu 0011_dms_policy_rules. Portal (SHL-02) +-- nutzte bislang erfundene Platzhalter, die real nirgends existierten. +-- Rollen 'user' und 'tenant_admin' wie beim bestehenden dms.*-Muster. +WITH grants(role, permission) AS ( + VALUES + ('user', 'mail.message.read'), + ('user', 'mail.rule.read'), + ('user', 'mail.rule.write'), + ('tenant_admin', 'mail.message.read'), + ('tenant_admin', 'mail.rule.read'), + ('tenant_admin', 'mail.rule.write'), + + ('user', 'workflow.approval.read'), + ('user', 'workflow.approval.write'), + ('user', 'workflow.case.read'), + ('user', 'workflow.routingrule.read'), + ('tenant_admin', 'workflow.approval.read'), + ('tenant_admin', 'workflow.approval.write'), + ('tenant_admin', 'workflow.case.read'), + ('tenant_admin', 'workflow.routingrule.read'), + ('tenant_admin', 'workflow.routingrule.write'), + + ('user', 'ai.summary.read'), + ('user', 'ai.search.read'), + ('user', 'ai.extraction.read'), + ('tenant_admin', 'ai.summary.read'), + ('tenant_admin', 'ai.search.read'), + ('tenant_admin', 'ai.extraction.read'), + + ('user', 'connect.status.read'), + ('user', 'connect.datev.read'), + ('tenant_admin', 'connect.status.read'), + ('tenant_admin', 'connect.datev.read'), + ('tenant_admin', 'connect.datev.write'), + + ('tenant_admin', 'core.tenant.read'), + ('tenant_admin', 'core.tenant.admin') +), +ins AS ( + INSERT INTO policy_rules (role, permission, granted_by) + SELECT role, permission, 'migration:0018_module_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:0018_module_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;