[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:
498
klausur-service/backend/rbac_engine.py
Normal file
498
klausur-service/backend/rbac_engine.py
Normal 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
|
||||
Reference in New Issue
Block a user