Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/api/chat_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@
from src.services.adk.memory import MemoryLimitExceeded
from src.services.session_service import SessionLimitExceeded
from src.middleware.permissions import RequirePermission
from src.services.permission_service import PermissionService

# Runtime permission an agent-bot key must hold to invoke an agent over a chat
# WebSocket. WebSocket routes bypass the HTTP RequirePermission gate, so this
# confinement is enforced inline against the same allowlist.
WS_RUNTIME_PERMISSION = "ai_agent_processor.execute"

import logging
import json
Expand Down Expand Up @@ -93,11 +99,18 @@ async def get_jwt_token_ws(token: str, skip_validation: bool = False) -> Optiona
if auth_response and auth_response.user:
# Return user context similar to what middleware does
user = auth_response.user.dict() if hasattr(auth_response.user, 'dict') else auth_response.user
# Surface agent-bot identity when the token is bound to an agent
# (metadata.agent_id), mirroring EvoAuthMiddleware, so WebSocket
# handlers can enforce the same per-agent confinement.
metadata = getattr(auth_response, "metadata", None) or {}
bot_agent_id = metadata.get("agent_id")
return {
"sub": user.get("email") or user.get("id"),
"email": user.get("email"),
"user_id": user.get("id"),
"user": user,
"is_agent_bot": bool(bot_agent_id),
"agent_id": bot_agent_id,
}
except Exception as e:
logger.warning(f"EvoAuth token validation failed: {str(e)}")
Expand Down Expand Up @@ -144,12 +157,14 @@ async def websocket_chat(

# Verify authentication
is_authenticated = False
auth_payload = None

# Try with token (Bearer token from EvoAuth)
if auth_data.get("token"):
try:
payload = await get_jwt_token_ws(auth_data["token"], True)
if payload:
auth_payload = payload
user_id = payload.get("user_id") or payload.get("sub")
is_authenticated = True
logger.info(f"WebSocket: User {user_id} authenticated for agent {agent_id}")
Expand All @@ -165,6 +180,18 @@ async def websocket_chat(
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return

# Confine agent-bot keys: a bot key may only invoke its OWN agent, and
# only for the runtime action. Reject at the handshake before any work.
if not PermissionService.is_agent_bot_permission_allowed(
auth_payload, websocket.url.path, WS_RUNTIME_PERMISSION
):
logger.warning(
f"WebSocket: Agent Bot for agent {(auth_payload or {}).get('agent_id')} "
f"denied access to agent {agent_id}"
)
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
return

logger.info(f"WebSocket authenticated for agent {agent_id}")

# Main message loop
Expand Down Expand Up @@ -402,6 +429,7 @@ async def websocket_live_chat(
# Start authentication process
logger.info("=== STARTING AUTHENTICATION PROCESS ===")
is_authenticated = False
auth_payload = None

# Try with JWT token first
if auth_data.get("token"):
Expand All @@ -414,6 +442,7 @@ async def websocket_live_chat(
logger.info(f"JWT payload received: {bool(payload)}")

if payload:
auth_payload = payload
logger.info(f"JWT payload keys: {list(payload.keys())}")
logger.info(f"JWT payload user: {payload.get('sub', 'N/A')}")
logger.info(f"JWT payload email: {payload.get('email', 'N/A')}")
Expand Down Expand Up @@ -514,6 +543,21 @@ async def websocket_live_chat(
pass # Already disconnected
return

# Confine agent-bot keys: a bot key may only invoke its OWN agent, and
# only for the runtime action. Reject at the handshake before any work.
if not PermissionService.is_agent_bot_permission_allowed(
auth_payload, websocket.url.path, WS_RUNTIME_PERMISSION
):
logger.warning(
f"❌ Live WebSocket: Agent Bot for agent "
f"{(auth_payload or {}).get('agent_id')} denied access to agent {agent_id}"
)
try:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
except:
pass # Already disconnected
return

logger.info(
f"✅ Live WebSocket authenticated successfully for agent {agent_id}"
)
Expand Down
83 changes: 53 additions & 30 deletions src/api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,27 @@ async def verify_agent_access(
db: Session,
agent: Any, # Agent object
required_permission: str = "read",
user_context: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, bool]:
"""
Checks if the user has access to an agent, either by:
1. Direct client ownership (admin or client user)
2. Folder sharing permissions (for agents in shared folders)
Object-level access check for an agent.

The Community box is single-tenant: agents carry no owner/account column,
so every authenticated user shares one agent pool and the coarse RBAC
lives in the route-level RequirePermission gates. What this enforces:

- Agent-bot credentials (is_agent_bot) act only on their OWN agent: a
bot key for agent A is denied on agent B.
- A missing user_context fails closed, so call sites cannot skip the
check accidentally.
- Regular users keep pool access; the return flags it as shared access
when it flows through an active folder share for the user's email.

Args:
#Removed for further handling - payload: JWT payload with user information
db: Database session
agent: Agent object to be checked
required_permission: Required permission ("read" or "write")
user_context: Authenticated context set by EvoAuthMiddleware

Returns:
tuple: (has_access: bool, is_shared_access: bool)
Expand All @@ -55,33 +65,46 @@ async def verify_agent_access(
Raises:
HTTPException: If access is denied
"""
try:
return True, False # Access granted by direct ownership
except HTTPException as client_error:
# If direct access fails, check folder sharing
if agent.folder_id:
# Waiting for token implementation to get the user's email
user_email = None
if user_email:
has_folder_access = folder_share_service.check_folder_access(
db, agent.folder_id, user_email, required_permission
if not user_context:
logger.error("verify_agent_access called without user_context - denying access")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)

if user_context.get("is_agent_bot"):
bot_agent_id = str(user_context.get("agent_id") or "")
if bot_agent_id and bot_agent_id == str(agent.id):
return True, False

logger.warning(
f"Agent Bot for agent {bot_agent_id or '<unknown>'} denied access to agent {agent.id}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Agent API Key can only access its own agent resources",
)

user_email = user_context.get("email")
if agent.folder_id and user_email:
try:
if folder_share_service.check_folder_access(db, agent.folder_id, user_email, required_permission):
logger.info(
f"User {user_email} granted {required_permission} access to agent {agent.id} "
f"via shared folder {agent.folder_id}"
)
if has_folder_access:
logger.info(
f"Usuário {user_email} recebeu acesso {required_permission} ao agente {agent.id} via pasta compartilhada {agent.folder_id}"
)
return True, True
else:
logger.warning(
f"Usuário {user_email} negado ao agente {agent.id} - sem permissão de pasta compartilhada"
)
else:
logger.warning("Nenhum e-mail de usuário encontrado no token para verificação de pasta compartilhada")
else:
logger.info(
f"Agente {agent.id} não está em uma pasta, não é possível verificar compartilhamento de pasta"
)
raise client_error
return True, True
except HTTPException:
# An explicit denial from the share service must fail closed: it
# must not be swallowed and downgraded into a silent grant.
raise
except Exception as e:
# Transient infra errors do not revoke the single-tenant pool
# access every authenticated user already has; only the shared
# upgrade is withheld.
logger.warning(f"Folder share lookup failed for agent {agent.id}: {e}")

return True, False

def get_request_optional(request: Request) -> Request:
"""Dependency to provide the Request object, making it optional in endpoint signatures."""
Expand Down
21 changes: 10 additions & 11 deletions src/api/session_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,8 @@ async def create_new_session(
status_code=status.HTTP_404_NOT_FOUND
)

# Verify access (skip for agent bots as they're already validated)
if not is_agent_bot:
has_access, is_shared_access = await verify_agent_access(db, agent, "read")
# Verify access (agent bots are constrained to their own agent inside)
has_access, is_shared_access = await verify_agent_access(db, agent, "read", current_user)

# Get user identifier
default_user_id = str(current_user.get("user_id") or current_user.get("email") or "") if current_user else ""
Expand Down Expand Up @@ -373,7 +372,7 @@ async def get_agent_sessions(
)

# Verify if the user has access to the agent (including shared folder access)
has_access, is_shared_access = await verify_agent_access(db, agent, "read")
has_access, is_shared_access = await verify_agent_access(db, agent, "read", current_user)

# List ALL sessions for the agent (both test sessions and real sessions)

Expand Down Expand Up @@ -448,7 +447,7 @@ async def bulk_delete_sessions(
if agent:
try:
has_access, is_shared_access = await verify_agent_access(
db, agent, "read"
db, agent, "read", current_user
)
validated_session_ids.append(session_id)
except HTTPException as e:
Expand Down Expand Up @@ -542,7 +541,7 @@ async def get_session(
agent = await agent_service.get_agent(db, agent_id)
if agent:
has_access, is_shared_access = await verify_agent_access(
db, agent, "read"
db, agent, "read", current_user
)

return success_response(
Expand Down Expand Up @@ -595,7 +594,7 @@ async def get_agent_messages(
agent = await agent_service.get_agent(db, agent_id)
if agent:
has_access, is_shared_access = await verify_agent_access(
db, agent, "read"
db, agent, "read", current_user
)

# Get app_name and user_id from the session object instead of parsing session_id
Expand Down Expand Up @@ -787,7 +786,7 @@ async def remove_session(
agent = await agent_service.get_agent(db, agent_id)
if agent:
has_access, is_shared_access = await verify_agent_access(
db, agent, "read"
db, agent, "read", current_user
)

# Delete the session (from both database and ADK)
Expand Down Expand Up @@ -850,7 +849,7 @@ async def get_session_metadata_endpoint(
agent = await agent_service.get_agent(db, agent_id)
if agent:
has_access, is_shared_access = await verify_agent_access(
db, agent, "read"
db, agent, "read", current_user
)

# Get metadata
Expand Down Expand Up @@ -907,7 +906,7 @@ async def update_session_metadata_endpoint(
agent = await agent_service.get_agent(db, agent_id)
if agent:
has_access, is_shared_access = await verify_agent_access(
db, agent, "write"
db, agent, "write", current_user
)

# Use user_id from the authenticated user
Expand Down Expand Up @@ -978,7 +977,7 @@ async def delete_session_metadata_endpoint(
agent = await agent_service.get_agent(db, agent_id)
if agent:
has_access, is_shared_access = await verify_agent_access(
db, agent, "write"
db, agent, "write", current_user
)

# Use user_id from the authenticated user
Expand Down
Loading
Loading