Enhance Tenant Context and Access Control Features
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 0s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Failing after 4m0s
Test Suite / playwright-tests (push) Failing after 3m41s
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 0s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Failing after 4m0s
Test Suite / playwright-tests (push) Failing after 3m41s
- Introduced `email_verified` and `account_state` attributes in the `TenantContext` to improve user state management. - Updated the `resolve_tenant_context` function to dynamically fetch `email_verified` status from the database and determine `account_state` based on user roles and memberships. - Implemented `assert_min_account_state` checks across various endpoints to enforce access control based on user account status. - Incremented version to 1.1.0 in version.py to reflect these enhancements in tenant context management and access control.
This commit is contained in:
parent
7cfbca40bb
commit
30dc30c7aa
77
backend/account_lifecycle.py
Normal file
77
backend/account_lifecycle.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""
|
||||
Account-Lifecycle (CAPABILITY_CATALOG.v1.md §3, M3 C0).
|
||||
|
||||
Zustände: unverified → verified_pending_club → active_member; platform_admin separat.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from club_tenancy import is_platform_admin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tenant_context import TenantContext
|
||||
|
||||
_ACCOUNT_STATE_RANK = {
|
||||
"unverified": 1,
|
||||
"verified_pending_club": 2,
|
||||
"active_member": 3,
|
||||
"platform_admin": 4,
|
||||
}
|
||||
|
||||
|
||||
def resolve_account_state(
|
||||
*,
|
||||
email_verified: bool,
|
||||
global_role: str,
|
||||
has_active_membership: bool,
|
||||
) -> str:
|
||||
"""Ermittelt account_state für ein Profil."""
|
||||
if is_platform_admin(global_role):
|
||||
return "platform_admin"
|
||||
if not email_verified:
|
||||
return "unverified"
|
||||
if not has_active_membership:
|
||||
return "verified_pending_club"
|
||||
return "active_member"
|
||||
|
||||
|
||||
def account_state_satisfies(current: str, required: str) -> bool:
|
||||
"""True wenn current mindestens required ist."""
|
||||
cur = _ACCOUNT_STATE_RANK.get(current, 0)
|
||||
req = _ACCOUNT_STATE_RANK.get(required, 99)
|
||||
if current == "platform_admin":
|
||||
return True
|
||||
return cur >= req
|
||||
|
||||
|
||||
def account_gate_enforcement_enabled() -> bool:
|
||||
"""Account-Gates aktiv (Default an — nur wenige Endpoints in M3)."""
|
||||
return os.getenv("ACCOUNT_GATE_ENFORCE", "1").strip() == "1"
|
||||
|
||||
|
||||
def assert_min_account_state(
|
||||
tenant: "TenantContext",
|
||||
min_state: str,
|
||||
*,
|
||||
endpoint: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Prüft Mindest-Account-Status. Wirft 403 wenn ACCOUNT_GATE_ENFORCE=1 (Default).
|
||||
"""
|
||||
current = getattr(tenant, "account_state", "active_member")
|
||||
ok = account_state_satisfies(current, min_state)
|
||||
if ok:
|
||||
return
|
||||
if not account_gate_enforcement_enabled():
|
||||
return
|
||||
detail = (
|
||||
f"Account-Status „{current}“ reicht nicht für diese Aktion "
|
||||
f"(erforderlich: {min_state})."
|
||||
)
|
||||
if endpoint:
|
||||
detail = f"{detail} ({endpoint})"
|
||||
raise HTTPException(status_code=403, detail=detail)
|
||||
232
backend/capabilities.py
Normal file
232
backend/capabilities.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"""
|
||||
Capability-Auflösung (CAPABILITY_CATALOG.v1.md, M3 C1).
|
||||
|
||||
Phase 2: probe_capability — JSON-Log, kein Block (CAPABILITY_ENFORCE=0).
|
||||
Phase 3+: CAPABILITY_ENFORCE=1 — HTTP 403 bei fehlender Capability.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from account_lifecycle import account_state_satisfies
|
||||
from club_tenancy import is_platform_admin
|
||||
from db import get_db, get_cursor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
def capability_enforcement_enabled() -> bool:
|
||||
return os.getenv("CAPABILITY_ENFORCE", "0").strip() == "1"
|
||||
|
||||
|
||||
def club_roles_in_club(tenant: "TenantContext", club_id: Optional[int]) -> List[str]:
|
||||
if club_id is None:
|
||||
return []
|
||||
for m in tenant.memberships or []:
|
||||
if int(m.get("id") or 0) == int(club_id):
|
||||
roles = m.get("roles") or []
|
||||
if hasattr(roles, "tolist"):
|
||||
roles = roles.tolist()
|
||||
return list(roles)
|
||||
return []
|
||||
|
||||
|
||||
def check_capability(
|
||||
cur,
|
||||
tenant: "TenantContext",
|
||||
capability_id: str,
|
||||
*,
|
||||
club_id: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Prüft eine Capability für Tenant + optionalen Vereinskontext.
|
||||
|
||||
Returns: allowed, reason, account_state, club_roles, linked_feature_id
|
||||
"""
|
||||
account_state = getattr(tenant, "account_state", "active_member")
|
||||
eff_club = club_id if club_id is not None else tenant.effective_club_id
|
||||
club_roles = club_roles_in_club(tenant, eff_club) if eff_club is not None else []
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, min_account_state, linked_feature_id, active, domain
|
||||
FROM capabilities
|
||||
WHERE id = %s
|
||||
""",
|
||||
(capability_id,),
|
||||
)
|
||||
cap = cur.fetchone()
|
||||
if not cap or not cap.get("active"):
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "capability_not_found",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": None,
|
||||
}
|
||||
|
||||
min_state = cap.get("min_account_state") or "active_member"
|
||||
if not account_state_satisfies(account_state, min_state):
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "account_state_insufficient",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
domain = (cap.get("domain") or "").strip().lower()
|
||||
|
||||
# Plattform-Capabilities
|
||||
if domain == "platform" or capability_id.startswith("platform."):
|
||||
role_lc = (tenant.global_role or "").lower()
|
||||
if not is_platform_admin(role_lc):
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "portal_role_required",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT 1 FROM portal_role_capability_grants
|
||||
WHERE portal_role = %s AND capability_id = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(role_lc, capability_id),
|
||||
)
|
||||
if not cur.fetchone():
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "portal_capability_denied",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
return {
|
||||
"allowed": True,
|
||||
"reason": "portal_granted",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
# Plattform-Admin-Bypass für Mandanten-Funktionen (Audit-Pflicht, s. Katalog §9)
|
||||
if is_platform_admin(tenant.global_role):
|
||||
return {
|
||||
"allowed": True,
|
||||
"reason": "platform_admin_bypass",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
# Vereins-Capabilities: aktive Mitgliedschaft im Zielverein
|
||||
if min_state == "active_member":
|
||||
if eff_club is None:
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "no_club_context",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
if eff_club not in tenant.club_ids:
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "not_club_member",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT role_code FROM club_role_capability_grants
|
||||
WHERE capability_id = %s
|
||||
""",
|
||||
(capability_id,),
|
||||
)
|
||||
required_roles = [r["role_code"] for r in cur.fetchall()]
|
||||
|
||||
if required_roles:
|
||||
if not any(r in required_roles for r in club_roles):
|
||||
return {
|
||||
"allowed": False,
|
||||
"reason": "club_role_denied",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
elif min_state == "active_member" and eff_club is not None:
|
||||
# Offene Capability für alle aktiven Mitglieder — Mitgliedschaft reicht
|
||||
pass
|
||||
|
||||
return {
|
||||
"allowed": True,
|
||||
"reason": "granted",
|
||||
"account_state": account_state,
|
||||
"club_roles": club_roles,
|
||||
"linked_feature_id": cap.get("linked_feature_id"),
|
||||
}
|
||||
|
||||
|
||||
def resolve_capabilities_map(
|
||||
cur,
|
||||
tenant: "TenantContext",
|
||||
*,
|
||||
club_id: Optional[int] = None,
|
||||
) -> Dict[str, bool]:
|
||||
"""Alle aktiven Capabilities → bool (für späteres /me/entitlements)."""
|
||||
cur.execute("SELECT id FROM capabilities WHERE active = true ORDER BY id")
|
||||
ids = [r["id"] for r in cur.fetchall()]
|
||||
out: Dict[str, bool] = {}
|
||||
for cid in ids:
|
||||
res = check_capability(cur, tenant, cid, club_id=club_id)
|
||||
out[cid] = bool(res.get("allowed"))
|
||||
return out
|
||||
|
||||
|
||||
def probe_capability(
|
||||
tenant: "TenantContext",
|
||||
capability_id: str,
|
||||
*,
|
||||
action: str,
|
||||
club_id: Optional[int] = None,
|
||||
endpoint: Optional[str] = None,
|
||||
conn=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Phase 2: Capability prüfen + JSON-Log; blockiert nur bei CAPABILITY_ENFORCE=1."""
|
||||
from capability_logger import log_capability_check
|
||||
|
||||
def _run(c):
|
||||
cur = get_cursor(c)
|
||||
result = check_capability(cur, tenant, capability_id, club_id=club_id)
|
||||
log_capability_check(
|
||||
club_id=club_id if club_id is not None else tenant.effective_club_id,
|
||||
profile_id=tenant.profile_id,
|
||||
capability_id=capability_id,
|
||||
action=action,
|
||||
result=result,
|
||||
endpoint=endpoint,
|
||||
phase="enforce" if capability_enforcement_enabled() else "probe",
|
||||
)
|
||||
if capability_enforcement_enabled() and not result.get("allowed"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
f"Keine Berechtigung für {capability_id} "
|
||||
f"({result.get('reason', 'denied')})."
|
||||
),
|
||||
)
|
||||
return result
|
||||
|
||||
if conn is not None:
|
||||
return _run(conn)
|
||||
with get_db() as c:
|
||||
return _run(c)
|
||||
64
backend/capability_logger.py
Normal file
64
backend/capability_logger.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""
|
||||
JSON-Log für Capability-Checks (M3 Phase 2 — analog club_feature_logger).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def _log_dir() -> Path:
|
||||
custom = (os.getenv("CAPABILITY_LOG_DIR") or "").strip()
|
||||
if custom:
|
||||
return Path(custom)
|
||||
return Path("/app/logs")
|
||||
|
||||
|
||||
capability_logger = logging.getLogger("shinkan.capability_usage")
|
||||
capability_logger.setLevel(logging.INFO)
|
||||
capability_logger.propagate = False
|
||||
|
||||
if not capability_logger.handlers:
|
||||
log_dir = _log_dir()
|
||||
try:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / "capability-usage.log"
|
||||
file_handler = logging.FileHandler(log_file, encoding="utf-8")
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
capability_logger.addHandler(file_handler)
|
||||
except OSError:
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(logging.Formatter("[capability-usage] %(message)s"))
|
||||
capability_logger.addHandler(stream_handler)
|
||||
|
||||
|
||||
def log_capability_check(
|
||||
*,
|
||||
club_id: Optional[int],
|
||||
profile_id: Optional[int],
|
||||
capability_id: str,
|
||||
action: str,
|
||||
result: Dict[str, Any],
|
||||
endpoint: Optional[str] = None,
|
||||
phase: str = "probe",
|
||||
) -> None:
|
||||
entry = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"club_id": club_id,
|
||||
"profile_id": profile_id,
|
||||
"capability": capability_id,
|
||||
"action": action,
|
||||
"endpoint": endpoint,
|
||||
"phase": phase,
|
||||
"allowed": result.get("allowed", True),
|
||||
"reason": result.get("reason", "unknown"),
|
||||
"account_state": result.get("account_state"),
|
||||
"club_roles": result.get("club_roles"),
|
||||
"enforcement": os.getenv("CAPABILITY_ENFORCE", "0") == "1",
|
||||
}
|
||||
capability_logger.info(json.dumps(entry, ensure_ascii=False))
|
||||
|
|
@ -446,8 +446,9 @@ def increment_club_feature_usage(
|
|||
_run(c)
|
||||
|
||||
|
||||
def list_club_entitlements(cur, club_id: int) -> Dict[str, Any]:
|
||||
"""Alle aktiven Shinkan-Features mit effektivem Limit und Verbrauch (für API/UI)."""
|
||||
def list_club_entitlements(cur, club_id: int, *, conn=None) -> Dict[str, Any]:
|
||||
"""Alle aktiven Shinkan-Features mit effektivem Limit und Verbrauch (Liste, intern)."""
|
||||
db_conn = conn if conn is not None else cur.connection
|
||||
plan_id = get_effective_club_plan(cur, club_id)
|
||||
cur.execute(
|
||||
"""
|
||||
|
|
@ -461,7 +462,7 @@ def list_club_entitlements(cur, club_id: int) -> Dict[str, Any]:
|
|||
features_out = []
|
||||
for row in rows:
|
||||
fid = row["id"]
|
||||
access = _check_club_impl(club_id, fid, cur.connection)
|
||||
access = _check_club_impl(club_id, fid, db_conn)
|
||||
features_out.append(
|
||||
{
|
||||
"id": fid,
|
||||
|
|
@ -474,6 +475,32 @@ def list_club_entitlements(cur, club_id: int) -> Dict[str, Any]:
|
|||
"used": access.get("used"),
|
||||
"remaining": access.get("remaining"),
|
||||
"reason": access.get("reason"),
|
||||
"reset_at": access.get("reset_at"),
|
||||
}
|
||||
)
|
||||
return {"club_id": club_id, "plan_id": plan_id, "features": features_out}
|
||||
|
||||
|
||||
def club_features_map(cur, club_id: int, *, conn=None) -> Dict[str, Any]:
|
||||
"""Feature-Kontingente als Dict feature_id → Zustand (für /me/entitlements)."""
|
||||
raw = list_club_entitlements(cur, club_id, conn=conn)
|
||||
features_dict: Dict[str, Any] = {}
|
||||
for row in raw.get("features") or []:
|
||||
fid = row["id"]
|
||||
features_dict[fid] = {
|
||||
"name": row.get("name"),
|
||||
"category": row.get("category"),
|
||||
"limit_type": row.get("limit_type"),
|
||||
"reset_period": row.get("reset_period"),
|
||||
"allowed": row.get("allowed"),
|
||||
"limit": row.get("limit"),
|
||||
"used": row.get("used"),
|
||||
"remaining": row.get("remaining"),
|
||||
"reason": row.get("reason"),
|
||||
"reset_at": row.get("reset_at"),
|
||||
}
|
||||
return {
|
||||
"club_id": raw.get("club_id"),
|
||||
"plan_id": raw.get("plan_id"),
|
||||
"features": features_dict,
|
||||
}
|
||||
|
|
|
|||
89
backend/entitlements.py
Normal file
89
backend/entitlements.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""
|
||||
Zusammenstellung effektiver Rechte für GET /api/me/entitlements (M4).
|
||||
|
||||
Spez: CAPABILITY_CATALOG.v1.md §7.1, CLUB_MEMBERSHIP_AND_FEATURES.v1.md §8.1
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional, TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from capabilities import club_roles_in_club, resolve_capabilities_map
|
||||
from club_features import club_features_map
|
||||
from club_tenancy import is_platform_admin
|
||||
from tenant_context import _club_exists
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
def _serialize_reset_at(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=None).isoformat() + "Z"
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _resolve_target_club_id(
|
||||
cur,
|
||||
tenant: "TenantContext",
|
||||
club_id: Optional[int],
|
||||
) -> Optional[int]:
|
||||
"""Effektiver Verein für Entitlements (Query > Tenant)."""
|
||||
target = int(club_id) if club_id is not None else tenant.effective_club_id
|
||||
if target is None:
|
||||
return None
|
||||
|
||||
if is_platform_admin(tenant.global_role):
|
||||
if not _club_exists(cur, target):
|
||||
raise HTTPException(status_code=400, detail="Verein nicht gefunden")
|
||||
return target
|
||||
|
||||
if target not in tenant.club_ids:
|
||||
raise HTTPException(status_code=403, detail="Keine Mitgliedschaft in diesem Verein")
|
||||
return target
|
||||
|
||||
|
||||
def build_me_entitlements(
|
||||
cur,
|
||||
tenant: "TenantContext",
|
||||
*,
|
||||
club_id: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Kombiniert Account-Status, Capabilities und Feature-Kontingente.
|
||||
"""
|
||||
target_club = _resolve_target_club_id(cur, tenant, club_id)
|
||||
club_roles = club_roles_in_club(tenant, target_club) if target_club is not None else []
|
||||
|
||||
capabilities = resolve_capabilities_map(cur, tenant, club_id=target_club)
|
||||
|
||||
features: Dict[str, Any] = {}
|
||||
plan_id = None
|
||||
if target_club is not None:
|
||||
raw = club_features_map(cur, target_club)
|
||||
plan_id = raw.get("plan_id")
|
||||
for fid, row in (raw.get("features") or {}).items():
|
||||
features[fid] = {
|
||||
"allowed": row.get("allowed"),
|
||||
"used": row.get("used"),
|
||||
"limit": row.get("limit"),
|
||||
"remaining": row.get("remaining"),
|
||||
"reset_at": _serialize_reset_at(row.get("reset_at")),
|
||||
"reason": row.get("reason"),
|
||||
}
|
||||
|
||||
return {
|
||||
"account_state": tenant.account_state,
|
||||
"portal_role": tenant.global_role,
|
||||
"club_id": target_club,
|
||||
"plan_id": plan_id,
|
||||
"club_roles": club_roles,
|
||||
"capabilities": capabilities,
|
||||
"features": features,
|
||||
}
|
||||
|
|
@ -193,7 +193,7 @@ def read_root():
|
|||
return out
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, admin_user_content, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, admin_user_content, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
|
|
@ -204,6 +204,7 @@ app.include_router(club_memberships.router)
|
|||
app.include_router(club_join_requests.router)
|
||||
app.include_router(admin_users.router)
|
||||
app.include_router(admin_user_content.router)
|
||||
app.include_router(me_entitlements.router)
|
||||
app.include_router(platform_media_storage.router)
|
||||
app.include_router(media_assets.router)
|
||||
app.include_router(media_assets.admin_rights_router)
|
||||
|
|
|
|||
211
backend/migrations/079_capabilities.sql
Normal file
211
backend/migrations/079_capabilities.sql
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
-- Migration 079: Capability-Registry + Rollen-Grants (M3 / CAPABILITY_CATALOG.v1.md C1)
|
||||
-- Account-Gates und Enforcement in Python (account_lifecycle.py, capabilities.py).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS capabilities (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
domain TEXT NOT NULL,
|
||||
min_account_state TEXT NOT NULL DEFAULT 'active_member'
|
||||
CHECK (min_account_state IN (
|
||||
'unverified', 'verified_pending_club', 'active_member', 'platform_admin'
|
||||
)),
|
||||
linked_feature_id TEXT REFERENCES features(id) ON DELETE SET NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_capabilities_domain ON capabilities(domain) WHERE active = true;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS club_role_capability_grants (
|
||||
role_code TEXT NOT NULL,
|
||||
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (role_code, capability_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_club_role_cap_grants_cap ON club_role_capability_grants(capability_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS portal_role_capability_grants (
|
||||
portal_role TEXT NOT NULL,
|
||||
capability_id TEXT NOT NULL REFERENCES capabilities(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (portal_role, capability_id)
|
||||
);
|
||||
|
||||
-- ── Seed: Capabilities (v1 Katalog §5) ───────────────────────────────────────
|
||||
INSERT INTO capabilities (id, name, domain, min_account_state, linked_feature_id) VALUES
|
||||
('account.settings.read', 'Einstellungen lesen', 'account', 'unverified', NULL),
|
||||
('account.settings.update', 'Einstellungen ändern', 'account', 'unverified', NULL),
|
||||
('account.password.change', 'Passwort ändern', 'account', 'unverified', NULL),
|
||||
('account.resend_verification', 'Verifizierung erneut senden', 'account', 'unverified', NULL),
|
||||
('club.directory.read', 'Vereinsverzeichnis', 'club', 'verified_pending_club', NULL),
|
||||
('club.join_request.create', 'Vereinsbeitritt beantragen', 'club', 'verified_pending_club', NULL),
|
||||
('club.join_request.withdraw', 'Beitrittsantrag zurückziehen', 'club', 'verified_pending_club', NULL),
|
||||
('club.join_request.read_own', 'Eigene Beitrittsanträge', 'club', 'verified_pending_club', NULL),
|
||||
('org.club.read', 'Vereine lesen', 'org', 'active_member', NULL),
|
||||
('org.club.create', 'Verein anlegen', 'org', 'platform_admin', NULL),
|
||||
('org.club.update', 'Verein bearbeiten', 'org', 'active_member', NULL),
|
||||
('org.club.delete', 'Verein löschen', 'org', 'platform_admin', NULL),
|
||||
('org.structure.manage', 'Vereinsstruktur verwalten', 'org', 'active_member', 'training_groups'),
|
||||
('org.members.read', 'Mitgliederliste', 'org', 'active_member', NULL),
|
||||
('org.members.manage', 'Mitglieder verwalten', 'org', 'active_member', 'active_members'),
|
||||
('org.members.directory', 'Mitglieder-Verzeichnis', 'org', 'active_member', NULL),
|
||||
('org.join_request.review', 'Beitrittsanträge prüfen', 'org', 'active_member', NULL),
|
||||
('org.inbox.read', 'Posteingang', 'org', 'active_member', NULL),
|
||||
('exercises.read', 'Übungen lesen', 'exercises', 'active_member', NULL),
|
||||
('exercises.create', 'Übung anlegen', 'exercises', 'active_member', 'exercises'),
|
||||
('exercises.update', 'Übung bearbeiten', 'exercises', 'active_member', NULL),
|
||||
('exercises.delete', 'Übung löschen', 'exercises', 'active_member', NULL),
|
||||
('exercises.bulk_metadata', 'Übungen Stapel-Metadaten', 'exercises', 'active_member', NULL),
|
||||
('exercises.ai.suggest', 'KI-Vorschlag Übung', 'exercises', 'active_member', 'ai_calls'),
|
||||
('exercises.ai.regenerate', 'KI neu generieren', 'exercises', 'active_member', 'ai_calls'),
|
||||
('exercises.media.read', 'Übungsmedien lesen', 'exercises', 'active_member', NULL),
|
||||
('exercises.media.upload', 'Übungsmedien hochladen', 'exercises', 'active_member', 'exercise_media'),
|
||||
('exercises.variants.manage', 'Übungsvarianten', 'exercises', 'active_member', NULL),
|
||||
('media.library.read', 'Medienbibliothek lesen', 'media', 'active_member', NULL),
|
||||
('media.library.upload', 'Medienbibliothek Upload', 'media', 'active_member', 'exercise_media'),
|
||||
('media.library.update', 'Medienbibliothek bearbeiten', 'media', 'active_member', NULL),
|
||||
('media.library.lifecycle', 'Medien-Lifecycle', 'media', 'active_member', NULL),
|
||||
('media.rights.declare', 'Medienrechte erklären', 'media', 'active_member', NULL),
|
||||
('media.admin.rights_review', 'Medienrechte Review (Plattform)', 'media', 'platform_admin', NULL),
|
||||
('modules.read', 'Trainingsmodule lesen', 'modules', 'active_member', NULL),
|
||||
('modules.create', 'Trainingsmodul anlegen', 'modules', 'active_member', 'training_programs'),
|
||||
('modules.update', 'Trainingsmodul bearbeiten', 'modules', 'active_member', NULL),
|
||||
('modules.delete', 'Trainingsmodul löschen', 'modules', 'active_member', NULL),
|
||||
('framework.read', 'Rahmenprogramme lesen', 'framework', 'active_member', NULL),
|
||||
('framework.create', 'Rahmenprogramm anlegen', 'framework', 'active_member', 'training_programs'),
|
||||
('framework.update', 'Rahmenprogramm bearbeiten', 'framework', 'active_member', NULL),
|
||||
('framework.delete', 'Rahmenprogramm löschen', 'framework', 'active_member', NULL),
|
||||
('plan_templates.read', 'Planungsvorlagen lesen', 'planning', 'active_member', NULL),
|
||||
('plan_templates.manage', 'Planungsvorlagen verwalten', 'planning', 'active_member', NULL),
|
||||
('progression.read', 'Progressionspfade lesen', 'progression', 'active_member', NULL),
|
||||
('progression.manage', 'Progressionspfade verwalten', 'progression', 'active_member', NULL),
|
||||
('planning.calendar.read', 'Planungskalender lesen', 'planning', 'active_member', NULL),
|
||||
('planning.units.create', 'Trainingseinheit anlegen', 'planning', 'active_member', 'training_units'),
|
||||
('planning.units.update', 'Trainingseinheit bearbeiten', 'planning', 'active_member', NULL),
|
||||
('planning.units.delete', 'Trainingseinheit löschen', 'planning', 'active_member', NULL),
|
||||
('planning.units.run', 'Training durchführen', 'planning', 'active_member', NULL),
|
||||
('planning.coach.execute', 'Coach ausführen', 'planning', 'active_member', NULL),
|
||||
('planning.ai.suggest', 'Planungs-KI Suggest', 'planning', 'active_member', 'ai_calls'),
|
||||
('planning.ai.progression_path', 'Planungs-KI Progressionspfad', 'planning', 'active_member', 'ai_calls'),
|
||||
('skills.catalog.read', 'Fähigkeitenkatalog', 'skills', 'active_member', NULL),
|
||||
('skills.discovery.read', 'Fähigkeiten-Discovery', 'skills', 'active_member', NULL),
|
||||
('skill_profiles.read', 'Skill-Profile lesen', 'skills', 'active_member', NULL),
|
||||
('governance.content_report.create', 'Inhalt melden', 'governance', 'active_member', NULL),
|
||||
('governance.content_report.review', 'Meldungen prüfen', 'governance', 'active_member', NULL),
|
||||
('platform.admin.access', 'Plattform-Admin-Bereich', 'platform', 'platform_admin', NULL),
|
||||
('platform.users.manage', 'Nutzer verwalten', 'platform', 'platform_admin', NULL),
|
||||
('platform.catalogs.manage', 'Kataloge verwalten', 'platform', 'platform_admin', NULL),
|
||||
('platform.maturity_models.manage', 'Reifegradmodelle', 'platform', 'platform_admin', NULL),
|
||||
('platform.wiki_import.execute', 'Wiki-Import', 'platform', 'platform_admin', 'wiki_import'),
|
||||
('platform.ai_prompts.manage', 'KI-Prompts verwalten', 'platform', 'platform_admin', NULL),
|
||||
('platform.exercise_enrichment.execute', 'Übungs-Anreicherung KI', 'platform', 'platform_admin', 'ai_calls'),
|
||||
('platform.user_content.moderate', 'Nutzer-Inhalte moderieren', 'platform', 'platform_admin', NULL),
|
||||
('platform.legal_documents.manage', 'Rechtstexte verwalten', 'platform', 'platform_admin', NULL),
|
||||
('platform.media_storage.manage', 'Medienspeicher verwalten', 'platform', 'platform_admin', NULL),
|
||||
('platform.club_creation.approve', 'Vereinsgründung freigeben', 'platform', 'platform_admin', NULL)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ── Vereinsrollen-Grants (§6 — nur eingeschränkte Capabilities) ─────────────
|
||||
-- Konvention: keine Grant-Zeile = alle aktiven Mitglieder (min_account_state reicht).
|
||||
|
||||
INSERT INTO club_role_capability_grants (role_code, capability_id)
|
||||
SELECT r.role_code, c.cap_id
|
||||
FROM (VALUES
|
||||
('club_admin', 'org.structure.manage'),
|
||||
('division_lead', 'org.structure.manage'),
|
||||
('club_admin', 'org.members.manage'),
|
||||
('club_admin', 'org.join_request.review'),
|
||||
('club_admin', 'org.inbox.read'),
|
||||
('club_admin', 'exercises.create'),
|
||||
('trainer', 'exercises.create'),
|
||||
('content_editor', 'exercises.create'),
|
||||
('division_lead', 'exercises.create'),
|
||||
('club_admin', 'exercises.update'),
|
||||
('trainer', 'exercises.update'),
|
||||
('content_editor', 'exercises.update'),
|
||||
('division_lead', 'exercises.update'),
|
||||
('club_admin', 'exercises.delete'),
|
||||
('club_admin', 'exercises.bulk_metadata'),
|
||||
('content_editor', 'exercises.bulk_metadata'),
|
||||
('club_admin', 'exercises.ai.suggest'),
|
||||
('trainer', 'exercises.ai.suggest'),
|
||||
('content_editor', 'exercises.ai.suggest'),
|
||||
('division_lead', 'exercises.ai.suggest'),
|
||||
('club_admin', 'exercises.ai.regenerate'),
|
||||
('trainer', 'exercises.ai.regenerate'),
|
||||
('content_editor', 'exercises.ai.regenerate'),
|
||||
('division_lead', 'exercises.ai.regenerate'),
|
||||
('club_admin', 'exercises.media.upload'),
|
||||
('trainer', 'exercises.media.upload'),
|
||||
('content_editor', 'exercises.media.upload'),
|
||||
('club_admin', 'exercises.variants.manage'),
|
||||
('trainer', 'exercises.variants.manage'),
|
||||
('content_editor', 'exercises.variants.manage'),
|
||||
('club_admin', 'media.library.upload'),
|
||||
('trainer', 'media.library.upload'),
|
||||
('content_editor', 'media.library.upload'),
|
||||
('club_admin', 'media.library.update'),
|
||||
('trainer', 'media.library.update'),
|
||||
('content_editor', 'media.library.update'),
|
||||
('club_admin', 'media.library.lifecycle'),
|
||||
('trainer', 'media.library.lifecycle'),
|
||||
('club_admin', 'media.rights.declare'),
|
||||
('trainer', 'media.rights.declare'),
|
||||
('club_admin', 'modules.create'),
|
||||
('trainer', 'modules.create'),
|
||||
('content_editor', 'modules.create'),
|
||||
('club_admin', 'modules.update'),
|
||||
('trainer', 'modules.update'),
|
||||
('content_editor', 'modules.update'),
|
||||
('club_admin', 'modules.delete'),
|
||||
('club_admin', 'framework.create'),
|
||||
('trainer', 'framework.create'),
|
||||
('club_admin', 'framework.update'),
|
||||
('trainer', 'framework.update'),
|
||||
('club_admin', 'framework.delete'),
|
||||
('club_admin', 'plan_templates.manage'),
|
||||
('trainer', 'plan_templates.manage'),
|
||||
('club_admin', 'progression.manage'),
|
||||
('trainer', 'progression.manage'),
|
||||
('content_editor', 'progression.manage'),
|
||||
('club_admin', 'planning.units.create'),
|
||||
('trainer', 'planning.units.create'),
|
||||
('division_lead', 'planning.units.create'),
|
||||
('club_admin', 'planning.units.update'),
|
||||
('trainer', 'planning.units.update'),
|
||||
('division_lead', 'planning.units.update'),
|
||||
('club_admin', 'planning.units.delete'),
|
||||
('trainer', 'planning.units.delete'),
|
||||
('club_admin', 'planning.units.run'),
|
||||
('trainer', 'planning.units.run'),
|
||||
('division_lead', 'planning.units.run'),
|
||||
('club_admin', 'planning.coach.execute'),
|
||||
('trainer', 'planning.coach.execute'),
|
||||
('club_admin', 'planning.ai.suggest'),
|
||||
('trainer', 'planning.ai.suggest'),
|
||||
('division_lead', 'planning.ai.suggest'),
|
||||
('club_admin', 'planning.ai.progression_path'),
|
||||
('trainer', 'planning.ai.progression_path'),
|
||||
('division_lead', 'planning.ai.progression_path'),
|
||||
('club_admin', 'skills.discovery.read'),
|
||||
('trainer', 'skills.discovery.read'),
|
||||
('content_editor', 'skills.discovery.read'),
|
||||
('club_admin', 'governance.content_report.review')
|
||||
) AS r(role_code, cap_id)
|
||||
JOIN capabilities c ON c.id = r.cap_id
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- org.club.update: club_admin (zusätzlich zu platform_admin via Bypass)
|
||||
INSERT INTO club_role_capability_grants (role_code, capability_id)
|
||||
VALUES ('club_admin', 'org.club.update')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ── Portal-Rollen ───────────────────────────────────────────────────────────
|
||||
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||
SELECT 'admin', id FROM capabilities WHERE id = 'platform.admin.access'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO portal_role_capability_grants (portal_role, capability_id)
|
||||
SELECT 'superadmin', id FROM capabilities WHERE domain = 'platform'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
|
@ -37,6 +37,8 @@ from media_rights import assert_rights_for_exercise_link, validate_rights_declar
|
|||
from media_legal_hold import assert_not_under_legal_hold
|
||||
from ai_prompt_context import ExerciseFormAiFocusRow, ExerciseFormAiPromptContext
|
||||
from ai_prompt_job import run_exercise_form_ai_suggestion
|
||||
from account_lifecycle import assert_min_account_state
|
||||
from capabilities import probe_capability
|
||||
from club_features import probe_club_feature_access, resolve_club_id_for_probe
|
||||
|
||||
from exercise_rich_text import (
|
||||
|
|
@ -2318,6 +2320,14 @@ def exercise_ai_suggest_endpoint(
|
|||
KI-Vorschlaege (Kurzfassung und/oder Skill-Zuordnung) ohne Speichern.
|
||||
OPENROUTER_API_KEY erforderlich.
|
||||
"""
|
||||
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/ai/suggest")
|
||||
probe_capability(
|
||||
tenant,
|
||||
"exercises.ai.suggest",
|
||||
action="suggest",
|
||||
club_id=resolve_club_id_for_probe(tenant),
|
||||
endpoint="POST /exercises/ai/suggest",
|
||||
)
|
||||
probe_club_feature_access(
|
||||
feature_id="ai_calls",
|
||||
action="suggest",
|
||||
|
|
@ -2344,6 +2354,14 @@ def exercise_ai_regenerate_endpoint(
|
|||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""Neu-Anfrage KI fuer eine gespeicherte Uebung; schreibendes Ergebnis nur im Frontend (PUT)."""
|
||||
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/{id}/ai/regenerate")
|
||||
probe_capability(
|
||||
tenant,
|
||||
"exercises.ai.regenerate",
|
||||
action="regenerate",
|
||||
club_id=resolve_club_id_for_probe(tenant),
|
||||
endpoint="POST /exercises/{id}/ai/regenerate",
|
||||
)
|
||||
probe_club_feature_access(
|
||||
feature_id="ai_calls",
|
||||
action="regenerate",
|
||||
|
|
@ -2436,6 +2454,14 @@ def create_exercise(
|
|||
club_id = tenant.effective_club_id
|
||||
|
||||
if club_id is not None:
|
||||
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises")
|
||||
probe_capability(
|
||||
tenant,
|
||||
"exercises.create",
|
||||
action="create",
|
||||
club_id=int(club_id),
|
||||
endpoint="POST /exercises",
|
||||
)
|
||||
probe_club_feature_access(
|
||||
feature_id="exercises",
|
||||
action="create",
|
||||
|
|
@ -3245,6 +3271,15 @@ async def upload_exercise_media(
|
|||
ex_club = cur.fetchone()
|
||||
media_club_id = ex_club.get("club_id") if ex_club else None
|
||||
if media_club_id is not None:
|
||||
assert_min_account_state(tenant, "active_member", endpoint="POST /exercises/{id}/media")
|
||||
probe_capability(
|
||||
tenant,
|
||||
"exercises.media.upload",
|
||||
action="upload",
|
||||
club_id=int(media_club_id),
|
||||
endpoint="POST /exercises/{id}/media",
|
||||
conn=conn,
|
||||
)
|
||||
probe_club_feature_access(
|
||||
feature_id="exercise_media",
|
||||
action="upload",
|
||||
|
|
|
|||
27
backend/routers/me_entitlements.py
Normal file
27
backend/routers/me_entitlements.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""
|
||||
GET /api/me/entitlements — effektive Capabilities + Feature-Kontingente (M4).
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from db import get_db, get_cursor
|
||||
from entitlements import build_me_entitlements
|
||||
from tenant_context import TenantContext, get_tenant_context
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["entitlements"])
|
||||
|
||||
|
||||
@router.get("/me/entitlements")
|
||||
def get_me_entitlements(
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
club_id: Optional[int] = Query(default=None, ge=1, description="Verein (Default: effective_club_id)"),
|
||||
):
|
||||
"""
|
||||
Effektive Rechte für Frontend: Account-Status, Capabilities, Feature-Limits.
|
||||
|
||||
Spez: CAPABILITY_CATALOG.v1.md §7.1
|
||||
"""
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
return build_me_entitlements(cur, tenant, club_id=club_id)
|
||||
|
|
@ -7,6 +7,8 @@ from db import get_db, get_cursor
|
|||
from tenant_context import TenantContext, get_tenant_context
|
||||
from planning_exercise_suggest import PlanningExerciseSuggestRequest, suggest_planning_exercises
|
||||
from planning_exercise_path_builder import ProgressionPathSuggestRequest, suggest_progression_path
|
||||
from account_lifecycle import assert_min_account_state
|
||||
from capabilities import probe_capability
|
||||
from club_features import probe_club_feature_access, resolve_club_id_for_probe
|
||||
|
||||
router = APIRouter(prefix="/api/planning", tags=["planning_exercise_suggest"])
|
||||
|
|
@ -18,6 +20,14 @@ def post_planning_exercise_suggest(
|
|||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
if body.include_llm_intent or body.include_llm_rank:
|
||||
assert_min_account_state(tenant, "active_member", endpoint="POST /planning/exercise-suggest")
|
||||
probe_capability(
|
||||
tenant,
|
||||
"planning.ai.suggest",
|
||||
action="planning_suggest",
|
||||
club_id=resolve_club_id_for_probe(tenant),
|
||||
endpoint="POST /planning/exercise-suggest",
|
||||
)
|
||||
probe_club_feature_access(
|
||||
feature_id="ai_calls",
|
||||
action="planning_suggest",
|
||||
|
|
@ -40,6 +50,16 @@ def post_progression_path_suggest(
|
|||
or body.include_llm_path_qa
|
||||
or body.include_ai_gap_fill
|
||||
):
|
||||
assert_min_account_state(
|
||||
tenant, "active_member", endpoint="POST /planning/progression-path-suggest"
|
||||
)
|
||||
probe_capability(
|
||||
tenant,
|
||||
"planning.ai.progression_path",
|
||||
action="progression_path_suggest",
|
||||
club_id=resolve_club_id_for_probe(tenant),
|
||||
endpoint="POST /planning/progression-path-suggest",
|
||||
)
|
||||
probe_club_feature_access(
|
||||
feature_id="ai_calls",
|
||||
action="progression_path_suggest",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from club_tenancy import (
|
|||
is_superadmin,
|
||||
memberships_with_roles,
|
||||
)
|
||||
from capabilities import club_roles_in_club
|
||||
from tenant_context import resolve_tenant_context, TenantContext, get_tenant_context
|
||||
from models import ProfileCreate, ProfileUpdate
|
||||
|
||||
|
|
@ -118,6 +119,11 @@ def get_current_profile(
|
|||
invalid_header_policy="ignore",
|
||||
)
|
||||
data["effective_club_id"] = tenant.effective_club_id
|
||||
data["account_state"] = tenant.account_state
|
||||
if tenant.effective_club_id is not None:
|
||||
data["club_roles"] = club_roles_in_club(tenant, tenant.effective_club_id)
|
||||
else:
|
||||
data["club_roles"] = []
|
||||
return data
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional
|
|||
from fastapi import Depends, Header, HTTPException
|
||||
|
||||
from auth import require_auth, require_auth_flexible
|
||||
from account_lifecycle import resolve_account_state
|
||||
from club_tenancy import is_platform_admin, memberships_with_roles
|
||||
from db import get_db, get_cursor
|
||||
|
||||
|
|
@ -142,6 +143,8 @@ class TenantContext:
|
|||
effective_club_id: Optional[int]
|
||||
club_ids: frozenset[int]
|
||||
memberships: List[Dict[str, Any]]
|
||||
email_verified: bool = True
|
||||
account_state: str = "active_member"
|
||||
|
||||
|
||||
def resolve_tenant_context(
|
||||
|
|
@ -153,6 +156,7 @@ def resolve_tenant_context(
|
|||
memberships: Optional[List[Dict[str, Any]]] = None,
|
||||
stored_active_club_id: Optional[int] = None,
|
||||
invalid_header_policy: str = "reject",
|
||||
email_verified: Optional[bool] = None,
|
||||
) -> TenantContext:
|
||||
"""
|
||||
Mitgliedschaften: wenn nicht übergeben, lädt ``active_only=True`` aus der DB.
|
||||
|
|
@ -176,6 +180,21 @@ def resolve_tenant_context(
|
|||
|
||||
club_ids = frozenset(int(r["id"]) for r in membership_rows if r.get("id") is not None)
|
||||
|
||||
if email_verified is None:
|
||||
cur.execute(
|
||||
"SELECT COALESCE(email_verified, false) AS email_verified FROM profiles WHERE id = %s",
|
||||
(profile_id,),
|
||||
)
|
||||
prof_row = cur.fetchone()
|
||||
email_verified = bool(prof_row.get("email_verified")) if prof_row else False
|
||||
else:
|
||||
email_verified = bool(email_verified)
|
||||
account_state = resolve_account_state(
|
||||
email_verified=email_verified,
|
||||
global_role=role_lc,
|
||||
has_active_membership=len(club_ids) > 0,
|
||||
)
|
||||
|
||||
if is_platform_admin(role_lc):
|
||||
if header_cid is not None:
|
||||
if not _club_exists(cur, header_cid):
|
||||
|
|
@ -194,6 +213,8 @@ def resolve_tenant_context(
|
|||
effective_club_id=effective,
|
||||
club_ids=club_ids,
|
||||
memberships=membership_rows,
|
||||
email_verified=email_verified,
|
||||
account_state=account_state,
|
||||
)
|
||||
|
||||
chosen_header = header_cid
|
||||
|
|
@ -222,6 +243,8 @@ def resolve_tenant_context(
|
|||
effective_club_id=effective,
|
||||
club_ids=club_ids,
|
||||
memberships=membership_rows,
|
||||
email_verified=email_verified,
|
||||
account_state=account_state,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ def test_resolve_platform_admin_uses_stored_club_without_header(monkeypatch):
|
|||
header_raw=None,
|
||||
memberships=[{"id": 10}],
|
||||
stored_active_club_id=99,
|
||||
email_verified=True,
|
||||
)
|
||||
assert ctx.effective_club_id == 99
|
||||
|
||||
|
|
@ -110,6 +111,7 @@ def test_resolve_platform_admin_header_overrides_stored(monkeypatch):
|
|||
header_raw="5",
|
||||
memberships=[{"id": 10}],
|
||||
stored_active_club_id=99,
|
||||
email_verified=True,
|
||||
)
|
||||
assert ctx.effective_club_id == 5
|
||||
|
||||
|
|
@ -124,6 +126,7 @@ def test_resolve_platform_admin_no_header_stored_invalid(monkeypatch):
|
|||
header_raw=None,
|
||||
memberships=[{"id": 1}],
|
||||
stored_active_club_id=123,
|
||||
email_verified=True,
|
||||
)
|
||||
assert ctx.effective_club_id is None
|
||||
|
||||
|
|
@ -142,6 +145,7 @@ def test_resolve_trainer_club_ids_excludes_inactive_memberships():
|
|||
],
|
||||
stored_active_club_id=None,
|
||||
invalid_header_policy="ignore",
|
||||
email_verified=True,
|
||||
)
|
||||
assert ctx.club_ids == frozenset({20})
|
||||
assert ctx.effective_club_id == 20
|
||||
|
|
@ -157,6 +161,7 @@ def test_resolve_all_memberships_inactive_no_effective_club():
|
|||
memberships=[{"id": 10, "membership_status": "inactive"}],
|
||||
stored_active_club_id=10,
|
||||
invalid_header_policy="ignore",
|
||||
email_verified=True,
|
||||
)
|
||||
assert ctx.club_ids == frozenset()
|
||||
assert ctx.effective_club_id is None
|
||||
|
|
|
|||
86
backend/tests/test_account_capabilities.py
Normal file
86
backend/tests/test_account_capabilities.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""Unit-Tests für Account-Lifecycle und Capability-Helfer (ohne DB)."""
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from account_lifecycle import (
|
||||
account_state_satisfies,
|
||||
assert_min_account_state,
|
||||
resolve_account_state,
|
||||
)
|
||||
from capabilities import club_roles_in_club
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
def test_resolve_account_state_platform_admin():
|
||||
assert (
|
||||
resolve_account_state(email_verified=False, global_role="superadmin", has_active_membership=False)
|
||||
== "platform_admin"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_account_state_unverified():
|
||||
assert (
|
||||
resolve_account_state(email_verified=False, global_role="trainer", has_active_membership=True)
|
||||
== "unverified"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_account_state_pending_club():
|
||||
assert (
|
||||
resolve_account_state(email_verified=True, global_role="user", has_active_membership=False)
|
||||
== "verified_pending_club"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_account_state_active_member():
|
||||
assert (
|
||||
resolve_account_state(email_verified=True, global_role="trainer", has_active_membership=True)
|
||||
== "active_member"
|
||||
)
|
||||
|
||||
|
||||
def test_account_state_satisfies():
|
||||
assert account_state_satisfies("active_member", "active_member")
|
||||
assert account_state_satisfies("active_member", "verified_pending_club")
|
||||
assert not account_state_satisfies("verified_pending_club", "active_member")
|
||||
assert account_state_satisfies("platform_admin", "active_member")
|
||||
|
||||
|
||||
def test_assert_min_account_state_blocks(monkeypatch):
|
||||
monkeypatch.setenv("ACCOUNT_GATE_ENFORCE", "1")
|
||||
tenant = TenantContext(
|
||||
profile_id=1,
|
||||
global_role="user",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
account_state="verified_pending_club",
|
||||
)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
assert_min_account_state(tenant, "active_member")
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_assert_min_account_state_off(monkeypatch):
|
||||
monkeypatch.setenv("ACCOUNT_GATE_ENFORCE", "0")
|
||||
tenant = TenantContext(
|
||||
profile_id=1,
|
||||
global_role="user",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
account_state="verified_pending_club",
|
||||
)
|
||||
assert_min_account_state(tenant, "active_member")
|
||||
|
||||
|
||||
def test_club_roles_in_club():
|
||||
tenant = TenantContext(
|
||||
profile_id=1,
|
||||
global_role="trainer",
|
||||
effective_club_id=5,
|
||||
club_ids=frozenset({5}),
|
||||
memberships=[{"id": 5, "roles": ["trainer", "club_admin"]}],
|
||||
)
|
||||
assert club_roles_in_club(tenant, 5) == ["trainer", "club_admin"]
|
||||
assert club_roles_in_club(tenant, 99) == []
|
||||
75
backend/tests/test_entitlements.py
Normal file
75
backend/tests/test_entitlements.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Tests für GET /me/entitlements Zusammenstellung."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from entitlements import _serialize_reset_at, build_me_entitlements
|
||||
from tenant_context import TenantContext
|
||||
|
||||
|
||||
def test_serialize_reset_at():
|
||||
dt = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||
assert _serialize_reset_at(dt) == "2026-07-01T00:00:00+00:00"
|
||||
assert _serialize_reset_at(None) is None
|
||||
|
||||
|
||||
def test_build_me_entitlements_no_club(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"entitlements.resolve_capabilities_map",
|
||||
lambda cur, tenant, club_id=None: {"exercises.read": False},
|
||||
)
|
||||
|
||||
tenant = TenantContext(
|
||||
profile_id=1,
|
||||
global_role="user",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
account_state="verified_pending_club",
|
||||
)
|
||||
out = build_me_entitlements(object(), tenant)
|
||||
assert out["account_state"] == "verified_pending_club"
|
||||
assert out["club_id"] is None
|
||||
assert out["features"] == {}
|
||||
assert out["capabilities"]["exercises.read"] is False
|
||||
|
||||
|
||||
def test_build_me_entitlements_with_club(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"entitlements.resolve_capabilities_map",
|
||||
lambda cur, tenant, club_id=None: {
|
||||
"exercises.read": True,
|
||||
"exercises.ai.suggest": True,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"entitlements.club_features_map",
|
||||
lambda cur, club_id, conn=None: {
|
||||
"plan_id": "free",
|
||||
"club_id": club_id,
|
||||
"features": {
|
||||
"ai_calls": {
|
||||
"allowed": False,
|
||||
"used": 0,
|
||||
"limit": 0,
|
||||
"remaining": 0,
|
||||
"reason": "feature_disabled",
|
||||
"reset_at": None,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr("entitlements._club_exists", lambda cur, cid: True)
|
||||
|
||||
tenant = TenantContext(
|
||||
profile_id=3,
|
||||
global_role="trainer",
|
||||
effective_club_id=1,
|
||||
club_ids=frozenset({1}),
|
||||
memberships=[{"id": 1, "roles": ["trainer"]}],
|
||||
account_state="active_member",
|
||||
)
|
||||
out = build_me_entitlements(object(), tenant, club_id=1)
|
||||
assert out["club_id"] == 1
|
||||
assert out["plan_id"] == "free"
|
||||
assert out["club_roles"] == ["trainer"]
|
||||
assert out["features"]["ai_calls"]["limit"] == 0
|
||||
assert out["capabilities"]["exercises.ai.suggest"] is True
|
||||
|
|
@ -2,18 +2,21 @@
|
|||
|
||||
APP_VERSION = "0.8.190"
|
||||
BUILD_DATE = "2026-05-23"
|
||||
DB_SCHEMA_VERSION = "20260606078"
|
||||
DB_SCHEMA_VERSION = "20260606079"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
|
||||
"profiles": "1.8.0", # training_planning_prefs JSONB (Planungs-UI); Patch via ProfileUpdate + Json(), Migration 055
|
||||
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
|
||||
"profiles": "1.8.1", # GET /profiles/me: account_state + club_roles
|
||||
"tenant_context": "1.1.0", # M3: account_state + email_verified im TenantContext
|
||||
"capabilities": "1.0.1", # resolve_capabilities_map für /me/entitlements
|
||||
"account_lifecycle": "1.0.0", # resolve_account_state + assert_min_account_state (ACCOUNT_GATE_ENFORCE)
|
||||
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
|
||||
"club_memberships": "1.0.1", # Depends(get_tenant_context)
|
||||
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
||||
"admin_users": "1.0.0", # GET /api/admin/users
|
||||
"club_features": "1.1.0", # M2: probe_club_feature_access + JSON-Log (Phase 2, non-blocking)
|
||||
"club_features": "1.2.0", # M4: club_features_map für /me/entitlements
|
||||
"entitlements": "1.0.0", # GET /api/me/entitlements — capabilities + features
|
||||
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
|
||||
"media_rights": "1.3.1", # acting_profile_id in write_audit_log_entry auf Optional[int] (P-13 anonyme Meldungen)
|
||||
"media_assets": "1.18.1", # P-13: open_report_count in Listendaten (fuer Admins)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
Outlet,
|
||||
} from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { EntitlementsProvider } from './context/EntitlementsContext'
|
||||
import { FormEditorActionsProvider, FormEditorBottomSlot } from './context/FormEditorActionsContext'
|
||||
import { ToastProvider } from './context/ToastContext'
|
||||
import { OrgInboxProvider, useOrgInbox } from './context/OrgInboxContext'
|
||||
|
|
@ -345,11 +346,13 @@ const appRouter = createBrowserRouter([
|
|||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<Suspense fallback={<AppRouteFallback />}>
|
||||
<RouterProvider router={appRouter} />
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
<EntitlementsProvider>
|
||||
<ToastProvider>
|
||||
<Suspense fallback={<AppRouteFallback />}>
|
||||
<RouterProvider router={appRouter} />
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
</EntitlementsProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
40
frontend/src/components/FeatureUsageBadge.jsx
Normal file
40
frontend/src/components/FeatureUsageBadge.jsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { useEntitlements } from '../context/EntitlementsContext'
|
||||
|
||||
/**
|
||||
* Zeigt Vereins-Kontingent für ein Feature (M4 UsageBadge).
|
||||
* Unbegrenzt (limit null) → nichts rendern.
|
||||
*/
|
||||
export default function FeatureUsageBadge({ featureId = 'ai_calls', label = 'KI-Kontingent' }) {
|
||||
const { entitlements, loading, getFeature } = useEntitlements()
|
||||
const feat = getFeature(featureId)
|
||||
|
||||
if (loading && !feat) {
|
||||
return (
|
||||
<span className="feature-usage-badge muted" style={{ fontSize: '0.8rem' }}>
|
||||
{label}: …
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (!feat) return null
|
||||
|
||||
const { used = 0, limit, remaining, allowed } = feat
|
||||
if (limit == null) return null
|
||||
|
||||
const tone = !allowed || remaining === 0 ? 'var(--danger)' : 'var(--text2)'
|
||||
|
||||
return (
|
||||
<span
|
||||
className="feature-usage-badge"
|
||||
style={{ fontSize: '0.8rem', color: tone }}
|
||||
title={
|
||||
entitlements?.plan_id
|
||||
? `Plan: ${entitlements.plan_id}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{label}: {used}/{limit}
|
||||
{remaining != null ? ` (${remaining} übrig)` : ''}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import { stripHtmlToText } from '../../utils/htmlUtils'
|
|||
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
|
||||
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import FeatureUsageBadge from '../FeatureUsageBadge'
|
||||
import { useToast } from '../../context/ToastContext'
|
||||
import {
|
||||
activeClubMemberships,
|
||||
|
|
@ -1653,15 +1654,18 @@ function ExerciseFormPageRoot() {
|
|||
<label className="form-label" style={{ marginBottom: 0 }}>
|
||||
Kurzbeschreibung
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px' }}
|
||||
disabled={aiSuggestBusy}
|
||||
onClick={() => runExerciseAiSuggestion('summary')}
|
||||
>
|
||||
KI: Kurzfassung
|
||||
</button>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<FeatureUsageBadge featureId="ai_calls" />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px' }}
|
||||
disabled={aiSuggestBusy}
|
||||
onClick={() => runExerciseAiSuggestion('summary')}
|
||||
>
|
||||
KI: Kurzfassung
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<RichTextEditor
|
||||
value={formData.summary}
|
||||
|
|
|
|||
64
frontend/src/context/EntitlementsContext.jsx
Normal file
64
frontend/src/context/EntitlementsContext.jsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { getMeEntitlements } from '../utils/api'
|
||||
import { useAuth } from './AuthContext'
|
||||
|
||||
const EntitlementsContext = createContext(null)
|
||||
|
||||
export function EntitlementsProvider({ children }) {
|
||||
const { user, isAuthenticated, loading: authLoading } = useAuth()
|
||||
const [entitlements, setEntitlements] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const clubId = user?.effective_club_id ?? null
|
||||
|
||||
const refreshEntitlements = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
setEntitlements(null)
|
||||
setError(null)
|
||||
return null
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await getMeEntitlements(clubId)
|
||||
setEntitlements(data)
|
||||
return data
|
||||
} catch (e) {
|
||||
setEntitlements(null)
|
||||
setError(e?.message || String(e))
|
||||
return null
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [isAuthenticated, clubId])
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return
|
||||
refreshEntitlements()
|
||||
}, [authLoading, refreshEntitlements])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
entitlements,
|
||||
loading,
|
||||
error,
|
||||
refreshEntitlements,
|
||||
hasCapability: (capId) => Boolean(entitlements?.capabilities?.[capId]),
|
||||
getFeature: (featureId) => entitlements?.features?.[featureId] ?? null,
|
||||
}),
|
||||
[entitlements, loading, error, refreshEntitlements],
|
||||
)
|
||||
|
||||
return (
|
||||
<EntitlementsContext.Provider value={value}>{children}</EntitlementsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useEntitlements() {
|
||||
const ctx = useContext(EntitlementsContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useEntitlements must be used within EntitlementsProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
|
@ -40,6 +40,15 @@ export async function getCurrentProfile() {
|
|||
return request('/api/profiles/me')
|
||||
}
|
||||
|
||||
/** Effektive Capabilities + Feature-Kontingente (M4). */
|
||||
export async function getMeEntitlements(clubId = null) {
|
||||
const q =
|
||||
clubId != null && clubId !== ''
|
||||
? `?club_id=${encodeURIComponent(String(clubId))}`
|
||||
: ''
|
||||
return request(`/api/me/entitlements${q}`)
|
||||
}
|
||||
|
||||
/** Liste aller Profile – nur für Plattform-Admins (Vereinsanlage). */
|
||||
export async function listProfiles() {
|
||||
return request('/api/profiles')
|
||||
|
|
@ -850,6 +859,7 @@ export const api = {
|
|||
register,
|
||||
logout,
|
||||
getCurrentProfile,
|
||||
getMeEntitlements,
|
||||
listProfiles,
|
||||
listAdminUsers,
|
||||
getAdminUserContentMeta,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user