DOC-23: rbac-11-objektrechte im dms verdrahten
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-22 12:50:07 +02:00
1 parent 78e3c9623c
commit 457346ccd1
9 files changed
+590 -16

No files matched your search

+20
View File
@@ -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)")
+9 -1
View File
@@ -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)
+93 -2
View File
@@ -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") {
+260
View File
@@ -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)
}
}
+59
View File
@@ -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
+31 -11
View File
@@ -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
+59
View File
@@ -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
}
+7 -2
View File
@@ -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),
+52
View File
@@ -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)
}
}