[split-required] Split final batch of monoliths >1000 LOC

Python (6 files in klausur-service):
- rbac.py (1,132 → 4), admin_api.py (1,012 → 4)
- routes/eh.py (1,111 → 4), ocr_pipeline_geometry.py (1,105 → 5)

Python (2 files in backend-lehrer):
- unit_api.py (1,226 → 6), game_api.py (1,129 → 5)

Website (6 page files):
- 4x klausur-korrektur pages (1,249-1,328 LOC each) → shared components
  in website/components/klausur-korrektur/ (17 shared files)
- companion (1,057 → 10), magic-help (1,017 → 8)

All re-export barrels preserve backward compatibility.
Zero import errors verified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 23:17:30 +02:00
parent b2a0126f14
commit 6811264756
67 changed files with 12270 additions and 13651 deletions

View File

@@ -0,0 +1,498 @@
"""
RBAC Policy Engine
Core engine for RBAC/ABAC permission checks,
role assignments, key shares, and default policies.
Extracted from rbac.py for file-size compliance.
"""
from typing import Optional, List, Dict, Set
from datetime import datetime, timezone
import uuid
from functools import wraps
from fastapi import HTTPException, Request
from rbac_types import (
Role,
Action,
ResourceType,
ZKVisibilityMode,
PolicySet,
RoleAssignment,
KeyShare,
)
from rbac_permissions import DEFAULT_PERMISSIONS
# =============================================
# POLICY ENGINE
# =============================================
class PolicyEngine:
"""
Engine fuer RBAC/ABAC Entscheidungen.
Prueft:
1. Basis-Rollenberechtigung (RBAC)
2. Policy-Einschraenkungen (ABAC)
3. Key Share Berechtigungen
"""
def __init__(self):
self.policy_sets: Dict[str, PolicySet] = {}
self.role_assignments: Dict[str, List[RoleAssignment]] = {} # user_id -> assignments
self.key_shares: Dict[str, List[KeyShare]] = {} # user_id -> shares
def register_policy_set(self, policy: PolicySet):
"""Registriere ein Policy Set."""
self.policy_sets[policy.id] = policy
def get_policy_for_context(
self,
bundesland: str,
jahr: int,
fach: Optional[str] = None,
verfahren: str = "abitur"
) -> Optional[PolicySet]:
"""Finde das passende Policy Set fuer einen Kontext."""
# Exakte Uebereinstimmung
for policy in self.policy_sets.values():
if (policy.bundesland == bundesland and
policy.jahr == jahr and
policy.verfahren == verfahren):
if policy.fach is None or policy.fach == fach:
return policy
# Fallback: Default Policy
for policy in self.policy_sets.values():
if policy.bundesland == "DEFAULT":
return policy
return None
def assign_role(
self,
user_id: str,
role: Role,
resource_type: ResourceType,
resource_id: str,
granted_by: str,
tenant_id: Optional[str] = None,
namespace_id: Optional[str] = None,
valid_to: Optional[datetime] = None
) -> RoleAssignment:
"""Weise einem User eine Rolle zu."""
assignment = RoleAssignment(
id=str(uuid.uuid4()),
user_id=user_id,
role=role,
resource_type=resource_type,
resource_id=resource_id,
tenant_id=tenant_id,
namespace_id=namespace_id,
granted_by=granted_by,
valid_to=valid_to
)
if user_id not in self.role_assignments:
self.role_assignments[user_id] = []
self.role_assignments[user_id].append(assignment)
return assignment
def revoke_role(self, assignment_id: str, revoked_by: str) -> bool:
"""Widerrufe eine Rollenzuweisung."""
for user_assignments in self.role_assignments.values():
for assignment in user_assignments:
if assignment.id == assignment_id:
assignment.revoked_at = datetime.now(timezone.utc)
return True
return False
def get_user_roles(
self,
user_id: str,
resource_type: Optional[ResourceType] = None,
resource_id: Optional[str] = None
) -> List[Role]:
"""Hole alle aktiven Rollen eines Users."""
assignments = self.role_assignments.get(user_id, [])
roles = []
for assignment in assignments:
if not assignment.is_active():
continue
if resource_type and assignment.resource_type != resource_type:
continue
if resource_id and assignment.resource_id != resource_id:
continue
roles.append(assignment.role)
return list(set(roles))
def create_key_share(
self,
user_id: str,
package_id: str,
permissions: Set[str],
granted_by: str,
scope: str = "full",
invite_token: Optional[str] = None
) -> KeyShare:
"""Erstelle einen Key Share."""
share = KeyShare(
id=str(uuid.uuid4()),
user_id=user_id,
package_id=package_id,
permissions=permissions,
scope=scope,
granted_by=granted_by,
invite_token=invite_token
)
if user_id not in self.key_shares:
self.key_shares[user_id] = []
self.key_shares[user_id].append(share)
return share
def accept_key_share(self, share_id: str, token: str) -> bool:
"""Akzeptiere einen Key Share via Invite Token."""
for user_shares in self.key_shares.values():
for share in user_shares:
if share.id == share_id and share.invite_token == token:
share.accepted_at = datetime.now(timezone.utc)
return True
return False
def revoke_key_share(self, share_id: str, revoked_by: str) -> bool:
"""Widerrufe einen Key Share."""
for user_shares in self.key_shares.values():
for share in user_shares:
if share.id == share_id:
share.revoked_at = datetime.now(timezone.utc)
share.revoked_by = revoked_by
return True
return False
def check_permission(
self,
user_id: str,
action: Action,
resource_type: ResourceType,
resource_id: str,
policy: Optional[PolicySet] = None,
package_id: Optional[str] = None
) -> bool:
"""
Pruefe ob ein User eine Aktion ausfuehren darf.
Prueft:
1. Basis-RBAC
2. Policy-Einschraenkungen
3. Key Share (falls package_id angegeben)
"""
# 1. Hole aktive Rollen
roles = self.get_user_roles(user_id, resource_type, resource_id)
if not roles:
return False
# 2. Pruefe Basis-RBAC
has_permission = False
for role in roles:
role_permissions = DEFAULT_PERMISSIONS.get(role, {})
resource_permissions = role_permissions.get(resource_type, set())
if action in resource_permissions:
has_permission = True
break
if not has_permission:
return False
# 3. Pruefe Policy-Einschraenkungen
if policy:
# ZK Visibility Mode
if Role.ZWEITKORREKTOR in roles:
if policy.zk_visibility_mode == ZKVisibilityMode.BLIND:
# Blind: ZK darf EK-Outputs nicht sehen
if resource_type in [ResourceType.EVALUATION, ResourceType.REPORT, ResourceType.GRADE_DECISION]:
if action == Action.READ:
# Pruefe ob es EK-Outputs sind (muesste ueber Metadaten geprueft werden)
pass # Implementierung abhaengig von Datenmodell
elif policy.zk_visibility_mode == ZKVisibilityMode.SEMI:
# Semi: ZK sieht Annotationen, aber keine Note
if resource_type == ResourceType.GRADE_DECISION and action == Action.READ:
return False
# 4. Pruefe Key Share (falls Package-basiert)
if package_id:
user_shares = self.key_shares.get(user_id, [])
has_key_share = any(
share.package_id == package_id and share.is_active()
for share in user_shares
)
if not has_key_share:
return False
return True
def get_allowed_actions(
self,
user_id: str,
resource_type: ResourceType,
resource_id: str,
policy: Optional[PolicySet] = None
) -> Set[Action]:
"""Hole alle erlaubten Aktionen fuer einen User auf einer Ressource."""
roles = self.get_user_roles(user_id, resource_type, resource_id)
allowed = set()
for role in roles:
role_permissions = DEFAULT_PERMISSIONS.get(role, {})
resource_permissions = role_permissions.get(resource_type, set())
allowed.update(resource_permissions)
# Policy-Einschraenkungen anwenden
if policy and Role.ZWEITKORREKTOR in roles:
if policy.zk_visibility_mode == ZKVisibilityMode.BLIND:
# Entferne READ fuer bestimmte Ressourcen
pass # Detailimplementierung
return allowed
# =============================================
# DEFAULT POLICY SETS (alle Bundeslaender)
# =============================================
def create_default_policy_sets() -> List[PolicySet]:
"""
Erstelle Default Policy Sets fuer alle Bundeslaender.
Diese koennen spaeter pro Land verfeinert werden.
"""
bundeslaender = [
"baden-wuerttemberg", "bayern", "berlin", "brandenburg",
"bremen", "hamburg", "hessen", "mecklenburg-vorpommern",
"niedersachsen", "nordrhein-westfalen", "rheinland-pfalz",
"saarland", "sachsen", "sachsen-anhalt", "schleswig-holstein",
"thueringen"
]
policies = []
# Default Policy (Fallback)
policies.append(PolicySet(
id="DEFAULT-2025",
bundesland="DEFAULT",
jahr=2025,
fach=None,
verfahren="abitur",
zk_visibility_mode=ZKVisibilityMode.FULL,
eh_visibility_mode=PolicySet.__dataclass_fields__["eh_visibility_mode"].default,
allow_teacher_uploaded_eh=True,
allow_land_uploaded_eh=True,
require_rights_confirmation_on_upload=True,
third_correction_threshold=4,
final_signoff_role="fachvorsitz"
))
# Niedersachsen (Beispiel mit spezifischen Anpassungen)
policies.append(PolicySet(
id="NI-2025-ABITUR",
bundesland="niedersachsen",
jahr=2025,
fach=None,
verfahren="abitur",
zk_visibility_mode=ZKVisibilityMode.FULL, # In NI sieht ZK alles
allow_teacher_uploaded_eh=True,
allow_land_uploaded_eh=True,
require_rights_confirmation_on_upload=True,
third_correction_threshold=4,
final_signoff_role="fachvorsitz",
export_template_id="niedersachsen-abitur"
))
# Bayern (Beispiel mit SEMI visibility)
policies.append(PolicySet(
id="BY-2025-ABITUR",
bundesland="bayern",
jahr=2025,
fach=None,
verfahren="abitur",
zk_visibility_mode=ZKVisibilityMode.SEMI, # ZK sieht Annotationen, nicht Note
allow_teacher_uploaded_eh=True,
allow_land_uploaded_eh=True,
require_rights_confirmation_on_upload=True,
third_correction_threshold=4,
final_signoff_role="fachvorsitz",
export_template_id="bayern-abitur"
))
# NRW (Beispiel)
policies.append(PolicySet(
id="NW-2025-ABITUR",
bundesland="nordrhein-westfalen",
jahr=2025,
fach=None,
verfahren="abitur",
zk_visibility_mode=ZKVisibilityMode.FULL,
allow_teacher_uploaded_eh=True,
allow_land_uploaded_eh=True,
require_rights_confirmation_on_upload=True,
third_correction_threshold=4,
final_signoff_role="fachvorsitz",
export_template_id="nrw-abitur"
))
# Generiere Basis-Policies fuer alle anderen Bundeslaender
for bl in bundeslaender:
if bl not in ["niedersachsen", "bayern", "nordrhein-westfalen"]:
policies.append(PolicySet(
id=f"{bl[:2].upper()}-2025-ABITUR",
bundesland=bl,
jahr=2025,
fach=None,
verfahren="abitur",
zk_visibility_mode=ZKVisibilityMode.FULL,
allow_teacher_uploaded_eh=True,
allow_land_uploaded_eh=True,
require_rights_confirmation_on_upload=True,
third_correction_threshold=4,
final_signoff_role="fachvorsitz"
))
return policies
# =============================================
# GLOBAL POLICY ENGINE INSTANCE
# =============================================
# Singleton Policy Engine
_policy_engine: Optional[PolicyEngine] = None
def get_policy_engine() -> PolicyEngine:
"""Hole die globale Policy Engine Instanz."""
global _policy_engine
if _policy_engine is None:
_policy_engine = PolicyEngine()
# Registriere Default Policies
for policy in create_default_policy_sets():
_policy_engine.register_policy_set(policy)
return _policy_engine
# =============================================
# API GUARDS (Decorators fuer FastAPI)
# =============================================
def require_permission(
action: Action,
resource_type: ResourceType,
resource_id_param: str = "resource_id"
):
"""
Decorator fuer FastAPI Endpoints.
Prueft ob der aktuelle User die angegebene Berechtigung hat.
Usage:
@app.get("/api/v1/packages/{package_id}")
@require_permission(Action.READ, ResourceType.EXAM_PACKAGE, "package_id")
async def get_package(package_id: str, request: Request):
...
"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
request = kwargs.get('request')
if not request:
for arg in args:
if isinstance(arg, Request):
request = arg
break
if not request:
raise HTTPException(status_code=500, detail="Request not found")
# User aus Token holen
user = getattr(request.state, 'user', None)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = user.get('user_id')
resource_id = kwargs.get(resource_id_param)
# Policy Engine pruefen
engine = get_policy_engine()
# Optional: Policy aus Kontext laden
policy = None
bundesland = user.get('bundesland')
if bundesland:
policy = engine.get_policy_for_context(bundesland, 2025)
if not engine.check_permission(
user_id=user_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
policy=policy
):
raise HTTPException(
status_code=403,
detail=f"Permission denied: {action.value} on {resource_type.value}"
)
return await func(*args, **kwargs)
return wrapper
return decorator
def require_role(role: Role):
"""
Decorator der prueft ob User eine bestimmte Rolle hat.
Usage:
@app.post("/api/v1/eh/publish")
@require_role(Role.LAND_ADMIN)
async def publish_eh(request: Request):
...
"""
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
request = kwargs.get('request')
if not request:
for arg in args:
if isinstance(arg, Request):
request = arg
break
if not request:
raise HTTPException(status_code=500, detail="Request not found")
user = getattr(request.state, 'user', None)
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = user.get('user_id')
engine = get_policy_engine()
user_roles = engine.get_user_roles(user_id)
if role not in user_roles:
raise HTTPException(
status_code=403,
detail=f"Role required: {role.value}"
)
return await func(*args, **kwargs)
return wrapper
return decorator