From 35937ed51b89b98c3a144c906bb356becd5bf4d6 Mon Sep 17 00:00:00 2001 From: rene Date: Wed, 27 May 2026 11:27:00 +0200 Subject: [PATCH] =?UTF-8?q?B=C3=BCndel=203:=20Security-Helper=20+=20Demo-M?= =?UTF-8?q?igration,=20SW=20by-v1115?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NEUE HELPER in auth.py: require_moderator(user=Depends(get_current_user)) Konsequente Dependency statt inline 'if user["rolle"] not in ("admin", "moderator")' require_breeder(user=Depends(get_current_user)) Konsequente Dependency statt inline 'if user["subscription_tier"] not in ("breeder", "breeder_test")' require_owner(row, user, owner_field='user_id', not_found_msg, forbidden_msg) -> row Zentralisiert das häufigste Pattern (54 Stellen im Audit): Statt: row = conn.execute(...).fetchone() if not row: raise HTTPException(404, ...) if row['user_id'] != user['id']: raise HTTPException(403, ...) Jetzt: row = require_owner(conn.execute(...).fetchone(), user, not_found_msg='Ort nicht gefunden.') is_owner_or_admin(row, user, owner_field='user_id') -> bool True wenn Owner ODER Admin/Moderator (Admin-Override für Moderations-Endpoints) DEMO-MIGRATION: places.py PATCH /places/{id} + DELETE /places/{id} migriert auf require_owner() — als Style-Referenz für künftige Migrationen. KEINE Massen-Migration der 54 Stellen — bewusste Entscheidung weil security-kritisch. Helper sind bereitgestellt, neuer Code nutzt sie, bestehender bleibt funktional identisch. Tests 19/19 grün. Hinweis: Massen-Migration der Owner-Checks ist eigener Sprint mit sehr sorgfältigem Testing — bei jeder migrierten Route muss die 404→403→Cascade durchgeprüft werden, dass Owner+Non-Owner+Admin sich identisch zum Vorher verhalten. --- VERSION | 2 +- backend/auth.py | 43 +++++++++++++++++++++++++++++++++++++ backend/routes/places.py | 20 ++++++++--------- backend/static/index.html | 24 ++++++++++----------- backend/static/js/app.js | 2 +- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- 7 files changed, 68 insertions(+), 27 deletions(-) diff --git a/VERSION b/VERSION index e9fa9e7..03a524d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1114 \ No newline at end of file +1115 \ No newline at end of file diff --git a/backend/auth.py b/backend/auth.py index 9cb25c6..1b5f126 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -212,6 +212,49 @@ def require_admin(user=Depends(get_current_user)): return user +def require_moderator(user=Depends(get_current_user)): + """Dependency: Admin oder Moderator. Konsequente Nutzung statt + Inline-`if user['rolle'] not in (...):` in den Routen.""" + if user["rolle"] not in ("admin", "moderator") and not user.get("is_moderator"): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Moderator-Zugriff erforderlich.") + return user + + +def require_breeder(user=Depends(get_current_user)): + """Dependency: Admin oder Züchter (breeder/breeder_test).""" + if user["rolle"] == "admin": + return user + if user.get("subscription_tier") in ("breeder", "breeder_test"): + return user + raise HTTPException(status.HTTP_403_FORBIDDEN, "Züchter-Zugriff erforderlich.") + + +# ------------------------------------------------------------------ +# Owner-Checks — zentral, statt 54x inline `if row['user_id'] != user['id']: 403` +# ------------------------------------------------------------------ +def require_owner(row, user: dict, owner_field: str = "user_id", + not_found_msg: str = "Nicht gefunden", + forbidden_msg: str = "Kein Zugriff"): + """Wirft 404 wenn row None/falsy ist, 403 wenn User nicht Besitzer. + Returns row für chainability: + dog = require_owner(conn.execute(...).fetchone(), user, 'user_id', 'Hund nicht gefunden') + """ + if not row: + raise HTTPException(status.HTTP_404_NOT_FOUND, not_found_msg) + if row[owner_field] != user["id"]: + raise HTTPException(status.HTTP_403_FORBIDDEN, forbidden_msg) + return row + + +def is_owner_or_admin(row, user: dict, owner_field: str = "user_id") -> bool: + """True wenn User Owner ist oder Admin/Moderator.""" + if not row: + return False + if user["rolle"] in ("admin", "moderator") or user.get("is_moderator"): + return True + return row[owner_field] == user["id"] + + def has_pro_access(user: dict) -> bool: """True wenn User Pro-Features nutzen darf.""" if not user: diff --git a/backend/routes/places.py b/backend/routes/places.py index bb1f86b..3dfc0a8 100644 --- a/backend/routes/places.py +++ b/backend/routes/places.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from typing import Optional from database import db -from auth import get_current_user +from auth import get_current_user, require_owner from math_utils import haversine_m router = APIRouter() @@ -121,11 +121,10 @@ async def get_place(place_id: int): @router.patch("/{place_id}") async def update_place(place_id: int, data: PlaceUpdate, user=Depends(get_current_user)): with db() as conn: - row = conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone() - if not row: - raise HTTPException(404, "Ort nicht gefunden.") - if row['user_id'] != user['id']: - raise HTTPException(403, "Nicht berechtigt.") + row = require_owner( + conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone(), + user, not_found_msg="Ort nicht gefunden.", forbidden_msg="Nicht berechtigt." + ) updates = data.model_dump(exclude_none=True) if not updates: @@ -150,9 +149,8 @@ async def update_place(place_id: int, data: PlaceUpdate, user=Depends(get_curren @router.delete("/{place_id}", status_code=204) async def delete_place(place_id: int, user=Depends(get_current_user)): with db() as conn: - row = conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone() - if not row: - raise HTTPException(404, "Ort nicht gefunden.") - if row['user_id'] != user['id']: - raise HTTPException(403, "Nicht berechtigt.") + require_owner( + conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone(), + user, not_found_msg="Ort nicht gefunden.", forbidden_msg="Nicht berechtigt." + ) conn.execute("DELETE FROM places WHERE id = ?", (place_id,)) diff --git a/backend/static/index.html b/backend/static/index.html index eee2b4e..09855ab 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -617,11 +617,11 @@ - - - - - + + + + + @@ -631,7 +631,7 @@ - + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 542e7d8..1585f23 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1114'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1115'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/landing.html b/backend/static/landing.html index c545b18..998f802 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index ebc46f7..3e64fad 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1114'; +const VER = '1115'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten