from fastapi import APIRouter, HTTPException, Header, Request from typing import Optional, List import logging import secrets import string from datetime import datetime, timedelta from pydantic import BaseModel from database.connection import get_database_connection, close_database_connection from auth_middleware import get_current_user_from_token from utils.activity_log import log_user_activity router = APIRouter(prefix="/api/security", tags=["security"]) logger = logging.getLogger(__name__) class AgentTokenCreate(BaseModel): name: str description: Optional[str] = None expires_in_days: Optional[int] = None # None = no expiration class AgentTokenResponse(BaseModel): id: int agent_id: int agent_name: str name: str description: Optional[str] token: Optional[str] = None # Only returned when creating created_at: datetime expires_at: Optional[datetime] last_used: Optional[datetime] created_by_username: str is_expired: bool def generate_secure_api_key() -> str: """Generate a cryptographically secure API key""" # Generate 32 bytes of random data and encode as base64 random_bytes = secrets.token_bytes(32) # Use URL-safe base64 encoding and remove padding api_key = secrets.token_urlsafe(32) return f"hap_{api_key}" @router.get("/agent-tokens") async def get_agent_tokens(authorization: str = Header(None)): """Get all agent API tokens (without revealing the actual tokens)""" try: current_user = await get_current_user_from_token(authorization) conn = await get_database_connection() # Get tokens with agent counts and details tokens = await conn.fetch(""" SELECT a.api_key, a.api_key_name as name, a.api_key_created_at as created_at, a.api_key_expires_at as expires_at, a.api_key_last_used as last_used, u.username as created_by_username, CASE WHEN a.api_key_expires_at IS NOT NULL AND a.api_key_expires_at < CURRENT_TIMESTAMP THEN true ELSE false END as is_expired, COUNT(CASE WHEN a.name NOT LIKE 'token_%' THEN 1 END) as agent_count, ARRAY_AGG( CASE WHEN a.name NOT LIKE 'token_%' THEN a.name END ) FILTER (WHERE a.name NOT LIKE 'token_%') as agent_names, MIN(a.id) as primary_agent_id FROM agents a LEFT JOIN users u ON a.api_key_created_by = u.id WHERE a.api_key IS NOT NULL AND a.enabled = TRUE GROUP BY a.api_key, a.api_key_name, a.api_key_created_at, a.api_key_expires_at, a.api_key_last_used, u.username ORDER BY a.api_key_created_at DESC """) await close_database_connection(conn) return { "tokens": [dict(token) for token in tokens], "total": len(tokens) } except HTTPException: raise except Exception as e: logger.error(f"Failed to fetch agent tokens: {e}") raise HTTPException(status_code=500, detail="Failed to fetch agent tokens") @router.post("/agent-tokens") async def create_agent_token(token_data: AgentTokenCreate, request: Request, authorization: str = Header(None)): """Create a new API token for an agent""" try: current_user = await get_current_user_from_token(authorization) conn = await get_database_connection() # Generate secure API key api_key = generate_secure_api_key() # Calculate expiration date expires_at = None if token_data.expires_in_days: expires_at = datetime.utcnow() + timedelta(days=token_data.expires_in_days) # Create a placeholder agent entry for standalone token # Agent will update this when it registers with this token placeholder_name = f"token_{api_key[-8:]}" # Last 8 chars of token as placeholder name agent_id = await conn.fetchval(""" INSERT INTO agents ( name, api_key, api_key_name, api_key_created_at, api_key_expires_at, api_key_created_by, status, enabled, created_at, updated_at ) VALUES ($1, $2, $3, CURRENT_TIMESTAMP, $4, $5, 'pending', TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) RETURNING id """, placeholder_name, api_key, token_data.name, expires_at, current_user['id']) await close_database_connection(conn) # Log activity await log_user_activity( user_id=current_user["id"], action='create_api_token', resource_type='agent', resource_id=str(agent_id), details={ 'agent_name': placeholder_name, 'token_name': token_data.name, 'expires_in_days': token_data.expires_in_days, 'token_type': 'standalone' }, ip_address=str(request.client.host) if request.client else None, user_agent=request.headers.get('user-agent') ) logger.info(f"Created standalone API token '{token_data.name}' (placeholder: {placeholder_name})") return { "message": "API token created successfully", "agent_name": placeholder_name, "token_name": token_data.name, "api_key": api_key, # Return the token only once "expires_at": expires_at.isoformat() if expires_at else None } except HTTPException: raise except Exception as e: logger.error(f"Failed to create agent token: {e}") raise HTTPException(status_code=500, detail="Failed to create agent token") @router.delete("/agent-tokens/{agent_id}") async def revoke_agent_token(agent_id: int, request: Request, authorization: str = Header(None)): """Revoke an agent's API token""" try: current_user = await get_current_user_from_token(authorization) conn = await get_database_connection() # Get agent info and API key before revoking agent_info = await conn.fetchrow(""" SELECT api_key, api_key_name, COUNT(*) OVER (PARTITION BY api_key) as agent_count, ARRAY_AGG(name) OVER (PARTITION BY api_key) as agent_names FROM agents WHERE id = $1 AND enabled = TRUE AND api_key IS NOT NULL """, agent_id) if not agent_info: await close_database_connection(conn) raise HTTPException(status_code=404, detail="Agent or API token not found") api_key = agent_info['api_key'] token_name = agent_info['api_key_name'] # Revoke the API key from ALL agents using this token revoked_count = await conn.fetchval(""" UPDATE agents SET api_key = NULL, api_key_name = NULL, api_key_created_at = NULL, api_key_expires_at = NULL, api_key_last_used = NULL, api_key_created_by = NULL, updated_at = CURRENT_TIMESTAMP WHERE api_key = $1 RETURNING (SELECT COUNT(*) FROM agents WHERE api_key = $1) """, api_key) # Delete placeholder agents (token_*) after revoking await conn.execute(""" DELETE FROM agents WHERE name LIKE 'token_%' AND api_key IS NULL """) await close_database_connection(conn) # Log activity await log_user_activity( user_id=current_user["id"], action='revoke_api_token', resource_type='token', resource_id=str(agent_id), details={ 'token_name': token_name, 'affected_agents': agent_info['agent_names'], 'revoked_count': agent_info['agent_count'] }, ip_address=str(request.client.host) if request.client else None, user_agent=request.headers.get('user-agent') ) logger.info(f"🔒 Revoked API token '{token_name}' affecting {agent_info['agent_count']} agent(s): {agent_info['agent_names']}") return { "message": f"API token revoked successfully. Affected {agent_info['agent_count']} agent(s).", "token_name": token_name, "affected_agents": agent_info['agent_names'] } except HTTPException: raise except Exception as e: logger.error(f"Failed to revoke agent token: {e}") raise HTTPException(status_code=500, detail="Failed to revoke agent token") @router.delete("/agent-tokens/by-name/{token_name}") async def revoke_agent_token_by_name(token_name: str, request: Request, authorization: str = Header(None)): """Revoke an agent token by token name""" try: current_user = await get_current_user_from_token(authorization) conn = await get_database_connection() # Get token info and all agents using this token token_info = await conn.fetchrow(""" SELECT api_key, api_key_name, COUNT(*) as agent_count, ARRAY_AGG(name) as agent_names FROM agents WHERE api_key_name = $1 AND enabled = TRUE AND api_key IS NOT NULL GROUP BY api_key, api_key_name """, token_name) if not token_info: await close_database_connection(conn) raise HTTPException(status_code=404, detail="Token not found") api_key = token_info['api_key'] # Revoke the API key from ALL agents using this token await conn.execute(""" UPDATE agents SET api_key = NULL, api_key_name = NULL, api_key_created_at = NULL, api_key_expires_at = NULL, api_key_last_used = NULL, api_key_created_by = NULL, updated_at = CURRENT_TIMESTAMP WHERE api_key = $1 """, api_key) # Delete placeholder agents (token_*) after revoking await conn.execute(""" DELETE FROM agents WHERE name LIKE 'token_%' AND api_key IS NULL """) await close_database_connection(conn) # Log activity await log_user_activity( user_id=current_user["id"], action='revoke_api_token', resource_type='token', resource_id=token_name, details={ 'token_name': token_name, 'affected_agents': token_info['agent_names'], 'revoked_count': token_info['agent_count'] }, ip_address=str(request.client.host) if request.client else None, user_agent=request.headers.get('user-agent') ) logger.info(f"🔒 Revoked API token '{token_name}' affecting {token_info['agent_count']} agent(s): {token_info['agent_names']}") return { "message": f"API token '{token_name}' revoked successfully. Affected {token_info['agent_count']} agent(s).", "token_name": token_name, "affected_agents": token_info['agent_names'] } except HTTPException: raise except Exception as e: logger.error(f"Failed to revoke agent token by name: {e}") raise HTTPException(status_code=500, detail="Failed to revoke agent token") @router.get("/agents-without-tokens") async def get_agents_without_tokens(authorization: str = Header(None)): """Get agents that don't have API tokens yet""" try: current_user = await get_current_user_from_token(authorization) conn = await get_database_connection() # First, let's see all agents all_agents = await conn.fetch(""" SELECT id, name, hostname, status, last_seen, api_key, enabled FROM agents ORDER BY name """) logger.info(f"SECURITY DEBUG: All agents in database: {len(all_agents)}") for agent in all_agents: logger.info(f"SECURITY DEBUG: Agent {agent['name']}: api_key={agent['api_key'] is not None}, enabled={agent['enabled']}") agents = await conn.fetch(""" SELECT id, name, hostname, status, last_seen FROM agents WHERE api_key IS NULL AND enabled = TRUE ORDER BY name """) logger.info(f"SECURITY DEBUG: Agents without tokens: {len(agents)}") await close_database_connection(conn) return { "agents": [dict(agent) for agent in agents], "total": len(agents) } except HTTPException: raise except Exception as e: logger.error(f"Failed to fetch agents without tokens: {e}") raise HTTPException(status_code=500, detail="Failed to fetch agents")