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
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:
parent
4130a63dfe
commit
a9a6153ed5
|
|
@ -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“).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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,7 +360,15 @@ 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
|
||||||
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user