diff --git a/app.py b/app.py index 5a6994da..ed80f158 100644 --- a/app.py +++ b/app.py @@ -30,6 +30,13 @@ def initLogging(): datefmt=APP_CONFIG.get("APP_LOGGING_DATE_FORMAT", "%Y-%m-%d %H:%M:%S") ) + # Add filter to exclude Chrome DevTools requests + class ChromeDevToolsFilter(logging.Filter): + def filter(self, record): + return not (isinstance(record.msg, str) and + ('.well-known/appspecific/com.chrome.devtools.json' in record.msg or + 'Request: /index.html' in record.msg)) + # Configure handlers based on config handlers = [] @@ -37,6 +44,7 @@ def initLogging(): if APP_CONFIG.get("APP_LOGGING_CONSOLE_ENABLED", True): consoleHandler = logging.StreamHandler() consoleHandler.setFormatter(consoleFormatter) + consoleHandler.addFilter(ChromeDevToolsFilter()) handlers.append(consoleHandler) # Add file handler if enabled @@ -62,6 +70,7 @@ def initLogging(): backupCount=backupCount ) fileHandler.setFormatter(fileFormatter) + fileHandler.addFilter(ChromeDevToolsFilter()) handlers.append(fileHandler) # Configure the root logger diff --git a/modules/connectors/connectorDbJson.py b/modules/connectors/connectorDbJson.py index 006ca7e4..fd38ffcc 100644 --- a/modules/connectors/connectorDbJson.py +++ b/modules/connectors/connectorDbJson.py @@ -162,11 +162,11 @@ class DatabaseConnector: raise ValueError(f"Record ID mismatch: file name ID ({recordId}) does not match record ID ({record['id']})") # Add metadata - currentTime = datetime.now().isoformat() + currentTime = datetime.now() if "_createdAt" not in record: - record["_createdAt"] = currentTime + record["_createdAt"] = currentTime.isoformat() record["_createdBy"] = self.userId - record["_modifiedAt"] = currentTime + record["_modifiedAt"] = currentTime.isoformat() record["_modifiedBy"] = self.userId # Save the record file diff --git a/modules/interfaces/serviceAppAccess.py b/modules/interfaces/serviceAppAccess.py index ec6ee3fc..a30fa015 100644 --- a/modules/interfaces/serviceAppAccess.py +++ b/modules/interfaces/serviceAppAccess.py @@ -59,6 +59,18 @@ class AppAccess: else: # Regular users only see themselves filtered_records = [r for r in recordset if r.get("id") == self.userId] + # Special handling for connections table + elif table == "connections": + if self.privilege == UserPrivilege.SYSADMIN: + # SysAdmin sees all connections + filtered_records = recordset + elif self.privilege == UserPrivilege.ADMIN: + # Admin sees connections for users in their mandate + user_ids = [u["id"] for u in self.db.getRecordset("users", recordFilter={"mandateId": self.mandateId})] + filtered_records = [r for r in recordset if r.get("userId") in user_ids] + else: + # Regular users only see their own connections + filtered_records = [r for r in recordset if r.get("userId") == self.userId] # System admins see all other records elif self.privilege == UserPrivilege.SYSADMIN: filtered_records = recordset @@ -93,6 +105,22 @@ class AppAccess: else: record["_hideEdit"] = record.get("id") != self.userId record["_hideDelete"] = True # Regular users cannot delete users + elif table == "connections": + # Everyone can view connections they have access to + record["_hideView"] = False + # SysAdmin can edit/delete any connection + if self.privilege == UserPrivilege.SYSADMIN: + record["_hideEdit"] = False + record["_hideDelete"] = False + # Admin can edit/delete connections for users in their mandate + elif self.privilege == UserPrivilege.ADMIN: + user_ids = [u["id"] for u in self.db.getRecordset("users", recordFilter={"mandateId": self.mandateId})] + record["_hideEdit"] = record.get("userId") not in user_ids + record["_hideDelete"] = record.get("userId") not in user_ids + # Regular users can only edit/delete their own connections + else: + record["_hideEdit"] = record.get("userId") != self.userId + record["_hideDelete"] = record.get("userId") != self.userId elif table == "sessions": # Only show sessions for the current user or if admin if self.privilege in [UserPrivilege.SYSADMIN, UserPrivilege.ADMIN]: @@ -145,6 +173,15 @@ class AppAccess: record = records[0] + # Special handling for connections + if table == "connections": + # Admin can modify connections for users in their mandate + if self.privilege == UserPrivilege.ADMIN: + user_ids = [u["id"] for u in self.db.getRecordset("users", recordFilter={"mandateId": self.mandateId})] + return record.get("userId") in user_ids + # Users can only modify their own connections + return record.get("userId") == self.userId + # Admins can modify anything in their mandate if self.privilege == UserPrivilege.ADMIN and record.get("mandateId","-") == self.mandateId: return True diff --git a/modules/interfaces/serviceAppClass.py b/modules/interfaces/serviceAppClass.py index 789348d2..fc5e4791 100644 --- a/modules/interfaces/serviceAppClass.py +++ b/modules/interfaces/serviceAppClass.py @@ -3,7 +3,7 @@ Interface to the Gateway system. Manages users and mandates for authentication. """ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, UTC import os import logging from typing import Dict, Any, List, Optional, Union @@ -249,44 +249,109 @@ class GatewayInterface: def getUser(self, userId: str) -> Optional[User]: """Returns a user by ID if user has access.""" - users = self.db.getRecordset("users", recordFilter={"id": userId}) - if not users: - return None - - filteredUsers = self._uam("users", users) - if not filteredUsers: - return None - - return User.from_dict(filteredUsers[0]) - - def addUserConnection(self, userId: str, authority: AuthAuthority, externalId: str, - externalUsername: str, externalEmail: Optional[str] = None) -> UserConnection: - """Add a new connection to an external service for a user""" try: - # Get user + # Get all users + users = self.db.getRecordset("users") + if not users: + return None + + # Find user by ID + for user_dict in users: + if user_dict.get("id") == userId: + # Apply access control + filteredUsers = self._uam("users", [user_dict]) + if filteredUsers: + return User.from_dict(filteredUsers[0]) + return None + + return None + + except Exception as e: + logger.error(f"Error getting user by ID: {str(e)}") + return None + + def getUserConnections(self, userId: str) -> List[UserConnection]: + """Returns all connections for a user.""" + try: + # Get connections for this user + connections = self.db.getRecordset("connections", recordFilter={"userId": userId}) + + # Convert to UserConnection objects + result = [] + for conn_dict in connections: + try: + # Convert string dates to datetime objects + for field in ['connectedAt', 'lastChecked', 'expiresAt']: + if field in conn_dict and conn_dict[field]: + try: + if isinstance(conn_dict[field], str): + conn_dict[field] = datetime.fromisoformat(conn_dict[field].replace('Z', '+00:00')) + except (ValueError, TypeError): + conn_dict[field] = None + + # Create UserConnection object + connection = UserConnection( + id=conn_dict["id"], + userId=conn_dict["userId"], + authority=conn_dict.get("authority"), + externalId=conn_dict.get("externalId", ""), + externalUsername=conn_dict.get("externalUsername", ""), + externalEmail=conn_dict.get("externalEmail"), + status=conn_dict.get("status", "pending"), + connectedAt=conn_dict.get("connectedAt"), + lastChecked=conn_dict.get("lastChecked"), + expiresAt=conn_dict.get("expiresAt") + ) + result.append(connection) + except Exception as e: + logger.error(f"Error converting connection dict to object: {str(e)}") + continue + + return result + + except Exception as e: + logger.error(f"Error getting user connections: {str(e)}") + return [] + + def addUserConnection(self, userId: str, authority: AuthAuthority, externalId: str, + externalUsername: str, externalEmail: Optional[str] = None, + status: ConnectionStatus = ConnectionStatus.PENDING) -> UserConnection: + """ + Adds a new connection for a user. + + Args: + userId: The ID of the user + authority: The authentication authority (e.g., MSFT, GOOGLE) + externalId: The external ID from the authority + externalUsername: The username from the authority + externalEmail: Optional email from the authority + status: The connection status (defaults to PENDING) + + Returns: + The created UserConnection object + """ + try: + # Get the user user = self.getUser(userId) if not user: - raise ValueError(f"User {userId} not found") - - # Check if connection already exists - for conn in user.connections: - if conn.authority == authority and conn.externalId == externalId: - raise ValueError(f"Connection to {authority} already exists for user {userId}") + raise ValueError(f"User not found: {userId}") - # Create new connection + # Create new connection with all required fields connection = UserConnection( + id=str(uuid.uuid4()), + userId=userId, authority=authority, externalId=externalId, externalUsername=externalUsername, externalEmail=externalEmail, - status=ConnectionStatus.ACTIVE + status=status, + connectedAt=datetime.now(UTC), + lastChecked=datetime.now(UTC), + expiresAt=None # Optional field, set to None by default ) - # Add connection to user - user.connections.append(connection) - - # Update user record - self.db.recordModify("users", userId, {"connections": [c.to_dict() for c in user.connections]}) + # Save to connections table + self.db.recordCreate("connections", connection.to_dict()) return connection @@ -294,19 +359,19 @@ class GatewayInterface: logger.error(f"Error adding user connection: {str(e)}") raise ValueError(f"Failed to add user connection: {str(e)}") - def removeUserConnection(self, userId: str, connectionId: str) -> None: - """Remove a connection to an external service for a user""" + def removeUserConnection(self, connectionId: str) -> None: + """Remove a connection to an external service""" try: - # Get user - user = self.getUser(userId) - if not user: - raise ValueError(f"User {userId} not found") - - # Find and remove connection - user.connections = [c for c in user.connections if c.id != connectionId] + # Get connection + connections = self.db.getRecordset("connections", recordFilter={ + "id": connectionId + }) - # Update user record - self.db.recordModify("users", userId, {"connections": [c.to_dict() for c in user.connections]}) + if not connections: + raise ValueError(f"Connection {connectionId} not found") + + # Delete connection + self.db.recordDelete("connections", connectionId) except Exception as e: logger.error(f"Error removing user connection: {str(e)}") @@ -468,11 +533,10 @@ class GatewayInterface: logger.debug(f"Deleted token {token['id']} for user {userId}") # Delete user connections - user = self.getUser(userId) - if user and user.connections: - for conn in user.connections: - self.removeUserConnection(userId, conn.id) - logger.debug(f"Deleted connection {conn.id} for user {userId}") + connections = self.db.getRecordset("connections", recordFilter={"userId": userId}) + for conn in connections: + self.db.recordDelete("connections", conn["id"]) + logger.debug(f"Deleted connection {conn['id']} for user {userId}") logger.info(f"All referenced data for user {userId} has been deleted") diff --git a/modules/interfaces/serviceAppModel.py b/modules/interfaces/serviceAppModel.py index f0947195..c98e62fc 100644 --- a/modules/interfaces/serviceAppModel.py +++ b/modules/interfaces/serviceAppModel.py @@ -50,6 +50,7 @@ register_model_labels( class UserConnection(BaseModel, ModelMixin): """Data model for a user's connection to an external service""" id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection") + userId: str = Field(description="ID of the user this connection belongs to") authority: AuthAuthority = Field(description="Authentication authority") externalId: str = Field(description="User ID in the external system") externalUsername: str = Field(description="Username in the external system") @@ -59,12 +60,28 @@ class UserConnection(BaseModel, ModelMixin): lastChecked: datetime = Field(default_factory=datetime.now, description="When the connection was last verified") expiresAt: Optional[datetime] = Field(None, description="When the connection expires") + def to_dict(self) -> Dict[str, Any]: + """Convert the model to a dictionary with proper datetime serialization""" + data = super().to_dict() + # Convert datetime fields to ISO format strings + for field in ['connectedAt', 'lastChecked', 'expiresAt']: + if field in data and data[field] is not None: + if isinstance(data[field], datetime): + data[field] = data[field].isoformat() + elif isinstance(data[field], (int, float)): + try: + data[field] = datetime.fromtimestamp(data[field]).isoformat() + except (ValueError, TypeError): + data[field] = None + return data + # Register labels for UserConnection register_model_labels( "UserConnection", {"en": "User Connection", "fr": "Connexion utilisateur"}, { "id": {"en": "ID", "fr": "ID"}, + "userId": {"en": "User ID", "fr": "ID utilisateur"}, "authority": {"en": "Authority", "fr": "Autorité"}, "externalId": {"en": "External ID", "fr": "ID externe"}, "externalUsername": {"en": "External Username", "fr": "Nom d'utilisateur externe"}, @@ -137,7 +154,6 @@ class User(BaseModel, ModelMixin): privilege: UserPrivilege = Field(default=UserPrivilege.USER, description="Permission level") authenticationAuthority: AuthAuthority = Field(default=AuthAuthority.LOCAL, description="Primary authentication authority") mandateId: Optional[str] = Field(None, description="ID of the mandate this user belongs to") - connections: List[UserConnection] = Field(default_factory=list, description="List of external service connections") # Register labels for User register_model_labels( @@ -152,8 +168,7 @@ register_model_labels( "enabled": {"en": "Enabled", "fr": "Activé"}, "privilege": {"en": "Privilege", "fr": "Privilège"}, "authenticationAuthority": {"en": "Auth Authority", "fr": "Autorité d'authentification"}, - "mandateId": {"en": "Mandate ID", "fr": "ID de mandat"}, - "connections": {"en": "Connections", "fr": "Connexions"} + "mandateId": {"en": "Mandate ID", "fr": "ID de mandat"} } ) diff --git a/modules/interfaces/serviceManagementAccess.py b/modules/interfaces/serviceManagementAccess.py index e781ded1..f52a253d 100644 --- a/modules/interfaces/serviceManagementAccess.py +++ b/modules/interfaces/serviceManagementAccess.py @@ -24,6 +24,10 @@ class ManagementAccess: self.privilege = currentUser.privilege self.db = db + def getInitialUserid(self): + return "----" + # return self.db.getInitialUserId() --> to get from AdminDB ! + def canModifyAttribute(self, table: str, attribute: str) -> bool: """ Checks if the current user can modify a specific attribute in a table. @@ -59,6 +63,8 @@ class ManagementAccess: filtered_records = [] + initialid = self.getInitialUserid() + # Apply filtering based on privilege if userPrivilege == "sysadmin": filtered_records = recordset # System admins see all records @@ -69,10 +75,15 @@ class ManagementAccess: # For prompts, users can see all prompts from their mandate if table == "prompts": filtered_records = [r for r in recordset if r.get("mandateId") == self.mandateId] + elif table == "users": + # For users table, users can only see their own record + filtered_records = [r for r in recordset if r.get("id") == self.userId] else: # Users see only their records for other tables - filtered_records = [r for r in recordset - if r.get("mandateId") == self.mandateId and r.get("_createdBy") == self.userId] + filtered_records = [ + r for r in recordset + if r.get("mandateId") == self.mandateId and r.get("_createdBy") == self.userId + ] # Add access control attributes to each record for record in filtered_records: @@ -104,6 +115,16 @@ class ManagementAccess: record["_hideView"] = False # Everyone can view record["_hideEdit"] = not self.canModify("workflows", record.get("workflowId")) record["_hideDelete"] = not self.canModify("workflows", record.get("workflowId")) + elif table == "users": + # For users table, users can only modify their own connections + record["_hideView"] = False + record["_hideEdit"] = record_id != self.userId + record["_hideDelete"] = record_id != self.userId + # Add connection-specific permissions + if "connections" in record: + for conn in record["connections"]: + conn["_hideEdit"] = record_id != self.userId + conn["_hideDelete"] = record_id != self.userId else: # Default access control for other tables record["_hideView"] = False @@ -138,6 +159,12 @@ class ManagementAccess: record = records[0] + # Special case for users table - users can modify their own connections + if table == "users": + if record.get("id") == self.userId: + return True + return False + # Admins can modify anything in their mandate, if mandate is specified for a record if userPrivilege == "admin" and record.get("mandateId","-") == self.mandateId: return True diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index 22c4da9a..0ac46005 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -8,6 +8,7 @@ from typing import List, Dict, Any, Optional from fastapi import status from datetime import datetime import logging +import json from modules.interfaces.serviceAppModel import User, UserConnection, AuthAuthority, ConnectionStatus from modules.security.auth import getCurrentUser, limiter @@ -31,16 +32,21 @@ async def get_connections( """Get all connections for the current user or all connections if admin""" try: interface = getInterface(currentUser) + + # Clear connections cache to ensure fresh data + if "connections" in interface.db._tablesCache: + del interface.db._tablesCache["connections"] + if currentUser.privilege in ['admin', 'sysadmin']: # Admins can see all connections users = interface.getAllUsers() connections = [] for user in users: - connections.extend(user.connections) + connections.extend(interface.getUserConnections(user.id)) return connections else: # Regular users can only see their own connections - return currentUser.connections + return interface.getUserConnections(currentUser.id) except Exception as e: logger.error(f"Error getting connections: {str(e)}") raise HTTPException( @@ -48,6 +54,99 @@ async def get_connections( detail=f"Failed to get connections: {str(e)}" ) +@router.post("/", response_model=UserConnection) +@limiter.limit("10/minute") +async def create_connection( + request: Request, + connection_data: Dict[str, Any] = Body(...), + currentUser: User = Depends(getCurrentUser) +) -> UserConnection: + """Create a new connection for the current user or update existing one""" + try: + interface = getInterface(currentUser) + + # Map type to authority + authority_map = { + 'msft': AuthAuthority.MSFT, + 'google': AuthAuthority.GOOGLE + } + + authority = authority_map.get(connection_data.get('type')) + if not authority: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Unsupported connection type: {connection_data.get('type')}" + ) + + # Get fresh copy of user from database + user = interface.getUser(currentUser.id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + # Check for existing connection of the same authority + existing_connection = None + connections = interface.getUserConnections(currentUser.id) + for conn in connections: + if conn.authority == authority: + existing_connection = conn + break + + if existing_connection: + # Update existing connection + existing_connection.status = ConnectionStatus.PENDING + existing_connection.lastChecked = datetime.now() + existing_connection.externalId = "" # Reset for new OAuth flow + existing_connection.externalUsername = "" # Reset for new OAuth flow + + # Convert connection to dict and ensure datetime fields are serialized + connection_dict = existing_connection.to_dict() + for field in ['connectedAt', 'lastChecked', 'expiresAt']: + if field in connection_dict and connection_dict[field] is not None: + if isinstance(connection_dict[field], datetime): + connection_dict[field] = connection_dict[field].isoformat() + elif isinstance(connection_dict[field], (int, float)): + connection_dict[field] = datetime.fromtimestamp(connection_dict[field]).isoformat() + + # Update connection record directly + interface.db.recordModify("connections", existing_connection.id, connection_dict) + + return existing_connection + else: + # Create new connection with PENDING status + connection = interface.addUserConnection( + userId=currentUser.id, + authority=authority, + externalId="", # Will be set after OAuth + externalUsername="", # Will be set after OAuth + status=ConnectionStatus.PENDING # Start with PENDING status + ) + + # Convert connection to dict and ensure datetime fields are serialized + connection_dict = connection.to_dict() + for field in ['connectedAt', 'lastChecked', 'expiresAt']: + if field in connection_dict and connection_dict[field] is not None: + if isinstance(connection_dict[field], datetime): + connection_dict[field] = connection_dict[field].isoformat() + elif isinstance(connection_dict[field], (int, float)): + connection_dict[field] = datetime.fromtimestamp(connection_dict[field]).isoformat() + + # Save connection record + interface.db.recordModify("connections", connection.id, connection_dict) + + return connection + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating connection: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create connection: {str(e)}" + ) + @router.post("/{connectionId}/connect") @limiter.limit("10/minute") async def connect_service( @@ -65,7 +164,8 @@ async def connect_service( # Admins can connect any connection users = interface.getAllUsers() for user in users: - for conn in user.connections: + connections = interface.getUserConnections(user.id) + for conn in connections: if conn.id == connectionId: connection = conn break @@ -73,7 +173,8 @@ async def connect_service( break else: # Regular users can only connect their own connections - for conn in currentUser.connections: + connections = interface.getUserConnections(currentUser.id) + for conn in connections: if conn.id == connectionId: connection = conn break @@ -87,9 +188,21 @@ async def connect_service( # Initiate OAuth flow with state=connect auth_url = None if connection.authority == AuthAuthority.MSFT: - auth_url = f"/api/msft/login?state=connect&connectionId={connectionId}" + # Use the same login endpoint with state=connect to ensure account selection + # Include current user ID in state + state_data = { + "type": "connect", + "connectionId": connectionId, + "userId": currentUser.id # Add current user ID + } + auth_url = f"/api/msft/login?state={json.dumps(state_data)}" elif connection.authority == AuthAuthority.GOOGLE: - auth_url = f"/api/google/login?state=connect&connectionId={connectionId}" + state_data = { + "type": "connect", + "connectionId": connectionId, + "userId": currentUser.id # Add current user ID + } + auth_url = f"/api/google/login?state={json.dumps(state_data)}" else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -124,7 +237,8 @@ async def disconnect_service( # Admins can disconnect any connection users = interface.getAllUsers() for user in users: - for conn in user.connections: + connections = interface.getUserConnections(user.id) + for conn in connections: if conn.id == connectionId: connection = conn break @@ -132,7 +246,8 @@ async def disconnect_service( break else: # Regular users can only disconnect their own connections - for conn in currentUser.connections: + connections = interface.getUserConnections(currentUser.id) + for conn in connections: if conn.id == connectionId: connection = conn break @@ -147,10 +262,8 @@ async def disconnect_service( connection.status = ConnectionStatus.INACTIVE connection.lastChecked = datetime.now() - # Update user record - interface.db.recordModify("users", connection.userId, { - "connections": [c.to_dict() for c in currentUser.connections] - }) + # Update connection record + interface.db.recordModify("connections", connectionId, connection.to_dict()) return {"message": "Service disconnected successfully"} @@ -161,4 +274,56 @@ async def disconnect_service( raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to disconnect service: {str(e)}" + ) + +@router.delete("/{connectionId}") +@limiter.limit("10/minute") +async def delete_connection( + request: Request, + connectionId: str = Path(..., description="The ID of the connection to delete"), + currentUser: User = Depends(getCurrentUser) +) -> Dict[str, Any]: + """Delete a connection""" + try: + interface = getInterface(currentUser) + + # Find the connection + connection = None + if currentUser.privilege in ['admin', 'sysadmin']: + # Admins can delete any connection + users = interface.getAllUsers() + for user in users: + connections = interface.getUserConnections(user.id) + for conn in connections: + if conn.id == connectionId: + connection = conn + break + if connection: + break + else: + # Regular users can only delete their own connections + connections = interface.getUserConnections(currentUser.id) + for conn in connections: + if conn.id == connectionId: + connection = conn + break + + if not connection: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Connection not found" + ) + + # Remove the connection - only need connectionId since permissions are verified + interface.removeUserConnection(connectionId) + + return {"message": "Connection deleted successfully"} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting connection: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete connection: {str(e)}" ) \ No newline at end of file diff --git a/modules/routes/routeSecurityGoogle.py b/modules/routes/routeSecurityGoogle.py index 4a5964ae..944b2fa7 100644 --- a/modules/routes/routeSecurityGoogle.py +++ b/modules/routes/routeSecurityGoogle.py @@ -68,14 +68,24 @@ async def login( scopes=SCOPES ) + # Generate auth URL with state - use state as is if it's already JSON, otherwise create new state + try: + # Try to parse state as JSON to check if it's already encoded + json.loads(state) + state_param = state # Use state as is if it's valid JSON + except json.JSONDecodeError: + # If not JSON, create new state object + state_param = json.dumps({ + "type": state, + "connectionId": connectionId + }) + # Generate auth URL with state auth_url, _ = flow.authorization_url( access_type="offline", include_granted_scopes="true", - state=json.dumps({ - "type": state, - "connectionId": connectionId - }) + state=state_param, + prompt="select_account" # Force account selection screen ) return RedirectResponse(auth_url) @@ -95,6 +105,9 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse state_data = json.loads(state) state_type = state_data.get("type", "login") connection_id = state_data.get("connectionId") + user_id = state_data.get("userId") # Get user ID from state + + logger.info(f"Processing Google auth callback: state_type={state_type}, connection_id={connection_id}, user_id={user_id}") # Create OAuth flow flow = Flow.from_client_config( @@ -137,12 +150,13 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse # Create token token = Token( - userId=user.id, + userId=user.id, # Use local user's ID authority=AuthAuthority.GOOGLE, tokenAccess=credentials.token, tokenRefresh=credentials.refresh_token, tokenType=credentials.token_type, - expiresAt=credentials.expiry.timestamp() if credentials.expiry else None + expiresAt=credentials.expiry.timestamp() if credentials.expiry else None, + createdAt=datetime.now() ) # Save token @@ -171,69 +185,178 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse ) else: # Handle connection flow - if not connection_id: + if not connection_id or not user_id: + logger.error("Connection ID or User ID is missing in connection flow") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Connection ID is required for connection flow" + detail="Connection ID and User ID are required for connection flow" ) - # Get current user from session - current_user = await getCurrentUser(request) - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="User not authenticated" + # Get user directly by ID + rootInterface = getRootInterface() + user = rootInterface.getUser(user_id) + + if not user: + logger.error(f"User {user_id} not found in database") + return HTMLResponse( + content=f""" + + Connection Failed + + + + + """, + status_code=404 ) - # Find and update connection - interface = getInterface(current_user) + # Get the connection from the connections table + interface = getInterface(user) + connections = interface.getUserConnections(user_id) connection = None - for conn in current_user.connections: + for conn in connections: if conn.id == connection_id: connection = conn + logger.info(f"Found existing connection for user {user.username}") break - if not connection: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Connection not found" + try: + if not connection: + logger.error(f"Connection {connection_id} not found in user's connections") + return HTMLResponse( + content=f""" + + Connection Failed + + + + + """, + status_code=404 + ) + + logger.info(f"Updating connection {connection_id} for user {user.username}") + # Update connection with external service details + connection.status = ConnectionStatus.ACTIVE + connection.lastChecked = datetime.now() + connection.expiresAt = credentials.expiry if credentials.expiry else None + connection.externalId = user_info.get("id") + connection.externalUsername = user_info.get("email") + connection.externalEmail = user_info.get("email") + + # Update connection record directly + rootInterface.db.recordModify("connections", connection_id, connection.to_dict()) + + # Save token + token = Token( + userId=user.id, # Use local user's ID + authority=AuthAuthority.GOOGLE, + tokenAccess=credentials.token, + tokenRefresh=credentials.refresh_token, + tokenType=credentials.token_type, + expiresAt=credentials.expiry.timestamp() if credentials.expiry else None, + createdAt=datetime.now() + ) + interface.saveToken(token) + + # Return success page with connection data + return HTMLResponse( + content=f""" + + Connection Successful + + + + + """ + ) + except Exception as e: + logger.error(f"Error updating connection: {str(e)}", exc_info=True) + return HTMLResponse( + content=f""" + + Connection Failed + + + + + """, + status_code=500 ) - # Update connection - connection.status = ConnectionStatus.ACTIVE - connection.lastChecked = datetime.now() - connection.expiresAt = credentials.expiry if credentials.expiry else None - - # Update user record - interface.db.recordModify("users", current_user.id, { - "connections": [c.to_dict() for c in current_user.connections] - }) - - # Return success page - return HTMLResponse( - content=f""" - - Connection Successful - - - - - """ - ) - except Exception as e: - logger.error(f"Error in auth callback: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Authentication failed: {str(e)}" + logger.error(f"Error in auth callback: {str(e)}", exc_info=True) + return HTMLResponse( + content=f""" + + Authentication Failed + + + + + """, + status_code=500 ) @router.get("/me", response_model=User) diff --git a/modules/routes/routeSecurityMsft.py b/modules/routes/routeSecurityMsft.py index bb767ef4..8b54c580 100644 --- a/modules/routes/routeSecurityMsft.py +++ b/modules/routes/routeSecurityMsft.py @@ -9,11 +9,12 @@ import json from typing import Dict, Any, Optional from datetime import datetime, timedelta import msal +import httpx from modules.shared.configuration import APP_CONFIG from modules.interfaces.serviceAppClass import getInterface, getRootInterface from modules.interfaces.serviceAppModel import AuthAuthority, User, Token, ConnectionStatus, UserConnection -from modules.security.auth import getCurrentUser, limiter +from modules.security.auth import getCurrentUser, limiter, createAccessToken from modules.shared.attributeUtils import ModelMixin # Configure logger @@ -56,14 +57,23 @@ async def login( client_credential=CLIENT_SECRET ) - # Generate auth URL with state - auth_url = msal_app.get_authorization_request_url( - scopes=SCOPES, - redirect_uri=REDIRECT_URI, - state=json.dumps({ + # Generate auth URL with state - use state as is if it's already JSON, otherwise create new state + try: + # Try to parse state as JSON to check if it's already encoded + json.loads(state) + state_param = state # Use state as is if it's valid JSON + except json.JSONDecodeError: + # If not JSON, create new state object + state_param = json.dumps({ "type": state, "connectionId": connectionId }) + + auth_url = msal_app.get_authorization_request_url( + scopes=SCOPES, + redirect_uri=REDIRECT_URI, + state=state_param, + prompt="select_account" # Force account selection screen ) return RedirectResponse(auth_url) @@ -83,6 +93,9 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse state_data = json.loads(state) state_type = state_data.get("type", "login") connection_id = state_data.get("connectionId") + user_id = state_data.get("userId") # Get user ID from state + + logger.info(f"Processing Microsoft auth callback: state_type={state_type}, connection_id={connection_id}, user_id={user_id}") # Create MSAL app msal_app = msal.ConfidentialClientApplication( @@ -99,49 +112,96 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse ) if "error" in token_response: + logger.error(f"Token acquisition failed: {token_response['error']}") return HTMLResponse( content="

