Merge branch 'feature/rbac-05-rechte-administrationsoberflaeche' into feature/qa-09-barrierefreiheits-audit
# Conflicts: # DEVLOG.md
This commit is contained in:
@@ -182,7 +182,29 @@ Keine Commits in dieser Session.
|
|||||||
- web/shl/vitest.setup.ts | 1 +
|
- 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
|
**Beschreibung:** Claude Code Session
|
||||||
**Projekt:** code
|
**Projekt:** code
|
||||||
|
|
||||||
@@ -197,5 +219,13 @@ Keine Commits in dieser Session.
|
|||||||
- migrations/0003_tenant_settings.up.sql | 27 +++++++++++++++++++++++++++
|
- migrations/0003_tenant_settings.up.sql | 27 +++++++++++++++++++++++++++
|
||||||
- scripts/reset-test-env.sh | 25 +++++++++++++++++++++++++
|
- scripts/reset-test-env.sh | 25 +++++++++++++++++++++++++
|
||||||
- scripts/run-checks.sh | 24 ++++++++++++++++++++++++
|
- 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 +++++++++++++++++++++
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS group_members;
|
||||||
|
DROP TABLE IF EXISTS groups;
|
||||||
@@ -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);
|
||||||
@@ -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<Group[]>([]);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(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<string | null>(null);
|
||||||
|
|
||||||
|
async function onCreate(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<main style={{ maxWidth: 640, margin: "40px auto", padding: "0 16px" }}>
|
||||||
|
<p role="alert">{loadError}</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 640, margin: "40px auto", padding: "0 16px" }}>
|
||||||
|
<h1>Gruppen & Abteilungen</h1>
|
||||||
|
|
||||||
|
<section aria-labelledby="neue-gruppe-heading">
|
||||||
|
<h2 id="neue-gruppe-heading">Neue Gruppe anlegen</h2>
|
||||||
|
<form onSubmit={onCreate} noValidate>
|
||||||
|
<TextField
|
||||||
|
label="Gruppenname"
|
||||||
|
required
|
||||||
|
value={newGroupName}
|
||||||
|
onChange={(e) => setNewGroupName(e.target.value)}
|
||||||
|
/>
|
||||||
|
{createError && (
|
||||||
|
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
||||||
|
{createError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="submit">Anlegen</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="gruppenliste-heading">
|
||||||
|
<h2 id="gruppenliste-heading">Bestehende Gruppen</h2>
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<p>Keine Gruppen vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
groups.map((g) => <GroupCard key={g.ID} group={g} onChanged={reload} onNotice={(t) => push(t, "success")} />)
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GroupCard({
|
||||||
|
group,
|
||||||
|
onChanged,
|
||||||
|
onNotice,
|
||||||
|
}: {
|
||||||
|
group: Group;
|
||||||
|
onChanged: () => void;
|
||||||
|
onNotice: (text: string) => void;
|
||||||
|
}) {
|
||||||
|
const [role, setRole] = useState<Role>(group.Role || "user");
|
||||||
|
const [newMemberId, setNewMemberId] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(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<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<div style={{ border: "1px solid var(--shl-color-border)", borderRadius: 8, padding: 16, marginBottom: 16 }}>
|
||||||
|
<h3>{group.Name}</h3>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-end", gap: 8 }}>
|
||||||
|
<SelectField label="Gruppenrolle" value={role} onChange={(e) => setRole(e.target.value as Role)}>
|
||||||
|
<option value="user">Benutzer</option>
|
||||||
|
<option value="tenant_admin">Tenant-Admin</option>
|
||||||
|
</SelectField>
|
||||||
|
<button type="button" onClick={onSetRole}>
|
||||||
|
Setzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4>Mitglieder</h4>
|
||||||
|
{group.members.length === 0 ? (
|
||||||
|
<p>Keine Mitglieder.</p>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{group.members.map((userId) => (
|
||||||
|
<li key={userId}>
|
||||||
|
{userId}{" "}
|
||||||
|
<button type="button" onClick={() => onRemoveMember(userId)}>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={onAddMember} noValidate>
|
||||||
|
<TextField
|
||||||
|
label="Benutzer-ID hinzufügen"
|
||||||
|
required
|
||||||
|
value={newMemberId}
|
||||||
|
onChange={(e) => setNewMemberId(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit">Hinzufügen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<html lang="de">
|
||||||
|
<body
|
||||||
|
style={{
|
||||||
|
fontFamily: typography.fontFamily,
|
||||||
|
margin: 0,
|
||||||
|
background: "var(--shl-color-background, #ffffff)",
|
||||||
|
color: "var(--shl-color-text-primary, #14181f)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ThemeProvider>
|
||||||
|
<I18nProvider initialLocale="de">
|
||||||
|
<ToastProvider>{children}</ToastProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function IndexPage() {
|
||||||
|
redirect("/roles");
|
||||||
|
}
|
||||||
@@ -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<RoleInfo[] | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [targetUserId, setTargetUserId] = useState("");
|
||||||
|
const [targetRole, setTargetRole] = useState<Role>("user");
|
||||||
|
const [assignError, setAssignError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [historyUserId, setHistoryUserId] = useState("");
|
||||||
|
const [history, setHistory] = useState<Assignment[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoles()
|
||||||
|
.then(setRoles)
|
||||||
|
.catch(() => setLoadError("Rollen konnten nicht geladen werden. Sind Sie als Tenant-Admin angemeldet?"));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function onAssign(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
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<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
setHistory(await fetchRoleHistory(historyUserId));
|
||||||
|
} catch {
|
||||||
|
setHistory([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyColumns: TableColumn<Assignment>[] = [
|
||||||
|
{ key: "role", header: "Rolle", render: (a) => a.Role },
|
||||||
|
{ key: "grantedBy", header: "Vergeben von", render: (a) => a.GrantedBy },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 640, margin: "40px auto", padding: "0 16px" }}>
|
||||||
|
<p role="alert">{loadError}</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 640, margin: "40px auto", padding: "0 16px" }}>
|
||||||
|
<h1>Rollen & Rechte</h1>
|
||||||
|
|
||||||
|
<section aria-labelledby="rollen-heading">
|
||||||
|
<h2 id="rollen-heading">Rollen und Grundrechte</h2>
|
||||||
|
{roles?.map((r) => (
|
||||||
|
<div key={r.role} style={{ marginBottom: 12 }}>
|
||||||
|
<strong>{roleLabel(r.role)}</strong>
|
||||||
|
<ul>
|
||||||
|
{r.permissions.map((p) => (
|
||||||
|
<li key={p}>{p}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="zuweisen-heading">
|
||||||
|
<h2 id="zuweisen-heading">Rolle zuweisen</h2>
|
||||||
|
<form onSubmit={onAssign} noValidate>
|
||||||
|
<TextField
|
||||||
|
label="Benutzer-ID"
|
||||||
|
required
|
||||||
|
value={targetUserId}
|
||||||
|
onChange={(e) => setTargetUserId(e.target.value)}
|
||||||
|
/>
|
||||||
|
<SelectField label="Rolle" value={targetRole} onChange={(e) => setTargetRole(e.target.value as Role)}>
|
||||||
|
<option value="user">Benutzer</option>
|
||||||
|
<option value="tenant_admin">Tenant-Admin</option>
|
||||||
|
</SelectField>
|
||||||
|
{assignError && (
|
||||||
|
<p role="alert" style={{ color: "var(--shl-color-danger)" }}>
|
||||||
|
{assignError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Wird zugewiesen…" : "Zuweisen"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="verlauf-heading">
|
||||||
|
<h2 id="verlauf-heading">Nachvollziehbarkeit (Audit-Verlauf)</h2>
|
||||||
|
<p>Zeigt jede Rollenzuweisung eines Benutzers chronologisch — wer wann welche Rolle vergeben hat.</p>
|
||||||
|
<form onSubmit={onLoadHistory} noValidate>
|
||||||
|
<TextField
|
||||||
|
label="Benutzer-ID"
|
||||||
|
required
|
||||||
|
value={historyUserId}
|
||||||
|
onChange={(e) => setHistoryUserId(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit">Verlauf anzeigen</button>
|
||||||
|
</form>
|
||||||
|
{history && (
|
||||||
|
<Table
|
||||||
|
columns={historyColumns}
|
||||||
|
rows={history}
|
||||||
|
rowKey={(a) => `${a.UserID}-${a.Role}-${a.GrantedBy}`}
|
||||||
|
caption="Rollenzuweisungs-Verlauf"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(role: Role): string {
|
||||||
|
return role === "tenant_admin" ? "Tenant-Admin" : "Benutzer";
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
const API_BASE = process.env.NEXT_PUBLIC_CORE_API_BASE ?? "";
|
||||||
|
|
||||||
|
export class ApiError extends Error {}
|
||||||
|
|
||||||
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
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<RoleInfo[]> {
|
||||||
|
return req("/rbac/roles");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Assignment {
|
||||||
|
UserID: string;
|
||||||
|
Role: Role;
|
||||||
|
GrantedBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignRole(userId: string, role: Role): Promise<Assignment> {
|
||||||
|
return req("/rbac/users/role", { method: "POST", body: JSON.stringify({ user_id: userId, role }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchRoleHistory(userId: string): Promise<Assignment[]> {
|
||||||
|
return req(`/rbac/users/${userId}/history`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
ID: string;
|
||||||
|
Name: string;
|
||||||
|
Role: Role | "";
|
||||||
|
members: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchGroups(): Promise<Group[]> {
|
||||||
|
return req("/rbac/groups");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createGroup(name: string): Promise<Group> {
|
||||||
|
return req("/rbac/groups", { method: "POST", body: JSON.stringify({ name }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setGroupRole(groupId: string, role: Role): Promise<void> {
|
||||||
|
return req("/rbac/groups/role", { method: "POST", body: JSON.stringify({ group_id: groupId, role }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addGroupMember(groupId: string, userId: string): Promise<void> {
|
||||||
|
return req("/rbac/groups/members", { method: "POST", body: JSON.stringify({ group_id: groupId, user_id: userId }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeGroupMember(groupId: string, userId: string): Promise<void> {
|
||||||
|
return req("/rbac/groups/members/remove", { method: "POST", body: JSON.stringify({ group_id: groupId, user_id: userId }) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
transpilePackages: ["@nexarch/shl"],
|
||||||
|
};
|
||||||
|
export default nextConfig;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user