Merge remote-tracking branch 'origin/feature/rbac-12-rechte-katalog-restliche-module'

This commit is contained in:
root committed 2026-09-26 14:26:59 +00:00
commit 62aa5f8439
9 files changed
+376 -3

No files matched your search

@@ -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
}
+30 -2
View File
@@ -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 {
@@ -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())
}
}
+33
View File
@@ -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.*`: `<modul>.<substantiv>.<verb>`.
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`
+32 -1
View File
@@ -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)))
}
@@ -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)
}
}
@@ -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
}
@@ -0,0 +1 @@
DELETE FROM policy_rules WHERE granted_by = 'migration:0018_module_policy_rules';
@@ -0,0 +1,49 @@
-- RBAC-12: echte Modulrechte fuer Mail/Workflow/AI/Connect/Core-Admin
-- (Namensraum <modul>.*), 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;