feat(api): implement secure API key management with encryption and CRUD operations

This commit is contained in:
Davidson Gomes 2025-05-07 13:08:59 -03:00
parent 72f666aca1
commit 17b4c20a4c
15 changed files with 773 additions and 141 deletions

View File

@ -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"

159
README.md
View File

@ -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
```

View File

@ -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)
sys.exit(0 if success else 1)

View File

@ -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"
)

View File

@ -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"
)

View File

@ -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)
sys.exit(0 if success else 1)

View File

@ -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:

View File

@ -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")

View File

@ -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")

View File

@ -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

View File

@ -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 += "<get_current_time_instructions>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.</get_current_time_instructions>\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\n<memory_instructions>ALWAYS use the load_memory tool to retrieve knowledge for your context</memory_instructions>\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,

View File

@ -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",
)

View File

@ -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",
)

View File

@ -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]

39
src/utils/crypto.py Normal file
View File

@ -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