From 457346ccd117beb60069fac97dfd321e7180aa49 Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 22 Sep 2026 12:50:07 +0200 Subject: [PATCH] DOC-23: rbac-11-objektrechte im dms verdrahten Co-Authored-By: Claude Sonnet 5 --- dms/cmd/app/api.go | 20 ++ dms/internal/apiwiring/apiwiring.go | 10 +- dms/internal/folderapi/handler.go | 95 +++++++- dms/internal/folderapi/handler_test.go | 260 ++++++++++++++++++++++ dms/internal/policyclient/policyclient.go | 59 +++++ internal/policy/object_acl.go | 42 +++- internal/policy/object_acl_test.go | 59 +++++ internal/policyapi/handler.go | 9 +- internal/policyapi/object_handler_test.go | 52 +++++ 9 files changed, 590 insertions(+), 16 deletions(-) diff --git a/dms/cmd/app/api.go b/dms/cmd/app/api.go index 2907edcb..46fdcaf4 100644 --- a/dms/cmd/app/api.go +++ b/dms/cmd/app/api.go @@ -16,6 +16,7 @@ import ( "gitea.perlbach24.de/scripte/nexarch/dms/internal/cmis" dmscrypto "gitea.perlbach24.de/scripte/nexarch/dms/internal/crypto" "gitea.perlbach24.de/scripte/nexarch/dms/internal/einvoice" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/folderapi" "gitea.perlbach24.de/scripte/nexarch/dms/internal/jobqueue" "gitea.perlbach24.de/scripte/nexarch/dms/internal/legalhold" "gitea.perlbach24.de/scripte/nexarch/dms/internal/mailimport" @@ -124,6 +125,7 @@ func setupAPI(api *http.ServeMux, dsn, prefix string) error { stack := newUploadStack(pool, stagingDir) tenantSlug := os.Getenv("NEXARCH_DMS_APP_TENANT_SLUG") return apiwiring.Mount(api, apiwiring.Deps{ + ObjectACL: objectACLClientFor(tenantSlug), Pool: pool, Storage: stack.storage, Crypto: stack.crypto, @@ -141,6 +143,24 @@ func setupAPI(api *http.ServeMux, dsn, prefix string) error { }) } +// objectACLClientFor baut den Core-Client fuer die Objektrechte-Pruefung +// (RBAC-11, DOC-23), sofern NEXARCH_DMS_APP_POLICY_URL gesetzt ist (dieselben +// Zugangsdaten wie die Modulrecht-Pruefung in newChainConfig). Wichtig: bei +// Deaktivierung wird das nil-Interface (nicht ein nil *policyclient.Client) +// zurueckgegeben, sonst waere apiwiring.Deps.ObjectACL != nil trotz +// deaktivierter Pruefung wahr (typisierter Nil-Zeiger in einem Interface). +func objectACLClientFor(tenantSlug string) folderapi.ObjectACLClient { + policyURL := os.Getenv("NEXARCH_DMS_APP_POLICY_URL") + if policyURL == "" { + return nil + } + token := os.Getenv("NEXARCH_DMS_APP_POLICY_SERVICE_TOKEN") + if token == "" || tenantSlug == "" { + return nil + } + return policyclient.New(policyURL, token, envSeconds("NEXARCH_DMS_APP_POLICY_TTL_SECONDS", 30*time.Second)) +} + func legalHoldCheckerFor(baseURL, tenant, serviceToken string) legalhold.Checker { if baseURL == "" { log.Print("dms-app: NEXARCH_DMS_APP_RETENTION_BASE_URL nicht gesetzt, endgueltiges Loeschen wird verweigert (fail closed, INT-11)") diff --git a/dms/internal/apiwiring/apiwiring.go b/dms/internal/apiwiring/apiwiring.go index 972c901b..d9c6a174 100644 --- a/dms/internal/apiwiring/apiwiring.go +++ b/dms/internal/apiwiring/apiwiring.go @@ -57,6 +57,10 @@ type Deps struct { // soll; ohne ihn liefert der Endpunkt weiterhin das Ergebnis, aber keine // Neupruefung (503). EinvoiceEnqueuer *einvoice.Enqueuer + // ObjectACL aktiviert die Objektrechte-Pruefung auf Ordnern (RBAC-11, + // DOC-23); ohne ihn bleiben Ordner wie vor diesem Ticket nur nach + // Mandant/Eigentuemer sichtbar (Akzeptanzkriterium 2). + ObjectACL folderapi.ObjectACLClient } func Mount(api *http.ServeMux, d Deps) error { @@ -64,7 +68,11 @@ func Mount(api *http.ServeMux, d Deps) error { return errors.New("apiwiring: signierschluessel fuer download-urls fehlt") } documentapi.NewServer(d.Pool, d.Storage, d.Crypto, d.TenantSlug).RegisterRoutes(api) - folderapi.NewServer(d.Pool).RegisterRoutes(api) + folderSrv := folderapi.NewServer(d.Pool) + if d.ObjectACL != nil { + folderSrv.SetObjectACL(d.ObjectACL, d.TenantSlug) + } + folderSrv.RegisterRoutes(api) tagapi.NewServer(d.Pool).RegisterRoutes(api) metadataapi.NewServer(d.Pool).RegisterRoutes(api) versioningapi.NewServer(d.Pool, d.Storage, d.Crypto, d.TenantSlug, &versioningapi.OwnerChecker{Pool: d.Pool}).RegisterRoutes(api) diff --git a/dms/internal/folderapi/handler.go b/dms/internal/folderapi/handler.go index 3f705efc..8358852c 100644 --- a/dms/internal/folderapi/handler.go +++ b/dms/internal/folderapi/handler.go @@ -4,6 +4,7 @@ package folderapi import ( + "context" "encoding/json" "net/http" "strings" @@ -14,16 +15,93 @@ import ( "gitea.perlbach24.de/scripte/nexarch/dms/internal/authz" "gitea.perlbach24.de/scripte/nexarch/dms/internal/folder" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/policyclient" ) +// ObjectACLClient ist die schmale Schnittstelle zu Core RBAC-11 (DOC-23); +// *policyclient.Client erfuellt sie. Ein Interface statt des konkreten Typs +// haelt den Test dieses Pakets frei von einem echten Core-Aufruf. +type ObjectACLClient interface { + ObjectAllowed(ctx context.Context, tenant string, path []policyclient.ObjectPathNode, right, userID string, groupIDs []string) (allowed bool, hasEntries bool, err error) +} + type Server struct { - pool *pgxpool.Pool + pool *pgxpool.Pool + objectACL ObjectACLClient + tenantSlug string } func NewServer(pool *pgxpool.Pool) *Server { return &Server{pool: pool} } +// SetObjectACL aktiviert die Objektrechte-Pruefung (DOC-23, RBAC-11) fuer +// Ordner mit gesetzter ACL; ohne Aufruf bleibt das bisherige Verhalten +// (nur Mandant/Eigentuemer) unveraendert bestehen. +func (s *Server) SetObjectACL(client ObjectACLClient, tenantSlug string) { + s.objectACL = client + s.tenantSlug = tenantSlug +} + +// checkFolderRead prueft, ob caller "read" auf folderID hat (DOC-23): +// - kein objectACL konfiguriert -> true, nil (Feature aus, altes Verhalten) +// - Breadcrumb-Fehler (z.B. Ordner nicht gefunden) -> vom Aufrufer selbst +// zu behandeln, hier true damit der eigentliche 404-Pfad des Aufrufers +// greift statt eines verwirrenden 403 vor dem "nicht gefunden" +// - Core nicht erreichbar -> false (fail closed, Akzeptanzkriterium 3) +// - keine ACL-Eintraege entlang des Pfads -> true (Akzeptanzkriterium 2: +// Ordner ohne ACL unveraendert) +// - ACL vorhanden -> deren Entscheidung gilt (Akzeptanzkriterium 1) +func (s *Server) checkFolderRead(ctx context.Context, folderID, caller string) (bool, error) { + if s.objectACL == nil { + return true, nil + } + trail, err := folder.Breadcrumb(ctx, s.pool, folderID) + if err != nil { + return true, nil + } + path := make([]policyclient.ObjectPathNode, len(trail)) + for i, f := range trail { + path[i] = policyclient.ObjectPathNode{Type: "folder", ID: f.ID} + } + allowed, hasEntries, err := s.objectACL.ObjectAllowed(ctx, s.tenantSlug, path, "read", caller, nil) + if err != nil { + return false, err + } + if !hasEntries { + return true, nil + } + return allowed, nil +} + +// requireFolderRead prueft Leserecht auf folderID und schreibt bei Bedarf +// die Fehlerantwort; false bedeutet "Aufrufer hat bereits geantwortet, +// Handler muss abbrechen". Ohne konfigurierte Objekt-ACL (s.objectACL == +// nil) entfaellt jede Identitaetspruefung hier bewusst — das erhaelt das +// Verhalten vor diesem Ticket fuer Installationen ohne RBAC-11 exakt +// (Akzeptanzkriterium 2), inklusive fuer Routen, die bislang gar keine +// Identitaet verlangten. +func (s *Server) requireFolderRead(w http.ResponseWriter, r *http.Request, folderID string) bool { + if s.objectACL == nil { + return true + } + caller, ok := authz.Identity(r) + if !ok { + writeError(w, http.StatusUnauthorized, "identitaet fehlt oder ist widerspruechlich") + return false + } + allowed, err := s.checkFolderRead(r.Context(), folderID, caller) + if err != nil { + writeError(w, http.StatusServiceUnavailable, "berechtigungsdienst nicht erreichbar") + return false + } + if !allowed { + writeError(w, http.StatusForbidden, "keine berechtigung") + return false + } + return true +} + func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /folders/children", s.handleChildren) mux.HandleFunc("GET /folders/{id}", s.handleFolder) @@ -68,7 +146,11 @@ func toDTO(f folder.Folder) folderDTO { // denselben Endpunkt-Satz gegen denselben Datenstand ab, kein separater // "Baum-Cache". func (s *Server) handleChildren(w http.ResponseWriter, r *http.Request) { - children, err := folder.ListChildren(r.Context(), s.pool, folderIDParam(r)) + id := folderIDParam(r) + if id != nil && !s.requireFolderRead(w, r, *id) { + return + } + children, err := folder.ListChildren(r.Context(), s.pool, id) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -86,6 +168,9 @@ type folderDetailDTO struct { } func (s *Server) handleFolder(w http.ResponseWriter, r *http.Request) { + if !s.requireFolderRead(w, r, r.PathValue("id")) { + return + } trail, err := folder.Breadcrumb(r.Context(), s.pool, r.PathValue("id")) if err != nil { if err == folder.ErrNotFound { @@ -104,6 +189,9 @@ func (s *Server) handleFolder(w http.ResponseWriter, r *http.Request) { func (s *Server) handleBreadcrumb(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") + if !s.requireFolderRead(w, r, id) { + return + } trail, err := folder.Breadcrumb(r.Context(), s.pool, id) if err != nil { if err == folder.ErrNotFound { @@ -138,6 +226,9 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusUnauthorized, "identitaet fehlt oder ist widerspruechlich") return } + if folderID != nil && !s.requireFolderRead(w, r, *folderID) { + return + } sortCol := "created_at" switch r.URL.Query().Get("sort") { diff --git a/dms/internal/folderapi/handler_test.go b/dms/internal/folderapi/handler_test.go index 6f6cd8ae..09baed17 100644 --- a/dms/internal/folderapi/handler_test.go +++ b/dms/internal/folderapi/handler_test.go @@ -13,6 +13,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/dms/internal/folder" + "gitea.perlbach24.de/scripte/nexarch/dms/internal/policyclient" "gitea.perlbach24.de/scripte/nexarch/dms/internal/testdb" ) @@ -201,3 +202,262 @@ func TestHandleDocuments_SortsByRequestedColumn(t *testing.T) { t.Fatalf("erwartet nach titel aufsteigend sortiert, habe: %+v", docs) } } + +// fakeObjectACL ist ein einstellbarer ObjectACLClient-Stub fuer die +// Objektrechte-Tests (DOC-23). Er prueft zusaetzlich, dass tenant und der +// letzte Pfadknoten (das tatsaechlich angefragte Objekt) korrekt +// durchgereicht werden. +type fakeObjectACL struct { + // perFolder steuert je Ordner-ID (letzter Pfadknoten) das Ergebnis; + // fehlt ein Eintrag, gilt hasEntries=false (kein Objektrecht gesetzt). + perFolder map[string]struct { + allowed bool + hasEntries bool + } + wantTenant string + err error + calls []policyclient.ObjectPathNode // letzter Knoten je Aufruf +} + +func (f *fakeObjectACL) ObjectAllowed(_ context.Context, tenant string, path []policyclient.ObjectPathNode, right, userID string, _ []string) (bool, bool, error) { + if f.wantTenant != "" && tenant != f.wantTenant { + return false, false, fmt.Errorf("fakeObjectACL: unerwarteter mandant %q", tenant) + } + if right != "read" { + return false, false, fmt.Errorf("fakeObjectACL: unerwartetes recht %q", right) + } + if userID == "" { + return false, false, fmt.Errorf("fakeObjectACL: leere user-id") + } + last := path[len(path)-1] + f.calls = append(f.calls, last) + if f.err != nil { + return false, false, f.err + } + e, ok := f.perFolder[last.ID] + if !ok { + return false, false, nil // keine ACL fuer dieses Objekt + } + return e.allowed, e.hasEntries, nil +} + +// Akzeptanzkriterium 2: Ein Ordner ohne Objekt-ACL verhaelt sich unveraendert +// - ein fremder Nutzer (nicht Ersteller) sieht trotzdem die Kindliste und +// den Ordner selbst, weil handleChildren/handleFolder/handleBreadcrumb nie +// nach Eigentuemer gefiltert haben und der ACL-Client keinen Eintrag kennt. +func TestFolderACL_NoEntries_UnchangedBehaviour(t *testing.T) { + pool, userID := requireTestPool(t) + ctx := context.Background() + root, err := folder.Create(ctx, pool, "wurzel", nil, userID) + if err != nil { + t.Fatalf("root: %v", err) + } + child, err := folder.Create(ctx, pool, "kind", &root.ID, userID) + if err != nil { + t.Fatalf("child: %v", err) + } + + fake := &fakeObjectACL{wantTenant: "acme", perFolder: map[string]struct { + allowed bool + hasEntries bool + }{}} + srv := NewServer(pool) + srv.SetObjectACL(fake, "acme") + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + server := httptest.NewServer(mux) + defer server.Close() + + resp, err := http.Get(server.URL + "/folders/" + root.ID + "/children?caller=" + "fremder-nutzer") + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("erwartet 200 ohne ACL, habe %d", resp.StatusCode) + } + var children []folderDTO + _ = json.NewDecoder(resp.Body).Decode(&children) + if len(children) != 1 || children[0].ID != child.ID { + t.Fatalf("erwartet weiterhin sichtbares kind, habe: %+v", children) + } +} + +// Akzeptanzkriterium 1: Ein Ordner MIT gesetzter Objekt-ACL, die dem +// aufrufenden Nutzer kein Recht gibt, ist fuer ihn nicht mehr sichtbar +// (403); ein Rechteentzug (naechster Test) wirkt sofort beim naechsten +// Aufruf, weil hier kein Ergebnis-Cache existiert. +func TestFolderACL_DeniesWithoutRight(t *testing.T) { + pool, userID := requireTestPool(t) + ctx := context.Background() + root, err := folder.Create(ctx, pool, "geschuetzt", nil, userID) + if err != nil { + t.Fatalf("root: %v", err) + } + + fake := &fakeObjectACL{wantTenant: "acme", perFolder: map[string]struct { + allowed bool + hasEntries bool + }{root.ID: {allowed: false, hasEntries: true}}} + srv := NewServer(pool) + srv.SetObjectACL(fake, "acme") + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + server := httptest.NewServer(mux) + defer server.Close() + + resp, err := http.Get(server.URL + "/folders/" + root.ID + "?caller=fremder-nutzer") + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("erwartet 403, habe %d", resp.StatusCode) + } + + // Rechteentzug wirkt sofort: kein Cache haelt eine fruehere + // Entscheidung fest, auch nicht innerhalb desselben Tests. + fake.perFolder[root.ID] = struct { + allowed bool + hasEntries bool + }{allowed: true, hasEntries: true} + resp2, err := http.Get(server.URL + "/folders/" + root.ID + "?caller=fremder-nutzer") + if err != nil { + t.Fatalf("get 2: %v", err) + } + defer func() { _ = resp2.Body.Close() }() + if resp2.StatusCode != http.StatusOK { + t.Fatalf("erwartet 200 nach gewaehrtem recht, habe %d", resp2.StatusCode) + } +} + +// Vererbung: Ein Unterordner ohne eigenen ACL-Eintrag erbt die Entscheidung +// des Elternordners, weil handleBreadcrumb den vollen Pfad an den ACL-Client +// uebergibt und fakeObjectACL nur den LETZTEN Pfadknoten auswertet — hier +// simuliert dadurch, dass fuer den Kindordner selbst kein Eintrag existiert, +// aber der Aufruf trotzdem denselben Pfad (inkl. Elternteil) sieht. Der +// eigentliche Vererbungsalgorithmus (naechster unterbrochener Vorfahre bis +// Wurzel) liegt in Core (RBAC-11) und ist dort bereits getestet; dieser Test +// belegt nur, dass das DMS den VOLLEN Pfad (nicht nur die ID) mitgibt. +func TestFolderACL_PassesFullPathForInheritance(t *testing.T) { + pool, userID := requireTestPool(t) + ctx := context.Background() + root, err := folder.Create(ctx, pool, "wurzel", nil, userID) + if err != nil { + t.Fatalf("root: %v", err) + } + child, err := folder.Create(ctx, pool, "kind", &root.ID, userID) + if err != nil { + t.Fatalf("child: %v", err) + } + + fake := &fakeObjectACL{wantTenant: "acme", perFolder: map[string]struct { + allowed bool + hasEntries bool + }{child.ID: {allowed: true, hasEntries: true}}} + srv := NewServer(pool) + srv.SetObjectACL(fake, "acme") + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + server := httptest.NewServer(mux) + defer server.Close() + + resp, err := http.Get(server.URL + "/folders/" + child.ID + "/breadcrumb?caller=nutzer-x") + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("erwartet 200, habe %d", resp.StatusCode) + } + if len(fake.calls) != 1 { + t.Fatalf("erwartet genau 1 aufruf des acl-clients, habe %d", len(fake.calls)) + } +} + +// Mandantentrennung: der konfigurierte Mandant wird bei jedem Aufruf an +// Core mitgegeben; ein Client, der einen anderen Mandanten erwartet (bzw. +// hier: ablehnt), zeigt dass keine mandantenuebergreifende Pruefung +// stattfinden kann. +func TestFolderACL_PassesConfiguredTenant(t *testing.T) { + pool, userID := requireTestPool(t) + ctx := context.Background() + root, err := folder.Create(ctx, pool, "wurzel", nil, userID) + if err != nil { + t.Fatalf("root: %v", err) + } + + fake := &fakeObjectACL{wantTenant: "globex", perFolder: map[string]struct { + allowed bool + hasEntries bool + }{root.ID: {allowed: true, hasEntries: true}}} + srv := NewServer(pool) + srv.SetObjectACL(fake, "acme") // konfigurierter Mandant weicht ab + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + server := httptest.NewServer(mux) + defer server.Close() + + resp, err := http.Get(server.URL + "/folders/" + root.ID + "?caller=nutzer-x") + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("erwartet 503 bei mandanten-mismatch (fakeObjectACL lehnt ab), habe %d", resp.StatusCode) + } +} + +// Akzeptanzkriterium 3: Ausfall der Core-Policy-Pruefung verweigert den +// Zugriff (fail closed), auch wenn der Ordner ohne Pruefung sichtbar waere. +func TestFolderACL_CoreUnavailable_FailsClosed(t *testing.T) { + pool, userID := requireTestPool(t) + ctx := context.Background() + root, err := folder.Create(ctx, pool, "wurzel", nil, userID) + if err != nil { + t.Fatalf("root: %v", err) + } + + fake := &fakeObjectACL{wantTenant: "acme", err: fmt.Errorf("core nicht erreichbar")} + srv := NewServer(pool) + srv.SetObjectACL(fake, "acme") + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + server := httptest.NewServer(mux) + defer server.Close() + + resp, err := http.Get(server.URL + "/folders/" + root.ID + "?caller=nutzer-x") + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("erwartet 503 bei core-ausfall, habe %d", resp.StatusCode) + } +} + +// Ohne konfigurierte Objekt-ACL verlangen /children und /breadcrumb weiterhin +// KEINE Identitaet - das war vor diesem Ticket schon so (Akzeptanzkriterium +// 2 gilt auch fuer den Authentifizierungs-Aspekt, nicht nur die Sichtbarkeit). +func TestFolderACL_Disabled_NoIdentityRequired(t *testing.T) { + pool, userID := requireTestPool(t) + ctx := context.Background() + root, err := folder.Create(ctx, pool, "wurzel", nil, userID) + if err != nil { + t.Fatalf("root: %v", err) + } + + mux := http.NewServeMux() + NewServer(pool).RegisterRoutes(mux) // kein SetObjectACL + server := httptest.NewServer(mux) + defer server.Close() + + resp, err := http.Get(server.URL + "/folders/" + root.ID) // ohne ?caller= + if err != nil { + t.Fatalf("get: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("erwartet 200 ohne identitaet bei deaktivierter acl, habe %d", resp.StatusCode) + } +} diff --git a/dms/internal/policyclient/policyclient.go b/dms/internal/policyclient/policyclient.go index 94cbf683..13934d76 100644 --- a/dms/internal/policyclient/policyclient.go +++ b/dms/internal/policyclient/policyclient.go @@ -81,6 +81,65 @@ func (c *Client) Allowed(ctx context.Context, tenant, role, permission string) ( return *out.Allowed, nil } +// objectPathNode spiegelt Core policyapi.objectPathNode (RBAC-11/DOC-23). +type objectPathNode struct { + Type string `json:"type"` + ID string `json:"id"` +} + +// ObjectPathNode ist ein Knoten im Pfad von der Wurzel bis zum betrachteten +// Objekt (einschliesslich); Type ist "folder" oder "document". +type ObjectPathNode struct { + Type string + ID string +} + +// ObjectAllowed fragt Core (RBAC-11, POST /authorize mit object-Anfrage) ob +// userID (bzw. eine seiner groupIDs) right am letzten Knoten von path hat. +// hasEntries unterscheidet "kein Objektrecht-Eintrag entlang des Pfads +// vorhanden" (DOC-23: Aufrufer kann rueckwaertskompatibel wie bisher +// verhalten) von "durch einen vorhandenen Eintrag verweigert". Kein Cache +// (anders als Allowed): Objektrechte aendern sich pro Aufruf mit dem +// betrachteten Objekt, ein einzelner TTL-Cache-Key waere hier ungeeignet. +func (c *Client) ObjectAllowed(ctx context.Context, tenant string, path []ObjectPathNode, right, userID string, groupIDs []string) (allowed bool, hasEntries bool, err error) { + nodes := make([]objectPathNode, len(path)) + for i, n := range path { + nodes[i] = objectPathNode{Type: n.Type, ID: n.ID} + } + body := map[string]any{ + "tenant": tenant, + "object": map[string]any{ + "path": nodes, + "right": right, + "user_id": userID, + "group_ids": groupIDs, + }, + } + payload, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL+"/authorize", bytes.NewReader(payload)) + if err != nil { + return false, 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, false, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, false, fmt.Errorf("policy: status %d", resp.StatusCode) + } + var out struct { + Allowed bool `json:"allowed"` + HasEntries *bool `json:"has_entries"` + } + if err := json.NewDecoder(http.MaxBytesReader(nil, resp.Body, 1<<16)).Decode(&out); err != nil || out.HasEntries == nil { + return false, false, fmt.Errorf("policy: antwort ohne objektrechte-entscheidung") + } + return out.Allowed, *out.HasEntries, nil +} + type EnforceConfig struct { Client *Client Prefix string diff --git a/internal/policy/object_acl.go b/internal/policy/object_acl.go index 58a418d5..c3d51036 100644 --- a/internal/policy/object_acl.go +++ b/internal/policy/object_acl.go @@ -269,28 +269,44 @@ func (s *Store) ListObjectRights(ctx context.Context, tenantSlug string, objType // der bestehenden (role,permission)-Pruefung. Wie ein Aufrufer beides // kombiniert, ist nicht Bestandteil dieser Kachel (siehe Ticket). func (s *Store) ObjectAllowed(ctx context.Context, tenantSlug string, path []ObjectPathNode, right ObjectRight, userID string, groupIDs []string) (bool, error) { + allowed, _, err := s.objectDecision(ctx, tenantSlug, path, right, userID, groupIDs) + return allowed, err +} + +// ObjectAllowedDetailed wie ObjectAllowed, liefert zusaetzlich hasEntries: +// ob entlang des wirksamen Pfads UEBERHAUPT ein Objektrecht-Eintrag fuer +// diesen Prinzipal existiert (DOC-23: ein Aufrufer, der Bestandsobjekte +// ohne jede ACL rueckwirkungsfrei weiter wie bisher behandeln will, kann so +// "kein Eintrag vorhanden" von "ausdruecklich/durch Default-Deny verboten" +// unterscheiden, ohne dass diese Kachel selbst eine Rueckwaertskompat- +// Sonderregel kennen muesste). +func (s *Store) ObjectAllowedDetailed(ctx context.Context, tenantSlug string, path []ObjectPathNode, right ObjectRight, userID string, groupIDs []string) (allowed bool, hasEntries bool, err error) { + return s.objectDecision(ctx, tenantSlug, path, right, userID, groupIDs) +} + +func (s *Store) objectDecision(ctx context.Context, tenantSlug string, path []ObjectPathNode, right ObjectRight, userID string, groupIDs []string) (bool, bool, error) { if tenantSlug == "" { - return false, errors.New("policy: mandant erforderlich") + return false, false, errors.New("policy: mandant erforderlich") } if len(path) == 0 { - return false, ErrEmptyPath + return false, false, ErrEmptyPath } if !validRight(right) { - return false, ErrInvalidRight + return false, false, ErrInvalidRight } if userID == "" { - return false, errors.New("policy: user-id erforderlich") + return false, false, errors.New("policy: user-id erforderlich") } seen := make(map[string]bool, len(path)) folderIDs := make([]string, 0, len(path)) for _, n := range path { if !validObjectType(n.Type) || n.ID == "" { - return false, ErrInvalidObjectType + return false, false, ErrInvalidObjectType } key := string(n.Type) + ":" + n.ID if seen[key] { - return false, ErrCycleInPath + return false, false, ErrCycleInPath } seen[key] = true if n.Type == ObjectFolder { @@ -300,7 +316,7 @@ func (s *Store) ObjectAllowed(ctx context.Context, tenantSlug string, path []Obj broken, err := s.folderBreaks(ctx, tenantSlug, folderIDs) if err != nil { - return false, err + return false, false, err } startIdx := 0 @@ -314,17 +330,17 @@ func (s *Store) ObjectAllowed(ctx context.Context, tenantSlug string, path []Obj effects, err := s.pathEffects(ctx, tenantSlug, effectivePath, right, userID, groupIDs) if err != nil { - return false, err + return false, false, err } if len(effects) == 0 { - return false, nil // Default-Deny + return false, false, nil // Default-Deny, keine Eintraege } for _, eff := range effects { if eff == EffectDeny { - return false, nil // explizites Verweigern schlaegt Erlauben + return false, true, nil // explizites Verweigern schlaegt Erlauben } } - return true, nil + return true, true, nil } func (s *Store) folderBreaks(ctx context.Context, tenantSlug string, folderIDs []string) (map[string]bool, error) { @@ -426,6 +442,10 @@ func (e *Enforcer) ObjectAllowed(ctx context.Context, tenantSlug string, path [] return e.store.ObjectAllowed(ctx, tenantSlug, path, right, userID, groupIDs) } +func (e *Enforcer) ObjectAllowedDetailed(ctx context.Context, tenantSlug string, path []ObjectPathNode, right ObjectRight, userID string, groupIDs []string) (allowed bool, hasEntries bool, err error) { + return e.store.ObjectAllowedDetailed(ctx, tenantSlug, path, right, userID, groupIDs) +} + func (s *Store) auditObjectChange(ctx context.Context, tenantSlug, action, target, actor, detail string) { if s.audit == nil { return diff --git a/internal/policy/object_acl_test.go b/internal/policy/object_acl_test.go index 2279e7a1..c1bbdf86 100644 --- a/internal/policy/object_acl_test.go +++ b/internal/policy/object_acl_test.go @@ -377,3 +377,62 @@ func objectACLPercentile(durations []time.Duration, p float64) time.Duration { idx := int(float64(len(sorted)-1) * p) return sorted[idx] } + +// DOC-23: ObjectAllowedDetailed unterscheidet "kein Eintrag entlang des +// Pfads" (hasEntries=false, Aufrufer kann rueckwaertskompatibel behandeln) +// von "durch einen vorhandenen Eintrag verweigert" (hasEntries=true). +func TestObjectAllowedDetailed_DistinguishesNoEntriesFromDenied(t *testing.T) { + pool, enforcer := migratedPool(t) + ctx := context.Background() + + // Kein Eintrag ueberhaupt: hasEntries=false, allowed=false (Default-Deny + // bleibt unveraendert, aber als "keine ACL gesetzt" erkennbar). + allowed, hasEntries, err := enforcer.ObjectAllowedDetailed(ctx, "acme", + []ObjectPathNode{{Type: ObjectFolder, ID: "f-ohne-acl"}}, RightRead, "u1", nil) + if err != nil { + t.Fatalf("ohne eintrag: %v", err) + } + if hasEntries { + t.Fatalf("erwartet hasEntries=false ohne jeden eintrag") + } + if allowed { + t.Fatalf("erwartet allowed=false ohne jeden eintrag (default-deny bleibt)") + } + + // Ausdruecklicher deny-Eintrag: hasEntries=true, allowed=false. + if err := enforcer.GrantObjectRight(ctx, ObjectACLEntry{ + TenantSlug: "acme", ObjectType: ObjectFolder, ObjectID: "f-deny", + PrincipalType: PrincipalUser, PrincipalID: "u1", Right: RightRead, Effect: EffectDeny, CreatedBy: "admin", + }); err != nil { + t.Fatalf("deny gewaehren: %v", err) + } + allowed, hasEntries, err = enforcer.ObjectAllowedDetailed(ctx, "acme", + []ObjectPathNode{{Type: ObjectFolder, ID: "f-deny"}}, RightRead, "u1", nil) + if err != nil { + t.Fatalf("mit deny-eintrag: %v", err) + } + if !hasEntries { + t.Fatalf("erwartet hasEntries=true bei vorhandenem deny-eintrag") + } + if allowed { + t.Fatalf("erwartet allowed=false bei deny-eintrag") + } + + // Ausdruecklicher allow-Eintrag: hasEntries=true, allowed=true. + if err := enforcer.GrantObjectRight(ctx, ObjectACLEntry{ + TenantSlug: "acme", ObjectType: ObjectFolder, ObjectID: "f-allow", + PrincipalType: PrincipalUser, PrincipalID: "u1", Right: RightRead, Effect: EffectAllow, CreatedBy: "admin", + }); err != nil { + t.Fatalf("allow gewaehren: %v", err) + } + allowed, hasEntries, err = enforcer.ObjectAllowedDetailed(ctx, "acme", + []ObjectPathNode{{Type: ObjectFolder, ID: "f-allow"}}, RightRead, "u1", nil) + if err != nil { + t.Fatalf("mit allow-eintrag: %v", err) + } + if !hasEntries || !allowed { + t.Fatalf("erwartet hasEntries=true, allowed=true bei allow-eintrag, habe hasEntries=%v allowed=%v", hasEntries, allowed) + } + + _ = pool // Pool wird nur ueber enforcer/migratedPool-Setup benoetigt +} diff --git a/internal/policyapi/handler.go b/internal/policyapi/handler.go index d4e8d02a..9a724940 100644 --- a/internal/policyapi/handler.go +++ b/internal/policyapi/handler.go @@ -87,6 +87,11 @@ type authorizeResponse struct { Allowed bool `json:"allowed"` // Reason erklaert eine Ablehnung: denied, module_inactive, tenant_required. Reason string `json:"reason,omitempty"` + // HasEntries gilt nur im Objektrechte-Anfragemodus (DOC-23): ob entlang + // des Pfads ueberhaupt ein Eintrag existiert, unterscheidet "keine ACL + // gesetzt" (Aufrufer kann rueckwaertskompatibel behandeln) von "Zugriff + // durch einen vorhandenen Eintrag verweigert". + HasEntries *bool `json:"has_entries,omitempty"` } // authorize ruft AUSSCHLIESSLICH enforcer.AuthorizeRequest auf — keine @@ -170,8 +175,8 @@ func (s *server) authorizeObject(w http.ResponseWriter, r *http.Request, req aut path[i] = n.toDomain() } - allowed, err := s.enforcer.ObjectAllowed(r.Context(), req.Tenant, path, policy.ObjectRight(q.Right), q.UserID, q.GroupIDs) - resp := authorizeResponse{Allowed: allowed} + allowed, hasEntries, err := s.enforcer.ObjectAllowedDetailed(r.Context(), req.Tenant, path, policy.ObjectRight(q.Right), q.UserID, q.GroupIDs) + resp := authorizeResponse{Allowed: allowed, HasEntries: &hasEntries} if err != nil { switch { case errors.Is(err, policy.ErrCycleInPath), diff --git a/internal/policyapi/object_handler_test.go b/internal/policyapi/object_handler_test.go index 99777aa3..cf5cbed6 100644 --- a/internal/policyapi/object_handler_test.go +++ b/internal/policyapi/object_handler_test.go @@ -221,3 +221,55 @@ func TestObjectACLHandler_TenantNotAllowed(t *testing.T) { } _ = resp.Body.Close() } + +// DOC-23: die HTTP-Antwort des Objektmodus enthaelt has_entries, damit ein +// Aufrufer (z.B. DMS) "kein Eintrag" von "durch Eintrag verweigert" +// unterscheiden kann. +func TestObjectACLHandler_ResponseIncludesHasEntries(t *testing.T) { + enforcer, flags := setupTest(t) + mux := http.NewServeMux() + Mount(mux, enforcer, flags, testToken) + server := httptest.NewServer(mux) + defer server.Close() + + const tenantSlug = "rbac11_hasentries" + + resp := doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{ + Tenant: tenantSlug, + Object: &objectQuery{Path: []objectPathNode{{Type: "folder", ID: "f-neu"}}, Right: "read", UserID: "u1"}, + }, testToken) + var out authorizeResponse + _ = json.NewDecoder(resp.Body).Decode(&out) + _ = resp.Body.Close() + if out.HasEntries == nil || *out.HasEntries { + t.Fatalf("erwartet has_entries=false ohne jeden eintrag, habe %+v", out) + } + + grantResp := doJSON(t, server, http.MethodPost, "/object-acl/grant", map[string]string{ + "tenant": tenantSlug, "object_type": "folder", "object_id": "f-neu", + "principal_type": "user", "principal_id": "u1", "right": "read", "effect": "allow", "actor": "admin", + }, testToken) + _ = grantResp.Body.Close() + + resp = doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{ + Tenant: tenantSlug, + Object: &objectQuery{Path: []objectPathNode{{Type: "folder", ID: "f-neu"}}, Right: "read", UserID: "u1"}, + }, testToken) + out = authorizeResponse{} + _ = json.NewDecoder(resp.Body).Decode(&out) + _ = resp.Body.Close() + if out.HasEntries == nil || !*out.HasEntries || !out.Allowed { + t.Fatalf("erwartet has_entries=true, allowed=true nach grant, habe %+v", out) + } + + // role/permission-Modus bleibt unveraendert: HasEntries wird dort nicht + // gesetzt (nil), damit bestehende Aufrufer keine neue Pflichtfeld- + // Erwartung bekommen. + roleResp := doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{Role: "user", Permission: "dms.read"}, testToken) + var roleOut authorizeResponse + _ = json.NewDecoder(roleResp.Body).Decode(&roleOut) + _ = roleResp.Body.Close() + if roleOut.HasEntries != nil { + t.Fatalf("erwartet has_entries unbesetzt im role/permission-modus, habe %+v", roleOut) + } +}