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
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:
1 parent
44ba59ac49
commit
77a89a9fcb
7 files changed
+1362
-3
No files matched your search
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 '\'`)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS object_acl_changes;
|
||||
DROP TABLE IF EXISTS object_acl_breaks;
|
||||
DROP TABLE IF EXISTS object_acl;
|
||||
@@ -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()
|
||||
);
|
||||
Reference in new issue
Block a user