gateway/modules/security/rbac.py
2026-01-17 02:17:58 +01:00

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)