From b16420db41a0c94cb353a312ee328313e0fa1f69 Mon Sep 17 00:00:00 2001
From: patrick-motsch
Date: Tue, 10 Feb 2026 01:44:21 +0100
Subject: [PATCH] fixed mandate routing
---
modules/datamodels/datamodelInvitation.py | 5 +-
modules/interfaces/interfaceBootstrap.py | 94 ++++++++++++++++++++---
modules/interfaces/interfaceDbApp.py | 15 ++++
modules/interfaces/interfaceDbBilling.py | 12 +--
modules/routes/routeAdminRbacRoles.py | 22 ++++--
modules/routes/routeAdminRbacRules.py | 7 +-
modules/routes/routeInvitations.py | 6 +-
modules/routes/routeSecurityLocal.py | 2 +-
8 files changed, 133 insertions(+), 30 deletions(-)
diff --git a/modules/datamodels/datamodelInvitation.py b/modules/datamodels/datamodelInvitation.py
index 348ef532..472318af 100644
--- a/modules/datamodels/datamodelInvitation.py
+++ b/modules/datamodels/datamodelInvitation.py
@@ -46,9 +46,10 @@ class Invitation(BaseModel):
)
# Einladungs-Details
- targetUsername: str = Field(
+ targetUsername: Optional[str] = Field(
+ default=None,
description="Username of the invited user (must match on acceptance)",
- json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
+ json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}
)
email: Optional[str] = Field(
default=None,
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index 2b55af51..8b51940c 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -54,6 +54,9 @@ def initBootstrap(db: DatabaseConnector) -> None:
# Migrate existing mandate records: description -> label
_migrateMandateDescriptionToLabel(db)
+ # Clean up duplicate roles and fix corrupted templates FIRST
+ _deduplicateRoles(db)
+
# Initialize system role TEMPLATES (mandateId=None, isSystemRole=True)
initRoles(db)
@@ -429,23 +432,89 @@ def initRoles(db: DatabaseConnector) -> None:
),
]
- existingRoles = db.getRecordset(Role)
- existingRoleLabels = {role.get("roleLabel"): role.get("id") for role in existingRoles}
+ # Check specifically for system template roles:
+ # mandateId=NULL, isSystemRole=True, featureCode=NULL
+ # Feature templates (e.g. chatplayground admin) share the same labels but have featureCode set!
+ allTemplates = db.getRecordset(
+ Role,
+ recordFilter={"mandateId": None, "isSystemRole": True}
+ )
+ # Filter for SYSTEM templates only (featureCode=None), not feature templates
+ systemTemplates = [r for r in allTemplates if r.get("featureCode") is None]
+ existingTemplateLabels = {role.get("roleLabel"): role.get("id") for role in systemTemplates}
for role in standardRoles:
- if role.roleLabel not in existingRoleLabels:
+ if role.roleLabel not in existingTemplateLabels:
try:
createdRole = db.recordCreate(Role, role)
_roleIdCache[role.roleLabel] = createdRole.get("id")
- logger.info(f"Created role: {role.roleLabel} with ID {createdRole.get('id')}")
+ logger.info(f"Created template role: {role.roleLabel} with ID {createdRole.get('id')}")
except Exception as e:
logger.warning(f"Error creating role {role.roleLabel}: {e}")
else:
- _roleIdCache[role.roleLabel] = existingRoleLabels[role.roleLabel]
+ _roleIdCache[role.roleLabel] = existingTemplateLabels[role.roleLabel]
logger.info("Roles initialization completed")
+def _deduplicateRoles(db: DatabaseConnector) -> None:
+ """
+ Remove duplicate roles (same roleLabel + mandateId + featureInstanceId).
+ Keeps the oldest role (smallest ID) and deletes newer duplicates.
+ """
+ allRoles = db.getRecordset(Role)
+
+ # Group by (roleLabel, mandateId, featureInstanceId, featureCode)
+ # featureCode is essential: system template ('admin', None, None, None)
+ # must NOT be grouped with feature template ('admin', None, None, 'chatplayground')
+ groups: dict = {}
+ for role in allRoles:
+ key = (role.get("roleLabel"), role.get("mandateId"), role.get("featureInstanceId"), role.get("featureCode"))
+ if key not in groups:
+ groups[key] = []
+ groups[key].append(role)
+
+ deletedCount = 0
+ for key, roles in groups.items():
+ if len(roles) > 1:
+ # Sort by id to keep the first (oldest), delete the rest
+ roles.sort(key=lambda r: r.get("id", ""))
+ for duplicate in roles[1:]:
+ try:
+ db.recordDelete(Role, duplicate.get("id"))
+ deletedCount += 1
+ logger.info(f"Deleted duplicate role: label='{key[0]}', mandateId={key[1]}, id={duplicate.get('id')}")
+ except Exception as e:
+ logger.warning(f"Failed to delete duplicate role {duplicate.get('id')}: {e}")
+
+ if deletedCount > 0:
+ logger.info(f"Deduplicated roles: removed {deletedCount} duplicates")
+
+ # Migration: Fix isSystemRole flags
+ fixedMandateCount = 0
+ fixedTemplateCount = 0
+ for role in allRoles:
+ # Mandate-level roles should NOT be isSystemRole=True
+ if role.get("mandateId") is not None and role.get("isSystemRole") is True:
+ try:
+ db.recordModify(Role, role.get("id"), {"isSystemRole": False})
+ fixedMandateCount += 1
+ except Exception as e:
+ logger.warning(f"Failed to fix mandate role {role.get('id')}: {e}")
+ # Template roles (mandateId=None, standard labels) MUST be isSystemRole=True
+ if role.get("mandateId") is None and role.get("isSystemRole") is not True:
+ if role.get("roleLabel") in ("admin", "user", "viewer"):
+ try:
+ db.recordModify(Role, role.get("id"), {"isSystemRole": True})
+ fixedTemplateCount += 1
+ except Exception as e:
+ logger.warning(f"Failed to fix template role {role.get('id')}: {e}")
+ if fixedMandateCount > 0:
+ logger.info(f"Fixed {fixedMandateCount} mandate-level roles: isSystemRole → False")
+ if fixedTemplateCount > 0:
+ logger.info(f"Fixed {fixedTemplateCount} template roles: isSystemRole → True")
+
+
def _ensureAllMandatesHaveSystemRoles(db: DatabaseConnector) -> None:
"""
Ensure all existing mandates have system-instance roles.
@@ -453,11 +522,15 @@ def _ensureAllMandatesHaveSystemRoles(db: DatabaseConnector) -> None:
"""
allMandates = db.getRecordset(Mandate)
if not allMandates:
+ logger.info("No mandates found, skipping system role copy")
return
+ logger.info(f"Ensuring system roles for {len(allMandates)} mandates...")
for mandate in allMandates:
mandateId = mandate.get("id")
- copySystemRolesToMandate(db, mandateId)
+ copiedCount = copySystemRolesToMandate(db, mandateId)
+ if copiedCount > 0:
+ logger.info(f"Copied {copiedCount} system roles to mandate {mandateId}")
def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
@@ -477,22 +550,23 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
"""
import uuid as _uuid
- # Find system template roles (global, no mandateId)
+ # Find system template roles (global: mandateId=NULL, isSystemRole=True)
templateRoles = db.getRecordset(
Role,
recordFilter={"isSystemRole": True, "mandateId": None}
)
if not templateRoles:
- logger.debug("No system template roles found to copy")
+ logger.warning(f"No system template roles found (mandateId IS NULL, isSystemRole=True)")
return 0
- # Check which roles already exist for this mandate
+ # Check which mandate-level roles already exist for this mandate
existingMandateRoles = db.getRecordset(
Role,
recordFilter={"mandateId": mandateId, "featureInstanceId": None}
)
existingLabels = {r.get("roleLabel") for r in existingMandateRoles}
+ logger.info(f"copySystemRolesToMandate: mandate={mandateId}, templates={len(templateRoles)}, existing={len(existingMandateRoles)}, labels={existingLabels}")
# Load all AccessRules for template roles
templateRoleIds = [r.get("id") for r in templateRoles]
@@ -520,7 +594,7 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
mandateId=mandateId,
featureInstanceId=None,
featureCode=None,
- isSystemRole=True # Still a system role, but bound to this mandate
+ isSystemRole=False # Mandate-level role, not a system template
)
db.recordCreate(Role, newRole.model_dump())
diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py
index d6e4c6c0..ecb1af1b 100644
--- a/modules/interfaces/interfaceDbApp.py
+++ b/modules/interfaces/interfaceDbApp.py
@@ -3142,6 +3142,21 @@ class AppObjects:
logger.error(f"Error getting role by label and scope {roleLabel}: {str(e)}")
return None
+ def getRolesForMandate(self, mandateId: str) -> List[Role]:
+ """
+ Get mandate-level roles for a specific mandate (featureInstanceId=NULL).
+ These are the roles created by copySystemRolesToMandate during bootstrap.
+ """
+ try:
+ roles = self.db.getRecordset(
+ Role,
+ recordFilter={"mandateId": mandateId, "featureInstanceId": None}
+ )
+ return [Role(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in roles]
+ except Exception as e:
+ logger.error(f"Error getting roles for mandate {mandateId}: {e}")
+ return []
+
def getAllRoles(self, pagination: Optional[PaginationParams] = None) -> Union[List[Role], PaginatedResult]:
"""
Get all roles with optional pagination, sorting, and filtering.
diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py
index 41be662c..1fd14307 100644
--- a/modules/interfaces/interfaceDbBilling.py
+++ b/modules/interfaces/interfaceDbBilling.py
@@ -983,7 +983,7 @@ class BillingObjects:
if not mandate:
continue
- mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
+ mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "")
settings = self.getSettings(mandateId)
if not settings:
@@ -1066,7 +1066,7 @@ class BillingObjects:
mandate = appInterface.getMandate(mandateId)
mandateName = ""
if mandate:
- mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
+ mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "")
for t in transactions:
t["mandateId"] = mandateId
@@ -1118,7 +1118,7 @@ class BillingObjects:
mandate = appInterface.getMandate(mandateId)
mandateName = ""
if mandate:
- mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
+ mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "")
# Get user accounts count (always exist now for audit trail)
userAccounts = self.db.getRecordset(
@@ -1186,7 +1186,7 @@ class BillingObjects:
mandate = appInterface.getMandate(mandateId)
mandateName = ""
if mandate:
- mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
+ mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "")
for t in transactions:
t["mandateId"] = mandateId
@@ -1251,7 +1251,7 @@ class BillingObjects:
for mandateId in mandateIdList:
mandate = appInterface.getMandate(mandateId)
if mandate:
- mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
+ mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "")
mandateMap[mandateId] = mandateName
for account in allAccounts:
@@ -1335,7 +1335,7 @@ class BillingObjects:
for mandateId in mandateIdList:
mandate = appInterface.getMandate(mandateId)
if mandate:
- mandateName = getattr(mandate, 'name', None) or (mandate.get("name", "") if isinstance(mandate, dict) else "")
+ mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "")
mandateMap[mandateId] = mandateName
# Get transactions for all accounts and collect createdByUserIds
diff --git a/modules/routes/routeAdminRbacRoles.py b/modules/routes/routeAdminRbacRoles.py
index 97830991..91dafb38 100644
--- a/modules/routes/routeAdminRbacRoles.py
+++ b/modules/routes/routeAdminRbacRoles.py
@@ -70,20 +70,28 @@ router = APIRouter(
@limiter.limit("60/minute")
def list_roles(
request: Request,
+ mandateId: Optional[str] = Query(None, description="Filter roles by mandate ID"),
currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
- Get list of all available roles with metadata.
- MULTI-TENANT: SysAdmin-only (roles are system resources).
+ Get list of roles with metadata.
- Returns:
- - List of role dictionaries with role label, description, and user count
+ Without mandateId: returns system template roles (mandateId=NULL).
+ With mandateId: returns mandate-level roles for that mandate (featureInstanceId=NULL).
"""
try:
interface = getRootInterface()
- # Get all roles from database
- dbRoles = interface.getAllRoles()
+ # Get roles filtered by scope
+ print(f"[DEBUG list_roles] mandateId={mandateId}")
+ if mandateId:
+ # Mandate-specific roles (mandate-level only, no feature-instance roles)
+ dbRoles = interface.getRolesForMandate(mandateId)
+ print(f"[DEBUG list_roles] getRolesForMandate returned {len(dbRoles)} roles")
+ else:
+ # System template roles only
+ dbRoles = interface.getAllRoles()
+ print(f"[DEBUG list_roles] getAllRoles returned {len(dbRoles)} roles")
# Count role assignments from UserMandateRole table
roleCounts = interface.countRoleAssignments()
@@ -95,6 +103,8 @@ def list_roles(
"id": role.id,
"roleLabel": role.roleLabel,
"description": role.description,
+ "mandateId": role.mandateId,
+ "featureInstanceId": role.featureInstanceId,
"userCount": roleCounts.get(str(role.id), 0),
"isSystemRole": role.isSystemRole
})
diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py
index 5a639431..25719c5d 100644
--- a/modules/routes/routeAdminRbacRules.py
+++ b/modules/routes/routeAdminRbacRules.py
@@ -720,15 +720,18 @@ def list_roles(
# Get all roles from database
dbRoles = interface.getAllRoles(pagination=None)
+
# Count role assignments from UserMandateRole table
roleCounts = interface.countRoleAssignments()
# Helper function to compute scopeType
+ # Note: mandateId takes precedence — a role with mandateId is always "mandate" scope,
+ # even if isSystemRole=True (which just means it was copied from a system template)
def _computeScopeType(role) -> str:
- if role.isSystemRole:
- return "system"
if role.mandateId:
return "mandate"
+ if role.isSystemRole:
+ return "system"
return "global"
# Convert Role objects to dictionaries and add user counts
diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py
index 2454db44..c4241c0f 100644
--- a/modules/routes/routeInvitations.py
+++ b/modules/routes/routeInvitations.py
@@ -666,16 +666,16 @@ def accept_invitation(
# Update invitation usage
rootInterface.db.recordModify(
Invitation,
- invitation.get("id"),
+ invitation.id,
{
- "currentUses": invitation.get("currentUses", 0) + 1,
+ "currentUses": (invitation.currentUses or 0) + 1,
"usedBy": str(currentUser.id),
"usedAt": currentTime
}
)
logger.info(
- f"User {currentUser.id} accepted invitation {invitation.get('id')} "
+ f"User {currentUser.id} accepted invitation {invitation.id} "
f"for mandate {mandateId}"
)
diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py
index 34bffb64..dd04a67a 100644
--- a/modules/routes/routeSecurityLocal.py
+++ b/modules/routes/routeSecurityLocal.py
@@ -348,7 +348,7 @@ Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
# Get inviter name
inviterId = invitation.createdBy
- inviter = appInterface.getUserById(inviterId) if inviterId else None
+ inviter = appInterface.getUser(inviterId) if inviterId else None
inviterName = (inviter.fullName or inviter.username) if inviter else "PowerOn"
createInvitationNotification(