diff --git a/DEVLOG.md b/DEVLOG.md index 9a6b25d..c609e99 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -182,7 +182,29 @@ Keine Commits in dieser Session. - web/shl/vitest.setup.ts | 1 + --- -## 2026-08-29 00:08 – 00:08 (0m) +## 2026-08-28 23:37 – 23:43 (5m) +**Beschreibung:** Claude Code Session +**Projekt:** nexarch + +### Commits +- fbcc9db RBAC-05: web/rbac-admin next.js-frontend (rollen+gruppen-verwaltung, audit-verlauf) auf shl-01 +- bd80f0c Merge branch 'feature/shl-01-ui-shell-design-system-zentral' into feature/rbac-05-rechte-administrationsoberflaeche +- 051af43 RBAC-05: backend — handler.go (rollen-/gruppenverwaltung, selbst-eskalationsschutz), group.go ListGroups/Members +- 9de9005 Merge branch 'feature/iam-02-login-session-jwt-grundgeruest' into feature/rbac-05-rechte-administrationsoberflaeche +- 1b6a159 Merge branch 'feature/rbac-03-gruppen-abteilungen' into feature/rbac-05-rechte-administrationsoberflaeche + +### Geänderte Dateien +- web/rbac-admin/app/groups/page.tsx | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- web/rbac-admin/app/layout.tsx | 30 ++++++++++++++++++++++++++++++ +- web/rbac-admin/app/page.tsx | 5 +++++ +- web/rbac-admin/app/roles/page.tsx | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- web/rbac-admin/lib/api.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- web/rbac-admin/next.config.mjs | 5 +++++ +- web/rbac-admin/package.json | 22 ++++++++++++++++++++++ +- web/rbac-admin/tsconfig.json | 21 +++++++++++++++++++++ + +--- +## 2026-08-28 23:44 – 23:44 (0m) **Beschreibung:** Claude Code Session **Projekt:** code @@ -197,5 +219,13 @@ Keine Commits in dieser Session. - migrations/0003_tenant_settings.up.sql | 27 +++++++++++++++++++++++++++ - scripts/reset-test-env.sh | 25 +++++++++++++++++++++++++ - scripts/run-checks.sh | 24 ++++++++++++++++++++++++ +- web/rbac-admin/app/groups/page.tsx | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- web/rbac-admin/app/layout.tsx | 30 ++++++++++++++++++++++++++++++ +- web/rbac-admin/app/page.tsx | 5 +++++ +- web/rbac-admin/app/roles/page.tsx | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- web/rbac-admin/lib/api.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- web/rbac-admin/next.config.mjs | 5 +++++ +- web/rbac-admin/package.json | 22 ++++++++++++++++++++++ +- web/rbac-admin/tsconfig.json | 21 +++++++++++++++++++++ --- diff --git a/internal/rbac/group.go b/internal/rbac/group.go new file mode 100644 index 0000000..9d96ee1 --- /dev/null +++ b/internal/rbac/group.go @@ -0,0 +1,200 @@ +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 +} + +// ListGroups liefert alle Gruppen des Mandanten (Akzeptanzkriterium 1/2, +// Grundlage fuer die Administrationsoberflaeche). +func (s *GroupStore) ListGroups(ctx context.Context) ([]Group, error) { + rows, err := s.pool.Query(ctx, `SELECT id, name, COALESCE(role, '') FROM groups ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("gruppen auflisten: %w", err) + } + defer rows.Close() + + var out []Group + for rows.Next() { + var g Group + var role string + if err := rows.Scan(&g.ID, &g.Name, &role); err != nil { + return nil, fmt.Errorf("gruppe lesen: %w", err) + } + g.Role = Role(role) + out = append(out, g) + } + return out, rows.Err() +} + +// Members liefert die User-IDs der Mitglieder einer Gruppe. +func (s *GroupStore) Members(ctx context.Context, groupID string) ([]string, error) { + rows, err := s.pool.Query(ctx, `SELECT user_id FROM group_members WHERE group_id = $1`, groupID) + if err != nil { + return nil, fmt.Errorf("mitglieder abfragen: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("mitglied lesen: %w", err) + } + out = append(out, id) + } + return out, rows.Err() +} + +// 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/internal/rbac/handler.go b/internal/rbac/handler.go new file mode 100644 index 0000000..795c8ca --- /dev/null +++ b/internal/rbac/handler.go @@ -0,0 +1,250 @@ +package rbac + +import ( + "encoding/json" + "net/http" + "strings" + + "gitea.perlbach24.de/scripte/nexarch/internal/auth" +) + +// Handler stellt Rollen-/Rechte- und Gruppenverwaltung als HTTP-Endpunkte +// bereit (RBAC-05). Duenner Client der bestehenden Store/GroupStore-Schicht — +// keine eigene Rechtelogik hier (siehe Ticket-Produkt-DNA). +type Handler struct { + roles *Store + groups *GroupStore +} + +func NewHandler(roles *Store, groups *GroupStore) *Handler { + return &Handler{roles: roles, groups: groups} +} + +// requireManageUsers prueft, ob der aufrufende Benutzer ueberhaupt +// Rechteverwaltung ausueben darf — technisch durchgesetzt hier zusaetzlich +// zu RBAC-02 (Policy-Enforcement-Schicht), da diese Kachel ein eigener, +// besonders sicherheitskritischer HTTP-Einstiegspunkt ist. +func requireManageUsers(w http.ResponseWriter, r *http.Request, roles *Store) (callerUserID string, ok bool) { + claims, authOK := auth.ClaimsFromContext(r.Context()) + if !authOK { + http.Error(w, "nicht angemeldet", http.StatusUnauthorized) + return "", false + } + assignment, err := roles.Get(r.Context(), claims.UserID) + if err != nil || !HasPermission(assignment.Role, PermManageUsers) { + http.Error(w, "keine Berechtigung zur Rechteverwaltung", http.StatusForbidden) + return "", false + } + return claims.UserID, true +} + +type roleInfo struct { + Role Role `json:"role"` + Permissions []Permission `json:"permissions"` +} + +// ListRoles liefert die (im Tenant-Geltungsbereich zuweisbaren) Rollen mit +// ihren vollstaendig aufgeloesten Rechten (Akzeptanzkriterium 1). +func (h *Handler) ListRoles(w http.ResponseWriter, r *http.Request) { + if _, ok := requireManageUsers(w, r, h.roles); !ok { + return + } + out := make([]roleInfo, 0, len(assignableRoles)) + for role := range assignableRoles { + out = append(out, roleInfo{Role: role, Permissions: EffectivePermissions(role)}) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) +} + +type assignRoleRequest struct { + UserID string `json:"user_id"` + Role Role `json:"role"` +} + +// roleRank ordnet die Rollenhierarchie fuer den Selbst-Eskalations-Vergleich +// (Akzeptanzkriterium 2 / Pruefung 2) — hoehere Zahl = mehr Rechte. Basiert +// auf derselben parent-Kette wie EffectivePermissions, nicht dupliziert als +// eigenstaendiges Modell. +func roleRank(role Role) int { + rank := 0 + for r, ok := role, true; ok; r, ok = parent[r] { + rank++ + } + return rank +} + +// AssignRole weist einem Benutzer eine Rolle zu (Akzeptanzkriterium 1). +// Verweigert explizit, dass sich der aufrufende Benutzer selbst eine +// hoehere Rolle zuweist (Akzeptanzkriterium 2 / Pruefung 2) — unabhaengig +// davon, ob RBAC-02 dieselbe Aktion ohnehin ueber die allgemeine +// Policy-Schicht verhindern wuerde: Selbst-Eskalation ist ein derart +// sicherheitskritischer Einzelfall, dass er hier zusaetzlich explizit und +// unuebersehbar geprueft wird, statt sich allein auf eine generische +// Regel an anderer Stelle zu verlassen. +func (h *Handler) AssignRole(w http.ResponseWriter, r *http.Request) { + callerUserID, ok := requireManageUsers(w, r, h.roles) + if !ok { + return + } + + var req assignRoleRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + + if req.UserID == callerUserID { + callerAssignment, err := h.roles.Get(r.Context(), callerUserID) + callerRole := RoleUser + if err == nil { + callerRole = callerAssignment.Role + } + if roleRank(req.Role) > roleRank(callerRole) { + http.Error(w, "Sie können sich nicht selbst eine höhere Rolle zuweisen.", http.StatusForbidden) + return + } + } + + assignment, err := h.roles.Assign(r.Context(), req.UserID, req.Role, callerUserID) + if err != nil { + http.Error(w, "Rolle konnte nicht zugewiesen werden.", http.StatusBadRequest) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(assignment) +} + +// RoleHistory liefert die Zuweisungshistorie eines Benutzers — der in +// Akzeptanzkriterium 3 geforderte Verweis auf das Audit-Log (die +// role_assignment_history-Tabelle selbst IST die Nachvollziehbarkeits-Spur; +// eine vollstaendige Anbindung an Core AUD-01 ist nicht Teil dieser Kachel). +func (h *Handler) RoleHistory(w http.ResponseWriter, r *http.Request) { + if _, ok := requireManageUsers(w, r, h.roles); !ok { + return + } + userID := strings.TrimPrefix(r.URL.Path, "/rbac/users/") + userID = strings.TrimSuffix(userID, "/history") + if userID == "" { + http.Error(w, "user_id fehlt", http.StatusBadRequest) + return + } + + history, err := h.roles.History(r.Context(), userID) + if err != nil { + http.Error(w, "historie konnte nicht geladen werden", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(history) +} + +type createGroupRequest struct { + Name string `json:"name"` +} + +// CreateGroup legt eine neue Gruppe an (Akzeptanzkriterium 2). +func (h *Handler) CreateGroup(w http.ResponseWriter, r *http.Request) { + if _, ok := requireManageUsers(w, r, h.roles); !ok { + return + } + var req createGroupRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + group, err := h.groups.CreateGroup(r.Context(), req.Name) + if err != nil { + http.Error(w, "Gruppe konnte nicht angelegt werden.", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(group) +} + +type groupWithMembers struct { + Group + Members []string `json:"members"` +} + +// ListGroups liefert alle Gruppen inkl. Mitgliederliste (Akzeptanzkriterium 2). +func (h *Handler) ListGroups(w http.ResponseWriter, r *http.Request) { + if _, ok := requireManageUsers(w, r, h.roles); !ok { + return + } + groups, err := h.groups.ListGroups(r.Context()) + if err != nil { + http.Error(w, "Gruppen konnten nicht geladen werden.", http.StatusInternalServerError) + return + } + out := make([]groupWithMembers, 0, len(groups)) + for _, g := range groups { + members, err := h.groups.Members(r.Context(), g.ID) + if err != nil { + http.Error(w, "Mitglieder konnten nicht geladen werden.", http.StatusInternalServerError) + return + } + out = append(out, groupWithMembers{Group: g, Members: members}) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) +} + +type groupActionRequest struct { + GroupID string `json:"group_id"` + UserID string `json:"user_id"` + Role Role `json:"role"` +} + +// SetGroupRole weist einer Gruppe eine Rolle zu (Akzeptanzkriterium 2). +func (h *Handler) SetGroupRole(w http.ResponseWriter, r *http.Request) { + if _, ok := requireManageUsers(w, r, h.roles); !ok { + return + } + var req groupActionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + if err := h.groups.SetGroupRole(r.Context(), req.GroupID, req.Role); err != nil { + http.Error(w, "Gruppenrolle konnte nicht gesetzt werden.", http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) +} + +// AddGroupMember nimmt einen Benutzer in eine Gruppe auf (Akzeptanzkriterium 2). +func (h *Handler) AddGroupMember(w http.ResponseWriter, r *http.Request) { + if _, ok := requireManageUsers(w, r, h.roles); !ok { + return + } + var req groupActionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + if err := h.groups.AddMember(r.Context(), req.GroupID, req.UserID); err != nil { + http.Error(w, "Mitglied konnte nicht hinzugefügt werden.", http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) +} + +// RemoveGroupMember entfernt einen Benutzer aus einer Gruppe. +func (h *Handler) RemoveGroupMember(w http.ResponseWriter, r *http.Request) { + if _, ok := requireManageUsers(w, r, h.roles); !ok { + return + } + var req groupActionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "ungueltige Anfrage", http.StatusBadRequest) + return + } + if err := h.groups.RemoveMember(r.Context(), req.GroupID, req.UserID); err != nil { + http.Error(w, "Mitglied konnte nicht entfernt werden.", http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) +} diff --git a/internal/rbac/handler_test.go b/internal/rbac/handler_test.go new file mode 100644 index 0000000..81c5635 --- /dev/null +++ b/internal/rbac/handler_test.go @@ -0,0 +1,309 @@ +package rbac + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/internal/auth" + "gitea.perlbach24.de/scripte/nexarch/internal/user" +) + +const handlerSchema = ` +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE TABLE 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 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 role_assignment_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, role TEXT NOT NULL, + granted_by TEXT NOT NULL, granted_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +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() +); +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) +);` + +func setupHandlerTest(t *testing.T, dbName string) (*Handler, *Store, *user.TenantUserStore, *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, handlerSchema); err != nil { + t.Fatalf("schema: %v", err) + } + + t.Cleanup(func() { + pool.Close() + _, _ = adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName)) + adminPool.Close() + }) + + roleStore := NewStore(pool) + groupStore := NewGroupStore(pool) + return NewHandler(roleStore, groupStore), roleStore, user.NewTenantUserStore(pool), pool +} + +func sessionCookieFor(t *testing.T, issuer *auth.TokenIssuer, userID string) *http.Cookie { + t.Helper() + token, err := issuer.Issue(userID, "acme") + if err != nil { + t.Fatalf("session-token ausstellen: %v", err) + } + return &http.Cookie{Name: auth.CookieName, Value: token} +} + +// Akzeptanzkriterium 1: Rollen und ihre Grundrechte sind einsehbar. +func TestListRoles(t *testing.T) { + h, roles, users, _ := setupHandlerTest(t, "rbac05_list_roles") + ctx := context.Background() + admin, err := users.Create(ctx, "admin@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) + } + + issuer := auth.NewTokenIssuer("test-secret") + req := httptest.NewRequest(http.MethodGet, "/rbac/roles", nil) + req.AddCookie(sessionCookieFor(t, issuer, admin.ID)) + rec := httptest.NewRecorder() + auth.RequireAuth(issuer, h.ListRoles)(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String()) + } + var out []roleInfo + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("response dekodieren: %v", err) + } + if len(out) != 2 { + t.Fatalf("erwartet 2 zuweisbare rollen (user, tenant_admin), habe %d", len(out)) + } +} + +// Akzeptanzkriterium 2 / Pruefung 2: Selbst-Eskalation wird abgewiesen. +func TestAssignRole_RejectsSelfEscalation(t *testing.T) { + h, roles, users, _ := setupHandlerTest(t, "rbac05_self_escalation") + ctx := context.Background() + u, err := users.Create(ctx, "normal@acme.example", "Normaler Nutzer") + if err != nil { + t.Fatalf("nutzer anlegen: %v", err) + } + if _, err := roles.Assign(ctx, u.ID, RoleUser, "system"); err != nil { + t.Fatalf("ausgangsrolle setzen: %v", err) + } + + issuer := auth.NewTokenIssuer("test-secret") + body, _ := json.Marshal(assignRoleRequest{UserID: u.ID, Role: RoleTenantAdmin}) + req := httptest.NewRequest(http.MethodPost, "/rbac/users/role", bytes.NewReader(body)) + req.AddCookie(sessionCookieFor(t, issuer, u.ID)) + rec := httptest.NewRecorder() + auth.RequireAuth(issuer, h.AssignRole)(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 (selbst-eskalation muss abgewiesen werden)", rec.Code) + } + + // Rolle muss unveraendert geblieben sein. + assignment, err := roles.Get(ctx, u.ID) + if err != nil { + t.Fatalf("rolle laden: %v", err) + } + if assignment.Role != RoleUser { + t.Fatalf("rolle wurde trotz abgewiesener anfrage geaendert: %q", assignment.Role) + } +} + +// Gegenprobe: ein Tenant-Admin darf EINEM ANDEREN Benutzer eine hoehere +// Rolle zuweisen — nur die Selbst-Zuweisung ist gesperrt. +func TestAssignRole_AdminCanPromoteOtherUser(t *testing.T) { + h, roles, users, _ := setupHandlerTest(t, "rbac05_promote_other") + ctx := context.Background() + admin, err := users.Create(ctx, "admin2@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) + } + other, err := users.Create(ctx, "other@acme.example", "Anderer Nutzer") + if err != nil { + t.Fatalf("anderen nutzer anlegen: %v", err) + } + + issuer := auth.NewTokenIssuer("test-secret") + body, _ := json.Marshal(assignRoleRequest{UserID: other.ID, Role: RoleTenantAdmin}) + req := httptest.NewRequest(http.MethodPost, "/rbac/users/role", bytes.NewReader(body)) + req.AddCookie(sessionCookieFor(t, issuer, admin.ID)) + rec := httptest.NewRecorder() + auth.RequireAuth(issuer, h.AssignRole)(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String()) + } +} + +// Akzeptanzkriterium 3: Aenderungen an Rechten sind nachvollziehbar. +func TestRoleHistory_TracksAssignments(t *testing.T) { + h, roles, users, _ := setupHandlerTest(t, "rbac05_history") + ctx := context.Background() + admin, err := users.Create(ctx, "admin3@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) + } + target, err := users.Create(ctx, "target@acme.example", "Zielnutzer") + if err != nil { + t.Fatalf("zielnutzer anlegen: %v", err) + } + if _, err := roles.Assign(ctx, target.ID, RoleUser, admin.ID); err != nil { + t.Fatalf("erste zuweisung: %v", err) + } + if _, err := roles.Assign(ctx, target.ID, RoleTenantAdmin, admin.ID); err != nil { + t.Fatalf("zweite zuweisung: %v", err) + } + + issuer := auth.NewTokenIssuer("test-secret") + req := httptest.NewRequest(http.MethodGet, "/rbac/users/"+target.ID+"/history", nil) + req.AddCookie(sessionCookieFor(t, issuer, admin.ID)) + rec := httptest.NewRecorder() + auth.RequireAuth(issuer, h.RoleHistory)(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body: %s", rec.Code, rec.Body.String()) + } + var history []Assignment + if err := json.Unmarshal(rec.Body.Bytes(), &history); err != nil { + t.Fatalf("response dekodieren: %v", err) + } + if len(history) != 2 { + t.Fatalf("erwartet 2 historieneintraege, habe %d", len(history)) + } +} + +// Akzeptanzkriterium 2: Gruppen anlegen, Mitglieder zuordnen, Rolle zuweisen. +func TestGroupWorkflow(t *testing.T) { + h, roles, users, _ := setupHandlerTest(t, "rbac05_group_workflow") + ctx := context.Background() + admin, err := users.Create(ctx, "admin4@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) + } + member, err := users.Create(ctx, "member@acme.example", "Mitglied") + if err != nil { + t.Fatalf("mitglied anlegen: %v", err) + } + + issuer := auth.NewTokenIssuer("test-secret") + + createBody, _ := json.Marshal(createGroupRequest{Name: "Buchhaltung"}) + createReq := httptest.NewRequest(http.MethodPost, "/rbac/groups", bytes.NewReader(createBody)) + createReq.AddCookie(sessionCookieFor(t, issuer, admin.ID)) + createRec := httptest.NewRecorder() + auth.RequireAuth(issuer, h.CreateGroup)(createRec, createReq) + if createRec.Code != http.StatusCreated { + t.Fatalf("create group: status = %d, body: %s", createRec.Code, createRec.Body.String()) + } + var group Group + if err := json.Unmarshal(createRec.Body.Bytes(), &group); err != nil { + t.Fatalf("group-response dekodieren: %v", err) + } + + roleBody, _ := json.Marshal(groupActionRequest{GroupID: group.ID, Role: RoleUser}) + roleReq := httptest.NewRequest(http.MethodPost, "/rbac/groups/role", bytes.NewReader(roleBody)) + roleReq.AddCookie(sessionCookieFor(t, issuer, admin.ID)) + roleRec := httptest.NewRecorder() + auth.RequireAuth(issuer, h.SetGroupRole)(roleRec, roleReq) + if roleRec.Code != http.StatusOK { + t.Fatalf("set group role: status = %d, body: %s", roleRec.Code, roleRec.Body.String()) + } + + memberBody, _ := json.Marshal(groupActionRequest{GroupID: group.ID, UserID: member.ID}) + memberReq := httptest.NewRequest(http.MethodPost, "/rbac/groups/members", bytes.NewReader(memberBody)) + memberReq.AddCookie(sessionCookieFor(t, issuer, admin.ID)) + memberRec := httptest.NewRecorder() + auth.RequireAuth(issuer, h.AddGroupMember)(memberRec, memberReq) + if memberRec.Code != http.StatusOK { + t.Fatalf("add member: status = %d, body: %s", memberRec.Code, memberRec.Body.String()) + } + + listReq := httptest.NewRequest(http.MethodGet, "/rbac/groups", nil) + listReq.AddCookie(sessionCookieFor(t, issuer, admin.ID)) + listRec := httptest.NewRecorder() + auth.RequireAuth(issuer, h.ListGroups)(listRec, listReq) + if listRec.Code != http.StatusOK { + t.Fatalf("list groups: status = %d, body: %s", listRec.Code, listRec.Body.String()) + } + var groups []groupWithMembers + if err := json.Unmarshal(listRec.Body.Bytes(), &groups); err != nil { + t.Fatalf("groups-response dekodieren: %v", err) + } + if len(groups) != 1 || len(groups[0].Members) != 1 || groups[0].Members[0] != member.ID { + t.Fatalf("erwartet 1 gruppe mit 1 mitglied (%s), habe %+v", member.ID, groups) + } +} + +// Ohne Rechteverwaltungsberechtigung (normale 'user'-Rolle) wird jeder +// Endpunkt abgewiesen — Tenant-Isolation ist strukturell (Modell C, siehe +// GroupStore-Kommentar), diese Pruefung deckt die Rechte-Ebene ab. +func TestRequireManageUsers_RejectsPlainUser(t *testing.T) { + h, roles, users, _ := setupHandlerTest(t, "rbac05_reject_plain") + ctx := context.Background() + u, err := users.Create(ctx, "plain@acme.example", "Normaler Nutzer") + if err != nil { + t.Fatalf("nutzer anlegen: %v", err) + } + if _, err := roles.Assign(ctx, u.ID, RoleUser, "system"); err != nil { + t.Fatalf("rolle setzen: %v", err) + } + + issuer := auth.NewTokenIssuer("test-secret") + req := httptest.NewRequest(http.MethodGet, "/rbac/roles", nil) + req.AddCookie(sessionCookieFor(t, issuer, u.ID)) + rec := httptest.NewRecorder() + auth.RequireAuth(issuer, h.ListRoles)(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403 fuer benutzer ohne rechteverwaltung", rec.Code) + } +} 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); diff --git a/web/rbac-admin/app/groups/page.tsx b/web/rbac-admin/app/groups/page.tsx new file mode 100644 index 0000000..229206d --- /dev/null +++ b/web/rbac-admin/app/groups/page.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { TextField, SelectField, useToast } from "@nexarch/shl"; +import { + ApiError, + Group, + Role, + addGroupMember, + createGroup, + fetchGroups, + removeGroupMember, + setGroupRole, +} from "../../lib/api"; + +export default function GroupsPage() { + const { push } = useToast(); + const [groups, setGroups] = useState([]); + const [loadError, setLoadError] = useState(null); + + const reload = () => + fetchGroups() + .then(setGroups) + .catch(() => setLoadError("Gruppen konnten nicht geladen werden. Sind Sie als Tenant-Admin angemeldet?")); + + useEffect(() => { + reload(); + }, []); + + const [newGroupName, setNewGroupName] = useState(""); + const [createError, setCreateError] = useState(null); + + async function onCreate(event: React.FormEvent) { + event.preventDefault(); + setCreateError(null); + try { + await createGroup(newGroupName); + setNewGroupName(""); + push("Gruppe angelegt.", "success"); + await reload(); + } catch (err) { + setCreateError(err instanceof ApiError ? err.message : "Gruppe konnte nicht angelegt werden."); + } + } + + if (loadError) { + return ( +
+

