# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ User routes for the backend API. Implements the endpoints for user management. MULTI-TENANT: User management requires RequestContext. - mandateId from X-Mandate-Id header determines which users are visible - SysAdmin can see all users across mandates """ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query from typing import List, Dict, Any, Optional from fastapi import status import logging import json # Import interfaces and models import modules.interfaces.interfaceDbAppObjects as interfaceDbAppObjects from modules.auth import limiter, getRequestContext, RequestContext # Import the attribute definition and helper functions from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict # Configure logger logger = logging.getLogger(__name__) router = APIRouter( prefix="/api/users", tags=["Manage Users"], responses={404: {"description": "Not found"}} ) @router.get("/", response_model=PaginatedResponse[User]) @limiter.limit("30/minute") async def get_users( request: Request, pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), context: RequestContext = Depends(getRequestContext) ) -> PaginatedResponse[User]: """ Get users with optional pagination, sorting, and filtering. MULTI-TENANT: mandateId from X-Mandate-Id header determines scope. SysAdmin without mandateId sees all users. Query Parameters: - pagination: JSON-encoded PaginationParams object, or None for no pagination Examples: - GET /api/users/ (no pagination - returns all users in mandate) - GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]} """ try: # Parse pagination parameter paginationParams = None if pagination: try: paginationDict = json.loads(pagination) if paginationDict: paginationDict = normalize_pagination_dict(paginationDict) paginationParams = PaginationParams(**paginationDict) except (json.JSONDecodeError, ValueError) as e: raise HTTPException( status_code=400, detail=f"Invalid pagination parameter: {str(e)}" ) appInterface = interfaceDbAppObjects.getInterface(context.user) # MULTI-TENANT: Use mandateId from context (header) # SysAdmin without mandateId can see all users if context.mandateId: # Get users for specific mandate using getUsersByMandate result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams) # getUsersByMandate returns PaginatedResult if pagination was provided if paginationParams and hasattr(result, 'items'): return PaginatedResponse( items=result.items, pagination=PaginationMetadata( currentPage=result.currentPage, pageSize=result.pageSize, totalItems=result.totalItems, totalPages=result.totalPages, sort=paginationParams.sort, filters=paginationParams.filters ) ) else: # No pagination - result is a list users = result if isinstance(result, list) else result.items if hasattr(result, 'items') else [] return PaginatedResponse( items=users, pagination=None ) elif context.isSysAdmin: # SysAdmin without mandateId sees all users # Get all users directly from database using UserInDB (the actual database model) from modules.datamodels.datamodelUam import UserInDB allUsers = appInterface.db.getRecordset(UserInDB) # Convert to User objects, filtering out password hash and database-specific fields users = [] for u in allUsers: cleanedUser = {k: v for k, v in u.items() if not k.startswith("_") and k != "hashedPassword" and k != "resetToken" and k != "resetTokenExpires"} # Ensure roleLabels is always a list if cleanedUser.get("roleLabels") is None: cleanedUser["roleLabels"] = [] users.append(User(**cleanedUser)) if paginationParams: totalItems = len(users) import math 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 ) else: # Non-SysAdmin without mandateId - should not happen (getRequestContext enforces) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="X-Mandate-Id header is required" ) except HTTPException: raise except Exception as e: logger.error(f"Error getting users: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get users: {str(e)}" ) @router.get("/{userId}", response_model=User) @limiter.limit("30/minute") async def get_user( request: Request, userId: str = Path(..., description="ID of the user"), context: RequestContext = Depends(getRequestContext) ) -> User: """ Get a specific user by ID. MULTI-TENANT: User must be in the same mandate (via UserMandate) or caller is SysAdmin. """ try: appInterface = interfaceDbAppObjects.getInterface(context.user) # Get user without filtering by enabled status user = appInterface.getUser(userId) if not user: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"User with ID {userId} not found" ) # MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin) if context.mandateId and not context.isSysAdmin: from modules.datamodels.datamodelMembership import UserMandate userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={ "userId": userId, "mandateId": str(context.mandateId) }) if not userMandate: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="User not in your mandate" ) return user except HTTPException: raise except Exception as e: logger.error(f"Error getting user {userId}: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to get user: {str(e)}" ) @router.post("", response_model=User) @limiter.limit("10/minute") async def create_user( request: Request, user_data: User = Body(...), password: Optional[str] = Body(None, embed=True), context: RequestContext = Depends(getRequestContext) ) -> User: """ Create a new user. MULTI-TENANT: User is created and automatically added to the current mandate. """ appInterface = interfaceDbAppObjects.getInterface(context.user) # Extract fields from User model and call createUser with individual parameters from modules.datamodels.datamodelUam import AuthAuthority newUser = appInterface.createUser( username=user_data.username, password=password, email=user_data.email, fullName=user_data.fullName, language=user_data.language, enabled=user_data.enabled, authenticationAuthority=user_data.authenticationAuthority ) # MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role if context.mandateId: # Get "user" role ID userRole = appInterface.getRoleByLabel("user") roleIds = [str(userRole.id)] if userRole else [] appInterface.createUserMandate( userId=str(newUser.id), mandateId=str(context.mandateId), roleIds=roleIds ) logger.info(f"Created UserMandate for user {newUser.id} in mandate {context.mandateId}") return newUser @router.put("/{userId}", response_model=User) @limiter.limit("10/minute") async def update_user( request: Request, userId: str = Path(..., description="ID of the user to update"), userData: User = Body(...), context: RequestContext = Depends(getRequestContext) ) -> User: """ Update an existing user. MULTI-TENANT: Can only update users in the same mandate (unless SysAdmin). """ appInterface = interfaceDbAppObjects.getInterface(context.user) # Check if the user exists existingUser = appInterface.getUser(userId) if not existingUser: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"User with ID {userId} not found" ) # MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin) if context.mandateId and not context.isSysAdmin: from modules.datamodels.datamodelMembership import UserMandate userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={ "userId": userId, "mandateId": str(context.mandateId) }) if not userMandate: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot update user outside your mandate" ) # Update user updatedUser = appInterface.updateUser(userId, userData) if not updatedUser: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Error updating the user" ) return updatedUser @router.post("/{userId}/reset-password") @limiter.limit("5/minute") async def reset_user_password( request: Request, userId: str = Path(..., description="ID of the user to reset password for"), newPassword: str = Body(..., embed=True), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Reset user password (Admin only). MULTI-TENANT: Can only reset passwords for users in the same mandate (unless SysAdmin). """ try: # Check if current user is admin if not context.isSysAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only administrators can reset passwords" ) # Get user interface appInterface = interfaceDbAppObjects.getInterface(context.user) # Get target user target_user = appInterface.getUserById(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: from modules.datamodels.datamodelMembership import UserMandate userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={ "userId": userId, "mandateId": str(context.mandateId) }) if not userMandate: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot reset password for user outside your mandate" ) # Validate password strength if len(newPassword) < 8: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters long" ) # Reset password success = appInterface.resetUserPassword(userId, newPassword) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to reset password" ) # SECURITY: Automatically revoke all tokens for the user after password reset try: from modules.datamodels.datamodelUam import AuthAuthority revoked_count = appInterface.revokeTokensByUser( userId=userId, authority=None, # Revoke all authorities mandateId=None, # Revoke across all mandates revokedBy=context.user.id, reason="password_reset" ) logger.info(f"Revoked {revoked_count} tokens for user {userId} after password reset") except Exception as e: logger.error(f"Failed to revoke tokens after password reset for user {userId}: {str(e)}") # Don't fail the password reset if token revocation fails # Log password reset try: from modules.shared.auditLogger import audit_logger audit_logger.logSecurityEvent( userId=str(context.user.id), mandateId=str(context.mandateId) if context.mandateId else "system", action="password_reset", details=f"Reset password for user {userId}", ipAddress=request.client.host if request.client else None, success=True ) except Exception: pass return { "message": "Password reset successfully", "user_id": userId } except HTTPException: raise except Exception as e: logger.error(f"Error resetting password: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Password reset failed: {str(e)}" ) @router.post("/change-password") @limiter.limit("5/minute") async def change_password( request: Request, currentPassword: str = Body(..., embed=True), newPassword: str = Body(..., embed=True), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Change current user's password. MULTI-TENANT: User changes their own password (no mandate restriction). """ try: # Get user interface appInterface = interfaceDbAppObjects.getInterface(context.user) # Verify current password if not appInterface.verifyPassword(currentPassword, context.user.passwordHash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect" ) # Validate new password strength if len(newPassword) < 8: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="New password must be at least 8 characters long" ) # Change password success = appInterface.resetUserPassword(str(context.user.id), newPassword) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to change password" ) # SECURITY: Automatically revoke all tokens for the user after password change try: from modules.datamodels.datamodelUam import AuthAuthority revoked_count = appInterface.revokeTokensByUser( userId=str(context.user.id), authority=None, # Revoke all authorities mandateId=None, # Revoke across all mandates revokedBy=context.user.id, reason="password_change" ) logger.info(f"Revoked {revoked_count} tokens for user {context.user.id} after password change") except Exception as e: logger.error(f"Failed to revoke tokens after password change for user {context.user.id}: {str(e)}") # Don't fail the password change if token revocation fails # Log password change try: from modules.shared.auditLogger import audit_logger audit_logger.logSecurityEvent( userId=str(context.user.id), mandateId=str(context.mandateId) if context.mandateId else "system", action="password_change", details="User changed their own password", ipAddress=request.client.host if request.client else None, success=True ) except Exception: pass return { "message": "Password changed successfully. Please log in again with your new password." } except HTTPException: raise except Exception as e: logger.error(f"Error changing password: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Password change failed: {str(e)}" ) @router.post("/{userId}/send-password-link") @limiter.limit("10/minute") async def sendPasswordLink( request: Request, userId: str = Path(..., description="ID of the user to send password setup link"), frontendUrl: str = Body(..., embed=True), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Send password setup/reset link to a user (admin function). MULTI-TENANT: Can only send to users in the same mandate (unless SysAdmin). 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. Args: userId: ID of the user to send the link to frontendUrl: The frontend URL to use in the magic link """ try: from modules.shared.configuration import APP_CONFIG from modules.interfaces.interfaceDbAppObjects import getRootInterface # Get user interface appInterface = interfaceDbAppObjects.getInterface(context.user) # Get target user targetUser = appInterface.getUser(userId) if not targetUser: 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: from modules.datamodels.datamodelMembership import UserMandate userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={ "userId": userId, "mandateId": str(context.mandateId) }) if not userMandate: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot send password link to user outside your mandate" ) # Check if user has an email if not targetUser.email: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="User has no email address configured" ) # Use root interface for token operations rootInterface = getRootInterface() # Generate reset token token, expires = rootInterface.generateResetTokenAndExpiry() # Set reset token (don't clear password - user might have one already) rootInterface.setResetToken(userId, token, expires, clearPassword=False) # Send email with magic link baseUrl = frontendUrl.rstrip("/") magicLink = f"{baseUrl}/reset?token={token}" expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24")) try: from modules.services import Services services = Services(targetUser) emailSubject = "PowerOn - Passwort setzen" emailBody = f""" Hallo {targetUser.fullName or targetUser.username}, Ein Administrator hat einen Link zum Setzen Ihres Passworts angefordert. Ihr Benutzername: {targetUser.username} Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen: {magicLink} Dieser Link ist {expiryHours} Stunden gültig. Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren Administrator. """ emailSent = services.messaging.sendEmailDirect( recipient=targetUser.email, subject=emailSubject, message=emailBody, userId=str(targetUser.id) ) if not emailSent: logger.warning(f"Failed to send password setup email to {targetUser.email}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to send email" ) except HTTPException: raise except Exception as emailErr: logger.error(f"Error sending password setup email: {str(emailErr)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to send email: {str(emailErr)}" ) # Log the action try: from modules.shared.auditLogger import audit_logger audit_logger.logSecurityEvent( userId=str(context.user.id), mandateId=str(context.mandateId) if context.mandateId else "system", action="send_password_link", details=f"Sent password setup link to user {userId} ({targetUser.email})" ) except Exception: pass logger.info(f"Password setup link sent to {targetUser.email} for user {targetUser.username} by admin {context.user.username}") return { "message": f"Password setup link sent to {targetUser.email}", "userId": userId, "email": targetUser.email } except HTTPException: raise except Exception as e: logger.error(f"Error sending password link: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to send password link: {str(e)}" ) @router.delete("/{userId}", response_model=Dict[str, Any]) @limiter.limit("10/minute") async def delete_user( request: Request, userId: str = Path(..., description="ID of the user to delete"), context: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Delete a user. MULTI-TENANT: Can only delete users in the same mandate (unless SysAdmin). """ appInterface = interfaceDbAppObjects.getInterface(context.user) # Check if the user exists existingUser = appInterface.getUser(userId) if not existingUser: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"User with ID {userId} not found" ) # MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin) if context.mandateId and not context.isSysAdmin: from modules.datamodels.datamodelMembership import UserMandate userMandate = appInterface.db.getRecordset(UserMandate, recordFilter={ "userId": userId, "mandateId": str(context.mandateId) }) if not userMandate: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete user outside your mandate" ) # Delete UserMandate entries for this user first from modules.datamodels.datamodelMembership import UserMandate userMandates = appInterface.db.getRecordset(UserMandate, recordFilter={"userId": userId}) for um in userMandates: appInterface.db.deleteRecord(UserMandate, um["id"]) success = appInterface.deleteUser(userId) if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Error deleting the user" ) return {"message": f"User with ID {userId} successfully deleted"}