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