diff --git a/arbeitskacheln/00_index.md b/arbeitskacheln/00_index.md index d904336..3811be5 100644 --- a/arbeitskacheln/00_index.md +++ b/arbeitskacheln/00_index.md @@ -218,7 +218,7 @@ vergeben statt geraten. | DOC-002 | Documents | Dokumenttypen | P2 | XS | LOW | DOC-001 | ✅ (`DokumentTyp`-Enum, Migration 0026, Filter beim Listen, Auswahl beim Upload) | | DOC-003 | Documents | Versionierung | P2 | M | MEDIUM | DOC-001 | ⬜ (kein Versionsfeld/-verweis im Dokument-Modell) | | DOC-004 | Documents | Original-vs-Kopie-Kennzeichnung | P2 | S | LOW | DOC-001 | ⬜ (kein entsprechendes Feld gefunden) | -| DOC-005 | Documents | Zugriffsrechte je Dokument | P2 | S | MEDIUM | DOC-001, FOUND-004 | ⬜ (kein dokumentspezifisches Berechtigungsmodell gefunden, nur allgemeine Rollen) | +| DOC-005 | Documents | Zugriffsrechte je Dokument | P2 | S | MEDIUM | DOC-001, FOUND-004 | ✅ (nach Dokumenttyp: Rechnungen nur für Materialverantwortliche+, per `EINGESCHRAENKTE_DOKUMENTTYPEN`) | | MOBILE-001 | Mobile | PWA-Grundgerüst | P1 | M | MEDIUM | FOUND-005 | ✅ (Vite-PWA-Build erzeugt `manifest.webmanifest`/`sw.js`, `vite-plugin-pwa` in package.json) | | MOBILE-002 | Mobile | QR-Scan-Workflow (Kernablauf) | P1 | M | MEDIUM | MOBILE-001, IDENT-008 | ✅ (`BarcodeScanner.tsx` verdrahtet in `ObjektListPage.tsx`) | | MOBILE-003 | Mobile | Offline-Grundgerüst | P2 | L | HIGH | MOBILE-001 | 🔶 (`offline/queue.ts`, `useOnlineStatus.ts`, `SyncStatus.tsx` vorhanden — Warteschlange für Kontrollen, kein vollständiges Offline-App-Grundgerüst über alle Module) | diff --git a/backend/app/api/v1/endpoints/dokument.py b/backend/app/api/v1/endpoints/dokument.py index dd58ddb..d37033f 100644 --- a/backend/app/api/v1/endpoints/dokument.py +++ b/backend/app/api/v1/endpoints/dokument.py @@ -6,8 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user, require_roles from app.db.session import get_db -from app.models.auth import RolleTyp -from app.models.dokument import Dokument, DokumentTyp +from app.models.auth import Benutzer, RolleTyp +from app.models.dokument import EINGESCHRAENKTE_DOKUMENTTYPEN, Dokument, DokumentTyp from app.schemas.dokument import DokumentRead, EntitaetTyp from app.services.dokument import ( DateityperlaubtError, @@ -31,6 +31,14 @@ _materialverantwortliche = require_roles( RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher ) +_VERANTWORTLICHE_ROLLEN = {RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher} + + +def _darf_eingeschraenkte_dokumente_sehen(benutzer: Benutzer) -> bool: + """DOC-005: Rechnungen etc. nur für Materialverantwortliche+ - siehe + EINGESCHRAENKTE_DOKUMENTTYPEN.""" + return bool({RolleTyp(r) for r in benutzer.rollen_namen} & _VERANTWORTLICHE_ROLLEN) + @router.get("/dokumente", response_model=list[DokumentRead]) async def liste_dokumente( @@ -38,10 +46,14 @@ async def liste_dokumente( entitaet_id: str, dokumenttyp: DokumentTyp | None = None, db: AsyncSession = Depends(get_db), - _=Depends(get_current_user), + current_user: Benutzer = Depends(get_current_user), ) -> list[Dokument]: return await liste_fuer_entitaet( - db, entitaet_typ=entitaet_typ, entitaet_id=entitaet_id, dokumenttyp=dokumenttyp + db, + entitaet_typ=entitaet_typ, + entitaet_id=entitaet_id, + dokumenttyp=dokumenttyp, + darf_eingeschraenkte_sehen=_darf_eingeschraenkte_dokumente_sehen(current_user), ) @@ -89,11 +101,17 @@ async def lade_dokument_hoch( @router.get("/dokumente/{dokument_id}/download") async def lade_dokument_herunter( - dokument_id: uuid.UUID, db: AsyncSession = Depends(get_db), _=Depends(get_current_user) + dokument_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + current_user: Benutzer = Depends(get_current_user), ) -> FileResponse: dokument = await db.get(Dokument, dokument_id) if dokument is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") + if dokument.dokumenttyp in EINGESCHRAENKTE_DOKUMENTTYPEN and not _darf_eingeschraenkte_dokumente_sehen( + current_user + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Für diesen Dokumenttyp fehlt die Berechtigung") pfad = dateipfad(dokument) if not pfad.exists(): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht mehr vorhanden") diff --git a/backend/app/models/dokument.py b/backend/app/models/dokument.py index 7d7b563..f73029d 100644 --- a/backend/app/models/dokument.py +++ b/backend/app/models/dokument.py @@ -24,6 +24,14 @@ class DokumentTyp(str, enum.Enum): dokument_typ_pg = PgEnum(DokumentTyp, name="dokument_typ", create_type=False) +# DOC-005: Rechnungen sind finanziell sensibel - nur Materialverantwortliche/ +# Leitungsverantwortliche/Administration dürfen sie sehen/herunterladen, +# Mitarbeiter nicht (dürfen sie aber weiterhin hochladen, z.B. direkt beim +# Wareneingang scannen). Andere Dokumenttypen (Prüfprotokolle, Anleitungen, +# ...) bleiben für alle mit Zugriff auf die Akte sichtbar - die werden im +# Feld gebraucht. +EINGESCHRAENKTE_DOKUMENTTYPEN = {DokumentTyp.rechnung} + class Dokument(Base): """Roadmap Phase 5 (Modul Dokumente): polymorpher Datei-Anhang an beliebige diff --git a/backend/app/services/dokument.py b/backend/app/services/dokument.py index 80abdeb..4bd072a 100644 --- a/backend/app/services/dokument.py +++ b/backend/app/services/dokument.py @@ -8,7 +8,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.app_settings import settings -from app.models.dokument import Dokument, DokumentTyp +from app.models.dokument import EINGESCHRAENKTE_DOKUMENTTYPEN, Dokument, DokumentTyp # Erlaubte MIME-Types (Nutzer-Vorgabe Modul 10: PDF/Bilder/Prüfprotokolle/ # Wartungsberichte/Bedienungsanleitungen/Rechnungen/Zulassungsdokumente) - @@ -99,11 +99,18 @@ async def speichere_dokument( async def liste_fuer_entitaet( - db: AsyncSession, *, entitaet_typ: str, entitaet_id: str, dokumenttyp: DokumentTyp | None = None + db: AsyncSession, + *, + entitaet_typ: str, + entitaet_id: str, + dokumenttyp: DokumentTyp | None = None, + darf_eingeschraenkte_sehen: bool = True, ) -> list[Dokument]: bedingungen = [Dokument.entitaet_typ == entitaet_typ, Dokument.entitaet_id == entitaet_id] if dokumenttyp is not None: bedingungen.append(Dokument.dokumenttyp == dokumenttyp) + if not darf_eingeschraenkte_sehen: + bedingungen.append(Dokument.dokumenttyp.notin_(EINGESCHRAENKTE_DOKUMENTTYPEN)) result = await db.execute(select(Dokument).where(*bedingungen).order_by(Dokument.hochgeladen_am.desc())) return list(result.scalars().all()) diff --git a/backend/tests/test_dokument.py b/backend/tests/test_dokument.py index 6d788f5..e8b934f 100644 --- a/backend/tests/test_dokument.py +++ b/backend/tests/test_dokument.py @@ -195,3 +195,63 @@ async def test_unbekannter_dokumenttyp_wird_abgelehnt(client, objekt_mit_positio headers=auth_header(token), ) assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_mitarbeiter_sieht_rechnungen_nicht_in_der_liste( + client, objekt_mit_position, mitarbeiter_user, materialverantwortlicher_user +): + """DOC-005: Rechnungen sind für Mitarbeiter unsichtbar, für Materialverantwortliche sichtbar.""" + objekt, _material = objekt_mit_position + verantwortlicher_token = await login(client, "materialverantwortlicher1") + await client.post( + "/api/v1/dokumente", + data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "rechnung"}, + files={"datei": ("rechnung.pdf", b"%PDF-1.4 r", "application/pdf")}, + headers=auth_header(verantwortlicher_token), + ) + await client.post( + "/api/v1/dokumente", + data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "pruefprotokoll"}, + files={"datei": ("pruefung.pdf", b"%PDF-1.4 p", "application/pdf")}, + headers=auth_header(verantwortlicher_token), + ) + + mitarbeiter_token = await login(client, "mitarbeiter1") + als_mitarbeiter = await client.get( + f"/api/v1/dokumente?entitaet_typ=objekt&entitaet_id={objekt.id}", headers=auth_header(mitarbeiter_token) + ) + dateinamen = [d["dateiname"] for d in als_mitarbeiter.json()] + assert "rechnung.pdf" not in dateinamen + assert "pruefung.pdf" in dateinamen + + als_verantwortlicher = await client.get( + f"/api/v1/dokumente?entitaet_typ=objekt&entitaet_id={objekt.id}", headers=auth_header(verantwortlicher_token) + ) + assert len(als_verantwortlicher.json()) == 2 + + +@pytest.mark.asyncio +async def test_mitarbeiter_kann_rechnung_nicht_direkt_herunterladen( + client, objekt_mit_position, mitarbeiter_user, materialverantwortlicher_user +): + objekt, _material = objekt_mit_position + verantwortlicher_token = await login(client, "materialverantwortlicher1") + hochgeladen = await client.post( + "/api/v1/dokumente", + data={"entitaet_typ": "objekt", "entitaet_id": str(objekt.id), "dokumenttyp": "rechnung"}, + files={"datei": ("rechnung.pdf", b"%PDF-1.4 r", "application/pdf")}, + headers=auth_header(verantwortlicher_token), + ) + dokument_id = hochgeladen.json()["id"] + + mitarbeiter_token = await login(client, "mitarbeiter1") + verweigert = await client.get( + f"/api/v1/dokumente/{dokument_id}/download", headers=auth_header(mitarbeiter_token) + ) + assert verweigert.status_code == 403 + + erlaubt = await client.get( + f"/api/v1/dokumente/{dokument_id}/download", headers=auth_header(verantwortlicher_token) + ) + assert erlaubt.status_code == 200