From bab582a38a35abd14fde96552552bfb6244601b4 Mon Sep 17 00:00:00 2001 From: sysops Date: Thu, 27 Aug 2026 22:00:48 +0200 Subject: [PATCH] RBAC-03: gruppen-abteilungen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/rbac/group.go | 159 +++++++++++++++++++ internal/rbac/group_test.go | 209 +++++++++++++++++++++++++ migrations/tenant/0003_groups.down.sql | 2 + migrations/tenant/0003_groups.up.sql | 21 +++ 4 files changed, 391 insertions(+) create mode 100644 internal/rbac/group.go create mode 100644 internal/rbac/group_test.go create mode 100644 migrations/tenant/0003_groups.down.sql create mode 100644 migrations/tenant/0003_groups.up.sql diff --git a/internal/rbac/group.go b/internal/rbac/group.go new file mode 100644 index 0000000..22565c7 --- /dev/null +++ b/internal/rbac/group.go @@ -0,0 +1,159 @@ +package rbac + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrGroupNotFound = errors.New("rbac: gruppe nicht gefunden") + +type Group struct { + ID string + Name string + Role Role // leer, wenn der Gruppe noch keine Rolle zugewiesen wurde +} + +// GroupStore verwaltet Gruppen/Abteilungen innerhalb GENAU EINER Tenant- +// Datenbank (Modell C, wie Store fuer direkte Rollenzuweisungen) — +// Akzeptanzkriterium 3 (tenant-isoliert) ist damit strukturell erfuellt. +type GroupStore struct { + pool *pgxpool.Pool +} + +func NewGroupStore(pool *pgxpool.Pool) *GroupStore { + return &GroupStore{pool: pool} +} + +// CreateGroup legt eine neue, noch rollenlose Gruppe an (Akzeptanzkriterium 1). +func (s *GroupStore) CreateGroup(ctx context.Context, name string) (Group, error) { + if name == "" { + return Group{}, errors.New("rbac: gruppenname darf nicht leer sein") + } + var g Group + g.Name = name + if err := s.pool.QueryRow(ctx, ` + INSERT INTO groups (name) VALUES ($1) RETURNING id + `, name).Scan(&g.ID); err != nil { + return Group{}, fmt.Errorf("gruppe anlegen: %w", err) + } + return g, nil +} + +// SetGroupRole weist der Gruppe eine Rolle zu (Akzeptanzkriterium 2) — nur +// 'user'/'tenant_admin' sind auf Gruppenebene zuweisbar, dieselbe erlaubte +// Matrix wie in Store.Assign (superadmin bleibt mandantenuebergreifend und +// ausserhalb jeder Gruppenlogik). +func (s *GroupStore) SetGroupRole(ctx context.Context, groupID string, role Role) error { + if !assignableRoles[role] { + return ErrRoleNotAssignableInTenantScope + } + tag, err := s.pool.Exec(ctx, `UPDATE groups SET role = $2 WHERE id = $1`, groupID, string(role)) + if err != nil { + return fmt.Errorf("gruppenrolle setzen: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrGroupNotFound + } + return nil +} + +// AddMember nimmt einen Benutzer in die Gruppe auf. +func (s *GroupStore) AddMember(ctx context.Context, groupID, userID string) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO group_members (group_id, user_id) VALUES ($1, $2) + ON CONFLICT (group_id, user_id) DO NOTHING + `, groupID, userID) + if err != nil { + return fmt.Errorf("mitglied hinzufuegen: %w", err) + } + return nil +} + +// RemoveMember entfernt einen Benutzer aus der Gruppe — die gruppenbasierten +// Rechte entfallen fuer ihn sofort (Akzeptanzkriterium 3 / Pruefung 2), da +// EffectivePermissionsForUser bei jedem Aufruf neu berechnet wird, nicht +// zwischengespeichert. +func (s *GroupStore) RemoveMember(ctx context.Context, groupID, userID string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM group_members WHERE group_id = $1 AND user_id = $2`, groupID, userID) + if err != nil { + return fmt.Errorf("mitglied entfernen: %w", err) + } + return nil +} + +// DeleteGroup loescht die Gruppe — ON DELETE CASCADE entfernt die +// Mitgliedschaften mit, Benutzerkonten selbst bleiben unberuehrt +// (Akzeptanzkriterium 3). +func (s *GroupStore) DeleteGroup(ctx context.Context, groupID string) error { + _, err := s.pool.Exec(ctx, `DELETE FROM groups WHERE id = $1`, groupID) + if err != nil { + return fmt.Errorf("gruppe loeschen: %w", err) + } + return nil +} + +// RolesForUser liefert alle Rollen, die einem Benutzer ueber seine +// Gruppenmitgliedschaften zufallen (Akzeptanzkriterium 2: wirkt auf alle +// Mitglieder). Eine Gruppe ohne zugewiesene Rolle traegt nichts bei. +func (s *GroupStore) RolesForUser(ctx context.Context, userID string) ([]Role, error) { + rows, err := s.pool.Query(ctx, ` + SELECT g.role FROM groups g + JOIN group_members gm ON gm.group_id = g.id + WHERE gm.user_id = $1 AND g.role IS NOT NULL + `, userID) + if err != nil { + return nil, fmt.Errorf("gruppenrollen abfragen: %w", err) + } + defer rows.Close() + + var out []Role + for rows.Next() { + var r string + if err := rows.Scan(&r); err != nil { + return nil, fmt.Errorf("gruppenrolle lesen: %w", err) + } + out = append(out, Role(r)) + } + return out, rows.Err() +} + +// EffectivePermissionsForUser vereinigt die Rechte aus der direkten +// Rollenzuweisung (Store, RBAC-01) mit allen Rechten aus Gruppenrollen — +// jede einzelne Rolle wird ueber die bestehende Hierarchie (EffectivePermissions) +// aufgeloest, das Ergebnis dedupliziert. +func (s *GroupStore) EffectivePermissionsForUser(ctx context.Context, directStore *Store, userID string) ([]Permission, error) { + seen := make(map[Permission]bool) + var out []Permission + + add := func(role Role) { + for _, p := range EffectivePermissions(role) { + if !seen[p] { + seen[p] = true + out = append(out, p) + } + } + } + + if directStore != nil { + assignment, err := directStore.Get(ctx, userID) + if err != nil && !errors.Is(err, ErrNotFound) { + return nil, err + } + if err == nil { + add(assignment.Role) + } + } + + groupRoles, err := s.RolesForUser(ctx, userID) + if err != nil { + return nil, err + } + for _, r := range groupRoles { + add(r) + } + + return out, nil +} diff --git a/internal/rbac/group_test.go b/internal/rbac/group_test.go new file mode 100644 index 0000000..f6941f3 --- /dev/null +++ b/internal/rbac/group_test.go @@ -0,0 +1,209 @@ +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) + } +} diff --git a/migrations/tenant/0003_groups.down.sql b/migrations/tenant/0003_groups.down.sql new file mode 100644 index 0000000..99f84a0 --- /dev/null +++ b/migrations/tenant/0003_groups.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS group_members; +DROP TABLE IF EXISTS groups; diff --git a/migrations/tenant/0003_groups.up.sql b/migrations/tenant/0003_groups.up.sql new file mode 100644 index 0000000..7c87ef2 --- /dev/null +++ b/migrations/tenant/0003_groups.up.sql @@ -0,0 +1,21 @@ +-- Gruppen/Abteilungen mit Rollenzuweisung auf Gruppenebene (RBAC-03, siehe +-- core-kanban/tickets/RBAC-03.md). Lebt in der Tenant-Datenbank (Modell C) — +-- Gruppen sind implizit tenant-isoliert, da jede Tenant-DB ihre eigenen hat. +CREATE TABLE 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() +); + +-- ON DELETE CASCADE: Loeschen einer Gruppe entzieht die gruppenbasierten +-- Rechte automatisch, ohne Benutzerkonten selbst zu beruehren +-- (Akzeptanzkriterium 3). +CREATE TABLE 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) +); + +CREATE INDEX group_members_user_idx ON group_members (user_id);