# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Centralized bootstrap interface for system initialization. Contains all bootstrap logic including mandate, users, and RBAC rules. Multi-Tenant Design: - Rollen werden mit Kontext erstellt (mandateId=None für globale Template-Rollen) - AccessRules referenzieren roleId (FK), nicht roleLabel - Admin-User bekommt isSysAdmin=True statt roleLabels """ import logging from typing import Optional, Dict from passlib.context import CryptContext from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.shared.configuration import APP_CONFIG from modules.datamodels.datamodelUam import ( Mandate, UserInDB, AuthAuthority, ) from modules.datamodels.datamodelRbac import ( AccessRule, AccessRuleContext, Role, ) from modules.datamodels.datamodelUam import AccessLevel from modules.datamodels.datamodelMembership import ( UserMandate, UserMandateRole, ) from modules.datamodels.datamodelFeatures import Feature logger = logging.getLogger(__name__) # Password-Hashing pwdContext = CryptContext(schemes=["argon2"], deprecated="auto") # Cache für Role-IDs (roleLabel -> roleId) _roleIdCache: Dict[str, str] = {} def initBootstrap(db: DatabaseConnector) -> None: """ Main bootstrap entry point - initializes all system components. Args: db: Database connector instance """ logger.info("Starting system bootstrap") # Initialize root mandate mandateId = initRootMandate(db) # Initialize roles FIRST (needed for AccessRules) initRoles(db) # Initialize features (trustee, chatbot, etc.) initFeatures(db) # Initialize RBAC rules (uses roleIds from roles) initRbacRules(db) # Initialize AccessRules for feature-template roles (idempotent - adds missing rules) _initFeatureTemplateRoleAccessRules(db) # Initialize admin user adminUserId = initAdminUser(db, mandateId) # Initialize event user eventUserId = initEventUser(db, mandateId) # Assign initial user memberships (via UserMandate + UserMandateRole) if adminUserId and eventUserId and mandateId: assignInitialUserMemberships(db, mandateId, adminUserId, eventUserId) # Apply multi-tenant database optimizations (indexes, triggers, FKs) _applyDatabaseOptimizations(db) logger.info("System bootstrap completed") def initRootMandate(db: DatabaseConnector) -> Optional[str]: """ Creates the Root mandate if it doesn't exist. Args: db: Database connector instance Returns: Mandate ID if created or found, None otherwise """ existingMandates = db.getRecordset(Mandate) if existingMandates: mandateId = existingMandates[0].get("id") logger.info(f"Root mandate already exists with ID {mandateId}") return mandateId logger.info("Creating Root mandate") rootMandate = Mandate(name="Root", enabled=True) createdMandate = db.recordCreate(Mandate, rootMandate) mandateId = createdMandate.get("id") logger.info(f"Root mandate created with ID {mandateId}") return mandateId def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[str]: """ Creates the Admin user if it doesn't exist. Admin user gets isSysAdmin=True for system-level access. Role assignment is done via UserMandate + UserMandateRole in assignInitialUserMemberships(). Args: db: Database connector instance mandateId: Root mandate ID (for membership assignment, not on User) Returns: User ID if created or found, None otherwise """ existingUsers = db.getRecordset(UserInDB, recordFilter={"username": "admin"}) if existingUsers: userId = existingUsers[0].get("id") existingIsSysAdmin = existingUsers[0].get("isSysAdmin", False) # Ensure admin user has isSysAdmin=True if not existingIsSysAdmin: logger.info(f"Updating admin user {userId} to set isSysAdmin=True") db.recordModify(UserInDB, userId, {"isSysAdmin": True}) logger.info(f"Admin user already exists with ID {userId}") return userId logger.info("Creating Admin user") adminUser = UserInDB( username="admin", email="admin@example.com", fullName="Administrator", enabled=True, language="en", isSysAdmin=True, authenticationAuthority=AuthAuthority.LOCAL, hashedPassword=_getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_ADMIN_SECRET")), ) createdUser = db.recordCreate(UserInDB, adminUser) userId = createdUser.get("id") logger.info(f"Admin user created with ID {userId}") return userId def initEventUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[str]: """ Creates the Event user if it doesn't exist. Event user gets isSysAdmin=True for system operations. Role assignment is done via UserMandate + UserMandateRole in assignInitialUserMemberships(). Args: db: Database connector instance mandateId: Root mandate ID (for membership assignment, not on User) Returns: User ID if created or found, None otherwise """ existingUsers = db.getRecordset(UserInDB, recordFilter={"username": "event"}) if existingUsers: userId = existingUsers[0].get("id") logger.info(f"Event user already exists with ID {userId}") return userId logger.info("Creating Event user") eventUser = UserInDB( username="event", email="event@example.com", fullName="Event", enabled=True, language="en", isSysAdmin=True, authenticationAuthority=AuthAuthority.LOCAL, hashedPassword=_getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_EVENT_SECRET")), ) createdUser = db.recordCreate(UserInDB, eventUser) userId = createdUser.get("id") logger.info(f"Event user created with ID {userId}") return userId def initRoles(db: DatabaseConnector) -> None: """ Initialize standard roles if they don't exist. Roles are created as GLOBAL (mandateId=None) template roles. NOTE: SysAdmin is NOT a role - it's a flag (User.isSysAdmin). SysAdmin users bypass RBAC entirely and have full system access. These template roles are for mandate/feature-level access control. Args: db: Database connector instance """ logger.info("Initializing roles") global _roleIdCache _roleIdCache = {} # Standard template roles for mandate/feature-level access # NOTE: No "sysadmin" role - SysAdmin is a flag (User.isSysAdmin), not a role! standardRoles = [ Role( roleLabel="admin", description={"en": "Administrator - Manage users and resources within mandate scope", "de": "Administrator - Benutzer und Ressourcen im Mandanten verwalten", "fr": "Administrateur - Gérer les utilisateurs et ressources dans le périmètre du mandat"}, mandateId=None, # Global template role featureInstanceId=None, featureCode=None, isSystemRole=True ), Role( roleLabel="user", description={"en": "User - Standard user with access to own records", "de": "Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze", "fr": "Utilisateur - Utilisateur standard avec accès à ses propres enregistrements"}, mandateId=None, # Global template role featureInstanceId=None, featureCode=None, isSystemRole=True ), Role( roleLabel="viewer", description={"en": "Viewer - Read-only access to group records", "de": "Betrachter - Nur-Lese-Zugriff auf Gruppen-Datensätze", "fr": "Visualiseur - Accès en lecture seule aux enregistrements du groupe"}, mandateId=None, # Global template role featureInstanceId=None, featureCode=None, isSystemRole=True ), ] existingRoles = db.getRecordset(Role) existingRoleLabels = {role.get("roleLabel"): role.get("id") for role in existingRoles} for role in standardRoles: if role.roleLabel not in existingRoleLabels: try: createdRole = db.recordCreate(Role, role) _roleIdCache[role.roleLabel] = createdRole.get("id") logger.info(f"Created role: {role.roleLabel} with ID {createdRole.get('id')}") except Exception as e: logger.warning(f"Error creating role {role.roleLabel}: {e}") else: _roleIdCache[role.roleLabel] = existingRoleLabels[role.roleLabel] logger.debug(f"Role {role.roleLabel} already exists with ID {existingRoleLabels[role.roleLabel]}") logger.info("Roles initialization completed") def initFeatures(db: DatabaseConnector) -> None: """ Initialize standard features if they don't exist. Features are global definitions that can be instantiated within mandates. Each feature has a unique code (e.g., 'trustee', 'chatbot'). Args: db: Database connector instance """ logger.info("Initializing features") # Standard features available in the system standardFeatures = [ Feature( code="trustee", label={"en": "Trustee", "de": "Treuhand", "fr": "Fiduciaire"}, icon="mdi-briefcase" ), Feature( code="chatbot", label={"en": "Chatbot", "de": "Chatbot", "fr": "Chatbot"}, icon="mdi-robot" ), Feature( code="chatworkflow", label={"en": "Chat Workflow", "de": "Chat-Workflow", "fr": "Workflow de Chat"}, icon="mdi-message-cog" ), Feature( code="neutralization", label={"en": "Neutralization", "de": "Neutralisierung", "fr": "Neutralisation"}, icon="mdi-shield-check" ), Feature( code="realestate", label={"en": "Real Estate", "de": "Immobilien", "fr": "Immobilier"}, icon="mdi-home-city" ), ] existingFeatures = db.getRecordset(Feature) existingCodes = {f.get("code") for f in existingFeatures} for feature in standardFeatures: if feature.code not in existingCodes: try: db.recordCreate(Feature, feature.model_dump()) logger.info(f"Created feature: {feature.code}") except Exception as e: logger.warning(f"Error creating feature {feature.code}: {e}") 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. Args: db: Database connector roleLabel: Role label to look up Returns: Role ID or None if not found """ global _roleIdCache if roleLabel in _roleIdCache: return _roleIdCache[roleLabel] # Lookup from database roles = db.getRecordset(Role, recordFilter={"roleLabel": roleLabel}) if roles: roleId = roles[0].get("id") _roleIdCache[roleLabel] = roleId return roleId logger.warning(f"Role not found: {roleLabel}") return None def _initFeatureTemplateRoleAccessRules(db: DatabaseConnector) -> None: """ Initialize AccessRules for feature-template roles. This is idempotent - only adds rules that don't exist yet. Feature-template roles need explicit AccessRules for their respective tables: - trustee-admin/accountant/client -> TrusteeOrganisation, TrusteeContract, etc. - chatbot-admin/user -> ChatSession, etc. - workflow-admin/editor/viewer -> ChatWorkflow, etc. Args: db: Database connector instance """ logger.info("Checking feature-template role AccessRules") # Define feature-specific table access featureTableAccess = { "trustee": { "tables": [ "TrusteeOrganisation", "TrusteeRole", "TrusteeAccess", "TrusteeContract", "TrusteeDocument", "TrusteePosition", "TrusteePositionDocument" ], "roles": { "trustee-admin": { "view": True, "read": AccessLevel.ALL, "create": AccessLevel.ALL, "update": AccessLevel.ALL, "delete": AccessLevel.ALL }, "trustee-accountant": { "view": True, "read": AccessLevel.GROUP, "create": AccessLevel.GROUP, "update": AccessLevel.GROUP, "delete": AccessLevel.NONE }, "trustee-client": { "view": True, "read": AccessLevel.MY, "create": AccessLevel.NONE, "update": AccessLevel.NONE, "delete": AccessLevel.NONE } } }, "chatbot": { "tables": ["ChatSession", "ChatMessage"], "roles": { "chatbot-admin": { "view": True, "read": AccessLevel.ALL, "create": AccessLevel.ALL, "update": AccessLevel.ALL, "delete": AccessLevel.ALL }, "chatbot-user": { "view": True, "read": AccessLevel.MY, "create": AccessLevel.MY, "update": AccessLevel.MY, "delete": AccessLevel.MY } } }, "chatworkflow": { "tables": ["ChatWorkflow", "AutomationDefinition"], "roles": { "workflow-admin": { "view": True, "read": AccessLevel.ALL, "create": AccessLevel.ALL, "update": AccessLevel.ALL, "delete": AccessLevel.ALL }, "workflow-editor": { "view": True, "read": AccessLevel.GROUP, "create": AccessLevel.GROUP, "update": AccessLevel.GROUP, "delete": AccessLevel.NONE }, "workflow-viewer": { "view": True, "read": AccessLevel.GROUP, "create": AccessLevel.NONE, "update": AccessLevel.NONE, "delete": AccessLevel.NONE } } }, "neutralization": { "tables": ["DataNeutraliserConfig", "DataNeutralizerAttributes"], "roles": { "neutralization-admin": { "view": True, "read": AccessLevel.ALL, "create": AccessLevel.ALL, "update": AccessLevel.ALL, "delete": AccessLevel.ALL }, "neutralization-analyst": { "view": True, "read": AccessLevel.GROUP, "create": AccessLevel.NONE, "update": AccessLevel.NONE, "delete": AccessLevel.NONE } } }, "realestate": { "tables": ["Projekt", "Parzelle", "Dokument", "Gemeinde", "Kanton", "Land"], "roles": { "realestate-admin": { "view": True, "read": AccessLevel.ALL, "create": AccessLevel.ALL, "update": AccessLevel.ALL, "delete": AccessLevel.ALL }, "realestate-manager": { "view": True, "read": AccessLevel.GROUP, "create": AccessLevel.GROUP, "update": AccessLevel.GROUP, "delete": AccessLevel.NONE }, "realestate-viewer": { "view": True, "read": AccessLevel.GROUP, "create": AccessLevel.NONE, "update": AccessLevel.NONE, "delete": AccessLevel.NONE } } } } createdCount = 0 for featureCode, featureConfig in featureTableAccess.items(): tables = featureConfig["tables"] roles = featureConfig["roles"] for roleLabel, permissions in roles.items(): roleId = _getRoleId(db, roleLabel) if not roleId: continue for tableName in tables: # Check if rule already exists existingRules = db.getRecordset( AccessRule, recordFilter={ "roleId": roleId, "context": AccessRuleContext.DATA, "item": tableName } ) if existingRules: continue # Rule already exists # Create new rule rule = AccessRule( roleId=roleId, context=AccessRuleContext.DATA, item=tableName, view=permissions["view"], read=permissions["read"], create=permissions["create"], update=permissions["update"], delete=permissions["delete"] ) db.recordCreate(AccessRule, rule) createdCount += 1 if createdCount > 0: logger.info(f"Created {createdCount} feature-template role AccessRules") else: logger.debug("All feature-template role AccessRules already exist") def initRbacRules(db: DatabaseConnector) -> None: """ Initialize RBAC rules if they don't exist. AccessRules now reference roleId (FK) instead of roleLabel. Args: db: Database connector instance """ existingRules = db.getRecordset(AccessRule) if existingRules: logger.info(f"RBAC rules already exist ({len(existingRules)} rules)") return logger.info("Initializing RBAC rules") # Create default role rules _createDefaultRoleRules(db) # Create table-specific rules (converted from UAM logic) _createTableSpecificRules(db) # Create UI context rules _createUiContextRules(db) # Create RESOURCE context rules _createResourceContextRules(db) logger.info("RBAC rules initialization completed") def _createDefaultRoleRules(db: DatabaseConnector) -> None: """ Create default role rules for generic access (item = null). Uses roleId instead of roleLabel. NOTE: No rules for "sysadmin" - SysAdmin is a flag (User.isSysAdmin), not a role! SysAdmin users bypass RBAC entirely via the isSysAdmin check in getRecordsetWithRBAC(). Args: db: Database connector instance """ defaultRules = [] # Admin Role - Group-level access (highest role-based permission) adminId = _getRoleId(db, "admin") if adminId: defaultRules.append(AccessRule( roleId=adminId, context=AccessRuleContext.DATA, item=None, view=True, read=AccessLevel.GROUP, create=AccessLevel.GROUP, update=AccessLevel.GROUP, delete=AccessLevel.NONE, )) # User Role - My records only userId = _getRoleId(db, "user") if userId: defaultRules.append(AccessRule( roleId=userId, context=AccessRuleContext.DATA, item=None, view=True, read=AccessLevel.MY, create=AccessLevel.MY, update=AccessLevel.MY, delete=AccessLevel.MY, )) # Viewer Role - Read-only group access viewerId = _getRoleId(db, "viewer") if viewerId: defaultRules.append(AccessRule( roleId=viewerId, context=AccessRuleContext.DATA, item=None, view=True, read=AccessLevel.GROUP, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) for rule in defaultRules: db.recordCreate(AccessRule, rule) logger.info(f"Created {len(defaultRules)} default role rules") def _createTableSpecificRules(db: DatabaseConnector) -> None: """ Create table-specific rules converted from UAM logic. These rules override generic rules for specific tables. Uses roleId instead of roleLabel. NOTE: No rules for "sysadmin" - SysAdmin is a flag (User.isSysAdmin), not a role! SysAdmin users bypass RBAC entirely via the isSysAdmin check in getRecordsetWithRBAC(). Args: db: Database connector instance """ tableRules = [] # Get role IDs (no sysadmin - that's a flag, not a role!) adminId = _getRoleId(db, "admin") userId = _getRoleId(db, "user") viewerId = _getRoleId(db, "viewer") # Mandate table - Only SysAdmin (flag) can access, not roles # Regular roles have no access to Mandate table if adminId: tableRules.append(AccessRule( roleId=adminId, context=AccessRuleContext.DATA, item="Mandate", view=False, read=AccessLevel.NONE, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) if userId: tableRules.append(AccessRule( roleId=userId, context=AccessRuleContext.DATA, item="Mandate", view=False, read=AccessLevel.NONE, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) if viewerId: tableRules.append(AccessRule( roleId=viewerId, context=AccessRuleContext.DATA, item="Mandate", view=False, read=AccessLevel.NONE, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # UserInDB table - Admin can manage users within group scope if adminId: tableRules.append(AccessRule( roleId=adminId, context=AccessRuleContext.DATA, item="UserInDB", view=True, read=AccessLevel.GROUP, create=AccessLevel.GROUP, update=AccessLevel.GROUP, delete=AccessLevel.GROUP, )) if userId: tableRules.append(AccessRule( roleId=userId, context=AccessRuleContext.DATA, item="UserInDB", view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.MY, delete=AccessLevel.NONE, )) if viewerId: tableRules.append(AccessRule( roleId=viewerId, context=AccessRuleContext.DATA, item="UserInDB", view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # Standard tables with typical access patterns # NOTE: No sysadmin rules - SysAdmin users bypass RBAC via isSysAdmin flag standardTables = [ "UserConnection", "DataNeutraliserConfig", "DataNeutralizerAttributes", "ChatWorkflow", "Prompt", "Projekt", "Parzelle", "Dokument", "Gemeinde", "Kanton", "Land", "TrusteeOrganisation", "TrusteeRole", "TrusteeAccess", "TrusteeContract", "TrusteeDocument", "TrusteePosition", "TrusteePositionDocument" ] for table in standardTables: # Admin gets full group-level access (highest role-based permission) if adminId: tableRules.append(AccessRule( roleId=adminId, context=AccessRuleContext.DATA, item=table, view=True, read=AccessLevel.GROUP, create=AccessLevel.GROUP, update=AccessLevel.GROUP, delete=AccessLevel.GROUP, )) if userId: tableRules.append(AccessRule( roleId=userId, context=AccessRuleContext.DATA, item=table, view=True, read=AccessLevel.MY, create=AccessLevel.MY, update=AccessLevel.MY, delete=AccessLevel.MY, )) if viewerId: tableRules.append(AccessRule( roleId=viewerId, context=AccessRuleContext.DATA, item=table, view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # AuthEvent table - Audit logs (no delete allowed for audit integrity!) # SysAdmin can delete via isSysAdmin bypass, but regular admins cannot if adminId: tableRules.append(AccessRule( roleId=adminId, context=AccessRuleContext.DATA, item="AuthEvent", view=True, read=AccessLevel.ALL, # Admin can see all auth events for security monitoring create=AccessLevel.NONE, # Events are system-generated update=AccessLevel.NONE, # Audit logs are immutable delete=AccessLevel.NONE, # NO delete - audit integrity! )) if userId: tableRules.append(AccessRule( roleId=userId, context=AccessRuleContext.DATA, item="AuthEvent", view=True, read=AccessLevel.MY, # Users can see their own auth events create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) if viewerId: tableRules.append(AccessRule( roleId=viewerId, context=AccessRuleContext.DATA, item="AuthEvent", view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # Create all table-specific rules for rule in tableRules: db.recordCreate(AccessRule, rule) logger.info(f"Created {len(tableRules)} table-specific rules") def _createUiContextRules(db: DatabaseConnector) -> None: """ Create UI context rules for controlling UI element visibility. Uses roleId instead of roleLabel. NOTE: No sysadmin rules - SysAdmin users bypass RBAC via isSysAdmin flag. Args: db: Database connector instance """ uiRules = [] # All roles get full UI access by default (no sysadmin - that's a flag) for roleLabel in ["admin", "user", "viewer"]: roleId = _getRoleId(db, roleLabel) if roleId: uiRules.append(AccessRule( roleId=roleId, context=AccessRuleContext.UI, item=None, view=True, read=None, create=None, update=None, delete=None, )) for rule in uiRules: db.recordCreate(AccessRule, rule) logger.info(f"Created {len(uiRules)} UI context rules") def _createResourceContextRules(db: DatabaseConnector) -> None: """ Create RESOURCE context rules for controlling resource access. Uses roleId instead of roleLabel. NOTE: No sysadmin rules - SysAdmin users bypass RBAC via isSysAdmin flag. Args: db: Database connector instance """ resourceRules = [] # All roles get full resource access by default (no sysadmin - that's a flag) for roleLabel in ["admin", "user", "viewer"]: roleId = _getRoleId(db, roleLabel) if roleId: resourceRules.append(AccessRule( roleId=roleId, context=AccessRuleContext.RESOURCE, item=None, view=True, read=None, create=None, update=None, delete=None, )) for rule in resourceRules: db.recordCreate(AccessRule, rule) logger.info(f"Created {len(resourceRules)} RESOURCE context rules") def assignInitialUserMemberships( db: DatabaseConnector, mandateId: str, adminUserId: str, eventUserId: str ) -> None: """ Assign initial memberships to admin and event users via UserMandate + UserMandateRole. This is the NEW multi-tenant way of assigning roles. NOTE: SysAdmin is a flag (User.isSysAdmin), not a role. Initial users get the "admin" role within the root mandate, plus they have isSysAdmin=True for system-level access. Args: db: Database connector instance mandateId: Root mandate ID adminUserId: Admin user ID eventUserId: Event user ID """ # Use "admin" role for mandate membership (SysAdmin is a flag, not a role!) adminRoleId = _getRoleId(db, "admin") if not adminRoleId: logger.warning("Admin role not found, skipping membership assignment") return for userId, userName in [(adminUserId, "admin"), (eventUserId, "event")]: # Check if UserMandate already exists existingMemberships = db.getRecordset( UserMandate, recordFilter={"userId": userId, "mandateId": mandateId} ) if existingMemberships: userMandateId = existingMemberships[0].get("id") logger.debug(f"UserMandate already exists for {userName} user") else: # Create UserMandate userMandate = UserMandate( userId=userId, mandateId=mandateId, enabled=True ) createdMembership = db.recordCreate(UserMandate, userMandate) userMandateId = createdMembership.get("id") logger.info(f"Created UserMandate for {userName} user with ID {userMandateId}") # Check if UserMandateRole already exists existingRoles = db.getRecordset( UserMandateRole, recordFilter={"userMandateId": userMandateId, "roleId": adminRoleId} ) if not existingRoles: # Create UserMandateRole with "admin" role userMandateRole = UserMandateRole( userMandateId=userMandateId, roleId=adminRoleId ) db.recordCreate(UserMandateRole, userMandateRole) logger.info(f"Assigned admin role to {userName} user in mandate") def _getPasswordHash(password: Optional[str]) -> Optional[str]: """ Hash a password using Argon2. Args: password: Plain text password Returns: Hashed password or None if password is None """ if password is None: return None return pwdContext.hash(password) def _applyDatabaseOptimizations(db: DatabaseConnector) -> None: """ Apply multi-tenant database optimizations after bootstrap. Creates indexes, immutable triggers, and foreign key constraints for the multi-tenant junction tables. All operations are idempotent. Args: db: Database connector instance """ try: from modules.shared.dbMultiTenantOptimizations import applyMultiTenantOptimizations result = applyMultiTenantOptimizations(db) if result.get("errors"): for error in result["errors"]: logger.warning(f"DB optimization error: {error}") else: totalCreated = ( result.get("indexesCreated", 0) + result.get("triggersCreated", 0) + result.get("foreignKeysCreated", 0) ) if totalCreated > 0: logger.info( f"Applied DB optimizations: {result['indexesCreated']} indexes, " f"{result['triggersCreated']} triggers, " f"{result['foreignKeysCreated']} foreign keys" ) # If nothing created, optimizations were already applied (idempotent) except ImportError as e: logger.warning(f"DB optimizations module not available: {e}") except Exception as e: # Don't fail bootstrap if optimizations fail logger.warning(f"Failed to apply DB optimizations (non-critical): {e}")