diff --git a/use-cases/ai/aws-agentcore/02-interceptor-identity-injection.ipynb b/use-cases/ai/aws-agentcore/02-interceptor-identity-injection.ipynb new file mode 100644 index 0000000..8b86b44 --- /dev/null +++ b/use-cases/ai/aws-agentcore/02-interceptor-identity-injection.ipynb @@ -0,0 +1,571 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# Notebook 02: Interceptor — Teleport Identity Injection\n", + "\n", + "## Overview\n", + "\n", + "The AgentCore Gateway validates the Teleport JWT cryptographically, but does not\n", + "automatically forward caller identity to Lambda targets. This notebook deploys a\n", + "REQUEST interceptor Lambda that runs before every tool call to bridge that gap:\n", + "\n", + "```\n", + "tsh mcp connect → AgentCore Gateway (JWT validated)\n", + " ↓ passRequestHeaders: true\n", + " Interceptor Lambda\n", + " 1. Reads Authorization: Bearer \n", + " 2. Decodes JWT payload (no re-verify — gateway already did it)\n", + " 3. Extracts sub + roles\n", + " 4. Injects _teleport_user + _teleport_roles into tools/call arguments\n", + " ↓\n", + " Tool Lambda ← event now contains verified caller identity\n", + "```\n", + "\n", + "After this notebook, `whoami_tool` returns the real Teleport identity instead of\n", + "the `unknown` placeholder.\n", + "\n", + "### Prerequisites\n", + "- Notebook 01 completed (gateway and tool Lambda exist)\n", + "- `.env` populated with AWS credentials" + ] + }, + { + "cell_type": "markdown", + "id": "step1-header", + "metadata": {}, + "source": [ + "## Step 1: Setup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "setup", + "metadata": { + "ExecuteTime": { + "end_time": "2026-05-28T15:59:02.879966Z", + "start_time": "2026-05-28T15:59:02.449069Z" + } + }, + "outputs": [], + "source": [ + "import os, json, time, io, zipfile, subprocess\n", + "import boto3\n", + "from dotenv import load_dotenv\n", + "from botocore.exceptions import ClientError\n", + "\n", + "load_dotenv(dotenv_path='.env', override=True)\n", + "os.environ.setdefault('AWS_DEFAULT_REGION', 'us-east-1')\n", + "REGION = os.environ['AWS_DEFAULT_REGION']\n", + "\n", + "# Explicit session forces boto3 to read from os.environ rather than its credential cache\n", + "session = boto3.Session(\n", + " aws_access_key_id=os.environ.get('AWS_ACCESS_KEY_ID'),\n", + " aws_secret_access_key=os.environ.get('AWS_SECRET_ACCESS_KEY'),\n", + " aws_session_token=os.environ.get('AWS_SESSION_TOKEN'),\n", + " region_name=REGION\n", + ")\n", + "\n", + "iam = session.client('iam')\n", + "lambda_client = session.client('lambda')\n", + "gateway_client = session.client('bedrock-agentcore-control')\n", + "account_id = session.client('sts').get_caller_identity()['Account']\n", + "\n", + "# Env-driven config (set in .env)\n", + "RESOURCE_PREFIX = os.environ.get('RESOURCE_PREFIX', 'teleport-demo')\n", + "TELEPORT_CLUSTER = os.environ.get('TELEPORT_CLUSTER', '')\n", + "TARGET_NAME = 'TeleportDemo'\n", + "TAG_CREATOR = os.environ.get('TAG_CREATOR', '')\n", + "TAG_DEMO = os.environ.get('TAG_DEMO', 'teleport-agentcore')\n", + "\n", + "# Resource names derived from prefix\n", + "GATEWAY_NAME = f'{RESOURCE_PREFIX}-identity-demo'\n", + "TELEPORT_DISCOVERY = f'https://{TELEPORT_CLUSTER}/.well-known/openid-configuration'\n", + "GATEWAY_ROLE_NAME = f'{RESOURCE_PREFIX}-agentcore-gateway-role'\n", + "\n", + "# Resolve gateway id + url\n", + "gw = next(g for g in gateway_client.list_gateways(maxResults=100)['items']\n", + " if g['name'] == GATEWAY_NAME)\n", + "gateway_id = gw['gatewayId']\n", + "gateway_url = gateway_client.get_gateway(gatewayIdentifier=gateway_id)['gatewayUrl']\n", + "gateway_role_arn = iam.get_role(RoleName=GATEWAY_ROLE_NAME)['Role']['Arn']\n", + "\n", + "print(f'Account : {account_id}')\n", + "print(f'Gateway : {gateway_id}')\n", + "print(f'URL : {gateway_url}')" + ] + }, + { + "cell_type": "markdown", + "id": "step2-header", + "metadata": {}, + "source": [ + "## Step 2: Create IAM Role for Interceptor Lambda" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "interceptor-role", + "metadata": { + "ExecuteTime": { + "end_time": "2026-05-28T15:59:18.114311Z", + "start_time": "2026-05-28T15:59:07.916857Z" + } + }, + "outputs": [], + "source": [ + "INTERCEPTOR_ROLE_NAME = f'{RESOURCE_PREFIX}-interceptor-lambda-role'\n", + "\n", + "trust_policy = {\n", + " 'Version': '2012-10-17',\n", + " 'Statement': [{\n", + " 'Effect': 'Allow',\n", + " 'Principal': {'Service': 'lambda.amazonaws.com'},\n", + " 'Action': 'sts:AssumeRole'\n", + " }]\n", + "}\n", + "\n", + "try:\n", + " resp = iam.create_role(\n", + " RoleName=INTERCEPTOR_ROLE_NAME,\n", + " AssumeRolePolicyDocument=json.dumps(trust_policy),\n", + " Description='Interceptor Lambda role for Teleport AgentCore demo'\n", + " )\n", + " interceptor_role_arn = resp['Role']['Arn']\n", + " print(f'Created role: {interceptor_role_arn}')\n", + " time.sleep(10)\n", + "except ClientError as e:\n", + " if e.response['Error']['Code'] == 'EntityAlreadyExists':\n", + " interceptor_role_arn = iam.get_role(RoleName=INTERCEPTOR_ROLE_NAME)['Role']['Arn']\n", + " print(f'Role already exists: {interceptor_role_arn}')\n", + " else:\n", + " raise\n", + "\n", + "iam.attach_role_policy(\n", + " RoleName=INTERCEPTOR_ROLE_NAME,\n", + " PolicyArn='arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'\n", + ")\n", + "print('Attached AWSLambdaBasicExecutionRole')" + ] + }, + { + "cell_type": "markdown", + "id": "step3-header", + "metadata": {}, + "source": [ + "## Step 3: Deploy Interceptor Lambda\n", + "\n", + "`lambda_interceptor.py` decodes the Teleport JWT from the `Authorization` header\n", + "and injects `_teleport_user` and `_teleport_roles` into `tools/call` arguments." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "deploy-interceptor", + "metadata": { + "ExecuteTime": { + "end_time": "2026-05-28T15:59:45.649521Z", + "start_time": "2026-05-28T15:59:41.826417Z" + } + }, + "outputs": [], + "source": [ + "INTERCEPTOR_LAMBDA_NAME = f'{RESOURCE_PREFIX}-interceptor'\n", + "\n", + "with open('lambda_interceptor.py', 'r') as f:\n", + " code = f.read()\n", + "\n", + "buf = io.BytesIO()\n", + "with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:\n", + " zf.writestr('lambda_function.py', code)\n", + "buf.seek(0)\n", + "pkg = buf.read()\n", + "print(f'Packaged lambda_interceptor.py ({len(pkg):,} bytes)')\n", + "\n", + "tags = {k: v for k, v in {'teleport.dev/creator': TAG_CREATOR, 'demo': TAG_DEMO}.items() if v}\n", + "\n", + "try:\n", + " resp = lambda_client.create_function(\n", + " FunctionName=INTERCEPTOR_LAMBDA_NAME,\n", + " Runtime='python3.12',\n", + " Role=interceptor_role_arn,\n", + " Handler='lambda_function.lambda_handler',\n", + " Code={'ZipFile': pkg},\n", + " Description='Teleport JWT identity injector for AgentCore Gateway',\n", + " Timeout=10,\n", + " MemorySize=128,\n", + " **({'Tags': tags} if tags else {}),\n", + " )\n", + " interceptor_lambda_arn = resp['FunctionArn']\n", + " print(f'Created: {interceptor_lambda_arn}')\n", + "except ClientError as e:\n", + " if e.response['Error']['Code'] == 'ResourceConflictException':\n", + " resp = lambda_client.update_function_code(\n", + " FunctionName=INTERCEPTOR_LAMBDA_NAME, ZipFile=pkg\n", + " )\n", + " interceptor_lambda_arn = resp['FunctionArn']\n", + " print(f'Updated: {interceptor_lambda_arn}')\n", + " else:\n", + " raise\n", + "\n", + "lambda_client.get_waiter('function_active_v2').wait(FunctionName=INTERCEPTOR_LAMBDA_NAME)\n", + "print('Lambda is active')\n" + ] + }, + { + "cell_type": "markdown", + "id": "step4-header", + "metadata": {}, + "source": [ + "## Step 4: Grant Gateway Permission to Invoke Interceptor" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "interceptor-permission", + "metadata": { + "ExecuteTime": { + "end_time": "2026-05-28T15:59:48.675502Z", + "start_time": "2026-05-28T15:59:48.419061Z" + } + }, + "outputs": [], + "source": [ + "try:\n", + " lambda_client.add_permission(\n", + " FunctionName=INTERCEPTOR_LAMBDA_NAME,\n", + " StatementId='AllowGatewayInvoke',\n", + " Action='lambda:InvokeFunction',\n", + " Principal='bedrock-agentcore.amazonaws.com',\n", + " SourceArn=f'arn:aws:bedrock-agentcore:{REGION}:{account_id}:gateway/*'\n", + " )\n", + " print('Permission added')\n", + "except ClientError as e:\n", + " if e.response['Error']['Code'] == 'ResourceConflictException':\n", + " print('Permission already exists')\n", + " else:\n", + " raise\n", + "\n", + "# Also grant the gateway IAM role permission to invoke the interceptor\n", + "iam.put_role_policy(\n", + " RoleName=GATEWAY_ROLE_NAME,\n", + " PolicyName='InterceptorInvokePolicy',\n", + " PolicyDocument=json.dumps({\n", + " 'Version': '2012-10-17',\n", + " 'Statement': [{\n", + " 'Effect': 'Allow',\n", + " 'Action': 'lambda:InvokeFunction',\n", + " 'Resource': interceptor_lambda_arn\n", + " }]\n", + " })\n", + ")\n", + "print('Gateway role updated with interceptor invoke permission')" + ] + }, + { + "cell_type": "markdown", + "id": "step5-header", + "metadata": {}, + "source": [ + "## Step 5: Wire Interceptor into the Gateway\n", + "\n", + "Update the gateway to run the interceptor on every `REQUEST` with `passRequestHeaders: true`\n", + "so the `Authorization: Bearer ` header is forwarded to the interceptor." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "wire-interceptor", + "metadata": { + "ExecuteTime": { + "end_time": "2026-05-28T15:59:53.382810Z", + "start_time": "2026-05-28T15:59:53.226640Z" + } + }, + "outputs": [], + "source": [ + "\n", + "# The gateway URL from the API omits the /mcp suffix, but Teleport appends /mcp\n", + "# in teleport.yaml and sets that as the JWT aud claim. Add it explicitly.\n", + "mcp_audience = f'mcp+{gateway_url.rstrip(\"/\")}/mcp'\n", + "print(f'Setting audience: {mcp_audience}')\n", + "\n", + "gateway_client.update_gateway(\n", + " gatewayIdentifier=gateway_id,\n", + " name=GATEWAY_NAME,\n", + " roleArn=gateway_role_arn,\n", + " authorizerType='CUSTOM_JWT',\n", + " authorizerConfiguration={\n", + " 'customJWTAuthorizer': {\n", + " 'discoveryUrl': TELEPORT_DISCOVERY,\n", + " 'allowedAudience': [mcp_audience],\n", + " 'customClaims': [{\n", + " 'inboundTokenClaimName': 'roles',\n", + " 'inboundTokenClaimValueType': 'STRING_ARRAY',\n", + " 'authorizingClaimMatchValue': {\n", + " 'claimMatchValue': {'matchValueString': 'mcp-user'},\n", + " 'claimMatchOperator': 'CONTAINS'\n", + " }\n", + " }]\n", + " }\n", + " },\n", + " interceptorConfigurations=[{\n", + " 'interceptor': {'lambda': {'arn': interceptor_lambda_arn}},\n", + " 'interceptionPoints': ['REQUEST'],\n", + " 'inputConfiguration': {'passRequestHeaders': True}\n", + " }]\n", + ")\n", + "print('Gateway updated with interceptor')\n", + "print(f' Interceptor : {interceptor_lambda_arn}')\n", + "print(f' Headers : passRequestHeaders=True')" + ] + }, + { + "cell_type": "markdown", + "id": "step6-header", + "metadata": {}, + "source": [ + "## Step 6: Wait for Gateway to be READY" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "wait-ready", + "metadata": { + "ExecuteTime": { + "end_time": "2026-05-28T15:59:59.585275Z", + "start_time": "2026-05-28T15:59:59.491429Z" + } + }, + "outputs": [], + "source": [ + "print('Waiting for gateway to reach READY status...')\n", + "while True:\n", + " status = gateway_client.get_gateway(gatewayIdentifier=gateway_id)['status']\n", + " print(f' Status: {status}')\n", + " if status == 'READY':\n", + " break\n", + " if status == 'FAILED':\n", + " raise RuntimeError('Gateway update failed')\n", + " time.sleep(10)\n", + "print('Gateway is READY')" + ] + }, + { + "cell_type": "markdown", + "id": "step7-header", + "metadata": {}, + "source": [ + "## Step 7: Test — whoami_tool Should Now Return Real Identity\n", + "\n", + "Call `whoami_tool` via `tsh mcp connect`. The interceptor decodes the Teleport JWT\n", + "and injects the identity before the tool Lambda runs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "test-whoami", + "metadata": { + "ExecuteTime": { + "end_time": "2026-05-28T16:00:20.009453Z", + "start_time": "2026-05-28T16:00:17.494225Z" + } + }, + "outputs": [], + "source": [ + "import json as _json\n", + "\n", + "APP_NAME = 'agentcore-gateway' # name in teleport.yaml\n", + "\n", + "msgs = [\n", + " '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"notebook\",\"version\":\"0.0.1\"}}}',\n", + " _json.dumps({\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":f\"{TARGET_NAME}___whoami_tool\",\"arguments\":{}}}),\n", + "]\n", + "\n", + "result = subprocess.run(\n", + " ['tsh', 'mcp', 'connect', APP_NAME],\n", + " input='\\n'.join(msgs) + '\\n',\n", + " capture_output=True, text=True, timeout=30\n", + ")\n", + "\n", + "print(\"stdout:\", result.stdout[:2000] or \"(empty)\")\n", + "\n", + "for line in result.stdout.splitlines():\n", + " try:\n", + " msg = _json.loads(line)\n", + " if msg.get('id') == 2:\n", + " text = msg['result']['content'][0]['text']\n", + " outer = _json.loads(text)\n", + " identity = _json.loads(outer['body'])\n", + " print('✓ whoami_tool — verified Teleport identity:')\n", + " print(_json.dumps(identity, indent=2))\n", + " except Exception as e:\n", + " if line.strip():\n", + " print(f'parse error on: {line[:200]} — {e}')\n", + "\n", + "real_errors = [l for l in result.stderr.splitlines()\n", + " if '\"level\":\"error\"' in l or ('ERROR' in l and 'listening' not in l)]\n", + "for e in real_errors:\n", + " print('ERROR:', e)\n" + ] + }, + { + "cell_type": "markdown", + "id": "step8-header", + "metadata": {}, + "source": [ + "## Step 8: Test — get_order_tool Includes Caller Identity" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "test-get-order", + "metadata": { + "ExecuteTime": { + "end_time": "2026-05-28T16:00:30.482863Z", + "start_time": "2026-05-28T16:00:28.354972Z" + } + }, + "outputs": [], + "source": [ + "msgs = [\n", + " '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"notebook\",\"version\":\"0.0.1\"}}}',\n", + " _json.dumps({\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":f\"{TARGET_NAME}___get_order_tool\",\"arguments\":{\"orderId\":\"ORD-42\"}}}),\n", + "]\n", + "\n", + "result = subprocess.run(\n", + " ['tsh', 'mcp', 'connect', APP_NAME],\n", + " input='\\n'.join(msgs) + '\\n',\n", + " capture_output=True, text=True, timeout=30\n", + ")\n", + "\n", + "print(\"stdout:\", result.stdout[:2000] or \"(empty)\")\n", + "\n", + "for line in result.stdout.splitlines():\n", + " try:\n", + " parsed = _json.loads(line)\n", + " if parsed.get('id') == 2:\n", + " print('get_order_tool response:')\n", + " content = parsed.get('result', {}).get('content', [])\n", + " for item in content:\n", + " body = _json.loads(_json.loads(item['text'])['body'])\n", + " print(_json.dumps(body, indent=2))\n", + " except Exception as e:\n", + " if line.strip():\n", + " print(f'parse error: {line[:200]} — {e}')\n" + ] + }, + { + "cell_type": "markdown", + "id": "next-steps", + "metadata": {}, + "source": [ + "## What's Next\n", + "\n", + "Identity now flows from Teleport through the gateway into every tool call.\n", + "Notebook 03 adds Cedar policy enforcement via Amazon Verified Permissions —\n", + "the interceptor will call AVP before forwarding, and `update_order_tool` will\n", + "be blocked for non-admin Teleport roles without any code change to the tool Lambda." + ] + }, + { + "cell_type": "markdown", + "id": "cleanup-header", + "metadata": {}, + "source": [ + "## Cleanup (Interceptor Only)\n", + "\n", + "To remove just the interceptor and revert the gateway to its notebook-01 state:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cleanup", + "metadata": { + "ExecuteTime": { + "end_time": "2026-05-28T16:08:05.227764Z", + "start_time": "2026-05-28T16:08:04.478024Z" + } + }, + "outputs": [], + "source": [ + "# ── Cleanup: safe to run at any point, even in a fresh kernel ──────────────\n", + "import os, io, zipfile\n", + "import boto3\n", + "from dotenv import load_dotenv\n", + "from botocore.exceptions import ClientError\n", + "\n", + "load_dotenv(dotenv_path='.env', override=True)\n", + "os.environ.setdefault('AWS_DEFAULT_REGION', 'us-east-1')\n", + "_s = boto3.Session(\n", + " aws_access_key_id=os.environ.get('AWS_ACCESS_KEY_ID'),\n", + " aws_secret_access_key=os.environ.get('AWS_SECRET_ACCESS_KEY'),\n", + " aws_session_token=os.environ.get('AWS_SESSION_TOKEN'),\n", + " region_name=os.environ['AWS_DEFAULT_REGION'],\n", + ")\n", + "_iam = _s.client('iam')\n", + "_lambda = _s.client('lambda')\n", + "\n", + "_PREFIX = os.environ.get('RESOURCE_PREFIX', 'teleport-demo')\n", + "_INTERCEPTOR_LAMBDA = f'{_PREFIX}-interceptor'\n", + "_INTERCEPTOR_ROLE = f'{_PREFIX}-interceptor-lambda-role'\n", + "_GATEWAY_ROLE = f'{_PREFIX}-agentcore-gateway-role'\n", + "\n", + "def _swallow(fn, *args, **kwargs):\n", + " try:\n", + " return fn(*args, **kwargs)\n", + " except ClientError as e:\n", + " print(f' skip ({e.response[\"Error\"][\"Code\"]})')\n", + " except Exception as e:\n", + " print(f' skip ({e})')\n", + "\n", + "# Remove interceptor invoke permission from gateway role\n", + "_swallow(_iam.delete_role_policy, RoleName=_GATEWAY_ROLE, PolicyName='InterceptorInvokePolicy')\n", + "print('Removed InterceptorInvokePolicy from gateway role')\n", + "\n", + "# Delete interceptor Lambda\n", + "_swallow(_lambda.delete_function, FunctionName=_INTERCEPTOR_LAMBDA)\n", + "print(f'Deleted Lambda: {_INTERCEPTOR_LAMBDA}')\n", + "\n", + "# Delete interceptor IAM role\n", + "try:\n", + " for p in _iam.list_attached_role_policies(RoleName=_INTERCEPTOR_ROLE).get('AttachedPolicies', []):\n", + " _swallow(_iam.detach_role_policy, RoleName=_INTERCEPTOR_ROLE, PolicyArn=p['PolicyArn'])\n", + " for p in _iam.list_role_policies(RoleName=_INTERCEPTOR_ROLE).get('PolicyNames', []):\n", + " _swallow(_iam.delete_role_policy, RoleName=_INTERCEPTOR_ROLE, PolicyName=p)\n", + " _swallow(_iam.delete_role, RoleName=_INTERCEPTOR_ROLE)\n", + " print(f'Deleted role: {_INTERCEPTOR_ROLE}')\n", + "except ClientError as e:\n", + " print(f' Role {_INTERCEPTOR_ROLE!r}: skip ({e.response[\"Error\"][\"Code\"]})')\n", + "\n", + "print()\n", + "print('Run notebook 01 cleanup to delete the gateway itself.')\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/use-cases/ai/aws-agentcore/README.md b/use-cases/ai/aws-agentcore/README.md index daa18b3..a858f1d 100644 --- a/use-cases/ai/aws-agentcore/README.md +++ b/use-cases/ai/aws-agentcore/README.md @@ -18,8 +18,10 @@ AgentCore Gateway employs a dual authentication model: - **Outbound Auth** — enables the gateway to securely connect to backend resources. Here that is an IAM role that grants the gateway permission to invoke the tool Lambdas. -Teleport's `tsh mcp connect` proxy forwards the signed JWT to the gateway as a Bearer token, -so every tool invocation carries a verified, auditable caller identity. +Teleport's `tsh mcp connect` proxy forwards the signed JWT to the gateway as a Bearer token. +A REQUEST interceptor Lambda decodes that JWT and injects the caller's verified identity +(`_teleport_user`, `_teleport_roles`) into every tool call — so Lambda tools know *who* +called them without any authentication code of their own. ### Architecture @@ -34,8 +36,12 @@ AgentCore Gateway │ Validates JWT against Teleport OIDC discovery URL │ Enforces: roles CONTAINS "mcp-user" ↓ +REQUEST Interceptor Lambda + │ Decodes JWT → sub, roles (no re-verify — gateway already did it) + │ Injects _teleport_user + _teleport_roles into tool arguments + ↓ Tool Lambda - │ whoami_tool → returns caller identity from JWT claims + │ whoami_tool → returns verified caller identity │ get_order_tool → retrieve order data │ update_order_tool → update order data ``` @@ -62,6 +68,15 @@ Sets up the foundation: - Registers the Lambda as an MCP target - Smoke-tests via `tsh mcp connect` +### 02 — Interceptor: Identity Injection +`02-interceptor-identity-injection.ipynb` + +Bridges the identity gap — AgentCore validates the JWT but doesn't forward it to Lambda: +- Deploys a REQUEST interceptor Lambda (`lambda_interceptor.py`) +- Interceptor decodes the Teleport JWT and injects `_teleport_user` + `_teleport_roles` + into every `tools/call` invocation +- After this notebook, `whoami_tool` returns the real Teleport identity + ## Prerequisites - AWS credentials with permissions for Lambda, IAM, bedrock-agentcore @@ -84,11 +99,11 @@ cp .env.example .env ### 2. Run the notebooks -Run notebook **01**. It is idempotent — re-running a cell that already created a resource -will skip creation gracefully. +Run in order: **01 → 02**. Each notebook is idempotent — re-running a cell that already +created a resource will skip creation gracefully. After notebook 01 completes and prints the gateway URL, complete the Teleport agent -setup below. +setup below before proceeding to notebook 02. ### 3. Configure the Teleport app agent @@ -187,5 +202,6 @@ bash test-mcp.sh | File | Purpose | |:-----|:--------| | `lambda_tool.py` | Tool Lambda handler (whoami, get_order, update_order) | +| `lambda_interceptor.py` | REQUEST interceptor — decodes Teleport JWT and injects caller identity | | `test-mcp.sh` | Shell script to test the MCP endpoint directly via `tsh mcp connect` | -| `.env.example` | Template for AWS credential environment variables | \ No newline at end of file +| `.env.example` | Template for AWS credential environment variables | diff --git a/use-cases/ai/aws-agentcore/lambda_interceptor.py b/use-cases/ai/aws-agentcore/lambda_interceptor.py new file mode 100644 index 0000000..210bbf0 --- /dev/null +++ b/use-cases/ai/aws-agentcore/lambda_interceptor.py @@ -0,0 +1,55 @@ +import json +import base64 +import logging + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def _decode_jwt_claims(token: str) -> dict: + payload = token.split('.')[1] + payload += '=' * (4 - len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload)) + + +def lambda_handler(event, context): + logger.info(f"Interceptor input keys: {list(event.keys())}") + + mcp_data = event.get('mcp', {}) + gateway_request = mcp_data.get('gatewayRequest', {}) + headers = gateway_request.get('headers', {}) + body = gateway_request.get('body', {}) + + # Decode the Teleport JWT — already validated by the gateway, no re-verify needed + teleport_user = 'unknown' + teleport_roles = '' + auth_header = headers.get('Authorization') or headers.get('authorization', '') + if auth_header.startswith('Bearer '): + try: + claims = _decode_jwt_claims(auth_header[7:]) + teleport_user = claims.get('sub', 'unknown') + roles = claims.get('roles', []) + teleport_roles = ','.join(roles) if isinstance(roles, list) else str(roles) + logger.info(f"Teleport identity: sub={teleport_user} roles={teleport_roles}") + except Exception as e: + logger.warning(f"Failed to decode JWT: {e}") + + # Inject identity into tool arguments so the tool Lambda event receives them. + # Only tools/call carries arguments; initialize and tools/list pass through unchanged. + method = body.get('method', '') + if method == 'tools/call': + params = body.setdefault('params', {}) + args = params.setdefault('arguments', {}) + args['_teleport_user'] = teleport_user + args['_teleport_roles'] = teleport_roles + logger.info(f"Injected identity into tools/call arguments for tool: {params.get('name')}") + + return { + 'interceptorOutputVersion': '1.0', + 'mcp': { + 'transformedGatewayRequest': { + 'headers': headers, + 'body': body, + } + } + } \ No newline at end of file diff --git a/use-cases/ai/aws-agentcore/requirements.txt b/use-cases/ai/aws-agentcore/requirements.txt index dfacef4..b105df5 100644 --- a/use-cases/ai/aws-agentcore/requirements.txt +++ b/use-cases/ai/aws-agentcore/requirements.txt @@ -1,4 +1,4 @@ -gitboto3 +boto3 strands-agents strands-agents-tools mcp