""" Microsoft interface for handling Microsoft authentication and Graph API operations. """ import logging import json import requests import base64 import msal from typing import Dict, Any, Optional, List, Tuple from datetime import datetime, timedelta import secrets import os from modules.shared.configuration import APP_CONFIG from .msftModel import MsftToken, MsftUserInfo, MsftConfig from modules.connectors.connectorDbJson import DatabaseConnector from .msftAccess import MsftAccess from modules.interfaces.gatewayInterface import getRootUser logger = logging.getLogger(__name__) # Singleton factory for MsftInterface instances per context _msftInterfaces = {} # Root interface instance _rootMsftInterface = None class MsftInterface: """Interface for Microsoft authentication and Graph API operations""" def __init__(self, currentUser: Dict[str, Any] = None): """Initialize the Microsoft interface""" # Initialize variables self.currentUser = currentUser self.mandateId = currentUser.get("mandateId") if currentUser else None self.userId = currentUser.get("id") if currentUser else None self.access = None # Will be set when user context is provided # Initialize configuration self.clientId = APP_CONFIG.get("Service_MSFT_CLIENT_ID") self.clientSecret = APP_CONFIG.get("Service_MSFT_CLIENT_SECRET") self.tenantId = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common") self.redirectUri = APP_CONFIG.get("Service_MSFT_REDIRECT_URI") self.authority = f"https://login.microsoftonline.com/{self.tenantId}" self.scopes = ["Mail.ReadWrite", "User.Read"] # Initialize database self._initializeDatabase() # Initialize MSAL application self.msal_app = msal.ConfidentialClientApplication( self.clientId, authority=self.authority, client_credential=self.clientSecret ) # Set user context if provided if currentUser: self.setUserContext(currentUser) def _initializeDatabase(self): """Initializes the database connection.""" try: # Get configuration values with defaults dbHost = APP_CONFIG.get("DB_MSFT_HOST", "data") dbDatabase = APP_CONFIG.get("DB_MSFT_DATABASE", "msft") dbUser = APP_CONFIG.get("DB_MSFT_USER") dbPassword = APP_CONFIG.get("DB_MSFT_PASSWORD_SECRET") # Ensure the database directory exists os.makedirs(dbHost, exist_ok=True) self.db = DatabaseConnector( dbHost=dbHost, dbDatabase=dbDatabase, dbUser=dbUser, dbPassword=dbPassword, mandateId=self.mandateId, userId=self.userId ) # Set context self.db.updateContext(self.mandateId, self.userId) logger.info("Database initialized successfully") except Exception as e: logger.error(f"Failed to initialize database: {str(e)}") raise def _uam(self, table: str, recordset: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """ Unified user access management function that filters data based on user privileges and adds access control attributes. Args: table: Name of the table recordset: Recordset to filter based on access rules Returns: Filtered recordset with access control attributes """ return self.access.uam(table, recordset) def _canModify(self, table: str, recordId: Optional[str] = None) -> bool: """ Checks if the current user can modify (create/update/delete) records in a table. Args: table: Name of the table recordId: Optional record ID for specific record check Returns: Boolean indicating permission """ return self.access.canModify(table, recordId) def initiateLogin(self) -> str: """Initiate Microsoft login flow""" try: # Generate auth URL auth_url = self.msal_app.get_authorization_request_url( scopes=self.scopes, redirect_uri=self.redirectUri, state=self._generateState() ) return auth_url except Exception as e: logger.error(f"Error initiating Microsoft login: {str(e)}") return None def handleAuthCallback(self, code: str) -> Optional[MsftToken]: """Handle Microsoft OAuth callback""" try: # Get token from code token_response = self.msal_app.acquire_token_by_authorization_code( code, scopes=self.scopes, redirect_uri=self.redirectUri ) if "error" in token_response: logger.error(f"Token acquisition failed: {token_response['error']}") return None # Get user info user_info = self.getUserInfoFromToken(token_response["access_token"]) if not user_info: return None # Create token model token = MsftToken( access_token=token_response["access_token"], refresh_token=token_response.get("refresh_token", ""), expires_in=token_response.get("expires_in", 0), token_type=token_response.get("token_type", "bearer"), expires_at=datetime.now().timestamp() + token_response.get("expires_in", 0), user_info=user_info.model_dump(), mandateId=self.mandateId, userId=self.userId ) return token except Exception as e: logger.error(f"Error handling auth callback: {str(e)}") return None def verifyToken(self, token: str) -> bool: """Verify Microsoft token""" try: # Get user info from token user_info = self.getUserInfoFromToken(token) if not user_info: return False # Get current user's Microsoft connection user = self.db.getRecordset("users", recordFilter={"id": self.userId})[0] msft_connection = next((conn for conn in user.get("connections", []) if conn.get("authority") == "microsoft"), None) if not msft_connection: return False # Verify the token belongs to this user return user_info.id == msft_connection.get("externalId") except Exception as e: logger.error(f"Error verifying Microsoft token: {str(e)}") return False def getUserInfoFromToken(self, token: str) -> Optional[MsftUserInfo]: """Get user info from Microsoft Graph""" try: # Call Microsoft Graph API response = requests.get( "https://graph.microsoft.com/v1.0/me", headers={"Authorization": f"Bearer {token}"} ) if response.status_code != 200: logger.error(f"Failed to get user info: {response.text}") return None data = response.json() # Create user info model return MsftUserInfo( id=data["id"], email=data.get("mail") or data.get("userPrincipalName"), name=data.get("displayName", ""), picture=None # Microsoft Graph doesn't provide profile picture by default ) except Exception as e: logger.error(f"Error getting user info: {str(e)}") return None def refreshToken(self, refresh_token: str) -> Optional[MsftToken]: """Refresh Microsoft token""" try: # Refresh token token_response = self.msal_app.acquire_token_by_refresh_token( refresh_token, scopes=self.scopes ) if "error" in token_response: logger.error(f"Token refresh failed: {token_response['error']}") return None # Get user info user_info = self.getUserInfoFromToken(token_response["access_token"]) if not user_info: return None # Create token model token = MsftToken( access_token=token_response["access_token"], refresh_token=token_response.get("refresh_token", refresh_token), expires_in=token_response.get("expires_in", 0), token_type=token_response.get("token_type", "bearer"), expires_at=datetime.now().timestamp() + token_response.get("expires_in", 0), user_info=user_info.model_dump(), mandateId=self.mandateId, userId=self.userId ) return token except Exception as e: logger.error(f"Error refreshing token: {str(e)}") return None def _generateState(self) -> str: """Generate secure state token""" return secrets.token_urlsafe(32) def createDraftEmail(self, recipient: str, subject: str, body: str, attachments: List[Dict[str, Any]] = None) -> bool: """Create a draft email using Microsoft Graph API""" try: user_info, access_token = self.getCurrentUserToken() if not user_info or not access_token: return False headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } email_data = { 'subject': subject, 'body': { 'contentType': 'HTML', 'content': body }, 'toRecipients': [ { 'emailAddress': { 'address': recipient } } ] } if attachments: email_data['attachments'] = [] for attachment in attachments: doc = attachment.get('document', {}) file_name = attachment.get('name', 'attachment.file') file_content = doc.get('data') if not file_content: continue mime_type = doc.get('mimeType', 'application/octet-stream') is_base64 = doc.get('base64Encoded', False) try: if is_base64: content_bytes = file_content else: if isinstance(file_content, str): content_bytes = base64.b64encode(file_content.encode('utf-8')).decode('utf-8') elif isinstance(file_content, bytes): content_bytes = base64.b64encode(file_content).decode('utf-8') else: continue decoded_size = len(base64.b64decode(content_bytes)) attachment_data = { '@odata.type': '#microsoft.graph.fileAttachment', 'name': file_name, 'contentType': mime_type, 'contentBytes': content_bytes, 'isInline': False, 'size': decoded_size } email_data['attachments'].append(attachment_data) except Exception as e: logger.error(f"Error processing attachment {file_name}: {str(e)}") continue response = requests.post( 'https://graph.microsoft.com/v1.0/me/messages', headers=headers, json=email_data ) return response.status_code >= 200 and response.status_code < 300 except Exception as e: logger.error(f"Error creating draft email: {str(e)}") return False def saveMsftToken(self, token_data: Dict[str, Any]) -> bool: """ Save Microsoft token data to the database. Args: token_data: Token data to save Returns: bool: True if successful, False otherwise """ try: # Get existing token if any existing_tokens = self.db.getRecordset( "msftTokens", recordFilter={ "mandateId": self.mandateId, "userId": self.userId } ) if existing_tokens: # Update existing token token_id = existing_tokens[0]["id"] success = self.db.updateRecord( "msftTokens", token_id, token_data ) else: # Create new token record success = self.db.createRecord( "msftTokens", token_data ) return success except Exception as e: logger.error(f"Error saving Microsoft token: {str(e)}") return False def getMsftToken(self) -> Optional[Dict[str, Any]]: """ Get Microsoft token data for current user. Returns: Optional[Dict[str, Any]]: Token data if found, None otherwise """ try: tokens = self.db.getRecordset( "msftTokens", recordFilter={ "mandateId": self.mandateId, "userId": self.userId } ) if not tokens: return None return tokens[0] except Exception as e: logger.error(f"Error getting Microsoft token: {str(e)}") return None def getCurrentUserToken(self) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: """ Get current user's Microsoft token and user info. Returns: Tuple[Optional[Dict[str, Any]], Optional[str]]: User info and access token """ try: token_data = self.getMsftToken() if not token_data: return None, None # Check if token needs refresh if datetime.now().timestamp() >= token_data["expires_at"]: if not token_data.get("refresh_token"): return None, None # Refresh token new_token = self.refreshToken(token_data["refresh_token"]) if not new_token: return None, None # Save new token self.saveMsftToken(new_token.model_dump()) token_data = new_token.model_dump() return token_data["user_info"], token_data["access_token"] except Exception as e: logger.error(f"Error getting current user token: {str(e)}") return None, None def deleteMsftToken(self) -> bool: """ Delete Microsoft token for current user. Returns: bool: True if successful, False otherwise """ try: # Get existing token existing_tokens = self.db.getRecordset( "msftTokens", recordFilter={ "mandateId": self.mandateId, "userId": self.userId } ) if not existing_tokens: return True # No token to delete # Delete token success = self.db.deleteRecord( "msftTokens", existing_tokens[0]["id"] ) return success except Exception as e: logger.error(f"Error deleting Microsoft token: {str(e)}") return False def setUserContext(self, currentUser: Dict[str, Any]): """Set user context for the interface""" if not currentUser: logger.info("Initializing interface without user context") return self.currentUser = currentUser self.mandateId = currentUser.get("mandateId") self.userId = currentUser.get("id") if not self.mandateId or not self.userId: raise ValueError("Invalid user context: mandateId and id are required") # Initialize access control with user context self.access = MsftAccess(self.currentUser, self.db) # Update database context self.db.updateContext(self.mandateId, self.userId) logger.debug(f"User context set: userId={self.userId}") def getRootInterface() -> MsftInterface: """ Returns a MsftInterface instance with root privileges. This is used for initial setup and user creation. """ global _rootMsftInterface if _rootMsftInterface is None: # Get root user from gateway rootUser = getRootUser() _rootMsftInterface = MsftInterface(rootUser) return _rootMsftInterface def getInterface(currentUser: Dict[str, Any] = None) -> MsftInterface: """ Returns a MsftInterface instance. If currentUser is provided, initializes with user context. Otherwise, returns an instance with only database access. """ # Create new instance if not exists if "default" not in _msftInterfaces: _msftInterfaces["default"] = MsftInterface(currentUser or {}) interface = _msftInterfaces["default"] if currentUser: interface.setUserContext(currentUser) else: logger.info("Returning interface without user context") return interface