# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Interface to Trustee database. Manages trustee organisations, roles, access, contracts, documents, and positions. """ import logging import math import uuid from datetime import datetime, timezone from typing import Dict, Any, List, Optional, Union from pydantic import ValidationError from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG from modules.interfaces.interfaceRbac import getRecordsetWithRBAC, getRecordsetPaginatedWithRBAC, getDistinctColumnValuesWithRBAC from modules.security.rbac import RbacClass from modules.datamodels.datamodelUam import User, AccessLevel from modules.datamodels.datamodelRbac import AccessRuleContext from .datamodelFeatureTrustee import ( TrusteeOrganisation, TrusteeRole, TrusteeAccess, TrusteeContract, TrusteeDocument, TrusteePosition, ) from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult logger = logging.getLogger(__name__) # Singleton factory for TrusteeObjects instances per context _trusteeInterfaces = {} def _toSafeFloat(value: Any, defaultValue: float = 0.0) -> float: """Convert mixed numeric inputs (str/number) to float safely.""" if value is None or value == "": return defaultValue if isinstance(value, (int, float)): return float(value) try: textValue = str(value).strip().replace("'", "").replace(" ", "") if "," in textValue and "." not in textValue: textValue = textValue.replace(",", ".") return float(textValue) except Exception: return defaultValue def _normaliseIsoDate(value: Any) -> Optional[str]: """Normalise date-like input to ISO date format YYYY-MM-DD.""" if value is None or value == "": return None if isinstance(value, (int, float)): try: return datetime.fromtimestamp(float(value), tz=timezone.utc).date().isoformat() except Exception: return None textValue = str(value).strip() if not textValue: return None # Try common explicit formats first (incl. Swiss/European notation). for formatValue in ( "%Y-%m-%d", "%d.%m.%Y", "%d/%m/%Y", "%d-%m-%Y", "%Y/%m/%d", "%Y.%m.%d", ): try: return datetime.strptime(textValue, formatValue).date().isoformat() except Exception: continue # Try ISO datetime variants. try: return datetime.fromisoformat(textValue.replace("Z", "+00:00")).date().isoformat() except Exception: return None def _normaliseTimestamp(value: Any, fallbackIsoDate: Optional[str] = None) -> Optional[float]: """Normalise timestamp input to unix seconds (float).""" if value is None or value == "": if fallbackIsoDate: try: fallbackDate = datetime.strptime(fallbackIsoDate, "%Y-%m-%d").replace(tzinfo=timezone.utc) return float(fallbackDate.timestamp()) except Exception: return None return None if isinstance(value, (int, float)): return float(value) textValue = str(value).strip() if not textValue: return None numericTimestamp = _toSafeFloat(textValue, defaultValue=float("nan")) if not math.isnan(numericTimestamp): return float(numericTimestamp) # Accept date-only input and normalise to midnight UTC timestamp. isoDate = _normaliseIsoDate(textValue) if isoDate: try: parsedDate = datetime.strptime(isoDate, "%Y-%m-%d").replace(tzinfo=timezone.utc) return float(parsedDate.timestamp()) except Exception: return None return None def _sanitisePositionPayload(data: Dict[str, Any]) -> Dict[str, Any]: """Failsafe normalisation for TrusteePosition payloads before DB writes.""" safeData = dict(data or {}) isoValuta = _normaliseIsoDate(safeData.get("valuta")) safeData["valuta"] = isoValuta safeData["transactionDateTime"] = _normaliseTimestamp( safeData.get("transactionDateTime"), fallbackIsoDate=isoValuta, ) safeData["bookingAmount"] = _toSafeFloat(safeData.get("bookingAmount"), defaultValue=0.0) safeData["originalAmount"] = _toSafeFloat( safeData.get("originalAmount"), defaultValue=safeData["bookingAmount"], ) safeData["vatPercentage"] = _toSafeFloat(safeData.get("vatPercentage"), defaultValue=0.0) safeData["vatAmount"] = _toSafeFloat(safeData.get("vatAmount"), defaultValue=0.0) bookingCurrency = (safeData.get("bookingCurrency") or "CHF") originalCurrency = (safeData.get("originalCurrency") or bookingCurrency) safeData["bookingCurrency"] = str(bookingCurrency).upper() safeData["originalCurrency"] = str(originalCurrency).upper() if "dueDate" in safeData and safeData["dueDate"]: safeData["dueDate"] = _normaliseIsoDate(safeData["dueDate"]) _VALID_DOC_TYPES = {"invoice", "expense_receipt", "bank_document", "contract", "unknown"} docType = safeData.get("documentType") if docType: docType = str(docType).strip().lower() safeData["documentType"] = docType if docType in _VALID_DOC_TYPES else None else: safeData["documentType"] = None for requiredStrField in ("company", "desc", "tags"): val = safeData.get(requiredStrField) safeData[requiredStrField] = str(val).strip() if val is not None else "" for strField in ("payeeIban", "payeeName", "payeeBic", "paymentReference"): val = safeData.get(strField) if val: safeData[strField] = str(val).strip() or None else: safeData[strField] = None return safeData def getInterface(currentUser: User, mandateId: Optional[Union[str, uuid.UUID]] = None, featureInstanceId: Optional[str] = None) -> "TrusteeObjects": """Get or create a TrusteeObjects instance for the given user context. Args: currentUser: The authenticated user mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required. featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header). """ global _trusteeInterfaces if not currentUser or not currentUser.id: raise ValueError("Valid user context required") effectiveMandateId = str(mandateId) if mandateId else None effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None # Include featureInstanceId in cache key for proper isolation cacheKey = f"{currentUser.id}_{effectiveMandateId}_{effectiveFeatureInstanceId}" if cacheKey not in _trusteeInterfaces: _trusteeInterfaces[cacheKey] = TrusteeObjects(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId) else: # Update user context if needed _trusteeInterfaces[cacheKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId) return _trusteeInterfaces[cacheKey] class TrusteeObjects: """ Interface to Trustee database. Manages trustee organisations, roles, access, contracts, documents, and positions. """ # Feature code for RBAC objectKey construction # Used to build: data.feature.trustee.{TableName} FEATURE_CODE = "trustee" def __init__(self, currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None): """Initializes the Trustee Interface. Args: currentUser: The authenticated user mandateId: The mandate ID from RequestContext (X-Mandate-Id header) featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header) """ self.currentUser = currentUser self.userId = currentUser.id if currentUser else None # Use mandateId from parameter (Request-Context), not from user object self.mandateId = mandateId self.featureInstanceId = featureInstanceId self.rbac = None # Initialize database self._initializeDatabase() # Set user context if provided if currentUser: self.setUserContext(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId) def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None): """Sets the user context for the interface. Args: currentUser: The authenticated user mandateId: The mandate ID from RequestContext (X-Mandate-Id header) featureInstanceId: The feature instance ID from RequestContext (X-Feature-Instance-Id header) """ if not currentUser: logger.info("Initializing interface without user context") return self.currentUser = currentUser self.userId = currentUser.id # Use mandateId from parameter (Request-Context), not from user object self.mandateId = mandateId self.featureInstanceId = featureInstanceId if not self.userId: raise ValueError("Invalid user context: id is required") # Note: mandateId is ALWAYS optional here - it comes from Request-Context, not from User. # Users are NOT assigned to mandates by design - they get mandate context from the request. # sysAdmin users can additionally perform cross-mandate operations. # Without mandateId, operations will be filtered to accessible mandates via RBAC. self.userLanguage = currentUser.language # Initialize RBAC interface from modules.security.rootAccess import getRootDbAppConnector dbApp = getRootDbAppConnector() self.rbac = RbacClass(self.db, dbApp=dbApp) # Update database context self.db.updateContext(self.userId) def __del__(self): """Cleanup method to close database connection.""" if hasattr(self, "db") and self.db is not None: try: self.db.close() except Exception as e: logger.error(f"Error closing database connection: {e}") def _initializeDatabase(self): """Initializes the database connection directly.""" try: dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") dbDatabase = "poweron_trustee" dbUser = APP_CONFIG.get("DB_USER") dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) self.db = DatabaseConnector( dbHost=dbHost, dbDatabase=dbDatabase, dbUser=dbUser, dbPassword=dbPassword, dbPort=dbPort, userId=self.userId, ) logger.info(f"Trustee database initialized successfully for user {self.userId}") except Exception as e: logger.error(f"Failed to initialize Trustee database: {str(e)}") raise def checkRbacPermission( self, modelClass: type, operation: str, recordId: Optional[str] = None ) -> bool: """Check RBAC permission for a specific operation on a table.""" if not self.rbac or not self.currentUser: return False tableName = modelClass.__name__ from modules.interfaces.interfaceRbac import buildDataObjectKey objectKey = buildDataObjectKey(tableName, featureCode=self.featureCode if hasattr(self, 'featureCode') else None) permissions = self.rbac.getUserPermissions( self.currentUser, AccessRuleContext.DATA, objectKey, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId ) if not permissions.view: return False permLevel = getattr(permissions, operation, AccessLevel.NONE) if permLevel == AccessLevel.NONE: return False return True def getRbacAccessLevel(self, modelClass: type, operation: str) -> AccessLevel: """Get the RBAC access level for a specific operation on a table. Returns: AccessLevel (ALL, GROUP, MY, NONE) - determines what filtering is needed """ if not self.rbac or not self.currentUser: return AccessLevel.NONE tableName = modelClass.__name__ from modules.interfaces.interfaceRbac import buildDataObjectKey objectKey = buildDataObjectKey(tableName, featureCode=self.featureCode if hasattr(self, 'featureCode') else None) permissions = self.rbac.getUserPermissions( self.currentUser, AccessRuleContext.DATA, objectKey, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId ) if not permissions.view: return AccessLevel.NONE return getattr(permissions, operation, AccessLevel.NONE) # ===== Pagination Helper Functions ===== def _applyFilters(self, records: List[Dict[str, Any]], params: Optional[PaginationParams]) -> List[Dict[str, Any]]: """ Apply filter criteria to records. Supports: - General search: params.filters["search"] - searches across all text fields - Field-specific filters: params.filters - Simple: {"status": "running"} - equals match - With operator: {"status": {"operator": "equals", "value": "running"}} - Operators: equals, contains, gt, gte, lt, lte, in, notIn, startsWith, endsWith Args: records: List of record dictionaries to filter params: PaginationParams with filters (search is inside filters) Returns: Filtered list of records """ if not params or not records: return records # Get filters safely (may be None) filters = getattr(params, 'filters', None) if not filters: return records filtered = records # Handle general search across text fields (search is inside filters) searchTerm = filters.get("search") if isinstance(filters, dict) else None if searchTerm: searchTerm = str(searchTerm).lower() if searchTerm: searchFiltered = [] for record in filtered: found = False for key, value in record.items(): if isinstance(value, str) and searchTerm in value.lower(): found = True break elif isinstance(value, (int, float)) and searchTerm in str(value): found = True break if found: searchFiltered.append(record) filtered = searchFiltered # Handle field-specific filters if filters: for fieldName, filterValue in filters.items(): if fieldName == "search": continue # Already handled above fieldFiltered = [] for record in filtered: if fieldName not in record: continue recordValue = record.get(fieldName) # Handle simple value (equals operator) if not isinstance(filterValue, dict): if str(recordValue).lower() == str(filterValue).lower(): fieldFiltered.append(record) continue # Handle filter with operator operator = filterValue.get("operator", "equals") filterVal = filterValue.get("value") matches = False if operator in ["equals", "eq"]: matches = str(recordValue).lower() == str(filterVal).lower() elif operator == "contains": recordStr = str(recordValue).lower() if recordValue is not None else "" filterStr = str(filterVal).lower() if filterVal is not None else "" matches = filterStr in recordStr elif operator == "startsWith": recordStr = str(recordValue).lower() if recordValue is not None else "" filterStr = str(filterVal).lower() if filterVal is not None else "" matches = recordStr.startswith(filterStr) elif operator == "endsWith": recordStr = str(recordValue).lower() if recordValue is not None else "" filterStr = str(filterVal).lower() if filterVal is not None else "" matches = recordStr.endswith(filterStr) elif operator == "gt": try: recordNum = float(recordValue) if recordValue is not None else float('-inf') filterNum = float(filterVal) if filterVal is not None else float('-inf') matches = recordNum > filterNum except (ValueError, TypeError): matches = False elif operator == "gte": try: recordNum = float(recordValue) if recordValue is not None else float('-inf') filterNum = float(filterVal) if filterVal is not None else float('-inf') matches = recordNum >= filterNum except (ValueError, TypeError): matches = False elif operator == "lt": try: recordNum = float(recordValue) if recordValue is not None else float('inf') filterNum = float(filterVal) if filterVal is not None else float('inf') matches = recordNum < filterNum except (ValueError, TypeError): matches = False elif operator == "lte": try: recordNum = float(recordValue) if recordValue is not None else float('inf') filterNum = float(filterVal) if filterVal is not None else float('inf') matches = recordNum <= filterNum except (ValueError, TypeError): matches = False elif operator == "in": if isinstance(filterVal, list): matches = recordValue in filterVal else: matches = False elif operator == "notIn": if isinstance(filterVal, list): matches = recordValue not in filterVal else: matches = False if matches: fieldFiltered.append(record) filtered = fieldFiltered return filtered def _applySorting(self, records: List[Dict[str, Any]], params: Optional[PaginationParams]) -> List[Dict[str, Any]]: """Apply multi-level sorting to records using stable sort.""" if not params: return records # Get sort safely (may be None or empty list) sortFields = getattr(params, 'sort', None) if not sortFields: return records sortedRecords = list(records) # Sort from least significant to most significant field (reverse order) # Python's sort is stable, so this creates proper multi-level sorting for sortField in reversed(sortFields): # Handle both dict and object formats if isinstance(sortField, dict): fieldName = sortField.get("field") direction = sortField.get("direction", "asc") else: fieldName = getattr(sortField, "field", None) direction = getattr(sortField, "direction", "asc") if not fieldName: continue isDesc = (direction == "desc") def makeSortKey(fName): def sortKey(record): value = record.get(fName) # Handle None values - place them at the end for both directions if value is None: return (1, "") # sorts after (0, ...) else: if isinstance(value, (int, float)): return (0, value) elif isinstance(value, str): return (0, value.lower()) elif isinstance(value, bool): return (0, value) else: return (0, str(value)) return sortKey sortedRecords.sort(key=makeSortKey(fieldName), reverse=isDesc) return sortedRecords # ===== Organisation CRUD ===== def createOrganisation(self, data: Dict[str, Any]) -> Optional[TrusteeOrganisation]: """Create a new organisation.""" if not self.checkRbacPermission(TrusteeOrganisation, "create"): logger.warning(f"User {self.userId} lacks permission to create organisation") return None # Set mandateId and featureInstanceId from context for proper data isolation data["mandateId"] = self.mandateId if "featureInstanceId" not in data: data["featureInstanceId"] = self.featureInstanceId # Validate ID format (alphanumeric, hyphens, underscores, 3-50 chars) orgId = data.get("id", "") if not orgId or len(orgId) < 3 or len(orgId) > 50: logger.error(f"Invalid organisation ID length: {len(orgId)}") return None import re if not re.match(r'^[a-zA-Z0-9_-]+$', orgId): logger.error(f"Invalid organisation ID format: {orgId}") return None createdRecord = self.db.recordCreate(TrusteeOrganisation, data) if createdRecord and createdRecord.get("id"): return TrusteeOrganisation(**{k: v for k, v in createdRecord.items() if not k.startswith("_")}) return None def getOrganisation(self, orgId: str) -> Optional[TrusteeOrganisation]: """Get a single organisation by ID.""" records = self.db.getRecordset(TrusteeOrganisation, recordFilter={"id": orgId}) if not records: return None return TrusteeOrganisation(**{k: v for k, v in records[0].items() if not k.startswith("_")}) def getAllOrganisations(self, params: Optional[PaginationParams] = None) -> Union[List[Dict], PaginatedResult]: """Get all organisations with RBAC filtering and optional DB-level pagination. Note: Organisations are managed at system level (by mandate). Feature-level filtering (trustee.access) is NOT applied here because: - Admin users with system RBAC can manage all orgs in their mandate - trustee.access grants access to specific orgs for other users - New organisations wouldn't be visible without an access record """ logger.debug(f"getAllOrganisations called for user {self.userId}, mandateId: {self.mandateId}") return getRecordsetPaginatedWithRBAC( connector=self.db, modelClass=TrusteeOrganisation, currentUser=self.currentUser, pagination=params, recordFilter=None, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.FEATURE_CODE ) def updateOrganisation(self, orgId: str, data: Dict[str, Any]) -> Optional[TrusteeOrganisation]: """Update an organisation.""" if not self.checkRbacPermission(TrusteeOrganisation, "update"): logger.warning(f"User {self.userId} lacks permission to update organisation") return None # ID cannot be changed after creation if "id" in data and data["id"] != orgId: logger.error("Organisation ID cannot be changed") return None data["id"] = orgId updatedRecord = self.db.recordModify(TrusteeOrganisation, orgId, data) if not updatedRecord: return None return TrusteeOrganisation(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")}) def deleteOrganisation(self, orgId: str) -> bool: """Delete an organisation.""" if not self.checkRbacPermission(TrusteeOrganisation, "delete"): logger.warning(f"User {self.userId} lacks permission to delete organisation") return False return self.db.recordDelete(TrusteeOrganisation, orgId) # ===== Role CRUD ===== def createRole(self, data: Dict[str, Any]) -> Optional[TrusteeRole]: """Create a new role (sysadmin only).""" if not self.checkRbacPermission(TrusteeRole, "create"): logger.warning(f"User {self.userId} lacks permission to create role") return None data["mandateId"] = self.mandateId if "featureInstanceId" not in data: data["featureInstanceId"] = self.featureInstanceId roleId = data.get("id", "") if not roleId: logger.error("Role ID is required") return None createdRecord = self.db.recordCreate(TrusteeRole, data) if createdRecord and createdRecord.get("id"): return TrusteeRole(**{k: v for k, v in createdRecord.items() if not k.startswith("_")}) return None def getRole(self, roleId: str) -> Optional[TrusteeRole]: """Get a single role by ID.""" records = self.db.getRecordset(TrusteeRole, recordFilter={"id": roleId}) if not records: return None return TrusteeRole(**{k: v for k, v in records[0].items() if not k.startswith("_")}) def getAllRoles(self, params: Optional[PaginationParams] = None) -> Union[List[Dict], PaginatedResult]: """Get all roles with RBAC filtering and optional DB-level pagination. Note: Roles are available to all users with trustee access. They are not filtered by organisation since they define the role types. Users with ALL access level see all roles; others need trustee.access records. NOTE(post-filter): Feature-level access check runs after the paginated query. When pagination is active the totals may overcount if the user lacks any trustee.access record (in that case the entire result is emptied). """ result = getRecordsetPaginatedWithRBAC( connector=self.db, modelClass=TrusteeRole, currentUser=self.currentUser, pagination=params, recordFilter=None, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.FEATURE_CODE ) accessLevel = self.getRbacAccessLevel(TrusteeRole, "read") if accessLevel != AccessLevel.ALL: userAccess = self.getAllUserAccess(self.userId) if not userAccess: if isinstance(result, PaginatedResult): result.items = [] result.totalItems = 0 result.totalPages = 0 return result return [] return result def updateRole(self, roleId: str, data: Dict[str, Any]) -> Optional[TrusteeRole]: """Update a role (sysadmin only).""" if not self.checkRbacPermission(TrusteeRole, "update"): logger.warning(f"User {self.userId} lacks permission to update role") return None data["id"] = roleId updatedRecord = self.db.recordModify(TrusteeRole, roleId, data) if not updatedRecord: return None return TrusteeRole(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")}) def deleteRole(self, roleId: str) -> bool: """Delete a role (sysadmin only, not if in use).""" if not self.checkRbacPermission(TrusteeRole, "delete"): logger.warning(f"User {self.userId} lacks permission to delete role") return False # Check if role is in use accessRecords = self.db.getRecordset(TrusteeAccess, recordFilter={"roleId": roleId}) if accessRecords: logger.error(f"Cannot delete role {roleId}: still in use") return False return self.db.recordDelete(TrusteeRole, roleId) # ===== Access CRUD ===== def createAccess(self, data: Dict[str, Any]) -> Optional[TrusteeAccess]: """Create a new access record. Requires admin role for the organisation or ALL access level.""" # Check system RBAC first if not self.checkRbacPermission(TrusteeAccess, "create"): logger.warning(f"User {self.userId} lacks system permission to create access") return None organisationId = data.get("organisationId") # Users with ALL access level bypass feature-level permission check accessLevel = self.getRbacAccessLevel(TrusteeAccess, "create") # Check feature-level permission - must have admin role for this organisation (unless ALL access) if accessLevel != AccessLevel.ALL and not self.checkUserTrusteePermission(self.userId, organisationId, "admin"): logger.warning(f"User {self.userId} lacks admin role for organisation {organisationId}") return None data["mandateId"] = self.mandateId if "featureInstanceId" not in data: data["featureInstanceId"] = self.featureInstanceId import uuid accessId = data.get("id") or str(uuid.uuid4()) data["id"] = accessId createdRecord = self.db.recordCreate(TrusteeAccess, data) if createdRecord and createdRecord.get("id"): return TrusteeAccess(**{k: v for k, v in createdRecord.items() if not k.startswith("_")}) return None def getAccess(self, accessId: str) -> Optional[TrusteeAccess]: """Get a single access record by ID.""" records = self.db.getRecordset(TrusteeAccess, recordFilter={"id": accessId}) if not records: return None return TrusteeAccess(**{k: v for k, v in records[0].items() if not k.startswith("_")}) def getAllAccess(self, params: Optional[PaginationParams] = None) -> Union[List[Dict], PaginatedResult]: """Get all access records with RBAC filtering + feature-level filtering. Users with ALL access level see all access records. Others can only see access records for organisations they have admin access to. NOTE(post-filter): Feature-level admin-org filtering runs after the paginated query, so totals may overcount when the user has restricted org access. """ result = getRecordsetPaginatedWithRBAC( connector=self.db, modelClass=TrusteeAccess, currentUser=self.currentUser, pagination=params, recordFilter=None, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.FEATURE_CODE ) accessLevel = self.getRbacAccessLevel(TrusteeAccess, "read") if accessLevel != AccessLevel.ALL: userAccess = self.getAllUserAccess(self.userId) adminOrgs = set() for access in userAccess: if access.get("roleId") == "admin": adminOrgs.add(access.get("organisationId")) if isinstance(result, PaginatedResult): if adminOrgs: result.items = [r for r in result.items if r.get("organisationId") in adminOrgs] else: result.items = [] return result if adminOrgs: result = [r for r in result if r.get("organisationId") in adminOrgs] else: result = [] return result def getAccessByOrganisation(self, organisationId: str, pagination: Optional[PaginationParams] = None) -> Union[List[TrusteeAccess], PaginatedResult]: """Get all access records for a specific organisation. Requires admin role for the organisation. """ if not self.checkUserTrusteePermission(self.userId, organisationId, "admin"): logger.warning(f"User {self.userId} lacks admin role for organisation {organisationId}") return [] result = getRecordsetPaginatedWithRBAC( connector=self.db, modelClass=TrusteeAccess, currentUser=self.currentUser, pagination=pagination, recordFilter={"organisationId": organisationId}, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.FEATURE_CODE ) if isinstance(result, PaginatedResult): result.items = [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in result.items] return result return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in result] def getAccessByUser(self, userId: str, pagination: Optional[PaginationParams] = None) -> Union[List[TrusteeAccess], PaginatedResult]: """Get all access records for a specific user. Users with ALL access level see all access records. Others can only see access records for organisations where they have admin role. NOTE(post-filter): Admin-org filtering runs after the paginated query, so totals may overcount when the user has restricted org access. """ result = getRecordsetPaginatedWithRBAC( connector=self.db, modelClass=TrusteeAccess, currentUser=self.currentUser, pagination=pagination, recordFilter={"userId": userId}, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.FEATURE_CODE ) accessLevel = self.getRbacAccessLevel(TrusteeAccess, "read") if accessLevel != AccessLevel.ALL: userAccess = self.getAllUserAccess(self.userId) adminOrgs = set() for access in userAccess: if access.get("roleId") == "admin": adminOrgs.add(access.get("organisationId")) if isinstance(result, PaginatedResult): result.items = [r for r in result.items if r.get("organisationId") in adminOrgs] result.items = [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in result.items] return result result = [r for r in result if r.get("organisationId") in adminOrgs] if isinstance(result, PaginatedResult): result.items = [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in result.items] return result return [TrusteeAccess(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in result] def updateAccess(self, accessId: str, data: Dict[str, Any]) -> Optional[TrusteeAccess]: """Update an access record. Requires admin role for the organisation or ALL access level.""" # Check system RBAC first if not self.checkRbacPermission(TrusteeAccess, "update"): logger.warning(f"User {self.userId} lacks system permission to update access") return None # Get existing access to check organisation existingRecords = self.db.getRecordset(TrusteeAccess, recordFilter={"id": accessId}) existing = existingRecords[0] if existingRecords else None if not existing: logger.warning(f"Access record {accessId} not found") return None organisationId = existing.get("organisationId") # Users with ALL access level bypass feature-level permission check accessLevel = self.getRbacAccessLevel(TrusteeAccess, "update") # Check feature-level permission - must have admin role for this organisation (unless ALL access) if accessLevel != AccessLevel.ALL and not self.checkUserTrusteePermission(self.userId, organisationId, "admin"): logger.warning(f"User {self.userId} lacks admin role for organisation {organisationId}") return None data["id"] = accessId updatedRecord = self.db.recordModify(TrusteeAccess, accessId, data) if not updatedRecord: return None return TrusteeAccess(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")}) def deleteAccess(self, accessId: str) -> bool: """Delete an access record. Requires admin role for the organisation or ALL access level.""" # Check system RBAC first if not self.checkRbacPermission(TrusteeAccess, "delete"): logger.warning(f"User {self.userId} lacks system permission to delete access") return False # Get existing access to check organisation existingRecords = self.db.getRecordset(TrusteeAccess, recordFilter={"id": accessId}) existing = existingRecords[0] if existingRecords else None if not existing: logger.warning(f"Access record {accessId} not found") return False organisationId = existing.get("organisationId") # Users with ALL access level bypass feature-level permission check accessLevel = self.getRbacAccessLevel(TrusteeAccess, "delete") # Check feature-level permission - must have admin role for this organisation (unless ALL access) if accessLevel != AccessLevel.ALL and not self.checkUserTrusteePermission(self.userId, organisationId, "admin"): logger.warning(f"User {self.userId} lacks admin role for organisation {organisationId}") return False return self.db.recordDelete(TrusteeAccess, accessId) # ===== Contract CRUD ===== def createContract(self, data: Dict[str, Any]) -> Optional[TrusteeContract]: """Create a new contract.""" organisationId = data.get("organisationId") # Check combined permission (system RBAC + feature-level) if not self.checkCombinedPermission(TrusteeContract, "create", organisationId): logger.warning(f"User {self.userId} lacks permission to create contract for org {organisationId}") return None data["mandateId"] = self.mandateId if "featureInstanceId" not in data: data["featureInstanceId"] = self.featureInstanceId import uuid contractId = data.get("id") or str(uuid.uuid4()) data["id"] = contractId createdRecord = self.db.recordCreate(TrusteeContract, data) if createdRecord and createdRecord.get("id"): return TrusteeContract(**{k: v for k, v in createdRecord.items() if not k.startswith("_")}) return None def getContract(self, contractId: str) -> Optional[TrusteeContract]: """Get a single contract by ID.""" records = self.db.getRecordset(TrusteeContract, recordFilter={"id": contractId}) if not records: return None return TrusteeContract(**{k: v for k, v in records[0].items() if not k.startswith("_")}) def getAllContracts(self, params: Optional[PaginationParams] = None) -> Union[List[Dict], PaginatedResult]: """Get all contracts with RBAC filtering and optional DB-level pagination.""" return getRecordsetPaginatedWithRBAC( connector=self.db, modelClass=TrusteeContract, currentUser=self.currentUser, pagination=params, recordFilter=None, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.FEATURE_CODE ) def getContractsByOrganisation(self, organisationId: str, pagination: Optional[PaginationParams] = None) -> Union[List[TrusteeContract], PaginatedResult]: """Get all contracts for a specific organisation.""" result = getRecordsetPaginatedWithRBAC( connector=self.db, modelClass=TrusteeContract, currentUser=self.currentUser, pagination=pagination, recordFilter={"organisationId": organisationId}, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.FEATURE_CODE ) if isinstance(result, PaginatedResult): result.items = [TrusteeContract(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in result.items] return result return [TrusteeContract(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in result] def updateContract(self, contractId: str, data: Dict[str, Any]) -> Optional[TrusteeContract]: """Update a contract (organisationId is immutable).""" # Get existing contract to check organisation existingRecords = self.db.getRecordset(TrusteeContract, recordFilter={"id": contractId}) existing = existingRecords[0] if existingRecords else None if not existing: logger.warning(f"Contract {contractId} not found") return None organisationId = existing.get("organisationId") # Check combined permission (system RBAC + feature-level) if not self.checkCombinedPermission(TrusteeContract, "update", organisationId): logger.warning(f"User {self.userId} lacks permission to update contract in org {organisationId}") return None # Check if organisationId is being changed (not allowed) if "organisationId" in data and data["organisationId"] != organisationId: logger.error("Contract organisationId cannot be changed after creation") return None data["id"] = contractId updatedRecord = self.db.recordModify(TrusteeContract, contractId, data) if not updatedRecord: return None return TrusteeContract(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")}) def deleteContract(self, contractId: str) -> bool: """Delete a contract.""" # Get existing contract to check organisation existingRecords = self.db.getRecordset(TrusteeContract, recordFilter={"id": contractId}) existing = existingRecords[0] if existingRecords else None if not existing: logger.warning(f"Contract {contractId} not found") return False organisationId = existing.get("organisationId") # Check combined permission (system RBAC + feature-level) if not self.checkCombinedPermission(TrusteeContract, "delete", organisationId): logger.warning(f"User {self.userId} lacks permission to delete contract in org {organisationId}") return False return self.db.recordDelete(TrusteeContract, contractId) # ===== Document CRUD ===== def createDocument(self, data: Dict[str, Any]) -> Optional[TrusteeDocument]: """Create a new document. Note: organisationId and contractId removed - feature instance IS the organisation. Permission is checked via system RBAC (feature-level access). """ # Check system RBAC permission if not self.checkCombinedPermission(TrusteeDocument, "create"): logger.warning(f"User {self.userId} lacks permission to create document") return None # Auto-set context fields data["mandateId"] = self.mandateId data["featureInstanceId"] = self.featureInstanceId import uuid documentId = data.get("id") or str(uuid.uuid4()) data["id"] = documentId createdRecord = self.db.recordCreate(TrusteeDocument, data) if createdRecord and createdRecord.get("id"): # Remove metadata from Pydantic model cleanedRecord = {k: v for k, v in createdRecord.items() if not k.startswith("_")} return TrusteeDocument(**cleanedRecord) return None def getDocument(self, documentId: str) -> Optional[TrusteeDocument]: """Get a single document by ID (metadata only).""" records = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId}) if not records: return None # Remove binary data and metadata from Pydantic model cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_") and k != "documentData"} return TrusteeDocument(**cleanedRecord) def getDocumentData(self, documentId: str) -> Optional[bytes]: """Get document binary data via fileId reference to central Files table.""" records = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId}) record = records[0] if records else None if not record: return None # New model: fileId references central Files table fileId = record.get("fileId") if fileId: from modules.interfaces.interfaceDbManagement import getInterface as getDbInterface dbInterface = getDbInterface(self.currentUser, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId) fileData = dbInterface.getFileData(fileId) if fileData: return fileData logger.warning(f"File data not found for fileId {fileId}") return None # Legacy fallback: documentData was stored directly (for migration) return record.get("documentData") def getAllDocuments(self, params: Optional[PaginationParams] = None) -> Union[List[Dict], PaginatedResult]: """Get all documents with RBAC filtering and optional DB-level pagination (metadata only). Filtering, sorting, and pagination are handled at the SQL level by getRecordsetPaginatedWithRBAC. Binary documentData is stripped from the returned items. """ result = getRecordsetPaginatedWithRBAC( connector=self.db, modelClass=TrusteeDocument, currentUser=self.currentUser, pagination=params, recordFilter=None, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.FEATURE_CODE ) def _cleanDocumentRecords(records): return [ TrusteeDocument(**{k: v for k, v in r.items() if not k.startswith("_") and k != "documentData"}) for r in records ] if isinstance(result, PaginatedResult): result.items = _cleanDocumentRecords(result.items) return result return _cleanDocumentRecords(result) def getDocumentsByContract(self, contractId: str, pagination: Optional[PaginationParams] = None) -> Union[List[TrusteeDocument], PaginatedResult]: """Get all documents for a specific contract.""" result = getRecordsetPaginatedWithRBAC( connector=self.db, modelClass=TrusteeDocument, currentUser=self.currentUser, pagination=pagination, recordFilter={"contractId": contractId}, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.FEATURE_CODE ) def _cleanDocumentRecords(records): return [ TrusteeDocument(**{k: v for k, v in r.items() if not k.startswith("_") and k != "documentData"}) for r in records ] if isinstance(result, PaginatedResult): result.items = _cleanDocumentRecords(result.items) return result return _cleanDocumentRecords(result) def updateDocument(self, documentId: str, data: Dict[str, Any]) -> Optional[TrusteeDocument]: """Update a document. Note: organisationId and contractId removed - feature instance IS the organisation. """ # Get existing document to check creator existingRecords = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId}) existing = existingRecords[0] if existingRecords else None if not existing: logger.warning(f"Document {documentId} not found") return None createdBy = existing.get("_createdBy") # Check system RBAC permission (userreport can only edit their own records) if not self.checkCombinedPermission(TrusteeDocument, "update", recordCreatedBy=createdBy): logger.warning(f"User {self.userId} lacks permission to update document") return None data["id"] = documentId updatedRecord = self.db.recordModify(TrusteeDocument, documentId, data) if not updatedRecord: return None cleanedRecord = {k: v for k, v in updatedRecord.items() if not k.startswith("_") and k != "documentData"} return TrusteeDocument(**cleanedRecord) def deleteDocument(self, documentId: str) -> bool: """Delete a document. Positions referencing this document will have their documentId set to None. """ existingRecords = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId}) existing = existingRecords[0] if existingRecords else None if not existing: logger.warning(f"Document {documentId} not found") return False createdBy = existing.get("_createdBy") if not self.checkCombinedPermission(TrusteeDocument, "delete", recordCreatedBy=createdBy): logger.warning(f"User {self.userId} lacks permission to delete document") return False # Clear documentId or bankDocumentId on positions that reference this document for field in ("documentId", "bankDocumentId"): positions = self.db.getRecordset(TrusteePosition, recordFilter={field: documentId}) for pos in positions: posId = pos.get("id") if posId: self.db.recordModify(TrusteePosition, posId, {field: None}) return self.db.recordDelete(TrusteeDocument, documentId) # ===== Position CRUD ===== def _toTrusteePositionOrDelete(self, rawRecord: Dict[str, Any], deleteCorrupt: bool = True) -> Optional[TrusteePosition]: """Build TrusteePosition safely; optionally delete irreparably corrupt records.""" cleanRecord = {k: v for k, v in (rawRecord or {}).items() if not k.startswith("_") or k == "_createdAt"} if not cleanRecord: return None normalisedRecord = _sanitisePositionPayload(cleanRecord) recordId = normalisedRecord.get("id") or cleanRecord.get("id") try: model = TrusteePosition(**normalisedRecord) if recordId and normalisedRecord != cleanRecord: try: self.db.recordModify(TrusteePosition, recordId, normalisedRecord) logger.info(f"Normalised legacy TrusteePosition record: {recordId}") except Exception as writeErr: logger.warning(f"Could not persist normalised TrusteePosition {recordId}: {writeErr}") return model except ValidationError as err: logger.error(f"Corrupt TrusteePosition record detected (id={recordId}): {err}") if deleteCorrupt and recordId: try: deleted = self.db.recordDelete(TrusteePosition, recordId) if deleted: logger.warning(f"Deleted corrupt TrusteePosition record: {recordId}") else: logger.warning(f"Failed to delete corrupt TrusteePosition record: {recordId}") except Exception as deleteErr: logger.error(f"Error deleting corrupt TrusteePosition record {recordId}: {deleteErr}") return None def createPosition(self, data: Dict[str, Any]) -> Optional[TrusteePosition]: """Create a new position. Note: organisationId and contractId removed - feature instance IS the organisation. Permission is checked via system RBAC (feature-level access). """ # Check system RBAC permission if not self.checkCombinedPermission(TrusteePosition, "create"): logger.warning(f"User {self.userId} lacks permission to create position") return None # Failsafe normalisation to keep DB payload stable for AI/manual inputs. data = _sanitisePositionPayload(data) # Auto-set context fields data["mandateId"] = self.mandateId data["featureInstanceId"] = self.featureInstanceId # Calculate VAT amount if not provided if "vatAmount" not in data or data.get("vatAmount") == 0: bookingAmount = data.get("bookingAmount", 0) vatPercentage = data.get("vatPercentage", 0) data["vatAmount"] = bookingAmount * vatPercentage / 100 import uuid positionId = data.get("id") or str(uuid.uuid4()) data["id"] = positionId createdRecord = self.db.recordCreate(TrusteePosition, data) if createdRecord and createdRecord.get("id"): return self._toTrusteePositionOrDelete(createdRecord, deleteCorrupt=False) return None def getPosition(self, positionId: str) -> Optional[TrusteePosition]: """Get a single position by ID.""" records = self.db.getRecordset(TrusteePosition, recordFilter={"id": positionId}) if not records: return None return self._toTrusteePositionOrDelete(records[0], deleteCorrupt=True) def getAllPositions(self, params: Optional[PaginationParams] = None) -> Union[List[Dict], PaginatedResult]: """Get all positions with RBAC filtering and optional DB-level pagination. Filtering, sorting, and pagination are handled at the SQL level. Post-processing cleans internal fields (keeps _createdAt) and validates each record via _toTrusteePositionOrDelete (corrupt rows are deleted). NOTE(post-process): totalItems may slightly overcount when corrupt legacy records are removed from the current page. """ result = getRecordsetPaginatedWithRBAC( connector=self.db, modelClass=TrusteePosition, currentUser=self.currentUser, pagination=params, recordFilter=None, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.FEATURE_CODE ) keepFields = {'_createdAt'} def _cleanAndValidate(records): items = [] for record in records: cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_") or k in keepFields} position = self._toTrusteePositionOrDelete(cleanedRecord, deleteCorrupt=True) if position is not None: items.append(position) return items if isinstance(result, PaginatedResult): result.items = _cleanAndValidate(result.items) return result return _cleanAndValidate(result) def getPositionsByContract(self, contractId: str, pagination: Optional[PaginationParams] = None) -> Union[List[TrusteePosition], PaginatedResult]: """Get all positions for a specific contract.""" result = getRecordsetPaginatedWithRBAC( connector=self.db, modelClass=TrusteePosition, currentUser=self.currentUser, pagination=pagination, recordFilter={"contractId": contractId}, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.FEATURE_CODE ) def _validatePositions(records): items = [] for record in records: position = self._toTrusteePositionOrDelete(record, deleteCorrupt=True) if position is not None: items.append(position) return items if isinstance(result, PaginatedResult): result.items = _validatePositions(result.items) return result return _validatePositions(result) def getPositionsByOrganisation(self, organisationId: str, pagination: Optional[PaginationParams] = None) -> Union[List[TrusteePosition], PaginatedResult]: """Get all positions for a specific organisation.""" result = getRecordsetPaginatedWithRBAC( connector=self.db, modelClass=TrusteePosition, currentUser=self.currentUser, pagination=pagination, recordFilter={"organisationId": organisationId}, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.FEATURE_CODE ) def _validatePositions(records): items = [] for record in records: position = self._toTrusteePositionOrDelete(record, deleteCorrupt=True) if position is not None: items.append(position) return items if isinstance(result, PaginatedResult): result.items = _validatePositions(result.items) return result return _validatePositions(result) def updatePosition(self, positionId: str, data: Dict[str, Any]) -> Optional[TrusteePosition]: """Update a position. Note: organisationId and contractId removed - feature instance IS the organisation. """ # Get existing position to check creator existingRecords = self.db.getRecordset(TrusteePosition, recordFilter={"id": positionId}) existing = existingRecords[0] if existingRecords else None if not existing: logger.warning(f"Position {positionId} not found") return None createdBy = existing.get("_createdBy") # Check system RBAC permission (userreport can only edit their own records) if not self.checkCombinedPermission(TrusteePosition, "update", recordCreatedBy=createdBy): logger.warning(f"User {self.userId} lacks permission to update position") return None data["id"] = positionId updatedRecord = self.db.recordModify(TrusteePosition, positionId, data) if not updatedRecord: return None return self._toTrusteePositionOrDelete(updatedRecord, deleteCorrupt=False) def deletePosition(self, positionId: str) -> bool: """Delete a position.""" existingRecords = self.db.getRecordset(TrusteePosition, recordFilter={"id": positionId}) existing = existingRecords[0] if existingRecords else None if not existing: logger.warning(f"Position {positionId} not found") return False createdBy = existing.get("_createdBy") if not self.checkCombinedPermission(TrusteePosition, "delete", recordCreatedBy=createdBy): logger.warning(f"User {self.userId} lacks permission to delete position") return False return self.db.recordDelete(TrusteePosition, positionId) # ===== Position-Document Queries ===== def getPositionsByDocument(self, documentId: str, pagination: Optional[PaginationParams] = None) -> Union[List[TrusteePosition], PaginatedResult]: """Get all positions that reference a specific document (1:N via documentId FK).""" result = getRecordsetPaginatedWithRBAC( connector=self.db, modelClass=TrusteePosition, currentUser=self.currentUser, pagination=pagination, recordFilter={"documentId": documentId}, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.FEATURE_CODE ) def _validatePositions(records): items = [] for record in records: position = self._toTrusteePositionOrDelete(record, deleteCorrupt=True) if position is not None: items.append(position) return items if isinstance(result, PaginatedResult): result.items = _validatePositions(result.items) return result return _validatePositions(result) # ===== Trustee-specific Access Check ===== def getUserAccessForOrganisation(self, userId: str, organisationId: str) -> List[Dict[str, Any]]: """Get all access records for a user in a specific organisation.""" return self.db.getRecordset( TrusteeAccess, {"userId": userId, "organisationId": organisationId} ) def getAllUserAccess(self, userId: str) -> List[Dict[str, Any]]: """Get all access records for a user across all organisations.""" return self.db.getRecordset(TrusteeAccess, recordFilter={"userId": userId}) def getUserTrusteeRoles(self, userId: str, organisationId: str, contractId: Optional[str] = None) -> List[str]: """ Get all trustee roles a user has for an organisation (and optionally contract). Args: userId: User ID to check organisationId: Organisation ID contractId: Optional contract ID for contract-specific access Returns: List of role IDs the user has """ accessRecords = self.getUserAccessForOrganisation(userId, organisationId) roles = [] for access in accessRecords: accessContractId = access.get("contractId") roleId = access.get("roleId") # If access has no contractId, it grants access to all contracts if accessContractId is None: if roleId not in roles: roles.append(roleId) # If checking for specific contract, match it elif contractId and accessContractId == contractId: if roleId not in roles: roles.append(roleId) # If no specific contract requested but access is contract-specific, # only include if we're not filtering by contract elif contractId is None: # User has contract-specific access, but we're not filtering # They can see the record but only for their specific contracts if roleId not in roles: roles.append(roleId) return roles def checkUserTrusteePermission( self, userId: str, organisationId: str, requiredRole: str, contractId: Optional[str] = None ) -> bool: """ Check if a user has a specific role for an organisation (and optionally contract). Args: userId: User ID to check organisationId: Organisation ID requiredRole: Required role (userreport, admin, operate) contractId: Optional contract ID for contract-specific access Returns: True if user has the required role """ roles = self.getUserTrusteeRoles(userId, organisationId, contractId) return requiredRole in roles def checkCombinedPermission( self, modelClass: type, operation: str, organisationId: Optional[str] = None, contractId: Optional[str] = None, recordCreatedBy: Optional[str] = None ) -> bool: """ Check combined system RBAC + feature-level RBAC permissions. Args: modelClass: The model class (e.g., TrusteeContract) operation: Operation type (read, create, update, delete) organisationId: Optional organisation ID for feature-level check contractId: Optional contract ID for contract-level filtering recordCreatedBy: Optional creator ID for userreport role checks Returns: True if user has permission """ # Step 1: Check system RBAC and get access level accessLevel = self.getRbacAccessLevel(modelClass, operation) if accessLevel == AccessLevel.NONE: return False # Users with ALL access level bypass feature-level checks if accessLevel == AccessLevel.ALL: return True # Step 2: If no organisationId, system RBAC is sufficient (for listing all) if organisationId is None: return True # Step 3: Check feature-level RBAC via trustee.access roles = self.getUserTrusteeRoles(self.userId, organisationId, contractId) if not roles: # No trustee access for this organisation return False # Check role-based permissions # admin role: full CRUD for organisation if "admin" in roles: return True # operate role: CRUD for contracts, documents, positions if "operate" in roles: if modelClass in (TrusteeContract, TrusteeDocument, TrusteePosition): return True # operate can read organisations if modelClass == TrusteeOrganisation and operation == "read": return True # userreport role: CRUD own records for documents/positions if "userreport" in roles: if modelClass in (TrusteeDocument, TrusteePosition): # For create, always allowed if operation == "create": return True # For read/update/delete, must be own record if recordCreatedBy == self.userId: return True # userreport can read organisations and contracts if modelClass in (TrusteeOrganisation, TrusteeContract) and operation == "read": return True return False