Bündel 3: Security-Helper + Demo-Migration, SW by-v1115

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.
This commit is contained in:
rene 2026-05-27 11:27:00 +02:00
parent 297bd22f96
commit 35937ed51b
7 changed files with 68 additions and 27 deletions

View file

@ -1 +1 @@
1114 1115

View file

@ -212,6 +212,49 @@ def require_admin(user=Depends(get_current_user)):
return 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: def has_pro_access(user: dict) -> bool:
"""True wenn User Pro-Features nutzen darf.""" """True wenn User Pro-Features nutzen darf."""
if not user: if not user:

View file

@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user from auth import get_current_user, require_owner
from math_utils import haversine_m from math_utils import haversine_m
router = APIRouter() router = APIRouter()
@ -121,11 +121,10 @@ async def get_place(place_id: int):
@router.patch("/{place_id}") @router.patch("/{place_id}")
async def update_place(place_id: int, data: PlaceUpdate, user=Depends(get_current_user)): async def update_place(place_id: int, data: PlaceUpdate, user=Depends(get_current_user)):
with db() as conn: with db() as conn:
row = conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone() row = require_owner(
if not row: conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone(),
raise HTTPException(404, "Ort nicht gefunden.") user, not_found_msg="Ort nicht gefunden.", forbidden_msg="Nicht berechtigt."
if row['user_id'] != user['id']: )
raise HTTPException(403, "Nicht berechtigt.")
updates = data.model_dump(exclude_none=True) updates = data.model_dump(exclude_none=True)
if not updates: 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) @router.delete("/{place_id}", status_code=204)
async def delete_place(place_id: int, user=Depends(get_current_user)): async def delete_place(place_id: int, user=Depends(get_current_user)):
with db() as conn: with db() as conn:
row = conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone() require_owner(
if not row: conn.execute("SELECT * FROM places WHERE id = ?", (place_id,)).fetchone(),
raise HTTPException(404, "Ort nicht gefunden.") user, not_found_msg="Ort nicht gefunden.", forbidden_msg="Nicht berechtigt."
if row['user_id'] != user['id']: )
raise HTTPException(403, "Nicht berechtigt.")
conn.execute("DELETE FROM places WHERE id = ?", (place_id,)) conn.execute("DELETE FROM places WHERE id = ?", (place_id,))

View file

@ -86,14 +86,14 @@
<title>Ban Yaro</title> <title>Ban Yaro</title>
<!-- Theme + theme-color Statusleiste vor CSS setzen --> <!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script src="/js/boot-early.js?v=1114"></script> <script src="/js/boot-early.js?v=1115"></script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung --> <!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1114"> <link rel="stylesheet" href="/css/design-system.css?v=1115">
<link rel="stylesheet" href="/css/layout.css?v=1114"> <link rel="stylesheet" href="/css/layout.css?v=1115">
<link rel="stylesheet" href="/css/components.css?v=1114"> <link rel="stylesheet" href="/css/components.css?v=1115">
<link rel="stylesheet" href="/css/utilities.css?v=1114"> <link rel="stylesheet" href="/css/utilities.css?v=1115">
<link rel="stylesheet" href="/css/lists.css?v=1114"> <link rel="stylesheet" href="/css/lists.css?v=1115">
</head> </head>
<body> <body>
@ -617,11 +617,11 @@
<div id="modal-container"></div> <div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features --> <!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1114"></script> <script src="/js/api.js?v=1115"></script>
<script src="/js/ui.js?v=1114"></script> <script src="/js/ui.js?v=1115"></script>
<script src="/js/app.js?v=1114"></script> <script src="/js/app.js?v=1115"></script>
<script src="/js/worlds.js?v=1114"></script> <script src="/js/worlds.js?v=1115"></script>
<script src="/js/offline-indicator.js?v=1114"></script> <script src="/js/offline-indicator.js?v=1115"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->
@ -631,7 +631,7 @@
<!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) --> <!-- Boot: Offline-Banner + SW-Registration (extrahiert für CSP) -->
<script src="/js/boot.js?v=1114"></script> <script src="/js/boot.js?v=1115"></script>
</body> </body>

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. 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 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_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator)
window.APP_VERSION = APP_VERSION; window.APP_VERSION = APP_VERSION;

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<script src="/js/landing-init.js?v=1114"></script> <script src="/js/landing-init.js?v=1115"></script>
<title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title> <title>Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz</title>
<meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store."> <meta name="description" content="Ban Yaro: Die kostenlose All-in-One Hunde-App für DACH. Tagebuch, Giftköder-Alarm, Training mit KI, Forum, Wurfbörse, Stammbaum, Inzucht-Check — DSGVO-konform, offline-fähig, ohne App Store.">
<meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz"> <meta name="keywords" content="Hunde App, Hunde Community, Wurfbörse, Züchter, Welpen kaufen, Stammbaum Hund, Inzuchtkoeffizient, Hundezucht, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundetraining App, Hunde Forum, Hunde KI, Hundefilm Datenbank, Welpen Marktplatz">

View file

@ -4,7 +4,7 @@
============================================================ */ ============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab // ← 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_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten