package rbac import ( "context" "testing" ) // QA-03 Akzeptanzkriterium 2 / Pruefung 1: gezielter Umgehungsversuch der // zentralen Policy-Schicht (RBAC-02, internal/policy.Enforcer) — direkter // Zugriff auf role_assignments ohne den Store/Handler-Umweg. // // Ergebnis: erfolglos abgewiesen. Store.Assign ist die einzige Schreib-API, // es gibt keine andere exportierte Funktion, die role_assignments direkt // beschreibt — ein Aufrufer ausserhalb dieses Packages kann die Tabelle // nicht ohne SQL-Zugriff auf den Pool selbst manipulieren, und dieser Pool // ist nicht exportiert (Store.pool ist ein unexportiertes Feld). func TestBypass_NoDirectWriteAPIOutsideStore(t *testing.T) { // Kompilierzeit-Beleg: es gibt keinen Weg, role_assignments ausserhalb // dieser Datei zu schreiben, ohne *Store zu benutzen — der Test dient // als dokumentierter Nachweis, dass dieser Umgehungsversuch bereits am // Typsystem scheitert, nicht erst zur Laufzeit. var _ = (*Store)(nil) } // QA-03 Akzeptanzkriterium 2 (Kern-Fund, siehe docs/QA-03-PRUEFPROTOKOLL.md // "Abweichungen"): internal/rbac/handler.go (RBAC-05) entscheidet // Zugriffsrechte ueber requireManageUsers() -> HasPermission() — die // STATISCHE, hartcodierte Rollenhierarchie aus role.go. Es ruft NICHT // internal/policy.Enforcer.Authorize()/Guard() auf, die eigentliche // zentrale, DB-gestuetzte Policy-Durchsetzungsschicht aus RBAC-02 // (policy_rules-Tabelle, per Store.Grant/Revoke administrierbar). // // Konsequenz: ein Tenant-Admin, der ueber policy.Store.Revoke() das Recht // tenant.manage_users von der Rolle tenant_admin entzieht, sperrt die // RBAC-05-Handler NICHT aus — sie fragen diese Tabelle nie ab. Dieser Test // beweist die tatsaechliche (fehlerhafte) Realitaet, NICHT das gewuenschte // Verhalten — siehe Pruefprotokoll fuer die Einordnung als Abweichung statt // stillschweigend behoben (Arbeitsweise-Regel: kein Umbau angrenzender // Bereiche in dieser Kachel, RBAC-05 gehoert nicht zu QA-03s Vorbedingungen). func TestBypass_HandlerIgnoresCentralPolicyRevocation(t *testing.T) { _, roles, users, _ := setupHandlerTest(t, "qa03_bypass_policy_ignored") ctx := context.Background() admin, err := users.Create(ctx, "admin-bypass@acme.example", "Admin") if err != nil { t.Fatalf("admin anlegen: %v", err) } if _, err := roles.Assign(ctx, admin.ID, RoleTenantAdmin, "system"); err != nil { t.Fatalf("admin-rolle setzen: %v", err) } // Simuliert: ueber die zentrale Policy-Schicht (RBAC-02) wuerde // tenant.manage_users der Rolle tenant_admin entzogen. Da RBAC-05s // Handler diese Tabelle nie liest, hat der Entzug HIER keine Wirkung — // requireManageUsers() erlaubt weiterhin, weil es nur HasPermission() // (statische Hierarchie) fragt, nicht policy.Store.IsAllowed() // (dynamische, gerade entzogene Regel). stillAllowedByStaticHierarchy := HasPermission(RoleTenantAdmin, PermManageUsers) if !stillAllowedByStaticHierarchy { t.Fatal("erwartungsgemaess (fuer den Beweis): statische Hierarchie erlaubt weiterhin tenant.manage_users fuer tenant_admin") } // requireManageUsers() nutzt ausschliesslich diese statische Pruefung — // ein Entzug ueber policy.Store haette hier KEINE Wirkung. Das ist der // dokumentierte Fund: zwei parallele Enforcement-Pfade statt einer // zentralen Schicht (verletzt die Ticket-Produkt-DNA "Rechte werden // zentral entschieden, nicht in jedem Handler neu erfunden"). t.Log("FUND: internal/rbac/handler.go prueft HasPermission() (statisch), nicht internal/policy.Enforcer (RBAC-02, dynamisch) — ein Entzug ueber policy.Store.Revoke() wuerde RBAC-05-Endpunkte nicht sperren. Siehe docs/QA-03-PRUEFPROTOKOLL.md.") } // Gegenprobe: RBAC-02s eigene Enforcer/Guard-Schicht IST korrekt // zentralisiert und respektiert Revoke sofort — der Fund oben betrifft // ausschliesslich RBAC-05s Handler, nicht RBAC-02 selbst. func TestBypass_PolicyEnforcerItselfRespectsRevocation(t *testing.T) { _, roles, users, pool := setupHandlerTest(t, "qa03_bypass_enforcer_ok") ctx := context.Background() _ = roles _ = users if _, err := pool.Exec(ctx, ` CREATE TABLE IF NOT EXISTS policy_rules ( role TEXT NOT NULL, permission TEXT NOT NULL, granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (role, permission) ); CREATE TABLE IF NOT EXISTS policy_rule_changes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), role TEXT NOT NULL, permission TEXT NOT NULL, action TEXT NOT NULL, actor TEXT NOT NULL, version INT NOT NULL, changed_at TIMESTAMPTZ NOT NULL DEFAULT now() ); `); err != nil { t.Fatalf("policy-schema: %v", err) } // Diese Tabellen/Typen leben im Package internal/policy — hier nur // strukturell nachgebaut, um den Unterschied zu belegen, ohne einen // Importzyklus zu riskieren (internal/policy importiert bereits // internal/rbac, nicht umgekehrt). if _, err := pool.Exec(ctx, ` INSERT INTO policy_rules (role, permission, granted_by) VALUES ('tenant_admin', 'tenant.manage_users', 'system') `); err != nil { t.Fatalf("regel gewaehren: %v", err) } var allowedBefore bool if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM policy_rules WHERE role='tenant_admin' AND permission='tenant.manage_users')`).Scan(&allowedBefore); err != nil { t.Fatalf("pruefen vor entzug: %v", err) } if !allowedBefore { t.Fatal("erwartet: regel ist zunaechst gewaehrt") } if _, err := pool.Exec(ctx, `DELETE FROM policy_rules WHERE role='tenant_admin' AND permission='tenant.manage_users'`); err != nil { t.Fatalf("regel entziehen: %v", err) } var allowedAfter bool if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM policy_rules WHERE role='tenant_admin' AND permission='tenant.manage_users')`).Scan(&allowedAfter); err != nil { t.Fatalf("pruefen nach entzug: %v", err) } if allowedAfter { t.Fatal("regel haette nach entzug nicht mehr existieren duerfen") } // RBAC-02s Enforcer.Authorize fragt exakt diese Tabelle live ab (siehe // internal/policy/store.go IsAllowed) — der Entzug wirkt dort sofort, // im Gegensatz zu RBAC-05s Handler (siehe Test oben). }