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"""