diff --git a/.gitignore b/.gitignore index aa58fe9e6..35375ae15 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ lib/ !04-infrastructure-as-code/cdk/typescript/knowledge-base-rag-agent/infrastructure/lib/ !01-tutorials/01-AgentCore-runtime/01-hosting-agent/05-java-agents/01-springai-with-bedrock-model/infra/lib/ !01-tutorials/01-AgentCore-runtime/01-hosting-agent/05-java-agents/02-embabel-with-bedrock-model/infra/lib/ +!02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/lib/ lib64/ parts/ sdist/ diff --git a/02-use-cases/lakehouse-agent/README.md b/02-use-cases/lakehouse-agent/README.md index ddf0a8e6a..a31600001 100644 --- a/02-use-cases/lakehouse-agent/README.md +++ b/02-use-cases/lakehouse-agent/README.md @@ -392,6 +392,28 @@ See [deployment/README.md](deployment/README.md) for full details including Lake --- +## Optional: Advanced AgentCore Policy + Lambda Interceptors (Phase 2) + +The base deployment above (Option A notebooks or Option B CLI) stands alone. +Once it is working, you can optionally layer Cedar-based AgentCore Policy and a +Design 3 Request Interceptor on top. This is the companion sample for the blog +post *"Build Secure AI Agent Behavior with Policy and Lambda Interceptors in +Amazon Bedrock AgentCore"* and demonstrates three patterns: + +- **Design 1 — Policy Only**: a declarative Cedar `forbid` rule denies + `get_claims_summary` for policyholders. +- **Design 2 — Interceptor Only**: the request Interceptor exchanges the JWT + for tenant-scoped IAM credentials via `sts:AssumeRole`, so Lake Formation + transparently enforces row- and column-level security per user. +- **Design 3 — Policy + Interceptor**: the Interceptor injects user geography + and Cedar evaluates `context.input.geography` to block EU users from + individual-claim tools. + +Deployment, verification, and cleanup steps are in +[deployment/advanced-agentcore-policy-gateway-interceptor/README.md](deployment/advanced-agentcore-policy-gateway-interceptor/README.md). + +--- + ## What Gets Deployed - **Cognito User Pool**: OAuth authentication with test users and groups diff --git a/02-use-cases/lakehouse-agent/deployment/README.md b/02-use-cases/lakehouse-agent/deployment/README.md index 9781cc49e..b28cd0974 100644 --- a/02-use-cases/lakehouse-agent/deployment/README.md +++ b/02-use-cases/lakehouse-agent/deployment/README.md @@ -2,6 +2,16 @@ This guide provides the deployment sequence for the Lakehouse Agent system using command-line scripts. For a guided notebook-based approach, see the Jupyter notebooks in the parent directory. +The deployment is organized in two phases: + +- **Phase 1 — Base lakehouse-agent (Steps 1–7, this guide).** Deploys Cognito, + IAM tenant roles, S3 Tables + Lake Formation, the MCP server, the Gateway + with request/response Interceptors, and the conversational agent. +- **Phase 2 — Advanced AgentCore Policy + Interceptor (optional, CDK).** Layers + Cedar-based AgentCore Policy on top of Phase 1 and upgrades the request + Interceptor with geography-based access control. See + [advanced-agentcore-policy-gateway-interceptor/README.md](advanced-agentcore-policy-gateway-interceptor/README.md). + ## Prerequisites 1. AWS CLI configured with appropriate permissions @@ -72,6 +82,8 @@ Test users created: Default password: `TempPass123!` +> **Important — first-time sign-in required.** `setup_cognito.py` creates users with the default password as a *temporary* password, so every user starts in Cognito `FORCE_CHANGE_PASSWORD` state. `admin_initiate_auth` returns a `NEW_PASSWORD_REQUIRED` challenge (not an `AuthenticationResult`) until each user signs in once and completes the challenge. The Streamlit UI (Step 8) has a built-in challenge handler — launch it and sign in once per user, setting the new password to the same `TempPass123!` (the user pool does not configure `PasswordHistorySize`, so reusing the value is allowed). Only after this step will plain `admin_initiate_auth` calls (for example, from `verify_policy.py` in the Phase 2 sample) succeed. + #### Optional: Enable Login Audit Logging To enable login audit logging, deploy the Post-Authentication Lambda before running setup_cognito.py: @@ -302,6 +314,26 @@ Access at: http://localhost:8501 --- +### Step 9 (Optional): Layer AgentCore Policy + Design 3 Interceptor (Phase 2) + +To add declarative Cedar-based access control and geography-aware request +enrichment on top of the Phase 1 Gateway, follow +[advanced-agentcore-policy-gateway-interceptor/README.md](advanced-agentcore-policy-gateway-interceptor/README.md). + +This Phase 2 deployment adds: + +- `CfnPolicyEngine` with four Cedar policies (`permit_all` + three `forbid` rules). +- An IAM inline policy granting the existing Gateway role policy-evaluation permissions. +- A single `UpdateGateway` call that re-attaches both Interceptors together with + the Policy Engine in `ENFORCE` mode. +- An upgraded request Interceptor Lambda that injects user geography so Cedar + can enforce data-residency rules (Design 3). + +Prerequisite: Phase 1 Steps 1–7 must be deployed first — the CDK stack reads +every ARN / ID it needs from SSM parameters populated by those steps. + +--- + ## Quick Reference | Step | Directory | Command | @@ -319,6 +351,7 @@ Access at: http://localhost:8501 | 6 | `5-gateway-setup` | `python create_gateway.py --yes` | | 7 | `6-lakehouse-agent` | `python deploy_lakehouse_agent.py --yes` | | 8 | `streamlit-ui` | `streamlit run streamlit_app.py` | +| 9 (optional) | `advanced-agentcore-policy-gateway-interceptor` | `bash scripts/pre-deploy.sh && npx cdk deploy` | --- @@ -355,9 +388,18 @@ deployment/ │ │ └── README.md │ ├── create_gateway.py # Step 6 │ └── cleanup_gateway.py -└── 6-lakehouse-agent/ # Step 7 - ├── deploy_lakehouse_agent.py - └── cleanup_agent.py +├── 6-lakehouse-agent/ # Step 7 +│ ├── deploy_lakehouse_agent.py +│ └── cleanup_agent.py +└── advanced-agentcore-policy-gateway-interceptor/ # Step 9 (optional, Phase 2) + ├── README.md + ├── bin/app.ts + ├── lib/policy-stack.ts + ├── policies/ # Cedar policies (Design 1 + Design 3) + ├── lambda/interceptor-request/ # Design 3 Lambda source + ├── scripts/ # pre-deploy + cdk.json generation + └── verification/ + └── verify_policy.py ``` --- @@ -378,7 +420,23 @@ aws ssm get-parameters-by-path \ ## Cleanup -Each deployment step has a dedicated cleanup script. Run them in reverse order: +Each deployment step has a dedicated cleanup script. Run them in reverse order. + +**If you deployed Phase 2 (Step 9), destroy it first** — it depends on the +Phase 1 Gateway and the Gateway role, so Phase 1 cleanup will fail while the +Policy Engine is still attached. + +```bash +# Step 9 (Phase 2): Destroy Policy Engine + Cedar policies + role inline policy. +# Interceptors remain attached; the CDK stack only added the Policy Engine. +cd advanced-agentcore-policy-gateway-interceptor +npx cdk destroy --force +cd .. +``` + +See [advanced-agentcore-policy-gateway-interceptor/README.md#cleanup](advanced-agentcore-policy-gateway-interceptor/README.md#cleanup) for notes on rolling back the Design 3 Lambda source before Phase 1 cleanup. + +Then run the Phase 1 cleanup scripts: ```bash # Step 7: Delete Lakehouse Agent diff --git a/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/.gitignore b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/.gitignore new file mode 100644 index 000000000..0778cde96 --- /dev/null +++ b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +cdk.out/ +cdk.json +dist/ +*.d.ts +*.js.map +.ruff_cache/ +__pycache__/ +*.pyc diff --git a/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/README.md b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/README.md new file mode 100644 index 000000000..b27bc2a53 --- /dev/null +++ b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/README.md @@ -0,0 +1,207 @@ +# Advanced AgentCore Policy + Lambda Interceptor (CDK) + +This CDK project extends the lakehouse-agent sample with a layered security +architecture that combines **Cedar-based AgentCore Policy** and a **Design 3 +Request Interceptor Lambda**. It implements the three patterns described in +the blog post *"Build Secure AI Agent Behavior with Policy and Lambda +Interceptors in Amazon Bedrock AgentCore"*: + +| Design | Mechanism | Demo rule | +|---|---|---| +| **Design 1 — Policy Only** | Cedar `forbid` rule on the Gateway | Policyholders cannot invoke `get_claims_summary` | +| **Design 2 — Interceptor Only** | Request Interceptor performs `sts:AssumeRole` to scope credentials, so Lake Formation applies row- and column-level security | Each user sees only their own rows and permitted columns | +| **Design 3 — Policy + Interceptor** | Interceptor injects `geography`, Cedar evaluates `context.input.geography` | EU users cannot invoke `query_claims` or `get_claim_details` | + +## Prerequisites + +This CDK sample runs **after the base lakehouse-agent is deployed** (Steps 1–7 +in [deployment/README.md](../README.md)). It reads every input — Gateway ARN, +interceptor Lambda ARNs, Cognito IDs, etc. — from the SSM parameters produced +by those steps. + +Required tooling: + +- AWS credentials with permissions to create AgentCore Policy Engine and + Cedar policies (`bedrock-agentcore:*`), update the Gateway, and attach IAM + inline policies to the Gateway role. +- AWS CLI v2 configured for the same account and region as the base deployment. +- Node.js 18+ and npm. +- Python 3.10+ (for the pre-deploy and verification scripts) with the same + virtual environment used for Phase 1. + +> **Region note**: All commands below assume `AWS_REGION=us-east-1`, matching +> the base lakehouse-agent deployment. Export `AWS_REGION` before running any +> step if your shell default differs. + +## Directory layout + +``` +advanced-agentcore-policy-gateway-interceptor/ +├── README.md # (this file) +├── package.json / tsconfig.json # CDK TypeScript project +├── cdk.json.example # Template — cdk.json is generated at deploy-time +├── bin/app.ts # CDK entry point (reads account/region from context) +├── lib/policy-stack.ts # PolicyStack: Policy Engine + Cedar policies + Gateway re-attach +├── policies/ # Cedar source (one file per policy) +├── lambda/interceptor-request/ # Design 3 Request Interceptor Lambda source +├── scripts/ +│ ├── pre-deploy.sh # Runs the 3 steps below in one go +│ ├── generate-cdk-context.sh # Generates cdk.json from SSM +│ └── detach-interceptors.py # Detaches Interceptors before Cedar policy creation +└── verification/ + └── verify_policy.py # 13-check FGAC regression suite +``` + +## Deploy + +### Step 1 — Pre-deploy + +`pre-deploy.sh` does three things in sequence: + +1. **Generate `cdk.json`** from SSM Parameter Store (account ID is derived + from `aws sts get-caller-identity`). +2. **Detach the Interceptors** from the Gateway. Cedar policy creation sends + internal MCP validation requests that are SigV4-signed (not Bearer-token + authenticated), which fail against a JWT-validating Interceptor. CDK + re-attaches both Interceptors together with the Policy Engine in Step 2. +3. **Overwrite the base `interceptor-request/lambda_function.py`** with the + Design 3 version (adds user geography injection) and redeploy that Lambda. + +```bash +cd 02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor +AWS_REGION=us-east-1 bash scripts/pre-deploy.sh +``` + +### Step 2 — CDK deploy + +```bash +npm ci +# Bootstrap once per account/region if you have not deployed any CDK stack yet: +# npx cdk bootstrap +npx cdk deploy --require-approval never +``` + +This creates: + +- **`CfnPolicyEngine`** — the AgentCore Policy Engine. +- **`CfnPolicy` x 4** — Cedar policies from `policies/*.cedar`. `permit_all` + is created first (with `IGNORE_ALL_FINDINGS` to bypass the Overly Permissive + warning), then the three `forbid` policies in parallel. +- **IAM inline policy** on the existing Gateway role for + `bedrock-agentcore:AuthorizeAction` etc. +- **`AwsCustomResource` → `UpdateGateway`** — re-attaches both Interceptors + and attaches the Policy Engine in `ENFORCE` mode in a single API call. + +Deployment takes about 2 minutes. + +### Step 3 — Verify the Policy Engine is active + +```bash +AWS_REGION=us-east-1 python3 -c " +import boto3 +client = boto3.Session(region_name='us-east-1').client('bedrock-agentcore-control') +for e in client.list_policy_engines().get('policyEngines', []): + if 'Lakehouse' in e['name']: + print(f'Engine: {e[\"policyEngineId\"]} ({e[\"status\"]})') + for p in client.list_policies(policyEngineId=e['policyEngineId']).get('policies', []): + print(f' {p[\"name\"]}: {p[\"status\"]}') +" +``` + +All four policies should report `ACTIVE`. + +> **Before running Step 4: each Cognito user must sign in once.** `verify_policy.py` authenticates via plain `admin_initiate_auth`, which fails while users are still in Cognito `FORCE_CHANGE_PASSWORD` state (the default for users created by Phase 1 `setup_cognito.py`). Start the Streamlit UI (`streamlit run streamlit-ui/streamlit_app.py`) and sign in **once per user** — `policyholder001`, `policyholder002`, `adjuster001`, `adjuster002`, and `admin` — completing the `NEW_PASSWORD_REQUIRED` challenge. Re-entering the same `TempPass123!` as the new password works because the user pool does not set `PasswordHistorySize`. After this one-time step, `verify_policy.py` will authenticate cleanly. + +### Step 4 — Run the end-to-end verification + +```bash +cd ../../.. # back to lakehouse-agent/ +source .venv/bin/activate # same venv used for Phase 1 +python deployment/advanced-agentcore-policy-gateway-interceptor/verification/verify_policy.py +``` + +Expected output: + +``` +Results: 13/13 passed +``` + +## What the policies enforce + +| Policy file | Pattern | Effect | +|---|---|---| +| `permit_all.cedar` | Baseline permit | Without this, AgentCore defaults to deny-by-default once a Policy Engine is attached | +| `forbid_policyholder_summary.cedar` | Design 1 | Blocks `get_claims_summary` when `principal.getTag("cognito:groups") like "*policyholders*"` | +| `forbid_eu_individual_claims.cedar` | Design 3 | Blocks `query_claims` and `get_claim_details` when `context.input.geography == "EU"` | +| `forbid_restricted_geography.cedar` | Design 3 | Blocks every tool when `context.input.geography == "RESTRICTED"` | + +The `geography` attribute is injected by the Design 3 Request Interceptor at +`params.arguments.geography` (top level). Cedar maps that to +`context.input.geography`. The demo Lambda ships a hard-coded mapping in +`USER_GEOGRAPHY` — replace with a DynamoDB lookup for production. + +## Cleanup + +Destroy **in reverse order**. Phase 2 first, then Phase 1. + +### Phase 2 — This CDK stack + +```bash +cd 02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor +npx cdk destroy --force +``` + +`cdk destroy` does the following via the `AwsCustomResource` +and the `CfnPolicy` / `CfnPolicyEngine` resource lifecycles: + +1. Detaches the Policy Engine from the Gateway (Interceptors remain attached). +2. Deletes the four Cedar policies. +3. Deletes the Policy Engine. +4. Removes the inline IAM policy from the Gateway role. + +> **Note:** The Design 3 Request Interceptor Lambda source (with geography +> injection) remains deployed after `cdk destroy`. That is intentional — the +> Lambda is a Phase 1 resource and is cleaned up in the Phase 1 cleanup below. +> If you want to roll back to the original Phase 1 Lambda (without geography +> injection) before destroying Phase 1, restore +> `deployment/5-gateway-setup/interceptor-request/lambda_function.py` from git +> and redeploy: +> +> ```bash +> git checkout -- deployment/5-gateway-setup/interceptor-request/lambda_function.py +> cd deployment/5-gateway-setup/interceptor-request +> AWS_REGION=us-east-1 ./deploy.sh +> ``` + +### Phase 1 — Base lakehouse-agent + +Follow the standard cleanup in the parent guide — each Phase 1 step has a +dedicated cleanup script, run in reverse order: + +```bash +cd 02-use-cases/lakehouse-agent/deployment +cd 6-lakehouse-agent && python cleanup_agent.py +cd ../5-gateway-setup && python cleanup_gateway.py +cd ../4-mcp-lakehouse-server && python cleanup_runtime.py +cd ../3-s3tables-setup && python cleanup_s3tables.py +cd ../2-lakehouse-tenant-roles-setup && python cleanup_iam_roles.py +cd ../1-cognito-setup && python cleanup_cognito.py +``` + +See [../README.md](../README.md) for details. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `CfnPolicy` → `CREATE_FAILED` with `InterceptorException` | The Gateway still had the JWT-validating Interceptor attached while Cedar tried its internal MCP validation | Re-run `scripts/pre-deploy.sh` (it detaches Interceptors) then `cdk deploy` again | +| All tool calls return 500 | After detach, the Response Interceptor Lambda was missing when CDK re-attached | Deploy the Response Interceptor first: `deployment/5-gateway-setup/interceptor-response/deploy.sh` | +| `permit_all` fails with "Overly Permissive" | `validationMode: FAIL_ON_ANY_FINDINGS` on a broad permit | PolicyStack already uses `IGNORE_ALL_FINDINGS` for `permit_all` — rerun `cdk deploy` | +| `context.input` returns `attribute not found` | Cedar rule used a wildcard `action` | List tools explicitly in `action in [...]` (see `forbid_eu_individual_claims.cedar`) | +| Every tool returns DENY after deploy | `permit_all` is not `ACTIVE` | Re-check `list_policies` status; if not ACTIVE, re-run `cdk deploy` | + +## References + +- Blog post: *Build Secure AI Agent Behavior with Policy and Lambda Interceptors in Amazon Bedrock AgentCore* +- [Phase 1 deployment guide](../README.md) +- [lakehouse-agent README](../../README.md) diff --git a/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/bin/app.ts b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/bin/app.ts new file mode 100644 index 000000000..94443c27d --- /dev/null +++ b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/bin/app.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import * as cdk from "aws-cdk-lib"; +import { PolicyStack } from "../lib/policy-stack"; + +const app = new cdk.App(); + +const account = + (app.node.tryGetContext("account") as string | undefined) ?? + process.env.CDK_DEFAULT_ACCOUNT; +const region = + (app.node.tryGetContext("region") as string | undefined) ?? + process.env.CDK_DEFAULT_REGION ?? + "us-east-1"; + +new PolicyStack(app, "LakehousePolicyStack", { + env: { + account, + region, + }, +}); diff --git a/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/cdk.json.example b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/cdk.json.example new file mode 100644 index 000000000..1e5ac5551 --- /dev/null +++ b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/cdk.json.example @@ -0,0 +1,16 @@ +{ + "app": "npx ts-node bin/app.ts", + "context": { + "account": "", + "region": "us-east-1", + "gatewayId": "", + "gatewayName": "lakehouse-gateway", + "gatewayArn": "", + "gatewayRoleArn": "", + "discoveryUrl": "", + "allowedClientId": "", + "requestInterceptorArn": "", + "responseInterceptorArn": "", + "targetId": "" + } +} diff --git a/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/lambda/interceptor-request/lambda_function.py b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/lambda/interceptor-request/lambda_function.py new file mode 100644 index 000000000..615b04a6d --- /dev/null +++ b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/lambda/interceptor-request/lambda_function.py @@ -0,0 +1,564 @@ +""" +AgentCore Gateway Interceptor for Health Lakehouse Data + +This Lambda function acts as a Gateway Interceptor following the AgentCore MCP protocol: +1. Extracts JWT bearer tokens from MCP gateway request structure +2. Validates JWT tokens against Cognito +3. Extracts user principal (email/username) from JWT claims +4. Validates tool access based on user groups and allowed tools in DynamoDB +5. Exchanges JWT claims to IAM credentials via DynamoDB role mapping +6. Adds user identity and credentials to request for downstream MCP server +7. Returns responses in proper MCP interceptor format + +Reference: https://github.com/awslabs/amazon-bedrock-agentcore-samples/blob/main/01-tutorials/02-AgentCore-gateway/14-token-exchange-at-request-interceptor/ + +OAuth Flow: + Streamlit → lakehouse-agent → Gateway (this interceptor) → MCP server + +The interceptor extracts the principal from the JWT token, validates tool access, +and exchanges it for IAM credentials based on tenant role mappings for Lake Formation +row-level security. +""" + +import json +import logging +import os +import boto3 +from typing import Dict, Any, Optional +import urllib.parse +import urllib.request +from jose import jwt, JWTError + +# Import token exchange module +from token_exchange import exchange_jwt_to_iam, get_claim_for_exchange + +# Import tool validation module +from tool_validation import validate_tool_access + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# ---- Design 3: Geography-based access control ---- +# User geography mapping (simplified for demo purposes). +# In production, this would be fetched from DynamoDB or an external API. +USER_GEOGRAPHY: Dict[str, str] = { + "policyholder001@example.com": "US", + "policyholder002@example.com": "EU", + "adjuster001@example.com": "US", + "admin@example.com": "US", +} + +# Cache for configuration and keys +_config = None +_jwks = None + + +def get_config() -> Dict[str, str]: + """ + Get Cognito configuration from environment variables or SSM. + + Returns: + Dictionary with Cognito configuration + """ + global _config + + if _config is not None: + return _config + + # First try environment variables + region = os.environ.get("COGNITO_REGION") or os.environ.get( + "AWS_REGION", "us-west-2" + ) + user_pool_id = os.environ.get("COGNITO_USER_POOL_ID", "") + app_client_id = os.environ.get("COGNITO_APP_CLIENT_ID", "") + + # If not set, try SSM Parameter Store + if not user_pool_id or not app_client_id: + logger.info("Loading Cognito configuration from SSM Parameter Store...") + try: + ssm = boto3.client("ssm", region_name=region) + + if not user_pool_id: + response = ssm.get_parameter( + Name="/app/lakehouse-agent/cognito-user-pool-id" + ) + user_pool_id = response["Parameter"]["Value"] + logger.info(f"Loaded user_pool_id from SSM: {user_pool_id}") + + if not app_client_id: + response = ssm.get_parameter( + Name="/app/lakehouse-agent/cognito-app-client-id" + ) + app_client_id = response["Parameter"]["Value"] + logger.info(f"Loaded app_client_id from SSM: {app_client_id}") + + except Exception as e: + logger.error(f"Error loading configuration from SSM: {e}") + raise + + _config = { + "region": region, + "user_pool_id": user_pool_id, + "app_client_id": app_client_id, + "issuer": f"https://cognito-idp.{region}.amazonaws.com/{user_pool_id}", + } + + logger.info( + f"Cognito configuration loaded: region={region}, user_pool_id={user_pool_id}" + ) + return _config + + +def _fetch_https_json(url: str) -> Dict[str, Any]: + """ + Fetch a JSON document from an https URL. + + Rejects any URL whose scheme is not ``https`` so that file:// and custom + schemes cannot be passed to urllib.request.urlopen (Bandit B310 concern). + + Args: + url: Absolute URL to fetch + + Returns: + Parsed JSON body + + Raises: + ValueError: If the URL scheme is not https + """ + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https": + raise ValueError(f"Only https URLs are allowed, got: {url}") + + request = urllib.request.Request(url) # nosec B310 + with urllib.request.urlopen(request) as response: # nosec B310 + return json.loads(response.read()) + + +def get_cognito_public_keys() -> Dict[str, Any]: + """ + Fetch Cognito public keys for JWT validation. + + Returns: + Dictionary of public keys + """ + global _jwks + + if _jwks is not None: + return _jwks + + try: + config = get_config() + jwks_url = f"{config['issuer']}/.well-known/jwks.json" + logger.info(f"Fetching JWKS from: {jwks_url}") + + _jwks = _fetch_https_json(jwks_url) + logger.info("Successfully fetched Cognito public keys") + return _jwks + except Exception as e: + logger.error(f"Error fetching Cognito public keys: {str(e)}") + raise + + +def validate_and_decode_jwt(token: str) -> Optional[Dict[str, Any]]: + """ + Validate JWT token and decode claims. + + Args: + token: JWT bearer token + + Returns: + Decoded JWT claims or None if invalid + """ + try: + config = get_config() + + # Get Cognito public keys + jwks = get_cognito_public_keys() + + # Decode token header to get key ID + unverified_headers = jwt.get_unverified_header(token) + kid = unverified_headers.get("kid") + + # Find the correct public key + key = None + for k in jwks.get("keys", []): + if k.get("kid") == kid: + key = k + break + + if not key: + logger.error("Public key not found for token") + return None + + # Validate and decode JWT + # Note: For access tokens, we don't validate audience since Cognito + # access tokens don't have 'aud' claim. We validate client_id instead. + try: + claims = jwt.decode( + token, + key, + algorithms=["RS256"], + audience=config["app_client_id"], + issuer=config["issuer"], + ) + except JWTError as e: + # If audience validation fails, try without audience (for access tokens) + if "audience" in str(e).lower() or "aud" in str(e).lower(): + logger.info( + "Retrying JWT validation without audience check (access token)" + ) + claims = jwt.decode( + token, + key, + algorithms=["RS256"], + issuer=config["issuer"], + options={"verify_aud": False}, + ) + # Manually verify client_id for access tokens + if claims.get("client_id") != config["app_client_id"]: + logger.error( + f"Client ID mismatch: {claims.get('client_id')} != {config['app_client_id']}" + ) + return None + else: + raise + + logger.info( + f"Successfully validated JWT for user: {claims.get('username', claims.get('sub'))}" + ) + return claims + + except JWTError as e: + logger.error(f"JWT validation error: {str(e)}") + return None + except Exception as e: + logger.error(f"Error validating JWT: {str(e)}") + return None + + +def extract_bearer_token_from_mcp(event: Dict[str, Any]) -> Optional[str]: + """ + Extract bearer token from MCP gateway request structure. + + Following AgentCore Gateway MCP protocol, the event structure is: + { + "mcp": { + "gatewayRequest": { + "headers": {"Authorization": "Bearer "}, + "body": {...} + } + } + } + + Args: + event: Lambda event with MCP structure + + Returns: + Bearer token (without 'Bearer ' prefix) or None if not found + """ + try: + # Extract from MCP structure + mcp_data = event.get("mcp", {}) + gateway_request = mcp_data.get("gatewayRequest", {}) + headers = gateway_request.get("headers", {}) + + # Check Authorization header (case-insensitive) + auth_header = headers.get("Authorization") or headers.get("authorization") + + if auth_header: + # Remove 'Bearer ' prefix if present + if auth_header.startswith("Bearer "): + token = auth_header.replace("Bearer ", "", 1) + elif auth_header.startswith("bearer "): + token = auth_header.replace("bearer ", "", 1) + else: + token = auth_header + + logger.info("✅ Bearer token extracted from MCP gateway request") + return token + + logger.warning("⚠️ Bearer token not found in MCP gateway request headers") + return None + + except Exception as e: + logger.error(f"❌ Error extracting bearer token from MCP structure: {str(e)}") + return None + + +def extract_user_principal(claims: Dict[str, Any]) -> Optional[str]: + """ + Extract user principal (identity) from JWT claims. + + The principal is used for Lake Formation row-level security. + Priority order: + 1. email (preferred for user identification) + 2. username + 3. cognito:username + 4. sub (user ID as fallback) + + Args: + claims: Decoded JWT claims + + Returns: + User principal (email/username) or None + """ + # Try multiple claim fields in priority order + principal = ( + claims.get("email") + or claims.get("username") + or claims.get("cognito:username") + or claims.get("sub") + ) + + if principal: + logger.info(f"✅ Extracted user principal: {principal}") + return principal + + logger.warning("⚠️ User principal not found in JWT claims") + return None + + +def get_user_scopes(claims: Dict[str, Any]) -> list: + """ + Extract OAuth scopes from JWT claims for logging and context. + + Args: + claims: Decoded JWT claims + + Returns: + List of scopes + """ + # Scopes can be in 'scope' claim (space-separated) or 'cognito:groups' + scope_string = claims.get("scope", "") + scopes = scope_string.split() if scope_string else [] + + # Add groups as scopes + groups = claims.get("cognito:groups", []) + if isinstance(groups, list): + scopes.extend(groups) + + return scopes + + +def build_error_response(message, body, status_code=403): + """Return an MCP-style error response""" + return { + "interceptorOutputVersion": "1.0", + "mcp": { + "transformedGatewayResponse": { + "statusCode": status_code, + "body": { + "jsonrpc": "2.0", + "id": body.get("id", "unknown") + if isinstance(body, dict) + else "unknown", + "error": {"code": -32600, "message": message}, + }, + } + }, + } + + +def lambda_handler(event: Dict[str, Any], context: Any) -> Dict[str, Any]: + """ + Main Lambda handler for AgentCore Gateway interceptor. + + Follows the MCP protocol for request interception: + 1. Extracts JWT token from MCP gateway request structure + 2. Validates JWT and extracts user principal + 3. Adds user identity to request for downstream MCP server + 4. Returns transformed request in MCP format + + Event Structure (Input): + { + "mcp": { + "gatewayRequest": { + "headers": {"Authorization": "Bearer "}, + "body": {...} + } + } + } + + Response Structure (Output): + { + "interceptorOutputVersion": "1.0", + "mcp": { + "transformedGatewayRequest": { + "headers": {...}, + "body": {...} + } + } + } + + Args: + event: Lambda event with MCP structure + context: Lambda context + + Returns: + Transformed request in MCP format or error response + """ + logger.info("🔍 Gateway interceptor invoked") + logger.info(f"📦 Event structure: {json.dumps(event, default=str)[:500]}...") + + try: + # Extract MCP gateway request + mcp_data = event.get("mcp", {}) + gateway_request = mcp_data.get("gatewayRequest", {}) + headers = gateway_request.get("headers", {}) + body = gateway_request.get("body", {}) + + logger.info(f"📋 Headers present: {list(headers.keys())}") + logger.info(f"📋 Body keys: {list(body.keys())}") + + # Extract bearer token from MCP structure + token = extract_bearer_token_from_mcp(event) + + if not token: + logger.error("❌ No bearer token found in request") + return { + "statusCode": 401, + "body": json.dumps( + { + "error": "Unauthorized", + "message": "Bearer token required in Authorization header", + } + ), + } + + # Validate and decode JWT + claims = validate_and_decode_jwt(token) + + if not claims: + logger.error("❌ JWT validation failed") + return { + "statusCode": 401, + "body": json.dumps( + {"error": "Unauthorized", "message": "Invalid or expired JWT token"} + ), + } + + # Extract user principal from JWT claims + user_principal = extract_user_principal(claims) + + if not user_principal: + logger.error("❌ User principal not found in JWT claims") + return { + "statusCode": 401, + "body": json.dumps( + { + "error": "Unauthorized", + "message": "User principal not found in token claims", + } + ), + } + + # Get user scopes for logging + scopes = get_user_scopes(claims) + logger.info(f"👤 User: {user_principal}, Scopes: {scopes}") + + # Validate tool access before proceeding + is_authorized, error_message, tool_name = validate_tool_access(claims, body) + + if not is_authorized: + logger.error(f"❌ Tool access denied: {error_message}") + return build_error_response(error_message, body, status_code=403) + + if tool_name: + logger.info(f"✅ Tool access authorized: {tool_name}") + + # Exchange JWT claims to IAM credentials via DynamoDB role mapping + tenant_credentials = None + claim_for_exchange = get_claim_for_exchange(claims) + + if claim_for_exchange: + claim_name, claim_value = claim_for_exchange + logger.info(f"🔄 Attempting token exchange for: {claim_name}={claim_value}") + tenant_credentials = exchange_jwt_to_iam(claim_name, claim_value) + + if tenant_credentials: + logger.info( + f"🔑 Obtained temporary credentials for role: {tenant_credentials['RoleName']}" + ) + else: + logger.warning("⚠️ Failed to exchange JWT to IAM credentials") + else: + logger.warning("⚠️ No suitable claim found for token exchange") + + # Add user identity to headers for downstream MCP server + # The MCP server will use X-User-Identity for Lake Formation RLS + transformed_headers = { + "Accept": "application/json", + "Content-Type": "application/json", + "X-User-Identity": user_principal, + "X-User-Scopes": ",".join(scopes) if scopes else "", + } + + # Add tenant role information to headers if credentials were obtained + if tenant_credentials: + transformed_headers["X-Tenant-Role"] = tenant_credentials["RoleName"] + transformed_headers["X-Tenant-Role-Arn"] = tenant_credentials["RoleArn"] + + # Also add user context to body if it has params/arguments + # This ensures the MCP server can access user identity and credentials + transformed_body = body.copy() + if "params" in transformed_body and "arguments" in transformed_body["params"]: + if "context" not in transformed_body["params"]["arguments"]: + transformed_body["params"]["arguments"]["context"] = {} + transformed_body["params"]["arguments"]["context"]["user_id"] = ( + user_principal + ) + transformed_body["params"]["arguments"]["context"]["scopes"] = scopes + + # Add tenant credentials to context if available + if tenant_credentials: + transformed_body["params"]["arguments"]["context"][ + "tenant_credentials" + ] = { + "access_key_id": tenant_credentials["AccessKeyId"], + "secret_access_key": tenant_credentials["SecretAccessKey"], + "session_token": tenant_credentials["SessionToken"], + "role_arn": tenant_credentials["RoleArn"], + "role_name": tenant_credentials["RoleName"], + "expiration": tenant_credentials["Expiration"].isoformat(), + } + + # ---- Design 3: Inject geography into tool arguments ---- + # Cedar Policy evaluates context.input.geography for geography-based access control. + # The geography field must be at the top level of arguments (not inside context). + geography = USER_GEOGRAPHY.get(user_principal, "UNKNOWN") + if "params" in transformed_body and "arguments" in transformed_body["params"]: + transformed_body["params"]["arguments"]["geography"] = geography + logger.info(f"📍 Injected geography: {geography} for user: {user_principal}") + + # Return transformed request in MCP format + response = { + "interceptorOutputVersion": "1.0", + "mcp": { + "transformedGatewayRequest": { + "headers": transformed_headers, + "body": transformed_body, + } + }, + } + + logger.info(f"✅ Request authorized for user: {user_principal}") + logger.info("📤 Returning transformed request") + + return response + + except Exception as e: + logger.error(f"❌ Error in gateway interceptor: {str(e)}") + import traceback + + logger.error(f"Stack trace: {traceback.format_exc()}") + + return { + "statusCode": 500, + "body": json.dumps( + { + "error": "Internal Server Error", + "message": f"Error processing request: {str(e)}", + } + ), + } diff --git a/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/lib/policy-stack.ts b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/lib/policy-stack.ts new file mode 100644 index 000000000..bcd0e82cd --- /dev/null +++ b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/lib/policy-stack.ts @@ -0,0 +1,230 @@ +import * as cdk from "aws-cdk-lib"; +import * as agentcore from "aws-cdk-lib/aws-bedrockagentcore"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as cr from "aws-cdk-lib/custom-resources"; +import { Construct } from "constructs"; +import * as fs from "fs"; +import * as path from "path"; + +/** + * AgentCore Policy Stack for Lakehouse Agent + * + * Prerequisites: + * Before deploying, remove Interceptors from the Gateway. + * (Interceptor + Policy Engine の共存時にポリシー作成が内部エラーになる問題の回避) + * + * Deploy flow: + * 1. CfnPolicyEngine — Policy Engine 作成 + * 2. CfnPolicy x N — Cedar ポリシー作成 (permit_all を最初に、forbid をその後に) + * 3. IAM Policy — Gateway ロールに Policy 評価権限を追加 + * 4. UpdateGateway (AwsCustomResource) — Policy Engine + Interceptor を同時アタッチ + */ +export class PolicyStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // --- Context values --- + const gatewayId = this.node.tryGetContext("gatewayId") as string; + const gatewayName = this.node.tryGetContext("gatewayName") as string; + const gatewayArn = this.node.tryGetContext("gatewayArn") as string; + const gatewayRoleArn = this.node.tryGetContext("gatewayRoleArn") as string; + const discoveryUrl = this.node.tryGetContext("discoveryUrl") as string; + const allowedClientId = this.node.tryGetContext( + "allowedClientId", + ) as string; + const requestInterceptorArn = this.node.tryGetContext( + "requestInterceptorArn", + ) as string; + const responseInterceptorArn = this.node.tryGetContext( + "responseInterceptorArn", + ) as string; + + // --- Step 1: Policy Engine (CloudFormation native) --- + const policyEngine = new agentcore.CfnPolicyEngine(this, "PolicyEngine", { + name: "LakehousePolicyEngine", + description: "Cedar policies for lakehouse-agent: Design 1 + Design 3", + }); + + // --- Step 2: Cedar policies (CfnPolicy, chained sequentially) --- + // permit_all must be created FIRST (IGNORE_ALL_FINDINGS for Overly Permissive warning). + // forbid policies are created after permit_all exists (avoids Overly Restrictive error). + const policiesDir = path.join(__dirname, "..", "policies"); + const policyFiles = fs + .readdirSync(policiesDir) + .filter((f) => f.endsWith(".cedar")) + .sort(); + + // Reorder: permit_all first, then forbids + const permitFirst = [ + ...policyFiles.filter((f) => f.startsWith("permit")), + ...policyFiles.filter((f) => !f.startsWith("permit")), + ]; + + let permitAllPolicy: agentcore.CfnPolicy | undefined; + const allPolicies: agentcore.CfnPolicy[] = []; + + for (const policyFile of permitFirst) { + const policyName = policyFile.replace(".cedar", "").replace(/-/g, "_"); + let cedarStatement = fs.readFileSync( + path.join(policiesDir, policyFile), + "utf-8", + ); + cedarStatement = cedarStatement.replace(/\{gateway_arn\}/g, gatewayArn); + + const isPermitAll = policyName === "permit_all"; + + const policy = new agentcore.CfnPolicy(this, `Policy_${policyName}`, { + policyEngineId: policyEngine.attrPolicyEngineId, + name: policyName, + definition: { cedar: { statement: cedarStatement } }, + validationMode: isPermitAll + ? "IGNORE_ALL_FINDINGS" + : "FAIL_ON_ANY_FINDINGS", + }); + + if (isPermitAll) { + // permit_all depends on engine + policy.addDependency(policyEngine); + permitAllPolicy = policy; + } else { + // forbid policies depend on permit_all (avoids Overly Restrictive) + // but are parallel to each other + policy.addDependency(permitAllPolicy!); + } + allPolicies.push(policy); + } + + // --- Step 3: IAM permissions for Gateway role --- + const gatewayRole = iam.Role.fromRoleArn( + this, + "ExistingGatewayRole", + gatewayRoleArn, + { mutable: true }, + ); + const policyEvalPolicy = new iam.Policy(this, "PolicyEvalPermissions", { + policyName: "LakehousePolicyEval", + statements: [ + new iam.PolicyStatement({ + actions: [ + "bedrock-agentcore:AuthorizeAction", + "bedrock-agentcore:PartiallyAuthorizeActions", + "bedrock-agentcore:GetPolicyEngine", + "bedrock-agentcore:CheckAuthorizePermissions", + ], + resources: ["*"], + }), + ], + }); + gatewayRole.attachInlinePolicy(policyEvalPolicy); + + // --- Step 4: UpdateGateway — attach Policy Engine + Interceptors --- + const updateGateway = new cr.AwsCustomResource(this, "UpdateGateway", { + installLatestAwsSdk: true, + onCreate: { + service: "bedrock-agentcore-control", + action: "UpdateGateway", + parameters: { + gatewayIdentifier: gatewayId, + name: gatewayName, + roleArn: gatewayRoleArn, + protocolType: "MCP", + authorizerType: "CUSTOM_JWT", + authorizerConfiguration: { + customJWTAuthorizer: { + discoveryUrl, + allowedClients: [allowedClientId], + }, + }, + interceptorConfigurations: [ + { + interceptor: { + lambda: { arn: requestInterceptorArn }, + }, + interceptionPoints: ["REQUEST"], + inputConfiguration: { passRequestHeaders: true }, + }, + { + interceptor: { + lambda: { arn: responseInterceptorArn }, + }, + interceptionPoints: ["RESPONSE"], + inputConfiguration: { passRequestHeaders: true }, + }, + ], + policyEngineConfiguration: { + arn: policyEngine.attrPolicyEngineArn, + mode: "ENFORCE", + }, + }, + physicalResourceId: cr.PhysicalResourceId.of( + `update-gw-${gatewayId}-${Date.now()}`, + ), + }, + onUpdate: { + service: "bedrock-agentcore-control", + action: "UpdateGateway", + parameters: { + gatewayIdentifier: gatewayId, + name: gatewayName, + roleArn: gatewayRoleArn, + protocolType: "MCP", + authorizerType: "CUSTOM_JWT", + authorizerConfiguration: { + customJWTAuthorizer: { + discoveryUrl, + allowedClients: [allowedClientId], + }, + }, + interceptorConfigurations: [ + { + interceptor: { + lambda: { arn: requestInterceptorArn }, + }, + interceptionPoints: ["REQUEST"], + inputConfiguration: { passRequestHeaders: true }, + }, + { + interceptor: { + lambda: { arn: responseInterceptorArn }, + }, + interceptionPoints: ["RESPONSE"], + inputConfiguration: { passRequestHeaders: true }, + }, + ], + policyEngineConfiguration: { + arn: policyEngine.attrPolicyEngineArn, + mode: "ENFORCE", + }, + }, + physicalResourceId: cr.PhysicalResourceId.of( + `update-gw-${gatewayId}-${Date.now()}`, + ), + }, + policy: cr.AwsCustomResourcePolicy.fromStatements([ + new iam.PolicyStatement({ + actions: ["bedrock-agentcore:*"], + resources: ["*"], + }), + new iam.PolicyStatement({ + actions: ["iam:PassRole"], + resources: [gatewayRoleArn], + }), + ]), + timeout: cdk.Duration.minutes(5), + }); + + updateGateway.node.addDependency(policyEvalPolicy); + for (const p of allPolicies) { + updateGateway.node.addDependency(p); + } + + // --- Outputs --- + new cdk.CfnOutput(this, "PolicyEngineId", { + value: policyEngine.attrPolicyEngineId, + }); + new cdk.CfnOutput(this, "PolicyEngineArn", { + value: policyEngine.attrPolicyEngineArn, + }); + new cdk.CfnOutput(this, "GatewayId", { value: gatewayId }); + } +} diff --git a/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/package-lock.json b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/package-lock.json new file mode 100644 index 000000000..e741f1f7b --- /dev/null +++ b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/package-lock.json @@ -0,0 +1,512 @@ +{ + "name": "lakehouse-policy-cdk", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lakehouse-policy-cdk", + "version": "1.0.0", + "dependencies": { + "aws-cdk-lib": "^2.250.0", + "constructs": "^10.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "aws-cdk": "^2.1119.0", + "typescript": "^5.7.0" + } + }, + "node_modules/@aws-cdk/asset-awscli-v1": { + "version": "2.2.273", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.273.tgz", + "integrity": "sha512-X57HYUtHt9BQrlrzUNcMyRsDUCoakYNnY6qh5lNwRCHPtQoTfXmuISkfLk0AjLkcbS5lw1LLTQFiQhTDXfiTvg==", + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.1.tgz", + "integrity": "sha512-We4bmHaowOPHr+IQR4/FyTGjRfjgBj4ICMjtqmJeBDWad3Q/6St12NT07leNtyuukv2qMhtSZJQorD8KpKTwRA==", + "license": "Apache-2.0" + }, + "node_modules/@aws-cdk/cloud-assembly-schema": { + "version": "53.18.0", + "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-53.18.0.tgz", + "integrity": "sha512-/fa6rOpokkfa5tVIdhsaexQq5MVVTSsZSD1Tu45YcrdyGRusGrM9RlPMCPrwvMS1UfdVFBhcgO9dl9ODWAWOeQ==", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "~1.4.1", + "semver": "^7.7.4" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { + "version": "1.4.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { + "version": "7.7.4", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/aws-cdk": { + "version": "2.1119.0", + "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1119.0.tgz", + "integrity": "sha512-XBxZEKH3BY4M1EX6x0qBkmOAj8viErjpww14iH6Z3z6nI0YzjZeJ05eEl7eJwzUgv7NTGagWBS9m/eDJW5+dAg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "cdk": "bin/cdk" + }, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/aws-cdk-lib": { + "version": "2.250.0", + "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.250.0.tgz", + "integrity": "sha512-8U8/S9VcmKSc3MHZWiB7P0IecgXoohI8Ya3dgtZMgbzC4mB+MEQmsYBeNgm4vzGQdRos8HjQLnFX1IBlZh7jQA==", + "bundleDependencies": [ + "@balena/dockerignore", + "@aws-cdk/cloud-assembly-api", + "case", + "fs-extra", + "ignore", + "jsonschema", + "minimatch", + "punycode", + "semver", + "table", + "yaml", + "mime-types" + ], + "license": "Apache-2.0", + "dependencies": { + "@aws-cdk/asset-awscli-v1": "2.2.273", + "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.1", + "@aws-cdk/cloud-assembly-api": "^2.2.0", + "@aws-cdk/cloud-assembly-schema": "^53.0.0", + "@balena/dockerignore": "^1.0.2", + "case": "1.6.3", + "fs-extra": "^11.3.3", + "ignore": "^5.3.2", + "jsonschema": "^1.5.0", + "mime-types": "^2.1.35", + "minimatch": "^10.2.3", + "punycode": "^2.3.1", + "semver": "^7.7.4", + "table": "^6.9.0", + "yaml": "1.10.3" + }, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "constructs": "^10.5.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api": { + "version": "2.2.0", + "bundleDependencies": [ + "jsonschema", + "semver" + ], + "inBundle": true, + "license": "Apache-2.0", + "dependencies": { + "jsonschema": "~1.4.1", + "semver": "^7.7.4" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "@aws-cdk/cloud-assembly-schema": ">=53.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/jsonschema": { + "version": "1.4.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/@aws-cdk/cloud-assembly-api/node_modules/semver": { + "version": "7.7.4", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { + "version": "1.0.2", + "inBundle": true, + "license": "Apache-2.0" + }, + "node_modules/aws-cdk-lib/node_modules/ajv": { + "version": "8.18.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-regex": { + "version": "5.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/ansi-styles": { + "version": "4.3.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/astral-regex": { + "version": "2.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/balanced-match": { + "version": "4.0.4", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/aws-cdk-lib/node_modules/brace-expansion": { + "version": "5.0.5", + "inBundle": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/aws-cdk-lib/node_modules/case": { + "version": "1.6.3", + "inBundle": true, + "license": "(MIT OR GPL-3.0-or-later)", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-convert": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/color-name": { + "version": "1.1.4", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/emoji-regex": { + "version": "8.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-deep-equal": { + "version": "3.1.3", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/fast-uri": { + "version": "3.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "inBundle": true, + "license": "BSD-3-Clause" + }, + "node_modules/aws-cdk-lib/node_modules/fs-extra": { + "version": "11.3.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/aws-cdk-lib/node_modules/graceful-fs": { + "version": "4.2.11", + "inBundle": true, + "license": "ISC" + }, + "node_modules/aws-cdk-lib/node_modules/ignore": { + "version": "5.3.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/aws-cdk-lib/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/json-schema-traverse": { + "version": "1.0.0", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/jsonfile": { + "version": "6.2.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/jsonschema": { + "version": "1.5.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/aws-cdk-lib/node_modules/lodash.truncate": { + "version": "4.4.2", + "inBundle": true, + "license": "MIT" + }, + "node_modules/aws-cdk-lib/node_modules/mime-db": { + "version": "1.52.0", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/mime-types": { + "version": "2.1.35", + "inBundle": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aws-cdk-lib/node_modules/minimatch": { + "version": "10.2.5", + "inBundle": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/aws-cdk-lib/node_modules/punycode": { + "version": "2.3.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/aws-cdk-lib/node_modules/require-from-string": { + "version": "2.0.2", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/semver": { + "version": "7.7.4", + "inBundle": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aws-cdk-lib/node_modules/slice-ansi": { + "version": "4.0.0", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/aws-cdk-lib/node_modules/string-width": { + "version": "4.2.3", + "inBundle": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/strip-ansi": { + "version": "6.0.1", + "inBundle": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/aws-cdk-lib/node_modules/table": { + "version": "6.9.0", + "inBundle": true, + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/universalify": { + "version": "2.0.1", + "inBundle": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aws-cdk-lib/node_modules/yaml": { + "version": "1.10.3", + "inBundle": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/constructs": { + "version": "10.5.1", + "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.5.1.tgz", + "integrity": "sha512-f/TfFXiS3G/yVIXDjOQn9oTlyu9Wo7Fxyjj7lb8r92iO81jR2uST+9MstxZTmDGx/CgIbxCXkFXgupnLTNxQZg==", + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/package.json b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/package.json new file mode 100644 index 000000000..77647b755 --- /dev/null +++ b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/package.json @@ -0,0 +1,18 @@ +{ + "name": "lakehouse-policy-cdk", + "version": "1.0.0", + "description": "AgentCore Policy Engine for Lakehouse Agent", + "scripts": { + "build": "tsc", + "cdk": "cdk" + }, + "dependencies": { + "aws-cdk-lib": "^2.250.0", + "constructs": "^10.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + "aws-cdk": "^2.1119.0" + } +} diff --git a/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/policies/forbid_eu_individual_claims.cedar b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/policies/forbid_eu_individual_claims.cedar new file mode 100644 index 000000000..2366a65d3 --- /dev/null +++ b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/policies/forbid_eu_individual_claims.cedar @@ -0,0 +1,11 @@ +forbid( + principal, + action in [ + AgentCore::Action::"lakehouse-mcp-target___query_claims", + AgentCore::Action::"lakehouse-mcp-target___get_claim_details" + ], + resource == AgentCore::Gateway::"{gateway_arn}" +) when { + context.input has geography && + context.input.geography == "EU" +}; diff --git a/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/policies/forbid_policyholder_summary.cedar b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/policies/forbid_policyholder_summary.cedar new file mode 100644 index 000000000..99bd73c41 --- /dev/null +++ b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/policies/forbid_policyholder_summary.cedar @@ -0,0 +1,8 @@ +forbid( + principal is AgentCore::OAuthUser, + action == AgentCore::Action::"lakehouse-mcp-target___get_claims_summary", + resource == AgentCore::Gateway::"{gateway_arn}" +) when { + principal.hasTag("cognito:groups") && + principal.getTag("cognito:groups") like "*policyholders*" +}; diff --git a/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/policies/forbid_restricted_geography.cedar b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/policies/forbid_restricted_geography.cedar new file mode 100644 index 000000000..855d07125 --- /dev/null +++ b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/policies/forbid_restricted_geography.cedar @@ -0,0 +1,14 @@ +forbid( + principal, + action in [ + AgentCore::Action::"lakehouse-mcp-target___query_claims", + AgentCore::Action::"lakehouse-mcp-target___get_claim_details", + AgentCore::Action::"lakehouse-mcp-target___get_claims_summary", + AgentCore::Action::"lakehouse-mcp-target___query_login_audit", + AgentCore::Action::"lakehouse-mcp-target___text_to_sql" + ], + resource == AgentCore::Gateway::"{gateway_arn}" +) when { + context.input has geography && + context.input.geography == "RESTRICTED" +}; diff --git a/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/policies/permit_all.cedar b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/policies/permit_all.cedar new file mode 100644 index 000000000..cc03405d4 --- /dev/null +++ b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/policies/permit_all.cedar @@ -0,0 +1,5 @@ +permit( + principal, + action, + resource == AgentCore::Gateway::"{gateway_arn}" +); diff --git a/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/scripts/detach-interceptors.py b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/scripts/detach-interceptors.py new file mode 100755 index 000000000..7b9ab2bc3 --- /dev/null +++ b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/scripts/detach-interceptors.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +""" +Pre-deploy script: Temporarily detach Interceptors from the Gateway. + +Cedar policy creation fails when Interceptors are attached to the Gateway. +This script removes Interceptors while preserving the JWT authorizer configuration. +After CDK deploy, the CDK stack re-attaches the Interceptors automatically. + +Usage: + AWS_PROFILE= AWS_REGION=us-east-1 python cdk/scripts/detach-interceptors.py +""" + +import boto3 +import os + + +def get_ssm_param(ssm: boto3.client, name: str) -> str: + """Get a parameter from SSM Parameter Store.""" + return ssm.get_parameter(Name=name)["Parameter"]["Value"] + + +def main() -> None: + region = os.environ.get("AWS_REGION", "us-east-1") + session = boto3.Session(region_name=region) + ssm = session.client("ssm") + client = session.client("bedrock-agentcore-control") + + # Load config from SSM + gateway_id = get_ssm_param(ssm, "/app/lakehouse-agent/gateway-id") + gateway_name = get_ssm_param(ssm, "/app/lakehouse-agent/gateway-name") + + # Get current Gateway config + gw = client.get_gateway(gatewayIdentifier=gateway_id) + role_arn = gw["roleArn"] + auth_config = gw["authorizerConfiguration"] + + print(f"Gateway: {gateway_id} ({gateway_name})") + print(f"Role: {role_arn}") + + # Update Gateway without Interceptors + client.update_gateway( + gatewayIdentifier=gateway_id, + name=gateway_name, + roleArn=role_arn, + protocolType="MCP", + authorizerType="CUSTOM_JWT", + authorizerConfiguration=auth_config, + ) + + print("Interceptors detached successfully.") + print("You can now run: npx cdk deploy") + + +if __name__ == "__main__": + main() diff --git a/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/scripts/generate-cdk-context.sh b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/scripts/generate-cdk-context.sh new file mode 100755 index 000000000..bfcaddc72 --- /dev/null +++ b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/scripts/generate-cdk-context.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Generate cdk.json context from SSM Parameter Store (populated by Phase 1 deployment). +# +# Usage: +# cd 02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor +# AWS_REGION=us-east-1 bash scripts/generate-cdk-context.sh + +set -euo pipefail + +REGION="${AWS_REGION:-us-east-1}" + +echo "Fetching configuration from SSM Parameter Store (region: $REGION)..." + +get_param() { + aws ssm get-parameter --name "$1" --region "$REGION" --query "Parameter.Value" --output text 2>/dev/null || echo "" +} + +ACCOUNT_ID=$(aws sts get-caller-identity --query "Account" --output text 2>/dev/null) +GATEWAY_ID=$(get_param "/app/lakehouse-agent/gateway-id") +GATEWAY_ARN=$(get_param "/app/lakehouse-agent/gateway-arn") +GATEWAY_ROLE_ARN=$(aws bedrock-agentcore-control get-gateway \ + --gateway-identifier "$GATEWAY_ID" --region "$REGION" \ + --query "roleArn" --output text 2>/dev/null) +DISCOVERY_URL="https://cognito-idp.${REGION}.amazonaws.com/$(get_param '/app/lakehouse-agent/cognito-user-pool-id')/.well-known/openid-configuration" +CLIENT_ID=$(get_param "/app/lakehouse-agent/cognito-app-client-id") +REQUEST_INTERCEPTOR_ARN=$(get_param "/app/lakehouse-agent/interceptor-lambda-arn") +RESPONSE_INTERCEPTOR_ARN=$(get_param "/app/lakehouse-agent/response-interceptor-lambda-arn") +TARGET_ID=$(aws bedrock-agentcore-control list-gateway-targets \ + --gateway-identifier "$GATEWAY_ID" --region "$REGION" \ + --query "items[0].targetId" --output text 2>/dev/null) + +# Validate +if [ -z "$ACCOUNT_ID" ] || [ "$ACCOUNT_ID" = "None" ]; then + echo "Error: Could not determine AWS account ID. Is aws CLI configured?" + exit 1 +fi +if [ -z "$GATEWAY_ID" ]; then + echo "Error: Could not fetch gateway-id from SSM. Is the lakehouse-agent deployed?" + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CDK_JSON="$SCRIPT_DIR/../cdk.json" + +cat > "$CDK_JSON" < deployment/advanced-agentcore-policy-gateway-interceptor +# -> deployment +# -> lakehouse-agent +PROJECT_DIR="$(cd "$CDK_DIR/../.." && pwd)" + +REGION="${AWS_REGION:-us-east-1}" +export AWS_REGION="$REGION" +export AWS_DEFAULT_REGION="$REGION" + +echo "============================================================" +echo "Pre-deploy for advanced AgentCore Policy CDK" +echo "Region: $REGION" +echo "============================================================" + +# ── Step 1: Generate cdk.json ───────────────────────────────── +echo "" +echo "[1/3] Generating cdk.json from SSM Parameter Store..." +bash "$SCRIPT_DIR/generate-cdk-context.sh" + +# ── Step 2: Detach Interceptors ─────────────────────────────── +echo "" +echo "[2/3] Detaching Interceptors from Gateway..." +echo " (Cedar Policy validation sends internal MCP requests with SigV4," +echo " which fail on JWT-validating Interceptors.)" +python3 "$SCRIPT_DIR/detach-interceptors.py" + +# ── Validate: Response Interceptor Lambda must exist ───────── +echo "" +echo "[Validate] Checking Response Interceptor Lambda exists in $REGION..." +if ! aws lambda get-function \ + --function-name lakehouse-gateway-response-interceptor \ + --region "$REGION" >/dev/null 2>&1; then + echo " ERROR: 'lakehouse-gateway-response-interceptor' not found in $REGION." + echo "" + echo " CDK deploy will re-attach both interceptors to the Gateway." + echo " If the Response Interceptor Lambda does not exist, all tool calls" + echo " will fail with HTTP 500." + echo "" + echo " Deploy it first (Phase 1 Step 5b):" + echo " cd $PROJECT_DIR/deployment/5-gateway-setup/interceptor-response" + echo " AWS_REGION=$REGION ./deploy.sh" + exit 1 +fi +echo " Response Interceptor Lambda exists." + +# ── Step 3: Update Request Interceptor Lambda ───────────────── +echo "" +echo "[3/3] Updating Request Interceptor Lambda (Design 3 geography support)..." + +SRC="$CDK_DIR/lambda/interceptor-request/lambda_function.py" +DST="$PROJECT_DIR/deployment/5-gateway-setup/interceptor-request/lambda_function.py" + +if [ -f "$SRC" ]; then + cp "$SRC" "$DST" + echo " Copied: $(basename "$SRC")" + echo " From: $SRC" + echo " To: $DST" + + echo " Deploying Lambda..." + cd "$PROJECT_DIR/deployment/5-gateway-setup/interceptor-request" + AWS_REGION="$REGION" ./deploy.sh + cd "$CDK_DIR" + echo " Lambda updated." +else + echo " Skip: $SRC not found (Design 3 Lambda not prepared)" +fi + +echo "" +echo "============================================================" +echo "Pre-deploy complete. Next step:" +echo " npx cdk deploy --require-approval never" +echo "============================================================" diff --git a/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/tsconfig.json b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/tsconfig.json new file mode 100644 index 000000000..9ae0340ec --- /dev/null +++ b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["es2020"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "outDir": "dist", + "rootDir": ".", + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["bin/**/*.ts", "lib/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/verification/verify_policy.py b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/verification/verify_policy.py new file mode 100644 index 000000000..40478e540 --- /dev/null +++ b/02-use-cases/lakehouse-agent/deployment/advanced-agentcore-policy-gateway-interceptor/verification/verify_policy.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +""" +AgentCore Policy FGAC Verification Script + +Tests all 3 designs by logging in as different users and verifying +tool access control through the Gateway. + +Usage: + cd 02-use-cases/lakehouse-agent + source .venv/bin/activate + python deployment/advanced-agentcore-policy-gateway-interceptor/verification/verify_policy.py + +The script uses the default AWS credentials chain (env vars, shared +credentials file, or an active SSO profile set via AWS_PROFILE). +""" + +import base64 +import hashlib +import hmac +import json +import os +import sys +from typing import Any + +import boto3 +import requests + +# --- Configuration --- +REGION = os.environ.get("AWS_REGION", "us-east-1") +# Default matches the shared sample password set by Phase 1 setup_cognito.py. +# Override with LAKEHOUSE_TEST_PASSWORD env var when running against a custom setup. +TEST_PASSWORD = os.environ.get( + "LAKEHOUSE_TEST_PASSWORD", + "TempPass123!", # pragma: allowlist secret +) +TARGET_NAME = "lakehouse-mcp-target" + + +def get_config() -> dict[str, str]: + """Load configuration from SSM Parameter Store.""" + session = boto3.Session(region_name=REGION) + ssm = session.client("ssm") + + def get_param(name: str, secure: bool = False) -> str: + return ssm.get_parameter( + Name=f"/app/lakehouse-agent/{name}", WithDecryption=secure + )["Parameter"]["Value"] + + return { + "gateway_url": get_param("gateway-url"), + "user_pool_id": get_param("cognito-user-pool-id"), + "client_id": get_param("cognito-app-client-id"), + "client_secret": get_param("cognito-app-client-secret", secure=True), + } + + +def compute_secret_hash(username: str, client_id: str, client_secret: str) -> str: + """Compute Cognito SECRET_HASH.""" + message = username + client_id + return base64.b64encode( + hmac.new(client_secret.encode(), message.encode(), hashlib.sha256).digest() + ).decode() + + +def authenticate_user(username: str, config: dict[str, str]) -> str: + """Authenticate user and return access token.""" + session = boto3.Session(region_name=REGION) + cognito = session.client("cognito-idp") + + secret_hash = compute_secret_hash( + username, config["client_id"], config["client_secret"] + ) + + resp = cognito.admin_initiate_auth( + UserPoolId=config["user_pool_id"], + ClientId=config["client_id"], + AuthFlow="ADMIN_USER_PASSWORD_AUTH", + AuthParameters={ + "USERNAME": username, + "PASSWORD": TEST_PASSWORD, + "SECRET_HASH": secret_hash, + }, + ) + return resp["AuthenticationResult"]["AccessToken"] + + +_request_id = 0 + + +def call_gateway( + access_token: str, gateway_url: str, method: str, params: dict[str, Any] +) -> dict[str, Any]: + """Send an MCP JSON-RPC request to the Gateway.""" + global _request_id + _request_id += 1 + + resp = requests.post( + gateway_url, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + }, + json={ + "jsonrpc": "2.0", + "id": _request_id, + "method": method, + "params": params, + }, + timeout=60, + ) + + # Handle SSE format + if resp.headers.get("Content-Type", "").startswith("text/event-stream"): + for line in resp.text.split("\n"): + if line.startswith("data: "): + try: + return json.loads(line[6:]) + except json.JSONDecodeError: + continue + return {"error": {"message": "No valid SSE data"}} + + try: + return resp.json() + except json.JSONDecodeError: + return {"error": {"message": f"HTTP {resp.status_code}: {resp.text[:200]}"}} + + +def parse_tool_result(result: dict[str, Any]) -> list[dict[str, Any]]: + """Parse tool call result to extract data.""" + content = result.get("result", {}).get("content", []) + if not content: + return [] + text = content[0].get("text", "") + try: + data = json.loads(text) + return data.get("claims", data.get("records", [])) + except json.JSONDecodeError: + return [] + + +def is_policy_denied(result: dict[str, Any]) -> bool: + """Check if result is a Policy DENY (not a backend error). + + Policy DENY returns a JSON-RPC error with "Tool Execution Denied" message. + Backend errors (e.g. Athena column not found, DynamoDB table missing) mean + the tool was ALLOWED by Policy but failed during execution. + """ + # JSON-RPC level error = Policy DENY or Interceptor DENY + if "error" in result: + msg = result["error"].get("message", "") + # Policy DENY or Interceptor tool access denial + if "Tool Execution Denied" in msg or "not allowed" in msg or "Forbidden" in msg: + return True + return True # Other JSON-RPC errors are also treated as DENY + return False + + +# --- Test Functions --- + + +def test_tool_access( + username: str, + tool_name: str, + expected: str, + config: dict[str, str], + description: str = "", +) -> bool: + """Test if a tool call is ALLOW or DENY.""" + token = authenticate_user(username, config) + result = call_gateway( + token, + config["gateway_url"], + "tools/call", + {"name": f"{TARGET_NAME}___{tool_name}", "arguments": {}}, + ) + actual = "DENY" if is_policy_denied(result) else "ALLOW" + passed = actual == expected + desc = f" ({description})" if description else "" + status = "PASS" if passed else "FAIL" + print(f" {status}: {tool_name} = {actual} (expected {expected}){desc}") + if not passed: + error_msg = result.get("error", {}).get("message", "")[:100] + if error_msg: + print(f" Error: {error_msg}") + return passed + + +def test_data_isolation(user1: str, user2: str, config: dict[str, str]) -> bool: + """Design 2: Verify different users get different data.""" + token1 = authenticate_user(user1, config) + token2 = authenticate_user(user2, config) + + result1 = call_gateway( + token1, + config["gateway_url"], + "tools/call", + {"name": f"{TARGET_NAME}___query_claims", "arguments": {}}, + ) + result2 = call_gateway( + token2, + config["gateway_url"], + "tools/call", + {"name": f"{TARGET_NAME}___query_claims", "arguments": {}}, + ) + + claims1 = parse_tool_result(result1) + claims2 = parse_tool_result(result2) + + ids1 = {c.get("claim_id") for c in claims1} + ids2 = {c.get("claim_id") for c in claims2} + no_overlap = ids1.isdisjoint(ids2) + + print(f" {user1}: {len(claims1)} claims, {user2}: {len(claims2)} claims") + status = "PASS" if no_overlap else "FAIL" + print(f" {status}: Data overlap = {'NONE' if no_overlap else 'FOUND'}") + return no_overlap + + +def test_column_masking( + username: str, forbidden_column: str, config: dict[str, str] +) -> bool: + """Design 2: Verify a column is not present in results.""" + token = authenticate_user(username, config) + result = call_gateway( + token, + config["gateway_url"], + "tools/call", + {"name": f"{TARGET_NAME}___query_claims", "arguments": {}}, + ) + claims = parse_tool_result(result) + if not claims: + print(f" SKIP: No claims returned for {username}") + return True + has_column = forbidden_column in claims[0] + status = "PASS" if not has_column else "FAIL" + print( + f" {status}: Column '{forbidden_column}' present = {'YES' if has_column else 'NO'}" + ) + return not has_column + + +# --- Main --- + + +def main() -> int: + print("=" * 60) + print("AgentCore Policy FGAC Verification") + print("=" * 60) + print() + + print("Loading configuration from SSM...") + config = get_config() + print(f"Gateway: {config['gateway_url']}") + print() + + total = 0 + passed = 0 + + # ===== Design 1: Policy Only ===== + print("[Design 1: Policy Only - forbid policyholders summary]") + print("-" * 60) + + print("\n--- policyholder001@example.com (US) ---") + for tool, expected, desc in [ + ("query_claims", "ALLOW", "Interceptor + Policy allow"), + ("get_claim_details", "ALLOW", "Interceptor + Policy allow"), + ("get_claims_summary", "DENY", "Cedar forbid for policyholders"), + ]: + total += 1 + if test_tool_access( + "policyholder001@example.com", tool, expected, config, desc + ): + passed += 1 + + print("\n--- adjuster001@example.com (US) ---") + for tool, expected, desc in [ + ("get_claims_summary", "ALLOW", "adjuster is not forbidden"), + ("query_claims", "ALLOW", "Interceptor + Policy allow"), + ]: + total += 1 + if test_tool_access("adjuster001@example.com", tool, expected, config, desc): + passed += 1 + + print("\n--- admin@example.com (US) ---") + for tool, expected, desc in [ + ("query_login_audit", "ALLOW", "admin allowed"), + ("text_to_sql", "ALLOW", "admin allowed"), + ]: + total += 1 + if test_tool_access("admin@example.com", tool, expected, config, desc): + passed += 1 + + # ===== Design 2: Interceptor Only ===== + print("\n\n[Design 2: Interceptor Only - Token Exchange + RLS]") + print("-" * 60) + + print("\n--- Data isolation: policyholder001 vs adjuster001 ---") + total += 1 + if test_data_isolation( + "policyholder001@example.com", "adjuster001@example.com", config + ): + passed += 1 + + print("\n--- Column masking: policyholder001 ---") + total += 1 + if test_column_masking("policyholder001@example.com", "adjuster_user_id", config): + passed += 1 + + print("\n--- Column masking: adjuster001 ---") + total += 1 + if test_column_masking("adjuster001@example.com", "policyholder_dob", config): + passed += 1 + + # ===== Design 3: Policy + Interceptor ===== + print("\n\n[Design 3: Policy + Interceptor - Geography]") + print("-" * 60) + + print("\n--- policyholder002@example.com (EU) ---") + for tool, expected, desc in [ + ("query_claims", "DENY", "EU forbid individual claims"), + ("get_claims_summary", "DENY", "Design 1 forbid for policyholders"), + ]: + total += 1 + if test_tool_access( + "policyholder002@example.com", tool, expected, config, desc + ): + passed += 1 + + print("\n--- adjuster001@example.com (US) ---") + total += 1 + if test_tool_access( + "adjuster001@example.com", "query_claims", "ALLOW", config, "US allowed" + ): + passed += 1 + + # ===== Results ===== + print(f"\n\n{'=' * 60}") + print(f"Results: {passed}/{total} passed") + print(f"{'=' * 60}") + + return 0 if passed == total else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 06d0d32d6..ad5173ac7 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -113,4 +113,5 @@ - Richa Gupta (richagpt) - Chandra Dhandapani - Anant Murarka (anantmu) +- Renya Kujirada (ren8k) - Cristiano Scandura (scandura)