From d8954f95af22f77bb5fed6ed5f0ebb2ed163df49 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Fri, 6 Jun 2025 07:42:56 +0200
Subject: [PATCH] data management tested
---
app.py | 9 +
modules/connectors/connectorDbJson.py | 6 +-
modules/interfaces/serviceAppAccess.py | 37 +++
modules/interfaces/serviceAppClass.py | 152 ++++++---
modules/interfaces/serviceAppModel.py | 21 +-
modules/interfaces/serviceManagementAccess.py | 31 +-
modules/routes/routeDataConnections.py | 189 ++++++++++-
modules/routes/routeSecurityGoogle.py | 233 +++++++++----
modules/routes/routeSecurityMsft.py | 310 ++++++++++++++----
modules/shared/attributeUtils.py | 20 +-
notes/changelog.txt | 62 ++--
static/favicon.ico | Bin 0 -> 3870 bytes
12 files changed, 835 insertions(+), 235 deletions(-)
create mode 100644 static/favicon.ico
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 0000000000000000000000000000000000000000..a11777cc471a4344702741ab1c8a588998b1311a
GIT binary patch
literal 3870
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
baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@
literal 0
HcmV?d00001