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
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:
1 parent
78e3c9623c
commit
457346ccd1
9 files changed
+590
-16
No files matched your search
@@ -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)")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user