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) } }