""" Service API routes for internal service-to-service communication. This module provides endpoints for trusted internal services (e.g., automations service) to perform privileged operations like creating API keys on behalf of users. Authentication is via a shared secret (X-Service-API-Key header) configured through the AUTOMATIONS_SERVICE_API_KEY environment variable. """ import os from uuid import UUID from fastapi import APIRouter, Header, HTTPException, status from pydantic import BaseModel, field_validator from storage.api_key_store import ApiKeyStore from storage.org_member_store import OrgMemberStore from storage.user_store import UserStore from openhands.core.logger import openhands_logger as logger # Environment variable for the service API key AUTOMATIONS_SERVICE_API_KEY = os.getenv('AUTOMATIONS_SERVICE_API_KEY', '').strip() service_router = APIRouter(prefix='/api/service', tags=['Service']) class CreateUserApiKeyRequest(BaseModel): """Request model for creating an API key on behalf of a user.""" name: str # Required - used to identify the key @field_validator('name') @classmethod def validate_name(cls, v: str) -> str: if not v or not v.strip(): raise ValueError('name is required and cannot be empty') return v.strip() class CreateUserApiKeyResponse(BaseModel): """Response model for created API key.""" key: str user_id: str org_id: str name: str class ServiceInfoResponse(BaseModel): """Response model for service info endpoint.""" service: str authenticated: bool async def validate_service_api_key( x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'), ) -> str: """ Validate the service API key from the request header. Args: x_service_api_key: The service API key from the X-Service-API-Key header Returns: str: Service identifier for audit logging Raises: HTTPException: 401 if key is missing or invalid HTTPException: 503 if service auth is not configured """ if not AUTOMATIONS_SERVICE_API_KEY: logger.warning( 'Service authentication not configured (AUTOMATIONS_SERVICE_API_KEY not set)' ) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='Service authentication not configured', ) if not x_service_api_key: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail='X-Service-API-Key header is required', ) if x_service_api_key != AUTOMATIONS_SERVICE_API_KEY: logger.warning('Invalid service API key attempted') raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail='Invalid service API key', ) return 'automations-service' @service_router.get('/health') async def service_health() -> dict: """Health check endpoint for the service API. This endpoint does not require authentication and can be used to verify the service routes are accessible. """ return { 'status': 'ok', 'service_auth_configured': bool(AUTOMATIONS_SERVICE_API_KEY), } @service_router.post('/users/{user_id}/orgs/{org_id}/api-keys') async def get_or_create_api_key_for_user( user_id: str, org_id: UUID, request: CreateUserApiKeyRequest, x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'), ) -> CreateUserApiKeyResponse: """ Get or create an API key for a user on behalf of the automations service. If a key with the given name already exists for the user/org and is not expired, returns the existing key. Otherwise, creates a new key. The created/returned keys are system keys and are: - Not visible to the user in their API keys list - Not deletable by the user - Never expire Args: user_id: The user ID org_id: The organization ID request: Request body containing name (required) x_service_api_key: Service API key header for authentication Returns: CreateUserApiKeyResponse: The API key and metadata Raises: HTTPException: 401 if service key is invalid HTTPException: 404 if user not found HTTPException: 403 if user is not a member of the specified org """ # Validate service API key service_id = await validate_service_api_key(x_service_api_key) # Verify user exists user = await UserStore.get_user_by_id(user_id) if not user: logger.warning( 'Service attempted to create key for non-existent user', extra={'user_id': user_id}, ) raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f'User {user_id} not found', ) # Verify user is a member of the specified org org_member = await OrgMemberStore.get_org_member(org_id, UUID(user_id)) if not org_member: logger.warning( 'Service attempted to create key for user not in org', extra={ 'user_id': user_id, 'org_id': str(org_id), }, ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f'User {user_id} is not a member of org {org_id}', ) # Get or create the system API key api_key_store = ApiKeyStore.get_instance() try: api_key = await api_key_store.get_or_create_system_api_key( user_id=user_id, org_id=org_id, name=request.name, ) except Exception as e: logger.exception( 'Failed to get or create system API key', extra={ 'user_id': user_id, 'org_id': str(org_id), 'error': str(e), }, ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Failed to get or create API key', ) logger.info( 'Service created API key for user', extra={ 'service_id': service_id, 'user_id': user_id, 'org_id': str(org_id), 'key_name': request.name, }, ) return CreateUserApiKeyResponse( key=api_key, user_id=user_id, org_id=str(org_id), name=request.name, ) @service_router.delete('/users/{user_id}/orgs/{org_id}/api-keys/{key_name}') async def delete_user_api_key( user_id: str, org_id: UUID, key_name: str, x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'), ) -> dict: """ Delete a system API key created by the service. This endpoint allows the automations service to clean up API keys it previously created for users. Args: user_id: The user ID org_id: The organization ID key_name: The name of the key to delete (without __SYSTEM__: prefix) x_service_api_key: Service API key header for authentication Returns: dict: Success message Raises: HTTPException: 401 if service key is invalid HTTPException: 404 if key not found """ # Validate service API key service_id = await validate_service_api_key(x_service_api_key) api_key_store = ApiKeyStore.get_instance() # Delete the key by name (wrap with system key prefix since service creates system keys) system_key_name = api_key_store.make_system_key_name(key_name) success = await api_key_store.delete_api_key_by_name( user_id=user_id, org_id=org_id, name=system_key_name, allow_system=True, ) if not success: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f'API key with name "{key_name}" not found for user {user_id} in org {org_id}', ) logger.info( 'Service deleted API key for user', extra={ 'service_id': service_id, 'user_id': user_id, 'org_id': str(org_id), 'key_name': key_name, }, ) return {'message': 'API key deleted successfully'}