RBAC-11: objektrechte mit vererbung für ordner
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
CI / Web (pdf-viewer) (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
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 11:54:06 +02:00
1 parent 44ba59ac49
commit 77a89a9fcb
7 files changed
+1362 -3

No files matched your search

+444
View File
@@ -0,0 +1,444 @@
package policy
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
)
// ObjectType ist der Typ eines Objekts mit Objektrechten (Akzeptanzkriterium 1).
type ObjectType string
const (
ObjectFolder ObjectType = "folder"
ObjectDocument ObjectType = "document"
)
// ObjectRight ist eines der drei speicherbaren Rechte. Rechte sind bewusst
// NICHT hierarchisch (manage impliziert nicht automatisch write/read) — das
// Ticket verlangt das nicht, und eine implizite Hierarchie waere eine
// zusaetzliche, ungetestete Annahme. Ein Aufrufer, der z.B. "manage"
// gewaehrt, muss "read"/"write" bei Bedarf eigens gewaehren. Siehe
// "QA und Entscheidungen" im Pruefprotokoll.
type ObjectRight string
const (
RightRead ObjectRight = "read"
RightWrite ObjectRight = "write"
RightManage ObjectRight = "manage"
)
// ACLEffect ist "allow" oder "deny" (Akzeptanzkriterium 1/2).
type ACLEffect string
const (
EffectAllow ACLEffect = "allow"
EffectDeny ACLEffect = "deny"
)
// PrincipalType ist "user" oder "group" (Akzeptanzkriterium 1/2).
type PrincipalType string
const (
PrincipalUser PrincipalType = "user"
PrincipalGroup PrincipalType = "group"
)
var (
ErrInvalidObjectType = errors.New("policy: ungueltiger objekttyp")
ErrInvalidPrincipalType = errors.New("policy: ungueltiger principal-typ")
ErrInvalidRight = errors.New("policy: ungueltiges recht")
ErrInvalidEffect = errors.New("policy: ungueltiger effekt")
ErrEmptyPath = errors.New("policy: objektpfad ist leer")
ErrCycleInPath = errors.New("policy: zyklus im objektpfad")
)
func validObjectType(t ObjectType) bool { return t == ObjectFolder || t == ObjectDocument }
func validPrincipalType(t PrincipalType) bool {
return t == PrincipalUser || t == PrincipalGroup
}
func validRight(r ObjectRight) bool {
return r == RightRead || r == RightWrite || r == RightManage
}
func validEffect(e ACLEffect) bool { return e == EffectAllow || e == EffectDeny }
// ObjectACLEntry ist ein gespeicherter Rechteeintrag (Akzeptanzkriterium 1).
type ObjectACLEntry struct {
TenantSlug string
ObjectType ObjectType
ObjectID string
PrincipalType PrincipalType
PrincipalID string
Right ObjectRight
Effect ACLEffect
CreatedBy string
}
// ObjectPathNode ist ein Knoten im Pfad von der Wurzel bis zum betrachteten
// Objekt (einschliesslich). Der Aufrufer (z.B. DMS) loest den Pfad selbst
// auf und uebergibt ihn — Core haelt keinen Spiegel des DMS-Ordnerbaums,
// siehe "QA und Entscheidungen": das haelt diese Kachel auf den Core-Anteil
// beschraenkt (Nicht-Bestandteil: DMS-Handler-Umbau) und macht "Verschieben
// eines Ordners aendert die wirksamen Rechte des Teilbaums" automatisch
// wahr, weil der Aufrufer bei jeder Anfrage den AKTUELLEN Pfad mitgibt.
type ObjectPathNode struct {
Type ObjectType
ID string
}
// Grant gewaehrt/verweigert (effect) einem Prinzipal ein Recht an einem
// Objekt. actor wird fuer Audit-Log und Historie festgehalten
// (Akzeptanzkriterium 3).
func (s *Store) GrantObjectRight(ctx context.Context, e ObjectACLEntry) error {
if !validObjectType(e.ObjectType) {
return ErrInvalidObjectType
}
if !validPrincipalType(e.PrincipalType) {
return ErrInvalidPrincipalType
}
if !validRight(e.Right) {
return ErrInvalidRight
}
if !validEffect(e.Effect) {
return ErrInvalidEffect
}
if e.TenantSlug == "" || e.ObjectID == "" || e.PrincipalID == "" || e.CreatedBy == "" {
return errors.New("policy: tenant, objekt-id, principal-id und created_by sind pflicht")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
INSERT INTO object_acl (tenant_slug, object_type, object_id, principal_type, principal_id, object_right, effect, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (tenant_slug, object_type, object_id, principal_type, principal_id, object_right)
DO UPDATE SET effect = $7, created_by = $8, created_at = now()
`, e.TenantSlug, string(e.ObjectType), e.ObjectID, string(e.PrincipalType), e.PrincipalID, string(e.Right), string(e.Effect), e.CreatedBy); err != nil {
return fmt.Errorf("objektrecht setzen: %w", err)
}
detail := fmt.Sprintf("%s:%s recht=%s effekt=%s", e.PrincipalType, e.PrincipalID, e.Right, e.Effect)
if err := s.recordObjectChange(ctx, tx, e.TenantSlug, e.ObjectType, e.ObjectID, "grant", detail, e.CreatedBy); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return err
}
s.auditObjectChange(ctx, e.TenantSlug, "object_acl.grant", string(e.ObjectType)+":"+e.ObjectID, e.CreatedBy, detail)
return nil
}
// RevokeObjectRight entfernt einen Rechteeintrag.
func (s *Store) RevokeObjectRight(ctx context.Context, tenantSlug string, objType ObjectType, objectID string, principalType PrincipalType, principalID string, right ObjectRight, actor string) error {
if !validObjectType(objType) || !validPrincipalType(principalType) || !validRight(right) {
return errors.New("policy: ungueltige objektrecht-angabe")
}
if tenantSlug == "" || objectID == "" || principalID == "" || actor == "" {
return errors.New("policy: tenant, objekt-id, principal-id und actor sind pflicht")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
DELETE FROM object_acl
WHERE tenant_slug = $1 AND object_type = $2 AND object_id = $3
AND principal_type = $4 AND principal_id = $5 AND object_right = $6
`, tenantSlug, string(objType), objectID, string(principalType), principalID, string(right)); err != nil {
return fmt.Errorf("objektrecht entfernen: %w", err)
}
detail := fmt.Sprintf("%s:%s recht=%s", principalType, principalID, right)
if err := s.recordObjectChange(ctx, tx, tenantSlug, objType, objectID, "revoke", detail, actor); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return err
}
s.auditObjectChange(ctx, tenantSlug, "object_acl.revoke", string(objType)+":"+objectID, actor, detail)
return nil
}
// SetInheritanceBreak unterbricht die Vererbung fuer folderID: Eintraege von
// Vorfahren wirken danach nicht mehr auf diesen Ordner und seinen Teilbaum
// (Akzeptanzkriterium 2).
func (s *Store) SetInheritanceBreak(ctx context.Context, tenantSlug, folderID, actor string) error {
if tenantSlug == "" || folderID == "" || actor == "" {
return errors.New("policy: tenant, ordner-id und actor sind pflicht")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
INSERT INTO object_acl_breaks (tenant_slug, folder_id, created_by)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_slug, folder_id) DO UPDATE SET created_by = $3, created_at = now()
`, tenantSlug, folderID, actor); err != nil {
return fmt.Errorf("vererbung unterbrechen: %w", err)
}
if err := s.recordObjectChange(ctx, tx, tenantSlug, ObjectFolder, folderID, "break_set", "", actor); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return err
}
s.auditObjectChange(ctx, tenantSlug, "object_acl.break_set", "folder:"+folderID, actor, "")
return nil
}
// ClearInheritanceBreak stellt die Vererbung fuer folderID wieder her.
func (s *Store) ClearInheritanceBreak(ctx context.Context, tenantSlug, folderID, actor string) error {
if tenantSlug == "" || folderID == "" || actor == "" {
return errors.New("policy: tenant, ordner-id und actor sind pflicht")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `
DELETE FROM object_acl_breaks WHERE tenant_slug = $1 AND folder_id = $2
`, tenantSlug, folderID); err != nil {
return fmt.Errorf("vererbungsunterbrechung aufheben: %w", err)
}
if err := s.recordObjectChange(ctx, tx, tenantSlug, ObjectFolder, folderID, "break_cleared", "", actor); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return err
}
s.auditObjectChange(ctx, tenantSlug, "object_acl.break_cleared", "folder:"+folderID, actor, "")
return nil
}
// ListObjectRights liefert alle direkt (nicht geerbt) an einem Objekt
// gesetzten Rechte — fuer Verwaltungsoberflaeche/Tests (Akzeptanzkriterium 1).
func (s *Store) ListObjectRights(ctx context.Context, tenantSlug string, objType ObjectType, objectID string) ([]ObjectACLEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT principal_type, principal_id, object_right, effect, created_by
FROM object_acl
WHERE tenant_slug = $1 AND object_type = $2 AND object_id = $3
ORDER BY principal_type, principal_id, object_right
`, tenantSlug, string(objType), objectID)
if err != nil {
return nil, fmt.Errorf("objektrechte lesen: %w", err)
}
defer rows.Close()
var out []ObjectACLEntry
for rows.Next() {
e := ObjectACLEntry{TenantSlug: tenantSlug, ObjectType: objType, ObjectID: objectID}
var pt, right, effect string
if err := rows.Scan(&pt, &e.PrincipalID, &right, &effect, &e.CreatedBy); err != nil {
return nil, fmt.Errorf("objektrecht-zeile lesen: %w", err)
}
e.PrincipalType, e.Right, e.Effect = PrincipalType(pt), ObjectRight(right), ACLEffect(effect)
out = append(out, e)
}
return out, rows.Err()
}
// ObjectAllowed entscheidet, ob der Prinzipal (userID und/oder eine seiner
// groupIDs — vom Aufrufer aufgeloest, siehe ObjectPathNode-Dokumentation)
// das angefragte Recht am LETZTEN Knoten von path hat, unter Beruecksichtigung
// der Vererbung entlang path (Akzeptanzkriterium 2):
// - Eintraege wirken vom naechsten unterbrochenen Vorfahren (ausschliesslich)
// bzw. der Wurzel bis zum Zielobjekt (einschliesslich).
// - Ein ausdrueckliches "deny" irgendwo im wirksamen Pfad gewinnt immer
// gegen jedes "allow" (Akzeptanzkriterium 2).
// - Kein passender Eintrag bedeutet Default-Deny (Akzeptanzkriterium 2,
// RBAC-02-Prinzip), unabhaengig von jeder Rollen-/Grundberechtigung —
// diese Funktion ist eine eigenstaendige Entscheidung, keine Ergaenzung
// 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) {
if tenantSlug == "" {
return false, errors.New("policy: mandant erforderlich")
}
if len(path) == 0 {
return false, ErrEmptyPath
}
if !validRight(right) {
return false, ErrInvalidRight
}
if userID == "" {
return 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
}
key := string(n.Type) + ":" + n.ID
if seen[key] {
return false, ErrCycleInPath
}
seen[key] = true
if n.Type == ObjectFolder {
folderIDs = append(folderIDs, n.ID)
}
}
broken, err := s.folderBreaks(ctx, tenantSlug, folderIDs)
if err != nil {
return false, err
}
startIdx := 0
for i := len(path) - 1; i >= 0; i-- {
if path[i].Type == ObjectFolder && broken[path[i].ID] {
startIdx = i
break
}
}
effectivePath := path[startIdx:]
effects, err := s.pathEffects(ctx, tenantSlug, effectivePath, right, userID, groupIDs)
if err != nil {
return false, err
}
if len(effects) == 0 {
return false, nil // Default-Deny
}
for _, eff := range effects {
if eff == EffectDeny {
return false, nil // explizites Verweigern schlaegt Erlauben
}
}
return true, nil
}
func (s *Store) folderBreaks(ctx context.Context, tenantSlug string, folderIDs []string) (map[string]bool, error) {
out := map[string]bool{}
if len(folderIDs) == 0 {
return out, nil
}
rows, err := s.pool.Query(ctx, `
SELECT folder_id FROM object_acl_breaks WHERE tenant_slug = $1 AND folder_id = ANY($2)
`, tenantSlug, folderIDs)
if err != nil {
return nil, fmt.Errorf("vererbungsunterbrechungen lesen: %w", err)
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("vererbungsunterbrechung lesen: %w", err)
}
out[id] = true
}
return out, rows.Err()
}
func (s *Store) pathEffects(ctx context.Context, tenantSlug string, path []ObjectPathNode, right ObjectRight, userID string, groupIDs []string) ([]ACLEffect, error) {
types := make([]string, len(path))
ids := make([]string, len(path))
for i, n := range path {
types[i], ids[i] = string(n.Type), n.ID
}
// UNNEST(types, ids) mit WITH ORDINALITY zippt beide Arrays paarweise
// zusammen (Objekttyp und Objekt-ID gehoeren je Pfadknoten zusammen,
// nicht als unabhaengiges kartesisches Produkt) — ein Ordner "42" und
// ein Dokument "42" (unterschiedliche Objekte, zufaellig gleiche ID)
// sollen sich nicht gegenseitig matchen.
rows, err := s.pool.Query(ctx, `
SELECT a.effect
FROM object_acl a
JOIN UNNEST($2::text[], $3::text[]) AS p(object_type, object_id) ON true
WHERE a.tenant_slug = $1
AND a.object_type = p.object_type AND a.object_id = p.object_id
AND a.object_right = $4
AND (
(a.principal_type = 'user' AND a.principal_id = $5)
OR (a.principal_type = 'group' AND a.principal_id = ANY($6::text[]))
)
`, tenantSlug, types, ids, string(right), userID, groupIDs)
if err != nil {
return nil, fmt.Errorf("objektrechte entlang des pfads lesen: %w", err)
}
defer rows.Close()
var out []ACLEffect
for rows.Next() {
var e string
if err := rows.Scan(&e); err != nil {
return nil, fmt.Errorf("objektrecht-effekt lesen: %w", err)
}
out = append(out, ACLEffect(e))
}
return out, rows.Err()
}
func (s *Store) recordObjectChange(ctx context.Context, tx pgx.Tx, tenantSlug string, objType ObjectType, objectID, action, detail, actor string) error {
if _, err := tx.Exec(ctx, `
INSERT INTO object_acl_changes (tenant_slug, object_type, object_id, action, detail, actor)
VALUES ($1, $2, $3, $4, $5, $6)
`, tenantSlug, string(objType), objectID, action, detail, actor); err != nil {
return fmt.Errorf("objektrecht-historie schreiben: %w", err)
}
return nil
}
// Duenne Passthroughs auf *Enforcer, damit policyapi weiterhin nur den
// Enforcer haelt (ein einziger Einstiegspunkt, siehe enforcer.go) statt
// zusaetzlich *Store zu importieren.
func (e *Enforcer) GrantObjectRight(ctx context.Context, entry ObjectACLEntry) error {
return e.store.GrantObjectRight(ctx, entry)
}
func (e *Enforcer) RevokeObjectRight(ctx context.Context, tenantSlug string, objType ObjectType, objectID string, principalType PrincipalType, principalID string, right ObjectRight, actor string) error {
return e.store.RevokeObjectRight(ctx, tenantSlug, objType, objectID, principalType, principalID, right, actor)
}
func (e *Enforcer) SetInheritanceBreak(ctx context.Context, tenantSlug, folderID, actor string) error {
return e.store.SetInheritanceBreak(ctx, tenantSlug, folderID, actor)
}
func (e *Enforcer) ClearInheritanceBreak(ctx context.Context, tenantSlug, folderID, actor string) error {
return e.store.ClearInheritanceBreak(ctx, tenantSlug, folderID, actor)
}
func (e *Enforcer) ListObjectRights(ctx context.Context, tenantSlug string, objType ObjectType, objectID string) ([]ObjectACLEntry, error) {
return e.store.ListObjectRights(ctx, tenantSlug, objType, objectID)
}
func (e *Enforcer) ObjectAllowed(ctx context.Context, tenantSlug string, path []ObjectPathNode, right ObjectRight, userID string, groupIDs []string) (bool, error) {
return e.store.ObjectAllowed(ctx, tenantSlug, path, right, userID, groupIDs)
}
func (s *Store) auditObjectChange(ctx context.Context, tenantSlug, action, target, actor, detail string) {
if s.audit == nil {
return
}
meta := map[string]any{}
if detail != "" {
meta["detail"] = detail
}
_ = s.audit.Record(ctx, audit.Event{
TenantSlug: tenantSlug,
Actor: actor,
Action: action,
Target: target,
Metadata: meta,
})
}
+379
View File
@@ -0,0 +1,379 @@
package policy
import (
"context"
"errors"
"fmt"
"os"
"sort"
"testing"
"time"
"gitea.perlbach24.de/scripte/nexarch/internal/audit"
)
// Akzeptanzkriterium 1: Objektrechte sind je Mandant speicherbar und ueber
// die Verwaltungsschnittstelle (hier: Store direkt, HTTP-Ebene in
// policyapi getestet) aenderbar.
func TestObjectACL_GrantListRevoke(t *testing.T) {
pool, enforcer := migratedPool(t)
ctx := context.Background()
if err := enforcer.GrantObjectRight(ctx, ObjectACLEntry{
TenantSlug: "acme", ObjectType: ObjectFolder, ObjectID: "f1",
PrincipalType: PrincipalUser, PrincipalID: "u1", Right: RightRead, Effect: EffectAllow, CreatedBy: "admin",
}); err != nil {
t.Fatalf("grant: %v", err)
}
entries, err := enforcer.ListObjectRights(ctx, "acme", ObjectFolder, "f1")
if err != nil || len(entries) != 1 || entries[0].Effect != EffectAllow {
t.Fatalf("list nach grant: %+v, %v", entries, err)
}
// erneutes Grant mit anderem Effekt ersetzt den Eintrag (ON CONFLICT),
// legt keinen zweiten an.
if err := enforcer.GrantObjectRight(ctx, ObjectACLEntry{
TenantSlug: "acme", ObjectType: ObjectFolder, ObjectID: "f1",
PrincipalType: PrincipalUser, PrincipalID: "u1", Right: RightRead, Effect: EffectDeny, CreatedBy: "admin",
}); err != nil {
t.Fatalf("re-grant: %v", err)
}
entries, err = enforcer.ListObjectRights(ctx, "acme", ObjectFolder, "f1")
if err != nil || len(entries) != 1 || entries[0].Effect != EffectDeny {
t.Fatalf("list nach re-grant: %+v, %v", entries, err)
}
if err := enforcer.RevokeObjectRight(ctx, "acme", ObjectFolder, "f1", PrincipalUser, "u1", RightRead, "admin"); err != nil {
t.Fatalf("revoke: %v", err)
}
entries, err = enforcer.ListObjectRights(ctx, "acme", ObjectFolder, "f1")
if err != nil || len(entries) != 0 {
t.Fatalf("list nach revoke: %+v, %v", entries, err)
}
_ = pool
}
// Validierung: ungueltige Werte werden abgewiesen (Teil von Pruefung 3
// "ungueltige ID" — hier auf Ebene der Eingabewerte selbst).
func TestObjectACL_GrantRejectsInvalidValues(t *testing.T) {
_, enforcer := migratedPool(t)
ctx := context.Background()
base := ObjectACLEntry{TenantSlug: "acme", ObjectID: "f1", PrincipalID: "u1", Right: RightRead, Effect: EffectAllow, CreatedBy: "admin"}
bad := base
bad.ObjectType = "raum"
bad.PrincipalType = PrincipalUser
if err := enforcer.GrantObjectRight(ctx, bad); !errors.Is(err, ErrInvalidObjectType) {
t.Errorf("ungueltiger objekttyp: %v", err)
}
bad = base
bad.ObjectType, bad.PrincipalType = ObjectFolder, "abteilung"
if err := enforcer.GrantObjectRight(ctx, bad); !errors.Is(err, ErrInvalidPrincipalType) {
t.Errorf("ungueltiger principal-typ: %v", err)
}
bad = base
bad.ObjectType, bad.PrincipalType, bad.Right = ObjectFolder, PrincipalUser, "loeschen"
if err := enforcer.GrantObjectRight(ctx, bad); !errors.Is(err, ErrInvalidRight) {
t.Errorf("ungueltiges recht: %v", err)
}
}
// Akzeptanzkriterium 2 + Pruefung 1 (Vererbungsmatrix): tiefer Baum,
// Unterbrechung, Gruppe, Verweigern gegen Erlauben.
func TestObjectACL_InheritanceMatrix(t *testing.T) {
pool, enforcer := migratedPool(t)
ctx := context.Background()
_ = pool
// Baum: root -> mid -> leaf (Ordner), darin ein Dokument doc.
root := ObjectPathNode{Type: ObjectFolder, ID: "root"}
mid := ObjectPathNode{Type: ObjectFolder, ID: "mid"}
leaf := ObjectPathNode{Type: ObjectFolder, ID: "leaf"}
doc := ObjectPathNode{Type: ObjectDocument, ID: "doc"}
fullPath := []ObjectPathNode{root, mid, leaf, doc}
// 1) Kein Eintrag irgendwo -> Default-Deny.
allowed, err := enforcer.ObjectAllowed(ctx, "acme", fullPath, RightRead, "u1", nil)
if err != nil || allowed {
t.Fatalf("default-deny ohne eintrag: allowed=%v err=%v", allowed, err)
}
// 2) Erlauben an der Wurzel wirkt bis zum Dokument (Vererbung ueber
// mehrere Ebenen).
if err := enforcer.GrantObjectRight(ctx, ObjectACLEntry{
TenantSlug: "acme", ObjectType: ObjectFolder, ObjectID: "root",
PrincipalType: PrincipalUser, PrincipalID: "u1", Right: RightRead, Effect: EffectAllow, CreatedBy: "admin",
}); err != nil {
t.Fatal(err)
}
allowed, err = enforcer.ObjectAllowed(ctx, "acme", fullPath, RightRead, "u1", nil)
if err != nil || !allowed {
t.Fatalf("erlauben an wurzel muss bis zum dokument wirken: allowed=%v err=%v", allowed, err)
}
// anderes Recht (write) bleibt unberuehrt — Rechte sind nicht hierarchisch.
allowed, err = enforcer.ObjectAllowed(ctx, "acme", fullPath, RightWrite, "u1", nil)
if err != nil || allowed {
t.Fatalf("read-erlaubnis darf write nicht implizieren: allowed=%v err=%v", allowed, err)
}
// 3) Unterbrechung an "mid": das Erlauben an der Wurzel wirkt nicht
// mehr auf mid/leaf/doc.
if err := enforcer.SetInheritanceBreak(ctx, "acme", "mid", "admin"); err != nil {
t.Fatal(err)
}
allowed, err = enforcer.ObjectAllowed(ctx, "acme", fullPath, RightRead, "u1", nil)
if err != nil || allowed {
t.Fatalf("nach unterbrechung darf wurzel-erlauben nicht mehr wirken: allowed=%v err=%v", allowed, err)
}
// Ein eigener Eintrag AUF dem unterbrochenen Ordner selbst wirkt aber
// weiterhin (die Unterbrechung kappt nur die Vererbung von OBEN).
if err := enforcer.GrantObjectRight(ctx, ObjectACLEntry{
TenantSlug: "acme", ObjectType: ObjectFolder, ObjectID: "mid",
PrincipalType: PrincipalUser, PrincipalID: "u1", Right: RightRead, Effect: EffectAllow, CreatedBy: "admin",
}); err != nil {
t.Fatal(err)
}
allowed, err = enforcer.ObjectAllowed(ctx, "acme", fullPath, RightRead, "u1", nil)
if err != nil || !allowed {
t.Fatalf("eintrag auf dem unterbrochenen ordner selbst muss wirken: allowed=%v err=%v", allowed, err)
}
// 4) Aufheben der Unterbrechung stellt die Vererbung wieder her.
if err := enforcer.ClearInheritanceBreak(ctx, "acme", "mid", "admin"); err != nil {
t.Fatal(err)
}
if err := enforcer.RevokeObjectRight(ctx, "acme", ObjectFolder, "mid", PrincipalUser, "u1", RightRead, "admin"); err != nil {
t.Fatal(err)
}
allowed, err = enforcer.ObjectAllowed(ctx, "acme", fullPath, RightRead, "u1", nil)
if err != nil || !allowed {
t.Fatalf("nach aufheben der unterbrechung muss wurzel-erlauben wieder wirken: allowed=%v err=%v", allowed, err)
}
// 5) Gruppenmitgliedschaft: u2 ist selbst nicht genannt, aber Mitglied
// von g1, dem auf "leaf" ein Recht gewaehrt wurde.
if err := enforcer.GrantObjectRight(ctx, ObjectACLEntry{
TenantSlug: "acme", ObjectType: ObjectFolder, ObjectID: "leaf",
PrincipalType: PrincipalGroup, PrincipalID: "g1", Right: RightWrite, Effect: EffectAllow, CreatedBy: "admin",
}); err != nil {
t.Fatal(err)
}
allowed, err = enforcer.ObjectAllowed(ctx, "acme", fullPath, RightWrite, "u2", nil)
if err != nil || allowed {
t.Fatalf("ohne gruppenzugehoerigkeit im request darf write nicht erlaubt sein: allowed=%v err=%v", allowed, err)
}
allowed, err = enforcer.ObjectAllowed(ctx, "acme", fullPath, RightWrite, "u2", []string{"g1"})
if err != nil || !allowed {
t.Fatalf("mit gruppenzugehoerigkeit g1 muss write erlaubt sein: allowed=%v err=%v", allowed, err)
}
// 6) Explizites Verweigern schlaegt Erlauben: root erlaubt u3 read,
// leaf verweigert es ausdruecklich -> insgesamt verweigert.
if err := enforcer.GrantObjectRight(ctx, ObjectACLEntry{
TenantSlug: "acme", ObjectType: ObjectFolder, ObjectID: "root",
PrincipalType: PrincipalUser, PrincipalID: "u3", Right: RightRead, Effect: EffectAllow, CreatedBy: "admin",
}); err != nil {
t.Fatal(err)
}
allowed, err = enforcer.ObjectAllowed(ctx, "acme", fullPath, RightRead, "u3", nil)
if err != nil || !allowed {
t.Fatalf("erlauben an der wurzel fuer u3: allowed=%v err=%v", allowed, err)
}
if err := enforcer.GrantObjectRight(ctx, ObjectACLEntry{
TenantSlug: "acme", ObjectType: ObjectFolder, ObjectID: "leaf",
PrincipalType: PrincipalUser, PrincipalID: "u3", Right: RightRead, Effect: EffectDeny, CreatedBy: "admin",
}); err != nil {
t.Fatal(err)
}
allowed, err = enforcer.ObjectAllowed(ctx, "acme", fullPath, RightRead, "u3", nil)
if err != nil || allowed {
t.Fatalf("verweigern auf leaf muss erlauben von root schlagen: allowed=%v err=%v", allowed, err)
}
}
// Pruefung 1 (Teil "Verschieben eines Ordners aendert die wirksamen Rechte
// des Teilbaums"): der Aufrufer loest den Pfad auf und uebergibt ihn je
// Anfrage — ein "Verschieben" ist deshalb einfach ein anderer Pfad in der
// naechsten Anfrage, ohne Zustand in Core. Dieser Test belegt genau das.
func TestObjectACL_MovingChangesEffectivePathImmediately(t *testing.T) {
_, enforcer := migratedPool(t)
ctx := context.Background()
if err := enforcer.GrantObjectRight(ctx, ObjectACLEntry{
TenantSlug: "acme", ObjectType: ObjectFolder, ObjectID: "old-parent",
PrincipalType: PrincipalUser, PrincipalID: "u1", Right: RightRead, Effect: EffectAllow, CreatedBy: "admin",
}); err != nil {
t.Fatal(err)
}
underOldParent := []ObjectPathNode{{Type: ObjectFolder, ID: "old-parent"}, {Type: ObjectFolder, ID: "moved"}}
allowed, err := enforcer.ObjectAllowed(ctx, "acme", underOldParent, RightRead, "u1", nil)
if err != nil || !allowed {
t.Fatalf("unter altem elternordner erlaubt: allowed=%v err=%v", allowed, err)
}
// "Verschieben" = derselbe Ordner "moved" erscheint jetzt unter einem
// anderen Elternordner ohne eigenes Recht -> sofort andere Entscheidung,
// ohne dass Core selbst etwas ueber die Verschiebung erfaehrt.
underNewParent := []ObjectPathNode{{Type: ObjectFolder, ID: "new-parent"}, {Type: ObjectFolder, ID: "moved"}}
allowed, err = enforcer.ObjectAllowed(ctx, "acme", underNewParent, RightRead, "u1", nil)
if err != nil || allowed {
t.Fatalf("nach verschieben unter neuen elternordner ohne recht: allowed=%v err=%v", allowed, err)
}
}
// Pruefung 3: Negativfaelle.
func TestObjectACL_NegativeCases(t *testing.T) {
_, enforcer := migratedPool(t)
ctx := context.Background()
if err := enforcer.GrantObjectRight(ctx, ObjectACLEntry{
TenantSlug: "acme", ObjectType: ObjectFolder, ObjectID: "f1",
PrincipalType: PrincipalUser, PrincipalID: "u1", Right: RightRead, Effect: EffectAllow, CreatedBy: "admin",
}); err != nil {
t.Fatal(err)
}
path := []ObjectPathNode{{Type: ObjectFolder, ID: "f1"}}
// fremder Mandant: derselbe Pfad/Principal in einem anderen Mandanten
// sieht den Eintrag nicht.
allowed, err := enforcer.ObjectAllowed(ctx, "globex", path, RightRead, "u1", nil)
if err != nil || allowed {
t.Errorf("fremder mandant darf nicht erlaubt sein: allowed=%v err=%v", allowed, err)
}
// geloeschtes/unbekanntes Objekt: keine Eintraege -> Default-Deny, kein Fehler.
allowed, err = enforcer.ObjectAllowed(ctx, "acme", []ObjectPathNode{{Type: ObjectFolder, ID: "geloescht"}}, RightRead, "u1", nil)
if err != nil || allowed {
t.Errorf("unbekanntes objekt: allowed=%v err=%v", allowed, err)
}
// ungueltige ID (leer).
if _, err := enforcer.ObjectAllowed(ctx, "acme", []ObjectPathNode{{Type: ObjectFolder, ID: ""}}, RightRead, "u1", nil); !errors.Is(err, ErrInvalidObjectType) {
t.Errorf("leere objekt-id: %v", err)
}
// Zyklus im Baum: derselbe Knoten taucht zweimal im Pfad auf.
cyclePath := []ObjectPathNode{{Type: ObjectFolder, ID: "a"}, {Type: ObjectFolder, ID: "b"}, {Type: ObjectFolder, ID: "a"}}
if _, err := enforcer.ObjectAllowed(ctx, "acme", cyclePath, RightRead, "u1", nil); !errors.Is(err, ErrCycleInPath) {
t.Errorf("zyklus im pfad: %v", err)
}
// leerer Pfad.
if _, err := enforcer.ObjectAllowed(ctx, "acme", nil, RightRead, "u1", nil); !errors.Is(err, ErrEmptyPath) {
t.Errorf("leerer pfad: %v", err)
}
}
// Pruefung 3 ("Policy-Ausfall fuehrt zu Ablehnung"): eine Datenbankstoerung
// liefert einen Fehler, niemals allowed=true.
func TestObjectACL_StoreFailureNeverReturnsAllowed(t *testing.T) {
pool, enforcer := migratedPool(t)
pool.Close() // erzwingt einen Verbindungsfehler bei der naechsten Abfrage
ctx := context.Background()
allowed, err := enforcer.ObjectAllowed(ctx, "acme", []ObjectPathNode{{Type: ObjectFolder, ID: "f1"}}, RightRead, "u1", nil)
if err == nil {
t.Fatal("bei db-ausfall wird ein fehler erwartet")
}
if allowed {
t.Fatal("bei db-ausfall darf niemals allowed=true geliefert werden")
}
}
// Akzeptanzkriterium 3: jede Aenderung steht im Audit-Log.
func TestObjectACL_ChangesAreAudited(t *testing.T) {
pool, _ := migratedPool(t)
ctx := context.Background()
store := NewStore(pool).WithAudit(audit.NewLog(pool))
enforcer := NewEnforcer(store)
if err := enforcer.GrantObjectRight(ctx, ObjectACLEntry{
TenantSlug: "acme", ObjectType: ObjectFolder, ObjectID: "f-audit",
PrincipalType: PrincipalUser, PrincipalID: "u1", Right: RightRead, Effect: EffectAllow, CreatedBy: "admin",
}); err != nil {
t.Fatal(err)
}
if err := enforcer.SetInheritanceBreak(ctx, "acme", "f-audit", "admin"); err != nil {
t.Fatal(err)
}
if err := enforcer.RevokeObjectRight(ctx, "acme", ObjectFolder, "f-audit", PrincipalUser, "u1", RightRead, "admin"); err != nil {
t.Fatal(err)
}
var n int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM audit_events
WHERE tenant_slug = 'acme' AND action LIKE 'object_acl.%' AND target = 'folder:f-audit'
`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 3 {
t.Fatalf("erwartet 3 audit-eintraege (grant, break_set, revoke), habe %d", n)
}
}
// Pruefung 2 (Leistung): 10.000 Objekte, Pfadtiefe 10, p95 < 50 ms.
// Zielwert per TEST_PERF=1 gemessen (Muster: nur bei Bedarf laufen lassen,
// wie die serielle Lastmessung im DMS/Core-Gate).
func TestObjectACL_PerformanceAtScale(t *testing.T) {
if os.Getenv("TEST_PERF") == "" {
t.Skip("TEST_PERF nicht gesetzt, Lastmessung uebersprungen")
}
pool, enforcer := migratedPool(t)
ctx := context.Background()
// Baum der Tiefe 10, ein Recht ganz oben, dazu 10.000 unbeteiligte
// Eintraege auf anderen Objekten (Grundrauschen fuer die Indexnutzung).
path := make([]ObjectPathNode, 10)
for i := 0; i < 10; i++ {
path[i] = ObjectPathNode{Type: ObjectFolder, ID: fmt.Sprintf("perf-%d", i)}
}
if err := enforcer.GrantObjectRight(ctx, ObjectACLEntry{
TenantSlug: "acme", ObjectType: ObjectFolder, ObjectID: path[0].ID,
PrincipalType: PrincipalUser, PrincipalID: "u1", Right: RightRead, Effect: EffectAllow, CreatedBy: "admin",
}); err != nil {
t.Fatal(err)
}
const total = 10000
const chunk = 1000
for i := 0; i < total; i += chunk {
end := i + chunk
if end > total {
end = total
}
ids := make([]string, 0, end-i)
for j := i; j < end; j++ {
ids = append(ids, fmt.Sprintf("noise-%d", j))
}
if _, err := pool.Exec(ctx, `
INSERT INTO object_acl (tenant_slug, object_type, object_id, principal_type, principal_id, object_right, effect, created_by)
SELECT 'acme', 'folder', id, 'user', 'someone-else', 'read', 'allow', 'admin' FROM unnest($1::text[]) AS id
`, ids); err != nil {
t.Fatal(err)
}
}
const iterations = 200
durations := make([]time.Duration, 0, iterations)
for i := 0; i < iterations; i++ {
start := time.Now()
if _, err := enforcer.ObjectAllowed(ctx, "acme", path, RightRead, "u1", nil); err != nil {
t.Fatal(err)
}
durations = append(durations, time.Since(start))
}
p95 := objectACLPercentile(durations, 0.95)
t.Logf("p95 ueber %d anfragen bei 10.000 objekten/tiefe 10: %v", iterations, p95)
if p95 > 50*time.Millisecond {
t.Errorf("p95 = %v, zielwert 50ms ueberschritten", p95)
}
}
func objectACLPercentile(durations []time.Duration, p float64) time.Duration {
sorted := append([]time.Duration(nil), durations...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
idx := int(float64(len(sorted)-1) * p)
return sorted[idx]
}
+247 -3
View File
@@ -40,12 +40,47 @@ func Mount(mux *http.ServeMux, enforcer *policy.Enforcer, flags *flag.Service, s
o(s)
}
mux.HandleFunc("POST /authorize", RequireServiceToken(serviceToken, s.authorize))
// RBAC-11: Verwaltungsschnittstelle fuer Objektrechte (Akzeptanzkriterium
// 1). Dieselbe Authentifizierung wie /authorize — object_acl liegt in
// derselben Registry-DB, ein eigener Dienst/eine Session-Auth waere hier
// unnoetige Duplizierung (RBAC-05, die Rechteverwaltungsoberflaeche
// selbst, ist ausdruecklich nicht Bestandteil dieser Kachel).
mux.HandleFunc("POST /object-acl/grant", RequireServiceToken(serviceToken, s.objectACLGrant))
mux.HandleFunc("POST /object-acl/revoke", RequireServiceToken(serviceToken, s.objectACLRevoke))
mux.HandleFunc("POST /object-acl/break", RequireServiceToken(serviceToken, s.objectACLBreak))
mux.HandleFunc("POST /object-acl/unbreak", RequireServiceToken(serviceToken, s.objectACLUnbreak))
mux.HandleFunc("GET /object-acl/list", RequireServiceToken(serviceToken, s.objectACLList))
}
type authorizeRequest struct {
Role string `json:"role"`
Permission string `json:"permission"`
Tenant string `json:"tenant,omitempty"`
Role string `json:"role,omitempty"`
Permission string `json:"permission,omitempty"`
Tenant string `json:"tenant,omitempty"`
Object *objectQuery `json:"object,omitempty"`
}
// objectQuery ist der zweite, eigenstaendige Anfragemodus von /authorize
// (RBAC-11): wenn gesetzt, werden role/permission NICHT geprueft — die
// Antwort spiegelt ausschliesslich die Objektrechte-Entscheidung wider
// (Akzeptanzkriterium 3: "ohne bestehende Aufrufer ohne Objektbezug zu
// aendern" gilt dadurch strukturell, role/permission-Anfragen bleiben
// unberuehrt). Ob und wie ein Modul Objektrechte mit der rollenbasierten
// Grundberechtigung kombiniert, ist dessen eigene Entscheidung (siehe
// Ticket "Nicht Bestandteil").
type objectQuery struct {
Path []objectPathNode `json:"path"`
Right string `json:"right"`
UserID string `json:"user_id"`
GroupIDs []string `json:"group_ids,omitempty"`
}
type objectPathNode struct {
Type string `json:"type"`
ID string `json:"id"`
}
func (n objectPathNode) toDomain() policy.ObjectPathNode {
return policy.ObjectPathNode{Type: policy.ObjectType(n.Type), ID: n.ID}
}
type authorizeResponse struct {
@@ -64,6 +99,10 @@ func (s *server) authorize(w http.ResponseWriter, r *http.Request) {
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
return
}
if req.Object != nil {
s.authorizeObject(w, r, req)
return
}
if req.Role == "" || req.Permission == "" {
http.Error(w, "role und permission sind pflichtfelder", http.StatusBadRequest)
return
@@ -99,3 +138,208 @@ func (s *server) authorize(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// authorizeObject beantwortet den Objektrechte-Anfragemodus (RBAC-11) —
// ruft AUSSCHLIESSLICH enforcer.ObjectAllowed auf, keine zweite
// Entscheidungslogik (dasselbe Prinzip wie authorize()).
func (s *server) authorizeObject(w http.ResponseWriter, r *http.Request, req authorizeRequest) {
q := req.Object
if req.Tenant == "" {
http.Error(w, "tenant ist fuer objektrechte-anfragen pflicht", http.StatusBadRequest)
return
}
if err := tenant.ValidateSlug(req.Tenant); err != nil {
http.Error(w, "ungültiger mandant: "+err.Error(), http.StatusBadRequest)
return
}
if s.allowed != nil && !s.allowed[req.Tenant] {
http.Error(w, "mandant für dieses service-token nicht zugelassen", http.StatusForbidden)
return
}
if len(q.Path) == 0 {
http.Error(w, "object.path darf nicht leer sein", http.StatusBadRequest)
return
}
if q.UserID == "" {
http.Error(w, "object.user_id ist pflicht", http.StatusBadRequest)
return
}
path := make([]policy.ObjectPathNode, len(q.Path))
for i, n := range q.Path {
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}
if err != nil {
switch {
case errors.Is(err, policy.ErrCycleInPath),
errors.Is(err, policy.ErrInvalidObjectType),
errors.Is(err, policy.ErrInvalidRight),
errors.Is(err, policy.ErrEmptyPath):
http.Error(w, err.Error(), http.StatusBadRequest)
default:
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
if !allowed {
resp.Reason = "object_denied"
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
type objectACLEntryRequest struct {
Tenant string `json:"tenant"`
ObjectType string `json:"object_type"`
ObjectID string `json:"object_id"`
PrincipalType string `json:"principal_type"`
PrincipalID string `json:"principal_id"`
Right string `json:"right"`
Effect string `json:"effect,omitempty"`
Actor string `json:"actor"`
}
func (r objectACLEntryRequest) validateCommon() error {
if r.Tenant == "" || r.ObjectID == "" || r.PrincipalID == "" || r.Actor == "" {
return errors.New("tenant, object_id, principal_id und actor sind pflichtfelder")
}
return tenant.ValidateSlug(r.Tenant)
}
func (s *server) tenantAllowed(w http.ResponseWriter, slug string) bool {
if s.allowed != nil && !s.allowed[slug] {
http.Error(w, "mandant für dieses service-token nicht zugelassen", http.StatusForbidden)
return false
}
return true
}
func (s *server) objectACLGrant(w http.ResponseWriter, r *http.Request) {
var req objectACLEntryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
return
}
if err := req.validateCommon(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !s.tenantAllowed(w, req.Tenant) {
return
}
if req.Effect == "" {
req.Effect = string(policy.EffectAllow)
}
err := s.enforcer.GrantObjectRight(r.Context(), policy.ObjectACLEntry{
TenantSlug: req.Tenant,
ObjectType: policy.ObjectType(req.ObjectType),
ObjectID: req.ObjectID,
PrincipalType: policy.PrincipalType(req.PrincipalType),
PrincipalID: req.PrincipalID,
Right: policy.ObjectRight(req.Right),
Effect: policy.ACLEffect(req.Effect),
CreatedBy: req.Actor,
})
writeACLResult(w, err)
}
func (s *server) objectACLRevoke(w http.ResponseWriter, r *http.Request) {
var req objectACLEntryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
return
}
if err := req.validateCommon(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !s.tenantAllowed(w, req.Tenant) {
return
}
err := s.enforcer.RevokeObjectRight(r.Context(), req.Tenant, policy.ObjectType(req.ObjectType), req.ObjectID,
policy.PrincipalType(req.PrincipalType), req.PrincipalID, policy.ObjectRight(req.Right), req.Actor)
writeACLResult(w, err)
}
type breakRequest struct {
Tenant string `json:"tenant"`
FolderID string `json:"folder_id"`
Actor string `json:"actor"`
}
func (s *server) objectACLBreak(w http.ResponseWriter, r *http.Request) {
s.handleBreak(w, r, true)
}
func (s *server) objectACLUnbreak(w http.ResponseWriter, r *http.Request) {
s.handleBreak(w, r, false)
}
func (s *server) handleBreak(w http.ResponseWriter, r *http.Request, set bool) {
var req breakRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger request-body: "+err.Error(), http.StatusBadRequest)
return
}
if req.Tenant == "" || req.FolderID == "" || req.Actor == "" {
http.Error(w, "tenant, folder_id und actor sind pflichtfelder", http.StatusBadRequest)
return
}
if err := tenant.ValidateSlug(req.Tenant); err != nil {
http.Error(w, "ungültiger mandant: "+err.Error(), http.StatusBadRequest)
return
}
if !s.tenantAllowed(w, req.Tenant) {
return
}
var err error
if set {
err = s.enforcer.SetInheritanceBreak(r.Context(), req.Tenant, req.FolderID, req.Actor)
} else {
err = s.enforcer.ClearInheritanceBreak(r.Context(), req.Tenant, req.FolderID, req.Actor)
}
writeACLResult(w, err)
}
func (s *server) objectACLList(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
tenantSlug, objType, objectID := q.Get("tenant"), q.Get("object_type"), q.Get("object_id")
if tenantSlug == "" || objType == "" || objectID == "" {
http.Error(w, "tenant, object_type und object_id sind pflicht", http.StatusBadRequest)
return
}
if err := tenant.ValidateSlug(tenantSlug); err != nil {
http.Error(w, "ungültiger mandant: "+err.Error(), http.StatusBadRequest)
return
}
if !s.tenantAllowed(w, tenantSlug) {
return
}
entries, err := s.enforcer.ListObjectRights(r.Context(), tenantSlug, policy.ObjectType(objType), objectID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(entries)
}
func writeACLResult(w http.ResponseWriter, err error) {
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, policy.ErrInvalidObjectType),
errors.Is(err, policy.ErrInvalidPrincipalType),
errors.Is(err, policy.ErrInvalidRight),
errors.Is(err, policy.ErrInvalidEffect):
status = http.StatusBadRequest
}
http.Error(w, err.Error(), status)
return
}
w.WriteHeader(http.StatusNoContent)
}
+21
View File
@@ -54,10 +54,31 @@ func setupTestFull(t *testing.T) (*policy.Enforcer, *flag.Service, *policy.Store
rollout_percentage INT NOT NULL DEFAULT 0, target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS object_acl (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_slug TEXT NOT NULL, object_type TEXT NOT NULL CHECK (object_type IN ('folder','document')),
object_id TEXT NOT NULL, principal_type TEXT NOT NULL CHECK (principal_type IN ('user','group')),
principal_id TEXT NOT NULL, object_right TEXT NOT NULL CHECK (object_right IN ('read','write','manage')),
effect TEXT NOT NULL CHECK (effect IN ('allow','deny')), created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_slug, object_type, object_id, principal_type, principal_id, object_right)
);
CREATE TABLE IF NOT EXISTS object_acl_breaks (
tenant_slug TEXT NOT NULL, folder_id TEXT NOT NULL, created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (tenant_slug, folder_id)
);
CREATE TABLE IF NOT EXISTS object_acl_changes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, tenant_slug TEXT NOT NULL,
object_type TEXT NOT NULL, object_id TEXT NOT NULL, action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '', actor TEXT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `DELETE FROM object_acl_changes WHERE tenant_slug LIKE 'rbac11\_%' ESCAPE '\'`)
_, _ = pool.Exec(context.Background(), `DELETE FROM object_acl_breaks WHERE tenant_slug LIKE 'rbac11\_%' ESCAPE '\'`)
_, _ = pool.Exec(context.Background(), `DELETE FROM object_acl WHERE tenant_slug LIKE 'rbac11\_%' ESCAPE '\'`)
_, _ = pool.Exec(context.Background(), `DELETE FROM policy_module_scopes WHERE role LIKE 'rbac06\_%' ESCAPE '\'`)
_, _ = pool.Exec(context.Background(), `DELETE FROM feature_flags WHERE key LIKE 'rbac06\_%' ESCAPE '\'`)
_, _ = pool.Exec(context.Background(), `DELETE FROM policy_rule_changes WHERE role LIKE 'rbac06\_%' ESCAPE '\'`)
+223
View File
@@ -0,0 +1,223 @@
package policyapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.perlbach24.de/scripte/nexarch/internal/policy"
)
func doJSON(t *testing.T, server *httptest.Server, method, path string, body any, token string) *http.Response {
t.Helper()
var reader *bytes.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(b)
} else {
reader = bytes.NewReader(nil)
}
req, err := http.NewRequest(method, server.URL+path, reader)
if err != nil {
t.Fatal(err)
}
if token != "" {
req.Header.Set("X-Service-Token", token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
// Akzeptanzkriterium 3: die Entscheidung ueber den bestehenden Policy-
// Endpunkt liefert Objektrechte, ohne bestehende role/permission-Aufrufer
// zu aendern (dieser Test ruft ausschliesslich den neuen Objektmodus auf).
func TestObjectACLHandler_AuthorizeObjectMode(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_http"
// Ohne jeden ACL-Eintrag: Default-Deny.
resp := doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{
Tenant: tenantSlug,
Object: &objectQuery{
Path: []objectPathNode{{Type: "folder", ID: "f1"}},
Right: "read",
UserID: "u1",
},
}, testToken)
var out authorizeResponse
if resp.StatusCode != http.StatusOK {
t.Fatalf("status: %d", resp.StatusCode)
}
_ = json.NewDecoder(resp.Body).Decode(&out)
_ = resp.Body.Close()
if out.Allowed || out.Reason != "object_denied" {
t.Fatalf("default-deny erwartet, habe %+v", out)
}
// Verwaltungsendpunkt: Recht gewaehren.
grantResp := doJSON(t, server, http.MethodPost, "/object-acl/grant", map[string]string{
"tenant": tenantSlug, "object_type": "folder", "object_id": "f1",
"principal_type": "user", "principal_id": "u1", "right": "read", "effect": "allow", "actor": "admin",
}, testToken)
if grantResp.StatusCode != http.StatusNoContent {
t.Fatalf("grant status: %d", grantResp.StatusCode)
}
_ = grantResp.Body.Close()
resp = doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{
Tenant: tenantSlug,
Object: &objectQuery{
Path: []objectPathNode{{Type: "folder", ID: "f1"}},
Right: "read",
UserID: "u1",
},
}, testToken)
out = authorizeResponse{}
_ = json.NewDecoder(resp.Body).Decode(&out)
_ = resp.Body.Close()
if !out.Allowed {
t.Fatalf("nach grant erwartet allowed=true, habe %+v", out)
}
// Verwaltungsendpunkt: Liste zeigt den Eintrag.
listResp := doJSON(t, server, http.MethodGet, "/object-acl/list?tenant="+tenantSlug+"&object_type=folder&object_id=f1", nil, testToken)
var entries []policy.ObjectACLEntry
_ = json.NewDecoder(listResp.Body).Decode(&entries)
_ = listResp.Body.Close()
if len(entries) != 1 {
t.Fatalf("erwartet 1 eintrag, habe %d", len(entries))
}
// Verwaltungsendpunkt: Unterbrechung setzen, dann Recht entziehen.
breakResp := doJSON(t, server, http.MethodPost, "/object-acl/break", map[string]string{
"tenant": tenantSlug, "folder_id": "f1", "actor": "admin",
}, testToken)
if breakResp.StatusCode != http.StatusNoContent {
t.Fatalf("break status: %d", breakResp.StatusCode)
}
_ = breakResp.Body.Close()
revokeResp := doJSON(t, server, http.MethodPost, "/object-acl/revoke", map[string]string{
"tenant": tenantSlug, "object_type": "folder", "object_id": "f1",
"principal_type": "user", "principal_id": "u1", "right": "read", "actor": "admin",
}, testToken)
if revokeResp.StatusCode != http.StatusNoContent {
t.Fatalf("revoke status: %d", revokeResp.StatusCode)
}
_ = revokeResp.Body.Close()
resp = doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{
Tenant: tenantSlug,
Object: &objectQuery{Path: []objectPathNode{{Type: "folder", ID: "f1"}}, Right: "read", UserID: "u1"},
}, testToken)
out = authorizeResponse{}
_ = json.NewDecoder(resp.Body).Decode(&out)
_ = resp.Body.Close()
if out.Allowed {
t.Fatalf("nach revoke erwartet allowed=false, habe %+v", out)
}
}
// Bestehendes Verhalten bleibt unveraendert: ein Request ohne Object-Feld
// nimmt weiterhin den alten role/permission-Pfad.
func TestObjectACLHandler_RoleModeUnaffected(t *testing.T) {
enforcer, flags := setupTest(t)
mux := http.NewServeMux()
Mount(mux, enforcer, flags, testToken)
server := httptest.NewServer(mux)
defer server.Close()
status, allowed := authorize(t, server, "rbac06_erlaubte_rolle", "rbac06_recht")
if status != http.StatusOK || !allowed {
t.Fatalf("status=%d allowed=%v", status, allowed)
}
}
// Authentifizierung und Validierung.
func TestObjectACLHandler_AuthAndValidation(t *testing.T) {
enforcer, flags := setupTest(t)
mux := http.NewServeMux()
Mount(mux, enforcer, flags, testToken)
server := httptest.NewServer(mux)
defer server.Close()
// Ohne Service-Token.
resp := doJSON(t, server, http.MethodPost, "/object-acl/grant", map[string]string{
"tenant": "rbac11_auth", "object_type": "folder", "object_id": "f1",
"principal_type": "user", "principal_id": "u1", "right": "read", "actor": "admin",
}, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("ohne token: status=%d", resp.StatusCode)
}
_ = resp.Body.Close()
// Objektanfrage ohne Tenant.
resp = doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{
Object: &objectQuery{Path: []objectPathNode{{Type: "folder", ID: "f1"}}, Right: "read", UserID: "u1"},
}, testToken)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("objekt-anfrage ohne tenant: status=%d", resp.StatusCode)
}
_ = resp.Body.Close()
// Leerer Pfad.
resp = doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{
Tenant: "rbac11_auth",
Object: &objectQuery{Right: "read", UserID: "u1"},
}, testToken)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("leerer pfad: status=%d", resp.StatusCode)
}
_ = resp.Body.Close()
// Ungueltiger objekttyp beim Grant.
resp = doJSON(t, server, http.MethodPost, "/object-acl/grant", map[string]string{
"tenant": "rbac11_auth", "object_type": "raum", "object_id": "f1",
"principal_type": "user", "principal_id": "u1", "right": "read", "actor": "admin",
}, testToken)
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("ungueltiger objekttyp: status=%d", resp.StatusCode)
}
_ = resp.Body.Close()
}
// Fremder Mandant: ein per WithAllowedTenants gebundenes Service-Token
// darf Objektrechte nur fuer die zugelassenen Mandanten anfragen/aendern.
func TestObjectACLHandler_TenantNotAllowed(t *testing.T) {
enforcer, flags := setupTest(t)
mux := http.NewServeMux()
Mount(mux, enforcer, flags, testToken, WithAllowedTenants([]string{"rbac11_erlaubt"}))
server := httptest.NewServer(mux)
defer server.Close()
resp := doJSON(t, server, http.MethodPost, "/object-acl/grant", map[string]string{
"tenant": "rbac11_fremd", "object_type": "folder", "object_id": "f1",
"principal_type": "user", "principal_id": "u1", "right": "read", "actor": "admin",
}, testToken)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("fremder mandant beim grant: status=%d", resp.StatusCode)
}
_ = resp.Body.Close()
resp = doJSON(t, server, http.MethodPost, "/authorize", authorizeRequest{
Tenant: "rbac11_fremd",
Object: &objectQuery{Path: []objectPathNode{{Type: "folder", ID: "f1"}}, Right: "read", UserID: "u1"},
}, testToken)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("fremder mandant bei objektanfrage: status=%d", resp.StatusCode)
}
_ = resp.Body.Close()
}
+3
View File
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS object_acl_changes;
DROP TABLE IF EXISTS object_acl_breaks;
DROP TABLE IF EXISTS object_acl;
+45
View File
@@ -0,0 +1,45 @@
-- RBAC-11: Objektrechte mit Vererbung fuer Ordner/Dokumente. Liegt wie
-- policy_rules/feature_flags in der Registry-DB (tenant_slug-Spalte statt
-- eigener Tenant-Datenbank), weil policy-api nur einen einzigen Pool haelt
-- (NEXARCH_POLICY_ADMIN_DSN) und ueber genau diesen bestehenden Endpunkt
-- bedient werden soll (Akzeptanzkriterium 3).
CREATE TABLE object_acl (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_slug TEXT NOT NULL,
object_type TEXT NOT NULL CHECK (object_type IN ('folder', 'document')),
object_id TEXT NOT NULL,
principal_type TEXT NOT NULL CHECK (principal_type IN ('user', 'group')),
principal_id TEXT NOT NULL,
object_right TEXT NOT NULL CHECK (object_right IN ('read', 'write', 'manage')),
effect TEXT NOT NULL CHECK (effect IN ('allow', 'deny')),
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_slug, object_type, object_id, principal_type, principal_id, object_right)
);
CREATE INDEX object_acl_lookup_idx ON object_acl (tenant_slug, object_type, object_id);
-- Vererbungsunterbrechung: ein Eintrag fuer einen Ordner bedeutet, dass
-- Eintraege von Vorfahren dieses Ordners fuer ihn und seinen Teilbaum nicht
-- mehr gelten (Akzeptanzkriterium 2). Nur Ordner koennen die Vererbung
-- unterbrechen, nicht Dokumente (Dokumente haben keine Kinder).
CREATE TABLE object_acl_breaks (
tenant_slug TEXT NOT NULL,
folder_id TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_slug, folder_id)
);
-- Aenderungshistorie fuer das Audit-Log (Akzeptanzkriterium 3).
CREATE TABLE object_acl_changes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_slug TEXT NOT NULL,
object_type TEXT NOT NULL,
object_id TEXT NOT NULL,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
actor TEXT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);