From 654d448e7bf6612bcbc2cf6f2dbfae6478a2807e Mon Sep 17 00:00:00 2001 From: patrick Date: Tue, 8 Sep 2026 10:39:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(dokumente):=20DOC-005=20Zugriffsrechte=20j?= =?UTF-8?q?e=20Dokumenttyp=20(Rechnungen=20eingeschr=C3=A4nkt)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rechnungen sind finanziell sensibel - nur Materialverantwortliche/ Leitungsverantwortliche/Administration dürfen sie in der Liste sehen und herunterladen (403 bei direktem Downloadversuch), Mitarbeiter nicht. Hochladen bleibt für alle offen (z.B. Wareneingang direkt scannen). Andere Dokumenttypen bleiben unverändert für jeden mit Aktenzugriff sichtbar - die werden im Feldeinsatz gebraucht. EINGESCHRAENKTE_DOKUMENTTYPEN als zentrale Stelle für künftige weitere Einschränkungen. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV --- arbeitskacheln/00_index.md | 2 +- backend/app/api/v1/endpoints/dokument.py | 28 +++++++++-- backend/app/models/dokument.py | 8 ++++ backend/app/services/dokument.py | 11 ++++- backend/tests/test_dokument.py | 60 ++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 8 deletions(-) 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