From 82c01b5cb009f2ec8a571b602ca79cd36d4f9097 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 21 Jan 2026 00:32:47 +0100 Subject: [PATCH] saas mandates core done --- env_dev.env | 2 +- modules/datamodels/datamodelFeatures.py | 4 +- modules/datamodels/datamodelMembership.py | 24 +-- modules/datamodels/datamodelRbac.py | 12 +- modules/datamodels/datamodelUam.py | 14 +- modules/interfaces/interfaceBootstrap.py | 201 +++++++++++-------- modules/interfaces/interfaceDbAppObjects.py | 4 +- modules/interfaces/interfaceRbac.py | 8 + modules/routes/routeAdminRbacRoles.py | 32 +-- modules/routes/routeDataMandates.py | 43 ++-- modules/routes/routeDataUsers.py | 34 +++- modules/routes/routeFeatureWorkflow.py | 13 +- modules/routes/routeFeatures.py | 79 ++++---- modules/routes/routeRbac.py | 46 +++-- modules/routes/routeSecurityLocal.py | 79 ++++++-- modules/shared/attributeUtils.py | 56 ++++-- modules/shared/dbMultiTenantOptimizations.py | 42 +++- 17 files changed, 438 insertions(+), 255 deletions(-) diff --git a/env_dev.env b/env_dev.env index 93523018..ac5349a7 100644 --- a/env_dev.env +++ b/env_dev.env @@ -19,7 +19,7 @@ APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2Z APP_TOKEN_EXPIRY=300 # CORS Configuration -APP_ALLOWED_ORIGINS=http://localhost:8080,https://playground.poweron-center.net +APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron-center.net # Logging configuration APP_LOGGING_LOG_LEVEL = DEBUG diff --git a/modules/datamodels/datamodelFeatures.py b/modules/datamodels/datamodelFeatures.py index e772a19e..60153f28 100644 --- a/modules/datamodels/datamodelFeatures.py +++ b/modules/datamodels/datamodelFeatures.py @@ -6,6 +6,7 @@ import uuid from typing import Optional from pydantic import BaseModel, Field from modules.shared.attributeUtils import registerModelLabels +from modules.datamodels.datamodelUtils import TextMultilingual class Feature(BaseModel): @@ -17,8 +18,7 @@ class Feature(BaseModel): description="Unique feature code (Primary Key), z.B. 'trustee', 'chatbot'", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} ) - label: dict = Field( - default_factory=dict, + label: TextMultilingual = Field( description="Feature label in multiple languages (I18n)", json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True} ) diff --git a/modules/datamodels/datamodelMembership.py b/modules/datamodels/datamodelMembership.py index e2cdb0b6..e100b23c 100644 --- a/modules/datamodels/datamodelMembership.py +++ b/modules/datamodels/datamodelMembership.py @@ -20,15 +20,15 @@ class UserMandate(BaseModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the user-mandate membership", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) userId: str = Field( description="FK → User.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"} ) mandateId: str = Field( description="FK → Mandate.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "name"} ) enabled: bool = Field( default=True, @@ -58,15 +58,15 @@ class FeatureAccess(BaseModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the feature access", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) userId: str = Field( description="FK → User.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"} ) featureInstanceId: str = Field( description="FK → FeatureInstance.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"} ) enabled: bool = Field( default=True, @@ -96,15 +96,15 @@ class UserMandateRole(BaseModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the junction record", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) userMandateId: str = Field( description="FK → UserMandate.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/user-mandates/", "frontend_fk_display_field": "userId"} ) roleId: str = Field( description="FK → Role.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"} ) @@ -127,15 +127,15 @@ class FeatureAccessRole(BaseModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the junction record", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) featureAccessId: str = Field( description="FK → FeatureAccess.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-access/", "frontend_fk_display_field": "userId"} ) roleId: str = Field( description="FK → Role.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"} ) diff --git a/modules/datamodels/datamodelRbac.py b/modules/datamodels/datamodelRbac.py index 666470c8..64ad56a4 100644 --- a/modules/datamodels/datamodelRbac.py +++ b/modules/datamodels/datamodelRbac.py @@ -40,7 +40,7 @@ class Role(BaseModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the role", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) roleLabel: str = Field( description="Unique role label identifier (e.g., 'admin', 'user', 'viewer')", @@ -55,17 +55,17 @@ class Role(BaseModel): mandateId: Optional[str] = Field( default=None, description="FK → Mandate.id (CASCADE DELETE). Null = Global/Template role.", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "name"} ) featureInstanceId: Optional[str] = Field( default=None, description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"} ) featureCode: Optional[str] = Field( default=None, description="Feature code (z.B. 'trustee') - für Template-Rollen", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) isSystemRole: bool = Field( @@ -100,11 +100,11 @@ class AccessRule(BaseModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the access rule", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) roleId: str = Field( description="FK → Role.id (CASCADE DELETE!)", - json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"} ) context: AccessRuleContext = Field( description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!", diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py index f1c5da33..96a99fee 100644 --- a/modules/datamodels/datamodelUam.py +++ b/modules/datamodels/datamodelUam.py @@ -67,12 +67,17 @@ class Mandate(BaseModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the mandate", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) name: str = Field( description="Name of the mandate", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} ) + description: Optional[str] = Field( + default=None, + description="Description of the mandate", + json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False} + ) enabled: bool = Field( default=True, description="Indicates whether the mandate is enabled", @@ -86,6 +91,7 @@ registerModelLabels( { "id": {"en": "ID", "de": "ID", "fr": "ID"}, "name": {"en": "Name", "de": "Name", "fr": "Nom"}, + "description": {"en": "Description", "de": "Beschreibung", "fr": "Description"}, "enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"}, }, ) @@ -143,11 +149,11 @@ class User(BaseModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the user", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) username: str = Field( - description="Username for login", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} + description="Username for login (immutable after creation)", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} ) email: Optional[EmailStr] = Field( default=None, diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 95b660ec..1d5f1139 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -30,6 +30,7 @@ from modules.datamodels.datamodelMembership import ( UserMandate, UserMandateRole, ) +from modules.datamodels.datamodelFeatures import Feature logger = logging.getLogger(__name__) @@ -55,6 +56,9 @@ def initBootstrap(db: DatabaseConnector) -> None: # Initialize roles FIRST (needed for AccessRules) initRoles(db) + # Initialize features (trustee, chatbot, etc.) + initFeatures(db) + # Initialize RBAC rules (uses roleIds from roles) initRbacRules(db) @@ -114,6 +118,13 @@ def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s 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 @@ -175,6 +186,10 @@ 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 """ @@ -182,15 +197,9 @@ def initRoles(db: DatabaseConnector) -> None: 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="sysadmin", - description={"en": "System Administrator - Full access to all system resources", "de": "System-Administrator - Vollzugriff auf alle System-Ressourcen", "fr": "Administrateur système - Accès complet à toutes les ressources"}, - mandateId=None, # Global template role - featureInstanceId=None, - featureCode=None, - isSystemRole=True - ), 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"}, @@ -235,6 +244,63 @@ def initRoles(db: DatabaseConnector) -> None: 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") + + logger.info("Features initialization completed") + + def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]: """ Get role ID by label, using cache or database lookup. @@ -297,26 +363,15 @@ 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 = [] - # SysAdmin Role - Full access to all - sysadminId = _getRoleId(db, "sysadmin") - if sysadminId: - defaultRules.append(AccessRule( - roleId=sysadminId, - context=AccessRuleContext.DATA, - item=None, - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - )) - - # Admin Role - Group-level access + # Admin Role - Group-level access (highest role-based permission) adminId = _getRoleId(db, "admin") if adminId: defaultRules.append(AccessRule( @@ -370,29 +425,21 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None: 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 - sysadminId = _getRoleId(db, "sysadmin") + # 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 can access - if sysadminId: - tableRules.append(AccessRule( - roleId=sysadminId, - context=AccessRuleContext.DATA, - item="Mandate", - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - )) + # Mandate table - Only SysAdmin (flag) can access, not roles + # Regular roles have no access to Mandate table if adminId: tableRules.append(AccessRule( roleId=adminId, @@ -427,18 +474,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None: delete=AccessLevel.NONE, )) - # UserInDB table - if sysadminId: - tableRules.append(AccessRule( - roleId=sysadminId, - context=AccessRuleContext.DATA, - item="UserInDB", - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - )) + # UserInDB table - Admin can manage users within group scope if adminId: tableRules.append(AccessRule( roleId=adminId, @@ -474,6 +510,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> 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", @@ -483,17 +520,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None: ] for table in standardTables: - if sysadminId: - tableRules.append(AccessRule( - roleId=sysadminId, - context=AccessRuleContext.DATA, - item=table, - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - )) + # Admin gets full group-level access (highest role-based permission) if adminId: tableRules.append(AccessRule( roleId=adminId, @@ -528,28 +555,18 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None: delete=AccessLevel.NONE, )) - # AuthEvent table - special handling - if sysadminId: - tableRules.append(AccessRule( - roleId=sysadminId, - context=AccessRuleContext.DATA, - item="AuthEvent", - view=True, - read=AccessLevel.ALL, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.ALL, - )) + # 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, - create=AccessLevel.NONE, - update=AccessLevel.NONE, - delete=AccessLevel.ALL, + 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( @@ -557,7 +574,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None: context=AccessRuleContext.DATA, item="AuthEvent", view=True, - read=AccessLevel.MY, + read=AccessLevel.MY, # Users can see their own auth events create=AccessLevel.NONE, update=AccessLevel.NONE, delete=AccessLevel.NONE, @@ -586,13 +603,15 @@ 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 - for roleLabel in ["sysadmin", "admin", "user", "viewer"]: + # 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( @@ -617,13 +636,15 @@ 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 - for roleLabel in ["sysadmin", "admin", "user", "viewer"]: + # 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( @@ -653,15 +674,19 @@ def assignInitialUserMemberships( 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 """ - sysadminRoleId = _getRoleId(db, "sysadmin") - if not sysadminRoleId: - logger.warning("Sysadmin role not found, skipping membership assignment") + # 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")]: @@ -688,17 +713,17 @@ def assignInitialUserMemberships( # Check if UserMandateRole already exists existingRoles = db.getRecordset( UserMandateRole, - recordFilter={"userMandateId": userMandateId, "roleId": sysadminRoleId} + recordFilter={"userMandateId": userMandateId, "roleId": adminRoleId} ) if not existingRoles: - # Create UserMandateRole + # Create UserMandateRole with "admin" role userMandateRole = UserMandateRole( userMandateId=userMandateId, - roleId=sysadminRoleId + roleId=adminRoleId ) db.recordCreate(UserMandateRole, userMandateRole) - logger.info(f"Assigned sysadmin role to {userName} user in mandate") + logger.info(f"Assigned admin role to {userName} user in mandate") def _getPasswordHash(password: Optional[str]) -> Optional[str]: diff --git a/modules/interfaces/interfaceDbAppObjects.py b/modules/interfaces/interfaceDbAppObjects.py index d661e603..ed8e1fc4 100644 --- a/modules/interfaces/interfaceDbAppObjects.py +++ b/modules/interfaces/interfaceDbAppObjects.py @@ -1313,13 +1313,13 @@ class AppObjects: return Mandate(**filteredMandates[0]) - def createMandate(self, name: str, language: str = "en") -> Mandate: + def createMandate(self, name: str, description: str = None, enabled: bool = True) -> Mandate: """Creates a new mandate if user has permission.""" if not self.checkRbacPermission(Mandate, "create"): raise PermissionError("No permission to create mandates") # Create mandate data using model - mandateData = Mandate(name=name, language=language) + mandateData = Mandate(name=name, description=description, enabled=enabled) # Create mandate record createdRecord = self.db.recordCreate(Mandate, mandateData) diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index 26515e94..6b432a2b 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -59,6 +59,14 @@ def getRecordsetWithRBAC( if not connector._ensureTableExists(modelClass): return [] + # SysAdmin bypass: SysAdmin users have full access to all tables + isSysAdmin = getattr(currentUser, 'isSysAdmin', False) + if isSysAdmin: + logger.debug(f"SysAdmin user {currentUser.id} bypassing RBAC for table {table}") + # Direct access without RBAC filtering + # Note: getRecordset doesn't support orderBy/limit - these are only used in RBAC path + return connector.getRecordset(modelClass, recordFilter=recordFilter) + # Get RBAC permissions for this table # AccessRule table is always in DbApp database dbApp = getRootDbAppConnector() diff --git a/modules/routes/routeAdminRbacRoles.py b/modules/routes/routeAdminRbacRoles.py index a9397867..b7069f86 100644 --- a/modules/routes/routeAdminRbacRoles.py +++ b/modules/routes/routeAdminRbacRoles.py @@ -13,7 +13,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Reques from typing import List, Dict, Any, Optional, Set import logging -from modules.auth import limiter, requireSysAdmin, RequestContext +from modules.auth import limiter, requireSysAdmin from modules.datamodels.datamodelUam import User, UserInDB from modules.datamodels.datamodelRbac import Role from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole @@ -78,7 +78,7 @@ router = APIRouter( @limiter.limit("60/minute") async def listRoles( request: Request, - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> List[Dict[str, Any]]: """ Get list of all available roles with metadata. @@ -123,7 +123,7 @@ async def listRoles( @limiter.limit("60/minute") async def getRoleOptions( request: Request, - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> List[Dict[str, Any]]: """ Get role options for select dropdowns. @@ -165,7 +165,7 @@ async def getRoleOptions( async def createRole( request: Request, role: Role = Body(...), - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Create a new role. @@ -209,7 +209,7 @@ async def createRole( async def getRole( request: Request, roleId: str = Path(..., description="Role ID"), - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Get a role by ID. @@ -254,7 +254,7 @@ async def updateRole( request: Request, roleId: str = Path(..., description="Role ID"), role: Role = Body(...), - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Update an existing role. @@ -301,7 +301,7 @@ async def updateRole( async def deleteRole( request: Request, roleId: str = Path(..., description="Role ID"), - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, str]: """ Delete a role. @@ -346,7 +346,7 @@ async def listUsersWithRoles( request: Request, roleLabel: Optional[str] = Query(None, description="Filter by role label"), mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"), - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> List[Dict[str, Any]]: """ Get list of users with their role assignments. @@ -417,7 +417,7 @@ async def listUsersWithRoles( async def getUserRoles( request: Request, userId: str = Path(..., description="User ID"), - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Get role assignments for a specific user. @@ -468,7 +468,7 @@ async def updateUserRoles( request: Request, userId: str = Path(..., description="User ID"), newRoleLabels: List[str] = Body(..., description="List of role labels to assign"), - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Update role assignments for a specific user. @@ -535,7 +535,7 @@ async def updateUserRoles( newRole = UserMandateRole(userMandateId=userMandateId, roleId=roleId) interface.db.recordCreate(UserMandateRole, newRole.model_dump()) - logger.info(f"Updated roles for user {userId}: {newRoleLabels} by SysAdmin {context.user.id}") + logger.info(f"Updated roles for user {userId}: {newRoleLabels} by SysAdmin {currentUser.id}") userRoleLabels = _getUserRoleLabels(interface, userId) return { @@ -565,7 +565,7 @@ async def addUserRole( request: Request, userId: str = Path(..., description="User ID"), roleLabel: str = Path(..., description="Role label to add"), - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Add a role to a user (if not already assigned). @@ -617,7 +617,7 @@ async def addUserRole( # Add the role newRole = UserMandateRole(userMandateId=userMandateId, roleId=str(role.id)) interface.db.recordCreate(UserMandateRole, newRole.model_dump()) - logger.info(f"Added role {roleLabel} to user {userId} by SysAdmin {context.user.id}") + logger.info(f"Added role {roleLabel} to user {userId} by SysAdmin {currentUser.id}") userRoleLabels = _getUserRoleLabels(interface, userId) return { @@ -647,7 +647,7 @@ async def removeUserRole( request: Request, userId: str = Path(..., description="User ID"), roleLabel: str = Path(..., description="Role label to remove"), - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Remove a role from a user. @@ -697,7 +697,7 @@ async def removeUserRole( roleRemoved = True if roleRemoved: - logger.info(f"Removed role {roleLabel} from user {userId} by SysAdmin {context.user.id}") + logger.info(f"Removed role {roleLabel} from user {userId} by SysAdmin {currentUser.id}") userRoleLabels = _getUserRoleLabels(interface, userId) return { @@ -727,7 +727,7 @@ async def getUsersWithRole( request: Request, roleLabel: str = Path(..., description="Role label"), mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"), - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> List[Dict[str, Any]]: """ Get all users with a specific role. diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py index 817ab762..ec109c6b 100644 --- a/modules/routes/routeDataMandates.py +++ b/modules/routes/routeDataMandates.py @@ -55,10 +55,10 @@ class MandateUserInfo(BaseModel): userId: str username: str email: Optional[str] - firstname: Optional[str] - lastname: Optional[str] + fullName: Optional[str] userMandateId: str roleIds: List[str] + roleLabels: List[str] # Resolved role labels for display enabled: bool # Configure logger @@ -79,7 +79,7 @@ router = APIRouter( async def get_mandates( request: Request, pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> PaginatedResponse[Mandate]: """ Get mandates with optional pagination, sorting, and filtering. @@ -143,7 +143,7 @@ async def get_mandates( async def get_mandate( request: Request, mandateId: str = Path(..., description="ID of the mandate"), - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Mandate: """ Get a specific mandate by ID. @@ -174,7 +174,7 @@ async def get_mandate( async def create_mandate( request: Request, mandateData: dict = Body(..., description="Mandate data with at least 'name' field"), - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Mandate: """ Create a new mandate. @@ -192,14 +192,16 @@ async def create_mandate( ) # Get optional fields with defaults - language = mandateData.get('language', 'en') + description = mandateData.get('description') + enabled = mandateData.get('enabled', True) appInterface = interfaceDbAppObjects.getRootInterface() # Create mandate newMandate = appInterface.createMandate( name=name, - language=language + description=description, + enabled=enabled ) if not newMandate: @@ -208,7 +210,7 @@ async def create_mandate( detail="Failed to create mandate" ) - logger.info(f"Mandate {newMandate.id} created by SysAdmin {context.user.id}") + logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}") return newMandate except HTTPException: @@ -226,7 +228,7 @@ async def update_mandate( request: Request, mandateId: str = Path(..., description="ID of the mandate to update"), mandateData: dict = Body(..., description="Mandate update data"), - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Mandate: """ Update an existing mandate. @@ -254,7 +256,7 @@ async def update_mandate( detail="Failed to update mandate" ) - logger.info(f"Mandate {mandateId} updated by SysAdmin {context.user.id}") + logger.info(f"Mandate {mandateId} updated by SysAdmin {currentUser.id}") return updatedMandate except HTTPException: @@ -271,7 +273,7 @@ async def update_mandate( async def delete_mandate( request: Request, mandateId: str = Path(..., description="ID of the mandate to delete"), - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Delete a mandate. @@ -304,7 +306,7 @@ async def delete_mandate( detail=str(e) ) - logger.info(f"Mandate {mandateId} deleted by SysAdmin {context.user.id}") + logger.info(f"Mandate {mandateId} deleted by SysAdmin {currentUser.id}") return {"message": f"Mandate {mandateId} deleted successfully"} except HTTPException: @@ -360,21 +362,30 @@ async def listMandateUsers( result = [] for um in userMandates: # Get user info - user = rootInterface.getUserById(um.get("userId")) + user = rootInterface.getUser(um.get("userId")) if not user: continue # Get roles for this membership roleIds = rootInterface.getRoleIdsForUserMandate(um.get("id")) + # Resolve role labels for display + roleLabels = [] + for roleId in roleIds: + role = rootInterface.getRole(roleId) + if role: + roleLabels.append(role.roleLabel) + else: + roleLabels.append(roleId) # Fallback to ID if not found + result.append(MandateUserInfo( userId=str(user.id), username=user.username, email=user.email, - firstname=user.firstname, - lastname=user.lastname, + fullName=user.fullName, userMandateId=um.get("id"), roleIds=roleIds, + roleLabels=roleLabels, enabled=um.get("enabled", True) )) @@ -434,7 +445,7 @@ async def addUserToMandate( ) # 4. Verify target user exists - targetUser = rootInterface.getUserById(data.targetUserId) + targetUser = rootInterface.getUser(data.targetUserId) if not targetUser: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py index a9ebbab5..81115e51 100644 --- a/modules/routes/routeDataUsers.py +++ b/modules/routes/routeDataUsers.py @@ -12,6 +12,7 @@ MULTI-TENANT: User management requires RequestContext. from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query from typing import List, Dict, Any, Optional from fastapi import status +from pydantic import BaseModel import logging import json @@ -192,12 +193,22 @@ async def get_user( detail=f"Failed to get user: {str(e)}" ) +class CreateUserRequest(BaseModel): + """Request body for creating a new user""" + username: str + email: Optional[str] = None + fullName: Optional[str] = None + language: str = "en" + enabled: bool = True + isSysAdmin: bool = False + password: Optional[str] = None + + @router.post("", response_model=User) @limiter.limit("10/minute") async def create_user( request: Request, - user_data: User = Body(...), - password: Optional[str] = Body(None, embed=True), + userData: CreateUserRequest = Body(...), context: RequestContext = Depends(getRequestContext) ) -> User: """ @@ -206,16 +217,17 @@ async def create_user( """ appInterface = interfaceDbAppObjects.getInterface(context.user) - # Extract fields from User model and call createUser with individual parameters + # Extract fields from request model and call createUser with individual parameters from modules.datamodels.datamodelUam import AuthAuthority newUser = appInterface.createUser( - username=user_data.username, - password=password, - email=user_data.email, - fullName=user_data.fullName, - language=user_data.language, - enabled=user_data.enabled, - authenticationAuthority=user_data.authenticationAuthority + username=userData.username, + password=userData.password, + email=userData.email, + fullName=userData.fullName, + language=userData.language, + enabled=userData.enabled, + authenticationAuthority=AuthAuthority.LOCAL, + isSysAdmin=userData.isSysAdmin ) # MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role @@ -303,7 +315,7 @@ async def reset_user_password( appInterface = interfaceDbAppObjects.getInterface(context.user) # Get target user - target_user = appInterface.getUserById(userId) + target_user = appInterface.getUser(userId) if not target_user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/modules/routes/routeFeatureWorkflow.py b/modules/routes/routeFeatureWorkflow.py index ac002332..33b9c0fc 100644 --- a/modules/routes/routeFeatureWorkflow.py +++ b/modules/routes/routeFeatureWorkflow.py @@ -13,6 +13,7 @@ import logging # Import interfaces and models import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext +from modules.datamodels.datamodelUam import User # Configure logger logger = logging.getLogger(__name__) @@ -34,7 +35,7 @@ router = APIRouter( @limiter.limit("30/minute") async def get_all_automation_events( request: Request, - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> List[Dict[str, Any]]: """ Get all automation events across all mandates (sysadmin only). @@ -68,7 +69,7 @@ async def get_all_automation_events( @limiter.limit("5/minute") async def sync_all_automation_events( request: Request, - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Manually trigger sync for all automations (sysadmin only). @@ -79,7 +80,7 @@ async def sync_all_automation_events( from modules.interfaces.interfaceDbAppObjects import getRootInterface from modules.features.workflow import syncAutomationEvents - chatInterface = getChatInterface(context.user) + chatInterface = getChatInterface(currentUser) # Get event user for sync operation (routes can import from interfaces) rootInterface = getRootInterface() eventUser = rootInterface.getUserByUsername("event") @@ -90,7 +91,7 @@ async def sync_all_automation_events( ) from modules.services import getInterface as getServices - services = getServices(context.user, None) + services = getServices(currentUser, None) result = await syncAutomationEvents(services, eventUser) return { "success": True, @@ -111,7 +112,7 @@ async def sync_all_automation_events( async def remove_event( request: Request, eventId: str = Path(..., description="Event ID to remove"), - context: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Manually remove a specific event from scheduler (sysadmin only). @@ -126,7 +127,7 @@ async def remove_event( # Update automation's eventId if it exists if eventId.startswith("automation."): automation_id = eventId.replace("automation.", "") - chatInterface = interfaceDbChatbot.getInterface(context.user) + chatInterface = interfaceDbChatbot.getInterface(currentUser) automation = chatInterface.getAutomationDefinition(automation_id) if automation and getattr(automation, "eventId", None) == eventId: chatInterface.updateAutomationDefinition(automation_id, {"eventId": None}) diff --git a/modules/routes/routeFeatures.py b/modules/routes/routeFeatures.py index 01e33eac..326adeb7 100644 --- a/modules/routes/routeFeatures.py +++ b/modules/routes/routeFeatures.py @@ -295,42 +295,6 @@ def _mergeAccessLevel(current: str, new: str) -> str: return current -@router.get("/{featureCode}", response_model=Dict[str, Any]) -@limiter.limit("60/minute") -async def getFeature( - request: Request, - featureCode: str, - context: RequestContext = Depends(getRequestContext) -) -> Dict[str, Any]: - """ - Get a specific feature by code. - - Args: - featureCode: Feature code (e.g., 'trustee', 'chatbot') - """ - try: - rootInterface = getRootInterface() - featureInterface = getFeatureInterface(rootInterface.db) - - feature = featureInterface.getFeature(featureCode) - if not feature: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Feature '{featureCode}' not found" - ) - - return feature.model_dump() - - except HTTPException: - raise - except Exception as e: - logger.error(f"Error getting feature {featureCode}: {e}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to get feature: {str(e)}" - ) - - @router.post("/", response_model=Dict[str, Any]) @limiter.limit("10/minute") async def createFeature( @@ -745,6 +709,49 @@ async def createTemplateRole( ) +# ============================================================================= +# Dynamic Feature Route (MUST be last to avoid catching /instances, /my, etc.) +# ============================================================================= + +@router.get("/{featureCode}", response_model=Dict[str, Any]) +@limiter.limit("60/minute") +async def getFeature( + request: Request, + featureCode: str, + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """ + Get a specific feature by code. + + IMPORTANT: This route must be defined LAST to avoid catching paths like + /instances, /my, /templates, etc. + + Args: + featureCode: Feature code (e.g., 'trustee', 'chatbot') + """ + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + feature = featureInterface.getFeature(featureCode) + if not feature: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Feature '{featureCode}' not found" + ) + + return feature.model_dump() + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting feature {featureCode}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to get feature: {str(e)}" + ) + + # ============================================================================= # Helper Functions # ============================================================================= diff --git a/modules/routes/routeRbac.py b/modules/routes/routeRbac.py index d2e92c82..03c66ae0 100644 --- a/modules/routes/routeRbac.py +++ b/modules/routes/routeRbac.py @@ -230,7 +230,7 @@ async def getAccessRules( context: Optional[str] = Query(None, description="Filter by context (DATA, UI, RESOURCE)"), item: Optional[str] = Query(None, description="Filter by item identifier"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), - reqContext: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> PaginatedResponse: """ Get access rules with optional filters. @@ -316,7 +316,7 @@ async def getAccessRules( async def getAccessRule( request: Request, ruleId: str = Path(..., description="Access rule ID"), - reqContext: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> dict: """ Get a specific access rule by ID. @@ -358,7 +358,7 @@ async def getAccessRule( async def createAccessRule( request: Request, accessRuleData: dict = Body(..., description="Access rule data"), - reqContext: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> dict: """ Create a new access rule. @@ -404,7 +404,7 @@ async def createAccessRule( # Create rule createdRule = interface.createAccessRule(accessRule) - logger.info(f"Created access rule {createdRule.id} by SysAdmin {reqContext.user.id}") + logger.info(f"Created access rule {createdRule.id} by SysAdmin {currentUser.id}") # Convert to dict for JSON serialization return createdRule.model_dump() @@ -425,7 +425,7 @@ async def updateAccessRule( request: Request, ruleId: str = Path(..., description="Access rule ID"), accessRuleData: dict = Body(..., description="Updated access rule data"), - reqContext: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> dict: """ Update an existing access rule. @@ -487,7 +487,7 @@ async def updateAccessRule( # Update rule updatedRule = interface.updateAccessRule(ruleId, accessRule) - logger.info(f"Updated access rule {ruleId} by SysAdmin {reqContext.user.id}") + logger.info(f"Updated access rule {ruleId} by SysAdmin {currentUser.id}") # Convert to dict for JSON serialization return updatedRule.model_dump() @@ -507,7 +507,7 @@ async def updateAccessRule( async def deleteAccessRule( request: Request, ruleId: str = Path(..., description="Access rule ID"), - reqContext: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> dict: """ Delete an access rule. @@ -540,7 +540,7 @@ async def deleteAccessRule( detail=f"Failed to delete access rule {ruleId}" ) - logger.info(f"Deleted access rule {ruleId} by SysAdmin {reqContext.user.id}") + logger.info(f"Deleted access rule {ruleId} by SysAdmin {currentUser.id}") return {"success": True, "message": f"Access rule {ruleId} deleted successfully"} @@ -565,7 +565,7 @@ async def deleteAccessRule( async def listRoles( request: Request, pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), - reqContext: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> PaginatedResponse: """ Get list of all available roles with metadata. @@ -604,6 +604,9 @@ async def listRoles( "id": role.id, "roleLabel": role.roleLabel, "description": role.description, + "mandateId": role.mandateId, + "featureInstanceId": role.featureInstanceId, + "featureCode": role.featureCode, "userCount": roleCounts.get(str(role.id), 0), "isSystemRole": role.isSystemRole }) @@ -662,7 +665,7 @@ async def listRoles( @limiter.limit("60/minute") async def getRoleOptions( request: Request, - reqContext: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> List[Dict[str, Any]]: """ Get role options for select dropdowns. @@ -704,7 +707,7 @@ async def getRoleOptions( async def createRole( request: Request, role: Role = Body(...), - reqContext: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Create a new role. @@ -721,12 +724,15 @@ async def createRole( createdRole = interface.createRole(role) - logger.info(f"Created role {createdRole.roleLabel} by SysAdmin {reqContext.user.id}") + logger.info(f"Created role {createdRole.roleLabel} by SysAdmin {currentUser.id}") return { "id": createdRole.id, "roleLabel": createdRole.roleLabel, "description": createdRole.description, + "mandateId": createdRole.mandateId, + "featureInstanceId": createdRole.featureInstanceId, + "featureCode": createdRole.featureCode, "isSystemRole": createdRole.isSystemRole } @@ -750,7 +756,7 @@ async def createRole( async def getRole( request: Request, roleId: str = Path(..., description="Role ID"), - reqContext: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Get a role by ID. @@ -776,6 +782,9 @@ async def getRole( "id": role.id, "roleLabel": role.roleLabel, "description": role.description, + "mandateId": role.mandateId, + "featureInstanceId": role.featureInstanceId, + "featureCode": role.featureCode, "isSystemRole": role.isSystemRole } @@ -795,7 +804,7 @@ async def updateRole( request: Request, roleId: str = Path(..., description="Role ID"), role: Role = Body(...), - reqContext: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, Any]: """ Update an existing role. @@ -815,12 +824,15 @@ async def updateRole( updatedRole = interface.updateRole(roleId, role) - logger.info(f"Updated role {roleId} by SysAdmin {reqContext.user.id}") + logger.info(f"Updated role {roleId} by SysAdmin {currentUser.id}") return { "id": updatedRole.id, "roleLabel": updatedRole.roleLabel, "description": updatedRole.description, + "mandateId": updatedRole.mandateId, + "featureInstanceId": updatedRole.featureInstanceId, + "featureCode": updatedRole.featureCode, "isSystemRole": updatedRole.isSystemRole } @@ -844,7 +856,7 @@ async def updateRole( async def deleteRole( request: Request, roleId: str = Path(..., description="Role ID"), - reqContext: RequestContext = Depends(requireSysAdmin) + currentUser: User = Depends(requireSysAdmin) ) -> Dict[str, str]: """ Delete a role. @@ -866,7 +878,7 @@ async def deleteRole( detail=f"Role {roleId} not found" ) - logger.info(f"Deleted role {roleId} by SysAdmin {reqContext.user.id}") + logger.info(f"Deleted role {roleId} by SysAdmin {currentUser.id}") return {"message": f"Role {roleId} deleted successfully"} diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py index 8589db04..96f22136 100644 --- a/modules/routes/routeSecurityLocal.py +++ b/modules/routes/routeSecurityLocal.py @@ -24,6 +24,55 @@ from modules.shared.configuration import APP_CONFIG # Configure logger logger = logging.getLogger(__name__) + +def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = None) -> bool: + """ + Send authentication-related email directly without requiring full Services initialization. + Used for registration, password reset, and other auth flows. + + Args: + recipient: Email address + subject: Email subject + message: Plain text message (will be converted to HTML) + userId: Optional user ID for logging + + Returns: + bool: True if email was sent successfully + """ + try: + import html + from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface + from modules.datamodels.datamodelMessaging import MessagingChannel + + # Convert plain text to simple HTML + escaped = html.escape(message) + escaped = escaped.replace('\n', '
\n') + htmlMessage = f""" + + + +{escaped} + +""" + + messagingInterface = getMessagingInterface() + success = messagingInterface.send( + channel=MessagingChannel.EMAIL, + recipient=recipient, + subject=subject, + message=htmlMessage + ) + + if success: + logger.info(f"Auth email sent successfully to {recipient} (userId: {userId})") + else: + logger.warning(f"Failed to send auth email to {recipient} (userId: {userId})") + + return success + except Exception as e: + logger.error(f"Error sending auth email to {recipient}: {str(e)}", exc_info=True) + return False + # Create router for Local Security endpoints router = APIRouter( prefix="/api/local", @@ -261,15 +310,11 @@ async def register_user( # Send registration email with magic link try: - from modules.services import Services - services = Services(user) - magicLink = f"{baseUrl}/reset?token={token}" expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24")) emailSubject = "PowerOn Registrierung - Passwort setzen" - emailBody = f""" -Hallo {user.fullName or user.username}, + emailBody = f"""Hallo {user.fullName or user.username}, Vielen Dank für Ihre Registrierung bei PowerOn. @@ -280,10 +325,9 @@ Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen: Dieser Link ist {expiryHours} Stunden gültig. -Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren. -""" +Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren.""" - emailSent = services.messaging.sendEmailDirect( + emailSent = _sendAuthEmail( recipient=user.email, subject=emailSubject, message=emailBody, @@ -529,7 +573,6 @@ async def passwordResetRequest( user = rootInterface.findUserByUsernameLocalAuth(username) if user and user.email: - from modules.services import Services expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24")) try: @@ -539,16 +582,12 @@ async def passwordResetRequest( # Set reset token (clears password) rootInterface.setResetToken(user.id, token, expires) - # Get services for email sending - services = Services(user) - # Generate magic link using provided frontend URL magicLink = f"{baseUrl}/reset?token={token}" - # Send email + # Send email using dedicated auth email function emailSubject = "PowerOn - Passwort zurücksetzen" - emailBody = f""" -Hallo {user.fullName or user.username}, + emailBody = f"""Hallo {user.fullName or user.username}, Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert. @@ -559,17 +598,19 @@ Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen: Dieser Link ist {expiryHours} Stunden gültig. -Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren. -""" +Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren.""" - services.messaging.sendEmailDirect( + emailSent = _sendAuthEmail( recipient=user.email, subject=emailSubject, message=emailBody, userId=str(user.id) ) - logger.info(f"Password reset email sent to {user.email} for user {user.username}") + if emailSent: + logger.info(f"Password reset email sent to {user.email} for user {user.username}") + else: + logger.warning(f"Failed to send password reset email to {user.email}") except Exception as userErr: logger.error(f"Failed to send reset email for user {username}: {str(userErr)}") else: diff --git a/modules/shared/attributeUtils.py b/modules/shared/attributeUtils.py index 8f8804ee..5f6c2531 100644 --- a/modules/shared/attributeUtils.py +++ b/modules/shared/attributeUtils.py @@ -120,6 +120,9 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag frontend_readonly = False frontend_required = field.is_required() frontend_options = None + frontend_visible = True # Default visible + frontend_fk_source = None # FK dropdown source (e.g., "/api/users/") + frontend_fk_display_field = None # Which field of the FK target to display (e.g., "username", "name") if field_info: # Try direct attributes first (though these won't exist for custom kwargs) @@ -167,6 +170,15 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag frontend_required = json_extra.get("frontend_required", frontend_required) if frontend_options is None and "frontend_options" in json_extra: frontend_options = json_extra.get("frontend_options") + # Extract frontend_visible (default True, can be set to False to hide field) + if "frontend_visible" in json_extra: + frontend_visible = json_extra.get("frontend_visible", True) + # Extract frontend_fk_source for FK dropdown references + if "frontend_fk_source" in json_extra: + frontend_fk_source = json_extra.get("frontend_fk_source") + # Extract frontend_fk_display_field - which field of FK target to display + if "frontend_fk_display_field" in json_extra: + frontend_fk_display_field = json_extra.get("frontend_fk_display_field") # Use frontend type if available, otherwise detect from Python type if frontend_type: @@ -215,22 +227,34 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag except Exception: pass - attributes.append( - { - "name": name, - "type": field_type, - "required": frontend_required, - "description": description, - "label": labels.get(name, name), - "placeholder": f"Please enter {labels.get(name, name)}", - "editable": not frontend_readonly, - "visible": True, - "order": len(attributes), - "readonly": frontend_readonly, - "options": frontend_options, - "default": field_default, - } - ) + # Hide "id" fields by default unless explicitly set to visible + # Also hide fields ending with "Id" that are FK references (unless they have fkSource) + if name == "id": + frontend_visible = False # Never show primary key in forms/tables + + attr_def = { + "name": name, + "type": field_type, + "required": frontend_required, + "description": description, + "label": labels.get(name, name), + "placeholder": f"Please enter {labels.get(name, name)}", + "editable": not frontend_readonly, + "visible": frontend_visible, + "order": len(attributes), + "readonly": frontend_readonly, + "options": frontend_options, + "default": field_default, + } + + # Add FK source for dropdown rendering if specified + if frontend_fk_source: + attr_def["fkSource"] = frontend_fk_source + # Also add display field if specified (which field of FK target to show) + if frontend_fk_display_field: + attr_def["fkDisplayField"] = frontend_fk_display_field + + attributes.append(attr_def) return {"model": model_label, "attributes": attributes} diff --git a/modules/shared/dbMultiTenantOptimizations.py b/modules/shared/dbMultiTenantOptimizations.py index 3e056179..ff8d7e8f 100644 --- a/modules/shared/dbMultiTenantOptimizations.py +++ b/modules/shared/dbMultiTenantOptimizations.py @@ -96,7 +96,7 @@ _PARTIAL_INDEXES = [ _FOREIGN_KEYS = [ # UserMandate FKs ("UserMandate", "fk_usermandate_mandate", "mandateId", "Mandate", "id"), - ("UserMandate", "fk_usermandate_user", "userId", "User", "id"), + ("UserMandate", "fk_usermandate_user", "userId", "UserInDB", "id"), # FeatureInstance FKs ("FeatureInstance", "fk_featureinstance_mandate", "mandateId", "Mandate", "id"), @@ -107,7 +107,7 @@ _FOREIGN_KEYS = [ # FeatureAccess FKs ("FeatureAccess", "fk_featureaccess_instance", "featureInstanceId", "FeatureInstance", "id"), - ("FeatureAccess", "fk_featureaccess_user", "userId", "User", "id"), + ("FeatureAccess", "fk_featureaccess_user", "userId", "UserInDB", "id"), # AccessRule FKs ("AccessRule", "fk_accessrule_role", "roleId", "Role", "id"), @@ -133,6 +133,9 @@ _IMMUTABLE_TRIGGERS = [ # AccessRule: context, roleId are immutable ("AccessRule", "tr_accessrule_immutable", ["context", "roleId"]), + + # User: username is immutable (login name cannot be changed) + ("UserInDB", "tr_user_immutable", ["username"]), ] @@ -340,6 +343,25 @@ def _applyIndexes(cursor, tables: Optional[List[str]]) -> int: return created +def _getForeignKeyTarget(cursor, constraintName: str) -> Optional[str]: + """Get the target table of an existing FK constraint.""" + cursor.execute(""" + SELECT ccu.table_name AS foreign_table_name + FROM information_schema.table_constraints AS tc + JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name + WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.constraint_name = %s + LIMIT 1 + """, (constraintName,)) + row = cursor.fetchone() + if row: + if isinstance(row, dict): + return row.get('foreign_table_name') + return row[0] + return None + + def _applyForeignKeys(cursor, tables: Optional[List[str]]) -> int: """Apply foreign key constraints with CASCADE DELETE. Returns count created.""" created = 0 @@ -351,8 +373,22 @@ def _applyForeignKeys(cursor, tables: Optional[List[str]]) -> int: continue if not _tableExists(cursor, refTable): continue + + # Check if constraint exists if _constraintExists(cursor, constraintName): - continue + # Verify it points to the correct table + currentTarget = _getForeignKeyTarget(cursor, constraintName) + if currentTarget == refTable: + # FK exists and points to correct table - skip + continue + else: + # FK exists but points to wrong table - drop and recreate + logger.info(f"FK {constraintName} points to {currentTarget}, expected {refTable} - recreating") + try: + cursor.execute(f'ALTER TABLE "{tableName}" DROP CONSTRAINT "{constraintName}"') + except Exception as e: + logger.warning(f"Failed to drop FK {constraintName}: {e}") + continue try: cursor.execute(f"""