Implement Club Feature Enforcement Logic and Update Versioning
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Failing after 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m15s

- 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.
This commit is contained in:
Lars 2026-06-07 15:47:49 +02:00
parent 4130a63dfe
commit a9a6153ed5
12 changed files with 81 additions and 7 deletions

View File

@ -34,6 +34,10 @@ DB_PASSWORD=CHANGE_ME_SECURE_PASSWORD
OPENROUTER_API_KEY=your_api_key_here OPENROUTER_API_KEY=your_api_key_here
OPENROUTER_MODEL=anthropic/claude-sonnet-4 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 # Standard-OpenRouter-Modell (alle Aufrufe). Optional pro Prompt in ai_prompts.openrouter_model
# ueberschreibbar (Migration 070, Superadmin unter „KI Prompts“). # ueberschreibbar (Migration 070, Superadmin unter „KI Prompts“).

View File

@ -20,7 +20,8 @@ if TYPE_CHECKING:
def capability_enforcement_enabled() -> bool: 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]: def club_roles_in_club(tenant: "TenantContext", club_id: Optional[int]) -> List[str]:

View File

@ -321,8 +321,9 @@ def _check_club_impl(club_id: int, feature_id: str, conn) -> Dict[str, Any]:
def club_feature_enforcement_enabled() -> bool: def club_feature_enforcement_enabled() -> bool:
"""Phase 4: Hard-Block aktiv (Env CLUB_FEATURE_ENFORCE=1).""" """Phase 4: Hard-Block aktiv (Env CLUB_FEATURE_ENFORCE=1|true|yes)."""
return os.getenv("CLUB_FEATURE_ENFORCE", "0").strip() == "1" v = os.getenv("CLUB_FEATURE_ENFORCE", "0").strip().lower()
return v in ("1", "true", "yes")
def probe_club_feature_access( def probe_club_feature_access(
@ -333,6 +334,7 @@ def probe_club_feature_access(
profile_id: Optional[int] = None, profile_id: Optional[int] = None,
portal_role: Optional[str] = None, portal_role: Optional[str] = None,
endpoint: Optional[str] = None, endpoint: Optional[str] = None,
tenant: Optional["TenantContext"] = None,
conn=None, conn=None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
@ -344,7 +346,7 @@ def probe_club_feature_access(
if club_id is None: if club_id is None:
access = { access = {
"allowed": True, "allowed": not club_feature_enforcement_enabled(),
"limit": None, "limit": None,
"used": 0, "used": 0,
"remaining": None, "remaining": None,
@ -358,8 +360,16 @@ def probe_club_feature_access(
action=action, action=action,
access=access, access=access,
endpoint=endpoint, 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 return access
def _resolve_access(connection): def _resolve_access(connection):
@ -371,6 +381,7 @@ def probe_club_feature_access(
profile_id=profile_id, profile_id=profile_id,
portal_role=portal_role, portal_role=portal_role,
feature_id=feature_id, feature_id=feature_id,
tenant=tenant,
): ):
plan_id = get_effective_club_plan(cur, int(club_id)) plan_id = get_effective_club_plan(cur, int(club_id))
return quota_bypass_access( return quota_bypass_access(

View File

@ -66,6 +66,14 @@ if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"):
print(f"[FAIL] Rights registry sync: {e}") print(f"[FAIL] Rights registry sync: {e}")
sys.exit(1) 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 from routers.auth import limiter as auth_rate_limiter
# OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1 # OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1

View File

@ -3,6 +3,7 @@ Superadmin: Rollen & Rechte — Capability-Grants, Kontingent-Bypass, Vereins-Ko
Ein Router für das Rechtesystem (M6). Kein paralleles Exemption-Schema. Ein Router für das Rechtesystem (M6). Kein paralleles Exemption-Schema.
""" """
import os
from typing import Dict, List, Optional from typing import Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
@ -15,10 +16,12 @@ from club_quota_bypass import (
list_quota_bypass_grants, list_quota_bypass_grants,
quota_bypass_capability_id_for_feature, quota_bypass_capability_id_for_feature,
) )
from capabilities import capability_enforcement_enabled
from capability_enforcement_audit import ( from capability_enforcement_audit import (
enforcement_status_for_capability, enforcement_status_for_capability,
feature_consume_status, feature_consume_status,
) )
from club_features import club_feature_enforcement_enabled
from club_tenancy import is_superadmin from club_tenancy import is_superadmin
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
@ -83,6 +86,26 @@ class QuotaBypassProfileBody(BaseModel):
reason: Optional[str] = Field(None, max_length=500) 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) ───────────────────────────────── # ── Capability-Matrix (Rollen → Fähigkeiten) ─────────────────────────────────
@router.get("/capability-matrix") @router.get("/capability-matrix")

View File

@ -2341,6 +2341,7 @@ def exercise_ai_suggest_endpoint(
profile_id=tenant.profile_id, profile_id=tenant.profile_id,
portal_role=tenant.global_role, portal_role=tenant.global_role,
endpoint="POST /exercises/ai/suggest", endpoint="POST /exercises/ai/suggest",
tenant=tenant,
) )
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -2388,6 +2389,7 @@ def exercise_ai_regenerate_endpoint(
profile_id=tenant.profile_id, profile_id=tenant.profile_id,
portal_role=tenant.global_role, portal_role=tenant.global_role,
endpoint="POST /exercises/{id}/ai/regenerate", endpoint="POST /exercises/{id}/ai/regenerate",
tenant=tenant,
) )
want_summary = "summary" in body.regenerate want_summary = "summary" in body.regenerate
want_skills = "skills" in body.regenerate want_skills = "skills" in body.regenerate
@ -2500,6 +2502,7 @@ def create_exercise(
profile_id=profile_id, profile_id=profile_id,
portal_role=tenant.global_role, portal_role=tenant.global_role,
endpoint="POST /exercises", endpoint="POST /exercises",
tenant=tenant,
) )
# §11 Inline-Medien: Kurzsyntax → kanonisches Markup; Verweise erst nach Medien-Anlage möglich # §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, profile_id=profile_id,
portal_role=tenant.global_role, portal_role=tenant.global_role,
endpoint="POST /exercises/{id}/media", endpoint="POST /exercises/{id}/media",
tenant=tenant,
conn=conn, conn=conn,
) )

View File

@ -42,6 +42,7 @@ def post_planning_exercise_suggest(
profile_id=tenant.profile_id, profile_id=tenant.profile_id,
portal_role=tenant.global_role, portal_role=tenant.global_role,
endpoint="POST /planning/exercise-suggest", endpoint="POST /planning/exercise-suggest",
tenant=tenant,
) )
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -90,6 +91,7 @@ def post_progression_path_suggest(
profile_id=tenant.profile_id, profile_id=tenant.profile_id,
portal_role=tenant.global_role, portal_role=tenant.global_role,
endpoint="POST /planning/progression-path-suggest", endpoint="POST /planning/progression-path-suggest",
tenant=tenant,
) )
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)

