diff --git a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/README.MD b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/README.MD index 6b9a03da7..366bd10e8 100644 --- a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/README.MD +++ b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/README.MD @@ -12,8 +12,8 @@ This CDK infrastructure deploys a complete MCP gateway solution that enables VS - **Policy-Based Access Control** with custom Cedar policies - **Request/Response Interception** for logging and transformation - **PII Protection** using Bedrock Guardrails -- **Flexible Deployment Options** (ALB or API Gateway) - **Custom Domain Support** with SSL/TLS +- **ALB Access Logging** to S3 with encryption and lifecycle management ## Architecture @@ -25,10 +25,8 @@ This CDK infrastructure deploys a complete MCP gateway solution that enables VS - **OAuth Clients**: - VS Code Client (Authorization Code Grant with PKCE) -#### 2. **API Gateway Layer** -Choose between two deployment options: -- **Application Load Balancer (ALB)**: Production-grade with custom domains and SSL/TLS -- **API Gateway HTTP API**: Serverless, cost-effective for development/testing +#### 2. **Application Load Balancer** +Production-grade internet-facing ALB with custom domain, SSL/TLS, WAF, and access logging. #### 3. **MCP Proxy Lambda** Central component that handles: @@ -90,12 +88,12 @@ Example Lambda functions that implement MCP tools: | Amazon Cognito | OAuth 2.0 authentication and user management | | AWS Lambda | Serverless compute for proxy, MCP servers, and policy engine | | Amazon Bedrock AgentCore | MCP gateway and protocol handling | -| Application Load Balancer | Production routing with custom domains (optional) | -| API Gateway HTTP API | Serverless API endpoint (optional) | -| Amazon VPC | Network isolation for ALB deployment | +| Application Load Balancer | Internet-facing ALB with TLS, WAF, and access logging | +| Amazon VPC | Network isolation with private subnets and VPC endpoints | | AWS IAM | Identity and access management | | Amazon Route53 | DNS management for custom domains | | AWS Certificate Manager | SSL/TLS certificates | +| Amazon S3 | ALB access log storage (encrypted, 90-day lifecycle) | | Bedrock Guardrails | Content filtering and PII protection | ## Prerequisites @@ -116,7 +114,7 @@ Example Lambda functions that implement MCP tools: ### 1. Install Dependencies ```bash -cd enterprise-mcp-infra/cdk +cd cdk npm install ``` @@ -130,11 +128,8 @@ cdk bootstrap aws://ACCOUNT-ID/REGION Edit `cdk/cdk.context.json` to configure your deployment: -#### Option A: ALB Deployment with Custom Domain - ```json { - "deploymentType": "ALB", "domainName": "enterprise-mcp", "hostedZoneName": "example.com", "hostedZoneId": "Z1234567890ABC", @@ -142,39 +137,14 @@ Edit `cdk/cdk.context.json` to configure your deployment: } ``` -#### Option B: API Gateway Deployment (Default URL) - -```json -{ - "deploymentType": "API_GATEWAY", - "domainName": "", - "hostedZoneName": "", - "hostedZoneId": "", - "certificateArn": "" -} -``` - -#### Option C: API Gateway with Custom Domain - -```json -{ - "deploymentType": "API_GATEWAY", - "domainName": "enterprise-mcp.example.com", - "hostedZoneName": "example.com", - "hostedZoneId": "Z1234567890ABC", - "certificateArn": "arn:aws:acm:region:account:certificate/xxx" -} -``` - **Configuration Parameters:** | Parameter | Description | Required | Default | |-----------|-------------|----------|---------| -| `deploymentType` | Deployment type: `ALB` or `API_GATEWAY` | Yes | `ALB` | -| `domainName` | Custom domain name (e.g., `enterprise-mcp` for ALB, or full domain for API Gateway) | No (API Gateway only) | `""` | -| `hostedZoneName` | Route53 hosted zone name (e.g., `example.com`) | Only with custom domain | `""` | -| `hostedZoneId` | Route53 hosted zone ID (e.g., `Z1234567890ABC`) | Only with custom domain | `""` | -| `certificateArn` | ACM certificate ARN for HTTPS | Only with custom domain | `""` | +| `domainName` | Custom domain name (e.g., `enterprise-mcp`) | Yes | `""` | +| `hostedZoneName` | Route53 hosted zone name (e.g., `example.com`) | Yes | `""` | +| `hostedZoneId` | Route53 hosted zone ID (e.g., `Z1234567890ABC`) | Yes | `""` | +| `certificateArn` | ACM certificate ARN for HTTPS | Yes | `""` | ### 4. Deploy the Stack @@ -182,11 +152,7 @@ Edit `cdk/cdk.context.json` to configure your deployment: cdk deploy ``` -You can also override context values via command line: - -```bash -cdk deploy -c deploymentType=API_GATEWAY -``` +> **Note:** The stack is pinned to `us-east-1` in `cdk/bin/enterprise-mcp-infra.ts`. Update the `region` value there if you need a different region. ### 5. Save CDK Outputs @@ -208,25 +174,26 @@ EnterpriseMcpInfraStack.Gateway = agentcore-mcp-gateway-xxxxx #### Using the Automated Script (Recommended) -1. **Edit** `enterprise-mcp-infra/scripts/script.py`: +1. **Edit** `scripts/script.py`: - Replace the `output` variable content with your actual CDK outputs (from step 4) - Customize the users list with your desired email addresses and passwords 2. **Run the script** to create users: ```bash -cd enterprise-mcp-infra/scripts +cd scripts python script.py ``` The script will: - Parse the CDK outputs to extract the User Pool ID -- Create two default users: `vscode-admin@example.com` and `vscode-user@example.com` +- Create three default users (admin, regular, and read-only) - Set permanent passwords (no need for password reset on first login) - Skip users that already exist **Default Users Created:** - `vscode-admin@example.com` / `TempPassword123!` - `vscode-user@example.com` / `TempPassword1234!` +- `vscode-readonly@example.com` / `TempPassword1235!` #### Manual User Creation (Alternative) @@ -374,15 +341,26 @@ cdk deploy ### Lambda Logs -```bash -# MCP Proxy Lambda -aws logs tail /aws/lambda/ --follow - -# Policy Engine Lambda -aws logs tail /aws/lambda/ --follow +The CDK stack outputs the function names for the two most commonly debugged Lambdas (`ProxyLambdaName`, `PreTokenGenerationLambdaName`). For the others, look up the auto-generated name in the AWS Console (Lambda → Functions, filter by stack name) or use the AWS CLI: -# Interceptor Lambda -aws logs tail /aws/lambda/ --follow +```bash +# MCP Proxy Lambda – name from CDK output: EnterpriseMcpInfraStack.ProxyLambdaName +aws logs tail /aws/lambda/$(aws cloudformation describe-stacks \ + --stack-name EnterpriseMcpInfraStack \ + --query "Stacks[0].Outputs[?OutputKey=='ProxyLambdaName'].OutputValue" \ + --output text) --follow + +# Pre-Token Generation Lambda – name from CDK output: EnterpriseMcpInfraStack.PreTokenGenerationLambdaName +aws logs tail /aws/lambda/$(aws cloudformation describe-stacks \ + --stack-name EnterpriseMcpInfraStack \ + --query "Stacks[0].Outputs[?OutputKey=='PreTokenGenerationLambdaName'].OutputValue" \ + --output text) --follow + +# Interceptor Lambda – look up name in AWS Console (filter by stack: EnterpriseMcpInfraStack) +# aws logs tail /aws/lambda/ --follow + +# Policy Engine Lambda – look up name in AWS Console (filter by stack: EnterpriseMcpInfraStack) +# aws logs tail /aws/lambda/ --follow ``` ### CloudWatch Insights Queries @@ -401,24 +379,66 @@ fields @timestamp, method, path, statusCode | sort @timestamp desc ``` -## Security Considerations - -### Authentication +## Security Posture + +### Implemented + +| Feature | Details | +|---|---| +| Cognito User Pool | Admin-only sign-up, strong password policy, Pre-Token Generation Lambda for audience/role claims | +| OAuth 2.0 | Authorization Code Grant with custom scopes (`mcp.read`, `mcp.write`) | +| JWT audience validation | Proxy Lambda validates `aud` claim before forwarding to AgentCore | +| AgentCore Cognito authorizer | Token verified a second time by AWS at the gateway level | +| Cedar policy engine | Fine-grained per-user tool access in ENFORCE mode | +| Bedrock Guardrails | PII masking (address, name, email) and blocking (credit card numbers) via interceptor | +| Lambda-in-VPC proxy | Private subnet, NAT egress only | +| VPC Interface Endpoint | AgentCore traffic stays on AWS private network, never crosses public internet | +| ALB TLS termination | TLS 1.2+ on custom domain via ACM certificate | +| ALB `dropInvalidHeaderFields` | Rejects malformed headers (request-smuggling mitigation) | +| ALB Host-header gating | Every forwarding rule requires Host header match; raw `*.elb` DNS returns 404 | +| HTTP → HTTPS redirect | Permanent redirect on port 80 | +| WAF WebACL | IP rate limit (1,000 req/5 min), AWS IP Reputation list, Core Rule Set (OWASP Top 10), Known Bad Inputs | +| WAF Bot Control | COMMON level in COUNT mode (switch to BLOCK after traffic validation) | +| Reserved Lambda concurrency | Caps on all functions to limit DoS blast radius | +| Gateway resource policy | Restricts `InvokeGateway` to the VPC | +| Shield Standard | Automatic L3/L4 DDoS protection on public ALBs | +| ALB access logging | S3 bucket with SSE, public access blocked, SSL enforced, 90-day lifecycle expiration | +| Redirect URI allowlist | `handle_callback` validates `redirect_uri` against registered Cognito callback URLs before issuing 302 redirects (prevents open-redirect / auth code theft) | +| Per-Lambda IAM roles | Four dedicated least-privilege roles: `preTokenLambdaRole` (Cognito trigger), `proxyLambdaRole` (VPC + AgentCore invoke), `interceptorLambdaRole` (Bedrock Guardrails only), `toolLambdaRole` (CloudWatch Logs only) | + +### Not Implemented – Consider Before Production + +| Feature | Details | +|---|---| +| Shield Advanced | L7 DDoS protection, SRT access, cost protection (subscription required) | +| Bot Control TARGETED | Higher inspection level for WAF Bot Control (additional cost) | +| CloudTrail / Security Hub | Centralized audit and security findings | +| ALB access-log Athena workgroup | Query access logs via Athena for forensic analysis | +| GuardDuty findings | Threat detection integration | +| MFA enforcement | Cognito User Pool is MFA-ready but not enforced (`mfa: cognito.Mfa.REQUIRED`) | +| Scoped IAM resources | Several policies use `Resource: "*"` — scope to specific ARNs | +| PKCE enforcement | Verify PKCE is enforced on the Cognito public client (no client secret) | +| Log encryption | Lambda CloudWatch logs use default settings (no KMS CMK encryption) | +| Log retention policy | Lambda CloudWatch log retention is indefinite by default | + +### Additional Security Details + +#### Authentication - OAuth 2.0 with PKCE (Proof Key for Code Exchange) - JWT tokens with custom claims - Secure token storage in VS Code -### Authorization +#### Authorization - Policy-based access control using Cedar - User attribute injection via Lambda triggers - Gateway-level authorization enforcement -### Data Protection +#### Data Protection - SSL/TLS encryption in transit - PII anonymization via Bedrock Guardrails -- VPC isolation for ALB deployments +- VPC isolation with private subnets and VPC endpoints -### Secrets Management +#### Secrets Management - Client secrets stored in environment variables - OAuth tokens never exposed to logs - IAM role-based access for Lambda functions @@ -452,10 +472,12 @@ cdk destroy ## Architecture Decisions -### Why Two Deployment Options? +### Why ALB? -- **ALB**: Production environments requiring custom domains, SSL/TLS, and fine-grained routing -- **API Gateway**: Development/testing, serverless preference, cost optimization +- Production-grade with custom domains, SSL/TLS, and fine-grained routing +- WAF WebACL integration for OWASP Top 10, rate limiting, and bot control +- Access logging to S3 for forensic analysis +- VPC integration for network isolation ### Why Lambda for MCP Servers? diff --git a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/assets/architecture.png b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/assets/architecture.png index cfe24cee3..15ae2ce19 100644 Binary files a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/assets/architecture.png and b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/assets/architecture.png differ diff --git a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/README.md b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/README.md index 9315fe5b9..ca792f69e 100644 --- a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/README.md +++ b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/README.md @@ -1,14 +1,123 @@ -# Welcome to your CDK TypeScript project +# Enterprise MCP Gateway – CDK Infrastructure -This is a blank project for CDK development with TypeScript. +This CDK stack deploys an enterprise-grade MCP (Model Context Protocol) gateway backed by Amazon Bedrock AgentCore, using an Application Load Balancer (ALB) with full security hardening. -The `cdk.json` file tells the CDK Toolkit how to execute your app. +## Architecture Overview -## Useful commands +The stack provisions: -* `npm run build` compile typescript to js -* `npm run watch` watch for changes and compile -* `npm run test` perform the jest unit tests -* `npx cdk deploy` deploy this stack to your default AWS account/region -* `npx cdk diff` compare deployed stack with current state -* `npx cdk synth` emits the synthesized CloudFormation template +- **Cognito User Pool** with OAuth 2.0 Authorization Code Grant, custom scopes (`mcp.read` / `mcp.write`), and a Pre-Token Generation Lambda for audience/role claim injection. +- **AgentCore Gateway** with Cognito authorizer, Cedar policy engine (ENFORCE mode), and Bedrock Guardrails (PII masking/blocking). +- **Lambda functions**: MCP proxy, weather tool, inventory tool, user details tool, interceptor, and pre-token generation. +- **VPC** with private subnets, NAT gateway, and VPC Interface Endpoint for AgentCore. +- **Internet-facing ALB** with TLS termination, WAF WebACL, and access logging. + +## Prerequisites + +- AWS CDK v2 installed (`npm install -g aws-cdk`) +- Node.js 18+ +- An ACM certificate and Route 53 hosted zone for the custom domain +- Python 3.12 (for Lambda bundling) + +## Configuration + +Set CDK context variables in `cdk.context.json` or via `-c` flags: + +| Variable | Description | Default | +|---|---|---| +| `domainName` | Custom domain name (e.g. `enterprise-mcp`) | `""` | +| `hostedZoneName` | Route 53 hosted zone name | `""` | +| `hostedZoneId` | Route 53 hosted zone ID | `""` | +| `certificateArn` | ACM certificate ARN | `""` | + +## Deployment + +```bash +# From the cdk/ directory +npm install +npx cdk synth +npx cdk deploy +``` + +> **Note:** The stack is pinned to `us-east-1` in `bin/enterprise-mcp-infra.ts`. Update the `region` value there if you need a different region. + +## Useful Commands + +| Command | Description | +|---|---| +| `npm run build` | Compile TypeScript to JS | +| `npm run watch` | Watch for changes and compile | +| `npm run test` | Run Jest unit tests | +| `npx cdk synth` | Emit the synthesized CloudFormation template | +| `npx cdk diff` | Compare deployed stack with current state | +| `npx cdk deploy` | Deploy this stack | +| `npx cdk destroy` | Tear down the stack | + +## Security Posture + +### Implemented + +| Feature | Details | +|---|---| +| Cognito User Pool | Admin-only sign-up, strong password policy, Pre-Token Generation Lambda for audience/role claims | +| OAuth 2.0 | Authorization Code Grant with custom scopes (`mcp.read`, `mcp.write`) | +| JWT audience validation | Proxy Lambda validates `aud` claim before forwarding to AgentCore | +| AgentCore Cognito authorizer | Token verified a second time by AWS at the gateway level | +| Cedar policy engine | Fine-grained per-user tool access in ENFORCE mode | +| Bedrock Guardrails | PII masking (address, name, email) and blocking (credit card numbers) via interceptor | +| Lambda-in-VPC proxy | Private subnet, NAT egress only | +| VPC Interface Endpoint | AgentCore traffic stays on AWS private network, never crosses public internet | +| ALB TLS termination | TLS 1.2+ on custom domain via ACM certificate | +| ALB `dropInvalidHeaderFields` | Rejects malformed headers (request-smuggling mitigation) | +| ALB Host-header gating | Every forwarding rule requires Host header match; raw `*.elb` DNS returns 404 | +| HTTP → HTTPS redirect | Permanent redirect on port 80 | +| WAF WebACL | IP rate limit (1,000 req/5 min), AWS IP Reputation list, Core Rule Set (OWASP Top 10), Known Bad Inputs | +| WAF Bot Control | COMMON level in COUNT mode (switch to BLOCK after traffic validation) | +| Reserved Lambda concurrency | Caps on all functions to limit DoS blast radius | +| Gateway resource policy | Restricts `InvokeGateway` to the VPC | +| Shield Standard | Automatic L3/L4 DDoS protection on public ALBs | +| ALB access logging | S3 bucket with SSE, public access blocked, SSL enforced, 90-day lifecycle expiration | +| Redirect URI allowlist | `handle_callback` validates `redirect_uri` against registered Cognito callback URLs before issuing 302 redirects (prevents open-redirect / auth code theft) | +| Per-Lambda IAM roles | Four dedicated least-privilege roles: `preTokenLambdaRole` (Cognito trigger), `proxyLambdaRole` (VPC + AgentCore invoke), `interceptorLambdaRole` (Bedrock Guardrails only), `toolLambdaRole` (CloudWatch Logs only) | + +### Not Implemented – Consider Before Production + +| Feature | Details | +|---|---| +| Shield Advanced | L7 DDoS protection, SRT access, cost protection (subscription required) | +| Bot Control TARGETED | Higher inspection level for WAF Bot Control (additional cost) | +| CloudTrail / Security Hub | Centralized audit and security findings | +| ALB access-log Athena workgroup | Query access logs via Athena for forensic analysis | +| GuardDuty findings | Threat detection integration | +| MFA enforcement | Cognito User Pool is MFA-ready but not enforced (`mfa: cognito.Mfa.REQUIRED`) | +| Scoped IAM resources | Several policies use `Resource: "*"` — scope to specific ARNs | +| PKCE enforcement | Verify PKCE is enforced on the Cognito public client (no client secret) | +| Log encryption | Lambda CloudWatch logs use default settings (no KMS CMK encryption) | +| Log retention policy | Lambda CloudWatch log retention is indefinite by default | + +## Project Structure + +``` +cdk/ +├── bin/ +│ └── enterprise-mcp-infra.ts # CDK app entry point (region pinned to us-east-1) +├── lib/ +│ ├── enterprise-mcp-infra-stack.ts # Main infrastructure stack +│ └── agentcore-policy-engine.ts # Cedar policy engine construct +├── lambda/ +│ ├── mcp_proxy_lambda.py # MCP OAuth proxy Lambda +│ ├── pre_token_generation_lambda.py # Cognito pre-token generation trigger +│ ├── interceptor/ +│ │ └── interceptor.py # Guardrails interceptor Lambda +│ ├── mcp-servers/ +│ │ ├── weather/ # Weather tool Lambda +│ │ ├── inventory/ # Inventory tool Lambda +│ │ └── user_details/ # User details tool Lambda +│ └── agentcore-policy-engine/ # Policy engine custom resource Lambda +├── test/ +│ └── enterprise-mcp-infra.test.ts # Jest tests +├── cdk.json +├── cdk.context.json +├── tsconfig.json +└── package.json +``` diff --git a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/bin/enterprise-mcp-infra.ts b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/bin/enterprise-mcp-infra.ts index d2fe66d73..caaa94da9 100644 --- a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/bin/enterprise-mcp-infra.ts +++ b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/bin/enterprise-mcp-infra.ts @@ -4,17 +4,5 @@ import { EnterpriseMcpInfraStack } from '../lib/enterprise-mcp-infra-stack'; const app = new cdk.App(); new EnterpriseMcpInfraStack(app, 'EnterpriseMcpInfraStack', { - /* If you don't specify 'env', this stack will be environment-agnostic. - * Account/Region-dependent features and context lookups will not work, - * but a single synthesized template can be deployed anywhere. */ - - /* Uncomment the next line to specialize this stack for the AWS Account - * and Region that are implied by the current CLI configuration. */ - // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, - - /* Uncomment the next line if you know exactly what Account and Region you - * want to deploy the stack to. */ - // env: { account: '123456789012', region: 'us-east-1' }, - - /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */ + env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: 'us-east-1' }, }); diff --git a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/cdk.context.json b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/cdk.context.json index 0310850cc..d3a002b3c 100644 --- a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/cdk.context.json +++ b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/cdk.context.json @@ -1,7 +1,8 @@ { - "deploymentType": "API_GATEWAY", - "domainName": "", + "domainName": "example.com", "hostedZoneName": "", "hostedZoneId": "", - "certificateArn": "" + "certificateArn": "", + "mcpMetadataKey": "com.example/target" + } diff --git a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lambda/interceptor/interceptor.py b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lambda/interceptor/interceptor.py index 964181b55..1283d5e56 100644 --- a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lambda/interceptor/interceptor.py +++ b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lambda/interceptor/interceptor.py @@ -9,6 +9,7 @@ GUARDRAIL_ID = os.getenv("GUARDRAIL_ID", None) GUARDRAIL_VERSION = os.getenv("GUARDRAIL_VERSION", "1.0") +MCP_METADATA_KEY = os.getenv("MCP_METADATA_KEY", "com.example/target") client = boto3.client("bedrock-runtime") @@ -46,6 +47,95 @@ def lambda_handler(event, context): logger.info(f"Processing RESPONSE interceptor - MCP method: {mcp_method}") + # === HANDLE TOOLS/LIST FILTERING BASED ON _meta === + if mcp_method == "tools/list" and response_body: + logger.info("tools/list response detected in RESPONSE interceptor") + + # Extract target filter from MCP _meta (spec-compliant) + target_filter = None + meta = request_body.get("_meta", {}) + if isinstance(meta, dict): + target_filter = meta.get(MCP_METADATA_KEY) + + if target_filter: + logger.info( + f"Target filter from _meta: {MCP_METADATA_KEY} = '{target_filter}'" + ) + logger.info( + f"Will filter tools to only those starting with '{target_filter}___'" + ) + else: + logger.info( + "No target filter in _meta - returning ALL tools (no filtering)" + ) + + # Filter tools if target filter is specified + if "result" in response_body and "tools" in response_body.get( + "result", {} + ): + result = response_body["result"] + original_tools = result.get("tools", []) + + logger.info(f"Original tools count: {len(original_tools)}") + + if target_filter: + # Filter by gateway target name prefix (format: "target___tool") + filtered_tools = [ + tool + for tool in original_tools + if tool.get("name", "").startswith(f"{target_filter}___") + ] + + logger.info( + f"Filtered to {len(filtered_tools)} tools for target '{target_filter}'" + ) + + # Log matched tools + if filtered_tools: + logger.info("Matched tools:") + for tool in filtered_tools: + logger.info(f" - {tool.get('name')}") + else: + logger.warning(f"No tools matched target '{target_filter}'") + + # Log filtering summary + removed = len(original_tools) - len(filtered_tools) + if removed > 0: + logger.info( + f"Filtered out {removed} tools not matching target" + ) + + # Create filtered response + filtered_body = { + "jsonrpc": response_body.get("jsonrpc", "2.0"), + "id": response_body.get("id"), + "result": {"tools": filtered_tools}, + } + + # Preserve _meta from response if present + if "_meta" in response_body: + filtered_body["_meta"] = response_body["_meta"] + + response = { + "interceptorOutputVersion": "1.0", + "mcp": { + "transformedGatewayResponse": { + "body": filtered_body, + "statusCode": 200, + } + }, + } + logger.info("Returning filtered tools/list response") + return response + else: + # No filtering - log all tools and return unchanged + logger.info( + f"No filtering applied - returning all {len(original_tools)} tools" + ) + logger.info("Available tools:") + for tool in original_tools: + logger.info(f" - {tool.get('name')}") + if mcp_method == "tools/call" and response_body: logger.info("tools/call response detected in RESPONSE interceptor") content = ( @@ -56,7 +146,7 @@ def lambda_handler(event, context): else None ) if GUARDRAIL_ID: - response = client.apply_guardrail( + gr_response = client.apply_guardrail( guardrailIdentifier=GUARDRAIL_ID, guardrailVersion=GUARDRAIL_VERSION, source="INPUT", @@ -70,13 +160,16 @@ def lambda_handler(event, context): ], outputScope="FULL", ) - if response.get("action", None) == "GUARDRAIL_INTERVENED": + if gr_response.get("action", None) == "GUARDRAIL_INTERVENED": logger.warning("Guardrail intervened on the content. Details:") - logger.warning(response.get("outputs", [{}])[0].get("text", {})) + guardrail_text = gr_response.get("outputs", [{}])[0].get( + "text", "" + ) + logger.warning(guardrail_text) body_transformed = response_body body_transformed["result"]["content"][0] = { "type": "text", - "text": response.get("outputs", [{}])[0].get("text", {}), + "text": guardrail_text, } statusCode = 403 response = { @@ -135,7 +228,7 @@ def lambda_handler(event, context): if mcp_method == "tools/call" and request_body: # This is a REQUEST interceptor if GUARDRAIL_ID: - response = client.apply_guardrail( + gr_response = client.apply_guardrail( guardrailIdentifier=GUARDRAIL_ID, guardrailVersion=GUARDRAIL_VERSION, source="INPUT", @@ -149,29 +242,38 @@ def lambda_handler(event, context): ], outputScope="FULL", ) - logger.info(f"Guardrail response: {response}") + logger.info(f"Guardrail response: {gr_response}") - if response.get("action", None) == "GUARDRAIL_INTERVENED": + if gr_response.get("action", None) == "GUARDRAIL_INTERVENED": logger.warning("Guardrail intervened on the content. Details:") - logger.warning( - json.dumps( - response.get("outputs", [{}])[0].get("text", {}), - indent=2, - ) + guardrail_text = gr_response.get("outputs", [{}])[0].get( + "text", "{}" ) - logger.info( - f"Interceptor response after guardrail intervention: {response}" - ) - return { + logger.warning(guardrail_text) + + # Parse the guardrail output back to a dict since the gateway + # expects body to be a JSON object, not a string + try: + transformed_body = json.loads(guardrail_text) + except (json.JSONDecodeError, TypeError): + # If guardrail output isn't valid JSON, pass through original request + logger.error( + "Guardrail output is not valid JSON, passing through original request" + ) + transformed_body = request_body + + response = { "interceptorOutputVersion": "1.0", "mcp": { "transformedGatewayRequest": { - "body": response.get("outputs", [{}])[0].get( - "text", {} - ), + "body": transformed_body, } }, } + logger.info( + f"Interceptor response after guardrail intervention: {response}" + ) + return response else: logger.info( "Guardrail did not intervene. Passing through original request." diff --git a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lambda/mcp_proxy_lambda.py b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lambda/mcp_proxy_lambda.py index 128123fdc..bb5551d65 100644 --- a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lambda/mcp_proxy_lambda.py +++ b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lambda/mcp_proxy_lambda.py @@ -11,15 +11,28 @@ import urllib.request import urllib.parse import urllib.error +import logging from botocore.auth import SigV4Auth from botocore.awsrequest import AWSRequest import boto3 +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.DEBUG) + # Configuration from environment variables GATEWAY_URL = os.environ.get("GATEWAY_URL", "") COGNITO_DOMAIN = os.environ.get("COGNITO_DOMAIN", "") CLIENT_ID = os.environ.get("CLIENT_ID", "") CLIENT_SECRET = os.environ.get("CLIENT_SECRET", "") +CALLBACK_LAMBDA_URL = os.environ.get("CALLBACK_LAMBDA_URL", "") +RESOURCE_SERVER_ID = os.environ.get("RESOURCE_SERVER_ID", "") +MCP_METADATA_KEY = os.environ.get("MCP_METADATA_KEY", "com.example/target") + +# Allowed redirect URIs for the OAuth callback, passed from CDK as a +# JSON-encoded list. Must match the Cognito client's registered callbackUrls +# to prevent open-redirect attacks. +ALLOWED_REDIRECT_URIS = json.loads(os.environ.get("ALLOWED_REDIRECT_URIS", "[]")) def sign_request(request): @@ -43,7 +56,7 @@ def sign_request(request): def lambda_handler(event, context): """Main Lambda handler - routes requests based on path.""" - print(f"Event: {json.dumps(event)}") + logger.debug(f"Event: {json.dumps(event)}") # Support both ALB and API Gateway v2 (HTTP API) events # ALB uses: path, httpMethod @@ -53,7 +66,7 @@ def lambda_handler(event, context): "http", {} ).get("method", "GET") - print(f"Method: {method}, Path: {path}") + logger.debug(f"Method: {method}, Path: {path}") if method == "OPTIONS": return { @@ -79,7 +92,7 @@ def lambda_handler(event, context): return handle_token(event) elif path == "/register" and method == "POST": return handle_dcr(event) - elif path == "/mcp": + elif path == "/mcp" or path.endswith("/mcp"): return proxy_to_gateway(event) else: return {"statusCode": 404, "body": json.dumps({"error": "Not found"})} @@ -103,7 +116,13 @@ def handle_oauth_metadata(event): "authorization_endpoint": f"{api_url}/authorize", "token_endpoint": f"{api_url}/token", "registration_endpoint": f"{api_url}/register", - "scopes_supported": ["openid", "profile", "email"], + "scopes_supported": [ + "openid", + "profile", + "email", + f"{RESOURCE_SERVER_ID}/mcp.read", + f"{RESOURCE_SERVER_ID}/mcp.write", + ], "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "token_endpoint_auth_methods_supported": ["none", "client_secret_post"], @@ -125,6 +144,13 @@ def handle_protected_resource_metadata(event): "resource": f"{api_url}/mcp", "authorization_servers": [api_url], "bearer_methods_supported": ["header"], + "scopes_supported": [ + "openid", + "profile", + "email", + f"{RESOURCE_SERVER_ID}/mcp.read", + f"{RESOURCE_SERVER_ID}/mcp.write", + ], }, ) @@ -135,40 +161,40 @@ def handle_authorize(event): Since Lambda is stateless, we encode the original redirect_uri in the state parameter so it survives across Lambda invocations. """ - print("=== HANDLE_AUTHORIZE DEBUG ===") + logger.debug("=== HANDLE_AUTHORIZE DEBUG ===") params = event.get("queryStringParameters", {}) or {} - print(f"Original params: {json.dumps(params)}") + logger.debug(f"Original params: {json.dumps(params)}") # Remove unsupported parameters (Cognito doesn't support 'resource' parameter) if "resource" in params: - print(f"Removing 'resource' parameter: {params['resource']}") + logger.debug(f"Removing 'resource' parameter: {params['resource']}") params.pop("resource", None) - # Fix scope parameter: convert + to spaces (Cognito expects space-separated scopes) + # Fix scope parameter: URL-decode and normalize spaces if "scope" in params: - # In URL encoding, + represents a space, so replace + with actual spaces - params["scope"] = params["scope"].replace("+", " ") - print(f"Fixed scope parameter: {params['scope']}") + # URL-decode first (handles %2F etc.), then normalize + to spaces + params["scope"] = urllib.parse.unquote(params["scope"]).replace("+", " ") + logger.debug(f"Fixed scope parameter: {params['scope']}") # Override client_id - print(f"Original client_id: {params.get('client_id', 'N/A')}") + logger.debug(f"Original client_id: {params.get('client_id', 'N/A')}") params["client_id"] = CLIENT_ID - print(f"Overridden client_id: {CLIENT_ID}") + logger.debug(f"Overridden client_id: {CLIENT_ID}") # Encode original redirect_uri and state together in a new state parameter original_redirect_uri = params.get("redirect_uri", "") original_state = params.get("state", "") - print(f"Original redirect_uri (URL encoded): {original_redirect_uri}") - print(f"Original state (URL encoded): {original_state}") + logger.debug(f"Original redirect_uri (URL encoded): {original_redirect_uri}") + logger.debug(f"Original state (URL encoded): {original_state}") if original_redirect_uri: # URL-decode both state and redirect_uri before storing decoded_state = urllib.parse.unquote(original_state) decoded_redirect_uri = urllib.parse.unquote(original_redirect_uri) - print(f"Decoded state: {decoded_state}") - print(f"Decoded redirect_uri: {decoded_redirect_uri}") + logger.debug(f"Decoded state: {decoded_state}") + logger.debug(f"Decoded redirect_uri: {decoded_redirect_uri}") # Create compound state: base64(json({original_state, original_redirect_uri})) compound_state = { @@ -180,18 +206,18 @@ def handle_authorize(event): ).decode() params["state"] = encoded_state - print(f"Compound state created: {json.dumps(compound_state)}") - print(f"Encoded state: {encoded_state}") + logger.debug(f"Compound state created: {json.dumps(compound_state)}") + logger.debug(f"Encoded state: {encoded_state}") # Replace redirect_uri with our callback api_url = get_api_url(event) params["redirect_uri"] = f"{api_url}/callback" - print(f"New redirect_uri: {params['redirect_uri']}") + logger.debug(f"New redirect_uri: {params['redirect_uri']}") - print(f"Final params being sent to Cognito: {json.dumps(params)}") + logger.debug(f"Final params being sent to Cognito: {json.dumps(params)}") redirect_url = f"{COGNITO_DOMAIN.rstrip('/')}/oauth2/authorize?{urllib.parse.urlencode(params)}" - print(f"Redirect URL: {redirect_url}") - print("=== END HANDLE_AUTHORIZE DEBUG ===") + logger.debug(f"Redirect URL: {redirect_url}") + logger.debug("=== END HANDLE_AUTHORIZE DEBUG ===") return {"statusCode": 302, "headers": {"Location": redirect_url}, "body": ""} @@ -206,10 +232,10 @@ def handle_callback(event): encoded_state = params.get("state", "") error = params.get("error", "") - print("=== HANDLE_CALLBACK DEBUG ===") - print(f"Code: {code}") - print(f"State (URL encoded): {encoded_state}") - print(f"Error: {error}") + logger.debug("=== HANDLE_CALLBACK DEBUG ===") + logger.debug(f"Code: {code}") + logger.debug(f"State (URL encoded): {encoded_state}") + logger.debug(f"Error: {error}") if error: return json_response(400, {"error": error}) @@ -218,33 +244,55 @@ def handle_callback(event): try: # First, URL-decode the state parameter (Cognito sends it URL-encoded) encoded_state_clean = urllib.parse.unquote(encoded_state) - print(f"State (URL decoded): {encoded_state_clean}") + logger.debug(f"State (URL decoded): {encoded_state_clean}") # Handle any remaining URL encoding issues (spaces become + or %20) encoded_state_clean = encoded_state_clean.replace(" ", "+") # The state should now be proper base64, no padding needed - print(f"State (ready for base64 decode): {encoded_state_clean}") - print(f"State length: {len(encoded_state_clean)}") + logger.debug(f"State (ready for base64 decode): {encoded_state_clean}") + logger.debug(f"State length: {len(encoded_state_clean)}") decoded = base64.urlsafe_b64decode(encoded_state_clean).decode() - print(f"Decoded JSON: {decoded}") + logger.debug(f"Decoded JSON: {decoded}") compound_state = json.loads(decoded) original_state = compound_state.get("state", "") original_redirect_uri = compound_state.get("redirect_uri", "") - print(f"Original state: {original_state}") - print(f"Original redirect_uri: {original_redirect_uri}") - print("=== END HANDLE_CALLBACK DEBUG ===") + logger.debug(f"Original state: {original_state}") + logger.debug(f"Original redirect_uri: {original_redirect_uri}") + logger.debug("=== END HANDLE_CALLBACK DEBUG ===") except Exception as e: - print(f"Error decoding state: {e}, state={encoded_state}") - print("=== END HANDLE_CALLBACK DEBUG (ERROR) ===") + logger.error(f"Error decoding state: {e}, state={encoded_state}") + logger.error("=== END HANDLE_CALLBACK DEBUG (ERROR) ===") return json_response(400, {"error": "Invalid state parameter"}) if not original_redirect_uri: return json_response(400, {"error": "Missing redirect_uri in state"}) + # Validate redirect_uri against the allowlist to prevent open-redirect attacks. + # A crafted state blob could otherwise redirect the authorization code to an + # attacker-controlled URL. + # + # Localhost URIs with any port are allowed because IDE clients (VS Code, Kiro) + # spin up an ephemeral local server on a random port for the OAuth callback. + normalized = original_redirect_uri.rstrip("/") + parsed = urllib.parse.urlparse(normalized) + is_localhost = parsed.scheme == "http" and parsed.hostname in ( + "localhost", + "127.0.0.1", + ) + allowed_normalized = [u.rstrip("/") for u in ALLOWED_REDIRECT_URIS] + if not is_localhost and normalized not in allowed_normalized: + logger.warning( + f"Rejected redirect_uri not in allowlist: {original_redirect_uri}" + ) + logger.debug(f"Normalized redirect_uri: {normalized}") + logger.debug(f"Allowed URIs (raw): {ALLOWED_REDIRECT_URIS}") + logger.debug(f"Allowed URIs (normalized): {allowed_normalized}") + return json_response(400, {"error": "invalid_redirect_uri"}) + # Forward to VS Code's callback with original state forward_params = urllib.parse.urlencode({"code": code, "state": original_state}) forward_url = f"{original_redirect_uri}?{forward_params}" @@ -302,19 +350,81 @@ def handle_dcr(event): def proxy_to_gateway(event): - """Forward MCP requests to AgentCore Gateway.""" - print("proxy_to_gateway") + """Forward MCP requests to AgentCore Gateway with optional target filtering.""" + logger.info("proxy_to_gateway") path = event.get("path", "/") method = event.get("httpMethod") or event.get("requestContext", {}).get( "http", {} ).get("method", "GET") headers = event.get("headers", {}) body = event.get("body", "") - print(f"Proxying to gateway - Method: {method}, Path: {path}") - print(f"Headers: {json.dumps(headers)}") + logger.info(f"Proxying to gateway - Method: {method}, Path: {path}") + logger.debug(f"Headers: {json.dumps(headers)}") if event.get("isBase64Encoded") and body: body = base64.b64decode(body) + # === EXTRACT TARGET FROM PATH === + # /mcp → no filter (return all tools) + # /gitlab/mcp → filter = "gitlab" + # /weather/mcp → filter = "weather" + target_filter = None + + if path and path != "/mcp": + # Remove leading/trailing slashes and split + parts = path.strip("/").split("/") + + # Check if path has format: /mcp + if len(parts) == 2 and parts[-1] == "mcp": + target_filter = parts[0] + logger.info(f"Target filter extracted from path: '{target_filter}'") + elif len(parts) > 2 and parts[-1] == "mcp": + # Handle nested paths like /api/v1/gitlab/mcp + target_filter = parts[-2] + logger.info(f"Target filter extracted from nested path: '{target_filter}'") + else: + logger.debug(f"Path '{path}' does not match target pattern, no filtering") + else: + logger.debug("Default path '/mcp' - returning all tools (no filtering)") + + # === INJECT INTO MCP _meta ONLY IF TARGET FILTER EXISTS === + if method == "POST" and body: + try: + # Parse MCP JSON-RPC request + mcp_request = json.loads(body if isinstance(body, str) else body.decode()) + + # Only inject _meta if we have a target filter AND it's a tool-related method + if target_filter and mcp_request.get("method") in [ + "tools/list", + "tools/call", + ]: + # Ensure _meta exists + if "_meta" not in mcp_request: + mcp_request["_meta"] = {} + + # Inject target filter using reverse DNS notation + mcp_request["_meta"][MCP_METADATA_KEY] = target_filter + + logger.info(f"Injected _meta: {MCP_METADATA_KEY} = '{target_filter}'") + logger.debug( + f"Modified MCP request: {json.dumps(mcp_request, indent=2)}" + ) + else: + if not target_filter: + logger.debug( + "No target filter - NOT injecting _meta (will return all tools)" + ) + else: + logger.debug( + f"Method '{mcp_request.get('method')}' - not injecting _meta" + ) + + # Re-serialize (possibly modified) request + body = json.dumps(mcp_request).encode() + + except json.JSONDecodeError as e: + logger.error(f"Failed to parse MCP request: {e}") + # Continue with original body if parsing fails + # target_url = f"{GATEWAY_URL.rstrip('/mcp')}{path}" if path != "/" else GATEWAY_URL target_url = GATEWAY_URL # Build request headers @@ -328,7 +438,7 @@ def proxy_to_gateway(event): if headers.get(h): req_headers[h.title()] = headers[h] - print(json.dumps(req_headers)) + logger.debug(json.dumps(req_headers)) try: if method == "POST" and body: data = body.encode() if isinstance(body, str) else body @@ -354,7 +464,7 @@ def proxy_to_gateway(event): if auth: req.add_header("Authorization", auth) - print( + logger.debug( "{}\n{}\r\n{}\r\n\r\n{}".format( "-----------START-----------", (req.method or "GET") + " " + req.full_url, @@ -365,8 +475,8 @@ def proxy_to_gateway(event): with urllib.request.urlopen(req, timeout=60) as resp: resp_body = resp.read().decode() - print(resp_body) - print(resp.headers) + logger.debug(resp_body) + logger.debug(resp.headers) resp_headers = { "Content-Type": resp.headers.get("Content-Type", "application/json") } @@ -387,7 +497,9 @@ def proxy_to_gateway(event): ) www_auth_rewritten = www_auth.replace(gateway_base, api_url) resp_headers["WWW-Authenticate"] = www_auth_rewritten - print(f"Rewrote WWW-Authenticate: {www_auth} -> {www_auth_rewritten}") + logger.debug( + f"Rewrote WWW-Authenticate: {www_auth} -> {www_auth_rewritten}" + ) return { "statusCode": resp.status, @@ -396,7 +508,7 @@ def proxy_to_gateway(event): } except urllib.error.HTTPError as e: error = e.read().decode() - print(f"Gateway error response: {error}") + logger.error(f"Gateway error response: {error}") # Rewrite any Gateway URLs in error response body api_url = get_api_url(event) @@ -404,7 +516,7 @@ def proxy_to_gateway(event): gateway_base = GATEWAY_URL[:-4] if GATEWAY_URL.endswith("/mcp") else GATEWAY_URL error_rewritten = error.replace(gateway_base, api_url) if error != error_rewritten: - print("Rewrote Gateway URL in error body") + logger.debug("Rewrote Gateway URL in error body") resp_headers = {"Content-Type": "application/json"} @@ -413,7 +525,7 @@ def proxy_to_gateway(event): if www_auth: www_auth_rewritten = www_auth.replace(gateway_base, api_url) resp_headers["WWW-Authenticate"] = www_auth_rewritten - print( + logger.debug( f"Rewrote WWW-Authenticate in error: {www_auth} -> {www_auth_rewritten}" ) diff --git a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lambda/pre_token_generation_lambda.py b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lambda/pre_token_generation_lambda.py index 0e9cf22bd..29bba6844 100644 --- a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lambda/pre_token_generation_lambda.py +++ b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lambda/pre_token_generation_lambda.py @@ -1,9 +1,12 @@ import json import logging +import os logger = logging.getLogger() logger.setLevel(logging.INFO) +RESOURCE_SERVER_ID = os.environ.get("RESOURCE_SERVER_ID", "") + def lambda_handler(event, context): """ @@ -27,6 +30,10 @@ def lambda_handler(event, context): if email == "vscode-admin@example.com": # Example: Set custom tag based on email custom_tag = "admin_user" + elif email == "vscode-readonly@example.com": + # Test user with limited scopes — only mcp.read, no mcp.write + # Used to verify the gateway rejects requests with insufficient scopes + custom_tag = "readonly_user" else: custom_tag = "regular_user" @@ -74,13 +81,34 @@ def lambda_handler(event, context): "claimsToAddOrOverride" ] = {} - # Add email and user_tag to access token + # Add email, user_tag, and aud to access token event["response"]["claimsAndScopeOverrideDetails"]["accessTokenGeneration"][ "claimsToAddOrOverride" ]["email"] = email event["response"]["claimsAndScopeOverrideDetails"]["accessTokenGeneration"][ "claimsToAddOrOverride" ]["user_tag"] = custom_tag + # Inject the audience claim so the proxy Lambda and AgentCore Gateway + # can verify the token is scoped to this resource server. + # Cognito requires aud to match the current session's app client ID. + client_id = event.get("callerContext", {}).get("clientId", "") + if client_id: + event["response"]["claimsAndScopeOverrideDetails"]["accessTokenGeneration"][ + "claimsToAddOrOverride" + ]["aud"] = client_id + + # For the readonly test user, suppress the mcp.write and mcp.read scopes so the + # gateway rejects write operations with insufficient_scope. + if custom_tag == "readonly_user": + event["response"]["claimsAndScopeOverrideDetails"]["accessTokenGeneration"][ + "scopesToSuppress" + ] = [ + f"{RESOURCE_SERVER_ID}/mcp.write", + f"{RESOURCE_SERVER_ID}/mcp.read", + ] + logger.info( + "Suppressed mcp.write and mcp.read scopes for readonly test user" + ) logger.info( f"Added custom claims to ID token and Access token: " diff --git a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lib/agentcore-policy-engine.ts b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lib/agentcore-policy-engine.ts index e7aab738c..63033604e 100644 --- a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lib/agentcore-policy-engine.ts +++ b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lib/agentcore-policy-engine.ts @@ -73,6 +73,13 @@ export class AgentCorePolicyEngine extends Construct { resources: ['*'], }), ); + this.policyFunction.addToRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: ['iam:GetRole', 'iam:GetRolePolicy', 'iam:ListAttachedRolePolicies', 'iam:ListRolePolicies'], + resources: ["arn:aws:iam::*:role/*"], + }), + ); // Grant permission to pass the gateway role if provided if (props.gatewayRole) { diff --git a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lib/enterprise-mcp-infra-stack.ts b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lib/enterprise-mcp-infra-stack.ts index 01d38f8f0..1c331b722 100644 --- a/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lib/enterprise-mcp-infra-stack.ts +++ b/01-tutorials/02-AgentCore-gateway/04-integration/04-enterprise-mcp-demo/cdk/lib/enterprise-mcp-infra-stack.ts @@ -5,11 +5,11 @@ import * as iam from "aws-cdk-lib/aws-iam"; import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2"; import * as targets from "aws-cdk-lib/aws-elasticloadbalancingv2-targets"; -import * as apigatewayv2 from "aws-cdk-lib/aws-apigatewayv2"; -import * as apigatewayv2integrations from "aws-cdk-lib/aws-apigatewayv2-integrations"; import * as certificatemanager from "aws-cdk-lib/aws-certificatemanager"; import * as route53 from "aws-cdk-lib/aws-route53"; import * as route53targets from "aws-cdk-lib/aws-route53-targets"; +import * as wafv2 from "aws-cdk-lib/aws-wafv2"; +import * as s3 from "aws-cdk-lib/aws-s3"; import { Construct } from "constructs"; import * as path from "path"; import * as agentcore from "@aws-cdk/aws-bedrock-agentcore-alpha"; @@ -21,20 +21,46 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { super(scope, id, props); // ============================================================================= - // CONFIGURATION FROM CONTEXT + // SECURITY POSTURE – what this stack provides and what it does NOT + // ============================================================================= + // PROVIDED: + // • Cognito User Pool (admin-only sign-up, MFA-ready, strong password policy) + // with a Pre-Token Generation Lambda that injects audience/role claims. + // • OAuth 2.0 Authorization Code Grant + custom scopes (mcp.read / mcp.write). + // • JWT audience validation in the proxy Lambda before any AgentCore call. + // • AgentCore Gateway Cognito authorizer (token verified a second time by AWS). + // • Cedar policy engine enforcing fine-grained per-user tool access (ENFORCE). + // • Bedrock Guardrails (PII masking/blocking) applied at the interceptor layer. + // • Lambda-in-VPC proxy (private subnet, NAT egress only). + // • VPC Interface Endpoint for bedrock-agentcore (InvokeGateway traffic stays + // on the AWS private network; never crosses the public internet). + // • Internet-facing ALB with: + // – TLS 1.2+ termination on a custom domain (ACM certificate). + // – dropInvalidHeaderFields (HTTP request-smuggling mitigation). + // – Host-header condition on every forwarding rule; raw *.elb DNS 404s. + // – HTTP → HTTPS permanent redirect on port 80. + // • WAF WebACL (Regional, attached to ALB): + // – IP rate limit (1 000 req / 5 min per IP) + // – AWS IP Reputation list (botnets, TOR exits, scanners) + // – Core Rule Set / OWASP Top 10 + // – Known Bad Inputs + // – Bot Control – COMMON level (COUNT mode; switch to BLOCK post-validation) + // • Reserved Lambda concurrency caps on every function (DoS blast-radius limit). + // • Gateway resource policy restricting InvokeGateway to the VPC. + // • Shield Standard (automatically active on public ALBs, L3/L4 DDoS only). + // • ALB access logging to S3 (encrypted, 90-day lifecycle, public access blocked). + // • Redirect URI allowlist in handle_callback (prevents open-redirect attacks). + // + // NOT PROVIDED – consider adding before going to production: + // • Shield Advanced (L7 DDoS + SRT + cost protection – subscription required). + // • Bot Control TARGETED inspection level (additional WAF cost). + // • CloudTrail / Security Hub integration for centralised audit. + // • ALB access-log Athena workgroup / GuardDuty findings. // ============================================================================= - // Deployment type is determined by CDK context variable - // Use: cdk deploy -c deploymentType=API_GATEWAY - // Or set in cdk.context.json - const deploymentType = this.node.tryGetContext("deploymentType") || "ALB"; - - if (!["ALB", "API_GATEWAY"].includes(deploymentType)) { - throw new Error(`Invalid deployment type: ${deploymentType}. Must be ALB or API_GATEWAY`); - } - - const isAlbDeployment = deploymentType === "ALB"; - const isApiGatewayDeployment = deploymentType === "API_GATEWAY"; + // ============================================================================= + // CONFIGURATION FROM CONTEXT + // ============================================================================= // Domain and infrastructure configuration from context const domainName = this.node.tryGetContext("domainName") || ""; @@ -42,6 +68,18 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { const hostedZoneId = this.node.tryGetContext("hostedZoneId") || ""; const certificateArn = this.node.tryGetContext("certificateArn") || ""; + // MCP metadata key for path-based routing (reverse DNS notation) + // Used in _meta field to filter tools by target + const mcpMetadataKey = this.node.tryGetContext("mcpMetadataKey") || "com.example/target"; + + // ============================================================================= + // RESOURCE SERVER IDENTIFIER + // The resource server identifier doubles as the OAuth audience claim + // (RFC 8707 resource indicator / RFC 9728 protected-resource metadata). + // All access tokens issued for the MCP endpoint carry this as their `aud`. + // ============================================================================= + const resourceServerIdentifier = "agentcore-gateway"; + // ============================================================================= // PRE-TOKEN GENERATION LAMBDA // ============================================================================= @@ -78,7 +116,16 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { role: preTokenLambdaRole, timeout: cdk.Duration.seconds(60), memorySize: 128, + // Bound concurrency so a token-generation burst cannot starve other + // workloads; tune this value to your peak sign-in rate. + reservedConcurrentExecutions: 50, description: "Lambda to add custom claims to Cognito tokens based on user email", + environment: { + // Injected into every access token as the `aud` claim so that the + // proxy Lambda's audience validator can verify the token is scoped + // to this resource server (RFC 8707 / MCP Authorization spec). + RESOURCE_SERVER_ID: resourceServerIdentifier, + }, } ); @@ -142,7 +189,7 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { const resourceServer = userPool.addResourceServer( "AgentCoreResourceServer", { - identifier: "agentcore-gateway", + identifier: resourceServerIdentifier, userPoolResourceServerName: "AgentCore Gateway", scopes: [readScope, writeScope], } @@ -194,59 +241,350 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { } ); + // ============================================================================= + // VPC SETUP + // ============================================================================= + + // Create a new VPC with public subnets and internet gateway + const vpc = new ec2.Vpc(this, "McpVpc", { + maxAzs: 2, + natGateways: 1, // NAT Gateway for Lambda in private subnet to access internet + subnetConfiguration: [ + { + name: "Public", + subnetType: ec2.SubnetType.PUBLIC, + cidrMask: 24, + }, + { + name: "Private", + subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, + cidrMask: 24, + }, + ], + }); + + // ============================================================================= + // VPC INTERFACE ENDPOINT – bedrock-agentcore + // Keeps all InvokeGateway traffic from the proxy Lambda on the AWS private + // network; packets never traverse the public internet. + // + // Security group: allows HTTPS (443) inbound only from the VPC CIDR so that + // only resources inside this VPC can use the endpoint. All other traffic is + // implicitly denied. + // + // NOTE: Interface endpoints incur an hourly charge per AZ plus a per-GB + // data-processing fee. See https://aws.amazon.com/privatelink/pricing/ + // ============================================================================= + const agentcoreEndpointSg = new ec2.SecurityGroup( + this, + "AgentCoreEndpointSg", + { + vpc, + description: + "Allow HTTPS from VPC to bedrock-agentcore interface endpoint", + allowAllOutbound: false, + } + ); + agentcoreEndpointSg.addIngressRule( + ec2.Peer.ipv4(vpc.vpcCidrBlock), + ec2.Port.tcp(443), + "HTTPS from VPC to AgentCore endpoint" + ); + + // Interface endpoint for the AgentCore data-plane (InvokeGateway). + // Placed in the private subnets alongside the proxy Lambda so no NAT hop + // is needed for AgentCore API calls. + vpc.addInterfaceEndpoint("AgentCoreEndpoint", { + service: new ec2.InterfaceVpcEndpointService( + `com.amazonaws.${this.region}.bedrock-agentcore.gateway`, + 443 + ), + subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + securityGroups: [agentcoreEndpointSg], + privateDnsEnabled: true, + }); + + // ============================================================================= + // WAF WEB ACL + // Layers of protection applied (all free-tier managed rule groups unless noted): + // 1. IP-level rate limit – 1 000 req / 5 min per source IP + // 2. AWSManagedRulesCommonRuleSet (CRS) – OWASP Top 10 signatures + // 3. AWSManagedRulesKnownBadInputsRuleSet – known attack patterns + // 4. AWSManagedRulesAmazonIpReputationList – AWS threat-intel IP block list + // 5. AWSManagedRulesBotControlRuleSet (common bots, COUNT mode) – set to + // COUNT so legitimate MCP clients are not accidentally blocked during + // testing; switch to BLOCK in production after validating traffic. + // + // NOTE: rules 2–5 are scoped-down to exclude the OAuth flow endpoints + // (/token, /authorize, /callback, /register) whose bodies legitimately + // contain patterns (redirect_uri, code_verifier, grant_type, etc.) that + // signature-based rules would otherwise flag as false positives. + // + // DDoS protection: Shield Standard is enabled automatically on any public + // ALB at no extra cost; it mitigates volumetric L3/L4 attacks. For L7 + // DDoS protection and SRT access, subscribe to Shield Advanced separately. + // ============================================================================= + + // Helper: re-usable OAuth-path scope-down statement (shared by CRS, KBI, + // IP-reputation and Bot-Control rules so we don't repeat the block 4 times). + const oauthScopeDown: wafv2.CfnWebACL.StatementProperty = { + notStatement: { + statement: { + orStatement: { + statements: [ + { + byteMatchStatement: { + searchString: "/token", + fieldToMatch: { uriPath: {} }, + textTransformations: [{ priority: 0, type: "LOWERCASE" }], + positionalConstraint: "EXACTLY", + }, + }, + { + byteMatchStatement: { + searchString: "/authorize", + fieldToMatch: { uriPath: {} }, + textTransformations: [{ priority: 0, type: "LOWERCASE" }], + positionalConstraint: "EXACTLY", + }, + }, + { + byteMatchStatement: { + searchString: "/callback", + fieldToMatch: { uriPath: {} }, + textTransformations: [{ priority: 0, type: "LOWERCASE" }], + positionalConstraint: "EXACTLY", + }, + }, + { + byteMatchStatement: { + searchString: "/register", + fieldToMatch: { uriPath: {} }, + textTransformations: [{ priority: 0, type: "LOWERCASE" }], + positionalConstraint: "EXACTLY", + }, + }, + ], + }, + }, + }, + }; + + let webAcl: wafv2.CfnWebACL; + + webAcl = new wafv2.CfnWebACL(this, "McpAlbWebAcl", { + name: "mcp-alb-web-acl", + scope: "REGIONAL", + defaultAction: { allow: {} }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "mcp-alb-web-acl", + sampledRequestsEnabled: true, + }, + rules: [ + // ── 1. IP-level rate limit ──────────────────────────────────────────── + // 1 000 requests per 5-minute window per source IP. + { + name: "RateLimit", + priority: 1, + action: { block: {} }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "RateLimit", + sampledRequestsEnabled: true, + }, + statement: { + rateBasedStatement: { + limit: 1000, + aggregateKeyType: "IP", + }, + }, + }, + + // ── 2. AWS IP Reputation list ───────────────────────────────────────── + // Blocks IPs on AWS-maintained threat-intel lists (botnets, TOR exit + // nodes, scanners). Applied before expensive rule evaluation. + { + name: "AWSManagedRulesIPReputation", + priority: 2, + overrideAction: { none: {} }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "AWSManagedRulesIPReputation", + sampledRequestsEnabled: true, + }, + statement: { + managedRuleGroupStatement: { + vendorName: "AWS", + name: "AWSManagedRulesAmazonIpReputationList", + scopeDownStatement: oauthScopeDown, + }, + }, + }, + + // ── 3. Core Rule Set (OWASP Top 10) ────────────────────────────────── + { + name: "AWSManagedRulesCRS", + priority: 3, + overrideAction: { none: {} }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "AWSManagedRulesCRS", + sampledRequestsEnabled: true, + }, + statement: { + managedRuleGroupStatement: { + vendorName: "AWS", + name: "AWSManagedRulesCommonRuleSet", + scopeDownStatement: oauthScopeDown, + }, + }, + }, + + // ── 4. Known Bad Inputs ─────────────────────────────────────────────── + { + name: "AWSManagedRulesKnownBadInputs", + priority: 4, + overrideAction: { none: {} }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "AWSManagedRulesKnownBadInputs", + sampledRequestsEnabled: true, + }, + statement: { + managedRuleGroupStatement: { + vendorName: "AWS", + name: "AWSManagedRulesKnownBadInputsRuleSet", + scopeDownStatement: oauthScopeDown, + }, + }, + }, + + // ── 5. Bot Control (common bots – COUNT mode) ───────────────────────── + // Runs in COUNT so automated MCP clients are not accidentally blocked + // during testing/piloting. Review CloudWatch metrics and switch + // overrideAction to { none: {} } (BLOCK) once traffic is validated. + { + name: "AWSManagedRulesBotControl", + priority: 5, + overrideAction: { count: {} }, + visibilityConfig: { + cloudWatchMetricsEnabled: true, + metricName: "AWSManagedRulesBotControl", + sampledRequestsEnabled: true, + }, + statement: { + managedRuleGroupStatement: { + vendorName: "AWS", + name: "AWSManagedRulesBotControlRuleSet", + managedRuleGroupConfigs: [ + { awsManagedRulesBotControlRuleSet: { inspectionLevel: "COMMON" } }, + ], + scopeDownStatement: oauthScopeDown, + }, + }, + }, + ], + }); + // ============================================================================= // LAMBDA FUNCTIONS // ============================================================================= - // Create Lambda execution role - const lambdaRole = new iam.Role(this, "McpProxyLambdaRole", { + // --------------------------------------------------------------------------- + // SECURITY: Dedicated least-privilege IAM role per Lambda function group. + // + // Role │ Used by │ Permissions + // ──────────────────┼────────────────────────────────┼──────────────────────── + // proxyLambdaRole │ McpProxyLambda (VPC-resident) │ VPC execution + + // │ │ bedrock-agentcore:InvokeGateway (scoped to gateway ARN after creation) + // │ │ bedrock-agentcore:CompleteResourceTokenAuth / GetResourceOauth2Token + // interceptorRole │ McpInterceptorLambda │ Basic execution + + // │ │ bedrock:ApplyGuardrail (scoped to this guardrail) + // toolLambdaRole │ WeatherLambda, InventoryLambda,│ Basic execution only – + // │ UserDetailsLambda │ tool Lambdas receive events from AgentCore + // │ │ and need no AWS API permissions + // --------------------------------------------------------------------------- + + // ── Proxy Lambda role ──────────────────────────────────────────────────────── + // Needs VPC access (private subnet) + AgentCore gateway invocation. + // NOTE: secretsmanager resource is scoped to "*" here as a placeholder – + // replace with the exact secret ARN once you create your Secrets Manager + // secret for the OAuth client credentials. + const proxyLambdaRole = new iam.Role(this, "McpProxyLambdaRole", { assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"), + description: "Least-privilege role for the MCP proxy Lambda (VPC-resident)", managedPolicies: [ + // Basic CloudWatch Logs permissions iam.ManagedPolicy.fromAwsManagedPolicyName( "service-role/AWSLambdaBasicExecutionRole" ), - iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonBedrockFullAccess"), + // ENI create/describe/delete for VPC placement + iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaVPCAccessExecutionRole" + ), ], }); - // Add inline policy for AgentCore Identity and Secrets Manager - lambdaRole.addToPolicy( + // AgentCore identity token exchange – scoped to gateway ARN added after + // gateway creation (see proxyLambdaRole.addToPolicy below the gateway block). + proxyLambdaRole.addToPolicy( new iam.PolicyStatement({ + sid: "AgentCoreIdentityTokenExchange", effect: iam.Effect.ALLOW, actions: [ "bedrock-agentcore:CompleteResourceTokenAuth", "bedrock-agentcore:GetResourceOauth2Token", ], + // These actions do not support resource-level conditions in the current + // AgentCore IAM reference; restrict once supported. resources: ["*"], }) ); - lambdaRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: [ - "bedrock:ApplyGuardrail" - ], - resources:[guardrails.attrGuardrailArn] - })); + // bedrock-agentcore:InvokeGateway is added below the gateway construct so we + // can scope it to the specific gateway ARN. - lambdaRole.addToPolicy( - new iam.PolicyStatement({ - effect: iam.Effect.ALLOW, - actions: ["bedrock-agentcore:InvokeGateway"], - resources: ["*"], - }) - ); + // ── Interceptor Lambda role ────────────────────────────────────────────────── + // Only needs to call bedrock:ApplyGuardrail on this specific guardrail. + const interceptorLambdaRole = new iam.Role(this, "McpInterceptorLambdaRole", { + assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"), + description: "Least-privilege role for the MCP interceptor Lambda", + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaBasicExecutionRole" + ), + ], + }); - lambdaRole.addToPolicy( + interceptorLambdaRole.addToPolicy( new iam.PolicyStatement({ + sid: "ApplyGuardrailThisGuardrailOnly", effect: iam.Effect.ALLOW, - actions: ["secretsmanager:GetSecretValue"], - resources: ["*"], + actions: ["bedrock:ApplyGuardrail"], + // Scoped to the exact guardrail created in this stack. + resources: [guardrails.attrGuardrailArn], }) ); + // ── Tool Lambda role ───────────────────────────────────────────────────────── + // Shared by WeatherLambda, InventoryLambda, and UserDetailsLambda. + // These Lambdas are invoked by AgentCore and only need CloudWatch Logs access. + // They do NOT require any Bedrock, Secrets Manager, or AgentCore permissions. + const toolLambdaRole = new iam.Role(this, "McpToolLambdaRole", { + assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"), + description: + "Least-privilege role for MCP tool Lambdas (weather, inventory, user-details) - no AWS API permissions required", + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaBasicExecutionRole" + ), + ], + }); + // MCP Proxy Lambda (with increased timeout for ALB) + // Reserved concurrency: cap concurrency so a traffic burst cannot exhaust the + // account limit and starve other workloads. Tune per your traffic profile. const proxyLambda = new lambda.Function(this, "McpProxyLambda", { runtime: lambda.Runtime.PYTHON_3_12, handler: "lambda_function.lambda_handler", @@ -262,15 +600,26 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { ], }, }), - role: lambdaRole, + role: proxyLambdaRole, timeout: cdk.Duration.seconds(300), // 5 minutes for ALB memorySize: 256, + vpc: vpc, + vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + reservedConcurrentExecutions: 100, environment: { GATEWAY_URL: "", // Will be updated after gateway creation COGNITO_DOMAIN: `https://${cognitoDomain.domainName}.auth.${this.region}.amazoncognito.com`, CLIENT_ID: "", // Will be updated after VS Code client creation // CLIENT_SECRET: "", CALLBACK_LAMBDA_URL: "", // Will be updated after ALB creation + // The resource server identifier is used for audience validation. + // Tokens whose `aud` claim does not contain this value are rejected + // before being forwarded to the AgentCore Gateway. + RESOURCE_SERVER_ID: resourceServerIdentifier, + COGNITO_USER_POOL_ID: userPool.userPoolId, + COGNITO_REGION: this.region, + // MCP metadata key for path-based routing + MCP_METADATA_KEY: mcpMetadataKey, }, }); @@ -290,9 +639,10 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { ], }, }), - role: lambdaRole, + role: toolLambdaRole, timeout: cdk.Duration.seconds(300), // 5 minutes for ALB memorySize: 256, + reservedConcurrentExecutions: 50, }); // Inventory Lambda @@ -311,9 +661,10 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { ], }, }), - role: lambdaRole, + role: toolLambdaRole, timeout: cdk.Duration.seconds(300), // 5 minutes for ALB memorySize: 256, + reservedConcurrentExecutions: 50, }); // User Details Lambda @@ -332,9 +683,10 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { ], }, }), - role: lambdaRole, + role: toolLambdaRole, timeout: cdk.Duration.seconds(300), // 5 minutes for ALB memorySize: 256, + reservedConcurrentExecutions: 50, }); @@ -354,47 +706,94 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { ], }, }), - role: lambdaRole, + role: interceptorLambdaRole, timeout: cdk.Duration.seconds(300), // 5 minutes for ALB memorySize: 256, + reservedConcurrentExecutions: 50, environment: { "GUARDRAIL_ID": guardrails.attrGuardrailId, - "GUARDRAIL_VERSION": guardrails.attrVersion + "GUARDRAIL_VERSION": guardrails.attrVersion, + "MCP_METADATA_KEY": mcpMetadataKey, }, }); // ============================================================================= - // CONDITIONAL DEPLOYMENT: ALB OR API GATEWAY + // APPLICATION LOAD BALANCER // ============================================================================= let endpointUrl: string; - let vpc: ec2.Vpc | undefined; - let alb: elbv2.ApplicationLoadBalancer | undefined; - let httpApi: apigatewayv2.HttpApi | undefined; - if (isAlbDeployment) { + // Security group: accept HTTPS (443) and HTTP (80) only. + // All other inbound traffic is implicitly denied. + const albSecurityGroup = new ec2.SecurityGroup(this, "AlbSecurityGroup", { + vpc: vpc, + description: "ALB security group - HTTPS/HTTP ingress only", + allowAllOutbound: true, + }); + albSecurityGroup.addIngressRule( + ec2.Peer.anyIpv4(), + ec2.Port.tcp(443), + "Allow HTTPS from the internet" + ); + albSecurityGroup.addIngressRule( + ec2.Peer.anyIpv4(), + ec2.Port.tcp(80), + "Allow HTTP from the internet (redirected to HTTPS)" + ); + albSecurityGroup.addIngressRule( + ec2.Peer.anyIpv6(), + ec2.Port.tcp(443), + "Allow HTTPS from the internet (IPv6)" + ); + albSecurityGroup.addIngressRule( + ec2.Peer.anyIpv6(), + ec2.Port.tcp(80), + "Allow HTTP from the internet (IPv6, redirected to HTTPS)" + ); + // ============================================================================= - // VPC AND APPLICATION LOAD BALANCER + // ALB ACCESS LOG BUCKET + // S3 bucket for ALB access logs with encryption, lifecycle, and public + // access blocked. The CDK logAccessLogs() helper automatically grants + // the correct regional ELB service account write access via bucket policy. + // Requires a concrete region in the stack env (set in bin/enterprise-mcp-infra.ts). // ============================================================================= - - // Create a new VPC with public subnets and internet gateway - vpc = new ec2.Vpc(this, "McpVpc", { - maxAzs: 2, - natGateways: 0, - subnetConfiguration: [ + const albLogBucket = new s3.Bucket(this, "AlbAccessLogBucket", { + bucketName: `mcp-alb-access-logs-${this.account}-${this.region}`, + encryption: s3.BucketEncryption.S3_MANAGED, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + enforceSSL: true, + versioned: false, + lifecycleRules: [ { - name: "Public", - subnetType: ec2.SubnetType.PUBLIC, - cidrMask: 24, + id: "ExpireAfter90Days", + expiration: cdk.Duration.days(90), }, ], + removalPolicy: cdk.RemovalPolicy.DESTROY, + autoDeleteObjects: true, // NOTE: set to false in production to prevent accidental log loss }); - // Create Application Load Balancer - alb = new elbv2.ApplicationLoadBalancer(this, "McpOAuthProxyALB", { - vpc, + // Create Application Load Balancer. + // dropInvalidHeaderFields: rejects requests whose headers contain + // characters outside the RFC 7230 allowed set, blocking several + // request-smuggling / header-injection attack vectors. + const alb = new elbv2.ApplicationLoadBalancer(this, "McpOAuthProxyALB", { + vpc: vpc, internetFacing: true, loadBalancerName: "mcp-oauth-proxy-alb", + securityGroup: albSecurityGroup, + dropInvalidHeaderFields: true, + }); + + // Enable ALB access logging. logAccessLogs() sets the correct bucket + // policy for the regional ELB account automatically. + alb.logAccessLogs(albLogBucket, "alb"); + + // Associate the WAF WebACL with the ALB + new wafv2.CfnWebACLAssociation(this, "AlbWebAclAssociation", { + resourceArn: alb.loadBalancerArn, + webAclArn: webAcl.attrArn, }); // Import the certificate @@ -466,10 +865,21 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { new iam.ServicePrincipal("elasticloadbalancing.amazonaws.com") ); + // Host-header condition: every forwarding rule requires the Host header to + // match the custom domain. Requests arriving via the raw ALB DNS name + // (*.elb.amazonaws.com) fall through to the listener's default 404 action + // and are never forwarded to the Lambda. + // This prevents virtual-hosting exploitation and removes the raw DNS name + // as a valid entry point that bypasses your WAF / custom-domain TLS policy. + const hostHeaderCondition = elbv2.ListenerCondition.hostHeaders([ + `${domainName}.${hostedZoneName}`, + ]); + // Proxy Lambda routes - specific paths mainListener.addTargetGroups("ProxyWellKnownAuthRule", { priority: 40, conditions: [ + hostHeaderCondition, elbv2.ListenerCondition.pathPatterns([ "/.well-known/oauth-authorization-server", ]), @@ -480,6 +890,7 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { mainListener.addTargetGroups("ProxyWellKnownResourceRule", { priority: 50, conditions: [ + hostHeaderCondition, elbv2.ListenerCondition.pathPatterns([ "/.well-known/oauth-protected-resource", ]), @@ -489,32 +900,59 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { mainListener.addTargetGroups("ProxyAuthorizeRule", { priority: 60, - conditions: [elbv2.ListenerCondition.pathPatterns(["/authorize"])], + conditions: [ + hostHeaderCondition, + elbv2.ListenerCondition.pathPatterns(["/authorize"]), + ], targetGroups: [proxyTargetGroup], }); mainListener.addTargetGroups("ProxyCallbackRule", { priority: 70, - conditions: [elbv2.ListenerCondition.pathPatterns(["/callback"])], + conditions: [ + hostHeaderCondition, + elbv2.ListenerCondition.pathPatterns(["/callback"]), + ], targetGroups: [proxyTargetGroup], }); mainListener.addTargetGroups("ProxyTokenRule", { priority: 80, - conditions: [elbv2.ListenerCondition.pathPatterns(["/token"])], + conditions: [ + hostHeaderCondition, + elbv2.ListenerCondition.pathPatterns(["/token"]), + ], targetGroups: [proxyTargetGroup], }); mainListener.addTargetGroups("ProxyRegisterRule", { priority: 90, - conditions: [elbv2.ListenerCondition.pathPatterns(["/register"])], + conditions: [ + hostHeaderCondition, + elbv2.ListenerCondition.pathPatterns(["/register"]), + ], + targetGroups: [proxyTargetGroup], + }); + + // MCP routes - wildcard pattern for dynamic target filtering + // Matches: /mcp, /gitlab/mcp, /weather/mcp, /inventory/mcp, /*/mcp + // No need to update ALB when adding new tool groups! + mainListener.addTargetGroups("ProxyMcpWildcardRule", { + priority: 95, + conditions: [ + hostHeaderCondition, + elbv2.ListenerCondition.pathPatterns(["/mcp", "/*/mcp"]), + ], targetGroups: [proxyTargetGroup], }); - // Default catch-all rule for Proxy Lambda + // Default catch-all rule for Proxy Lambda (still host-header gated) mainListener.addTargetGroups("ProxyDefaultRule", { priority: 100, - conditions: [elbv2.ListenerCondition.pathPatterns(["/*"])], + conditions: [ + hostHeaderCondition, + elbv2.ListenerCondition.pathPatterns(["/*"]), + ], targetGroups: [proxyTargetGroup], }); @@ -536,148 +974,6 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { value: alb.loadBalancerDnsName, description: "ALB DNS Name", }); - } else { - // ============================================================================= - // API GATEWAY HTTP API - // ============================================================================= - - // Create HTTP API - httpApi = new apigatewayv2.HttpApi(this, "McpHttpApi", { - apiName: "mcp-oauth-proxy-api", - description: "MCP OAuth Proxy HTTP API", - corsPreflight: { - allowOrigins: ["*"], - allowMethods: [ - apigatewayv2.CorsHttpMethod.GET, - apigatewayv2.CorsHttpMethod.POST, - apigatewayv2.CorsHttpMethod.OPTIONS, - ], - allowHeaders: ["*"], - }, - }); - - // Create Lambda integrations - const proxyIntegration = - new apigatewayv2integrations.HttpLambdaIntegration( - "ProxyIntegration", - proxyLambda - ); - - // Add OAuth and well-known routes - httpApi.addRoutes({ - path: "/.well-known/oauth-authorization-server", - methods: [apigatewayv2.HttpMethod.GET], - integration: proxyIntegration, - }); - - httpApi.addRoutes({ - path: "/.well-known/oauth-protected-resource", - methods: [apigatewayv2.HttpMethod.GET], - integration: proxyIntegration, - }); - - httpApi.addRoutes({ - path: "/authorize", - methods: [apigatewayv2.HttpMethod.GET], - integration: proxyIntegration, - }); - - httpApi.addRoutes({ - path: "/callback", - methods: [apigatewayv2.HttpMethod.GET], - integration: proxyIntegration, - }); - - httpApi.addRoutes({ - path: "/token", - methods: [apigatewayv2.HttpMethod.POST], - integration: proxyIntegration, - }); - - httpApi.addRoutes({ - path: "/register", - methods: [apigatewayv2.HttpMethod.POST], - integration: proxyIntegration, - }); - - // Default route for MCP proxy (catch-all) - httpApi.addRoutes({ - path: "/{proxy+}", - methods: [apigatewayv2.HttpMethod.ANY], - integration: proxyIntegration, - }); - - // Check if custom domain is provided - if (domainName && domainName.trim() !== "") { - // Custom domain setup for API Gateway - const certificate = certificatemanager.Certificate.fromCertificateArn( - this, - "ApiGatewayCertificate", - certificateArn - ); - - const domainNameResource = new apigatewayv2.DomainName( - this, - "ApiGatewayDomain", - { - domainName: domainName, - certificate: certificate, - } - ); - - new apigatewayv2.ApiMapping(this, "ApiMapping", { - api: httpApi, - domainName: domainNameResource, - }); - - // Create DNS record - const hostedZone = route53.HostedZone.fromHostedZoneAttributes( - this, - "HostedZone", - { - hostedZoneId: hostedZoneId, - zoneName: hostedZoneName, - } - ); - - new route53.ARecord(this, "ApiGatewayAliasRecord", { - zone: hostedZone, - recordName: domainName, - target: route53.RecordTarget.fromAlias( - new route53targets.ApiGatewayv2DomainProperties( - domainNameResource.regionalDomainName, - domainNameResource.regionalHostedZoneId - ) - ), - }); - - endpointUrl = `https://${domainName}`; - - new cdk.CfnOutput(this, "CustomDomain", { - value: domainName, - description: "Custom Domain Name", - }); - } else { - // Use default API Gateway URL - endpointUrl = httpApi.apiEndpoint; - } - - // Outputs for API Gateway - new cdk.CfnOutput(this, "ApiGatewayEndpoint", { - value: endpointUrl, - description: "API Gateway Endpoint URL", - }); - - new cdk.CfnOutput(this, "ApiGatewayId", { - value: httpApi.apiId, - description: "API Gateway ID", - }); - - new cdk.CfnOutput(this, "ApiGatewayDefaultUrl", { - value: httpApi.apiEndpoint, - description: "API Gateway Default URL", - }); - } // ============================================================================= // VS CODE COGNITO CLIENT (with callback URLs) @@ -688,6 +984,8 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { "http://127.0.0.1:33418/", "http://localhost:33418", "http://localhost:33418/", + "http://localhost:54038", + "http://localhost:54038/", `${endpointUrl}/callback`, `${endpointUrl}/callback/`, "https://vscode.dev/redirect", @@ -721,6 +1019,9 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { // Update Lambda environment variables with VS Code client ID and endpoint proxyLambda.addEnvironment("CLIENT_ID", vscodeClient.userPoolClientId); proxyLambda.addEnvironment("CALLBACK_LAMBDA_URL", endpointUrl); + // Pass the Cognito-registered callback URLs so the proxy Lambda can + // validate redirect_uri in handle_callback (open-redirect prevention). + proxyLambda.addEnvironment("ALLOWED_REDIRECT_URIS", JSON.stringify(callbackUrls)); const gatewayRole = new iam.Role(this, "GatewayRole", { assumedBy: iam.ServicePrincipal.fromStaticServicePrincipleName( @@ -734,9 +1035,9 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { "bedrock-agentcore:GetWorkloadAccess*", "bedrock-agentcore:GetResourceOauth2Token", "bedrock-agentcore:GetPolicyEngine", - "secretsmanager:GetSecretValue", "bedrock-agentcore:AuthorizeAction", - "bedrock-agentcore:PartiallyAuthorizeActions" + "bedrock-agentcore:PartiallyAuthorizeActions", + "bedrock-agentcore:CheckAuthorizePermissions" ], resources: ["*"], effect: iam.Effect.ALLOW, @@ -762,6 +1063,8 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { authorizerConfiguration: agentcore.GatewayAuthorizer.usingCognito({ userPool: userPool, allowedClients: [vscodeClient], + allowedAudiences: [vscodeClient.userPoolClientId], + allowedScopes: mcpScopes.map((s) => s.scopeName), }), interceptorConfigurations: [ agentcore.LambdaInterceptor.forRequest(interceptorLambda, { passRequestHeaders: true }), @@ -853,6 +1156,153 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { proxyLambda.addEnvironment("GATEWAY_URL", gateway.gatewayUrl ?? ""); + // Now that the gateway ARN is known, scope InvokeGateway to this gateway only. + // This must come AFTER the gateway construct so CDK can resolve the ARN token. + proxyLambdaRole.addToPolicy( + new iam.PolicyStatement({ + sid: "InvokeThisGatewayOnly", + effect: iam.Effect.ALLOW, + actions: ["bedrock-agentcore:InvokeGateway"], + // Scoped to the specific gateway ARN – not wildcard "*". + resources: [gateway.gatewayArn], + }) + ); + + // ============================================================================= + // GATEWAY RESOURCE-BASED POLICY (VPC restriction) + // ============================================================================= + + // Create a custom resource to attach VPC-based policy to the gateway + const policyCustomResourceRole = new iam.Role( + this, + "GatewayPolicyCustomResourceRole", + { + assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaBasicExecutionRole" + ), + ], + } + ); + + // Add permissions to manage gateway resource policy + policyCustomResourceRole.addToPolicy( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: [ + "bedrock-agentcore:PutResourcePolicy", + "bedrock-agentcore:GetResourcePolicy", + "bedrock-agentcore:DeleteResourcePolicy", + ], + resources: [gateway.gatewayArn], + }) + ); + + // Create Lambda layer with boto3 1.42.69 + const boto3Layer = new lambda.LayerVersion(this, "Boto3Layer", { + code: lambda.Code.fromAsset(path.join(__dirname, "../lambda"), { + bundling: { + image: lambda.Runtime.PYTHON_3_12.bundlingImage, + command: [ + "bash", + "-c", + [ + "pip install boto3==1.42.69 -t /asset-output/python", + ].join(" && "), + ], + }, + }), + compatibleRuntimes: [lambda.Runtime.PYTHON_3_12], + description: "boto3 1.42.69 for AgentCore Gateway policy management", + }); + + // Custom resource Lambda to manage gateway resource policy + const gatewayPolicyCustomResource = new lambda.Function( + this, + "GatewayPolicyCustomResource", + { + runtime: lambda.Runtime.PYTHON_3_12, + handler: "index.handler", + layers: [boto3Layer], + code: lambda.Code.fromInline(` +import json +import boto3 +import cfnresponse + +bedrock_agentcore = boto3.client('bedrock-agentcore-control') + +def handler(event, context): + try: + request_type = event['RequestType'] + gateway_id = event['ResourceProperties']['GatewayId'] + vpc_id = event['ResourceProperties']['VpcId'] + gateway_arn = event['ResourceProperties']['GatewayArn'] + + if request_type in ['Create', 'Update']: + policy = { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "AllowInvokeFromVPC", + "Effect": "Allow", + "Principal": "*", + "Action": "bedrock-agentcore:InvokeGateway", + "Resource": gateway_arn, + "Condition": { + "StringEquals": { + "aws:SourceVpc": vpc_id + } + } + } + ] + } + + bedrock_agentcore.put_resource_policy( + resourceArn=gateway_arn, + policy=json.dumps(policy) + ) + + cfnresponse.send(event, context, cfnresponse.SUCCESS, + {'PolicyApplied': 'true'}) + + elif request_type == 'Delete': + try: + bedrock_agentcore.delete_resource_policy( + resourceArn=gateway_arn + ) + except bedrock_agentcore.exceptions.ResourceNotFoundException: + pass # Policy already deleted + + cfnresponse.send(event, context, cfnresponse.SUCCESS, {}) + + except Exception as e: + print(f"Error: {str(e)}") + cfnresponse.send(event, context, cfnresponse.FAILED, + {'Error': str(e)}) +`), + role: policyCustomResourceRole, + timeout: cdk.Duration.minutes(2), + } + ); + + // Create custom resource + const gatewayPolicy = new cdk.CustomResource( + this, + "GatewayVpcPolicy", + { + serviceToken: gatewayPolicyCustomResource.functionArn, + properties: { + GatewayId: gateway.gatewayId, + VpcId: vpc.vpcId, + GatewayArn: gateway.gatewayArn, + }, + } + ); + + // Ensure policy is applied after gateway is created + gatewayPolicy.node.addDependency(gateway); + // Create policy engine const agentCorePolicyEngine = new AgentCorePolicyEngine(this, "AgentCorePolicyEngine", { policyEngineName: `enterprise_mcp_policy_engine`, @@ -891,6 +1341,9 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { agentCorePolicyEngine.associateWithGateway(gateway.gatewayId, 'ENFORCE'); agentCorePolicyEngine.node.addDependency(interceptorLambda); // Ensure interceptor Lambda is created before policy engine association + // Ensure the gateway VPC resource policy is applied after all Cedar policies + gatewayPolicy.node.addDependency(agentCorePolicyEngine); + // ============================================================================= // OUTPUTS // ============================================================================= @@ -925,11 +1378,6 @@ export class EnterpriseMcpInfraStack extends cdk.Stack { description: "VS Code Client ID", }); - new cdk.CfnOutput(this, "DeploymentTypeOutput", { - value: deploymentType, - description: "Deployment Type (ALB or API_GATEWAY)", - }); - new cdk.CfnOutput(this, "EndpointUrl", { value: endpointUrl, description: "Service Endpoint URL",