diff --git a/internal/rbac/group.go b/internal/rbac/group.go index 22565c7..9d96ee1 100644 --- a/internal/rbac/group.go +++ b/internal/rbac/group.go @@ -95,6 +95,47 @@ func (s *GroupStore) DeleteGroup(ctx context.Context, groupID string) error { 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. 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) + } +}