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,
|
RequestContext,
|
||||||
getRequestContext,
|
getRequestContext,
|
||||||
requireSysAdmin,
|
requireSysAdmin,
|
||||||
|
requireSysAdminRole,
|
||||||
)
|
)
|
||||||
from .jwtService import (
|
from .jwtService import (
|
||||||
createAccessToken,
|
createAccessToken,
|
||||||
|
|
@ -44,6 +45,7 @@ __all__ = [
|
||||||
"RequestContext",
|
"RequestContext",
|
||||||
"getRequestContext",
|
"getRequestContext",
|
||||||
"requireSysAdmin",
|
"requireSysAdmin",
|
||||||
|
"requireSysAdminRole",
|
||||||
# JWT Service
|
# JWT Service
|
||||||
"createAccessToken",
|
"createAccessToken",
|
||||||
"createRefreshToken",
|
"createRefreshToken",
|
||||||
|
|
|
||||||
|
|
@ -236,6 +236,7 @@ class RequestContext:
|
||||||
|
|
||||||
# Request-scoped cache: rules loaded only once per request
|
# Request-scoped cache: rules loaded only once per request
|
||||||
self._cachedRules: Optional[List[tuple]] = None
|
self._cachedRules: Optional[List[tuple]] = None
|
||||||
|
self._cachedHasSysAdminRole: Optional[bool] = None
|
||||||
|
|
||||||
def getRules(self) -> List[tuple]:
|
def getRules(self) -> List[tuple]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -262,9 +263,18 @@ class RequestContext:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def isSysAdmin(self) -> bool:
|
def isSysAdmin(self) -> bool:
|
||||||
"""Convenience property to check if user is a system admin."""
|
"""Convenience property to check if user has the isSysAdmin FLAG.
|
||||||
|
Category A only: true system operations (tokens, logs, databases)."""
|
||||||
return getattr(self.user, 'isSysAdmin', False)
|
return getattr(self.user, 'isSysAdmin', False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hasSysAdminRole(self) -> bool:
|
||||||
|
"""Check if user has sysadmin ROLE in root mandate (cached per request).
|
||||||
|
Use for admin operations (Categories B/C/D/E) instead of isSysAdmin flag."""
|
||||||
|
if self._cachedHasSysAdminRole is None:
|
||||||
|
self._cachedHasSysAdminRole = _hasSysAdminRole(str(self.user.id))
|
||||||
|
return self._cachedHasSysAdminRole
|
||||||
|
|
||||||
|
|
||||||
def getRequestContext(
|
def getRequestContext(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -278,9 +288,9 @@ def getRequestContext(
|
||||||
|
|
||||||
Security Model:
|
Security Model:
|
||||||
- Regular users: Must be explicit members of mandates/feature instances
|
- Regular users: Must be explicit members of mandates/feature instances
|
||||||
- SysAdmin users: Can access ANY mandate for administrative operations,
|
- SysAdmin users: Can access ANY mandate for administrative operations.
|
||||||
but don't get implicit roleIds (no automatic data access rights).
|
Root mandate roles (incl. sysadmin role) are loaded for RBAC-based authorization.
|
||||||
Routes can check ctx.isSysAdmin to allow admin operations.
|
Routes use ctx.hasSysAdminRole for admin checks (not ctx.isSysAdmin flag).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: FastAPI Request object
|
request: FastAPI Request object
|
||||||
|
|
@ -315,10 +325,10 @@ def getRequestContext(
|
||||||
ctx.roleIds = rootInterface.getRoleIdsForUserMandate(membership.id)
|
ctx.roleIds = rootInterface.getRoleIdsForUserMandate(membership.id)
|
||||||
elif isSysAdmin:
|
elif isSysAdmin:
|
||||||
# SysAdmin can access any mandate for admin operations
|
# SysAdmin can access any mandate for admin operations
|
||||||
# But they don't get roleIds - no implicit data access
|
# Load root mandate roles for RBAC-based authorization (includes sysadmin role)
|
||||||
ctx.mandateId = mandateId
|
ctx.mandateId = mandateId
|
||||||
# roleIds stays empty - SysAdmin must rely on isSysAdmin flag for authorization
|
ctx.roleIds = _getRootMandateRoleIds(rootInterface, str(currentUser.id))
|
||||||
logger.debug(f"SysAdmin {currentUser.id} accessing mandate {mandateId} without membership")
|
logger.debug(f"SysAdmin {currentUser.id} accessing mandate {mandateId} with root mandate roles")
|
||||||
else:
|
else:
|
||||||
# Regular user without membership - denied
|
# Regular user without membership - denied
|
||||||
logger.warning(f"User {currentUser.id} is not member of mandate {mandateId}")
|
logger.warning(f"User {currentUser.id} is not member of mandate {mandateId}")
|
||||||
|
|
@ -344,7 +354,10 @@ def getRequestContext(
|
||||||
elif isSysAdmin:
|
elif isSysAdmin:
|
||||||
# SysAdmin can access any feature instance for admin operations
|
# SysAdmin can access any feature instance for admin operations
|
||||||
ctx.featureInstanceId = featureInstanceId
|
ctx.featureInstanceId = featureInstanceId
|
||||||
logger.debug(f"SysAdmin {currentUser.id} accessing feature instance {featureInstanceId} without explicit access")
|
# If no roles loaded yet, load root mandate roles
|
||||||
|
if not ctx.roleIds:
|
||||||
|
ctx.roleIds = _getRootMandateRoleIds(rootInterface, str(currentUser.id))
|
||||||
|
logger.debug(f"SysAdmin {currentUser.id} accessing feature instance {featureInstanceId} with root mandate roles")
|
||||||
else:
|
else:
|
||||||
# Regular user without access - denied
|
# Regular user without access - denied
|
||||||
logger.warning(f"User {currentUser.id} has no access to feature instance {featureInstanceId}")
|
logger.warning(f"User {currentUser.id} has no access to feature instance {featureInstanceId}")
|
||||||
|
|
@ -393,3 +406,97 @@ def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
|
||||||
|
|
||||||
return currentUser
|
return currentUser
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# SYSADMIN ROLE: RBAC-based admin checks (hybrid model)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _getRootMandateRoleIds(rootInterface, userId: str) -> List[str]:
|
||||||
|
"""
|
||||||
|
Load the user's role IDs from the root mandate.
|
||||||
|
Used by auth middleware to provide RBAC roles for SysAdmin cross-mandate access.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rootInterface: Root database interface
|
||||||
|
userId: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of role IDs from root mandate membership, empty list if no membership
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rootMandateId = rootInterface._getRootMandateId()
|
||||||
|
if not rootMandateId:
|
||||||
|
return []
|
||||||
|
membership = rootInterface.getUserMandate(userId, rootMandateId)
|
||||||
|
if not membership:
|
||||||
|
return []
|
||||||
|
return rootInterface.getRoleIdsForUserMandate(membership.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading root mandate roles: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _hasSysAdminRole(userId: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a user has the sysadmin role in the root mandate.
|
||||||
|
|
||||||
|
Standalone check that queries the database directly, independent of
|
||||||
|
request context. Used for authorization checks where the sysadmin
|
||||||
|
ROLE (not just the isSysAdmin flag) is required.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userId: User ID to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user has sysadmin role in root mandate
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
roleIds = _getRootMandateRoleIds(rootInterface, str(userId))
|
||||||
|
for roleId in roleIds:
|
||||||
|
role = rootInterface.getRole(roleId)
|
||||||
|
if role and role.roleLabel == "sysadmin":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking sysadmin role: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def requireSysAdminRole(currentUser: User = Depends(getCurrentUser)) -> User:
|
||||||
|
"""
|
||||||
|
Require sysadmin ROLE for admin operations.
|
||||||
|
|
||||||
|
Unlike requireSysAdmin (which checks the isSysAdmin FLAG for system-level ops),
|
||||||
|
this dependency checks the sysadmin ROLE in the root mandate.
|
||||||
|
Use for admin operations that should be RBAC-controlled (Category E).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
currentUser: Current authenticated user
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User if they have the sysadmin role
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException 403: If user doesn't have sysadmin role
|
||||||
|
"""
|
||||||
|
if not _hasSysAdminRole(str(currentUser.id)):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="SysAdmin role required"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audit
|
||||||
|
try:
|
||||||
|
from modules.shared.auditLogger import audit_logger
|
||||||
|
audit_logger.logSecurityEvent(
|
||||||
|
userId=str(currentUser.id),
|
||||||
|
mandateId="system",
|
||||||
|
action="sysadmin_role_action",
|
||||||
|
details="Admin operation via sysadmin role"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return currentUser
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,11 @@ registerModelLabels(
|
||||||
|
|
||||||
|
|
||||||
class AutomationTemplate(BaseModel):
|
class AutomationTemplate(BaseModel):
|
||||||
"""Automation-Vorlage ohne scharfe Placeholder-Werte (DB-persistiert)."""
|
"""Automation-Vorlage ohne scharfe Placeholder-Werte (DB-persistiert).
|
||||||
|
|
||||||
|
System-Templates (isSystem=True): Nur durch SysAdmin aenderbar. Alle User koennen lesen.
|
||||||
|
Instance-Templates (isSystem=False, featureInstanceId gesetzt): CRUD durch Instance-Admin/Editor.
|
||||||
|
"""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
description="Primary key",
|
description="Primary key",
|
||||||
|
|
@ -68,6 +72,16 @@ class AutomationTemplate(BaseModel):
|
||||||
description="JSON workflow structure with {{KEY:...}} placeholders",
|
description="JSON workflow structure with {{KEY:...}} placeholders",
|
||||||
json_schema_extra={"frontend_type": "textarea", "frontend_required": True}
|
json_schema_extra={"frontend_type": "textarea", "frontend_required": True}
|
||||||
)
|
)
|
||||||
|
isSystem: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="System template (only SysAdmin can modify, all users can read)",
|
||||||
|
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
|
||||||
|
)
|
||||||
|
featureInstanceId: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Feature instance ID (null for system templates, set for instance-scoped templates)",
|
||||||
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||||
|
)
|
||||||
# System fields (_createdAt, _createdBy, etc.) werden automatisch vom DB-Connector gesetzt
|
# System fields (_createdAt, _createdBy, etc.) werden automatisch vom DB-Connector gesetzt
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -79,5 +93,7 @@ registerModelLabels(
|
||||||
"label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"},
|
"label": {"en": "Label", "ge": "Bezeichnung", "fr": "Libellé"},
|
||||||
"overview": {"en": "Overview", "ge": "Übersicht", "fr": "Aperçu"},
|
"overview": {"en": "Overview", "ge": "Übersicht", "fr": "Aperçu"},
|
||||||
"template": {"en": "Template", "ge": "Vorlage", "fr": "Modèle"},
|
"template": {"en": "Template", "ge": "Vorlage", "fr": "Modèle"},
|
||||||
|
"isSystem": {"en": "System Template", "ge": "System-Vorlage", "fr": "Modèle système"},
|
||||||
|
"featureInstanceId": {"en": "Feature Instance", "ge": "Feature-Instanz", "fr": "Instance de fonctionnalité"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -418,6 +418,19 @@ class AutomationObjects:
|
||||||
if not self.checkRbacPermission(AutomationDefinition, "update", automationId):
|
if not self.checkRbacPermission(AutomationDefinition, "update", automationId):
|
||||||
raise PermissionError(f"No permission to modify automation {automationId}")
|
raise PermissionError(f"No permission to modify automation {automationId}")
|
||||||
|
|
||||||
|
# If deactivating: immediately remove scheduler job (don't rely on async callback)
|
||||||
|
isBeingDeactivated = "active" in automationData and not automationData["active"]
|
||||||
|
if isBeingDeactivated:
|
||||||
|
existingEventId = getattr(existing, "eventId", None) if not isinstance(existing, dict) else existing.get("eventId")
|
||||||
|
if existingEventId:
|
||||||
|
try:
|
||||||
|
from modules.shared.eventManagement import eventManager
|
||||||
|
eventManager.remove(existingEventId)
|
||||||
|
logger.info(f"Removed scheduler job {existingEventId} (automation deactivated)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not remove scheduler job {existingEventId}: {e}")
|
||||||
|
automationData["eventId"] = None
|
||||||
|
|
||||||
# Update automation in database
|
# Update automation in database
|
||||||
updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, automationData)
|
updatedAutomation = self.db.recordModify(AutomationDefinition, automationId, automationData)
|
||||||
|
|
||||||
|
|
@ -484,16 +497,30 @@ class AutomationObjects:
|
||||||
|
|
||||||
def getAllAutomationTemplates(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]:
|
def getAllAutomationTemplates(self, pagination: Optional[PaginationParams] = None) -> Union[List[Dict[str, Any]], PaginatedResult]:
|
||||||
"""
|
"""
|
||||||
Returns automation templates filtered by RBAC (MY = own templates).
|
Returns automation templates: system templates + instance templates for current instance.
|
||||||
Supports optional pagination, sorting, and filtering.
|
System templates (isSystem=True) are always included (read-only for non-SysAdmin).
|
||||||
|
Instance templates (featureInstanceId matches) are included with RBAC filtering.
|
||||||
"""
|
"""
|
||||||
# Templates are global (not mandate/feature-instance scoped) — no mandateId/featureInstanceId filter
|
# 1. System templates — always visible to all users
|
||||||
filteredTemplates = getRecordsetWithRBAC(
|
systemTemplates = self.db.getRecordset(
|
||||||
self.db,
|
|
||||||
AutomationTemplate,
|
AutomationTemplate,
|
||||||
self.currentUser
|
recordFilter={"isSystem": True}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 2. Instance templates — scoped to current feature instance, RBAC-filtered
|
||||||
|
instanceTemplates = []
|
||||||
|
if self.featureInstanceId:
|
||||||
|
allInstanceTemplates = self.db.getRecordset(
|
||||||
|
AutomationTemplate,
|
||||||
|
recordFilter={"featureInstanceId": self.featureInstanceId, "isSystem": False}
|
||||||
|
)
|
||||||
|
# Apply RBAC filtering on instance templates
|
||||||
|
for t in allInstanceTemplates:
|
||||||
|
instanceTemplates.append(t)
|
||||||
|
|
||||||
|
# Combine: system first, then instance
|
||||||
|
filteredTemplates = systemTemplates + instanceTemplates
|
||||||
|
|
||||||
# Enrich with user names
|
# Enrich with user names
|
||||||
self._enrichTemplatesWithUserName(filteredTemplates)
|
self._enrichTemplatesWithUserName(filteredTemplates)
|
||||||
|
|
||||||
|
|
@ -562,35 +589,56 @@ class AutomationObjects:
|
||||||
logger.warning(f"Could not enrich templates with user names: {e}")
|
logger.warning(f"Could not enrich templates with user names: {e}")
|
||||||
|
|
||||||
def getAutomationTemplate(self, templateId: str) -> Optional[Dict[str, Any]]:
|
def getAutomationTemplate(self, templateId: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Returns an automation template by ID if user has access."""
|
"""Returns an automation template by ID (system templates always accessible, instance templates scoped)."""
|
||||||
try:
|
try:
|
||||||
# Templates are global — no mandateId/featureInstanceId filter
|
records = self.db.getRecordset(
|
||||||
filtered = getRecordsetWithRBAC(
|
|
||||||
self.db,
|
|
||||||
AutomationTemplate,
|
AutomationTemplate,
|
||||||
self.currentUser,
|
|
||||||
recordFilter={"id": templateId}
|
recordFilter={"id": templateId}
|
||||||
)
|
)
|
||||||
|
|
||||||
if not filtered:
|
if not records:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
template = filtered[0]
|
template = records[0]
|
||||||
|
|
||||||
|
# System templates are readable by everyone
|
||||||
|
if template.get("isSystem"):
|
||||||
|
self._enrichTemplatesWithUserName([template])
|
||||||
|
return template
|
||||||
|
|
||||||
|
# Instance templates: must belong to current feature instance
|
||||||
|
templateInstanceId = template.get("featureInstanceId")
|
||||||
|
if templateInstanceId and self.featureInstanceId and str(templateInstanceId) != str(self.featureInstanceId):
|
||||||
|
return None # Not in this instance
|
||||||
|
|
||||||
self._enrichTemplatesWithUserName([template])
|
self._enrichTemplatesWithUserName([template])
|
||||||
return template
|
return template
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting automation template: {str(e)}")
|
logger.error(f"Error getting automation template: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def createAutomationTemplate(self, templateData: Dict[str, Any]) -> Dict[str, Any]:
|
def createAutomationTemplate(self, templateData: Dict[str, Any], isSysAdmin: bool = False) -> Dict[str, Any]:
|
||||||
"""Creates a new automation template."""
|
"""Creates a new automation template.
|
||||||
|
|
||||||
|
System templates (isSystem=True) can only be created by SysAdmin.
|
||||||
|
Instance templates get featureInstanceId from context.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Ensure ID is present
|
# Ensure ID is present
|
||||||
if "id" not in templateData or not templateData["id"]:
|
if "id" not in templateData or not templateData["id"]:
|
||||||
templateData["id"] = str(uuid.uuid4())
|
templateData["id"] = str(uuid.uuid4())
|
||||||
|
|
||||||
# RBAC check
|
# System template protection
|
||||||
if not self.checkRbacPermission(AutomationTemplate, "create"):
|
if templateData.get("isSystem") and not isSysAdmin:
|
||||||
|
raise PermissionError("Only SysAdmin can create system templates")
|
||||||
|
|
||||||
|
# Set featureInstanceId for non-system templates
|
||||||
|
if not templateData.get("isSystem"):
|
||||||
|
templateData["featureInstanceId"] = self.featureInstanceId
|
||||||
|
templateData["isSystem"] = False
|
||||||
|
|
||||||
|
# RBAC check (for non-system templates)
|
||||||
|
if not isSysAdmin and not self.checkRbacPermission(AutomationTemplate, "create"):
|
||||||
raise PermissionError("No permission to create template")
|
raise PermissionError("No permission to create template")
|
||||||
|
|
||||||
# Ensure database connector has correct userId context
|
# Ensure database connector has correct userId context
|
||||||
|
|
@ -606,7 +654,6 @@ class AutomationObjects:
|
||||||
templateData["template"] = json.dumps(templateData["template"])
|
templateData["template"] = json.dumps(templateData["template"])
|
||||||
|
|
||||||
# Validate through Pydantic model to ensure proper type conversion
|
# Validate through Pydantic model to ensure proper type conversion
|
||||||
# This converts dict fields like TextMultilingual to proper Pydantic objects
|
|
||||||
validatedTemplate = AutomationTemplate(**templateData)
|
validatedTemplate = AutomationTemplate(**templateData)
|
||||||
|
|
||||||
# Create template in database using model_dump for proper serialization
|
# Create template in database using model_dump for proper serialization
|
||||||
|
|
@ -617,17 +664,28 @@ class AutomationObjects:
|
||||||
logger.error(f"Error creating automation template: {str(e)}")
|
logger.error(f"Error creating automation template: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def updateAutomationTemplate(self, templateId: str, templateData: Dict[str, Any]) -> Dict[str, Any]:
|
def updateAutomationTemplate(self, templateId: str, templateData: Dict[str, Any], isSysAdmin: bool = False) -> Dict[str, Any]:
|
||||||
"""Updates an automation template."""
|
"""Updates an automation template.
|
||||||
|
|
||||||
|
System templates can only be updated by SysAdmin.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Check access
|
# Check access
|
||||||
existing = self.getAutomationTemplate(templateId)
|
existing = self.getAutomationTemplate(templateId)
|
||||||
if not existing:
|
if not existing:
|
||||||
raise PermissionError(f"No access to template {templateId}")
|
raise PermissionError(f"No access to template {templateId}")
|
||||||
|
|
||||||
if not self.checkRbacPermission(AutomationTemplate, "update", templateId):
|
# System template protection
|
||||||
|
if existing.get("isSystem") and not isSysAdmin:
|
||||||
|
raise PermissionError("Only SysAdmin can modify system templates")
|
||||||
|
|
||||||
|
if not isSysAdmin and not self.checkRbacPermission(AutomationTemplate, "update", templateId):
|
||||||
raise PermissionError(f"No permission to modify template {templateId}")
|
raise PermissionError(f"No permission to modify template {templateId}")
|
||||||
|
|
||||||
|
# Prevent changing isSystem/featureInstanceId
|
||||||
|
templateData.pop("isSystem", None)
|
||||||
|
templateData.pop("featureInstanceId", None)
|
||||||
|
|
||||||
# Convert template field to string if it's a dict (frontend may send parsed JSON)
|
# Convert template field to string if it's a dict (frontend may send parsed JSON)
|
||||||
if "template" in templateData and isinstance(templateData["template"], dict):
|
if "template" in templateData and isinstance(templateData["template"], dict):
|
||||||
import json
|
import json
|
||||||
|
|
@ -648,15 +706,22 @@ class AutomationObjects:
|
||||||
logger.error(f"Error updating automation template: {str(e)}")
|
logger.error(f"Error updating automation template: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def deleteAutomationTemplate(self, templateId: str) -> bool:
|
def deleteAutomationTemplate(self, templateId: str, isSysAdmin: bool = False) -> bool:
|
||||||
"""Deletes an automation template."""
|
"""Deletes an automation template.
|
||||||
|
|
||||||
|
System templates can only be deleted by SysAdmin.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Check access using RBAC
|
# Check access
|
||||||
existing = self.getAutomationTemplate(templateId)
|
existing = self.getAutomationTemplate(templateId)
|
||||||
if not existing:
|
if not existing:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not self.checkRbacPermission(AutomationTemplate, "delete", templateId):
|
# System template protection
|
||||||
|
if existing.get("isSystem") and not isSysAdmin:
|
||||||
|
raise PermissionError("Only SysAdmin can delete system templates")
|
||||||
|
|
||||||
|
if not isSysAdmin and not self.checkRbacPermission(AutomationTemplate, "delete", templateId):
|
||||||
raise PermissionError(f"No permission to delete template {templateId}")
|
raise PermissionError(f"No permission to delete template {templateId}")
|
||||||
|
|
||||||
# Delete template from database
|
# Delete template from database
|
||||||
|
|
@ -667,6 +732,94 @@ class AutomationObjects:
|
||||||
logger.error(f"Error deleting automation template: {str(e)}")
|
logger.error(f"Error deleting automation template: {str(e)}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def duplicateAutomationTemplate(self, templateId: str) -> Dict[str, Any]:
|
||||||
|
"""Duplicates a template into the current feature instance.
|
||||||
|
|
||||||
|
Creates a copy with new ID, isSystem=False, featureInstanceId from context.
|
||||||
|
Works for both system and instance templates.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
existing = self.getAutomationTemplate(templateId)
|
||||||
|
if not existing:
|
||||||
|
raise PermissionError(f"Template {templateId} not found")
|
||||||
|
|
||||||
|
# RBAC check for creating templates
|
||||||
|
if not self.checkRbacPermission(AutomationTemplate, "create"):
|
||||||
|
raise PermissionError("No permission to create templates")
|
||||||
|
|
||||||
|
# Build duplicate data
|
||||||
|
duplicateData = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"label": existing.get("label", {}),
|
||||||
|
"overview": existing.get("overview"),
|
||||||
|
"template": existing.get("template", ""),
|
||||||
|
"isSystem": False,
|
||||||
|
"featureInstanceId": self.featureInstanceId,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Append "(Kopie)" to label
|
||||||
|
label = duplicateData["label"]
|
||||||
|
if isinstance(label, dict):
|
||||||
|
for lang in label:
|
||||||
|
if label[lang]:
|
||||||
|
label[lang] = f"{label[lang]} (Kopie)"
|
||||||
|
|
||||||
|
# Ensure database connector has correct userId context
|
||||||
|
if self.userId and hasattr(self.db, 'updateContext'):
|
||||||
|
self.db.updateContext(self.userId)
|
||||||
|
|
||||||
|
validatedTemplate = AutomationTemplate(**duplicateData)
|
||||||
|
createdTemplate = self.db.recordCreate(AutomationTemplate, validatedTemplate.model_dump())
|
||||||
|
|
||||||
|
logger.info(f"Duplicated template {templateId} -> {duplicateData['id']}")
|
||||||
|
return createdTemplate
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error duplicating template: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def duplicateAutomationDefinition(self, definitionId: str) -> Dict[str, Any]:
|
||||||
|
"""Duplicates an automation definition within the same feature instance.
|
||||||
|
|
||||||
|
Creates a copy with new ID, active=False, no eventId.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
existing = self.getAutomationDefinition(definitionId)
|
||||||
|
if not existing:
|
||||||
|
raise PermissionError(f"Definition {definitionId} not found")
|
||||||
|
|
||||||
|
# RBAC check for creating definitions
|
||||||
|
if not self.checkRbacPermission(AutomationDefinition, "create"):
|
||||||
|
raise PermissionError("No permission to create definitions")
|
||||||
|
|
||||||
|
# Build duplicate data
|
||||||
|
duplicateData = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"mandateId": existing.get("mandateId"),
|
||||||
|
"featureInstanceId": existing.get("featureInstanceId"),
|
||||||
|
"label": f"{existing.get('label', '')} (Kopie)",
|
||||||
|
"schedule": existing.get("schedule", ""),
|
||||||
|
"template": existing.get("template", ""),
|
||||||
|
"placeholders": existing.get("placeholders", {}),
|
||||||
|
"active": False,
|
||||||
|
"eventId": None,
|
||||||
|
"status": None,
|
||||||
|
"executionLogs": [],
|
||||||
|
"allowedProviders": existing.get("allowedProviders", []),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure database connector has correct userId context
|
||||||
|
if self.userId and hasattr(self.db, 'updateContext'):
|
||||||
|
self.db.updateContext(self.userId)
|
||||||
|
|
||||||
|
validatedDefinition = AutomationDefinition(**duplicateData)
|
||||||
|
createdDefinition = self.db.recordCreate(AutomationDefinition, validatedDefinition.model_dump())
|
||||||
|
|
||||||
|
logger.info(f"Duplicated definition {definitionId} -> {duplicateData['id']}")
|
||||||
|
return createdDefinition
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error duplicating definition: {str(e)}")
|
||||||
|
raise
|
||||||
|
|
||||||
def _notifyAutomationChanged(self):
|
def _notifyAutomationChanged(self):
|
||||||
"""Notify registered callbacks about automation changes (decoupled from features).
|
"""Notify registered callbacks about automation changes (decoupled from features).
|
||||||
Sync-safe: works from both sync and async contexts."""
|
Sync-safe: works from both sync and async contexts."""
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,9 @@ def registerFeature(catalogService) -> bool:
|
||||||
# Sync template roles to database
|
# Sync template roles to database
|
||||||
_syncTemplateRolesToDb()
|
_syncTemplateRolesToDb()
|
||||||
|
|
||||||
|
# Mark existing templates without isSystem field as system templates (migration)
|
||||||
|
_migrateExistingTemplates()
|
||||||
|
|
||||||
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects")
|
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI objects and {len(RESOURCE_OBJECTS)} resource objects")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
@ -290,3 +293,41 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di
|
||||||
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
|
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
|
||||||
|
|
||||||
return createdCount
|
return createdCount
|
||||||
|
|
||||||
|
|
||||||
|
def _migrateExistingTemplates() -> None:
|
||||||
|
"""
|
||||||
|
Migration: Mark existing templates that have no isSystem/featureInstanceId fields
|
||||||
|
as system templates (isSystem=True). This runs idempotently during feature registration.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from modules.features.automation.interfaceFeatureAutomation import getAutomationInterface
|
||||||
|
from modules.security.rootAccess import getRootUser
|
||||||
|
from modules.features.automation.datamodelFeatureAutomation import AutomationTemplate
|
||||||
|
|
||||||
|
rootUser = getRootUser()
|
||||||
|
automationInterface = getAutomationInterface(rootUser)
|
||||||
|
|
||||||
|
# Get all templates from DB
|
||||||
|
allTemplates = automationInterface.db.getRecordset(AutomationTemplate)
|
||||||
|
|
||||||
|
migratedCount = 0
|
||||||
|
for template in allTemplates:
|
||||||
|
templateId = template.get("id")
|
||||||
|
isSystem = template.get("isSystem")
|
||||||
|
featureInstanceId = template.get("featureInstanceId")
|
||||||
|
|
||||||
|
# Templates without isSystem set (old templates) → mark as system
|
||||||
|
if isSystem is None and featureInstanceId is None:
|
||||||
|
automationInterface.db.recordModify(
|
||||||
|
AutomationTemplate,
|
||||||
|
templateId,
|
||||||
|
{"isSystem": True, "featureInstanceId": None}
|
||||||
|
)
|
||||||
|
migratedCount += 1
|
||||||
|
|
||||||
|
if migratedCount > 0:
|
||||||
|
logger.info(f"Migrated {migratedCount} existing templates to isSystem=True")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Template migration check failed (non-critical): {e}")
|
||||||
|
|
|
||||||
|
|
@ -530,7 +530,7 @@ def create_db_template(
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
)
|
)
|
||||||
created = chatInterface.createAutomationTemplate(templateData)
|
created = chatInterface.createAutomationTemplate(templateData, isSysAdmin=context.hasSysAdminRole)
|
||||||
return JSONResponse(content=created)
|
return JSONResponse(content=created)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -562,7 +562,7 @@ def update_db_template(
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
)
|
)
|
||||||
updated = chatInterface.updateAutomationTemplate(templateId, templateData)
|
updated = chatInterface.updateAutomationTemplate(templateId, templateData, isSysAdmin=context.hasSysAdminRole)
|
||||||
return JSONResponse(content=updated)
|
return JSONResponse(content=updated)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -593,7 +593,7 @@ def delete_db_template(
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
)
|
)
|
||||||
success = chatInterface.deleteAutomationTemplate(templateId)
|
success = chatInterface.deleteAutomationTemplate(templateId, isSysAdmin=context.hasSysAdminRole)
|
||||||
if success:
|
if success:
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
else:
|
else:
|
||||||
|
|
@ -616,3 +616,63 @@ def delete_db_template(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@templateRouter.post("/{templateId}/duplicate")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
def duplicate_db_template(
|
||||||
|
request: Request,
|
||||||
|
templateId: str = Path(..., description="Template ID to duplicate"),
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Duplicate a template into the current feature instance (system or instance template)."""
|
||||||
|
try:
|
||||||
|
chatInterface = getAutomationInterface(
|
||||||
|
context.user,
|
||||||
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
|
)
|
||||||
|
duplicated = chatInterface.duplicateAutomationTemplate(templateId)
|
||||||
|
return JSONResponse(content=duplicated)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error duplicating template: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error duplicating template: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{automationId}/duplicate")
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
def duplicate_automation(
|
||||||
|
request: Request,
|
||||||
|
automationId: str = Path(..., description="Automation definition ID to duplicate"),
|
||||||
|
context: RequestContext = Depends(getRequestContext)
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Duplicate an automation definition within the same feature instance."""
|
||||||
|
try:
|
||||||
|
chatInterface = getAutomationInterface(
|
||||||
|
context.user,
|
||||||
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
|
)
|
||||||
|
duplicated = chatInterface.duplicateAutomationDefinition(automationId)
|
||||||
|
return JSONResponse(content=duplicated)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except PermissionError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=str(e)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error duplicating automation: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error duplicating automation: {str(e)}"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
|
||||||
_chatInterfaces = {}
|
_chatInterfaces = {}
|
||||||
|
|
||||||
|
|
||||||
def storeDebugMessageAndDocuments(message, currentUser) -> None:
|
def storeDebugMessageAndDocuments(message, currentUser, mandateId=None, featureInstanceId=None) -> None:
|
||||||
"""
|
"""
|
||||||
Store message and documents (metadata and file bytes) for debugging purposes.
|
Store message and documents (metadata and file bytes) for debugging purposes.
|
||||||
Structure: {log_dir}/debug/messages/m_round_task_action_timestamp/documentlist_label/
|
Structure: {log_dir}/debug/messages/m_round_task_action_timestamp/documentlist_label/
|
||||||
|
|
@ -53,6 +53,8 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
|
||||||
Args:
|
Args:
|
||||||
message: ChatMessage object to store
|
message: ChatMessage object to store
|
||||||
currentUser: Current user for component interface access
|
currentUser: Current user for component interface access
|
||||||
|
mandateId: Mandate ID for RBAC context (avoids overwriting singleton state)
|
||||||
|
featureInstanceId: Feature instance ID for RBAC context
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import os
|
import os
|
||||||
|
|
@ -152,7 +154,7 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
|
||||||
|
|
||||||
# Also store the actual file bytes next to metadata for debugging
|
# Also store the actual file bytes next to metadata for debugging
|
||||||
try:
|
try:
|
||||||
componentInterface = getInterface(currentUser)
|
componentInterface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
|
||||||
file_bytes = componentInterface.getFileData(doc.fileId)
|
file_bytes = componentInterface.getFileData(doc.fileId)
|
||||||
if file_bytes:
|
if file_bytes:
|
||||||
# Build a safe filename preserving original name
|
# Build a safe filename preserving original name
|
||||||
|
|
@ -1134,7 +1136,7 @@ class ChatObjects:
|
||||||
logger.debug(f"Could not emit message event: {e}")
|
logger.debug(f"Could not emit message event: {e}")
|
||||||
|
|
||||||
# Debug: Store message and documents for debugging - only if debug enabled
|
# Debug: Store message and documents for debugging - only if debug enabled
|
||||||
storeDebugMessageAndDocuments(chat_message, self.currentUser)
|
storeDebugMessageAndDocuments(chat_message, self.currentUser, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId)
|
||||||
|
|
||||||
return chat_message
|
return chat_message
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify user has access to this instance
|
# Verify user has access to this instance
|
||||||
if not context.isSysAdmin:
|
if not context.hasSysAdminRole:
|
||||||
# Check if user has FeatureAccess for this instance
|
# Check if user has FeatureAccess for this instance
|
||||||
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
||||||
hasAccess = any(
|
hasAccess = any(
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,8 @@ class InterfaceFeatureNeutralizer:
|
||||||
self.db,
|
self.db,
|
||||||
DataNeutraliserConfig,
|
DataNeutraliserConfig,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter={"mandateId": self.mandateId}
|
recordFilter={"mandateId": self.mandateId},
|
||||||
|
mandateId=self.mandateId
|
||||||
)
|
)
|
||||||
|
|
||||||
if not filteredConfigs:
|
if not filteredConfigs:
|
||||||
|
|
@ -153,7 +154,8 @@ class InterfaceFeatureNeutralizer:
|
||||||
self.db,
|
self.db,
|
||||||
DataNeutralizerAttributes,
|
DataNeutralizerAttributes,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter=filterDict
|
recordFilter=filterDict,
|
||||||
|
mandateId=self.mandateId
|
||||||
)
|
)
|
||||||
|
|
||||||
# Filter out database-specific fields
|
# Filter out database-specific fields
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Instance '{instanceId}' is not a realestate instance"
|
detail=f"Instance '{instanceId}' is not a realestate instance"
|
||||||
)
|
)
|
||||||
if not context.isSysAdmin:
|
if not context.hasSysAdminRole:
|
||||||
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
||||||
hasAccess = any(
|
hasAccess = any(
|
||||||
str(fa.featureInstanceId) == instanceId and fa.enabled
|
str(fa.featureInstanceId) == instanceId and fa.enabled
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify user has access to this instance
|
# Verify user has access to this instance
|
||||||
if not context.isSysAdmin:
|
if not context.hasSysAdminRole:
|
||||||
# Check if user has FeatureAccess for this instance
|
# Check if user has FeatureAccess for this instance
|
||||||
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
||||||
hasAccess = any(
|
hasAccess = any(
|
||||||
|
|
@ -1319,27 +1319,29 @@ def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> str:
|
||||||
Validate that the user has admin access to the feature instance.
|
Validate that the user has admin access to the feature instance.
|
||||||
Returns the mandateId if authorized.
|
Returns the mandateId if authorized.
|
||||||
|
|
||||||
This checks for the RESOURCE permission 'instance-roles.manage'.
|
This checks for the RESOURCE permission 'instance-roles.manage'
|
||||||
|
via AccessRules assigned to the user's current roles.
|
||||||
"""
|
"""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
|
||||||
# SysAdmin always has access
|
# SysAdmin role always has access
|
||||||
if context.user.isSysAdmin:
|
if context.hasSysAdminRole:
|
||||||
return mandateId
|
return mandateId
|
||||||
|
|
||||||
# Check for instance-roles.manage resource permission
|
# Check for instance-roles.manage resource permission via AccessRules
|
||||||
featureInterface = getFeatureInterface()
|
rootInterface = getRootInterface()
|
||||||
permissions = featureInterface.getUserPermissionsForInstance(context.user.id, instanceId)
|
hasAdminPermission = False
|
||||||
|
|
||||||
if not permissions:
|
for roleId in context.roleIds:
|
||||||
raise HTTPException(
|
rules = rootInterface.db.getRecordset(
|
||||||
status_code=403,
|
AccessRule,
|
||||||
detail="Keine Berechtigung zur Rollenverwaltung"
|
{"roleId": roleId, "context": AccessRuleContext.RESOURCE.value, "item": "resource.trustee.instance-roles.manage"}
|
||||||
)
|
)
|
||||||
|
if rules:
|
||||||
|
hasAdminPermission = True
|
||||||
|
break
|
||||||
|
|
||||||
# Check for resource permission
|
if not hasAdminPermission:
|
||||||
resourcePermissions = permissions.get("resources", {})
|
|
||||||
if not resourcePermissions.get("instance-roles.manage"):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail="Keine Berechtigung zur Rollenverwaltung"
|
detail="Keine Berechtigung zur Rollenverwaltung"
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,14 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
||||||
# This also serves as migration for existing mandates that don't have instance roles yet
|
# This also serves as migration for existing mandates that don't have instance roles yet
|
||||||
_ensureAllMandatesHaveSystemRoles(db)
|
_ensureAllMandatesHaveSystemRoles(db)
|
||||||
|
|
||||||
|
# Initialize sysadmin role in root mandate (NOT a template, mandate-specific)
|
||||||
|
# Hybrid model: isSysAdmin flag → system ops, sysadmin role → admin ops via RBAC
|
||||||
|
if mandateId:
|
||||||
|
_initSysAdminRole(db, mandateId)
|
||||||
|
|
||||||
|
# Ensure UI rules for sysadmin role (created after initRbacRules, needs second pass)
|
||||||
|
_ensureUiContextRules(db)
|
||||||
|
|
||||||
# Initialize admin user
|
# Initialize admin user
|
||||||
adminUserId = initAdminUser(db, mandateId)
|
adminUserId = initAdminUser(db, mandateId)
|
||||||
|
|
||||||
|
|
@ -392,9 +400,9 @@ def initRoles(db: DatabaseConnector) -> None:
|
||||||
Initialize standard roles if they don't exist.
|
Initialize standard roles if they don't exist.
|
||||||
Roles are created as GLOBAL (mandateId=None) template roles.
|
Roles are created as GLOBAL (mandateId=None) template roles.
|
||||||
|
|
||||||
NOTE: SysAdmin is NOT a role - it's a flag (User.isSysAdmin).
|
NOTE: The "sysadmin" role is NOT a template - it's created separately in
|
||||||
SysAdmin users bypass RBAC entirely and have full system access.
|
_initSysAdminRole() as a root-mandate-specific role (isSystemRole=False).
|
||||||
These template roles are for mandate/feature-level access control.
|
These template roles (admin/user/viewer) are for mandate/feature-level access control.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database connector instance
|
db: Database connector instance
|
||||||
|
|
@ -404,7 +412,7 @@ def initRoles(db: DatabaseConnector) -> None:
|
||||||
_roleIdCache = {}
|
_roleIdCache = {}
|
||||||
|
|
||||||
# Standard template roles for mandate/feature-level access
|
# Standard template roles for mandate/feature-level access
|
||||||
# NOTE: No "sysadmin" role - SysAdmin is a flag (User.isSysAdmin), not a role!
|
# NOTE: "sysadmin" role is created separately in _initSysAdminRole (root mandate only)
|
||||||
standardRoles = [
|
standardRoles = [
|
||||||
Role(
|
Role(
|
||||||
roleLabel="admin",
|
roleLabel="admin",
|
||||||
|
|
@ -501,14 +509,13 @@ def _deduplicateRoles(db: DatabaseConnector) -> None:
|
||||||
fixedMandateCount += 1
|
fixedMandateCount += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to fix mandate role {role.get('id')}: {e}")
|
logger.warning(f"Failed to fix mandate role {role.get('id')}: {e}")
|
||||||
# Template roles (mandateId=None, standard labels) MUST be isSystemRole=True
|
# Template roles (mandateId=None, no featureCode) MUST be isSystemRole=True
|
||||||
if role.get("mandateId") is None and role.get("isSystemRole") is not True:
|
if role.get("mandateId") is None and role.get("featureCode") is None and role.get("isSystemRole") is not True:
|
||||||
if role.get("roleLabel") in ("admin", "user", "viewer"):
|
try:
|
||||||
try:
|
db.recordModify(Role, role.get("id"), {"isSystemRole": True})
|
||||||
db.recordModify(Role, role.get("id"), {"isSystemRole": True})
|
fixedTemplateCount += 1
|
||||||
fixedTemplateCount += 1
|
except Exception as e:
|
||||||
except Exception as e:
|
logger.warning(f"Failed to fix template role {role.get('id')}: {e}")
|
||||||
logger.warning(f"Failed to fix template role {role.get('id')}: {e}")
|
|
||||||
if fixedMandateCount > 0:
|
if fixedMandateCount > 0:
|
||||||
logger.info(f"Fixed {fixedMandateCount} mandate-level roles: isSystemRole → False")
|
logger.info(f"Fixed {fixedMandateCount} mandate-level roles: isSystemRole → False")
|
||||||
if fixedTemplateCount > 0:
|
if fixedTemplateCount > 0:
|
||||||
|
|
@ -623,6 +630,151 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
|
||||||
return copiedCount
|
return copiedCount
|
||||||
|
|
||||||
|
|
||||||
|
def _initSysAdminRole(db: DatabaseConnector, mandateId: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Initialize the sysadmin role in the root mandate.
|
||||||
|
|
||||||
|
The sysadmin role is a mandate-specific role (NOT a system template) that provides
|
||||||
|
full administrative access via RBAC. It only exists in the root mandate and is
|
||||||
|
NOT copied to other mandates (isSystemRole=False).
|
||||||
|
|
||||||
|
Hybrid model:
|
||||||
|
- User.isSysAdmin flag → true system operations (Category A: tokens, logs, databases)
|
||||||
|
- sysadmin role → admin operations via RBAC (Categories B/C/D/E)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connector instance
|
||||||
|
mandateId: Root mandate ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sysadmin role ID or None
|
||||||
|
"""
|
||||||
|
# Check if sysadmin role already exists in root mandate
|
||||||
|
existingRoles = db.getRecordset(
|
||||||
|
Role,
|
||||||
|
recordFilter={"roleLabel": "sysadmin", "mandateId": mandateId, "featureInstanceId": None}
|
||||||
|
)
|
||||||
|
|
||||||
|
if existingRoles:
|
||||||
|
sysadminRoleId = existingRoles[0].get("id")
|
||||||
|
logger.info(f"Sysadmin role already exists in root mandate with ID {sysadminRoleId}")
|
||||||
|
# Ensure AccessRules exist (migration safety)
|
||||||
|
_ensureSysAdminAccessRules(db, sysadminRoleId)
|
||||||
|
return sysadminRoleId
|
||||||
|
|
||||||
|
# Create sysadmin role in root mandate
|
||||||
|
logger.info("Creating sysadmin role in root mandate")
|
||||||
|
sysadminRole = Role(
|
||||||
|
roleLabel="sysadmin",
|
||||||
|
description={
|
||||||
|
"en": "System Administrator - Full administrative access across all mandates",
|
||||||
|
"de": "System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten",
|
||||||
|
"fr": "Administrateur système - Accès administratif complet à tous les mandats"
|
||||||
|
},
|
||||||
|
mandateId=mandateId,
|
||||||
|
featureInstanceId=None,
|
||||||
|
featureCode=None,
|
||||||
|
isSystemRole=False # NOT a template → NOT copied to other mandates
|
||||||
|
)
|
||||||
|
createdRole = db.recordCreate(Role, sysadminRole)
|
||||||
|
sysadminRoleId = createdRole.get("id")
|
||||||
|
logger.info(f"Created sysadmin role with ID {sysadminRoleId}")
|
||||||
|
|
||||||
|
# Create AccessRules for sysadmin role
|
||||||
|
_createSysAdminAccessRules(db, sysadminRoleId)
|
||||||
|
|
||||||
|
return sysadminRoleId
|
||||||
|
|
||||||
|
|
||||||
|
def _createSysAdminAccessRules(db: DatabaseConnector, sysadminRoleId: str) -> None:
|
||||||
|
"""
|
||||||
|
Create AccessRules for the sysadmin role.
|
||||||
|
|
||||||
|
DATA + RESOURCE: generic item=None (full access).
|
||||||
|
UI: NO generic rule here — explicit ui.admin.* rules are created by
|
||||||
|
_ensureUiContextRules() (same logic as admin role).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connector instance
|
||||||
|
sysadminRoleId: Sysadmin role ID
|
||||||
|
"""
|
||||||
|
rules = [
|
||||||
|
# DATA: Full access to all data tables (generic rule, item=None)
|
||||||
|
AccessRule(
|
||||||
|
roleId=sysadminRoleId,
|
||||||
|
context=AccessRuleContext.DATA,
|
||||||
|
item=None,
|
||||||
|
view=True,
|
||||||
|
read=AccessLevel.ALL,
|
||||||
|
create=AccessLevel.ALL,
|
||||||
|
update=AccessLevel.ALL,
|
||||||
|
delete=AccessLevel.ALL,
|
||||||
|
),
|
||||||
|
# RESOURCE: Access to all system resources (generic rule, item=None)
|
||||||
|
AccessRule(
|
||||||
|
roleId=sysadminRoleId,
|
||||||
|
context=AccessRuleContext.RESOURCE,
|
||||||
|
item=None,
|
||||||
|
view=True,
|
||||||
|
read=None,
|
||||||
|
create=None,
|
||||||
|
update=None,
|
||||||
|
delete=None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
db.recordCreate(AccessRule, rule)
|
||||||
|
|
||||||
|
logger.info(f"Created {len(rules)} AccessRules for sysadmin role (UI rules via _ensureUiContextRules)")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensureSysAdminAccessRules(db: DatabaseConnector, sysadminRoleId: str) -> None:
|
||||||
|
"""
|
||||||
|
Ensure AccessRules exist for the sysadmin role (migration safety).
|
||||||
|
Creates missing rules without duplicating existing ones.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connector instance
|
||||||
|
sysadminRoleId: Sysadmin role ID
|
||||||
|
"""
|
||||||
|
existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": sysadminRoleId})
|
||||||
|
|
||||||
|
if not existingRules:
|
||||||
|
logger.info("No AccessRules found for sysadmin role, creating them")
|
||||||
|
_createSysAdminAccessRules(db, sysadminRoleId)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check for DATA and RESOURCE contexts (UI is handled by _ensureUiContextRules)
|
||||||
|
existingContexts = {r.get("context") for r in existingRules}
|
||||||
|
|
||||||
|
missingRules = []
|
||||||
|
if AccessRuleContext.DATA.value not in existingContexts:
|
||||||
|
missingRules.append(AccessRule(
|
||||||
|
roleId=sysadminRoleId,
|
||||||
|
context=AccessRuleContext.DATA,
|
||||||
|
item=None,
|
||||||
|
view=True,
|
||||||
|
read=AccessLevel.ALL,
|
||||||
|
create=AccessLevel.ALL,
|
||||||
|
update=AccessLevel.ALL,
|
||||||
|
delete=AccessLevel.ALL,
|
||||||
|
))
|
||||||
|
if AccessRuleContext.RESOURCE.value not in existingContexts:
|
||||||
|
missingRules.append(AccessRule(
|
||||||
|
roleId=sysadminRoleId,
|
||||||
|
context=AccessRuleContext.RESOURCE,
|
||||||
|
item=None,
|
||||||
|
view=True,
|
||||||
|
read=None, create=None, update=None, delete=None,
|
||||||
|
))
|
||||||
|
|
||||||
|
if missingRules:
|
||||||
|
for rule in missingRules:
|
||||||
|
db.recordCreate(AccessRule, rule)
|
||||||
|
logger.info(f"Created {len(missingRules)} missing AccessRules for sysadmin role")
|
||||||
|
|
||||||
|
|
||||||
def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]:
|
def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Get role ID by label, using cache or database lookup.
|
Get role ID by label, using cache or database lookup.
|
||||||
|
|
@ -688,8 +840,8 @@ def _createDefaultRoleRules(db: DatabaseConnector) -> None:
|
||||||
Create default role rules for generic access (item = null).
|
Create default role rules for generic access (item = null).
|
||||||
Uses roleId instead of roleLabel.
|
Uses roleId instead of roleLabel.
|
||||||
|
|
||||||
NOTE: No rules for "sysadmin" - SysAdmin is a flag (User.isSysAdmin), not a role!
|
NOTE: Sysadmin role rules are created separately in _initSysAdminRole().
|
||||||
SysAdmin users bypass RBAC entirely via the isSysAdmin check in getRecordsetWithRBAC().
|
These default rules cover admin/user/viewer template roles.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database connector instance
|
db: Database connector instance
|
||||||
|
|
@ -710,19 +862,8 @@ def _createDefaultRoleRules(db: DatabaseConnector) -> None:
|
||||||
delete=AccessLevel.NONE,
|
delete=AccessLevel.NONE,
|
||||||
))
|
))
|
||||||
|
|
||||||
# User Role - My records only
|
# User Role - No access rights (mandate membership marker only)
|
||||||
userId = _getRoleId(db, "user")
|
# Users get their actual permissions from feature-instance-level roles
|
||||||
if userId:
|
|
||||||
defaultRules.append(AccessRule(
|
|
||||||
roleId=userId,
|
|
||||||
context=AccessRuleContext.DATA,
|
|
||||||
item=None,
|
|
||||||
view=True,
|
|
||||||
read=AccessLevel.MY,
|
|
||||||
create=AccessLevel.MY,
|
|
||||||
update=AccessLevel.MY,
|
|
||||||
delete=AccessLevel.MY,
|
|
||||||
))
|
|
||||||
|
|
||||||
# Viewer Role - Read-only group access
|
# Viewer Role - Read-only group access
|
||||||
viewerId = _getRoleId(db, "viewer")
|
viewerId = _getRoleId(db, "viewer")
|
||||||
|
|
@ -750,15 +891,15 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
|
||||||
These rules override generic rules for specific tables.
|
These rules override generic rules for specific tables.
|
||||||
Uses roleId instead of roleLabel.
|
Uses roleId instead of roleLabel.
|
||||||
|
|
||||||
NOTE: No rules for "sysadmin" - SysAdmin is a flag (User.isSysAdmin), not a role!
|
NOTE: Sysadmin role rules are created separately in _initSysAdminRole().
|
||||||
SysAdmin users bypass RBAC entirely via the isSysAdmin check in getRecordsetWithRBAC().
|
These table-specific rules cover admin/user/viewer template roles.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database connector instance
|
db: Database connector instance
|
||||||
"""
|
"""
|
||||||
tableRules = []
|
tableRules = []
|
||||||
|
|
||||||
# Get role IDs (no sysadmin - that's a flag, not a role!)
|
# Get role IDs for template roles (sysadmin is a separate mandate-level role)
|
||||||
adminId = _getRoleId(db, "admin")
|
adminId = _getRoleId(db, "admin")
|
||||||
userId = _getRoleId(db, "user")
|
userId = _getRoleId(db, "user")
|
||||||
viewerId = _getRoleId(db, "viewer")
|
viewerId = _getRoleId(db, "viewer")
|
||||||
|
|
@ -1276,15 +1417,50 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
|
||||||
Ensure UI context rules exist for all navigation items.
|
Ensure UI context rules exist for all navigation items.
|
||||||
This is called during bootstrap to add missing UI rules for new navigation items.
|
This is called during bootstrap to add missing UI rules for new navigation items.
|
||||||
|
|
||||||
|
Creates rules for BOTH template roles AND mandate-instance roles.
|
||||||
|
This ensures new navigation items are visible even for mandates created before
|
||||||
|
the navigation item was added.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database connector instance
|
db: Database connector instance
|
||||||
"""
|
"""
|
||||||
from modules.system.mainSystem import NAVIGATION_SECTIONS
|
from modules.system.mainSystem import NAVIGATION_SECTIONS
|
||||||
|
|
||||||
|
# Template role IDs
|
||||||
adminId = _getRoleId(db, "admin")
|
adminId = _getRoleId(db, "admin")
|
||||||
userId = _getRoleId(db, "user")
|
userId = _getRoleId(db, "user")
|
||||||
viewerId = _getRoleId(db, "viewer")
|
viewerId = _getRoleId(db, "viewer")
|
||||||
|
|
||||||
|
# Mandate-instance role IDs (same roleLabel, but mandateId set, featureInstanceId=None)
|
||||||
|
mandateAdminRoleIds = []
|
||||||
|
mandateUserRoleIds = []
|
||||||
|
mandateViewerRoleIds = []
|
||||||
|
sysadminRoleIds = []
|
||||||
|
|
||||||
|
mandateRoles = db.getRecordset(
|
||||||
|
Role,
|
||||||
|
recordFilter={"isSystemRole": False, "featureInstanceId": None}
|
||||||
|
)
|
||||||
|
for role in mandateRoles:
|
||||||
|
roleId = role.get("id")
|
||||||
|
label = role.get("roleLabel")
|
||||||
|
if not roleId or not label or not role.get("mandateId"):
|
||||||
|
continue
|
||||||
|
if label == "admin":
|
||||||
|
mandateAdminRoleIds.append(roleId)
|
||||||
|
elif label == "user":
|
||||||
|
mandateUserRoleIds.append(roleId)
|
||||||
|
elif label == "viewer":
|
||||||
|
mandateViewerRoleIds.append(roleId)
|
||||||
|
elif label == "sysadmin":
|
||||||
|
sysadminRoleIds.append(roleId)
|
||||||
|
|
||||||
|
# All role IDs per level (template + mandate-instance)
|
||||||
|
# sysadmin gets ALL UI rules (admin-only + public) — same logic, explicit rules
|
||||||
|
allAdminRoleIds = ([adminId] if adminId else []) + mandateAdminRoleIds + sysadminRoleIds
|
||||||
|
allUserRoleIds = ([userId] if userId else []) + mandateUserRoleIds
|
||||||
|
allViewerRoleIds = ([viewerId] if viewerId else []) + mandateViewerRoleIds
|
||||||
|
|
||||||
# Get existing UI rules
|
# Get existing UI rules
|
||||||
existingUiRules = db.getRecordset(
|
existingUiRules = db.getRecordset(
|
||||||
AccessRule,
|
AccessRule,
|
||||||
|
|
@ -1312,19 +1488,20 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
|
||||||
isAdminOnly = item.get("adminOnly", False) or isAdminSection
|
isAdminOnly = item.get("adminOnly", False) or isAdminSection
|
||||||
|
|
||||||
if isAdminOnly:
|
if isAdminOnly:
|
||||||
# Admin-only: only admin role
|
# Admin-only: all admin roles (template + mandate-instance)
|
||||||
if adminId and (adminId, objectKey) not in existingCombinations:
|
for roleId in allAdminRoleIds:
|
||||||
missingRules.append(AccessRule(
|
if (roleId, objectKey) not in existingCombinations:
|
||||||
roleId=adminId,
|
missingRules.append(AccessRule(
|
||||||
context=AccessRuleContext.UI,
|
roleId=roleId,
|
||||||
item=objectKey,
|
context=AccessRuleContext.UI,
|
||||||
view=True,
|
item=objectKey,
|
||||||
read=None, create=None, update=None, delete=None,
|
view=True,
|
||||||
))
|
read=None, create=None, update=None, delete=None,
|
||||||
|
))
|
||||||
else:
|
else:
|
||||||
# Public/normal: all roles
|
# Public/normal: all roles (template + mandate-instance)
|
||||||
for roleId in [adminId, userId, viewerId]:
|
for roleId in allAdminRoleIds + allUserRoleIds + allViewerRoleIds:
|
||||||
if roleId and (roleId, objectKey) not in existingCombinations:
|
if (roleId, objectKey) not in existingCombinations:
|
||||||
missingRules.append(AccessRule(
|
missingRules.append(AccessRule(
|
||||||
roleId=roleId,
|
roleId=roleId,
|
||||||
context=AccessRuleContext.UI,
|
context=AccessRuleContext.UI,
|
||||||
|
|
@ -1337,7 +1514,7 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
|
||||||
if missingRules:
|
if missingRules:
|
||||||
for rule in missingRules:
|
for rule in missingRules:
|
||||||
db.recordCreate(AccessRule, rule)
|
db.recordCreate(AccessRule, rule)
|
||||||
logger.info(f"Created {len(missingRules)} missing UI context rules")
|
logger.info(f"Created {len(missingRules)} missing UI context rules (incl. mandate-instance roles)")
|
||||||
# All UI context rules already exist (nothing to create)
|
# All UI context rules already exist (nothing to create)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1812,8 +1989,8 @@ def assignInitialUserMemberships(
|
||||||
Assign initial memberships to admin and event users via UserMandate + UserMandateRole.
|
Assign initial memberships to admin and event users via UserMandate + UserMandateRole.
|
||||||
This is the NEW multi-tenant way of assigning roles.
|
This is the NEW multi-tenant way of assigning roles.
|
||||||
|
|
||||||
NOTE: SysAdmin is a flag (User.isSysAdmin), not a role. Initial users get the "admin" role
|
Hybrid model: Initial users get BOTH the isSysAdmin flag (for system ops)
|
||||||
within the root mandate, plus they have isSysAdmin=True for system-level access.
|
AND the "admin" + "sysadmin" roles in the root mandate (for RBAC-based admin ops).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database connector instance
|
db: Database connector instance
|
||||||
|
|
@ -1821,16 +1998,24 @@ def assignInitialUserMemberships(
|
||||||
adminUserId: Admin user ID
|
adminUserId: Admin user ID
|
||||||
eventUserId: Event user ID
|
eventUserId: Event user ID
|
||||||
"""
|
"""
|
||||||
# Use mandate-instance "admin" role (not the global template)
|
# Find the highest-privilege mandate-level role (prefer "admin", fallback to first available)
|
||||||
mandateAdminRoles = db.getRecordset(
|
mandateRoles = db.getRecordset(
|
||||||
Role,
|
Role,
|
||||||
recordFilter={"roleLabel": "admin", "mandateId": mandateId, "featureInstanceId": None}
|
recordFilter={"mandateId": mandateId, "featureInstanceId": None}
|
||||||
)
|
)
|
||||||
adminRoleId = mandateAdminRoles[0].get("id") if mandateAdminRoles else None
|
# Prefer "admin" role, fall back to first available mandate role
|
||||||
|
adminRole = next((r for r in mandateRoles if r.get("roleLabel") == "admin"), None)
|
||||||
|
adminRoleId = adminRole.get("id") if adminRole else (mandateRoles[0].get("id") if mandateRoles else None)
|
||||||
if not adminRoleId:
|
if not adminRoleId:
|
||||||
logger.warning(f"Admin role not found for mandate {mandateId}, skipping membership assignment")
|
logger.warning(f"No mandate-level role found for mandate {mandateId}, skipping membership assignment")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Find sysadmin role in root mandate (created by _initSysAdminRole)
|
||||||
|
sysadminRole = next((r for r in mandateRoles if r.get("roleLabel") == "sysadmin"), None)
|
||||||
|
sysadminRoleId = sysadminRole.get("id") if sysadminRole else None
|
||||||
|
if not sysadminRoleId:
|
||||||
|
logger.warning("Sysadmin role not found in root mandate - run _initSysAdminRole first")
|
||||||
|
|
||||||
for userId, userName in [(adminUserId, "admin"), (eventUserId, "event")]:
|
for userId, userName in [(adminUserId, "admin"), (eventUserId, "event")]:
|
||||||
# Check if UserMandate already exists
|
# Check if UserMandate already exists
|
||||||
existingMemberships = db.getRecordset(
|
existingMemberships = db.getRecordset(
|
||||||
|
|
@ -1851,7 +2036,7 @@ def assignInitialUserMemberships(
|
||||||
userMandateId = createdMembership.get("id")
|
userMandateId = createdMembership.get("id")
|
||||||
logger.info(f"Created UserMandate for {userName} user with ID {userMandateId}")
|
logger.info(f"Created UserMandate for {userName} user with ID {userMandateId}")
|
||||||
|
|
||||||
# Check if UserMandateRole already exists
|
# Check if UserMandateRole already exists for admin role
|
||||||
existingRoles = db.getRecordset(
|
existingRoles = db.getRecordset(
|
||||||
UserMandateRole,
|
UserMandateRole,
|
||||||
recordFilter={"userMandateId": userMandateId, "roleId": adminRoleId}
|
recordFilter={"userMandateId": userMandateId, "roleId": adminRoleId}
|
||||||
|
|
@ -1866,6 +2051,20 @@ def assignInitialUserMemberships(
|
||||||
db.recordCreate(UserMandateRole, userMandateRole)
|
db.recordCreate(UserMandateRole, userMandateRole)
|
||||||
logger.info(f"Assigned admin role to {userName} user in mandate")
|
logger.info(f"Assigned admin role to {userName} user in mandate")
|
||||||
|
|
||||||
|
# Assign sysadmin role (in addition to admin role)
|
||||||
|
if sysadminRoleId:
|
||||||
|
existingSysadminRoles = db.getRecordset(
|
||||||
|
UserMandateRole,
|
||||||
|
recordFilter={"userMandateId": userMandateId, "roleId": sysadminRoleId}
|
||||||
|
)
|
||||||
|
if not existingSysadminRoles:
|
||||||
|
sysadminMandateRole = UserMandateRole(
|
||||||
|
userMandateId=userMandateId,
|
||||||
|
roleId=sysadminRoleId
|
||||||
|
)
|
||||||
|
db.recordCreate(UserMandateRole, sysadminMandateRole)
|
||||||
|
logger.info(f"Assigned sysadmin role to {userName} user in root mandate")
|
||||||
|
|
||||||
|
|
||||||
def _getPasswordHash(password: Optional[str]) -> Optional[str]:
|
def _getPasswordHash(password: Optional[str]) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ class AppObjects:
|
||||||
self.currentUser = currentUser # Store User object directly
|
self.currentUser = currentUser # Store User object directly
|
||||||
self.userId = currentUser.id if currentUser else None
|
self.userId = currentUser.id if currentUser else None
|
||||||
self.mandateId = None # mandateId comes from setUserContext, not from User
|
self.mandateId = None # mandateId comes from setUserContext, not from User
|
||||||
|
self.featureInstanceId = None # featureInstanceId comes from setUserContext
|
||||||
|
|
||||||
# Initialize database
|
# Initialize database
|
||||||
self._initializeDatabase()
|
self._initializeDatabase()
|
||||||
|
|
@ -501,34 +502,30 @@ class AppObjects:
|
||||||
|
|
||||||
def getUsersByMandate(self, mandateId: str, pagination: Optional[PaginationParams] = None) -> Union[List[User], PaginatedResult]:
|
def getUsersByMandate(self, mandateId: str, pagination: Optional[PaginationParams] = None) -> Union[List[User], PaginatedResult]:
|
||||||
"""
|
"""
|
||||||
Returns users for a specific mandate if user has access.
|
Returns users for a specific mandate.
|
||||||
Supports optional pagination, sorting, and filtering.
|
Uses UserMandate junction table to find users belonging to the mandate.
|
||||||
For SYSADMIN, returns all users regardless of mandate.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mandateId: The mandate ID to get users for (ignored for SYSADMIN)
|
mandateId: The mandate ID to get users for
|
||||||
pagination: Optional pagination parameters. If None, returns all items.
|
pagination: Optional pagination parameters. If None, returns all items.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
If pagination is None: List[User]
|
If pagination is None: List[User]
|
||||||
If pagination is provided: PaginatedResult with items and metadata
|
If pagination is provided: PaginatedResult with items and metadata
|
||||||
"""
|
"""
|
||||||
# Use RBAC filtering
|
# Get user IDs via UserMandate junction table (UserInDB has no mandateId column)
|
||||||
users = getRecordsetWithRBAC(
|
userMandates = self.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
|
||||||
self.db,
|
userIds = [um.get("userId") for um in userMandates if um.get("userId")]
|
||||||
UserInDB,
|
|
||||||
self.currentUser,
|
|
||||||
recordFilter={"mandateId": mandateId} if mandateId else None
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter out database-specific fields and normalize data
|
# Fetch each user by ID
|
||||||
filteredUsers = []
|
filteredUsers = []
|
||||||
for user in users:
|
for userId in userIds:
|
||||||
cleanedUser = {k: v for k, v in user.items() if not k.startswith("_")}
|
userRecords = self.db.getRecordset(UserInDB, recordFilter={"id": userId})
|
||||||
# Ensure roleLabels is always a list, not None
|
if userRecords:
|
||||||
if cleanedUser.get("roleLabels") is None:
|
cleanedUser = {k: v for k, v in userRecords[0].items() if not k.startswith("_")}
|
||||||
cleanedUser["roleLabels"] = []
|
if cleanedUser.get("roleLabels") is None:
|
||||||
filteredUsers.append(cleanedUser)
|
cleanedUser["roleLabels"] = []
|
||||||
|
filteredUsers.append(cleanedUser)
|
||||||
|
|
||||||
# If no pagination requested, return all items
|
# If no pagination requested, return all items
|
||||||
if pagination is None:
|
if pagination is None:
|
||||||
|
|
@ -572,7 +569,8 @@ class AppObjects:
|
||||||
users = getRecordsetWithRBAC(self.db,
|
users = getRecordsetWithRBAC(self.db,
|
||||||
UserInDB,
|
UserInDB,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter={"username": username}
|
recordFilter={"username": username},
|
||||||
|
mandateId=self.mandateId
|
||||||
)
|
)
|
||||||
|
|
||||||
if not users:
|
if not users:
|
||||||
|
|
@ -599,7 +597,8 @@ class AppObjects:
|
||||||
users = getRecordsetWithRBAC(self.db,
|
users = getRecordsetWithRBAC(self.db,
|
||||||
UserInDB,
|
UserInDB,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter={"id": userId}
|
recordFilter={"id": userId},
|
||||||
|
mandateId=self.mandateId
|
||||||
)
|
)
|
||||||
|
|
||||||
if not users:
|
if not users:
|
||||||
|
|
@ -1202,7 +1201,8 @@ class AppObjects:
|
||||||
users = getRecordsetWithRBAC(self.db,
|
users = getRecordsetWithRBAC(self.db,
|
||||||
UserInDB,
|
UserInDB,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter={"id": initialUserId}
|
recordFilter={"id": initialUserId},
|
||||||
|
mandateId=self.mandateId
|
||||||
)
|
)
|
||||||
return users[0] if users else None
|
return users[0] if users else None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1384,7 +1384,7 @@ class AppObjects:
|
||||||
If pagination is provided: PaginatedResult with items and metadata
|
If pagination is provided: PaginatedResult with items and metadata
|
||||||
"""
|
"""
|
||||||
# Use RBAC filtering
|
# Use RBAC filtering
|
||||||
allMandates = getRecordsetWithRBAC(self.db, Mandate, self.currentUser)
|
allMandates = getRecordsetWithRBAC(self.db, Mandate, self.currentUser, mandateId=self.mandateId)
|
||||||
|
|
||||||
# Filter out database-specific fields
|
# Filter out database-specific fields
|
||||||
filteredMandates = []
|
filteredMandates = []
|
||||||
|
|
@ -1428,7 +1428,8 @@ class AppObjects:
|
||||||
mandates = getRecordsetWithRBAC(self.db,
|
mandates = getRecordsetWithRBAC(self.db,
|
||||||
Mandate,
|
Mandate,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter={"id": mandateId}
|
recordFilter={"id": mandateId},
|
||||||
|
mandateId=self.mandateId
|
||||||
)
|
)
|
||||||
|
|
||||||
if not mandates:
|
if not mandates:
|
||||||
|
|
@ -1968,6 +1969,7 @@ class AppObjects:
|
||||||
def createFeatureAccess(self, userId: str, featureInstanceId: str, roleIds: List[str] = None) -> FeatureAccess:
|
def createFeatureAccess(self, userId: str, featureInstanceId: str, roleIds: List[str] = None) -> FeatureAccess:
|
||||||
"""
|
"""
|
||||||
Create a FeatureAccess record (grant user access to feature instance).
|
Create a FeatureAccess record (grant user access to feature instance).
|
||||||
|
Also auto-assigns the user to the mandate with the 'user' role if not already a member.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
userId: User ID
|
userId: User ID
|
||||||
|
|
@ -1983,6 +1985,9 @@ class AppObjects:
|
||||||
if existing:
|
if existing:
|
||||||
raise ValueError(f"User {userId} already has access to feature instance {featureInstanceId}")
|
raise ValueError(f"User {userId} already has access to feature instance {featureInstanceId}")
|
||||||
|
|
||||||
|
# Auto-assign user to mandate with 'user' role if not already a member
|
||||||
|
self._ensureUserMandateMembership(userId, featureInstanceId)
|
||||||
|
|
||||||
# Create FeatureAccess
|
# Create FeatureAccess
|
||||||
featureAccess = FeatureAccess(
|
featureAccess = FeatureAccess(
|
||||||
userId=userId,
|
userId=userId,
|
||||||
|
|
@ -2007,6 +2012,45 @@ class AppObjects:
|
||||||
logger.error(f"Error creating FeatureAccess: {e}")
|
logger.error(f"Error creating FeatureAccess: {e}")
|
||||||
raise ValueError(f"Failed to create FeatureAccess: {e}")
|
raise ValueError(f"Failed to create FeatureAccess: {e}")
|
||||||
|
|
||||||
|
def _ensureUserMandateMembership(self, userId: str, featureInstanceId: str) -> None:
|
||||||
|
"""
|
||||||
|
Ensure user is a member of the mandate that owns the feature instance.
|
||||||
|
If not already a member, adds them with the 'user' role (no access rights, membership only).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||||
|
|
||||||
|
featureInterface = getFeatureInterface(self.db)
|
||||||
|
instance = featureInterface.getFeatureInstance(featureInstanceId)
|
||||||
|
if not instance or not instance.mandateId:
|
||||||
|
logger.warning(f"Cannot auto-assign mandate: feature instance {featureInstanceId} not found or has no mandateId")
|
||||||
|
return
|
||||||
|
|
||||||
|
mandateId = str(instance.mandateId)
|
||||||
|
|
||||||
|
# Check if user already has mandate membership
|
||||||
|
existing = self.getUserMandate(userId, mandateId)
|
||||||
|
if existing:
|
||||||
|
logger.debug(f"User {userId} already member of mandate {mandateId}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Find the mandate-level 'user' role (membership marker, no access rights)
|
||||||
|
userRoles = self.db.getRecordset(
|
||||||
|
Role,
|
||||||
|
recordFilter={"roleLabel": "user", "mandateId": mandateId, "featureInstanceId": None}
|
||||||
|
)
|
||||||
|
userRoleId = userRoles[0].get("id") if userRoles else None
|
||||||
|
roleIds = [userRoleId] if userRoleId else []
|
||||||
|
|
||||||
|
self.createUserMandate(userId, mandateId, roleIds)
|
||||||
|
logger.info(f"Auto-assigned user {userId} to mandate {mandateId} with 'user' role (via feature instance {featureInstanceId})")
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
# createUserMandate raises ValueError if already exists - safe to ignore
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error auto-assigning user {userId} to mandate: {e}")
|
||||||
|
|
||||||
def getRoleIdsForFeatureAccess(self, featureAccessId: str) -> List[str]:
|
def getRoleIdsForFeatureAccess(self, featureAccessId: str) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Get all role IDs assigned to a FeatureAccess.
|
Get all role IDs assigned to a FeatureAccess.
|
||||||
|
|
@ -2027,6 +2071,34 @@ class AppObjects:
|
||||||
logger.error(f"Error getting role IDs for FeatureAccess: {e}")
|
logger.error(f"Error getting role IDs for FeatureAccess: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def addRoleToFeatureAccess(self, featureAccessId: str, roleId: str) -> None:
|
||||||
|
"""
|
||||||
|
Add a role to a FeatureAccess (via junction table).
|
||||||
|
Skips if the role is already assigned.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
featureAccessId: FeatureAccess ID
|
||||||
|
roleId: Role ID to add
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check if already exists
|
||||||
|
existing = self.db.getRecordset(
|
||||||
|
FeatureAccessRole,
|
||||||
|
recordFilter={"featureAccessId": featureAccessId, "roleId": roleId}
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return # Already assigned
|
||||||
|
|
||||||
|
featureAccessRole = FeatureAccessRole(
|
||||||
|
featureAccessId=featureAccessId,
|
||||||
|
roleId=roleId
|
||||||
|
)
|
||||||
|
self.db.recordCreate(FeatureAccessRole, featureAccessRole.model_dump())
|
||||||
|
logger.debug(f"Added role {roleId} to FeatureAccess {featureAccessId}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding role to FeatureAccess: {e}")
|
||||||
|
raise ValueError(f"Failed to add role to FeatureAccess: {e}")
|
||||||
|
|
||||||
def deleteFeatureAccessRoles(self, featureAccessId: str) -> int:
|
def deleteFeatureAccessRoles(self, featureAccessId: str) -> int:
|
||||||
"""
|
"""
|
||||||
Delete all FeatureAccessRole records for a FeatureAccess.
|
Delete all FeatureAccessRole records for a FeatureAccess.
|
||||||
|
|
@ -2962,7 +3034,8 @@ class AppObjects:
|
||||||
self.db,
|
self.db,
|
||||||
AccessRule,
|
AccessRule,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter=recordFilter if recordFilter else None
|
recordFilter=recordFilter if recordFilter else None,
|
||||||
|
mandateId=self.mandateId
|
||||||
)
|
)
|
||||||
|
|
||||||
# Filter out database-specific fields
|
# Filter out database-specific fields
|
||||||
|
|
|
||||||
|
|
@ -966,20 +966,22 @@ class BillingObjects:
|
||||||
Returns:
|
Returns:
|
||||||
List of BillingBalanceResponse
|
List of BillingBalanceResponse
|
||||||
"""
|
"""
|
||||||
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
||||||
balances = []
|
balances = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
appInterface = getAppInterface(self.currentUser)
|
# Use rootInterface (privileged, SysAdmin context) to bypass RBAC
|
||||||
userMandates = appInterface.getUserMandates(userId)
|
# for mandate/user lookups. User access is verified via UserMandate membership.
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
userMandates = rootInterface.getUserMandates(userId)
|
||||||
|
|
||||||
for um in userMandates:
|
for um in userMandates:
|
||||||
mandateId = getattr(um, 'mandateId', None) or (um.get("mandateId") if isinstance(um, dict) else None)
|
mandateId = getattr(um, 'mandateId', None) or (um.get("mandateId") if isinstance(um, dict) else None)
|
||||||
if not mandateId:
|
if not mandateId:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
mandate = appInterface.getMandate(mandateId)
|
mandate = rootInterface.getMandate(mandateId)
|
||||||
if not mandate:
|
if not mandate:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -995,14 +997,14 @@ class BillingObjects:
|
||||||
|
|
||||||
# Determine effective balance based on billing model
|
# Determine effective balance based on billing model
|
||||||
if billingModel == BillingModelEnum.PREPAY_USER:
|
if billingModel == BillingModelEnum.PREPAY_USER:
|
||||||
account = self.getUserAccount(mandateId, userId)
|
account = self.getOrCreateUserAccount(mandateId, userId)
|
||||||
if not account:
|
if not account:
|
||||||
continue
|
continue
|
||||||
balance = account.get("balance", 0.0)
|
balance = account.get("balance", 0.0)
|
||||||
warningThreshold = account.get("warningThreshold", 0.0)
|
warningThreshold = account.get("warningThreshold", 0.0)
|
||||||
creditLimit = account.get("creditLimit")
|
creditLimit = account.get("creditLimit")
|
||||||
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
|
elif billingModel in [BillingModelEnum.PREPAY_MANDATE, BillingModelEnum.CREDIT_POSTPAY]:
|
||||||
poolAccount = self.getMandateAccount(mandateId)
|
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||||
if not poolAccount:
|
if not poolAccount:
|
||||||
continue
|
continue
|
||||||
balance = poolAccount.get("balance", 0.0)
|
balance = poolAccount.get("balance", 0.0)
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
|
||||||
_chatInterfaces = {}
|
_chatInterfaces = {}
|
||||||
|
|
||||||
|
|
||||||
def storeDebugMessageAndDocuments(message, currentUser) -> None:
|
def storeDebugMessageAndDocuments(message, currentUser, mandateId=None, featureInstanceId=None) -> None:
|
||||||
"""
|
"""
|
||||||
Store message and documents (metadata and file bytes) for debugging purposes.
|
Store message and documents (metadata and file bytes) for debugging purposes.
|
||||||
Structure: {log_dir}/debug/messages/m_round_task_action_timestamp/documentlist_label/
|
Structure: {log_dir}/debug/messages/m_round_task_action_timestamp/documentlist_label/
|
||||||
|
|
@ -53,6 +53,8 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
|
||||||
Args:
|
Args:
|
||||||
message: ChatMessage object to store
|
message: ChatMessage object to store
|
||||||
currentUser: Current user for component interface access
|
currentUser: Current user for component interface access
|
||||||
|
mandateId: Mandate ID for RBAC context (avoids overwriting singleton state)
|
||||||
|
featureInstanceId: Feature instance ID for RBAC context
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import os
|
import os
|
||||||
|
|
@ -152,7 +154,7 @@ def storeDebugMessageAndDocuments(message, currentUser) -> None:
|
||||||
|
|
||||||
# Also store the actual file bytes next to metadata for debugging
|
# Also store the actual file bytes next to metadata for debugging
|
||||||
try:
|
try:
|
||||||
componentInterface = getInterface(currentUser)
|
componentInterface = getInterface(currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
|
||||||
file_bytes = componentInterface.getFileData(doc.fileId)
|
file_bytes = componentInterface.getFileData(doc.fileId)
|
||||||
if file_bytes:
|
if file_bytes:
|
||||||
# Build a safe filename preserving original name
|
# Build a safe filename preserving original name
|
||||||
|
|
@ -715,8 +717,13 @@ class ChatObjects:
|
||||||
logger.debug(f"createWorkflow: Using Root mandate {effectiveMandateId}")
|
logger.debug(f"createWorkflow: Using Root mandate {effectiveMandateId}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not get Root mandate: {e}")
|
logger.warning(f"Could not get Root mandate: {e}")
|
||||||
# Note: Chat data is user-owned, no mandate/featureInstance context stored
|
# Note: ChatWorkflow has featureInstanceId for multi-tenancy isolation.
|
||||||
# mandateId/featureInstanceId removed from ChatWorkflow model
|
# Child tables (ChatMessage, ChatLog, ChatStat, ChatDocument) are user-owned
|
||||||
|
# and do NOT store featureInstanceId - they inherit isolation from ChatWorkflow.
|
||||||
|
# Ensure featureInstanceId is set from context if not already in workflowData
|
||||||
|
if "featureInstanceId" not in workflowData or not workflowData.get("featureInstanceId"):
|
||||||
|
if self.featureInstanceId:
|
||||||
|
workflowData["featureInstanceId"] = self.featureInstanceId
|
||||||
|
|
||||||
# Use generic field separation based on ChatWorkflow model
|
# Use generic field separation based on ChatWorkflow model
|
||||||
simpleFields, objectFields = self._separateObjectFields(ChatWorkflow, workflowData)
|
simpleFields, objectFields = self._separateObjectFields(ChatWorkflow, workflowData)
|
||||||
|
|
@ -726,7 +733,6 @@ class ChatObjects:
|
||||||
|
|
||||||
|
|
||||||
# Convert to ChatWorkflow model (empty related data for new workflow)
|
# Convert to ChatWorkflow model (empty related data for new workflow)
|
||||||
# Note: Chat data is user-owned, no mandate/featureInstance fields
|
|
||||||
return ChatWorkflow(
|
return ChatWorkflow(
|
||||||
id=created["id"],
|
id=created["id"],
|
||||||
status=created.get("status", "running"),
|
status=created.get("status", "running"),
|
||||||
|
|
@ -1122,7 +1128,7 @@ class ChatObjects:
|
||||||
logger.debug(f"Could not emit message event: {e}")
|
logger.debug(f"Could not emit message event: {e}")
|
||||||
|
|
||||||
# Debug: Store message and documents for debugging - only if debug enabled
|
# Debug: Store message and documents for debugging - only if debug enabled
|
||||||
storeDebugMessageAndDocuments(chat_message, self.currentUser)
|
storeDebugMessageAndDocuments(chat_message, self.currentUser, mandateId=self.mandateId, featureInstanceId=self.featureInstanceId)
|
||||||
|
|
||||||
return chat_message
|
return chat_message
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -593,7 +593,11 @@ class ComponentObjects:
|
||||||
# Prompt methods
|
# Prompt methods
|
||||||
|
|
||||||
def _isSysAdmin(self) -> bool:
|
def _isSysAdmin(self) -> bool:
|
||||||
"""Check if the current user is a SysAdmin."""
|
"""Check if the current user has sysadmin role (or isSysAdmin flag as fallback)."""
|
||||||
|
from modules.auth.authentication import _hasSysAdminRole
|
||||||
|
userId = getattr(self.currentUser, 'id', None)
|
||||||
|
if userId and _hasSysAdminRole(str(userId)):
|
||||||
|
return True
|
||||||
return hasattr(self.currentUser, 'isSysAdmin') and self.currentUser.isSysAdmin
|
return hasattr(self.currentUser, 'isSysAdmin') and self.currentUser.isSysAdmin
|
||||||
|
|
||||||
def _enrichPromptsWithPermissions(self, prompts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
def _enrichPromptsWithPermissions(self, prompts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
|
|
@ -1005,7 +1009,8 @@ class ComponentObjects:
|
||||||
# Get all files filtered by RBAC (will be filtered by user's access level)
|
# Get all files filtered by RBAC (will be filtered by user's access level)
|
||||||
files = getRecordsetWithRBAC(self.db,
|
files = getRecordsetWithRBAC(self.db,
|
||||||
FileItem,
|
FileItem,
|
||||||
self.currentUser
|
self.currentUser,
|
||||||
|
mandateId=self.mandateId
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if fileName exists (excluding the current file if updating)
|
# Check if fileName exists (excluding the current file if updating)
|
||||||
|
|
@ -1208,7 +1213,7 @@ class ComponentObjects:
|
||||||
logger.warning(f"No access to file ID {fileId}")
|
logger.warning(f"No access to file ID {fileId}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
fileDataEntries = getRecordsetWithRBAC(self.db, FileData, self.currentUser, recordFilter={"id": fileId})
|
fileDataEntries = getRecordsetWithRBAC(self.db, FileData, self.currentUser, recordFilter={"id": fileId}, mandateId=self.mandateId)
|
||||||
if not fileDataEntries:
|
if not fileDataEntries:
|
||||||
logger.warning(f"No data found for file ID {fileId}")
|
logger.warning(f"No data found for file ID {fileId}")
|
||||||
return None
|
return None
|
||||||
|
|
@ -1367,7 +1372,8 @@ class ComponentObjects:
|
||||||
filteredSettings = getRecordsetWithRBAC(self.db,
|
filteredSettings = getRecordsetWithRBAC(self.db,
|
||||||
VoiceSettings,
|
VoiceSettings,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter={"userId": targetUserId}
|
recordFilter={"userId": targetUserId},
|
||||||
|
mandateId=self.mandateId
|
||||||
)
|
)
|
||||||
|
|
||||||
if not filteredSettings:
|
if not filteredSettings:
|
||||||
|
|
@ -1510,7 +1516,8 @@ class ComponentObjects:
|
||||||
try:
|
try:
|
||||||
filteredSubscriptions = getRecordsetWithRBAC(self.db,
|
filteredSubscriptions = getRecordsetWithRBAC(self.db,
|
||||||
MessagingSubscription,
|
MessagingSubscription,
|
||||||
self.currentUser
|
self.currentUser,
|
||||||
|
mandateId=self.mandateId
|
||||||
)
|
)
|
||||||
|
|
||||||
if pagination is None:
|
if pagination is None:
|
||||||
|
|
@ -1547,7 +1554,8 @@ class ComponentObjects:
|
||||||
filteredSubscriptions = getRecordsetWithRBAC(self.db,
|
filteredSubscriptions = getRecordsetWithRBAC(self.db,
|
||||||
MessagingSubscription,
|
MessagingSubscription,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter={"subscriptionId": subscriptionId}
|
recordFilter={"subscriptionId": subscriptionId},
|
||||||
|
mandateId=self.mandateId
|
||||||
)
|
)
|
||||||
return MessagingSubscription(**filteredSubscriptions[0]) if filteredSubscriptions else None
|
return MessagingSubscription(**filteredSubscriptions[0]) if filteredSubscriptions else None
|
||||||
|
|
||||||
|
|
@ -1556,7 +1564,8 @@ class ComponentObjects:
|
||||||
filteredSubscriptions = getRecordsetWithRBAC(self.db,
|
filteredSubscriptions = getRecordsetWithRBAC(self.db,
|
||||||
MessagingSubscription,
|
MessagingSubscription,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter={"id": id}
|
recordFilter={"id": id},
|
||||||
|
mandateId=self.mandateId
|
||||||
)
|
)
|
||||||
return MessagingSubscription(**filteredSubscriptions[0]) if filteredSubscriptions else None
|
return MessagingSubscription(**filteredSubscriptions[0]) if filteredSubscriptions else None
|
||||||
|
|
||||||
|
|
@ -1629,7 +1638,8 @@ class ComponentObjects:
|
||||||
filteredRegistrations = getRecordsetWithRBAC(self.db,
|
filteredRegistrations = getRecordsetWithRBAC(self.db,
|
||||||
MessagingSubscriptionRegistration,
|
MessagingSubscriptionRegistration,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter=recordFilter if recordFilter else None
|
recordFilter=recordFilter if recordFilter else None,
|
||||||
|
mandateId=self.mandateId
|
||||||
)
|
)
|
||||||
|
|
||||||
if pagination is None:
|
if pagination is None:
|
||||||
|
|
@ -1666,7 +1676,8 @@ class ComponentObjects:
|
||||||
filteredRegistrations = getRecordsetWithRBAC(self.db,
|
filteredRegistrations = getRecordsetWithRBAC(self.db,
|
||||||
MessagingSubscriptionRegistration,
|
MessagingSubscriptionRegistration,
|
||||||
self.currentUser,
|
self.currentUser,
|
||||||
recordFilter={"id": registrationId}
|
recordFilter={"id": registrationId},
|
||||||
|
mandateId=self.mandateId
|
||||||
)
|
)
|
||||||
return MessagingSubscriptionRegistration(**filteredRegistrations[0]) if filteredRegistrations else None
|
return MessagingSubscriptionRegistration(**filteredRegistrations[0]) if filteredRegistrations else None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -163,13 +163,21 @@ def getRecordsetWithRBAC(
|
||||||
|
|
||||||
# Check view permission first
|
# Check view permission first
|
||||||
if not permissions.view:
|
if not permissions.view:
|
||||||
logger.debug(f"User {currentUser.id} has no view permission for {objectKey} (mandateId={effectiveMandateId}, featureInstanceId={featureInstanceId})")
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Build WHERE clause with RBAC filtering
|
# Build WHERE clause with RBAC filtering
|
||||||
whereConditions = []
|
whereConditions = []
|
||||||
whereValues = []
|
whereValues = []
|
||||||
|
|
||||||
|
# CRITICAL: Only pass featureInstanceId to WHERE clause if the model actually has
|
||||||
|
# this column. Chat child tables (ChatMessage, ChatLog, ChatStat, ChatDocument)
|
||||||
|
# are user-owned and do NOT have featureInstanceId - only ChatWorkflow does.
|
||||||
|
# Without this check, the SQL query would reference a non-existent column,
|
||||||
|
# causing a silent error that returns empty results.
|
||||||
|
featureInstanceIdForQuery = featureInstanceId
|
||||||
|
if featureInstanceId and hasattr(modelClass, 'model_fields') and "featureInstanceId" not in modelClass.model_fields:
|
||||||
|
featureInstanceIdForQuery = None
|
||||||
|
|
||||||
# Add RBAC WHERE clause based on read permission
|
# Add RBAC WHERE clause based on read permission
|
||||||
rbacWhereClause = buildRbacWhereClause(
|
rbacWhereClause = buildRbacWhereClause(
|
||||||
permissions,
|
permissions,
|
||||||
|
|
@ -177,7 +185,7 @@ def getRecordsetWithRBAC(
|
||||||
table,
|
table,
|
||||||
connector,
|
connector,
|
||||||
mandateId=effectiveMandateId,
|
mandateId=effectiveMandateId,
|
||||||
featureInstanceId=featureInstanceId
|
featureInstanceId=featureInstanceIdForQuery
|
||||||
)
|
)
|
||||||
if rbacWhereClause:
|
if rbacWhereClause:
|
||||||
whereConditions.append(rbacWhereClause["condition"])
|
whereConditions.append(rbacWhereClause["condition"])
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Admin automation events routes for the backend API.
|
Admin automation events routes for the backend API.
|
||||||
Sysadmin-only endpoints for viewing and controlling automation events.
|
Sysadmin-only endpoints for viewing and controlling scheduler events.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Path, Request, Response
|
from fastapi import APIRouter, HTTPException, Depends, Path, Request, Response
|
||||||
|
|
@ -12,7 +12,7 @@ import logging
|
||||||
|
|
||||||
# Import interfaces and models from feature containers
|
# Import interfaces and models from feature containers
|
||||||
import modules.features.automation.interfaceFeatureAutomation as interfaceAutomation
|
import modules.features.automation.interfaceFeatureAutomation as interfaceAutomation
|
||||||
from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext
|
from modules.auth import limiter, getRequestContext, requireSysAdminRole, RequestContext
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
|
|
||||||
# Configure logger
|
# Configure logger
|
||||||
|
|
@ -35,27 +35,110 @@ router = APIRouter(
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def get_all_automation_events(
|
def get_all_automation_events(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
currentUser: User = Depends(requireSysAdminRole)
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get all automation events across all mandates (sysadmin only).
|
Get all active scheduler jobs (sysadmin only).
|
||||||
Returns list of all registered events with their automation IDs and schedules.
|
Each job is enriched with context from its automation definition
|
||||||
|
(name, mandate, feature instance, creator) for readability.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from modules.shared.eventManagement import eventManager
|
from modules.shared.eventManagement import eventManager
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
from modules.services import getInterface as getServices
|
||||||
|
|
||||||
# Get all jobs from scheduler
|
if not eventManager.scheduler:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 1. Collect all scheduler jobs
|
||||||
jobs = []
|
jobs = []
|
||||||
if eventManager.scheduler:
|
automationIds = []
|
||||||
for job in eventManager.scheduler.get_jobs():
|
for job in eventManager.scheduler.get_jobs():
|
||||||
if job.id.startswith("automation."):
|
if job.id.startswith("automation."):
|
||||||
automation_id = job.id.replace("automation.", "")
|
automationId = job.id.replace("automation.", "")
|
||||||
jobs.append({
|
automationIds.append(automationId)
|
||||||
"eventId": job.id,
|
jobs.append({
|
||||||
"automationId": automation_id,
|
"eventId": job.id,
|
||||||
"nextRunTime": str(job.next_run_time) if job.next_run_time else None,
|
"automationId": automationId,
|
||||||
"trigger": str(job.trigger) if job.trigger else None
|
"nextRunTime": str(job.next_run_time) if job.next_run_time else None,
|
||||||
})
|
"trigger": str(job.trigger) if job.trigger else None,
|
||||||
|
"name": "",
|
||||||
|
"createdBy": "",
|
||||||
|
"mandate": "",
|
||||||
|
"featureInstance": ""
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. Enrich with context from automation definitions
|
||||||
|
if jobs:
|
||||||
|
try:
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
eventUser = rootInterface.getUserByUsername("event")
|
||||||
|
if eventUser:
|
||||||
|
services = getServices(currentUser, None)
|
||||||
|
allAutomations = services.interfaceDbAutomation.getAllAutomationDefinitionsWithRBAC(eventUser)
|
||||||
|
|
||||||
|
# Build lookup by automation ID
|
||||||
|
automationLookup = {}
|
||||||
|
for a in allAutomations:
|
||||||
|
aId = a.get("id", "") if isinstance(a, dict) else getattr(a, "id", "")
|
||||||
|
automationLookup[aId] = a
|
||||||
|
|
||||||
|
# Caches for resolving UUIDs to names
|
||||||
|
_userCache = {}
|
||||||
|
_mandateCache = {}
|
||||||
|
_featureCache = {}
|
||||||
|
|
||||||
|
def _resolveUsername(userId):
|
||||||
|
if not userId:
|
||||||
|
return ""
|
||||||
|
if userId not in _userCache:
|
||||||
|
try:
|
||||||
|
user = rootInterface.getUser(userId)
|
||||||
|
_userCache[userId] = user.username if user else userId[:8]
|
||||||
|
except Exception:
|
||||||
|
_userCache[userId] = userId[:8]
|
||||||
|
return _userCache[userId]
|
||||||
|
|
||||||
|
def _resolveMandateLabel(mandateId):
|
||||||
|
if not mandateId:
|
||||||
|
return ""
|
||||||
|
if mandateId not in _mandateCache:
|
||||||
|
try:
|
||||||
|
mandate = rootInterface.getMandate(mandateId)
|
||||||
|
_mandateCache[mandateId] = getattr(mandate, "label", None) or mandateId[:8]
|
||||||
|
except Exception:
|
||||||
|
_mandateCache[mandateId] = mandateId[:8]
|
||||||
|
return _mandateCache[mandateId]
|
||||||
|
|
||||||
|
def _resolveFeatureLabel(featureInstanceId):
|
||||||
|
if not featureInstanceId:
|
||||||
|
return ""
|
||||||
|
if featureInstanceId not in _featureCache:
|
||||||
|
try:
|
||||||
|
instance = rootInterface.getFeatureInstance(featureInstanceId)
|
||||||
|
_featureCache[featureInstanceId] = getattr(instance, "label", None) or getattr(instance, "featureCode", None) or featureInstanceId[:8]
|
||||||
|
except Exception:
|
||||||
|
_featureCache[featureInstanceId] = featureInstanceId[:8]
|
||||||
|
return _featureCache[featureInstanceId]
|
||||||
|
|
||||||
|
# Enrich each job
|
||||||
|
for job in jobs:
|
||||||
|
automation = automationLookup.get(job["automationId"])
|
||||||
|
if automation:
|
||||||
|
if isinstance(automation, dict):
|
||||||
|
job["name"] = automation.get("label", "")
|
||||||
|
job["createdBy"] = _resolveUsername(automation.get("_createdBy", ""))
|
||||||
|
job["mandate"] = _resolveMandateLabel(automation.get("mandateId", ""))
|
||||||
|
job["featureInstance"] = _resolveFeatureLabel(automation.get("featureInstanceId", ""))
|
||||||
|
else:
|
||||||
|
job["name"] = getattr(automation, "label", "")
|
||||||
|
job["createdBy"] = _resolveUsername(getattr(automation, "_createdBy", ""))
|
||||||
|
job["mandate"] = _resolveMandateLabel(getattr(automation, "mandateId", ""))
|
||||||
|
job["featureInstance"] = _resolveFeatureLabel(getattr(automation, "featureInstanceId", ""))
|
||||||
|
else:
|
||||||
|
job["name"] = "(orphaned)"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not enrich automation events with context: {e}")
|
||||||
|
|
||||||
return jobs
|
return jobs
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -69,7 +152,7 @@ def get_all_automation_events(
|
||||||
@limiter.limit("5/minute")
|
@limiter.limit("5/minute")
|
||||||
async def sync_all_automation_events(
|
async def sync_all_automation_events(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
currentUser: User = Depends(requireSysAdminRole)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Manually trigger sync for all automations (sysadmin only).
|
Manually trigger sync for all automations (sysadmin only).
|
||||||
|
|
@ -110,25 +193,26 @@ async def sync_all_automation_events(
|
||||||
def remove_event(
|
def remove_event(
|
||||||
request: Request,
|
request: Request,
|
||||||
eventId: str = Path(..., description="Event ID to remove"),
|
eventId: str = Path(..., description="Event ID to remove"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
currentUser: User = Depends(requireSysAdminRole)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Manually remove a specific event from scheduler (sysadmin only).
|
Remove a scheduler job (sysadmin only).
|
||||||
Used for debugging and manual event cleanup.
|
Removes the job from the scheduler and clears the eventId on the automation definition.
|
||||||
|
Does NOT delete the automation definition itself.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from modules.shared.eventManagement import eventManager
|
from modules.shared.eventManagement import eventManager
|
||||||
|
|
||||||
# Remove event
|
# Remove scheduler job
|
||||||
eventManager.remove(eventId)
|
eventManager.remove(eventId)
|
||||||
|
|
||||||
# Update automation's eventId if it exists
|
# Clear eventId on the automation definition (so it can be re-synced later)
|
||||||
if eventId.startswith("automation."):
|
if eventId.startswith("automation."):
|
||||||
automation_id = eventId.replace("automation.", "")
|
automationId = eventId.replace("automation.", "")
|
||||||
automationInterface = interfaceAutomation.getInterface(currentUser)
|
automationInterface = interfaceAutomation.getInterface(currentUser)
|
||||||
automation = automationInterface.getAutomationDefinition(automation_id)
|
automation = automationInterface.getAutomationDefinition(automationId)
|
||||||
if automation and getattr(automation, "eventId", None) == eventId:
|
if automation and getattr(automation, "eventId", None) == eventId:
|
||||||
automationInterface.updateAutomationDefinition(automation_id, {"eventId": None})
|
automationInterface.updateAutomationDefinition(automationId, {"eventId": None})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ from fastapi import status
|
||||||
import logging
|
import logging
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdmin
|
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdminRole
|
||||||
from modules.datamodels.datamodelUam import User, UserInDB
|
from modules.datamodels.datamodelUam import User, UserInDB
|
||||||
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
|
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
@ -337,7 +337,7 @@ def create_feature(
|
||||||
code: str = Query(..., description="Unique feature code"),
|
code: str = Query(..., description="Unique feature code"),
|
||||||
label: Dict[str, str] = None,
|
label: Dict[str, str] = None,
|
||||||
icon: str = Query("mdi-puzzle", description="Icon identifier"),
|
icon: str = Query("mdi-puzzle", description="Icon identifier"),
|
||||||
sysAdmin: User = Depends(requireSysAdmin)
|
sysAdmin: User = Depends(requireSysAdminRole)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Create a new feature definition.
|
Create a new feature definition.
|
||||||
|
|
@ -453,7 +453,7 @@ def get_feature_instance(
|
||||||
|
|
||||||
# Verify mandate access (unless SysAdmin)
|
# Verify mandate access (unless SysAdmin)
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isSysAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Access denied to this feature instance"
|
detail="Access denied to this feature instance"
|
||||||
|
|
@ -567,14 +567,14 @@ def delete_feature_instance(
|
||||||
|
|
||||||
# Verify mandate access
|
# Verify mandate access
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isSysAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Access denied to this feature instance"
|
detail="Access denied to this feature instance"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check mandate admin permission
|
# Check mandate admin permission
|
||||||
if not _hasMandateAdminRole(context) and not context.isSysAdmin:
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Mandate-Admin role required to delete feature instances"
|
detail="Mandate-Admin role required to delete feature instances"
|
||||||
|
|
@ -634,14 +634,14 @@ def updateFeatureInstance(
|
||||||
|
|
||||||
# Verify mandate access
|
# Verify mandate access
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isSysAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Access denied to this feature instance"
|
detail="Access denied to this feature instance"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check mandate admin permission
|
# Check mandate admin permission
|
||||||
if not _hasMandateAdminRole(context) and not context.isSysAdmin:
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Mandate-Admin role required to update feature instances"
|
detail="Mandate-Admin role required to update feature instances"
|
||||||
|
|
@ -712,14 +712,14 @@ def sync_instance_roles(
|
||||||
|
|
||||||
# Verify mandate access
|
# Verify mandate access
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isSysAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Access denied to this feature instance"
|
detail="Access denied to this feature instance"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check admin permission (Mandate-Admin or Feature-Admin)
|
# Check admin permission (Mandate-Admin or Feature-Admin)
|
||||||
if not _hasMandateAdminRole(context) and not context.isSysAdmin:
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Admin role required to sync roles"
|
detail="Admin role required to sync roles"
|
||||||
|
|
@ -752,7 +752,7 @@ def sync_instance_roles(
|
||||||
def list_template_roles(
|
def list_template_roles(
|
||||||
request: Request,
|
request: Request,
|
||||||
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
|
featureCode: Optional[str] = Query(None, description="Filter by feature code"),
|
||||||
sysAdmin: User = Depends(requireSysAdmin)
|
sysAdmin: User = Depends(requireSysAdminRole)
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
List global template roles.
|
List global template roles.
|
||||||
|
|
@ -784,7 +784,7 @@ def create_template_role(
|
||||||
roleLabel: str = Query(..., description="Role label (e.g., 'admin', 'viewer')"),
|
roleLabel: str = Query(..., description="Role label (e.g., 'admin', 'viewer')"),
|
||||||
featureCode: str = Query(..., description="Feature code this role belongs to"),
|
featureCode: str = Query(..., description="Feature code this role belongs to"),
|
||||||
description: Dict[str, str] = None,
|
description: Dict[str, str] = None,
|
||||||
sysAdmin: User = Depends(requireSysAdmin)
|
sysAdmin: User = Depends(requireSysAdminRole)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Create a global template role for a feature.
|
Create a global template role for a feature.
|
||||||
|
|
@ -891,7 +891,7 @@ def list_feature_instance_users(
|
||||||
|
|
||||||
# Verify mandate access (unless SysAdmin)
|
# Verify mandate access (unless SysAdmin)
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isSysAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Access denied to this feature instance"
|
detail="Access denied to this feature instance"
|
||||||
|
|
@ -971,14 +971,14 @@ def add_user_to_feature_instance(
|
||||||
|
|
||||||
# Verify mandate access
|
# Verify mandate access
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isSysAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Access denied to this feature instance"
|
detail="Access denied to this feature instance"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check admin permission
|
# Check admin permission
|
||||||
if not _hasMandateAdminRole(context) and not context.isSysAdmin:
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Admin role required to add users to feature instances"
|
detail="Admin role required to add users to feature instances"
|
||||||
|
|
@ -1072,14 +1072,14 @@ def remove_user_from_feature_instance(
|
||||||
|
|
||||||
# Verify mandate access
|
# Verify mandate access
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isSysAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Access denied to this feature instance"
|
detail="Access denied to this feature instance"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check admin permission
|
# Check admin permission
|
||||||
if not _hasMandateAdminRole(context) and not context.isSysAdmin:
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Admin role required to remove users from feature instances"
|
detail="Admin role required to remove users from feature instances"
|
||||||
|
|
@ -1153,14 +1153,14 @@ def update_feature_instance_user_roles(
|
||||||
|
|
||||||
# Verify mandate access
|
# Verify mandate access
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isSysAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Access denied to this feature instance"
|
detail="Access denied to this feature instance"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check admin permission
|
# Check admin permission
|
||||||
if not _hasMandateAdminRole(context) and not context.isSysAdmin:
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Admin role required to update user roles"
|
detail="Admin role required to update user roles"
|
||||||
|
|
@ -1243,7 +1243,7 @@ def get_feature_instance_available_roles(
|
||||||
|
|
||||||
# Verify mandate access
|
# Verify mandate access
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isSysAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Access denied to this feature instance"
|
detail="Access denied to this feature instance"
|
||||||
|
|
@ -1326,7 +1326,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
|
||||||
|
|
||||||
A user is mandate admin if they have the 'admin' role at mandate level.
|
A user is mandate admin if they have the 'admin' role at mandate level.
|
||||||
"""
|
"""
|
||||||
if context.isSysAdmin:
|
if context.hasSysAdminRole:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if not context.roleIds:
|
if not context.roleIds:
|
||||||
|
|
@ -1341,7 +1341,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
|
||||||
if role:
|
if role:
|
||||||
roleLabel = role.roleLabel
|
roleLabel = role.roleLabel
|
||||||
# Admin role at mandate level (not feature-instance level)
|
# Admin role at mandate level (not feature-instance level)
|
||||||
if roleLabel == "admin" and role.mandateId and not role.featureInstanceId:
|
if roleLabel == "admin" and not role.featureInstanceId:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import logging
|
||||||
import json
|
import json
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdmin
|
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdminRole
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.datamodels.datamodelRbac import Role, AccessRule
|
from modules.datamodels.datamodelRbac import Role, AccessRule
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
@ -74,7 +74,7 @@ class RbacImportResult(BaseModel):
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
def export_global_rbac(
|
def export_global_rbac(
|
||||||
request: Request,
|
request: Request,
|
||||||
sysAdmin: User = Depends(requireSysAdmin)
|
sysAdmin: User = Depends(requireSysAdminRole)
|
||||||
) -> RbacExportData:
|
) -> RbacExportData:
|
||||||
"""
|
"""
|
||||||
Export global (template) RBAC rules.
|
Export global (template) RBAC rules.
|
||||||
|
|
@ -139,7 +139,7 @@ async def import_global_rbac(
|
||||||
request: Request,
|
request: Request,
|
||||||
file: UploadFile = File(..., description="JSON file with RBAC export data"),
|
file: UploadFile = File(..., description="JSON file with RBAC export data"),
|
||||||
updateExisting: bool = False,
|
updateExisting: bool = False,
|
||||||
sysAdmin: User = Depends(requireSysAdmin)
|
sysAdmin: User = Depends(requireSysAdminRole)
|
||||||
) -> RbacImportResult:
|
) -> RbacImportResult:
|
||||||
"""
|
"""
|
||||||
Import global (template) RBAC rules.
|
Import global (template) RBAC rules.
|
||||||
|
|
@ -538,7 +538,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if the user has mandate admin role in the current context.
|
Check if the user has mandate admin role in the current context.
|
||||||
"""
|
"""
|
||||||
if context.isSysAdmin:
|
if context.hasSysAdminRole:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if not context.roleIds:
|
if not context.roleIds:
|
||||||
|
|
@ -552,7 +552,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
|
||||||
if role:
|
if role:
|
||||||
roleLabel = role.roleLabel
|
roleLabel = role.roleLabel
|
||||||
# Admin role at mandate level
|
# Admin role at mandate level
|
||||||
if roleLabel == "admin" and role.mandateId and not role.featureInstanceId:
|
if roleLabel == "admin" and not role.featureInstanceId:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,19 @@
|
||||||
Admin RBAC Roles Management routes.
|
Admin RBAC Roles Management routes.
|
||||||
Provides endpoints for managing roles and role assignments to users.
|
Provides endpoints for managing roles and role assignments to users.
|
||||||
|
|
||||||
MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true.
|
MULTI-TENANT: Context-aware access control.
|
||||||
Roles are global system resources, not mandate-specific.
|
- SysAdmin: Full access to all roles and assignments across all mandates.
|
||||||
|
- MandateAdmin: Can manage roles and assignments within their own mandates.
|
||||||
|
Template roles (mandateId=None, isSystemRole=True) are read-only.
|
||||||
|
The sysadmin role (roleLabel="sysadmin") is not manageable by MandateAdmins.
|
||||||
Role assignments are managed via UserMandateRole (not User.roleLabels).
|
Role assignments are managed via UserMandateRole (not User.roleLabels).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request
|
from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request, status
|
||||||
from typing import List, Dict, Any, Optional, Set
|
from typing import List, Dict, Any, Optional, Set
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from modules.auth import limiter, requireSysAdmin
|
from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
|
||||||
from modules.datamodels.datamodelUam import User, UserInDB
|
from modules.datamodels.datamodelUam import User, UserInDB
|
||||||
from modules.datamodels.datamodelRbac import Role
|
from modules.datamodels.datamodelRbac import Role
|
||||||
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
||||||
|
|
@ -59,6 +62,31 @@ def _hasRoleLabel(interface, userId: str, roleLabel: str) -> bool:
|
||||||
"""
|
"""
|
||||||
return roleLabel in _getUserRoleLabels(interface, userId)
|
return roleLabel in _getUserRoleLabels(interface, userId)
|
||||||
|
|
||||||
|
|
||||||
|
def _getAdminMandateIds(context: RequestContext) -> List[str]:
|
||||||
|
"""Get mandate IDs where the user has admin role."""
|
||||||
|
mandateIds = []
|
||||||
|
try:
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
userMandates = rootInterface.getUserMandates(str(context.user.id))
|
||||||
|
for um in userMandates:
|
||||||
|
if not getattr(um, 'enabled', True):
|
||||||
|
continue
|
||||||
|
umId = getattr(um, 'id', None)
|
||||||
|
mandateId = getattr(um, 'mandateId', None)
|
||||||
|
if not umId or not mandateId:
|
||||||
|
continue
|
||||||
|
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
|
||||||
|
for roleId in roleIds:
|
||||||
|
role = rootInterface.getRole(roleId)
|
||||||
|
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
||||||
|
mandateIds.append(str(mandateId))
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting admin mandate IDs: {e}")
|
||||||
|
return mandateIds
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/api/admin/rbac/roles",
|
prefix="/api/admin/rbac/roles",
|
||||||
tags=["Admin RBAC Roles"],
|
tags=["Admin RBAC Roles"],
|
||||||
|
|
@ -71,20 +99,32 @@ router = APIRouter(
|
||||||
def list_roles(
|
def list_roles(
|
||||||
request: Request,
|
request: Request,
|
||||||
mandateId: Optional[str] = Query(None, description="Filter roles by mandate ID"),
|
mandateId: Optional[str] = Query(None, description="Filter roles by mandate ID"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get list of roles with metadata.
|
Get list of roles with metadata.
|
||||||
|
|
||||||
|
Context-aware: SysAdmin sees all roles. MandateAdmin sees roles from own mandates
|
||||||
|
plus template roles (read-only).
|
||||||
|
|
||||||
Without mandateId: returns system template roles (mandateId=NULL).
|
Without mandateId: returns system template roles (mandateId=NULL).
|
||||||
With mandateId: returns mandate-level roles for that mandate (featureInstanceId=NULL).
|
With mandateId: returns mandate-level roles for that mandate (featureInstanceId=NULL).
|
||||||
"""
|
"""
|
||||||
|
isSysAdmin = context.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||||
|
currentUser = context.user # backward compat for existing code
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
# Get roles filtered by scope
|
# Get roles filtered by scope
|
||||||
print(f"[DEBUG list_roles] mandateId={mandateId}")
|
print(f"[DEBUG list_roles] mandateId={mandateId}")
|
||||||
if mandateId:
|
if mandateId:
|
||||||
|
# MandateAdmin can only query mandates they admin
|
||||||
|
if not isSysAdmin and mandateId not in adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this mandate")
|
||||||
# Mandate-specific roles (mandate-level only, no feature-instance roles)
|
# Mandate-specific roles (mandate-level only, no feature-instance roles)
|
||||||
dbRoles = interface.getRolesForMandate(mandateId)
|
dbRoles = interface.getRolesForMandate(mandateId)
|
||||||
print(f"[DEBUG list_roles] getRolesForMandate returned {len(dbRoles)} roles")
|
print(f"[DEBUG list_roles] getRolesForMandate returned {len(dbRoles)} roles")
|
||||||
|
|
@ -92,6 +132,9 @@ def list_roles(
|
||||||
# System template roles only
|
# System template roles only
|
||||||
dbRoles = interface.getAllRoles()
|
dbRoles = interface.getAllRoles()
|
||||||
print(f"[DEBUG list_roles] getAllRoles returned {len(dbRoles)} roles")
|
print(f"[DEBUG list_roles] getAllRoles returned {len(dbRoles)} roles")
|
||||||
|
# MandateAdmin: filter to template roles + roles from own mandates
|
||||||
|
if not isSysAdmin:
|
||||||
|
dbRoles = [r for r in dbRoles if r.mandateId is None or str(r.mandateId) in adminMandateIds]
|
||||||
|
|
||||||
# Count role assignments from UserMandateRole table
|
# Count role assignments from UserMandateRole table
|
||||||
roleCounts = interface.countRoleAssignments()
|
roleCounts = interface.countRoleAssignments()
|
||||||
|
|
@ -125,21 +168,32 @@ def list_roles(
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
def get_role_options(
|
def get_role_options(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get role options for select dropdowns.
|
Get role options for select dropdowns.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
Context-aware: SysAdmin sees all roles. MandateAdmin sees roles from own mandates
|
||||||
|
plus template roles.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- List of role option dictionaries with value and label
|
- List of role option dictionaries with value and label
|
||||||
"""
|
"""
|
||||||
|
isSysAdmin = context.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||||
|
currentUser = context.user # backward compat for existing code
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
# Get all roles from database
|
# Get all roles from database
|
||||||
dbRoles = interface.getAllRoles()
|
dbRoles = interface.getAllRoles()
|
||||||
|
|
||||||
|
# MandateAdmin: filter to template roles + roles from own mandates
|
||||||
|
if not isSysAdmin:
|
||||||
|
dbRoles = [r for r in dbRoles if r.mandateId is None or str(r.mandateId) in adminMandateIds]
|
||||||
|
|
||||||
# Convert to options format
|
# Convert to options format
|
||||||
options = []
|
options = []
|
||||||
for role in dbRoles:
|
for role in dbRoles:
|
||||||
|
|
@ -167,11 +221,12 @@ def get_role_options(
|
||||||
def create_role(
|
def create_role(
|
||||||
request: Request,
|
request: Request,
|
||||||
role: Role = Body(...),
|
role: Role = Body(...),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Create a new role.
|
Create a new role.
|
||||||
MULTI-TENANT: SysAdmin-only (roles are system resources).
|
Context-aware: SysAdmin can create any role. MandateAdmin can create roles
|
||||||
|
within own mandates only (not template or sysadmin roles).
|
||||||
|
|
||||||
Request Body:
|
Request Body:
|
||||||
- role: Role object to create
|
- role: Role object to create
|
||||||
|
|
@ -179,9 +234,24 @@ def create_role(
|
||||||
Returns:
|
Returns:
|
||||||
- Created role dictionary
|
- Created role dictionary
|
||||||
"""
|
"""
|
||||||
|
isSysAdmin = context.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||||
|
currentUser = context.user # backward compat for existing code
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
|
# MandateAdmin restrictions
|
||||||
|
if not isSysAdmin:
|
||||||
|
if role.roleLabel == "sysadmin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot create sysadmin role")
|
||||||
|
if role.mandateId is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot create template roles")
|
||||||
|
if str(role.mandateId) not in adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this mandate")
|
||||||
|
|
||||||
createdRole = interface.createRole(role)
|
createdRole = interface.createRole(role)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -211,11 +281,12 @@ def create_role(
|
||||||
def get_role(
|
def get_role(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleId: str = Path(..., description="Role ID"),
|
roleId: str = Path(..., description="Role ID"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get a role by ID.
|
Get a role by ID.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
Context-aware: SysAdmin sees all. MandateAdmin sees roles from own mandates
|
||||||
|
plus template roles (read-only).
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- roleId: Role ID
|
- roleId: Role ID
|
||||||
|
|
@ -223,6 +294,12 @@ def get_role(
|
||||||
Returns:
|
Returns:
|
||||||
- Role dictionary
|
- Role dictionary
|
||||||
"""
|
"""
|
||||||
|
isSysAdmin = context.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||||
|
currentUser = context.user # backward compat for existing code
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
|
|
@ -233,6 +310,11 @@ def get_role(
|
||||||
detail=f"Role {roleId} not found"
|
detail=f"Role {roleId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# MandateAdmin: can view template roles (read-only) or own mandate roles
|
||||||
|
if not isSysAdmin:
|
||||||
|
if role.mandateId is not None and str(role.mandateId) not in adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this role")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": role.id,
|
"id": role.id,
|
||||||
"roleLabel": role.roleLabel,
|
"roleLabel": role.roleLabel,
|
||||||
|
|
@ -256,11 +338,12 @@ def update_role(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleId: str = Path(..., description="Role ID"),
|
roleId: str = Path(..., description="Role ID"),
|
||||||
role: Role = Body(...),
|
role: Role = Body(...),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Update an existing role.
|
Update an existing role.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
Context-aware: SysAdmin can update any role. MandateAdmin can update roles
|
||||||
|
within own mandates only. Template roles and sysadmin role are blocked.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- roleId: Role ID
|
- roleId: Role ID
|
||||||
|
|
@ -271,9 +354,27 @@ def update_role(
|
||||||
Returns:
|
Returns:
|
||||||
- Updated role dictionary
|
- Updated role dictionary
|
||||||
"""
|
"""
|
||||||
|
isSysAdmin = context.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||||
|
currentUser = context.user # backward compat for existing code
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
|
# MandateAdmin restrictions: check existing role before updating
|
||||||
|
if not isSysAdmin:
|
||||||
|
existingRole = interface.getRole(roleId)
|
||||||
|
if not existingRole:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
|
||||||
|
if existingRole.roleLabel == "sysadmin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot modify sysadmin role")
|
||||||
|
if existingRole.mandateId is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot modify template roles")
|
||||||
|
if str(existingRole.mandateId) not in adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this role")
|
||||||
|
|
||||||
updatedRole = interface.updateRole(roleId, role)
|
updatedRole = interface.updateRole(roleId, role)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -303,11 +404,12 @@ def update_role(
|
||||||
def delete_role(
|
def delete_role(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleId: str = Path(..., description="Role ID"),
|
roleId: str = Path(..., description="Role ID"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, str]:
|
) -> Dict[str, str]:
|
||||||
"""
|
"""
|
||||||
Delete a role.
|
Delete a role.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
Context-aware: SysAdmin can delete any role. MandateAdmin can delete roles
|
||||||
|
within own mandates only. Template roles and sysadmin role are blocked.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- roleId: Role ID
|
- roleId: Role ID
|
||||||
|
|
@ -315,9 +417,27 @@ def delete_role(
|
||||||
Returns:
|
Returns:
|
||||||
- Success message
|
- Success message
|
||||||
"""
|
"""
|
||||||
|
isSysAdmin = context.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||||
|
currentUser = context.user # backward compat for existing code
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
|
# MandateAdmin restrictions: check existing role before deleting
|
||||||
|
if not isSysAdmin:
|
||||||
|
existingRole = interface.getRole(roleId)
|
||||||
|
if not existingRole:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
|
||||||
|
if existingRole.roleLabel == "sysadmin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete sysadmin role")
|
||||||
|
if existingRole.mandateId is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete template roles")
|
||||||
|
if str(existingRole.mandateId) not in adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this role")
|
||||||
|
|
||||||
success = interface.deleteRole(roleId)
|
success = interface.deleteRole(roleId)
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -348,11 +468,11 @@ def list_users_with_roles(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleLabel: Optional[str] = Query(None, description="Filter by role label"),
|
roleLabel: Optional[str] = Query(None, description="Filter by role label"),
|
||||||
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
|
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get list of users with their role assignments.
|
Get list of users with their role assignments.
|
||||||
MULTI-TENANT: SysAdmin-only, can see all users across mandates.
|
Context-aware: SysAdmin sees all users. MandateAdmin sees users from own mandates only.
|
||||||
|
|
||||||
Query Parameters:
|
Query Parameters:
|
||||||
- roleLabel: Optional filter by role label
|
- roleLabel: Optional filter by role label
|
||||||
|
|
@ -361,6 +481,12 @@ def list_users_with_roles(
|
||||||
Returns:
|
Returns:
|
||||||
- List of user dictionaries with role assignments
|
- List of user dictionaries with role assignments
|
||||||
"""
|
"""
|
||||||
|
isSysAdmin = context.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||||
|
currentUser = context.user # backward compat for existing code
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
|
|
@ -369,9 +495,20 @@ def list_users_with_roles(
|
||||||
|
|
||||||
# Filter by mandate if specified (via UserMandate table)
|
# Filter by mandate if specified (via UserMandate table)
|
||||||
if mandateId:
|
if mandateId:
|
||||||
|
# MandateAdmin can only query mandates they admin
|
||||||
|
if not isSysAdmin and mandateId not in adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this mandate")
|
||||||
userMandates = interface.getUserMandatesByMandate(mandateId)
|
userMandates = interface.getUserMandatesByMandate(mandateId)
|
||||||
mandateUserIds = {str(um.userId) for um in userMandates}
|
mandateUserIds = {str(um.userId) for um in userMandates}
|
||||||
users = [u for u in users if str(u.id) in mandateUserIds]
|
users = [u for u in users if str(u.id) in mandateUserIds]
|
||||||
|
elif not isSysAdmin:
|
||||||
|
# MandateAdmin without mandateId filter: restrict to users in admin's mandates
|
||||||
|
allowedUserIds: Set[str] = set()
|
||||||
|
for mId in adminMandateIds:
|
||||||
|
userMandates = interface.getUserMandatesByMandate(mId)
|
||||||
|
for um in userMandates:
|
||||||
|
allowedUserIds.add(str(um.userId))
|
||||||
|
users = [u for u in users if str(u.id) in allowedUserIds]
|
||||||
|
|
||||||
# Filter by role if specified (via UserMandateRole)
|
# Filter by role if specified (via UserMandateRole)
|
||||||
if roleLabel:
|
if roleLabel:
|
||||||
|
|
@ -409,11 +546,11 @@ def list_users_with_roles(
|
||||||
def get_user_roles(
|
def get_user_roles(
|
||||||
request: Request,
|
request: Request,
|
||||||
userId: str = Path(..., description="User ID"),
|
userId: str = Path(..., description="User ID"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get role assignments for a specific user.
|
Get role assignments for a specific user.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
Context-aware: SysAdmin sees all. MandateAdmin can view users in own mandates only.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- userId: User ID
|
- userId: User ID
|
||||||
|
|
@ -421,6 +558,12 @@ def get_user_roles(
|
||||||
Returns:
|
Returns:
|
||||||
- User dictionary with role assignments
|
- User dictionary with role assignments
|
||||||
"""
|
"""
|
||||||
|
isSysAdmin = context.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||||
|
currentUser = context.user # backward compat for existing code
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
|
|
@ -432,6 +575,13 @@ def get_user_roles(
|
||||||
detail=f"User {userId} not found"
|
detail=f"User {userId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# MandateAdmin: check user is in one of admin's mandates
|
||||||
|
if not isSysAdmin:
|
||||||
|
userMandates = interface.getUserMandates(userId)
|
||||||
|
userMandateMandateIds = {str(um.mandateId) for um in userMandates}
|
||||||
|
if not userMandateMandateIds.intersection(adminMandateIds):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this user")
|
||||||
|
|
||||||
userRoleLabels = _getUserRoleLabels(interface, str(user.id))
|
userRoleLabels = _getUserRoleLabels(interface, str(user.id))
|
||||||
return {
|
return {
|
||||||
"id": user.id,
|
"id": user.id,
|
||||||
|
|
@ -460,11 +610,12 @@ def update_user_roles(
|
||||||
request: Request,
|
request: Request,
|
||||||
userId: str = Path(..., description="User ID"),
|
userId: str = Path(..., description="User ID"),
|
||||||
newRoleLabels: List[str] = Body(..., description="List of role labels to assign"),
|
newRoleLabels: List[str] = Body(..., description="List of role labels to assign"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Update role assignments for a specific user.
|
Update role assignments for a specific user.
|
||||||
MULTI-TENANT: SysAdmin-only. Updates roles in user's first mandate.
|
Context-aware: SysAdmin can update any user's roles. MandateAdmin can update roles
|
||||||
|
for users in own mandates only. Cannot assign sysadmin role.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- userId: User ID
|
- userId: User ID
|
||||||
|
|
@ -475,6 +626,12 @@ def update_user_roles(
|
||||||
Returns:
|
Returns:
|
||||||
- Updated user dictionary with role assignments
|
- Updated user dictionary with role assignments
|
||||||
"""
|
"""
|
||||||
|
isSysAdmin = context.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||||
|
currentUser = context.user # backward compat for existing code
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
|
|
@ -486,6 +643,11 @@ def update_user_roles(
|
||||||
detail=f"User {userId} not found"
|
detail=f"User {userId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# MandateAdmin restrictions
|
||||||
|
if not isSysAdmin:
|
||||||
|
if "sysadmin" in newRoleLabels:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot assign sysadmin role")
|
||||||
|
|
||||||
# Validate role labels (basic validation - check against standard roles)
|
# Validate role labels (basic validation - check against standard roles)
|
||||||
standardRoles = ["sysadmin", "admin", "user", "viewer"]
|
standardRoles = ["sysadmin", "admin", "user", "viewer"]
|
||||||
for roleLabel in newRoleLabels:
|
for roleLabel in newRoleLabels:
|
||||||
|
|
@ -501,17 +663,26 @@ def update_user_roles(
|
||||||
)
|
)
|
||||||
|
|
||||||
userMandateId = str(userMandates[0].id)
|
userMandateId = str(userMandates[0].id)
|
||||||
|
targetMandateId = str(userMandates[0].mandateId)
|
||||||
|
|
||||||
|
# MandateAdmin: check target mandate belongs to admin's mandates
|
||||||
|
if not isSysAdmin:
|
||||||
|
if targetMandateId not in adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this mandate")
|
||||||
|
|
||||||
# Get current roles for this mandate (Pydantic models)
|
# Get current roles for this mandate (Pydantic models)
|
||||||
existingRoles = interface.getUserMandateRoles(userMandateId)
|
existingRoles = interface.getUserMandateRoles(userMandateId)
|
||||||
existingRoleIds = {str(r.roleId) for r in existingRoles}
|
existingRoleIds = {str(r.roleId) for r in existingRoles}
|
||||||
|
|
||||||
# Convert roleLabels to roleIds
|
# Convert roleLabels to roleIds - use mandate-scoped lookup to get instance roles
|
||||||
|
# (prevents assigning template roles instead of mandate-instance roles)
|
||||||
newRoleIds = set()
|
newRoleIds = set()
|
||||||
for roleLabel in newRoleLabels:
|
for roleLabel in newRoleLabels:
|
||||||
role = interface.getRoleByLabel(roleLabel)
|
role = interface.getRoleByLabelAndScope(roleLabel, mandateId=targetMandateId)
|
||||||
if role:
|
if not role:
|
||||||
newRoleIds.add(str(role.id))
|
logger.warning(f"Role '{roleLabel}' not found for mandate {targetMandateId}, skipping")
|
||||||
|
continue
|
||||||
|
newRoleIds.add(str(role.id))
|
||||||
|
|
||||||
# Remove roles that are no longer needed
|
# Remove roles that are no longer needed
|
||||||
for existingRole in existingRoles:
|
for existingRole in existingRoles:
|
||||||
|
|
@ -524,7 +695,7 @@ def update_user_roles(
|
||||||
newRole = UserMandateRole(userMandateId=userMandateId, roleId=roleId)
|
newRole = UserMandateRole(userMandateId=userMandateId, roleId=roleId)
|
||||||
interface.db.recordCreate(UserMandateRole, newRole.model_dump())
|
interface.db.recordCreate(UserMandateRole, newRole.model_dump())
|
||||||
|
|
||||||
logger.info(f"Updated roles for user {userId}: {newRoleLabels} by SysAdmin {currentUser.id}")
|
logger.info(f"Updated roles for user {userId}: {newRoleLabels} by admin {currentUser.id}")
|
||||||
|
|
||||||
userRoleLabels = _getUserRoleLabels(interface, userId)
|
userRoleLabels = _getUserRoleLabels(interface, userId)
|
||||||
return {
|
return {
|
||||||
|
|
@ -554,11 +725,12 @@ def add_user_role(
|
||||||
request: Request,
|
request: Request,
|
||||||
userId: str = Path(..., description="User ID"),
|
userId: str = Path(..., description="User ID"),
|
||||||
roleLabel: str = Path(..., description="Role label to add"),
|
roleLabel: str = Path(..., description="Role label to add"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Add a role to a user (if not already assigned).
|
Add a role to a user (if not already assigned).
|
||||||
MULTI-TENANT: SysAdmin-only. Adds role to user's first mandate.
|
Context-aware: SysAdmin can add any role. MandateAdmin can add roles to users
|
||||||
|
in own mandates only. Cannot assign sysadmin role.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- userId: User ID
|
- userId: User ID
|
||||||
|
|
@ -567,6 +739,12 @@ def add_user_role(
|
||||||
Returns:
|
Returns:
|
||||||
- Updated user dictionary with role assignments
|
- Updated user dictionary with role assignments
|
||||||
"""
|
"""
|
||||||
|
isSysAdmin = context.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||||
|
currentUser = context.user # backward compat for existing code
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
|
|
@ -578,13 +756,10 @@ def add_user_role(
|
||||||
detail=f"User {userId} not found"
|
detail=f"User {userId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get role by label
|
# MandateAdmin restrictions
|
||||||
role = interface.getRoleByLabel(roleLabel)
|
if not isSysAdmin:
|
||||||
if not role:
|
if roleLabel == "sysadmin":
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot assign sysadmin role")
|
||||||
status_code=404,
|
|
||||||
detail=f"Role '{roleLabel}' not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get user's first mandate
|
# Get user's first mandate
|
||||||
userMandates = interface.getUserMandates(userId)
|
userMandates = interface.getUserMandates(userId)
|
||||||
|
|
@ -595,6 +770,21 @@ def add_user_role(
|
||||||
)
|
)
|
||||||
|
|
||||||
userMandateId = str(userMandates[0].id)
|
userMandateId = str(userMandates[0].id)
|
||||||
|
targetMandateId = str(userMandates[0].mandateId)
|
||||||
|
|
||||||
|
# MandateAdmin: check target mandate belongs to admin's mandates
|
||||||
|
if not isSysAdmin:
|
||||||
|
if targetMandateId not in adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this mandate")
|
||||||
|
|
||||||
|
# Get role by label - use mandate-scoped lookup to get instance role
|
||||||
|
# (prevents assigning template roles instead of mandate-instance roles)
|
||||||
|
role = interface.getRoleByLabelAndScope(roleLabel, mandateId=targetMandateId)
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Role '{roleLabel}' not found for mandate {targetMandateId}"
|
||||||
|
)
|
||||||
|
|
||||||
# Check if role is already assigned - use interface method
|
# Check if role is already assigned - use interface method
|
||||||
existingRoles = interface.getUserMandateRoles(userMandateId)
|
existingRoles = interface.getUserMandateRoles(userMandateId)
|
||||||
|
|
@ -603,7 +793,7 @@ def add_user_role(
|
||||||
if not roleAlreadyAssigned:
|
if not roleAlreadyAssigned:
|
||||||
# Add the role via interface method
|
# Add the role via interface method
|
||||||
interface.addRoleToUserMandate(userMandateId, str(role.id))
|
interface.addRoleToUserMandate(userMandateId, str(role.id))
|
||||||
logger.info(f"Added role {roleLabel} to user {userId} by SysAdmin {currentUser.id}")
|
logger.info(f"Added role {roleLabel} to user {userId} by admin {currentUser.id}")
|
||||||
|
|
||||||
userRoleLabels = _getUserRoleLabels(interface, userId)
|
userRoleLabels = _getUserRoleLabels(interface, userId)
|
||||||
return {
|
return {
|
||||||
|
|
@ -633,11 +823,12 @@ def remove_user_role(
|
||||||
request: Request,
|
request: Request,
|
||||||
userId: str = Path(..., description="User ID"),
|
userId: str = Path(..., description="User ID"),
|
||||||
roleLabel: str = Path(..., description="Role label to remove"),
|
roleLabel: str = Path(..., description="Role label to remove"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Remove a role from a user.
|
Remove a role from a user.
|
||||||
MULTI-TENANT: SysAdmin-only. Removes role from all user's mandates.
|
Context-aware: SysAdmin can remove any role. MandateAdmin can remove roles from
|
||||||
|
users in own mandates only. Cannot remove sysadmin role.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- userId: User ID
|
- userId: User ID
|
||||||
|
|
@ -646,6 +837,12 @@ def remove_user_role(
|
||||||
Returns:
|
Returns:
|
||||||
- Updated user dictionary with role assignments
|
- Updated user dictionary with role assignments
|
||||||
"""
|
"""
|
||||||
|
isSysAdmin = context.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||||
|
currentUser = context.user # backward compat for existing code
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
|
|
@ -657,6 +854,11 @@ def remove_user_role(
|
||||||
detail=f"User {userId} not found"
|
detail=f"User {userId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# MandateAdmin restrictions
|
||||||
|
if not isSysAdmin:
|
||||||
|
if roleLabel == "sysadmin":
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot remove sysadmin role")
|
||||||
|
|
||||||
# Get role by label
|
# Get role by label
|
||||||
role = interface.getRoleByLabel(roleLabel)
|
role = interface.getRoleByLabel(roleLabel)
|
||||||
if not role:
|
if not role:
|
||||||
|
|
@ -665,19 +867,30 @@ def remove_user_role(
|
||||||
detail=f"Role '{roleLabel}' not found"
|
detail=f"Role '{roleLabel}' not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Remove role from all user's mandates
|
# Remove role from user's mandates
|
||||||
userMandates = interface.getUserMandates(userId)
|
userMandates = interface.getUserMandates(userId)
|
||||||
|
|
||||||
|
# MandateAdmin: check user's mandates overlap with admin's mandates
|
||||||
|
if not isSysAdmin:
|
||||||
|
userMandateMandateIds = {str(um.mandateId) for um in userMandates}
|
||||||
|
if not userMandateMandateIds.intersection(set(adminMandateIds)):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this user")
|
||||||
|
|
||||||
roleRemoved = False
|
roleRemoved = False
|
||||||
|
|
||||||
for um in userMandates:
|
for um in userMandates:
|
||||||
userMandateId = str(um.id)
|
userMandateId = str(um.id)
|
||||||
|
|
||||||
|
# MandateAdmin: only remove from mandates they admin
|
||||||
|
if not isSysAdmin and str(um.mandateId) not in adminMandateIds:
|
||||||
|
continue
|
||||||
|
|
||||||
# Remove role via interface method
|
# Remove role via interface method
|
||||||
if interface.removeRoleFromUserMandate(userMandateId, str(role.id)):
|
if interface.removeRoleFromUserMandate(userMandateId, str(role.id)):
|
||||||
roleRemoved = True
|
roleRemoved = True
|
||||||
|
|
||||||
if roleRemoved:
|
if roleRemoved:
|
||||||
logger.info(f"Removed role {roleLabel} from user {userId} by SysAdmin {currentUser.id}")
|
logger.info(f"Removed role {roleLabel} from user {userId} by admin {currentUser.id}")
|
||||||
|
|
||||||
userRoleLabels = _getUserRoleLabels(interface, userId)
|
userRoleLabels = _getUserRoleLabels(interface, userId)
|
||||||
return {
|
return {
|
||||||
|
|
@ -707,11 +920,11 @@ def get_users_with_role(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleLabel: str = Path(..., description="Role label"),
|
roleLabel: str = Path(..., description="Role label"),
|
||||||
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
|
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get all users with a specific role.
|
Get all users with a specific role.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
Context-aware: SysAdmin sees all. MandateAdmin sees users from own mandates only.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- roleLabel: Role label
|
- roleLabel: Role label
|
||||||
|
|
@ -722,6 +935,12 @@ def get_users_with_role(
|
||||||
Returns:
|
Returns:
|
||||||
- List of users with the specified role
|
- List of users with the specified role
|
||||||
"""
|
"""
|
||||||
|
isSysAdmin = context.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(context)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||||
|
currentUser = context.user # backward compat for existing code
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
|
|
@ -747,6 +966,9 @@ def get_users_with_role(
|
||||||
# Filter by mandate if specified
|
# Filter by mandate if specified
|
||||||
if mandateId and str(um.mandateId) != mandateId:
|
if mandateId and str(um.mandateId) != mandateId:
|
||||||
continue
|
continue
|
||||||
|
# MandateAdmin: filter to own mandates
|
||||||
|
if not isSysAdmin and str(um.mandateId) not in adminMandateIds:
|
||||||
|
continue
|
||||||
userIds.add(str(um.userId))
|
userIds.add(str(um.userId))
|
||||||
|
|
||||||
# Get users and format response
|
# Get users and format response
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,9 @@ Implements endpoints for role-based access control permissions.
|
||||||
|
|
||||||
MULTI-TENANT:
|
MULTI-TENANT:
|
||||||
- Permission queries use RequestContext (mandateId from header)
|
- Permission queries use RequestContext (mandateId from header)
|
||||||
- AccessRule management is SysAdmin-only (system resources)
|
- AccessRule management is Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's rules)
|
||||||
- Role management is SysAdmin-only (system resources)
|
- Role management is Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's roles)
|
||||||
|
- Catalog stats and cleanup remain SysAdmin-only
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request
|
from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Request
|
||||||
|
|
@ -16,7 +17,7 @@ import logging
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
|
||||||
from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext
|
from modules.auth import limiter, getRequestContext, requireSysAdminRole, RequestContext
|
||||||
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
|
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
|
||||||
from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role
|
from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role
|
||||||
from modules.datamodels.datamodelMembership import UserMandate
|
from modules.datamodels.datamodelMembership import UserMandate
|
||||||
|
|
@ -33,6 +34,44 @@ router = APIRouter(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _getAdminMandateIds(context: RequestContext) -> List[str]:
|
||||||
|
"""Get mandate IDs where the user has an admin role."""
|
||||||
|
mandateIds = []
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
userMandates = rootInterface.getUserMandates(str(context.user.id))
|
||||||
|
for um in userMandates:
|
||||||
|
if not getattr(um, 'enabled', True):
|
||||||
|
continue
|
||||||
|
umId = getattr(um, 'id', None)
|
||||||
|
mandateId = getattr(um, 'mandateId', None)
|
||||||
|
if not umId or not mandateId:
|
||||||
|
continue
|
||||||
|
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
|
||||||
|
for roleId in roleIds:
|
||||||
|
role = rootInterface.getRole(roleId)
|
||||||
|
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
||||||
|
mandateIds.append(str(mandateId))
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting admin mandate IDs: {e}")
|
||||||
|
return mandateIds
|
||||||
|
|
||||||
|
|
||||||
|
def _isRoleInAdminMandates(roleId: str, adminMandateIds: List[str]) -> bool:
|
||||||
|
"""Check if a role belongs to one of the admin's mandates."""
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
role = rootInterface.getRole(roleId)
|
||||||
|
if not role:
|
||||||
|
return False
|
||||||
|
return str(role.mandateId) in adminMandateIds if role.mandateId else False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@router.get("/permissions", response_model=UserPermissions)
|
@router.get("/permissions", response_model=UserPermissions)
|
||||||
@limiter.limit("300/minute") # Raised from 60 - sidebar checks many pages individually
|
@limiter.limit("300/minute") # Raised from 60 - sidebar checks many pages individually
|
||||||
def get_permissions(
|
def get_permissions(
|
||||||
|
|
@ -201,7 +240,7 @@ def get_all_permissions(
|
||||||
|
|
||||||
logger.debug(f"UI/RESOURCE permissions: User has {len(roleIds)} roles across all mandates")
|
logger.debug(f"UI/RESOURCE permissions: User has {len(roleIds)} roles across all mandates")
|
||||||
|
|
||||||
if not roleIds and not reqContext.isSysAdmin:
|
if not roleIds and not reqContext.hasSysAdminRole:
|
||||||
# No roles at all, return empty permissions
|
# No roles at all, return empty permissions
|
||||||
for ctx in contextsToFetch:
|
for ctx in contextsToFetch:
|
||||||
result[ctx.value.lower()] = {}
|
result[ctx.value.lower()] = {}
|
||||||
|
|
@ -306,11 +345,11 @@ def get_access_rules(
|
||||||
context: Optional[str] = Query(None, description="Filter by context (DATA, UI, RESOURCE)"),
|
context: Optional[str] = Query(None, description="Filter by context (DATA, UI, RESOURCE)"),
|
||||||
item: Optional[str] = Query(None, description="Filter by item identifier"),
|
item: Optional[str] = Query(None, description="Filter by item identifier"),
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
reqContext: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse:
|
) -> PaginatedResponse:
|
||||||
"""
|
"""
|
||||||
Get access rules with optional filters.
|
Get access rules with optional filters.
|
||||||
MULTI-TENANT: SysAdmin-only (AccessRules are system resources).
|
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's rules).
|
||||||
|
|
||||||
Query Parameters:
|
Query Parameters:
|
||||||
- roleLabel: Optional role label filter
|
- roleLabel: Optional role label filter
|
||||||
|
|
@ -321,7 +360,12 @@ def get_access_rules(
|
||||||
- List of AccessRule objects
|
- List of AccessRule objects
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get interface - SysAdmin uses root interface
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin role required")
|
||||||
|
|
||||||
|
# Get interface - uses root interface for admin access
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
# Parse context if provided
|
# Parse context if provided
|
||||||
|
|
@ -350,6 +394,39 @@ def get_access_rules(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get rules with optional pagination
|
# Get rules with optional pagination
|
||||||
|
# MandateAdmin: fetch all then filter by admin's mandates
|
||||||
|
if not isSysAdmin:
|
||||||
|
allRules = interface.getAccessRules(
|
||||||
|
roleLabel=roleLabel,
|
||||||
|
context=accessContext,
|
||||||
|
item=item,
|
||||||
|
pagination=None
|
||||||
|
)
|
||||||
|
filteredRules = [rule for rule in allRules if _isRoleInAdminMandates(str(rule.roleId), adminMandateIds)]
|
||||||
|
|
||||||
|
if paginationParams:
|
||||||
|
totalItems = len(filteredRules)
|
||||||
|
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||||
|
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
||||||
|
endIdx = startIdx + paginationParams.pageSize
|
||||||
|
return PaginatedResponse(
|
||||||
|
items=[rule.model_dump() for rule in filteredRules[startIdx:endIdx]],
|
||||||
|
pagination=PaginationMetadata(
|
||||||
|
currentPage=paginationParams.page,
|
||||||
|
pageSize=paginationParams.pageSize,
|
||||||
|
totalItems=totalItems,
|
||||||
|
totalPages=totalPages,
|
||||||
|
sort=paginationParams.sort,
|
||||||
|
filters=paginationParams.filters
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return PaginatedResponse(
|
||||||
|
items=[rule.model_dump() for rule in filteredRules],
|
||||||
|
pagination=None
|
||||||
|
)
|
||||||
|
|
||||||
|
# SysAdmin: use server-side pagination
|
||||||
result = interface.getAccessRules(
|
result = interface.getAccessRules(
|
||||||
roleLabel=roleLabel,
|
roleLabel=roleLabel,
|
||||||
context=accessContext,
|
context=accessContext,
|
||||||
|
|
@ -392,11 +469,11 @@ def get_access_rules(
|
||||||
def get_access_rules_by_role(
|
def get_access_rules_by_role(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleId: str = Path(..., description="Role ID to get rules for"),
|
roleId: str = Path(..., description="Role ID to get rules for"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
reqContext: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse:
|
) -> PaginatedResponse:
|
||||||
"""
|
"""
|
||||||
Get all access rules for a specific role.
|
Get all access rules for a specific role.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's roles).
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- roleId: The role ID to get rules for
|
- roleId: The role ID to get rules for
|
||||||
|
|
@ -405,6 +482,15 @@ def get_access_rules_by_role(
|
||||||
- List of AccessRule objects for the specified role
|
- List of AccessRule objects for the specified role
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin role required")
|
||||||
|
|
||||||
|
# MandateAdmin: verify role belongs to their mandates
|
||||||
|
if not isSysAdmin and not _isRoleInAdminMandates(roleId, adminMandateIds):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: role not in your mandates")
|
||||||
|
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
# Get rules from database using interface method
|
# Get rules from database using interface method
|
||||||
|
|
@ -430,11 +516,11 @@ def get_access_rules_by_role(
|
||||||
def get_access_rule(
|
def get_access_rule(
|
||||||
request: Request,
|
request: Request,
|
||||||
ruleId: str = Path(..., description="Access rule ID"),
|
ruleId: str = Path(..., description="Access rule ID"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
reqContext: RequestContext = Depends(getRequestContext)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Get a specific access rule by ID.
|
Get a specific access rule by ID.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's rules).
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- ruleId: Access rule ID
|
- ruleId: Access rule ID
|
||||||
|
|
@ -443,7 +529,12 @@ def get_access_rule(
|
||||||
- AccessRule object
|
- AccessRule object
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get interface - SysAdmin uses root interface
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin role required")
|
||||||
|
|
||||||
|
# Get interface - uses root interface for admin access
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
# Get rule
|
# Get rule
|
||||||
|
|
@ -454,6 +545,10 @@ def get_access_rule(
|
||||||
detail=f"Access rule {ruleId} not found"
|
detail=f"Access rule {ruleId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# MandateAdmin: verify rule's role belongs to their mandates
|
||||||
|
if not isSysAdmin and not _isRoleInAdminMandates(str(rule.roleId), adminMandateIds):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: rule's role not in your mandates")
|
||||||
|
|
||||||
# Convert to dict for JSON serialization
|
# Convert to dict for JSON serialization
|
||||||
return rule.model_dump()
|
return rule.model_dump()
|
||||||
|
|
||||||
|
|
@ -472,11 +567,11 @@ def get_access_rule(
|
||||||
def create_access_rule(
|
def create_access_rule(
|
||||||
request: Request,
|
request: Request,
|
||||||
accessRuleData: dict = Body(..., description="Access rule data"),
|
accessRuleData: dict = Body(..., description="Access rule data"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
reqContext: RequestContext = Depends(getRequestContext)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Create a new access rule.
|
Create a new access rule.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin creates for own mandate's roles).
|
||||||
|
|
||||||
Request Body:
|
Request Body:
|
||||||
- AccessRule object data (roleLabel, context, item, view, read, create, update, delete)
|
- AccessRule object data (roleLabel, context, item, view, read, create, update, delete)
|
||||||
|
|
@ -485,7 +580,12 @@ def create_access_rule(
|
||||||
- Created AccessRule object
|
- Created AccessRule object
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get interface - SysAdmin uses root interface
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin role required")
|
||||||
|
|
||||||
|
# Get interface - uses root interface for admin access
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
# Validate and parse access rule data
|
# Validate and parse access rule data
|
||||||
|
|
@ -515,10 +615,15 @@ def create_access_rule(
|
||||||
detail=f"Invalid access rule data: {str(e)}"
|
detail=f"Invalid access rule data: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# MandateAdmin: verify the rule's role belongs to their mandates
|
||||||
|
if not isSysAdmin and accessRule.roleId:
|
||||||
|
if not _isRoleInAdminMandates(str(accessRule.roleId), adminMandateIds):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: role not in your mandates")
|
||||||
|
|
||||||
# Create rule
|
# Create rule
|
||||||
createdRule = interface.createAccessRule(accessRule)
|
createdRule = interface.createAccessRule(accessRule)
|
||||||
|
|
||||||
logger.info(f"Created access rule {createdRule.id} by SysAdmin {currentUser.id}")
|
logger.info(f"Created access rule {createdRule.id} by admin {reqContext.user.id}")
|
||||||
|
|
||||||
# Convert to dict for JSON serialization
|
# Convert to dict for JSON serialization
|
||||||
return createdRule.model_dump()
|
return createdRule.model_dump()
|
||||||
|
|
@ -539,11 +644,11 @@ def update_access_rule(
|
||||||
request: Request,
|
request: Request,
|
||||||
ruleId: str = Path(..., description="Access rule ID"),
|
ruleId: str = Path(..., description="Access rule ID"),
|
||||||
accessRuleData: dict = Body(..., description="Updated access rule data"),
|
accessRuleData: dict = Body(..., description="Updated access rule data"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
reqContext: RequestContext = Depends(getRequestContext)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Update an existing access rule.
|
Update an existing access rule.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin updates own mandate's rules).
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- ruleId: Access rule ID
|
- ruleId: Access rule ID
|
||||||
|
|
@ -555,7 +660,12 @@ def update_access_rule(
|
||||||
- Updated AccessRule object
|
- Updated AccessRule object
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get interface - SysAdmin uses root interface
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin role required")
|
||||||
|
|
||||||
|
# Get interface - uses root interface for admin access
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
# Get existing rule to ensure it exists
|
# Get existing rule to ensure it exists
|
||||||
|
|
@ -566,6 +676,10 @@ def update_access_rule(
|
||||||
detail=f"Access rule {ruleId} not found"
|
detail=f"Access rule {ruleId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# MandateAdmin: verify existing rule's role belongs to their mandates
|
||||||
|
if not isSysAdmin and not _isRoleInAdminMandates(str(existingRule.roleId), adminMandateIds):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: rule's role not in your mandates")
|
||||||
|
|
||||||
# Validate and parse access rule data
|
# Validate and parse access rule data
|
||||||
try:
|
try:
|
||||||
# Merge with existing rule data
|
# Merge with existing rule data
|
||||||
|
|
@ -601,7 +715,7 @@ def update_access_rule(
|
||||||
# Update rule
|
# Update rule
|
||||||
updatedRule = interface.updateAccessRule(ruleId, accessRule)
|
updatedRule = interface.updateAccessRule(ruleId, accessRule)
|
||||||
|
|
||||||
logger.info(f"Updated access rule {ruleId} by SysAdmin {currentUser.id}")
|
logger.info(f"Updated access rule {ruleId} by admin {reqContext.user.id}")
|
||||||
|
|
||||||
# Convert to dict for JSON serialization
|
# Convert to dict for JSON serialization
|
||||||
return updatedRule.model_dump()
|
return updatedRule.model_dump()
|
||||||
|
|
@ -621,11 +735,11 @@ def update_access_rule(
|
||||||
def delete_access_rule(
|
def delete_access_rule(
|
||||||
request: Request,
|
request: Request,
|
||||||
ruleId: str = Path(..., description="Access rule ID"),
|
ruleId: str = Path(..., description="Access rule ID"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
reqContext: RequestContext = Depends(getRequestContext)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Delete an access rule.
|
Delete an access rule.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin deletes own mandate's rules).
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- ruleId: Access rule ID
|
- ruleId: Access rule ID
|
||||||
|
|
@ -634,7 +748,12 @@ def delete_access_rule(
|
||||||
- Success message
|
- Success message
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get interface - SysAdmin uses root interface
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin role required")
|
||||||
|
|
||||||
|
# Get interface - uses root interface for admin access
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
# Get existing rule to ensure it exists
|
# Get existing rule to ensure it exists
|
||||||
|
|
@ -645,6 +764,10 @@ def delete_access_rule(
|
||||||
detail=f"Access rule {ruleId} not found"
|
detail=f"Access rule {ruleId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# MandateAdmin: verify rule's role belongs to their mandates
|
||||||
|
if not isSysAdmin and not _isRoleInAdminMandates(str(existingRule.roleId), adminMandateIds):
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: rule's role not in your mandates")
|
||||||
|
|
||||||
# Delete rule
|
# Delete rule
|
||||||
success = interface.deleteAccessRule(ruleId)
|
success = interface.deleteAccessRule(ruleId)
|
||||||
|
|
||||||
|
|
@ -654,7 +777,7 @@ def delete_access_rule(
|
||||||
detail=f"Failed to delete access rule {ruleId}"
|
detail=f"Failed to delete access rule {ruleId}"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Deleted access rule {ruleId} by SysAdmin {currentUser.id}")
|
logger.info(f"Deleted access rule {ruleId} by admin {reqContext.user.id}")
|
||||||
|
|
||||||
return {"success": True, "message": f"Access rule {ruleId} deleted successfully"}
|
return {"success": True, "message": f"Access rule {ruleId} deleted successfully"}
|
||||||
|
|
||||||
|
|
@ -670,7 +793,7 @@ def delete_access_rule(
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Role Management Endpoints
|
# Role Management Endpoints
|
||||||
# MULTI-TENANT: All role management is SysAdmin-only (roles are system resources)
|
# MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's roles)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -682,11 +805,11 @@ def list_roles(
|
||||||
includeTemplates: bool = Query(False, description="Include feature template roles"),
|
includeTemplates: bool = Query(False, description="Include feature template roles"),
|
||||||
mandateId: Optional[str] = Query(None, description="Include mandate-specific roles for this mandate"),
|
mandateId: Optional[str] = Query(None, description="Include mandate-specific roles for this mandate"),
|
||||||
scopeFilter: Optional[str] = Query(None, description="Filter by scope: 'all', 'mandate', 'global', 'system'"),
|
scopeFilter: Optional[str] = Query(None, description="Filter by scope: 'all', 'mandate', 'global', 'system'"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
reqContext: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse:
|
) -> PaginatedResponse:
|
||||||
"""
|
"""
|
||||||
Get list of roles with metadata.
|
Get list of roles with metadata.
|
||||||
MULTI-TENANT: SysAdmin-only (roles are system resources).
|
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's roles).
|
||||||
|
|
||||||
By default, only returns true global roles (mandateId=None, featureInstanceId=None, featureCode=None).
|
By default, only returns true global roles (mandateId=None, featureInstanceId=None, featureCode=None).
|
||||||
Feature template roles are managed via /api/features/templates/roles.
|
Feature template roles are managed via /api/features/templates/roles.
|
||||||
|
|
@ -701,6 +824,11 @@ def list_roles(
|
||||||
- List of role dictionaries with role label, description, user count, and computed scopeType
|
- List of role dictionaries with role label, description, user count, and computed scopeType
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin role required")
|
||||||
|
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
# Parse pagination parameter
|
# Parse pagination parameter
|
||||||
|
|
@ -777,6 +905,10 @@ def list_roles(
|
||||||
"scopeType": scopeType # Computed field for frontend display
|
"scopeType": scopeType # Computed field for frontend display
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# MandateAdmin: filter to only roles in admin's mandates
|
||||||
|
if not isSysAdmin:
|
||||||
|
result = [r for r in result if r.get("mandateId") and str(r["mandateId"]) in adminMandateIds]
|
||||||
|
|
||||||
# Apply search, filtering and sorting if pagination requested
|
# Apply search, filtering and sorting if pagination requested
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
# Apply search (if search term provided in filters)
|
# Apply search (if search term provided in filters)
|
||||||
|
|
@ -850,7 +982,7 @@ def list_roles(
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
def get_role_options(
|
def get_role_options(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
currentUser: User = Depends(requireSysAdminRole)
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get role options for select dropdowns.
|
Get role options for select dropdowns.
|
||||||
|
|
@ -892,11 +1024,11 @@ def get_role_options(
|
||||||
def create_role(
|
def create_role(
|
||||||
request: Request,
|
request: Request,
|
||||||
role: Role = Body(...),
|
role: Role = Body(...),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
reqContext: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Create a new role.
|
Create a new role.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin creates in own mandate).
|
||||||
|
|
||||||
Request Body:
|
Request Body:
|
||||||
- role: Role object to create
|
- role: Role object to create
|
||||||
|
|
@ -905,11 +1037,21 @@ def create_role(
|
||||||
- Created role dictionary
|
- Created role dictionary
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin role required")
|
||||||
|
|
||||||
|
# MandateAdmin: can only create roles in their own mandates
|
||||||
|
if not isSysAdmin:
|
||||||
|
if not role.mandateId or str(role.mandateId) not in adminMandateIds:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: can only create roles in your own mandates")
|
||||||
|
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
createdRole = interface.createRole(role)
|
createdRole = interface.createRole(role)
|
||||||
|
|
||||||
logger.info(f"Created role {createdRole.roleLabel} by SysAdmin {currentUser.id}")
|
logger.info(f"Created role {createdRole.roleLabel} by admin {reqContext.user.id}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": createdRole.id,
|
"id": createdRole.id,
|
||||||
|
|
@ -941,11 +1083,11 @@ def create_role(
|
||||||
def get_role(
|
def get_role(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleId: str = Path(..., description="Role ID"),
|
roleId: str = Path(..., description="Role ID"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
reqContext: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get a role by ID.
|
Get a role by ID.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin sees own mandate's roles).
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- roleId: Role ID
|
- roleId: Role ID
|
||||||
|
|
@ -954,6 +1096,11 @@ def get_role(
|
||||||
- Role dictionary
|
- Role dictionary
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin role required")
|
||||||
|
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
role = interface.getRole(roleId)
|
role = interface.getRole(roleId)
|
||||||
|
|
@ -963,6 +1110,11 @@ def get_role(
|
||||||
detail=f"Role {roleId} not found"
|
detail=f"Role {roleId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# MandateAdmin: verify role belongs to their mandates
|
||||||
|
if not isSysAdmin:
|
||||||
|
if not role.mandateId or str(role.mandateId) not in adminMandateIds:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: role not in your mandates")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": role.id,
|
"id": role.id,
|
||||||
"roleLabel": role.roleLabel,
|
"roleLabel": role.roleLabel,
|
||||||
|
|
@ -989,11 +1141,11 @@ def update_role(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleId: str = Path(..., description="Role ID"),
|
roleId: str = Path(..., description="Role ID"),
|
||||||
role: Role = Body(...),
|
role: Role = Body(...),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
reqContext: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Update an existing role.
|
Update an existing role.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin updates own mandate's roles, not template/system).
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- roleId: Role ID
|
- roleId: Role ID
|
||||||
|
|
@ -1005,11 +1157,26 @@ def update_role(
|
||||||
- Updated role dictionary
|
- Updated role dictionary
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin role required")
|
||||||
|
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
|
# MandateAdmin: verify role belongs to their mandates and is not a template/system role
|
||||||
|
if not isSysAdmin:
|
||||||
|
existingRole = interface.getRole(roleId)
|
||||||
|
if not existingRole:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
|
||||||
|
if existingRole.isSystemRole and not existingRole.mandateId:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: cannot modify template/system roles")
|
||||||
|
if not existingRole.mandateId or str(existingRole.mandateId) not in adminMandateIds:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: role not in your mandates")
|
||||||
|
|
||||||
updatedRole = interface.updateRole(roleId, role)
|
updatedRole = interface.updateRole(roleId, role)
|
||||||
|
|
||||||
logger.info(f"Updated role {roleId} by SysAdmin {currentUser.id}")
|
logger.info(f"Updated role {roleId} by admin {reqContext.user.id}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": updatedRole.id,
|
"id": updatedRole.id,
|
||||||
|
|
@ -1041,11 +1208,11 @@ def update_role(
|
||||||
def delete_role(
|
def delete_role(
|
||||||
request: Request,
|
request: Request,
|
||||||
roleId: str = Path(..., description="Role ID"),
|
roleId: str = Path(..., description="Role ID"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
reqContext: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, str]:
|
) -> Dict[str, str]:
|
||||||
"""
|
"""
|
||||||
Delete a role.
|
Delete a role.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
MULTI-TENANT: Admin-aware (SysAdmin sees all, MandateAdmin deletes own mandate's roles, not template/system).
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- roleId: Role ID
|
- roleId: Role ID
|
||||||
|
|
@ -1054,8 +1221,23 @@ def delete_role(
|
||||||
- Success message
|
- Success message
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
|
if not isSysAdmin and not adminMandateIds:
|
||||||
|
raise HTTPException(status_code=403, detail="Admin role required")
|
||||||
|
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
|
# MandateAdmin: verify role belongs to their mandates and is not a template/system role
|
||||||
|
if not isSysAdmin:
|
||||||
|
existingRole = interface.getRole(roleId)
|
||||||
|
if not existingRole:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
|
||||||
|
if existingRole.isSystemRole and not existingRole.mandateId:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: cannot delete template/system roles")
|
||||||
|
if not existingRole.mandateId or str(existingRole.mandateId) not in adminMandateIds:
|
||||||
|
raise HTTPException(status_code=403, detail="Access denied: role not in your mandates")
|
||||||
|
|
||||||
success = interface.deleteRole(roleId)
|
success = interface.deleteRole(roleId)
|
||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -1063,7 +1245,7 @@ def delete_role(
|
||||||
detail=f"Role {roleId} not found"
|
detail=f"Role {roleId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Deleted role {roleId} by SysAdmin {currentUser.id}")
|
logger.info(f"Deleted role {roleId} by admin {reqContext.user.id}")
|
||||||
|
|
||||||
return {"message": f"Role {roleId} deleted successfully"}
|
return {"message": f"Role {roleId} deleted successfully"}
|
||||||
|
|
||||||
|
|
@ -1182,7 +1364,7 @@ def getCatalogObjects(
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
def getCatalogStats(
|
def getCatalogStats(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
currentUser: User = Depends(requireSysAdminRole)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get statistics about the RBAC catalog.
|
Get statistics about the RBAC catalog.
|
||||||
|
|
@ -1213,7 +1395,7 @@ def getCatalogStats(
|
||||||
def cleanup_duplicate_access_rules(
|
def cleanup_duplicate_access_rules(
|
||||||
request: Request,
|
request: Request,
|
||||||
dryRun: bool = Query(True, description="If true, only report duplicates without deleting"),
|
dryRun: bool = Query(True, description="If true, only report duplicates without deleting"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
currentUser: User = Depends(requireSysAdminRole)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Find and remove duplicate AccessRules.
|
Find and remove duplicate AccessRules.
|
||||||
|
|
@ -1278,19 +1460,102 @@ def cleanup_duplicate_access_rules(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to delete rule {ruleId}: {e}")
|
logger.warning(f"Failed to delete rule {ruleId}: {e}")
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# Phase 2: Fix template role assignments
|
||||||
|
# UserMandateRole should reference mandate-instance roles, not templates
|
||||||
|
# =====================================================================
|
||||||
|
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
||||||
|
|
||||||
|
allUserMandateRoles = rootInterface.db.getRecordset(UserMandateRole)
|
||||||
|
templateFixDetails = []
|
||||||
|
templateFixedCount = 0
|
||||||
|
|
||||||
|
for umr in allUserMandateRoles:
|
||||||
|
roleId = umr.get("roleId")
|
||||||
|
userMandateId = umr.get("userMandateId")
|
||||||
|
umrId = umr.get("id")
|
||||||
|
if not roleId or not userMandateId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if assigned role is a template
|
||||||
|
role = rootInterface.getRole(roleId)
|
||||||
|
if not role or role.mandateId is not None:
|
||||||
|
continue # Not a template role, OK
|
||||||
|
|
||||||
|
if not role.isSystemRole:
|
||||||
|
continue # Not a system template, skip
|
||||||
|
|
||||||
|
# Template role assigned! Find the UserMandate to get the mandateId
|
||||||
|
userMandateRecords = rootInterface.db.getRecordset(
|
||||||
|
UserMandate, recordFilter={"id": userMandateId}
|
||||||
|
)
|
||||||
|
if not userMandateRecords:
|
||||||
|
continue
|
||||||
|
mandateId = userMandateRecords[0].get("mandateId")
|
||||||
|
if not mandateId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find the correct mandate-instance role
|
||||||
|
mandateRoles = rootInterface.db.getRecordset(
|
||||||
|
Role, recordFilter={"roleLabel": role.roleLabel, "mandateId": mandateId, "featureInstanceId": None}
|
||||||
|
)
|
||||||
|
|
||||||
|
detail = {
|
||||||
|
"userMandateRoleId": umrId,
|
||||||
|
"userMandateId": userMandateId,
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"templateRoleId": roleId,
|
||||||
|
"templateRoleLabel": role.roleLabel,
|
||||||
|
"action": "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
if mandateRoles:
|
||||||
|
instanceRoleId = mandateRoles[0].get("id")
|
||||||
|
detail["instanceRoleId"] = instanceRoleId
|
||||||
|
detail["action"] = "replace" if not dryRun else "would_replace"
|
||||||
|
|
||||||
|
if not dryRun:
|
||||||
|
try:
|
||||||
|
rootInterface.db.recordModify(UserMandateRole, umrId, {"roleId": instanceRoleId})
|
||||||
|
templateFixedCount += 1
|
||||||
|
logger.info(f"Fixed template role assignment: {umrId} → {role.roleLabel} template → instance {instanceRoleId}")
|
||||||
|
except Exception as e:
|
||||||
|
detail["action"] = f"error: {e}"
|
||||||
|
logger.warning(f"Failed to fix role assignment {umrId}: {e}")
|
||||||
|
else:
|
||||||
|
detail["action"] = "delete_inconsistent" if not dryRun else "would_delete_inconsistent"
|
||||||
|
if not dryRun:
|
||||||
|
try:
|
||||||
|
rootInterface.db.recordDelete(UserMandateRole, umrId)
|
||||||
|
templateFixedCount += 1
|
||||||
|
logger.info(f"Deleted inconsistent template role assignment: {umrId} (template '{role.roleLabel}' in mandate {mandateId}, no instance role found)")
|
||||||
|
except Exception as e:
|
||||||
|
detail["action"] = f"error: {e}"
|
||||||
|
logger.warning(f"Failed to delete inconsistent assignment {umrId}: {e}")
|
||||||
|
|
||||||
|
templateFixDetails.append(detail)
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"dryRun": dryRun,
|
"dryRun": dryRun,
|
||||||
"totalRules": len(allRules),
|
"duplicateRules": {
|
||||||
"uniqueSignatures": len(rulesBySignature),
|
"totalRules": len(allRules),
|
||||||
"duplicateGroups": len(duplicateGroups),
|
"uniqueSignatures": len(rulesBySignature),
|
||||||
"duplicateRulesToDelete": len(idsToDelete),
|
"duplicateGroups": len(duplicateGroups),
|
||||||
"deletedCount": deletedCount,
|
"duplicateRulesToDelete": len(idsToDelete),
|
||||||
"details": duplicateGroups[:50] # Limit details to 50 groups
|
"deletedCount": deletedCount,
|
||||||
|
"details": duplicateGroups[:50]
|
||||||
|
},
|
||||||
|
"templateRoleAssignments": {
|
||||||
|
"totalUserMandateRoles": len(allUserMandateRoles),
|
||||||
|
"invalidAssignments": len(templateFixDetails),
|
||||||
|
"fixedCount": templateFixedCount,
|
||||||
|
"details": templateFixDetails[:50]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(f"AccessRule cleanup: dryRun={dryRun}, total={len(allRules)}, "
|
logger.info(f"RBAC cleanup: dryRun={dryRun}, "
|
||||||
f"duplicateGroups={len(duplicateGroups)}, toDelete={len(idsToDelete)}, "
|
f"duplicates={len(duplicateGroups)}/{deletedCount} deleted, "
|
||||||
f"deleted={deletedCount}")
|
f"templateFixes={len(templateFixDetails)}/{templateFixedCount} fixed")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,12 @@ MULTI-TENANT: These are SYSTEM-LEVEL operations requiring isSysAdmin=true.
|
||||||
Shows comprehensive view of what a user can see and access.
|
Shows comprehensive view of what a user can see and access.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Query, Path, Request
|
from fastapi import APIRouter, HTTPException, Depends, Query, Path, Request, status
|
||||||
from typing import List, Dict, Any, Optional, Set
|
from typing import List, Dict, Any, Optional, Set
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from modules.auth import limiter, requireSysAdmin
|
from modules.auth import limiter
|
||||||
|
from modules.auth.authentication import getRequestContext, RequestContext
|
||||||
from modules.datamodels.datamodelUam import User, UserInDB
|
from modules.datamodels.datamodelUam import User, UserInDB
|
||||||
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
|
from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext
|
||||||
from modules.datamodels.datamodelMembership import (
|
from modules.datamodels.datamodelMembership import (
|
||||||
|
|
@ -67,34 +68,101 @@ def _getRoleScopePriority(scope: str) -> int:
|
||||||
return priorities.get(scope, 0)
|
return priorities.get(scope, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _hasMandateAdminRole(context: RequestContext) -> bool:
|
||||||
|
"""Check if the user has mandate admin role in ANY mandate.
|
||||||
|
Loads roles independently from request context (context.roleIds may be empty
|
||||||
|
when no X-Mandate-Id header is sent, e.g., on admin pages).
|
||||||
|
"""
|
||||||
|
if context.hasSysAdminRole:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
userMandates = rootInterface.getUserMandates(str(context.user.id))
|
||||||
|
for um in userMandates:
|
||||||
|
umId = getattr(um, 'id', None)
|
||||||
|
if not umId:
|
||||||
|
continue
|
||||||
|
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
|
||||||
|
for roleId in roleIds:
|
||||||
|
role = rootInterface.getRole(roleId)
|
||||||
|
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking mandate admin role: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _isUserInMandate(rootInterface, userId: str, mandateId: str) -> bool:
|
||||||
|
"""Check if a user belongs to a specific mandate."""
|
||||||
|
try:
|
||||||
|
userMandates = rootInterface.db.getRecordset(UserMandate, {"userId": userId, "mandateId": mandateId})
|
||||||
|
return len(userMandates) > 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users", response_model=List[Dict[str, Any]])
|
@router.get("/users", response_model=List[Dict[str, Any]])
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
def listUsersForOverview(
|
def listUsersForOverview(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Get list of all users for selection in the overview.
|
Get list of users for selection in the overview.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
SysAdmin sees all users. MandateAdmin sees users in their mandate.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
- List of user dictionaries with basic info
|
- List of user dictionaries with basic info
|
||||||
"""
|
"""
|
||||||
|
if not _hasMandateAdminRole(context):
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung für die Benutzerzugriffsübersicht")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
# Get all users using interface method
|
if context.hasSysAdminRole and not context.mandateId:
|
||||||
allUsers = interface.getAllUsers()
|
# SysAdmin without mandate context: all users
|
||||||
|
allUsers = interface.getAllUsers()
|
||||||
|
elif context.mandateId:
|
||||||
|
# With explicit mandate context: users in that mandate
|
||||||
|
allUsers = interface.getUsersByMandate(str(context.mandateId))
|
||||||
|
else:
|
||||||
|
# MandateAdmin without mandate context: aggregate across all admin mandates
|
||||||
|
userMandates = interface.getUserMandates(str(context.user.id))
|
||||||
|
adminMandateIds = []
|
||||||
|
for um in userMandates:
|
||||||
|
umId = getattr(um, 'id', None)
|
||||||
|
mid = getattr(um, 'mandateId', None)
|
||||||
|
if not umId or not mid:
|
||||||
|
continue
|
||||||
|
roleIds = interface.getRoleIdsForUserMandate(str(umId))
|
||||||
|
for roleId in roleIds:
|
||||||
|
role = interface.getRole(roleId)
|
||||||
|
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
||||||
|
adminMandateIds.append(str(mid))
|
||||||
|
break
|
||||||
|
|
||||||
|
seenUserIds = set()
|
||||||
|
allUsers = []
|
||||||
|
for mid in adminMandateIds:
|
||||||
|
mandateUsers = interface.getUsersByMandate(mid)
|
||||||
|
for u in (mandateUsers if isinstance(mandateUsers, list) else mandateUsers.items if hasattr(mandateUsers, 'items') else []):
|
||||||
|
uid = u.get("id") if isinstance(u, dict) else getattr(u, "id", None)
|
||||||
|
if uid and uid not in seenUserIds:
|
||||||
|
seenUserIds.add(uid)
|
||||||
|
allUsers.append(u)
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for u in allUsers:
|
for u in allUsers:
|
||||||
|
userData = u if isinstance(u, dict) else u.model_dump() if hasattr(u, 'model_dump') else vars(u)
|
||||||
result.append({
|
result.append({
|
||||||
"id": u.id,
|
"id": userData.get("id"),
|
||||||
"username": u.username,
|
"username": userData.get("username"),
|
||||||
"email": u.email,
|
"email": userData.get("email"),
|
||||||
"fullName": u.fullName,
|
"fullName": userData.get("fullName"),
|
||||||
"isSysAdmin": u.isSysAdmin,
|
"isSysAdmin": userData.get("isSysAdmin", False),
|
||||||
"enabled": u.enabled,
|
"enabled": userData.get("enabled", True),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Sort by username
|
# Sort by username
|
||||||
|
|
@ -102,6 +170,8 @@ def listUsersForOverview(
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error listing users for overview: {str(e)}")
|
logger.error(f"Error listing users for overview: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -117,11 +187,11 @@ def getUserAccessOverview(
|
||||||
userId: str = Path(..., description="User ID to get access overview for"),
|
userId: str = Path(..., description="User ID to get access overview for"),
|
||||||
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"),
|
mandateId: Optional[str] = Query(None, description="Filter by mandate ID"),
|
||||||
featureInstanceId: Optional[str] = Query(None, description="Filter by feature instance ID"),
|
featureInstanceId: Optional[str] = Query(None, description="Filter by feature instance ID"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get comprehensive access overview for a specific user.
|
Get comprehensive access overview for a specific user.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
SysAdmin sees all users. MandateAdmin sees users in their mandate.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- userId: User ID
|
- userId: User ID
|
||||||
|
|
@ -138,9 +208,39 @@ def getUserAccessOverview(
|
||||||
- Data access (what tables/fields the user can access)
|
- Data access (what tables/fields the user can access)
|
||||||
- Resource access (what resources the user can use)
|
- Resource access (what resources the user can use)
|
||||||
"""
|
"""
|
||||||
|
if not _hasMandateAdminRole(context):
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung für die Benutzerzugriffsübersicht")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
|
# MandateAdmin: verify the requested user shares at least one admin mandate
|
||||||
|
if not context.hasSysAdminRole:
|
||||||
|
# Get admin's mandate IDs
|
||||||
|
adminMandateIds = []
|
||||||
|
userMandates = interface.getUserMandates(str(context.user.id))
|
||||||
|
for um in userMandates:
|
||||||
|
umId = getattr(um, 'id', None)
|
||||||
|
mid = getattr(um, 'mandateId', None)
|
||||||
|
if not umId or not mid:
|
||||||
|
continue
|
||||||
|
roleIds = interface.getRoleIdsForUserMandate(str(umId))
|
||||||
|
for roleId in roleIds:
|
||||||
|
role = interface.getRole(roleId)
|
||||||
|
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
||||||
|
adminMandateIds.append(str(mid))
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check that requested user belongs to at least one of the admin's mandates
|
||||||
|
userInAdminMandate = False
|
||||||
|
for mid in adminMandateIds:
|
||||||
|
if _isUserInMandate(interface, userId, mid):
|
||||||
|
userInAdminMandate = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not userInAdminMandate:
|
||||||
|
raise HTTPException(status_code=403, detail="Benutzer gehört nicht zu Ihrem Mandate")
|
||||||
|
|
||||||
# Get user
|
# Get user
|
||||||
user = interface.getUser(userId)
|
user = interface.getUser(userId)
|
||||||
if not user:
|
if not user:
|
||||||
|
|
@ -159,19 +259,6 @@ def getUserAccessOverview(
|
||||||
"enabled": user.enabled,
|
"enabled": user.enabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
# If user is SysAdmin, they have full access to everything
|
|
||||||
if user.isSysAdmin:
|
|
||||||
return {
|
|
||||||
"user": userInfo,
|
|
||||||
"isSysAdmin": True,
|
|
||||||
"sysAdminNote": "SysAdmin users have full access to all system-level resources without mandate context.",
|
|
||||||
"roles": [],
|
|
||||||
"mandates": [],
|
|
||||||
"uiAccess": [],
|
|
||||||
"dataAccess": [],
|
|
||||||
"resourceAccess": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Collect all roles for the user
|
# Collect all roles for the user
|
||||||
allRoles = []
|
allRoles = []
|
||||||
roleIdToInfo = {} # Map roleId to role info for later reference
|
roleIdToInfo = {} # Map roleId to role info for later reference
|
||||||
|
|
@ -415,14 +502,14 @@ def getEffectivePermissions(
|
||||||
userId: str = Path(..., description="User ID"),
|
userId: str = Path(..., description="User ID"),
|
||||||
mandateId: str = Query(..., description="Mandate ID context"),
|
mandateId: str = Query(..., description="Mandate ID context"),
|
||||||
featureInstanceId: Optional[str] = Query(None, description="Feature instance ID context"),
|
featureInstanceId: Optional[str] = Query(None, description="Feature instance ID context"),
|
||||||
context: str = Query("DATA", description="Context type: DATA, UI, or RESOURCE"),
|
accessContext: str = Query("DATA", alias="context", description="Context type: DATA, UI, or RESOURCE"),
|
||||||
item: Optional[str] = Query(None, description="Specific item to check permissions for"),
|
item: Optional[str] = Query(None, description="Specific item to check permissions for"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get effective (resolved) permissions for a user in a specific context.
|
Get effective (resolved) permissions for a user in a specific context.
|
||||||
This uses the RBAC resolution logic to show what permissions actually apply.
|
This uses the RBAC resolution logic to show what permissions actually apply.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
MULTI-TENANT: SysAdmin sees all. MandateAdmin can check users in their own mandates.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
- userId: User ID
|
- userId: User ID
|
||||||
|
|
@ -436,6 +523,11 @@ def getEffectivePermissions(
|
||||||
Returns:
|
Returns:
|
||||||
- Effective permissions after RBAC resolution
|
- Effective permissions after RBAC resolution
|
||||||
"""
|
"""
|
||||||
|
if not context.hasSysAdminRole:
|
||||||
|
# Check if user has admin role in any mandate
|
||||||
|
if not _hasMandateAdminRole(context):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
|
|
@ -449,11 +541,11 @@ def getEffectivePermissions(
|
||||||
|
|
||||||
# Convert context string to enum
|
# Convert context string to enum
|
||||||
try:
|
try:
|
||||||
contextEnum = AccessRuleContext(context)
|
contextEnum = AccessRuleContext(accessContext)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Invalid context: {context}. Must be DATA, UI, or RESOURCE."
|
detail=f"Invalid context: {accessContext}. Must be DATA, UI, or RESOURCE."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Use RBAC interface to get actual permissions
|
# Use RBAC interface to get actual permissions
|
||||||
|
|
@ -472,7 +564,7 @@ def getEffectivePermissions(
|
||||||
"userId": userId,
|
"userId": userId,
|
||||||
"mandateId": mandateId,
|
"mandateId": mandateId,
|
||||||
"featureInstanceId": featureInstanceId,
|
"featureInstanceId": featureInstanceId,
|
||||||
"context": context,
|
"context": accessContext,
|
||||||
"item": item,
|
"item": item,
|
||||||
"effectivePermissions": {
|
"effectivePermissions": {
|
||||||
"view": permissions.view,
|
"view": permissions.view,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from datetime import date, datetime
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
# Import auth module
|
# Import auth module
|
||||||
from modules.auth import limiter, requireSysAdmin, getRequestContext, RequestContext
|
from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
|
||||||
|
|
||||||
# Import billing components
|
# Import billing components
|
||||||
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface
|
||||||
|
|
@ -84,7 +84,8 @@ def _getBillingDataScope(user) -> BillingDataScope:
|
||||||
"""
|
"""
|
||||||
scope = BillingDataScope(userId=user.id)
|
scope = BillingDataScope(userId=user.id)
|
||||||
|
|
||||||
if user.isSysAdmin:
|
from modules.auth.authentication import _hasSysAdminRole
|
||||||
|
if _hasSysAdminRole(str(user.id)):
|
||||||
scope.isGlobalAdmin = True
|
scope.isGlobalAdmin = True
|
||||||
return scope
|
return scope
|
||||||
|
|
||||||
|
|
@ -137,6 +138,30 @@ def _getBillingDataScope(user) -> BillingDataScope:
|
||||||
return scope
|
return scope
|
||||||
|
|
||||||
|
|
||||||
|
def _isAdminOfMandate(ctx: RequestContext, targetMandateId: str) -> bool:
|
||||||
|
"""Check if user is SysAdmin or admin of the specified mandate."""
|
||||||
|
if ctx.hasSysAdminRole:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
userMandates = rootInterface.getUserMandates(str(ctx.user.id))
|
||||||
|
for um in userMandates:
|
||||||
|
if str(getattr(um, 'mandateId', None)) != str(targetMandateId):
|
||||||
|
continue
|
||||||
|
if not getattr(um, 'enabled', True):
|
||||||
|
continue
|
||||||
|
umId = str(getattr(um, 'id', ''))
|
||||||
|
roleIds = rootInterface.getRoleIdsForUserMandate(umId)
|
||||||
|
for roleId in roleIds:
|
||||||
|
role = rootInterface.getRole(roleId)
|
||||||
|
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _filterTransactionsByScope(transactions: list, scope: BillingDataScope) -> list:
|
def _filterTransactionsByScope(transactions: list, scope: BillingDataScope) -> list:
|
||||||
"""
|
"""
|
||||||
Filter a list of transaction dicts based on the user's BillingDataScope.
|
Filter a list of transaction dicts based on the user's BillingDataScope.
|
||||||
|
|
@ -537,11 +562,13 @@ def getSettingsAdmin(
|
||||||
request: Request,
|
request: Request,
|
||||||
targetMandateId: str = Path(..., description="Mandate ID"),
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||||
ctx: RequestContext = Depends(getRequestContext),
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
_admin = Depends(requireSysAdmin)
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get billing settings for a mandate (SysAdmin only).
|
Get billing settings for a mandate.
|
||||||
|
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
|
||||||
"""
|
"""
|
||||||
|
if not _isAdminOfMandate(ctx, targetMandateId):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
|
||||||
try:
|
try:
|
||||||
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||||
settings = billingInterface.getSettings(targetMandateId)
|
settings = billingInterface.getSettings(targetMandateId)
|
||||||
|
|
@ -565,7 +592,7 @@ def createOrUpdateSettings(
|
||||||
targetMandateId: str = Path(..., description="Mandate ID"),
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||||
settingsUpdate: BillingSettingsUpdate = Body(...),
|
settingsUpdate: BillingSettingsUpdate = Body(...),
|
||||||
ctx: RequestContext = Depends(getRequestContext),
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
_admin = Depends(requireSysAdmin)
|
_admin = Depends(requireSysAdminRole)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Create or update billing settings for a mandate (SysAdmin only).
|
Create or update billing settings for a mandate (SysAdmin only).
|
||||||
|
|
@ -618,7 +645,7 @@ def addCredit(
|
||||||
targetMandateId: str = Path(..., description="Mandate ID"),
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||||
creditRequest: CreditAddRequest = Body(...),
|
creditRequest: CreditAddRequest = Body(...),
|
||||||
ctx: RequestContext = Depends(getRequestContext),
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
_admin = Depends(requireSysAdmin)
|
_admin = Depends(requireSysAdminRole)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Add credit to a billing account (SysAdmin only).
|
Add credit to a billing account (SysAdmin only).
|
||||||
|
|
@ -681,11 +708,13 @@ def getAccounts(
|
||||||
request: Request,
|
request: Request,
|
||||||
targetMandateId: str = Path(..., description="Mandate ID"),
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||||
ctx: RequestContext = Depends(getRequestContext),
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
_admin = Depends(requireSysAdmin)
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get all billing accounts for a mandate (SysAdmin only).
|
Get all billing accounts for a mandate.
|
||||||
|
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
|
||||||
"""
|
"""
|
||||||
|
if not _isAdminOfMandate(ctx, targetMandateId):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
|
||||||
try:
|
try:
|
||||||
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||||
|
|
||||||
|
|
@ -728,12 +757,14 @@ def getUsersForMandate(
|
||||||
request: Request,
|
request: Request,
|
||||||
targetMandateId: str = Path(..., description="Mandate ID"),
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||||
ctx: RequestContext = Depends(getRequestContext),
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
_admin = Depends(requireSysAdmin)
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get all users belonging to a mandate (SysAdmin only).
|
Get all users belonging to a mandate.
|
||||||
|
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
|
||||||
Used by billing admin to select users for credit assignment.
|
Used by billing admin to select users for credit assignment.
|
||||||
"""
|
"""
|
||||||
|
if not _isAdminOfMandate(ctx, targetMandateId):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
|
||||||
|
|
@ -787,11 +818,13 @@ def getTransactionsAdmin(
|
||||||
targetMandateId: str = Path(..., description="Mandate ID"),
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||||
limit: int = Query(default=100, ge=1, le=1000),
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
ctx: RequestContext = Depends(getRequestContext),
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
_admin = Depends(requireSysAdmin)
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get all transactions for a mandate (SysAdmin only).
|
Get all transactions for a mandate.
|
||||||
|
Access: SysAdmin (any mandate) or MandateAdmin (own mandate).
|
||||||
"""
|
"""
|
||||||
|
if not _isAdminOfMandate(ctx, targetMandateId):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate")
|
||||||
try:
|
try:
|
||||||
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
billingInterface = getBillingInterface(ctx.user, targetMandateId)
|
||||||
transactions = billingInterface.getTransactionsByMandate(targetMandateId, limit=limit)
|
transactions = billingInterface.getTransactionsByMandate(targetMandateId, limit=limit)
|
||||||
|
|
@ -830,7 +863,7 @@ def getTransactionsAdmin(
|
||||||
def getMandateViewBalances(
|
def getMandateViewBalances(
|
||||||
request: Request,
|
request: Request,
|
||||||
ctx: RequestContext = Depends(getRequestContext),
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
_admin = Depends(requireSysAdmin)
|
_admin = Depends(requireSysAdminRole)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get mandate-level balances (SysAdmin only).
|
Get mandate-level balances (SysAdmin only).
|
||||||
|
|
@ -853,7 +886,7 @@ def getMandateViewTransactions(
|
||||||
request: Request,
|
request: Request,
|
||||||
limit: int = Query(default=100, ge=1, le=1000),
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
ctx: RequestContext = Depends(getRequestContext),
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
_admin = Depends(requireSysAdmin)
|
_admin = Depends(requireSysAdminRole)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get all transactions across mandates (SysAdmin only).
|
Get all transactions across mandates (SysAdmin only).
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import json
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
# Import auth module
|
# Import auth module
|
||||||
from modules.auth import limiter, requireSysAdmin, getRequestContext, RequestContext
|
from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
|
||||||
|
|
||||||
# Import interfaces
|
# Import interfaces
|
||||||
import modules.interfaces.interfaceDbApp as interfaceDbApp
|
import modules.interfaces.interfaceDbApp as interfaceDbApp
|
||||||
|
|
@ -79,20 +79,30 @@ router = APIRouter(
|
||||||
def get_mandates(
|
def get_mandates(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[Mandate]:
|
) -> PaginatedResponse[Mandate]:
|
||||||
"""
|
"""
|
||||||
Get mandates with optional pagination, sorting, and filtering.
|
Get mandates with optional pagination, sorting, and filtering.
|
||||||
MULTI-TENANT: SysAdmin-only (mandates are system resources).
|
|
||||||
|
Access:
|
||||||
|
- SysAdmin: all mandates
|
||||||
|
- MandateAdmin: only mandates where user has admin role
|
||||||
|
- Other: 403
|
||||||
|
|
||||||
Query Parameters:
|
Query Parameters:
|
||||||
- pagination: JSON-encoded PaginationParams object, or None for no pagination
|
- pagination: JSON-encoded PaginationParams object, or None for no pagination
|
||||||
|
|
||||||
Examples:
|
|
||||||
- GET /api/mandates/ (no pagination - returns all items)
|
|
||||||
- GET /api/mandates/?pagination={"page":1,"pageSize":10,"sort":[]}
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Check admin access
|
||||||
|
isSysAdmin = context.hasSysAdminRole
|
||||||
|
if not isSysAdmin:
|
||||||
|
adminMandateIds = _getAdminMandateIds(context)
|
||||||
|
if not adminMandateIds:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin role required"
|
||||||
|
)
|
||||||
|
|
||||||
# Parse pagination parameter
|
# Parse pagination parameter
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
|
|
@ -108,11 +118,24 @@ def get_mandates(
|
||||||
)
|
)
|
||||||
|
|
||||||
appInterface = interfaceDbApp.getRootInterface()
|
appInterface = interfaceDbApp.getRootInterface()
|
||||||
result = appInterface.getAllMandates(pagination=paginationParams)
|
|
||||||
|
if isSysAdmin:
|
||||||
|
# SysAdmin: all mandates
|
||||||
|
result = appInterface.getAllMandates(pagination=paginationParams)
|
||||||
|
else:
|
||||||
|
# MandateAdmin: only their mandates
|
||||||
|
allMandates = []
|
||||||
|
for mandateId in adminMandateIds:
|
||||||
|
mandate = appInterface.getMandate(mandateId)
|
||||||
|
if mandate:
|
||||||
|
mandateDict = mandate if isinstance(mandate, dict) else mandate.model_dump() if hasattr(mandate, 'model_dump') else vars(mandate)
|
||||||
|
allMandates.append(mandateDict)
|
||||||
|
result = allMandates
|
||||||
|
paginationParams = None # Client-side pagination for filtered results
|
||||||
|
|
||||||
# If pagination was requested, result is PaginatedResult
|
# If pagination was requested, result is PaginatedResult
|
||||||
# If no pagination, result is List[Mandate]
|
# If no pagination, result is List[Mandate]
|
||||||
if paginationParams:
|
if paginationParams and hasattr(result, 'items'):
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
items=result.items,
|
items=result.items,
|
||||||
pagination=PaginationMetadata(
|
pagination=PaginationMetadata(
|
||||||
|
|
@ -125,8 +148,9 @@ def get_mandates(
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
items = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else result)
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
items=result,
|
items=items,
|
||||||
pagination=None
|
pagination=None
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -138,18 +162,32 @@ def get_mandates(
|
||||||
detail=f"Failed to get mandates: {str(e)}"
|
detail=f"Failed to get mandates: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/{mandateId}", response_model=Mandate)
|
@router.get("/{targetMandateId}", response_model=Mandate)
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def get_mandate(
|
def get_mandate(
|
||||||
request: Request,
|
request: Request,
|
||||||
mandateId: str = Path(..., description="ID of the mandate"),
|
targetMandateId: str = Path(..., description="ID of the mandate"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> Mandate:
|
) -> Mandate:
|
||||||
"""
|
"""
|
||||||
Get a specific mandate by ID.
|
Get a specific mandate by ID.
|
||||||
MULTI-TENANT: SysAdmin-only.
|
|
||||||
|
Access:
|
||||||
|
- SysAdmin: any mandate
|
||||||
|
- MandateAdmin: only mandates where user has admin role
|
||||||
|
- Other: 403
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
mandateId = targetMandateId
|
||||||
|
# Check access
|
||||||
|
if not context.hasSysAdminRole:
|
||||||
|
adminMandateIds = _getAdminMandateIds(context)
|
||||||
|
if mandateId not in adminMandateIds:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin role required for this mandate"
|
||||||
|
)
|
||||||
|
|
||||||
appInterface = interfaceDbApp.getRootInterface()
|
appInterface = interfaceDbApp.getRootInterface()
|
||||||
mandate = appInterface.getMandate(mandateId)
|
mandate = appInterface.getMandate(mandateId)
|
||||||
|
|
||||||
|
|
@ -174,7 +212,7 @@ def get_mandate(
|
||||||
def create_mandate(
|
def create_mandate(
|
||||||
request: Request,
|
request: Request,
|
||||||
mandateData: dict = Body(..., description="Mandate data with at least 'name' field"),
|
mandateData: dict = Body(..., description="Mandate data with at least 'name' field"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
currentUser: User = Depends(requireSysAdminRole)
|
||||||
) -> Mandate:
|
) -> Mandate:
|
||||||
"""
|
"""
|
||||||
Create a new mandate.
|
Create a new mandate.
|
||||||
|
|
@ -228,7 +266,7 @@ def update_mandate(
|
||||||
request: Request,
|
request: Request,
|
||||||
mandateId: str = Path(..., description="ID of the mandate to update"),
|
mandateId: str = Path(..., description="ID of the mandate to update"),
|
||||||
mandateData: dict = Body(..., description="Mandate update data"),
|
mandateData: dict = Body(..., description="Mandate update data"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
currentUser: User = Depends(requireSysAdminRole)
|
||||||
) -> Mandate:
|
) -> Mandate:
|
||||||
"""
|
"""
|
||||||
Update an existing mandate.
|
Update an existing mandate.
|
||||||
|
|
@ -273,7 +311,7 @@ def update_mandate(
|
||||||
def delete_mandate(
|
def delete_mandate(
|
||||||
request: Request,
|
request: Request,
|
||||||
mandateId: str = Path(..., description="ID of the mandate to delete"),
|
mandateId: str = Path(..., description="ID of the mandate to delete"),
|
||||||
currentUser: User = Depends(requireSysAdmin)
|
currentUser: User = Depends(requireSysAdminRole)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Delete a mandate.
|
Delete a mandate.
|
||||||
|
|
@ -339,7 +377,7 @@ def list_mandate_users(
|
||||||
pagination: Optional pagination parameters (page, pageSize, search, filters, sort)
|
pagination: Optional pagination parameters (page, pageSize, search, filters, sort)
|
||||||
"""
|
"""
|
||||||
# Check permission
|
# Check permission
|
||||||
if not _hasMandateAdminRole(context, targetMandateId) and not context.isSysAdmin:
|
if not _hasMandateAdminRole(context, targetMandateId) and not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Mandate-Admin role required"
|
detail="Mandate-Admin role required"
|
||||||
|
|
@ -510,7 +548,7 @@ def add_user_to_mandate(
|
||||||
data: User ID and role IDs to assign
|
data: User ID and role IDs to assign
|
||||||
"""
|
"""
|
||||||
# 1. SysAdmin Self-Eskalation Prevention
|
# 1. SysAdmin Self-Eskalation Prevention
|
||||||
if context.isSysAdmin and data.targetUserId == str(context.user.id):
|
if context.hasSysAdminRole and data.targetUserId == str(context.user.id):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="SysAdmin cannot add themselves to a mandate. A Mandate-Admin must grant access."
|
detail="SysAdmin cannot add themselves to a mandate. A Mandate-Admin must grant access."
|
||||||
|
|
@ -784,35 +822,58 @@ def update_user_roles_in_mandate(
|
||||||
# Helper Functions
|
# Helper Functions
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
def _getAdminMandateIds(context: RequestContext) -> List[str]:
|
||||||
|
"""
|
||||||
|
Get list of mandate IDs where the user has the admin role.
|
||||||
|
Returns empty list if user has no admin roles.
|
||||||
|
"""
|
||||||
|
mandateIds = []
|
||||||
|
try:
|
||||||
|
rootInterface = interfaceDbApp.getRootInterface()
|
||||||
|
userId = str(context.user.id)
|
||||||
|
userMandates = rootInterface.getUserMandates(userId)
|
||||||
|
for um in userMandates:
|
||||||
|
if not getattr(um, 'enabled', True):
|
||||||
|
continue
|
||||||
|
umId = getattr(um, 'id', None)
|
||||||
|
mandateId = getattr(um, 'mandateId', None)
|
||||||
|
if not umId or not mandateId:
|
||||||
|
continue
|
||||||
|
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
|
||||||
|
for roleId in roleIds:
|
||||||
|
role = rootInterface.getRole(roleId)
|
||||||
|
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
||||||
|
mandateIds.append(str(mandateId))
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting admin mandate IDs: {e}")
|
||||||
|
return mandateIds
|
||||||
|
|
||||||
|
|
||||||
def _hasMandateAdminRole(context: RequestContext, mandateId: str) -> bool:
|
def _hasMandateAdminRole(context: RequestContext, mandateId: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if the user has mandate admin role for the specified mandate.
|
Check if the user has mandate admin role for the specified mandate.
|
||||||
|
Works with or without X-Mandate-Id header (admin pages don't send it).
|
||||||
"""
|
"""
|
||||||
if context.isSysAdmin:
|
if context.hasSysAdminRole:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Must be in the same mandate context
|
# If mandate context matches, check roles from context directly
|
||||||
if str(context.mandateId) != str(mandateId):
|
if context.mandateId and str(context.mandateId) == str(mandateId):
|
||||||
|
if context.roleIds:
|
||||||
|
try:
|
||||||
|
rootInterface = interfaceDbApp.getRootInterface()
|
||||||
|
for roleId in context.roleIds:
|
||||||
|
role = rootInterface.getRole(roleId)
|
||||||
|
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking mandate admin role: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not context.roleIds:
|
# No mandate context (admin pages) — check via user's mandate memberships
|
||||||
return False
|
adminMandateIds = _getAdminMandateIds(context)
|
||||||
|
return str(mandateId) in adminMandateIds
|
||||||
try:
|
|
||||||
rootInterface = interfaceDbApp.getRootInterface()
|
|
||||||
|
|
||||||
for roleId in context.roleIds:
|
|
||||||
role = rootInterface.getRole(roleId)
|
|
||||||
if role:
|
|
||||||
# Admin role at mandate level (not feature-instance level)
|
|
||||||
if role.roleLabel == "admin" and role.mandateId and not role.featureInstanceId:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error checking mandate admin role: {e}")
|
|
||||||
return False # Fail-safe: no access on error
|
|
||||||
|
|
||||||
|
|
||||||
def _isLastMandateAdmin(interface, mandateId: str, excludeUserId: str) -> bool:
|
def _isLastMandateAdmin(interface, mandateId: str, excludeUserId: str) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,46 @@ from modules.datamodels.datamodelPagination import PaginationParams, PaginatedRe
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _isAdminForUser(context: RequestContext, targetUserId: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the current user has admin rights for the target user.
|
||||||
|
SysAdmin can manage all users. MandateAdmin can manage users in their mandates.
|
||||||
|
Works without X-Mandate-Id header (admin pages don't send it).
|
||||||
|
"""
|
||||||
|
if context.hasSysAdminRole:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Find mandates where current user is admin
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
userId = str(context.user.id)
|
||||||
|
userMandates = rootInterface.getUserMandates(userId)
|
||||||
|
adminMandateIds = []
|
||||||
|
for um in userMandates:
|
||||||
|
if not getattr(um, 'enabled', True):
|
||||||
|
continue
|
||||||
|
umId = getattr(um, 'id', None)
|
||||||
|
mandateId = getattr(um, 'mandateId', None)
|
||||||
|
if not umId or not mandateId:
|
||||||
|
continue
|
||||||
|
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
|
||||||
|
for roleId in roleIds:
|
||||||
|
role = rootInterface.getRole(roleId)
|
||||||
|
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
||||||
|
adminMandateIds.append(str(mandateId))
|
||||||
|
break
|
||||||
|
|
||||||
|
if not adminMandateIds:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if target user is in any of the admin's mandates
|
||||||
|
targetMandates = rootInterface.getUserMandates(targetUserId)
|
||||||
|
for tm in targetMandates:
|
||||||
|
if str(getattr(tm, 'mandateId', '')) in adminMandateIds:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _applyFiltersAndSort(items: List[Dict[str, Any]], paginationParams: Optional[PaginationParams]) -> List[Dict[str, Any]]:
|
def _applyFiltersAndSort(items: List[Dict[str, Any]], paginationParams: Optional[PaginationParams]) -> List[Dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Apply filters and sorting to a list of items.
|
Apply filters and sorting to a list of items.
|
||||||
|
|
@ -168,7 +208,7 @@ def get_user_options(
|
||||||
if context.mandateId:
|
if context.mandateId:
|
||||||
result = appInterface.getUsersByMandate(str(context.mandateId), None)
|
result = appInterface.getUsersByMandate(str(context.mandateId), None)
|
||||||
users = result.items if hasattr(result, 'items') else result
|
users = result.items if hasattr(result, 'items') else result
|
||||||
elif context.isSysAdmin:
|
elif context.hasSysAdminRole:
|
||||||
users = appInterface.getAllUsers()
|
users = appInterface.getAllUsers()
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
@ -222,7 +262,7 @@ def get_users(
|
||||||
detail=f"Invalid pagination parameter: {str(e)}"
|
detail=f"Invalid pagination parameter: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
appInterface = interfaceDbApp.getInterface(context.user)
|
appInterface = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId)
|
||||||
|
|
||||||
# MULTI-TENANT: Use mandateId from context (header)
|
# MULTI-TENANT: Use mandateId from context (header)
|
||||||
# SysAdmin without mandateId can see all users
|
# SysAdmin without mandateId can see all users
|
||||||
|
|
@ -250,7 +290,7 @@ def get_users(
|
||||||
items=users,
|
items=users,
|
||||||
pagination=None
|
pagination=None
|
||||||
)
|
)
|
||||||
elif context.isSysAdmin:
|
elif context.hasSysAdminRole:
|
||||||
# SysAdmin without mandateId sees all users
|
# SysAdmin without mandateId sees all users
|
||||||
# Get all users via interface method (returns Pydantic User models)
|
# Get all users via interface method (returns Pydantic User models)
|
||||||
allUserModels = appInterface.getAllUsers()
|
allUserModels = appInterface.getAllUsers()
|
||||||
|
|
@ -288,11 +328,76 @@ def get_users(
|
||||||
pagination=None
|
pagination=None
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Non-SysAdmin without mandateId - should not happen (getRequestContext enforces)
|
# Non-SysAdmin without mandateId: aggregate users across all admin mandates
|
||||||
raise HTTPException(
|
rootInterface = getRootInterface()
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
userMandates = rootInterface.getUserMandates(str(context.user.id))
|
||||||
detail="X-Mandate-Id header is required"
|
|
||||||
)
|
# Find mandates where user has admin role
|
||||||
|
adminMandateIds = []
|
||||||
|
for um in userMandates:
|
||||||
|
umId = getattr(um, 'id', None)
|
||||||
|
mandateId = getattr(um, 'mandateId', None)
|
||||||
|
if not umId or not mandateId:
|
||||||
|
continue
|
||||||
|
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
|
||||||
|
for roleId in roleIds:
|
||||||
|
role = rootInterface.getRole(roleId)
|
||||||
|
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
||||||
|
adminMandateIds.append(str(mandateId))
|
||||||
|
break
|
||||||
|
|
||||||
|
if not adminMandateIds:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="No admin access to any mandate"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Aggregate users across all admin mandates (deduplicate by user ID)
|
||||||
|
seenUserIds = set()
|
||||||
|
allUsers = []
|
||||||
|
for mid in adminMandateIds:
|
||||||
|
mandateUsers = rootInterface.getUsersByMandate(mid)
|
||||||
|
if isinstance(mandateUsers, list):
|
||||||
|
users = mandateUsers
|
||||||
|
elif hasattr(mandateUsers, 'items'):
|
||||||
|
users = mandateUsers.items
|
||||||
|
else:
|
||||||
|
users = []
|
||||||
|
for u in users:
|
||||||
|
uid = u.get("id") if isinstance(u, dict) else getattr(u, "id", None)
|
||||||
|
if uid and uid not in seenUserIds:
|
||||||
|
seenUserIds.add(uid)
|
||||||
|
userData = u if isinstance(u, dict) else u.model_dump() if hasattr(u, 'model_dump') else vars(u)
|
||||||
|
allUsers.append(userData)
|
||||||
|
|
||||||
|
# Apply server-side filtering and sorting
|
||||||
|
filteredUsers = _applyFiltersAndSort(allUsers, paginationParams)
|
||||||
|
users = [User(**u) for u in filteredUsers]
|
||||||
|
|
||||||
|
if paginationParams:
|
||||||
|
import math
|
||||||
|
totalItems = len(users)
|
||||||
|
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||||
|
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
||||||
|
endIdx = startIdx + paginationParams.pageSize
|
||||||
|
paginatedUsers = users[startIdx:endIdx]
|
||||||
|
|
||||||
|
return PaginatedResponse(
|
||||||
|
items=paginatedUsers,
|
||||||
|
pagination=PaginationMetadata(
|
||||||
|
currentPage=paginationParams.page,
|
||||||
|
pageSize=paginationParams.pageSize,
|
||||||
|
totalItems=totalItems,
|
||||||
|
totalPages=totalPages,
|
||||||
|
sort=paginationParams.sort,
|
||||||
|
filters=paginationParams.filters
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return PaginatedResponse(
|
||||||
|
items=users,
|
||||||
|
pagination=None
|
||||||
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -325,7 +430,7 @@ def get_user(
|
||||||
)
|
)
|
||||||
|
|
||||||
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
||||||
if context.mandateId and not context.isSysAdmin:
|
if context.mandateId and not context.hasSysAdminRole:
|
||||||
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
|
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
|
||||||
if not userMandate:
|
if not userMandate:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -404,29 +509,31 @@ def update_user(
|
||||||
) -> User:
|
) -> User:
|
||||||
"""
|
"""
|
||||||
Update an existing user.
|
Update an existing user.
|
||||||
MULTI-TENANT: Can only update users in the same mandate (unless SysAdmin).
|
Self-service: Users can update their own profile (language, fullName, etc.).
|
||||||
|
Admin: MandateAdmin can update users in their mandates. SysAdmin for all.
|
||||||
"""
|
"""
|
||||||
appInterface = interfaceDbApp.getInterface(context.user)
|
isSelfUpdate = str(context.user.id) == str(userId)
|
||||||
|
|
||||||
|
# Non-self updates require admin permission
|
||||||
|
if not isSelfUpdate and not _isAdminForUser(context, userId):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin role required to update other users"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use rootInterface for user lookup/update (avoids RBAC filtering on User table)
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
|
||||||
# Check if the user exists
|
# Check if the user exists
|
||||||
existingUser = appInterface.getUser(userId)
|
existingUser = rootInterface.getUser(userId)
|
||||||
if not existingUser:
|
if not existingUser:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"User with ID {userId} not found"
|
detail=f"User with ID {userId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
|
||||||
if context.mandateId and not context.isSysAdmin:
|
|
||||||
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
|
|
||||||
if not userMandate:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Cannot update user outside your mandate"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update user
|
# Update user
|
||||||
updatedUser = appInterface.updateUser(userId, userData)
|
updatedUser = rootInterface.updateUser(userId, userData)
|
||||||
|
|
||||||
if not updatedUser:
|
if not updatedUser:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -446,36 +553,19 @@ def reset_user_password(
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Reset user password (Admin only).
|
Reset user password (Admin only).
|
||||||
MULTI-TENANT: Can only reset passwords for users in the same mandate (unless SysAdmin).
|
MULTI-TENANT: MandateAdmin can reset passwords for users in their mandates. SysAdmin for all.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if current user is admin
|
# Check admin permission (SysAdmin or MandateAdmin for this user)
|
||||||
if not context.isSysAdmin:
|
if not _isAdminForUser(context, userId):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Only administrators can reset passwords"
|
detail="Admin role required to reset passwords"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get user interface
|
# Get user interface
|
||||||
appInterface = interfaceDbApp.getInterface(context.user)
|
appInterface = interfaceDbApp.getInterface(context.user)
|
||||||
|
|
||||||
# Get target user
|
|
||||||
target_user = appInterface.getUser(userId)
|
|
||||||
if not target_user:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="User not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
|
||||||
if context.mandateId and not context.isSysAdmin:
|
|
||||||
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
|
|
||||||
if not userMandate:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Cannot reset password for user outside your mandate"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate password strength
|
# Validate password strength
|
||||||
if len(newPassword) < 8:
|
if len(newPassword) < 8:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -622,7 +712,7 @@ def send_password_link(
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Send password setup/reset link to a user (admin function).
|
Send password setup/reset link to a user (admin function).
|
||||||
MULTI-TENANT: Can only send to users in the same mandate (unless SysAdmin).
|
MULTI-TENANT: MandateAdmin can send to users in their mandates. SysAdmin for all.
|
||||||
|
|
||||||
This allows admins to send a magic link to users to set or reset their password.
|
This allows admins to send a magic link to users to set or reset their password.
|
||||||
Used when creating users without password or to help users who forgot their password.
|
Used when creating users without password or to help users who forgot their password.
|
||||||
|
|
@ -634,6 +724,13 @@ def send_password_link(
|
||||||
try:
|
try:
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
|
# Check admin permission (SysAdmin or MandateAdmin for this user)
|
||||||
|
if not _isAdminForUser(context, userId):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin role required to send password links"
|
||||||
|
)
|
||||||
|
|
||||||
# Get user interface
|
# Get user interface
|
||||||
appInterface = interfaceDbApp.getInterface(context.user)
|
appInterface = interfaceDbApp.getInterface(context.user)
|
||||||
|
|
||||||
|
|
@ -645,15 +742,6 @@ def send_password_link(
|
||||||
detail="User not found"
|
detail="User not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
|
||||||
if context.mandateId and not context.isSysAdmin:
|
|
||||||
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
|
|
||||||
if not userMandate:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Cannot send password link to user outside your mandate"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if user has an email
|
# Check if user has an email
|
||||||
if not targetUser.email:
|
if not targetUser.email:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -769,7 +857,7 @@ def delete_user(
|
||||||
)
|
)
|
||||||
|
|
||||||
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
||||||
if context.mandateId and not context.isSysAdmin:
|
if context.mandateId and not context.hasSysAdminRole:
|
||||||
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
|
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
|
||||||
if not userMandate:
|
if not userMandate:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Respon
|
||||||
|
|
||||||
# Import auth modules
|
# Import auth modules
|
||||||
from modules.auth import limiter, getCurrentUser
|
from modules.auth import limiter, getCurrentUser
|
||||||
|
from modules.auth.authentication import getRequestContext, RequestContext
|
||||||
|
|
||||||
# Import interfaces from feature containers
|
# Import interfaces from feature containers
|
||||||
import modules.interfaces.interfaceDbChat as interfaceDbChat
|
import modules.interfaces.interfaceDbChat as interfaceDbChat
|
||||||
|
|
@ -44,8 +45,11 @@ router = APIRouter(
|
||||||
responses={404: {"description": "Not found"}}
|
responses={404: {"description": "Not found"}}
|
||||||
)
|
)
|
||||||
|
|
||||||
def getServiceChat(currentUser: User):
|
def getServiceChat(ctx: RequestContext):
|
||||||
return interfaceDbChat.getInterface(currentUser)
|
# Workflows are not feature-instance-specific — only mandateId is needed for RBAC.
|
||||||
|
# Passing featureInstanceId would add a SQL WHERE filter that excludes workflows
|
||||||
|
# created by other features (e.g., automation vs chatbot).
|
||||||
|
return interfaceDbChat.getInterface(ctx.user, mandateId=ctx.mandateId)
|
||||||
|
|
||||||
# Consolidated endpoint for getting all workflows
|
# Consolidated endpoint for getting all workflows
|
||||||
@router.get("/", response_model=PaginatedResponse[ChatWorkflow])
|
@router.get("/", response_model=PaginatedResponse[ChatWorkflow])
|
||||||
|
|
@ -53,7 +57,7 @@ def getServiceChat(currentUser: User):
|
||||||
def get_workflows(
|
def get_workflows(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[ChatWorkflow]:
|
) -> PaginatedResponse[ChatWorkflow]:
|
||||||
"""
|
"""
|
||||||
Get workflows with optional pagination, sorting, and filtering.
|
Get workflows with optional pagination, sorting, and filtering.
|
||||||
|
|
@ -81,7 +85,7 @@ def get_workflows(
|
||||||
detail=f"Invalid pagination parameter: {str(e)}"
|
detail=f"Invalid pagination parameter: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
appInterface = getInterface(currentUser)
|
appInterface = getServiceChat(ctx)
|
||||||
result = appInterface.getWorkflows(pagination=paginationParams)
|
result = appInterface.getWorkflows(pagination=paginationParams)
|
||||||
|
|
||||||
# If pagination was requested, result is PaginatedResult with items as dicts
|
# If pagination was requested, result is PaginatedResult with items as dicts
|
||||||
|
|
@ -126,12 +130,12 @@ def get_workflows(
|
||||||
def get_workflow(
|
def get_workflow(
|
||||||
request: Request,
|
request: Request,
|
||||||
workflowId: str = Path(..., description="ID of the workflow"),
|
workflowId: str = Path(..., description="ID of the workflow"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
) -> ChatWorkflow:
|
) -> ChatWorkflow:
|
||||||
"""Get workflow by ID"""
|
"""Get workflow by ID"""
|
||||||
try:
|
try:
|
||||||
# Get workflow interface with current user context
|
# Get workflow interface with current user context
|
||||||
workflowInterface = getInterface(currentUser)
|
workflowInterface = getServiceChat(ctx)
|
||||||
|
|
||||||
# Get workflow
|
# Get workflow
|
||||||
workflow = workflowInterface.getWorkflow(workflowId)
|
workflow = workflowInterface.getWorkflow(workflowId)
|
||||||
|
|
@ -156,12 +160,12 @@ def update_workflow(
|
||||||
request: Request,
|
request: Request,
|
||||||
workflowId: str = Path(..., description="ID of the workflow to update"),
|
workflowId: str = Path(..., description="ID of the workflow to update"),
|
||||||
workflowData: Dict[str, Any] = Body(...),
|
workflowData: Dict[str, Any] = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
) -> ChatWorkflow:
|
) -> ChatWorkflow:
|
||||||
"""Update workflow by ID"""
|
"""Update workflow by ID"""
|
||||||
try:
|
try:
|
||||||
# Get workflow interface with current user context
|
# Get workflow interface with current user context
|
||||||
workflowInterface = getInterface(currentUser)
|
workflowInterface = getServiceChat(ctx)
|
||||||
|
|
||||||
# Get workflow using interface method to check permissions
|
# Get workflow using interface method to check permissions
|
||||||
workflow = workflowInterface.getWorkflow(workflowId)
|
workflow = workflowInterface.getWorkflow(workflowId)
|
||||||
|
|
@ -203,12 +207,12 @@ def update_workflow(
|
||||||
def get_workflow_status(
|
def get_workflow_status(
|
||||||
request: Request,
|
request: Request,
|
||||||
workflowId: str = Path(..., description="ID of the workflow"),
|
workflowId: str = Path(..., description="ID of the workflow"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
) -> ChatWorkflow:
|
) -> ChatWorkflow:
|
||||||
"""Get the current status of a workflow."""
|
"""Get the current status of a workflow."""
|
||||||
try:
|
try:
|
||||||
# Get service center
|
# Get service center
|
||||||
interfaceDbChat = getServiceChat(currentUser)
|
interfaceDbChat = getServiceChat(ctx)
|
||||||
|
|
||||||
# Retrieve workflow
|
# Retrieve workflow
|
||||||
workflow = interfaceDbChat.getWorkflow(workflowId)
|
workflow = interfaceDbChat.getWorkflow(workflowId)
|
||||||
|
|
@ -235,7 +239,7 @@ def get_workflow_status(
|
||||||
async def stop_workflow(
|
async def stop_workflow(
|
||||||
request: Request,
|
request: Request,
|
||||||
workflowId: str = Path(..., description="ID of the workflow to stop"),
|
workflowId: str = Path(..., description="ID of the workflow to stop"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
) -> ChatWorkflow:
|
) -> ChatWorkflow:
|
||||||
"""
|
"""
|
||||||
Stop a running workflow.
|
Stop a running workflow.
|
||||||
|
|
@ -245,7 +249,7 @@ async def stop_workflow(
|
||||||
from modules.workflows.automation import chatStop
|
from modules.workflows.automation import chatStop
|
||||||
|
|
||||||
# Get the workflow first to get mandateId
|
# Get the workflow first to get mandateId
|
||||||
interfaceChatDb = getServiceChat(currentUser)
|
interfaceChatDb = getServiceChat(ctx)
|
||||||
workflow = interfaceChatDb.getWorkflow(workflowId)
|
workflow = interfaceChatDb.getWorkflow(workflowId)
|
||||||
|
|
||||||
if not workflow:
|
if not workflow:
|
||||||
|
|
@ -257,7 +261,7 @@ async def stop_workflow(
|
||||||
mandateId = workflow.get("mandateId") if isinstance(workflow, dict) else getattr(workflow, "mandateId", None)
|
mandateId = workflow.get("mandateId") if isinstance(workflow, dict) else getattr(workflow, "mandateId", None)
|
||||||
|
|
||||||
# Stop the workflow
|
# Stop the workflow
|
||||||
stoppedWorkflow = await chatStop(currentUser, workflowId, mandateId=mandateId)
|
stoppedWorkflow = await chatStop(ctx.user, workflowId, mandateId=mandateId)
|
||||||
|
|
||||||
return stoppedWorkflow
|
return stoppedWorkflow
|
||||||
|
|
||||||
|
|
@ -279,7 +283,7 @@ def get_workflow_logs(
|
||||||
workflowId: str = Path(..., description="ID of the workflow"),
|
workflowId: str = Path(..., description="ID of the workflow"),
|
||||||
logId: Optional[str] = Query(None, description="Optional log ID to get only newer logs (legacy selective data transfer)"),
|
logId: Optional[str] = Query(None, description="Optional log ID to get only newer logs (legacy selective data transfer)"),
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[ChatLog]:
|
) -> PaginatedResponse[ChatLog]:
|
||||||
"""
|
"""
|
||||||
Get logs for a workflow with optional pagination, sorting, and filtering.
|
Get logs for a workflow with optional pagination, sorting, and filtering.
|
||||||
|
|
@ -306,7 +310,7 @@ def get_workflow_logs(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get service center
|
# Get service center
|
||||||
interfaceDbChat = getServiceChat(currentUser)
|
interfaceDbChat = getServiceChat(ctx)
|
||||||
|
|
||||||
# Verify workflow exists
|
# Verify workflow exists
|
||||||
workflow = interfaceDbChat.getWorkflow(workflowId)
|
workflow = interfaceDbChat.getWorkflow(workflowId)
|
||||||
|
|
@ -370,7 +374,7 @@ def get_workflow_messages(
|
||||||
workflowId: str = Path(..., description="ID of the workflow"),
|
workflowId: str = Path(..., description="ID of the workflow"),
|
||||||
messageId: Optional[str] = Query(None, description="Optional message ID to get only newer messages (legacy selective data transfer)"),
|
messageId: Optional[str] = Query(None, description="Optional message ID to get only newer messages (legacy selective data transfer)"),
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
) -> PaginatedResponse[ChatMessage]:
|
) -> PaginatedResponse[ChatMessage]:
|
||||||
"""
|
"""
|
||||||
Get messages for a workflow with optional pagination, sorting, and filtering.
|
Get messages for a workflow with optional pagination, sorting, and filtering.
|
||||||
|
|
@ -397,7 +401,7 @@ def get_workflow_messages(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get service center
|
# Get service center
|
||||||
interfaceDbChat = getServiceChat(currentUser)
|
interfaceDbChat = getServiceChat(ctx)
|
||||||
|
|
||||||
# Verify workflow exists
|
# Verify workflow exists
|
||||||
workflow = interfaceDbChat.getWorkflow(workflowId)
|
workflow = interfaceDbChat.getWorkflow(workflowId)
|
||||||
|
|
@ -460,19 +464,20 @@ def get_workflow_messages(
|
||||||
def delete_workflow(
|
def delete_workflow(
|
||||||
request: Request,
|
request: Request,
|
||||||
workflowId: str = Path(..., description="ID of the workflow to delete"),
|
workflowId: str = Path(..., description="ID of the workflow to delete"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Deletes a workflow and its associated data."""
|
"""Deletes a workflow and its associated data."""
|
||||||
try:
|
try:
|
||||||
# Get service center
|
# Get service center
|
||||||
interfaceDbChat = getServiceChat(currentUser)
|
interfaceDbChat = getServiceChat(ctx)
|
||||||
|
|
||||||
# Check workflow access and permission using RBAC
|
# Check workflow access and permission using RBAC
|
||||||
workflows = getRecordsetWithRBAC(
|
workflows = getRecordsetWithRBAC(
|
||||||
interfaceDbChat.db,
|
interfaceDbChat.db,
|
||||||
ChatWorkflow,
|
ChatWorkflow,
|
||||||
currentUser,
|
ctx.user,
|
||||||
recordFilter={"id": workflowId}
|
recordFilter={"id": workflowId},
|
||||||
|
mandateId=ctx.mandateId
|
||||||
)
|
)
|
||||||
if not workflows:
|
if not workflows:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -520,12 +525,12 @@ def delete_workflow_message(
|
||||||
request: Request,
|
request: Request,
|
||||||
workflowId: str = Path(..., description="ID of the workflow"),
|
workflowId: str = Path(..., description="ID of the workflow"),
|
||||||
messageId: str = Path(..., description="ID of the message to delete"),
|
messageId: str = Path(..., description="ID of the message to delete"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Delete a message from a workflow."""
|
"""Delete a message from a workflow."""
|
||||||
try:
|
try:
|
||||||
# Get service center
|
# Get service center
|
||||||
interfaceDbChat = getServiceChat(currentUser)
|
interfaceDbChat = getServiceChat(ctx)
|
||||||
|
|
||||||
# Verify workflow exists
|
# Verify workflow exists
|
||||||
workflow = interfaceDbChat.getWorkflow(workflowId)
|
workflow = interfaceDbChat.getWorkflow(workflowId)
|
||||||
|
|
@ -571,12 +576,12 @@ def delete_file_from_message(
|
||||||
workflowId: str = Path(..., description="ID of the workflow"),
|
workflowId: str = Path(..., description="ID of the workflow"),
|
||||||
messageId: str = Path(..., description="ID of the message"),
|
messageId: str = Path(..., description="ID of the message"),
|
||||||
fileId: str = Path(..., description="ID of the file to delete"),
|
fileId: str = Path(..., description="ID of the file to delete"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
ctx: RequestContext = Depends(getRequestContext)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Delete a file reference from a message in a workflow."""
|
"""Delete a file reference from a message in a workflow."""
|
||||||
try:
|
try:
|
||||||
# Get service center
|
# Get service center
|
||||||
interfaceDbChat = getServiceChat(currentUser)
|
interfaceDbChat = getServiceChat(ctx)
|
||||||
|
|
||||||
# Verify workflow exists
|
# Verify workflow exists
|
||||||
workflow = interfaceDbChat.getWorkflow(workflowId)
|
workflow = interfaceDbChat.getWorkflow(workflowId)
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ def export_user_data(
|
||||||
"mandateName": mandateName,
|
"mandateName": mandateName,
|
||||||
"enabled": um.enabled,
|
"enabled": um.enabled,
|
||||||
"roleIds": roleIds,
|
"roleIds": roleIds,
|
||||||
"joinedAt": um.createdAt
|
"joinedAt": getattr(um, 'createdAt', None)
|
||||||
})
|
})
|
||||||
|
|
||||||
# Feature access records using interface method
|
# Feature access records using interface method
|
||||||
|
|
@ -159,11 +159,11 @@ def export_user_data(
|
||||||
invitationsCreatedList = [
|
invitationsCreatedList = [
|
||||||
{
|
{
|
||||||
"id": inv.id,
|
"id": inv.id,
|
||||||
"mandateId": inv.mandateId,
|
"mandateId": getattr(inv, 'mandateId', None),
|
||||||
"createdAt": inv.createdAt,
|
"createdAt": getattr(inv, 'createdAt', None),
|
||||||
"expiresAt": inv.expiresAt,
|
"expiresAt": getattr(inv, 'expiresAt', None),
|
||||||
"maxUses": inv.maxUses,
|
"maxUses": getattr(inv, 'maxUses', None),
|
||||||
"currentUses": inv.currentUses
|
"currentUses": getattr(inv, 'currentUses', None)
|
||||||
}
|
}
|
||||||
for inv in invitationsCreated
|
for inv in invitationsCreated
|
||||||
]
|
]
|
||||||
|
|
@ -174,8 +174,8 @@ def export_user_data(
|
||||||
invitationsUsedList = [
|
invitationsUsedList = [
|
||||||
{
|
{
|
||||||
"id": inv.id,
|
"id": inv.id,
|
||||||
"mandateId": inv.mandateId,
|
"mandateId": getattr(inv, 'mandateId', None),
|
||||||
"usedAt": inv.usedAt
|
"usedAt": getattr(inv, 'usedAt', None)
|
||||||
}
|
}
|
||||||
for inv in invitationsUsed
|
for inv in invitationsUsed
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,14 @@ router = APIRouter(
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
class InvitationCreate(BaseModel):
|
class InvitationCreate(BaseModel):
|
||||||
"""Request model for creating an invitation"""
|
"""Request model for creating an invitation.
|
||||||
|
Invitations are feature-instance-level: the user selects a feature instance and
|
||||||
|
instance-level roles. The mandateId is derived from the feature instance automatically.
|
||||||
|
"""
|
||||||
targetUsername: str = Field(..., description="Username of the user to invite (must match on acceptance)")
|
targetUsername: str = Field(..., description="Username of the user to invite (must match on acceptance)")
|
||||||
email: Optional[str] = Field(None, description="Email address to send invitation link (optional)")
|
email: Optional[str] = Field(None, description="Email address to send invitation link (optional)")
|
||||||
roleIds: List[str] = Field(..., description="Role IDs to assign to the invited user")
|
featureInstanceId: str = Field(..., description="Feature instance to grant access to")
|
||||||
featureInstanceId: Optional[str] = Field(None, description="Optional feature instance access")
|
roleIds: List[str] = Field(..., description="Instance-level role IDs to assign to the invited user")
|
||||||
frontendUrl: str = Field(..., description="Frontend URL for building the invite link (provided by frontend)")
|
frontendUrl: str = Field(..., description="Frontend URL for building the invite link (provided by frontend)")
|
||||||
expiresInHours: int = Field(
|
expiresInHours: int = Field(
|
||||||
72,
|
72,
|
||||||
|
|
@ -102,36 +105,46 @@ def create_invitation(
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> InvitationResponse:
|
) -> InvitationResponse:
|
||||||
"""
|
"""
|
||||||
Create a new invitation for the current mandate.
|
Create a new invitation for a feature instance.
|
||||||
|
|
||||||
Requires Mandate-Admin role. Creates a secure token that can be shared
|
Requires SysAdmin or Mandate-Admin role. Creates a secure token that can be shared
|
||||||
with users to join the mandate with predefined roles.
|
with users to join a feature instance with predefined roles.
|
||||||
|
The mandateId is derived from the feature instance automatically.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
data: Invitation creation data
|
data: Invitation creation data (featureInstanceId + roleIds required)
|
||||||
"""
|
"""
|
||||||
if not context.mandateId:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="X-Mandate-Id header is required"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check mandate admin permission
|
|
||||||
if not _hasMandateAdminRole(context):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Mandate-Admin role required to create invitations"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
|
|
||||||
|
# Validate feature instance exists and get mandateId from it
|
||||||
|
instance = rootInterface.getFeatureInstance(data.featureInstanceId)
|
||||||
|
if not instance:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Feature instance '{data.featureInstanceId}' not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
mandateId = str(instance.mandateId)
|
||||||
|
|
||||||
|
# Check admin permission: SysAdmin can invite for any mandate,
|
||||||
|
# MandateAdmin can invite for their own mandate
|
||||||
|
if not context.hasSysAdminRole:
|
||||||
|
if str(context.mandateId) != mandateId:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Feature instance belongs to a different mandate"
|
||||||
|
)
|
||||||
|
if not _hasMandateAdminRole(context):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Mandate-Admin role required to create invitations"
|
||||||
|
)
|
||||||
|
|
||||||
# Note: targetUsername does NOT need to exist yet!
|
# Note: targetUsername does NOT need to exist yet!
|
||||||
# The invitation can be for a user who will register later.
|
# The invitation can be for a user who will register later.
|
||||||
# When they register with this username (or accept the invitation),
|
|
||||||
# they will get the assigned roles.
|
|
||||||
|
|
||||||
# Validate role IDs exist and belong to this mandate or are global
|
# Validate role IDs exist and belong to this feature instance
|
||||||
for roleId in data.roleIds:
|
for roleId in data.roleIds:
|
||||||
role = rootInterface.getRole(roleId)
|
role = rootInterface.getRole(roleId)
|
||||||
if not role:
|
if not role:
|
||||||
|
|
@ -139,34 +152,20 @@ def create_invitation(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"Role '{roleId}' not found"
|
detail=f"Role '{roleId}' not found"
|
||||||
)
|
)
|
||||||
# Role must be global or belong to this mandate
|
# Role must belong to this feature instance
|
||||||
if role.mandateId and str(role.mandateId) != str(context.mandateId):
|
if str(role.featureInstanceId or "") != data.featureInstanceId:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Role '{roleId}' belongs to a different mandate"
|
detail=f"Role '{roleId}' does not belong to feature instance '{data.featureInstanceId}'"
|
||||||
)
|
|
||||||
|
|
||||||
# Validate feature instance if provided
|
|
||||||
if data.featureInstanceId:
|
|
||||||
instance = rootInterface.getFeatureInstance(data.featureInstanceId)
|
|
||||||
if not instance:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"Feature instance '{data.featureInstanceId}' not found"
|
|
||||||
)
|
|
||||||
if str(instance.mandateId) != str(context.mandateId):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Feature instance belongs to a different mandate"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate expiration time
|
# Calculate expiration time
|
||||||
currentTime = getUtcTimestamp()
|
currentTime = getUtcTimestamp()
|
||||||
expiresAt = currentTime + (data.expiresInHours * 3600)
|
expiresAt = currentTime + (data.expiresInHours * 3600)
|
||||||
|
|
||||||
# Create invitation
|
# Create invitation (mandateId derived from feature instance)
|
||||||
invitation = Invitation(
|
invitation = Invitation(
|
||||||
mandateId=str(context.mandateId),
|
mandateId=mandateId,
|
||||||
featureInstanceId=data.featureInstanceId,
|
featureInstanceId=data.featureInstanceId,
|
||||||
roleIds=data.roleIds,
|
roleIds=data.roleIds,
|
||||||
targetUsername=data.targetUsername,
|
targetUsername=data.targetUsername,
|
||||||
|
|
@ -628,42 +627,50 @@ def accept_invitation(
|
||||||
roleIds = invitation.roleIds or []
|
roleIds = invitation.roleIds or []
|
||||||
featureInstanceId = str(invitation.featureInstanceId) if invitation.featureInstanceId else None
|
featureInstanceId = str(invitation.featureInstanceId) if invitation.featureInstanceId else None
|
||||||
|
|
||||||
# Check if user is already a member
|
# Grant feature access (creates FeatureAccess + auto-assigns mandate 'user' role via Regel 4)
|
||||||
existingMembership = rootInterface.getUserMandate(str(currentUser.id), mandateId)
|
|
||||||
|
|
||||||
if existingMembership:
|
|
||||||
# Update existing membership with additional roles
|
|
||||||
for roleId in roleIds:
|
|
||||||
try:
|
|
||||||
rootInterface.addRoleToUserMandate(str(existingMembership.id), roleId)
|
|
||||||
except Exception:
|
|
||||||
pass # Role might already be assigned
|
|
||||||
|
|
||||||
userMandateId = str(existingMembership.id)
|
|
||||||
message = "Roles updated for existing membership"
|
|
||||||
else:
|
|
||||||
# Create new membership
|
|
||||||
userMandate = rootInterface.createUserMandate(
|
|
||||||
userId=str(currentUser.id),
|
|
||||||
mandateId=mandateId,
|
|
||||||
roleIds=roleIds
|
|
||||||
)
|
|
||||||
userMandateId = str(userMandate.id)
|
|
||||||
message = "Successfully joined mandate"
|
|
||||||
|
|
||||||
# Grant feature access if specified
|
|
||||||
featureAccessId = None
|
featureAccessId = None
|
||||||
if featureInstanceId:
|
if featureInstanceId:
|
||||||
existingAccess = rootInterface.getFeatureAccess(str(currentUser.id), featureInstanceId)
|
existingAccess = rootInterface.getFeatureAccess(str(currentUser.id), featureInstanceId)
|
||||||
if not existingAccess:
|
if existingAccess:
|
||||||
# Create feature access with instance-level roles if any
|
# Update existing access with additional roles
|
||||||
instanceRoleIds = [r for r in roleIds if _isInstanceRole(rootInterface, r, featureInstanceId)]
|
featureAccessId = str(existingAccess.id)
|
||||||
|
for roleId in roleIds:
|
||||||
|
try:
|
||||||
|
rootInterface.addRoleToFeatureAccess(str(existingAccess.id), roleId)
|
||||||
|
except Exception:
|
||||||
|
pass # Role might already be assigned
|
||||||
|
message = "Roles updated for existing feature access"
|
||||||
|
else:
|
||||||
|
# Create feature access with instance-level roles
|
||||||
|
# This auto-creates UserMandate with 'user' role (Regel 4)
|
||||||
featureAccess = rootInterface.createFeatureAccess(
|
featureAccess = rootInterface.createFeatureAccess(
|
||||||
userId=str(currentUser.id),
|
userId=str(currentUser.id),
|
||||||
featureInstanceId=featureInstanceId,
|
featureInstanceId=featureInstanceId,
|
||||||
roleIds=instanceRoleIds
|
roleIds=roleIds
|
||||||
)
|
)
|
||||||
featureAccessId = str(featureAccess.id)
|
featureAccessId = str(featureAccess.id)
|
||||||
|
message = "Successfully joined feature instance"
|
||||||
|
else:
|
||||||
|
# Legacy: mandate-only invitation (no feature instance)
|
||||||
|
existingMembership = rootInterface.getUserMandate(str(currentUser.id), mandateId)
|
||||||
|
if existingMembership:
|
||||||
|
for roleId in roleIds:
|
||||||
|
try:
|
||||||
|
rootInterface.addRoleToUserMandate(str(existingMembership.id), roleId)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
message = "Roles updated for existing membership"
|
||||||
|
else:
|
||||||
|
rootInterface.createUserMandate(
|
||||||
|
userId=str(currentUser.id),
|
||||||
|
mandateId=mandateId,
|
||||||
|
roleIds=roleIds
|
||||||
|
)
|
||||||
|
message = "Successfully joined mandate"
|
||||||
|
|
||||||
|
# Get userMandateId for response
|
||||||
|
userMandate = rootInterface.getUserMandate(str(currentUser.id), mandateId)
|
||||||
|
userMandateId = str(userMandate.id) if userMandate else None
|
||||||
|
|
||||||
# Update invitation usage
|
# Update invitation usage
|
||||||
rootInterface.db.recordModify(
|
rootInterface.db.recordModify(
|
||||||
|
|
@ -707,7 +714,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if the user has mandate admin role in the current context.
|
Check if the user has mandate admin role in the current context.
|
||||||
"""
|
"""
|
||||||
if context.isSysAdmin:
|
if context.hasSysAdminRole:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if not context.roleIds:
|
if not context.roleIds:
|
||||||
|
|
@ -720,7 +727,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
|
||||||
role = rootInterface.getRole(roleId)
|
role = rootInterface.getRole(roleId)
|
||||||
if role:
|
if role:
|
||||||
# Admin role at mandate level (not feature-instance level)
|
# Admin role at mandate level (not feature-instance level)
|
||||||
if role.roleLabel == "admin" and role.mandateId and not role.featureInstanceId:
|
if role.roleLabel == "admin" and not role.featureInstanceId:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -410,7 +410,7 @@ def _hasTriggerPermission(context: RequestContext) -> bool:
|
||||||
Check if user has permission to trigger subscriptions.
|
Check if user has permission to trigger subscriptions.
|
||||||
Requires admin or mandate-admin role.
|
Requires admin or mandate-admin role.
|
||||||
"""
|
"""
|
||||||
if context.isSysAdmin:
|
if context.hasSysAdminRole:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if not context.roleIds:
|
if not context.roleIds:
|
||||||
|
|
|
||||||
|
|
@ -331,7 +331,7 @@ def executeAction(
|
||||||
# Execute action based on notification type
|
# Execute action based on notification type
|
||||||
actionResult = None
|
actionResult = None
|
||||||
|
|
||||||
if notification.get("type") == NotificationType.INVITATION.value:
|
if notification.type == NotificationType.INVITATION.value:
|
||||||
actionResult = _handleInvitationAction(
|
actionResult = _handleInvitationAction(
|
||||||
notification=notification,
|
notification=notification,
|
||||||
actionId=actionRequest.actionId,
|
actionId=actionRequest.actionId,
|
||||||
|
|
@ -381,7 +381,7 @@ def _handleInvitationAction(
|
||||||
from modules.datamodels.datamodelUam import Mandate
|
from modules.datamodels.datamodelUam import Mandate
|
||||||
from modules.datamodels.datamodelMembership import UserMandate
|
from modules.datamodels.datamodelMembership import UserMandate
|
||||||
|
|
||||||
invitationId = notification.get("referenceId")
|
invitationId = notification.referenceId
|
||||||
if not invitationId:
|
if not invitationId:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ async def _validateInstanceAccess(instanceId: str, context: RequestContext) -> s
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Instance '{instanceId}' is not a realestate instance"
|
detail=f"Instance '{instanceId}' is not a realestate instance"
|
||||||
)
|
)
|
||||||
if not context.isSysAdmin:
|
if not context.hasSysAdminRole:
|
||||||
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
||||||
hasAccess = any(
|
hasAccess = any(
|
||||||
str(fa.featureInstanceId) == instanceId and fa.enabled
|
str(fa.featureInstanceId) == instanceId and fa.enabled
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ def _checkUiPermission(roleIds: List[str], objectKey: str) -> bool:
|
||||||
if rule.item == objectKey:
|
if rule.item == objectKey:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Wildcard match (e.g., ui.system.* matches ui.system.playground)
|
# Wildcard match (e.g., ui.admin.* matches ui.admin.mandates)
|
||||||
if rule.item.endswith(".*"):
|
if rule.item.endswith(".*"):
|
||||||
prefix = rule.item[:-2]
|
prefix = rule.item[:-2]
|
||||||
if objectKey.startswith(prefix):
|
if objectKey.startswith(prefix):
|
||||||
|
|
@ -347,28 +347,21 @@ def _buildStaticBlocks(
|
||||||
blocks = []
|
blocks = []
|
||||||
|
|
||||||
for section in NAVIGATION_SECTIONS:
|
for section in NAVIGATION_SECTIONS:
|
||||||
# Skip admin-only sections for non-admins
|
# Filter items based on UI AccessRules (ui.admin.*, ui.billing.*, etc.)
|
||||||
if section.get("adminOnly") and not isSysAdmin:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Filter items based on permissions
|
|
||||||
filteredItems = []
|
filteredItems = []
|
||||||
for item in section.get("items", []):
|
for item in section.get("items", []):
|
||||||
# Skip admin-only items for non-admins
|
|
||||||
if item.get("adminOnly") and not isSysAdmin:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Public items are always visible
|
# Public items are always visible
|
||||||
if item.get("public"):
|
if item.get("public"):
|
||||||
filteredItems.append(_formatBlockItem(item, language))
|
filteredItems.append(_formatBlockItem(item, language))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# SysAdmin sees everything
|
# SysAdmin-only items (e.g. automation-events) require isSysAdmin
|
||||||
if isSysAdmin:
|
if item.get("sysAdminOnly") and not isSysAdmin:
|
||||||
filteredItems.append(_formatBlockItem(item, language))
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check permission for this item
|
# Check UI AccessRule for this objectKey
|
||||||
|
# Roles with item=None rule (e.g. sysadmin) get access to everything
|
||||||
|
# Roles with specific ui.admin.* rules get access to those items
|
||||||
if hasGlobalPermission or _checkUiPermission(roleIds, item["objectKey"]):
|
if hasGlobalPermission or _checkUiPermission(roleIds, item["objectKey"]):
|
||||||
filteredItems.append(_formatBlockItem(item, language))
|
filteredItems.append(_formatBlockItem(item, language))
|
||||||
|
|
||||||
|
|
@ -459,7 +452,7 @@ def get_navigation(
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
isSysAdmin = reqContext.isSysAdmin
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
userId = str(reqContext.user.id) if reqContext.user else None
|
userId = str(reqContext.user.id) if reqContext.user else None
|
||||||
|
|
||||||
# Get user's role IDs for permission checking
|
# Get user's role IDs for permission checking
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,23 @@ class RbacClass:
|
||||||
delete=AccessLevel.NONE
|
delete=AccessLevel.NONE
|
||||||
)
|
)
|
||||||
|
|
||||||
# SysAdmin hat vollen Zugriff - unabhängig vom Kontext (Mandant/Feature)
|
# Category A: isSysAdmin FLAG bypass (safety net for system operations)
|
||||||
|
# NOTE: sysadmin ROLE users get full access via AccessRules (DATA: ALL)
|
||||||
|
# This flag bypass is kept as fallback for true system-level operations
|
||||||
if hasattr(user, 'isSysAdmin') and user.isSysAdmin:
|
if hasattr(user, 'isSysAdmin') and user.isSysAdmin:
|
||||||
|
# User-owned namespaces: SysAdmin gets MY access only (own data).
|
||||||
|
# Every user -- including SysAdmin -- only has CRUD for their own
|
||||||
|
# chat workflows and files. Automation is excluded because it's
|
||||||
|
# managed by admins and the system event user needs ALL access.
|
||||||
|
_USER_OWNED_PREFIXES = ("data.chat.", "data.files.")
|
||||||
|
if item and any(item.startswith(p) for p in _USER_OWNED_PREFIXES):
|
||||||
|
return UserPermissions(
|
||||||
|
view=True,
|
||||||
|
read=AccessLevel.MY,
|
||||||
|
create=AccessLevel.MY,
|
||||||
|
update=AccessLevel.MY,
|
||||||
|
delete=AccessLevel.MY
|
||||||
|
)
|
||||||
return UserPermissions(
|
return UserPermissions(
|
||||||
view=True,
|
view=True,
|
||||||
read=AccessLevel.ALL,
|
read=AccessLevel.ALL,
|
||||||
|
|
@ -178,8 +193,8 @@ class RbacClass:
|
||||||
roleIds = set() # Use set to avoid duplicates
|
roleIds = set() # Use set to avoid duplicates
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Load roles from the requested mandate
|
|
||||||
if mandateId:
|
if mandateId:
|
||||||
|
# Specific mandate context: load roles from that mandate only
|
||||||
userMandateRecords = self.dbApp.getRecordset(
|
userMandateRecords = self.dbApp.getRecordset(
|
||||||
UserMandate,
|
UserMandate,
|
||||||
recordFilter={"userId": user.id, "mandateId": mandateId, "enabled": True}
|
recordFilter={"userId": user.id, "mandateId": mandateId, "enabled": True}
|
||||||
|
|
@ -196,9 +211,27 @@ class RbacClass:
|
||||||
|
|
||||||
foundRoles = [r["roleId"] for r in userMandateRoleRecords if r.get("roleId")]
|
foundRoles = [r["roleId"] for r in userMandateRoleRecords if r.get("roleId")]
|
||||||
roleIds.update(foundRoles)
|
roleIds.update(foundRoles)
|
||||||
|
else:
|
||||||
|
# No mandate context: load roles from ALL user's mandates.
|
||||||
|
# Required for user-owned namespaces (files, chat, automation) that
|
||||||
|
# are accessed without mandate context (e.g., /api/files/ endpoints).
|
||||||
|
# Data isolation is still enforced by _createdBy WHERE clause.
|
||||||
|
allUserMandates = self.dbApp.getRecordset(
|
||||||
|
UserMandate,
|
||||||
|
recordFilter={"userId": user.id, "enabled": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
for um in allUserMandates:
|
||||||
|
userMandateId = um["id"]
|
||||||
|
userMandateRoleRecords = self.dbApp.getRecordset(
|
||||||
|
UserMandateRole,
|
||||||
|
recordFilter={"userMandateId": userMandateId}
|
||||||
|
)
|
||||||
|
roleIds.update([r["roleId"] for r in userMandateRoleRecords if r.get("roleId")])
|
||||||
|
|
||||||
# Load FeatureAccess + FeatureAccessRole (Instance-level roles)
|
# Load FeatureAccess + FeatureAccessRole (Instance-level roles)
|
||||||
if featureInstanceId:
|
if featureInstanceId:
|
||||||
|
# Specific feature instance: load roles from that instance only
|
||||||
featureAccessRecords = self.dbApp.getRecordset(
|
featureAccessRecords = self.dbApp.getRecordset(
|
||||||
FeatureAccess,
|
FeatureAccess,
|
||||||
recordFilter={
|
recordFilter={
|
||||||
|
|
@ -217,6 +250,21 @@ class RbacClass:
|
||||||
)
|
)
|
||||||
|
|
||||||
roleIds.update([r["roleId"] for r in featureAccessRoleRecords if r.get("roleId")])
|
roleIds.update([r["roleId"] for r in featureAccessRoleRecords if r.get("roleId")])
|
||||||
|
elif not mandateId:
|
||||||
|
# No context at all: also load feature-instance roles from ALL user's accesses.
|
||||||
|
# Same rationale: user-owned data needs roles for permission resolution.
|
||||||
|
allFeatureAccess = self.dbApp.getRecordset(
|
||||||
|
FeatureAccess,
|
||||||
|
recordFilter={"userId": user.id, "enabled": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
for fa in allFeatureAccess:
|
||||||
|
featureAccessId = fa["id"]
|
||||||
|
featureAccessRoleRecords = self.dbApp.getRecordset(
|
||||||
|
FeatureAccessRole,
|
||||||
|
recordFilter={"featureAccessId": featureAccessId}
|
||||||
|
)
|
||||||
|
roleIds.update([r["roleId"] for r in featureAccessRoleRecords if r.get("roleId")])
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error loading role IDs for user {user.id}: {e}")
|
logger.error(f"Error loading role IDs for user {user.id}: {e}")
|
||||||
|
|
|
||||||
|
|
@ -64,9 +64,7 @@ class ExtractionService:
|
||||||
|
|
||||||
results: List[ContentExtracted] = []
|
results: List[ContentExtracted] = []
|
||||||
|
|
||||||
# Lazy import to avoid circular deps and heavy init at module import
|
dbInterface = self.services.interfaceDbComponent
|
||||||
from modules.interfaces.interfaceDbManagement import getInterface
|
|
||||||
dbInterface = getInterface()
|
|
||||||
|
|
||||||
totalDocs = len(documents)
|
totalDocs = len(documents)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,14 +155,14 @@ class UtilsService:
|
||||||
# Silent fail to never break main flow
|
# Silent fail to never break main flow
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def storeDebugMessageAndDocuments(self, message, currentUser):
|
def storeDebugMessageAndDocuments(self, message, currentUser, mandateId=None, featureInstanceId=None):
|
||||||
"""
|
"""
|
||||||
Wrapper to store debug messages and documents via interfaceDbChat.
|
Wrapper to store debug messages and documents via interfaceDbChat.
|
||||||
Mirrors storeDebugMessageAndDocuments() in modules.interfaces.interfaceDbChat.
|
Mirrors storeDebugMessageAndDocuments() in modules.interfaces.interfaceDbChat.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbChat import storeDebugMessageAndDocuments as _storeDebugMessageAndDocuments
|
from modules.interfaces.interfaceDbChat import storeDebugMessageAndDocuments as _storeDebugMessageAndDocuments
|
||||||
_storeDebugMessageAndDocuments(message, currentUser)
|
_storeDebugMessageAndDocuments(message, currentUser, mandateId=mandateId, featureInstanceId=featureInstanceId)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Silent fail to never break main flow
|
# Silent fail to never break main flow
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -196,11 +196,12 @@ NAVIGATION_SECTIONS = [
|
||||||
{
|
{
|
||||||
"id": "admin-feature-roles",
|
"id": "admin-feature-roles",
|
||||||
"objectKey": "ui.admin.featureRoles",
|
"objectKey": "ui.admin.featureRoles",
|
||||||
"label": {"en": "Feature Roles & Permissions", "de": "Features Rollen & Rechte", "fr": "Rôles et droits des features"},
|
"label": {"en": "Feature Role Templates", "de": "Features Rollen-Vorlagen", "fr": "Modèles de rôles features"},
|
||||||
"icon": "FaShieldAlt",
|
"icon": "FaShieldAlt",
|
||||||
"path": "/admin/feature-roles",
|
"path": "/admin/feature-roles",
|
||||||
"order": 50,
|
"order": 50,
|
||||||
"adminOnly": True,
|
"adminOnly": True,
|
||||||
|
"sysAdminOnly": True,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "admin-billing",
|
"id": "admin-billing",
|
||||||
|
|
@ -210,6 +211,17 @@ NAVIGATION_SECTIONS = [
|
||||||
"path": "/admin/billing",
|
"path": "/admin/billing",
|
||||||
"order": 60,
|
"order": 60,
|
||||||
"adminOnly": True,
|
"adminOnly": True,
|
||||||
|
"sysAdminOnly": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "admin-automation-events",
|
||||||
|
"objectKey": "ui.admin.automationEvents",
|
||||||
|
"label": {"en": "Automation Events", "de": "Automation Events", "fr": "Événements d'automatisation"},
|
||||||
|
"icon": "FaClock",
|
||||||
|
"path": "/admin/automation-events",
|
||||||
|
"order": 65,
|
||||||
|
"adminOnly": True,
|
||||||
|
"sysAdminOnly": True,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -158,10 +158,12 @@ async def executeAutomation(automationId: str, automation, creatorUser: User, se
|
||||||
executionLog["messages"].append(f"Workflow {workflow.id} started successfully")
|
executionLog["messages"].append(f"Workflow {workflow.id} started successfully")
|
||||||
logger.info(f"Started workflow {workflow.id} with plan containing {len(plan.get('tasks', []))} tasks (plan embedded in userInput)")
|
logger.info(f"Started workflow {workflow.id} with plan containing {len(plan.get('tasks', []))} tasks (plan embedded in userInput)")
|
||||||
|
|
||||||
# Set workflow name with "automated" prefix
|
# Set workflow name with "automated" prefix — use creatorUser's Services
|
||||||
|
# (services parameter is eventServices with eventUser context, must use creatorUser context)
|
||||||
|
creatorServices = getServices(creatorUser, mandateId=automationMandateId, featureInstanceId=automationFeatureInstanceId)
|
||||||
automationLabel = automation.label or "Unknown Automation"
|
automationLabel = automation.label or "Unknown Automation"
|
||||||
workflowName = f"automated: {automationLabel}"
|
workflowName = f"automated: {automationLabel}"
|
||||||
services.interfaceDbChat.updateWorkflow(workflow.id, {"name": workflowName})
|
creatorServices.interfaceDbChat.updateWorkflow(workflow.id, {"name": workflowName})
|
||||||
logger.info(f"Set workflow {workflow.id} name to: {workflowName}")
|
logger.info(f"Set workflow {workflow.id} name to: {workflowName}")
|
||||||
|
|
||||||
# Save execution log (bypasses RBAC — system operation, not a user edit)
|
# Save execution log (bypasses RBAC — system operation, not a user edit)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue