internal/rbac/group.go: GroupStore verwaltet Gruppen innerhalb GENAU EINER Tenant-Datenbank (Modell C, analog Store/TenantUserStore) — Tenant-Isolation ist damit strukturell erfuellt, keine zusaetzliche Filterlogik noetig (Akzeptanzkriterium 3). Nur 'user'/'tenant_admin' sind auf Gruppenebene zuweisbar (dieselbe assignableRoles-Matrix wie bei direkter Zuweisung) — superadmin bleibt mandantenuebergreifend und ausserhalb jeder Gruppenlogik. EffectivePermissionsForUser vereinigt die direkte Rollenzuweisung (RBAC-01 Store) mit allen Rechten aus Gruppenrollen, live berechnet bei jedem Aufruf statt zwischengespeichert — RemoveMember/DeleteGroup wirken dadurch sofort (Akzeptanzkriterium 3). ON DELETE CASCADE auf group_members entzieht beim Loeschen einer Gruppe die Mitgliedschaften automatisch, Benutzerkonten selbst bleiben unberuehrt. Pruefungen (ausgefuehrt auf root@192.168.1.131, go build/vet/test PASS): 1. Gruppenrolle wirkt korrekt auf ALLE aktuellen Mitglieder — TestGroup_RoleAffectsAllCurrentMembers: zwei Mitglieder, beide erhalten die Gruppenrolle-Rechte. PASS. 2. Entfernen eines Benutzers aus der Gruppe entzieht Rechte sofort — TestGroup_RemoveMemberRevokesRightsImmediately. PASS. 3. Gruppen sauber tenant-isoliert (Stichprobe ueber zwei Tenants) — TestGroup_TenantIsolation: Gruppe in Tenant A taucht in Tenant B nicht auf. PASS. Zusaetzlich: TestGroup_DeleteGroupRevokesRightsWithoutDeletingUser belegt Akzeptanzkriterium 3 (Loeschung ohne Benutzerkonto-Verlust) konkret. PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
210 lines
6.4 KiB
Go
210 lines
6.4 KiB
Go
package rbac
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/internal/user"
|
|
)
|
|
|
|
const groupSchema = `
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS role_assignments (
|
|
user_id UUID PRIMARY KEY REFERENCES users(id), role TEXT NOT NULL CHECK (role IN ('user','tenant_admin')),
|
|
granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS groups (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, role TEXT CHECK (role IN ('user','tenant_admin')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
CREATE TABLE IF NOT EXISTS group_members (
|
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id),
|
|
added_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (group_id, user_id)
|
|
);`
|
|
|
|
func setupGroupTestDB(t *testing.T, dbName string) *pgxpool.Pool {
|
|
t.Helper()
|
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
|
if adminDSN == "" {
|
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
adminPool, err := pgxpool.New(ctx, adminDSN)
|
|
if err != nil {
|
|
t.Fatalf("admin pool: %v", err)
|
|
}
|
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
|
if _, err := adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
|
|
t.Fatalf("testdatenbank anlegen: %v", err)
|
|
}
|
|
|
|
dsn := strings.Replace(adminDSN, "/postgres?", "/"+dbName+"?", 1)
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatalf("connect testdatenbank: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx, groupSchema); err != nil {
|
|
t.Fatalf("schema anwenden: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
pool.Close()
|
|
_, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
|
|
adminPool.Close()
|
|
})
|
|
return pool
|
|
}
|
|
|
|
func createTestUser(t *testing.T, pool *pgxpool.Pool, email string) string {
|
|
t.Helper()
|
|
u, err := user.NewTenantUserStore(pool).Create(context.Background(), email, "Test")
|
|
if err != nil {
|
|
t.Fatalf("testuser anlegen: %v", err)
|
|
}
|
|
return u.ID
|
|
}
|
|
|
|
// Akzeptanzkriterium 1: Gruppen anlegen und Benutzer zuordnen.
|
|
func TestGroup_CreateAndAddMember(t *testing.T) {
|
|
pool := setupGroupTestDB(t, "test_rbac03_create")
|
|
gs := NewGroupStore(pool)
|
|
ctx := context.Background()
|
|
|
|
g, err := gs.CreateGroup(ctx, "Buchhaltung")
|
|
if err != nil {
|
|
t.Fatalf("create group: %v", err)
|
|
}
|
|
userID := createTestUser(t, pool, "alice@example.com")
|
|
|
|
if err := gs.AddMember(ctx, g.ID, userID); err != nil {
|
|
t.Fatalf("add member: %v", err)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 2 + Pruefung 1: Gruppenrolle wirkt auf ALLE aktuellen
|
|
// Mitglieder.
|
|
func TestGroup_RoleAffectsAllCurrentMembers(t *testing.T) {
|
|
pool := setupGroupTestDB(t, "test_rbac03_roleall")
|
|
gs := NewGroupStore(pool)
|
|
ctx := context.Background()
|
|
|
|
g, err := gs.CreateGroup(ctx, "Admins")
|
|
if err != nil {
|
|
t.Fatalf("create group: %v", err)
|
|
}
|
|
if err := gs.SetGroupRole(ctx, g.ID, RoleTenantAdmin); err != nil {
|
|
t.Fatalf("set group role: %v", err)
|
|
}
|
|
|
|
alice := createTestUser(t, pool, "alice@example.com")
|
|
bob := createTestUser(t, pool, "bob@example.com")
|
|
if err := gs.AddMember(ctx, g.ID, alice); err != nil {
|
|
t.Fatalf("add alice: %v", err)
|
|
}
|
|
if err := gs.AddMember(ctx, g.ID, bob); err != nil {
|
|
t.Fatalf("add bob: %v", err)
|
|
}
|
|
|
|
for _, uid := range []string{alice, bob} {
|
|
perms, err := gs.EffectivePermissionsForUser(ctx, nil, uid)
|
|
if err != nil {
|
|
t.Fatalf("effective permissions fuer %s: %v", uid, err)
|
|
}
|
|
if !contains(perms, PermManageUsers) {
|
|
t.Fatalf("mitglied %s sollte tenant_admin-rechte ueber die gruppe haben, habe %v", uid, perms)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 + Pruefung 2: Entfernen aus der Gruppe entzieht die
|
|
// gruppenbasierten Rechte sofort.
|
|
func TestGroup_RemoveMemberRevokesRightsImmediately(t *testing.T) {
|
|
pool := setupGroupTestDB(t, "test_rbac03_remove")
|
|
gs := NewGroupStore(pool)
|
|
ctx := context.Background()
|
|
|
|
g, _ := gs.CreateGroup(ctx, "Admins")
|
|
_ = gs.SetGroupRole(ctx, g.ID, RoleTenantAdmin)
|
|
userID := createTestUser(t, pool, "alice@example.com")
|
|
_ = gs.AddMember(ctx, g.ID, userID)
|
|
|
|
perms, err := gs.EffectivePermissionsForUser(ctx, nil, userID)
|
|
if err != nil {
|
|
t.Fatalf("effective permissions (mitglied): %v", err)
|
|
}
|
|
if !contains(perms, PermManageUsers) {
|
|
t.Fatal("erwartet tenant_admin-rechte als mitglied")
|
|
}
|
|
|
|
if err := gs.RemoveMember(ctx, g.ID, userID); err != nil {
|
|
t.Fatalf("remove member: %v", err)
|
|
}
|
|
|
|
perms, err = gs.EffectivePermissionsForUser(ctx, nil, userID)
|
|
if err != nil {
|
|
t.Fatalf("effective permissions (entfernt): %v", err)
|
|
}
|
|
if contains(perms, PermManageUsers) {
|
|
t.Fatal("erwartet KEINE tenant_admin-rechte nach entfernen aus der gruppe")
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3: Loeschen einer Gruppe entzieht Rechte sauber, ohne
|
|
// das Benutzerkonto zu loeschen.
|
|
func TestGroup_DeleteGroupRevokesRightsWithoutDeletingUser(t *testing.T) {
|
|
pool := setupGroupTestDB(t, "test_rbac03_delete")
|
|
gs := NewGroupStore(pool)
|
|
us := user.NewTenantUserStore(pool)
|
|
ctx := context.Background()
|
|
|
|
g, _ := gs.CreateGroup(ctx, "Temp")
|
|
_ = gs.SetGroupRole(ctx, g.ID, RoleTenantAdmin)
|
|
userID := createTestUser(t, pool, "alice@example.com")
|
|
_ = gs.AddMember(ctx, g.ID, userID)
|
|
|
|
if err := gs.DeleteGroup(ctx, g.ID); err != nil {
|
|
t.Fatalf("delete group: %v", err)
|
|
}
|
|
|
|
perms, err := gs.EffectivePermissionsForUser(ctx, nil, userID)
|
|
if err != nil {
|
|
t.Fatalf("effective permissions: %v", err)
|
|
}
|
|
if contains(perms, PermManageUsers) {
|
|
t.Fatal("erwartet keine rechte mehr nach loeschen der gruppe")
|
|
}
|
|
|
|
if _, err := us.Get(ctx, userID); err != nil {
|
|
t.Fatalf("benutzerkonto haette erhalten bleiben muessen: %v", err)
|
|
}
|
|
}
|
|
|
|
// Akzeptanzkriterium 3 + Pruefung 3: Gruppen sind sauber tenant-isoliert.
|
|
func TestGroup_TenantIsolation(t *testing.T) {
|
|
poolA := setupGroupTestDB(t, "test_rbac03_tenanta")
|
|
poolB := setupGroupTestDB(t, "test_rbac03_tenantb")
|
|
gsA := NewGroupStore(poolA)
|
|
ctx := context.Background()
|
|
|
|
if _, err := gsA.CreateGroup(ctx, "Nur-Tenant-A"); err != nil {
|
|
t.Fatalf("create group a: %v", err)
|
|
}
|
|
|
|
var countB int
|
|
if err := poolB.QueryRow(ctx, `SELECT count(*) FROM groups`).Scan(&countB); err != nil {
|
|
t.Fatalf("count tenant b: %v", err)
|
|
}
|
|
if countB != 0 {
|
|
t.Fatalf("tenant b sollte keine gruppen aus tenant a sehen, habe %d", countB)
|
|
}
|
|
}
|