# 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 UND isPlatformAdmin=True (statt einer Rolle) """ 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.datamodelUtils import coerce_text_multilingual from modules.datamodels.datamodelUam import AccessLevel from modules.datamodels.datamodelMembership import ( UserMandate, UserMandateRole, ) logger = logging.getLogger(__name__) # Password-Hashing pwdContext = CryptContext(schemes=["argon2"], deprecated="auto") # Cache für Role-IDs (roleLabel -> roleId) _roleIdCache: Dict[str, str] = {} _bootstrapDone: bool = False def initBootstrap(db: DatabaseConnector) -> None: """ Main bootstrap entry point - initializes all system components. Idempotent: runs only once per process regardless of how many callers invoke it. Args: db: Database connector instance """ global _bootstrapDone if _bootstrapDone: return _bootstrapDone = True logger.info("Starting system bootstrap") # Initialize root mandate mandateId = initRootMandate(db) # Migrate existing mandate records: description -> label _migrateMandateDescriptionToLabel(db) _migrateMandateNameLabelSlugRules(db) # Clean up duplicate roles and fix corrupted templates FIRST _deduplicateRoles(db) # Initialize system role TEMPLATES (mandateId=None, isSystemRole=True) initRoles(db) # Initialize RBAC rules for template roles initRbacRules(db) # Copy system template roles to ALL mandates as mandate-instance roles # This also serves as migration for existing mandates that don't have instance roles yet _ensureAllMandatesHaveSystemRoles(db) # Migration: eliminate the legacy ``sysadmin`` role in root mandate # (replaced by ``User.isPlatformAdmin`` flag — see # wiki/c-work/4-done/2026-04-sysadmin-authority-split.md). # Idempotent: noop after first successful run. if mandateId: _migrateAndDropSysAdminRole(db, mandateId) # Ensure UI rules for navigation items (admin/user/viewer roles) _ensureUiContextRules(db) # Initialize admin user adminUserId = initAdminUser(db, mandateId) # Initialize event user eventUserId = initEventUser(db, mandateId) # Assign initial user memberships (via UserMandate + UserMandateRole) # Uses mandate-instance roles (not template roles) if adminUserId and eventUserId and mandateId: assignInitialUserMemberships(db, mandateId, adminUserId, eventUserId) # Apply multi-tenant database optimizations (indexes, triggers, FKs) _applyDatabaseOptimizations(db) # Initialize root mandate feature instances if mandateId: initRootMandateFeatures(db, mandateId) # Remove feature instances for features that no longer exist in the codebase _cleanupRemovedFeatureInstances(db) # Initialize billing settings for root mandate if mandateId: initRootMandateBilling(mandateId) # Initialize subscription for root mandate if mandateId: _initRootMandateSubscription(mandateId) # Auto-provision Stripe Products/Prices for paid plans (idempotent) _bootstrapStripePrices() # Purge soft-deleted mandates past 30-day retention try: from modules.interfaces.interfaceDbApp import getRootInterface rootIf = getRootInterface() rootIf.purgeExpiredMandates(retentionDays=30) except Exception as e: logger.warning(f"Mandate retention purge failed: {e}") # Bootstrap system workflow templates for graphical editor _bootstrapSystemTemplates(db) # Ensure billing settings and accounts exist for all mandates _bootstrapBilling() def _bootstrapBilling() -> None: """ Ensure billing settings and accounts exist for all mandates. Idempotent: only creates missing settings/accounts. """ try: from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRootInterface billingInterface = getBillingRootInterface() settingsCreated = billingInterface.ensureAllMandateSettingsExist() if settingsCreated > 0: logger.info(f"Billing bootstrap: Created {settingsCreated} missing mandate billing settings") accountsCreated = billingInterface.ensureAllUserAccountsExist() if accountsCreated > 0: logger.info(f"Billing bootstrap: Created {accountsCreated} missing user accounts") except Exception as e: logger.warning(f"Billing bootstrap failed (non-critical): {e}") def _bootstrapSystemTemplates(db: DatabaseConnector) -> None: """ Seed platform-wide workflow templates (templateScope='system', mandateId=None). Idempotent: skips if templates with the same label already exist. """ try: from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase import uuid greenfieldDb = DatabaseConnector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), dbDatabase=graphicalEditorDatabase, dbUser=APP_CONFIG.get("DB_USER"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), ) greenfieldDb._ensureTableExists(AutoWorkflow) existing = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={ "isTemplate": True, "templateScope": "system", }) existingLabels = {r.get("label") if isinstance(r, dict) else getattr(r, "label", "") for r in (existing or [])} templates = _buildSystemTemplates() created = 0 for tpl in templates: if tpl["label"] in existingLabels: continue tpl["id"] = str(uuid.uuid4()) greenfieldDb.recordCreate(AutoWorkflow, tpl) created += 1 if created: logger.info(f"Bootstrapped {created} system workflow template(s)") greenfieldDb.close() except Exception as e: logger.warning(f"System workflow template bootstrap failed: {e}") def _buildSystemTemplates(): """Build the graph definitions for platform system templates.""" return [ { "label": "Personal Assistant: E-Mail-Antwort-Drafting", "mandateId": None, "featureInstanceId": None, "isTemplate": True, "templateScope": "system", "sharedReadOnly": True, "active": False, "graph": { "nodes": [ {"id": "n1", "type": "trigger.schedule", "x": 50, "y": 200, "title": "Täglicher Check", "parameters": {}}, {"id": "n2", "type": "email.checkEmail", "x": 300, "y": 200, "title": "Mailbox prüfen", "parameters": {}}, { "id": "n3", "type": "flow.loop", "x": 550, "y": 200, "title": "Pro E-Mail", "parameters": { "items": {"type": "ref", "nodeId": "n2", "path": ["emails"]}, "level": "auto", "concurrency": 1, }, }, {"id": "n4", "type": "ai.prompt", "x": 800, "y": 200, "title": "Analyse: Antwort nötig?", "parameters": {}}, {"id": "n5", "type": "flow.ifElse", "x": 1050, "y": 200, "title": "Antwort nötig?", "parameters": {}}, {"id": "n6", "type": "ai.prompt", "x": 1300, "y": 100, "title": "Kontext abrufen & Antwort formulieren", "parameters": {}}, {"id": "n7", "type": "email.draftEmail", "x": 1550, "y": 100, "title": "Draft erstellen", "parameters": {}}, ], "connections": [ {"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0}, {"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0}, {"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0}, {"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0}, {"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0}, {"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0}, ], }, "invocations": [{"type": "schedule", "cronExpression": "0 8 * * 1-5"}], }, { "label": "Treuhand: PDF-Klassifizierung & Trustee-Import", "mandateId": None, "featureInstanceId": None, "isTemplate": True, "templateScope": "system", "sharedReadOnly": True, "active": False, "graph": { "nodes": [ {"id": "n1", "type": "trigger.schedule", "x": 50, "y": 200, "title": "Geplanter Import", "parameters": {}}, {"id": "n2", "type": "sharepoint.listFiles", "x": 300, "y": 200, "title": "SharePoint Ordner lesen", "parameters": {}}, { "id": "n3", "type": "flow.loop", "x": 550, "y": 200, "title": "Pro Dokument", "parameters": { "items": {"type": "ref", "nodeId": "n2", "path": ["files"]}, "level": "auto", "concurrency": 1, }, }, {"id": "n4", "type": "sharepoint.readFile", "x": 800, "y": 200, "title": "PDF-Inhalt lesen", "parameters": {}}, {"id": "n5", "type": "ai.prompt", "x": 1050, "y": 200, "title": "Typ klassifizieren (Rechnung, Beleg, Bankauszug, Vertrag, etc.)", "parameters": {}}, {"id": "n6", "type": "trustee.extractFromFiles", "x": 1300, "y": 200, "title": "Dokument extrahieren", "parameters": {}}, {"id": "n7", "type": "trustee.processDocuments", "x": 1550, "y": 200, "title": "In Trustee einlesen", "parameters": {}}, ], "connections": [ {"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0}, {"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0}, {"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0}, {"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0}, {"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0}, {"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0}, ], }, "invocations": [{"type": "schedule", "cronExpression": "0 7 * * 1-5"}], }, ] def initRootMandateFeatures(db: DatabaseConnector, mandateId: str) -> None: """ Create feature instances for root mandate. Dynamically discovers all feature modules with autoCreateInstance=True. Args: db: Database connector instance mandateId: Root mandate ID """ from modules.datamodels.datamodelFeatures import FeatureInstance from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.system.registry import loadFeatureMainModules logger.info("Initializing root mandate features") # Dynamically discover features with autoCreateInstance=True featuresToCreate = [] mainModules = loadFeatureMainModules() for featureName, module in mainModules.items(): if hasattr(module, "getFeatureDefinition"): try: from modules.shared.i18nRegistry import resolveText featureDef = module.getFeatureDefinition() if featureDef.get("autoCreateInstance", False): featureCode = featureDef.get("code", featureName) featureLabel = resolveText(featureDef.get("label", featureName)) featuresToCreate.append({"code": featureCode, "label": featureLabel}) logger.debug(f"Feature '{featureCode}' marked for auto-creation in root mandate") except Exception as e: logger.warning(f"Could not read feature definition for '{featureName}': {e}") if not featuresToCreate: logger.info("No features marked for auto-creation in root mandate") return featureInterface = getFeatureInterface(db) for featureConfig in featuresToCreate: featureCode = featureConfig["code"] featureLabel = featureConfig["label"] try: # Check if instance already exists existingInstances = db.getRecordset( FeatureInstance, recordFilter={ "mandateId": mandateId, "featureCode": featureCode } ) if existingInstances: logger.info(f"Feature instance for '{featureCode}' already exists in root mandate") continue # Create feature instance with template roles copied instance = featureInterface.createFeatureInstance( featureCode=featureCode, mandateId=mandateId, label=featureLabel, enabled=True, copyTemplateRoles=True ) if instance: instanceId = instance.get("id") if isinstance(instance, dict) else instance.id logger.info(f"Created feature instance '{instanceId}' for '{featureCode}' in root mandate") else: logger.warning(f"Failed to create feature instance for '{featureCode}'") except Exception as e: logger.error(f"Error creating feature instance for '{featureCode}': {e}") logger.info("Root mandate features initialization completed") def _cleanupRemovedFeatureInstances(db: DatabaseConnector) -> None: """Remove feature instances whose featureCode no longer exists in the codebase.""" from modules.datamodels.datamodelFeatures import FeatureInstance from modules.system.registry import loadFeatureMainModules mainModules = loadFeatureMainModules() activeCodes = set() for featureName, module in mainModules.items(): if hasattr(module, "getFeatureDefinition"): try: featureDef = module.getFeatureDefinition() activeCodes.add(featureDef.get("code", featureName)) except Exception: pass allInstances = db.getRecordset(FeatureInstance) for inst in allInstances: code = inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", None) instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None) if code and code not in activeCodes: try: db.recordDelete(FeatureInstance, str(instId)) logger.info(f"Removed orphaned feature instance '{instId}' (featureCode='{code}')") except Exception as e: logger.warning(f"Could not remove orphaned feature instance '{instId}': {e}") def initRootMandate(db: DatabaseConnector) -> Optional[str]: """ Creates the Root mandate if it doesn't exist. Root mandate is identified by name='root' AND isSystem=True. Args: db: Database connector instance Returns: Mandate ID if created or found, None otherwise """ # Find existing root mandate by name AND isSystem flag existingMandates = db.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True}) if existingMandates: mandateId = existingMandates[0].get("id") logger.info(f"Root mandate already exists with ID {mandateId}") return mandateId # Check for legacy root mandates (name="Root" without isSystem flag) and migrate legacyMandates = db.getRecordset(Mandate, recordFilter={"name": "Root"}) if legacyMandates: mandateId = legacyMandates[0].get("id") logger.info(f"Migrating legacy Root mandate {mandateId}: setting name='root', isSystem=True") db.recordModify(Mandate, mandateId, {"name": "root", "isSystem": True}) return mandateId logger.info("Creating Root mandate") rootMandate = Mandate(name="root", label="Root", isSystem=True, enabled=True) createdMandate = db.recordCreate(Mandate, rootMandate) mandateId = createdMandate.get("id") logger.info(f"Root mandate created with ID {mandateId}") return mandateId def _migrateMandateDescriptionToLabel(db: DatabaseConnector) -> None: """ Migration: Rename 'description' field to 'label' in all Mandate records. Copies existing 'description' values to 'label' and removes the old field. Safe to run multiple times (idempotent). """ allMandates = db.getRecordset(Mandate) migratedCount = 0 for mandateRecord in allMandates: mandateId = mandateRecord.get("id") hasDescription = "description" in mandateRecord and mandateRecord.get("description") is not None hasLabel = "label" in mandateRecord and mandateRecord.get("label") is not None if hasDescription and not hasLabel: # Copy description to label updateData = {"label": mandateRecord["description"]} db.recordModify(Mandate, mandateId, updateData) migratedCount += 1 logger.info(f"Migrated mandate {mandateId}: description -> label") if migratedCount > 0: logger.info(f"Migrated {migratedCount} mandate(s) from description to label") else: logger.debug("No mandate description->label migration needed") def _migrateMandateNameLabelSlugRules(db: DatabaseConnector) -> None: """ Migration: normalize Mandate.name to the slug rules ([a-z0-9-], length 2..32, single hyphen segments) and ensure Mandate.label is non-empty. Rules (see wiki/c-work/1-plan/2026-04-mandate-name-label-logic.md): 1. If ``label`` is empty/None → set ``label := name`` (or "Mandate" when both empty). 2. If ``name`` is not a valid slug, or collides with an earlier mandate in stable id order, allocate a unique slug from the (now non-empty) ``label`` using ``slugifyMandateName`` + ``allocateUniqueMandateSlug``. Idempotent: a second run is a no-op because all valid names stay valid and stay unique. Each rename and label fill-in is logged for audit. """ from modules.shared.mandateNameUtils import ( allocateUniqueMandateSlug, isValidMandateName, slugifyMandateName, ) allRows = db.getRecordset(Mandate) if not allRows: return sortedRows = sorted(allRows, key=lambda r: str(r.get("id", ""))) used: set[str] = set() labelFills = 0 nameRenames: list[tuple[str, str, str]] = [] for rec in sortedRows: mid = rec.get("id") if not mid: continue name = (rec.get("name") or "").strip() labelRaw = rec.get("label") label = (labelRaw or "").strip() if labelRaw is not None else "" if not label: label = name if name else "Mandate" db.recordModify(Mandate, mid, {"label": label}) labelFills += 1 logger.info(f"Mandate {mid}: filled empty label with '{label}'") nameFits = isValidMandateName(name) nameCollides = name in used if nameFits and not nameCollides: used.add(name) continue base = slugifyMandateName(label) or "mn" newName = allocateUniqueMandateSlug(base, used) used.add(newName) if newName != name: db.recordModify(Mandate, mid, {"name": newName}) nameRenames.append((str(mid), name, newName)) logger.info(f"Mandate {mid}: renamed name '{name}' -> '{newName}'") if labelFills or nameRenames: logger.info( "Mandate name/label slug migration: %d label fill-in(s), %d name rename(s)", labelFills, len(nameRenames), ) else: logger.debug("No mandate name/label slug migration needed") def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[str]: """ Creates the Admin user if it doesn't exist. Admin user gets BOTH platform flags: - isSysAdmin=True (Infrastructure: logs/tokens/DB-health) - isPlatformAdmin=True (Cross-Mandate-Governance: user/mandate/RBAC mgmt) 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") updates: Dict[str, bool] = {} if not existingUsers[0].get("isSysAdmin", False): updates["isSysAdmin"] = True if not existingUsers[0].get("isPlatformAdmin", False): updates["isPlatformAdmin"] = True if updates: logger.info(f"Updating admin user {userId} platform flags: {updates}") db.recordModify(UserInDB, userId, updates) 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, isPlatformAdmin=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 infrastructure-level operations (system events, internal callbacks). It does NOT need cross-mandate governance, so isPlatformAdmin is left False. 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") # Defensive: revoke any historic platform-admin grant on the event user if existingUsers[0].get("isPlatformAdmin", False): logger.warning( f"Event user {userId} had isPlatformAdmin=True; " f"revoking (event user is infrastructure-only)" ) db.recordModify(UserInDB, userId, {"isPlatformAdmin": False}) 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, isPlatformAdmin=False, 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: There is no platform-level "sysadmin" role any more — platform authority lives on the User record via ``isSysAdmin`` and ``isPlatformAdmin``. These template roles (admin/user/viewer) are purely for mandate/feature-level access control. Args: db: Database connector instance """ logger.info("Initializing roles") global _roleIdCache _roleIdCache = {} standardRoles = [ Role( roleLabel="admin", description=coerce_text_multilingual("Administrator - Benutzer und Ressourcen im Mandanten verwalten"), mandateId=None, # Global template role featureInstanceId=None, featureCode=None, isSystemRole=True ), Role( roleLabel="user", description=coerce_text_multilingual("Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze"), mandateId=None, # Global template role featureInstanceId=None, featureCode=None, isSystemRole=True ), Role( roleLabel="viewer", description=coerce_text_multilingual("Betrachter - Nur-Lese-Zugriff auf Gruppen-Datensätze"), mandateId=None, # Global template role featureInstanceId=None, featureCode=None, isSystemRole=True ), ] # Check specifically for system template roles: # mandateId=NULL, isSystemRole=True, featureCode=NULL # Feature templates share the same labels but have featureCode set! allTemplates = db.getRecordset( Role, recordFilter={"mandateId": None, "isSystemRole": True} ) # Filter for SYSTEM templates only (featureCode=None), not feature templates systemTemplates = [r for r in allTemplates if r.get("featureCode") is None] existingTemplateLabels = {role.get("roleLabel"): role.get("id") for role in systemTemplates} for role in standardRoles: if role.roleLabel not in existingTemplateLabels: try: createdRole = db.recordCreate(Role, role) _roleIdCache[role.roleLabel] = createdRole.get("id") logger.info(f"Created template 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] = existingTemplateLabels[role.roleLabel] logger.info("Roles initialization completed") def _deduplicateRoles(db: DatabaseConnector) -> None: """ Remove duplicate roles (same roleLabel + mandateId + featureInstanceId). Keeps the oldest role (smallest ID) and deletes newer duplicates. """ allRoles = db.getRecordset(Role) # Group by (roleLabel, mandateId, featureInstanceId, featureCode) # featureCode is essential: system template ('admin', None, None, None) # must NOT be grouped with feature template ('admin', None, None, '') groups: dict = {} for role in allRoles: key = (role.get("roleLabel"), role.get("mandateId"), role.get("featureInstanceId"), role.get("featureCode")) if key not in groups: groups[key] = [] groups[key].append(role) deletedCount = 0 for key, roles in groups.items(): if len(roles) > 1: # Sort by id to keep the first (oldest), delete the rest roles.sort(key=lambda r: r.get("id", "")) for duplicate in roles[1:]: try: db.recordDelete(Role, duplicate.get("id")) deletedCount += 1 logger.info(f"Deleted duplicate role: label='{key[0]}', mandateId={key[1]}, id={duplicate.get('id')}") except Exception as e: logger.warning(f"Failed to delete duplicate role {duplicate.get('id')}: {e}") if deletedCount > 0: logger.info(f"Deduplicated roles: removed {deletedCount} duplicates") # Migration: Fix isSystemRole flags fixedMandateCount = 0 fixedTemplateCount = 0 for role in allRoles: # Mandate-level roles should NOT be isSystemRole=True if role.get("mandateId") is not None and role.get("isSystemRole") is True: try: db.recordModify(Role, role.get("id"), {"isSystemRole": False}) fixedMandateCount += 1 except Exception as e: logger.warning(f"Failed to fix mandate role {role.get('id')}: {e}") # Template roles (mandateId=None, no featureCode) MUST be isSystemRole=True if role.get("mandateId") is None and role.get("featureCode") is None and role.get("isSystemRole") is not True: try: db.recordModify(Role, role.get("id"), {"isSystemRole": True}) fixedTemplateCount += 1 except Exception as e: logger.warning(f"Failed to fix template role {role.get('id')}: {e}") if fixedMandateCount > 0: logger.info(f"Fixed {fixedMandateCount} mandate-level roles: isSystemRole → False") if fixedTemplateCount > 0: logger.info(f"Fixed {fixedTemplateCount} template roles: isSystemRole → True") def _ensureAllMandatesHaveSystemRoles(db: DatabaseConnector) -> None: """ Ensure all existing mandates have system-instance roles. Serves as both initial setup and migration for existing mandates. """ allMandates = db.getRecordset(Mandate) if not allMandates: logger.info("No mandates found, skipping system role copy") return logger.info(f"Ensuring system roles for {len(allMandates)} mandates...") for mandate in allMandates: mandateId = mandate.get("id") copiedCount = copySystemRolesToMandate(db, mandateId) if copiedCount > 0: logger.info(f"Copied {copiedCount} system roles to mandate {mandateId}") def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int: """ Copy system template roles (mandateId=None, isSystemRole=True) to a mandate as mandate-instance roles. Also copies all AccessRules for each role. This is analogous to how feature template roles are copied to feature instances. Each mandate gets its own instances of admin/user/viewer with their AccessRules. Args: db: Database connector instance mandateId: Target mandate ID Returns: Number of roles copied """ import uuid as _uuid # Find system template roles (global: mandateId=NULL, isSystemRole=True) templateRoles = db.getRecordset( Role, recordFilter={"isSystemRole": True, "mandateId": None} ) if not templateRoles: logger.warning(f"No system template roles found (mandateId IS NULL, isSystemRole=True)") return 0 # Check which mandate-level roles already exist for this mandate existingMandateRoles = db.getRecordset( Role, recordFilter={"mandateId": mandateId, "featureInstanceId": None} ) existingLabels = {r.get("roleLabel") for r in existingMandateRoles} logger.info(f"copySystemRolesToMandate: mandate={mandateId}, templates={len(templateRoles)}, existing={len(existingMandateRoles)}, labels={existingLabels}") # Load all AccessRules for template roles templateRoleIds = [r.get("id") for r in templateRoles] rulesByRoleId = {} for roleId in templateRoleIds: rules = db.getRecordset(AccessRule, recordFilter={"roleId": roleId}) rulesByRoleId[roleId] = rules copiedCount = 0 for templateRole in templateRoles: roleLabel = templateRole.get("roleLabel") # Skip if mandate already has this role if roleLabel in existingLabels: logger.debug(f"Mandate {mandateId} already has role '{roleLabel}', skipping") continue newRoleId = str(_uuid.uuid4()) # Create mandate-instance role newRole = Role( id=newRoleId, roleLabel=roleLabel, description=coerce_text_multilingual(templateRole.get("description", {})), mandateId=mandateId, featureInstanceId=None, featureCode=None, isSystemRole=False # Mandate-level role, not a system template ) db.recordCreate(Role, newRole.model_dump()) # Copy AccessRules templateRules = rulesByRoleId.get(templateRole.get("id"), []) for rule in templateRules: newRule = AccessRule( id=str(_uuid.uuid4()), roleId=newRoleId, context=rule.get("context"), item=rule.get("item"), view=rule.get("view", False), read=rule.get("read"), create=rule.get("create"), update=rule.get("update"), delete=rule.get("delete") ) db.recordCreate(AccessRule, newRule.model_dump()) copiedCount += 1 logger.info(f"Copied system role '{roleLabel}' to mandate {mandateId} with {len(templateRules)} AccessRules") if copiedCount > 0: logger.info(f"Copied {copiedCount} system roles to mandate {mandateId}") return copiedCount def _migrateAndDropSysAdminRole(db: DatabaseConnector, mandateId: str) -> None: """ One-shot migration: eliminate the legacy ``sysadmin`` role in the root mandate. Authority semantics moved to two orthogonal flags on User: - ``isSysAdmin`` → Infrastructure-Operator (RBAC bypass) - ``isPlatformAdmin`` → Cross-Mandate-Governance (no bypass) Migration steps (idempotent): 1. Find sysadmin role(s) in root mandate. If none exist → done. 2. For every UserMandateRole row referencing such a role: set ``user.isPlatformAdmin = True`` (preserves cross-mandate authority). 3. Delete those UserMandateRole rows. 4. Delete AccessRules attached to the sysadmin role. 5. Delete the sysadmin Role record. Args: db: Database connector instance mandateId: Root mandate ID """ sysadminRoles = db.getRecordset( Role, recordFilter={"roleLabel": "sysadmin", "mandateId": mandateId, "featureInstanceId": None}, ) if not sysadminRoles: logger.debug("Sysadmin role migration: no legacy sysadmin role present, nothing to do") return sysadminRoleIds = [str(r.get("id")) for r in sysadminRoles if r.get("id")] logger.warning( f"Sysadmin role migration: found {len(sysadminRoleIds)} legacy sysadmin role(s) " f"in root mandate, migrating to isPlatformAdmin flag" ) # 1) Promote every holder to isPlatformAdmin=True promoted = 0 for sysadminRoleId in sysadminRoleIds: umRoleRows = db.getRecordset( UserMandateRole, recordFilter={"roleId": sysadminRoleId} ) userMandateIds = [str(r.get("userMandateId")) for r in umRoleRows if r.get("userMandateId")] if not userMandateIds: continue # Resolve userIds via UserMandate userIds = set() for umId in userMandateIds: ums = db.getRecordset(UserMandate, recordFilter={"id": umId}) for um in ums: uid = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None) if uid: userIds.add(str(uid)) for userId in userIds: users = db.getRecordset(UserInDB, recordFilter={"id": userId}) if not users: continue current = users[0].get("isPlatformAdmin", False) if not current: db.recordModify(UserInDB, userId, {"isPlatformAdmin": True}) promoted += 1 logger.warning( f"Sysadmin role migration: granted isPlatformAdmin=True to user {userId}" ) # 2) Delete UserMandateRole rows for umRow in umRoleRows: rowId = umRow.get("id") if isinstance(umRow, dict) else getattr(umRow, "id", None) if rowId: try: db.recordDelete(UserMandateRole, str(rowId)) except Exception as e: logger.error(f"Sysadmin role migration: failed to drop UserMandateRole {rowId}: {e}") # 3) Delete AccessRules accessRules = db.getRecordset(AccessRule, recordFilter={"roleId": sysadminRoleId}) for ar in accessRules: arId = ar.get("id") if isinstance(ar, dict) else getattr(ar, "id", None) if arId: try: db.recordDelete(AccessRule, str(arId)) except Exception as e: logger.error(f"Sysadmin role migration: failed to drop AccessRule {arId}: {e}") # 4) Delete the Role try: db.recordDelete(Role, sysadminRoleId) except Exception as e: logger.error(f"Sysadmin role migration: failed to drop Role {sysadminRoleId}: {e}") logger.warning( f"Sysadmin role migration: completed; promoted {promoted} user(s) to isPlatformAdmin" ) 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 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)") # Still ensure UI and DATA rules exist (may have been added later) _ensureUiContextRules(db) _ensureDataContextRules(db) 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: There is no sysadmin role any more — platform/infra authority is governed by the ``isSysAdmin`` / ``isPlatformAdmin`` flags on the User record. These default rules cover admin/user/viewer template roles. 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 - No access rights (mandate membership marker only) # Users get their actual permissions from feature-instance-level roles # 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: There is no sysadmin role any more — platform/infra authority is governed by the ``isSysAdmin`` / ``isPlatformAdmin`` flags on the User record. These table-specific rules cover admin/user/viewer template roles. Args: db: Database connector instance """ tableRules = [] # Get role IDs for template roles (platform authority lives on User flags) adminId = _getRoleId(db, "admin") userId = _getRoleId(db, "user") viewerId = _getRoleId(db, "viewer") # ========================================================================== # DATA TABLE RULES - Using semantic namespace structure # ========================================================================== # Namespace structure: # - data.uam.* → User Access Management (mandantenübergreifend) # - data.chat.* → Chat/AI-Daten (benutzer-eigen, kein Mandantenkontext) # - data.files.* → Dateien (benutzer-eigen) # - data.feature.* → Mandanten-/Feature-spezifische Daten (dynamisch) # # GROUP-Berechtigung: # - data.uam.*: GROUP filtert nach Mandant (via UserMandate) # - data.chat.*, data.files.*: GROUP = MY (benutzer-eigen) # ========================================================================== # ------------------------------------------------------------------------- # UAM Namespace - User Access Management # ------------------------------------------------------------------------- # 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="data.uam.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="data.uam.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="data.uam.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="data.uam.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="data.uam.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="data.uam.UserInDB", view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # UserConnection: All users only MY-level CRUD (UAM namespace) for roleId in [adminId, userId]: if roleId: tableRules.append(AccessRule( roleId=roleId, context=AccessRuleContext.DATA, item="data.uam.UserConnection", 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="data.uam.UserConnection", view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # Invitation: Standard group-level access (UAM namespace) if adminId: tableRules.append(AccessRule( roleId=adminId, context=AccessRuleContext.DATA, item="data.uam.Invitation", 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="data.uam.Invitation", 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="data.uam.Invitation", view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # AuthEvent table - Audit logs (UAM namespace, no delete for audit integrity!) if adminId: tableRules.append(AccessRule( roleId=adminId, context=AccessRuleContext.DATA, item="data.uam.AuthEvent", view=True, read=AccessLevel.ALL, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) if userId: tableRules.append(AccessRule( roleId=userId, context=AccessRuleContext.DATA, item="data.uam.AuthEvent", view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) if viewerId: tableRules.append(AccessRule( roleId=viewerId, context=AccessRuleContext.DATA, item="data.uam.AuthEvent", view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # ------------------------------------------------------------------------- # Chat Namespace - User-owned, no mandate context # ------------------------------------------------------------------------- # Prompt: Only MY-level access (user-owned, no mandate context) # Each user manages only their own prompts for roleId in [adminId, userId]: if roleId: tableRules.append(AccessRule( roleId=roleId, context=AccessRuleContext.DATA, item="data.chat.Prompt", 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="data.chat.Prompt", view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # ChatWorkflow: Only MY-level access (user-owned, no mandate context) for roleId in [adminId, userId]: if roleId: tableRules.append(AccessRule( roleId=roleId, context=AccessRuleContext.DATA, item="data.chat.ChatWorkflow", 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="data.chat.ChatWorkflow", view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # ------------------------------------------------------------------------- # Files Namespace - User-owned, no mandate context # ------------------------------------------------------------------------- # FileItem: Only MY-level access (user-owned) for roleId in [adminId, userId]: if roleId: tableRules.append(AccessRule( roleId=roleId, context=AccessRuleContext.DATA, item="data.files.FileItem", 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="data.files.FileItem", view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # ------------------------------------------------------------------------- # Billing Namespace - Billing accounts and transactions # ------------------------------------------------------------------------- # BillingAccount: User sees own accounts (MY), Admin sees all in mandate (GROUP) # Each user must see all billing accounts assigned to them if adminId: tableRules.append(AccessRule( roleId=adminId, context=AccessRuleContext.DATA, item="data.billing.BillingAccount", view=True, read=AccessLevel.GROUP, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) if userId: tableRules.append(AccessRule( roleId=userId, context=AccessRuleContext.DATA, item="data.billing.BillingAccount", view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) if viewerId: tableRules.append(AccessRule( roleId=viewerId, context=AccessRuleContext.DATA, item="data.billing.BillingAccount", view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # BillingTransaction: User sees own transactions (MY), Admin sees all in mandate (GROUP) if adminId: tableRules.append(AccessRule( roleId=adminId, context=AccessRuleContext.DATA, item="data.billing.BillingTransaction", view=True, read=AccessLevel.GROUP, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) if userId: tableRules.append(AccessRule( roleId=userId, context=AccessRuleContext.DATA, item="data.billing.BillingTransaction", view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) if viewerId: tableRules.append(AccessRule( roleId=viewerId, context=AccessRuleContext.DATA, item="data.billing.BillingTransaction", view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # BillingSettings: Only admin can view mandate settings (read-only) # SysAdmin (flag) manages settings, roles only read if adminId: tableRules.append(AccessRule( roleId=adminId, context=AccessRuleContext.DATA, item="data.billing.BillingSettings", view=True, read=AccessLevel.GROUP, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) if userId: tableRules.append(AccessRule( roleId=userId, context=AccessRuleContext.DATA, item="data.billing.BillingSettings", 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="data.billing.BillingSettings", view=False, read=AccessLevel.NONE, 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. Creates rules for system pages based on NAVIGATION_SECTIONS. Admin pages require admin role, public pages are available to all. NOTE: No sysadmin rules - SysAdmin users bypass RBAC via isSysAdmin flag. Args: db: Database connector instance """ from modules.system.mainSystem import NAVIGATION_SECTIONS uiRules = [] adminId = _getRoleId(db, "admin") userId = _getRoleId(db, "user") viewerId = _getRoleId(db, "viewer") # Create rules based on navigation sections for section in NAVIGATION_SECTIONS: isAdminSection = section.get("adminOnly", False) for item in section.get("items", []): objectKey = item.get("objectKey") isPublic = item.get("public", False) isAdminOnly = item.get("adminOnly", False) or isAdminSection if isAdminOnly: # Admin-only pages: only admin role if adminId: uiRules.append(AccessRule( roleId=adminId, context=AccessRuleContext.UI, item=objectKey, view=True, read=None, create=None, update=None, delete=None, )) else: # Public/normal pages: all roles for roleId in [adminId, userId, viewerId]: if roleId: uiRules.append(AccessRule( roleId=roleId, context=AccessRuleContext.UI, item=objectKey, 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 _ensureUiContextRules(db: DatabaseConnector) -> None: """ Ensure UI context rules exist for all navigation items. This is called during bootstrap to add missing UI rules for new navigation items. Creates rules for BOTH template roles AND mandate-instance roles. This ensures new navigation items are visible even for mandates created before the navigation item was added. Args: db: Database connector instance """ from modules.system.mainSystem import NAVIGATION_SECTIONS # Template role IDs adminId = _getRoleId(db, "admin") userId = _getRoleId(db, "user") viewerId = _getRoleId(db, "viewer") # Mandate-instance role IDs (same roleLabel, but mandateId set, featureInstanceId=None) mandateAdminRoleIds = [] mandateUserRoleIds = [] mandateViewerRoleIds = [] mandateRoles = db.getRecordset( Role, recordFilter={"isSystemRole": False, "featureInstanceId": None} ) for role in mandateRoles: roleId = role.get("id") label = role.get("roleLabel") if not roleId or not label or not role.get("mandateId"): continue if label == "admin": mandateAdminRoleIds.append(roleId) elif label == "user": mandateUserRoleIds.append(roleId) elif label == "viewer": mandateViewerRoleIds.append(roleId) # All role IDs per level (template + mandate-instance). # Admin-only navigation items are governed by these admin roles plus the # ``isPlatformAdmin`` flag (checked in routes via requirePlatformAdmin), # NOT by a dedicated platform-level role. allAdminRoleIds = ([adminId] if adminId else []) + mandateAdminRoleIds allUserRoleIds = ([userId] if userId else []) + mandateUserRoleIds allViewerRoleIds = ([viewerId] if viewerId else []) + mandateViewerRoleIds # Get existing UI rules existingUiRules = db.getRecordset( AccessRule, recordFilter={"context": AccessRuleContext.UI.value} ) # Build set of existing (roleId, item) combinations existingCombinations = set() for rule in existingUiRules: roleId = rule.get("roleId") item = rule.get("item") if roleId and item: existingCombinations.add((roleId, item)) # Check each navigation item and add missing rules (including subgroup items) missingRules = [] for section in NAVIGATION_SECTIONS: isAdminSection = section.get("adminOnly", False) allItems = list(section.get("items", [])) for subgroup in section.get("subgroups", []): allItems.extend(subgroup.get("items", [])) for item in allItems: objectKey = item.get("objectKey") if not objectKey: continue isAdminOnly = item.get("adminOnly", False) or isAdminSection if isAdminOnly: # Admin-only: all admin roles (template + mandate-instance) for roleId in allAdminRoleIds: if (roleId, objectKey) not in existingCombinations: missingRules.append(AccessRule( roleId=roleId, context=AccessRuleContext.UI, item=objectKey, view=True, read=None, create=None, update=None, delete=None, )) else: # Public/normal: all roles (template + mandate-instance) for roleId in allAdminRoleIds + allUserRoleIds + allViewerRoleIds: if (roleId, objectKey) not in existingCombinations: missingRules.append(AccessRule( roleId=roleId, context=AccessRuleContext.UI, item=objectKey, view=True, read=None, create=None, update=None, delete=None, )) # Create missing rules if missingRules: for rule in missingRules: db.recordCreate(AccessRule, rule) logger.info(f"Created {len(missingRules)} missing UI context rules (incl. mandate-instance roles)") # All UI context rules already exist (nothing to create) def _ensureDataContextRules(db: DatabaseConnector) -> None: """ Ensure DATA context rules exist for key tables like ChatWorkflow and AutomationDefinition. This is called during bootstrap to add missing DATA rules for new tables. Args: db: Database connector instance """ adminId = _getRoleId(db, "admin") userId = _getRoleId(db, "user") viewerId = _getRoleId(db, "viewer") # Get existing DATA rules existingDataRules = db.getRecordset( AccessRule, recordFilter={"context": AccessRuleContext.DATA.value} ) # Build set of existing (roleId, item) combinations existingCombinations = set() for rule in existingDataRules: roleId = rule.get("roleId") item = rule.get("item") if roleId and item: existingCombinations.add((roleId, item)) # Define tables that need rules (user-owned, no mandate context) # Users can only manage their own records (MY-level access) tablesNeedingMyRules = [ "data.chat.ChatWorkflow", ] # Billing tables: read-only for all roles, scoped by role level # Users see their own accounts/transactions (MY), Admins see mandate-wide (GROUP) billingReadOnlyTables = [ "data.billing.BillingAccount", "data.billing.BillingTransaction", ] missingRules = [] # MY-level rules for user-owned tables for objectKey in tablesNeedingMyRules: # Admin: MY-level access (user-owned, no mandate context) if adminId and (adminId, objectKey) not in existingCombinations: missingRules.append(AccessRule( roleId=adminId, context=AccessRuleContext.DATA, item=objectKey, view=True, read=AccessLevel.MY, create=AccessLevel.MY, update=AccessLevel.MY, delete=AccessLevel.MY, )) # User: MY-level access (user-owned, no mandate context) if userId and (userId, objectKey) not in existingCombinations: missingRules.append(AccessRule( roleId=userId, context=AccessRuleContext.DATA, item=objectKey, view=True, read=AccessLevel.MY, create=AccessLevel.MY, update=AccessLevel.MY, delete=AccessLevel.MY, )) # Viewer: MY read-only (user-owned, no mandate context) if viewerId and (viewerId, objectKey) not in existingCombinations: missingRules.append(AccessRule( roleId=viewerId, context=AccessRuleContext.DATA, item=objectKey, view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # Billing read-only rules: Admin=GROUP, User/Viewer=MY (own accounts/transactions) for objectKey in billingReadOnlyTables: # Admin: GROUP-level read (sees all accounts in their mandates) if adminId and (adminId, objectKey) not in existingCombinations: missingRules.append(AccessRule( roleId=adminId, context=AccessRuleContext.DATA, item=objectKey, view=True, read=AccessLevel.GROUP, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # User: MY-level read (sees only own billing accounts/transactions) if userId and (userId, objectKey) not in existingCombinations: missingRules.append(AccessRule( roleId=userId, context=AccessRuleContext.DATA, item=objectKey, view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # Viewer: MY-level read-only (sees only own billing accounts/transactions) if viewerId and (viewerId, objectKey) not in existingCombinations: missingRules.append(AccessRule( roleId=viewerId, context=AccessRuleContext.DATA, item=objectKey, view=True, read=AccessLevel.MY, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # BillingSettings: Admin can view (GROUP), User/Viewer have no access billingSettingsKey = "data.billing.BillingSettings" if adminId and (adminId, billingSettingsKey) not in existingCombinations: missingRules.append(AccessRule( roleId=adminId, context=AccessRuleContext.DATA, item=billingSettingsKey, view=True, read=AccessLevel.GROUP, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) if userId and (userId, billingSettingsKey) not in existingCombinations: missingRules.append(AccessRule( roleId=userId, context=AccessRuleContext.DATA, item=billingSettingsKey, view=False, read=AccessLevel.NONE, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) if viewerId and (viewerId, billingSettingsKey) not in existingCombinations: missingRules.append(AccessRule( roleId=viewerId, context=AccessRuleContext.DATA, item=billingSettingsKey, view=False, read=AccessLevel.NONE, create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, )) # Create missing rules if missingRules: for rule in missingRules: db.recordCreate(AccessRule, rule) logger.info(f"Created {len(missingRules)} missing DATA context rules") # All DATA context rules already exist (nothing to create) 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 = [] # Admin and User get default resource access; Viewer gets NO resource access for roleLabel in ["admin", "user"]: 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, )) # Viewer: no default RESOURCE access (viewer cannot use system resources) for rule in resourceRules: db.recordCreate(AccessRule, rule) logger.info(f"Created {len(resourceRules)} RESOURCE context rules") # Create AICore provider RBAC rules _createAicoreProviderRules(db) # Create Store resource RBAC rules _createStoreResourceRules(db) def _createAicoreProviderRules(db: DatabaseConnector) -> None: """ Create RBAC rules for AICore providers (resource.aicore.{provider}). Provider access per role: - admin: all providers allowed - user: all providers allowed - viewer: NO provider access (viewer has no RESOURCE permissions) NOTE: Provider list is dynamically discovered from AICore model registry. Args: db: Database connector instance """ try: from modules.aicore.aicoreModelRegistry import modelRegistry # Discover available connectors dynamically connectors = modelRegistry.discoverConnectors() providers = [c.getConnectorType() for c in connectors] if not providers: logger.warning("No AICore providers discovered, skipping provider RBAC rules") return logger.info(f"Creating RBAC rules for AICore providers: {providers}") providerRules = [] # Admin: access to ALL providers adminId = _getRoleId(db, "admin") if adminId: for provider in providers: resourceKey = f"resource.aicore.{provider}" existingRules = db.getRecordset( AccessRule, recordFilter={ "roleId": adminId, "context": AccessRuleContext.RESOURCE.value, "item": resourceKey } ) if not existingRules: providerRules.append(AccessRule( roleId=adminId, context=AccessRuleContext.RESOURCE, item=resourceKey, view=True, read=None, create=None, update=None, delete=None, )) # User: access to all providers (same provider keys as admin) userId = _getRoleId(db, "user") if userId: for provider in providers: resourceKey = f"resource.aicore.{provider}" existingRules = db.getRecordset( AccessRule, recordFilter={ "roleId": userId, "context": AccessRuleContext.RESOURCE.value, "item": resourceKey } ) if not existingRules: providerRules.append(AccessRule( roleId=userId, context=AccessRuleContext.RESOURCE, item=resourceKey, view=True, read=None, create=None, update=None, delete=None, )) # Viewer: NO provider access (viewer has no RESOURCE permissions at all) for rule in providerRules: db.recordCreate(AccessRule, rule) if providerRules: logger.info(f"Created {len(providerRules)} AICore provider RBAC rules") else: logger.debug("All AICore provider RBAC rules already exist") except Exception as e: logger.warning(f"Failed to create AICore provider RBAC rules: {e}") def _createStoreResourceRules(db: DatabaseConnector) -> None: """ Create RBAC rules for Store feature activation resources. Store resources control which roles can activate features via the Store. - admin/user: view=True (can see and activate store features) - viewer: no store access - isSysAdmin flag bypasses RBAC (rbac.py:getUserPermissions) Args: db: Database connector instance """ storeResources = [ "resource.store.teamsbot", "resource.store.workspace", "resource.store.commcoach", "resource.store.trustee", "resource.store.graphicalEditor", ] storeRules = [] for roleLabel in ["admin", "user"]: roleId = _getRoleId(db, roleLabel) if not roleId: continue for resourceKey in storeResources: existingRules = db.getRecordset( AccessRule, recordFilter={ "roleId": roleId, "context": AccessRuleContext.RESOURCE.value, "item": resourceKey } ) if not existingRules: storeRules.append(AccessRule( roleId=roleId, context=AccessRuleContext.RESOURCE, item=resourceKey, view=True, read=None, create=None, update=None, delete=None, )) for rule in storeRules: db.recordCreate(AccessRule, rule) if storeRules: logger.info(f"Created {len(storeRules)} Store resource RBAC rules") def initRootMandateBilling(mandateId: str) -> None: """ Initialize billing settings for root mandate (PREPAY_MANDATE). Creates mandate pool account and user audit accounts. """ try: from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRootInterface from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface from modules.datamodels.datamodelBilling import BillingSettings billingInterface = getBillingRootInterface() appInterface = getAppRootInterface() existingSettings = billingInterface.getSettings(mandateId) if existingSettings: logger.info("Billing settings for root mandate already exist") else: settings = BillingSettings( mandateId=mandateId, warningThresholdPercent=10.0, notifyOnWarning=True ) billingInterface.createSettings(settings) logger.info("Created billing settings for root mandate: PREPAY_MANDATE") existingSettings = billingInterface.getSettings(mandateId) if existingSettings: billingInterface.getOrCreateMandateAccount(mandateId, initialBalance=0.0) userMandates = appInterface.getUserMandatesByMandate(mandateId) accountsCreated = 0 for um in userMandates: userId = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None) if userId: existingAccount = billingInterface.getUserAccount(mandateId, userId) if not existingAccount: billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0) accountsCreated += 1 if accountsCreated > 0: logger.info(f"Created {accountsCreated} billing audit accounts for root mandate users") except Exception as e: logger.warning(f"Failed to initialize root mandate billing (non-critical): {e}") def _initRootMandateSubscription(mandateId: str) -> None: """ Ensure the root mandate has an active ROOT subscription. Called during bootstrap after billing init. """ try: from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface from modules.datamodels.datamodelSubscription import ( MandateSubscription, SubscriptionStatusEnum, ) subInterface = getSubRootInterface() existing = subInterface.getOperativeForMandate(mandateId) if existing: logger.info("Root mandate subscription already exists") return sub = MandateSubscription( mandateId=mandateId, planKey="ROOT", status=SubscriptionStatusEnum.ACTIVE, recurring=False, ) subInterface.createSubscription(sub) logger.info("Created ROOT subscription for root mandate") except Exception as e: logger.warning(f"Failed to initialize root mandate subscription (non-critical): {e}") def _bootstrapStripePrices() -> None: """Auto-create Stripe Products and Prices for all paid plans. Idempotent — safe on every startup. IDs are persisted in the StripePlanPrice table.""" try: from modules.serviceCenter.services.serviceSubscription.stripeBootstrap import bootstrapStripePrices bootstrapStripePrices() except Exception as e: logger.error(f"Stripe price bootstrap failed (subscriptions will not work for paid plans): {e}") 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. Initial users get the "admin" role in the root mandate. Platform-level authority (cross-mandate governance + infrastructure ops) is conveyed via the ``isSysAdmin`` / ``isPlatformAdmin`` flags on the User record itself (see ``initAdminUser`` / ``initEventUser``). Args: db: Database connector instance mandateId: Root mandate ID adminUserId: Admin user ID eventUserId: Event user ID """ # Find the highest-privilege mandate-level role (prefer "admin", fallback to first available) mandateRoles = db.getRecordset( Role, recordFilter={"mandateId": mandateId, "featureInstanceId": None} ) # Prefer "admin" role, fall back to first available mandate role adminRole = next((r for r in mandateRoles if r.get("roleLabel") == "admin"), None) adminRoleId = adminRole.get("id") if adminRole else (mandateRoles[0].get("id") if mandateRoles else None) if not adminRoleId: logger.warning(f"No mandate-level role found for mandate {mandateId}, 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") 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 for admin role 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}")