gateway/modules/datamodels/datamodelRbac.py
2026-01-21 00:32:47 +01:00

212 lines
10 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# 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
from enum import Enum
from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
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.)
class Role(BaseModel):
"""
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={"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={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
)
description: TextMultilingual = Field(
description="Role description in multiple languages",
json_schema_extra={"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={"frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "name"}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"}
)
featureCode: Optional[str] = Field(
default=None,
description="Feature code (z.B. 'trustee') - für Template-Rollen",
json_schema_extra={"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={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
)
registerModelLabels(
"Role",
{"en": "Role", "de": "Rolle", "fr": "Rôle"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"roleLabel": {"en": "Role Label", "de": "Rollen-Label", "fr": "Label du rôle"},
"description": {"en": "Description", "de": "Beschreibung", "fr": "Description"},
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"},
"featureCode": {"en": "Feature Code", "de": "Feature-Code", "fr": "Code fonctionnalité"},
"isSystemRole": {"en": "System Role", "de": "System-Rolle", "fr": "Rôle système"},
},
)
class AccessRule(BaseModel):
"""
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={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE!)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
)
context: AccessRuleContext = Field(
description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_options": [
{"value": "DATA", "label": {"en": "Data", "de": "Daten", "fr": "Données"}},
{"value": "UI", "label": {"en": "UI", "de": "Oberfläche", "fr": "Interface"}},
{"value": "RESOURCE", "label": {"en": "Resource", "de": "Ressource", "fr": "Ressource"}}
]}
)
item: Optional[str] = Field(
default=None,
description="Item identifier (null = all items in context). Format: DATA: '<table>' or '<table>.<field>', UI: cascading string (e.g., 'playground.voice.settings'), RESOURCE: cascading string (e.g., 'ai.model.anthropic')",
json_schema_extra={"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={"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={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
]}
)
create: Optional[AccessLevel] = Field(
default=None,
description="Create permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
]}
)
update: Optional[AccessLevel] = Field(
default=None,
description="Update permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
]}
)
delete: Optional[AccessLevel] = Field(
default=None,
description="Delete permission level (only for DATA context)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}},
{"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}},
{"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}},
{"value": "n", "label": {"en": "No Access", "de": "Kein Zugriff", "fr": "Aucun accès"}}
]}
)
registerModelLabels(
"AccessRule",
{"en": "Access Rule", "de": "Zugriffsregel", "fr": "Règle d'accès"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"},
"context": {"en": "Context", "de": "Kontext", "fr": "Contexte"},
"item": {"en": "Item", "de": "Element", "fr": "Élément"},
"view": {"en": "View", "de": "Anzeigen", "fr": "Vue"},
"read": {"en": "Read", "de": "Lesen", "fr": "Lecture"},
"create": {"en": "Create", "de": "Erstellen", "fr": "Créer"},
"update": {"en": "Update", "de": "Aktualisieren", "fr": "Mettre à jour"},
"delete": {"en": "Delete", "de": "Löschen", "fr": "Supprimer"},
},
)
# 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."
)