""" 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