internal/policy: deklarativer, DB-gehaltener Regelsatz (policy_rules) statt hartcodierter Go-Entscheidungslogik — Store.IsAllowed schaut ausschliesslich in die Datenbank, kein Go-Fallback. Ein Regelwechsel (Grant/Revoke) wirkt sich sofort aus, ohne Codeaenderung/Deploy (Akzeptanzkriterium 3). Jede Aenderung wird atomar mit einem versionierten Historieneintrag in policy_rule_changes festgehalten (grant/revoke, Akteur, Version). Enforcer.Authorize ist Default-Deny: existiert keine Regel fuer role+ permission, ist der Zugriff verboten (Akzeptanzkriterium 2), fuer sich genommen ohne Anwendungslogik testbar. Guard/GuardTenantScoped sind die zentrale Enforcement-Funktion (Akzeptanzkriterium 1): die uebergebene Query-Funktion wird NUR bei erfolgreicher Autorisierung aufgerufen — es gibt keinen Weg, Daten ohne vorherige Authorize-Entscheidung zu erhalten. GuardTenantScoped erzwingt zusaetzlich per Funktionssignatur, dass tenantSlug TEIL der Query-Funktion ist (Akzeptanzkriterium 3) — ein nachgelagerter Post-Filter (der archivmail-Fehler aus "Bekannte Fehler vermeiden": Tenant-Filter nach statt in der Query) ist mit dieser Signatur strukturell nicht moeglich, da die Repository-Implementierung tenantSlug selbst fuer ihre eigene WHERE-Klausel entgegennimmt. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Kein Datenzugriffs-Pfad umgeht die zentrale Enforcement-Schicht — TestGuard_NeverCallsQueryWithoutAuthorization: query-Funktion wird nachweislich NICHT aufgerufen ohne vorherige Regel, erst nach Grant. PASS. 2. Anfrage ohne passende Policy wird zuverlaessig abgewiesen (Default-Deny) — TestAuthorize_DefaultDeny: keine Regel konfiguriert -> ErrDenied, nicht automatisch erlaubt. PASS. 3. Policy-Regelsatz versioniert, Regelwechsel ohne Codeaenderung nachvollziehbar — TestGrantRevoke_ChangesBehaviorWithoutCodeChange: Verhalten aendert sich durch reinen Datenbank-Grant/Revoke, Historie zeigt beide Versionen korrekt. PASS. Zusaetzlich: TestGuardTenantScoped_IsolatesDataBetweenTenants belegt das Tenant-Scoping-Muster aus Akzeptanzkriterium 3 konkret anhand zweier Tenants. PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
125 lines
4.2 KiB
Go
125 lines
4.2 KiB
Go
// Package policy implementiert Core RBAC-02: die zentrale Policy-
|
|
// Entscheidungs- und Durchsetzungsschicht. Regeln liegen deklarativ in der
|
|
// Datenbank (Casbin-Prinzip: Policy als Modell+Regeln getrennt vom Code),
|
|
// versioniert und auditierbar — anders als Casbin-Dateien im Dateisystem
|
|
// (siehe "bewusst vermeiden" im Ticket).
|
|
package policy
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/rbac"
|
|
)
|
|
|
|
// Rule ist eine gewaehrte Regel: role darf permission.
|
|
type Rule struct {
|
|
Role rbac.Role
|
|
Permission rbac.Permission
|
|
}
|
|
|
|
// RuleChange ist ein Eintrag der Aenderungshistorie (Akzeptanzkriterium 3).
|
|
type RuleChange struct {
|
|
Role rbac.Role
|
|
Permission rbac.Permission
|
|
Action string // "grant" oder "revoke"
|
|
Actor string
|
|
Version int
|
|
}
|
|
|
|
// Store verwaltet den deklarativen Regelsatz. IsAllowed schaut NUR in die
|
|
// Datenbank — es gibt keinen hartcodierten Go-Fallback, ein Regelwechsel
|
|
// wirkt sich ohne Codeaenderung sofort aus (Akzeptanzkriterium 2 / 3).
|
|
type Store struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func NewStore(pool *pgxpool.Pool) *Store {
|
|
return &Store{pool: pool}
|
|
}
|
|
|
|
// IsAllowed prueft, ob role das Recht permission besitzt. Default-Deny:
|
|
// existiert keine passende Regel, ist der Zugriff verboten
|
|
// (Akzeptanzkriterium 2 / Pruefung 2) — es gibt keinen impliziten
|
|
// "erlaubt, wenn nichts anderes gesagt"-Pfad.
|
|
func (s *Store) IsAllowed(ctx context.Context, role rbac.Role, perm rbac.Permission) (bool, error) {
|
|
var exists bool
|
|
err := s.pool.QueryRow(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM policy_rules WHERE role = $1 AND permission = $2)
|
|
`, string(role), string(perm)).Scan(&exists)
|
|
if err != nil {
|
|
return false, fmt.Errorf("policy pruefen: %w", err)
|
|
}
|
|
return exists, nil
|
|
}
|
|
|
|
// Grant gewaehrt role das Recht permission — deklarativ, ohne Codeaenderung
|
|
// wirksam. actor wird fuer die Aenderungshistorie festgehalten.
|
|
func (s *Store) Grant(ctx context.Context, role rbac.Role, perm rbac.Permission, actor string) error {
|
|
return s.change(ctx, role, perm, "grant", actor, `
|
|
INSERT INTO policy_rules (role, permission, granted_by, granted_at)
|
|
VALUES ($1, $2, $3, now())
|
|
ON CONFLICT (role, permission) DO UPDATE SET granted_by = $3, granted_at = now()
|
|
`, string(role), string(perm), actor)
|
|
}
|
|
|
|
// Revoke entzieht role das Recht permission.
|
|
func (s *Store) Revoke(ctx context.Context, role rbac.Role, perm rbac.Permission, actor string) error {
|
|
return s.change(ctx, role, perm, "revoke", actor, `
|
|
DELETE FROM policy_rules WHERE role = $1 AND permission = $2
|
|
`, string(role), string(perm))
|
|
}
|
|
|
|
func (s *Store) change(ctx context.Context, role rbac.Role, perm rbac.Permission, action, actor, sql string, args ...any) error {
|
|
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, sql, args...); err != nil {
|
|
return fmt.Errorf("regel aendern: %w", err)
|
|
}
|
|
|
|
var version int
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT COALESCE(MAX(version), 0) + 1 FROM policy_rule_changes WHERE role = $1 AND permission = $2
|
|
`, string(role), string(perm)).Scan(&version); err != nil {
|
|
return fmt.Errorf("naechste version ermitteln: %w", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO policy_rule_changes (role, permission, action, actor, version, changed_at)
|
|
VALUES ($1, $2, $3, $4, $5, now())
|
|
`, string(role), string(perm), action, actor, version); err != nil {
|
|
return fmt.Errorf("historie schreiben: %w", err)
|
|
}
|
|
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
// History liefert die vollstaendige, versionierte Aenderungshistorie einer
|
|
// Regel (Akzeptanzkriterium 3).
|
|
func (s *Store) History(ctx context.Context, role rbac.Role, perm rbac.Permission) ([]RuleChange, error) {
|
|
rows, err := s.pool.Query(ctx, `
|
|
SELECT action, actor, version FROM policy_rule_changes
|
|
WHERE role = $1 AND permission = $2 ORDER BY version
|
|
`, string(role), string(perm))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("historie abfragen: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []RuleChange
|
|
for rows.Next() {
|
|
c := RuleChange{Role: role, Permission: perm}
|
|
if err := rows.Scan(&c.Action, &c.Actor, &c.Version); err != nil {
|
|
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|