# Copyright (c) 2026 PowerOn AG # All rights reserved. """ RBAC models: AccessRule, AccessRuleContext, Role. Multi-Tenant Design: - Role hat einen unveränderlichen Kontext (mandateId, featureInstanceId, featureCode) - AccessRule referenziert Role via roleId (FK), nicht via roleLabel - Kontext-Felder sind IMMUTABLE nach Erstellung """ import uuid from typing import Optional, Dict, List, Protocol, runtime_checkable from enum import Enum from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel from modules.shared.i18nRegistry import i18nModel from modules.datamodels.datamodelUtils import TextMultilingual from modules.datamodels.datamodelUam import AccessLevel class AccessRuleContext(str, Enum): """Context type enumeration""" DATA = "DATA" # Database tables and fields UI = "UI" # UI elements and features RESOURCE = "RESOURCE" # System resources (AI models, actions, etc.) @i18nModel("Rolle") class Role(PowerOnModel): """ Data model for RBAC roles. Kernkonzept: Eine Rolle existiert immer in einem spezifischen Kontext. Der Kontext ist IMMUTABLE nach Erstellung. Kontext-Typen: - mandateId=None, featureInstanceId=None → GLOBAL (Template-Rolle) - mandateId=X, featureInstanceId=None → MANDATE-Rolle - mandateId=X, featureInstanceId=Y → INSTANCE-Rolle """ id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the role", json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) roleLabel: str = Field( description="Unique role label identifier (e.g., 'admin', 'user', 'viewer')", json_schema_extra={"label": "Rollen-Label", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True} ) description: TextMultilingual = Field( description="Role description in multiple languages", json_schema_extra={"label": "Beschreibung", "frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True} ) # KONTEXT - IMMUTABLE nach Create (nur Create/Delete, kein Update!) mandateId: Optional[str] = Field( default=None, description="FK → Mandate.id (CASCADE DELETE). Null = Global/Template role.", json_schema_extra={ "label": "Mandant", "frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}, }, ) featureInstanceId: Optional[str] = Field( default=None, description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.", json_schema_extra={ "label": "Feature-Instanz", "frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}, }, ) featureCode: Optional[str] = Field( default=None, description="Feature code (z.B. 'trustee') - für Template-Rollen", json_schema_extra={"label": "Feature-Code", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) isSystemRole: bool = Field( default=False, description="Whether this is a system role that cannot be deleted", json_schema_extra={"label": "System-Rolle", "frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False} ) @i18nModel("Zugriffsregel") class AccessRule(PowerOnModel): """ Data model for access control rules. WICHTIG: roleId referenziert die Role via FK (nicht mehr roleLabel!) Die Felder 'context' und 'roleId' sind IMMUTABLE nach Erstellung. """ id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the access rule", json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) roleId: str = Field( description="FK → Role.id (CASCADE DELETE!)", json_schema_extra={ "label": "Rolle", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "fk_target": {"db": "poweron_app", "table": "Role", "labelField": "roleLabel"}, }, ) context: AccessRuleContext = Field( description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!", json_schema_extra={"label": "Kontext", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_options": [ {"value": "DATA", "label": "Daten"}, {"value": "UI", "label": "Oberfläche"}, {"value": "RESOURCE", "label": "Ressource"} ]} ) item: Optional[str] = Field( default=None, description="Item identifier (null = all items in context). Format: DATA: '' or '
.', UI: cascading string (e.g., 'playground.voice.settings'), RESOURCE: cascading string (e.g., 'ai.model.anthropic')", json_schema_extra={"label": "Element", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False} ) view: bool = Field( default=False, description="View permission: if true, item is visible/enabled. Only objects with view=true are shown.", json_schema_extra={"label": "Anzeigen", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": True} ) read: Optional[AccessLevel] = Field( default=None, description="Read permission level (only for DATA context)", json_schema_extra={"label": "Lesen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ {"value": "a", "label": "Alle Datensätze"}, {"value": "m", "label": "Meine Datensätze"}, {"value": "g", "label": "Gruppen-Datensätze"}, {"value": "n", "label": "Kein Zugriff"} ]} ) create: Optional[AccessLevel] = Field( default=None, description="Create permission level (only for DATA context)", json_schema_extra={"label": "Erstellen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ {"value": "a", "label": "Alle Datensätze"}, {"value": "m", "label": "Meine Datensätze"}, {"value": "g", "label": "Gruppen-Datensätze"}, {"value": "n", "label": "Kein Zugriff"} ]} ) update: Optional[AccessLevel] = Field( default=None, description="Update permission level (only for DATA context)", json_schema_extra={"label": "Aktualisieren", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ {"value": "a", "label": "Alle Datensätze"}, {"value": "m", "label": "Meine Datensätze"}, {"value": "g", "label": "Gruppen-Datensätze"}, {"value": "n", "label": "Kein Zugriff"} ]} ) delete: Optional[AccessLevel] = Field( default=None, description="Delete permission level (only for DATA context)", json_schema_extra={"label": "Loeschen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ {"value": "a", "label": "Alle Datensätze"}, {"value": "m", "label": "Meine Datensätze"}, {"value": "g", "label": "Gruppen-Datensätze"}, {"value": "n", "label": "Kein Zugriff"} ]} ) @runtime_checkable class RbacProtocol(Protocol): """Structural type for RBAC checkers — allows aicore (L3) to reference the RBAC contract without importing from security (L4).""" def checkResourceAccessBulk( self, user: "User", resourcePaths: List[str], mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None, ) -> Dict[str, bool]: ... # IMMUTABLE Fields Definition - für Enforcement auf Application-Level IMMUTABLE_FIELDS = { "Role": ["mandateId", "featureInstanceId", "featureCode"], "AccessRule": ["context", "roleId"] } def validateUpdateNotImmutable(model: str, updateData: dict): """ Blockiert Updates auf immutable Felder. Wirft ValueError wenn versucht wird, Kontext-Felder zu ändern. Args: model: Model name (z.B. "Role", "AccessRule") updateData: Dictionary mit Update-Daten Raises: ValueError: Wenn immutable Felder im Update enthalten sind """ forbidden = IMMUTABLE_FIELDS.get(model, []) violations = [f for f in forbidden if f in updateData] if violations: raise ValueError( f"Cannot update immutable fields on {model}: {violations}. " f"Delete and recreate instead." )