diff --git a/DEVLOG.md b/DEVLOG.md index 8a2107b..8ec62f1 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -4106,3 +4106,15 @@ Keine Commits in dieser Session. - frontend/src/pages/admin/HistorieSection.tsx | 11 ++++++++++- --- +## 2026-09-05 13:52 – 13:57 (5m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +- dbbbd09 fix(deploy): systemd ReadWritePaths um /opt/mabea/uploads ergänzen +- 73bf6c1 feat(dokument): Dokumente-Modul (Roadmap Phase 5) + +### Geänderte Dateien +- deploy/mabea-backend.service.template | 2 +- + +--- diff --git a/backend/alembic/versions/0017_granulare_rollen.py b/backend/alembic/versions/0017_granulare_rollen.py new file mode 100644 index 0000000..71613f3 --- /dev/null +++ b/backend/alembic/versions/0017_granulare_rollen.py @@ -0,0 +1,93 @@ +"""Granulares Rechte-System (Roadmap Phase 6): additiv neben dem bestehenden +4-Rollen-System (mitarbeiter/materialverantwortlicher/leitungsverantwortlicher/ +administration bleiben unverändert, gleiches enum-basiertes benutzer_rolle). + +Neu: berechtigung (Katalog einzelner Rechte, z.B. "material.erstellen"), +rolle (vom Admin frei anlegbare Rollen wie "Materialwart"/"Helfer"), +rolle_berechtigung (M:N), benutzer_rolle_zuordnung (M:N Benutzer<->neue Rolle - +bewusst eigene Tabelle statt Wiederverwendung von benutzer_rolle, da dessen +rolle-Spalte ein fester ENUM ist, keine FK auf frei anlegbare Zeilen). + +Ein Benutzer kann künftig BEIDES gleichzeitig haben: eine feste Rolle (steuert +weiterhin alle bisherigen require_roles()-Endpunkte unverändert) und/oder eine +oder mehrere frei konfigurierte Rollen (steuert die neuen, granular geprüften +Endpunkte über require_permission()). + +Revision ID: 0017_granulare_rollen +Revises: 0016_dokument +Create Date: 2026-09-05 +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0017_granulare_rollen" +down_revision: Union[str, None] = "0016_dokument" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# Katalog-Startbestand: deckt die vom Nutzer genannten Beispiel-Rollen +# (Materialwart, Helfer) ab. Weitere Berechtigungen können später ergänzt +# werden, ohne bestehende Zeilen zu ändern (additiv). +BERECHTIGUNGEN = [ + ("material.ansehen", "Material ansehen"), + ("material.erstellen", "Material erstellen"), + ("material.bearbeiten", "Material bearbeiten"), + ("lagerbewegung.durchfuehren", "Lagerbewegungen durchführen"), + ("mangel.lesen", "Mängel ansehen"), + ("mangel.melden", "Mängel melden"), + ("mangel.bearbeiten", "Mängel bearbeiten/erledigen"), + ("pruefung.durchfuehren", "Prüfungen durchführen"), +] + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE berechtigung ( + id SERIAL PRIMARY KEY, + schluessel TEXT NOT NULL UNIQUE, + beschreibung TEXT NOT NULL + ) + """ + ) + op.execute( + """ + CREATE TABLE rolle ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + beschreibung TEXT + ) + """ + ) + op.execute( + """ + CREATE TABLE rolle_berechtigung ( + rolle_id INTEGER NOT NULL REFERENCES rolle(id) ON DELETE CASCADE, + berechtigung_id INTEGER NOT NULL REFERENCES berechtigung(id) ON DELETE CASCADE, + PRIMARY KEY (rolle_id, berechtigung_id) + ) + """ + ) + op.execute( + """ + CREATE TABLE benutzer_rolle_zuordnung ( + benutzer_id INTEGER NOT NULL REFERENCES benutzer(id) ON DELETE CASCADE, + rolle_id INTEGER NOT NULL REFERENCES rolle(id) ON DELETE CASCADE, + PRIMARY KEY (benutzer_id, rolle_id) + ) + """ + ) + + for schluessel, beschreibung in BERECHTIGUNGEN: + op.execute( + "INSERT INTO berechtigung (schluessel, beschreibung) VALUES " + f"('{schluessel}', '{beschreibung}')" + ) + + +def downgrade() -> None: + op.execute("DROP TABLE benutzer_rolle_zuordnung") + op.execute("DROP TABLE rolle_berechtigung") + op.execute("DROP TABLE rolle") + op.execute("DROP TABLE berechtigung") diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index aefa229..3661efe 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.security import InvalidTokenError, decode_access_token from app.db.session import get_db from app.models.auth import Benutzer, RolleTyp +from app.models.permission import Berechtigung, BenutzerRolleZuordnung, Rolle, RolleBerechtigung oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") @@ -50,3 +51,55 @@ def require_roles(*erlaubte_rollen: RolleTyp) -> Callable: return current_user return checker + + +async def _hat_berechtigung(db: AsyncSession, benutzer_id: int, schluessel: tuple[str, ...]) -> bool: + result = await db.execute( + select(Berechtigung.schluessel) + .join(RolleBerechtigung, RolleBerechtigung.berechtigung_id == Berechtigung.id) + .join(Rolle, Rolle.id == RolleBerechtigung.rolle_id) + .join(BenutzerRolleZuordnung, BenutzerRolleZuordnung.rolle_id == Rolle.id) + .where(BenutzerRolleZuordnung.benutzer_id == benutzer_id, Berechtigung.schluessel.in_(schluessel)) + ) + return result.first() is not None + + +def require_permission(*erlaubte_berechtigungen: str) -> Callable: + """Roadmap Phase 6 (granulares Rechte-System): prüft, ob eine der frei + konfigurierbaren Rollen des Benutzers (app.models.permission.Rolle) eine + der übergebenen Berechtigungen trägt - additiv zum festen 4-Rollen-System, + das über require_roles() unverändert weiterläuft.""" + + async def checker( + current_user: Benutzer = Depends(get_current_user), db: AsyncSession = Depends(get_db) + ) -> Benutzer: + if not await _hat_berechtigung(db, current_user.id, erlaubte_berechtigungen): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Für diese Aktion fehlt die erforderliche Berechtigung", + ) + return current_user + + return checker + + +def require_roles_or_permission(*erlaubte_rollen: RolleTyp, berechtigung: str) -> Callable: + """Kombinierte Prüfung für Endpunkte, die sowohl über eine der festen + Rollen (bestehende Admins/Materialverantwortliche/... - Rückwärts- + kompatibilität) als auch über eine neu zugewiesene granulare Berechtigung + (z.B. eine custom "Materialwart"-Rolle) erreichbar sein sollen.""" + + async def checker( + current_user: Benutzer = Depends(get_current_user), db: AsyncSession = Depends(get_db) + ) -> Benutzer: + besitzt = {RolleTyp(r) for r in current_user.rollen_namen} + if besitzt.intersection(erlaubte_rollen): + return current_user + if await _hat_berechtigung(db, current_user.id, (berechtigung,)): + return current_user + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Für diese Aktion fehlt die erforderliche Rolle oder Berechtigung", + ) + + return checker diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 2157c1c..0eff51d 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -14,6 +14,7 @@ from app.api.v1.endpoints import ( lagerbewegung, mangel, objekte, + permission, personal, stammdaten, vorlagen, @@ -38,3 +39,4 @@ api_router.include_router(personal.router, tags=["personal"]) api_router.include_router(mangel.router, tags=["mangel"]) api_router.include_router(lagerbewegung.router, tags=["lagerbewegung"]) api_router.include_router(dokument.router, tags=["dokument"]) +api_router.include_router(permission.router, tags=["permission"]) diff --git a/backend/app/api/v1/endpoints/geraet_instanz.py b/backend/app/api/v1/endpoints/geraet_instanz.py index ae13d74..4a06332 100644 --- a/backend/app/api/v1/endpoints/geraet_instanz.py +++ b/backend/app/api/v1/endpoints/geraet_instanz.py @@ -5,7 +5,7 @@ from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps import require_roles +from app.api.deps import require_roles, require_roles_or_permission from app.db.session import get_db from app.models.auth import KnotenTyp, RolleTyp, Systemknoten from app.models.geraet_instanz import GeraetInstanz @@ -18,6 +18,14 @@ router = APIRouter() _materialverantwortliche = require_roles( RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher ) +# Roadmap Phase 6: Prüfung durchführen zusätzlich über die granulare +# Berechtigung "pruefung.durchfuehren" erreichbar (z.B. custom "Helfer"-Rolle). +_darf_pruefen = require_roles_or_permission( + RolleTyp.administration, + RolleTyp.materialverantwortlicher, + RolleTyp.leitungsverantwortlicher, + berechtigung="pruefung.durchfuehren", +) async def _hauptserver_id(db: AsyncSession) -> int: @@ -61,7 +69,7 @@ async def aendere_geraet_status( geraet_id: uuid.UUID, payload: GeraetInstanzStatusUpdate, db: AsyncSession = Depends(get_db), - current_user=Depends(_materialverantwortliche), + current_user=Depends(_darf_pruefen), ) -> GeraetInstanz: instanz = await db.get(GeraetInstanz, geraet_id) if instanz is None: diff --git a/backend/app/api/v1/endpoints/lagerbewegung.py b/backend/app/api/v1/endpoints/lagerbewegung.py index 7e4ebd9..023e336 100644 --- a/backend/app/api/v1/endpoints/lagerbewegung.py +++ b/backend/app/api/v1/endpoints/lagerbewegung.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps import get_current_user, require_roles +from app.api.deps import get_current_user, require_roles_or_permission from app.db.session import get_db from app.models.auth import RolleTyp from app.models.lagerbewegung import Lagerbewegung @@ -13,10 +13,14 @@ from app.services.lagerbewegung import verschiebe_objekt router = APIRouter() -# Deckt sich mit der Rollen-Vorgabe aus dem Mangel-Modul (Materialwart- -# Berechtigung "Lagerbewegungen durchführen" aus der Ursprungs-Anforderung). -_materialverantwortliche = require_roles( - RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher +# Deckt sich mit der Rollen-Vorgabe aus dem Mangel-Modul, zusätzlich über die +# granulare Berechtigung "lagerbewegung.durchfuehren" erreichbar (z.B. eine +# custom "Materialwart"-Rolle, Roadmap Phase 6). +_materialverantwortliche = require_roles_or_permission( + RolleTyp.administration, + RolleTyp.materialverantwortlicher, + RolleTyp.leitungsverantwortlicher, + berechtigung="lagerbewegung.durchfuehren", ) diff --git a/backend/app/api/v1/endpoints/mangel.py b/backend/app/api/v1/endpoints/mangel.py index 555c3c1..677f27f 100644 --- a/backend/app/api/v1/endpoints/mangel.py +++ b/backend/app/api/v1/endpoints/mangel.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps import require_roles +from app.api.deps import require_roles_or_permission from app.db.session import get_db from app.models.auth import KnotenTyp, RolleTyp, Systemknoten from app.models.mangel import Mangel, MangelStatus @@ -16,14 +16,21 @@ from app.services.mangel import aendere_status router = APIRouter() -_mitarbeiter_plus = require_roles( +_ALLE_ROLLEN = ( RolleTyp.mitarbeiter, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher, RolleTyp.administration, ) -_materialverantwortliche = require_roles( - RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher +# Roadmap Phase 6: zusätzlich über eine granulare custom-Rolle (z.B. "Helfer") +# erreichbar, auch wenn diese keine der vier festen RolleTyp-Rollen trägt. +_darf_lesen = require_roles_or_permission(*_ALLE_ROLLEN, berechtigung="mangel.lesen") +_darf_melden = require_roles_or_permission(*_ALLE_ROLLEN, berechtigung="mangel.melden") +_materialverantwortliche = require_roles_or_permission( + RolleTyp.administration, + RolleTyp.materialverantwortlicher, + RolleTyp.leitungsverantwortlicher, + berechtigung="mangel.bearbeiten", ) @@ -37,7 +44,7 @@ async def liste_maengel( objekt_id: int | None = None, status_filter: MangelStatus | None = None, db: AsyncSession = Depends(get_db), - _=Depends(_mitarbeiter_plus), + _=Depends(_darf_lesen), ) -> list[Mangel]: """Prompt-Erweiterung (Mangel-Modul): jeder Mitarbeiter+ darf lesen (Mängel sind für alle im Einsatzalltag relevant), Status ändern bleibt @@ -54,7 +61,7 @@ async def liste_maengel( @router.post("/maengel", response_model=MangelRead, status_code=status.HTTP_201_CREATED) async def melde_mangel( - payload: MangelCreate, db: AsyncSession = Depends(get_db), current_user=Depends(_mitarbeiter_plus) + payload: MangelCreate, db: AsyncSession = Depends(get_db), current_user=Depends(_darf_melden) ) -> Mangel: objekt = await db.get(Objekt, payload.objekt_id) if objekt is None: diff --git a/backend/app/api/v1/endpoints/permission.py b/backend/app/api/v1/endpoints/permission.py new file mode 100644 index 0000000..84b7730 --- /dev/null +++ b/backend/app/api/v1/endpoints/permission.py @@ -0,0 +1,129 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import require_roles +from app.db.session import get_db +from app.models.auth import Benutzer, RolleTyp +from app.models.permission import Berechtigung, BenutzerRolleZuordnung, Rolle, RolleBerechtigung +from app.schemas.permission import BerechtigungRead, RolleCreate, RolleRead, RolleUpdate + +router = APIRouter() + +_admin_only = require_roles(RolleTyp.administration) + + +async def _rolle_read(db: AsyncSession, rolle: Rolle) -> RolleRead: + ids = ( + await db.execute(select(RolleBerechtigung.berechtigung_id).where(RolleBerechtigung.rolle_id == rolle.id)) + ).scalars().all() + return RolleRead(id=rolle.id, name=rolle.name, beschreibung=rolle.beschreibung, berechtigung_ids=list(ids)) + + +@router.get("/berechtigungen", response_model=list[BerechtigungRead]) +async def liste_berechtigungen(db: AsyncSession = Depends(get_db), _=Depends(_admin_only)) -> list[Berechtigung]: + result = await db.execute(select(Berechtigung)) + return list(result.scalars().all()) + + +@router.get("/rollen", response_model=list[RolleRead]) +async def liste_rollen(db: AsyncSession = Depends(get_db), _=Depends(_admin_only)) -> list[RolleRead]: + result = await db.execute(select(Rolle)) + return [await _rolle_read(db, r) for r in result.scalars().all()] + + +@router.post("/rollen", response_model=RolleRead, status_code=status.HTTP_201_CREATED) +async def erstelle_rolle( + payload: RolleCreate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> RolleRead: + rolle = Rolle(name=payload.name, beschreibung=payload.beschreibung) + db.add(rolle) + try: + await db.flush() + except IntegrityError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Rollenname bereits vergeben") from exc + return await _rolle_read(db, rolle) + + +@router.patch("/rollen/{rolle_id}", response_model=RolleRead) +async def aendere_rolle( + rolle_id: int, payload: RolleUpdate, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> RolleRead: + rolle = await db.get(Rolle, rolle_id) + if rolle is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rolle nicht gefunden") + for feld, wert in payload.model_dump(exclude_unset=True).items(): + setattr(rolle, feld, wert) + try: + await db.flush() + except IntegrityError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Rollenname bereits vergeben") from exc + return await _rolle_read(db, rolle) + + +@router.delete("/rollen/{rolle_id}", status_code=status.HTTP_204_NO_CONTENT) +async def loesche_rolle(rolle_id: int, db: AsyncSession = Depends(get_db), _=Depends(_admin_only)) -> None: + rolle = await db.get(Rolle, rolle_id) + if rolle is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rolle nicht gefunden") + await db.delete(rolle) + await db.flush() + + +@router.put("/rollen/{rolle_id}/berechtigungen/{berechtigung_id}", status_code=status.HTTP_204_NO_CONTENT) +async def setze_rollen_berechtigung( + rolle_id: int, berechtigung_id: int, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> None: + if await db.get(Rolle, rolle_id) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rolle nicht gefunden") + if await db.get(Berechtigung, berechtigung_id) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Berechtigung nicht gefunden") + bestehend = await db.get(RolleBerechtigung, {"rolle_id": rolle_id, "berechtigung_id": berechtigung_id}) + if bestehend is None: + db.add(RolleBerechtigung(rolle_id=rolle_id, berechtigung_id=berechtigung_id)) + await db.flush() + + +@router.delete("/rollen/{rolle_id}/berechtigungen/{berechtigung_id}", status_code=status.HTTP_204_NO_CONTENT) +async def entferne_rollen_berechtigung( + rolle_id: int, berechtigung_id: int, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> None: + bestehend = await db.get(RolleBerechtigung, {"rolle_id": rolle_id, "berechtigung_id": berechtigung_id}) + if bestehend is not None: + await db.delete(bestehend) + await db.flush() + + +@router.put("/benutzer/{benutzer_id}/rollen/{rolle_id}", status_code=status.HTTP_204_NO_CONTENT) +async def weise_rolle_zu( + benutzer_id: int, rolle_id: int, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> None: + if await db.get(Benutzer, benutzer_id) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Benutzer nicht gefunden") + if await db.get(Rolle, rolle_id) is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rolle nicht gefunden") + bestehend = await db.get(BenutzerRolleZuordnung, {"benutzer_id": benutzer_id, "rolle_id": rolle_id}) + if bestehend is None: + db.add(BenutzerRolleZuordnung(benutzer_id=benutzer_id, rolle_id=rolle_id)) + await db.flush() + + +@router.delete("/benutzer/{benutzer_id}/rollen/{rolle_id}", status_code=status.HTTP_204_NO_CONTENT) +async def entferne_rollen_zuordnung( + benutzer_id: int, rolle_id: int, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> None: + bestehend = await db.get(BenutzerRolleZuordnung, {"benutzer_id": benutzer_id, "rolle_id": rolle_id}) + if bestehend is not None: + await db.delete(bestehend) + await db.flush() + + +@router.get("/benutzer/{benutzer_id}/rollen", response_model=list[int]) +async def liste_rollen_zuordnung( + benutzer_id: int, db: AsyncSession = Depends(get_db), _=Depends(_admin_only) +) -> list[int]: + result = await db.execute( + select(BenutzerRolleZuordnung.rolle_id).where(BenutzerRolleZuordnung.benutzer_id == benutzer_id) + ) + return list(result.scalars().all()) diff --git a/backend/app/api/v1/endpoints/stammdaten.py b/backend/app/api/v1/endpoints/stammdaten.py index cc32794..c042fa5 100644 --- a/backend/app/api/v1/endpoints/stammdaten.py +++ b/backend/app/api/v1/endpoints/stammdaten.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps import get_current_user, require_roles +from app.api.deps import get_current_user, require_roles, require_roles_or_permission from app.db.session import get_db from app.models.auth import RolleTyp from app.models.stammdaten import Bereich, Fach, Kategorie, Material, Objekttyp, Standort @@ -259,7 +259,7 @@ async def hole_material( async def erstelle_material( payload: MaterialCreate, db: AsyncSession = Depends(get_db), - _=Depends(require_roles(RolleTyp.administration)), + _=Depends(require_roles_or_permission(RolleTyp.administration, berechtigung="material.erstellen")), ) -> Material: material = Material(**payload.model_dump()) db.add(material) @@ -272,7 +272,7 @@ async def aendere_material( material_id: int, payload: MaterialUpdate, db: AsyncSession = Depends(get_db), - _=Depends(require_roles(RolleTyp.administration)), + _=Depends(require_roles_or_permission(RolleTyp.administration, berechtigung="material.bearbeiten")), ) -> Material: material = await db.get(Material, material_id) if material is None: diff --git a/backend/app/models/permission.py b/backend/app/models/permission.py new file mode 100644 index 0000000..964c676 --- /dev/null +++ b/backend/app/models/permission.py @@ -0,0 +1,48 @@ +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class Berechtigung(Base): + """Roadmap Phase 6: Katalog einzelner Rechte (z.B. "material.erstellen"). + Fest im Code definiert (welche Endpunkte welches Recht prüfen), aber wer + welche Berechtigung über welche Rolle bekommt, ist frei konfigurierbar.""" + + __tablename__ = "berechtigung" + + id: Mapped[int] = mapped_column(primary_key=True) + schluessel: Mapped[str] = mapped_column(String, unique=True, nullable=False) + beschreibung: Mapped[str] = mapped_column(String, nullable=False) + + +class Rolle(Base): + """Vom Admin frei anlegbare Rolle (z.B. "Materialwart", "Helfer") - additiv + neben dem bestehenden festen 4-Rollen-System (RolleTyp), nicht dessen + Ersatz. Ein Benutzer kann beides gleichzeitig haben.""" + + __tablename__ = "rolle" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String, unique=True, nullable=False) + beschreibung: Mapped[str | None] = mapped_column(String) + + +class RolleBerechtigung(Base): + __tablename__ = "rolle_berechtigung" + + rolle_id: Mapped[int] = mapped_column(ForeignKey("rolle.id", ondelete="CASCADE"), primary_key=True) + berechtigung_id: Mapped[int] = mapped_column( + ForeignKey("berechtigung.id", ondelete="CASCADE"), primary_key=True + ) + + +class BenutzerRolleZuordnung(Base): + """M:N Benutzer<->Rolle (neues System) - eigene Tabelle statt + Wiederverwendung von benutzer_rolle, dessen rolle-Spalte ein fester ENUM + ist und keine FK auf frei anlegbare rolle-Zeilen erlaubt.""" + + __tablename__ = "benutzer_rolle_zuordnung" + + benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id", ondelete="CASCADE"), primary_key=True) + rolle_id: Mapped[int] = mapped_column(ForeignKey("rolle.id", ondelete="CASCADE"), primary_key=True) diff --git a/backend/app/schemas/permission.py b/backend/app/schemas/permission.py new file mode 100644 index 0000000..3972dd5 --- /dev/null +++ b/backend/app/schemas/permission.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, ConfigDict + + +class BerechtigungRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + schluessel: str + beschreibung: str + + +class RolleCreate(BaseModel): + name: str + beschreibung: str | None = None + + +class RolleUpdate(BaseModel): + name: str | None = None + beschreibung: str | None = None + + +class RolleRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + name: str + beschreibung: str | None + berechtigung_ids: list[int] = [] diff --git a/backend/tests/test_permission.py b/backend/tests/test_permission.py new file mode 100644 index 0000000..5c31b18 --- /dev/null +++ b/backend/tests/test_permission.py @@ -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 diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 0f33458..15b4587 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -238,6 +238,19 @@ export interface Benutzer { einheit_id: number | null; } +export interface Berechtigung { + id: number; + schluessel: string; + beschreibung: string; +} + +export interface CustomRolle { + id: number; + name: string; + beschreibung: string | null; + berechtigung_ids: number[]; +} + export interface Einheit { id: number; name: string; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index afc57fa..32e4446 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -11,6 +11,7 @@ import { FachSection } from "./admin/FachSection"; import { HistorieSection } from "./admin/HistorieSection"; import { KategorieSection } from "./admin/KategorieSection"; import { LagerbewegungSection } from "./admin/LagerbewegungSection"; +import { RolleSection } from "./admin/RolleSection"; import { KontrollverantwortungSection } from "./admin/KontrollverantwortungSection"; import { MaterialSection } from "./admin/MaterialSection"; import { ObjektSection } from "./admin/ObjektSection"; @@ -32,7 +33,8 @@ type Tab = | "kontrollverantwortung" | "zustaendigkeit" | "personal" - | "lagerbewegungen"; + | "lagerbewegungen" + | "rollen"; // Deckt sich mit den require_roles()-Gates der jeweiligen Backend-Endpunkte // (Rollen-Vorgabe: Material-/Leitungsverantwortliche sahen bisher exakt @@ -63,6 +65,7 @@ const TABS: { key: Tab; label: string; rollen: ("administration" | "materialvera label: "Lagerbewegungen", rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"], }, + { key: "rollen", label: "Rollen & Rechte", rollen: ["administration"] }, ]; /** @@ -222,6 +225,7 @@ export function AdminPage() { {aktiverTab === "lagerbewegungen" && ( )} + {aktiverTab === "rollen" && } {aktiverTab === "eskalation" && } {aktiverTab === "personal" && ( <> diff --git a/frontend/src/pages/admin/RolleSection.tsx b/frontend/src/pages/admin/RolleSection.tsx new file mode 100644 index 0000000..85824d4 --- /dev/null +++ b/frontend/src/pages/admin/RolleSection.tsx @@ -0,0 +1,205 @@ +import { useEffect, useState } from "react"; + +import { apiRequest } from "../../api/client"; +import type { Benutzer, Berechtigung, CustomRolle } from "../../api/types"; + +interface Props { + benutzer: Benutzer[]; + onFehler: (text: string) => void; +} + +// Granulares Rechte-System (Roadmap Phase 6): additiv neben dem bestehenden +// 4-Rollen-System - Admin kann hier eigene Rollen (z.B. "Materialwart", +// "Helfer") mit frei wählbaren Einzelrechten anlegen und Benutzern zuweisen. +export function RolleSection({ benutzer, onFehler }: Props) { + const [rollen, setRollen] = useState([]); + const [berechtigungen, setBerechtigungen] = useState([]); + const [name, setName] = useState(""); + const [beschreibung, setBeschreibung] = useState(""); + const [laedt, setLaedt] = useState(true); + const [ausgeklapptId, setAusgeklapptId] = useState(null); + const [zugeordneteBenutzer, setZugeordneteBenutzer] = useState>({}); + const [neuerBenutzerId, setNeuerBenutzerId] = useState(""); + + async function laden() { + setLaedt(true); + try { + const [r, b] = await Promise.all([ + apiRequest("/rollen"), + apiRequest("/berechtigungen"), + ]); + setRollen(r); + setBerechtigungen(b); + } catch { + onFehler("Rollen konnten nicht geladen werden."); + } finally { + setLaedt(false); + } + } + + useEffect(() => { + laden(); + }, []); + + async function ladeZuordnungen(rolleId: number) { + const treffer = benutzer.filter((b) => zugeordneteBenutzer[rolleId]?.includes(b.id)); + if (treffer.length > 0 || zugeordneteBenutzer[rolleId]) return; + const ergebnisse = await Promise.all( + benutzer.map(async (b) => { + const ids = await apiRequest(`/benutzer/${b.id}/rollen`).catch((): number[] => []); + return ids.includes(rolleId) ? b.id : null; + }) + ); + setZugeordneteBenutzer((z) => ({ ...z, [rolleId]: ergebnisse.filter((x): x is number => x !== null) })); + } + + function ausklappen(rolleId: number) { + if (ausgeklapptId === rolleId) { + setAusgeklapptId(null); + return; + } + setAusgeklapptId(rolleId); + ladeZuordnungen(rolleId); + } + + async function anlegen() { + try { + await apiRequest("/rollen", { method: "POST", body: { name, beschreibung: beschreibung || null } }); + setName(""); + setBeschreibung(""); + await laden(); + } catch { + onFehler("Rolle konnte nicht angelegt werden (Name evtl. schon vergeben)."); + } + } + + async function berechtigungUmschalten(rolle: CustomRolle, berechtigungId: number) { + const hat = rolle.berechtigung_ids.includes(berechtigungId); + try { + await apiRequest(`/rollen/${rolle.id}/berechtigungen/${berechtigungId}`, { + method: hat ? "DELETE" : "PUT", + }); + await laden(); + } catch { + onFehler("Berechtigung konnte nicht geändert werden."); + } + } + + async function benutzerHinzufuegen(rolleId: number) { + if (neuerBenutzerId === "") return; + try { + await apiRequest(`/benutzer/${neuerBenutzerId}/rollen/${rolleId}`, { method: "PUT" }); + setZugeordneteBenutzer((z) => ({ ...z, [rolleId]: [...(z[rolleId] ?? []), neuerBenutzerId as number] })); + setNeuerBenutzerId(""); + } catch { + onFehler("Benutzer konnte der Rolle nicht zugeordnet werden."); + } + } + + async function benutzerEntfernen(rolleId: number, benutzerId: number) { + try { + await apiRequest(`/benutzer/${benutzerId}/rollen/${rolleId}`, { method: "DELETE" }); + setZugeordneteBenutzer((z) => ({ ...z, [rolleId]: (z[rolleId] ?? []).filter((id) => id !== benutzerId) })); + } catch { + onFehler("Benutzer konnte nicht entfernt werden."); + } + } + + return ( +
+

Rollen & Rechte

+
+
+ setName(e.target.value)} placeholder="Rollenname (z.B. Materialwart)" /> + setBeschreibung(e.target.value)} + placeholder="Beschreibung (optional)" + /> + +
+ + {laedt &&

Lade…

} + {!laedt && rollen.length === 0 &&

Noch keine eigenen Rollen angelegt.

} + + {!laedt && rollen.length > 0 && ( +
    + {rollen.map((r) => ( +
  • +
    + + {r.name} + {r.beschreibung && – {r.beschreibung}} + + +
    + {ausgeklapptId === r.id && ( +
    +
    + Berechtigungen +
    + {berechtigungen.map((b) => ( + + ))} +
    +
    +
    + Zugewiesene Benutzer +
      + {(zugeordneteBenutzer[r.id] ?? []).map((benutzerId) => { + const b = benutzer.find((x) => x.id === benutzerId); + return ( +
    • + {b ? `${b.name} (${b.login})` : `#${benutzerId}`} + +
    • + ); + })} +
    +
    + + +
    +
    +
    + )} +
  • + ))} +
+ )} +
+
+ ); +}