487 lines
18 KiB
Python
487 lines
18 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
RBAC interface: Core RBAC logic and permission resolution.
|
|
|
|
Multi-Tenant Design:
|
|
- AccessRules referenzieren roleId (FK), nicht roleLabel
|
|
- Rollen werden über UserMandate + UserMandateRole geladen
|
|
- Priorisierung: Instance > Mandate > Global
|
|
- Stateless Design: Kein Cache, direkt aus DB
|
|
"""
|
|
|
|
import logging
|
|
from typing import List, Optional, TYPE_CHECKING
|
|
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role
|
|
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
|
|
from modules.datamodels.datamodelMembership import (
|
|
UserMandate,
|
|
UserMandateRole,
|
|
FeatureAccess,
|
|
FeatureAccessRole
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RbacClass:
|
|
"""
|
|
RBAC interface for permission resolution and rule validation.
|
|
|
|
Multi-Tenant Design:
|
|
- Lädt Rollen über UserMandate + UserMandateRole
|
|
- AccessRules werden über roleId gefunden
|
|
- isSysAdmin für System-Level Operationen (ohne Mandant)
|
|
"""
|
|
|
|
def __init__(self, db: "DatabaseConnector", dbApp: "DatabaseConnector"):
|
|
"""
|
|
Initialize RBAC interface with database connector.
|
|
|
|
Args:
|
|
db: Database connector for general operations (may be from any database)
|
|
dbApp: DbApp database connector for AccessRule queries.
|
|
AccessRule table is always in the DbApp database.
|
|
"""
|
|
self.db = db
|
|
self.dbApp = dbApp
|
|
|
|
def getUserPermissions(
|
|
self,
|
|
user: User,
|
|
context: AccessRuleContext,
|
|
item: str,
|
|
mandateId: Optional[str] = None,
|
|
featureInstanceId: Optional[str] = None
|
|
) -> UserPermissions:
|
|
"""
|
|
Get combined permissions for a user across all their roles.
|
|
|
|
Multi-Tenant Design:
|
|
- Lädt Rollen aus UserMandate + UserMandateRole wenn mandateId gegeben
|
|
- isSysAdmin gibt vollen Zugriff auf System-Level (kein mandateId)
|
|
|
|
Args:
|
|
user: User object
|
|
context: Access rule context (DATA, UI, RESOURCE)
|
|
item: Item identifier (table name, UI path, resource path)
|
|
mandateId: Optional mandate context for role lookup
|
|
featureInstanceId: Optional feature instance context
|
|
|
|
Returns:
|
|
UserPermissions object with combined permissions
|
|
"""
|
|
permissions = UserPermissions(
|
|
view=False,
|
|
read=AccessLevel.NONE,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE
|
|
)
|
|
|
|
# SysAdmin auf System-Level (kein Mandant) hat vollen Zugriff
|
|
if hasattr(user, 'isSysAdmin') and user.isSysAdmin and not mandateId:
|
|
return UserPermissions(
|
|
view=True,
|
|
read=AccessLevel.ALL,
|
|
create=AccessLevel.ALL,
|
|
update=AccessLevel.ALL,
|
|
delete=AccessLevel.ALL
|
|
)
|
|
|
|
# Lade Role-IDs für den User via UserMandate + UserMandateRole
|
|
roleIds = self._getRoleIdsForUser(user, mandateId, featureInstanceId)
|
|
|
|
if not roleIds:
|
|
return permissions
|
|
|
|
# Lade alle relevanten Regeln für alle Rollen
|
|
allRulesWithPriority = self._getRulesForRoleIds(roleIds, context, mandateId, featureInstanceId)
|
|
|
|
# Für jede Rolle die spezifischste Regel finden
|
|
rolePermissions = {}
|
|
for priority, rule in allRulesWithPriority:
|
|
# Find most specific rule for this item
|
|
if self._ruleMatchesItem(rule, item):
|
|
roleId = rule.roleId
|
|
# Speichere mit Priorität (höhere Priorität überschreibt)
|
|
if roleId not in rolePermissions or priority > rolePermissions[roleId][0]:
|
|
rolePermissions[roleId] = (priority, rule)
|
|
|
|
# Combine permissions across roles using opening (union) logic
|
|
for roleId, (priority, rule) in rolePermissions.items():
|
|
# View: union logic - if ANY role has view=true, then view=true
|
|
if rule.view:
|
|
permissions.view = True
|
|
|
|
if context == AccessRuleContext.DATA:
|
|
# For DATA context, use most permissive access level across roles
|
|
if rule.read and self._isMorePermissive(rule.read, permissions.read):
|
|
permissions.read = rule.read
|
|
if rule.create and self._isMorePermissive(rule.create, permissions.create):
|
|
permissions.create = rule.create
|
|
if rule.update and self._isMorePermissive(rule.update, permissions.update):
|
|
permissions.update = rule.update
|
|
if rule.delete and self._isMorePermissive(rule.delete, permissions.delete):
|
|
permissions.delete = rule.delete
|
|
|
|
return permissions
|
|
|
|
def _getRoleIdsForUser(
|
|
self,
|
|
user: User,
|
|
mandateId: Optional[str],
|
|
featureInstanceId: Optional[str]
|
|
) -> List[str]:
|
|
"""
|
|
Get all role IDs for a user in the given context.
|
|
Uses UserMandate + UserMandateRole for the new multi-tenant model.
|
|
|
|
Args:
|
|
user: User object
|
|
mandateId: Mandate context
|
|
featureInstanceId: Feature instance context
|
|
|
|
Returns:
|
|
List of role IDs
|
|
"""
|
|
roleIds = []
|
|
|
|
if not mandateId:
|
|
return roleIds
|
|
|
|
try:
|
|
# Lade UserMandate
|
|
userMandates = self.dbApp.getRecordset(
|
|
UserMandate,
|
|
recordFilter={"userId": user.id, "mandateId": mandateId, "enabled": True}
|
|
)
|
|
|
|
if not userMandates:
|
|
return roleIds
|
|
|
|
userMandateId = userMandates[0].get("id")
|
|
|
|
# Lade UserMandateRoles (Mandate-level roles)
|
|
userMandateRoles = self.dbApp.getRecordset(
|
|
UserMandateRole,
|
|
recordFilter={"userMandateId": userMandateId}
|
|
)
|
|
|
|
roleIds.extend([r.get("roleId") for r in userMandateRoles if r.get("roleId")])
|
|
|
|
# Load FeatureAccess + FeatureAccessRole (Instance-level roles)
|
|
if featureInstanceId:
|
|
featureAccessRecords = self.dbApp.getRecordset(
|
|
FeatureAccess,
|
|
recordFilter={
|
|
"userId": user.id,
|
|
"featureInstanceId": featureInstanceId,
|
|
"enabled": True
|
|
}
|
|
)
|
|
|
|
if featureAccessRecords:
|
|
featureAccessId = featureAccessRecords[0].get("id")
|
|
|
|
featureAccessRoles = self.dbApp.getRecordset(
|
|
FeatureAccessRole,
|
|
recordFilter={"featureAccessId": featureAccessId}
|
|
)
|
|
|
|
roleIds.extend([r.get("roleId") for r in featureAccessRoles if r.get("roleId")])
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error loading role IDs for user {user.id}: {e}")
|
|
|
|
return roleIds
|
|
|
|
def getRulesForUserBulk(
|
|
self,
|
|
userId: str,
|
|
mandateId: str,
|
|
featureInstanceId: Optional[str] = None
|
|
) -> List[tuple]:
|
|
"""
|
|
Lädt alle relevanten Regeln für einen User in EINEM Query.
|
|
Stateless: Kein Cache, direkt aus DB.
|
|
|
|
Optimiert für Multi-Tenant mit Junction Tables:
|
|
- Mandant-Rollen via UserMandate → UserMandateRole
|
|
- Instanz-Rollen via FeatureAccess → FeatureAccessRole
|
|
|
|
Args:
|
|
userId: User ID
|
|
mandateId: Mandate context
|
|
featureInstanceId: Optional feature instance context
|
|
|
|
Returns:
|
|
Liste von (priority, AccessRule) Tupeln
|
|
"""
|
|
if not mandateId:
|
|
return []
|
|
|
|
try:
|
|
conn = self.dbApp.connection
|
|
roleIds = set()
|
|
|
|
# 1. Mandant-Rollen via UserMandate → UserMandateRole (SINGLE Query)
|
|
with conn.cursor() as cursor:
|
|
cursor.execute(
|
|
"""
|
|
SELECT umr."roleId"
|
|
FROM "UserMandate" um
|
|
JOIN "UserMandateRole" umr ON umr."userMandateId" = um.id
|
|
WHERE um."userId" = %s AND um."mandateId" = %s AND um."enabled" = true
|
|
""",
|
|
(userId, mandateId)
|
|
)
|
|
mandateRoles = cursor.fetchall()
|
|
roleIds.update(r["roleId"] for r in mandateRoles if r.get("roleId"))
|
|
|
|
# 2. Instanz-Rollen via FeatureAccess → FeatureAccessRole (SINGLE Query)
|
|
if featureInstanceId:
|
|
with conn.cursor() as cursor:
|
|
cursor.execute(
|
|
"""
|
|
SELECT far."roleId"
|
|
FROM "FeatureAccess" fa
|
|
JOIN "FeatureAccessRole" far ON far."featureAccessId" = fa.id
|
|
WHERE fa."userId" = %s AND fa."featureInstanceId" = %s AND fa."enabled" = true
|
|
""",
|
|
(userId, featureInstanceId)
|
|
)
|
|
instanceRoles = cursor.fetchall()
|
|
roleIds.update(r["roleId"] for r in instanceRoles if r.get("roleId"))
|
|
|
|
if not roleIds:
|
|
return []
|
|
|
|
# 3. BULK Query: Alle Regeln für alle Rollen + zugehörige Role-Daten
|
|
# SINGLE Query mit JOIN statt N+1
|
|
roleIdsList = list(roleIds)
|
|
with conn.cursor() as cursor:
|
|
cursor.execute(
|
|
"""
|
|
SELECT ar.*, r."mandateId" as "roleMandateId",
|
|
r."featureInstanceId" as "roleInstanceId"
|
|
FROM "AccessRule" ar
|
|
JOIN "Role" r ON ar."roleId" = r.id
|
|
WHERE ar."roleId" = ANY(%s)
|
|
""",
|
|
(roleIdsList,)
|
|
)
|
|
allRulesWithContext = cursor.fetchall()
|
|
|
|
# 4. Priorität zuweisen basierend auf Role-Scope
|
|
rulesWithPriority = []
|
|
for ruleRecord in allRulesWithContext:
|
|
ruleDict = dict(ruleRecord)
|
|
|
|
# Bestimme Priorität
|
|
if ruleDict.get("roleInstanceId"):
|
|
priority = 3 # Instance-Rolle = höchste Priorität
|
|
elif ruleDict.get("roleMandateId"):
|
|
priority = 2 # Mandate-Rolle
|
|
else:
|
|
priority = 1 # Global-Rolle = niedrigste Priorität
|
|
|
|
# Entferne Hilfsspalten vor AccessRule-Erstellung
|
|
ruleDict.pop("roleMandateId", None)
|
|
ruleDict.pop("roleInstanceId", None)
|
|
|
|
try:
|
|
rule = AccessRule(**ruleDict)
|
|
rulesWithPriority.append((priority, rule))
|
|
except Exception as e:
|
|
logger.error(f"Error converting rule record: {e}")
|
|
|
|
return rulesWithPriority
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in getRulesForUserBulk: {e}")
|
|
return []
|
|
|
|
def _getRulesForRoleIds(
|
|
self,
|
|
roleIds: List[str],
|
|
context: AccessRuleContext,
|
|
mandateId: Optional[str],
|
|
featureInstanceId: Optional[str]
|
|
) -> List[tuple]:
|
|
"""
|
|
Get all access rules for the given role IDs with priority.
|
|
|
|
Priority:
|
|
- 3: Instance-specific role (featureInstanceId set)
|
|
- 2: Mandate-specific role (mandateId set, no featureInstanceId)
|
|
- 1: Global role (no mandateId)
|
|
|
|
Args:
|
|
roleIds: List of role IDs
|
|
context: Access rule context
|
|
mandateId: Current mandate context
|
|
featureInstanceId: Current feature instance context
|
|
|
|
Returns:
|
|
List of (priority, AccessRule) tuples
|
|
"""
|
|
rulesWithPriority = []
|
|
|
|
if not roleIds:
|
|
return rulesWithPriority
|
|
|
|
try:
|
|
# Lade alle Regeln für alle Rollen
|
|
for roleId in roleIds:
|
|
rules = self.dbApp.getRecordset(
|
|
AccessRule,
|
|
recordFilter={"roleId": roleId, "context": context.value}
|
|
)
|
|
|
|
# Lade Role um Priorität zu bestimmen
|
|
roleRecords = self.dbApp.getRecordset(Role, recordFilter={"id": roleId})
|
|
if not roleRecords:
|
|
continue
|
|
|
|
role = roleRecords[0]
|
|
|
|
# Bestimme Priorität basierend auf Role-Scope
|
|
if role.get("featureInstanceId"):
|
|
priority = 3 # Instance-specific
|
|
elif role.get("mandateId"):
|
|
priority = 2 # Mandate-specific
|
|
else:
|
|
priority = 1 # Global
|
|
|
|
for ruleRecord in rules:
|
|
try:
|
|
rule = AccessRule(**ruleRecord)
|
|
rulesWithPriority.append((priority, rule))
|
|
except Exception as e:
|
|
logger.error(f"Error converting rule record: {e}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error loading rules for role IDs: {e}")
|
|
|
|
return rulesWithPriority
|
|
|
|
def _ruleMatchesItem(self, rule: AccessRule, item: str) -> bool:
|
|
"""
|
|
Check if a rule matches the given item.
|
|
|
|
Args:
|
|
rule: Access rule to check
|
|
item: Item to match against
|
|
|
|
Returns:
|
|
True if rule matches item
|
|
"""
|
|
if rule.item is None:
|
|
# Generic rule matches everything
|
|
return True
|
|
|
|
if not item:
|
|
# No item specified, only generic rules match
|
|
return rule.item is None
|
|
|
|
# Exact match
|
|
if rule.item == item:
|
|
return True
|
|
|
|
# Prefix match (e.g., "trustee" matches "trustee.contract")
|
|
if item.startswith(rule.item + "."):
|
|
return True
|
|
|
|
return False
|
|
|
|
def findMostSpecificRule(self, rules: List[AccessRule], item: str) -> Optional[AccessRule]:
|
|
"""
|
|
Find the most specific rule for an item (longest matching prefix wins).
|
|
|
|
Args:
|
|
rules: List of access rules to search
|
|
item: Item identifier to match
|
|
|
|
Returns:
|
|
Most specific matching rule, or None if no match
|
|
"""
|
|
if not item:
|
|
# If no item specified, return generic rule (item = null)
|
|
genericRules = [r for r in rules if r.item is None]
|
|
return genericRules[0] if genericRules else None
|
|
|
|
# Find longest matching prefix
|
|
bestMatch = None
|
|
bestMatchLength = -1
|
|
|
|
for rule in rules:
|
|
if rule.item is None:
|
|
# Generic rule - use as fallback if no specific match found
|
|
if bestMatch is None:
|
|
bestMatch = rule
|
|
elif rule.item == item:
|
|
# Exact match - most specific
|
|
return rule
|
|
elif item.startswith(rule.item + "."):
|
|
# Prefix match - check if it's longer than current best
|
|
matchLength = len(rule.item.split("."))
|
|
if matchLength > bestMatchLength:
|
|
bestMatch = rule
|
|
bestMatchLength = matchLength
|
|
|
|
return bestMatch
|
|
|
|
def validateAccessRule(self, rule: AccessRule) -> bool:
|
|
"""
|
|
Validate that CUD permissions are allowed by read permission level (only for DATA context).
|
|
|
|
Args:
|
|
rule: AccessRule to validate
|
|
|
|
Returns:
|
|
True if rule is valid, False otherwise
|
|
"""
|
|
if rule.context != AccessRuleContext.DATA:
|
|
# For UI and RESOURCE contexts, only view is relevant
|
|
return True
|
|
|
|
if rule.read is None:
|
|
return False # DATA context requires read permission
|
|
|
|
readLevel = AccessLevel(rule.read)
|
|
|
|
# CUD operations are only allowed if read permission exists
|
|
for operation in [rule.create, rule.update, rule.delete]:
|
|
if operation is None or operation == AccessLevel.NONE.value:
|
|
continue # No access is always valid
|
|
if readLevel == AccessLevel.NONE:
|
|
return False # No CUD allowed if no read access
|
|
if readLevel == AccessLevel.MY and operation not in [AccessLevel.NONE.value, AccessLevel.MY.value]:
|
|
return False
|
|
if readLevel == AccessLevel.GROUP and operation not in [AccessLevel.NONE.value, AccessLevel.MY.value, AccessLevel.GROUP.value]:
|
|
return False
|
|
|
|
return True
|
|
|
|
def _isMorePermissive(self, level1: AccessLevel, level2: AccessLevel) -> bool:
|
|
"""
|
|
Check if level1 is more permissive than level2.
|
|
|
|
Args:
|
|
level1: First access level
|
|
level2: Second access level
|
|
|
|
Returns:
|
|
True if level1 is more permissive than level2
|
|
"""
|
|
hierarchy = {
|
|
AccessLevel.NONE: 0,
|
|
AccessLevel.MY: 1,
|
|
AccessLevel.GROUP: 2,
|
|
AccessLevel.ALL: 3
|
|
}
|
|
return hierarchy.get(level1, 0) > hierarchy.get(level2, 0)
|