View File

@ -204,6 +204,22 @@ def test_consume_with_usage_returns_snapshot(monkeypatch):
assert usage["ai_calls"]["used"] == 4 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): def test_club_feature_enforcement_env_default_off(monkeypatch):
monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False) monkeypatch.delenv("CLUB_FEATURE_ENFORCE", raising=False)
assert club_feature_enforcement_enabled() is False assert club_feature_enforcement_enabled() is False

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.201" APP_VERSION = "0.8.202"
BUILD_DATE = "2026-06-07" BUILD_DATE = "2026-06-07"
DB_SCHEMA_VERSION = "20260606084" DB_SCHEMA_VERSION = "20260606084"

View File

@ -39,7 +39,7 @@ services:
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}" ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098}"
ENVIRONMENT: "${ENVIRONMENT:-development}" ENVIRONMENT: "${ENVIRONMENT:-development}"
# M5: Hard-Block Vereins-Kontingente (Default aus — in .env auf 1 setzen zum Testen) # 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_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}"
MEDIAWIKI_USER: "${MEDIAWIKI_USER:-Jinkendo}" MEDIAWIKI_USER: "${MEDIAWIKI_USER:-Jinkendo}"
MEDIAWIKI_PASSWORD: "${MEDIAWIKI_PASSWORD:-CHANGE_ME}" MEDIAWIKI_PASSWORD: "${MEDIAWIKI_PASSWORD:-CHANGE_ME}"

View File

@ -44,6 +44,7 @@ services:
APP_URL: "${APP_URL:-https://shinkan.jinkendo.de}" APP_URL: "${APP_URL:-https://shinkan.jinkendo.de}"
ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://shinkan.jinkendo.de}" ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:-https://shinkan.jinkendo.de}"
ENVIRONMENT: "${ENVIRONMENT:-production}" ENVIRONMENT: "${ENVIRONMENT:-production}"
CLUB_FEATURE_ENFORCE: "${CLUB_FEATURE_ENFORCE:-1}"
# MediaWiki/SMW Import — in dev-env.yml bereits gesetzt; Prod brauchte diese Zeilen ebenfalls, # 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). # 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}" MEDIAWIKI_API_URL: "${MEDIAWIKI_API_URL:-https://karatetrainer.net/api.php}"

View File

@ -262,6 +262,10 @@ export async function rejectClubCreationRequest(requestId) {
} }
/** M6: Rollen & Rechte — Capabilities, Kontingent-Bypass, Vereins-Kontingente (Superadmin). */ /** M6: Rollen & Rechte — Capabilities, Kontingent-Bypass, Vereins-Kontingente (Superadmin). */
export async function getAdminRightsEnforcementStatus() {
return request('/api/admin/rights/enforcement-status')
}
export async function getAdminRightsCapabilityMatrix() { export async function getAdminRightsCapabilityMatrix() {
return request('/api/admin/rights/capability-matrix') return request('/api/admin/rights/capability-matrix')
} }