Authentication Failed

Could not acquire token.

", status_code=400 ) - # Get user info from Microsoft - user_info = msal_app.acquire_token_for_client(scopes=["User.Read"]) - if "error" in user_info: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to get user info from Microsoft" + # Get user info using the access token + headers = { + 'Authorization': f"Bearer {token_response['access_token']}", + 'Content-Type': 'application/json' + } + async with httpx.AsyncClient() as client: + user_info_response = await client.get( + "https://graph.microsoft.com/v1.0/me", + headers=headers ) + if user_info_response.status_code != 200: + logger.error(f"Failed to get user info: {user_info_response.text}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to get user info from Microsoft" + ) + user_info = user_info_response.json() + logger.info(f"Got user info from Microsoft: {user_info.get('userPrincipalName')}") if state_type == "login": # Handle login flow rootInterface = getRootInterface() - user = rootInterface.getUserByUsername(user_info.get("preferred_username")) + user = rootInterface.getUserByUsername(user_info.get("userPrincipalName")) if not user: + logger.info(f"Creating new user for {user_info.get('userPrincipalName')}") # Create new user if doesn't exist user = rootInterface.createUser( - username=user_info.get("preferred_username"), - email=user_info.get("email"), - fullName=user_info.get("name"), + username=user_info.get("userPrincipalName"), + email=user_info.get("mail"), + fullName=user_info.get("displayName"), authenticationAuthority=AuthAuthority.MSFT, externalId=user_info.get("id"), - externalUsername=user_info.get("preferred_username"), - externalEmail=user_info.get("email") + externalUsername=user_info.get("userPrincipalName"), + externalEmail=user_info.get("mail") ) # Create token token = Token( - userId=user.id, + userId=user.id, # Use local user's ID authority=AuthAuthority.MSFT, tokenAccess=token_response["access_token"], tokenRefresh=token_response.get("refresh_token", ""), tokenType=token_response.get("token_type", "bearer"), - expiresAt=datetime.now().timestamp() + token_response.get("expires_in", 0) + expiresAt=datetime.now().timestamp() + token_response.get("expires_in", 0), + createdAt=datetime.now() ) # Save token appInterface = getInterface(user) appInterface.saveToken(token) + + # Create JWT token data + jwt_token_data = { + "sub": user.username, + "mandateId": str(user.mandateId), + "userId": str(user.id), + "authenticationAuthority": AuthAuthority.MSFT + } + + # Create JWT access token + jwt_token, jwt_expires_at = createAccessToken(jwt_token_data) + + # Create JWT token + jwt_token_obj = Token( + userId=user.id, + authority=AuthAuthority.MSFT, + tokenAccess=jwt_token, + tokenType="bearer", + expiresAt=jwt_expires_at.timestamp(), + createdAt=datetime.now() + ) + + # Save JWT token + appInterface.saveToken(jwt_token_obj) + + # Convert token to dict and ensure all datetime fields are serialized + token_dict = jwt_token_obj.to_dict() + if isinstance(token_dict.get('createdAt'), datetime): + token_dict['createdAt'] = token_dict['createdAt'].isoformat() + if isinstance(token_dict.get('expiresAt'), datetime): + token_dict['expiresAt'] = token_dict['expiresAt'].isoformat() + elif isinstance(token_dict.get('expiresAt'), float): + token_dict['expiresAt'] = int(token_dict['expiresAt']) # Return success page with token data return HTMLResponse( @@ -153,8 +213,7 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse if (window.opener) {{ window.opener.postMessage({{ type: 'msft_auth_success', - access_token: {json.dumps(token_response["access_token"])}, - token_data: {json.dumps(token.to_dict())} + token_data: {json.dumps(token_dict)} }}, '*'); }} setTimeout(() => window.close(), 1000); @@ -165,69 +224,178 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse ) else: # Handle connection flow - if not connection_id: + if not connection_id or not user_id: + logger.error("Connection ID or User ID is missing in connection flow") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Connection ID is required for connection flow" + detail="Connection ID and User ID are required for connection flow" ) - # Get current user from session - current_user = await getCurrentUser(request) - if not current_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="User not authenticated" + # Get user directly by ID + rootInterface = getRootInterface() + user = rootInterface.getUser(user_id) + + if not user: + logger.error(f"User {user_id} not found in database") + return HTMLResponse( + content=f""" + + Connection Failed + + + + + """, + status_code=404 ) - # Find and update connection - interface = getInterface(current_user) + # Get the connection from the connections table + interface = getInterface(user) + connections = interface.getUserConnections(user_id) connection = None - for conn in current_user.connections: + for conn in connections: if conn.id == connection_id: connection = conn + logger.info(f"Found existing connection for user {user.username}") break - if not connection: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Connection not found" + try: + if not connection: + logger.error(f"Connection {connection_id} not found in user's connections") + return HTMLResponse( + content=f""" + + Connection Failed + + + + + """, + status_code=404 + ) + + logger.info(f"Updating connection {connection_id} for user {user.username}") + # Update connection with external service details + connection.status = ConnectionStatus.ACTIVE + connection.lastChecked = datetime.now() + connection.expiresAt = datetime.now() + timedelta(seconds=token_response.get("expires_in", 0)) + connection.externalId = user_info.get("id") + connection.externalUsername = user_info.get("userPrincipalName") + connection.externalEmail = user_info.get("mail") + + # Update connection record directly + rootInterface.db.recordModify("connections", connection_id, connection.to_dict()) + + # Save token + token = Token( + userId=user.id, # Use local user's ID + authority=AuthAuthority.MSFT, + tokenAccess=token_response["access_token"], + tokenRefresh=token_response.get("refresh_token", ""), + tokenType=token_response.get("token_type", "bearer"), + expiresAt=datetime.now().timestamp() + token_response.get("expires_in", 0), + createdAt=datetime.now() + ) + interface.saveToken(token) + + # Return success page with connection data + return HTMLResponse( + content=f""" + + Connection Successful + + + + + """ + ) + except Exception as e: + logger.error(f"Error updating connection: {str(e)}", exc_info=True) + return HTMLResponse( + content=f""" + + Connection Failed + + + + + """, + status_code=500 ) - # Update connection - connection.status = ConnectionStatus.ACTIVE - connection.lastChecked = datetime.now() - connection.expiresAt = datetime.now() + timedelta(seconds=token_response.get("expires_in", 0)) - - # Update user record - interface.db.recordModify("users", current_user.id, { - "connections": [c.to_dict() for c in current_user.connections] - }) - - # Return success page - return HTMLResponse( - content=f""" - - Connection Successful - - - - - """ - ) - except Exception as e: - logger.error(f"Error in auth callback: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Authentication failed: {str(e)}" + logger.error(f"Error in auth callback: {str(e)}", exc_info=True) + return HTMLResponse( + content=f""" + + Authentication Failed + + + + + """, + status_code=500 ) @router.get("/me", response_model=User) diff --git a/modules/shared/attributeUtils.py b/modules/shared/attributeUtils.py index 0c4bcd04..6382592f 100644 --- a/modules/shared/attributeUtils.py +++ b/modules/shared/attributeUtils.py @@ -16,13 +16,29 @@ class ModelMixin: """ Convert a Pydantic model to a dictionary. Handles both Pydantic v1 and v2. + Properly serializes datetime fields to ISO format strings. Returns: Dict[str, Any]: Dictionary representation of the model """ + # Get the raw dictionary if hasattr(self, 'model_dump'): - return self.model_dump() # Pydantic v2 - return self.dict() # Pydantic v1 + data = self.model_dump() # Pydantic v2 + else: + data = self.dict() # Pydantic v1 + + # Convert datetime fields to ISO format strings + for key, value in data.items(): + if isinstance(value, datetime): + data[key] = value.isoformat() + elif isinstance(value, (int, float)) and key.lower().endswith(('at', 'date')): + # Handle timestamp fields + try: + data[key] = datetime.fromtimestamp(value).isoformat() + except (ValueError, TypeError): + pass + + return data @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'ModelMixin': diff --git a/notes/changelog.txt b/notes/changelog.txt index c959119a..b92b2cdc 100644 --- a/notes/changelog.txt +++ b/notes/changelog.txt @@ -1,50 +1,18 @@ ....................... TASKS -FIXES: - -now to adapt users.js with following specifics: -- MandateId not editable, to use user's mandateId -- authenticationAuthority to be "local" authority, not editable -- Actions: Edit, Delete, Toggle "enabled" -- Connections: not to render -- language to select -- privilege to select -clean the module and remove legacy code and renderings, as now everything done in formGeneric - - -now to adapt mandates.js with following specifics: -- Actions: Edit, Delete, Toggle "enabled" -- language to select -clean the module and remove legacy code and renderings, as now everything done in formGeneric - - -now to adapt connections.js with following specifics: -- Actions: Delete, toggle Connect/Disconnect (based on status attribute's value) -- Adding a new connection has two custom buttons, no form: One to connect to a "google" account and one to connect to a "msft" account. api connectors are already available. New record is produced. -clean the module and remove legacy code and renderings, as now everything done in formGeneric +We need to adapt the workflow part. -UAC: -- Users disabled nicht sichtbar! +@workflowManager.py only to contain the workflow steps. All implementations done with the self.service objects. this workflow steps as a draft: +- init or load workflow (workflow id) --> chat workflow object +- get user input into new workflow message --> chat message +- generate Task (chat message, workflow object) -Workflow: -- Liste der Prompts nicht aktualisiert nach Änderungen in FormGeneric - - - -- diese seite gesucht: "GET /.well-known/appspecific/com.chrome.devtools.json HTTP/1.1" 404 ? - - -Test paths: -- Admin -- User -- MSFT-Google -- Alle Management items -- Workflow -- Connections on/off -- Mail 2 Connectors +all functions from workflow.py to check stepwise and deeply analysed: +- are they already implemented in serviceChatClass.py? +- if not to move to taskManager, documentManager, or agentManager Agents and Manager: @@ -56,6 +24,16 @@ Agents and Manager: 4. task manager to add a task based on agents result and feedback - document extraction to have error handling for big documents. if document too large, then to get content in pieces - depending on document type + +Test paths: +- Admin +- User +- MSFT-Google +- Alle Management items +- Workflow +- Connections on/off +- Mail 2 Connectors + Walkthroughs: - register - login local @@ -63,9 +41,7 @@ Walkthroughs: - management pages - workflow -Install a Test environment with same prod_env -- add CORS url names to prod_env -- + ----------------------- OPEN diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 00000000..a11777cc Binary files /dev/null and b/static/favicon.ico differ