feat(dokumente): DOC-005 Zugriffsrechte je Dokumenttyp (Rechnungen eingeschränkt)
CI / backend-tests (push) Failing after 2m17s
CI / frontend-build (push) Successful in 28s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-08 10:39:42 +02:00
co-authored by Claude Sonnet 5
parent 069a1ee7ee
commit 654d448e7b
5 changed files with 101 additions and 8 deletions
+1 -1
View File
@@ -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-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-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-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-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-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) | | 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) |
+23 -5
View File
@@ -6,8 +6,8 @@ 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
from app.db.session import get_db from app.db.session import get_db
from app.models.auth import RolleTyp from app.models.auth import Benutzer, RolleTyp
from app.models.dokument import Dokument, DokumentTyp from app.models.dokument import EINGESCHRAENKTE_DOKUMENTTYPEN, Dokument, DokumentTyp
from app.schemas.dokument import DokumentRead, EntitaetTyp from app.schemas.dokument import DokumentRead, EntitaetTyp
from app.services.dokument import ( from app.services.dokument import (
DateityperlaubtError, DateityperlaubtError,
@@ -31,6 +31,14 @@ _materialverantwortliche = require_roles(
RolleTyp.administration, RolleTyp.materialverantwortlicher, RolleTyp.leitungsverantwortlicher 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]) @router.get("/dokumente", response_model=list[DokumentRead])
async def liste_dokumente( async def liste_dokumente(
@@ -38,10 +46,14 @@ async def liste_dokumente(
entitaet_id: str, entitaet_id: str,
dokumenttyp: DokumentTyp | None = None, dokumenttyp: DokumentTyp | None = None,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_=Depends(get_current_user), current_user: Benutzer = Depends(get_current_user),
) -> list[Dokument]: ) -> list[Dokument]:
return await liste_fuer_entitaet( 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") @router.get("/dokumente/{dokument_id}/download")
async def lade_dokument_herunter( 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: ) -> FileResponse:
dokument = await db.get(Dokument, dokument_id) dokument = await db.get(Dokument, dokument_id)
if dokument is None: if dokument is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Dokument nicht gefunden") 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) pfad = dateipfad(dokument)
if not pfad.exists(): if not pfad.exists():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht mehr vorhanden") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Datei nicht mehr vorhanden")
+8
View File
@@ -24,6 +24,14 @@ class DokumentTyp(str, enum.Enum):
dokument_typ_pg = PgEnum(DokumentTyp, name="dokument_typ", create_type=False) 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): class Dokument(Base):
"""Roadmap Phase 5 (Modul Dokumente): polymorpher Datei-Anhang an beliebige """Roadmap Phase 5 (Modul Dokumente): polymorpher Datei-Anhang an beliebige
+9 -2
View File
@@ -8,7 +8,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.app_settings import settings 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/ # Erlaubte MIME-Types (Nutzer-Vorgabe Modul 10: PDF/Bilder/Prüfprotokolle/
# Wartungsberichte/Bedienungsanleitungen/Rechnungen/Zulassungsdokumente) - # Wartungsberichte/Bedienungsanleitungen/Rechnungen/Zulassungsdokumente) -
@@ -99,11 +99,18 @@ async def speichere_dokument(
async def liste_fuer_entitaet( 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]: ) -> list[Dokument]:
bedingungen = [Dokument.entitaet_typ == entitaet_typ, Dokument.entitaet_id == entitaet_id] bedingungen = [Dokument.entitaet_typ == entitaet_typ, Dokument.entitaet_id == entitaet_id]
if dokumenttyp is not None: if dokumenttyp is not None:
bedingungen.append(Dokument.dokumenttyp == dokumenttyp) 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())) result = await db.execute(select(Dokument).where(*bedingungen).order_by(Dokument.hochgeladen_am.desc()))
return list(result.scalars().all()) return list(result.scalars().all())
+60
View File
@@ -195,3 +195,63 @@ async def test_unbekannter_dokumenttyp_wird_abgelehnt(client, objekt_mit_positio
headers=auth_header(token), headers=auth_header(token),
) )
assert response.status_code == 422 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