From 870a7611dc7c2bedbbaa5a4a139ae615764573bb Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 21:57:55 +0200 Subject: [PATCH] feat: enhance governance visibility checks and update login rate limit - Updated governance visibility logic in `assert_valid_governance_visibility` to enforce club membership checks for platform admins and ensure proper club existence validation. - Increased login request limit from 5 to 30 per minute to improve user experience. - Refactored exercise update logic to better handle visibility and club ID requirements, ensuring compliance with governance rules. --- backend/club_tenancy.py | 9 ++++-- backend/routers/auth.py | 2 +- backend/routers/exercises.py | 54 ++++++++++++++++++++++++++++-------- backend/version.py | 15 ++++++++-- frontend/src/version.js | 2 +- 5 files changed, 64 insertions(+), 18 deletions(-) diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py index 178f515..6756c24 100644 --- a/backend/club_tenancy.py +++ b/backend/club_tenancy.py @@ -108,7 +108,7 @@ def assert_valid_governance_visibility( visibility: str, club_id: Optional[int], ) -> None: - """Pflicht club_id bei visibility=club + Mitgliedschaft; official nur Plattform-Admin.""" + """Pflicht club_id bei visibility=club; Mitgliedschaft außer Plattform-Admin; official nur Plattform-Admin.""" if visibility not in _GOVERNANCE_VISIBILITY: raise HTTPException(status_code=400, detail="Ungültige visibility") if visibility == "official" and not is_platform_admin(role): @@ -119,7 +119,12 @@ def assert_valid_governance_visibility( if visibility == "club": if club_id is None: raise HTTPException(status_code=400, detail="club_id ist bei visibility=club erforderlich") - assert_club_member(cur, profile_id, club_id) + if is_platform_admin(role): + cur.execute("SELECT 1 FROM clubs WHERE id = %s", (club_id,)) + if not cur.fetchone(): + raise HTTPException(status_code=400, detail="Verein nicht gefunden") + else: + assert_club_member(cur, profile_id, club_id) def exercise_visible_to_profile( diff --git a/backend/routers/auth.py b/backend/routers/auth.py index 8222213..ce4b554 100644 --- a/backend/routers/auth.py +++ b/backend/routers/auth.py @@ -26,7 +26,7 @@ limiter = Limiter(key_func=get_remote_address) @router.post("/login") -@limiter.limit("5/minute") +@limiter.limit("30/minute") async def login(req: LoginRequest, request: Request): """Login with email + password.""" with get_db() as conn: diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 2a697d8..5bd3b83 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -853,23 +853,59 @@ def update_exercise( with get_db() as conn: cur = get_cursor(conn) - # Existiert die Übung? - cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,)) + cur.execute( + "SELECT created_by, visibility, club_id FROM exercises WHERE id = %s", + (exercise_id,), + ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Übung nicht gefunden") - # Permission Check if _row_created_by(row) != profile_id: raise HTTPException(status_code=403, detail="Nur der Ersteller darf editieren") - # UPDATE (nur gesetzte Felder) - fields = [] - params = [] + ex_vis = (row.get("visibility") or "private").strip().lower() + ex_cid = row.get("club_id") + if ex_cid is not None: + ex_cid = int(ex_cid) data = body.dict(exclude_unset=True) - # Basis-Felder + next_vis = ex_vis + if "visibility" in data and data["visibility"] is not None: + v_raw = str(data["visibility"]).strip().lower() + if v_raw: + next_vis = v_raw + + next_club = ex_cid + if "club_id" in data: + raw_c = data["club_id"] + if raw_c in (None, "", []): + next_club = None + else: + next_club = int(raw_c) + + if next_vis == "club": + if next_club is None: + next_club = tenant.effective_club_id + if next_club is None: + raise HTTPException( + status_code=400, + detail="Vereins-Übung: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).", + ) + data["club_id"] = next_club + + if next_vis != ex_vis: + data["visibility"] = next_vis + + gov_club = next_club if next_vis == "club" else None + assert_valid_governance_visibility( + cur, profile_id, tenant.global_role, next_vis, gov_club + ) + + fields = [] + params = [] + for field in ["title", "summary", "goal", "execution", "preparation", "trainer_notes", "duration_min", "duration_max", "group_size_min", "group_size_max", "visibility", "status", "club_id"]: @@ -877,12 +913,10 @@ def update_exercise( fields.append(f"{field} = %s") params.append(data[field]) - # Equipment (JSONB) if "equipment" in data: fields.append("equipment = %s") params.append(json.dumps(data["equipment"]) if data["equipment"] else None) - # UPDATE ausführen (wenn Basis-Felder geändert wurden) if fields: fields.append("updated_at = NOW()") params.append(exercise_id) @@ -890,10 +924,8 @@ def update_exercise( cur.execute(query, params) conn.commit() - # M:N Relations aktualisieren (wenn angegeben) assign_exercise_relations(cur, conn, exercise_id, data) - # Vollständiges Objekt zurückgeben exercise = enrich_exercise_detail(exercise_id, cur) return exercise diff --git a/backend/version.py b/backend/version.py index 897cbaa..2bfe0fb 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,11 +1,11 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.24" +APP_VERSION = "0.8.25" BUILD_DATE = "2026-05-05" DB_SCHEMA_VERSION = "20260505041" MODULE_VERSIONS = { - "auth": "1.2.0", # Erster/bootstrap Nutzer und ADMIN_BOOTSTRAP_EMAILS → superadmin (nicht mehr admin) + "auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) "profiles": "1.4.0", # GET /profiles/me: effective_club_id (TenantContext-Auflösung); TenantContext-Modul "tenant_context": "1.0.0", # resolve/get_depends; library_content_visibility_sql "clubs": "0.4.0", # public-directory, members/directory; Vereins-GUI verwendet Endpoints @@ -15,7 +15,7 @@ MODULE_VERSIONS = { "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.6.0", # Alle geschützten Endpoints: TenantContext (Detail, Mutations, Medien, Varianten) + "exercises": "2.6.1", # PUT Übung: visibility club ohne club_id → effective_club_id / Body club_id; Governance für Plattform-Admins ohne Vereinsmitgliedschaft bei club "training_units": "0.1.0", "training_programs": "0.1.0", "planning": "0.8.0", # TenantContext auf allen Planungs-Endpunkten; Vorlagen-Liste wie Übungen nach aktivem Verein @@ -27,6 +27,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.25", + "date": "2026-05-05", + "changes": [ + "Übungen PUT: bei visibility club wird club_id aus aktivem Verein oder Body gesetzt (verhindert club ohne club_id für Vereinsnutzer)", + "club_tenancy: governance visibility club für Plattform-Admins ohne Vereinsmitgliedschaft (nur Existenz clubs.id)", + "Login POST /api/auth/login: Rate-Limit 30/minute pro IP (vorher 5/minute)", + ], + }, { "version": "0.8.24", "date": "2026-05-05", diff --git a/frontend/src/version.js b/frontend/src/version.js index dea8351..fd7a553 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -1,6 +1,6 @@ // Shinkan Jinkendo Frontend Version -export const APP_VERSION = "0.8.24" +export const APP_VERSION = "0.8.25" export const BUILD_DATE = "2026-05-05" export const PAGE_VERSIONS = {