From 31b098d11c2560c64b2e51a8059601b31bf0d5ca Mon Sep 17 00:00:00 2001 From: Jeff <11721615+jeffellin@users.noreply.github.com> Date: Fri, 22 May 2026 08:59:23 -0400 Subject: [PATCH 1/8] add example for teleport jwt-verification in an mcp server --- use-cases/ai/mcp-auth-basic/teleport.yaml | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 use-cases/ai/mcp-auth-basic/teleport.yaml diff --git a/use-cases/ai/mcp-auth-basic/teleport.yaml b/use-cases/ai/mcp-auth-basic/teleport.yaml new file mode 100644 index 0000000..bb969dc --- /dev/null +++ b/use-cases/ai/mcp-auth-basic/teleport.yaml @@ -0,0 +1,33 @@ +version: v3 +teleport: + proxy_server: ellinj.teleport.sh:443 + auth_token: # tctl tokens add --type=app --ttl=1h + data_dir: /Users/jeff/dev/teleport-agent/data + log: + output: stderr + severity: DEBUG + format: + output: text + extra_fields: [timestamp, level, component, caller] + +auth_service: + enabled: false + +proxy_service: + enabled: false + +ssh_service: + enabled: false + +app_service: + enabled: true + debug_app: true # serves a diagnostic page at /teleport-debug showing forwarded headers and JWT claims + apps: + - name: identity-aware-mcp + uri: "mcp+http://localhost:9999/mcp" + labels: + env: dev + insecure_skip_verify: false # set true to skip TLS verification for the backend + rewrite: + headers: + - "Authorization: Bearer {{internal.id_token}}" From 50340f4672c0f1f5f17e6c251d202048c4b59a69 Mon Sep 17 00:00:00 2001 From: Jeff <11721615+jeffellin@users.noreply.github.com> Date: Fri, 22 May 2026 14:13:45 -0400 Subject: [PATCH 2/8] Refactor and enhance MCP demo: generate `teleport.yaml` in notebook, update readme and `.env.example`, and gitignore generated files. --- use-cases/ai/mcp-auth-basic/teleport.yaml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/use-cases/ai/mcp-auth-basic/teleport.yaml b/use-cases/ai/mcp-auth-basic/teleport.yaml index bb969dc..462738b 100644 --- a/use-cases/ai/mcp-auth-basic/teleport.yaml +++ b/use-cases/ai/mcp-auth-basic/teleport.yaml @@ -1,11 +1,11 @@ version: v3 teleport: proxy_server: ellinj.teleport.sh:443 - auth_token: # tctl tokens add --type=app --ttl=1h - data_dir: /Users/jeff/dev/teleport-agent/data + auth_token: xfdsf + data_dir: /Users/jeff/dev/rev-tech/use-cases/ai/mcp-auth-basic/teleport-data log: output: stderr - severity: DEBUG + severity: INFO format: output: text extra_fields: [timestamp, level, component, caller] @@ -21,13 +21,11 @@ ssh_service: app_service: enabled: true - debug_app: true # serves a diagnostic page at /teleport-debug showing forwarded headers and JWT claims apps: - name: identity-aware-mcp uri: "mcp+http://localhost:9999/mcp" labels: env: dev - insecure_skip_verify: false # set true to skip TLS verification for the backend rewrite: headers: - "Authorization: Bearer {{internal.id_token}}" From a75aab378c98244ffe2e8e48f7f221798fb25c6a Mon Sep 17 00:00:00 2001 From: Jeff <11721615+jeffellin@users.noreply.github.com> Date: Tue, 26 May 2026 10:49:10 -0400 Subject: [PATCH 3/8] Remove `teleport.yaml`, update JWT claims, and revise README examples to align with new user and domain. --- use-cases/ai/mcp-auth-basic/teleport.yaml | 31 ----------------------- 1 file changed, 31 deletions(-) delete mode 100644 use-cases/ai/mcp-auth-basic/teleport.yaml diff --git a/use-cases/ai/mcp-auth-basic/teleport.yaml b/use-cases/ai/mcp-auth-basic/teleport.yaml deleted file mode 100644 index 462738b..0000000 --- a/use-cases/ai/mcp-auth-basic/teleport.yaml +++ /dev/null @@ -1,31 +0,0 @@ -version: v3 -teleport: - proxy_server: ellinj.teleport.sh:443 - auth_token: xfdsf - data_dir: /Users/jeff/dev/rev-tech/use-cases/ai/mcp-auth-basic/teleport-data - log: - output: stderr - severity: INFO - format: - output: text - extra_fields: [timestamp, level, component, caller] - -auth_service: - enabled: false - -proxy_service: - enabled: false - -ssh_service: - enabled: false - -app_service: - enabled: true - apps: - - name: identity-aware-mcp - uri: "mcp+http://localhost:9999/mcp" - labels: - env: dev - rewrite: - headers: - - "Authorization: Bearer {{internal.id_token}}" From f448f50bd2649994e31a79bee2cdef314559febe Mon Sep 17 00:00:00 2001 From: Jeff <11721615+jeffellin@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:23:22 -0400 Subject: [PATCH 4/8] Add lambda interceptor for identity injection and AVP authorization. Update README with deployment details. --- .../02-interceptor-identity-injection.ipynb | 571 +++++++++++++++++ .../03-cedar-avp-authorization.ipynb | 585 ++++++++++++++++++ use-cases/ai/aws-agentcore/README.md | 51 +- .../ai/aws-agentcore/lambda_interceptor.py | 55 ++ .../aws-agentcore/lambda_interceptor_avp.py | 121 ++++ 5 files changed, 1373 insertions(+), 10 deletions(-) create mode 100644 use-cases/ai/aws-agentcore/02-interceptor-identity-injection.ipynb create mode 100644 use-cases/ai/aws-agentcore/03-cedar-avp-authorization.ipynb create mode 100644 use-cases/ai/aws-agentcore/lambda_interceptor.py create mode 100644 use-cases/ai/aws-agentcore/lambda_interceptor_avp.py 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/03-cedar-avp-authorization.ipynb b/use-cases/ai/aws-agentcore/03-cedar-avp-authorization.ipynb new file mode 100644 index 0000000..8e5581c --- /dev/null +++ b/use-cases/ai/aws-agentcore/03-cedar-avp-authorization.ipynb @@ -0,0 +1,585 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# Notebook 03: Cedar Policy Authorization via Amazon Verified Permissions\n", + "\n", + "## Overview\n", + "\n", + "Notebook 02 proved that Teleport identity flows into every tool call. This notebook\n", + "adds **policy enforcement**: the interceptor Lambda now calls Amazon Verified Permissions\n", + "(AVP) before forwarding any tool call. AVP evaluates Cedar policies that map\n", + "Teleport roles to allowed tools — no code changes required to add or change a rule.\n", + "\n", + "```\n", + "tools/call arrives at interceptor\n", + " │\n", + " ├─ decode Teleport JWT → sub, roles\n", + " │\n", + " ├─ AVP IsAuthorized:\n", + " │ principal: TeleportUser::\"alice@acme.com\"\n", + " │ action: Action::\"invoke_tool\"\n", + " │ resource: Tool::\"update_order_tool\"\n", + " │ entities: user is member of [mcp-user, aws-personal-admin, ...]\n", + " │\n", + " ├─ DENY → MCP error returned, tool Lambda never invoked\n", + " └─ ALLOW → inject identity, forward to tool Lambda\n", + "```\n", + "\n", + "### Demo highlight — live policy change\n", + "At step 9, `update_order_tool` is denied for `mcp-user`. We then add a Cedar\n", + "policy in AVP (no redeploy) and the same call immediately succeeds.\n", + "\n", + "### Prerequisites\n", + "- Notebooks 01 and 02 completed\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": {}, + "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", + "# Create an explicit session so boto3 reads credentials from os.environ\n", + "# rather than any internally cached credential chain from a prior run.\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", + "avp = session.client('verifiedpermissions')\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", + "INTERCEPTOR_LAMBDA_NAME = f'{RESOURCE_PREFIX}-interceptor'\n", + "APP_NAME = 'agentcore-gateway'\n", + "\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", + "interceptor_lambda_arn = lambda_client.get_function(\n", + " FunctionName=INTERCEPTOR_LAMBDA_NAME)['Configuration']['FunctionArn']\n", + "\n", + "print(f'Account : {account_id}')\n", + "print(f'Gateway : {gateway_id}')\n", + "print(f'Interceptor: {interceptor_lambda_arn}')" + ] + }, + { + "cell_type": "markdown", + "id": "step2-header", + "metadata": {}, + "source": [ + "## Step 2: Create AVP Policy Store" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "create-policy-store", + "metadata": {}, + "outputs": [], + "source": [ + "AVP_POLICY_STORE_DESC = 'Teleport role → AgentCore tool access control'\n", + "\n", + "resp = avp.create_policy_store(\n", + " validationSettings={'mode': 'OFF'},\n", + " description=AVP_POLICY_STORE_DESC\n", + ")\n", + "policy_store_id = resp['policyStoreId']\n", + "print(f'Policy store: {policy_store_id}')\n", + "\n", + "# Persist to .env so the cleanup cell can find it later\n", + "_env_path = '.env'\n", + "_lines = open(_env_path).readlines()\n", + "_lines = [l for l in _lines if not l.startswith('AVP_POLICY_STORE_ID=')]\n", + "_lines.append(f'AVP_POLICY_STORE_ID={policy_store_id}\\n')\n", + "open(_env_path, 'w').writelines(_lines)\n", + "print(f'Saved AVP_POLICY_STORE_ID to .env')" + ] + }, + { + "cell_type": "markdown", + "id": "step3-header", + "metadata": {}, + "source": [ + "## Step 3: Cedar Policies\n", + "\n", + "We use validation mode `OFF` so no Cedar JSON schema is needed.\n", + "AVP still enforces the policies exactly as written — `OFF` only skips\n", + "compile-time type-checking of policy syntax against a schema.\n", + "The allow/deny runtime behaviour is unchanged." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "put-schema", + "metadata": {}, + "outputs": [], + "source": [ + "print('Validation mode is OFF — no schema required. Proceeding to policy creation.')" + ] + }, + { + "cell_type": "markdown", + "id": "step4-header", + "metadata": {}, + "source": [ + "## Step 4: Create Cedar Policies\n", + "\n", + "- `mcp-user` role → `whoami_tool` and `get_order_tool`\n", + "- `order-admin` role → `update_order_tool`\n", + "\n", + "The caller holds `mcp-user` but **not** `order-admin`, so `update_order_tool` should\n", + "be denied at step 9. Step 10 grants it to `mcp-user` live — no redeploy needed." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "create-policies", + "metadata": {}, + "outputs": [], + "source": [ + "policies = [\n", + " (\n", + " 'mcp-user-whoami',\n", + " 'mcp-user role can call whoami_tool',\n", + " 'permit(principal in TeleportRole::\"mcp-user\", action == Action::\"invoke_tool\", resource == Tool::\"whoami_tool\");'\n", + " ),\n", + " (\n", + " 'mcp-user-get-order',\n", + " 'mcp-user role can read orders',\n", + " 'permit(principal in TeleportRole::\"mcp-user\", action == Action::\"invoke_tool\", resource == Tool::\"get_order_tool\");'\n", + " ),\n", + " (\n", + " 'order-admin-update-order',\n", + " 'order-admin role can mutate orders (user does not have this role)',\n", + " 'permit(principal in TeleportRole::\"order-admin\", action == Action::\"invoke_tool\", resource == Tool::\"update_order_tool\");'\n", + " ),\n", + "]\n", + "\n", + "policy_ids = {}\n", + "for name, description, statement in policies:\n", + " resp = avp.create_policy(\n", + " policyStoreId=policy_store_id,\n", + " definition={'static': {'description': description, 'statement': statement}}\n", + " )\n", + " policy_ids[name] = resp['policyId']\n", + " print(f' {name}: {resp[\"policyId\"]}')\n", + "\n", + "print(f'\\n{len(policy_ids)} policies created')\n", + "print()\n", + "print('mcp-user → whoami_tool, get_order_tool')\n", + "print('order-admin → update_order_tool (caller does not hold this role → DENY expected)')" + ] + }, + { + "cell_type": "markdown", + "id": "step5-header", + "metadata": {}, + "source": [ + "## Step 5: Grant Interceptor Lambda Permission to Call AVP" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "grant-avp", + "metadata": {}, + "outputs": [], + "source": [ + "interceptor_role_name = f'{RESOURCE_PREFIX}-interceptor-lambda-role'\n", + "\n", + "iam.put_role_policy(\n", + " RoleName=interceptor_role_name,\n", + " PolicyName='AVPIsAuthorized',\n", + " PolicyDocument=json.dumps({\n", + " 'Version': '2012-10-17',\n", + " 'Statement': [{\n", + " 'Effect': 'Allow',\n", + " 'Action': 'verifiedpermissions:IsAuthorized',\n", + " 'Resource': f'arn:aws:verifiedpermissions::{account_id}:policy-store/{policy_store_id}'\n", + " }]\n", + " })\n", + ")\n", + "print('AVP IsAuthorized permission granted to interceptor role')" + ] + }, + { + "cell_type": "markdown", + "id": "step6-header", + "metadata": {}, + "source": [ + "## Step 6: Deploy Updated Interceptor Lambda\n", + "\n", + "`lambda_interceptor_avp.py` adds AVP `IsAuthorized` calls before forwarding.\n", + "The policy store ID is passed via the `AVP_POLICY_STORE_ID` environment variable." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "deploy-avp-interceptor", + "metadata": {}, + "outputs": [], + "source": [ + "with open('lambda_interceptor_avp.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", + "\n", + "lambda_client.update_function_code(\n", + " FunctionName=INTERCEPTOR_LAMBDA_NAME, ZipFile=pkg\n", + ")\n", + "lambda_client.get_waiter('function_updated_v2').wait(FunctionName=INTERCEPTOR_LAMBDA_NAME)\n", + "\n", + "lambda_client.update_function_configuration(\n", + " FunctionName=INTERCEPTOR_LAMBDA_NAME,\n", + " Environment={'Variables': {'AVP_POLICY_STORE_ID': policy_store_id}}\n", + ")\n", + "lambda_client.get_waiter('function_updated_v2').wait(FunctionName=INTERCEPTOR_LAMBDA_NAME)\n", + "\n", + "print(f'Interceptor updated with AVP_POLICY_STORE_ID={policy_store_id}')" + ] + }, + { + "cell_type": "markdown", + "id": "step7-header", + "metadata": {}, + "source": [ + "## Step 7: Helper — Call a Tool via tsh" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "helper", + "metadata": {}, + "outputs": [], + "source": [ + "import json as _json\n", + "\n", + "def call_tool(tool_name, arguments={}):\n", + " \"\"\"Call a gateway tool via tsh mcp connect and return the parsed result.\"\"\"\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({\n", + " 'jsonrpc': '2.0', 'id': 2, 'method': 'tools/call',\n", + " 'params': {'name': f'{TARGET_NAME}___{tool_name}', 'arguments': 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", + " for line in result.stdout.splitlines():\n", + " try:\n", + " msg = _json.loads(line)\n", + " if msg.get('id') == 2:\n", + " return msg\n", + " except Exception:\n", + " pass\n", + " return None\n", + "\n", + "def show(tool_name, arguments={}):\n", + " print(f'\\n→ {tool_name}({arguments})')\n", + " msg = call_tool(tool_name, arguments)\n", + " if msg is None:\n", + " print(' (no response)')\n", + " return\n", + " if 'error' in msg:\n", + " print(f' ✗ DENIED: {msg[\"error\"][\"message\"]}')\n", + " else:\n", + " content = msg['result']['content'][0]['text']\n", + " try:\n", + " outer = _json.loads(content)\n", + " body = _json.loads(outer['body'])\n", + " print(f' ✓ ALLOWED: {_json.dumps(body, indent=4)}')\n", + " except Exception:\n", + " print(f' ✓ ALLOWED: {content}')\n", + "\n", + "print('Helper ready')" + ] + }, + { + "cell_type": "markdown", + "id": "step8-header", + "metadata": {}, + "source": [ + "## Step 8: Test Allowed Calls\n", + "\n", + "`whoami_tool` and `get_order_tool` are permitted for `mcp-user`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "test-allowed", + "metadata": {}, + "outputs": [], + "source": [ + "show('whoami_tool')\n", + "show('get_order_tool', {'orderId': 'ORD-42'})" + ] + }, + { + "cell_type": "markdown", + "id": "step9-header", + "metadata": {}, + "source": [ + "## Step 9: Test Denied Call\n", + "\n", + "`update_order_tool` is only permitted for `order-admin`. The caller holds `mcp-user`\n", + "but not `order-admin` — Cedar default-deny blocks it. The tool Lambda is **never invoked**." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "test-denied", + "metadata": {}, + "outputs": [], + "source": [ + "show('update_order_tool', {'orderId': 'ORD-42'})" + ] + }, + { + "cell_type": "markdown", + "id": "step10-header", + "metadata": {}, + "source": [ + "## Step 10: Live Policy Change — Grant Access Without Redeploying\n", + "\n", + "Add a Cedar policy that permits `mcp-user` to call `update_order_tool`.\n", + "No Lambda redeployment, no gateway restart — AVP evaluates it immediately." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "live-policy-change", + "metadata": {}, + "outputs": [], + "source": [ + "resp = avp.create_policy(\n", + " policyStoreId=policy_store_id,\n", + " definition={'static': {\n", + " 'description': 'DEMO: grant mcp-user update access (added live)',\n", + " 'statement': 'permit(principal in TeleportRole::\"mcp-user\", action == Action::\"invoke_tool\", resource == Tool::\"update_order_tool\");'\n", + " }}\n", + ")\n", + "new_policy_id = resp['policyId']\n", + "print(f'Policy added: {new_policy_id}')\n", + "print('No Lambda redeployment needed — calling update_order_tool again...')\n", + "\n", + "show('update_order_tool', {'orderId': 'ORD-42'})" + ] + }, + { + "cell_type": "markdown", + "id": "step11-header", + "metadata": {}, + "source": [ + "## Step 11: Revert — Remove the Policy\n", + "\n", + "Delete the policy added in step 10 to restore the original deny." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "revert-policy", + "metadata": {}, + "outputs": [], + "source": [ + "avp.delete_policy(policyStoreId=policy_store_id, policyId=new_policy_id)\n", + "print(f'Policy {new_policy_id} removed')\n", + "\n", + "show('update_order_tool', {'orderId': 'ORD-42'})" + ] + }, + { + "cell_type": "markdown", + "id": "what-happened", + "metadata": {}, + "source": [ + "## What Just Happened\n", + "\n", + "| Step | Result |\n", + "|:-----|:-------|\n", + "| `whoami_tool` | ✓ Cedar ALLOW — `mcp-user` policy matches |\n", + "| `get_order_tool` | ✓ Cedar ALLOW — `mcp-user` policy matches |\n", + "| `update_order_tool` (before) | ✗ Cedar DENY — no policy for `mcp-user` |\n", + "| Add policy in AVP console/API | — no code deployed |\n", + "| `update_order_tool` (after) | ✓ Cedar ALLOW — new policy matched |\n", + "| Remove policy | ✗ Cedar DENY again — instantly reverted |\n", + "\n", + "**Two independent audit trails:**\n", + "- Teleport logs: who connected, when, from where\n", + "- AVP logs: every `IsAuthorized` decision with the matching policy ID\n", + "\n", + "## What's Next\n", + "\n", + "Notebook 04 wires a Strands agent to the gateway so you can drive the same\n", + "tools with natural language and see the identity + Cedar enforcement in action\n", + "end-to-end." + ] + }, + { + "cell_type": "markdown", + "id": "cleanup-header", + "metadata": {}, + "source": [ + "## Cleanup" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cleanup", + "metadata": {}, + "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", + "_lambda = _s.client('lambda')\n", + "_avp = _s.client('verifiedpermissions')\n", + "\n", + "_PREFIX = os.environ.get('RESOURCE_PREFIX', 'teleport-demo')\n", + "_INTERCEPTOR_LAMBDA = f'{_PREFIX}-interceptor'\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", + "# Delete AVP policy store (lists and deletes all policies first)\n", + "try:\n", + " stores = _avp.list_policy_stores().get('policyStores', [])\n", + " store_id = os.environ.get('AVP_POLICY_STORE_ID', '')\n", + " if not store_id:\n", + " if len(stores) == 1:\n", + " store_id = stores[0]['policyStoreId']\n", + " print(f'Found one policy store: {store_id}')\n", + " elif stores:\n", + " print(f'Multiple policy stores found; set AVP_POLICY_STORE_ID in .env to target one.')\n", + " print(f' Stores: {[s[\"policyStoreId\"] for s in stores]}')\n", + " store_id = None\n", + " else:\n", + " print('No policy stores found — skipping AVP cleanup')\n", + " store_id = None\n", + "\n", + " if store_id:\n", + " try:\n", + " policies = _avp.list_policies(policyStoreId=store_id).get('policies', [])\n", + " for p in policies:\n", + " _swallow(_avp.delete_policy, policyStoreId=store_id, policyId=p['policyId'])\n", + " print(f' Deleted policy: {p[\"policyId\"]}')\n", + " _swallow(_avp.delete_policy_store, policyStoreId=store_id)\n", + " print(f'Deleted policy store: {store_id}')\n", + " except ClientError as e:\n", + " code = e.response[\"Error\"][\"Code\"]\n", + " if code in ('ResourceNotFoundException', 'ValidationException'):\n", + " print(f'Policy store {store_id!r} not found or invalid — skipping')\n", + " else:\n", + " print(f'AVP cleanup: skip ({code}: {e.response[\"Error\"][\"Message\"]})')\n", + "except ClientError as e:\n", + " print(f'AVP list_policy_stores: skip ({e.response[\"Error\"][\"Code\"]}: {e.response[\"Error\"][\"Message\"]})')\n", + "\n", + "# Roll interceptor back to identity-only version (no AVP)\n", + "try:\n", + " with open('lambda_interceptor.py', 'r') as f:\n", + " code = f.read()\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", + " _swallow(_lambda.update_function_code, FunctionName=_INTERCEPTOR_LAMBDA, ZipFile=pkg)\n", + " _lambda.get_waiter('function_updated_v2').wait(FunctionName=_INTERCEPTOR_LAMBDA)\n", + " _swallow(_lambda.update_function_configuration,\n", + " FunctionName=_INTERCEPTOR_LAMBDA, Environment={'Variables': {}})\n", + " _lambda.get_waiter('function_updated_v2').wait(FunctionName=_INTERCEPTOR_LAMBDA)\n", + " print('Interceptor rolled back to identity-only')\n", + "except ClientError as e:\n", + " print(f'Interceptor rollback: skip ({e.response[\"Error\"][\"Code\"]})')\n", + "except FileNotFoundError:\n", + " print('lambda_interceptor.py not found — skipping rollback')\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..4163ff5 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,10 +36,16 @@ 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) + │ Calls Amazon Verified Permissions (Cedar policy) + │ DENY → MCP error returned, tool Lambda never invoked + │ ALLOW → inject _teleport_user + _teleport_roles into tool arguments + ↓ Tool Lambda - │ whoami_tool → returns caller identity from JWT claims - │ get_order_tool → retrieve order data - │ update_order_tool → update order data + │ whoami_tool → returns verified caller identity + │ get_order_tool → readable by mcp-user role (Cedar ALLOW) + │ update_order_tool → requires order-admin role (Cedar DENY for mcp-user) ``` ### Tutorial Details @@ -47,6 +55,7 @@ Tool Lambda | **Tutorial type** | Interactive (Jupyter notebooks) | | **AgentCore components** | AgentCore Gateway | | **Identity provider** | Teleport (OIDC) | +| **Authorization** | Amazon Verified Permissions (Cedar policies) | | **Gateway target** | AWS Lambda | | **MCP transport** | `mcp+https` (Streamable HTTP) | | **SDK** | boto3 / tsh CLI | @@ -62,9 +71,29 @@ 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 +- 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 + +### 03 — Cedar Policy Authorization +`03-cedar-avp-authorization.ipynb` + +Adds policy enforcement via Amazon Verified Permissions: +- Creates an AVP policy store with Cedar policies mapping Teleport roles to tools +- `mcp-user` → `whoami_tool`, `get_order_tool` +- `order-admin` → `update_order_tool` *(caller does not hold this role — expect DENY)* +- Deploys the AVP-aware interceptor (`lambda_interceptor_avp.py`) +- **Live policy change demo**: grants `mcp-user` access to `update_order_tool` by adding + a Cedar policy in AVP — no Lambda redeployment, no gateway restart + ## Prerequisites -- AWS credentials with permissions for Lambda, IAM, bedrock-agentcore +- AWS credentials with permissions for Lambda, IAM, bedrock-agentcore, verifiedpermissions - A Teleport cluster (self-hosted or Teleport Cloud) with admin access (e.g. the built-in `editor` role) - `tsh` installed and logged in (`tsh login --proxy=`) - Python 3.9+ @@ -84,11 +113,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 → 03**. Each notebook is self-contained and 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 +216,7 @@ bash test-mcp.sh | File | Purpose | |:-----|:--------| | `lambda_tool.py` | Tool Lambda handler (whoami, get_order, update_order) | +| `lambda_interceptor.py` | REQUEST interceptor — identity injection only | +| `lambda_interceptor_avp.py` | REQUEST interceptor — identity injection + Cedar/AVP enforcement | | `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/lambda_interceptor_avp.py b/use-cases/ai/aws-agentcore/lambda_interceptor_avp.py new file mode 100644 index 0000000..66fcd68 --- /dev/null +++ b/use-cases/ai/aws-agentcore/lambda_interceptor_avp.py @@ -0,0 +1,121 @@ +import json +import base64 +import logging +import os +import boto3 + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +avp = boto3.client('verifiedpermissions') +POLICY_STORE_ID = os.environ.get('AVP_POLICY_STORE_ID', '') + + +def _decode_jwt_claims(token: str) -> dict: + payload = token.split('.')[1] + payload += '=' * (4 - len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload)) + + +def _tool_name(full_name: str) -> str: + """Strip target prefix: 'TeleportDemo___get_order_tool' → 'get_order_tool'""" + delimiter = '___' + if delimiter in full_name: + return full_name[full_name.index(delimiter) + len(delimiter):] + return full_name + + +def _avp_deny_response(request_id, tool_name: str, reasons: list) -> dict: + reason_str = '; '.join(reasons) if reasons else 'policy deny' + return { + 'interceptorOutputVersion': '1.0', + 'mcp': { + 'transformedGatewayResponse': { + 'statusCode': 403, + 'body': { + 'jsonrpc': '2.0', + 'id': request_id, + 'error': { + 'code': -32001, + 'message': f'Access denied: {reason_str}', + 'data': {'tool': tool_name} + } + } + } + } + } + + +def lambda_handler(event, context): + mcp_data = event.get('mcp', {}) + gateway_request = mcp_data.get('gatewayRequest', {}) + headers = gateway_request.get('headers', {}) + body = gateway_request.get('body', {}) + + # --- Decode Teleport JWT (already validated by the gateway) --- + 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 = roles if isinstance(roles, list) else [str(roles)] + logger.info(f'Identity: sub={teleport_user} roles={teleport_roles}') + except Exception as e: + logger.warning(f'Failed to decode JWT: {e}') + + method = body.get('method', '') + + # --- AVP authorization for tool calls --- + if method == 'tools/call' and POLICY_STORE_ID: + full_tool_name = body.get('params', {}).get('name', '') + tool = _tool_name(full_tool_name) + request_id = body.get('id', 0) + + # Build entity list: user is a member of each Teleport role + entities = [{ + 'identifier': {'entityType': 'TeleportUser', 'entityId': teleport_user}, + 'attributes': {}, + 'parents': [ + {'entityType': 'TeleportRole', 'entityId': role} + for role in teleport_roles + ] + }] + + try: + resp = avp.is_authorized( + policyStoreId=POLICY_STORE_ID, + principal={'entityType': 'TeleportUser', 'entityId': teleport_user}, + action={'actionType': 'Action', 'actionId': 'invoke_tool'}, + resource={'entityType': 'Tool', 'entityId': tool}, + entities={'entityList': entities}, + ) + decision = resp['decision'] + reasons = [r.get('policyId', '') for r in resp.get('determiningPolicies', [])] + logger.info(f'AVP {decision}: user={teleport_user} tool={tool} policies={reasons}') + + if decision == 'DENY': + return _avp_deny_response(request_id, tool, [f'no policy permits {tool}']) + + except Exception as e: + logger.error(f'AVP call failed: {e}') + # Fail closed — deny if we can't reach AVP + return _avp_deny_response(request_id, tool, ['authorization service unavailable']) + + # --- Inject identity into tool call arguments --- + if method == 'tools/call': + args = body.setdefault('params', {}).setdefault('arguments', {}) + args['_teleport_user'] = teleport_user + args['_teleport_roles'] = ','.join(teleport_roles) + + return { + 'interceptorOutputVersion': '1.0', + 'mcp': { + 'transformedGatewayRequest': { + 'headers': headers, + 'body': body, + } + } + } From 3085ae4583ce46b56f483a3f62bb5e1902ae6b29 Mon Sep 17 00:00:00 2001 From: Jeff <11721615+jeffellin@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:32:54 -0400 Subject: [PATCH 5/8] Remove notebooks 02/03 and interceptor files for incremental PRs Strips interceptor identity injection and Cedar/AVP authorization notebooks and their Lambda handler files so each can be added back as a separate, reviewable commit. Updates README to reflect the notebook 01 baseline only. --- .../02-interceptor-identity-injection.ipynb | 571 ----------------- .../03-cedar-avp-authorization.ipynb | 585 ------------------ use-cases/ai/aws-agentcore/README.md | 52 +- .../ai/aws-agentcore/lambda_interceptor.py | 55 -- .../aws-agentcore/lambda_interceptor_avp.py | 121 ---- 5 files changed, 11 insertions(+), 1373 deletions(-) delete mode 100644 use-cases/ai/aws-agentcore/02-interceptor-identity-injection.ipynb delete mode 100644 use-cases/ai/aws-agentcore/03-cedar-avp-authorization.ipynb delete mode 100644 use-cases/ai/aws-agentcore/lambda_interceptor.py delete mode 100644 use-cases/ai/aws-agentcore/lambda_interceptor_avp.py diff --git a/use-cases/ai/aws-agentcore/02-interceptor-identity-injection.ipynb b/use-cases/ai/aws-agentcore/02-interceptor-identity-injection.ipynb deleted file mode 100644 index 8b86b44..0000000 --- a/use-cases/ai/aws-agentcore/02-interceptor-identity-injection.ipynb +++ /dev/null @@ -1,571 +0,0 @@ -{ - "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/03-cedar-avp-authorization.ipynb b/use-cases/ai/aws-agentcore/03-cedar-avp-authorization.ipynb deleted file mode 100644 index 8e5581c..0000000 --- a/use-cases/ai/aws-agentcore/03-cedar-avp-authorization.ipynb +++ /dev/null @@ -1,585 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "intro", - "metadata": {}, - "source": [ - "# Notebook 03: Cedar Policy Authorization via Amazon Verified Permissions\n", - "\n", - "## Overview\n", - "\n", - "Notebook 02 proved that Teleport identity flows into every tool call. This notebook\n", - "adds **policy enforcement**: the interceptor Lambda now calls Amazon Verified Permissions\n", - "(AVP) before forwarding any tool call. AVP evaluates Cedar policies that map\n", - "Teleport roles to allowed tools — no code changes required to add or change a rule.\n", - "\n", - "```\n", - "tools/call arrives at interceptor\n", - " │\n", - " ├─ decode Teleport JWT → sub, roles\n", - " │\n", - " ├─ AVP IsAuthorized:\n", - " │ principal: TeleportUser::\"alice@acme.com\"\n", - " │ action: Action::\"invoke_tool\"\n", - " │ resource: Tool::\"update_order_tool\"\n", - " │ entities: user is member of [mcp-user, aws-personal-admin, ...]\n", - " │\n", - " ├─ DENY → MCP error returned, tool Lambda never invoked\n", - " └─ ALLOW → inject identity, forward to tool Lambda\n", - "```\n", - "\n", - "### Demo highlight — live policy change\n", - "At step 9, `update_order_tool` is denied for `mcp-user`. We then add a Cedar\n", - "policy in AVP (no redeploy) and the same call immediately succeeds.\n", - "\n", - "### Prerequisites\n", - "- Notebooks 01 and 02 completed\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": {}, - "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", - "# Create an explicit session so boto3 reads credentials from os.environ\n", - "# rather than any internally cached credential chain from a prior run.\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", - "avp = session.client('verifiedpermissions')\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", - "INTERCEPTOR_LAMBDA_NAME = f'{RESOURCE_PREFIX}-interceptor'\n", - "APP_NAME = 'agentcore-gateway'\n", - "\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", - "interceptor_lambda_arn = lambda_client.get_function(\n", - " FunctionName=INTERCEPTOR_LAMBDA_NAME)['Configuration']['FunctionArn']\n", - "\n", - "print(f'Account : {account_id}')\n", - "print(f'Gateway : {gateway_id}')\n", - "print(f'Interceptor: {interceptor_lambda_arn}')" - ] - }, - { - "cell_type": "markdown", - "id": "step2-header", - "metadata": {}, - "source": [ - "## Step 2: Create AVP Policy Store" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "create-policy-store", - "metadata": {}, - "outputs": [], - "source": [ - "AVP_POLICY_STORE_DESC = 'Teleport role → AgentCore tool access control'\n", - "\n", - "resp = avp.create_policy_store(\n", - " validationSettings={'mode': 'OFF'},\n", - " description=AVP_POLICY_STORE_DESC\n", - ")\n", - "policy_store_id = resp['policyStoreId']\n", - "print(f'Policy store: {policy_store_id}')\n", - "\n", - "# Persist to .env so the cleanup cell can find it later\n", - "_env_path = '.env'\n", - "_lines = open(_env_path).readlines()\n", - "_lines = [l for l in _lines if not l.startswith('AVP_POLICY_STORE_ID=')]\n", - "_lines.append(f'AVP_POLICY_STORE_ID={policy_store_id}\\n')\n", - "open(_env_path, 'w').writelines(_lines)\n", - "print(f'Saved AVP_POLICY_STORE_ID to .env')" - ] - }, - { - "cell_type": "markdown", - "id": "step3-header", - "metadata": {}, - "source": [ - "## Step 3: Cedar Policies\n", - "\n", - "We use validation mode `OFF` so no Cedar JSON schema is needed.\n", - "AVP still enforces the policies exactly as written — `OFF` only skips\n", - "compile-time type-checking of policy syntax against a schema.\n", - "The allow/deny runtime behaviour is unchanged." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "put-schema", - "metadata": {}, - "outputs": [], - "source": [ - "print('Validation mode is OFF — no schema required. Proceeding to policy creation.')" - ] - }, - { - "cell_type": "markdown", - "id": "step4-header", - "metadata": {}, - "source": [ - "## Step 4: Create Cedar Policies\n", - "\n", - "- `mcp-user` role → `whoami_tool` and `get_order_tool`\n", - "- `order-admin` role → `update_order_tool`\n", - "\n", - "The caller holds `mcp-user` but **not** `order-admin`, so `update_order_tool` should\n", - "be denied at step 9. Step 10 grants it to `mcp-user` live — no redeploy needed." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "create-policies", - "metadata": {}, - "outputs": [], - "source": [ - "policies = [\n", - " (\n", - " 'mcp-user-whoami',\n", - " 'mcp-user role can call whoami_tool',\n", - " 'permit(principal in TeleportRole::\"mcp-user\", action == Action::\"invoke_tool\", resource == Tool::\"whoami_tool\");'\n", - " ),\n", - " (\n", - " 'mcp-user-get-order',\n", - " 'mcp-user role can read orders',\n", - " 'permit(principal in TeleportRole::\"mcp-user\", action == Action::\"invoke_tool\", resource == Tool::\"get_order_tool\");'\n", - " ),\n", - " (\n", - " 'order-admin-update-order',\n", - " 'order-admin role can mutate orders (user does not have this role)',\n", - " 'permit(principal in TeleportRole::\"order-admin\", action == Action::\"invoke_tool\", resource == Tool::\"update_order_tool\");'\n", - " ),\n", - "]\n", - "\n", - "policy_ids = {}\n", - "for name, description, statement in policies:\n", - " resp = avp.create_policy(\n", - " policyStoreId=policy_store_id,\n", - " definition={'static': {'description': description, 'statement': statement}}\n", - " )\n", - " policy_ids[name] = resp['policyId']\n", - " print(f' {name}: {resp[\"policyId\"]}')\n", - "\n", - "print(f'\\n{len(policy_ids)} policies created')\n", - "print()\n", - "print('mcp-user → whoami_tool, get_order_tool')\n", - "print('order-admin → update_order_tool (caller does not hold this role → DENY expected)')" - ] - }, - { - "cell_type": "markdown", - "id": "step5-header", - "metadata": {}, - "source": [ - "## Step 5: Grant Interceptor Lambda Permission to Call AVP" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "grant-avp", - "metadata": {}, - "outputs": [], - "source": [ - "interceptor_role_name = f'{RESOURCE_PREFIX}-interceptor-lambda-role'\n", - "\n", - "iam.put_role_policy(\n", - " RoleName=interceptor_role_name,\n", - " PolicyName='AVPIsAuthorized',\n", - " PolicyDocument=json.dumps({\n", - " 'Version': '2012-10-17',\n", - " 'Statement': [{\n", - " 'Effect': 'Allow',\n", - " 'Action': 'verifiedpermissions:IsAuthorized',\n", - " 'Resource': f'arn:aws:verifiedpermissions::{account_id}:policy-store/{policy_store_id}'\n", - " }]\n", - " })\n", - ")\n", - "print('AVP IsAuthorized permission granted to interceptor role')" - ] - }, - { - "cell_type": "markdown", - "id": "step6-header", - "metadata": {}, - "source": [ - "## Step 6: Deploy Updated Interceptor Lambda\n", - "\n", - "`lambda_interceptor_avp.py` adds AVP `IsAuthorized` calls before forwarding.\n", - "The policy store ID is passed via the `AVP_POLICY_STORE_ID` environment variable." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "deploy-avp-interceptor", - "metadata": {}, - "outputs": [], - "source": [ - "with open('lambda_interceptor_avp.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", - "\n", - "lambda_client.update_function_code(\n", - " FunctionName=INTERCEPTOR_LAMBDA_NAME, ZipFile=pkg\n", - ")\n", - "lambda_client.get_waiter('function_updated_v2').wait(FunctionName=INTERCEPTOR_LAMBDA_NAME)\n", - "\n", - "lambda_client.update_function_configuration(\n", - " FunctionName=INTERCEPTOR_LAMBDA_NAME,\n", - " Environment={'Variables': {'AVP_POLICY_STORE_ID': policy_store_id}}\n", - ")\n", - "lambda_client.get_waiter('function_updated_v2').wait(FunctionName=INTERCEPTOR_LAMBDA_NAME)\n", - "\n", - "print(f'Interceptor updated with AVP_POLICY_STORE_ID={policy_store_id}')" - ] - }, - { - "cell_type": "markdown", - "id": "step7-header", - "metadata": {}, - "source": [ - "## Step 7: Helper — Call a Tool via tsh" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "helper", - "metadata": {}, - "outputs": [], - "source": [ - "import json as _json\n", - "\n", - "def call_tool(tool_name, arguments={}):\n", - " \"\"\"Call a gateway tool via tsh mcp connect and return the parsed result.\"\"\"\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({\n", - " 'jsonrpc': '2.0', 'id': 2, 'method': 'tools/call',\n", - " 'params': {'name': f'{TARGET_NAME}___{tool_name}', 'arguments': 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", - " for line in result.stdout.splitlines():\n", - " try:\n", - " msg = _json.loads(line)\n", - " if msg.get('id') == 2:\n", - " return msg\n", - " except Exception:\n", - " pass\n", - " return None\n", - "\n", - "def show(tool_name, arguments={}):\n", - " print(f'\\n→ {tool_name}({arguments})')\n", - " msg = call_tool(tool_name, arguments)\n", - " if msg is None:\n", - " print(' (no response)')\n", - " return\n", - " if 'error' in msg:\n", - " print(f' ✗ DENIED: {msg[\"error\"][\"message\"]}')\n", - " else:\n", - " content = msg['result']['content'][0]['text']\n", - " try:\n", - " outer = _json.loads(content)\n", - " body = _json.loads(outer['body'])\n", - " print(f' ✓ ALLOWED: {_json.dumps(body, indent=4)}')\n", - " except Exception:\n", - " print(f' ✓ ALLOWED: {content}')\n", - "\n", - "print('Helper ready')" - ] - }, - { - "cell_type": "markdown", - "id": "step8-header", - "metadata": {}, - "source": [ - "## Step 8: Test Allowed Calls\n", - "\n", - "`whoami_tool` and `get_order_tool` are permitted for `mcp-user`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "test-allowed", - "metadata": {}, - "outputs": [], - "source": [ - "show('whoami_tool')\n", - "show('get_order_tool', {'orderId': 'ORD-42'})" - ] - }, - { - "cell_type": "markdown", - "id": "step9-header", - "metadata": {}, - "source": [ - "## Step 9: Test Denied Call\n", - "\n", - "`update_order_tool` is only permitted for `order-admin`. The caller holds `mcp-user`\n", - "but not `order-admin` — Cedar default-deny blocks it. The tool Lambda is **never invoked**." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "test-denied", - "metadata": {}, - "outputs": [], - "source": [ - "show('update_order_tool', {'orderId': 'ORD-42'})" - ] - }, - { - "cell_type": "markdown", - "id": "step10-header", - "metadata": {}, - "source": [ - "## Step 10: Live Policy Change — Grant Access Without Redeploying\n", - "\n", - "Add a Cedar policy that permits `mcp-user` to call `update_order_tool`.\n", - "No Lambda redeployment, no gateway restart — AVP evaluates it immediately." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "live-policy-change", - "metadata": {}, - "outputs": [], - "source": [ - "resp = avp.create_policy(\n", - " policyStoreId=policy_store_id,\n", - " definition={'static': {\n", - " 'description': 'DEMO: grant mcp-user update access (added live)',\n", - " 'statement': 'permit(principal in TeleportRole::\"mcp-user\", action == Action::\"invoke_tool\", resource == Tool::\"update_order_tool\");'\n", - " }}\n", - ")\n", - "new_policy_id = resp['policyId']\n", - "print(f'Policy added: {new_policy_id}')\n", - "print('No Lambda redeployment needed — calling update_order_tool again...')\n", - "\n", - "show('update_order_tool', {'orderId': 'ORD-42'})" - ] - }, - { - "cell_type": "markdown", - "id": "step11-header", - "metadata": {}, - "source": [ - "## Step 11: Revert — Remove the Policy\n", - "\n", - "Delete the policy added in step 10 to restore the original deny." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "revert-policy", - "metadata": {}, - "outputs": [], - "source": [ - "avp.delete_policy(policyStoreId=policy_store_id, policyId=new_policy_id)\n", - "print(f'Policy {new_policy_id} removed')\n", - "\n", - "show('update_order_tool', {'orderId': 'ORD-42'})" - ] - }, - { - "cell_type": "markdown", - "id": "what-happened", - "metadata": {}, - "source": [ - "## What Just Happened\n", - "\n", - "| Step | Result |\n", - "|:-----|:-------|\n", - "| `whoami_tool` | ✓ Cedar ALLOW — `mcp-user` policy matches |\n", - "| `get_order_tool` | ✓ Cedar ALLOW — `mcp-user` policy matches |\n", - "| `update_order_tool` (before) | ✗ Cedar DENY — no policy for `mcp-user` |\n", - "| Add policy in AVP console/API | — no code deployed |\n", - "| `update_order_tool` (after) | ✓ Cedar ALLOW — new policy matched |\n", - "| Remove policy | ✗ Cedar DENY again — instantly reverted |\n", - "\n", - "**Two independent audit trails:**\n", - "- Teleport logs: who connected, when, from where\n", - "- AVP logs: every `IsAuthorized` decision with the matching policy ID\n", - "\n", - "## What's Next\n", - "\n", - "Notebook 04 wires a Strands agent to the gateway so you can drive the same\n", - "tools with natural language and see the identity + Cedar enforcement in action\n", - "end-to-end." - ] - }, - { - "cell_type": "markdown", - "id": "cleanup-header", - "metadata": {}, - "source": [ - "## Cleanup" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cleanup", - "metadata": {}, - "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", - "_lambda = _s.client('lambda')\n", - "_avp = _s.client('verifiedpermissions')\n", - "\n", - "_PREFIX = os.environ.get('RESOURCE_PREFIX', 'teleport-demo')\n", - "_INTERCEPTOR_LAMBDA = f'{_PREFIX}-interceptor'\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", - "# Delete AVP policy store (lists and deletes all policies first)\n", - "try:\n", - " stores = _avp.list_policy_stores().get('policyStores', [])\n", - " store_id = os.environ.get('AVP_POLICY_STORE_ID', '')\n", - " if not store_id:\n", - " if len(stores) == 1:\n", - " store_id = stores[0]['policyStoreId']\n", - " print(f'Found one policy store: {store_id}')\n", - " elif stores:\n", - " print(f'Multiple policy stores found; set AVP_POLICY_STORE_ID in .env to target one.')\n", - " print(f' Stores: {[s[\"policyStoreId\"] for s in stores]}')\n", - " store_id = None\n", - " else:\n", - " print('No policy stores found — skipping AVP cleanup')\n", - " store_id = None\n", - "\n", - " if store_id:\n", - " try:\n", - " policies = _avp.list_policies(policyStoreId=store_id).get('policies', [])\n", - " for p in policies:\n", - " _swallow(_avp.delete_policy, policyStoreId=store_id, policyId=p['policyId'])\n", - " print(f' Deleted policy: {p[\"policyId\"]}')\n", - " _swallow(_avp.delete_policy_store, policyStoreId=store_id)\n", - " print(f'Deleted policy store: {store_id}')\n", - " except ClientError as e:\n", - " code = e.response[\"Error\"][\"Code\"]\n", - " if code in ('ResourceNotFoundException', 'ValidationException'):\n", - " print(f'Policy store {store_id!r} not found or invalid — skipping')\n", - " else:\n", - " print(f'AVP cleanup: skip ({code}: {e.response[\"Error\"][\"Message\"]})')\n", - "except ClientError as e:\n", - " print(f'AVP list_policy_stores: skip ({e.response[\"Error\"][\"Code\"]}: {e.response[\"Error\"][\"Message\"]})')\n", - "\n", - "# Roll interceptor back to identity-only version (no AVP)\n", - "try:\n", - " with open('lambda_interceptor.py', 'r') as f:\n", - " code = f.read()\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", - " _swallow(_lambda.update_function_code, FunctionName=_INTERCEPTOR_LAMBDA, ZipFile=pkg)\n", - " _lambda.get_waiter('function_updated_v2').wait(FunctionName=_INTERCEPTOR_LAMBDA)\n", - " _swallow(_lambda.update_function_configuration,\n", - " FunctionName=_INTERCEPTOR_LAMBDA, Environment={'Variables': {}})\n", - " _lambda.get_waiter('function_updated_v2').wait(FunctionName=_INTERCEPTOR_LAMBDA)\n", - " print('Interceptor rolled back to identity-only')\n", - "except ClientError as e:\n", - " print(f'Interceptor rollback: skip ({e.response[\"Error\"][\"Code\"]})')\n", - "except FileNotFoundError:\n", - " print('lambda_interceptor.py not found — skipping rollback')\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 4163ff5..795a442 100644 --- a/use-cases/ai/aws-agentcore/README.md +++ b/use-cases/ai/aws-agentcore/README.md @@ -18,10 +18,8 @@ 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. -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. +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. ### Architecture @@ -36,16 +34,10 @@ 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) - │ Calls Amazon Verified Permissions (Cedar policy) - │ DENY → MCP error returned, tool Lambda never invoked - │ ALLOW → inject _teleport_user + _teleport_roles into tool arguments - ↓ Tool Lambda - │ whoami_tool → returns verified caller identity - │ get_order_tool → readable by mcp-user role (Cedar ALLOW) - │ update_order_tool → requires order-admin role (Cedar DENY for mcp-user) + │ whoami_tool → returns caller identity from JWT claims + │ get_order_tool → retrieve order data + │ update_order_tool → update order data ``` ### Tutorial Details @@ -55,7 +47,6 @@ Tool Lambda | **Tutorial type** | Interactive (Jupyter notebooks) | | **AgentCore components** | AgentCore Gateway | | **Identity provider** | Teleport (OIDC) | -| **Authorization** | Amazon Verified Permissions (Cedar policies) | | **Gateway target** | AWS Lambda | | **MCP transport** | `mcp+https` (Streamable HTTP) | | **SDK** | boto3 / tsh CLI | @@ -71,30 +62,10 @@ 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 -- 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 - -### 03 — Cedar Policy Authorization -`03-cedar-avp-authorization.ipynb` - -Adds policy enforcement via Amazon Verified Permissions: -- Creates an AVP policy store with Cedar policies mapping Teleport roles to tools -- `mcp-user` → `whoami_tool`, `get_order_tool` -- `order-admin` → `update_order_tool` *(caller does not hold this role — expect DENY)* -- Deploys the AVP-aware interceptor (`lambda_interceptor_avp.py`) -- **Live policy change demo**: grants `mcp-user` access to `update_order_tool` by adding - a Cedar policy in AVP — no Lambda redeployment, no gateway restart - ## Prerequisites -- AWS credentials with permissions for Lambda, IAM, bedrock-agentcore, verifiedpermissions -- A Teleport cluster (self-hosted or Teleport Cloud) with admin access (e.g. the built-in `editor` role) +- AWS credentials with permissions for Lambda, IAM, bedrock-agentcore +- A Teleport cluster (self-hosted or Teleport Cloud) with admin access - `tsh` installed and logged in (`tsh login --proxy=`) - Python 3.9+ @@ -113,11 +84,11 @@ cp .env.example .env ### 2. Run the notebooks -Run in order: **01 → 02 → 03**. Each notebook is self-contained and idempotent — -re-running a cell that already created a resource will skip creation gracefully. +Run notebook **01**. It 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 before proceeding to notebook 02. +setup below. ### 3. Configure the Teleport app agent @@ -216,7 +187,6 @@ bash test-mcp.sh | File | Purpose | |:-----|:--------| | `lambda_tool.py` | Tool Lambda handler (whoami, get_order, update_order) | -| `lambda_interceptor.py` | REQUEST interceptor — identity injection only | -| `lambda_interceptor_avp.py` | REQUEST interceptor — identity injection + Cedar/AVP enforcement | +| `teleport.yaml` | Teleport app service config pointing at the AgentCore Gateway | | `test-mcp.sh` | Shell script to test the MCP endpoint directly via `tsh mcp connect` | | `.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 deleted file mode 100644 index 210bbf0..0000000 --- a/use-cases/ai/aws-agentcore/lambda_interceptor.py +++ /dev/null @@ -1,55 +0,0 @@ -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/lambda_interceptor_avp.py b/use-cases/ai/aws-agentcore/lambda_interceptor_avp.py deleted file mode 100644 index 66fcd68..0000000 --- a/use-cases/ai/aws-agentcore/lambda_interceptor_avp.py +++ /dev/null @@ -1,121 +0,0 @@ -import json -import base64 -import logging -import os -import boto3 - -logger = logging.getLogger() -logger.setLevel(logging.INFO) - -avp = boto3.client('verifiedpermissions') -POLICY_STORE_ID = os.environ.get('AVP_POLICY_STORE_ID', '') - - -def _decode_jwt_claims(token: str) -> dict: - payload = token.split('.')[1] - payload += '=' * (4 - len(payload) % 4) - return json.loads(base64.urlsafe_b64decode(payload)) - - -def _tool_name(full_name: str) -> str: - """Strip target prefix: 'TeleportDemo___get_order_tool' → 'get_order_tool'""" - delimiter = '___' - if delimiter in full_name: - return full_name[full_name.index(delimiter) + len(delimiter):] - return full_name - - -def _avp_deny_response(request_id, tool_name: str, reasons: list) -> dict: - reason_str = '; '.join(reasons) if reasons else 'policy deny' - return { - 'interceptorOutputVersion': '1.0', - 'mcp': { - 'transformedGatewayResponse': { - 'statusCode': 403, - 'body': { - 'jsonrpc': '2.0', - 'id': request_id, - 'error': { - 'code': -32001, - 'message': f'Access denied: {reason_str}', - 'data': {'tool': tool_name} - } - } - } - } - } - - -def lambda_handler(event, context): - mcp_data = event.get('mcp', {}) - gateway_request = mcp_data.get('gatewayRequest', {}) - headers = gateway_request.get('headers', {}) - body = gateway_request.get('body', {}) - - # --- Decode Teleport JWT (already validated by the gateway) --- - 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 = roles if isinstance(roles, list) else [str(roles)] - logger.info(f'Identity: sub={teleport_user} roles={teleport_roles}') - except Exception as e: - logger.warning(f'Failed to decode JWT: {e}') - - method = body.get('method', '') - - # --- AVP authorization for tool calls --- - if method == 'tools/call' and POLICY_STORE_ID: - full_tool_name = body.get('params', {}).get('name', '') - tool = _tool_name(full_tool_name) - request_id = body.get('id', 0) - - # Build entity list: user is a member of each Teleport role - entities = [{ - 'identifier': {'entityType': 'TeleportUser', 'entityId': teleport_user}, - 'attributes': {}, - 'parents': [ - {'entityType': 'TeleportRole', 'entityId': role} - for role in teleport_roles - ] - }] - - try: - resp = avp.is_authorized( - policyStoreId=POLICY_STORE_ID, - principal={'entityType': 'TeleportUser', 'entityId': teleport_user}, - action={'actionType': 'Action', 'actionId': 'invoke_tool'}, - resource={'entityType': 'Tool', 'entityId': tool}, - entities={'entityList': entities}, - ) - decision = resp['decision'] - reasons = [r.get('policyId', '') for r in resp.get('determiningPolicies', [])] - logger.info(f'AVP {decision}: user={teleport_user} tool={tool} policies={reasons}') - - if decision == 'DENY': - return _avp_deny_response(request_id, tool, [f'no policy permits {tool}']) - - except Exception as e: - logger.error(f'AVP call failed: {e}') - # Fail closed — deny if we can't reach AVP - return _avp_deny_response(request_id, tool, ['authorization service unavailable']) - - # --- Inject identity into tool call arguments --- - if method == 'tools/call': - args = body.setdefault('params', {}).setdefault('arguments', {}) - args['_teleport_user'] = teleport_user - args['_teleport_roles'] = ','.join(teleport_roles) - - return { - 'interceptorOutputVersion': '1.0', - 'mcp': { - 'transformedGatewayRequest': { - 'headers': headers, - 'body': body, - } - } - } From a4a6bf767457f09d53562c2aa25154bc1c4c35b9 Mon Sep 17 00:00:00 2001 From: Jeff <11721615+jeffellin@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:37:53 -0400 Subject: [PATCH 6/8] update based on review comments. --- use-cases/ai/aws-agentcore/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/use-cases/ai/aws-agentcore/README.md b/use-cases/ai/aws-agentcore/README.md index 795a442..57548c0 100644 --- a/use-cases/ai/aws-agentcore/README.md +++ b/use-cases/ai/aws-agentcore/README.md @@ -65,7 +65,7 @@ Sets up the foundation: ## Prerequisites - AWS credentials with permissions for Lambda, IAM, bedrock-agentcore -- A Teleport cluster (self-hosted or Teleport Cloud) with admin access +- A Teleport cluster (self-hosted or Teleport Cloud) with admin access (e.g. the built-in `editor` role) - `tsh` installed and logged in (`tsh login --proxy=`) - Python 3.9+ @@ -187,6 +187,5 @@ bash test-mcp.sh | File | Purpose | |:-----|:--------| | `lambda_tool.py` | Tool Lambda handler (whoami, get_order, update_order) | -| `teleport.yaml` | Teleport app service config pointing at the AgentCore Gateway | | `test-mcp.sh` | Shell script to test the MCP endpoint directly via `tsh mcp connect` | | `.env.example` | Template for AWS credential environment variables | From 2b92045c3d10772d3b9c93b385ce0c0939b72f3c Mon Sep 17 00:00:00 2001 From: Jeff <11721615+jeffellin@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:24:25 -0400 Subject: [PATCH 7/8] Add notebook 2, interceptor identity injection, and update readme and requirements.txt --- .../02-interceptor-identity-injection.ipynb | 571 ++++++++++++++++++ use-cases/ai/aws-agentcore/README.md | 21 +- .../ai/aws-agentcore/lambda_interceptor.py | 55 ++ use-cases/ai/aws-agentcore/requirements.txt | 2 +- 4 files changed, 645 insertions(+), 4 deletions(-) create mode 100644 use-cases/ai/aws-agentcore/02-interceptor-identity-injection.ipynb create mode 100644 use-cases/ai/aws-agentcore/lambda_interceptor.py 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 57548c0..d594b5b 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 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 From 244055337e14b6a9249395a4150b89094f8234dc Mon Sep 17 00:00:00 2001 From: Jeff <11721615+jeffellin@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:26:53 -0400 Subject: [PATCH 8/8] Update README to clarify notebook execution order and add interceptor description --- use-cases/ai/aws-agentcore/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/use-cases/ai/aws-agentcore/README.md b/use-cases/ai/aws-agentcore/README.md index d594b5b..a858f1d 100644 --- a/use-cases/ai/aws-agentcore/README.md +++ b/use-cases/ai/aws-agentcore/README.md @@ -99,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 @@ -202,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 |