fixed rbac issues and sysadmin integration
This commit is contained in:
parent
eb33b3dd38
commit
7051a6e35f
38 changed files with 2170 additions and 576 deletions
|
|
@ -19,6 +19,7 @@ from .authentication import (
|
|||
RequestContext,
|
||||
getRequestContext,
|
||||
requireSysAdmin,
|
||||
requireSysAdminRole,
|
||||
)
|
||||
from .jwtService import (
|
||||
createAccessToken,
|
||||
|
|
@ -44,6 +45,7 @@ __all__ = [
|
|||
"RequestContext",
|
||||
"getRequestContext",
|
||||
"requireSysAdmin",
|
||||
"requireSysAdminRole",
|
||||
# JWT Service
|
||||
"createAccessToken",
|
||||
"createRefreshToken",
|
||||
|
|
|
|||
|
|
@ -236,6 +236,7 @@ class RequestContext:
|
|||
|
||||
# Request-scoped cache: rules loaded only once per request
|
||||
self._cachedRules: Optional[List[tuple]] = None
|
||||
self._cachedHasSysAdminRole: Optional[bool] = None
|
||||
|
||||
def getRules(self) -> List[tuple]:
|
||||
"""
|
||||
|
|
@ -262,8 +263,17 @@ class RequestContext:
|
|||
|
||||
@property
|
||||
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)
|
||||
|
||||
@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(
|
||||
|
|
@ -278,9 +288,9 @@ def getRequestContext(
|
|||
|
||||
Security Model:
|
||||
- Regular users: Must be explicit members of mandates/feature instances
|
||||
- SysAdmin users: Can access ANY mandate for administrative operations,
|
||||
but don't get implicit roleIds (no automatic data access rights).
|
||||
Routes can check ctx.isSysAdmin to allow admin operations.
|
||||
- SysAdmin users: Can access ANY mandate for administrative operations.
|
||||
Root mandate roles (incl. sysadmin role) are loaded for RBAC-based authorization.
|
||||
Routes use ctx.hasSysAdminRole for admin checks (not ctx.isSysAdmin flag).
|
||||
|
||||
Args:
|
||||
request: FastAPI Request object
|
||||
|
|
@ -315,10 +325,10 @@ def getRequestContext(
|
|||
ctx.roleIds = rootInterface.getRoleIdsForUserMandate(membership.id)
|
||||
elif isSysAdmin:
|
||||
# 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
|
||||
# roleIds stays empty - SysAdmin must rely on isSysAdmin flag for authorization
|
||||
logger.debug(f"SysAdmin {currentUser.id} accessing mandate {mandateId} without membership")
|
||||
ctx.roleIds = _getRootMandateRoleIds(rootInterface, str(currentUser.id))
|
||||
logger.debug(f"SysAdmin {currentUser.id} accessing mandate {mandateId} with root mandate roles")
|
||||
else:
|
||||
# Regular user without membership - denied
|
||||
logger.warning(f"User {currentUser.id} is not member of mandate {mandateId}")
|
||||
|
|
@ -344,7 +354,10 @@ def getRequestContext(
|
|||
elif isSysAdmin:
|
||||
# SysAdmin can access any feature instance for admin operations
|
||||
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:
|
||||
# Regular user without access - denied
|
||||
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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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
|
||||
|
||||
|
|
|
|||
|
|
@ -49,7 +49,11 @@ registerModelLabels(
|
|||
|
||||
|
||||
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(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Primary key",
|
||||
|
|
@ -68,6 +72,16 @@ class AutomationTemplate(BaseModel):
|
|||
description="JSON workflow structure with {{KEY:...}} placeholders",
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -79,5 +93,7 @@ registerModelLabels(
|
|||
"label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"},
|
||||
"overview": {"en": "Overview", "ge": "Übersicht", "fr": "Aperçu"},
|
||||
"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é"},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -418,6 +418,19 @@ class AutomationObjects:
|
|||
if not self.checkRbacPermission(AutomationDefinition, "update", 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
|
||||
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]:
|
||||
"""
|
||||
Returns automation templates filtered by RBAC (MY = own templates).
|
||||
Supports optional pagination, sorting, and filtering.
|
||||
Returns automation templates: system templates + instance templates for current instance.
|
||||
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
|
||||
filteredTemplates = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
# 1. System templates — always visible to all users
|
||||
systemTemplates = self.db.getRecordset(
|
||||
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
|
||||
self._enrichTemplatesWithUserName(filteredTemplates)
|
||||
|
||||
|
|
@ -562,35 +589,56 @@ class AutomationObjects:
|
|||
logger.warning(f"Could not enrich templates with user names: {e}")
|
||||
|
||||
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:
|
||||
# Templates are global — no mandateId/featureInstanceId filter
|
||||
filtered = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
records = self.db.getRecordset(
|
||||
AutomationTemplate,
|
||||
self.currentUser,
|
||||
recordFilter={"id": templateId}
|
||||
)
|
||||
|
||||
if not filtered:
|
||||
if not records:
|
||||
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])
|
||||
return template
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting automation template: {str(e)}")
|
||||
return None
|
||||
|
||||
def createAutomationTemplate(self, templateData: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Creates a new automation template."""
|
||||
def createAutomationTemplate(self, templateData: Dict[str, Any], isSysAdmin: bool = False) -> Dict[str, Any]:
|
||||
"""Creates a new automation template.
|
||||
|
||||
System templates (isSystem=True) can only be created by SysAdmin.
|
||||
Instance templates get featureInstanceId from context.
|
||||
"""
|
||||
try:
|
||||
# Ensure ID is present
|
||||
if "id" not in templateData or not templateData["id"]:
|
||||
templateData["id"] = str(uuid.uuid4())
|
||||
|
||||
# RBAC check
|
||||
if not self.checkRbacPermission(AutomationTemplate, "create"):
|
||||
# System template protection
|
||||
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")
|
||||
|
||||
# Ensure database connector has correct userId context
|
||||
|
|
@ -606,7 +654,6 @@ class AutomationObjects:
|
|||
templateData["template"] = json.dumps(templateData["template"])
|
||||
|
||||
# Validate through Pydantic model to ensure proper type conversion
|
||||
# This converts dict fields like TextMultilingual to proper Pydantic objects
|
||||
validatedTemplate = AutomationTemplate(**templateData)
|
||||
|
||||
# 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)}")
|
||||
raise
|
||||
|
||||
def updateAutomationTemplate(self, templateId: str, templateData: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Updates an automation template."""
|
||||
def updateAutomationTemplate(self, templateId: str, templateData: Dict[str, Any], isSysAdmin: bool = False) -> Dict[str, Any]:
|
||||
"""Updates an automation template.
|
||||
|
||||
System templates can only be updated by SysAdmin.
|
||||
"""
|
||||
try:
|
||||
# Check access
|
||||
existing = self.getAutomationTemplate(templateId)
|
||||
if not existing:
|
||||
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}")
|
||||
|
||||
# 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)
|
||||
if "template" in templateData and isinstance(templateData["template"], dict):
|
||||
import json
|
||||
|
|
@ -648,15 +706,22 @@ class AutomationObjects:
|
|||
logger.error(f"Error updating automation template: {str(e)}")
|
||||
raise
|
||||
|
||||
def deleteAutomationTemplate(self, templateId: str) -> bool:
|
||||
"""Deletes an automation template."""
|
||||
def deleteAutomationTemplate(self, templateId: str, isSysAdmin: bool = False) -> bool:
|
||||
"""Deletes an automation template.
|
||||
|
||||
System templates can only be deleted by SysAdmin.
|
||||
"""
|
||||
try:
|
||||
# Check access using RBAC
|
||||
# Check access
|
||||
existing = self.getAutomationTemplate(templateId)
|
||||
if not existing:
|
||||
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}")
|
||||
|
||||
# Delete template from database
|
||||
|
|
@ -666,6 +731,94 @@ class AutomationObjects:
|
|||
except Exception as e:
|
||||
logger.error(f"Error deleting automation template: {str(e)}")
|
||||
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):
|
||||
"""Notify registered callbacks about automation changes (decoupled from features).
|
||||
|
|
|
|||
|
|
@ -165,6 +165,9 @@ def registerFeature(catalogService) -> bool:
|
|||
# Sync template roles to database
|
||||
_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")
|
||||
return True
|
||||
|
||||
|
|
@ -290,3 +293,41 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di
|
|||
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
|
||||
|
||||
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}")
|
||||
|
|
|
|||
|
|
@ -530,7 +530,7 @@ def create_db_template(
|
|||
mandateId=str(context.mandateId) if context.mandateId 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)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -562,7 +562,7 @@ def update_db_template(
|
|||
mandateId=str(context.mandateId) if context.mandateId 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)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -593,7 +593,7 @@ def delete_db_template(
|
|||
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||
)
|
||||
success = chatInterface.deleteAutomationTemplate(templateId)
|
||||
success = chatInterface.deleteAutomationTemplate(templateId, isSysAdmin=context.hasSysAdminRole)
|
||||
if success:
|
||||
return Response(status_code=204)
|
||||
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)}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
|
|||
_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.
|
||||
Structure: {log_dir}/debug/messages/m_round_task_action_timestamp/documentlist_label/
|
||||
|
|
@ -53,6 +53,8 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
|
|||
Args:
|
||||
message: ChatMessage object to store
|
||||
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:
|
||||
import os
|
||||
|
|
@ -152,7 +154,7 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
|
|||
|
||||
# Also store the actual file bytes next to metadata for debugging
|
||||
try:
|
||||
componentInterface = getInterface(currentUser)
|
||||
componentInterface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
|
||||
file_bytes = componentInterface.getFileData(doc.fileId)
|
||||
if file_bytes:
|
||||
# Build a safe filename preserving original name
|
||||
|
|
@ -1134,7 +1136,7 @@ class ChatObjects:
|
|||
logger.debug(f"Could not emit message event: {e}")
|
||||
|
||||
# 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
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
|
|||
)
|
||||
|
||||
# Verify user has access to this instance
|
||||
if not context.isSysAdmin:
|
||||
if not context.hasSysAdminRole:
|
||||
# Check if user has FeatureAccess for this instance
|
||||
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
||||
hasAccess = any(
|
||||
|
|
|
|||
|
|
@ -90,7 +90,8 @@ class InterfaceFeatureNeutralizer:
|
|||
self.db,
|
||||
DataNeutraliserConfig,
|
||||
self.currentUser,
|
||||
recordFilter={"mandateId": self.mandateId}
|
||||
recordFilter={"mandateId": self.mandateId},
|
||||
mandateId=self.mandateId
|
||||
)
|
||||
|
||||
if not filteredConfigs:
|
||||
|
|
@ -153,7 +154,8 @@ class InterfaceFeatureNeutralizer:
|
|||
self.db,
|
||||
DataNeutralizerAttributes,
|
||||
self.currentUser,
|
||||
recordFilter=filterDict
|
||||
recordFilter=filterDict,
|
||||
mandateId=self.mandateId
|
||||
)
|
||||
|
||||
# Filter out database-specific fields
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
|
|||
status_code=400,
|
||||
detail=f"Instance '{instanceId}' is not a realestate instance"
|
||||
)
|
||||
if not context.isSysAdmin:
|
||||
if not context.hasSysAdminRole:
|
||||
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
||||
hasAccess = any(
|
||||
str(fa.featureInstanceId) == instanceId and fa.enabled
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
|
|||
)
|
||||
|
||||
# Verify user has access to this instance
|
||||
if not context.isSysAdmin:
|
||||
if not context.hasSysAdminRole:
|
||||
# Check if user has FeatureAccess for this instance
|
||||
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
||||
hasAccess = any(
|
||||
|
|
@ -1319,27 +1319,29 @@ def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> str:
|
|||
Validate that the user has admin access to the feature instance.
|
||||
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)
|
||||
|
||||
# SysAdmin always has access
|
||||
if context.user.isSysAdmin:
|
||||
# SysAdmin role always has access
|
||||
if context.hasSysAdminRole:
|
||||
return mandateId
|
||||
|
||||
# Check for instance-roles.manage resource permission
|
||||
featureInterface = getFeatureInterface()
|
||||
permissions = featureInterface.getUserPermissionsForInstance(context.user.id, instanceId)
|
||||
# Check for instance-roles.manage resource permission via AccessRules
|
||||
rootInterface = getRootInterface()
|
||||
hasAdminPermission = False
|
||||
|
||||
if not permissions:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Keine Berechtigung zur Rollenverwaltung"
|
||||
for roleId in context.roleIds:
|
||||
rules = rootInterface.db.getRecordset(
|
||||
AccessRule,
|
||||
{"roleId": roleId, "context": AccessRuleContext.RESOURCE.value, "item": "resource.trustee.instance-roles.manage"}
|
||||
)
|
||||
if rules:
|
||||
hasAdminPermission = True
|
||||
break
|
||||
|
||||
# Check for resource permission
|
||||
resourcePermissions = permissions.get("resources", {})
|
||||
if not resourcePermissions.get("instance-roles.manage"):
|
||||
if not hasAdminPermission:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Keine Berechtigung zur Rollenverwaltung"
|
||||
|
|
|
|||
|
|
@ -67,6 +67,14 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
|||
# This also serves as migration for existing mandates that don't have instance roles yet
|
||||
_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
|
||||
adminUserId = initAdminUser(db, mandateId)
|
||||
|
||||
|
|
@ -392,9 +400,9 @@ def initRoles(db: DatabaseConnector) -> None:
|
|||
Initialize standard roles if they don't exist.
|
||||
Roles are created as GLOBAL (mandateId=None) template roles.
|
||||
|
||||
NOTE: SysAdmin is NOT a role - it's a flag (User.isSysAdmin).
|
||||
SysAdmin users bypass RBAC entirely and have full system access.
|
||||
These template roles are for mandate/feature-level access control.
|
||||
NOTE: The "sysadmin" role is NOT a template - it's created separately in
|
||||
_initSysAdminRole() as a root-mandate-specific role (isSystemRole=False).
|
||||
These template roles (admin/user/viewer) are for mandate/feature-level access control.
|
||||
|
||||
Args:
|
||||
db: Database connector instance
|
||||
|
|
@ -404,7 +412,7 @@ def initRoles(db: DatabaseConnector) -> None:
|
|||
_roleIdCache = {}
|
||||
|
||||
# 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 = [
|
||||
Role(
|
||||
roleLabel="admin",
|
||||
|
|
@ -501,14 +509,13 @@ def _deduplicateRoles(db: DatabaseConnector) -> None:
|
|||
fixedMandateCount += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fix mandate role {role.get('id')}: {e}")
|
||||
# Template roles (mandateId=None, standard labels) MUST be isSystemRole=True
|
||||
if role.get("mandateId") is None and role.get("isSystemRole") is not True:
|
||||
if role.get("roleLabel") in ("admin", "user", "viewer"):
|
||||
try:
|
||||
db.recordModify(Role, role.get("id"), {"isSystemRole": True})
|
||||
fixedTemplateCount += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fix template role {role.get('id')}: {e}")
|
||||
# Template roles (mandateId=None, no featureCode) MUST be isSystemRole=True
|
||||
if role.get("mandateId") is None and role.get("featureCode") is None and role.get("isSystemRole") is not True:
|
||||
try:
|
||||
db.recordModify(Role, role.get("id"), {"isSystemRole": True})
|
||||
fixedTemplateCount += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fix template role {role.get('id')}: {e}")
|
||||
if fixedMandateCount > 0:
|
||||
logger.info(f"Fixed {fixedMandateCount} mandate-level roles: isSystemRole → False")
|
||||
if fixedTemplateCount > 0:
|
||||
|
|
@ -623,6 +630,151 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
|
|||
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]:
|
||||
"""
|
||||
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).
|
||||
Uses roleId instead of roleLabel.
|
||||
|
||||
NOTE: No rules for "sysadmin" - SysAdmin is a flag (User.isSysAdmin), not a role!
|
||||
SysAdmin users bypass RBAC entirely via the isSysAdmin check in getRecordsetWithRBAC().
|
||||
NOTE: Sysadmin role rules are created separately in _initSysAdminRole().
|
||||
These default rules cover admin/user/viewer template roles.
|
||||
|
||||
Args:
|
||||
db: Database connector instance
|
||||
|
|
@ -710,19 +862,8 @@ def _createDefaultRoleRules(db: DatabaseConnector) -> None:
|
|||
delete=AccessLevel.NONE,
|
||||
))
|
||||
|
||||
# User Role - My records only
|
||||
userId = _getRoleId(db, "user")
|
||||
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,
|
||||
))
|
||||
# User Role - No access rights (mandate membership marker only)
|
||||
# Users get their actual permissions from feature-instance-level roles
|
||||
|
||||
# Viewer Role - Read-only group access
|
||||
viewerId = _getRoleId(db, "viewer")
|
||||
|
|
@ -750,15 +891,15 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
|
|||
These rules override generic rules for specific tables.
|
||||
Uses roleId instead of roleLabel.
|
||||
|
||||
NOTE: No rules for "sysadmin" - SysAdmin is a flag (User.isSysAdmin), not a role!
|
||||
SysAdmin users bypass RBAC entirely via the isSysAdmin check in getRecordsetWithRBAC().
|
||||
NOTE: Sysadmin role rules are created separately in _initSysAdminRole().
|
||||
These table-specific rules cover admin/user/viewer template roles.
|
||||
|
||||
Args:
|
||||
db: Database connector instance
|
||||
"""
|
||||
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")
|
||||
userId = _getRoleId(db, "user")
|
||||
viewerId = _getRoleId(db, "viewer")
|
||||
|
|
@ -1276,15 +1417,50 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
|
|||
Ensure UI context rules exist for all navigation items.
|
||||
This is called during bootstrap to add missing UI rules for new navigation items.
|
||||
|
||||
Creates rules for BOTH template roles AND mandate-instance roles.
|
||||
This ensures new navigation items are visible even for mandates created before
|
||||
the navigation item was added.
|
||||
|
||||
Args:
|
||||
db: Database connector instance
|
||||
"""
|
||||
from modules.system.mainSystem import NAVIGATION_SECTIONS
|
||||
|
||||
# Template role IDs
|
||||
adminId = _getRoleId(db, "admin")
|
||||
userId = _getRoleId(db, "user")
|
||||
viewerId = _getRoleId(db, "viewer")
|
||||
|
||||
# Mandate-instance role IDs (same roleLabel, but mandateId set, featureInstanceId=None)
|
||||
mandateAdminRoleIds = []
|
||||
mandateUserRoleIds = []
|
||||
mandateViewerRoleIds = []
|
||||
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
|
||||
existingUiRules = db.getRecordset(
|
||||
AccessRule,
|
||||
|
|
@ -1312,19 +1488,20 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
|
|||
isAdminOnly = item.get("adminOnly", False) or isAdminSection
|
||||
|
||||
if isAdminOnly:
|
||||
# Admin-only: only admin role
|
||||
if adminId and (adminId, objectKey) not in existingCombinations:
|
||||
missingRules.append(AccessRule(
|
||||
roleId=adminId,
|
||||
context=AccessRuleContext.UI,
|
||||
item=objectKey,
|
||||
view=True,
|
||||
read=None, create=None, update=None, delete=None,
|
||||
))
|
||||
# Admin-only: all admin roles (template + mandate-instance)
|
||||
for roleId in allAdminRoleIds:
|
||||
if (roleId, objectKey) not in existingCombinations:
|
||||
missingRules.append(AccessRule(
|
||||
roleId=roleId,
|
||||
context=AccessRuleContext.UI,
|
||||
item=objectKey,
|
||||
view=True,
|
||||
read=None, create=None, update=None, delete=None,
|
||||
))
|
||||
else:
|
||||
# Public/normal: all roles
|
||||
for roleId in [adminId, userId, viewerId]:
|
||||
if roleId and (roleId, objectKey) not in existingCombinations:
|
||||
# Public/normal: all roles (template + mandate-instance)
|
||||
for roleId in allAdminRoleIds + allUserRoleIds + allViewerRoleIds:
|
||||
if (roleId, objectKey) not in existingCombinations:
|
||||
missingRules.append(AccessRule(
|
||||
roleId=roleId,
|
||||
context=AccessRuleContext.UI,
|
||||
|
|
@ -1337,7 +1514,7 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
|
|||
if missingRules:
|
||||
for rule in missingRules:
|
||||
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)
|
||||
|
||||
|
||||
|
|
@ -1812,8 +1989,8 @@ def assignInitialUserMemberships(
|
|||
Assign initial memberships to admin and event users via UserMandate + UserMandateRole.
|
||||
This is the NEW multi-tenant way of assigning roles.
|
||||
|
||||
NOTE: SysAdmin is a flag (User.isSysAdmin), not a role. Initial users get the "admin" role
|
||||
within the root mandate, plus they have isSysAdmin=True for system-level access.
|
||||
Hybrid model: Initial users get BOTH the isSysAdmin flag (for system ops)
|
||||
AND the "admin" + "sysadmin" roles in the root mandate (for RBAC-based admin ops).
|
||||
|
||||
Args:
|
||||
db: Database connector instance
|
||||
|
|
@ -1821,16 +1998,24 @@ def assignInitialUserMemberships(
|
|||
adminUserId: Admin user ID
|
||||
eventUserId: Event user ID
|
||||
"""
|
||||
# Use mandate-instance "admin" role (not the global template)
|
||||
mandateAdminRoles = db.getRecordset(
|
||||
# Find the highest-privilege mandate-level role (prefer "admin", fallback to first available)
|
||||
mandateRoles = db.getRecordset(
|
||||
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:
|
||||
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
|
||||
|
||||
# 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")]:
|
||||
# Check if UserMandate already exists
|
||||
existingMemberships = db.getRecordset(
|
||||
|
|
@ -1851,7 +2036,7 @@ def assignInitialUserMemberships(
|
|||
userMandateId = createdMembership.get("id")
|
||||
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(
|
||||
UserMandateRole,
|
||||
recordFilter={"userMandateId": userMandateId, "roleId": adminRoleId}
|
||||
|
|
@ -1865,6 +2050,20 @@ def assignInitialUserMemberships(
|
|||
)
|
||||
db.recordCreate(UserMandateRole, userMandateRole)
|
||||
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]:
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ class AppObjects:
|
|||
self.currentUser = currentUser # Store User object directly
|
||||
self.userId = currentUser.id if currentUser else None
|
||||
self.mandateId = None # mandateId comes from setUserContext, not from User
|
||||
self.featureInstanceId = None # featureInstanceId comes from setUserContext
|
||||
|
||||
# Initialize database
|
||||
self._initializeDatabase()
|
||||
|
|
@ -501,34 +502,30 @@ class AppObjects:
|
|||
|
||||
def getUsersByMandate(self, mandateId: str, pagination: Optional[PaginationParams] = None) -> Union[List[User], PaginatedResult]:
|
||||
"""
|
||||
Returns users for a specific mandate if user has access.
|
||||
Supports optional pagination, sorting, and filtering.
|
||||
For SYSADMIN, returns all users regardless of mandate.
|
||||
Returns users for a specific mandate.
|
||||
Uses UserMandate junction table to find users belonging to the mandate.
|
||||
|
||||
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.
|
||||
|
||||
Returns:
|
||||
If pagination is None: List[User]
|
||||
If pagination is provided: PaginatedResult with items and metadata
|
||||
"""
|
||||
# Use RBAC filtering
|
||||
users = getRecordsetWithRBAC(
|
||||
self.db,
|
||||
UserInDB,
|
||||
self.currentUser,
|
||||
recordFilter={"mandateId": mandateId} if mandateId else None
|
||||
)
|
||||
# Get user IDs via UserMandate junction table (UserInDB has no mandateId column)
|
||||
userMandates = self.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
|
||||
userIds = [um.get("userId") for um in userMandates if um.get("userId")]
|
||||
|
||||
# Filter out database-specific fields and normalize data
|
||||
# Fetch each user by ID
|
||||
filteredUsers = []
|
||||
for user in users:
|
||||
cleanedUser = {k: v for k, v in user.items() if not k.startswith("_")}
|
||||
# Ensure roleLabels is always a list, not None
|
||||
if cleanedUser.get("roleLabels") is None:
|
||||
cleanedUser["roleLabels"] = []
|
||||
filteredUsers.append(cleanedUser)
|
||||
for userId in userIds:
|
||||
userRecords = self.db.getRecordset(UserInDB, recordFilter={"id": userId})
|
||||
if userRecords:
|
||||
cleanedUser = {k: v for k, v in userRecords[0].items() if not k.startswith("_")}
|
||||
if cleanedUser.get("roleLabels") is None:
|
||||
cleanedUser["roleLabels"] = []
|
||||
filteredUsers.append(cleanedUser)
|
||||
|
||||
# If no pagination requested, return all items
|
||||
if pagination is None:
|
||||
|
|
@ -572,7 +569,8 @@ class AppObjects:
|
|||
users = getRecordsetWithRBAC(self.db,
|
||||
UserInDB,
|
||||
self.currentUser,
|
||||
recordFilter={"username": username}
|
||||
recordFilter={"username": username},
|
||||
mandateId=self.mandateId
|
||||
)
|
||||
|
||||
if not users:
|
||||
|
|
@ -599,7 +597,8 @@ class AppObjects:
|
|||
users = getRecordsetWithRBAC(self.db,
|
||||
UserInDB,
|
||||
self.currentUser,
|
||||
recordFilter={"id": userId}
|
||||
recordFilter={"id": userId},
|
||||
mandateId=self.mandateId
|
||||
)
|
||||
|
||||
if not users:
|
||||
|
|
@ -1202,7 +1201,8 @@ class AppObjects:
|
|||
users = getRecordsetWithRBAC(self.db,
|
||||
UserInDB,
|
||||
self.currentUser,
|
||||
recordFilter={"id": initialUserId}
|
||||
recordFilter={"id": initialUserId},
|
||||
mandateId=self.mandateId
|
||||
)
|
||||
return users[0] if users else None
|
||||
except Exception as e:
|
||||
|
|
@ -1384,7 +1384,7 @@ class AppObjects:
|
|||
If pagination is provided: PaginatedResult with items and metadata
|
||||
"""
|
||||
# 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
|
||||
filteredMandates = []
|
||||
|
|
@ -1428,7 +1428,8 @@ class AppObjects:
|
|||
mandates = getRecordsetWithRBAC(self.db,
|
||||
Mandate,
|
||||
self.currentUser,
|
||||
recordFilter={"id": mandateId}
|
||||
recordFilter={"id": mandateId},
|
||||
mandateId=self.mandateId
|
||||
)
|
||||
|
||||
if not mandates:
|
||||
|
|
@ -1968,6 +1969,7 @@ class AppObjects:
|
|||
def createFeatureAccess(self, userId: str, featureInstanceId: str, roleIds: List[str] = None) -> FeatureAccess:
|
||||
"""
|
||||
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:
|
||||
userId: User ID
|
||||
|
|
@ -1983,6 +1985,9 @@ class AppObjects:
|
|||
if existing:
|
||||
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
|
||||
featureAccess = FeatureAccess(
|
||||
userId=userId,
|
||||
|
|
@ -2007,6 +2012,45 @@ class AppObjects:
|
|||
logger.error(f"Error creating 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]:
|
||||
"""
|
||||
Get all role IDs assigned to a FeatureAccess.
|
||||
|
|
@ -2027,6 +2071,34 @@ class AppObjects:
|
|||
logger.error(f"Error getting role IDs for FeatureAccess: {e}")
|
||||
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:
|
||||
"""
|
||||
Delete all FeatureAccessRole records for a FeatureAccess.
|
||||
|
|
@ -2962,7 +3034,8 @@ class AppObjects:
|
|||
self.db,
|
||||
AccessRule,
|
||||
self.currentUser,
|
||||
recordFilter=recordFilter if recordFilter else None
|
||||
recordFilter=recordFilter if recordFilter else None,
|
||||
mandateId=self.mandateId
|
||||
)
|
||||
|
||||
# Filter out database-specific fields
|
||||
|
|
|
|||
|
|
@ -966,20 +966,22 @@ class BillingObjects:
|
|||
Returns:
|
||||
List of BillingBalanceResponse
|
||||
"""
|
||||
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
|
||||
balances = []
|
||||
|
||||
try:
|
||||
appInterface = getAppInterface(self.currentUser)
|
||||
userMandates = appInterface.getUserMandates(userId)
|
||||
# Use rootInterface (privileged, SysAdmin context) to bypass RBAC
|
||||
# for mandate/user lookups. User access is verified via UserMandate membership.
|
||||
rootInterface = getRootInterface()
|
||||
userMandates = rootInterface.getUserMandates(userId)
|
||||
|
||||
for um in userMandates:
|
||||
mandateId = getattr(um, 'mandateId', None) or (um.get("mandateId") if isinstance(um, dict) else None)
|
||||
if not mandateId:
|
||||
continue
|
||||
|
||||
mandate = appInterface.getMandate(mandateId)
|
||||
|
||||
mandate = rootInterface.getMandate(mandateId)
|
||||
if not mandate:
|
||||
continue
|
||||
|
||||
|
|
@ -995,14 +997,14 @@ class BillingObjects:
|
|||
|
||||
# Determine effective balance based on billing model
|
||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||
account = self.getUserAccount(mandateId, userId)
|
||||
account = self.getOrCreateUserAccount(mandateId, userId)
|
||||
if not account:
|
||||
continue
|
||||
balance = account.get("balance", 0.0)
|
||||
warningThreshold = account.get("warningThreshold", 0.0)
|
||||
creditLimit = account.get("creditLimit")
|
||||
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
|
||||
poolAccount = self.getMandateAccount(mandateId)
|
||||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||
if not poolAccount:
|
||||
continue
|
||||
balance = poolAccount.get("balance", 0.0)
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
|
|||
_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.
|
||||
Structure: {log_dir}/debug/messages/m_round_task_action_timestamp/documentlist_label/
|
||||
|
|
@ -53,6 +53,8 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
|
|||
Args:
|
||||
message: ChatMessage object to store
|
||||
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:
|
||||
import os
|
||||
|
|
@ -152,7 +154,7 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
|
|||
|
||||
# Also store the actual file bytes next to metadata for debugging
|
||||
try:
|
||||
componentInterface = getInterface(currentUser)
|
||||
componentInterface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
|
||||
file_bytes = componentInterface.getFileData(doc.fileId)
|
||||
if file_bytes:
|
||||
# Build a safe filename preserving original name
|
||||
|
|
@ -715,8 +717,13 @@ class ChatObjects:
|
|||
logger.debug(f"createWorkflow: Using Root mandate {effectiveMandateId}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get Root mandate: {e}")
|
||||
# Note: Chat data is user-owned, no mandate/featureInstance context stored
|
||||
# mandateId/featureInstanceId removed from ChatWorkflow model
|
||||
# Note: ChatWorkflow has featureInstanceId for multi-tenancy isolation.
|
||||
# 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
|
||||
simpleFields, objectFields = self._separateObjectFields(ChatWorkflow, workflowData)
|
||||
|
|
@ -726,7 +733,6 @@ class ChatObjects:
|
|||
|
||||
|
||||
# Convert to ChatWorkflow model (empty related data for new workflow)
|
||||
# Note: Chat data is user-owned, no mandate/featureInstance fields
|
||||
return ChatWorkflow(
|
||||
id=created["id"],
|
||||
status=created.get("status", "running"),
|
||||
|
|
@ -1122,7 +1128,7 @@ class ChatObjects:
|
|||
logger.debug(f"Could not emit message event: {e}")
|
||||
|
||||
# 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
|
||||
|
||||
|
|
|
|||
|
|
@ -593,7 +593,11 @@ class ComponentObjects:
|
|||
# Prompt methods
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
files = getRecordsetWithRBAC(self.db,
|
||||
FileItem,
|
||||
self.currentUser
|
||||
self.currentUser,
|
||||
mandateId=self.mandateId
|
||||
)
|
||||
|
||||
# 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}")
|
||||
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:
|
||||
logger.warning(f"No data found for file ID {fileId}")
|
||||
return None
|
||||
|
|
@ -1367,7 +1372,8 @@ class ComponentObjects:
|
|||
filteredSettings = getRecordsetWithRBAC(self.db,
|
||||
VoiceSettings,
|
||||
self.currentUser,
|
||||
recordFilter={"userId": targetUserId}
|
||||
recordFilter={"userId": targetUserId},
|
||||
mandateId=self.mandateId
|
||||
)
|
||||
|
||||
if not filteredSettings:
|
||||
|
|
@ -1510,7 +1516,8 @@ class ComponentObjects:
|
|||
try:
|
||||
filteredSubscriptions = getRecordsetWithRBAC(self.db,
|
||||
MessagingSubscription,
|
||||
self.currentUser
|
||||
self.currentUser,
|
||||
mandateId=self.mandateId
|
||||
)
|
||||
|
||||
if pagination is None:
|
||||
|
|
@ -1547,7 +1554,8 @@ class ComponentObjects:
|
|||
filteredSubscriptions = getRecordsetWithRBAC(self.db,
|
||||
MessagingSubscription,
|
||||
self.currentUser,
|
||||
recordFilter={"subscriptionId": subscriptionId}
|
||||
recordFilter={"subscriptionId": subscriptionId},
|
||||
mandateId=self.mandateId
|
||||
)
|
||||
return MessagingSubscription(**filteredSubscriptions[0]) if filteredSubscriptions else None
|
||||
|
||||
|
|
@ -1556,7 +1564,8 @@ class ComponentObjects:
|
|||
filteredSubscriptions = getRecordsetWithRBAC(self.db,
|
||||
MessagingSubscription,
|
||||
self.currentUser,
|
||||
recordFilter={"id": id}
|
||||
recordFilter={"id": id},
|
||||
mandateId=self.mandateId
|
||||
)
|
||||
return MessagingSubscription(**filteredSubscriptions[0]) if filteredSubscriptions else None
|
||||
|
||||
|
|
@ -1629,7 +1638,8 @@ class ComponentObjects:
|
|||
filteredRegistrations = getRecordsetWithRBAC(self.db,
|
||||
MessagingSubscriptionRegistration,
|
||||
self.currentUser,
|
||||
recordFilter=recordFilter if recordFilter else None
|
||||
recordFilter=recordFilter if recordFilter else None,
|
||||
mandateId=self.mandateId
|
||||
)
|
||||
|
||||
if pagination is None:
|
||||
|
|
@ -1666,7 +1676,8 @@ class ComponentObjects:
|
|||
filteredRegistrations = getRecordsetWithRBAC(self.db,
|
||||
MessagingSubscriptionRegistration,
|
||||
self.currentUser,
|
||||
recordFilter={"id": registrationId}
|
||||
recordFilter={"id": registrationId},
|
||||
mandateId=self.mandateId
|
||||
)
|
||||
return MessagingSubscriptionRegistration(**filteredRegistrations[0]) if filteredRegistrations else None
|
||||
|
||||
|
|
|
|||
|
|
@ -163,13 +163,21 @@ def getRecordsetWithRBAC(
|
|||
|
||||
# Check view permission first
|
||||
if not permissions.view:
|
||||
logger.debug(f"User {currentUser.id} has no view permission for {objectKey} (mandateId={effectiveMandateId}, featureInstanceId={featureInstanceId})")
|
||||
return []
|
||||
|
||||
# Build WHERE clause with RBAC filtering
|
||||
whereConditions = []
|
||||
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
|
||||
rbacWhereClause = buildRbacWhereClause(
|
||||
permissions,
|
||||
|
|
@ -177,7 +185,7 @@ def getRecordsetWithRBAC(
|
|||
table,
|
||||
connector,
|
||||
mandateId=effectiveMandateId,
|
||||
featureInstanceId=featureInstanceId
|
||||
featureInstanceId=featureInstanceIdForQuery
|
||||
)
|
||||
if rbacWhereClause:
|
||||
whereConditions.append(rbacWhereClause["condition"])
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# All rights reserved.
|
||||
"""
|
||||
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
|
||||
|
|
@ -12,7 +12,7 @@ import logging
|
|||
|
||||
# Import interfaces and models from feature containers
|
||||
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
|
||||
|
||||
# Configure logger
|
||||
|
|
@ -35,27 +35,110 @@ router = APIRouter(
|
|||
@limiter.limit("30/minute")
|
||||
def get_all_automation_events(
|
||||
request: Request,
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
currentUser: User = Depends(requireSysAdminRole)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all automation events across all mandates (sysadmin only).
|
||||
Returns list of all registered events with their automation IDs and schedules.
|
||||
Get all active scheduler jobs (sysadmin only).
|
||||
Each job is enriched with context from its automation definition
|
||||
(name, mandate, feature instance, creator) for readability.
|
||||
"""
|
||||
try:
|
||||
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 = []
|
||||
if eventManager.scheduler:
|
||||
for job in eventManager.scheduler.get_jobs():
|
||||
if job.id.startswith("automation."):
|
||||
automation_id = job.id.replace("automation.", "")
|
||||
jobs.append({
|
||||
"eventId": job.id,
|
||||
"automationId": automation_id,
|
||||
"nextRunTime": str(job.next_run_time) if job.next_run_time else None,
|
||||
"trigger": str(job.trigger) if job.trigger else None
|
||||
})
|
||||
automationIds = []
|
||||
for job in eventManager.scheduler.get_jobs():
|
||||
if job.id.startswith("automation."):
|
||||
automationId = job.id.replace("automation.", "")
|
||||
automationIds.append(automationId)
|
||||
jobs.append({
|
||||
"eventId": job.id,
|
||||
"automationId": automationId,
|
||||
"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
|
||||
except Exception as e:
|
||||
|
|
@ -69,7 +152,7 @@ def get_all_automation_events(
|
|||
@limiter.limit("5/minute")
|
||||
async def sync_all_automation_events(
|
||||
request: Request,
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
currentUser: User = Depends(requireSysAdminRole)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Manually trigger sync for all automations (sysadmin only).
|
||||
|
|
@ -110,25 +193,26 @@ async def sync_all_automation_events(
|
|||
def remove_event(
|
||||
request: Request,
|
||||
eventId: str = Path(..., description="Event ID to remove"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
currentUser: User = Depends(requireSysAdminRole)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Manually remove a specific event from scheduler (sysadmin only).
|
||||
Used for debugging and manual event cleanup.
|
||||
Remove a scheduler job (sysadmin only).
|
||||
Removes the job from the scheduler and clears the eventId on the automation definition.
|
||||
Does NOT delete the automation definition itself.
|
||||
"""
|
||||
try:
|
||||
from modules.shared.eventManagement import eventManager
|
||||
|
||||
# Remove event
|
||||
# Remove scheduler job
|
||||
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."):
|
||||
automation_id = eventId.replace("automation.", "")
|
||||
automationId = eventId.replace("automation.", "")
|
||||
automationInterface = interfaceAutomation.getInterface(currentUser)
|
||||
automation = automationInterface.getAutomationDefinition(automation_id)
|
||||
automation = automationInterface.getAutomationDefinition(automationId)
|
||||
if automation and getattr(automation, "eventId", None) == eventId:
|
||||
automationInterface.updateAutomationDefinition(automation_id, {"eventId": None})
|
||||
automationInterface.updateAutomationDefinition(automationId, {"eventId": None})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ from fastapi import status
|
|||
import logging
|
||||
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.datamodelFeatures import Feature, FeatureInstance
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
|
|
@ -337,7 +337,7 @@ def create_feature(
|
|||
code: str = Query(..., description="Unique feature code"),
|
||||
label: Dict[str, str] = None,
|
||||
icon: str = Query("mdi-puzzle", description="Icon identifier"),
|
||||
sysAdmin: User = Depends(requireSysAdmin)
|
||||
sysAdmin: User = Depends(requireSysAdminRole)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new feature definition.
|
||||
|
|
@ -453,7 +453,7 @@ def get_feature_instance(
|
|||
|
||||
# Verify mandate access (unless SysAdmin)
|
||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||
if not context.isSysAdmin:
|
||||
if not context.hasSysAdminRole:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this feature instance"
|
||||
|
|
@ -567,14 +567,14 @@ def delete_feature_instance(
|
|||
|
||||
# Verify mandate access
|
||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||
if not context.isSysAdmin:
|
||||
if not context.hasSysAdminRole:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this feature instance"
|
||||
)
|
||||
|
||||
# Check mandate admin permission
|
||||
if not _hasMandateAdminRole(context) and not context.isSysAdmin:
|
||||
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Mandate-Admin role required to delete feature instances"
|
||||
|
|
@ -634,14 +634,14 @@ def updateFeatureInstance(
|
|||
|
||||
# Verify mandate access
|
||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||
if not context.isSysAdmin:
|
||||
if not context.hasSysAdminRole:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this feature instance"
|
||||
)
|
||||
|
||||
# Check mandate admin permission
|
||||
if not _hasMandateAdminRole(context) and not context.isSysAdmin:
|
||||
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Mandate-Admin role required to update feature instances"
|
||||
|
|
@ -712,14 +712,14 @@ def sync_instance_roles(
|
|||
|
||||
# Verify mandate access
|
||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||
if not context.isSysAdmin:
|
||||
if not context.hasSysAdminRole:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this feature instance"
|
||||
)
|
||||
|
||||
# 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(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin role required to sync roles"
|
||||
|
|
@ -752,7 +752,7 @@ def sync_instance_roles(
|
|||
def list_template_roles(
|
||||
request: Request,
|
||||
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
|
||||
sysAdmin: User = Depends(requireSysAdmin)
|
||||
sysAdmin: User = Depends(requireSysAdminRole)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List global template roles.
|
||||
|
|
@ -784,7 +784,7 @@ def create_template_role(
|
|||
roleLabel: str = Query(..., description="Role label (e.g., 'admin', 'viewer')"),
|
||||
featureCode: str = Query(..., description="Feature code this role belongs to"),
|
||||
description: Dict[str, str] = None,
|
||||
sysAdmin: User = Depends(requireSysAdmin)
|
||||
sysAdmin: User = Depends(requireSysAdminRole)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a global template role for a feature.
|
||||
|
|
@ -891,7 +891,7 @@ def list_feature_instance_users(
|
|||
|
||||
# Verify mandate access (unless SysAdmin)
|
||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||
if not context.isSysAdmin:
|
||||
if not context.hasSysAdminRole:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this feature instance"
|
||||
|
|
@ -971,14 +971,14 @@ def add_user_to_feature_instance(
|
|||
|
||||
# Verify mandate access
|
||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||
if not context.isSysAdmin:
|
||||
if not context.hasSysAdminRole:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this feature instance"
|
||||
)
|
||||
|
||||
# Check admin permission
|
||||
if not _hasMandateAdminRole(context) and not context.isSysAdmin:
|
||||
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin role required to add users to feature instances"
|
||||
|
|
@ -1072,14 +1072,14 @@ def remove_user_from_feature_instance(
|
|||
|
||||
# Verify mandate access
|
||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||
if not context.isSysAdmin:
|
||||
if not context.hasSysAdminRole:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this feature instance"
|
||||
)
|
||||
|
||||
# Check admin permission
|
||||
if not _hasMandateAdminRole(context) and not context.isSysAdmin:
|
||||
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin role required to remove users from feature instances"
|
||||
|
|
@ -1153,14 +1153,14 @@ def update_feature_instance_user_roles(
|
|||
|
||||
# Verify mandate access
|
||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||
if not context.isSysAdmin:
|
||||
if not context.hasSysAdminRole:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this feature instance"
|
||||
)
|
||||
|
||||
# Check admin permission
|
||||
if not _hasMandateAdminRole(context) and not context.isSysAdmin:
|
||||
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin role required to update user roles"
|
||||
|
|
@ -1243,7 +1243,7 @@ def get_feature_instance_available_roles(
|
|||
|
||||
# Verify mandate access
|
||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||
if not context.isSysAdmin:
|
||||
if not context.hasSysAdminRole:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
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.
|
||||
"""
|
||||
if context.isSysAdmin:
|
||||
if context.hasSysAdminRole:
|
||||
return True
|
||||
|
||||
if not context.roleIds:
|
||||
|
|
@ -1341,7 +1341,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
|
|||
if role:
|
||||
roleLabel = role.roleLabel
|
||||
# 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 False
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import logging
|
|||
import json
|
||||
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.datamodelRbac import Role, AccessRule
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
|
|
@ -74,7 +74,7 @@ class RbacImportResult(BaseModel):
|
|||
@limiter.limit("10/minute")
|
||||
def export_global_rbac(
|
||||
request: Request,
|
||||
sysAdmin: User = Depends(requireSysAdmin)
|
||||
sysAdmin: User = Depends(requireSysAdminRole)
|
||||
) -> RbacExportData:
|
||||
"""
|
||||
Export global (template) RBAC rules.
|
||||
|
|
@ -139,7 +139,7 @@ async def import_global_rbac(
|
|||
request: Request,
|
||||
file: UploadFile = File(..., description="JSON file with RBAC export data"),
|
||||
updateExisting: bool = False,
|
||||
sysAdmin: User = Depends(requireSysAdmin)
|
||||
sysAdmin: User = Depends(requireSysAdminRole)
|
||||
) -> RbacImportResult:
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
if context.isSysAdmin:
|
||||
if context.hasSysAdminRole:
|
||||
return True
|
||||
|
||||
if not context.roleIds:
|
||||
|
|
@ -552,7 +552,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
|
|||
if role:
|
||||
roleLabel = role.roleLabel
|
||||
# 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 False
|
||||
|
|
|
|||
|
|
@ -4,16 +4,19 @@
|
|||
Admin RBAC Roles Management routes.
|
||||
Provides endpoints for managing roles and role assignments to users.
|
||||
|
||||
MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true.
|
||||
Roles are global system resources, not mandate-specific.
|
||||
MULTI-TENANT: Context-aware access control.
|
||||
- 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).
|
||||
"""
|
||||
|
||||
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
|
||||
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.datamodelRbac import Role
|
||||
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)
|
||||
|
||||
|
||||
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(
|
||||
prefix="/api/admin/rbac/roles",
|
||||
tags=["Admin RBAC Roles"],
|
||||
|
|
@ -71,20 +99,32 @@ router = APIRouter(
|
|||
def list_roles(
|
||||
request: Request,
|
||||
mandateId: Optional[str] = Query(None, description="Filter roles by mandate ID"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
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).
|
||||
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:
|
||||
interface = getRootInterface()
|
||||
|
||||
# Get roles filtered by scope
|
||||
print(f"[DEBUG list_roles] mandateId={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)
|
||||
dbRoles = interface.getRolesForMandate(mandateId)
|
||||
print(f"[DEBUG list_roles] getRolesForMandate returned {len(dbRoles)} roles")
|
||||
|
|
@ -92,6 +132,9 @@ def list_roles(
|
|||
# System template roles only
|
||||
dbRoles = interface.getAllRoles()
|
||||
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
|
||||
roleCounts = interface.countRoleAssignments()
|
||||
|
|
@ -125,21 +168,32 @@ def list_roles(
|
|||
@limiter.limit("60/minute")
|
||||
def get_role_options(
|
||||
request: Request,
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
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:
|
||||
- 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:
|
||||
interface = getRootInterface()
|
||||
|
||||
# Get all roles from database
|
||||
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
|
||||
options = []
|
||||
for role in dbRoles:
|
||||
|
|
@ -167,11 +221,12 @@ def get_role_options(
|
|||
def create_role(
|
||||
request: Request,
|
||||
role: Role = Body(...),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
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:
|
||||
- role: Role object to create
|
||||
|
|
@ -179,9 +234,24 @@ def create_role(
|
|||
Returns:
|
||||
- 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:
|
||||
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)
|
||||
|
||||
return {
|
||||
|
|
@ -211,11 +281,12 @@ def create_role(
|
|||
def get_role(
|
||||
request: Request,
|
||||
roleId: str = Path(..., description="Role ID"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
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:
|
||||
- roleId: Role ID
|
||||
|
|
@ -223,6 +294,12 @@ def get_role(
|
|||
Returns:
|
||||
- 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:
|
||||
interface = getRootInterface()
|
||||
|
||||
|
|
@ -233,6 +310,11 @@ def get_role(
|
|||
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 {
|
||||
"id": role.id,
|
||||
"roleLabel": role.roleLabel,
|
||||
|
|
@ -256,11 +338,12 @@ def update_role(
|
|||
request: Request,
|
||||
roleId: str = Path(..., description="Role ID"),
|
||||
role: Role = Body(...),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
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:
|
||||
- roleId: Role ID
|
||||
|
|
@ -271,9 +354,27 @@ def update_role(
|
|||
Returns:
|
||||
- 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:
|
||||
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)
|
||||
|
||||
return {
|
||||
|
|
@ -303,11 +404,12 @@ def update_role(
|
|||
def delete_role(
|
||||
request: Request,
|
||||
roleId: str = Path(..., description="Role ID"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
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:
|
||||
- roleId: Role ID
|
||||
|
|
@ -315,9 +417,27 @@ def delete_role(
|
|||
Returns:
|
||||
- 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:
|
||||
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)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
|
|
@ -348,11 +468,11 @@ def list_users_with_roles(
|
|||
request: Request,
|
||||
roleLabel: Optional[str] = Query(None, description="Filter by role label"),
|
||||
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
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:
|
||||
- roleLabel: Optional filter by role label
|
||||
|
|
@ -361,6 +481,12 @@ def list_users_with_roles(
|
|||
Returns:
|
||||
- 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:
|
||||
interface = getRootInterface()
|
||||
|
||||
|
|
@ -369,9 +495,20 @@ def list_users_with_roles(
|
|||
|
||||
# Filter by mandate if specified (via UserMandate table)
|
||||
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)
|
||||
mandateUserIds = {str(um.userId) for um in userMandates}
|
||||
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)
|
||||
if roleLabel:
|
||||
|
|
@ -409,11 +546,11 @@ def list_users_with_roles(
|
|||
def get_user_roles(
|
||||
request: Request,
|
||||
userId: str = Path(..., description="User ID"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
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:
|
||||
- userId: User ID
|
||||
|
|
@ -421,6 +558,12 @@ def get_user_roles(
|
|||
Returns:
|
||||
- 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:
|
||||
interface = getRootInterface()
|
||||
|
||||
|
|
@ -432,6 +575,13 @@ def get_user_roles(
|
|||
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))
|
||||
return {
|
||||
"id": user.id,
|
||||
|
|
@ -460,11 +610,12 @@ def update_user_roles(
|
|||
request: Request,
|
||||
userId: str = Path(..., description="User ID"),
|
||||
newRoleLabels: List[str] = Body(..., description="List of role labels to assign"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
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:
|
||||
- userId: User ID
|
||||
|
|
@ -475,6 +626,12 @@ def update_user_roles(
|
|||
Returns:
|
||||
- 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:
|
||||
interface = getRootInterface()
|
||||
|
||||
|
|
@ -486,6 +643,11 @@ def update_user_roles(
|
|||
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)
|
||||
standardRoles = ["sysadmin", "admin", "user", "viewer"]
|
||||
for roleLabel in newRoleLabels:
|
||||
|
|
@ -501,17 +663,26 @@ def update_user_roles(
|
|||
)
|
||||
|
||||
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)
|
||||
existingRoles = interface.getUserMandateRoles(userMandateId)
|
||||
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()
|
||||
for roleLabel in newRoleLabels:
|
||||
role = interface.getRoleByLabel(roleLabel)
|
||||
if role:
|
||||
newRoleIds.add(str(role.id))
|
||||
role = interface.getRoleByLabelAndScope(roleLabel, mandateId=targetMandateId)
|
||||
if not role:
|
||||
logger.warning(f"Role '{roleLabel}' not found for mandate {targetMandateId}, skipping")
|
||||
continue
|
||||
newRoleIds.add(str(role.id))
|
||||
|
||||
# Remove roles that are no longer needed
|
||||
for existingRole in existingRoles:
|
||||
|
|
@ -524,7 +695,7 @@ def update_user_roles(
|
|||
newRole = UserMandateRole(userMandateId=userMandateId, roleId=roleId)
|
||||
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)
|
||||
return {
|
||||
|
|
@ -554,11 +725,12 @@ def add_user_role(
|
|||
request: Request,
|
||||
userId: str = Path(..., description="User ID"),
|
||||
roleLabel: str = Path(..., description="Role label to add"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
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:
|
||||
- userId: User ID
|
||||
|
|
@ -567,6 +739,12 @@ def add_user_role(
|
|||
Returns:
|
||||
- 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:
|
||||
interface = getRootInterface()
|
||||
|
||||
|
|
@ -578,13 +756,10 @@ def add_user_role(
|
|||
detail=f"User {userId} not found"
|
||||
)
|
||||
|
||||
# Get role by label
|
||||
role = interface.getRoleByLabel(roleLabel)
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Role '{roleLabel}' not found"
|
||||
)
|
||||
# MandateAdmin restrictions
|
||||
if not isSysAdmin:
|
||||
if roleLabel == "sysadmin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot assign sysadmin role")
|
||||
|
||||
# Get user's first mandate
|
||||
userMandates = interface.getUserMandates(userId)
|
||||
|
|
@ -595,6 +770,21 @@ def add_user_role(
|
|||
)
|
||||
|
||||
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
|
||||
existingRoles = interface.getUserMandateRoles(userMandateId)
|
||||
|
|
@ -603,7 +793,7 @@ def add_user_role(
|
|||
if not roleAlreadyAssigned:
|
||||
# Add the role via interface method
|
||||
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)
|
||||
return {
|
||||
|
|
@ -633,11 +823,12 @@ def remove_user_role(
|
|||
request: Request,
|
||||
userId: str = Path(..., description="User ID"),
|
||||
roleLabel: str = Path(..., description="Role label to remove"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
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:
|
||||
- userId: User ID
|
||||
|
|
@ -646,6 +837,12 @@ def remove_user_role(
|
|||
Returns:
|
||||
- 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:
|
||||
interface = getRootInterface()
|
||||
|
||||
|
|
@ -657,6 +854,11 @@ def remove_user_role(
|
|||
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
|
||||
role = interface.getRoleByLabel(roleLabel)
|
||||
if not role:
|
||||
|
|
@ -665,19 +867,30 @@ def remove_user_role(
|
|||
detail=f"Role '{roleLabel}' not found"
|
||||
)
|
||||
|
||||
# Remove role from all user's mandates
|
||||
# Remove role from user's mandates
|
||||
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
|
||||
|
||||
for um in userMandates:
|
||||
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
|
||||
if interface.removeRoleFromUserMandate(userMandateId, str(role.id)):
|
||||
roleRemoved = True
|
||||
|
||||
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)
|
||||
return {
|
||||
|
|
@ -707,11 +920,11 @@ def get_users_with_role(
|
|||
request: Request,
|
||||
roleLabel: str = Path(..., description="Role label"),
|
||||
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
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:
|
||||
- roleLabel: Role label
|
||||
|
|
@ -722,6 +935,12 @@ def get_users_with_role(
|
|||
Returns:
|
||||
- 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:
|
||||
interface = getRootInterface()
|
||||
|
||||
|
|
@ -747,6 +966,9 @@ def get_users_with_role(
|
|||
# Filter by mandate if specified
|
||||
if mandateId and str(um.mandateId) != mandateId:
|
||||
continue
|
||||
# MandateAdmin: filter to own mandates
|
||||
if not isSysAdmin and str(um.mandateId) not in adminMandateIds:
|
||||
continue
|
||||
userIds.add(str(um.userId))
|
||||
|
||||
# Get users and format response
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@ Implements endpoints for role-based access control permissions.
|
|||
|
||||
MULTI-TENANT:
|
||||
- Permission queries use RequestContext (mandateId from header)
|
||||
- AccessRule management is SysAdmin-only (system resources)
|
||||
- Role management is SysAdmin-only (system resources)
|
||||
- AccessRule management is Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's rules)
|
||||
- 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
|
||||
|
|
@ -16,7 +17,7 @@ import logging
|
|||
import json
|
||||
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.datamodelRbac import AccessRuleContext, AccessRule, Role
|
||||
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)
|
||||
@limiter.limit("300/minute") # Raised from 60 - sidebar checks many pages individually
|
||||
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")
|
||||
|
||||
if not roleIds and not reqContext.isSysAdmin:
|
||||
if not roleIds and not reqContext.hasSysAdminRole:
|
||||
# No roles at all, return empty permissions
|
||||
for ctx in contextsToFetch:
|
||||
result[ctx.value.lower()] = {}
|
||||
|
|
@ -306,11 +345,11 @@ def get_access_rules(
|
|||
context: Optional[str] = Query(None, description="Filter by context (DATA, UI, RESOURCE)"),
|
||||
item: Optional[str] = Query(None, description="Filter by item identifier"),
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
reqContext: RequestContext = Depends(getRequestContext)
|
||||
) -> PaginatedResponse:
|
||||
"""
|
||||
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:
|
||||
- roleLabel: Optional role label filter
|
||||
|
|
@ -321,7 +360,12 @@ def get_access_rules(
|
|||
- List of AccessRule objects
|
||||
"""
|
||||
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()
|
||||
|
||||
# Parse context if provided
|
||||
|
|
@ -350,6 +394,39 @@ def get_access_rules(
|
|||
)
|
||||
|
||||
# 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(
|
||||
roleLabel=roleLabel,
|
||||
context=accessContext,
|
||||
|
|
@ -392,11 +469,11 @@ def get_access_rules(
|
|||
def get_access_rules_by_role(
|
||||
request: Request,
|
||||
roleId: str = Path(..., description="Role ID to get rules for"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
reqContext: RequestContext = Depends(getRequestContext)
|
||||
) -> PaginatedResponse:
|
||||
"""
|
||||
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:
|
||||
- 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
|
||||
"""
|
||||
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()
|
||||
|
||||
# Get rules from database using interface method
|
||||
|
|
@ -430,11 +516,11 @@ def get_access_rules_by_role(
|
|||
def get_access_rule(
|
||||
request: Request,
|
||||
ruleId: str = Path(..., description="Access rule ID"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
reqContext: RequestContext = Depends(getRequestContext)
|
||||
) -> dict:
|
||||
"""
|
||||
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:
|
||||
- ruleId: Access rule ID
|
||||
|
|
@ -443,7 +529,12 @@ def get_access_rule(
|
|||
- AccessRule object
|
||||
"""
|
||||
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()
|
||||
|
||||
# Get rule
|
||||
|
|
@ -454,6 +545,10 @@ def get_access_rule(
|
|||
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
|
||||
return rule.model_dump()
|
||||
|
||||
|
|
@ -472,11 +567,11 @@ def get_access_rule(
|
|||
def create_access_rule(
|
||||
request: Request,
|
||||
accessRuleData: dict = Body(..., description="Access rule data"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
reqContext: RequestContext = Depends(getRequestContext)
|
||||
) -> dict:
|
||||
"""
|
||||
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:
|
||||
- AccessRule object data (roleLabel, context, item, view, read, create, update, delete)
|
||||
|
|
@ -485,7 +580,12 @@ def create_access_rule(
|
|||
- Created AccessRule object
|
||||
"""
|
||||
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()
|
||||
|
||||
# Validate and parse access rule data
|
||||
|
|
@ -515,10 +615,15 @@ def create_access_rule(
|
|||
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
|
||||
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
|
||||
return createdRule.model_dump()
|
||||
|
|
@ -539,11 +644,11 @@ def update_access_rule(
|
|||
request: Request,
|
||||
ruleId: str = Path(..., description="Access rule ID"),
|
||||
accessRuleData: dict = Body(..., description="Updated access rule data"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
reqContext: RequestContext = Depends(getRequestContext)
|
||||
) -> dict:
|
||||
"""
|
||||
Update an existing access rule.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin updates own mandate's rules).
|
||||
|
||||
Path Parameters:
|
||||
- ruleId: Access rule ID
|
||||
|
|
@ -555,7 +660,12 @@ def update_access_rule(
|
|||
- Updated AccessRule object
|
||||
"""
|
||||
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()
|
||||
|
||||
# Get existing rule to ensure it exists
|
||||
|
|
@ -566,6 +676,10 @@ def update_access_rule(
|
|||
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
|
||||
try:
|
||||
# Merge with existing rule data
|
||||
|
|
@ -601,7 +715,7 @@ def update_access_rule(
|
|||
# Update rule
|
||||
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
|
||||
return updatedRule.model_dump()
|
||||
|
|
@ -621,11 +735,11 @@ def update_access_rule(
|
|||
def delete_access_rule(
|
||||
request: Request,
|
||||
ruleId: str = Path(..., description="Access rule ID"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
reqContext: RequestContext = Depends(getRequestContext)
|
||||
) -> dict:
|
||||
"""
|
||||
Delete an access rule.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin deletes own mandate's rules).
|
||||
|
||||
Path Parameters:
|
||||
- ruleId: Access rule ID
|
||||
|
|
@ -634,7 +748,12 @@ def delete_access_rule(
|
|||
- Success message
|
||||
"""
|
||||
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()
|
||||
|
||||
# Get existing rule to ensure it exists
|
||||
|
|
@ -645,6 +764,10 @@ def delete_access_rule(
|
|||
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
|
||||
success = interface.deleteAccessRule(ruleId)
|
||||
|
||||
|
|
@ -654,7 +777,7 @@ def delete_access_rule(
|
|||
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"}
|
||||
|
||||
|
|
@ -670,7 +793,7 @@ def delete_access_rule(
|
|||
|
||||
# ============================================================================
|
||||
# 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"),
|
||||
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'"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
reqContext: RequestContext = Depends(getRequestContext)
|
||||
) -> PaginatedResponse:
|
||||
"""
|
||||
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).
|
||||
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
|
||||
"""
|
||||
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()
|
||||
|
||||
# Parse pagination parameter
|
||||
|
|
@ -777,6 +905,10 @@ def list_roles(
|
|||
"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
|
||||
if paginationParams:
|
||||
# Apply search (if search term provided in filters)
|
||||
|
|
@ -850,7 +982,7 @@ def list_roles(
|
|||
@limiter.limit("60/minute")
|
||||
def get_role_options(
|
||||
request: Request,
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
currentUser: User = Depends(requireSysAdminRole)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get role options for select dropdowns.
|
||||
|
|
@ -892,11 +1024,11 @@ def get_role_options(
|
|||
def create_role(
|
||||
request: Request,
|
||||
role: Role = Body(...),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
reqContext: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new role.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin creates in own mandate).
|
||||
|
||||
Request Body:
|
||||
- role: Role object to create
|
||||
|
|
@ -905,11 +1037,21 @@ def create_role(
|
|||
- Created role dictionary
|
||||
"""
|
||||
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()
|
||||
|
||||
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 {
|
||||
"id": createdRole.id,
|
||||
|
|
@ -941,11 +1083,11 @@ def create_role(
|
|||
def get_role(
|
||||
request: Request,
|
||||
roleId: str = Path(..., description="Role ID"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
reqContext: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get a role by ID.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's roles).
|
||||
|
||||
Path Parameters:
|
||||
- roleId: Role ID
|
||||
|
|
@ -954,6 +1096,11 @@ def get_role(
|
|||
- Role dictionary
|
||||
"""
|
||||
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()
|
||||
|
||||
role = interface.getRole(roleId)
|
||||
|
|
@ -963,6 +1110,11 @@ def get_role(
|
|||
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 {
|
||||
"id": role.id,
|
||||
"roleLabel": role.roleLabel,
|
||||
|
|
@ -989,11 +1141,11 @@ def update_role(
|
|||
request: Request,
|
||||
roleId: str = Path(..., description="Role ID"),
|
||||
role: Role = Body(...),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
reqContext: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
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:
|
||||
- roleId: Role ID
|
||||
|
|
@ -1005,11 +1157,26 @@ def update_role(
|
|||
- Updated role dictionary
|
||||
"""
|
||||
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()
|
||||
|
||||
# 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)
|
||||
|
||||
logger.info(f"Updated role {roleId} by SysAdmin {currentUser.id}")
|
||||
logger.info(f"Updated role {roleId} by admin {reqContext.user.id}")
|
||||
|
||||
return {
|
||||
"id": updatedRole.id,
|
||||
|
|
@ -1041,11 +1208,11 @@ def update_role(
|
|||
def delete_role(
|
||||
request: Request,
|
||||
roleId: str = Path(..., description="Role ID"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
reqContext: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
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:
|
||||
- roleId: Role ID
|
||||
|
|
@ -1054,8 +1221,23 @@ def delete_role(
|
|||
- Success message
|
||||
"""
|
||||
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()
|
||||
|
||||
# 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)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
|
|
@ -1063,7 +1245,7 @@ def delete_role(
|
|||
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"}
|
||||
|
||||
|
|
@ -1182,7 +1364,7 @@ def getCatalogObjects(
|
|||
@limiter.limit("60/minute")
|
||||
def getCatalogStats(
|
||||
request: Request,
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
currentUser: User = Depends(requireSysAdminRole)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get statistics about the RBAC catalog.
|
||||
|
|
@ -1213,7 +1395,7 @@ def getCatalogStats(
|
|||
def cleanup_duplicate_access_rules(
|
||||
request: Request,
|
||||
dryRun: bool = Query(True, description="If true, only report duplicates without deleting"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
currentUser: User = Depends(requireSysAdminRole)
|
||||
) -> dict:
|
||||
"""
|
||||
Find and remove duplicate AccessRules.
|
||||
|
|
@ -1278,19 +1460,102 @@ def cleanup_duplicate_access_rules(
|
|||
except Exception as 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 = {
|
||||
"dryRun": dryRun,
|
||||
"totalRules": len(allRules),
|
||||
"uniqueSignatures": len(rulesBySignature),
|
||||
"duplicateGroups": len(duplicateGroups),
|
||||
"duplicateRulesToDelete": len(idsToDelete),
|
||||
"deletedCount": deletedCount,
|
||||
"details": duplicateGroups[:50] # Limit details to 50 groups
|
||||
"duplicateRules": {
|
||||
"totalRules": len(allRules),
|
||||
"uniqueSignatures": len(rulesBySignature),
|
||||
"duplicateGroups": len(duplicateGroups),
|
||||
"duplicateRulesToDelete": len(idsToDelete),
|
||||
"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)}, "
|
||||
f"duplicateGroups={len(duplicateGroups)}, toDelete={len(idsToDelete)}, "
|
||||
f"deleted={deletedCount}")
|
||||
logger.info(f"RBAC cleanup: dryRun={dryRun}, "
|
||||
f"duplicates={len(duplicateGroups)}/{deletedCount} deleted, "
|
||||
f"templateFixes={len(templateFixDetails)}/{templateFixedCount} fixed")
|
||||
|
||||
return result
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
||||
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
|
||||
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.datamodelRbac import Role, AccessRule, AccessRuleContext
|
||||
from modules.datamodels.datamodelMembership import (
|
||||
|
|
@ -67,34 +68,101 @@ def _getRoleScopePriority(scope: str) -> int:
|
|||
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]])
|
||||
@limiter.limit("60/minute")
|
||||
def listUsersForOverview(
|
||||
request: Request,
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get list of all users for selection in the overview.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
Get list of users for selection in the overview.
|
||||
SysAdmin sees all users. MandateAdmin sees users in their mandate.
|
||||
|
||||
Returns:
|
||||
- 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:
|
||||
interface = getRootInterface()
|
||||
|
||||
# Get all users using interface method
|
||||
allUsers = interface.getAllUsers()
|
||||
if context.hasSysAdminRole and not context.mandateId:
|
||||
# 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 = []
|
||||
for u in allUsers:
|
||||
userData = u if isinstance(u, dict) else u.model_dump() if hasattr(u, 'model_dump') else vars(u)
|
||||
result.append({
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"email": u.email,
|
||||
"fullName": u.fullName,
|
||||
"isSysAdmin": u.isSysAdmin,
|
||||
"enabled": u.enabled,
|
||||
"id": userData.get("id"),
|
||||
"username": userData.get("username"),
|
||||
"email": userData.get("email"),
|
||||
"fullName": userData.get("fullName"),
|
||||
"isSysAdmin": userData.get("isSysAdmin", False),
|
||||
"enabled": userData.get("enabled", True),
|
||||
})
|
||||
|
||||
# Sort by username
|
||||
|
|
@ -102,6 +170,8 @@ def listUsersForOverview(
|
|||
|
||||
return result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing users for overview: {str(e)}")
|
||||
raise HTTPException(
|
||||
|
|
@ -117,11 +187,11 @@ def getUserAccessOverview(
|
|||
userId: str = Path(..., description="User ID to get access overview for"),
|
||||
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"),
|
||||
featureInstanceId: Optional[str] = Query(None, description="Filter by feature instance ID"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive access overview for a specific user.
|
||||
MULTI-TENANT: SysAdmin-only.
|
||||
SysAdmin sees all users. MandateAdmin sees users in their mandate.
|
||||
|
||||
Path Parameters:
|
||||
- userId: User ID
|
||||
|
|
@ -138,9 +208,39 @@ def getUserAccessOverview(
|
|||
- Data access (what tables/fields the user can access)
|
||||
- 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:
|
||||
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
|
||||
user = interface.getUser(userId)
|
||||
if not user:
|
||||
|
|
@ -159,19 +259,6 @@ def getUserAccessOverview(
|
|||
"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
|
||||
allRoles = []
|
||||
roleIdToInfo = {} # Map roleId to role info for later reference
|
||||
|
|
@ -415,14 +502,14 @@ def getEffectivePermissions(
|
|||
userId: str = Path(..., description="User ID"),
|
||||
mandateId: str = Query(..., description="Mandate 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"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get effective (resolved) permissions for a user in a specific context.
|
||||
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:
|
||||
- userId: User ID
|
||||
|
|
@ -436,6 +523,11 @@ def getEffectivePermissions(
|
|||
Returns:
|
||||
- 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:
|
||||
interface = getRootInterface()
|
||||
|
||||
|
|
@ -449,11 +541,11 @@ def getEffectivePermissions(
|
|||
|
||||
# Convert context string to enum
|
||||
try:
|
||||
contextEnum = AccessRuleContext(context)
|
||||
contextEnum = AccessRuleContext(accessContext)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
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
|
||||
|
|
@ -472,7 +564,7 @@ def getEffectivePermissions(
|
|||
"userId": userId,
|
||||
"mandateId": mandateId,
|
||||
"featureInstanceId": featureInstanceId,
|
||||
"context": context,
|
||||
"context": accessContext,
|
||||
"item": item,
|
||||
"effectivePermissions": {
|
||||
"view": permissions.view,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from datetime import date, datetime
|
|||
from pydantic import BaseModel, Field
|
||||
|
||||
# Import auth module
|
||||
from modules.auth import limiter, requireSysAdmin, getRequestContext, RequestContext
|
||||
from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
|
||||
|
||||
# Import billing components
|
||||
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
||||
|
|
@ -84,7 +84,8 @@ def _getBillingDataScope(user) -> BillingDataScope:
|
|||
"""
|
||||
scope = BillingDataScope(userId=user.id)
|
||||
|
||||
if user.isSysAdmin:
|
||||
from modules.auth.authentication import _hasSysAdminRole
|
||||
if _hasSysAdminRole(str(user.id)):
|
||||
scope.isGlobalAdmin = True
|
||||
return scope
|
||||
|
||||
|
|
@ -137,6 +138,30 @@ def _getBillingDataScope(user) -> BillingDataScope:
|
|||
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:
|
||||
"""
|
||||
Filter a list of transaction dicts based on the user's BillingDataScope.
|
||||
|
|
@ -537,11 +562,13 @@ def getSettingsAdmin(
|
|||
request: Request,
|
||||
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||
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:
|
||||
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||
settings = billingInterface.getSettings(targetMandateId)
|
||||
|
|
@ -565,7 +592,7 @@ def createOrUpdateSettings(
|
|||
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||
settingsUpdate: BillingSettingsUpdate = Body(...),
|
||||
ctx: RequestContext = Depends(getRequestContext),
|
||||
_admin = Depends(requireSysAdmin)
|
||||
_admin = Depends(requireSysAdminRole)
|
||||
):
|
||||
"""
|
||||
Create or update billing settings for a mandate (SysAdmin only).
|
||||
|
|
@ -618,7 +645,7 @@ def addCredit(
|
|||
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||
creditRequest: CreditAddRequest = Body(...),
|
||||
ctx: RequestContext = Depends(getRequestContext),
|
||||
_admin = Depends(requireSysAdmin)
|
||||
_admin = Depends(requireSysAdminRole)
|
||||
):
|
||||
"""
|
||||
Add credit to a billing account (SysAdmin only).
|
||||
|
|
@ -681,11 +708,13 @@ def getAccounts(
|
|||
request: Request,
|
||||
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||
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:
|
||||
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||
|
||||
|
|
@ -728,12 +757,14 @@ def getUsersForMandate(
|
|||
request: Request,
|
||||
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||
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.
|
||||
"""
|
||||
if not _isAdminOfMandate(ctx, targetMandateId):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||
|
||||
|
|
@ -787,11 +818,13 @@ def getTransactionsAdmin(
|
|||
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
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:
|
||||
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||
transactions = billingInterface.getTransactionsByMandate(targetMandateId, limit=limit)
|
||||
|
|
@ -830,7 +863,7 @@ def getTransactionsAdmin(
|
|||
def getMandateViewBalances(
|
||||
request: Request,
|
||||
ctx: RequestContext = Depends(getRequestContext),
|
||||
_admin = Depends(requireSysAdmin)
|
||||
_admin = Depends(requireSysAdminRole)
|
||||
):
|
||||
"""
|
||||
Get mandate-level balances (SysAdmin only).
|
||||
|
|
@ -853,7 +886,7 @@ def getMandateViewTransactions(
|
|||
request: Request,
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
ctx: RequestContext = Depends(getRequestContext),
|
||||
_admin = Depends(requireSysAdmin)
|
||||
_admin = Depends(requireSysAdminRole)
|
||||
):
|
||||
"""
|
||||
Get all transactions across mandates (SysAdmin only).
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import json
|
|||
from pydantic import BaseModel, Field
|
||||
|
||||
# Import auth module
|
||||
from modules.auth import limiter, requireSysAdmin, getRequestContext, RequestContext
|
||||
from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
|
||||
|
||||
# Import interfaces
|
||||
import modules.interfaces.interfaceDbApp as interfaceDbApp
|
||||
|
|
@ -79,20 +79,30 @@ router = APIRouter(
|
|||
def get_mandates(
|
||||
request: Request,
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> PaginatedResponse[Mandate]:
|
||||
"""
|
||||
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:
|
||||
- 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:
|
||||
# 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
|
||||
paginationParams = None
|
||||
if pagination:
|
||||
|
|
@ -108,11 +118,24 @@ def get_mandates(
|
|||
)
|
||||
|
||||
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 no pagination, result is List[Mandate]
|
||||
if paginationParams:
|
||||
if paginationParams and hasattr(result, 'items'):
|
||||
return PaginatedResponse(
|
||||
items=result.items,
|
||||
pagination=PaginationMetadata(
|
||||
|
|
@ -125,8 +148,9 @@ def get_mandates(
|
|||
)
|
||||
)
|
||||
else:
|
||||
items = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else result)
|
||||
return PaginatedResponse(
|
||||
items=result,
|
||||
items=items,
|
||||
pagination=None
|
||||
)
|
||||
except HTTPException:
|
||||
|
|
@ -138,18 +162,32 @@ def get_mandates(
|
|||
detail=f"Failed to get mandates: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get("/{mandateId}", response_model=Mandate)
|
||||
@router.get("/{targetMandateId}", response_model=Mandate)
|
||||
@limiter.limit("30/minute")
|
||||
def get_mandate(
|
||||
request: Request,
|
||||
mandateId: str = Path(..., description="ID of the mandate"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
targetMandateId: str = Path(..., description="ID of the mandate"),
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> Mandate:
|
||||
"""
|
||||
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:
|
||||
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()
|
||||
mandate = appInterface.getMandate(mandateId)
|
||||
|
||||
|
|
@ -174,7 +212,7 @@ def get_mandate(
|
|||
def create_mandate(
|
||||
request: Request,
|
||||
mandateData: dict = Body(..., description="Mandate data with at least 'name' field"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
currentUser: User = Depends(requireSysAdminRole)
|
||||
) -> Mandate:
|
||||
"""
|
||||
Create a new mandate.
|
||||
|
|
@ -228,7 +266,7 @@ def update_mandate(
|
|||
request: Request,
|
||||
mandateId: str = Path(..., description="ID of the mandate to update"),
|
||||
mandateData: dict = Body(..., description="Mandate update data"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
currentUser: User = Depends(requireSysAdminRole)
|
||||
) -> Mandate:
|
||||
"""
|
||||
Update an existing mandate.
|
||||
|
|
@ -273,7 +311,7 @@ def update_mandate(
|
|||
def delete_mandate(
|
||||
request: Request,
|
||||
mandateId: str = Path(..., description="ID of the mandate to delete"),
|
||||
currentUser: User = Depends(requireSysAdmin)
|
||||
currentUser: User = Depends(requireSysAdminRole)
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete a mandate.
|
||||
|
|
@ -339,7 +377,7 @@ def list_mandate_users(
|
|||
pagination: Optional pagination parameters (page, pageSize, search, filters, sort)
|
||||
"""
|
||||
# Check permission
|
||||
if not _hasMandateAdminRole(context, targetMandateId) and not context.isSysAdmin:
|
||||
if not _hasMandateAdminRole(context, targetMandateId) and not context.hasSysAdminRole:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Mandate-Admin role required"
|
||||
|
|
@ -510,7 +548,7 @@ def add_user_to_mandate(
|
|||
data: User ID and role IDs to assign
|
||||
"""
|
||||
# 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(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
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:
|
||||
"""
|
||||
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
|
||||
|
||||
# Must be in the same mandate context
|
||||
if str(context.mandateId) != str(mandateId):
|
||||
# If mandate context matches, check roles from context directly
|
||||
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
|
||||
|
||||
if not context.roleIds:
|
||||
return False
|
||||
|
||||
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
|
||||
# No mandate context (admin pages) — check via user's mandate memberships
|
||||
adminMandateIds = _getAdminMandateIds(context)
|
||||
return str(mandateId) in adminMandateIds
|
||||
|
||||
|
||||
def _isLastMandateAdmin(interface, mandateId: str, excludeUserId: str) -> bool:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,46 @@ from modules.datamodels.datamodelPagination import PaginationParams, PaginatedRe
|
|||
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]]:
|
||||
"""
|
||||
Apply filters and sorting to a list of items.
|
||||
|
|
@ -168,7 +208,7 @@ def get_user_options(
|
|||
if context.mandateId:
|
||||
result = appInterface.getUsersByMandate(str(context.mandateId), None)
|
||||
users = result.items if hasattr(result, 'items') else result
|
||||
elif context.isSysAdmin:
|
||||
elif context.hasSysAdminRole:
|
||||
users = appInterface.getAllUsers()
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
|
@ -222,7 +262,7 @@ def get_users(
|
|||
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)
|
||||
# SysAdmin without mandateId can see all users
|
||||
|
|
@ -250,7 +290,7 @@ def get_users(
|
|||
items=users,
|
||||
pagination=None
|
||||
)
|
||||
elif context.isSysAdmin:
|
||||
elif context.hasSysAdminRole:
|
||||
# SysAdmin without mandateId sees all users
|
||||
# Get all users via interface method (returns Pydantic User models)
|
||||
allUserModels = appInterface.getAllUsers()
|
||||
|
|
@ -288,11 +328,76 @@ def get_users(
|
|||
pagination=None
|
||||
)
|
||||
else:
|
||||
# Non-SysAdmin without mandateId - should not happen (getRequestContext enforces)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="X-Mandate-Id header is required"
|
||||
)
|
||||
# Non-SysAdmin without mandateId: aggregate users across all admin mandates
|
||||
rootInterface = getRootInterface()
|
||||
userMandates = rootInterface.getUserMandates(str(context.user.id))
|
||||
|
||||
# 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:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -325,7 +430,7 @@ def get_user(
|
|||
)
|
||||
|
||||
# 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))
|
||||
if not userMandate:
|
||||
raise HTTPException(
|
||||
|
|
@ -404,29 +509,31 @@ def update_user(
|
|||
) -> 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
|
||||
existingUser = appInterface.getUser(userId)
|
||||
existingUser = rootInterface.getUser(userId)
|
||||
if not existingUser:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_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
|
||||
updatedUser = appInterface.updateUser(userId, userData)
|
||||
updatedUser = rootInterface.updateUser(userId, userData)
|
||||
|
||||
if not updatedUser:
|
||||
raise HTTPException(
|
||||
|
|
@ -446,36 +553,19 @@ def reset_user_password(
|
|||
) -> Dict[str, Any]:
|
||||
"""
|
||||
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:
|
||||
# Check if current user is admin
|
||||
if not context.isSysAdmin:
|
||||
# Check admin permission (SysAdmin or MandateAdmin for this user)
|
||||
if not _isAdminForUser(context, userId):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only administrators can reset passwords"
|
||||
detail="Admin role required to reset passwords"
|
||||
)
|
||||
|
||||
# Get user interface
|
||||
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
|
||||
if len(newPassword) < 8:
|
||||
raise HTTPException(
|
||||
|
|
@ -622,7 +712,7 @@ def send_password_link(
|
|||
) -> Dict[str, Any]:
|
||||
"""
|
||||
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.
|
||||
Used when creating users without password or to help users who forgot their password.
|
||||
|
|
@ -634,6 +724,13 @@ def send_password_link(
|
|||
try:
|
||||
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
|
||||
appInterface = interfaceDbApp.getInterface(context.user)
|
||||
|
||||
|
|
@ -645,15 +742,6 @@ def send_password_link(
|
|||
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
|
||||
if not targetUser.email:
|
||||
raise HTTPException(
|
||||
|
|
@ -769,7 +857,7 @@ def delete_user(
|
|||
)
|
||||
|
||||
# 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))
|
||||
if not userMandate:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Respon
|
|||
|
||||
# Import auth modules
|
||||
from modules.auth import limiter, getCurrentUser
|
||||
from modules.auth.authentication import getRequestContext, RequestContext
|
||||
|
||||
# Import interfaces from feature containers
|
||||
import modules.interfaces.interfaceDbChat as interfaceDbChat
|
||||
|
|
@ -44,8 +45,11 @@ router = APIRouter(
|
|||
responses={404: {"description": "Not found"}}
|
||||
)
|
||||
|
||||
def getServiceChat(currentUser: User):
|
||||
return interfaceDbChat.getInterface(currentUser)
|
||||
def getServiceChat(ctx: RequestContext):
|
||||
# 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
|
||||
@router.get("/", response_model=PaginatedResponse[ChatWorkflow])
|
||||
|
|
@ -53,7 +57,7 @@ def getServiceChat(currentUser: User):
|
|||
def get_workflows(
|
||||
request: Request,
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
ctx: RequestContext = Depends(getRequestContext)
|
||||
) -> PaginatedResponse[ChatWorkflow]:
|
||||
"""
|
||||
Get workflows with optional pagination, sorting, and filtering.
|
||||
|
|
@ -81,7 +85,7 @@ def get_workflows(
|
|||
detail=f"Invalid pagination parameter: {str(e)}"
|
||||
)
|
||||
|
||||
appInterface = getInterface(currentUser)
|
||||
appInterface = getServiceChat(ctx)
|
||||
result = appInterface.getWorkflows(pagination=paginationParams)
|
||||
|
||||
# If pagination was requested, result is PaginatedResult with items as dicts
|
||||
|
|
@ -126,12 +130,12 @@ def get_workflows(
|
|||
def get_workflow(
|
||||
request: Request,
|
||||
workflowId: str = Path(..., description="ID of the workflow"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
ctx: RequestContext = Depends(getRequestContext)
|
||||
) -> ChatWorkflow:
|
||||
"""Get workflow by ID"""
|
||||
try:
|
||||
# Get workflow interface with current user context
|
||||
workflowInterface = getInterface(currentUser)
|
||||
workflowInterface = getServiceChat(ctx)
|
||||
|
||||
# Get workflow
|
||||
workflow = workflowInterface.getWorkflow(workflowId)
|
||||
|
|
@ -156,12 +160,12 @@ def update_workflow(
|
|||
request: Request,
|
||||
workflowId: str = Path(..., description="ID of the workflow to update"),
|
||||
workflowData: Dict[str, Any] = Body(...),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
ctx: RequestContext = Depends(getRequestContext)
|
||||
) -> ChatWorkflow:
|
||||
"""Update workflow by ID"""
|
||||
try:
|
||||
# Get workflow interface with current user context
|
||||
workflowInterface = getInterface(currentUser)
|
||||
workflowInterface = getServiceChat(ctx)
|
||||
|
||||
# Get workflow using interface method to check permissions
|
||||
workflow = workflowInterface.getWorkflow(workflowId)
|
||||
|
|
@ -203,12 +207,12 @@ def update_workflow(
|
|||
def get_workflow_status(
|
||||
request: Request,
|
||||
workflowId: str = Path(..., description="ID of the workflow"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
ctx: RequestContext = Depends(getRequestContext)
|
||||
) -> ChatWorkflow:
|
||||
"""Get the current status of a workflow."""
|
||||
try:
|
||||
# Get service center
|
||||
interfaceDbChat = getServiceChat(currentUser)
|
||||
interfaceDbChat = getServiceChat(ctx)
|
||||
|
||||
# Retrieve workflow
|
||||
workflow = interfaceDbChat.getWorkflow(workflowId)
|
||||
|
|
@ -235,7 +239,7 @@ def get_workflow_status(
|
|||
async def stop_workflow(
|
||||
request: Request,
|
||||
workflowId: str = Path(..., description="ID of the workflow to stop"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
ctx: RequestContext = Depends(getRequestContext)
|
||||
) -> ChatWorkflow:
|
||||
"""
|
||||
Stop a running workflow.
|
||||
|
|
@ -245,7 +249,7 @@ async def stop_workflow(
|
|||
from modules.workflows.automation import chatStop
|
||||
|
||||
# Get the workflow first to get mandateId
|
||||
interfaceChatDb = getServiceChat(currentUser)
|
||||
interfaceChatDb = getServiceChat(ctx)
|
||||
workflow = interfaceChatDb.getWorkflow(workflowId)
|
||||
|
||||
if not workflow:
|
||||
|
|
@ -257,7 +261,7 @@ async def stop_workflow(
|
|||
mandateId = workflow.get("mandateId") if isinstance(workflow, dict) else getattr(workflow, "mandateId", None)
|
||||
|
||||
# Stop the workflow
|
||||
stoppedWorkflow = await chatStop(currentUser, workflowId, mandateId=mandateId)
|
||||
stoppedWorkflow = await chatStop(ctx.user, workflowId, mandateId=mandateId)
|
||||
|
||||
return stoppedWorkflow
|
||||
|
||||
|
|
@ -279,7 +283,7 @@ def get_workflow_logs(
|
|||
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)"),
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
ctx: RequestContext = Depends(getRequestContext)
|
||||
) -> PaginatedResponse[ChatLog]:
|
||||
"""
|
||||
Get logs for a workflow with optional pagination, sorting, and filtering.
|
||||
|
|
@ -306,7 +310,7 @@ def get_workflow_logs(
|
|||
)
|
||||
|
||||
# Get service center
|
||||
interfaceDbChat = getServiceChat(currentUser)
|
||||
interfaceDbChat = getServiceChat(ctx)
|
||||
|
||||
# Verify workflow exists
|
||||
workflow = interfaceDbChat.getWorkflow(workflowId)
|
||||
|
|
@ -370,7 +374,7 @@ def get_workflow_messages(
|
|||
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)"),
|
||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
ctx: RequestContext = Depends(getRequestContext)
|
||||
) -> PaginatedResponse[ChatMessage]:
|
||||
"""
|
||||
Get messages for a workflow with optional pagination, sorting, and filtering.
|
||||
|
|
@ -397,7 +401,7 @@ def get_workflow_messages(
|
|||
)
|
||||
|
||||
# Get service center
|
||||
interfaceDbChat = getServiceChat(currentUser)
|
||||
interfaceDbChat = getServiceChat(ctx)
|
||||
|
||||
# Verify workflow exists
|
||||
workflow = interfaceDbChat.getWorkflow(workflowId)
|
||||
|
|
@ -460,19 +464,20 @@ def get_workflow_messages(
|
|||
def delete_workflow(
|
||||
request: Request,
|
||||
workflowId: str = Path(..., description="ID of the workflow to delete"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
ctx: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""Deletes a workflow and its associated data."""
|
||||
try:
|
||||
# Get service center
|
||||
interfaceDbChat = getServiceChat(currentUser)
|
||||
interfaceDbChat = getServiceChat(ctx)
|
||||
|
||||
# Check workflow access and permission using RBAC
|
||||
workflows = getRecordsetWithRBAC(
|
||||
interfaceDbChat.db,
|
||||
ChatWorkflow,
|
||||
currentUser,
|
||||
recordFilter={"id": workflowId}
|
||||
ctx.user,
|
||||
recordFilter={"id": workflowId},
|
||||
mandateId=ctx.mandateId
|
||||
)
|
||||
if not workflows:
|
||||
raise HTTPException(
|
||||
|
|
@ -520,12 +525,12 @@ def delete_workflow_message(
|
|||
request: Request,
|
||||
workflowId: str = Path(..., description="ID of the workflow"),
|
||||
messageId: str = Path(..., description="ID of the message to delete"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
ctx: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete a message from a workflow."""
|
||||
try:
|
||||
# Get service center
|
||||
interfaceDbChat = getServiceChat(currentUser)
|
||||
interfaceDbChat = getServiceChat(ctx)
|
||||
|
||||
# Verify workflow exists
|
||||
workflow = interfaceDbChat.getWorkflow(workflowId)
|
||||
|
|
@ -571,12 +576,12 @@ def delete_file_from_message(
|
|||
workflowId: str = Path(..., description="ID of the workflow"),
|
||||
messageId: str = Path(..., description="ID of the message"),
|
||||
fileId: str = Path(..., description="ID of the file to delete"),
|
||||
currentUser: User = Depends(getCurrentUser)
|
||||
ctx: RequestContext = Depends(getRequestContext)
|
||||
) -> Dict[str, Any]:
|
||||
"""Delete a file reference from a message in a workflow."""
|
||||
try:
|
||||
# Get service center
|
||||
interfaceDbChat = getServiceChat(currentUser)
|
||||
interfaceDbChat = getServiceChat(ctx)
|
||||
|
||||
# Verify workflow exists
|
||||
workflow = interfaceDbChat.getWorkflow(workflowId)
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ def export_user_data(
|
|||
"mandateName": mandateName,
|
||||
"enabled": um.enabled,
|
||||
"roleIds": roleIds,
|
||||
"joinedAt": um.createdAt
|
||||
"joinedAt": getattr(um, 'createdAt', None)
|
||||
})
|
||||
|
||||
# Feature access records using interface method
|
||||
|
|
@ -159,11 +159,11 @@ def export_user_data(
|
|||
invitationsCreatedList = [
|
||||
{
|
||||
"id": inv.id,
|
||||
"mandateId": inv.mandateId,
|
||||
"createdAt": inv.createdAt,
|
||||
"expiresAt": inv.expiresAt,
|
||||
"maxUses": inv.maxUses,
|
||||
"currentUses": inv.currentUses
|
||||
"mandateId": getattr(inv, 'mandateId', None),
|
||||
"createdAt": getattr(inv, 'createdAt', None),
|
||||
"expiresAt": getattr(inv, 'expiresAt', None),
|
||||
"maxUses": getattr(inv, 'maxUses', None),
|
||||
"currentUses": getattr(inv, 'currentUses', None)
|
||||
}
|
||||
for inv in invitationsCreated
|
||||
]
|
||||
|
|
@ -174,8 +174,8 @@ def export_user_data(
|
|||
invitationsUsedList = [
|
||||
{
|
||||
"id": inv.id,
|
||||
"mandateId": inv.mandateId,
|
||||
"usedAt": inv.usedAt
|
||||
"mandateId": getattr(inv, 'mandateId', None),
|
||||
"usedAt": getattr(inv, 'usedAt', None)
|
||||
}
|
||||
for inv in invitationsUsed
|
||||
]
|
||||
|
|
|
|||
|
|
@ -36,11 +36,14 @@ router = APIRouter(
|
|||
# =============================================================================
|
||||
|
||||
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)")
|
||||
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: Optional[str] = Field(None, description="Optional feature instance access")
|
||||
featureInstanceId: str = Field(..., description="Feature instance to grant access to")
|
||||
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)")
|
||||
expiresInHours: int = Field(
|
||||
72,
|
||||
|
|
@ -102,36 +105,46 @@ def create_invitation(
|
|||
context: RequestContext = Depends(getRequestContext)
|
||||
) -> 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
|
||||
with users to join the mandate with predefined roles.
|
||||
Requires SysAdmin or Mandate-Admin role. Creates a secure token that can be shared
|
||||
with users to join a feature instance with predefined roles.
|
||||
The mandateId is derived from the feature instance automatically.
|
||||
|
||||
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:
|
||||
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!
|
||||
# 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:
|
||||
role = rootInterface.getRole(roleId)
|
||||
if not role:
|
||||
|
|
@ -139,34 +152,20 @@ def create_invitation(
|
|||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Role '{roleId}' not found"
|
||||
)
|
||||
# Role must be global or belong to this mandate
|
||||
if role.mandateId and str(role.mandateId) != str(context.mandateId):
|
||||
# Role must belong to this feature instance
|
||||
if str(role.featureInstanceId or "") != data.featureInstanceId:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Role '{roleId}' belongs to a different mandate"
|
||||
)
|
||||
|
||||
# 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"
|
||||
detail=f"Role '{roleId}' does not belong to feature instance '{data.featureInstanceId}'"
|
||||
)
|
||||
|
||||
# Calculate expiration time
|
||||
currentTime = getUtcTimestamp()
|
||||
expiresAt = currentTime + (data.expiresInHours * 3600)
|
||||
|
||||
# Create invitation
|
||||
# Create invitation (mandateId derived from feature instance)
|
||||
invitation = Invitation(
|
||||
mandateId=str(context.mandateId),
|
||||
mandateId=mandateId,
|
||||
featureInstanceId=data.featureInstanceId,
|
||||
roleIds=data.roleIds,
|
||||
targetUsername=data.targetUsername,
|
||||
|
|
@ -628,42 +627,50 @@ def accept_invitation(
|
|||
roleIds = invitation.roleIds or []
|
||||
featureInstanceId = str(invitation.featureInstanceId) if invitation.featureInstanceId else None
|
||||
|
||||
# Check if user is already a member
|
||||
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
|
||||
# Grant feature access (creates FeatureAccess + auto-assigns mandate 'user' role via Regel 4)
|
||||
featureAccessId = None
|
||||
if featureInstanceId:
|
||||
existingAccess = rootInterface.getFeatureAccess(str(currentUser.id), featureInstanceId)
|
||||
if not existingAccess:
|
||||
# Create feature access with instance-level roles if any
|
||||
instanceRoleIds = [r for r in roleIds if _isInstanceRole(rootInterface, r, featureInstanceId)]
|
||||
if existingAccess:
|
||||
# Update existing access with additional roles
|
||||
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(
|
||||
userId=str(currentUser.id),
|
||||
featureInstanceId=featureInstanceId,
|
||||
roleIds=instanceRoleIds
|
||||
roleIds=roleIds
|
||||
)
|
||||
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
|
||||
rootInterface.db.recordModify(
|
||||
|
|
@ -707,7 +714,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
|
|||
"""
|
||||
Check if the user has mandate admin role in the current context.
|
||||
"""
|
||||
if context.isSysAdmin:
|
||||
if context.hasSysAdminRole:
|
||||
return True
|
||||
|
||||
if not context.roleIds:
|
||||
|
|
@ -720,7 +727,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
|
|||
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:
|
||||
if role.roleLabel == "admin" and not role.featureInstanceId:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -410,7 +410,7 @@ def _hasTriggerPermission(context: RequestContext) -> bool:
|
|||
Check if user has permission to trigger subscriptions.
|
||||
Requires admin or mandate-admin role.
|
||||
"""
|
||||
if context.isSysAdmin:
|
||||
if context.hasSysAdminRole:
|
||||
return True
|
||||
|
||||
if not context.roleIds:
|
||||
|
|
|
|||
|
|
@ -331,7 +331,7 @@ def executeAction(
|
|||
# Execute action based on notification type
|
||||
actionResult = None
|
||||
|
||||
if notification.get("type") == NotificationType.INVITATION.value:
|
||||
if notification.type == NotificationType.INVITATION.value:
|
||||
actionResult = _handleInvitationAction(
|
||||
notification=notification,
|
||||
actionId=actionRequest.actionId,
|
||||
|
|
@ -381,7 +381,7 @@ def _handleInvitationAction(
|
|||
from modules.datamodels.datamodelUam import Mandate
|
||||
from modules.datamodels.datamodelMembership import UserMandate
|
||||
|
||||
invitationId = notification.get("referenceId")
|
||||
invitationId = notification.referenceId
|
||||
if not invitationId:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ async def _validateInstanceAccess(instanceId: str, context: RequestContext) -> s
|
|||
status_code=400,
|
||||
detail=f"Instance '{instanceId}' is not a realestate instance"
|
||||
)
|
||||
if not context.isSysAdmin:
|
||||
if not context.hasSysAdminRole:
|
||||
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
||||
hasAccess = any(
|
||||
str(fa.featureInstanceId) == instanceId and fa.enabled
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ def _checkUiPermission(roleIds: List[str], objectKey: str) -> bool:
|
|||
if rule.item == objectKey:
|
||||
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(".*"):
|
||||
prefix = rule.item[:-2]
|
||||
if objectKey.startswith(prefix):
|
||||
|
|
@ -347,28 +347,21 @@ def _buildStaticBlocks(
|
|||
blocks = []
|
||||
|
||||
for section in NAVIGATION_SECTIONS:
|
||||
# Skip admin-only sections for non-admins
|
||||
if section.get("adminOnly") and not isSysAdmin:
|
||||
continue
|
||||
|
||||
# Filter items based on permissions
|
||||
# Filter items based on UI AccessRules (ui.admin.*, ui.billing.*, etc.)
|
||||
filteredItems = []
|
||||
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
|
||||
if item.get("public"):
|
||||
filteredItems.append(_formatBlockItem(item, language))
|
||||
continue
|
||||
|
||||
# SysAdmin sees everything
|
||||
if isSysAdmin:
|
||||
filteredItems.append(_formatBlockItem(item, language))
|
||||
# SysAdmin-only items (e.g. automation-events) require isSysAdmin
|
||||
if item.get("sysAdminOnly") and not isSysAdmin:
|
||||
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"]):
|
||||
filteredItems.append(_formatBlockItem(item, language))
|
||||
|
||||
|
|
@ -459,7 +452,7 @@ def get_navigation(
|
|||
}
|
||||
"""
|
||||
try:
|
||||
isSysAdmin = reqContext.isSysAdmin
|
||||
isSysAdmin = reqContext.hasSysAdminRole
|
||||
userId = str(reqContext.user.id) if reqContext.user else None
|
||||
|
||||
# Get user's role IDs for permission checking
|
||||
|
|
|
|||
|
|
@ -82,8 +82,23 @@ class RbacClass:
|
|||
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:
|
||||
# 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(
|
||||
view=True,
|
||||
read=AccessLevel.ALL,
|
||||
|
|
@ -178,8 +193,8 @@ class RbacClass:
|
|||
roleIds = set() # Use set to avoid duplicates
|
||||
|
||||
try:
|
||||
# Load roles from the requested mandate
|
||||
if mandateId:
|
||||
# Specific mandate context: load roles from that mandate only
|
||||
userMandateRecords = self.dbApp.getRecordset(
|
||||
UserMandate,
|
||||
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")]
|
||||
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)
|
||||
if featureInstanceId:
|
||||
# Specific feature instance: load roles from that instance only
|
||||
featureAccessRecords = self.dbApp.getRecordset(
|
||||
FeatureAccess,
|
||||
recordFilter={
|
||||
|
|
@ -217,6 +250,21 @@ class RbacClass:
|
|||
)
|
||||
|
||||
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:
|
||||
logger.error(f"Error loading role IDs for user {user.id}: {e}")
|
||||
|
|
|
|||
|
|
@ -64,9 +64,7 @@ class ExtractionService:
|
|||
|
||||
results: List[ContentExtracted] = []
|
||||
|
||||
# Lazy import to avoid circular deps and heavy init at module import
|
||||
from modules.interfaces.interfaceDbManagement import getInterface
|
||||
dbInterface = getInterface()
|
||||
dbInterface = self.services.interfaceDbComponent
|
||||
|
||||
totalDocs = len(documents)
|
||||
|
||||
|
|
|
|||
|
|
@ -155,14 +155,14 @@ class UtilsService:
|
|||
# Silent fail to never break main flow
|
||||
pass
|
||||
|
||||
def storeDebugMessageAndDocuments(self, message, currentUser):
|
||||
def storeDebugMessageAndDocuments(self, message, currentUser, mandateId=None, featureInstanceId=None):
|
||||
"""
|
||||
Wrapper to store debug messages and documents via interfaceDbChat.
|
||||
Mirrors storeDebugMessageAndDocuments() in modules.interfaces.interfaceDbChat.
|
||||
"""
|
||||
try:
|
||||
from modules.interfaces.interfaceDbChat import storeDebugMessageAndDocuments as _storeDebugMessageAndDocuments
|
||||
_storeDebugMessageAndDocuments(message, currentUser)
|
||||
_storeDebugMessageAndDocuments(message, currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
|
||||
except Exception:
|
||||
# Silent fail to never break main flow
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -196,11 +196,12 @@ NAVIGATION_SECTIONS = [
|
|||
{
|
||||
"id": "admin-feature-roles",
|
||||
"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",
|
||||
"path": "/admin/feature-roles",
|
||||
"order": 50,
|
||||
"adminOnly": True,
|
||||
"sysAdminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-billing",
|
||||
|
|
@ -210,6 +211,17 @@ NAVIGATION_SECTIONS = [
|
|||
"path": "/admin/billing",
|
||||
"order": 60,
|
||||
"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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -158,10 +158,12 @@ async def executeAutomation(automationId: str, automation, creatorUser: User, se
|
|||
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)")
|
||||
|
||||
# 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"
|
||||
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}")
|
||||
|
||||
# Save execution log (bypasses RBAC — system operation, not a user edit)
|
||||
|
|
|
|||
Loading…
Reference in a new issue