diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py index 5cf2dc62..2dfec2b4 100644 --- a/modules/connectors/connectorDbPostgre.py +++ b/modules/connectors/connectorDbPostgre.py @@ -853,8 +853,12 @@ class DatabaseConnector: if recordFilter: for field, value in recordFilter.items(): - where_conditions.append(f'"{field}" = %s') - where_values.append(value) + if value is None: + # Use IS NULL for None values (= NULL is always false in SQL) + where_conditions.append(f'"{field}" IS NULL') + else: + where_conditions.append(f'"{field}" = %s') + where_values.append(value) # Build the query if where_conditions: diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 1d5f1139..545b0040 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -298,9 +298,179 @@ def initFeatures(db: DatabaseConnector) -> None: else: logger.debug(f"Feature {feature.code} already exists") + # Initialize feature-specific template roles + _initFeatureTemplateRoles(db) + logger.info("Features initialization completed") +def _initFeatureTemplateRoles(db: DatabaseConnector) -> None: + """ + Initialize feature-specific template roles. + + These are global template roles (mandateId=None, featureInstanceId=None) + that get copied when a new FeatureInstance is created. + + Template roles are NOT system roles (isSystemRole=False) and can be + modified or deleted by administrators. + + Args: + db: Database connector instance + """ + logger.info("Initializing feature-specific template roles") + + # Feature-specific template roles definition + # Each feature has its own set of roles with appropriate descriptions + featureTemplateRoles = { + "trustee": [ + { + "roleLabel": "trustee-admin", + "description": { + "en": "Trustee Administrator - Full access to all trustee data and settings", + "de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen", + "fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires" + } + }, + { + "roleLabel": "trustee-accountant", + "description": { + "en": "Trustee Accountant - Manage accounting and financial data", + "de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten", + "fr": "Comptable fiduciaire - Gérer les données comptables et financières" + } + }, + { + "roleLabel": "trustee-client", + "description": { + "en": "Trustee Client - View own accounting data and documents", + "de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen", + "fr": "Client fiduciaire - Consulter ses propres données comptables et documents" + } + }, + ], + "chatbot": [ + { + "roleLabel": "chatbot-admin", + "description": { + "en": "Chatbot Administrator - Full access to chatbot settings and all conversations", + "de": "Chatbot-Administrator - Vollzugriff auf Chatbot-Einstellungen und alle Konversationen", + "fr": "Administrateur chatbot - Accès complet aux paramètres et conversations" + } + }, + { + "roleLabel": "chatbot-user", + "description": { + "en": "Chatbot User - Use chatbot and view own conversations", + "de": "Chatbot-Benutzer - Chatbot nutzen und eigene Konversationen einsehen", + "fr": "Utilisateur chatbot - Utiliser le chatbot et consulter ses conversations" + } + }, + ], + "chatworkflow": [ + { + "roleLabel": "workflow-admin", + "description": { + "en": "Workflow Administrator - Full access to workflow configuration and execution", + "de": "Workflow-Administrator - Vollzugriff auf Workflow-Konfiguration und Ausführung", + "fr": "Administrateur workflow - Accès complet à la configuration et exécution" + } + }, + { + "roleLabel": "workflow-editor", + "description": { + "en": "Workflow Editor - Create and modify workflows", + "de": "Workflow-Editor - Workflows erstellen und bearbeiten", + "fr": "Éditeur workflow - Créer et modifier les workflows" + } + }, + { + "roleLabel": "workflow-viewer", + "description": { + "en": "Workflow Viewer - View workflows and execution results", + "de": "Workflow-Betrachter - Workflows und Ausführungsergebnisse einsehen", + "fr": "Visualiseur workflow - Consulter les workflows et résultats" + } + }, + ], + "neutralization": [ + { + "roleLabel": "neutralization-admin", + "description": { + "en": "Neutralization Administrator - Full access to neutralization settings and data", + "de": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten", + "fr": "Administrateur neutralisation - Accès complet aux paramètres et données" + } + }, + { + "roleLabel": "neutralization-analyst", + "description": { + "en": "Neutralization Analyst - Analyze and process neutralization data", + "de": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten", + "fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation" + } + }, + ], + "realestate": [ + { + "roleLabel": "realestate-admin", + "description": { + "en": "Real Estate Administrator - Full access to all property data and settings", + "de": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen", + "fr": "Administrateur immobilier - Accès complet aux données et paramètres" + } + }, + { + "roleLabel": "realestate-manager", + "description": { + "en": "Real Estate Manager - Manage properties and tenants", + "de": "Immobilien-Verwalter - Immobilien und Mieter verwalten", + "fr": "Gestionnaire immobilier - Gérer les propriétés et locataires" + } + }, + { + "roleLabel": "realestate-viewer", + "description": { + "en": "Real Estate Viewer - View property information", + "de": "Immobilien-Betrachter - Immobilien-Informationen einsehen", + "fr": "Visualiseur immobilier - Consulter les informations immobilières" + } + }, + ], + } + + # Get existing template roles (mandateId=None, featureCode set) + existingRoles = db.getRecordset(Role, recordFilter={"mandateId": None}) + existingRoleKeys = { + (r.get("featureCode"), r.get("roleLabel")) + for r in existingRoles + if r.get("featureCode") is not None + } + + createdCount = 0 + for featureCode, roles in featureTemplateRoles.items(): + for roleDef in roles: + roleKey = (featureCode, roleDef["roleLabel"]) + if roleKey not in existingRoleKeys: + try: + templateRole = Role( + roleLabel=roleDef["roleLabel"], + description=roleDef["description"], + mandateId=None, # Global template role + featureInstanceId=None, + featureCode=featureCode, + isSystemRole=False # Can be deleted by admins + ) + db.recordCreate(Role, templateRole) + createdCount += 1 + logger.info(f"Created template role: {roleDef['roleLabel']} for feature {featureCode}") + except Exception as e: + logger.warning(f"Error creating template role {roleDef['roleLabel']}: {e}") + else: + logger.debug(f"Template role {roleDef['roleLabel']} for {featureCode} already exists") + + logger.info(f"Feature template roles initialization completed ({createdCount} created)") + + def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]: """ Get role ID by label, using cache or database lookup. diff --git a/modules/routes/routeFeatures.py b/modules/routes/routeFeatures.py index 326adeb7..a9a124a8 100644 --- a/modules/routes/routeFeatures.py +++ b/modules/routes/routeFeatures.py @@ -709,6 +709,469 @@ async def createTemplateRole( ) +# ============================================================================= +# Feature Instance Users Endpoints +# Manage which users have access to a specific feature instance +# ============================================================================= + +class FeatureInstanceUserCreate(BaseModel): + """Request model for adding a user to a feature instance""" + userId: str = Field(..., description="User ID to add") + roleIds: List[str] = Field(default_factory=list, description="Role IDs to assign") + + +class FeatureInstanceUserResponse(BaseModel): + """Response model for a user in a feature instance""" + userId: str + username: str + email: Optional[str] + fullName: Optional[str] + featureAccessId: str + roleIds: List[str] + roleLabels: List[str] + enabled: bool + + +@router.get("/instances/{instanceId}/users", response_model=List[FeatureInstanceUserResponse]) +@limiter.limit("60/minute") +async def listFeatureInstanceUsers( + request: Request, + instanceId: str, + context: RequestContext = Depends(getRequestContext) +) -> List[FeatureInstanceUserResponse]: + """ + List all users with access to a specific feature instance. + + Returns users and their roles for the given instance. + + Args: + instanceId: FeatureInstance ID + """ + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + # Verify instance exists + instance = featureInterface.getFeatureInstance(instanceId) + if not instance: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Feature instance '{instanceId}' not found" + ) + + # Verify mandate access (unless SysAdmin) + if context.mandateId and str(instance.mandateId) != str(context.mandateId): + if not context.isSysAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this feature instance" + ) + + # Get all FeatureAccess records for this instance + from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole + from modules.datamodels.datamodelRbac import Role + from modules.datamodels.datamodelUam import UserInDB + + featureAccesses = rootInterface.db.getRecordset( + FeatureAccess, + recordFilter={"featureInstanceId": instanceId} + ) + + result = [] + for fa in featureAccesses: + userId = fa.get("userId") + featureAccessId = fa.get("id") + + # Get user info + users = rootInterface.db.getRecordset(UserInDB, recordFilter={"id": userId}) + if not users: + continue + user = users[0] + + # Get role IDs via FeatureAccessRole junction table + featureAccessRoles = rootInterface.db.getRecordset( + FeatureAccessRole, + recordFilter={"featureAccessId": featureAccessId} + ) + roleIds = [far.get("roleId") for far in featureAccessRoles] + + # Get role labels + roleLabels = [] + for roleId in roleIds: + roles = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId}) + if roles: + roleLabels.append(roles[0].get("roleLabel", "")) + + result.append(FeatureInstanceUserResponse( + userId=userId, + username=user.get("username", ""), + email=user.get("email"), + fullName=user.get("fullName"), + featureAccessId=featureAccessId, + roleIds=roleIds, + roleLabels=roleLabels, + enabled=fa.get("enabled", True) + )) + + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error listing feature instance users: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list feature instance users: {str(e)}" + ) + + +@router.post("/instances/{instanceId}/users", response_model=Dict[str, Any]) +@limiter.limit("30/minute") +async def addUserToFeatureInstance( + request: Request, + instanceId: str, + data: FeatureInstanceUserCreate, + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """ + Add a user to a feature instance with specified roles. + + Creates a FeatureAccess record and associated FeatureAccessRole records. + + Args: + instanceId: FeatureInstance ID + data: User and role data + """ + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + # Verify instance exists + instance = featureInterface.getFeatureInstance(instanceId) + if not instance: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Feature instance '{instanceId}' not found" + ) + + # Verify mandate access + if context.mandateId and str(instance.mandateId) != str(context.mandateId): + if not context.isSysAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this feature instance" + ) + + # Check admin permission + if not _hasMandateAdminRole(context) and not context.isSysAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin role required to add users to feature instances" + ) + + # Verify user exists + from modules.datamodels.datamodelUam import UserInDB + users = rootInterface.db.getRecordset(UserInDB, recordFilter={"id": data.userId}) + if not users: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User '{data.userId}' not found" + ) + + # Check if user already has access + from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole + existingAccess = rootInterface.db.getRecordset( + FeatureAccess, + recordFilter={"userId": data.userId, "featureInstanceId": instanceId} + ) + if existingAccess: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User already has access to this feature instance" + ) + + # Create FeatureAccess record + featureAccess = FeatureAccess( + userId=data.userId, + featureInstanceId=instanceId, + enabled=True + ) + createdAccess = rootInterface.db.recordCreate(FeatureAccess, featureAccess.model_dump()) + featureAccessId = createdAccess.get("id") + + # Create FeatureAccessRole records for each role + for roleId in data.roleIds: + featureAccessRole = FeatureAccessRole( + featureAccessId=featureAccessId, + roleId=roleId + ) + rootInterface.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump()) + + logger.info( + f"User {context.user.id} added user {data.userId} to feature instance {instanceId} " + f"with roles {data.roleIds}" + ) + + return { + "featureAccessId": featureAccessId, + "userId": data.userId, + "featureInstanceId": instanceId, + "roleIds": data.roleIds, + "enabled": True + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error adding user to feature instance: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to add user to feature instance: {str(e)}" + ) + + +@router.delete("/instances/{instanceId}/users/{userId}", response_model=Dict[str, str]) +@limiter.limit("30/minute") +async def removeUserFromFeatureInstance( + request: Request, + instanceId: str, + userId: str, + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, str]: + """ + Remove a user's access from a feature instance. + + Deletes the FeatureAccess record (CASCADE will delete FeatureAccessRole records). + + Args: + instanceId: FeatureInstance ID + userId: User ID to remove + """ + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + # Verify instance exists + instance = featureInterface.getFeatureInstance(instanceId) + if not instance: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Feature instance '{instanceId}' not found" + ) + + # Verify mandate access + if context.mandateId and str(instance.mandateId) != str(context.mandateId): + if not context.isSysAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this feature instance" + ) + + # Check admin permission + if not _hasMandateAdminRole(context) and not context.isSysAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin role required to remove users from feature instances" + ) + + # Find FeatureAccess record + from modules.datamodels.datamodelMembership import FeatureAccess + existingAccess = rootInterface.db.getRecordset( + FeatureAccess, + recordFilter={"userId": userId, "featureInstanceId": instanceId} + ) + if not existingAccess: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User does not have access to this feature instance" + ) + + featureAccessId = existingAccess[0].get("id") + + # Delete FeatureAccess (CASCADE will delete FeatureAccessRole records) + rootInterface.db.recordDelete(FeatureAccess, featureAccessId) + + logger.info( + f"User {context.user.id} removed user {userId} from feature instance {instanceId}" + ) + + return { + "message": "User access removed", + "userId": userId, + "featureInstanceId": instanceId + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error removing user from feature instance: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to remove user from feature instance: {str(e)}" + ) + + +@router.put("/instances/{instanceId}/users/{userId}/roles", response_model=Dict[str, Any]) +@limiter.limit("30/minute") +async def updateFeatureInstanceUserRoles( + request: Request, + instanceId: str, + userId: str, + roleIds: List[str], + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """ + Update a user's roles in a feature instance. + + Replaces all existing FeatureAccessRole records with new ones. + + Args: + instanceId: FeatureInstance ID + userId: User ID to update + roleIds: New list of role IDs + """ + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + # Verify instance exists + instance = featureInterface.getFeatureInstance(instanceId) + if not instance: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Feature instance '{instanceId}' not found" + ) + + # Verify mandate access + if context.mandateId and str(instance.mandateId) != str(context.mandateId): + if not context.isSysAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this feature instance" + ) + + # Check admin permission + if not _hasMandateAdminRole(context) and not context.isSysAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin role required to update user roles" + ) + + # Find FeatureAccess record + from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole + existingAccess = rootInterface.db.getRecordset( + FeatureAccess, + recordFilter={"userId": userId, "featureInstanceId": instanceId} + ) + if not existingAccess: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User does not have access to this feature instance" + ) + + featureAccessId = existingAccess[0].get("id") + + # Delete existing FeatureAccessRole records + existingRoles = rootInterface.db.getRecordset( + FeatureAccessRole, + recordFilter={"featureAccessId": featureAccessId} + ) + for role in existingRoles: + rootInterface.db.recordDelete(FeatureAccessRole, role.get("id")) + + # Create new FeatureAccessRole records + for roleId in roleIds: + featureAccessRole = FeatureAccessRole( + featureAccessId=featureAccessId, + roleId=roleId + ) + rootInterface.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump()) + + logger.info( + f"User {context.user.id} updated roles for user {userId} in feature instance {instanceId}: {roleIds}" + ) + + return { + "featureAccessId": featureAccessId, + "userId": userId, + "featureInstanceId": instanceId, + "roleIds": roleIds + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating user roles in feature instance: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update user roles: {str(e)}" + ) + + +@router.get("/instances/{instanceId}/available-roles", response_model=List[Dict[str, Any]]) +@limiter.limit("60/minute") +async def getFeatureInstanceAvailableRoles( + request: Request, + instanceId: str, + context: RequestContext = Depends(getRequestContext) +) -> List[Dict[str, Any]]: + """ + Get available roles for a feature instance. + + Returns instance-specific roles (copied from templates) that can be assigned to users. + + Args: + instanceId: FeatureInstance ID + """ + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + # Verify instance exists + instance = featureInterface.getFeatureInstance(instanceId) + if not instance: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Feature instance '{instanceId}' not found" + ) + + # Verify mandate access + if context.mandateId and str(instance.mandateId) != str(context.mandateId): + if not context.isSysAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this feature instance" + ) + + # Get roles for this instance + from modules.datamodels.datamodelRbac import Role + instanceRoles = rootInterface.db.getRecordset( + Role, + recordFilter={"featureInstanceId": instanceId} + ) + + result = [] + for role in instanceRoles: + result.append({ + "id": role.get("id"), + "roleLabel": role.get("roleLabel"), + "description": role.get("description", {}), + "featureCode": role.get("featureCode"), + "isSystemRole": role.get("isSystemRole", False) + }) + + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting available roles for feature instance: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get available roles: {str(e)}" + ) + + # ============================================================================= # Dynamic Feature Route (MUST be last to avoid catching /instances, /my, etc.) # ============================================================================= diff --git a/modules/routes/routeRbac.py b/modules/routes/routeRbac.py index 03c66ae0..5592bfa1 100644 --- a/modules/routes/routeRbac.py +++ b/modules/routes/routeRbac.py @@ -565,12 +565,20 @@ async def deleteAccessRule( async def listRoles( request: Request, pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), + includeTemplates: bool = Query(False, description="Include feature template roles"), currentUser: User = Depends(requireSysAdmin) ) -> PaginatedResponse: """ - Get list of all available roles with metadata. + Get list of global/system roles with metadata. MULTI-TENANT: SysAdmin-only (roles are system resources). + By default, only returns true global roles (mandateId=None, featureInstanceId=None, featureCode=None). + Feature template roles are managed via /api/features/templates/roles. + + Args: + pagination: Optional pagination parameters + includeTemplates: If True, also include feature template roles (featureCode != None) + Returns: - List of role dictionaries with role label, description, and user count """ @@ -598,8 +606,18 @@ async def listRoles( roleCounts = interface.countRoleAssignments() # Convert Role objects to dictionaries and add user counts + # Filter to only global roles (mandateId=None, featureInstanceId=None) + # Unless includeTemplates=True, also exclude feature template roles (featureCode != None) result = [] for role in dbRoles: + # Filter: Only global roles (no mandate, no instance) + if role.mandateId is not None or role.featureInstanceId is not None: + continue + + # Filter: Exclude feature template roles unless includeTemplates=True + if not includeTemplates and role.featureCode is not None: + continue + result.append({ "id": role.id, "roleLabel": role.roleLabel, diff --git a/tool_db_adapt_to_models.py b/scripts/script_db_adapt_to_models.py similarity index 99% rename from tool_db_adapt_to_models.py rename to scripts/script_db_adapt_to_models.py index 85c6e8fc..163c4cb8 100644 --- a/tool_db_adapt_to_models.py +++ b/scripts/script_db_adapt_to_models.py @@ -8,7 +8,7 @@ Einfaches Script das: 3. Spezialfall: UserInDB.privilege → roleLabels migriert Verwendung: - python tool_db_adapt_to_models.py [--dry-run] [--db ] + python script_db_adapt_to_models.py [--dry-run] [--db ] """ import os @@ -21,7 +21,7 @@ from typing import Dict, List, Any, Optional # Gateway-Pfad setzen scriptPath = Path(__file__).resolve() -gatewayPath = scriptPath.parent +gatewayPath = scriptPath.parent.parent sys.path.insert(0, str(gatewayPath)) os.chdir(str(gatewayPath)) diff --git a/scripts/script_db_cleanup_duplicate_roles.py b/scripts/script_db_cleanup_duplicate_roles.py new file mode 100644 index 00000000..392cde80 --- /dev/null +++ b/scripts/script_db_cleanup_duplicate_roles.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +""" +Cleanup script for duplicate roles in the database. + +This script removes duplicate Role records that were created due to the IS NULL bug +in connectorDbPostgre.py. The bug caused `mandateId = NULL` to always return FALSE, +which meant the duplicate check in bootstrap didn't work. + +Usage: + python cleanupDuplicateRoles.py + +The script will: +1. Find all duplicate roles (same roleLabel + featureCode + featureInstanceId + mandateId) +2. Keep the oldest one (first created) and delete the rest +3. Report the number of deleted roles +""" + +import sys +import os + +# Add parent directory to path +gatewayDir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, gatewayDir) + +# Load environment variables from env_dev.env +from dotenv import load_dotenv +envPath = os.path.join(gatewayDir, "env_dev.env") +if os.path.exists(envPath): + load_dotenv(envPath) + +from modules.datamodels.datamodelRbac import Role +from modules.security.rootAccess import getRootDbAppConnector +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def _getDbConnector(): + """Get a database connector using the application's configuration.""" + return getRootDbAppConnector() + + +def cleanupDuplicateRoles(): + """ + Clean up duplicate roles in the database. + + Keeps the first role (by ID, which is UUID-based) for each unique combination of: + - roleLabel + - featureCode + - featureInstanceId + - mandateId + """ + db = _getDbConnector() + + # Get all roles + allRoles = db.getRecordset(Role, recordFilter=None) + logger.info(f"Found {len(allRoles)} total roles in database") + + # Group roles by their unique key + roleGroups = {} + for role in allRoles: + # Create a key tuple for grouping + # Note: None values need special handling for dict keys + key = ( + role.get("roleLabel"), + role.get("featureCode") or "__NONE__", + role.get("featureInstanceId") or "__NONE__", + role.get("mandateId") or "__NONE__" + ) + + if key not in roleGroups: + roleGroups[key] = [] + roleGroups[key].append(role) + + # Find and delete duplicates + deletedCount = 0 + for key, roles in roleGroups.items(): + if len(roles) > 1: + # Sort by ID (UUID, string comparison works for finding "first") + # Actually, we want to keep one - let's keep by created order if available + # Since there's no createdAt, we'll just keep the first one + toKeep = roles[0] + toDelete = roles[1:] + + logger.info(f"Found {len(roles)} duplicates for key {key}") + logger.info(f" Keeping: {toKeep.get('id')} ({toKeep.get('roleLabel')})") + + for role in toDelete: + roleId = role.get("id") + try: + db.recordDelete(Role, roleId) + deletedCount += 1 + logger.info(f" Deleted: {roleId}") + except Exception as e: + logger.error(f" Failed to delete {roleId}: {e}") + + logger.info(f"Cleanup complete: {deletedCount} duplicate roles deleted") + logger.info(f"Remaining roles: {len(allRoles) - deletedCount}") + + return deletedCount + + +def showRoleSummary(): + """Show a summary of roles grouped by type.""" + db = _getDbConnector() + + allRoles = db.getRecordset(Role, recordFilter=None) + + # Categorize roles + systemRoles = [] + templateRoles = [] + mandateRoles = [] + instanceRoles = [] + + for role in allRoles: + mandateId = role.get("mandateId") + featureInstanceId = role.get("featureInstanceId") + featureCode = role.get("featureCode") + isSystemRole = role.get("isSystemRole", False) + + if isSystemRole: + systemRoles.append(role) + elif mandateId is None and featureInstanceId is None and featureCode: + templateRoles.append(role) + elif mandateId is None and featureInstanceId is None and not featureCode: + # Global non-system role (shouldn't exist normally) + systemRoles.append(role) + elif mandateId and featureInstanceId is None: + mandateRoles.append(role) + elif featureInstanceId: + instanceRoles.append(role) + + print("\n" + "=" * 60) + print("ROLE SUMMARY") + print("=" * 60) + + print(f"\n1. SYSTEM ROLES ({len(systemRoles)}):") + for r in systemRoles: + print(f" - {r.get('roleLabel')} (isSystemRole={r.get('isSystemRole')})") + + print(f"\n2. TEMPLATE ROLES ({len(templateRoles)}) - (mandateId=NULL, featureInstanceId=NULL, featureCode!=NULL):") + templateByFeature = {} + for r in templateRoles: + fc = r.get("featureCode") + if fc not in templateByFeature: + templateByFeature[fc] = [] + templateByFeature[fc].append(r) + + for fc, roles in sorted(templateByFeature.items()): + print(f" [{fc}] ({len(roles)} roles):") + for r in roles: + print(f" - {r.get('roleLabel')}") + + print(f"\n3. MANDATE ROLES ({len(mandateRoles)}) - (mandateId!=NULL, featureInstanceId=NULL):") + for r in mandateRoles[:10]: # Show max 10 + print(f" - {r.get('roleLabel')} (mandate: {r.get('mandateId')[:8]}...)") + if len(mandateRoles) > 10: + print(f" ... and {len(mandateRoles) - 10} more") + + print(f"\n4. INSTANCE ROLES ({len(instanceRoles)}) - (featureInstanceId!=NULL):") + for r in instanceRoles[:10]: # Show max 10 + print(f" - {r.get('roleLabel')} (instance: {r.get('featureInstanceId')[:8]}...)") + if len(instanceRoles) > 10: + print(f" ... and {len(instanceRoles) - 10} more") + + print("\n" + "=" * 60) + print(f"TOTAL: {len(allRoles)} roles") + print("=" * 60 + "\n") + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Cleanup duplicate roles in database") + parser.add_argument("--summary", action="store_true", help="Show role summary without deleting") + parser.add_argument("--cleanup", action="store_true", help="Delete duplicate roles") + + args = parser.parse_args() + + if args.summary: + showRoleSummary() + elif args.cleanup: + cleanupDuplicateRoles() + showRoleSummary() + else: + # Default: show summary only + showRoleSummary() + print("\nTo delete duplicates, run with --cleanup flag") diff --git a/tool_db_export_migration.py b/scripts/script_db_export_migration.py similarity index 97% rename from tool_db_export_migration.py rename to scripts/script_db_export_migration.py index aea697c1..dd3cc940 100644 --- a/tool_db_export_migration.py +++ b/scripts/script_db_export_migration.py @@ -16,7 +16,7 @@ Datenbanken: - poweron_trustee (Trustee Daten) Verwendung: - python tool_db_export_migration.py [--output ] [--pretty] + python script_db_export_migration.py [--output ] [--pretty] Optionen: --output, -o Pfad zur Ausgabedatei (Standard: migration_export_.json) @@ -40,23 +40,26 @@ from pathlib import Path # Find gateway directory (could be in local/pending/ or gateway/) scriptPath = Path(__file__).resolve() gatewayPath = scriptPath.parent +# If we're in scripts/, go up to gateway/ +if gatewayPath.name == "scripts": + gatewayPath = gatewayPath.parent # If we're in local/pending/, go up to find gateway/ -if gatewayPath.name == "pending": +elif gatewayPath.name == "pending": gatewayPath = gatewayPath.parent.parent / "gateway" elif gatewayPath.name == "local": gatewayPath = gatewayPath.parent / "gateway" # If gateway doesn't exist, try current directory if not gatewayPath.exists(): - gatewayPath = Path(__file__).parent.parent.parent / "gateway" + gatewayPath = scriptPath.parent.parent.parent / "gateway" if gatewayPath.exists(): sys.path.insert(0, str(gatewayPath)) # Change working directory to gateway so APP_CONFIG can find .env file os.chdir(str(gatewayPath)) else: # Fallback: assume we're already in gateway/ or add parent - sys.path.insert(0, str(Path(__file__).parent)) + sys.path.insert(0, str(scriptPath.parent)) # Try to change to gateway directory if it exists - potentialGateway = Path(__file__).parent + potentialGateway = scriptPath.parent if potentialGateway.exists() and (potentialGateway / "modules" / "shared" / "configuration.py").exists(): os.chdir(str(potentialGateway)) @@ -579,7 +582,7 @@ def exportDatabase( if logDir and os.path.isabs(logDir): outputDir = logDir else: - outputDir = os.path.join(os.path.dirname(__file__), "local", "logs") + outputDir = os.path.join(str(gatewayPath), "local", "logs") os.makedirs(outputDir, exist_ok=True) outputPath = os.path.join(outputDir, f"migration_export_{timestamp}.json") @@ -751,12 +754,12 @@ Datenbanken: poweron_trustee - Trustee Daten Beispiele: - python tool_db_export_migration.py - python tool_db_export_migration.py --pretty - python tool_db_export_migration.py -o backup.json --pretty - python tool_db_export_migration.py --db poweron_app,poweron_chat - python tool_db_export_migration.py --exclude Token,AuthEvent --include-meta - python tool_db_export_migration.py --summary + python script_db_export_migration.py + python script_db_export_migration.py --pretty + python script_db_export_migration.py -o backup.json --pretty + python script_db_export_migration.py --db poweron_app,poweron_chat + python script_db_export_migration.py --exclude Token,AuthEvent --include-meta + python script_db_export_migration.py --summary """ ) diff --git a/tool_security_encrypt_all_env_files.py b/scripts/script_security_encrypt_all_env_files.py similarity index 95% rename from tool_security_encrypt_all_env_files.py rename to scripts/script_security_encrypt_all_env_files.py index 420591dd..9981ad52 100644 --- a/tool_security_encrypt_all_env_files.py +++ b/scripts/script_security_encrypt_all_env_files.py @@ -10,16 +10,16 @@ keys for each environment. Usage: # Encrypt all secrets in all environment files - python tool_security_encrypt_all_env_files.py + python script_security_encrypt_all_env_files.py # Dry run - show what would be changed without making changes - python tool_security_encrypt_all_env_files.py --dry-run + python script_security_encrypt_all_env_files.py --dry-run # Skip backup creation - python tool_security_encrypt_all_env_files.py --no-backup + python script_security_encrypt_all_env_files.py --no-backup # Process only specific environment files - python tool_security_encrypt_all_env_files.py --files env_dev.env env_prod.env + python script_security_encrypt_all_env_files.py --files env_dev.env env_prod.env """ import sys @@ -29,14 +29,15 @@ from pathlib import Path from datetime import datetime from typing import List, Dict, Any -# Add the modules directory to the Python path -current_dir = Path(__file__).parent -modules_dir = current_dir / 'modules' -if modules_dir.exists(): - sys.path.insert(0, str(modules_dir)) +# Add the gateway directory to the Python path +scriptPath = Path(__file__).resolve() +gatewayPath = scriptPath.parent.parent +modulesDir = gatewayPath / "modules" +if modulesDir.exists(): + sys.path.insert(0, str(gatewayPath)) else: - print(f"Error: Modules directory not found: {modules_dir}") - print(f"Make sure you're running this script from the gateway directory") + print(f"Error: Modules directory not found: {modulesDir}") + print("Make sure you're running this script from the gateway directory") sys.exit(1) # Import encryption functions @@ -45,7 +46,7 @@ try: except ImportError as e: print(f"Error: Could not import encryption functions from shared.configuration: {e}") print(f"Make sure you're running this script from the gateway directory") - print(f"Modules directory: {modules_dir}") + print(f"Modules directory: {modulesDir}") sys.exit(1) def get_env_type_from_file(file_path: Path) -> str: diff --git a/tool_security_encrypt_config_value.py b/scripts/script_security_encrypt_config_value.py similarity index 96% rename from tool_security_encrypt_config_value.py rename to scripts/script_security_encrypt_config_value.py index e082e541..afaeb827 100644 --- a/tool_security_encrypt_config_value.py +++ b/scripts/script_security_encrypt_config_value.py @@ -10,18 +10,18 @@ It can also encrypt all *_SECRET keys in an environment file at once. Usage: # Encrypt a single value - python tool_encrypt_config_value.py --value "my_secret_value" --env dev - python tool_encrypt_config_value.py --file "path/to/file.json" --env prod + python script_security_encrypt_config_value.py --value "my_secret_value" --env dev + python script_security_encrypt_config_value.py --file "path/to/file.json" --env prod # Encrypt all secrets in a file - python tool_encrypt_config_value.py --encrypt-all env_dev.env --env dev - python tool_encrypt_config_value.py --encrypt-all env_prod.env --env prod --dry-run + python script_security_encrypt_config_value.py --encrypt-all env_dev.env --env dev + python script_security_encrypt_config_value.py --encrypt-all env_prod.env --env prod --dry-run # Decrypt a value (for testing) - python tool_encrypt_config_value.py --decrypt "DEV_ENC:encrypted_value" + python script_security_encrypt_config_value.py --decrypt "DEV_ENC:encrypted_value" # Verify master key is correct - python tool_encrypt_config_value.py --verify "PROD_ENC:Z0FBQUFBQm8xSU5p..." + python script_security_encrypt_config_value.py --verify "PROD_ENC:Z0FBQUFBQm8xSU5p..." """ import sys @@ -32,8 +32,11 @@ import shutil from pathlib import Path from datetime import datetime -# Add the modules directory to the Python path -sys.path.insert(0, str(Path(__file__).parent / 'modules')) +# Add the gateway directory to the Python path +scriptPath = Path(__file__).resolve() +gatewayPath = scriptPath.parent.parent +projectRoot = gatewayPath.parent +sys.path.insert(0, str(gatewayPath)) from modules.shared.configuration import encryptValue, decryptValue, _isEncryptedValue as isEncryptedValue @@ -412,7 +415,7 @@ def main(): key_source = f"file '{key_location}'" else: # Try default key file location - default_key_file = Path(__file__).parent.parent / 'local' / 'key.txt' + default_key_file = projectRoot / "local" / "key.txt" if default_key_file.exists(): print(f" [OK] Found master key in default file: {default_key_file}") key_source = f"file '{default_key_file}'" diff --git a/tool_security_generate_master_keys.py b/scripts/script_security_generate_master_keys.py similarity index 88% rename from tool_security_generate_master_keys.py rename to scripts/script_security_generate_master_keys.py index a5426e4f..6da55d26 100644 --- a/tool_security_generate_master_keys.py +++ b/scripts/script_security_generate_master_keys.py @@ -8,8 +8,8 @@ This tool generates cryptographically secure 256-bit master keys for all environ and updates the key.txt file with the new keys. Usage: - python generate_master_keys.py - python generate_master_keys.py --output "path/to/key.txt" + python script_security_generate_master_keys.py + python script_security_generate_master_keys.py --output "path/to/key.txt" """ import sys @@ -19,6 +19,10 @@ import base64 import argparse from pathlib import Path +scriptPath = Path(__file__).resolve() +gatewayPath = scriptPath.parent.parent +projectRoot = gatewayPath.parent + def generate_master_key(): """Generate a secure 256-bit master key.""" # Generate 32 random bytes (256 bits) @@ -29,8 +33,8 @@ def generate_master_key(): def main(): parser = argparse.ArgumentParser(description='Generate secure master keys for all environments') parser.add_argument('--output', '-o', - default='../local/key.txt', - help='Output file path (default: ../local/key.txt)') + default=str(projectRoot / "local" / "key.txt"), + help='Output file path (default: poweron/local/key.txt)') parser.add_argument('--force', '-f', action='store_true', help='Overwrite existing key file without confirmation') diff --git a/tool_stats_durations_from_log.py b/scripts/script_stats_durations_from_log.py similarity index 100% rename from tool_stats_durations_from_log.py rename to scripts/script_stats_durations_from_log.py diff --git a/tool_stats_get_codelines.py b/scripts/script_stats_get_codelines.py similarity index 100% rename from tool_stats_get_codelines.py rename to scripts/script_stats_get_codelines.py diff --git a/tool_stats_showUnusedFunctions.py b/scripts/script_stats_showUnusedFunctions.py similarity index 96% rename from tool_stats_showUnusedFunctions.py rename to scripts/script_stats_showUnusedFunctions.py index 22dd5144..f7f6b2c3 100644 --- a/tool_stats_showUnusedFunctions.py +++ b/scripts/script_stats_showUnusedFunctions.py @@ -191,18 +191,19 @@ class FunctionUsageAnalyzer: def main(): """Main function to run the analysis.""" - # Get the directory where this script is located - script_dir = Path(__file__).parent - logger.info(f"Analyzing codebase in: {script_dir}") + # Get the gateway directory for a full codebase scan + scriptPath = Path(__file__).resolve() + gatewayPath = scriptPath.parent.parent + logger.info(f"Analyzing codebase in: {gatewayPath}") - analyzer = FunctionUsageAnalyzer(script_dir) + analyzer = FunctionUsageAnalyzer(gatewayPath) analyzer.analyze_codebase() report = analyzer.generate_report() print(report) # Save report to file - report_file = script_dir / "unused_functions_report.txt" + report_file = gatewayPath / "unused_functions_report.txt" with open(report_file, 'w', encoding='utf-8') as f: f.write(report)