359 lines
13 KiB
Go
359 lines
13 KiB
Go
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())
|
|
}
|
|
}
|
|
|
|
// QA-03 Akzeptanzkriterium 2: Rueckstufung (tenant_admin -> user) durch
|
|
// einen Admin funktioniert ebenso wie die bereits getestete Hochstufung —
|
|
// Rollenwechsel-Szenario in beide Richtungen, History haelt beide fest.
|
|
func TestAssignRole_AdminCanDemoteOtherUser(t *testing.T) {
|
|
h, roles, users, _ := setupHandlerTest(t, "qa03_demote_other")
|
|
ctx := context.Background()
|
|
admin, err := users.Create(ctx, "admin-demote@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, "wird-zurueckgestuft@acme.example", "Wird zurueckgestuft")
|
|
if err != nil {
|
|
t.Fatalf("anderen nutzer anlegen: %v", err)
|
|
}
|
|
if _, err := roles.Assign(ctx, other.ID, RoleTenantAdmin, admin.ID); err != nil {
|
|
t.Fatalf("ausgangsrolle (tenant_admin) setzen: %v", err)
|
|
}
|
|
|
|
issuer := auth.NewTokenIssuer("test-secret")
|
|
body, _ := json.Marshal(assignRoleRequest{UserID: other.ID, Role: RoleUser})
|
|
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())
|
|
}
|
|
|
|
assignment, err := roles.Get(ctx, other.ID)
|
|
if err != nil {
|
|
t.Fatalf("rolle laden: %v", err)
|
|
}
|
|
if assignment.Role != RoleUser {
|
|
t.Fatalf("rolle = %q, want user (rueckgestuft)", assignment.Role)
|
|
}
|
|
|
|
history, err := roles.History(ctx, other.ID)
|
|
if err != nil {
|
|
t.Fatalf("history laden: %v", err)
|
|
}
|
|
if len(history) != 2 || history[0].Role != RoleTenantAdmin || history[1].Role != RoleUser {
|
|
t.Fatalf("erwartet history [tenant_admin, user] (hoch- dann rueckgestuft), habe %+v", history)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|