diff --git a/.env.example b/.env.example index 70f2a096..ff067eeb 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,9 @@ JWT_ALGORITHM="HS256" # In seconds JWT_EXPIRATION_TIME=3600 +# Encryption key for API keys +ENCRYPTION_KEY="your-encryption-key" + # SendGrid SENDGRID_API_KEY="your-sendgrid-api-key" EMAIL_FROM="noreply@yourdomain.com" diff --git a/README.md b/README.md index b79d10e0..db2cffd5 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ The Evo AI platform allows: - JWT authentication with email verification - **Agent 2 Agent (A2A) Protocol Support**: Interoperability between AI agents following Google's A2A specification - **Workflow Agent with LangGraph**: Building complex agent workflows with LangGraph and ReactFlow +- **Secure API Key Management**: Encrypted storage of API keys with Fernet encryption +- **Agent Organization**: Folder structure for organizing agents by categories ## 🤖 Agent Types and Creation @@ -30,7 +32,8 @@ Agent based on language models like GPT-4, Claude, etc. Can be configured with t "description": "Specialized personal assistant", "type": "llm", "model": "gpt-4", - "api_key": "your-api-key", + "api_key_id": "stored-api-key-uuid", + "folder_id": "folder_id (optional)", "instruction": "Detailed instructions for agent behavior", "config": { "tools": [ @@ -69,6 +72,7 @@ Agent that implements Google's A2A protocol for agent interoperability. "client_id": "{{client_id}}", "type": "a2a", "agent_card_url": "http://localhost:8001/api/v1/a2a/your-agent/.well-known/agent.json", + "folder_id": "folder_id (optional)", "config": { "sub_agents": ["sub-agent-uuid"] } @@ -84,6 +88,7 @@ Executes a sequence of sub-agents in a specific order. "client_id": "{{client_id}}", "name": "processing_flow", "type": "sequential", + "folder_id": "folder_id (optional)", "config": { "sub_agents": ["agent-uuid-1", "agent-uuid-2", "agent-uuid-3"] } @@ -99,6 +104,7 @@ Executes multiple sub-agents simultaneously. "client_id": "{{client_id}}", "name": "parallel_processing", "type": "parallel", + "folder_id": "folder_id (optional)", "config": { "sub_agents": ["agent-uuid-1", "agent-uuid-2"] } @@ -114,6 +120,7 @@ Executes sub-agents in a loop with a defined maximum number of iterations. "client_id": "{{client_id}}", "name": "loop_processing", "type": "loop", + "folder_id": "folder_id (optional)", "config": { "sub_agents": ["sub-agent-uuid"], "max_iterations": 5 @@ -130,6 +137,7 @@ Executes sub-agents in a custom workflow defined by a graph structure. This agen "client_id": "{{client_id}}", "name": "workflow_agent", "type": "workflow", + "folder_id": "folder_id (optional)", "config": { "sub_agents": ["agent-uuid-1", "agent-uuid-2", "agent-uuid-3"], "workflow": { @@ -469,6 +477,9 @@ JWT_EXPIRATION_TIME=30 # In seconds SENDGRID_API_KEY="your-sendgrid-api-key" EMAIL_FROM="noreply@yourdomain.com" APP_URL="https://yourdomain.com" + +# Encryption for API keys +ENCRYPTION_KEY="your-encryption-key" ``` ### Project Dependencies @@ -734,3 +745,149 @@ The main environment variables used by the API container: - `SENDGRID_API_KEY`: SendGrid API key for sending emails - `EMAIL_FROM`: Email used as sender - `APP_URL`: Base URL of the application + +## 🔒 Secure API Key Management + +Evo AI implements a secure API key management system that protects sensitive credentials: + +- **Encrypted Storage**: API keys are encrypted using Fernet symmetric encryption before storage +- **Secure References**: Agents reference API keys by UUID (api_key_id) instead of storing raw keys +- **Centralized Management**: API keys can be created, updated, and rotated without changing agent configurations +- **Client Isolation**: API keys are scoped to specific clients for better security isolation + +### Encryption Configuration + +The encryption system uses a secure key defined in the `.env` file: + +```env +ENCRYPTION_KEY="your-secure-encryption-key" +``` + +If not provided, a secure key will be generated automatically at startup. + +### API Key Management + +API keys can be managed through dedicated endpoints: + +```http +# Create a new API key +POST /api/v1/agents/apikeys +Content-Type: application/json +Authorization: Bearer your-token-jwt +x-client-id: client-uuid + +{ + "client_id": "client-uuid", + "name": "My OpenAI Key", + "provider": "openai", + "key_value": "sk-actual-api-key-value" +} + +# List all API keys for a client +GET /api/v1/agents/apikeys +Authorization: Bearer your-token-jwt +x-client-id: client-uuid + +# Get a specific API key +GET /api/v1/agents/apikeys/{key_id} +Authorization: Bearer your-token-jwt +x-client-id: client-uuid + +# Update an API key +PUT /api/v1/agents/apikeys/{key_id} +Content-Type: application/json +Authorization: Bearer your-token-jwt +x-client-id: client-uuid + +{ + "name": "Updated Key Name", + "provider": "anthropic", + "key_value": "new-key-value", + "is_active": true +} + +# Delete an API key (soft delete) +DELETE /api/v1/agents/apikeys/{key_id} +Authorization: Bearer your-token-jwt +x-client-id: client-uuid +``` + +## 📁 Agent Organization + +Agents can be organized into folders for better management: + +### Creating and Managing Folders + +```http +# Create a new folder +POST /api/v1/agents/folders +Content-Type: application/json +Authorization: Bearer your-token-jwt + +{ + "client_id": "client-uuid", + "name": "Marketing Agents", + "description": "Agents for content marketing tasks" +} + +# List all folders +GET /api/v1/agents/folders +Authorization: Bearer your-token-jwt +x-client-id: client-uuid + +# Get a specific folder +GET /api/v1/agents/folders/{folder_id} +Authorization: Bearer your-token-jwt +x-client-id: client-uuid + +# Update a folder +PUT /api/v1/agents/folders/{folder_id} +Content-Type: application/json +Authorization: Bearer your-token-jwt +x-client-id: client-uuid + +{ + "name": "Updated Folder Name", + "description": "Updated folder description" +} + +# Delete a folder +DELETE /api/v1/agents/folders/{folder_id} +Authorization: Bearer your-token-jwt +x-client-id: client-uuid + +# List agents in a folder +GET /api/v1/agents/folders/{folder_id}/agents +Authorization: Bearer your-token-jwt +x-client-id: client-uuid + +# Assign an agent to a folder +PUT /api/v1/agents/{agent_id}/folder +Content-Type: application/json +Authorization: Bearer your-token-jwt +x-client-id: client-uuid + +{ + "folder_id": "folder-uuid" +} + +# Remove an agent from any folder +PUT /api/v1/agents/{agent_id}/folder +Content-Type: application/json +Authorization: Bearer your-token-jwt +x-client-id: client-uuid + +{ + "folder_id": null +} +``` + +### Filtering Agents by Folder + +When listing agents, you can filter by folder: + +```http +GET /api/v1/agents?folder_id=folder-uuid +Authorization: Bearer your-token-jwt +x-client-id: client-uuid +``` diff --git a/scripts/seeders/admin_seeder.py b/scripts/seeders/admin_seeder.py index 076458b2..a489db50 100644 --- a/scripts/seeders/admin_seeder.py +++ b/scripts/seeders/admin_seeder.py @@ -16,64 +16,67 @@ from dotenv import load_dotenv from src.models.models import User from src.utils.security import get_password_hash -# Configurar logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) logger = logging.getLogger(__name__) + def create_admin_user(): """Create an initial admin user in the system""" try: # Load environment variables load_dotenv() - + # Get database settings db_url = os.getenv("POSTGRES_CONNECTION_STRING") if not db_url: logger.error("Environment variable POSTGRES_CONNECTION_STRING not defined") return False - + # Get admin password admin_password = os.getenv("ADMIN_INITIAL_PASSWORD") if not admin_password: logger.error("Environment variable ADMIN_INITIAL_PASSWORD not defined") return False - + # Admin email configuration admin_email = os.getenv("ADMIN_EMAIL", "admin@evoai.com") - + # Connect to the database engine = create_engine(db_url) Session = sessionmaker(bind=engine) session = Session() - - # Verificar se o administrador já existe + + # Verify if the admin already exists existing_admin = session.query(User).filter(User.email == admin_email).first() if existing_admin: logger.info(f"Admin with email {admin_email} already exists") return True - + # Create admin admin_user = User( email=admin_email, password_hash=get_password_hash(admin_password), is_admin=True, is_active=True, - email_verified=True + email_verified=True, ) - + # Add and commit session.add(admin_user) session.commit() - + logger.info(f"Admin created successfully: {admin_email}") return True - + except Exception as e: logger.error(f"Error creating admin: {str(e)}") return False finally: session.close() + if __name__ == "__main__": success = create_admin_user() - sys.exit(0 if success else 1) \ No newline at end of file + sys.exit(0 if success else 1) diff --git a/scripts/seeders/client_seeder.py b/scripts/seeders/client_seeder.py index 8bdd36be..108db838 100644 --- a/scripts/seeders/client_seeder.py +++ b/scripts/seeders/client_seeder.py @@ -19,7 +19,6 @@ from dotenv import load_dotenv from src.models.models import User, Client from src.utils.security import get_password_hash -# Configurar logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" ) diff --git a/scripts/seeders/mcp_server_seeder.py b/scripts/seeders/mcp_server_seeder.py index 4bfa4e20..668372b3 100644 --- a/scripts/seeders/mcp_server_seeder.py +++ b/scripts/seeders/mcp_server_seeder.py @@ -16,7 +16,6 @@ from sqlalchemy.exc import SQLAlchemyError from dotenv import load_dotenv from src.models.models import MCPServer -# Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" ) diff --git a/scripts/seeders/tool_seeder.py b/scripts/seeders/tool_seeder.py index db3014fa..8d82932c 100644 --- a/scripts/seeders/tool_seeder.py +++ b/scripts/seeders/tool_seeder.py @@ -14,64 +14,68 @@ from dotenv import load_dotenv from src.models.models import Tool # Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" +) logger = logging.getLogger(__name__) + def create_tools(): - """Cria ferramentas padrão no sistema""" + """Create default tools in the system""" try: # Load environment variables load_dotenv() - + # Get database settings db_url = os.getenv("POSTGRES_CONNECTION_STRING") if not db_url: logger.error("Environment variable POSTGRES_CONNECTION_STRING not defined") return False - + # Connect to the database engine = create_engine(db_url) Session = sessionmaker(bind=engine) session = Session() - + try: # Check if there are already tools existing_tools = session.query(Tool).all() if existing_tools: logger.info(f"There are already {len(existing_tools)} tools registered") return True - + # Tools definitions tools = [] - + # Create the tools for tool_data in tools: - + tool = Tool( name=tool_data["name"], description=tool_data["description"], config_json=tool_data["config_json"], - environments=tool_data["environments"] + environments=tool_data["environments"], ) - + session.add(tool) logger.info(f"Tool '{tool_data['name']}' created successfully") - + session.commit() logger.info("All tools were created successfully") return True - + except SQLAlchemyError as e: session.rollback() logger.error(f"Database error when creating tools: {str(e)}") return False - + except Exception as e: logger.error(f"Error when creating tools: {str(e)}") return False finally: session.close() + if __name__ == "__main__": success = create_tools() - sys.exit(0 if success else 1) \ No newline at end of file + sys.exit(0 if success else 1) diff --git a/src/api/agent_routes.py b/src/api/agent_routes.py index 36c43b2e..a84e6ee2 100644 --- a/src/api/agent_routes.py +++ b/src/api/agent_routes.py @@ -13,11 +13,11 @@ from src.schemas.schemas import ( AgentFolder, AgentFolderCreate, AgentFolderUpdate, + ApiKey, + ApiKeyCreate, + ApiKeyUpdate, ) -from src.services import ( - agent_service, - mcp_server_service, -) +from src.services import agent_service, mcp_server_service, apikey_service import logging logger = logging.getLogger(__name__) @@ -68,7 +68,142 @@ router = APIRouter( ) -# Rotas para pastas de agentes +@router.post("/apikeys", response_model=ApiKey, status_code=status.HTTP_201_CREATED) +async def create_api_key( + key: ApiKeyCreate, + db: Session = Depends(get_db), + payload: dict = Depends(get_jwt_token), +): + """Create a new API key""" + await verify_user_client(payload, db, key.client_id) + + db_key = apikey_service.create_api_key( + db, key.client_id, key.name, key.provider, key.key_value + ) + + return db_key + + +@router.get("/apikeys", response_model=List[ApiKey]) +async def read_api_keys( + x_client_id: uuid.UUID = Header(..., alias="x-client-id"), + skip: int = 0, + limit: int = 100, + sort_by: str = Query( + "name", description="Field to sort: name, provider, created_at" + ), + sort_direction: str = Query("asc", description="Sort direction: asc, desc"), + db: Session = Depends(get_db), + payload: dict = Depends(get_jwt_token), +): + """List API keys for a client""" + # Verify if the user has access to this client's data + await verify_user_client(payload, db, x_client_id) + + keys = apikey_service.get_api_keys_by_client( + db, x_client_id, skip, limit, sort_by, sort_direction + ) + return keys + + +@router.get("/apikeys/{key_id}", response_model=ApiKey) +async def read_api_key( + key_id: uuid.UUID, + x_client_id: uuid.UUID = Header(..., alias="x-client-id"), + db: Session = Depends(get_db), + payload: dict = Depends(get_jwt_token), +): + """Get details of a specific API key""" + # Verify if the user has access to this client's data + await verify_user_client(payload, db, x_client_id) + + key = apikey_service.get_api_key(db, key_id) + if not key: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="API Key not found" + ) + + # Verify if the key belongs to the specified client + if key.client_id != x_client_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="API Key does not belong to the specified client", + ) + + return key + + +@router.put("/apikeys/{key_id}", response_model=ApiKey) +async def update_api_key( + key_id: uuid.UUID, + key_data: ApiKeyUpdate, + x_client_id: uuid.UUID = Header(..., alias="x-client-id"), + db: Session = Depends(get_db), + payload: dict = Depends(get_jwt_token), +): + """Update an API key""" + # Verify if the user has access to this client's data + await verify_user_client(payload, db, x_client_id) + + # Verify if the key exists + key = apikey_service.get_api_key(db, key_id) + if not key: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="API Key not found" + ) + + # Verify if the key belongs to the specified client + if key.client_id != x_client_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="API Key does not belong to the specified client", + ) + + # Update the key + updated_key = apikey_service.update_api_key( + db, + key_id, + key_data.name, + key_data.provider, + key_data.key_value, + key_data.is_active, + ) + return updated_key + + +@router.delete("/apikeys/{key_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_api_key( + key_id: uuid.UUID, + x_client_id: uuid.UUID = Header(..., alias="x-client-id"), + db: Session = Depends(get_db), + payload: dict = Depends(get_jwt_token), +): + """Deactivate an API key (soft delete)""" + # Verify if the user has access to this client's data + await verify_user_client(payload, db, x_client_id) + + # Verify if the key exists + key = apikey_service.get_api_key(db, key_id) + if not key: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="API Key not found" + ) + + # Verify if the key belongs to the specified client + if key.client_id != x_client_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="API Key does not belong to the specified client", + ) + + # Deactivate the key + if not apikey_service.delete_api_key(db, key_id): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="API Key not found" + ) + + +# Agent folder routes @router.post( "/folders", response_model=AgentFolder, status_code=status.HTTP_201_CREATED ) @@ -77,8 +212,8 @@ async def create_folder( db: Session = Depends(get_db), payload: dict = Depends(get_jwt_token), ): - """Cria uma nova pasta para organizar agentes""" - # Verifica se o usuário tem acesso ao cliente da pasta + """Create a new folder to organize agents""" + # Verify if the user has access to the folder's client await verify_user_client(payload, db, folder.client_id) return agent_service.create_agent_folder( @@ -94,8 +229,8 @@ async def read_folders( db: Session = Depends(get_db), payload: dict = Depends(get_jwt_token), ): - """Lista as pastas de agentes de um cliente""" - # Verifica se o usuário tem acesso aos dados deste cliente + """List agent folders for a client""" + # Verify if the user has access to this client's data await verify_user_client(payload, db, x_client_id) return agent_service.get_agent_folders_by_client(db, x_client_id, skip, limit) @@ -108,21 +243,21 @@ async def read_folder( db: Session = Depends(get_db), payload: dict = Depends(get_jwt_token), ): - """Obtém os detalhes de uma pasta específica""" - # Verifica se o usuário tem acesso aos dados deste cliente + """Get details of a specific folder""" + # Verify if the user has access to this client's data await verify_user_client(payload, db, x_client_id) folder = agent_service.get_agent_folder(db, folder_id) if not folder: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Pasta não encontrada" + status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found" ) - # Verifica se a pasta pertence ao cliente informado + # Verify if the folder belongs to the specified client if folder.client_id != x_client_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Pasta não pertence ao cliente informado", + detail="Folder does not belong to the specified client", ) return folder @@ -136,25 +271,25 @@ async def update_folder( db: Session = Depends(get_db), payload: dict = Depends(get_jwt_token), ): - """Atualiza uma pasta de agentes""" - # Verifica se o usuário tem acesso aos dados deste cliente + """Update an agent folder""" + # Verify if the user has access to this client's data await verify_user_client(payload, db, x_client_id) - # Verifica se a pasta existe + # Verify if the folder exists folder = agent_service.get_agent_folder(db, folder_id) if not folder: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Pasta não encontrada" + status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found" ) - # Verifica se a pasta pertence ao cliente informado + # Verify if the folder belongs to the specified client if folder.client_id != x_client_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Pasta não pertence ao cliente informado", + detail="Folder does not belong to the specified client", ) - # Atualiza a pasta + # Update the folder updated_folder = agent_service.update_agent_folder( db, folder_id, folder_data.name, folder_data.description ) @@ -168,28 +303,28 @@ async def delete_folder( db: Session = Depends(get_db), payload: dict = Depends(get_jwt_token), ): - """Remove uma pasta de agentes""" - # Verifica se o usuário tem acesso aos dados deste cliente + """Remove an agent folder""" + # Verify if the user has access to this client's data await verify_user_client(payload, db, x_client_id) - # Verifica se a pasta existe + # Verify if the folder exists folder = agent_service.get_agent_folder(db, folder_id) if not folder: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Pasta não encontrada" + status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found" ) - # Verifica se a pasta pertence ao cliente informado + # Verify if the folder belongs to the specified client if folder.client_id != x_client_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Pasta não pertence ao cliente informado", + detail="Folder does not belong to the specified client", ) - # Deleta a pasta + # Delete the folder if not agent_service.delete_agent_folder(db, folder_id): raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Pasta não encontrada" + status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found" ) @@ -202,28 +337,28 @@ async def read_folder_agents( db: Session = Depends(get_db), payload: dict = Depends(get_jwt_token), ): - """Lista os agentes em uma pasta específica""" - # Verifica se o usuário tem acesso aos dados deste cliente + """List agents in a specific folder""" + # Verify if the user has access to this client's data await verify_user_client(payload, db, x_client_id) - # Verifica se a pasta existe + # Verify if the folder exists folder = agent_service.get_agent_folder(db, folder_id) if not folder: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Pasta não encontrada" + status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found" ) - # Verifica se a pasta pertence ao cliente informado + # Verify if the folder belongs to the specified client if folder.client_id != x_client_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Pasta não pertence ao cliente informado", + detail="Folder does not belong to the specified client", ) - # Lista os agentes da pasta + # List the agents in the folder agents = agent_service.get_agents_by_folder(db, folder_id, skip, limit) - # Adiciona URL do agent card quando necessário + # Add agent card URL when needed for agent in agents: if not agent.agent_card_url: agent.agent_card_url = agent.agent_card_url_property @@ -239,39 +374,39 @@ async def assign_agent_to_folder( db: Session = Depends(get_db), payload: dict = Depends(get_jwt_token), ): - """Atribui um agente a uma pasta ou remove da pasta atual (se folder_id=None)""" - # Verifica se o usuário tem acesso aos dados deste cliente + """Assign an agent to a folder or remove from the current folder (if folder_id=None)""" + # Verify if the user has access to this client's data await verify_user_client(payload, db, x_client_id) - # Verifica se o agente existe + # Verify if the agent exists agent = agent_service.get_agent(db, agent_id) if not agent: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Agente não encontrado" + status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found" ) - # Verifica se o agente pertence ao cliente informado + # Verify if the agent belongs to the specified client if agent.client_id != x_client_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Agente não pertence ao cliente informado", + detail="Agent does not belong to the specified client", ) - # Se folder_id for fornecido, verifica se a pasta existe e pertence ao mesmo cliente + # If folder_id is provided, verify if the folder exists and belongs to the same client if folder_id: folder = agent_service.get_agent_folder(db, folder_id) if not folder: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Pasta não encontrada" + status_code=status.HTTP_404_NOT_FOUND, detail="Folder not found" ) if folder.client_id != x_client_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Pasta não pertence ao cliente informado", + detail="Folder does not belong to the specified client", ) - # Atribui o agente à pasta ou remove da pasta atual + # Assign the agent to the folder or remove from the current folder updated_agent = agent_service.assign_agent_to_folder(db, agent_id, folder_id) if not updated_agent.agent_card_url: @@ -280,22 +415,24 @@ async def assign_agent_to_folder( return updated_agent -# Modificação nas rotas existentes para suportar filtro por pasta +# Agent routes (after specific routes) @router.get("/", response_model=List[Agent]) async def read_agents( x_client_id: uuid.UUID = Header(..., alias="x-client-id"), skip: int = 0, limit: int = 100, - folder_id: Optional[uuid.UUID] = Query(None, description="Filtrar por pasta"), + folder_id: Optional[uuid.UUID] = Query(None, description="Filter by folder"), + sort_by: str = Query("name", description="Field to sort: name, created_at"), + sort_direction: str = Query("asc", description="Sort direction: asc, desc"), db: Session = Depends(get_db), payload: dict = Depends(get_jwt_token), ): # Verify if the user has access to this client's data await verify_user_client(payload, db, x_client_id) - # Get agents with optional folder filter + # Get agents with optional folder filter and sorting agents = agent_service.get_agents_by_client( - db, x_client_id, skip, limit, True, folder_id + db, x_client_id, skip, limit, True, folder_id, sort_by, sort_direction ) for agent in agents: diff --git a/src/config/settings.py b/src/config/settings.py index 3b374484..37561de6 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -49,6 +49,9 @@ class Settings(BaseSettings): JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256") JWT_EXPIRATION_TIME: int = int(os.getenv("JWT_EXPIRATION_TIME", 3600)) + # Encryption settings + ENCRYPTION_KEY: str = os.getenv("ENCRYPTION_KEY", secrets.token_urlsafe(32)) + # SendGrid settings SENDGRID_API_KEY: str = os.getenv("SENDGRID_API_KEY", "") EMAIL_FROM: str = os.getenv("EMAIL_FROM", "noreply@yourdomain.com") diff --git a/src/models/models.py b/src/models/models.py index 91f479da..2e0324dc 100644 --- a/src/models/models.py +++ b/src/models/models.py @@ -45,7 +45,6 @@ class User(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) - # Relationship with Client (One-to-One, optional for administrators) client = relationship( "Client", backref=backref("user", uselist=False, cascade="all, delete-orphan") ) @@ -61,10 +60,8 @@ class AgentFolder(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) - # Relação com o cliente client = relationship("Client", backref="agent_folders") - # Relação com os agentes agents = relationship("Agent", back_populates="folder") @@ -77,10 +74,13 @@ class Agent(Base): description = Column(Text, nullable=True) type = Column(String, nullable=False) model = Column(String, nullable=True, default="") - api_key = Column(String, nullable=True, default="") + api_key_id = Column( + UUID(as_uuid=True), + ForeignKey("api_keys.id", ondelete="SET NULL"), + nullable=True, + ) instruction = Column(Text) agent_card_url = Column(String, nullable=True) - # Nova coluna para a pasta - opcional (nullable=True) folder_id = Column( UUID(as_uuid=True), ForeignKey("agent_folders.id", ondelete="SET NULL"), @@ -97,9 +97,10 @@ class Agent(Base): ), ) - # Relação com a pasta folder = relationship("AgentFolder", back_populates="agents") + api_key_ref = relationship("ApiKey", foreign_keys=[api_key_id]) + @property def agent_card_url_property(self) -> str: """Virtual URL for the agent card""" @@ -192,7 +193,6 @@ class Tool(Base): class Session(Base): __tablename__ = "sessions" - # The directive below makes Alembic ignore this table in migrations __table_args__ = {"extend_existing": True, "info": {"skip_autogenerate": True}} id = Column(String, primary_key=True) @@ -218,5 +218,19 @@ class AuditLog(Base): user_agent = Column(String, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) - # Relationship with User user = relationship("User", backref="audit_logs") + + +class ApiKey(Base): + __tablename__ = "api_keys" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + client_id = Column(UUID(as_uuid=True), ForeignKey("clients.id", ondelete="CASCADE")) + name = Column(String, nullable=False) + provider = Column(String, nullable=False) + encrypted_key = Column(String, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + is_active = Column(Boolean, default=True) + + client = relationship("Client", backref="api_keys") diff --git a/src/schemas/schemas.py b/src/schemas/schemas.py index fa15b866..62545f3d 100644 --- a/src/schemas/schemas.py +++ b/src/schemas/schemas.py @@ -33,6 +33,33 @@ class Client(ClientBase): from_attributes = True +class ApiKeyBase(BaseModel): + name: str + provider: str + + +class ApiKeyCreate(ApiKeyBase): + client_id: UUID4 + key_value: str + + +class ApiKeyUpdate(BaseModel): + name: Optional[str] = None + provider: Optional[str] = None + key_value: Optional[str] = None + is_active: Optional[bool] = None + + +class ApiKey(ApiKeyBase): + id: UUID4 + client_id: UUID4 + created_at: datetime + updated_at: Optional[datetime] = None + is_active: bool + + model_config = ConfigDict(from_attributes=True) + + class AgentBase(BaseModel): name: Optional[str] = Field( None, description="Agent name (no spaces or special characters)" @@ -44,8 +71,8 @@ class AgentBase(BaseModel): model: Optional[str] = Field( None, description="Agent model (required only for llm type)" ) - api_key: Optional[str] = Field( - None, description="Agent API Key (required only for llm type)" + api_key_id: Optional[UUID4] = Field( + None, description="Reference to a stored API Key ID" ) instruction: Optional[str] = None agent_card_url: Optional[str] = Field( @@ -88,12 +115,22 @@ class AgentBase(BaseModel): raise ValueError("Model is required for llm type agents") return v - @validator("api_key") - def validate_api_key(cls, v, values): - if "type" in values and values["type"] == "llm" and not v: - raise ValueError("API Key is required for llm type agents") + @validator("api_key_id") + def validate_api_key_id(cls, v, values): return v + # Código anterior (comentado temporariamente) + # # Se o tipo for llm, api_key_id é obrigatório + # if "type" in values and values["type"] == "llm" and not v: + # # Verifica se tem api_key no config (retrocompatibilidade) + # if "config" in values and values["config"] and "api_key" in values["config"]: + # # Tem api_key no config, então aceita + # return v + # raise ValueError( + # "api_key_id é obrigatório para agentes do tipo llm" + # ) + # return v + @validator("config") def validate_config(cls, v, values): if "type" in values and values["type"] == "a2a": @@ -215,7 +252,6 @@ class Tool(ToolBase): from_attributes = True -# Schema para pasta de agentes class AgentFolderBase(BaseModel): name: str description: Optional[str] = None diff --git a/src/services/agent_builder.py b/src/services/agent_builder.py index a23881cf..fff26a79 100644 --- a/src/services/agent_builder.py +++ b/src/services/agent_builder.py @@ -10,11 +10,13 @@ from src.services.custom_tools import CustomToolBuilder from src.services.mcp_service import MCPService from src.services.a2a_agent import A2ACustomAgent from src.services.workflow_agent import WorkflowAgent +from src.services.apikey_service import get_decrypted_api_key from sqlalchemy.orm import Session from contextlib import AsyncExitStack from google.adk.tools import load_memory from datetime import datetime +import uuid logger = setup_logger(__name__) @@ -120,7 +122,6 @@ class AgentBuilder: formatted_prompt += "Use the get_current_time tool to get the current time in a city. The tool is available in the tools section of the configuration. Use 'new york' by default if no city is provided.ALWAYS use the 'get_current_time' tool when you need to use the current date and time in any type of situation, whether in interaction with the user or for calling other tools.\n\n" # Check if load_memory is enabled - # before_model_callback_func = None if agent.config.get("load_memory"): all_tools.append(load_memory) formatted_prompt = ( @@ -128,10 +129,48 @@ class AgentBuilder: + "\n\nALWAYS use the load_memory tool to retrieve knowledge for your context\n\n" ) + # Get API key from api_key_id + api_key = None + + # Get API key from api_key_id + if hasattr(agent, "api_key_id") and agent.api_key_id: + decrypted_key = get_decrypted_api_key(self.db, agent.api_key_id) + if decrypted_key: + logger.info(f"Using stored API key for agent {agent.name}") + api_key = decrypted_key + else: + logger.error( + f"Stored API key not found for agent {agent.name}" + ) + raise ValueError( + f"API key with ID {agent.api_key_id} not found or inactive" + ) + else: + # Check if there is an API key in the config (temporary field) + config_api_key = agent.config.get("api_key") if agent.config else None + if config_api_key: + logger.info(f"Using config API key for agent {agent.name}") + # Check if it is a UUID of a stored key + try: + key_id = uuid.UUID(config_api_key) + decrypted_key = get_decrypted_api_key(self.db, key_id) + if decrypted_key: + logger.info(f"Config API key is a valid reference") + api_key = decrypted_key + else: + # Use the key directly + api_key = config_api_key + except (ValueError, TypeError): + # It is not a UUID, use directly + api_key = config_api_key + else: + logger.error(f"No API key configured for agent {agent.name}") + raise ValueError(f"Agent {agent.name} does not have a configured API key") + return ( LlmAgent( name=agent.name, - model=LiteLlm(model=agent.model, api_key=agent.api_key), + model=LiteLlm(model=agent.model, api_key=api_key), instruction=formatted_prompt, description=agent.description, tools=all_tools, diff --git a/src/services/agent_service.py b/src/services/agent_service.py index 8df60a1b..1d7f4c1d 100644 --- a/src/services/agent_service.py +++ b/src/services/agent_service.py @@ -1,7 +1,7 @@ from sqlalchemy.orm import Session from sqlalchemy.exc import SQLAlchemyError from fastapi import HTTPException, status -from src.models.models import Agent, AgentFolder +from src.models.models import Agent, AgentFolder, ApiKey from src.schemas.schemas import AgentCreate from typing import List, Optional, Dict, Any, Union from src.services.mcp_server_service import get_mcp_server @@ -90,15 +90,29 @@ def get_agents_by_client( limit: int = 100, active_only: bool = True, folder_id: Optional[uuid.UUID] = None, + sort_by: str = "name", + sort_direction: str = "asc", ) -> List[Agent]: """Search for agents by client with pagination and optional folder filter""" try: query = db.query(Agent).filter(Agent.client_id == client_id) - # Filtra por pasta se especificado + # Filter by folder if specified if folder_id is not None: query = query.filter(Agent.folder_id == folder_id) + # Apply sorting + if sort_by == "name": + if sort_direction.lower() == "desc": + query = query.order_by(Agent.name.desc()) + else: + query = query.order_by(Agent.name) + elif sort_by == "created_at": + if sort_direction.lower() == "desc": + query = query.order_by(Agent.created_at.desc()) + else: + query = query.order_by(Agent.created_at) + agents = query.offset(skip).limit(limit).all() return agents @@ -356,6 +370,28 @@ async def update_agent( if not agent: raise HTTPException(status_code=404, detail="Agent not found") + # Check if api_key_id is defined + if "api_key_id" in agent_data and agent_data["api_key_id"]: + # Check if the referenced API key exists + api_key_id = agent_data["api_key_id"] + if isinstance(api_key_id, str): + api_key_id = uuid.UUID(api_key_id) + + api_key = db.query(ApiKey).filter(ApiKey.id == api_key_id).first() + if not api_key: + raise HTTPException( + status_code=400, + detail=f"API Key with ID {api_key_id} not found", + ) + + # Check if the key belongs to the agent's client + if api_key.client_id != agent.client_id: + raise HTTPException( + status_code=403, + detail="API Key does not belong to the same client as the agent", + ) + + # Continue with the original code if "type" in agent_data and agent_data["type"] == "a2a": if "agent_card_url" not in agent_data or not agent_data["agent_card_url"]: raise HTTPException( @@ -611,43 +647,43 @@ def activate_agent(db: Session, agent_id: uuid.UUID) -> bool: ) -# Funções para pastas de agentes +# Functions for agent folders def create_agent_folder( db: Session, client_id: uuid.UUID, name: str, description: Optional[str] = None ) -> AgentFolder: - """Cria uma nova pasta para organizar agentes""" + """Create a new folder to organize agents""" try: folder = AgentFolder(client_id=client_id, name=name, description=description) db.add(folder) db.commit() db.refresh(folder) - logger.info(f"Pasta de agentes criada com sucesso: {folder.id}") + logger.info(f"Agent folder created successfully: {folder.id}") return folder except SQLAlchemyError as e: db.rollback() - logger.error(f"Erro ao criar pasta de agentes: {str(e)}") + logger.error(f"Error creating agent folder: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Erro ao criar pasta de agentes: {str(e)}", + detail=f"Error creating agent folder: {str(e)}", ) def get_agent_folder(db: Session, folder_id: uuid.UUID) -> Optional[AgentFolder]: - """Busca uma pasta de agentes pelo ID""" + """Search for an agent folder by ID""" try: return db.query(AgentFolder).filter(AgentFolder.id == folder_id).first() except SQLAlchemyError as e: - logger.error(f"Erro ao buscar pasta de agentes {folder_id}: {str(e)}") + logger.error(f"Error searching for agent folder {folder_id}: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Erro ao buscar pasta de agentes", + detail="Error searching for agent folder", ) def get_agent_folders_by_client( db: Session, client_id: uuid.UUID, skip: int = 0, limit: int = 100 ) -> List[AgentFolder]: - """Lista as pastas de agentes de um cliente""" + """List the agent folders of a client""" try: return ( db.query(AgentFolder) @@ -657,10 +693,10 @@ def get_agent_folders_by_client( .all() ) except SQLAlchemyError as e: - logger.error(f"Erro ao listar pastas de agentes: {str(e)}") + logger.error(f"Error listing agent folders: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Erro ao listar pastas de agentes", + detail="Error listing agent folders", ) @@ -670,7 +706,7 @@ def update_agent_folder( name: Optional[str] = None, description: Optional[str] = None, ) -> Optional[AgentFolder]: - """Atualiza uma pasta de agentes""" + """Update an agent folder""" try: folder = get_agent_folder(db, folder_id) if not folder: @@ -683,94 +719,94 @@ def update_agent_folder( db.commit() db.refresh(folder) - logger.info(f"Pasta de agentes atualizada: {folder_id}") + logger.info(f"Agent folder updated: {folder_id}") return folder except SQLAlchemyError as e: db.rollback() - logger.error(f"Erro ao atualizar pasta de agentes {folder_id}: {str(e)}") + logger.error(f"Error updating agent folder {folder_id}: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Erro ao atualizar pasta de agentes", + detail="Error updating agent folder", ) def delete_agent_folder(db: Session, folder_id: uuid.UUID) -> bool: - """Remove uma pasta de agentes e desvincula os agentes""" + """Remove an agent folder and unassign the agents""" try: folder = get_agent_folder(db, folder_id) if not folder: return False - # Desvincula os agentes da pasta (não deleta os agentes) + # Unassign the agents from the folder (do not delete the agents) agents = db.query(Agent).filter(Agent.folder_id == folder_id).all() for agent in agents: agent.folder_id = None - # Deleta a pasta + # Delete the folder db.delete(folder) db.commit() - logger.info(f"Pasta de agentes removida: {folder_id}") + logger.info(f"Agent folder removed: {folder_id}") return True except SQLAlchemyError as e: db.rollback() - logger.error(f"Erro ao remover pasta de agentes {folder_id}: {str(e)}") + logger.error(f"Error removing agent folder {folder_id}: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Erro ao remover pasta de agentes", + detail="Error removing agent folder", ) def assign_agent_to_folder( db: Session, agent_id: uuid.UUID, folder_id: Optional[uuid.UUID] ) -> Optional[Agent]: - """Atribui um agente a uma pasta (ou remove da pasta se folder_id for None)""" + """Assign an agent to a folder (or remove from folder if folder_id is None)""" try: agent = get_agent(db, agent_id) if not agent: return None - # Se folder_id for None, remove o agente da pasta atual + # If folder_id is None, remove the agent from the current folder if folder_id is None: agent.folder_id = None db.commit() db.refresh(agent) - logger.info(f"Agente removido da pasta: {agent_id}") + logger.info(f"Agent removed from folder: {agent_id}") return agent - # Verifica se a pasta existe + # Verify if the folder exists folder = get_agent_folder(db, folder_id) if not folder: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Pasta não encontrada", + detail="Folder not found", ) - # Verifica se a pasta pertence ao mesmo cliente do agente + # Verify if the folder belongs to the same client as the agent if folder.client_id != agent.client_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="A pasta deve pertencer ao mesmo cliente do agente", + detail="The folder must belong to the same client as the agent", ) - # Atribui o agente à pasta + # Assign the agent to the folder agent.folder_id = folder_id db.commit() db.refresh(agent) - logger.info(f"Agente atribuído à pasta {folder_id}: {agent_id}") + logger.info(f"Agent assigned to folder: {folder_id}") return agent except SQLAlchemyError as e: db.rollback() - logger.error(f"Erro ao atribuir agente à pasta: {str(e)}") + logger.error(f"Error assigning agent to folder: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Erro ao atribuir agente à pasta", + detail="Error assigning agent to folder", ) def get_agents_by_folder( db: Session, folder_id: uuid.UUID, skip: int = 0, limit: int = 100 ) -> List[Agent]: - """Lista os agentes de uma pasta específica""" + """List the agents of a specific folder""" try: return ( db.query(Agent) @@ -780,8 +816,8 @@ def get_agents_by_folder( .all() ) except SQLAlchemyError as e: - logger.error(f"Erro ao listar agentes da pasta {folder_id}: {str(e)}") + logger.error(f"Error listing agents of folder {folder_id}: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Erro ao listar agentes da pasta", + detail="Error listing agents of folder", ) diff --git a/src/services/apikey_service.py b/src/services/apikey_service.py new file mode 100644 index 00000000..fd6ca17f --- /dev/null +++ b/src/services/apikey_service.py @@ -0,0 +1,166 @@ +from src.models.models import ApiKey +from src.utils.crypto import encrypt_api_key, decrypt_api_key +from sqlalchemy.orm import Session +from sqlalchemy.exc import SQLAlchemyError +from fastapi import HTTPException, status +import uuid +import logging +from typing import List, Optional + +logger = logging.getLogger(__name__) + + +def create_api_key( + db: Session, client_id: uuid.UUID, name: str, provider: str, key_value: str +) -> ApiKey: + """Create a new encrypted API key""" + try: + # Encrypt the key before saving + encrypted = encrypt_api_key(key_value) + + # Create the ApiKey object + api_key = ApiKey( + client_id=client_id, + name=name, + provider=provider, + encrypted_key=encrypted, + is_active=True, + ) + + # Save in the database + db.add(api_key) + db.commit() + db.refresh(api_key) + logger.info(f"API key '{name}' created for client {client_id}") + return api_key + except SQLAlchemyError as e: + db.rollback() + logger.error(f"Error creating API key: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Error creating API key: {str(e)}", + ) + + +def get_api_key(db: Session, key_id: uuid.UUID) -> Optional[ApiKey]: + """Get an API key by ID""" + try: + return db.query(ApiKey).filter(ApiKey.id == key_id).first() + except SQLAlchemyError as e: + logger.error(f"Error getting API key {key_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error getting API key", + ) + + +def get_api_keys_by_client( + db: Session, + client_id: uuid.UUID, + skip: int = 0, + limit: int = 100, + sort_by: str = "name", + sort_direction: str = "asc", +) -> List[ApiKey]: + """List the API keys of a client""" + try: + query = ( + db.query(ApiKey) + .filter(ApiKey.client_id == client_id) + .filter(ApiKey.is_active == True) + ) + + # Apply sorting + if sort_by == "name": + if sort_direction.lower() == "desc": + query = query.order_by(ApiKey.name.desc()) + else: + query = query.order_by(ApiKey.name) + elif sort_by == "provider": + if sort_direction.lower() == "desc": + query = query.order_by(ApiKey.provider.desc()) + else: + query = query.order_by(ApiKey.provider) + elif sort_by == "created_at": + if sort_direction.lower() == "desc": + query = query.order_by(ApiKey.created_at.desc()) + else: + query = query.order_by(ApiKey.created_at) + + return query.offset(skip).limit(limit).all() + except SQLAlchemyError as e: + logger.error(f"Error listing API keys for client {client_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error listing API keys", + ) + + +def get_decrypted_api_key(db: Session, key_id: uuid.UUID) -> Optional[str]: + """Get the decrypted value of an API key""" + try: + key = get_api_key(db, key_id) + if not key or not key.is_active: + logger.warning(f"API key {key_id} not found or inactive") + return None + return decrypt_api_key(key.encrypted_key) + except Exception as e: + logger.error(f"Error decrypting API key {key_id}: {str(e)}") + return None + + +def update_api_key( + db: Session, + key_id: uuid.UUID, + name: Optional[str] = None, + provider: Optional[str] = None, + key_value: Optional[str] = None, + is_active: Optional[bool] = None, +) -> Optional[ApiKey]: + """Update an API key""" + try: + key = get_api_key(db, key_id) + if not key: + return None + + if name is not None: + key.name = name + if provider is not None: + key.provider = provider + if key_value is not None: + key.encrypted_key = encrypt_api_key(key_value) + if is_active is not None: + key.is_active = is_active + + db.commit() + db.refresh(key) + logger.info(f"API key {key_id} updated") + return key + except SQLAlchemyError as e: + db.rollback() + logger.error(f"Error updating API key {key_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error updating API key", + ) + + +def delete_api_key(db: Session, key_id: uuid.UUID) -> bool: + """Remove an API key (soft delete)""" + try: + key = get_api_key(db, key_id) + if not key: + return False + + # Soft delete - only marks as inactive + key.is_active = False + db.commit() + logger.info(f"API key {key_id} deactivated") + return True + except SQLAlchemyError as e: + db.rollback() + logger.error(f"Error deleting API key {key_id}: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Error deleting API key", + ) diff --git a/src/services/workflow_agent.py b/src/services/workflow_agent.py index 94f3edb8..1a14b6a5 100644 --- a/src/services/workflow_agent.py +++ b/src/services/workflow_agent.py @@ -199,12 +199,9 @@ class WorkflowAgent(BaseAgent): content = state.get("content", []) conversation_history = state.get("conversation_history", []) - # Obter apenas o evento mais recente para avaliação latest_event = None if content and len(content) > 0: - # Ignorar eventos gerados por nós de condição para avaliação for event in reversed(content): - # Verificar se é um evento gerado pelo condition_node if ( event.author != "agent" or not hasattr(event.content, "parts") @@ -214,10 +211,10 @@ class WorkflowAgent(BaseAgent): break if latest_event: print( - f"Avaliando condição apenas para o evento mais recente: '{latest_event}'" + f"Evaluating condition only for the most recent event: '{latest_event}'" ) - # Usar apenas o evento mais recente para avaliação de condição + # Use only the most recent event for condition evaluation evaluation_state = state.copy() if latest_event: evaluation_state["content"] = [latest_event] diff --git a/src/utils/crypto.py b/src/utils/crypto.py new file mode 100644 index 00000000..b09716ff --- /dev/null +++ b/src/utils/crypto.py @@ -0,0 +1,39 @@ +from cryptography.fernet import Fernet +import os +from dotenv import load_dotenv +import logging + +logger = logging.getLogger(__name__) + +load_dotenv() + +# Get the secret key from the .env or generate one +SECRET_KEY = os.getenv("ENCRYPTION_KEY") +if not SECRET_KEY: + SECRET_KEY = Fernet.generate_key().decode() + logger.warning(f"ENCRYPTION_KEY missing from .env. Generated: {SECRET_KEY}") + +# Create the Fernet object with the key +fernet = Fernet(SECRET_KEY.encode() if isinstance(SECRET_KEY, str) else SECRET_KEY) + + +def encrypt_api_key(api_key: str) -> str: + """Encrypt an API key before saving in the database""" + if not api_key: + return "" + try: + return fernet.encrypt(api_key.encode()).decode() + except Exception as e: + logger.error(f"Error encrypting API key: {str(e)}") + raise + + +def decrypt_api_key(encrypted_key: str) -> str: + """Decrypt an API key for use""" + if not encrypted_key: + return "" + try: + return fernet.decrypt(encrypted_key.encode()).decode() + except Exception as e: + logger.error(f"Error decrypting API key: {str(e)}") + raise