fixed rbac issues and sysadmin integration

This commit is contained in:
patrick-motsch 2026-02-12 00:34:17 +01:00
parent eb33b3dd38
commit 7051a6e35f
38 changed files with 2170 additions and 576 deletions

View file

@ -19,6 +19,7 @@ from .authentication import (
RequestContext, RequestContext,
getRequestContext, getRequestContext,
requireSysAdmin, requireSysAdmin,
requireSysAdminRole,
) )
from .jwtService import ( from .jwtService import (
createAccessToken, createAccessToken,
@ -44,6 +45,7 @@ __all__ = [
"RequestContext", "RequestContext",
"getRequestContext", "getRequestContext",
"requireSysAdmin", "requireSysAdmin",
"requireSysAdminRole",
# JWT Service # JWT Service
"createAccessToken", "createAccessToken",
"createRefreshToken", "createRefreshToken",

View file

@ -236,6 +236,7 @@ class RequestContext:
# Request-scoped cache: rules loaded only once per request # Request-scoped cache: rules loaded only once per request
self._cachedRules: Optional[List[tuple]] = None self._cachedRules: Optional[List[tuple]] = None
self._cachedHasSysAdminRole: Optional[bool] = None
def getRules(self) -> List[tuple]: def getRules(self) -> List[tuple]:
""" """
@ -262,9 +263,18 @@ class RequestContext:
@property @property
def isSysAdmin(self) -> bool: def isSysAdmin(self) -> bool:
"""Convenience property to check if user is a system admin.""" """Convenience property to check if user has the isSysAdmin FLAG.
Category A only: true system operations (tokens, logs, databases)."""
return getattr(self.user, 'isSysAdmin', False) return getattr(self.user, 'isSysAdmin', False)
@property
def hasSysAdminRole(self) -> bool:
"""Check if user has sysadmin ROLE in root mandate (cached per request).
Use for admin operations (Categories B/C/D/E) instead of isSysAdmin flag."""
if self._cachedHasSysAdminRole is None:
self._cachedHasSysAdminRole = _hasSysAdminRole(str(self.user.id))
return self._cachedHasSysAdminRole
def getRequestContext( def getRequestContext(
request: Request, request: Request,
@ -278,9 +288,9 @@ def getRequestContext(
Security Model: Security Model:
- Regular users: Must be explicit members of mandates/feature instances - Regular users: Must be explicit members of mandates/feature instances
- SysAdmin users: Can access ANY mandate for administrative operations, - SysAdmin users: Can access ANY mandate for administrative operations.
but don't get implicit roleIds (no automatic data access rights). Root mandate roles (incl. sysadmin role) are loaded for RBAC-based authorization.
Routes can check ctx.isSysAdmin to allow admin operations. Routes use ctx.hasSysAdminRole for admin checks (not ctx.isSysAdmin flag).
Args: Args:
request: FastAPI Request object request: FastAPI Request object
@ -315,10 +325,10 @@ def getRequestContext(
ctx.roleIds = rootInterface.getRoleIdsForUserMandate(membership.id) ctx.roleIds = rootInterface.getRoleIdsForUserMandate(membership.id)
elif isSysAdmin: elif isSysAdmin:
# SysAdmin can access any mandate for admin operations # SysAdmin can access any mandate for admin operations
# But they don't get roleIds - no implicit data access # Load root mandate roles for RBAC-based authorization (includes sysadmin role)
ctx.mandateId = mandateId ctx.mandateId = mandateId
# roleIds stays empty - SysAdmin must rely on isSysAdmin flag for authorization ctx.roleIds = _getRootMandateRoleIds(rootInterface, str(currentUser.id))
logger.debug(f"SysAdmin {currentUser.id} accessing mandate {mandateId} without membership") logger.debug(f"SysAdmin {currentUser.id} accessing mandate {mandateId} with root mandate roles")
else: else:
# Regular user without membership - denied # Regular user without membership - denied
logger.warning(f"User {currentUser.id} is not member of mandate {mandateId}") logger.warning(f"User {currentUser.id} is not member of mandate {mandateId}")
@ -344,7 +354,10 @@ def getRequestContext(
elif isSysAdmin: elif isSysAdmin:
# SysAdmin can access any feature instance for admin operations # SysAdmin can access any feature instance for admin operations
ctx.featureInstanceId = featureInstanceId ctx.featureInstanceId = featureInstanceId
logger.debug(f"SysAdmin {currentUser.id} accessing feature instance {featureInstanceId} without explicit access") # If no roles loaded yet, load root mandate roles
if not ctx.roleIds:
ctx.roleIds = _getRootMandateRoleIds(rootInterface, str(currentUser.id))
logger.debug(f"SysAdmin {currentUser.id} accessing feature instance {featureInstanceId} with root mandate roles")
else: else:
# Regular user without access - denied # Regular user without access - denied
logger.warning(f"User {currentUser.id} has no access to feature instance {featureInstanceId}") logger.warning(f"User {currentUser.id} has no access to feature instance {featureInstanceId}")
@ -393,3 +406,97 @@ def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
return currentUser return currentUser
# =============================================================================
# SYSADMIN ROLE: RBAC-based admin checks (hybrid model)
# =============================================================================
def _getRootMandateRoleIds(rootInterface, userId: str) -> List[str]:
"""
Load the user's role IDs from the root mandate.
Used by auth middleware to provide RBAC roles for SysAdmin cross-mandate access.
Args:
rootInterface: Root database interface
userId: User ID
Returns:
List of role IDs from root mandate membership, empty list if no membership
"""
try:
rootMandateId = rootInterface._getRootMandateId()
if not rootMandateId:
return []
membership = rootInterface.getUserMandate(userId, rootMandateId)
if not membership:
return []
return rootInterface.getRoleIdsForUserMandate(membership.id)
except Exception as e:
logger.error(f"Error loading root mandate roles: {e}")
return []
def _hasSysAdminRole(userId: str) -> bool:
"""
Check if a user has the sysadmin role in the root mandate.
Standalone check that queries the database directly, independent of
request context. Used for authorization checks where the sysadmin
ROLE (not just the isSysAdmin flag) is required.
Args:
userId: User ID to check
Returns:
True if user has sysadmin role in root mandate
"""
try:
rootInterface = getRootInterface()
roleIds = _getRootMandateRoleIds(rootInterface, str(userId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "sysadmin":
return True
return False
except Exception as e:
logger.error(f"Error checking sysadmin role: {e}")
return False
def requireSysAdminRole(currentUser: User = Depends(getCurrentUser)) -> User:
"""
Require sysadmin ROLE for admin operations.
Unlike requireSysAdmin (which checks the isSysAdmin FLAG for system-level ops),
this dependency checks the sysadmin ROLE in the root mandate.
Use for admin operations that should be RBAC-controlled (Category E).
Args:
currentUser: Current authenticated user
Returns:
User if they have the sysadmin role
Raises:
HTTPException 403: If user doesn't have sysadmin role
"""
if not _hasSysAdminRole(str(currentUser.id)):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="SysAdmin role required"
)
# Audit
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId="system",
action="sysadmin_role_action",
details="Admin operation via sysadmin role"
)
except Exception:
pass
return currentUser

View file

@ -49,7 +49,11 @@ registerModelLabels(
class AutomationTemplate(BaseModel): class AutomationTemplate(BaseModel):
"""Automation-Vorlage ohne scharfe Placeholder-Werte (DB-persistiert).""" """Automation-Vorlage ohne scharfe Placeholder-Werte (DB-persistiert).
System-Templates (isSystem=True): Nur durch SysAdmin aenderbar. Alle User koennen lesen.
Instance-Templates (isSystem=False, featureInstanceId gesetzt): CRUD durch Instance-Admin/Editor.
"""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Primary key", description="Primary key",
@ -68,6 +72,16 @@ class AutomationTemplate(BaseModel):
description="JSON workflow structure with {{KEY:...}} placeholders", description="JSON workflow structure with {{KEY:...}} placeholders",
json_schema_extra={"frontend_type": "textarea", "frontend_required": True} json_schema_extra={"frontend_type": "textarea", "frontend_required": True}
) )
isSystem: bool = Field(
default=False,
description="System template (only SysAdmin can modify, all users can read)",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
)
featureInstanceId: Optional[str] = Field(
None,
description="Feature instance ID (null for system templates, set for instance-scoped templates)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
# System fields (_createdAt, _createdBy, etc.) werden automatisch vom DB-Connector gesetzt # System fields (_createdAt, _createdBy, etc.) werden automatisch vom DB-Connector gesetzt
@ -79,5 +93,7 @@ registerModelLabels(
"label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"}, "label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"},
"overview": {"en": "Overview", "ge": "Übersicht", "fr": "Aperçu"}, "overview": {"en": "Overview", "ge": "Übersicht", "fr": "Aperçu"},
"template": {"en": "Template", "ge": "Vorlage", "fr": "Modèle"}, "template": {"en": "Template", "ge": "Vorlage", "fr": "Modèle"},
"isSystem": {"en": "System Template", "ge": "System-Vorlage", "fr": "Modèle système"},
"featureInstanceId": {"en": "Feature Instance", "ge": "Feature-Instanz", "fr": "Instance de fonctionnalité"},
}, },
) )

View file

@ -418,6 +418,19 @@ class AutomationObjects:
if not self.checkRbacPermission(AutomationDefinition, "update", automationId): if not self.checkRbacPermission(AutomationDefinition, "update", automationId):
raise PermissionError(f"No permission to modify automation {automationId}") raise PermissionError(f"No permission to modify automation {automationId}")
# If deactivating: immediately remove scheduler job (don't rely on async callback)
isBeingDeactivated = "active" in automationData and not automationData["active"]
if isBeingDeactivated:
existingEventId = getattr(existing, "eventId", None) if not isinstance(existing, dict) else existing.get("eventId")
if existingEventId:
try:
from modules.shared.eventManagement import eventManager
eventManager.remove(existingEventId)
logger.info(f"Removed scheduler job {existingEventId} (automation deactivated)")
except Exception as e:
logger.warning(f"Could not remove scheduler job {existingEventId}: {e}")
automationData["eventId"] = None
# Update automation in database # Update automation in database
updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, automationData) updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, automationData)
@ -484,16 +497,30 @@ class AutomationObjects:
def getAllAutomationTemplates(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]: def getAllAutomationTemplates(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]:
""" """
Returns automation templates filtered by RBAC (MY = own templates). Returns automation templates: system templates + instance templates for current instance.
Supports optional pagination, sorting, and filtering. System templates (isSystem=True) are always included (read-only for non-SysAdmin).
Instance templates (featureInstanceId matches) are included with RBAC filtering.
""" """
# Templates are global (not mandate/feature-instance scoped) — no mandateId/featureInstanceId filter # 1. System templates — always visible to all users
filteredTemplates = getRecordsetWithRBAC( systemTemplates = self.db.getRecordset(
self.db,
AutomationTemplate, AutomationTemplate,
self.currentUser recordFilter={"isSystem": True}
) )
# 2. Instance templates — scoped to current feature instance, RBAC-filtered
instanceTemplates = []
if self.featureInstanceId:
allInstanceTemplates = self.db.getRecordset(
AutomationTemplate,
recordFilter={"featureInstanceId": self.featureInstanceId, "isSystem": False}
)
# Apply RBAC filtering on instance templates
for t in allInstanceTemplates:
instanceTemplates.append(t)
# Combine: system first, then instance
filteredTemplates = systemTemplates + instanceTemplates
# Enrich with user names # Enrich with user names
self._enrichTemplatesWithUserName(filteredTemplates) self._enrichTemplatesWithUserName(filteredTemplates)
@ -562,35 +589,56 @@ class AutomationObjects:
logger.warning(f"Could not enrich templates with user names: {e}") logger.warning(f"Could not enrich templates with user names: {e}")
def getAutomationTemplate(self, templateId: str) -> Optional[Dict[str, Any]]: def getAutomationTemplate(self, templateId: str) -> Optional[Dict[str, Any]]:
"""Returns an automation template by ID if user has access.""" """Returns an automation template by ID (system templates always accessible, instance templates scoped)."""
try: try:
# Templates are global — no mandateId/featureInstanceId filter records = self.db.getRecordset(
filtered = getRecordsetWithRBAC(
self.db,
AutomationTemplate, AutomationTemplate,
self.currentUser,
recordFilter={"id": templateId} recordFilter={"id": templateId}
) )
if not filtered: if not records:
return None return None
template = filtered[0] template = records[0]
# System templates are readable by everyone
if template.get("isSystem"):
self._enrichTemplatesWithUserName([template])
return template
# Instance templates: must belong to current feature instance
templateInstanceId = template.get("featureInstanceId")
if templateInstanceId and self.featureInstanceId and str(templateInstanceId) != str(self.featureInstanceId):
return None # Not in this instance
self._enrichTemplatesWithUserName([template]) self._enrichTemplatesWithUserName([template])
return template return template
except Exception as e: except Exception as e:
logger.error(f"Error getting automation template: {str(e)}") logger.error(f"Error getting automation template: {str(e)}")
return None return None
def createAutomationTemplate(self, templateData: Dict[str, Any]) -> Dict[str, Any]: def createAutomationTemplate(self, templateData: Dict[str, Any], isSysAdmin: bool = False) -> Dict[str, Any]:
"""Creates a new automation template.""" """Creates a new automation template.
System templates (isSystem=True) can only be created by SysAdmin.
Instance templates get featureInstanceId from context.
"""
try: try:
# Ensure ID is present # Ensure ID is present
if "id" not in templateData or not templateData["id"]: if "id" not in templateData or not templateData["id"]:
templateData["id"] = str(uuid.uuid4()) templateData["id"] = str(uuid.uuid4())
# RBAC check # System template protection
if not self.checkRbacPermission(AutomationTemplate, "create"): if templateData.get("isSystem") and not isSysAdmin:
raise PermissionError("Only SysAdmin can create system templates")
# Set featureInstanceId for non-system templates
if not templateData.get("isSystem"):
templateData["featureInstanceId"] = self.featureInstanceId
templateData["isSystem"] = False
# RBAC check (for non-system templates)
if not isSysAdmin and not self.checkRbacPermission(AutomationTemplate, "create"):
raise PermissionError("No permission to create template") raise PermissionError("No permission to create template")
# Ensure database connector has correct userId context # Ensure database connector has correct userId context
@ -606,7 +654,6 @@ class AutomationObjects:
templateData["template"] = json.dumps(templateData["template"]) templateData["template"] = json.dumps(templateData["template"])
# Validate through Pydantic model to ensure proper type conversion # Validate through Pydantic model to ensure proper type conversion
# This converts dict fields like TextMultilingual to proper Pydantic objects
validatedTemplate = AutomationTemplate(**templateData) validatedTemplate = AutomationTemplate(**templateData)
# Create template in database using model_dump for proper serialization # Create template in database using model_dump for proper serialization
@ -617,17 +664,28 @@ class AutomationObjects:
logger.error(f"Error creating automation template: {str(e)}") logger.error(f"Error creating automation template: {str(e)}")
raise raise
def updateAutomationTemplate(self, templateId: str, templateData: Dict[str, Any]) -> Dict[str, Any]: def updateAutomationTemplate(self, templateId: str, templateData: Dict[str, Any], isSysAdmin: bool = False) -> Dict[str, Any]:
"""Updates an automation template.""" """Updates an automation template.
System templates can only be updated by SysAdmin.
"""
try: try:
# Check access # Check access
existing = self.getAutomationTemplate(templateId) existing = self.getAutomationTemplate(templateId)
if not existing: if not existing:
raise PermissionError(f"No access to template {templateId}") raise PermissionError(f"No access to template {templateId}")
if not self.checkRbacPermission(AutomationTemplate, "update", templateId): # System template protection
if existing.get("isSystem") and not isSysAdmin:
raise PermissionError("Only SysAdmin can modify system templates")
if not isSysAdmin and not self.checkRbacPermission(AutomationTemplate, "update", templateId):
raise PermissionError(f"No permission to modify template {templateId}") raise PermissionError(f"No permission to modify template {templateId}")
# Prevent changing isSystem/featureInstanceId
templateData.pop("isSystem", None)
templateData.pop("featureInstanceId", None)
# Convert template field to string if it's a dict (frontend may send parsed JSON) # Convert template field to string if it's a dict (frontend may send parsed JSON)
if "template" in templateData and isinstance(templateData["template"], dict): if "template" in templateData and isinstance(templateData["template"], dict):
import json import json
@ -648,15 +706,22 @@ class AutomationObjects:
logger.error(f"Error updating automation template: {str(e)}") logger.error(f"Error updating automation template: {str(e)}")
raise raise
def deleteAutomationTemplate(self, templateId: str) -> bool: def deleteAutomationTemplate(self, templateId: str, isSysAdmin: bool = False) -> bool:
"""Deletes an automation template.""" """Deletes an automation template.
System templates can only be deleted by SysAdmin.
"""
try: try:
# Check access using RBAC # Check access
existing = self.getAutomationTemplate(templateId) existing = self.getAutomationTemplate(templateId)
if not existing: if not existing:
return False return False
if not self.checkRbacPermission(AutomationTemplate, "delete", templateId): # System template protection
if existing.get("isSystem") and not isSysAdmin:
raise PermissionError("Only SysAdmin can delete system templates")
if not isSysAdmin and not self.checkRbacPermission(AutomationTemplate, "delete", templateId):
raise PermissionError(f"No permission to delete template {templateId}") raise PermissionError(f"No permission to delete template {templateId}")
# Delete template from database # Delete template from database
@ -667,6 +732,94 @@ class AutomationObjects:
logger.error(f"Error deleting automation template: {str(e)}") logger.error(f"Error deleting automation template: {str(e)}")
raise raise
def duplicateAutomationTemplate(self, templateId: str) -> Dict[str, Any]:
"""Duplicates a template into the current feature instance.
Creates a copy with new ID, isSystem=False, featureInstanceId from context.
Works for both system and instance templates.
"""
try:
existing = self.getAutomationTemplate(templateId)
if not existing:
raise PermissionError(f"Template {templateId} not found")
# RBAC check for creating templates
if not self.checkRbacPermission(AutomationTemplate, "create"):
raise PermissionError("No permission to create templates")
# Build duplicate data
duplicateData = {
"id": str(uuid.uuid4()),
"label": existing.get("label", {}),
"overview": existing.get("overview"),
"template": existing.get("template", ""),
"isSystem": False,
"featureInstanceId": self.featureInstanceId,
}
# Append "(Kopie)" to label
label = duplicateData["label"]
if isinstance(label, dict):
for lang in label:
if label[lang]:
label[lang] = f"{label[lang]} (Kopie)"
# Ensure database connector has correct userId context
if self.userId and hasattr(self.db, 'updateContext'):
self.db.updateContext(self.userId)
validatedTemplate = AutomationTemplate(**duplicateData)
createdTemplate = self.db.recordCreate(AutomationTemplate, validatedTemplate.model_dump())
logger.info(f"Duplicated template {templateId} -> {duplicateData['id']}")
return createdTemplate
except Exception as e:
logger.error(f"Error duplicating template: {str(e)}")
raise
def duplicateAutomationDefinition(self, definitionId: str) -> Dict[str, Any]:
"""Duplicates an automation definition within the same feature instance.
Creates a copy with new ID, active=False, no eventId.
"""
try:
existing = self.getAutomationDefinition(definitionId)
if not existing:
raise PermissionError(f"Definition {definitionId} not found")
# RBAC check for creating definitions
if not self.checkRbacPermission(AutomationDefinition, "create"):
raise PermissionError("No permission to create definitions")
# Build duplicate data
duplicateData = {
"id": str(uuid.uuid4()),
"mandateId": existing.get("mandateId"),
"featureInstanceId": existing.get("featureInstanceId"),
"label": f"{existing.get('label', '')} (Kopie)",
"schedule": existing.get("schedule", ""),
"template": existing.get("template", ""),
"placeholders": existing.get("placeholders", {}),
"active": False,
"eventId": None,
"status": None,
"executionLogs": [],
"allowedProviders": existing.get("allowedProviders", []),
}
# Ensure database connector has correct userId context
if self.userId and hasattr(self.db, 'updateContext'):
self.db.updateContext(self.userId)
validatedDefinition = AutomationDefinition(**duplicateData)
createdDefinition = self.db.recordCreate(AutomationDefinition, validatedDefinition.model_dump())
logger.info(f"Duplicated definition {definitionId} -> {duplicateData['id']}")
return createdDefinition
except Exception as e:
logger.error(f"Error duplicating definition: {str(e)}")
raise
def _notifyAutomationChanged(self): def _notifyAutomationChanged(self):
"""Notify registered callbacks about automation changes (decoupled from features). """Notify registered callbacks about automation changes (decoupled from features).
Sync-safe: works from both sync and async contexts.""" Sync-safe: works from both sync and async contexts."""

View file

@ -165,6 +165,9 @@ def registerFeature(catalogService) -> bool:
# Sync template roles to database # Sync template roles to database
_syncTemplateRolesToDb() _syncTemplateRolesToDb()
# Mark existing templates without isSystem field as system templates (migration)
_migrateExistingTemplates()
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects") logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects")
return True return True
@ -290,3 +293,41 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di
logger.debug(f"Created {createdCount} AccessRules for role {roleId}") logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
return createdCount return createdCount
def _migrateExistingTemplates() -> None:
"""
Migration: Mark existing templates that have no isSystem/featureInstanceId fields
as system templates (isSystem=True). This runs idempotently during feature registration.
"""
try:
from modules.features.automation.interfaceFeatureAutomation import getAutomationInterface
from modules.security.rootAccess import getRootUser
from modules.features.automation.datamodelFeatureAutomation import AutomationTemplate
rootUser = getRootUser()
automationInterface = getAutomationInterface(rootUser)
# Get all templates from DB
allTemplates = automationInterface.db.getRecordset(AutomationTemplate)
migratedCount = 0
for template in allTemplates:
templateId = template.get("id")
isSystem = template.get("isSystem")
featureInstanceId = template.get("featureInstanceId")
# Templates without isSystem set (old templates) → mark as system
if isSystem is None and featureInstanceId is None:
automationInterface.db.recordModify(
AutomationTemplate,
templateId,
{"isSystem": True, "featureInstanceId": None}
)
migratedCount += 1
if migratedCount > 0:
logger.info(f"Migrated {migratedCount} existing templates to isSystem=True")
except Exception as e:
logger.warning(f"Template migration check failed (non-critical): {e}")

View file

@ -530,7 +530,7 @@ def create_db_template(
mandateId=str(context.mandateId) if context.mandateId else None, mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
) )
created = chatInterface.createAutomationTemplate(templateData) created = chatInterface.createAutomationTemplate(templateData, isSysAdmin=context.hasSysAdminRole)
return JSONResponse(content=created) return JSONResponse(content=created)
except HTTPException: except HTTPException:
raise raise
@ -562,7 +562,7 @@ def update_db_template(
mandateId=str(context.mandateId) if context.mandateId else None, mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
) )
updated = chatInterface.updateAutomationTemplate(templateId, templateData) updated = chatInterface.updateAutomationTemplate(templateId, templateData, isSysAdmin=context.hasSysAdminRole)
return JSONResponse(content=updated) return JSONResponse(content=updated)
except HTTPException: except HTTPException:
raise raise
@ -593,7 +593,7 @@ def delete_db_template(
mandateId=str(context.mandateId) if context.mandateId else None, mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
) )
success = chatInterface.deleteAutomationTemplate(templateId) success = chatInterface.deleteAutomationTemplate(templateId, isSysAdmin=context.hasSysAdminRole)
if success: if success:
return Response(status_code=204) return Response(status_code=204)
else: else:
@ -616,3 +616,63 @@ def delete_db_template(
) )
@templateRouter.post("/{templateId}/duplicate")
@limiter.limit("10/minute")
def duplicate_db_template(
request: Request,
templateId: str = Path(..., description="Template ID to duplicate"),
context: RequestContext = Depends(getRequestContext)
) -> JSONResponse:
"""Duplicate a template into the current feature instance (system or instance template)."""
try:
chatInterface = getAutomationInterface(
context.user,
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
)
duplicated = chatInterface.duplicateAutomationTemplate(templateId)
return JSONResponse(content=duplicated)
except HTTPException:
raise
except PermissionError as e:
raise HTTPException(
status_code=403,
detail=str(e)
)
except Exception as e:
logger.error(f"Error duplicating template: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error duplicating template: {str(e)}"
)
@router.post("/{automationId}/duplicate")
@limiter.limit("10/minute")
def duplicate_automation(
request: Request,
automationId: str = Path(..., description="Automation definition ID to duplicate"),
context: RequestContext = Depends(getRequestContext)
) -> JSONResponse:
"""Duplicate an automation definition within the same feature instance."""
try:
chatInterface = getAutomationInterface(
context.user,
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
)
duplicated = chatInterface.duplicateAutomationDefinition(automationId)
return JSONResponse(content=duplicated)
except HTTPException:
raise
except PermissionError as e:
raise HTTPException(
status_code=403,
detail=str(e)
)
except Exception as e:
logger.error(f"Error duplicating automation: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"Error duplicating automation: {str(e)}"
)