{loadError}

+
+ ); + } + + return ( +
+

Gruppen & Abteilungen

+ +
+

Neue Gruppe anlegen

+
+ setNewGroupName(e.target.value)} + /> + {createError && ( +

+ {createError} +

+ )} + + +
+ +
+

Bestehende Gruppen

+ {groups.length === 0 ? ( +

Keine Gruppen vorhanden.

+ ) : ( + groups.map((g) => push(t, "success")} />) + )} +
+
+ ); +} + +function GroupCard({ + group, + onChanged, + onNotice, +}: { + group: Group; + onChanged: () => void; + onNotice: (text: string) => void; +}) { + const [role, setRole] = useState(group.Role || "user"); + const [newMemberId, setNewMemberId] = useState(""); + const [error, setError] = useState(null); + + async function onSetRole() { + setError(null); + try { + await setGroupRole(group.ID, role); + onNotice("Gruppenrolle gesetzt."); + onChanged(); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Gruppenrolle konnte nicht gesetzt werden."); + } + } + + async function onAddMember(event: React.FormEvent) { + event.preventDefault(); + setError(null); + try { + await addGroupMember(group.ID, newMemberId); + setNewMemberId(""); + onNotice("Mitglied hinzugefügt."); + onChanged(); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Mitglied konnte nicht hinzugefügt werden."); + } + } + + async function onRemoveMember(userId: string) { + setError(null); + try { + await removeGroupMember(group.ID, userId); + onNotice("Mitglied entfernt."); + onChanged(); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Mitglied konnte nicht entfernt werden."); + } + } + + return ( +
+

{group.Name}

+ +
+ setRole(e.target.value as Role)}> + + + + +
+ +

Mitglieder

+ {group.members.length === 0 ? ( +

Keine Mitglieder.

+ ) : ( +
    + {group.members.map((userId) => ( +
  • + {userId}{" "} + +
  • + ))} +
+ )} + +
+ setNewMemberId(e.target.value)} + /> + + + + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/web/rbac-admin/app/layout.tsx b/web/rbac-admin/app/layout.tsx new file mode 100644 index 0000000..7390a13 --- /dev/null +++ b/web/rbac-admin/app/layout.tsx @@ -0,0 +1,30 @@ +import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl"; + +export const metadata = { + title: "NEXARCH Rechteverwaltung", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + {children} + + + + + ); +} diff --git a/web/rbac-admin/app/page.tsx b/web/rbac-admin/app/page.tsx new file mode 100644 index 0000000..36a566d --- /dev/null +++ b/web/rbac-admin/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function IndexPage() { + redirect("/roles"); +} diff --git a/web/rbac-admin/app/roles/page.tsx b/web/rbac-admin/app/roles/page.tsx new file mode 100644 index 0000000..2757f8c --- /dev/null +++ b/web/rbac-admin/app/roles/page.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Table, TextField, SelectField, useToast } from "@nexarch/shl"; +import type { TableColumn } from "@nexarch/shl"; +import { + ApiError, + Assignment, + Role, + RoleInfo, + assignRole, + fetchRoleHistory, + fetchRoles, +} from "../../lib/api"; + +export default function RolesPage() { + const { push } = useToast(); + const [roles, setRoles] = useState(null); + const [loadError, setLoadError] = useState(null); + + const [targetUserId, setTargetUserId] = useState(""); + const [targetRole, setTargetRole] = useState("user"); + const [assignError, setAssignError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const [historyUserId, setHistoryUserId] = useState(""); + const [history, setHistory] = useState(null); + + useEffect(() => { + fetchRoles() + .then(setRoles) + .catch(() => setLoadError("Rollen konnten nicht geladen werden. Sind Sie als Tenant-Admin angemeldet?")); + }, []); + + async function onAssign(event: React.FormEvent) { + event.preventDefault(); + setAssignError(null); + setSubmitting(true); + try { + await assignRole(targetUserId, targetRole); + push("Rolle zugewiesen.", "success"); + } catch (err) { + // Akzeptanzkriterium 2: die Backend-Meldung bei Selbst-Eskalation wird + // unveraendert angezeigt, keine eigene Interpretation im Frontend. + setAssignError(err instanceof ApiError ? err.message : "Rolle konnte nicht zugewiesen werden."); + } finally { + setSubmitting(false); + } + } + + async function onLoadHistory(event: React.FormEvent) { + event.preventDefault(); + try { + setHistory(await fetchRoleHistory(historyUserId)); + } catch { + setHistory([]); + } + } + + const historyColumns: TableColumn[] = [ + { key: "role", header: "Rolle", render: (a) => a.Role }, + { key: "grantedBy", header: "Vergeben von", render: (a) => a.GrantedBy }, + ]; + + if (loadError) { + return ( +
+

