Files
MABEA/backend/app/services/fehlbestand.py
T
patrickandClaude Sonnet 5 ceddeb1bf4
CI / backend-tests (push) Failing after 19s
CI / frontend-build (push) Successful in 25s
Multi-Agent-Review: Race Conditions, DB-Indizes, TLS/Security-Härtung
Konsolidierte Funde aus postgres-/sql-/jwt-/owasp-top10-expert-Review:

- Race Conditions gefixt: doppelte aktive Kontrolle (SAVEPOINT + partieller
  Unique-Index), doppelte Mindermengen-Genehmigung (FOR UPDATE + Unique-Index),
  Lost-Update bei Nachfüllung (FOR UPDATE auf Fehlbestand/Objektposition).
- Migration 0007: partielle Unique-Indizes als DB-Sicherheitsnetz + fehlende
  FK-Indizes (fehlbestand.material_id, kontrolle(objekt_id,status),
  zustaendigkeit, benutzer_rolle.rolle, objektposition.ablaufdatum u.a.).
- Connection-Pool explizit begrenzt (pool_size=5, max_overflow=5) - ohne das
  könnte jeder uvicorn-Worker den Postgres max_connections-Wert sprengen.
- Timing-Angriff bei Login-Enumeration gefixt (konstante Antwortzeit über
  Dummy-Hash), JWT-Decode verlangt jetzt exp/sub-Claims.
- App-seitiges Rate-Limiting (slowapi, 5/min) auf /auth/login als Verteidigung
  in der Tiefe zusätzlich zum nginx-Limit.
- nginx: TLS mit selbstsigniertem Zertifikat (LAN-Betrieb, keine Domain),
  HSTS, Content-Security-Policy, Permissions-Policy ergänzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
2026-09-04 18:47:06 +02:00

130 lines
5.3 KiB
Python

from datetime import datetime, timezone
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.fehlbestand import Fehlbestand, FehlbestandStatus
from app.models.mindermenge import MindermengeStatus, MindermengenGenehmigung
from app.models.nachfuellung import Nachfuellung
from app.models.objektposition import Objektposition
from app.services import historie as historie_service
class FehlbestandBereitsErledigtError(Exception):
"""Ein bereits erledigter Fehlbestand kann nicht erneut nachgefüllt werden."""
async def nachfuellen(
db: AsyncSession,
*,
fehlbestand: Fehlbestand,
menge: Decimal,
benutzer_id: int,
zustaendiger_server_id: int,
) -> tuple[Nachfuellung, Decimal]:
"""Vier-Kernbegriffe (Prompt 02.9): Nachfüllung ist die einzige Aktion, die
Objektposition.istmenge tatsächlich ändert. Deckt sowohl Sofort-Nachfüllung
während der Kontrolle (Karte 07) als auch spätere/externe Nachfüllung
(Prompt 02.5) ab - technisch derselbe Vorgang, nur anderer Zeitpunkt.
Überbestand durch Nachfüllung ist erlaubt (Prompt 21.9/E4): kein Fehler, nur
als Info im Rückgabewert `ueberbestand` gekennzeichnet.
Wichtig: `fehlbestand.istmenge` ist die zuletzt GEZÄHLTE Wahrheit (aus der
Kontrolle, die den Fehlbestand ausgelöst hat) - Objektposition.istmenge wird
hier auf den neuen Wert GESETZT, nicht unabhängig davon inkrementiert. Sonst
bliebe Objektposition.istmenge bei ihrem alten (ggf. 0/"nicht kontrolliert")
Stand stehen, obwohl die Kontrolle bereits einen realen Zählwert kennt -
"Ist-Menge wird auf Soll korrigiert" (Prompt 02.3) meint genau dieses Setzen.
"""
# Row-Lock gegen Lost-Update bei gleichzeitiger Nachfüllung derselben Position
# (z. B. zwei Stationen erfassen parallel, sql-expert-Review-Fund) - blockiert
# bis Transaktionsende (get_db committet/rollbacked nach dem Request), danach
# sieht der zweite Aufruf bereits die aktualisierte istmenge/status.
result = await db.execute(select(Fehlbestand).where(Fehlbestand.id == fehlbestand.id).with_for_update())
fehlbestand = result.scalar_one()
if fehlbestand.status == FehlbestandStatus.erledigt:
raise FehlbestandBereitsErledigtError()
neue_istmenge = fehlbestand.istmenge + menge
ueberbestand = max(Decimal(0), neue_istmenge - fehlbestand.sollmenge)
neue_fehlmenge = max(Decimal(0), fehlbestand.sollmenge - neue_istmenge)
result = await db.execute(
select(Objektposition)
.where(
Objektposition.objekt_id == fehlbestand.objekt_id,
Objektposition.material_id == fehlbestand.material_id,
)
.with_for_update()
)
objektposition = result.scalar_one_or_none()
if objektposition is not None:
objektposition.istmenge = neue_istmenge
fehlbestand.istmenge = neue_istmenge
fehlbestand.fehlmenge = neue_fehlmenge
wird_erledigt = neue_fehlmenge <= 0
if wird_erledigt:
fehlbestand.status = FehlbestandStatus.erledigt
fehlbestand.erledigt_am = datetime.now(timezone.utc)
else:
fehlbestand.status = FehlbestandStatus.nachgefuellt_teilweise
nachfuellung = Nachfuellung(
fehlbestand_id=fehlbestand.id,
objekt_id=fehlbestand.objekt_id,
material_id=fehlbestand.material_id,
menge=menge,
benutzer_id=benutzer_id,
zeitpunkt=datetime.now(timezone.utc),
)
db.add(nachfuellung)
await db.flush()
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=benutzer_id,
ereignistyp="nachfuellung_erfasst",
entitaet_typ="fehlbestand",
entitaet_id=fehlbestand.id,
neuer_wert={"menge": str(menge), "neue_istmenge": str(neue_istmenge), "ueberbestand": str(ueberbestand)},
)
if wird_erledigt:
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=benutzer_id,
ereignistyp="fehlbestand_erledigt",
entitaet_typ="fehlbestand",
entitaet_id=fehlbestand.id,
neuer_wert={"status": "erledigt"},
)
# U7 (Prompt 04.4): wird die Abweichung behoben, erledigt sich eine noch
# aktive Mindermengen-Genehmigung gemeinsam mit dem Fehlbestand.
result = await db.execute(
select(MindermengenGenehmigung).where(
MindermengenGenehmigung.fehlbestand_id == fehlbestand.id,
MindermengenGenehmigung.status == MindermengeStatus.aktiv,
)
)
genehmigung = result.scalar_one_or_none()
if genehmigung is not None:
genehmigung.status = MindermengeStatus.beendet_durch_erledigung
genehmigung.beendet_am = datetime.now(timezone.utc)
await historie_service.log(
db,
zustaendiger_server_id=zustaendiger_server_id,
benutzer_id=benutzer_id,
ereignistyp="mindermenge_beendet_durch_erledigung",
entitaet_typ="mindermengen_genehmigung",
entitaet_id=genehmigung.id,
neuer_wert={"status": "beendet_durch_erledigung"},
)
await db.flush()
return nachfuellung, ueberbestand