From a9a6153ed5d0e6ef748267667603c38612aa8503 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 7 Jun 2026 15:47:49 +0200 Subject: [PATCH] Implement Club Feature Enforcement Logic and Update Versioning - Introduced a new environment variable `CLUB_FEATURE_ENFORCE` to control club feature access, allowing values of 1, true, or yes for activation. - Updated the backend logic to check for club feature enforcement, raising HTTP exceptions when access is denied without an active club context. - Enhanced the admin rights router with a new endpoint to check the enforcement status of club features. - Incremented application version to 0.8.202 to reflect these changes. --- .env.example | 4 ++++ backend/capabilities.py | 3 ++- backend/club_features.py | 19 ++++++++++++---- backend/main.py | 8 +++++++ backend/routers/admin_rights.py | 23 ++++++++++++++++++++ backend/routers/exercises.py | 4 ++++ backend/routers/planning_exercise_suggest.py | 2 ++ backend/tests/test_club_feature_m5.py | 16 ++++++++++++++ backend/version.py | 2 +- docker-compose.dev-env.yml | 2 +- docker-compose.yml | 1 + frontend/src/utils/api.js | 4 ++++ 12 files changed, 81 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 66c24e7..ad3344b 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,10 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD OPENROUTER_API_KEY=your_api_key_here OPENROUTER_MODEL=anthropic/claude-sonnet-4 + +# Vereins-Kontingente hart blockieren (KI-Kosten!). Nur 1, true oder yes aktivieren. +# Nach Änderung: docker compose -f docker-compose.dev-env.yml up -d backend +CLUB_FEATURE_ENFORCE=1 # Standard-OpenRouter-Modell (alle Aufrufe). Optional pro Prompt in ai_prompts.openrouter_model # ueberschreibbar (Migration 070, Superadmin unter „KI Prompts“). diff --git a/backend/capabilities.py b/backend/capabilities.py index 0981f70..4e5d799 100644 --- a/backend/capabilities.py +++ b/backend/capabilities.py @@ -20,7 +20,8 @@ if TYPE_CHECKING: def capability_enforcement_enabled() -> bool: - return os.getenv("CAPABILITY_ENFORCE", "0").strip() == "1" + v = os.getenv("CAPABILITY_ENFORCE", "0").strip().lower() + return v in ("1", "true", "yes") def club_roles_in_club(tenant: "TenantContext", club_id: Optional[int]) -> List[str]: diff --git a/backend/club_features.py b/backend/club_features.py index 8495bbe..3fbd937 100644 --- a/backend/club_features.py +++ b/backend/club_features.py @@ -321,8 +321,9 @@ def _check_club_impl(club_id: int, feature_id: str, conn) -> Dict[str, Any]: def club_feature_enforcement_enabled() -> bool: - """Phase 4: Hard-Block aktiv (Env CLUB_FEATURE_ENFORCE=1).""" - return os.getenv("CLUB_FEATURE_ENFORCE", "0").strip() == "1" + """Phase 4: Hard-Block aktiv (Env CLUB_FEATURE_ENFORCE=1|true|yes).""" + v = os.getenv("CLUB_FEATURE_ENFORCE", "0").strip().lower() + return v in ("1", "true", "yes") def probe_club_feature_access( @@ -333,6 +334,7 @@ def probe_club_feature_access( profile_id: Optional[int] = None, portal_role: Optional[str] = None, endpoint: Optional[str] = None, + tenant: Optional["TenantContext"] = None, conn=None, ) -> Dict[str, Any]: """ @@ -344,7 +346,7 @@ def probe_club_feature_access( if club_id is None: access = { - "allowed": True, + "allowed": not club_feature_enforcement_enabled(), "limit": None, "used": 0, "remaining": None, @@ -358,8 +360,16 @@ def probe_club_feature_access( action=action, access=access, endpoint=endpoint, - phase="probe", + phase="enforce" if club_feature_enforcement_enabled() else "probe", ) + if club_feature_enforcement_enabled() and not access.get("allowed"): + raise HTTPException( + status_code=403, + detail=( + f"Kein Vereinskontext für {feature_id} — " + "aktiven Verein wählen (X-Active-Club-Id)." + ), + ) return access def _resolve_access(connection): @@ -371,6 +381,7 @@ def probe_club_feature_access( profile_id=profile_id, portal_role=portal_role, feature_id=feature_id, + tenant=tenant, ): plan_id = get_effective_club_plan(cur, int(club_id)) return quota_bypass_access( diff --git a/backend/main.py b/backend/main.py index 3a9ad09..a1a6cad 100644 --- a/backend/main.py +++ b/backend/main.py @@ -66,6 +66,14 @@ if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"): print(f"[FAIL] Rights registry sync: {e}") sys.exit(1) + from club_features import club_feature_enforcement_enabled + + _cfe = os.getenv("CLUB_FEATURE_ENFORCE", "0") + print( + f"[OK] CLUB_FEATURE_ENFORCE raw={_cfe!r} " + f"active={club_feature_enforcement_enabled()}" + ) + from routers.auth import limiter as auth_rate_limiter # OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1 diff --git a/backend/routers/admin_rights.py b/backend/routers/admin_rights.py index 17c996e..f233ea0 100644 --- a/backend/routers/admin_rights.py +++ b/backend/routers/admin_rights.py @@ -3,6 +3,7 @@ Superadmin: Rollen & Rechte — Capability-Grants, Kontingent-Bypass, Vereins-Ko Ein Router für das Rechtesystem (M6). Kein paralleles Exemption-Schema. """ +import os from typing import Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query @@ -15,10 +16,12 @@ from club_quota_bypass import ( list_quota_bypass_grants, quota_bypass_capability_id_for_feature, ) +from capabilities import capability_enforcement_enabled from capability_enforcement_audit import ( enforcement_status_for_capability, feature_consume_status, ) +from club_features import club_feature_enforcement_enabled from club_tenancy import is_superadmin from db import get_db, get_cursor, r2d @@ -83,6 +86,26 @@ class QuotaBypassProfileBody(BaseModel): reason: Optional[str] = Field(None, max_length=500) +# ── Enforcement-Diagnose (Superadmin) ──────────────────────────────────────── + +@router.get("/enforcement-status") +def get_enforcement_status(session: dict = Depends(require_auth)): + """Prüft ob Hard-Block-Env im laufenden Container/Prozess ankommt.""" + _require_superadmin(session) + raw = os.getenv("CLUB_FEATURE_ENFORCE", "0") + return { + "club_feature_enforce_active": club_feature_enforcement_enabled(), + "club_feature_enforce_raw": raw, + "capability_enforce_active": capability_enforcement_enabled(), + "capability_enforce_raw": os.getenv("CAPABILITY_ENFORCE", "0"), + "hints": [ + "Nach .env-Änderung: docker compose up -d backend (Container neu erstellen).", + "Superadmin hat Quota-Bypass — KI-Limit-Test als Trainer, nicht als Superadmin.", + "Ohne aktiven Verein (X-Active-Club-Id) blockiert Enforce ebenfalls.", + ], + } + + # ── Capability-Matrix (Rollen → Fähigkeiten) ───────────────────────────────── @router.get("/capability-matrix") diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 48a1461..9941ce2 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -2341,6 +2341,7 @@ def exercise_ai_suggest_endpoint( profile_id=tenant.profile_id, portal_role=tenant.global_role, endpoint="POST /exercises/ai/suggest", + tenant=tenant, ) with get_db() as conn: cur = get_cursor(conn) @@ -2388,6 +2389,7 @@ def exercise_ai_regenerate_endpoint( profile_id=tenant.profile_id, portal_role=tenant.global_role, endpoint="POST /exercises/{id}/ai/regenerate", + tenant=tenant, ) want_summary = "summary" in body.regenerate want_skills = "skills" in body.regenerate @@ -2500,6 +2502,7 @@ def create_exercise( profile_id=profile_id, portal_role=tenant.global_role, endpoint="POST /exercises", + tenant=tenant, ) # §11 Inline-Medien: Kurzsyntax → kanonisches Markup; Verweise erst nach Medien-Anlage möglich @@ -3319,6 +3322,7 @@ async def upload_exercise_media( profile_id=profile_id, portal_role=tenant.global_role, endpoint="POST /exercises/{id}/media", + tenant=tenant, conn=conn, ) diff --git a/backend/routers/planning_exercise_suggest.py b/backend/routers/planning_exercise_suggest.py index f13402f..cede94a 100644 --- a/backend/routers/planning_exercise_suggest.py +++ b/backend/routers/planning_exercise_suggest.py @@ -42,6 +42,7 @@ def post_planning_exercise_suggest( profile_id=tenant.profile_id, portal_role=tenant.global_role, endpoint="POST /planning/exercise-suggest", + tenant=tenant, ) with get_db() as conn: cur = get_cursor(conn) @@ -90,6 +91,7 @@ def post_progression_path_suggest( profile_id=tenant.profile_id, portal_role=tenant.global_role, endpoint="POST /planning/progression-path-suggest", + tenant=tenant, ) with get_db() as conn: cur = get_cursor(conn) diff --git a/backend/tests/test_club_feature_m5.py b/backend/tests/test_club_feature_m5.py index a31caaa..46b88d4 100644 --- a/backend/tests/test_club_feature_m5.py +++ b/backend/tests/test_club_feature_m5.py @@ -204,6 +204,22 @@ def test_consume_with_usage_returns_snapshot(monkeypatch): assert usage["ai_calls"]["used"] == 4 +def test_probe_blocks_no_club_context_when_enforce(monkeypatch): + monkeypatch.setenv("CLUB_FEATURE_ENFORCE", "1") + monkeypatch.setattr("club_feature_logger.log_club_feature_usage", lambda **kwargs: None) + + with pytest.raises(HTTPException) as exc: + probe_club_feature_access( + feature_id="ai_calls", + action="suggest", + club_id=None, + profile_id=3, + endpoint="POST /exercises/ai/suggest", + ) + assert exc.value.status_code == 403 + assert "Vereinskontext" in str(exc.value.detail) + + def test_club_feature_enforcement_env_default_off(monkeypatch): monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False) assert club_feature_enforcement_enabled() is False diff --git a/backend/version.py b/backend/version.py index 285b284..8fdb24c 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.201" +APP_VERSION = "0.8.202" BUILD_DATE = "2026-06-07" DB_SCHEMA_VERSION = "20260606084" diff --git a/docker-compose.dev-env.yml b/docker-compose.dev-env.yml index 8614b5a..dbb8d6a 100644 --- a/docker-compose.dev-env.yml +++ b/docker-compose.dev-env.yml @@ -39,7 +39,7 @@ services: ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}" ENVIRONMENT: "${ENVIRONMENT:-development}" # M5: Hard-Block Vereins-Kontingente (Default aus — in .env auf 1 setzen zum Testen) - CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-0}" + CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}" MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}" MEDIAWIKI_USER: "${MEDIAWIKI_USER:-Jinkendo}" MEDIAWIKI_PASSWORD: "${MEDIAWIKI_PASSWORD:-CHANGE_ME}" diff --git a/docker-compose.yml b/docker-compose.yml index fc1b4cf..de0426a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,7 @@ services: APP_URL: "${APP_URL:-https://shinkan.jinkendo.de}" ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://shinkan.jinkendo.de}" ENVIRONMENT: "${ENVIRONMENT:-production}" + CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}" # MediaWiki/SMW Import — in dev-env.yml bereits gesetzt; Prod brauchte diese Zeilen ebenfalls, # sonst: leere MEDIAWIKI_API_URL im Container → Import bricht ab (auf Test/Dev war es immer gesetzt). MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}" diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 28fa2eb..e33a462 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -262,6 +262,10 @@ export async function rejectClubCreationRequest(requestId) { } /** M6: Rollen & Rechte — Capabilities, Kontingent-Bypass, Vereins-Kontingente (Superadmin). */ +export async function getAdminRightsEnforcementStatus() { + return request('/api/admin/rights/enforcement-status') +} + export async function getAdminRightsCapabilityMatrix() { return request('/api/admin/rights/capability-matrix') }