{loadError}

+
+ ); + } + + return ( +
+

Rollen & Rechte

+ +
+

Rollen und Grundrechte

+ {roles?.map((r) => ( +
+ {roleLabel(r.role)} +
    + {r.permissions.map((p) => ( +
  • {p}
  • + ))} +
+
+ ))} +
+ +
+

Rolle zuweisen

+
+ setTargetUserId(e.target.value)} + /> + setTargetRole(e.target.value as Role)}> + + + + {assignError && ( +

+ {assignError} +

+ )} + + +
+ +
+

Nachvollziehbarkeit (Audit-Verlauf)

+

Zeigt jede Rollenzuweisung eines Benutzers chronologisch — wer wann welche Rolle vergeben hat.

+
+ setHistoryUserId(e.target.value)} + /> + + + {history && ( + `${a.UserID}-${a.Role}-${a.GrantedBy}`} + caption="Rollenzuweisungs-Verlauf" + /> + )} + + + ); +} + +function roleLabel(role: Role): string { + return role === "tenant_admin" ? "Tenant-Admin" : "Benutzer"; +} diff --git a/web/rbac-admin/lib/api.ts b/web/rbac-admin/lib/api.ts new file mode 100644 index 0000000..8ab70dd --- /dev/null +++ b/web/rbac-admin/lib/api.ts @@ -0,0 +1,68 @@ +const API_BASE = process.env.NEXT_PUBLIC_CORE_API_BASE ?? ""; + +export class ApiError extends Error {} + +async function req(path: string, init?: RequestInit): Promise { + const res = await fetch(`${API_BASE}${path}`, { + credentials: "include", + headers: init?.body ? { "Content-Type": "application/json" } : undefined, + ...init, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new ApiError(data.error ?? data.message ?? "Unbekannter Fehler"); + } + return data as T; +} + +export type Role = "user" | "tenant_admin"; + +export interface RoleInfo { + role: Role; + permissions: string[]; +} + +export function fetchRoles(): Promise { + return req("/rbac/roles"); +} + +export interface Assignment { + UserID: string; + Role: Role; + GrantedBy: string; +} + +export function assignRole(userId: string, role: Role): Promise { + return req("/rbac/users/role", { method: "POST", body: JSON.stringify({ user_id: userId, role }) }); +} + +export function fetchRoleHistory(userId: string): Promise { + return req(`/rbac/users/${userId}/history`); +} + +export interface Group { + ID: string; + Name: string; + Role: Role | ""; + members: string[]; +} + +export function fetchGroups(): Promise { + return req("/rbac/groups"); +} + +export function createGroup(name: string): Promise { + return req("/rbac/groups", { method: "POST", body: JSON.stringify({ name }) }); +} + +export function setGroupRole(groupId: string, role: Role): Promise { + return req("/rbac/groups/role", { method: "POST", body: JSON.stringify({ group_id: groupId, role }) }); +} + +export function addGroupMember(groupId: string, userId: string): Promise { + return req("/rbac/groups/members", { method: "POST", body: JSON.stringify({ group_id: groupId, user_id: userId }) }); +} + +export function removeGroupMember(groupId: string, userId: string): Promise { + return req("/rbac/groups/members/remove", { method: "POST", body: JSON.stringify({ group_id: groupId, user_id: userId }) }); +} diff --git a/web/rbac-admin/next.config.mjs b/web/rbac-admin/next.config.mjs new file mode 100644 index 0000000..aa96725 --- /dev/null +++ b/web/rbac-admin/next.config.mjs @@ -0,0 +1,5 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ["@nexarch/shl"], +}; +export default nextConfig; diff --git a/web/rbac-admin/package.json b/web/rbac-admin/package.json new file mode 100644 index 0000000..922d9a7 --- /dev/null +++ b/web/rbac-admin/package.json @@ -0,0 +1,22 @@ +{ + "name": "nexarch-rbac-admin", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@nexarch/shl": "file:../shl", + "next": "14.2.35", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "20.14.9", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "typescript": "5.5.3" + } +} diff --git a/web/rbac-admin/tsconfig.json b/web/rbac-admin/tsconfig.json new file mode 100644 index 0000000..8b1f373 --- /dev/null +++ b/web/rbac-admin/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}