feat(rollen): granulares Rechte-System (Roadmap Phase 6, letztes Modul)
CI / backend-tests (push) Failing after 1m55s
CI / frontend-build (push) Successful in 19s

Additiv neben dem bestehenden 4-Rollen-System (mitarbeiter/material-
verantwortlicher/leitungsverantwortlicher/administration bleiben unverändert,
kein Breaking Change): Admin kann jetzt eigene Rollen (z.B. "Materialwart",
"Helfer" aus der Ursprungs-Anforderung) mit frei wählbaren Einzelrechten aus
einem Berechtigungs-Katalog anlegen und Benutzern zuweisen - auch Benutzern
ganz ohne feste RolleTyp-Zuordnung.

Neue Tabellen: berechtigung (Katalog), rolle (custom, admin-anlegbar),
rolle_berechtigung (M:N), benutzer_rolle_zuordnung (M:N, eigene Tabelle statt
Wiederverwendung des ENUM-basierten benutzer_rolle).

require_roles_or_permission() kombiniert beide Systeme: bestehende feste
Rollen ODER eine passende granulare Berechtigung. Auf die vom Nutzer genannten
Beispiel-Endpunkte angewendet: Material anlegen/bearbeiten, Lagerbewegung
durchführen, Mangel melden/lesen/bearbeiten, Prüfung durchführen - weitere
Endpunkte folgen bei Bedarf nach demselben Muster (require_permission()/
require_roles_or_permission() stehen jetzt als Bausteine bereit).

Neue Endpunkte: GET /berechtigungen, CRUD /rollen, PUT/DELETE
/rollen/{id}/berechtigungen/{id}, PUT/DELETE /benutzer/{id}/rollen/{id}.

Frontend: neuer Admin-Tab "Rollen & Rechte" (Rolle anlegen, Rechte togglen,
Benutzer zuweisen/entfernen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC8HYvv6UkCVYheYiTw9DD
This commit is contained in:
2026-09-05 14:07:03 +02:00
co-authored by Claude Sonnet 5
parent dbbbd0940b
commit c56fc285de
15 changed files with 740 additions and 17 deletions
+119
View File
@@ -0,0 +1,119 @@
import pytest
from tests.conftest import auth_header, login
async def _benutzer_ohne_feste_rolle(db_session, login_name: str):
from app.core.security import hash_password
from app.models.auth import Benutzer
benutzer = Benutzer(
name="Helfer Ohne Feste Rolle",
login=login_name,
passwort_hash=hash_password("test-passwort-123"),
aktiv=True,
)
db_session.add(benutzer)
await db_session.flush()
return benutzer
@pytest.mark.asyncio
async def test_custom_rolle_gewaehrt_zugriff_ohne_feste_rolle(client, db_session, admin_user):
"""Kern des granularen Rechte-Systems: ein Benutzer OHNE jede feste
RolleTyp-Zuordnung darf trotzdem etwas tun, wenn ihm eine custom Rolle mit
passender Berechtigung zugewiesen wurde (Nutzer-Beispiel: "Materialwart"
darf Material erstellen)."""
helfer = await _benutzer_ohne_feste_rolle(db_session, "materialwart1")
admin_token = await login(client, "admin1")
ohne_rolle = await client.post(
"/api/v1/materialien",
json={"name": "Verband", "einheit": "Stück", "materialtyp": "standard"},
headers=auth_header(await login(client, "materialwart1")),
)
assert ohne_rolle.status_code == 403
rolle = await client.post(
"/api/v1/rollen", json={"name": "Materialwart"}, headers=auth_header(admin_token)
)
rolle_id = rolle.json()["id"]
berechtigungen = await client.get("/api/v1/berechtigungen", headers=auth_header(admin_token))
material_erstellen = next(b for b in berechtigungen.json() if b["schluessel"] == "material.erstellen")
await client.put(
f"/api/v1/rollen/{rolle_id}/berechtigungen/{material_erstellen['id']}", headers=auth_header(admin_token)
)
await client.put(f"/api/v1/benutzer/{helfer.id}/rollen/{rolle_id}", headers=auth_header(admin_token))
helfer_token = await login(client, "materialwart1")
mit_rolle = await client.post(
"/api/v1/materialien",
json={"name": "Verband", "einheit": "Stück", "materialtyp": "standard"},
headers=auth_header(helfer_token),
)
assert mit_rolle.status_code == 201
@pytest.mark.asyncio
async def test_legacy_admin_funktioniert_weiterhin_ohne_custom_rolle(client, admin_user):
"""Regressionstest: bestehende feste Rollen funktionieren unverändert,
das granulare System ist rein additiv."""
token = await login(client, "admin1")
response = await client.post(
"/api/v1/materialien",
json={"name": "Schere", "einheit": "Stück", "materialtyp": "standard"},
headers=auth_header(token),
)
assert response.status_code == 201
@pytest.mark.asyncio
async def test_nur_admin_darf_rollen_verwalten(client, mitarbeiter_user):
token = await login(client, "mitarbeiter1")
response = await client.post("/api/v1/rollen", json={"name": "X"}, headers=auth_header(token))
assert response.status_code == 403
@pytest.mark.asyncio
async def test_rolle_ohne_berechtigung_bleibt_wirkungslos(client, db_session, admin_user):
helfer = await _benutzer_ohne_feste_rolle(db_session, "leerrolle1")
admin_token = await login(client, "admin1")
rolle = await client.post("/api/v1/rollen", json={"name": "Leer"}, headers=auth_header(admin_token))
rolle_id = rolle.json()["id"]
await client.put(f"/api/v1/benutzer/{helfer.id}/rollen/{rolle_id}", headers=auth_header(admin_token))
helfer_token = await login(client, "leerrolle1")
response = await client.post(
"/api/v1/materialien",
json={"name": "X", "einheit": "Stück", "materialtyp": "standard"},
headers=auth_header(helfer_token),
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_berechtigung_entfernen_wirkt_sofort(client, db_session, admin_user):
helfer = await _benutzer_ohne_feste_rolle(db_session, "materialwart2")
admin_token = await login(client, "admin1")
rolle = await client.post("/api/v1/rollen", json={"name": "Materialwart2"}, headers=auth_header(admin_token))
rolle_id = rolle.json()["id"]
berechtigungen = await client.get("/api/v1/berechtigungen", headers=auth_header(admin_token))
material_erstellen = next(b for b in berechtigungen.json() if b["schluessel"] == "material.erstellen")
await client.put(
f"/api/v1/rollen/{rolle_id}/berechtigungen/{material_erstellen['id']}", headers=auth_header(admin_token)
)
await client.put(f"/api/v1/benutzer/{helfer.id}/rollen/{rolle_id}", headers=auth_header(admin_token))
await client.delete(
f"/api/v1/rollen/{rolle_id}/berechtigungen/{material_erstellen['id']}", headers=auth_header(admin_token)
)
helfer_token = await login(client, "materialwart2")
response = await client.post(
"/api/v1/materialien",
json={"name": "X", "einheit": "Stück", "materialtyp": "standard"},
headers=auth_header(helfer_token),
)
assert response.status_code == 403