212 lines
10 KiB
Python
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_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": "text", "frontend_readonly": True, "frontend_required": False}
|
|
)
|
|
featureInstanceId: Optional[str] = Field(
|
|
default=None,
|
|
description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.",
|
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
|
)
|
|
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_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_required": False}
|
|
)
|
|
roleId: str = Field(
|
|
description="FK → Role.id (CASCADE DELETE!)",
|
|
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True}
|
|
)
|
|
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."
|
|
)
|