392 lines
16 KiB
Python
392 lines
16 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
RBAC helper functions for interfaces.
|
|
Provides RBAC filtering for database queries without connectors importing security.
|
|
|
|
Multi-Tenant Design:
|
|
- mandateId kommt aus Request-Context (X-Mandate-Id Header)
|
|
- GROUP-Filter verwendet expliziten mandateId Parameter
|
|
"""
|
|
|
|
import logging
|
|
import json
|
|
from typing import List, Dict, Any, Optional, Type
|
|
from pydantic import BaseModel
|
|
from modules.datamodels.datamodelRbac import AccessRuleContext
|
|
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
|
|
from modules.security.rbac import RbacClass
|
|
from modules.security.rootAccess import getRootDbAppConnector
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def getRecordsetWithRBAC(
|
|
connector, # DatabaseConnector instance
|
|
modelClass: Type[BaseModel],
|
|
currentUser: User,
|
|
recordFilter: Dict[str, Any] = None,
|
|
orderBy: str = None,
|
|
limit: int = None,
|
|
mandateId: Optional[str] = None,
|
|
featureInstanceId: Optional[str] = None,
|
|
enrichPermissions: bool = False,
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get records with RBAC filtering applied at database level.
|
|
This function wraps connector.getRecordset() with RBAC logic.
|
|
|
|
Multi-Tenant Design:
|
|
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
|
|
|
|
Args:
|
|
connector: DatabaseConnector instance
|
|
modelClass: Pydantic model class for the table
|
|
currentUser: User object
|
|
recordFilter: Additional record filters
|
|
orderBy: Field to order by (defaults to "id")
|
|
limit: Maximum number of records to return
|
|
mandateId: Explicit mandate context (from request header). Required for GROUP access.
|
|
featureInstanceId: Explicit feature instance context
|
|
enrichPermissions: If True, adds _permissions field to each record with row-level
|
|
permissions { canUpdate, canDelete } based on RBAC rules and _createdBy
|
|
|
|
Returns:
|
|
List of filtered records (with _permissions if enrichPermissions=True)
|
|
"""
|
|
table = modelClass.__name__
|
|
|
|
effectiveMandateId = mandateId
|
|
|
|
try:
|
|
if not connector._ensureTableExists(modelClass):
|
|
return []
|
|
|
|
# SysAdmin bypass: SysAdmin users have full access to all tables
|
|
isSysAdmin = getattr(currentUser, 'isSysAdmin', False)
|
|
if isSysAdmin:
|
|
# Direct access without RBAC filtering
|
|
# Note: getRecordset doesn't support orderBy/limit - these are only used in RBAC path
|
|
records = connector.getRecordset(modelClass, recordFilter=recordFilter)
|
|
if enrichPermissions:
|
|
# SysAdmin has full permissions on all records
|
|
for record in records:
|
|
record["_permissions"] = {"canUpdate": True, "canDelete": True}
|
|
return records
|
|
|
|
# Get RBAC permissions for this table
|
|
# AccessRule table is always in DbApp database
|
|
dbApp = getRootDbAppConnector()
|
|
rbacInstance = RbacClass(connector, dbApp=dbApp)
|
|
permissions = rbacInstance.getUserPermissions(
|
|
currentUser,
|
|
AccessRuleContext.DATA,
|
|
table,
|
|
mandateId=effectiveMandateId,
|
|
featureInstanceId=featureInstanceId
|
|
)
|
|
|
|
# Check view permission first
|
|
if not permissions.view:
|
|
logger.debug(f"User {currentUser.id} has no view permission for table {table}")
|
|
return []
|
|
|
|
# Build WHERE clause with RBAC filtering
|
|
whereConditions = []
|
|
whereValues = []
|
|
|
|
# Add RBAC WHERE clause based on read permission
|
|
rbacWhereClause = buildRbacWhereClause(
|
|
permissions,
|
|
currentUser,
|
|
table,
|
|
connector,
|
|
mandateId=effectiveMandateId
|
|
)
|
|
if rbacWhereClause:
|
|
whereConditions.append(rbacWhereClause["condition"])
|
|
whereValues.extend(rbacWhereClause["values"])
|
|
|
|
# Add additional record filters
|
|
if recordFilter:
|
|
for field, value in recordFilter.items():
|
|
whereConditions.append(f'"{field}" = %s')
|
|
whereValues.append(value)
|
|
|
|
# Build the query
|
|
whereClause = ""
|
|
if whereConditions:
|
|
whereClause = " WHERE " + " AND ".join(whereConditions)
|
|
|
|
orderByClause = f' ORDER BY "{orderBy}"' if orderBy else ' ORDER BY "id"'
|
|
limitClause = f" LIMIT {limit}" if limit else ""
|
|
|
|
query = f'SELECT * FROM "{table}"{whereClause}{orderByClause}{limitClause}'
|
|
|
|
with connector.connection.cursor() as cursor:
|
|
cursor.execute(query, whereValues)
|
|
records = [dict(row) for row in cursor.fetchall()]
|
|
|
|
# Handle JSONB fields and ensure numeric types are correct
|
|
# Import the helper function from connector module
|
|
from modules.connectors.connectorDbPostgre import _get_model_fields
|
|
fields = _get_model_fields(modelClass)
|
|
for record in records:
|
|
for fieldName, fieldType in fields.items():
|
|
# Ensure numeric fields are properly typed
|
|
if fieldType in ("DOUBLE PRECISION", "INTEGER") and fieldName in record:
|
|
value = record[fieldName]
|
|
if value is not None:
|
|
try:
|
|
if fieldType == "DOUBLE PRECISION":
|
|
record[fieldName] = float(value)
|
|
elif fieldType == "INTEGER":
|
|
record[fieldName] = int(value)
|
|
except (ValueError, TypeError):
|
|
logger.warning(
|
|
f"Could not convert {fieldName} to {fieldType} for record {record.get('id', 'unknown')}: {value}"
|
|
)
|
|
elif fieldType == "JSONB" and fieldName in record:
|
|
if record[fieldName] is None:
|
|
# Generic type-based default: List types -> [], Dict types -> {}
|
|
# Interfaces handle domain-specific defaults
|
|
modelFields = modelClass.model_fields
|
|
fieldInfo = modelFields.get(fieldName)
|
|
if fieldInfo:
|
|
fieldAnnotation = fieldInfo.annotation
|
|
# Check if it's a List type
|
|
if (fieldAnnotation == list or
|
|
(hasattr(fieldAnnotation, "__origin__") and
|
|
fieldAnnotation.__origin__ is list)):
|
|
record[fieldName] = []
|
|
# Check if it's a Dict type
|
|
elif (fieldAnnotation == dict or
|
|
(hasattr(fieldAnnotation, "__origin__") and
|
|
fieldAnnotation.__origin__ is dict)):
|
|
record[fieldName] = {}
|
|
else:
|
|
record[fieldName] = None
|
|
else:
|
|
record[fieldName] = None
|
|
else:
|
|
try:
|
|
if isinstance(record[fieldName], str):
|
|
record[fieldName] = json.loads(record[fieldName])
|
|
elif isinstance(record[fieldName], (dict, list)):
|
|
pass
|
|
else:
|
|
record[fieldName] = json.loads(str(record[fieldName]))
|
|
except (json.JSONDecodeError, TypeError, ValueError):
|
|
logger.warning(
|
|
f"Could not parse JSONB field {fieldName}, keeping as string: {record[fieldName]}"
|
|
)
|
|
|
|
# Enrich records with row-level permissions if requested
|
|
if enrichPermissions:
|
|
records = _enrichRecordsWithPermissions(
|
|
records, permissions, currentUser
|
|
)
|
|
|
|
return records
|
|
except Exception as e:
|
|
logger.error(f"Error loading records with RBAC from table {table}: {e}")
|
|
return []
|
|
|
|
|
|
def buildRbacWhereClause(
|
|
permissions: UserPermissions,
|
|
currentUser: User,
|
|
table: str,
|
|
connector, # DatabaseConnector instance for connection access
|
|
mandateId: Optional[str] = None
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Build RBAC WHERE clause based on permissions and access level.
|
|
|
|
Multi-Tenant Design:
|
|
- mandateId wird explizit übergeben (aus Request-Context / X-Mandate-Id Header)
|
|
|
|
Args:
|
|
permissions: UserPermissions object
|
|
currentUser: User object
|
|
table: Table name
|
|
connector: DatabaseConnector instance (needed for GROUP queries)
|
|
mandateId: Explicit mandate context (from request header). Required for GROUP access.
|
|
|
|
Returns:
|
|
Dictionary with "condition" and "values" keys, or None if no filtering needed
|
|
"""
|
|
if not permissions or not hasattr(permissions, "read"):
|
|
return None
|
|
|
|
readLevel = permissions.read
|
|
|
|
# No access - return empty result condition
|
|
if readLevel == AccessLevel.NONE:
|
|
return {"condition": "1 = 0", "values": []}
|
|
|
|
# All records - no filtering needed
|
|
if readLevel == AccessLevel.ALL:
|
|
return None
|
|
|
|
# My records - filter by _createdBy or userId field
|
|
if readLevel == AccessLevel.MY:
|
|
# Try common field names for creator
|
|
userIdField = None
|
|
if table == "UserInDB":
|
|
userIdField = "id"
|
|
elif table == "UserConnection":
|
|
userIdField = "userId"
|
|
else:
|
|
userIdField = "_createdBy"
|
|
|
|
return {
|
|
"condition": f'"{userIdField}" = %s',
|
|
"values": [currentUser.id]
|
|
}
|
|
|
|
# Group records - filter by mandateId
|
|
if readLevel == AccessLevel.GROUP:
|
|
effectiveMandateId = mandateId
|
|
|
|
if not effectiveMandateId:
|
|
logger.warning(f"User {currentUser.id} has no mandateId for GROUP access")
|
|
return {"condition": "1 = 0", "values": []}
|
|
|
|
# For UserInDB: Filter via UserMandate junction table
|
|
# Multi-Tenant Design: Users do NOT have mandateId - they are linked via UserMandate
|
|
if table == "UserInDB":
|
|
try:
|
|
with connector.connection.cursor() as cursor:
|
|
# Get all user IDs that are members of the current mandate
|
|
cursor.execute(
|
|
'SELECT "userId" FROM "UserMandate" WHERE "mandateId" = %s AND "enabled" = true',
|
|
(effectiveMandateId,)
|
|
)
|
|
userMandates = cursor.fetchall()
|
|
userIds = [um["userId"] for um in userMandates]
|
|
if not userIds:
|
|
return {"condition": "1 = 0", "values": []}
|
|
placeholders = ",".join(["%s"] * len(userIds))
|
|
return {
|
|
"condition": f'"id" IN ({placeholders})',
|
|
"values": userIds
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error building GROUP filter for UserInDB via UserMandate: {e}")
|
|
return {"condition": "1 = 0", "values": []}
|
|
|
|
# For UserConnection: Filter via UserMandate junction table
|
|
elif table == "UserConnection":
|
|
try:
|
|
with connector.connection.cursor() as cursor:
|
|
# Get all user IDs that are members of the current mandate
|
|
cursor.execute(
|
|
'SELECT "userId" FROM "UserMandate" WHERE "mandateId" = %s AND "enabled" = true',
|
|
(effectiveMandateId,)
|
|
)
|
|
userMandates = cursor.fetchall()
|
|
userIds = [um["userId"] for um in userMandates]
|
|
if not userIds:
|
|
return {"condition": "1 = 0", "values": []}
|
|
placeholders = ",".join(["%s"] * len(userIds))
|
|
return {
|
|
"condition": f'"userId" IN ({placeholders})',
|
|
"values": userIds
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error building GROUP filter for UserConnection: {e}")
|
|
return {"condition": "1 = 0", "values": []}
|
|
|
|
# For other tables, filter by mandateId field
|
|
else:
|
|
return {
|
|
"condition": '"mandateId" = %s',
|
|
"values": [effectiveMandateId]
|
|
}
|
|
|
|
return None
|
|
|
|
|
|
def _enrichRecordsWithPermissions(
|
|
records: List[Dict[str, Any]],
|
|
permissions: UserPermissions,
|
|
currentUser: User
|
|
) -> List[Dict[str, Any]]:
|
|
"""
|
|
Enrich records with per-row permissions (_permissions field).
|
|
|
|
The _permissions field contains:
|
|
- canUpdate: bool - whether current user can update this record
|
|
- canDelete: bool - whether current user can delete this record
|
|
|
|
Logic:
|
|
- AccessLevel.ALL ('a'): User can update/delete all records
|
|
- AccessLevel.MY ('m'): User can only update/delete records where _createdBy == userId
|
|
- AccessLevel.GROUP ('g'): Same as MY for now (group-level ownership)
|
|
- AccessLevel.NONE ('n'): User cannot update/delete any records
|
|
|
|
Args:
|
|
records: List of record dicts
|
|
permissions: UserPermissions with update/delete levels
|
|
currentUser: Current user object
|
|
|
|
Returns:
|
|
Records with _permissions field added
|
|
"""
|
|
enriched = []
|
|
userId = currentUser.id if currentUser else None
|
|
|
|
for record in records:
|
|
recordCopy = dict(record)
|
|
createdBy = record.get("_createdBy")
|
|
|
|
# Determine canUpdate
|
|
canUpdate = _checkRowPermission(permissions.update, userId, createdBy)
|
|
# Determine canDelete
|
|
canDelete = _checkRowPermission(permissions.delete, userId, createdBy)
|
|
|
|
recordCopy["_permissions"] = {
|
|
"canUpdate": canUpdate,
|
|
"canDelete": canDelete
|
|
}
|
|
enriched.append(recordCopy)
|
|
|
|
return enriched
|
|
|
|
|
|
def _checkRowPermission(
|
|
accessLevel: Optional[AccessLevel],
|
|
userId: Optional[str],
|
|
recordCreatedBy: Optional[str]
|
|
) -> bool:
|
|
"""
|
|
Check if user has permission for a specific row based on access level.
|
|
|
|
Args:
|
|
accessLevel: The permission level (ALL, MY, GROUP, NONE)
|
|
userId: Current user's ID
|
|
recordCreatedBy: The _createdBy value of the record
|
|
|
|
Returns:
|
|
True if user has permission, False otherwise
|
|
"""
|
|
if not accessLevel or accessLevel == AccessLevel.NONE:
|
|
return False
|
|
|
|
if accessLevel == AccessLevel.ALL:
|
|
return True
|
|
|
|
# MY and GROUP: Check ownership via _createdBy
|
|
if accessLevel in (AccessLevel.MY, AccessLevel.GROUP):
|
|
# If record has no _createdBy, allow access (can't verify ownership)
|
|
if not recordCreatedBy:
|
|
return True
|
|
# If no userId, can't verify - deny
|
|
if not userId:
|
|
return False
|
|
# Check ownership
|
|
return recordCreatedBy == userId
|
|
|
|
# Unknown level - deny by default
|
|
return False
|