diff --git a/internal/policy/object_acl.go b/internal/policy/object_acl.go new file mode 100644 index 00000000..58a418d5 --- /dev/null +++ b/internal/policy/object_acl.go @@ -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, + }) +} diff --git a/internal/policy/object_acl_test.go b/internal/policy/object_acl_test.go new file mode 100644 index 00000000..2279e7a1 --- /dev/null +++ b/internal/policy/object_acl_test.go @@ -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] +} diff --git a/internal/policyapi/handler.go b/internal/policyapi/handler.go index 93df13e4..d4e8d02a 100644 --- a/internal/policyapi/handler.go +++ b/internal/policyapi/handler.go @@ -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) +} diff --git a/internal/policyapi/handler_test.go b/internal/policyapi/handler_test.go index 32ae15ba..90a2fb4c 100644 --- a/internal/policyapi/handler_test.go +++ b/internal/policyapi/handler_test.go @@ -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 '\'`) diff --git a/internal/policyapi/object_handler_test.go b/internal/policyapi/object_handler_test.go new file mode 100644 index 00000000..99777aa3 --- /dev/null +++ b/internal/policyapi/object_handler_test.go @@ -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() +} diff --git a/migrations/0014_object_acl.down.sql b/migrations/0014_object_acl.down.sql new file mode 100644 index 00000000..6e372eaa --- /dev/null +++ b/migrations/0014_object_acl.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS object_acl_changes; +DROP TABLE IF EXISTS object_acl_breaks; +DROP TABLE IF EXISTS object_acl; diff --git a/migrations/0014_object_acl.up.sql b/migrations/0014_object_acl.up.sql new file mode 100644 index 00000000..c8cfc3c7 --- /dev/null +++ b/migrations/0014_object_acl.up.sql @@ -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() +);