View file

@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
_chatInterfaces = {} _chatInterfaces = {}
def storeDebugMessageAndDocuments(message, currentUser) -> None: def storeDebugMessageAndDocuments(message, currentUser, mandateId=None, featureInstanceId=None) -> None:
""" """
Store message and documents (metadata and file bytes) for debugging purposes. Store message and documents (metadata and file bytes) for debugging purposes.
Structure: {log_dir}/debug/messages/m_round_task_action_timestamp/documentlist_label/ Structure: {log_dir}/debug/messages/m_round_task_action_timestamp/documentlist_label/
@ -53,6 +53,8 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
Args: Args:
message: ChatMessage object to store message: ChatMessage object to store
currentUser: Current user for component interface access currentUser: Current user for component interface access
mandateId: Mandate ID for RBAC context (avoids overwriting singleton state)
featureInstanceId: Feature instance ID for RBAC context
""" """
try: try:
import os import os
@ -152,7 +154,7 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
# Also store the actual file bytes next to metadata for debugging # Also store the actual file bytes next to metadata for debugging
try: try:
componentInterface = getInterface(currentUser) componentInterface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
file_bytes = componentInterface.getFileData(doc.fileId) file_bytes = componentInterface.getFileData(doc.fileId)
if file_bytes: if file_bytes:
# Build a safe filename preserving original name # Build a safe filename preserving original name
@ -1134,7 +1136,7 @@ class ChatObjects:
logger.debug(f"Could not emit message event: {e}") logger.debug(f"Could not emit message event: {e}")
# Debug: Store message and documents for debugging - only if debug enabled # Debug: Store message and documents for debugging - only if debug enabled
storeDebugMessageAndDocuments(chat_message, self.currentUser) storeDebugMessageAndDocuments(chat_message, self.currentUser, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId)
return chat_message return chat_message

View file

@ -86,7 +86,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
) )
# Verify user has access to this instance # Verify user has access to this instance
if not context.isSysAdmin: if not context.hasSysAdminRole:
# Check if user has FeatureAccess for this instance # Check if user has FeatureAccess for this instance
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id)) featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
hasAccess = any( hasAccess = any(

View file

@ -90,7 +90,8 @@ class InterfaceFeatureNeutralizer:
self.db, self.db,
DataNeutraliserConfig, DataNeutraliserConfig,
self.currentUser, self.currentUser,
recordFilter={"mandateId": self.mandateId} recordFilter={"mandateId": self.mandateId},
mandateId=self.mandateId
) )
if not filteredConfigs: if not filteredConfigs:
@ -153,7 +154,8 @@ class InterfaceFeatureNeutralizer:
self.db, self.db,
DataNeutralizerAttributes, DataNeutralizerAttributes,
self.currentUser, self.currentUser,
recordFilter=filterDict recordFilter=filterDict,
mandateId=self.mandateId
) )
# Filter out database-specific fields # Filter out database-specific fields

View file

