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>
499 lines
16 KiB
Python
499 lines
16 KiB
Python
"""
|
|
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
|