@ -101,7 +101,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
status_code=400, status_code=400,
detail=f"Instance '{instanceId}' is not a realestate instance" detail=f"Instance '{instanceId}' is not a realestate instance"
) )
if not context.isSysAdmin: if not context.hasSysAdminRole:
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id)) featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
hasAccess = any( hasAccess = any(
str(fa.featureInstanceId) == instanceId and fa.enabled str(fa.featureInstanceId) == instanceId and fa.enabled

View file

@ -100,7 +100,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
) )
# Verify user has access to this instance # Verify user has access to this instance
if not context.isSysAdmin: if not context.hasSysAdminRole:
# Check if user has FeatureAccess for this instance # Check if user has FeatureAccess for this instance
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id)) featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
hasAccess = any( hasAccess = any(
@ -1319,27 +1319,29 @@ def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> str:
Validate that the user has admin access to the feature instance. Validate that the user has admin access to the feature instance.
Returns the mandateId if authorized. Returns the mandateId if authorized.
This checks for the RESOURCE permission 'instance-roles.manage'. This checks for the RESOURCE permission 'instance-roles.manage'
via AccessRules assigned to the user's current roles.
""" """
mandateId = _validateInstanceAccess(instanceId, context) mandateId = _validateInstanceAccess(instanceId, context)
# SysAdmin always has access # SysAdmin role always has access
if context.user.isSysAdmin: if context.hasSysAdminRole:
return mandateId return mandateId
# Check for instance-roles.manage resource permission # Check for instance-roles.manage resource permission via AccessRules
featureInterface = getFeatureInterface() rootInterface = getRootInterface()
permissions = featureInterface.getUserPermissionsForInstance(context.user.id, instanceId) hasAdminPermission = False
if not permissions: for roleId in context.roleIds:
raise HTTPException( rules = rootInterface.db.getRecordset(
status_code=403, AccessRule,
detail="Keine Berechtigung zur Rollenverwaltung" {"roleId": roleId, "context": AccessRuleContext.RESOURCE.value, "item": "resource.trustee.instance-roles.manage"}
) )
if rules:
hasAdminPermission = True
break
# Check for resource permission if not hasAdminPermission:
resourcePermissions = permissions.get("resources", {})
if not resourcePermissions.get("instance-roles.manage"):
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
detail="Keine Berechtigung zur Rollenverwaltung" detail="Keine Berechtigung zur Rollenverwaltung"

View file

@ -67,6 +67,14 @@ def initBootstrap(db: DatabaseConnector) -> None:
# This also serves as migration for existing mandates that don't have instance roles yet # This also serves as migration for existing mandates that don't have instance roles yet
_ensureAllMandatesHaveSystemRoles(db) _ensureAllMandatesHaveSystemRoles(db)
# Initialize sysadmin role in root mandate (NOT a template, mandate-specific)
# Hybrid model: isSysAdmin flag → system ops, sysadmin role → admin ops via RBAC
if mandateId:
_initSysAdminRole(db, mandateId)
# Ensure UI rules for sysadmin role (created after initRbacRules, needs second pass)
_ensureUiContextRules(db)
# Initialize admin user # Initialize admin user
adminUserId = initAdminUser(db, mandateId) adminUserId = initAdminUser(db, mandateId)
@ -392,9 +400,9 @@ def initRoles(db: DatabaseConnector) -> None:
Initialize standard roles if they don't exist. Initialize standard roles if they don't exist.
Roles are created as GLOBAL (mandateId=None) template roles. Roles are created as GLOBAL (mandateId=None) template roles.
NOTE: SysAdmin is NOT a role - it's a flag (User.isSysAdmin). NOTE: The "sysadmin" role is NOT a template - it's created separately in
SysAdmin users bypass RBAC entirely and have full system access. _initSysAdminRole() as a root-mandate-specific role (isSystemRole=False).
These template roles are for mandate/feature-level access control. These template roles (admin/user/viewer) are for mandate/feature-level access control.
Args: Args:
db: Database connector instance db: Database connector instance
@ -404,7 +412,7 @@ def initRoles(db: DatabaseConnector) -> None:
_roleIdCache = {} _roleIdCache = {}
# Standard template roles for mandate/feature-level access # Standard template roles for mandate/feature-level access
# NOTE: No "sysadmin" role - SysAdmin is a flag (User.isSysAdmin), not a role! # NOTE: "sysadmin" role is created separately in _initSysAdminRole (root mandate only)
standardRoles = [ standardRoles = [
Role( Role(
roleLabel="admin", roleLabel="admin",
@ -501,14 +509,13 @@ def _deduplicateRoles(db: DatabaseConnector) -> None:
fixedMandateCount += 1 fixedMandateCount += 1
except Exception as e: except Exception as e:
logger.warning(f"Failed to fix mandate role {role.get('id')}: {e}") logger.warning(f"Failed to fix mandate role {role.get('id')}: {e}")
# Template roles (mandateId=None, standard labels) MUST be isSystemRole=True # Template roles (mandateId=None, no featureCode) MUST be isSystemRole=True
if role.get("mandateId") is None and role.get("isSystemRole") is not True: if role.get("mandateId") is None and role.get("featureCode") is None and role.get("isSystemRole") is not True:
if role.get("roleLabel") in ("admin", "user", "viewer"): try:
try: db.recordModify(Role, role.get("id"), {"isSystemRole": True})
db.recordModify(Role, role.get("id"), {"isSystemRole": True}) fixedTemplateCount += 1
fixedTemplateCount += 1 except Exception as e:
except Exception as e: logger.warning(f"Failed to fix template role {role.get('id')}: {e}")
logger.warning(f"Failed to fix template role {role.get('id')}: {e}")
if fixedMandateCount > 0: if fixedMandateCount > 0:
logger.info(f"Fixed {fixedMandateCount} mandate-level roles: isSystemRole → False") logger.info(f"Fixed {fixedMandateCount} mandate-level roles: isSystemRole → False")
if fixedTemplateCount > 0: if fixedTemplateCount > 0:
@ -623,6 +630,151 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
return copiedCount return copiedCount
def _initSysAdminRole(db: DatabaseConnector, mandateId: str) -> Optional[str]:
"""
Initialize the sysadmin role in the root mandate.
The sysadmin role is a mandate-specific role (NOT a system template) that provides
full administrative access via RBAC. It only exists in the root mandate and is
NOT copied to other mandates (isSystemRole=False).
Hybrid model:
- User.isSysAdmin flag true system operations (Category A: tokens, logs, databases)
- sysadmin role admin operations via RBAC (Categories B/C/D/E)
Args:
db: Database connector instance
mandateId: Root mandate ID
Returns:
Sysadmin role ID or None
"""
# Check if sysadmin role already exists in root mandate
existingRoles = db.getRecordset(
Role,
recordFilter={"roleLabel": "sysadmin", "mandateId": mandateId, "featureInstanceId": None}
)
if existingRoles:
sysadminRoleId = existingRoles[0].get("id")
logger.info(f"Sysadmin role already exists in root mandate with ID {sysadminRoleId}")
# Ensure AccessRules exist (migration safety)
_ensureSysAdminAccessRules(db, sysadminRoleId)
return sysadminRoleId
# Create sysadmin role in root mandate
logger.info("Creating sysadmin role in root mandate")
sysadminRole = Role(
roleLabel="sysadmin",
description={
"en": "System Administrator - Full administrative access across all mandates",
"de": "System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten",
"fr": "Administrateur système - Accès administratif complet à tous les mandats"
},
mandateId=mandateId,
featureInstanceId=None,
featureCode=None,
isSystemRole=False # NOT a template → NOT copied to other mandates
)
createdRole = db.recordCreate(Role, sysadminRole)
sysadminRoleId = createdRole.get("id")
logger.info(f"Created sysadmin role with ID {sysadminRoleId}")
# Create AccessRules for sysadmin role
_createSysAdminAccessRules(db, sysadminRoleId)
return sysadminRoleId
def _createSysAdminAccessRules(db: DatabaseConnector, sysadminRoleId: str) -> None:
"""
Create AccessRules for the sysadmin role.
DATA + RESOURCE: generic item=None (full access).
UI: NO generic rule here explicit ui.admin.* rules are created by
_ensureUiContextRules() (same logic as admin role).
Args:
db: Database connector instance
sysadminRoleId: Sysadmin role ID
"""
rules = [
# DATA: Full access to all data tables (generic rule, item=None)
AccessRule(
roleId=sysadminRoleId,
context=AccessRuleContext.DATA,
item=None,
view=True,
read=AccessLevel.ALL,
create=AccessLevel.ALL,
update=AccessLevel.ALL,
delete=AccessLevel.ALL,
),
# RESOURCE: Access to all system resources (generic rule, item=None)
AccessRule(
roleId=sysadminRoleId,
context=AccessRuleContext.RESOURCE,
item=None,
view=True,
read=None,
create=None,
update=None,
delete=None,
),
]
for rule in rules:
db.recordCreate(AccessRule, rule)
logger.info(f"Created {len(rules)} AccessRules for sysadmin role (UI rules via _ensureUiContextRules)")
def _ensureSysAdminAccessRules(db: DatabaseConnector, sysadminRoleId: str) -> None:
"""
Ensure AccessRules exist for the sysadmin role (migration safety).
Creates missing rules without duplicating existing ones.
Args:
db: Database connector instance
sysadminRoleId: Sysadmin role ID
"""
existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": sysadminRoleId})
if not existingRules:
logger.info("No AccessRules found for sysadmin role, creating them")
_createSysAdminAccessRules(db, sysadminRoleId)
return
# Check for DATA and RESOURCE contexts (UI is handled by _ensureUiContextRules)
existingContexts = {r.get("context") for r in existingRules}
missingRules = []
if AccessRuleContext.DATA.value not in existingContexts:
missingRules.append(AccessRule(
roleId=sysadminRoleId,
context=AccessRuleContext.DATA,
item=None,
view=True,
read=AccessLevel.ALL,
create=AccessLevel.ALL,
update=AccessLevel.ALL,
delete=AccessLevel.ALL,
))
if AccessRuleContext.RESOURCE.value not in existingContexts:
missingRules.append(AccessRule(
roleId=sysadminRoleId,
context=AccessRuleContext.RESOURCE,
item=None,
view=True,
read=None, create=None, update=None, delete=None,
))
if missingRules:
for rule in missingRules:
db.recordCreate(AccessRule, rule)
logger.info(f"Created {len(missingRules)} missing AccessRules for sysadmin role")
def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]: def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]:
""" """
Get role ID by label, using cache or database lookup. Get role ID by label, using cache or database lookup.
@ -688,8 +840,8 @@ def _createDefaultRoleRules(db: DatabaseConnector) -> None:
Create default role rules for generic access (item = null). Create default role rules for generic access (item = null).
Uses roleId instead of roleLabel. Uses roleId instead of roleLabel.
NOTE: No rules for "sysadmin" - SysAdmin is a flag (User.isSysAdmin), not a role! NOTE: Sysadmin role rules are created separately in _initSysAdminRole().
SysAdmin users bypass RBAC entirely via the isSysAdmin check in getRecordsetWithRBAC(). These default rules cover admin/user/viewer template roles.
Args: Args:
db: Database connector instance db: Database connector instance
@ -710,19 +862,8 @@ def _createDefaultRoleRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE, delete=AccessLevel.NONE,
)) ))
# User Role - My records only # User Role - No access rights (mandate membership marker only)
userId = _getRoleId(db, "user") # Users get their actual permissions from feature-instance-level roles
if userId:
defaultRules.append(AccessRule(
roleId=userId,
context=AccessRuleContext.DATA,
item=None,
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
update=AccessLevel.MY,
delete=AccessLevel.MY,
))
# Viewer Role - Read-only group access # Viewer Role - Read-only group access
viewerId = _getRoleId(db, "viewer") viewerId = _getRoleId(db, "viewer")
@ -750,15 +891,15 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
These rules override generic rules for specific tables. These rules override generic rules for specific tables.
Uses roleId instead of roleLabel. Uses roleId instead of roleLabel.
NOTE: No rules for "sysadmin" - SysAdmin is a flag (User.isSysAdmin), not a role! NOTE: Sysadmin role rules are created separately in _initSysAdminRole().
SysAdmin users bypass RBAC entirely via the isSysAdmin check in getRecordsetWithRBAC(). These table-specific rules cover admin/user/viewer template roles.
Args: Args:
db: Database connector instance db: Database connector instance
""" """
tableRules = [] tableRules = []
# Get role IDs (no sysadmin - that's a flag, not a role!) # Get role IDs for template roles (sysadmin is a separate mandate-level role)
adminId = _getRoleId(db, "admin") adminId = _getRoleId(db, "admin")
userId = _getRoleId(db, "user") userId = _getRoleId(db, "user")
viewerId = _getRoleId(db, "viewer") viewerId = _getRoleId(db, "viewer")
@ -1276,15 +1417,50 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
Ensure UI context rules exist for all navigation items. Ensure UI context rules exist for all navigation items.
This is called during bootstrap to add missing UI rules for new navigation items. This is called during bootstrap to add missing UI rules for new navigation items.
Creates rules for BOTH template roles AND mandate-instance roles.
This ensures new navigation items are visible even for mandates created before
the navigation item was added.
Args: Args:
db: Database connector instance db: Database connector instance
""" """
from modules.system.mainSystem import NAVIGATION_SECTIONS from modules.system.mainSystem import NAVIGATION_SECTIONS
# Template role IDs
adminId = _getRoleId(db, "admin") adminId = _getRoleId(db, "admin")
userId = _getRoleId(db, "user") userId = _getRoleId(db, "user")
viewerId = _getRoleId(db, "viewer") viewerId = _getRoleId(db, "viewer")
# Mandate-instance role IDs (same roleLabel, but mandateId set, featureInstanceId=None)
mandateAdminRoleIds = []
mandateUserRoleIds = []
mandateViewerRoleIds = []
sysadminRoleIds = []
mandateRoles = db.getRecordset(
Role,
recordFilter={"isSystemRole": False, "featureInstanceId": None}
)
for role in mandateRoles:
roleId = role.get("id")
label = role.get("roleLabel")
if not roleId or not label or not role.get("mandateId"):
continue
if label == "admin":
mandateAdminRoleIds.append(roleId)
elif label == "user":
mandateUserRoleIds.append(roleId)
elif label == "viewer":
mandateViewerRoleIds.append(roleId)
elif label == "sysadmin":
sysadminRoleIds.append(roleId)
# All role IDs per level (template + mandate-instance)
# sysadmin gets ALL UI rules (admin-only + public) — same logic, explicit rules
allAdminRoleIds = ([adminId] if adminId else []) + mandateAdminRoleIds + sysadminRoleIds
allUserRoleIds = ([userId] if userId else []) + mandateUserRoleIds
allViewerRoleIds = ([viewerId] if viewerId else []) + mandateViewerRoleIds
# Get existing UI rules # Get existing UI rules
existingUiRules = db.getRecordset( existingUiRules = db.getRecordset(
AccessRule, AccessRule,
@ -1312,19 +1488,20 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
isAdminOnly = item.get("adminOnly", False) or isAdminSection isAdminOnly = item.get("adminOnly", False) or isAdminSection
if isAdminOnly: if isAdminOnly:
# Admin-only: only admin role # Admin-only: all admin roles (template + mandate-instance)
if adminId and (adminId, objectKey) not in existingCombinations: for roleId in allAdminRoleIds:
missingRules.append(AccessRule( if (roleId, objectKey) not in existingCombinations:
roleId=adminId, missingRules.append(AccessRule(
context=AccessRuleContext.UI, roleId=roleId,
item=objectKey, context=AccessRuleContext.UI,
view=True, item=objectKey,
read=None, create=None, update=None, delete=None, view=True,
)) read=None, create=None, update=None, delete=None,
))
else: else:
# Public/normal: all roles # Public/normal: all roles (template + mandate-instance)
for roleId in [adminId, userId, viewerId]: for roleId in allAdminRoleIds + allUserRoleIds + allViewerRoleIds:
if roleId and (roleId, objectKey) not in existingCombinations: if (roleId, objectKey) not in existingCombinations:
missingRules.append(AccessRule( missingRules.append(AccessRule(
roleId=roleId, roleId=roleId,
context=AccessRuleContext.UI, context=AccessRuleContext.UI,
@ -1337,7 +1514,7 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
if missingRules: if missingRules:
for rule in missingRules: for rule in missingRules:
db.recordCreate(AccessRule, rule) db.recordCreate(AccessRule, rule)
logger.info(f"Created {len(missingRules)} missing UI context rules") logger.info(f"Created {len(missingRules)} missing UI context rules (incl. mandate-instance roles)")
# All UI context rules already exist (nothing to create) # All UI context rules already exist (nothing to create)
@ -1812,8 +1989,8 @@ def assignInitialUserMemberships(
Assign initial memberships to admin and event users via UserMandate + UserMandateRole. Assign initial memberships to admin and event users via UserMandate + UserMandateRole.
This is the NEW multi-tenant way of assigning roles. 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 Hybrid model: Initial users get BOTH the isSysAdmin flag (for system ops)
within the root mandate, plus they have isSysAdmin=True for system-level access. AND the "admin" + "sysadmin" roles in the root mandate (for RBAC-based admin ops).
Args: Args:
db: Database connector instance db: Database connector instance
@ -1821,16 +1998,24 @@ def assignInitialUserMemberships(
adminUserId: Admin user ID adminUserId: Admin user ID
eventUserId: Event user ID eventUserId: Event user ID
""" """
# Use mandate-instance "admin" role (not the global template) # Find the highest-privilege mandate-level role (prefer "admin", fallback to first available)
mandateAdminRoles = db.getRecordset( mandateRoles = db.getRecordset(
Role, Role,
recordFilter={"roleLabel": "admin", "mandateId": mandateId, "featureInstanceId": None} recordFilter={"mandateId": mandateId, "featureInstanceId": None}
) )
adminRoleId = mandateAdminRoles[0].get("id") if mandateAdminRoles else None # Prefer "admin" role, fall back to first available mandate role
adminRole = next((r for r in mandateRoles if r.get("roleLabel") == "admin"), None)
adminRoleId = adminRole.get("id") if adminRole else (mandateRoles[0].get("id") if mandateRoles else None)
if not adminRoleId: if not adminRoleId:
logger.warning(f"Admin role not found for mandate {mandateId}, skipping membership assignment") logger.warning(f"No mandate-level role found for mandate {mandateId}, skipping membership assignment")
return return
# Find sysadmin role in root mandate (created by _initSysAdminRole)
sysadminRole = next((r for r in mandateRoles if r.get("roleLabel") == "sysadmin"), None)
sysadminRoleId = sysadminRole.get("id") if sysadminRole else None
if not sysadminRoleId:
logger.warning("Sysadmin role not found in root mandate - run _initSysAdminRole first")
for userId, userName in [(adminUserId, "admin"), (eventUserId, "event")]: for userId, userName in [(adminUserId, "admin"), (eventUserId, "event")]:
# Check if UserMandate already exists # Check if UserMandate already exists
existingMemberships = db.getRecordset( existingMemberships = db.getRecordset(
@ -1851,7 +2036,7 @@ def assignInitialUserMemberships(
userMandateId = createdMembership.get("id") userMandateId = createdMembership.get("id")
logger.info(f"Created UserMandate for {userName} user with ID {userMandateId}") logger.info(f"Created UserMandate for {userName} user with ID {userMandateId}")
# Check if UserMandateRole already exists # Check if UserMandateRole already exists for admin role
existingRoles = db.getRecordset( existingRoles = db.getRecordset(
UserMandateRole, UserMandateRole,
recordFilter={"userMandateId": userMandateId, "roleId": adminRoleId} recordFilter={"userMandateId": userMandateId, "roleId": adminRoleId}
@ -1866,6 +2051,20 @@ def assignInitialUserMemberships(
db.recordCreate(UserMandateRole, userMandateRole) db.recordCreate(UserMandateRole, userMandateRole)
logger.info(f"Assigned admin role to {userName} user in mandate") logger.info(f"Assigned admin role to {userName} user in mandate")
# Assign sysadmin role (in addition to admin role)
if sysadminRoleId:
existingSysadminRoles = db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId, "roleId": sysadminRoleId}
)
if not existingSysadminRoles:
sysadminMandateRole = UserMandateRole(
userMandateId=userMandateId,
roleId=sysadminRoleId
)
db.recordCreate(UserMandateRole, sysadminMandateRole)
logger.info(f"Assigned sysadmin role to {userName} user in root mandate")
def _getPasswordHash(password: Optional[str]) -> Optional[str]: def _getPasswordHash(password: Optional[str]) -> Optional[str]:
""" """

View file

@ -74,6 +74,7 @@ class AppObjects:
self.currentUser = currentUser # Store User object directly self.currentUser = currentUser # Store User object directly
self.userId = currentUser.id if currentUser else None self.userId = currentUser.id if currentUser else None
self.mandateId = None # mandateId comes from setUserContext, not from User self.mandateId = None # mandateId comes from setUserContext, not from User
self.featureInstanceId = None # featureInstanceId comes from setUserContext
# Initialize database # Initialize database
self._initializeDatabase() self._initializeDatabase()
@ -501,34 +502,30 @@ class AppObjects:
def getUsersByMandate(self, mandateId: str, pagination: Optional[PaginationParams] = None) -> Union[List[User], PaginatedResult]: def getUsersByMandate(self, mandateId: str, pagination: Optional[PaginationParams] = None) -> Union[List[User], PaginatedResult]:
""" """
Returns users for a specific mandate if user has access. Returns users for a specific mandate.
Supports optional pagination, sorting, and filtering. Uses UserMandate junction table to find users belonging to the mandate.
For SYSADMIN, returns all users regardless of mandate.
Args: Args:
mandateId: The mandate ID to get users for (ignored for SYSADMIN) mandateId: The mandate ID to get users for
pagination: Optional pagination parameters. If None, returns all items. pagination: Optional pagination parameters. If None, returns all items.
Returns: Returns:
If pagination is None: List[User] If pagination is None: List[User]
If pagination is provided: PaginatedResult with items and metadata If pagination is provided: PaginatedResult with items and metadata
""" """
# Use RBAC filtering # Get user IDs via UserMandate junction table (UserInDB has no mandateId column)
users = getRecordsetWithRBAC( userMandates = self.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
self.db, userIds = [um.get("userId") for um in userMandates if um.get("userId")]
UserInDB,
self.currentUser,
recordFilter={"mandateId": mandateId} if mandateId else None
)
# Filter out database-specific fields and normalize data # Fetch each user by ID
filteredUsers = [] filteredUsers = []
for user in users: for userId in userIds:
cleanedUser = {k: v for k, v in user.items() if not k.startswith("_")} userRecords = self.db.getRecordset(UserInDB, recordFilter={"id": userId})
# Ensure roleLabels is always a list, not None if userRecords:
if cleanedUser.get("roleLabels") is None: cleanedUser = {k: v for k, v in userRecords[0].items() if not k.startswith("_")}
cleanedUser["roleLabels"] = [] if cleanedUser.get("roleLabels") is None:
filteredUsers.append(cleanedUser) cleanedUser["roleLabels"] = []
filteredUsers.append(cleanedUser)
# If no pagination requested, return all items # If no pagination requested, return all items
if pagination is None: if pagination is None:
@ -572,7 +569,8 @@ class AppObjects:
users = getRecordsetWithRBAC(self.db, users = getRecordsetWithRBAC(self.db,
UserInDB, UserInDB,
self.currentUser, self.currentUser,
recordFilter={"username": username} recordFilter={"username": username},
mandateId=self.mandateId
) )
if not users: if not users:
@ -599,7 +597,8 @@ class AppObjects:
users = getRecordsetWithRBAC(self.db, users = getRecordsetWithRBAC(self.db,
UserInDB, UserInDB,
self.currentUser, self.currentUser,
recordFilter={"id": userId} recordFilter={"id": userId},
mandateId=self.mandateId
) )
if not users: if not users:
@ -1202,7 +1201,8 @@ class AppObjects:
users = getRecordsetWithRBAC(self.db, users = getRecordsetWithRBAC(self.db,
UserInDB, UserInDB,
self.currentUser, self.currentUser,
recordFilter={"id": initialUserId} recordFilter={"id": initialUserId},
mandateId=self.mandateId
) )
return users[0] if users else None return users[0] if users else None
except Exception as e: except Exception as e:
@ -1384,7 +1384,7 @@ class AppObjects:
If pagination is provided: PaginatedResult with items and metadata If pagination is provided: PaginatedResult with items and metadata
""" """
# Use RBAC filtering # Use RBAC filtering
allMandates = getRecordsetWithRBAC(self.db, Mandate, self.currentUser) allMandates = getRecordsetWithRBAC(self.db, Mandate, self.currentUser, mandateId=self.mandateId)
# Filter out database-specific fields # Filter out database-specific fields
filteredMandates = [] filteredMandates = []
@ -1428,7 +1428,8 @@ class AppObjects:
mandates = getRecordsetWithRBAC(self.db, mandates = getRecordsetWithRBAC(self.db,
Mandate, Mandate,
self.currentUser, self.currentUser,
recordFilter={"id": mandateId} recordFilter={"id": mandateId},
mandateId=self.mandateId
) )
if not mandates: if not mandates:
@ -1968,6 +1969,7 @@ class AppObjects:
def createFeatureAccess(self, userId: str, featureInstanceId: str, roleIds: List[str] = None) -> FeatureAccess: def createFeatureAccess(self, userId: str, featureInstanceId: str, roleIds: List[str] = None) -> FeatureAccess:
""" """
Create a FeatureAccess record (grant user access to feature instance). Create a FeatureAccess record (grant user access to feature instance).
Also auto-assigns the user to the mandate with the 'user' role if not already a member.
Args: Args:
userId: User ID userId: User ID
@ -1983,6 +1985,9 @@ class AppObjects:
if existing: if existing:
raise ValueError(f"User {userId} already has access to feature instance {featureInstanceId}") raise ValueError(f"User {userId} already has access to feature instance {featureInstanceId}")
# Auto-assign user to mandate with 'user' role if not already a member
self._ensureUserMandateMembership(userId, featureInstanceId)
# Create FeatureAccess # Create FeatureAccess
featureAccess = FeatureAccess( featureAccess = FeatureAccess(
userId=userId, userId=userId,
@ -2007,6 +2012,45 @@ class AppObjects:
logger.error(f"Error creating FeatureAccess: {e}") logger.error(f"Error creating FeatureAccess: {e}")
raise ValueError(f"Failed to create FeatureAccess: {e}") raise ValueError(f"Failed to create FeatureAccess: {e}")
def _ensureUserMandateMembership(self, userId: str, featureInstanceId: str) -> None:
"""
Ensure user is a member of the mandate that owns the feature instance.
If not already a member, adds them with the 'user' role (no access rights, membership only).
"""
try:
from modules.interfaces.interfaceFeatures import getFeatureInterface
featureInterface = getFeatureInterface(self.db)
instance = featureInterface.getFeatureInstance(featureInstanceId)
if not instance or not instance.mandateId:
logger.warning(f"Cannot auto-assign mandate: feature instance {featureInstanceId} not found or has no mandateId")
return
mandateId = str(instance.mandateId)
# Check if user already has mandate membership
existing = self.getUserMandate(userId, mandateId)
if existing:
logger.debug(f"User {userId} already member of mandate {mandateId}")
return
# Find the mandate-level 'user' role (membership marker, no access rights)
userRoles = self.db.getRecordset(
Role,
recordFilter={"roleLabel": "user", "mandateId": mandateId, "featureInstanceId": None}
)
userRoleId = userRoles[0].get("id") if userRoles else None
roleIds = [userRoleId] if userRoleId else []
self.createUserMandate(userId, mandateId, roleIds)
logger.info(f"Auto-assigned user {userId} to mandate {mandateId} with 'user' role (via feature instance {featureInstanceId})")
except ValueError:
# createUserMandate raises ValueError if already exists - safe to ignore
pass
except Exception as e:
logger.error(f"Error auto-assigning user {userId} to mandate: {e}")
def getRoleIdsForFeatureAccess(self, featureAccessId: str) -> List[str]: def getRoleIdsForFeatureAccess(self, featureAccessId: str) -> List[str]:
""" """
Get all role IDs assigned to a FeatureAccess. Get all role IDs assigned to a FeatureAccess.
@ -2027,6 +2071,34 @@ class AppObjects:
logger.error(f"Error getting role IDs for FeatureAccess: {e}") logger.error(f"Error getting role IDs for FeatureAccess: {e}")
return [] return []
def addRoleToFeatureAccess(self, featureAccessId: str, roleId: str) -> None:
"""
Add a role to a FeatureAccess (via junction table).
Skips if the role is already assigned.
Args:
featureAccessId: FeatureAccess ID
roleId: Role ID to add
"""
try:
# Check if already exists
existing = self.db.getRecordset(
FeatureAccessRole,
recordFilter={"featureAccessId": featureAccessId, "roleId": roleId}
)
if existing:
return # Already assigned
featureAccessRole = FeatureAccessRole(
featureAccessId=featureAccessId,
roleId=roleId
)
self.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
logger.debug(f"Added role {roleId} to FeatureAccess {featureAccessId}")
except Exception as e:
logger.error(f"Error adding role to FeatureAccess: {e}")
raise ValueError(f"Failed to add role to FeatureAccess: {e}")
def deleteFeatureAccessRoles(self, featureAccessId: str) -> int: def deleteFeatureAccessRoles(self, featureAccessId: str) -> int:
""" """
Delete all FeatureAccessRole records for a FeatureAccess. Delete all FeatureAccessRole records for a FeatureAccess.
@ -2962,7 +3034,8 @@ class AppObjects:
self.db, self.db,
AccessRule, AccessRule,
self.currentUser, self.currentUser,
recordFilter=recordFilter if recordFilter else None recordFilter=recordFilter if recordFilter else None,
mandateId=self.mandateId
) )
# Filter out database-specific fields # Filter out database-specific fields

View file

@ -966,20 +966,22 @@ class BillingObjects:
Returns: Returns:
List of BillingBalanceResponse List of BillingBalanceResponse
""" """
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface from modules.interfaces.interfaceDbApp import getRootInterface
balances = [] balances = []
try: try:
appInterface = getAppInterface(self.currentUser) # Use rootInterface (privileged, SysAdmin context) to bypass RBAC
userMandates = appInterface.getUserMandates(userId) # for mandate/user lookups. User access is verified via UserMandate membership.
rootInterface = getRootInterface()
userMandates = rootInterface.getUserMandates(userId)
for um in userMandates: for um in userMandates:
mandateId = getattr(um, 'mandateId', None) or (um.get("mandateId") if isinstance(um, dict) else None) mandateId = getattr(um, 'mandateId', None) or (um.get("mandateId") if isinstance(um, dict) else None)
if not mandateId: if not mandateId:
continue continue
mandate = appInterface.getMandate(mandateId) mandate = rootInterface.getMandate(mandateId)
if not mandate: if not mandate:
continue continue
@ -995,14 +997,14 @@ class BillingObjects:
# Determine effective balance based on billing model # Determine effective balance based on billing model
if billingModel == BillingModelEnum.PREPAY_USER: if billingModel == BillingModelEnum.PREPAY_USER:
account = self.getUserAccount(mandateId, userId) account = self.getOrCreateUserAccount(mandateId, userId)
if not account: if not account:
continue continue
balance = account.get("balance", 0.0) balance = account.get("balance", 0.0)
warningThreshold = account.get("warningThreshold", 0.0) warningThreshold = account.get("warningThreshold", 0.0)
creditLimit = account.get("creditLimit") creditLimit = account.get("creditLimit")
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]: elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
poolAccount = self.getMandateAccount(mandateId) poolAccount = self.getOrCreateMandateAccount(mandateId)
if not poolAccount: if not poolAccount:
continue continue
balance = poolAccount.get("balance", 0.0) balance = poolAccount.get("balance", 0.0)

View file

@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
_chatInterfaces = {} _chatInterfaces = {}
def storeDebugMessageAndDocuments(message, currentUser) -> None: def storeDebugMessageAndDocuments(message, currentUser, mandateId=None, featureInstanceId=None) -> None:
""" """
Store message and documents (metadata and file bytes) for debugging purposes. Store message and documents (metadata and file bytes) for debugging purposes.
Structure: {log_dir}/debug/messages/m_round_task_action_timestamp/documentlist_label/ Structure: {log_dir}/debug/messages/m_round_task_action_timestamp/documentlist_label/
@ -53,6 +53,8 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
Args: Args:
message: ChatMessage object to store message: ChatMessage object to store
currentUser: Current user for component interface access currentUser: Current user for component interface access
mandateId: Mandate ID for RBAC context (avoids overwriting singleton state)
featureInstanceId: Feature instance ID for RBAC context
""" """
try: try:
import os import os
@ -152,7 +154,7 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
# Also store the actual file bytes next to metadata for debugging # Also store the actual file bytes next to metadata for debugging
try: try:
componentInterface = getInterface(currentUser) componentInterface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
file_bytes = componentInterface.getFileData(doc.fileId) file_bytes = componentInterface.getFileData(doc.fileId)
if file_bytes: if file_bytes:
# Build a safe filename preserving original name # Build a safe filename preserving original name
@ -715,8 +717,13 @@ class ChatObjects:
logger.debug(f"createWorkflow: Using Root mandate {effectiveMandateId}") logger.debug(f"createWorkflow: Using Root mandate {effectiveMandateId}")
except Exception as e: except Exception as e:
logger.warning(f"Could not get Root mandate: {e}") logger.warning(f"Could not get Root mandate: {e}")
# Note: Chat data is user-owned, no mandate/featureInstance context stored # Note: ChatWorkflow has featureInstanceId for multi-tenancy isolation.
# mandateId/featureInstanceId removed from ChatWorkflow model # Child tables (ChatMessage, ChatLog, ChatStat, ChatDocument) are user-owned
# and do NOT store featureInstanceId - they inherit isolation from ChatWorkflow.
# Ensure featureInstanceId is set from context if not already in workflowData
if "featureInstanceId" not in workflowData or not workflowData.get("featureInstanceId"):
if self.featureInstanceId:
workflowData["featureInstanceId"] = self.featureInstanceId
# Use generic field separation based on ChatWorkflow model # Use generic field separation based on ChatWorkflow model
simpleFields, objectFields = self._separateObjectFields(ChatWorkflow, workflowData) simpleFields, objectFields = self._separateObjectFields(ChatWorkflow, workflowData)
@ -726,7 +733,6 @@ class ChatObjects:
# Convert to ChatWorkflow model (empty related data for new workflow) # Convert to ChatWorkflow model (empty related data for new workflow)
# Note: Chat data is user-owned, no mandate/featureInstance fields
return ChatWorkflow( return ChatWorkflow(
id=created["id"], id=created["id"],
status=created.get("status", "running"), status=created.get("status", "running"),
@ -1122,7 +1128,7 @@ class ChatObjects:
logger.debug(f"Could not emit message event: {e}") logger.debug(f"Could not emit message event: {e}")
# Debug: Store message and documents for debugging - only if debug enabled # Debug: Store message and documents for debugging - only if debug enabled
storeDebugMessageAndDocuments(chat_message, self.currentUser) storeDebugMessageAndDocuments(chat_message, self.currentUser, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId)
return chat_message return chat_message

View file

@ -593,7 +593,11 @@ class ComponentObjects:
# Prompt methods # Prompt methods
def _isSysAdmin(self) -> bool: def _isSysAdmin(self) -> bool:
"""Check if the current user is a SysAdmin.""" """Check if the current user has sysadmin role (or isSysAdmin flag as fallback)."""
from modules.auth.authentication import _hasSysAdminRole
userId = getattr(self.currentUser, 'id', None)
if userId and _hasSysAdminRole(str(userId)):
return True
return hasattr(self.currentUser, 'isSysAdmin') and self.currentUser.isSysAdmin return hasattr(self.currentUser, 'isSysAdmin') and self.currentUser.isSysAdmin
def _enrichPromptsWithPermissions(self, prompts: List[Dict[str, Any]]) -> List[Dict[str, Any]]: def _enrichPromptsWithPermissions(self, prompts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
@ -1005,7 +1009,8 @@ class ComponentObjects:
# Get all files filtered by RBAC (will be filtered by user's access level) # Get all files filtered by RBAC (will be filtered by user's access level)
files = getRecordsetWithRBAC(self.db, files = getRecordsetWithRBAC(self.db,
FileItem, FileItem,
self.currentUser self.currentUser,
mandateId=self.mandateId
) )
# Check if fileName exists (excluding the current file if updating) # Check if fileName exists (excluding the current file if updating)
@ -1208,7 +1213,7 @@ class ComponentObjects:
logger.warning(f"No access to file ID {fileId}") logger.warning(f"No access to file ID {fileId}")
return None return None
fileDataEntries = getRecordsetWithRBAC(self.db, FileData, self.currentUser, recordFilter={"id": fileId}) fileDataEntries = getRecordsetWithRBAC(self.db, FileData, self.currentUser, recordFilter={"id": fileId}, mandateId=self.mandateId)
if not fileDataEntries: if not fileDataEntries:
logger.warning(f"No data found for file ID {fileId}") logger.warning(f"No data found for file ID {fileId}")
return None return None
@ -1367,7 +1372,8 @@ class ComponentObjects:
filteredSettings = getRecordsetWithRBAC(self.db, filteredSettings = getRecordsetWithRBAC(self.db,
VoiceSettings, VoiceSettings,
self.currentUser, self.currentUser,
recordFilter={"userId": targetUserId} recordFilter={"userId": targetUserId},
mandateId=self.mandateId
) )
if not filteredSettings: if not filteredSettings:
@ -1510,7 +1516,8 @@ class ComponentObjects:
try: try:
filteredSubscriptions = getRecordsetWithRBAC(self.db, filteredSubscriptions = getRecordsetWithRBAC(self.db,
MessagingSubscription, MessagingSubscription,
self.currentUser self.currentUser,
mandateId=self.mandateId
) )
if pagination is None: if pagination is None:
@ -1547,7 +1554,8 @@ class ComponentObjects:
filteredSubscriptions = getRecordsetWithRBAC(self.db, filteredSubscriptions = getRecordsetWithRBAC(self.db,
MessagingSubscription, MessagingSubscription,
self.currentUser, self.currentUser,
recordFilter={"subscriptionId": subscriptionId} recordFilter={"subscriptionId": subscriptionId},
mandateId=self.mandateId
) )
return MessagingSubscription(**filteredSubscriptions[0]) if filteredSubscriptions else None return MessagingSubscription(**filteredSubscriptions[0]) if filteredSubscriptions else None
@ -1556,7 +1564,8 @@ class ComponentObjects:
filteredSubscriptions = getRecordsetWithRBAC(self.db, filteredSubscriptions = getRecordsetWithRBAC(self.db,
MessagingSubscription, MessagingSubscription,
self.currentUser, self.currentUser,
recordFilter={"id": id} recordFilter={"id": id},
mandateId=self.mandateId
) )
return MessagingSubscription(**filteredSubscriptions[0]) if filteredSubscriptions else None return MessagingSubscription(**filteredSubscriptions[0]) if filteredSubscriptions else None
@ -1629,7 +1638,8 @@ class ComponentObjects:
filteredRegistrations = getRecordsetWithRBAC(self.db, filteredRegistrations = getRecordsetWithRBAC(self.db,
MessagingSubscriptionRegistration, MessagingSubscriptionRegistration,
self.currentUser, self.currentUser,
recordFilter=recordFilter if recordFilter else None recordFilter=recordFilter if recordFilter else None,
mandateId=self.mandateId
) )
if pagination is None: if pagination is None:
@ -1666,7 +1676,8 @@ class ComponentObjects:
filteredRegistrations = getRecordsetWithRBAC(self.db, filteredRegistrations = getRecordsetWithRBAC(self.db,
MessagingSubscriptionRegistration, MessagingSubscriptionRegistration,
self.currentUser, self.currentUser,
recordFilter={"id": registrationId} recordFilter={"id": registrationId},
mandateId=self.mandateId
) )
return MessagingSubscriptionRegistration(**filteredRegistrations[0]) if filteredRegistrations else None return MessagingSubscriptionRegistration(**filteredRegistrations[0]) if filteredRegistrations else None

View file

@ -163,13 +163,21 @@ def getRecordsetWithRBAC(
# Check view permission first # Check view permission first
if not permissions.view: if not permissions.view:
logger.debug(f"User {currentUser.id} has no view permission for {objectKey} (mandateId={effectiveMandateId}, featureInstanceId={featureInstanceId})")
return [] return []
# Build WHERE clause with RBAC filtering # Build WHERE clause with RBAC filtering
whereConditions = [] whereConditions = []
whereValues = [] whereValues = []
# CRITICAL: Only pass featureInstanceId to WHERE clause if the model actually has
# this column. Chat child tables (ChatMessage, ChatLog, ChatStat, ChatDocument)
# are user-owned and do NOT have featureInstanceId - only ChatWorkflow does.
# Without this check, the SQL query would reference a non-existent column,
# causing a silent error that returns empty results.
featureInstanceIdForQuery = featureInstanceId
if featureInstanceId and hasattr(modelClass, 'model_fields') and "featureInstanceId" not in modelClass.model_fields:
featureInstanceIdForQuery = None
# Add RBAC WHERE clause based on read permission # Add RBAC WHERE clause based on read permission
rbacWhereClause = buildRbacWhereClause( rbacWhereClause = buildRbacWhereClause(
permissions, permissions,
@ -177,7 +185,7 @@ def getRecordsetWithRBAC(
table, table,
connector, connector,
mandateId=effectiveMandateId, mandateId=effectiveMandateId,
featureInstanceId=featureInstanceId featureInstanceId=featureInstanceIdForQuery
) )
if rbacWhereClause: if rbacWhereClause:
whereConditions.append(rbacWhereClause["condition"]) whereConditions.append(rbacWhereClause["condition"])

View file

@ -2,7 +2,7 @@
# All rights reserved. # All rights reserved.
""" """
Admin automation events routes for the backend API. Admin automation events routes for the backend API.
Sysadmin-only endpoints for viewing and controlling automation events. Sysadmin-only endpoints for viewing and controlling scheduler events.
""" """
from fastapi import APIRouter, HTTPException, Depends, Path, Request, Response from fastapi import APIRouter, HTTPException, Depends, Path, Request, Response
@ -12,7 +12,7 @@ import logging
# Import interfaces and models from feature containers # Import interfaces and models from feature containers
import modules.features.automation.interfaceFeatureAutomation as interfaceAutomation import modules.features.automation.interfaceFeatureAutomation as interfaceAutomation
from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext from modules.auth import limiter, getRequestContext, requireSysAdminRole, RequestContext
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
# Configure logger # Configure logger
@ -35,27 +35,110 @@ router = APIRouter(
@limiter.limit("30/minute") @limiter.limit("30/minute")
def get_all_automation_events( def get_all_automation_events(
request: Request, request: Request,
currentUser: User = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdminRole)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get all automation events across all mandates (sysadmin only). Get all active scheduler jobs (sysadmin only).
Returns list of all registered events with their automation IDs and schedules. Each job is enriched with context from its automation definition
(name, mandate, feature instance, creator) for readability.
""" """
try: try:
from modules.shared.eventManagement import eventManager from modules.shared.eventManagement import eventManager
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.services import getInterface as getServices
# Get all jobs from scheduler if not eventManager.scheduler:
return []
# 1. Collect all scheduler jobs
jobs = [] jobs = []
if eventManager.scheduler: automationIds = []
for job in eventManager.scheduler.get_jobs(): for job in eventManager.scheduler.get_jobs():
if job.id.startswith("automation."): if job.id.startswith("automation."):
automation_id = job.id.replace("automation.", "") automationId = job.id.replace("automation.", "")
jobs.append({ automationIds.append(automationId)
"eventId": job.id, jobs.append({
"automationId": automation_id, "eventId": job.id,
"nextRunTime": str(job.next_run_time) if job.next_run_time else None, "automationId": automationId,
"trigger": str(job.trigger) if job.trigger else None "nextRunTime": str(job.next_run_time) if job.next_run_time else None,
}) "trigger": str(job.trigger) if job.trigger else None,
"name": "",
"createdBy": "",
"mandate": "",
"featureInstance": ""
})
# 2. Enrich with context from automation definitions
if jobs:
try:
rootInterface = getRootInterface()
eventUser = rootInterface.getUserByUsername("event")
if eventUser:
services = getServices(currentUser, None)
allAutomations = services.interfaceDbAutomation.getAllAutomationDefinitionsWithRBAC(eventUser)
# Build lookup by automation ID
automationLookup = {}
for a in allAutomations:
aId = a.get("id", "") if isinstance(a, dict) else getattr(a, "id", "")
automationLookup[aId] = a
# Caches for resolving UUIDs to names
_userCache = {}
_mandateCache = {}
_featureCache = {}
def _resolveUsername(userId):
if not userId:
return ""
if userId not in _userCache:
try:
user = rootInterface.getUser(userId)
_userCache[userId] = user.username if user else userId[:8]
except Exception:
_userCache[userId] = userId[:8]
return _userCache[userId]
def _resolveMandateLabel(mandateId):
if not mandateId:
return ""
if mandateId not in _mandateCache:
try:
mandate = rootInterface.getMandate(mandateId)
_mandateCache[mandateId] = getattr(mandate, "label", None) or mandateId[:8]
except Exception:
_mandateCache[mandateId] = mandateId[:8]
return _mandateCache[mandateId]
def _resolveFeatureLabel(featureInstanceId):
if not featureInstanceId:
return ""
if featureInstanceId not in _featureCache:
try:
instance = rootInterface.getFeatureInstance(featureInstanceId)
_featureCache[featureInstanceId] = getattr(instance, "label", None) or getattr(instance, "featureCode", None) or featureInstanceId[:8]
except Exception:
_featureCache[featureInstanceId] = featureInstanceId[:8]
return _featureCache[featureInstanceId]
# Enrich each job
for job in jobs:
automation = automationLookup.get(job["automationId"])
if automation:
if isinstance(automation, dict):
job["name"] = automation.get("label", "")
job["createdBy"] = _resolveUsername(automation.get("_createdBy", ""))
job["mandate"] = _resolveMandateLabel(automation.get("mandateId", ""))
job["featureInstance"] = _resolveFeatureLabel(automation.get("featureInstanceId", ""))
else:
job["name"] = getattr(automation, "label", "")
job["createdBy"] = _resolveUsername(getattr(automation, "_createdBy", ""))
job["mandate"] = _resolveMandateLabel(getattr(automation, "mandateId", ""))
job["featureInstance"] = _resolveFeatureLabel(getattr(automation, "featureInstanceId", ""))
else:
job["name"] = "(orphaned)"
except Exception as e:
logger.warning(f"Could not enrich automation events with context: {e}")
return jobs return jobs
except Exception as e: except Exception as e:
@ -69,7 +152,7 @@ def get_all_automation_events(
@limiter.limit("5/minute") @limiter.limit("5/minute")
async def sync_all_automation_events( async def sync_all_automation_events(
request: Request, request: Request,
currentUser: User = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdminRole)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Manually trigger sync for all automations (sysadmin only). Manually trigger sync for all automations (sysadmin only).
@ -110,25 +193,26 @@ async def sync_all_automation_events(
def remove_event( def remove_event(
request: Request, request: Request,
eventId: str = Path(..., description="Event ID to remove"), eventId: str = Path(..., description="Event ID to remove"),
currentUser: User = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdminRole)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Manually remove a specific event from scheduler (sysadmin only). Remove a scheduler job (sysadmin only).
Used for debugging and manual event cleanup. Removes the job from the scheduler and clears the eventId on the automation definition.
Does NOT delete the automation definition itself.
""" """
try: try:
from modules.shared.eventManagement import eventManager from modules.shared.eventManagement import eventManager
# Remove event # Remove scheduler job
eventManager.remove(eventId) eventManager.remove(eventId)
# Update automation's eventId if it exists # Clear eventId on the automation definition (so it can be re-synced later)
if eventId.startswith("automation."): if eventId.startswith("automation."):
automation_id = eventId.replace("automation.", "") automationId = eventId.replace("automation.", "")
automationInterface = interfaceAutomation.getInterface(currentUser) automationInterface = interfaceAutomation.getInterface(currentUser)
automation = automationInterface.getAutomationDefinition(automation_id) automation = automationInterface.getAutomationDefinition(automationId)
if automation and getattr(automation, "eventId", None) == eventId: if automation and getattr(automation, "eventId", None) == eventId:
automationInterface.updateAutomationDefinition(automation_id, {"eventId": None}) automationInterface.updateAutomationDefinition(automationId, {"eventId": None})
return { return {
"success": True, "success": True,

View file

@ -16,7 +16,7 @@ from fastapi import status
import logging import logging
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdmin from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdminRole
from modules.datamodels.datamodelUam import User, UserInDB from modules.datamodels.datamodelUam import User, UserInDB
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
@ -337,7 +337,7 @@ def create_feature(
code: str = Query(..., description="Unique feature code"), code: str = Query(..., description="Unique feature code"),
label: Dict[str, str] = None, label: Dict[str, str] = None,
icon: str = Query("mdi-puzzle", description="Icon identifier"), icon: str = Query("mdi-puzzle", description="Icon identifier"),
sysAdmin: User = Depends(requireSysAdmin) sysAdmin: User = Depends(requireSysAdminRole)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Create a new feature definition. Create a new feature definition.
@ -453,7 +453,7 @@ def get_feature_instance(
# Verify mandate access (unless SysAdmin) # Verify mandate access (unless SysAdmin)
if context.mandateId and str(instance.mandateId) != str(context.mandateId): if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance" detail="Access denied to this feature instance"
@ -567,14 +567,14 @@ def delete_feature_instance(
# Verify mandate access # Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId): if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance" detail="Access denied to this feature instance"
) )
# Check mandate admin permission # Check mandate admin permission
if not _hasMandateAdminRole(context) and not context.isSysAdmin: if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to delete feature instances" detail="Mandate-Admin role required to delete feature instances"
@ -634,14 +634,14 @@ def updateFeatureInstance(
# Verify mandate access # Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId): if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance" detail="Access denied to this feature instance"
) )
# Check mandate admin permission # Check mandate admin permission
if not _hasMandateAdminRole(context) and not context.isSysAdmin: if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to update feature instances" detail="Mandate-Admin role required to update feature instances"
@ -712,14 +712,14 @@ def sync_instance_roles(
# Verify mandate access # Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId): if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance" detail="Access denied to this feature instance"
) )
# Check admin permission (Mandate-Admin or Feature-Admin) # Check admin permission (Mandate-Admin or Feature-Admin)
if not _hasMandateAdminRole(context) and not context.isSysAdmin: if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required to sync roles" detail="Admin role required to sync roles"
@ -752,7 +752,7 @@ def sync_instance_roles(
def list_template_roles( def list_template_roles(
request: Request, request: Request,
featureCode: Optional[str] = Query(None, description="Filter by feature code"), featureCode: Optional[str] = Query(None, description="Filter by feature code"),
sysAdmin: User = Depends(requireSysAdmin) sysAdmin: User = Depends(requireSysAdminRole)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
List global template roles. List global template roles.
@ -784,7 +784,7 @@ def create_template_role(
roleLabel: str = Query(..., description="Role label (e.g., 'admin', 'viewer')"), roleLabel: str = Query(..., description="Role label (e.g., 'admin', 'viewer')"),
featureCode: str = Query(..., description="Feature code this role belongs to"), featureCode: str = Query(..., description="Feature code this role belongs to"),
description: Dict[str, str] = None, description: Dict[str, str] = None,
sysAdmin: User = Depends(requireSysAdmin) sysAdmin: User = Depends(requireSysAdminRole)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Create a global template role for a feature. Create a global template role for a feature.
@ -891,7 +891,7 @@ def list_feature_instance_users(
# Verify mandate access (unless SysAdmin) # Verify mandate access (unless SysAdmin)
if context.mandateId and str(instance.mandateId) != str(context.mandateId): if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance" detail="Access denied to this feature instance"
@ -971,14 +971,14 @@ def add_user_to_feature_instance(
# Verify mandate access # Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId): if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance" detail="Access denied to this feature instance"
) )
# Check admin permission # Check admin permission
if not _hasMandateAdminRole(context) and not context.isSysAdmin: if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required to add users to feature instances" detail="Admin role required to add users to feature instances"
@ -1072,14 +1072,14 @@ def remove_user_from_feature_instance(
# Verify mandate access # Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId): if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance" detail="Access denied to this feature instance"
) )
# Check admin permission # Check admin permission
if not _hasMandateAdminRole(context) and not context.isSysAdmin: if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required to remove users from feature instances" detail="Admin role required to remove users from feature instances"
@ -1153,14 +1153,14 @@ def update_feature_instance_user_roles(
# Verify mandate access # Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId): if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance" detail="Access denied to this feature instance"
) )
# Check admin permission # Check admin permission
if not _hasMandateAdminRole(context) and not context.isSysAdmin: if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required to update user roles" detail="Admin role required to update user roles"
@ -1243,7 +1243,7 @@ def get_feature_instance_available_roles(
# Verify mandate access # Verify mandate access
if context.mandateId and str(instance.mandateId) != str(context.mandateId): if context.mandateId and str(instance.mandateId) != str(context.mandateId):
if not context.isSysAdmin: if not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this feature instance" detail="Access denied to this feature instance"
@ -1326,7 +1326,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
A user is mandate admin if they have the 'admin' role at mandate level. A user is mandate admin if they have the 'admin' role at mandate level.
""" """
if context.isSysAdmin: if context.hasSysAdminRole:
return True return True
if not context.roleIds: if not context.roleIds:
@ -1341,7 +1341,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
if role: if role:
roleLabel = role.roleLabel roleLabel = role.roleLabel
# Admin role at mandate level (not feature-instance level) # Admin role at mandate level (not feature-instance level)
if roleLabel == "admin" and role.mandateId and not role.featureInstanceId: if roleLabel == "admin" and not role.featureInstanceId:
return True return True
return False return False

View file

@ -18,7 +18,7 @@ import logging
import json import json
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdmin from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdminRole
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelRbac import Role, AccessRule from modules.datamodels.datamodelRbac import Role, AccessRule
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
@ -74,7 +74,7 @@ class RbacImportResult(BaseModel):
@limiter.limit("10/minute") @limiter.limit("10/minute")
def export_global_rbac( def export_global_rbac(
request: Request, request: Request,
sysAdmin: User = Depends(requireSysAdmin) sysAdmin: User = Depends(requireSysAdminRole)
) -> RbacExportData: ) -> RbacExportData:
""" """
Export global (template) RBAC rules. Export global (template) RBAC rules.
@ -139,7 +139,7 @@ async def import_global_rbac(
request: Request, request: Request,
file: UploadFile = File(..., description="JSON file with RBAC export data"), file: UploadFile = File(..., description="JSON file with RBAC export data"),
updateExisting: bool = False, updateExisting: bool = False,
sysAdmin: User = Depends(requireSysAdmin) sysAdmin: User = Depends(requireSysAdminRole)
) -> RbacImportResult: ) -> RbacImportResult:
""" """
Import global (template) RBAC rules. Import global (template) RBAC rules.
@ -538,7 +538,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
""" """
Check if the user has mandate admin role in the current context. Check if the user has mandate admin role in the current context.
""" """
if context.isSysAdmin: if context.hasSysAdminRole:
return True return True
if not context.roleIds: if not context.roleIds:
@ -552,7 +552,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
if role: if role:
roleLabel = role.roleLabel roleLabel = role.roleLabel
# Admin role at mandate level # Admin role at mandate level
if roleLabel == "admin" and role.mandateId and not role.featureInstanceId: if roleLabel == "admin" and not role.featureInstanceId:
return True return True
return False return False

View file

@ -4,16 +4,19 @@
Admin RBAC Roles Management routes. Admin RBAC Roles Management routes.
Provides endpoints for managing roles and role assignments to users. Provides endpoints for managing roles and role assignments to users.
MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true. MULTI-TENANT: Context-aware access control.
Roles are global system resources, not mandate-specific. - SysAdmin: Full access to all roles and assignments across all mandates.
- MandateAdmin: Can manage roles and assignments within their own mandates.
Template roles (mandateId=None, isSystemRole=True) are read-only.
The sysadmin role (roleLabel="sysadmin") is not manageable by MandateAdmins.
Role assignments are managed via UserMandateRole (not User.roleLabels). Role assignments are managed via UserMandateRole (not User.roleLabels).
""" """
from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request, status
from typing import List, Dict, Any, Optional, Set from typing import List, Dict, Any, Optional, Set
import logging import logging
from modules.auth import limiter, requireSysAdmin from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
from modules.datamodels.datamodelUam import User, UserInDB from modules.datamodels.datamodelUam import User, UserInDB
from modules.datamodels.datamodelRbac import Role from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
@ -59,6 +62,31 @@ def _hasRoleLabel(interface, userId: str, roleLabel: str) -> bool:
""" """
return roleLabel in _getUserRoleLabels(interface, userId) return roleLabel in _getUserRoleLabels(interface, userId)
def _getAdminMandateIds(context: RequestContext) -> List[str]:
"""Get mandate IDs where the user has admin role."""
mandateIds = []
try:
rootInterface = getRootInterface()
userMandates = rootInterface.getUserMandates(str(context.user.id))
for um in userMandates:
if not getattr(um, 'enabled', True):
continue
umId = getattr(um, 'id', None)
mandateId = getattr(um, 'mandateId', None)
if not umId or not mandateId:
continue
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
mandateIds.append(str(mandateId))
break
except Exception as e:
logger.error(f"Error getting admin mandate IDs: {e}")
return mandateIds
router = APIRouter( router = APIRouter(
prefix="/api/admin/rbac/roles", prefix="/api/admin/rbac/roles",
tags=["Admin RBAC Roles"], tags=["Admin RBAC Roles"],
@ -71,20 +99,32 @@ router = APIRouter(
def list_roles( def list_roles(
request: Request, request: Request,
mandateId: Optional[str] = Query(None, description="Filter roles by mandate ID"), mandateId: Optional[str] = Query(None, description="Filter roles by mandate ID"),
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get list of roles with metadata. Get list of roles with metadata.
Context-aware: SysAdmin sees all roles. MandateAdmin sees roles from own mandates
plus template roles (read-only).
Without mandateId: returns system template roles (mandateId=NULL). Without mandateId: returns system template roles (mandateId=NULL).
With mandateId: returns mandate-level roles for that mandate (featureInstanceId=NULL). With mandateId: returns mandate-level roles for that mandate (featureInstanceId=NULL).
""" """
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try: try:
interface = getRootInterface() interface = getRootInterface()
# Get roles filtered by scope # Get roles filtered by scope
print(f"[DEBUG list_roles] mandateId={mandateId}") print(f"[DEBUG list_roles] mandateId={mandateId}")
if mandateId: if mandateId:
# MandateAdmin can only query mandates they admin
if not isSysAdmin and mandateId not in adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this mandate")
# Mandate-specific roles (mandate-level only, no feature-instance roles) # Mandate-specific roles (mandate-level only, no feature-instance roles)
dbRoles = interface.getRolesForMandate(mandateId) dbRoles = interface.getRolesForMandate(mandateId)
print(f"[DEBUG list_roles] getRolesForMandate returned {len(dbRoles)} roles") print(f"[DEBUG list_roles] getRolesForMandate returned {len(dbRoles)} roles")
@ -92,6 +132,9 @@ def list_roles(
# System template roles only # System template roles only
dbRoles = interface.getAllRoles() dbRoles = interface.getAllRoles()
print(f"[DEBUG list_roles] getAllRoles returned {len(dbRoles)} roles") print(f"[DEBUG list_roles] getAllRoles returned {len(dbRoles)} roles")
# MandateAdmin: filter to template roles + roles from own mandates
if not isSysAdmin:
dbRoles = [r for r in dbRoles if r.mandateId is None or str(r.mandateId) in adminMandateIds]
# Count role assignments from UserMandateRole table # Count role assignments from UserMandateRole table
roleCounts = interface.countRoleAssignments() roleCounts = interface.countRoleAssignments()
@ -125,21 +168,32 @@ def list_roles(
@limiter.limit("60/minute") @limiter.limit("60/minute")
def get_role_options( def get_role_options(
request: Request, request: Request,
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get role options for select dropdowns. Get role options for select dropdowns.
MULTI-TENANT: SysAdmin-only. Context-aware: SysAdmin sees all roles. MandateAdmin sees roles from own mandates
plus template roles.
Returns: Returns:
- List of role option dictionaries with value and label - List of role option dictionaries with value and label
""" """
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try: try:
interface = getRootInterface() interface = getRootInterface()
# Get all roles from database # Get all roles from database
dbRoles = interface.getAllRoles() dbRoles = interface.getAllRoles()
# MandateAdmin: filter to template roles + roles from own mandates
if not isSysAdmin:
dbRoles = [r for r in dbRoles if r.mandateId is None or str(r.mandateId) in adminMandateIds]
# Convert to options format # Convert to options format
options = [] options = []
for role in dbRoles: for role in dbRoles:
@ -167,11 +221,12 @@ def get_role_options(
def create_role( def create_role(
request: Request, request: Request,
role: Role = Body(...), role: Role = Body(...),
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Create a new role. Create a new role.
MULTI-TENANT: SysAdmin-only (roles are system resources). Context-aware: SysAdmin can create any role. MandateAdmin can create roles
within own mandates only (not template or sysadmin roles).
Request Body: Request Body:
- role: Role object to create - role: Role object to create
@ -179,9 +234,24 @@ def create_role(
Returns: Returns:
- Created role dictionary - Created role dictionary
""" """
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try: try:
interface = getRootInterface() interface = getRootInterface()
# MandateAdmin restrictions
if not isSysAdmin:
if role.roleLabel == "sysadmin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot create sysadmin role")
if role.mandateId is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot create template roles")
if str(role.mandateId) not in adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this mandate")
createdRole = interface.createRole(role) createdRole = interface.createRole(role)
return { return {
@ -211,11 +281,12 @@ def create_role(
def get_role( def get_role(
request: Request, request: Request,
roleId: str = Path(..., description="Role ID"), roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get a role by ID. Get a role by ID.
MULTI-TENANT: SysAdmin-only. Context-aware: SysAdmin sees all. MandateAdmin sees roles from own mandates
plus template roles (read-only).
Path Parameters: Path Parameters:
- roleId: Role ID - roleId: Role ID
@ -223,6 +294,12 @@ def get_role(
Returns: Returns:
- Role dictionary - Role dictionary
""" """
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try: try:
interface = getRootInterface() interface = getRootInterface()
@ -233,6 +310,11 @@ def get_role(
detail=f"Role {roleId} not found" detail=f"Role {roleId} not found"
) )
# MandateAdmin: can view template roles (read-only) or own mandate roles
if not isSysAdmin:
if role.mandateId is not None and str(role.mandateId) not in adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this role")
return { return {
"id": role.id, "id": role.id,
"roleLabel": role.roleLabel, "roleLabel": role.roleLabel,
@ -256,11 +338,12 @@ def update_role(
request: Request, request: Request,
roleId: str = Path(..., description="Role ID"), roleId: str = Path(..., description="Role ID"),
role: Role = Body(...), role: Role = Body(...),
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Update an existing role. Update an existing role.
MULTI-TENANT: SysAdmin-only. Context-aware: SysAdmin can update any role. MandateAdmin can update roles
within own mandates only. Template roles and sysadmin role are blocked.
Path Parameters: Path Parameters:
- roleId: Role ID - roleId: Role ID
@ -271,9 +354,27 @@ def update_role(
Returns: Returns:
- Updated role dictionary - Updated role dictionary
""" """
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try: try:
interface = getRootInterface() interface = getRootInterface()
# MandateAdmin restrictions: check existing role before updating
if not isSysAdmin:
existingRole = interface.getRole(roleId)
if not existingRole:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
if existingRole.roleLabel == "sysadmin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot modify sysadmin role")
if existingRole.mandateId is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot modify template roles")
if str(existingRole.mandateId) not in adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this role")
updatedRole = interface.updateRole(roleId, role) updatedRole = interface.updateRole(roleId, role)
return { return {
@ -303,11 +404,12 @@ def update_role(
def delete_role( def delete_role(
request: Request, request: Request,
roleId: str = Path(..., description="Role ID"), roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, str]: ) -> Dict[str, str]:
""" """
Delete a role. Delete a role.
MULTI-TENANT: SysAdmin-only. Context-aware: SysAdmin can delete any role. MandateAdmin can delete roles
within own mandates only. Template roles and sysadmin role are blocked.
Path Parameters: Path Parameters:
- roleId: Role ID - roleId: Role ID
@ -315,9 +417,27 @@ def delete_role(
Returns: Returns:
- Success message - Success message
""" """
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try: try:
interface = getRootInterface() interface = getRootInterface()
# MandateAdmin restrictions: check existing role before deleting
if not isSysAdmin:
existingRole = interface.getRole(roleId)
if not existingRole:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
if existingRole.roleLabel == "sysadmin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete sysadmin role")
if existingRole.mandateId is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete template roles")
if str(existingRole.mandateId) not in adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this role")
success = interface.deleteRole(roleId) success = interface.deleteRole(roleId)
if not success: if not success:
raise HTTPException( raise HTTPException(
@ -348,11 +468,11 @@ def list_users_with_roles(
request: Request, request: Request,
roleLabel: Optional[str] = Query(None, description="Filter by role label"), roleLabel: Optional[str] = Query(None, description="Filter by role label"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"), mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get list of users with their role assignments. Get list of users with their role assignments.
MULTI-TENANT: SysAdmin-only, can see all users across mandates. Context-aware: SysAdmin sees all users. MandateAdmin sees users from own mandates only.
Query Parameters: Query Parameters:
- roleLabel: Optional filter by role label - roleLabel: Optional filter by role label
@ -361,6 +481,12 @@ def list_users_with_roles(
Returns: Returns:
- List of user dictionaries with role assignments - List of user dictionaries with role assignments
""" """
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try: try:
interface = getRootInterface() interface = getRootInterface()
@ -369,9 +495,20 @@ def list_users_with_roles(
# Filter by mandate if specified (via UserMandate table) # Filter by mandate if specified (via UserMandate table)
if mandateId: if mandateId:
# MandateAdmin can only query mandates they admin
if not isSysAdmin and mandateId not in adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this mandate")
userMandates = interface.getUserMandatesByMandate(mandateId) userMandates = interface.getUserMandatesByMandate(mandateId)
mandateUserIds = {str(um.userId) for um in userMandates} mandateUserIds = {str(um.userId) for um in userMandates}
users = [u for u in users if str(u.id) in mandateUserIds] users = [u for u in users if str(u.id) in mandateUserIds]
elif not isSysAdmin:
# MandateAdmin without mandateId filter: restrict to users in admin's mandates
allowedUserIds: Set[str] = set()
for mId in adminMandateIds:
userMandates = interface.getUserMandatesByMandate(mId)
for um in userMandates:
allowedUserIds.add(str(um.userId))
users = [u for u in users if str(u.id) in allowedUserIds]
# Filter by role if specified (via UserMandateRole) # Filter by role if specified (via UserMandateRole)
if roleLabel: if roleLabel:
@ -409,11 +546,11 @@ def list_users_with_roles(
def get_user_roles( def get_user_roles(
request: Request, request: Request,
userId: str = Path(..., description="User ID"), userId: str = Path(..., description="User ID"),
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get role assignments for a specific user. Get role assignments for a specific user.
MULTI-TENANT: SysAdmin-only. Context-aware: SysAdmin sees all. MandateAdmin can view users in own mandates only.
Path Parameters: Path Parameters:
- userId: User ID - userId: User ID
@ -421,6 +558,12 @@ def get_user_roles(
Returns: Returns:
- User dictionary with role assignments - User dictionary with role assignments
""" """
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try: try:
interface = getRootInterface() interface = getRootInterface()
@ -432,6 +575,13 @@ def get_user_roles(
detail=f"User {userId} not found" detail=f"User {userId} not found"
) )
# MandateAdmin: check user is in one of admin's mandates
if not isSysAdmin:
userMandates = interface.getUserMandates(userId)
userMandateMandateIds = {str(um.mandateId) for um in userMandates}
if not userMandateMandateIds.intersection(adminMandateIds):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this user")
userRoleLabels = _getUserRoleLabels(interface, str(user.id)) userRoleLabels = _getUserRoleLabels(interface, str(user.id))
return { return {
"id": user.id, "id": user.id,
@ -460,11 +610,12 @@ def update_user_roles(
request: Request, request: Request,
userId: str = Path(..., description="User ID"), userId: str = Path(..., description="User ID"),
newRoleLabels: List[str] = Body(..., description="List of role labels to assign"), newRoleLabels: List[str] = Body(..., description="List of role labels to assign"),
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Update role assignments for a specific user. Update role assignments for a specific user.
MULTI-TENANT: SysAdmin-only. Updates roles in user's first mandate. Context-aware: SysAdmin can update any user's roles. MandateAdmin can update roles
for users in own mandates only. Cannot assign sysadmin role.
Path Parameters: Path Parameters:
- userId: User ID - userId: User ID
@ -475,6 +626,12 @@ def update_user_roles(
Returns: Returns:
- Updated user dictionary with role assignments - Updated user dictionary with role assignments
""" """
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try: try:
interface = getRootInterface() interface = getRootInterface()
@ -486,6 +643,11 @@ def update_user_roles(
detail=f"User {userId} not found" detail=f"User {userId} not found"
) )
# MandateAdmin restrictions
if not isSysAdmin:
if "sysadmin" in newRoleLabels:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot assign sysadmin role")
# Validate role labels (basic validation - check against standard roles) # Validate role labels (basic validation - check against standard roles)
standardRoles = ["sysadmin", "admin", "user", "viewer"] standardRoles = ["sysadmin", "admin", "user", "viewer"]
for roleLabel in newRoleLabels: for roleLabel in newRoleLabels:
@ -501,17 +663,26 @@ def update_user_roles(
) )
userMandateId = str(userMandates[0].id) userMandateId = str(userMandates[0].id)
targetMandateId = str(userMandates[0].mandateId)
# MandateAdmin: check target mandate belongs to admin's mandates
if not isSysAdmin:
if targetMandateId not in adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this mandate")
# Get current roles for this mandate (Pydantic models) # Get current roles for this mandate (Pydantic models)
existingRoles = interface.getUserMandateRoles(userMandateId) existingRoles = interface.getUserMandateRoles(userMandateId)
existingRoleIds = {str(r.roleId) for r in existingRoles} existingRoleIds = {str(r.roleId) for r in existingRoles}
# Convert roleLabels to roleIds # Convert roleLabels to roleIds - use mandate-scoped lookup to get instance roles
# (prevents assigning template roles instead of mandate-instance roles)
newRoleIds = set() newRoleIds = set()
for roleLabel in newRoleLabels: for roleLabel in newRoleLabels:
role = interface.getRoleByLabel(roleLabel) role = interface.getRoleByLabelAndScope(roleLabel, mandateId=targetMandateId)
if role: if not role:
newRoleIds.add(str(role.id)) logger.warning(f"Role '{roleLabel}' not found for mandate {targetMandateId}, skipping")
continue
newRoleIds.add(str(role.id))
# Remove roles that are no longer needed # Remove roles that are no longer needed
for existingRole in existingRoles: for existingRole in existingRoles:
@ -524,7 +695,7 @@ def update_user_roles(
newRole = UserMandateRole(userMandateId=userMandateId, roleId=roleId) newRole = UserMandateRole(userMandateId=userMandateId, roleId=roleId)
interface.db.recordCreate(UserMandateRole, newRole.model_dump()) interface.db.recordCreate(UserMandateRole, newRole.model_dump())
logger.info(f"Updated roles for user {userId}: {newRoleLabels} by SysAdmin {currentUser.id}") logger.info(f"Updated roles for user {userId}: {newRoleLabels} by admin {currentUser.id}")
userRoleLabels = _getUserRoleLabels(interface, userId) userRoleLabels = _getUserRoleLabels(interface, userId)
return { return {
@ -554,11 +725,12 @@ def add_user_role(
request: Request, request: Request,
userId: str = Path(..., description="User ID"), userId: str = Path(..., description="User ID"),
roleLabel: str = Path(..., description="Role label to add"), roleLabel: str = Path(..., description="Role label to add"),
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Add a role to a user (if not already assigned). Add a role to a user (if not already assigned).
MULTI-TENANT: SysAdmin-only. Adds role to user's first mandate. Context-aware: SysAdmin can add any role. MandateAdmin can add roles to users
in own mandates only. Cannot assign sysadmin role.
Path Parameters: Path Parameters:
- userId: User ID - userId: User ID
@ -567,6 +739,12 @@ def add_user_role(
Returns: Returns:
- Updated user dictionary with role assignments - Updated user dictionary with role assignments
""" """
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try: try:
interface = getRootInterface() interface = getRootInterface()
@ -578,13 +756,10 @@ def add_user_role(
detail=f"User {userId} not found" detail=f"User {userId} not found"
) )
# Get role by label # MandateAdmin restrictions
role = interface.getRoleByLabel(roleLabel) if not isSysAdmin:
if not role: if roleLabel == "sysadmin":
raise HTTPException( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot assign sysadmin role")
status_code=404,
detail=f"Role '{roleLabel}' not found"
)
# Get user's first mandate # Get user's first mandate
userMandates = interface.getUserMandates(userId) userMandates = interface.getUserMandates(userId)
@ -595,6 +770,21 @@ def add_user_role(
) )
userMandateId = str(userMandates[0].id) userMandateId = str(userMandates[0].id)
targetMandateId = str(userMandates[0].mandateId)
# MandateAdmin: check target mandate belongs to admin's mandates
if not isSysAdmin:
if targetMandateId not in adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this mandate")
# Get role by label - use mandate-scoped lookup to get instance role
# (prevents assigning template roles instead of mandate-instance roles)
role = interface.getRoleByLabelAndScope(roleLabel, mandateId=targetMandateId)
if not role:
raise HTTPException(
status_code=404,
detail=f"Role '{roleLabel}' not found for mandate {targetMandateId}"
)
# Check if role is already assigned - use interface method # Check if role is already assigned - use interface method
existingRoles = interface.getUserMandateRoles(userMandateId) existingRoles = interface.getUserMandateRoles(userMandateId)
@ -603,7 +793,7 @@ def add_user_role(
if not roleAlreadyAssigned: if not roleAlreadyAssigned:
# Add the role via interface method # Add the role via interface method
interface.addRoleToUserMandate(userMandateId, str(role.id)) interface.addRoleToUserMandate(userMandateId, str(role.id))
logger.info(f"Added role {roleLabel} to user {userId} by SysAdmin {currentUser.id}") logger.info(f"Added role {roleLabel} to user {userId} by admin {currentUser.id}")
userRoleLabels = _getUserRoleLabels(interface, userId) userRoleLabels = _getUserRoleLabels(interface, userId)
return { return {
@ -633,11 +823,12 @@ def remove_user_role(
request: Request, request: Request,
userId: str = Path(..., description="User ID"), userId: str = Path(..., description="User ID"),
roleLabel: str = Path(..., description="Role label to remove"), roleLabel: str = Path(..., description="Role label to remove"),
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Remove a role from a user. Remove a role from a user.
MULTI-TENANT: SysAdmin-only. Removes role from all user's mandates. Context-aware: SysAdmin can remove any role. MandateAdmin can remove roles from
users in own mandates only. Cannot remove sysadmin role.
Path Parameters: Path Parameters:
- userId: User ID - userId: User ID
@ -646,6 +837,12 @@ def remove_user_role(
Returns: Returns:
- Updated user dictionary with role assignments - Updated user dictionary with role assignments
""" """
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try: try:
interface = getRootInterface() interface = getRootInterface()
@ -657,6 +854,11 @@ def remove_user_role(
detail=f"User {userId} not found" detail=f"User {userId} not found"
) )
# MandateAdmin restrictions
if not isSysAdmin:
if roleLabel == "sysadmin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot remove sysadmin role")
# Get role by label # Get role by label
role = interface.getRoleByLabel(roleLabel) role = interface.getRoleByLabel(roleLabel)
if not role: if not role:
@ -665,19 +867,30 @@ def remove_user_role(
detail=f"Role '{roleLabel}' not found" detail=f"Role '{roleLabel}' not found"
) )
# Remove role from all user's mandates # Remove role from user's mandates
userMandates = interface.getUserMandates(userId) userMandates = interface.getUserMandates(userId)
# MandateAdmin: check user's mandates overlap with admin's mandates
if not isSysAdmin:
userMandateMandateIds = {str(um.mandateId) for um in userMandates}
if not userMandateMandateIds.intersection(set(adminMandateIds)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this user")
roleRemoved = False roleRemoved = False
for um in userMandates: for um in userMandates:
userMandateId = str(um.id) userMandateId = str(um.id)
# MandateAdmin: only remove from mandates they admin
if not isSysAdmin and str(um.mandateId) not in adminMandateIds:
continue
# Remove role via interface method # Remove role via interface method
if interface.removeRoleFromUserMandate(userMandateId, str(role.id)): if interface.removeRoleFromUserMandate(userMandateId, str(role.id)):
roleRemoved = True roleRemoved = True
if roleRemoved: if roleRemoved:
logger.info(f"Removed role {roleLabel} from user {userId} by SysAdmin {currentUser.id}") logger.info(f"Removed role {roleLabel} from user {userId} by admin {currentUser.id}")
userRoleLabels = _getUserRoleLabels(interface, userId) userRoleLabels = _getUserRoleLabels(interface, userId)
return { return {
@ -707,11 +920,11 @@ def get_users_with_role(
request: Request, request: Request,
roleLabel: str = Path(..., description="Role label"), roleLabel: str = Path(..., description="Role label"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"), mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get all users with a specific role. Get all users with a specific role.
MULTI-TENANT: SysAdmin-only. Context-aware: SysAdmin sees all. MandateAdmin sees users from own mandates only.
Path Parameters: Path Parameters:
- roleLabel: Role label - roleLabel: Role label
@ -722,6 +935,12 @@ def get_users_with_role(
Returns: Returns:
- List of users with the specified role - List of users with the specified role
""" """
isSysAdmin = context.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
currentUser = context.user # backward compat for existing code
try: try:
interface = getRootInterface() interface = getRootInterface()
@ -747,6 +966,9 @@ def get_users_with_role(
# Filter by mandate if specified # Filter by mandate if specified
if mandateId and str(um.mandateId) != mandateId: if mandateId and str(um.mandateId) != mandateId:
continue continue
# MandateAdmin: filter to own mandates
if not isSysAdmin and str(um.mandateId) not in adminMandateIds:
continue
userIds.add(str(um.userId)) userIds.add(str(um.userId))
# Get users and format response # Get users and format response

View file

@ -6,8 +6,9 @@ Implements endpoints for role-based access control permissions.
MULTI-TENANT: MULTI-TENANT:
- Permission queries use RequestContext (mandateId from header) - Permission queries use RequestContext (mandateId from header)
- AccessRule management is SysAdmin-only (system resources) - AccessRule management is Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's rules)
- Role management is SysAdmin-only (system resources) - Role management is Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's roles)
- Catalog stats and cleanup remain SysAdmin-only
""" """
from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request
@ -16,7 +17,7 @@ import logging
import json import json
import math import math
from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext from modules.auth import limiter, getRequestContext, requireSysAdminRole, RequestContext
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role
from modules.datamodels.datamodelMembership import UserMandate from modules.datamodels.datamodelMembership import UserMandate
@ -33,6 +34,44 @@ router = APIRouter(
) )
def _getAdminMandateIds(context: RequestContext) -> List[str]:
"""Get mandate IDs where the user has an admin role."""
mandateIds = []
try:
from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface()
userMandates = rootInterface.getUserMandates(str(context.user.id))
for um in userMandates:
if not getattr(um, 'enabled', True):
continue
umId = getattr(um, 'id', None)
mandateId = getattr(um, 'mandateId', None)
if not umId or not mandateId:
continue
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
mandateIds.append(str(mandateId))
break
except Exception as e:
logger.error(f"Error getting admin mandate IDs: {e}")
return mandateIds
def _isRoleInAdminMandates(roleId: str, adminMandateIds: List[str]) -> bool:
"""Check if a role belongs to one of the admin's mandates."""
try:
from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface()
role = rootInterface.getRole(roleId)
if not role:
return False
return str(role.mandateId) in adminMandateIds if role.mandateId else False
except Exception:
return False
@router.get("/permissions", response_model=UserPermissions) @router.get("/permissions", response_model=UserPermissions)
@limiter.limit("300/minute") # Raised from 60 - sidebar checks many pages individually @limiter.limit("300/minute") # Raised from 60 - sidebar checks many pages individually
def get_permissions( def get_permissions(
@ -201,7 +240,7 @@ def get_all_permissions(
logger.debug(f"UI/RESOURCE permissions: User has {len(roleIds)} roles across all mandates") logger.debug(f"UI/RESOURCE permissions: User has {len(roleIds)} roles across all mandates")
if not roleIds and not reqContext.isSysAdmin: if not roleIds and not reqContext.hasSysAdminRole:
# No roles at all, return empty permissions # No roles at all, return empty permissions
for ctx in contextsToFetch: for ctx in contextsToFetch:
result[ctx.value.lower()] = {} result[ctx.value.lower()] = {}
@ -306,11 +345,11 @@ def get_access_rules(
context: Optional[str] = Query(None, description="Filter by context (DATA, UI, RESOURCE)"), context: Optional[str] = Query(None, description="Filter by context (DATA, UI, RESOURCE)"),
item: Optional[str] = Query(None, description="Filter by item identifier"), item: Optional[str] = Query(None, description="Filter by item identifier"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(requireSysAdmin) reqContext: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse: ) -> PaginatedResponse:
""" """
Get access rules with optional filters. Get access rules with optional filters.
MULTI-TENANT: SysAdmin-only (AccessRules are system resources). MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's rules).
Query Parameters: Query Parameters:
- roleLabel: Optional role label filter - roleLabel: Optional role label filter
@ -321,7 +360,12 @@ def get_access_rules(
- List of AccessRule objects - List of AccessRule objects
""" """
try: try:
# Get interface - SysAdmin uses root interface isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
# Get interface - uses root interface for admin access
interface = getRootInterface() interface = getRootInterface()
# Parse context if provided # Parse context if provided
@ -350,6 +394,39 @@ def get_access_rules(
) )
# Get rules with optional pagination # Get rules with optional pagination
# MandateAdmin: fetch all then filter by admin's mandates
if not isSysAdmin:
allRules = interface.getAccessRules(
roleLabel=roleLabel,
context=accessContext,
item=item,
pagination=None
)
filteredRules = [rule for rule in allRules if _isRoleInAdminMandates(str(rule.roleId), adminMandateIds)]
if paginationParams:
totalItems = len(filteredRules)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
return PaginatedResponse(
items=[rule.model_dump() for rule in filteredRules[startIdx:endIdx]],
pagination=PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=totalItems,
totalPages=totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
)
)
else:
return PaginatedResponse(
items=[rule.model_dump() for rule in filteredRules],
pagination=None
)
# SysAdmin: use server-side pagination
result = interface.getAccessRules( result = interface.getAccessRules(
roleLabel=roleLabel, roleLabel=roleLabel,
context=accessContext, context=accessContext,
@ -392,11 +469,11 @@ def get_access_rules(
def get_access_rules_by_role( def get_access_rules_by_role(
request: Request, request: Request,
roleId: str = Path(..., description="Role ID to get rules for"), roleId: str = Path(..., description="Role ID to get rules for"),
currentUser: User = Depends(requireSysAdmin) reqContext: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse: ) -> PaginatedResponse:
""" """
Get all access rules for a specific role. Get all access rules for a specific role.
MULTI-TENANT: SysAdmin-only. MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's roles).
Path Parameters: Path Parameters:
- roleId: The role ID to get rules for - roleId: The role ID to get rules for
@ -405,6 +482,15 @@ def get_access_rules_by_role(
- List of AccessRule objects for the specified role - List of AccessRule objects for the specified role
""" """
try: try:
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
# MandateAdmin: verify role belongs to their mandates
if not isSysAdmin and not _isRoleInAdminMandates(roleId, adminMandateIds):
raise HTTPException(status_code=403, detail="Access denied: role not in your mandates")
interface = getRootInterface() interface = getRootInterface()
# Get rules from database using interface method # Get rules from database using interface method
@ -430,11 +516,11 @@ def get_access_rules_by_role(
def get_access_rule( def get_access_rule(
request: Request, request: Request,
ruleId: str = Path(..., description="Access rule ID"), ruleId: str = Path(..., description="Access rule ID"),
currentUser: User = Depends(requireSysAdmin) reqContext: RequestContext = Depends(getRequestContext)
) -> dict: ) -> dict:
""" """
Get a specific access rule by ID. Get a specific access rule by ID.
MULTI-TENANT: SysAdmin-only. MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's rules).
Path Parameters: Path Parameters:
- ruleId: Access rule ID - ruleId: Access rule ID
@ -443,7 +529,12 @@ def get_access_rule(
- AccessRule object - AccessRule object
""" """
try: try:
# Get interface - SysAdmin uses root interface isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
# Get interface - uses root interface for admin access
interface = getRootInterface() interface = getRootInterface()
# Get rule # Get rule
@ -454,6 +545,10 @@ def get_access_rule(
detail=f"Access rule {ruleId} not found" detail=f"Access rule {ruleId} not found"
) )
# MandateAdmin: verify rule's role belongs to their mandates
if not isSysAdmin and not _isRoleInAdminMandates(str(rule.roleId), adminMandateIds):
raise HTTPException(status_code=403, detail="Access denied: rule's role not in your mandates")
# Convert to dict for JSON serialization # Convert to dict for JSON serialization
return rule.model_dump() return rule.model_dump()
@ -472,11 +567,11 @@ def get_access_rule(
def create_access_rule( def create_access_rule(
request: Request, request: Request,
accessRuleData: dict = Body(..., description="Access rule data"), accessRuleData: dict = Body(..., description="Access rule data"),
currentUser: User = Depends(requireSysAdmin) reqContext: RequestContext = Depends(getRequestContext)
) -> dict: ) -> dict:
""" """
Create a new access rule. Create a new access rule.
MULTI-TENANT: SysAdmin-only. MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin creates for own mandate's roles).
Request Body: Request Body:
- AccessRule object data (roleLabel, context, item, view, read, create, update, delete) - AccessRule object data (roleLabel, context, item, view, read, create, update, delete)
@ -485,7 +580,12 @@ def create_access_rule(
- Created AccessRule object - Created AccessRule object
""" """
try: try:
# Get interface - SysAdmin uses root interface isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
# Get interface - uses root interface for admin access
interface = getRootInterface() interface = getRootInterface()
# Validate and parse access rule data # Validate and parse access rule data
@ -515,10 +615,15 @@ def create_access_rule(
detail=f"Invalid access rule data: {str(e)}" detail=f"Invalid access rule data: {str(e)}"
) )
# MandateAdmin: verify the rule's role belongs to their mandates
if not isSysAdmin and accessRule.roleId:
if not _isRoleInAdminMandates(str(accessRule.roleId), adminMandateIds):
raise HTTPException(status_code=403, detail="Access denied: role not in your mandates")
# Create rule # Create rule
createdRule = interface.createAccessRule(accessRule) createdRule = interface.createAccessRule(accessRule)
logger.info(f"Created access rule {createdRule.id} by SysAdmin {currentUser.id}") logger.info(f"Created access rule {createdRule.id} by admin {reqContext.user.id}")
# Convert to dict for JSON serialization # Convert to dict for JSON serialization
return createdRule.model_dump() return createdRule.model_dump()
@ -539,11 +644,11 @@ def update_access_rule(
request: Request, request: Request,
ruleId: str = Path(..., description="Access rule ID"), ruleId: str = Path(..., description="Access rule ID"),
accessRuleData: dict = Body(..., description="Updated access rule data"), accessRuleData: dict = Body(..., description="Updated access rule data"),
currentUser: User = Depends(requireSysAdmin) reqContext: RequestContext = Depends(getRequestContext)
) -> dict: ) -> dict:
""" """
Update an existing access rule. Update an existing access rule.
MULTI-TENANT: SysAdmin-only. MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin updates own mandate's rules).
Path Parameters: Path Parameters:
- ruleId: Access rule ID - ruleId: Access rule ID
@ -555,7 +660,12 @@ def update_access_rule(
- Updated AccessRule object - Updated AccessRule object
""" """
try: try:
# Get interface - SysAdmin uses root interface isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
# Get interface - uses root interface for admin access
interface = getRootInterface() interface = getRootInterface()
# Get existing rule to ensure it exists # Get existing rule to ensure it exists
@ -566,6 +676,10 @@ def update_access_rule(
detail=f"Access rule {ruleId} not found" detail=f"Access rule {ruleId} not found"
) )
# MandateAdmin: verify existing rule's role belongs to their mandates
if not isSysAdmin and not _isRoleInAdminMandates(str(existingRule.roleId), adminMandateIds):
raise HTTPException(status_code=403, detail="Access denied: rule's role not in your mandates")
# Validate and parse access rule data # Validate and parse access rule data
try: try:
# Merge with existing rule data # Merge with existing rule data
@ -601,7 +715,7 @@ def update_access_rule(
# Update rule # Update rule
updatedRule = interface.updateAccessRule(ruleId, accessRule) updatedRule = interface.updateAccessRule(ruleId, accessRule)
logger.info(f"Updated access rule {ruleId} by SysAdmin {currentUser.id}") logger.info(f"Updated access rule {ruleId} by admin {reqContext.user.id}")
# Convert to dict for JSON serialization # Convert to dict for JSON serialization
return updatedRule.model_dump() return updatedRule.model_dump()
@ -621,11 +735,11 @@ def update_access_rule(
def delete_access_rule( def delete_access_rule(
request: Request, request: Request,
ruleId: str = Path(..., description="Access rule ID"), ruleId: str = Path(..., description="Access rule ID"),
currentUser: User = Depends(requireSysAdmin) reqContext: RequestContext = Depends(getRequestContext)
) -> dict: ) -> dict:
""" """
Delete an access rule. Delete an access rule.
MULTI-TENANT: SysAdmin-only. MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin deletes own mandate's rules).
Path Parameters: Path Parameters:
- ruleId: Access rule ID - ruleId: Access rule ID
@ -634,7 +748,12 @@ def delete_access_rule(
- Success message - Success message
""" """
try: try:
# Get interface - SysAdmin uses root interface isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
# Get interface - uses root interface for admin access
interface = getRootInterface() interface = getRootInterface()
# Get existing rule to ensure it exists # Get existing rule to ensure it exists
@ -645,6 +764,10 @@ def delete_access_rule(
detail=f"Access rule {ruleId} not found" detail=f"Access rule {ruleId} not found"
) )
# MandateAdmin: verify rule's role belongs to their mandates
if not isSysAdmin and not _isRoleInAdminMandates(str(existingRule.roleId), adminMandateIds):
raise HTTPException(status_code=403, detail="Access denied: rule's role not in your mandates")
# Delete rule # Delete rule
success = interface.deleteAccessRule(ruleId) success = interface.deleteAccessRule(ruleId)
@ -654,7 +777,7 @@ def delete_access_rule(
detail=f"Failed to delete access rule {ruleId}" detail=f"Failed to delete access rule {ruleId}"
) )
logger.info(f"Deleted access rule {ruleId} by SysAdmin {currentUser.id}") logger.info(f"Deleted access rule {ruleId} by admin {reqContext.user.id}")
return {"success": True, "message": f"Access rule {ruleId} deleted successfully"} return {"success": True, "message": f"Access rule {ruleId} deleted successfully"}
@ -670,7 +793,7 @@ def delete_access_rule(
# ============================================================================ # ============================================================================
# Role Management Endpoints # Role Management Endpoints
# MULTI-TENANT: All role management is SysAdmin-only (roles are system resources) # MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's roles)
# ============================================================================ # ============================================================================
@ -682,11 +805,11 @@ def list_roles(
includeTemplates: bool = Query(False, description="Include feature template roles"), includeTemplates: bool = Query(False, description="Include feature template roles"),
mandateId: Optional[str] = Query(None, description="Include mandate-specific roles for this mandate"), mandateId: Optional[str] = Query(None, description="Include mandate-specific roles for this mandate"),
scopeFilter: Optional[str] = Query(None, description="Filter by scope: 'all', 'mandate', 'global', 'system'"), scopeFilter: Optional[str] = Query(None, description="Filter by scope: 'all', 'mandate', 'global', 'system'"),
currentUser: User = Depends(requireSysAdmin) reqContext: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse: ) -> PaginatedResponse:
""" """
Get list of roles with metadata. Get list of roles with metadata.
MULTI-TENANT: SysAdmin-only (roles are system resources). MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's roles).
By default, only returns true global roles (mandateId=None, featureInstanceId=None, featureCode=None). By default, only returns true global roles (mandateId=None, featureInstanceId=None, featureCode=None).
Feature template roles are managed via /api/features/templates/roles. Feature template roles are managed via /api/features/templates/roles.
@ -701,6 +824,11 @@ def list_roles(
- List of role dictionaries with role label, description, user count, and computed scopeType - List of role dictionaries with role label, description, user count, and computed scopeType
""" """
try: try:
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
interface = getRootInterface() interface = getRootInterface()
# Parse pagination parameter # Parse pagination parameter
@ -777,6 +905,10 @@ def list_roles(
"scopeType": scopeType # Computed field for frontend display "scopeType": scopeType # Computed field for frontend display
}) })
# MandateAdmin: filter to only roles in admin's mandates
if not isSysAdmin:
result = [r for r in result if r.get("mandateId") and str(r["mandateId"]) in adminMandateIds]
# Apply search, filtering and sorting if pagination requested # Apply search, filtering and sorting if pagination requested
if paginationParams: if paginationParams:
# Apply search (if search term provided in filters) # Apply search (if search term provided in filters)
@ -850,7 +982,7 @@ def list_roles(
@limiter.limit("60/minute") @limiter.limit("60/minute")
def get_role_options( def get_role_options(
request: Request, request: Request,
currentUser: User = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdminRole)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get role options for select dropdowns. Get role options for select dropdowns.
@ -892,11 +1024,11 @@ def get_role_options(
def create_role( def create_role(
request: Request, request: Request,
role: Role = Body(...), role: Role = Body(...),
currentUser: User = Depends(requireSysAdmin) reqContext: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Create a new role. Create a new role.
MULTI-TENANT: SysAdmin-only. MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin creates in own mandate).
Request Body: Request Body:
- role: Role object to create - role: Role object to create
@ -905,11 +1037,21 @@ def create_role(
- Created role dictionary - Created role dictionary
""" """
try: try:
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
# MandateAdmin: can only create roles in their own mandates
if not isSysAdmin:
if not role.mandateId or str(role.mandateId) not in adminMandateIds:
raise HTTPException(status_code=403, detail="Access denied: can only create roles in your own mandates")
interface = getRootInterface() interface = getRootInterface()
createdRole = interface.createRole(role) createdRole = interface.createRole(role)
logger.info(f"Created role {createdRole.roleLabel} by SysAdmin {currentUser.id}") logger.info(f"Created role {createdRole.roleLabel} by admin {reqContext.user.id}")
return { return {
"id": createdRole.id, "id": createdRole.id,
@ -941,11 +1083,11 @@ def create_role(
def get_role( def get_role(
request: Request, request: Request,
roleId: str = Path(..., description="Role ID"), roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(requireSysAdmin) reqContext: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get a role by ID. Get a role by ID.
MULTI-TENANT: SysAdmin-only. MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's roles).
Path Parameters: Path Parameters:
- roleId: Role ID - roleId: Role ID
@ -954,6 +1096,11 @@ def get_role(
- Role dictionary - Role dictionary
""" """
try: try:
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
interface = getRootInterface() interface = getRootInterface()
role = interface.getRole(roleId) role = interface.getRole(roleId)
@ -963,6 +1110,11 @@ def get_role(
detail=f"Role {roleId} not found" detail=f"Role {roleId} not found"
) )
# MandateAdmin: verify role belongs to their mandates
if not isSysAdmin:
if not role.mandateId or str(role.mandateId) not in adminMandateIds:
raise HTTPException(status_code=403, detail="Access denied: role not in your mandates")
return { return {
"id": role.id, "id": role.id,
"roleLabel": role.roleLabel, "roleLabel": role.roleLabel,
@ -989,11 +1141,11 @@ def update_role(
request: Request, request: Request,
roleId: str = Path(..., description="Role ID"), roleId: str = Path(..., description="Role ID"),
role: Role = Body(...), role: Role = Body(...),
currentUser: User = Depends(requireSysAdmin) reqContext: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Update an existing role. Update an existing role.
MULTI-TENANT: SysAdmin-only. MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin updates own mandate's roles, not template/system).
Path Parameters: Path Parameters:
- roleId: Role ID - roleId: Role ID
@ -1005,11 +1157,26 @@ def update_role(
- Updated role dictionary - Updated role dictionary
""" """
try: try:
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
interface = getRootInterface() interface = getRootInterface()
# MandateAdmin: verify role belongs to their mandates and is not a template/system role
if not isSysAdmin:
existingRole = interface.getRole(roleId)
if not existingRole:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
if existingRole.isSystemRole and not existingRole.mandateId:
raise HTTPException(status_code=403, detail="Access denied: cannot modify template/system roles")
if not existingRole.mandateId or str(existingRole.mandateId) not in adminMandateIds:
raise HTTPException(status_code=403, detail="Access denied: role not in your mandates")
updatedRole = interface.updateRole(roleId, role) updatedRole = interface.updateRole(roleId, role)
logger.info(f"Updated role {roleId} by SysAdmin {currentUser.id}") logger.info(f"Updated role {roleId} by admin {reqContext.user.id}")
return { return {
"id": updatedRole.id, "id": updatedRole.id,
@ -1041,11 +1208,11 @@ def update_role(
def delete_role( def delete_role(
request: Request, request: Request,
roleId: str = Path(..., description="Role ID"), roleId: str = Path(..., description="Role ID"),
currentUser: User = Depends(requireSysAdmin) reqContext: RequestContext = Depends(getRequestContext)
) -> Dict[str, str]: ) -> Dict[str, str]:
""" """
Delete a role. Delete a role.
MULTI-TENANT: SysAdmin-only. MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin deletes own mandate's roles, not template/system).
Path Parameters: Path Parameters:
- roleId: Role ID - roleId: Role ID
@ -1054,8 +1221,23 @@ def delete_role(
- Success message - Success message
""" """
try: try:
isSysAdmin = reqContext.hasSysAdminRole
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
if not isSysAdmin and not adminMandateIds:
raise HTTPException(status_code=403, detail="Admin role required")
interface = getRootInterface() interface = getRootInterface()
# MandateAdmin: verify role belongs to their mandates and is not a template/system role
if not isSysAdmin:
existingRole = interface.getRole(roleId)
if not existingRole:
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
if existingRole.isSystemRole and not existingRole.mandateId:
raise HTTPException(status_code=403, detail="Access denied: cannot delete template/system roles")
if not existingRole.mandateId or str(existingRole.mandateId) not in adminMandateIds:
raise HTTPException(status_code=403, detail="Access denied: role not in your mandates")
success = interface.deleteRole(roleId) success = interface.deleteRole(roleId)
if not success: if not success:
raise HTTPException( raise HTTPException(
@ -1063,7 +1245,7 @@ def delete_role(
detail=f"Role {roleId} not found" detail=f"Role {roleId} not found"
) )
logger.info(f"Deleted role {roleId} by SysAdmin {currentUser.id}") logger.info(f"Deleted role {roleId} by admin {reqContext.user.id}")
return {"message": f"Role {roleId} deleted successfully"} return {"message": f"Role {roleId} deleted successfully"}
@ -1182,7 +1364,7 @@ def getCatalogObjects(
@limiter.limit("60/minute") @limiter.limit("60/minute")
def getCatalogStats( def getCatalogStats(
request: Request, request: Request,
currentUser: User = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdminRole)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get statistics about the RBAC catalog. Get statistics about the RBAC catalog.
@ -1213,7 +1395,7 @@ def getCatalogStats(
def cleanup_duplicate_access_rules( def cleanup_duplicate_access_rules(
request: Request, request: Request,
dryRun: bool = Query(True, description="If true, only report duplicates without deleting"), dryRun: bool = Query(True, description="If true, only report duplicates without deleting"),
currentUser: User = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdminRole)
) -> dict: ) -> dict:
""" """
Find and remove duplicate AccessRules. Find and remove duplicate AccessRules.
@ -1278,19 +1460,102 @@ def cleanup_duplicate_access_rules(
except Exception as e: except Exception as e:
logger.warning(f"Failed to delete rule {ruleId}: {e}") logger.warning(f"Failed to delete rule {ruleId}: {e}")
# =====================================================================
# Phase 2: Fix template role assignments
# UserMandateRole should reference mandate-instance roles, not templates
# =====================================================================
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
allUserMandateRoles = rootInterface.db.getRecordset(UserMandateRole)
templateFixDetails = []
templateFixedCount = 0
for umr in allUserMandateRoles:
roleId = umr.get("roleId")
userMandateId = umr.get("userMandateId")
umrId = umr.get("id")
if not roleId or not userMandateId:
continue
# Check if assigned role is a template
role = rootInterface.getRole(roleId)
if not role or role.mandateId is not None:
continue # Not a template role, OK
if not role.isSystemRole:
continue # Not a system template, skip
# Template role assigned! Find the UserMandate to get the mandateId
userMandateRecords = rootInterface.db.getRecordset(
UserMandate, recordFilter={"id": userMandateId}
)
if not userMandateRecords:
continue
mandateId = userMandateRecords[0].get("mandateId")
if not mandateId:
continue
# Find the correct mandate-instance role
mandateRoles = rootInterface.db.getRecordset(
Role, recordFilter={"roleLabel": role.roleLabel, "mandateId": mandateId, "featureInstanceId": None}
)
detail = {
"userMandateRoleId": umrId,
"userMandateId": userMandateId,
"mandateId": mandateId,
"templateRoleId": roleId,
"templateRoleLabel": role.roleLabel,
"action": "none"
}
if mandateRoles:
instanceRoleId = mandateRoles[0].get("id")
detail["instanceRoleId"] = instanceRoleId
detail["action"] = "replace" if not dryRun else "would_replace"
if not dryRun:
try:
rootInterface.db.recordModify(UserMandateRole, umrId, {"roleId": instanceRoleId})
templateFixedCount += 1
logger.info(f"Fixed template role assignment: {umrId}{role.roleLabel} template → instance {instanceRoleId}")
except Exception as e:
detail["action"] = f"error: {e}"
logger.warning(f"Failed to fix role assignment {umrId}: {e}")
else:
detail["action"] = "delete_inconsistent" if not dryRun else "would_delete_inconsistent"
if not dryRun:
try:
rootInterface.db.recordDelete(UserMandateRole, umrId)
templateFixedCount += 1
logger.info(f"Deleted inconsistent template role assignment: {umrId} (template '{role.roleLabel}' in mandate {mandateId}, no instance role found)")
except Exception as e:
detail["action"] = f"error: {e}"
logger.warning(f"Failed to delete inconsistent assignment {umrId}: {e}")
templateFixDetails.append(detail)
result = { result = {
"dryRun": dryRun, "dryRun": dryRun,
"totalRules": len(allRules), "duplicateRules": {
"uniqueSignatures": len(rulesBySignature), "totalRules": len(allRules),
"duplicateGroups": len(duplicateGroups), "uniqueSignatures": len(rulesBySignature),
"duplicateRulesToDelete": len(idsToDelete), "duplicateGroups": len(duplicateGroups),
"deletedCount": deletedCount, "duplicateRulesToDelete": len(idsToDelete),
"details": duplicateGroups[:50] # Limit details to 50 groups "deletedCount": deletedCount,
"details": duplicateGroups[:50]
},
"templateRoleAssignments": {
"totalUserMandateRoles": len(allUserMandateRoles),
"invalidAssignments": len(templateFixDetails),
"fixedCount": templateFixedCount,
"details": templateFixDetails[:50]
}
} }
logger.info(f"AccessRule cleanup: dryRun={dryRun}, total={len(allRules)}, " logger.info(f"RBAC cleanup: dryRun={dryRun}, "
f"duplicateGroups={len(duplicateGroups)}, toDelete={len(idsToDelete)}, " f"duplicates={len(duplicateGroups)}/{deletedCount} deleted, "
f"deleted={deletedCount}") f"templateFixes={len(templateFixDetails)}/{templateFixedCount} fixed")
return result return result

View file

@ -8,11 +8,12 @@ MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true.
Shows comprehensive view of what a user can see and access. Shows comprehensive view of what a user can see and access.
""" """
from fastapi import APIRouter, HTTPException, Depends, Query, Path, Request from fastapi import APIRouter, HTTPException, Depends, Query, Path, Request, status
from typing import List, Dict, Any, Optional, Set from typing import List, Dict, Any, Optional, Set
import logging import logging
from modules.auth import limiter, requireSysAdmin from modules.auth import limiter
from modules.auth.authentication import getRequestContext, RequestContext
from modules.datamodels.datamodelUam import User, UserInDB from modules.datamodels.datamodelUam import User, UserInDB
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
from modules.datamodels.datamodelMembership import ( from modules.datamodels.datamodelMembership import (
@ -67,34 +68,101 @@ def _getRoleScopePriority(scope: str) -> int:
return priorities.get(scope, 0) return priorities.get(scope, 0)
def _hasMandateAdminRole(context: RequestContext) -> bool:
"""Check if the user has mandate admin role in ANY mandate.
Loads roles independently from request context (context.roleIds may be empty
when no X-Mandate-Id header is sent, e.g., on admin pages).
"""
if context.hasSysAdminRole:
return True
try:
rootInterface = getRootInterface()
userMandates = rootInterface.getUserMandates(str(context.user.id))
for um in userMandates:
umId = getattr(um, 'id', None)
if not umId:
continue
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
return True
return False
except Exception as e:
logger.error(f"Error checking mandate admin role: {e}")
return False
def _isUserInMandate(rootInterface, userId: str, mandateId: str) -> bool:
"""Check if a user belongs to a specific mandate."""
try:
userMandates = rootInterface.db.getRecordset(UserMandate, {"userId": userId, "mandateId": mandateId})
return len(userMandates) > 0
except Exception:
return False
@router.get("/users", response_model=List[Dict[str, Any]]) @router.get("/users", response_model=List[Dict[str, Any]])
@limiter.limit("60/minute") @limiter.limit("60/minute")
def listUsersForOverview( def listUsersForOverview(
request: Request, request: Request,
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get list of all users for selection in the overview. Get list of users for selection in the overview.
MULTI-TENANT: SysAdmin-only. SysAdmin sees all users. MandateAdmin sees users in their mandate.
Returns: Returns:
- List of user dictionaries with basic info - List of user dictionaries with basic info
""" """
if not _hasMandateAdminRole(context):
raise HTTPException(status_code=403, detail="Keine Berechtigung für die Benutzerzugriffsübersicht")
try: try:
interface = getRootInterface() interface = getRootInterface()
# Get all users using interface method if context.hasSysAdminRole and not context.mandateId:
allUsers = interface.getAllUsers() # SysAdmin without mandate context: all users
allUsers = interface.getAllUsers()
elif context.mandateId:
# With explicit mandate context: users in that mandate
allUsers = interface.getUsersByMandate(str(context.mandateId))
else:
# MandateAdmin without mandate context: aggregate across all admin mandates
userMandates = interface.getUserMandates(str(context.user.id))
adminMandateIds = []
for um in userMandates:
umId = getattr(um, 'id', None)
mid = getattr(um, 'mandateId', None)
if not umId or not mid:
continue
roleIds = interface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = interface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
adminMandateIds.append(str(mid))
break
seenUserIds = set()
allUsers = []
for mid in adminMandateIds:
mandateUsers = interface.getUsersByMandate(mid)
for u in (mandateUsers if isinstance(mandateUsers, list) else mandateUsers.items if hasattr(mandateUsers, 'items') else []):
uid = u.get("id") if isinstance(u, dict) else getattr(u, "id", None)
if uid and uid not in seenUserIds:
seenUserIds.add(uid)
allUsers.append(u)
result = [] result = []
for u in allUsers: for u in allUsers:
userData = u if isinstance(u, dict) else u.model_dump() if hasattr(u, 'model_dump') else vars(u)
result.append({ result.append({
"id": u.id, "id": userData.get("id"),
"username": u.username, "username": userData.get("username"),
"email": u.email, "email": userData.get("email"),
"fullName": u.fullName, "fullName": userData.get("fullName"),
"isSysAdmin": u.isSysAdmin, "isSysAdmin": userData.get("isSysAdmin", False),
"enabled": u.enabled, "enabled": userData.get("enabled", True),
}) })
# Sort by username # Sort by username
@ -102,6 +170,8 @@ def listUsersForOverview(
return result return result
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"Error listing users for overview: {str(e)}") logger.error(f"Error listing users for overview: {str(e)}")
raise HTTPException( raise HTTPException(
@ -117,11 +187,11 @@ def getUserAccessOverview(
userId: str = Path(..., description="User ID to get access overview for"), userId: str = Path(..., description="User ID to get access overview for"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"), mandateId: Optional[str] = Query(None, description="Filter by mandate ID"),
featureInstanceId: Optional[str] = Query(None, description="Filter by feature instance ID"), featureInstanceId: Optional[str] = Query(None, description="Filter by feature instance ID"),
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get comprehensive access overview for a specific user. Get comprehensive access overview for a specific user.
MULTI-TENANT: SysAdmin-only. SysAdmin sees all users. MandateAdmin sees users in their mandate.
Path Parameters: Path Parameters:
- userId: User ID - userId: User ID
@ -138,9 +208,39 @@ def getUserAccessOverview(
- Data access (what tables/fields the user can access) - Data access (what tables/fields the user can access)
- Resource access (what resources the user can use) - Resource access (what resources the user can use)
""" """
if not _hasMandateAdminRole(context):
raise HTTPException(status_code=403, detail="Keine Berechtigung für die Benutzerzugriffsübersicht")
try: try:
interface = getRootInterface() interface = getRootInterface()
# MandateAdmin: verify the requested user shares at least one admin mandate
if not context.hasSysAdminRole:
# Get admin's mandate IDs
adminMandateIds = []
userMandates = interface.getUserMandates(str(context.user.id))
for um in userMandates:
umId = getattr(um, 'id', None)
mid = getattr(um, 'mandateId', None)
if not umId or not mid:
continue
roleIds = interface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = interface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
adminMandateIds.append(str(mid))
break
# Check that requested user belongs to at least one of the admin's mandates
userInAdminMandate = False
for mid in adminMandateIds:
if _isUserInMandate(interface, userId, mid):
userInAdminMandate = True
break
if not userInAdminMandate:
raise HTTPException(status_code=403, detail="Benutzer gehört nicht zu Ihrem Mandate")
# Get user # Get user
user = interface.getUser(userId) user = interface.getUser(userId)
if not user: if not user:
@ -159,19 +259,6 @@ def getUserAccessOverview(
"enabled": user.enabled, "enabled": user.enabled,
} }
# If user is SysAdmin, they have full access to everything
if user.isSysAdmin:
return {
"user": userInfo,
"isSysAdmin": True,
"sysAdminNote": "SysAdmin users have full access to all system-level resources without mandate context.",
"roles": [],
"mandates": [],
"uiAccess": [],
"dataAccess": [],
"resourceAccess": [],
}
# Collect all roles for the user # Collect all roles for the user
allRoles = [] allRoles = []
roleIdToInfo = {} # Map roleId to role info for later reference roleIdToInfo = {} # Map roleId to role info for later reference
@ -415,14 +502,14 @@ def getEffectivePermissions(
userId: str = Path(..., description="User ID"), userId: str = Path(..., description="User ID"),
mandateId: str = Query(..., description="Mandate ID context"), mandateId: str = Query(..., description="Mandate ID context"),
featureInstanceId: Optional[str] = Query(None, description="Feature instance ID context"), featureInstanceId: Optional[str] = Query(None, description="Feature instance ID context"),
context: str = Query("DATA", description="Context type: DATA, UI, or RESOURCE"), accessContext: str = Query("DATA", alias="context", description="Context type: DATA, UI, or RESOURCE"),
item: Optional[str] = Query(None, description="Specific item to check permissions for"), item: Optional[str] = Query(None, description="Specific item to check permissions for"),
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get effective (resolved) permissions for a user in a specific context. Get effective (resolved) permissions for a user in a specific context.
This uses the RBAC resolution logic to show what permissions actually apply. This uses the RBAC resolution logic to show what permissions actually apply.
MULTI-TENANT: SysAdmin-only. MULTI-TENANT: SysAdmin sees all. MandateAdmin can check users in their own mandates.
Path Parameters: Path Parameters:
- userId: User ID - userId: User ID
@ -436,6 +523,11 @@ def getEffectivePermissions(
Returns: Returns:
- Effective permissions after RBAC resolution - Effective permissions after RBAC resolution
""" """
if not context.hasSysAdminRole:
# Check if user has admin role in any mandate
if not _hasMandateAdminRole(context):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
try: try:
interface = getRootInterface() interface = getRootInterface()
@ -449,11 +541,11 @@ def getEffectivePermissions(
# Convert context string to enum # Convert context string to enum
try: try:
contextEnum = AccessRuleContext(context) contextEnum = AccessRuleContext(accessContext)
except ValueError: except ValueError:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Invalid context: {context}. Must be DATA, UI, or RESOURCE." detail=f"Invalid context: {accessContext}. Must be DATA, UI, or RESOURCE."
) )
# Use RBAC interface to get actual permissions # Use RBAC interface to get actual permissions
@ -472,7 +564,7 @@ def getEffectivePermissions(
"userId": userId, "userId": userId,
"mandateId": mandateId, "mandateId": mandateId,
"featureInstanceId": featureInstanceId, "featureInstanceId": featureInstanceId,
"context": context, "context": accessContext,
"item": item, "item": item,
"effectivePermissions": { "effectivePermissions": {
"view": permissions.view, "view": permissions.view,

View file

@ -17,7 +17,7 @@ from datetime import date, datetime
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# Import auth module # Import auth module
from modules.auth import limiter, requireSysAdmin, getRequestContext, RequestContext from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
# Import billing components # Import billing components
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
@ -84,7 +84,8 @@ def _getBillingDataScope(user) -> BillingDataScope:
""" """
scope = BillingDataScope(userId=user.id) scope = BillingDataScope(userId=user.id)
if user.isSysAdmin: from modules.auth.authentication import _hasSysAdminRole
if _hasSysAdminRole(str(user.id)):
scope.isGlobalAdmin = True scope.isGlobalAdmin = True
return scope return scope
@ -137,6 +138,30 @@ def _getBillingDataScope(user) -> BillingDataScope:
return scope return scope
def _isAdminOfMandate(ctx: RequestContext, targetMandateId: str) -> bool:
"""Check if user is SysAdmin or admin of the specified mandate."""
if ctx.hasSysAdminRole:
return True
try:
from modules.interfaces.interfaceDbApp import getRootInterface
rootInterface = getRootInterface()
userMandates = rootInterface.getUserMandates(str(ctx.user.id))
for um in userMandates:
if str(getattr(um, 'mandateId', None)) != str(targetMandateId):
continue
if not getattr(um, 'enabled', True):
continue
umId = str(getattr(um, 'id', ''))
roleIds = rootInterface.getRoleIdsForUserMandate(umId)
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
return True
return False
except Exception:
return False
def _filterTransactionsByScope(transactions: list, scope: BillingDataScope) -> list: def _filterTransactionsByScope(transactions: list, scope: BillingDataScope) -> list:
""" """
Filter a list of transaction dicts based on the user's BillingDataScope. Filter a list of transaction dicts based on the user's BillingDataScope.
@ -537,11 +562,13 @@ def getSettingsAdmin(
request: Request, request: Request,
targetMandateId: str = Path(..., description="Mandate ID"), targetMandateId: str = Path(..., description="Mandate ID"),
ctx: RequestContext = Depends(getRequestContext), ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin)
): ):
""" """
Get billing settings for a mandate (SysAdmin only). Get billing settings for a mandate.
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
""" """
if not _isAdminOfMandate(ctx, targetMandateId):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
try: try:
billingInterface = getBillingInterface(ctx.user, targetMandateId) billingInterface = getBillingInterface(ctx.user, targetMandateId)
settings = billingInterface.getSettings(targetMandateId) settings = billingInterface.getSettings(targetMandateId)
@ -565,7 +592,7 @@ def createOrUpdateSettings(
targetMandateId: str = Path(..., description="Mandate ID"), targetMandateId: str = Path(..., description="Mandate ID"),
settingsUpdate: BillingSettingsUpdate = Body(...), settingsUpdate: BillingSettingsUpdate = Body(...),
ctx: RequestContext = Depends(getRequestContext), ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin) _admin = Depends(requireSysAdminRole)
): ):
""" """
Create or update billing settings for a mandate (SysAdmin only). Create or update billing settings for a mandate (SysAdmin only).
@ -618,7 +645,7 @@ def addCredit(
targetMandateId: str = Path(..., description="Mandate ID"), targetMandateId: str = Path(..., description="Mandate ID"),
creditRequest: CreditAddRequest = Body(...), creditRequest: CreditAddRequest = Body(...),
ctx: RequestContext = Depends(getRequestContext), ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin) _admin = Depends(requireSysAdminRole)
): ):
""" """
Add credit to a billing account (SysAdmin only). Add credit to a billing account (SysAdmin only).
@ -681,11 +708,13 @@ def getAccounts(
request: Request, request: Request,
targetMandateId: str = Path(..., description="Mandate ID"), targetMandateId: str = Path(..., description="Mandate ID"),
ctx: RequestContext = Depends(getRequestContext), ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin)
): ):
""" """
Get all billing accounts for a mandate (SysAdmin only). Get all billing accounts for a mandate.
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
""" """
if not _isAdminOfMandate(ctx, targetMandateId):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
try: try:
billingInterface = getBillingInterface(ctx.user, targetMandateId) billingInterface = getBillingInterface(ctx.user, targetMandateId)
@ -728,12 +757,14 @@ def getUsersForMandate(
request: Request, request: Request,
targetMandateId: str = Path(..., description="Mandate ID"), targetMandateId: str = Path(..., description="Mandate ID"),
ctx: RequestContext = Depends(getRequestContext), ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin)
): ):
""" """
Get all users belonging to a mandate (SysAdmin only). Get all users belonging to a mandate.
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
Used by billing admin to select users for credit assignment. Used by billing admin to select users for credit assignment.
""" """
if not _isAdminOfMandate(ctx, targetMandateId):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
try: try:
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
@ -787,11 +818,13 @@ def getTransactionsAdmin(
targetMandateId: str = Path(..., description="Mandate ID"), targetMandateId: str = Path(..., description="Mandate ID"),
limit: int = Query(default=100, ge=1, le=1000), limit: int = Query(default=100, ge=1, le=1000),
ctx: RequestContext = Depends(getRequestContext), ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin)
): ):
""" """
Get all transactions for a mandate (SysAdmin only). Get all transactions for a mandate.
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
""" """
if not _isAdminOfMandate(ctx, targetMandateId):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
try: try:
billingInterface = getBillingInterface(ctx.user, targetMandateId) billingInterface = getBillingInterface(ctx.user, targetMandateId)
transactions = billingInterface.getTransactionsByMandate(targetMandateId, limit=limit) transactions = billingInterface.getTransactionsByMandate(targetMandateId, limit=limit)
@ -830,7 +863,7 @@ def getTransactionsAdmin(
def getMandateViewBalances( def getMandateViewBalances(
request: Request, request: Request,
ctx: RequestContext = Depends(getRequestContext), ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin) _admin = Depends(requireSysAdminRole)
): ):
""" """
Get mandate-level balances (SysAdmin only). Get mandate-level balances (SysAdmin only).
@ -853,7 +886,7 @@ def getMandateViewTransactions(
request: Request, request: Request,
limit: int = Query(default=100, ge=1, le=1000), limit: int = Query(default=100, ge=1, le=1000),
ctx: RequestContext = Depends(getRequestContext), ctx: RequestContext = Depends(getRequestContext),
_admin = Depends(requireSysAdmin) _admin = Depends(requireSysAdminRole)
): ):
""" """
Get all transactions across mandates (SysAdmin only). Get all transactions across mandates (SysAdmin only).

View file

@ -17,7 +17,7 @@ import json
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# Import auth module # Import auth module
from modules.auth import limiter, requireSysAdmin, getRequestContext, RequestContext from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
# Import interfaces # Import interfaces
import modules.interfaces.interfaceDbApp as interfaceDbApp import modules.interfaces.interfaceDbApp as interfaceDbApp
@ -79,20 +79,30 @@ router = APIRouter(
def get_mandates( def get_mandates(
request: Request, request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[Mandate]: ) -> PaginatedResponse[Mandate]:
""" """
Get mandates with optional pagination, sorting, and filtering. Get mandates with optional pagination, sorting, and filtering.
MULTI-TENANT: SysAdmin-only (mandates are system resources).
Access:
- SysAdmin: all mandates
- MandateAdmin: only mandates where user has admin role
- Other: 403
Query Parameters: Query Parameters:
- pagination: JSON-encoded PaginationParams object, or None for no pagination - pagination: JSON-encoded PaginationParams object, or None for no pagination
Examples:
- GET /api/mandates/ (no pagination - returns all items)
- GET /api/mandates/?pagination={"page":1,"pageSize":10,"sort":[]}
""" """
try: try:
# Check admin access
isSysAdmin = context.hasSysAdminRole
if not isSysAdmin:
adminMandateIds = _getAdminMandateIds(context)
if not adminMandateIds:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required"
)
# Parse pagination parameter # Parse pagination parameter
paginationParams = None paginationParams = None
if pagination: if pagination:
@ -108,11 +118,24 @@ def get_mandates(
) )
appInterface = interfaceDbApp.getRootInterface() appInterface = interfaceDbApp.getRootInterface()
result = appInterface.getAllMandates(pagination=paginationParams)
if isSysAdmin:
# SysAdmin: all mandates
result = appInterface.getAllMandates(pagination=paginationParams)
else:
# MandateAdmin: only their mandates
allMandates = []
for mandateId in adminMandateIds:
mandate = appInterface.getMandate(mandateId)
if mandate:
mandateDict = mandate if isinstance(mandate, dict) else mandate.model_dump() if hasattr(mandate, 'model_dump') else vars(mandate)
allMandates.append(mandateDict)
result = allMandates
paginationParams = None # Client-side pagination for filtered results
# If pagination was requested, result is PaginatedResult # If pagination was requested, result is PaginatedResult
# If no pagination, result is List[Mandate] # If no pagination, result is List[Mandate]
if paginationParams: if paginationParams and hasattr(result, 'items'):
return PaginatedResponse( return PaginatedResponse(
items=result.items, items=result.items,
pagination=PaginationMetadata( pagination=PaginationMetadata(
@ -125,8 +148,9 @@ def get_mandates(
) )
) )
else: else:
items = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else result)
return PaginatedResponse( return PaginatedResponse(
items=result, items=items,
pagination=None pagination=None
) )
except HTTPException: except HTTPException:
@ -138,18 +162,32 @@ def get_mandates(
detail=f"Failed to get mandates: {str(e)}" detail=f"Failed to get mandates: {str(e)}"
) )
@router.get("/{mandateId}", response_model=Mandate) @router.get("/{targetMandateId}", response_model=Mandate)
@limiter.limit("30/minute") @limiter.limit("30/minute")
def get_mandate( def get_mandate(
request: Request, request: Request,
mandateId: str = Path(..., description="ID of the mandate"), targetMandateId: str = Path(..., description="ID of the mandate"),
currentUser: User = Depends(requireSysAdmin) context: RequestContext = Depends(getRequestContext)
) -> Mandate: ) -> Mandate:
""" """
Get a specific mandate by ID. Get a specific mandate by ID.
MULTI-TENANT: SysAdmin-only.
Access:
- SysAdmin: any mandate
- MandateAdmin: only mandates where user has admin role
- Other: 403
""" """
try: try:
mandateId = targetMandateId
# Check access
if not context.hasSysAdminRole:
adminMandateIds = _getAdminMandateIds(context)
if mandateId not in adminMandateIds:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required for this mandate"
)
appInterface = interfaceDbApp.getRootInterface() appInterface = interfaceDbApp.getRootInterface()
mandate = appInterface.getMandate(mandateId) mandate = appInterface.getMandate(mandateId)
@ -174,7 +212,7 @@ def get_mandate(
def create_mandate( def create_mandate(
request: Request, request: Request,
mandateData: dict = Body(..., description="Mandate data with at least 'name' field"), mandateData: dict = Body(..., description="Mandate data with at least 'name' field"),
currentUser: User = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdminRole)
) -> Mandate: ) -> Mandate:
""" """
Create a new mandate. Create a new mandate.
@ -228,7 +266,7 @@ def update_mandate(
request: Request, request: Request,
mandateId: str = Path(..., description="ID of the mandate to update"), mandateId: str = Path(..., description="ID of the mandate to update"),
mandateData: dict = Body(..., description="Mandate update data"), mandateData: dict = Body(..., description="Mandate update data"),
currentUser: User = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdminRole)
) -> Mandate: ) -> Mandate:
""" """
Update an existing mandate. Update an existing mandate.
@ -273,7 +311,7 @@ def update_mandate(
def delete_mandate( def delete_mandate(
request: Request, request: Request,
mandateId: str = Path(..., description="ID of the mandate to delete"), mandateId: str = Path(..., description="ID of the mandate to delete"),
currentUser: User = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdminRole)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Delete a mandate. Delete a mandate.
@ -339,7 +377,7 @@ def list_mandate_users(
pagination: Optional pagination parameters (page, pageSize, search, filters, sort) pagination: Optional pagination parameters (page, pageSize, search, filters, sort)
""" """
# Check permission # Check permission
if not _hasMandateAdminRole(context, targetMandateId) and not context.isSysAdmin: if not _hasMandateAdminRole(context, targetMandateId) and not context.hasSysAdminRole:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required" detail="Mandate-Admin role required"
@ -510,7 +548,7 @@ def add_user_to_mandate(
data: User ID and role IDs to assign data: User ID and role IDs to assign
""" """
# 1. SysAdmin Self-Eskalation Prevention # 1. SysAdmin Self-Eskalation Prevention
if context.isSysAdmin and data.targetUserId == str(context.user.id): if context.hasSysAdminRole and data.targetUserId == str(context.user.id):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="SysAdmin cannot add themselves to a mandate. A Mandate-Admin must grant access." detail="SysAdmin cannot add themselves to a mandate. A Mandate-Admin must grant access."
@ -784,35 +822,58 @@ def update_user_roles_in_mandate(
# Helper Functions # Helper Functions
# ============================================================================= # =============================================================================
def _getAdminMandateIds(context: RequestContext) -> List[str]:
"""
Get list of mandate IDs where the user has the admin role.
Returns empty list if user has no admin roles.
"""
mandateIds = []
try:
rootInterface = interfaceDbApp.getRootInterface()
userId = str(context.user.id)
userMandates = rootInterface.getUserMandates(userId)
for um in userMandates:
if not getattr(um, 'enabled', True):
continue
umId = getattr(um, 'id', None)
mandateId = getattr(um, 'mandateId', None)
if not umId or not mandateId:
continue
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
mandateIds.append(str(mandateId))
break
except Exception as e:
logger.error(f"Error getting admin mandate IDs: {e}")
return mandateIds
def _hasMandateAdminRole(context: RequestContext, mandateId: str) -> bool: def _hasMandateAdminRole(context: RequestContext, mandateId: str) -> bool:
""" """
Check if the user has mandate admin role for the specified mandate. Check if the user has mandate admin role for the specified mandate.
Works with or without X-Mandate-Id header (admin pages don't send it).
""" """
if context.isSysAdmin: if context.hasSysAdminRole:
return True return True
# Must be in the same mandate context # If mandate context matches, check roles from context directly
if str(context.mandateId) != str(mandateId): if context.mandateId and str(context.mandateId) == str(mandateId):
if context.roleIds:
try:
rootInterface = interfaceDbApp.getRootInterface()
for roleId in context.roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
return True
except Exception as e:
logger.error(f"Error checking mandate admin role: {e}")
return False return False
if not context.roleIds: # No mandate context (admin pages) — check via user's mandate memberships
return False adminMandateIds = _getAdminMandateIds(context)
return str(mandateId) in adminMandateIds
try:
rootInterface = interfaceDbApp.getRootInterface()
for roleId in context.roleIds:
role = rootInterface.getRole(roleId)
if role:
# Admin role at mandate level (not feature-instance level)
if role.roleLabel == "admin" and role.mandateId and not role.featureInstanceId:
return True
return False
except Exception as e:
logger.error(f"Error checking mandate admin role: {e}")
return False # Fail-safe: no access on error
def _isLastMandateAdmin(interface, mandateId: str, excludeUserId: str) -> bool: def _isLastMandateAdmin(interface, mandateId: str, excludeUserId: str) -> bool:

View file

@ -29,6 +29,46 @@ from modules.datamodels.datamodelPagination import PaginationParams, PaginatedRe
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _isAdminForUser(context: RequestContext, targetUserId: str) -> bool:
"""
Check if the current user has admin rights for the target user.
SysAdmin can manage all users. MandateAdmin can manage users in their mandates.
Works without X-Mandate-Id header (admin pages don't send it).
"""
if context.hasSysAdminRole:
return True
# Find mandates where current user is admin
rootInterface = getRootInterface()
userId = str(context.user.id)
userMandates = rootInterface.getUserMandates(userId)
adminMandateIds = []
for um in userMandates:
if not getattr(um, 'enabled', True):
continue
umId = getattr(um, 'id', None)
mandateId = getattr(um, 'mandateId', None)
if not umId or not mandateId:
continue
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
adminMandateIds.append(str(mandateId))
break
if not adminMandateIds:
return False
# Check if target user is in any of the admin's mandates
targetMandates = rootInterface.getUserMandates(targetUserId)
for tm in targetMandates:
if str(getattr(tm, 'mandateId', '')) in adminMandateIds:
return True
return False
def _applyFiltersAndSort(items: List[Dict[str, Any]], paginationParams: Optional[PaginationParams]) -> List[Dict[str, Any]]: def _applyFiltersAndSort(items: List[Dict[str, Any]], paginationParams: Optional[PaginationParams]) -> List[Dict[str, Any]]:
""" """
Apply filters and sorting to a list of items. Apply filters and sorting to a list of items.
@ -168,7 +208,7 @@ def get_user_options(
if context.mandateId: if context.mandateId:
result = appInterface.getUsersByMandate(str(context.mandateId), None) result = appInterface.getUsersByMandate(str(context.mandateId), None)
users = result.items if hasattr(result, 'items') else result users = result.items if hasattr(result, 'items') else result
elif context.isSysAdmin: elif context.hasSysAdminRole:
users = appInterface.getAllUsers() users = appInterface.getAllUsers()
else: else:
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
@ -222,7 +262,7 @@ def get_users(
detail=f"Invalid pagination parameter: {str(e)}" detail=f"Invalid pagination parameter: {str(e)}"
) )
appInterface = interfaceDbApp.getInterface(context.user) appInterface = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId)
# MULTI-TENANT: Use mandateId from context (header) # MULTI-TENANT: Use mandateId from context (header)
# SysAdmin without mandateId can see all users # SysAdmin without mandateId can see all users
@ -250,7 +290,7 @@ def get_users(
items=users, items=users,
pagination=None pagination=None
) )
elif context.isSysAdmin: elif context.hasSysAdminRole:
# SysAdmin without mandateId sees all users # SysAdmin without mandateId sees all users
# Get all users via interface method (returns Pydantic User models) # Get all users via interface method (returns Pydantic User models)
allUserModels = appInterface.getAllUsers() allUserModels = appInterface.getAllUsers()
@ -288,11 +328,76 @@ def get_users(
pagination=None pagination=None
) )
else: else:
# Non-SysAdmin without mandateId - should not happen (getRequestContext enforces) # Non-SysAdmin without mandateId: aggregate users across all admin mandates
raise HTTPException( rootInterface = getRootInterface()
status_code=status.HTTP_400_BAD_REQUEST, userMandates = rootInterface.getUserMandates(str(context.user.id))
detail="X-Mandate-Id header is required"
) # Find mandates where user has admin role
adminMandateIds = []
for um in userMandates:
umId = getattr(um, 'id', None)
mandateId = getattr(um, 'mandateId', None)
if not umId or not mandateId:
continue
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role and role.roleLabel == "admin" and not role.featureInstanceId:
adminMandateIds.append(str(mandateId))
break
if not adminMandateIds:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="No admin access to any mandate"
)
# Aggregate users across all admin mandates (deduplicate by user ID)
seenUserIds = set()
allUsers = []
for mid in adminMandateIds:
mandateUsers = rootInterface.getUsersByMandate(mid)
if isinstance(mandateUsers, list):
users = mandateUsers
elif hasattr(mandateUsers, 'items'):
users = mandateUsers.items
else:
users = []
for u in users:
uid = u.get("id") if isinstance(u, dict) else getattr(u, "id", None)
if uid and uid not in seenUserIds:
seenUserIds.add(uid)
userData = u if isinstance(u, dict) else u.model_dump() if hasattr(u, 'model_dump') else vars(u)
allUsers.append(userData)
# Apply server-side filtering and sorting
filteredUsers = _applyFiltersAndSort(allUsers, paginationParams)
users = [User(**u) for u in filteredUsers]
if paginationParams:
import math
totalItems = len(users)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
paginatedUsers = users[startIdx:endIdx]
return PaginatedResponse(
items=paginatedUsers,
pagination=PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=totalItems,
totalPages=totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
)
)
else:
return PaginatedResponse(
items=users,
pagination=None
)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@ -325,7 +430,7 @@ def get_user(
) )
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin) # MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin: if context.mandateId and not context.hasSysAdminRole:
userMandate = appInterface.getUserMandate(userId, str(context.mandateId)) userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
if not userMandate: if not userMandate:
raise HTTPException( raise HTTPException(
@ -404,29 +509,31 @@ def update_user(
) -> User: ) -> User:
""" """
Update an existing user. Update an existing user.
MULTI-TENANT: Can only update users in the same mandate (unless SysAdmin). Self-service: Users can update their own profile (language, fullName, etc.).
Admin: MandateAdmin can update users in their mandates. SysAdmin for all.
""" """
appInterface = interfaceDbApp.getInterface(context.user) isSelfUpdate = str(context.user.id) == str(userId)
# Non-self updates require admin permission
if not isSelfUpdate and not _isAdminForUser(context, userId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required to update other users"
)
# Use rootInterface for user lookup/update (avoids RBAC filtering on User table)
rootInterface = getRootInterface()
# Check if the user exists # Check if the user exists
existingUser = appInterface.getUser(userId) existingUser = rootInterface.getUser(userId)
if not existingUser: if not existingUser:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {userId} not found" detail=f"User with ID {userId} not found"
) )
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot update user outside your mandate"
)
# Update user # Update user
updatedUser = appInterface.updateUser(userId, userData) updatedUser = rootInterface.updateUser(userId, userData)
if not updatedUser: if not updatedUser:
raise HTTPException( raise HTTPException(
@ -446,36 +553,19 @@ def reset_user_password(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Reset user password (Admin only). Reset user password (Admin only).
MULTI-TENANT: Can only reset passwords for users in the same mandate (unless SysAdmin). MULTI-TENANT: MandateAdmin can reset passwords for users in their mandates. SysAdmin for all.
""" """
try: try:
# Check if current user is admin # Check admin permission (SysAdmin or MandateAdmin for this user)
if not context.isSysAdmin: if not _isAdminForUser(context, userId):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can reset passwords" detail="Admin role required to reset passwords"
) )
# Get user interface # Get user interface
appInterface = interfaceDbApp.getInterface(context.user) appInterface = interfaceDbApp.getInterface(context.user)
# Get target user
target_user = appInterface.getUser(userId)
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot reset password for user outside your mandate"
)
# Validate password strength # Validate password strength
if len(newPassword) < 8: if len(newPassword) < 8:
raise HTTPException( raise HTTPException(
@ -622,7 +712,7 @@ def send_password_link(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Send password setup/reset link to a user (admin function). Send password setup/reset link to a user (admin function).
MULTI-TENANT: Can only send to users in the same mandate (unless SysAdmin). MULTI-TENANT: MandateAdmin can send to users in their mandates. SysAdmin for all.
This allows admins to send a magic link to users to set or reset their password. This allows admins to send a magic link to users to set or reset their password.
Used when creating users without password or to help users who forgot their password. Used when creating users without password or to help users who forgot their password.
@ -634,6 +724,13 @@ def send_password_link(
try: try:
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
# Check admin permission (SysAdmin or MandateAdmin for this user)
if not _isAdminForUser(context, userId):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required to send password links"
)
# Get user interface # Get user interface
appInterface = interfaceDbApp.getInterface(context.user) appInterface = interfaceDbApp.getInterface(context.user)
@ -645,15 +742,6 @@ def send_password_link(
detail="User not found" detail="User not found"
) )
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin:
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
if not userMandate:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot send password link to user outside your mandate"
)
# Check if user has an email # Check if user has an email
if not targetUser.email: if not targetUser.email:
raise HTTPException( raise HTTPException(
@ -769,7 +857,7 @@ def delete_user(
) )
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin) # MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
if context.mandateId and not context.isSysAdmin: if context.mandateId and not context.hasSysAdminRole:
userMandate = appInterface.getUserMandate(userId, str(context.mandateId)) userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
if not userMandate: if not userMandate:
raise HTTPException( raise HTTPException(

View file

@ -12,6 +12,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Respon
# Import auth modules # Import auth modules
from modules.auth import limiter, getCurrentUser from modules.auth import limiter, getCurrentUser
from modules.auth.authentication import getRequestContext, RequestContext
# Import interfaces from feature containers # Import interfaces from feature containers
import modules.interfaces.interfaceDbChat as interfaceDbChat import modules.interfaces.interfaceDbChat as interfaceDbChat
@ -44,8 +45,11 @@ router = APIRouter(
responses={404: {"description": "Not found"}} responses={404: {"description": "Not found"}}
) )
def getServiceChat(currentUser: User): def getServiceChat(ctx: RequestContext):
return interfaceDbChat.getInterface(currentUser) # Workflows are not feature-instance-specific — only mandateId is needed for RBAC.
# Passing featureInstanceId would add a SQL WHERE filter that excludes workflows
# created by other features (e.g., automation vs chatbot).
return interfaceDbChat.getInterface(ctx.user, mandateId=ctx.mandateId)
# Consolidated endpoint for getting all workflows # Consolidated endpoint for getting all workflows
@router.get("/", response_model=PaginatedResponse[ChatWorkflow]) @router.get("/", response_model=PaginatedResponse[ChatWorkflow])
@ -53,7 +57,7 @@ def getServiceChat(currentUser: User):
def get_workflows( def get_workflows(
request: Request, request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser) ctx: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[ChatWorkflow]: ) -> PaginatedResponse[ChatWorkflow]:
""" """
Get workflows with optional pagination, sorting, and filtering. Get workflows with optional pagination, sorting, and filtering.
@ -81,7 +85,7 @@ def get_workflows(
detail=f"Invalid pagination parameter: {str(e)}" detail=f"Invalid pagination parameter: {str(e)}"
) )
appInterface = getInterface(currentUser) appInterface = getServiceChat(ctx)
result = appInterface.getWorkflows(pagination=paginationParams) result = appInterface.getWorkflows(pagination=paginationParams)
# If pagination was requested, result is PaginatedResult with items as dicts # If pagination was requested, result is PaginatedResult with items as dicts
@ -126,12 +130,12 @@ def get_workflows(
def get_workflow( def get_workflow(
request: Request, request: Request,
workflowId: str = Path(..., description="ID of the workflow"), workflowId: str = Path(..., description="ID of the workflow"),
currentUser: User = Depends(getCurrentUser) ctx: RequestContext = Depends(getRequestContext)
) -> ChatWorkflow: ) -> ChatWorkflow:
"""Get workflow by ID""" """Get workflow by ID"""
try: try:
# Get workflow interface with current user context # Get workflow interface with current user context
workflowInterface = getInterface(currentUser) workflowInterface = getServiceChat(ctx)
# Get workflow # Get workflow
workflow = workflowInterface.getWorkflow(workflowId) workflow = workflowInterface.getWorkflow(workflowId)
@ -156,12 +160,12 @@ def update_workflow(
request: Request, request: Request,
workflowId: str = Path(..., description="ID of the workflow to update"), workflowId: str = Path(..., description="ID of the workflow to update"),
workflowData: Dict[str, Any] = Body(...), workflowData: Dict[str, Any] = Body(...),
currentUser: User = Depends(getCurrentUser) ctx: RequestContext = Depends(getRequestContext)
) -> ChatWorkflow: ) -> ChatWorkflow:
"""Update workflow by ID""" """Update workflow by ID"""
try: try:
# Get workflow interface with current user context # Get workflow interface with current user context
workflowInterface = getInterface(currentUser) workflowInterface = getServiceChat(ctx)
# Get workflow using interface method to check permissions # Get workflow using interface method to check permissions
workflow = workflowInterface.getWorkflow(workflowId) workflow = workflowInterface.getWorkflow(workflowId)
@ -203,12 +207,12 @@ def update_workflow(
def get_workflow_status( def get_workflow_status(
request: Request, request: Request,
workflowId: str = Path(..., description="ID of the workflow"), workflowId: str = Path(..., description="ID of the workflow"),
currentUser: User = Depends(getCurrentUser) ctx: RequestContext = Depends(getRequestContext)
) -> ChatWorkflow: ) -> ChatWorkflow:
"""Get the current status of a workflow.""" """Get the current status of a workflow."""
try: try:
# Get service center # Get service center
interfaceDbChat = getServiceChat(currentUser) interfaceDbChat = getServiceChat(ctx)
# Retrieve workflow # Retrieve workflow
workflow = interfaceDbChat.getWorkflow(workflowId) workflow = interfaceDbChat.getWorkflow(workflowId)
@ -235,7 +239,7 @@ def get_workflow_status(
async def stop_workflow( async def stop_workflow(
request: Request, request: Request,
workflowId: str = Path(..., description="ID of the workflow to stop"), workflowId: str = Path(..., description="ID of the workflow to stop"),
currentUser: User = Depends(getCurrentUser) ctx: RequestContext = Depends(getRequestContext)
) -> ChatWorkflow: ) -> ChatWorkflow:
""" """
Stop a running workflow. Stop a running workflow.
@ -245,7 +249,7 @@ async def stop_workflow(
from modules.workflows.automation import chatStop from modules.workflows.automation import chatStop
# Get the workflow first to get mandateId # Get the workflow first to get mandateId
interfaceChatDb = getServiceChat(currentUser) interfaceChatDb = getServiceChat(ctx)
workflow = interfaceChatDb.getWorkflow(workflowId) workflow = interfaceChatDb.getWorkflow(workflowId)
if not workflow: if not workflow:
@ -257,7 +261,7 @@ async def stop_workflow(
mandateId = workflow.get("mandateId") if isinstance(workflow, dict) else getattr(workflow, "mandateId", None) mandateId = workflow.get("mandateId") if isinstance(workflow, dict) else getattr(workflow, "mandateId", None)
# Stop the workflow # Stop the workflow
stoppedWorkflow = await chatStop(currentUser, workflowId, mandateId=mandateId) stoppedWorkflow = await chatStop(ctx.user, workflowId, mandateId=mandateId)
return stoppedWorkflow return stoppedWorkflow
@ -279,7 +283,7 @@ def get_workflow_logs(
workflowId: str = Path(..., description="ID of the workflow"), workflowId: str = Path(..., description="ID of the workflow"),
logId: Optional[str] = Query(None, description="Optional log ID to get only newer logs (legacy selective data transfer)"), logId: Optional[str] = Query(None, description="Optional log ID to get only newer logs (legacy selective data transfer)"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser) ctx: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[ChatLog]: ) -> PaginatedResponse[ChatLog]:
""" """
Get logs for a workflow with optional pagination, sorting, and filtering. Get logs for a workflow with optional pagination, sorting, and filtering.
@ -306,7 +310,7 @@ def get_workflow_logs(
) )
# Get service center # Get service center
interfaceDbChat = getServiceChat(currentUser) interfaceDbChat = getServiceChat(ctx)
# Verify workflow exists # Verify workflow exists
workflow = interfaceDbChat.getWorkflow(workflowId) workflow = interfaceDbChat.getWorkflow(workflowId)
@ -370,7 +374,7 @@ def get_workflow_messages(
workflowId: str = Path(..., description="ID of the workflow"), workflowId: str = Path(..., description="ID of the workflow"),
messageId: Optional[str] = Query(None, description="Optional message ID to get only newer messages (legacy selective data transfer)"), messageId: Optional[str] = Query(None, description="Optional message ID to get only newer messages (legacy selective data transfer)"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser) ctx: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[ChatMessage]: ) -> PaginatedResponse[ChatMessage]:
""" """
Get messages for a workflow with optional pagination, sorting, and filtering. Get messages for a workflow with optional pagination, sorting, and filtering.
@ -397,7 +401,7 @@ def get_workflow_messages(
) )
# Get service center # Get service center
interfaceDbChat = getServiceChat(currentUser) interfaceDbChat = getServiceChat(ctx)
# Verify workflow exists # Verify workflow exists
workflow = interfaceDbChat.getWorkflow(workflowId) workflow = interfaceDbChat.getWorkflow(workflowId)
@ -460,19 +464,20 @@ def get_workflow_messages(
def delete_workflow( def delete_workflow(
request: Request, request: Request,
workflowId: str = Path(..., description="ID of the workflow to delete"), workflowId: str = Path(..., description="ID of the workflow to delete"),
currentUser: User = Depends(getCurrentUser) ctx: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Deletes a workflow and its associated data.""" """Deletes a workflow and its associated data."""
try: try:
# Get service center # Get service center
interfaceDbChat = getServiceChat(currentUser) interfaceDbChat = getServiceChat(ctx)
# Check workflow access and permission using RBAC # Check workflow access and permission using RBAC
workflows = getRecordsetWithRBAC( workflows = getRecordsetWithRBAC(
interfaceDbChat.db, interfaceDbChat.db,
ChatWorkflow, ChatWorkflow,
currentUser, ctx.user,
recordFilter={"id": workflowId} recordFilter={"id": workflowId},
mandateId=ctx.mandateId
) )
if not workflows: if not workflows:
raise HTTPException( raise HTTPException(
@ -520,12 +525,12 @@ def delete_workflow_message(
request: Request, request: Request,
workflowId: str = Path(..., description="ID of the workflow"), workflowId: str = Path(..., description="ID of the workflow"),
messageId: str = Path(..., description="ID of the message to delete"), messageId: str = Path(..., description="ID of the message to delete"),
currentUser: User = Depends(getCurrentUser) ctx: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Delete a message from a workflow.""" """Delete a message from a workflow."""
try: try:
# Get service center # Get service center
interfaceDbChat = getServiceChat(currentUser) interfaceDbChat = getServiceChat(ctx)
# Verify workflow exists # Verify workflow exists
workflow = interfaceDbChat.getWorkflow(workflowId) workflow = interfaceDbChat.getWorkflow(workflowId)
@ -571,12 +576,12 @@ def delete_file_from_message(
workflowId: str = Path(..., description="ID of the workflow"), workflowId: str = Path(..., description="ID of the workflow"),
messageId: str = Path(..., description="ID of the message"), messageId: str = Path(..., description="ID of the message"),
fileId: str = Path(..., description="ID of the file to delete"), fileId: str = Path(..., description="ID of the file to delete"),
currentUser: User = Depends(getCurrentUser) ctx: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Delete a file reference from a message in a workflow.""" """Delete a file reference from a message in a workflow."""
try: try:
# Get service center # Get service center
interfaceDbChat = getServiceChat(currentUser) interfaceDbChat = getServiceChat(ctx)
# Verify workflow exists # Verify workflow exists
workflow = interfaceDbChat.getWorkflow(workflowId) workflow = interfaceDbChat.getWorkflow(workflowId)

View file

@ -129,7 +129,7 @@ def export_user_data(
"mandateName": mandateName, "mandateName": mandateName,
"enabled": um.enabled, "enabled": um.enabled,
"roleIds": roleIds, "roleIds": roleIds,
"joinedAt": um.createdAt "joinedAt": getattr(um, 'createdAt', None)
}) })
# Feature access records using interface method # Feature access records using interface method
@ -159,11 +159,11 @@ def export_user_data(
invitationsCreatedList = [ invitationsCreatedList = [
{ {
"id": inv.id, "id": inv.id,
"mandateId": inv.mandateId, "mandateId": getattr(inv, 'mandateId', None),
"createdAt": inv.createdAt, "createdAt": getattr(inv, 'createdAt', None),
"expiresAt": inv.expiresAt, "expiresAt": getattr(inv, 'expiresAt', None),
"maxUses": inv.maxUses, "maxUses": getattr(inv, 'maxUses', None),
"currentUses": inv.currentUses "currentUses": getattr(inv, 'currentUses', None)
} }
for inv in invitationsCreated for inv in invitationsCreated
] ]
@ -174,8 +174,8 @@ def export_user_data(
invitationsUsedList = [ invitationsUsedList = [
{ {
"id": inv.id, "id": inv.id,
"mandateId": inv.mandateId, "mandateId": getattr(inv, 'mandateId', None),
"usedAt": inv.usedAt "usedAt": getattr(inv, 'usedAt', None)
} }
for inv in invitationsUsed for inv in invitationsUsed
] ]

View file

@ -36,11 +36,14 @@ router = APIRouter(
# ============================================================================= # =============================================================================
class InvitationCreate(BaseModel): class InvitationCreate(BaseModel):
"""Request model for creating an invitation""" """Request model for creating an invitation.
Invitations are feature-instance-level: the user selects a feature instance and
instance-level roles. The mandateId is derived from the feature instance automatically.
"""
targetUsername: str = Field(..., description="Username of the user to invite (must match on acceptance)") targetUsername: str = Field(..., description="Username of the user to invite (must match on acceptance)")
email: Optional[str] = Field(None, description="Email address to send invitation link (optional)") email: Optional[str] = Field(None, description="Email address to send invitation link (optional)")
roleIds: List[str] = Field(..., description="Role IDs to assign to the invited user") featureInstanceId: str = Field(..., description="Feature instance to grant access to")
featureInstanceId: Optional[str] = Field(None, description="Optional feature instance access") roleIds: List[str] = Field(..., description="Instance-level role IDs to assign to the invited user")
frontendUrl: str = Field(..., description="Frontend URL for building the invite link (provided by frontend)") frontendUrl: str = Field(..., description="Frontend URL for building the invite link (provided by frontend)")
expiresInHours: int = Field( expiresInHours: int = Field(
72, 72,
@ -102,36 +105,46 @@ def create_invitation(
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> InvitationResponse: ) -> InvitationResponse:
""" """
Create a new invitation for the current mandate. Create a new invitation for a feature instance.
Requires Mandate-Admin role. Creates a secure token that can be shared Requires SysAdmin or Mandate-Admin role. Creates a secure token that can be shared
with users to join the mandate with predefined roles. with users to join a feature instance with predefined roles.
The mandateId is derived from the feature instance automatically.
Args: Args:
data: Invitation creation data data: Invitation creation data (featureInstanceId + roleIds required)
""" """
if not context.mandateId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Mandate-Id header is required"
)
# Check mandate admin permission
if not _hasMandateAdminRole(context):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to create invitations"
)
try: try:
rootInterface = getRootInterface() rootInterface = getRootInterface()
# Validate feature instance exists and get mandateId from it
instance = rootInterface.getFeatureInstance(data.featureInstanceId)
if not instance:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature instance '{data.featureInstanceId}' not found"
)
mandateId = str(instance.mandateId)
# Check admin permission: SysAdmin can invite for any mandate,
# MandateAdmin can invite for their own mandate
if not context.hasSysAdminRole:
if str(context.mandateId) != mandateId:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Feature instance belongs to a different mandate"
)
if not _hasMandateAdminRole(context):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Mandate-Admin role required to create invitations"
)
# Note: targetUsername does NOT need to exist yet! # Note: targetUsername does NOT need to exist yet!
# The invitation can be for a user who will register later. # The invitation can be for a user who will register later.
# When they register with this username (or accept the invitation),
# they will get the assigned roles.
# Validate role IDs exist and belong to this mandate or are global # Validate role IDs exist and belong to this feature instance
for roleId in data.roleIds: for roleId in data.roleIds:
role = rootInterface.getRole(roleId) role = rootInterface.getRole(roleId)
if not role: if not role:
@ -139,34 +152,20 @@ def create_invitation(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"Role '{roleId}' not found" detail=f"Role '{roleId}' not found"
) )
# Role must be global or belong to this mandate # Role must belong to this feature instance
if role.mandateId and str(role.mandateId) != str(context.mandateId): if str(role.featureInstanceId or "") != data.featureInstanceId:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Role '{roleId}' belongs to a different mandate" detail=f"Role '{roleId}' does not belong to feature instance '{data.featureInstanceId}'"
)
# Validate feature instance if provided
if data.featureInstanceId:
instance = rootInterface.getFeatureInstance(data.featureInstanceId)
if not instance:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature instance '{data.featureInstanceId}' not found"
)
if str(instance.mandateId) != str(context.mandateId):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Feature instance belongs to a different mandate"
) )
# Calculate expiration time # Calculate expiration time
currentTime = getUtcTimestamp() currentTime = getUtcTimestamp()
expiresAt = currentTime + (data.expiresInHours * 3600) expiresAt = currentTime + (data.expiresInHours * 3600)
# Create invitation # Create invitation (mandateId derived from feature instance)
invitation = Invitation( invitation = Invitation(
mandateId=str(context.mandateId), mandateId=mandateId,
featureInstanceId=data.featureInstanceId, featureInstanceId=data.featureInstanceId,
roleIds=data.roleIds, roleIds=data.roleIds,
targetUsername=data.targetUsername, targetUsername=data.targetUsername,
@ -628,42 +627,50 @@ def accept_invitation(
roleIds = invitation.roleIds or [] roleIds = invitation.roleIds or []
featureInstanceId = str(invitation.featureInstanceId) if invitation.featureInstanceId else None featureInstanceId = str(invitation.featureInstanceId) if invitation.featureInstanceId else None
# Check if user is already a member # Grant feature access (creates FeatureAccess + auto-assigns mandate 'user' role via Regel 4)
existingMembership = rootInterface.getUserMandate(str(currentUser.id), mandateId)
if existingMembership:
# Update existing membership with additional roles
for roleId in roleIds:
try:
rootInterface.addRoleToUserMandate(str(existingMembership.id), roleId)
except Exception:
pass # Role might already be assigned
userMandateId = str(existingMembership.id)
message = "Roles updated for existing membership"
else:
# Create new membership
userMandate = rootInterface.createUserMandate(
userId=str(currentUser.id),
mandateId=mandateId,
roleIds=roleIds
)
userMandateId = str(userMandate.id)
message = "Successfully joined mandate"
# Grant feature access if specified
featureAccessId = None featureAccessId = None
if featureInstanceId: if featureInstanceId:
existingAccess = rootInterface.getFeatureAccess(str(currentUser.id), featureInstanceId) existingAccess = rootInterface.getFeatureAccess(str(currentUser.id), featureInstanceId)
if not existingAccess: if existingAccess:
# Create feature access with instance-level roles if any # Update existing access with additional roles
instanceRoleIds = [r for r in roleIds if _isInstanceRole(rootInterface, r, featureInstanceId)] featureAccessId = str(existingAccess.id)
for roleId in roleIds:
try:
rootInterface.addRoleToFeatureAccess(str(existingAccess.id), roleId)
except Exception:
pass # Role might already be assigned
message = "Roles updated for existing feature access"
else:
# Create feature access with instance-level roles
# This auto-creates UserMandate with 'user' role (Regel 4)
featureAccess = rootInterface.createFeatureAccess( featureAccess = rootInterface.createFeatureAccess(
userId=str(currentUser.id), userId=str(currentUser.id),
featureInstanceId=featureInstanceId, featureInstanceId=featureInstanceId,
roleIds=instanceRoleIds roleIds=roleIds
) )
featureAccessId = str(featureAccess.id) featureAccessId = str(featureAccess.id)
message = "Successfully joined feature instance"
else:
# Legacy: mandate-only invitation (no feature instance)
existingMembership = rootInterface.getUserMandate(str(currentUser.id), mandateId)
if existingMembership:
for roleId in roleIds:
try:
rootInterface.addRoleToUserMandate(str(existingMembership.id), roleId)
except Exception:
pass
message = "Roles updated for existing membership"
else:
rootInterface.createUserMandate(
userId=str(currentUser.id),
mandateId=mandateId,
roleIds=roleIds
)
message = "Successfully joined mandate"
# Get userMandateId for response
userMandate = rootInterface.getUserMandate(str(currentUser.id), mandateId)
userMandateId = str(userMandate.id) if userMandate else None
# Update invitation usage # Update invitation usage
rootInterface.db.recordModify( rootInterface.db.recordModify(
@ -707,7 +714,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
""" """
Check if the user has mandate admin role in the current context. Check if the user has mandate admin role in the current context.
""" """
if context.isSysAdmin: if context.hasSysAdminRole:
return True return True
if not context.roleIds: if not context.roleIds:
@ -720,7 +727,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
role = rootInterface.getRole(roleId) role = rootInterface.getRole(roleId)
if role: if role:
# Admin role at mandate level (not feature-instance level) # Admin role at mandate level (not feature-instance level)
if role.roleLabel == "admin" and role.mandateId and not role.featureInstanceId: if role.roleLabel == "admin" and not role.featureInstanceId:
return True return True
return False return False

View file

@ -410,7 +410,7 @@ def _hasTriggerPermission(context: RequestContext) -> bool:
Check if user has permission to trigger subscriptions. Check if user has permission to trigger subscriptions.
Requires admin or mandate-admin role. Requires admin or mandate-admin role.
""" """
if context.isSysAdmin: if context.hasSysAdminRole:
return True return True
if not context.roleIds: if not context.roleIds:

View file

@ -331,7 +331,7 @@ def executeAction(
# Execute action based on notification type # Execute action based on notification type
actionResult = None actionResult = None
if notification.get("type") == NotificationType.INVITATION.value: if notification.type == NotificationType.INVITATION.value:
actionResult = _handleInvitationAction( actionResult = _handleInvitationAction(
notification=notification, notification=notification,
actionId=actionRequest.actionId, actionId=actionRequest.actionId,
@ -381,7 +381,7 @@ def _handleInvitationAction(
from modules.datamodels.datamodelUam import Mandate from modules.datamodels.datamodelUam import Mandate
from modules.datamodels.datamodelMembership import UserMandate from modules.datamodels.datamodelMembership import UserMandate
invitationId = notification.get("referenceId") invitationId = notification.referenceId
if not invitationId: if not invitationId:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,

View file

@ -107,7 +107,7 @@ async def _validateInstanceAccess(instanceId: str, context: RequestContext) -> s
status_code=400, status_code=400,
detail=f"Instance '{instanceId}' is not a realestate instance" detail=f"Instance '{instanceId}' is not a realestate instance"
) )
if not context.isSysAdmin: if not context.hasSysAdminRole:
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id)) featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
hasAccess = any( hasAccess = any(
str(fa.featureInstanceId) == instanceId and fa.enabled str(fa.featureInstanceId) == instanceId and fa.enabled

View file

@ -75,7 +75,7 @@ def _checkUiPermission(roleIds: List[str], objectKey: str) -> bool:
if rule.item == objectKey: if rule.item == objectKey:
return True return True
# Wildcard match (e.g., ui.system.* matches ui.system.playground) # Wildcard match (e.g., ui.admin.* matches ui.admin.mandates)
if rule.item.endswith(".*"): if rule.item.endswith(".*"):
prefix = rule.item[:-2] prefix = rule.item[:-2]
if objectKey.startswith(prefix): if objectKey.startswith(prefix):
@ -347,28 +347,21 @@ def _buildStaticBlocks(
blocks = [] blocks = []
for section in NAVIGATION_SECTIONS: for section in NAVIGATION_SECTIONS:
# Skip admin-only sections for non-admins # Filter items based on UI AccessRules (ui.admin.*, ui.billing.*, etc.)
if section.get("adminOnly") and not isSysAdmin:
continue
# Filter items based on permissions
filteredItems = [] filteredItems = []
for item in section.get("items", []): for item in section.get("items", []):
# Skip admin-only items for non-admins
if item.get("adminOnly") and not isSysAdmin:
continue
# Public items are always visible # Public items are always visible
if item.get("public"): if item.get("public"):
filteredItems.append(_formatBlockItem(item, language)) filteredItems.append(_formatBlockItem(item, language))
continue continue
# SysAdmin sees everything # SysAdmin-only items (e.g. automation-events) require isSysAdmin
if isSysAdmin: if item.get("sysAdminOnly") and not isSysAdmin:
filteredItems.append(_formatBlockItem(item, language))
continue continue
# Check permission for this item # Check UI AccessRule for this objectKey
# Roles with item=None rule (e.g. sysadmin) get access to everything
# Roles with specific ui.admin.* rules get access to those items
if hasGlobalPermission or _checkUiPermission(roleIds, item["objectKey"]): if hasGlobalPermission or _checkUiPermission(roleIds, item["objectKey"]):
filteredItems.append(_formatBlockItem(item, language)) filteredItems.append(_formatBlockItem(item, language))
@ -459,7 +452,7 @@ def get_navigation(
} }
""" """
try: try:
isSysAdmin = reqContext.isSysAdmin isSysAdmin = reqContext.hasSysAdminRole
userId = str(reqContext.user.id) if reqContext.user else None userId = str(reqContext.user.id) if reqContext.user else None
# Get user's role IDs for permission checking # Get user's role IDs for permission checking

View file

@ -82,8 +82,23 @@ class RbacClass:
delete=AccessLevel.NONE delete=AccessLevel.NONE
) )
# SysAdmin hat vollen Zugriff - unabhängig vom Kontext (Mandant/Feature) # Category A: isSysAdmin FLAG bypass (safety net for system operations)
# NOTE: sysadmin ROLE users get full access via AccessRules (DATA: ALL)
# This flag bypass is kept as fallback for true system-level operations
if hasattr(user, 'isSysAdmin') and user.isSysAdmin: if hasattr(user, 'isSysAdmin') and user.isSysAdmin:
# User-owned namespaces: SysAdmin gets MY access only (own data).
# Every user -- including SysAdmin -- only has CRUD for their own
# chat workflows and files. Automation is excluded because it's
# managed by admins and the system event user needs ALL access.
_USER_OWNED_PREFIXES = ("data.chat.", "data.files.")
if item and any(item.startswith(p) for p in _USER_OWNED_PREFIXES):
return UserPermissions(
view=True,
read=AccessLevel.MY,
create=AccessLevel.MY,
update=AccessLevel.MY,
delete=AccessLevel.MY
)
return UserPermissions( return UserPermissions(
view=True, view=True,
read=AccessLevel.ALL, read=AccessLevel.ALL,
@ -178,8 +193,8 @@ class RbacClass:
roleIds = set() # Use set to avoid duplicates roleIds = set() # Use set to avoid duplicates
try: try:
# Load roles from the requested mandate
if mandateId: if mandateId:
# Specific mandate context: load roles from that mandate only
userMandateRecords = self.dbApp.getRecordset( userMandateRecords = self.dbApp.getRecordset(
UserMandate, UserMandate,
recordFilter={"userId": user.id, "mandateId": mandateId, "enabled": True} recordFilter={"userId": user.id, "mandateId": mandateId, "enabled": True}
@ -196,9 +211,27 @@ class RbacClass:
foundRoles = [r["roleId"] for r in userMandateRoleRecords if r.get("roleId")] foundRoles = [r["roleId"] for r in userMandateRoleRecords if r.get("roleId")]
roleIds.update(foundRoles) roleIds.update(foundRoles)
else:
# No mandate context: load roles from ALL user's mandates.
# Required for user-owned namespaces (files, chat, automation) that
# are accessed without mandate context (e.g., /api/files/ endpoints).
# Data isolation is still enforced by _createdBy WHERE clause.
allUserMandates = self.dbApp.getRecordset(
UserMandate,
recordFilter={"userId": user.id, "enabled": True}
)
for um in allUserMandates:
userMandateId = um["id"]
userMandateRoleRecords = self.dbApp.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId}
)
roleIds.update([r["roleId"] for r in userMandateRoleRecords if r.get("roleId")])
# Load FeatureAccess + FeatureAccessRole (Instance-level roles) # Load FeatureAccess + FeatureAccessRole (Instance-level roles)
if featureInstanceId: if featureInstanceId:
# Specific feature instance: load roles from that instance only
featureAccessRecords = self.dbApp.getRecordset( featureAccessRecords = self.dbApp.getRecordset(
FeatureAccess, FeatureAccess,
recordFilter={ recordFilter={
@ -217,6 +250,21 @@ class RbacClass:
) )
roleIds.update([r["roleId"] for r in featureAccessRoleRecords if r.get("roleId")]) roleIds.update([r["roleId"] for r in featureAccessRoleRecords if r.get("roleId")])
elif not mandateId:
# No context at all: also load feature-instance roles from ALL user's accesses.
# Same rationale: user-owned data needs roles for permission resolution.
allFeatureAccess = self.dbApp.getRecordset(
FeatureAccess,
recordFilter={"userId": user.id, "enabled": True}
)
for fa in allFeatureAccess:
featureAccessId = fa["id"]
featureAccessRoleRecords = self.dbApp.getRecordset(
FeatureAccessRole,
recordFilter={"featureAccessId": featureAccessId}
)
roleIds.update([r["roleId"] for r in featureAccessRoleRecords if r.get("roleId")])
except Exception as e: except Exception as e:
logger.error(f"Error loading role IDs for user {user.id}: {e}") logger.error(f"Error loading role IDs for user {user.id}: {e}")

View file

@ -64,9 +64,7 @@ class ExtractionService:
results: List[ContentExtracted] = [] results: List[ContentExtracted] = []
# Lazy import to avoid circular deps and heavy init at module import dbInterface = self.services.interfaceDbComponent
from modules.interfaces.interfaceDbManagement import getInterface
dbInterface = getInterface()
totalDocs = len(documents) totalDocs = len(documents)

View file

@ -155,14 +155,14 @@ class UtilsService:
# Silent fail to never break main flow # Silent fail to never break main flow
pass pass
def storeDebugMessageAndDocuments(self, message, currentUser): def storeDebugMessageAndDocuments(self, message, currentUser, mandateId=None, featureInstanceId=None):
""" """
Wrapper to store debug messages and documents via interfaceDbChat. Wrapper to store debug messages and documents via interfaceDbChat.
Mirrors storeDebugMessageAndDocuments() in modules.interfaces.interfaceDbChat. Mirrors storeDebugMessageAndDocuments() in modules.interfaces.interfaceDbChat.
""" """
try: try:
from modules.interfaces.interfaceDbChat import storeDebugMessageAndDocuments as _storeDebugMessageAndDocuments from modules.interfaces.interfaceDbChat import storeDebugMessageAndDocuments as _storeDebugMessageAndDocuments
_storeDebugMessageAndDocuments(message, currentUser) _storeDebugMessageAndDocuments(message, currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
except Exception: except Exception:
# Silent fail to never break main flow # Silent fail to never break main flow
pass pass

View file

@ -196,11 +196,12 @@ NAVIGATION_SECTIONS = [
{ {
"id": "admin-feature-roles", "id": "admin-feature-roles",
"objectKey": "ui.admin.featureRoles", "objectKey": "ui.admin.featureRoles",
"label": {"en": "Feature Roles & Permissions", "de": "Features Rollen & Rechte", "fr": "Rôles et droits des features"}, "label": {"en": "Feature Role Templates", "de": "Features Rollen-Vorlagen", "fr": "Modèles de rôles features"},
"icon": "FaShieldAlt", "icon": "FaShieldAlt",
"path": "/admin/feature-roles", "path": "/admin/feature-roles",
"order": 50, "order": 50,
"adminOnly": True, "adminOnly": True,
"sysAdminOnly": True,
}, },
{ {
"id": "admin-billing", "id": "admin-billing",
@ -210,6 +211,17 @@ NAVIGATION_SECTIONS = [
"path": "/admin/billing", "path": "/admin/billing",
"order": 60, "order": 60,
"adminOnly": True, "adminOnly": True,
"sysAdminOnly": True,
},
{
"id": "admin-automation-events",
"objectKey": "ui.admin.automationEvents",
"label": {"en": "Automation Events", "de": "Automation Events", "fr": "Événements d'automatisation"},
"icon": "FaClock",
"path": "/admin/automation-events",
"order": 65,
"adminOnly": True,
"sysAdminOnly": True,
}, },
], ],
}, },

View file

@ -158,10 +158,12 @@ async def executeAutomation(automationId: str, automation, creatorUser: User, se
executionLog["messages"].append(f"Workflow {workflow.id} started successfully") executionLog["messages"].append(f"Workflow {workflow.id} started successfully")
logger.info(f"Started workflow {workflow.id} with plan containing {len(plan.get('tasks', []))} tasks (plan embedded in userInput)") logger.info(f"Started workflow {workflow.id} with plan containing {len(plan.get('tasks', []))} tasks (plan embedded in userInput)")
# Set workflow name with "automated" prefix # Set workflow name with "automated" prefix — use creatorUser's Services
# (services parameter is eventServices with eventUser context, must use creatorUser context)
creatorServices = getServices(creatorUser, mandateId=automationMandateId, featureInstanceId=automationFeatureInstanceId)
automationLabel = automation.label or "Unknown Automation" automationLabel = automation.label or "Unknown Automation"
workflowName = f"automated: {automationLabel}" workflowName = f"automated: {automationLabel}"
services.interfaceDbChat.updateWorkflow(workflow.id, {"name": workflowName}) creatorServices.interfaceDbChat.updateWorkflow(workflow.id, {"name": workflowName})
logger.info(f"Set workflow {workflow.id} name to: {workflowName}") logger.info(f"Set workflow {workflow.id} name to: {workflowName}")
# Save execution log (bypasses RBAC — system operation, not a user edit) # Save execution log (bypasses RBAC — system operation, not a user edit)