Merge pull request 'Reversible PII de-identification round trip (T1–T7)' (#1) from feat/deid-round-trip-t1-t7 into main

Reviewed-on: #1
This commit is contained in:
2026-07-01 07:22:08 +00:00
22 changed files with 1631 additions and 107 deletions

17
.gitignore vendored
View File

@@ -37,3 +37,20 @@ override.tf.json
.terraformrc .terraformrc
terraform.rc terraform.rc
# Terraform Lambda build artifacts
terraform/*.zip
# Local venvs
.venv*/
# AgentCore generated (env-specific)
agent/.bedrock_agentcore.yaml
agent/Dockerfile
.bedrock_agentcore/
# Python / OS noise
__pycache__/
*.pyc
.DS_Store
agent/.dockerignore

125
CLAUDE.md
View File

@@ -60,30 +60,107 @@ fusion/ POLICY_SETUP.md (SaaS console config — human does this, not y
## Task backlog (pick these up) ## Task backlog (pick these up)
Each task: keep it demo-grade, add a note in the file, and update this list. Each task: keep it demo-grade, add a note in the file, and update this list.
- **T1 — `/tokenize` endpoint** (`gateway_api/tokenize/`). HTTPS endpoint Fusion - **T1 — `/tokenize` endpoint** **done + live-verified** (`gateway_api/tokenize/handler.py`,
calls with `{query}`. Steps: POST query to Presidio `/analyze`; for each finding terraform `terraform/gateway.tf`). Lambda behind a **public API Gateway HTTP API**
mint a random token (`CUST_<rand>` for PERSON→customer, `TW_<rand>` for TW_ROC_ID); (not a Function URL — this account's SCP blocks unauthenticated Function URLs),
write vault items `{token, type, value, session_id, expires_at}`; splice tokens shared-secret auth (`tokenize_api_key`, sent as `x-api-key` / bearer, checked
into the text. Return `{deidentified_prompt, session_id}`. in-handler). Calls Presidio `/analyze`, resolves overlapping findings (specific
*Accept:* real name in → tokenized text + session_id out; vault rows written; entity wins, so the ROC ID stays `TW_ROC_ID` not `PERSON`), mints random
same input yields different tokens on repeat calls. `CUST_<rand>`/`TW_<rand>` tokens, writes vault rows
- **T2 — `/restore` endpoint** (`gateway_api/restore/`). Input `{session_id, text}`. `{token, type, value, session_id, expires_at [, customer_id]}` (`value` = original
Look up this session's tokens in the vault, re-attach identity (envelope-level: PII for /restore; `customer_id` best-effort resolved for the RAG tool), splices
prepend the real name; also swap any inline tokens). Return `{final}`. right-to-left, returns `{deidentified_prompt, session_id}`. **Verified live** in
*Accept:* tokenized answer + session_id → answer with `王小明` restored. `ap-southeast-1`: `王小明``CUST_*`, `A123456789``TW_*`, clean prompt, vault rows
- **T3 — reachability from SaaS.** Make Presidio (or fold detection into T1 so only written with resolved `customer_id`, repeat→different tokens, no/bad key→401.
`/tokenize` + `/restore` are public) reachable from Fusion SaaS with auth. Infra changes made while deploying: Presidio task bumped to **2GB** (1GB OOM'd on
*Accept:* Fusion SaaS can call the endpoints over HTTPS; nothing else is public. the 603MB zh model → connection refused), **awslogs** added, **ARM64/Graviton**
- **T4 — AgentCore Gateway target.** Wire the RAG Lambda as the MCP tool; confirm runtime (built on Apple Silicon via podman). All AWS objects tagged
the agent can call `get_customer_activity_summary(CUST_000123)` and get the package. `Owner="conan hncb demo"`. *Caveats:* Presidio has no stable endpoint — its
*Accept:* a manual `agentcore` invoke returns the evidence package. Fargate public IP changes per task launch, so `presidio_url` in
- **T5 — Agent runtime.** Pin deps, `agentcore launch`, capture the Runtime ARN. `terraform/local.auto.tfvars` must be refreshed and the tokenize Lambda re-applied
*Accept:* invoking the runtime with a tokenized prompt returns talking points. (an ALB/Cloud Map would fix this; out of scope for the demo). The default VPC was
- **T6 — UI wiring.** Point `ui/index.html` GATEWAY_URL at the Fusion SaaS entry created by hand and tagged `maintenance=manual-cleanup-required` (Terraform doesn't
(or `/tokenize` for a Fusion-less dry run); host on S3+CloudFront. own it, so `teardown.sh` won't remove it).
*Accept:* the split view renders restored vs tokenized. - **T2 — `/restore` endpoint** ✅ **done + live-verified** (`gateway_api/restore/handler.py`,
- **T7 — E2E rehearsal.** Run the full script in README; confirm the Bedrock/ terraform in `terraform/gateway.tf`). `POST /restore` on the same API Gateway,
AgentCore trace shows only tokens. Then verify `teardown.sh` leaves nothing paid-for. same shared-secret auth; IAM is read-only `dynamodb:Scan` on the vault. Input
`{session_id, text}` → scans the vault for THIS session's tokens (scoped, so one
session can't restore another's), swaps inline tokens back to the original PII
(longest-token-first), and prepends the customer's real name at the envelope
level (`(客戶:王小明)`). Returns `{final, restored}`. **Verified live** with the
full T1→T2 round trip: tokenized prompt out → simulated token-only cloud answer →
`王小明` + `A123456789` restored, no tokens left; no/bad key → 401; unknown
session → text unchanged, `restored:0` (no cross-session leak). `restore_url`
is a Terraform output.
- **T3 — reachability from SaaS** ✅ **done + live-verified** (`terraform/ecs.tf`,
`terraform/gateway.tf`). Presidio is now private: its SG allows 5001 only from the
tokenize Lambda's SG (no `0.0.0.0/0`). The tokenize Lambda runs **in the VPC**
(`vpc_config` + `AWSLambdaVPCAccessExecutionRole`) and reaches Presidio on its
**private** IP; DynamoDB is reached via a **gateway VPC endpoint** (no NAT). The
Fargate task keeps a public IP only to pull from ECR, but all inbound is SG-locked.
Only the API Gateway (`/tokenize` + `/restore`, shared-secret auth) is public.
**Verified live:** Presidio's public IP `:5001` now times out from the internet;
the full round trip still works through the private path. *Caveat update:* the
tokenize Lambda now targets Presidio's **private** IP (`172.31.2.216` currently),
which still changes per task launch — refresh `presidio_url` in
`terraform/local.auto.tfvars` and re-apply `aws_lambda_function.tokenize` if the
task restarts (Cloud Map/ALB would give a stable name; out of scope for the demo).
- **T4 — AgentCore Gateway target** ✅ **done + live-verified** (`terraform/agentcore.tf`,
`scripts/agentcore_setup.sh`, `scripts/gateway_invoke_test.py`). Created an MCP
Gateway (`hncb-rag-gateway`, `authorizerType=AWS_IAM` so inbound is SigV4 — no
Cognito) with the RAG Lambda registered as a `lambda` MCP target; the Gateway
assumes a Terraform-managed IAM role that can only `lambda:InvokeFunction` the RAG
tool. Built the Gateway/target with `aws bedrock-agentcore-control` (Terraform
lags here). **Verified live:** `gateway_invoke_test.py` (SigV4 MCP
initialize→tools/list→tools/call) returned the full de-identified evidence package
for `CUST_000123` through the Gateway; the tool shows up namespaced as
`rag___get_customer_activity_summary`. Gotchas handled: the Gateway `inputSchema`
is a restricted JSON-Schema subset (strip `default`/unknown keys); MCP responses
can be SSE; tool names are target-prefixed. Gateway tagged `Owner="conan hncb demo"`;
`teardown.sh` deletes it (not in Terraform state). MCP URL is printed by the setup
script. *Note:* `.venv-t4/` (gitignored) holds boto3 for the invoke test.
- **T5 — Agent runtime** ✅ **done + live-verified** (`agent/agent.py`,
`agent/requirements.txt`, `scripts/agentcore_setup.sh` T5 section). Pinned deps
(bedrock-agentcore 1.16.0 / strands-agents 1.45.0 / mcp 1.28.1 / boto3 1.43.38),
`agentcore configure --non-interactive` + `agentcore launch` (cloud CodeBuild,
auto-created execution role + ECR + STM memory). **Runtime ARN:**
`arn:aws:bedrock-agentcore:ap-southeast-1:286171702468:runtime/hncb_advisor_agent-duWZOT5Far`.
**Verified live:** `agentcore invoke {"prompt": "...CUST_000123..."}` returns
numbered talking points built only from the de-identified evidence — no name, no
leaked token. Three gotchas fixed (all captured in code/scripts):
1. In `ap-southeast-1` the model is **INFERENCE_PROFILE-only** → use
`apac.anthropic.claude-3-5-sonnet-20241022-v2:0`, not the raw id.
2. Our Gateway is **AWS_IAM**, so `agent.py` SigV4-signs MCP requests (httpx.Auth)
instead of using a bearer token.
3. The auto-created runtime **execution role can't call the Gateway** by default →
must attach `bedrock-agentcore:InvokeGateway` on the gateway ARN (the launch
step in `agentcore_setup.sh` shows the exact command). Runtime + memory added to
`teardown.sh`; agent logic was de-risked locally before launch.
- **T6 — UI wiring** ✅ **done + live-verified** (`ui/index.html`, `terraform/ui.tf`,
`gateway_api/orchestrator/`). Hosted on **S3 + CloudFront** (private bucket, OAC).
For the Fusion-less dry run I added a thin **demo orchestrator** (`POST /demo`, a
Lambda on the same API) that runs the full chain server-side —
`/tokenize` → SigV4 `InvokeAgentRuntime``/restore` — and returns
`{final, deidentified_prompt, agent_tokenized}` (this stands in for Fusion ONLY
for the dry run; we still never build/host Fusion). `index.html` reads the live
endpoint from a generated `config.js` (so no ephemeral URL is committed) and
renders the split view. CORS enabled on the API. **UI:**
`https://d3n89cj9w7ynf0.cloudfront.net`. **Verified live:** CloudFront serves
`index.html` + `config.js` (200); `/demo` returns `王小明``CUST_*` on the cloud
side and `王小明`-restored talking points on the advisor side. Only the browser
render itself is left as a human eyeball check. All resources tagged.
- **T7 — E2E rehearsal** ✅ **done + live-verified** (README demo/deploy sections
rewritten with the real commands). Ran the full round trip via `/demo`
(`王小明``CUST_317499` out, agent talking points on tokens only, `王小明` restored).
**Money shot proven:** in the cloud runtime CloudWatch trace for that session the
token appears in **13 events** while `王小明` and `A123456789` appear in **0**
the Bedrock/AgentCore trace is token-only. **Teardown verified (non-destructively):**
`terraform destroy` targets 43 resources (Fargate, CloudFront, S3, DynamoDB, all
Lambdas, API GW, VPC endpoint, SGs, IAM) and `teardown.sh` additionally deletes the
Gateway/targets, Runtime, Memory, and both ECR repos — i.e. every billed resource.
Residual is free-only: the hand-made default VPC, the AgentCore SDK exec role, an
idle CodeBuild project. The actual `teardown.sh` run is left for the human to fire
after recording (didn't auto-destroy the live demo).
## Known caveats (don't "fix" these silently) ## Known caveats (don't "fix" these silently)
- zh-TW detection is demo-narrow (tuned to the scripted entities), not production recall. - zh-TW detection is demo-narrow (tuned to the scripted entities), not production recall.

View File

@@ -62,30 +62,53 @@ fusion/ POLICY_SETUP.md (SaaS console config — the manual part)
``` ```
## Prerequisites ## Prerequisites
`aws-cli` configured (creds + region), `terraform >= 1.5`, `docker`, `python3`, `aws-cli` (creds + region — this demo runs in `ap-southeast-1`), `terraform >= 1.5`,
the AgentCore CLI (`npm i -g @aws/agentcore`), Bedrock model access enabled for `podman` or `docker` (Presidio image), `python3` + a venv with `boto3`,
`bedrock_model_id`, and access to the shared **Fusion SaaS** instance. `bedrock-agentcore-starter-toolkit` (`pip install`, provides `agentcore`), Bedrock
access for the **apac** Claude 3.5 Sonnet v2 *inference profile*
(`apac.anthropic.claude-3-5-sonnet-20241022-v2:0` — the raw id is not on-demand in
this region), and — for the production path — the shared **Fusion SaaS** instance.
## Deploy ## Deploy
```bash The live stack was brought up in this order (authoritative runbook: the task notes
bash scripts/deploy.sh # infra -> presidio image -> ECS -> seed -> AgentCore in `CLAUDE.md`, which record the exact sequencing and gotchas):
# then: build /tokenize + /restore (gateway_api/, tasks T1/T2) 1. `terraform -chdir=terraform apply` core infra (DynamoDB, RAG + tokenize/restore/
# then: configure the shared Fusion SaaS instance (fusion/POLICY_SETUP.md) orchestrator Lambdas, API Gateway, Presidio on Fargate, UI on S3+CloudFront).
# then: point ui/index.html GATEWAY_URL at the Fusion SaaS entrypoint and open it Presidio needs its image in ECR and `presidio_url` set to the task's **private** IP.
``` 2. Seed the synthetic customer + the demo vault token (`seed/seed.py`).
3. `scripts/agentcore_setup.sh` → MCP Gateway + RAG tool target.
4. `agentcore configure` + `agentcore launch` → Runtime ARN; then grant the runtime
execution role `bedrock-agentcore:InvokeGateway` (command in `agentcore_setup.sh`).
5. Put the Runtime ARN in `terraform/local.auto.tfvars` (`agent_runtime_arn`) and
re-apply so `/demo` can invoke it.
## Demo script (maps to the 8 steps) ## Demo script (Fusion-less dry run — maps to the 8 steps)
1. Advisor UI: submit *"請幫我整理王小明最近三個月的理財往來,並給我下次拜訪話術。"* ```bash
2. Fusion (via `/tokenize`) detects `王小明` + `A123456789`, tokenizes, logs tokens only. cd terraform && DEMO=$(terraform output -raw demo_url) && UI=$(terraform output -raw ui_url); cd ..
3. Show the Bedrock/AgentCore trace — the prompt the cloud saw contains `CUST_000123`.
4-6. Agent tool-calls back on-prem; RAG resolves the token, returns a summary. # 1-2, 4-8: advisor query -> tokenize -> agent (on tokens) -> restore, in one call:
7. Agent writes talking points (no PII). curl -s -X POST "$DEMO" -H 'content-type: application/json' \
8. Fusion (via `/restore`) restores `王小明`; the UI shows restored beside tokenized. -d '{"query":"請幫我整理王小明最近三個月的理財往來,並給我下次拜訪話術。"}' | python3 -m json.tool
# -> deidentified_prompt: "...CUST_xxxxxx..." (what left for the cloud)
# agent_tokenized: talking points, tokens only
# final: "(客戶:王小明)..." (identity restored on-prem)
# 3. money shot — the cloud runtime trace only ever shows the token:
LG=/aws/bedrock-agentcore/runtimes/<runtime-id>-DEFAULT
aws logs filter-log-events --log-group-name "$LG" --filter-pattern '"王小明"' --query 'length(events)' # 0
aws logs filter-log-events --log-group-name "$LG" --filter-pattern '"CUST_"' --query 'length(events)' # >0
# 8 (visual): open the split-view UI and submit the same query
echo "$UI"
```
## Teardown ## Teardown
```bash ```bash
bash scripts/teardown.sh # stop paying for Fargate / AgentCore bash scripts/teardown.sh # deletes Gateway/Runtime/Memory + `terraform destroy` + ECR repos
``` ```
Leftover-but-free after teardown: the hand-made default VPC, the
`AmazonBedrockAgentCoreSDKRuntime-*` role, and a CodeBuild project (delete by hand
if you want it spotless).
## Honest caveats ## Honest caveats
- **zh-TW detection is demo-narrow** — tuned to the scripted entities, not - **zh-TW detection is demo-narrow** — tuned to the scripted entities, not

View File

@@ -6,10 +6,19 @@ CUST_000123, never a name), reasons with a Bedrock model, calls the RAG tool
through AgentCore Gateway (MCP), and returns de-identified talking points. through AgentCore Gateway (MCP), and returns de-identified talking points.
Fusion restores the real identity on the way back out -- not this agent. Fusion restores the real identity on the way back out -- not this agent.
Framework: Strands. Deploy target: AgentCore Runtime (see scripts/agentcore_setup.py). Framework: Strands. Deploy target: AgentCore Runtime (see scripts/agentcore_setup.sh).
Pin versions in requirements.txt; SDK surfaces move quickly. Pin versions in requirements.txt; SDK surfaces move quickly.
Auth to the Gateway: our Gateway uses AWS_IAM, so every MCP request is SigV4-signed
with the Runtime's execution-role credentials (service `bedrock-agentcore`). If a
GATEWAY_TOKEN is provided instead, we fall back to bearer auth (CUSTOM_JWT gateways).
""" """
import os import os
import httpx
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
from botocore.session import Session
from bedrock_agentcore.runtime import BedrockAgentCoreApp from bedrock_agentcore.runtime import BedrockAgentCoreApp
from strands import Agent from strands import Agent
from strands.models import BedrockModel from strands.models import BedrockModel
@@ -18,9 +27,12 @@ from mcp.client.streamable_http import streamablehttp_client
app = BedrockAgentCoreApp() app = BedrockAgentCoreApp()
GATEWAY_URL = os.environ["AGENTCORE_GATEWAY_URL"] # set by agentcore_setup.py GATEWAY_URL = os.environ["AGENTCORE_GATEWAY_URL"] # set by agentcore_setup.sh
GATEWAY_TOKEN = os.environ.get("AGENTCORE_GATEWAY_TOKEN", "") GATEWAY_TOKEN = os.environ.get("AGENTCORE_GATEWAY_TOKEN", "")
MODEL_ID = os.environ.get("BEDROCK_MODEL_ID", "anthropic.claude-3-5-sonnet-20241022-v2:0") REGION = os.environ.get("AWS_REGION", "ap-southeast-1")
# In non-US regions Claude 3.5 Sonnet v2 is INFERENCE_PROFILE-only, so default to
# the APAC cross-region profile. Override with BEDROCK_MODEL_ID at launch.
MODEL_ID = os.environ.get("BEDROCK_MODEL_ID", "apac.anthropic.claude-3-5-sonnet-20241022-v2:0")
SYSTEM_PROMPT = ( SYSTEM_PROMPT = (
"You are a financial-advisor assistant. You will be given a customer reference " "You are a financial-advisor assistant. You will be given a customer reference "
@@ -32,9 +44,34 @@ SYSTEM_PROMPT = (
) )
class _SigV4Auth(httpx.Auth):
"""SigV4-sign each MCP request with the Runtime's execution-role credentials."""
requires_request_body = True
def __init__(self, service, region):
self._creds = Session().get_credentials()
self._service = service
self._region = region
def auth_flow(self, request):
aws_req = AWSRequest(
method=request.method,
url=str(request.url),
data=request.content,
headers=dict(request.headers),
)
SigV4Auth(self._creds, self._service, self._region).add_auth(aws_req)
request.headers.update(dict(aws_req.headers))
yield request
def _mcp_client(): def _mcp_client():
headers = {"Authorization": f"Bearer {GATEWAY_TOKEN}"} if GATEWAY_TOKEN else {} if GATEWAY_TOKEN:
headers = {"Authorization": f"Bearer {GATEWAY_TOKEN}"}
return MCPClient(lambda: streamablehttp_client(GATEWAY_URL, headers=headers)) return MCPClient(lambda: streamablehttp_client(GATEWAY_URL, headers=headers))
auth = _SigV4Auth("bedrock-agentcore", REGION)
return MCPClient(lambda: streamablehttp_client(GATEWAY_URL, auth=auth))
@app.entrypoint @app.entrypoint

View File

@@ -1,4 +1,6 @@
bedrock-agentcore # Pinned before `agentcore launch` (versions resolved 2026-07-01). These SDKs move
strands-agents # fast; re-resolve and re-pin if you rebuild.
mcp bedrock-agentcore==1.16.0
boto3 strands-agents==1.45.0
mcp==1.28.1
boto3==1.43.38

192
docs/architecture.md Normal file
View File

@@ -0,0 +1,192 @@
# Architecture & data flow
Reversible PII de-identification round trip for HNCB. An advisor query with a real
name is tokenized before it leaves for the cloud; the AgentCore agent reasons on
tokens only; identity is restored on-prem before the advisor sees the answer.
Everything runs in **one AWS account** (`ap-southeast-1`); the trust zones are
**logical**, represented by tags (`Zone=on-prem-VPC-A` vs `Zone=cloud-VPC-B`) — the
demo proves data-flow behaviour, not physical residency.
> **Core invariant:** cloud-zone components (AgentCore Runtime, Gateway, Bedrock)
> never receive the real name. Proven live — for one session the token `CUST_*`
> appeared in 13 CloudWatch trace events while `王小明` / `A123456789` appeared in 0.
## Component reference
| # | Component | Repo | AWS resource | Zone | Sees real PII? |
|---|---|---|---|---|---|
| UI | Advisor split-view UI | `ui/` | S3 + CloudFront | cloud (host) | shows restored answer to the advisor |
| entry | Orchestration | `fusion/` (prod) · `gateway_api/orchestrator/` (dry run) | Fusion SaaS · `/demo` Lambda | — | ✅ owns the flow |
| 2 | `/tokenize` | `gateway_api/tokenize/` | Lambda (in-VPC) + API Gateway | on-prem | ✅ mints the map |
| 2 | PII detector | `presidio/` | Fargate (private, SG-locked `:5001`) | on-prem | typed findings only |
| 2,4,8 | Token vault | `terraform/main.tf` | DynamoDB `…-vault` | on-prem | ✅ reversible map |
| 4-6 | Customer records | `seed/` | DynamoDB `…-customers` | on-prem | ✅ never leave zone |
| 3,7 | Cloud agent + model | `agent/` | AgentCore Runtime + Bedrock (apac Claude 3.5 Sonnet v2) | **cloud** | ❌ tokens only |
| 4 | Tool bridge | `scripts/agentcore_setup.sh` | AgentCore Gateway (MCP, AWS_IAM) | **cloud** | ❌ tokens only |
| 4-6 | RAG tool | `lambda_rag/` | Lambda | on-prem | ✅ resolves token, returns none |
| 8 | `/restore` | `gateway_api/restore/` | Lambda + API Gateway | on-prem | ✅ re-attaches identity |
## Flow diagram
```mermaid
flowchart TB
advisor(["Advisor (browser)"])
ui["Advisor UI<br/>S3 + CloudFront<br/>(split view)"]
subgraph entry["Entry / orchestration"]
fusion["Fusion SaaS (prod)<br/>— or —<br/>/demo Lambda (Fusion-less dry run)"]
end
subgraph cloudzone["CLOUD zone (Zone=cloud-VPC-B) — sees TOKENS only"]
runtime["AgentCore Runtime<br/>Strands agent + Bedrock<br/>apac Claude 3.5 Sonnet v2"]
gateway["AgentCore Gateway<br/>MCP · AWS_IAM / SigV4"]
end
subgraph onprem["ON-PREM zone (Zone=on-prem-VPC-A) — holds the reversible map + PII"]
tokenize["/tokenize Lambda (in-VPC)<br/>detect → mint → vault → splice"]
presidio["Presidio detector<br/>Fargate (PRIVATE, SG-locked :5001)"]
vault[("DynamoDB VAULT<br/>token &lt;-&gt; PII (+customer_id)")]
customers[("DynamoDB CUSTOMERS<br/>raw records — never leave")]
rag["RAG tool Lambda<br/>token -> evidence (no PII out)"]
restore["/restore Lambda<br/>re-attach identity"]
end
advisor -- "1 query: 王小明 + A123456789" --> ui
ui -- "POST {query}" --> fusion
fusion -- "2 {query}" --> tokenize
tokenize -- "POST /analyze" --> presidio
presidio -- "typed findings" --> tokenize
tokenize -- "write {token,type,value,session}" --> vault
tokenize -- "3 deidentified_prompt (CUST_*)" --> runtime
runtime -- "4 tools/call get_customer_activity_summary(CUST_*)" --> gateway
gateway -- "5 invoke (SigV4)" --> rag
rag -- "6 resolve token" --> vault
rag -- "read record" --> customers
rag -- "7 evidence package (token-keyed, no PII)" --> runtime
runtime -- "talking points (tokens only)" --> fusion
fusion -- "8 {session_id, text}" --> restore
restore -- "lookup session tokens" --> vault
restore -- "final: (客戶:王小明)…" --> fusion
fusion -- "{final, deidentified_prompt, agent_tokenized}" --> ui
ui -- "split view: restored vs tokenized" --> advisor
```
## Sequence (the 8-step round trip)
```mermaid
sequenceDiagram
autonumber
actor A as Advisor
participant UI as UI (S3/CloudFront)
participant F as Fusion or demo
participant TK as tokenize (on-prem)
participant PR as Presidio (private)
participant V as Vault (DynamoDB)
participant RT as AgentCore Runtime + Bedrock (cloud)
participant GW as AgentCore Gateway (cloud)
participant RG as RAG Lambda (on-prem)
participant RS as restore (on-prem)
A->>UI: query with 王小明 + A123456789
UI->>F: POST {query}
F->>TK: {query}
TK->>PR: POST /analyze
PR-->>TK: typed findings
TK->>V: write token to PII map (session)
TK-->>F: deidentified_prompt (CUST_*)
F->>RT: {prompt: CUST_*}
Note over RT,GW: cloud sees TOKENS only
RT->>GW: tools/call get_customer_activity_summary(CUST_*)
GW->>RG: invoke (SigV4)
RG->>V: resolve token to customer_id
RG-->>RT: de-identified evidence package
RT-->>F: talking points (tokens only)
F->>RS: {session_id, text}
RS->>V: lookup session tokens
RS-->>F: final (王小明 restored)
F-->>UI: {final, deidentified_prompt, agent_tokenized}
UI-->>A: split view (restored vs tokenized)
```
## ASCII fallback
```
┌──────────────────────────────────────────────┐
Advisor │ "請問客戶 王小明 (A123456789) 最近三個月…" │ ← REAL PII
(browser) └──────────────────────────────────────────────┘
│ │
│ 1 │
▼ ▼
┌─────────────────┐ ┌───────────────────────────┐
│ Advisor UI │ │ Entry / orchestration │
│ S3 + CloudFront │──POST─────▶│ • Fusion SaaS (prod) │ stands in for Fusion
│ (split view) │ {query} │ • /demo Lambda (dry run) │ only, in the demo
└─────────────────┘◀───────────│ tokenize→agent→restore │
▲ {final, └───────────┬───────────────┘
│ deidentified, │
│ agent_tokenized} │ 2 {query}
════════╪════════════════════════════════════╪════════ TRUST BOUNDARY (public HTTPS + x-api-key)
ON-PREM│ (Zone=on-prem-VPC-A) ▼
│ ┌───────────────────────┐ findings ┌────────────────────┐
│ │ /tokenize Lambda │────────────▶│ Presidio detector │
│ │ (in VPC) │◀────────────│ Fargate (PRIVATE, │
│ │ detect→mint→vault→splice│ │ SG-locked :5001) │
│ └───────┬───────────────┘ └────────────────────┘
│ │ write {token,type,value,session}
│ ▼
│ ┌───────────────────────┐ (VPC gateway endpoint)
│ │ DynamoDB VAULT │◀────────────────┐
│ │ token ⇄ PII (+cust_id) │ │
│ └───────────────────────┘ │
│ deidentified_prompt = "…CUST_317499…" ← TOKENS ONLY │
════════╪═════════════════════════════════│═════════════════════════════════╪══════════════
CLOUD │ (Zone=cloud-VPC-B) │ 3 │ resolve token
│ ▼ │
│ ┌────────────────────────┐ │
│ │ AgentCore RUNTIME │ │
│ │ Strands agent + Bedrock│ │
│ │ (apac Claude 3.5 Sonnet)│ │
│ └───────────┬────────────┘ │
│ │ 4 tools/call (MCP, SigV4) │
│ ▼ get_customer_activity_summary │
│ ┌────────────────────────┐ │
│ │ AgentCore GATEWAY (MCP) │ │
│ │ AWS_IAM auth │ │
│ └───────────┬────────────┘ │
│ ← trace shows ONLY tokens │ 5 invoke │
════════╪══(王小明: 0 hits, CUST_: 13)═══│═════════════════════════════════════╪══════════════
ON-PREM│ ▼ │
│ ┌────────────────────────┐ 6 reads │
│ │ RAG tool Lambda │─────────────────────┘
│ │ token→customer_id→ │ ┌────────────────────┐
│ │ de-identified evidence │─────▶│ DynamoDB CUSTOMERS │ ← raw PII
│ │ (raw record stays here) │ read │ (never leaves zone)│ stays put
│ └───────────┬────────────┘ └────────────────────┘
│ │ 7 evidence package (token-keyed, no PII)
│ ▼ → agent writes talking points (tokens only)
│ ┌────────────────────────┐
│ 8 {session_id, │ /restore Lambda │
└───────────────────▶│ vault lookup by session │ prepend 王小明 + swap inline tokens
final = "(客戶:王小明)…" │ re-attach identity │
← PII restored on-prem └────────────────────────┘
```
## Auth & network per hop
| Hop | Transport | Auth |
|---|---|---|
| UI → entry | public HTTPS | `/demo` (dry run) or Fusion SaaS (prod) |
| entry → `/tokenize`, `/restore` | public API Gateway (HTTPS) | shared secret (`x-api-key` / bearer, checked in-handler) |
| `/tokenize` → Presidio | private (in-VPC) | Presidio SG allows only the tokenize Lambda's SG |
| `/tokenize`,`/restore`,RAG → DynamoDB | AWS API | IAM (tokenize reaches it via a VPC gateway endpoint) |
| Runtime → Gateway → RAG | MCP over HTTPS | SigV4 (`AWS_IAM`); runtime role has `bedrock-agentcore:InvokeGateway`, gateway role has `lambda:InvokeFunction` |
Notes:
- The **UI is cloud-hosted** but only ever displays the *restored* answer to the
authorized advisor; the cloud *reasoning* path (Runtime/Gateway/Bedrock) is the
part that never sees PII.
- **Fusion is shared SaaS** and is never built or hosted here. The `/demo`
orchestrator only stands in for Fusion's orchestration in the Fusion-less dry run.
- Tokens are **random per request** (`CUST_<rand>` for a PERSON, `TW_<rand>` for a
Taiwan ROC ID); the reversible map lives only in the on-prem vault.

View File

@@ -8,10 +8,18 @@ directly. Instead it calls two **public HTTPS endpoints** this repo exposes
writes, and restore on the AWS side. Fusion owns the orchestration and routing. writes, and restore on the AWS side. Fusion owns the orchestration and routing.
## Endpoints Fusion calls ## Endpoints Fusion calls
- `TOKENIZE_URL` = `https://<...>/tokenize` (ingress: detect + mint + vault-write + splice) - `TOKENIZE_URL` = `https://<api-id>.execute-api.<region>.amazonaws.com/tokenize`
- `RESTORE_URL` = `https://<...>/restore` (egress: vault lookup + re-attach identity) (ingress: detect + mint + vault-write + splice). Get the live value with
`terraform -chdir=terraform output -raw tokenize_url`.
- `RESTORE_URL` = `https://<...>/restore` (egress: vault lookup + re-attach identity — T2)
- `AGENT_RUNTIME_ARN` (or its HTTPS invoke endpoint) = printed by `scripts/agentcore_setup.sh` - `AGENT_RUNTIME_ARN` (or its HTTPS invoke endpoint) = printed by `scripts/agentcore_setup.sh`
- Secure all three with an API key / OAuth from the Fusion outbound config. - **Auth (T1, live):** the endpoint is a public API Gateway HTTP API; it requires a
shared secret in the **`x-api-key`** header (a `Authorization: Bearer <secret>`
header also works). Configure Fusion's outbound request to send it. The secret is
the Terraform `tokenize_api_key` var (kept in gitignored `terraform/local.auto.tfvars`,
never committed) — hand it to the Fusion console operator out of band.
- *Note:* a Lambda Function URL was the first choice, but this account's SCP blocks
unauthenticated Function URLs, so the public front door is API Gateway instead.
## Ingress policy (advisor request → cloud) ## Ingress policy (advisor request → cloud)
1. **Authenticate** the advisor; apply the RBAC / business-purpose check. 1. **Authenticate** the advisor; apply the RBAC / business-purpose check.
@@ -27,10 +35,17 @@ writes, and restore on the AWS side. Fusion owns the orchestration and routing.
3. Return `{ "final": ..., "deidentified_prompt": ... }` so the UI shows the split view. 3. Return `{ "final": ..., "deidentified_prompt": ... }` so the UI shows the split view.
## Vault item shape (DynamoDB, written by /tokenize) ## Vault item shape (DynamoDB, written by /tokenize)
One row per detected entity. `value` is the original PII (so `/restore` can put it
back); a resolvable PERSON also gets a `customer_id` so the RAG tool can turn the
token into a de-identified evidence package.
``` ```
{ "token": "CUST_000123", "type": "CUSTOMER", "value": "cust-0001", { "token": "CUST_863651", "type": "PERSON", "value": "王小明",
"session_id": "<conv id>", "expires_at": <epoch+ttl>, "customer_id": "cust-0001" }
{ "token": "TW_683250", "type": "TW_ROC_ID", "value": "A123456789",
"session_id": "<conv id>", "expires_at": <epoch+ttl> } "session_id": "<conv id>", "expires_at": <epoch+ttl> }
``` ```
(The seed's `--with-demo-token` writes a different `type=CUSTOMER, value=cust-0001`
row; that's only a standalone RAG test aid, not what `/tokenize` mints.)
## Why this split ## Why this split
Keeping detection, minting, and the vault behind `/tokenize` and `/restore` means Keeping detection, minting, and the vault behind `/tokenize` and `/restore` means

View File

@@ -0,0 +1,100 @@
"""
Demo orchestrator (T6) -- Fusion-less dry-run entrypoint for the UI.
Fusion is shared SaaS and configured by a human, so for a self-contained demo this
tiny endpoint stands in for Fusion's orchestration ONLY: it runs the full round trip
server-side so the static UI has one URL to call. In production Fusion does this; we
never build/host Fusion itself.
Chain (mirrors the 8 steps):
{query}
-> POST /tokenize -> {deidentified_prompt, session_id} (identity leaves as tokens)
-> InvokeAgentRuntime -> token-only talking points (cloud sees tokens only)
-> POST /restore -> {final} (identity restored on-prem)
return {final, deidentified_prompt, agent_tokenized, session_id}
The UI renders `final` (advisor view) vs `deidentified_prompt`+`agent_tokenized`
(what the cloud actually saw). Stdlib + botocore (bundled in Lambda) only.
"""
import json
import os
import secrets
import urllib.parse
import urllib.request
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
from botocore.session import Session
REGION = os.environ["REGION"]
TOKENIZE_URL = os.environ["TOKENIZE_URL"]
RESTORE_URL = os.environ["RESTORE_URL"]
API_KEY = os.environ.get("TOKENIZE_API_KEY", "")
RUNTIME_ARN = os.environ.get("AGENT_RUNTIME_ARN", "")
_creds = Session().get_credentials()
def _post_json(url, obj):
req = urllib.request.Request(
url, data=json.dumps(obj).encode(),
headers={"Content-Type": "application/json", "x-api-key": API_KEY}, method="POST",
)
with urllib.request.urlopen(req, timeout=50) as r:
return json.loads(r.read().decode())
def _invoke_runtime(prompt):
"""SigV4-signed InvokeAgentRuntime data-plane call; returns the agent's text."""
url = (f"https://bedrock-agentcore.{REGION}.amazonaws.com"
f"/runtimes/{urllib.parse.quote(RUNTIME_ARN, safe='')}/invocations?qualifier=DEFAULT")
body = json.dumps({"prompt": prompt}).encode()
headers = {
"Content-Type": "application/json",
"X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": secrets.token_hex(20),
}
aws_req = AWSRequest(method="POST", url=url, data=body, headers=headers)
SigV4Auth(_creds, "bedrock-agentcore", REGION).add_auth(aws_req)
req = urllib.request.Request(url, data=body, headers=dict(aws_req.headers), method="POST")
with urllib.request.urlopen(req, timeout=120) as r:
raw = r.read().decode()
try:
obj = json.loads(raw)
return obj.get("result", raw) if isinstance(obj, dict) else raw
except json.JSONDecodeError:
return raw
def _reply(status, obj):
return {
"statusCode": status,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(obj, ensure_ascii=False),
}
def lambda_handler(event, _context):
try:
payload = json.loads(event.get("body") or "{}") if "body" in event else event
query = (payload.get("query") or "").strip()
if not query:
return _reply(400, {"error": "query is required"})
tok = _post_json(TOKENIZE_URL, {"query": query})
deid = tok["deidentified_prompt"]
session_id = tok["session_id"]
# Cloud reasoning on tokens only (skip gracefully if no runtime configured).
agent_tokenized = _invoke_runtime(deid) if RUNTIME_ARN else deid
# Restore identity into the agent's answer (envelope + inline).
res = _post_json(RESTORE_URL, {"session_id": session_id, "text": agent_tokenized})
return _reply(200, {
"final": res["final"],
"deidentified_prompt": deid,
"agent_tokenized": agent_tokenized,
"session_id": session_id,
})
except Exception as e:
return _reply(502, {"error": f"{type(e).__name__}: {e}"})

View File

@@ -0,0 +1,98 @@
"""
/restore endpoint (T2) -- egress re-identification for the HNCB demo (step 8).
Fusion SaaS POSTs the agent's token-only answer back here with the session_id.
We:
1. read THIS session's tokens from the on-prem vault (scoped by session_id, so
one session can never restore another's identities)
2. swap any inline tokens in the text back to the original PII
3. re-attach identity at the envelope level too: prepend the real customer name,
so the advisor sees who the answer is about even if no token appears inline
4. return {final} -- the only step where a real name comes back into view
The reversible map lives only in the vault (on-prem zone); the cloud never saw it.
Runs as a Lambda behind the same public API Gateway as /tokenize, guarded by the
same shared secret. Stdlib + boto3 only, per repo conventions.
"""
import base64
import hmac
import json
import os
import boto3
from boto3.dynamodb.conditions import Attr
ddb = boto3.resource("dynamodb")
VAULT = ddb.Table(os.environ["VAULT_TABLE"])
# Shared secret Fusion presents (x-api-key / bearer). Unset -> allow (dev only).
API_KEY = os.environ.get("TOKENIZE_API_KEY")
def _authorized(headers):
if not API_KEY:
return True
presented = headers.get("x-api-key") or ""
if not presented:
auth = headers.get("authorization") or ""
if auth.lower().startswith("bearer "):
presented = auth[7:]
return bool(presented) and hmac.compare_digest(presented, API_KEY)
def _parse(event):
if "body" in event and not isinstance(event.get("body"), dict):
raw = event.get("body") or "{}"
if event.get("isBase64Encoded"):
raw = base64.b64decode(raw).decode("utf-8")
payload = json.loads(raw) if raw else {}
headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
return payload, headers
return event, {}
def _reply(status, obj):
return {
"statusCode": status,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(obj, ensure_ascii=False),
}
def _session_tokens(session_id):
# Demo-grade: scan the vault filtered by session_id (small table, no GSI).
# A production vault would query a session_id index instead.
items, kwargs = [], {"FilterExpression": Attr("session_id").eq(session_id)}
while True:
res = VAULT.scan(**kwargs)
items.extend(res.get("Items", []))
lek = res.get("LastEvaluatedKey")
if not lek:
return items
kwargs["ExclusiveStartKey"] = lek
def lambda_handler(event, _context):
payload, headers = _parse(event)
if not _authorized(headers):
return _reply(401, {"error": "unauthorized"})
session_id = (payload.get("session_id") or "").strip()
text = payload.get("text")
if not session_id or text is None:
return _reply(400, {"error": "session_id and text are required"})
tokens = _session_tokens(session_id)
# Inline: swap every token for this session back to its original value.
# Longest token first so no token is a prefix of another mid-replace.
restored = text
for it in sorted(tokens, key=lambda t: len(t["token"]), reverse=True):
restored = restored.replace(it["token"], it["value"])
# Envelope: prepend the customer's real name (the PERSON mapping), if any.
name = next((it["value"] for it in tokens if it.get("type") == "PERSON"), None)
final = f"(客戶:{name}\n{restored}" if name else restored
return _reply(200, {"final": final, "restored": len(tokens)})

View File

@@ -0,0 +1,187 @@
"""
/tokenize endpoint (T1) -- ingress de-identification for the HNCB demo (step 2).
Fusion SaaS POSTs the advisor's raw query here. We:
1. send the text to the Presidio detector (/analyze) -> typed findings
2. mint a RANDOM token per finding (CUST_<rand> for a PERSON, TW_<rand> for a
Taiwan ROC ID) -- detection != redaction: the detector never sees a token,
WE own minting + the reversible map
3. write each mapping into the on-prem vault, keyed by a fresh session_id
4. splice the tokens back into the text (right-to-left, so offsets stay valid)
5. return {deidentified_prompt, session_id} -- the only thing that leaves for the cloud
Vault row shape (kept compatible with lambda_rag/handler.py):
{token, type, value, session_id, expires_at [, customer_id]}
- `value` is the ORIGINAL pii string -> lets /restore (T2) put identity back
- `customer_id` is added for a PERSON we can resolve, so the RAG tool (T4)
can turn a token into a de-identified evidence package. The RAG handler reads
entry.get("customer_id") for non-"CUSTOMER" types, so this Just Works.
Runs as a Lambda behind a Function URL (public HTTPS). Auth is a shared secret
(Fusion sends it as a bearer token / x-api-key); see TOKENIZE_API_KEY below.
Stdlib + boto3 only, per repo conventions.
"""
import base64
import hmac
import json
import os
import secrets
import time
import urllib.request
import uuid
import boto3
ddb = boto3.resource("dynamodb")
VAULT = ddb.Table(os.environ["VAULT_TABLE"])
# Optional: resolving PERSON -> customer_id makes the vault row usable by the RAG
# tool. If the customers table isn't configured we still tokenize (best effort).
_CUSTOMERS_TABLE = os.environ.get("CUSTOMERS_TABLE")
CUSTOMERS = ddb.Table(_CUSTOMERS_TABLE) if _CUSTOMERS_TABLE else None
PRESIDIO_URL = os.environ.get("PRESIDIO_URL", "http://localhost:5001").rstrip("/")
TTL_SECONDS = int(os.environ.get("VAULT_TTL_SECONDS", "3600"))
# Shared secret Fusion presents. If unset we allow all callers (local dev only)
# and say so in the response so it's obvious this isn't locked down.
API_KEY = os.environ.get("TOKENIZE_API_KEY")
# entity type -> token prefix. Only these are tokenized in the demo.
_PREFIX = {"PERSON": "CUST", "TW_ROC_ID": "TW"}
# When Presidio returns overlapping spans (e.g. the ROC ID digits flagged as BOTH
# TW_ROC_ID and PERSON), the more specific entity wins. Higher = kept.
_PRIORITY = {"TW_ROC_ID": 2, "PERSON": 1}
def _resolve_overlaps(findings):
# Keep only demo-scoped entities, then greedily drop any finding that overlaps
# one we've already kept. Best-first order = specific entity, then score, then
# longer span -- so the ROC ID stays TW_ROC_ID and the spurious PERSON on the
# same digits is discarded (no double-tokenising, no corrupted splice).
cand = [f for f in findings if f["entity_type"] in _PREFIX]
cand.sort(
key=lambda f: (_PRIORITY.get(f["entity_type"], 0), f["score"], f["end"] - f["start"]),
reverse=True,
)
kept = []
for f in cand:
if any(f["start"] < k["end"] and f["end"] > k["start"] for k in kept):
continue # overlaps a higher-priority finding already kept
kept.append(f)
return kept
def _mint(prefix):
# Random per request -> the same query yields different tokens every call.
# 6 digits mirrors the seeded demo token (CUST_000123) for a familiar look.
return f"{prefix}_{secrets.randbelow(1_000_000):06d}"
def _analyze(text, language="zh"):
body = json.dumps({"text": text, "language": language}).encode("utf-8")
req = urllib.request.Request(
f"{PRESIDIO_URL}/analyze",
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8"))
def _resolve_customer_id(name):
# Demo-grade name -> customer_id resolution (one seeded customer, so a scan is
# fine). Best effort: if it fails we still tokenize, just without a RAG link.
if not CUSTOMERS:
return None
try:
from boto3.dynamodb.conditions import Attr
res = CUSTOMERS.scan(FilterExpression=Attr("name").eq(name), Limit=1)
items = res.get("Items", [])
return items[0]["customer_id"] if items else None
except Exception:
return None
def _authorized(headers):
if not API_KEY:
return True # local dev / unset -> allow (flagged in the response)
presented = headers.get("x-api-key") or ""
if not presented:
auth = headers.get("authorization") or ""
if auth.lower().startswith("bearer "):
presented = auth[7:]
return bool(presented) and hmac.compare_digest(presented, API_KEY)
def _parse(event):
"""Accept a Lambda Function URL / API Gateway event, or a raw dict (tests)."""
if "body" in event and not isinstance(event.get("body"), dict):
raw = event.get("body") or "{}"
if event.get("isBase64Encoded"):
raw = base64.b64decode(raw).decode("utf-8")
payload = json.loads(raw) if raw else {}
headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
return payload, headers
# Direct invoke: the event itself is the payload; no HTTP headers.
return event, {}
def _reply(status, obj):
return {
"statusCode": status,
"headers": {"Content-Type": "application/json"},
"body": json.dumps(obj, ensure_ascii=False),
}
def lambda_handler(event, _context):
payload, headers = _parse(event)
if not _authorized(headers):
return _reply(401, {"error": "unauthorized"})
query = (payload.get("query") or "").strip()
if not query:
return _reply(400, {"error": "query is required"})
language = payload.get("language", "zh")
try:
findings = _analyze(query, language)
except Exception as e: # detector unreachable -> fail closed, never leak raw text
return _reply(502, {"error": f"detector unavailable: {e}"})
session_id = uuid.uuid4().hex
expires_at = int(time.time()) + TTL_SECONDS
# Drop overlapping/duplicate spans, then tokenize right-to-left so earlier
# offsets stay valid as we splice.
findings = _resolve_overlaps(findings)
findings.sort(key=lambda f: f["start"], reverse=True)
text = query
minted = []
for f in findings:
prefix = _PREFIX[f["entity_type"]] # guaranteed present by _resolve_overlaps
original = f["text"]
token = _mint(prefix)
item = {
"token": token,
"type": f["entity_type"],
"value": original, # original PII -> lets /restore put it back
"session_id": session_id,
"expires_at": expires_at,
}
if f["entity_type"] == "PERSON":
customer_id = _resolve_customer_id(original)
if customer_id:
item["customer_id"] = customer_id # RAG tool resolves via this
VAULT.put_item(Item=item)
text = text[: f["start"]] + token + text[f["end"] :]
minted.append({"token": token, "type": f["entity_type"]})
resp = {"deidentified_prompt": text, "session_id": session_id, "tokens": minted}
if not API_KEY:
resp["warning"] = "TOKENIZE_API_KEY unset -- endpoint is unauthenticated (dev only)"
return _reply(200, resp)

View File

@@ -1,41 +1,117 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# AgentCore setup (steps 3-4-7). Terraform's AgentCore coverage lags, so this # AgentCore setup (steps 3-4-7). Terraform's AgentCore coverage lags, so this
# stage uses the AgentCore CLI / SDK. Commands below reflect the current CLI # stage uses the AWS CLI's `bedrock-agentcore-control` API. Verified against the
# shape -- CHECK against `agentcore --help`, the surface moves fast. # CLI on 2026-07-01; the surface moves fast, so re-check with
# `aws bedrock-agentcore-control help` if a call rejects.
#
# T4 (implemented below): create an MCP Gateway and register the RAG Lambda as a
# tool. Inbound auth is AWS_IAM (SigV4) -- no Cognito needed. The Gateway assumes
# the IAM role from Terraform (`terraform output -raw gateway_role_arn`) to invoke
# the Lambda.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")"
: "${REGION:?set REGION}" : "${REGION:?set REGION}"
: "${RAG_LAMBDA_ARN:?set RAG_LAMBDA_ARN (from: terraform output -raw rag_lambda_arn)}" : "${RAG_LAMBDA_ARN:?set RAG_LAMBDA_ARN (from: terraform -chdir=terraform output -raw rag_lambda_arn)}"
: "${GATEWAY_ROLE_ARN:?set GATEWAY_ROLE_ARN (from: terraform -chdir=terraform output -raw gateway_role_arn)}"
: "${BEDROCK_MODEL_ID:=anthropic.claude-3-5-sonnet-20241022-v2:0}" : "${BEDROCK_MODEL_ID:=anthropic.claude-3-5-sonnet-20241022-v2:0}"
GW_NAME="${GW_NAME:-hncb-rag-gateway}"
TAG='conan hncb demo'
export AWS_PAGER=""
echo "==> 1. Install the AgentCore CLI (recommended tooling)" echo "==> 1. Create the MCP Gateway (AWS_IAM inbound auth)"
echo " npm install -g @aws/agentcore # or: pip install bedrock-agentcore-starter-toolkit" GW_JSON="$(aws bedrock-agentcore-control create-gateway \
--region "$REGION" \
--name "$GW_NAME" \
--role-arn "$GATEWAY_ROLE_ARN" \
--protocol-type MCP \
--authorizer-type AWS_IAM \
--tags "Owner=$TAG" 2>/dev/null \
|| aws bedrock-agentcore-control list-gateways --region "$REGION" \
--query "items[?name=='$GW_NAME']|[0]" --output json)"
GW_ID="$(echo "$GW_JSON" | python3 -c 'import sys,json;print(json.load(sys.stdin)["gatewayId"])')"
echo " gatewayId=$GW_ID"
echo "==> 2. Create a Gateway that exposes the RAG Lambda as an MCP tool" echo "==> 2. Wait for the Gateway to be READY"
echo " AgentCore Gateway turns a Lambda into an MCP tool with no code changes." for i in $(seq 1 20); do
ST="$(aws bedrock-agentcore-control get-gateway --region "$REGION" --gateway-identifier "$GW_ID" --query status --output text)"
echo " [$i] status=$ST"
[ "$ST" = "READY" ] && break
sleep 6
done
GW_URL="$(aws bedrock-agentcore-control get-gateway --region "$REGION" --gateway-identifier "$GW_ID" --query gatewayUrl --output text)"
echo "==> 3. Register the RAG Lambda as an MCP tool target"
# Build the target config: mcp.lambda with the tool schema from agent/tool_schema.json
# (wrapped in the inlinePayload list). The Gateway's inputSchema is a RESTRICTED
# JSON-Schema subset (only type/properties/required/items/description), so we strip
# anything else (e.g. the "default" on period_days) recursively.
python3 - "$RAG_LAMBDA_ARN" > /tmp/hncb_target.json <<'PY'
import json, sys, os
ALLOWED = {"type", "properties", "required", "items", "description"}
def clean(s):
if not isinstance(s, dict):
return s
out = {k: v for k, v in s.items() if k in ALLOWED}
if "properties" in out:
out["properties"] = {k: clean(v) for k, v in out["properties"].items()}
if "items" in out:
out["items"] = clean(out["items"])
return out
lambda_arn = sys.argv[1]
schema = json.load(open(os.path.join("..", "agent", "tool_schema.json")))
schema["inputSchema"] = clean(schema["inputSchema"])
if "outputSchema" in schema:
schema["outputSchema"] = clean(schema["outputSchema"])
print(json.dumps({
"mcp": {"lambda": {"lambdaArn": lambda_arn, "toolSchema": {"inlinePayload": [schema]}}}
}))
PY
if aws bedrock-agentcore-control list-gateway-targets --region "$REGION" \
--gateway-identifier "$GW_ID" --query 'items[?name==`rag`]' --output text | grep -q rag; then
echo " target 'rag' already exists -- reusing"
else
aws bedrock-agentcore-control create-gateway-target \
--region "$REGION" \
--gateway-identifier "$GW_ID" \
--name "rag" \
--target-configuration file:///tmp/hncb_target.json \
--credential-provider-configurations '[{"credentialProviderType":"GATEWAY_IAM_ROLE"}]' \
--query '{target:targetId,status:status}' --output json
fi
echo ""
echo "Gateway ready. MCP URL:"
echo " $GW_URL"
echo "Export it for the agent + the invoke test:"
echo " export AGENTCORE_GATEWAY_URL=$GW_URL"
echo ""
echo "Verify T4 (manual invoke returns the evidence package):"
echo " REGION=$REGION AGENTCORE_GATEWAY_URL=$GW_URL python3 scripts/gateway_invoke_test.py CUST_000123"
########################################
# T5: deploy the agent to AgentCore Runtime (uses the starter toolkit CLI).
# In ap-southeast-1 Claude 3.5 Sonnet v2 is INFERENCE_PROFILE-only -> use the
# apac.* profile id, not the raw model id.
########################################
APAC_MODEL="apac.anthropic.claude-3-5-sonnet-20241022-v2:0"
echo ""
echo "==> (T5) Deploy the agent to AgentCore Runtime:"
cat <<EOF cat <<EOF
agentcore gateway create \\ cd ../agent # requirements.txt is version-pinned
--name hncb-rag-gateway \\ pip install bedrock-agentcore-starter-toolkit # provides 'agentcore'
--region ${REGION} \\ agentcore configure -e agent.py -n hncb_advisor_agent -r ${REGION} \\
--target-type lambda \\ -rf requirements.txt --non-interactive
--target-arn ${RAG_LAMBDA_ARN} \\ agentcore launch --env AGENTCORE_GATEWAY_URL=${GW_URL} --env BEDROCK_MODEL_ID=${APAC_MODEL}
--tool-name get_customer_activity_summary \\ # -> note the printed Runtime ARN (Fusion / the UI will invoke this).
--tool-description "Return a de-identified activity summary for a customer TOKEN" \\
--tool-schema file://../agent/tool_schema.json # REQUIRED post-launch: the auto-created execution role can't call the Gateway
# Note the printed Gateway MCP URL -> export AGENTCORE_GATEWAY_URL=... # yet. Grant it (role name is printed by launch as AmazonBedrockAgentCoreSDKRuntime-*):
ROLE=\$(aws iam list-roles --query "Roles[?starts_with(RoleName,'AmazonBedrockAgentCoreSDKRuntime-${REGION}')].RoleName | [0]" --output text)
GW_ARN=\$(aws bedrock-agentcore-control get-gateway --region ${REGION} --gateway-identifier ${GW_ID} --query gatewayArn --output text)
aws iam put-role-policy --role-name "\$ROLE" --policy-name hncb-gateway-invoke \\
--policy-document "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Action\":[\"bedrock-agentcore:InvokeGateway\"],\"Resource\":[\"\$GW_ARN\",\"\$GW_ARN/*\"]}]}"
# Verify: agentcore invoke '{"prompt":"talking points for CUST_000123"}'
EOF EOF
echo "Fill AGENTCORE_GATEWAY_URL + Runtime ARN into fusion/POLICY_SETUP.md."
echo "==> 3. Deploy the agent to AgentCore Runtime"
cat <<EOF
cd ../agent
agentcore configure --entrypoint agent.py --name hncb-advisor-agent --region ${REGION}
AGENTCORE_GATEWAY_URL=<from step 2> BEDROCK_MODEL_ID=${BEDROCK_MODEL_ID} \\
agentcore launch
# Note the printed Runtime ARN -> Fusion will invoke this.
EOF
echo "==> 4. (recommended) Lock the Runtime to accept calls only from the Gateway"
echo " Use a resource policy (SigV4) or allowedWorkloadConfiguration (JWT)."
echo " See AgentCore 'Runtime targets' docs."
echo "Done printing steps. Fill AGENTCORE_GATEWAY_URL + Runtime ARN into fusion/POLICY_SETUP.md."

View File

@@ -16,7 +16,8 @@ terraform -chdir=terraform apply -target=aws_lambda_function.rag -auto-approve
echo "==> Step 2: build + push the Presidio detector image" echo "==> Step 2: build + push the Presidio detector image"
aws ecr describe-repositories --repository-names hncb-presidio --region "$REGION" >/dev/null 2>&1 \ aws ecr describe-repositories --repository-names hncb-presidio --region "$REGION" >/dev/null 2>&1 \
|| aws ecr create-repository --repository-name hncb-presidio --region "$REGION" >/dev/null || aws ecr create-repository --repository-name hncb-presidio --region "$REGION" \
--tags Key=Owner,Value="conan hncb demo" >/dev/null
aws ecr get-login-password --region "$REGION" | docker login --username AWS --password-stdin "$ECR" aws ecr get-login-password --region "$REGION" | docker login --username AWS --password-stdin "$ECR"
docker build -t "$ECR/hncb-presidio:latest" ./presidio docker build -t "$ECR/hncb-presidio:latest" ./presidio
docker push "$ECR/hncb-presidio:latest" docker push "$ECR/hncb-presidio:latest"

View File

@@ -0,0 +1,104 @@
"""
Manual T4 verification: call the RAG tool THROUGH the AgentCore Gateway (MCP) and
print the de-identified evidence package. This is the "manual agentcore invoke"
the task's acceptance asks for -- it exercises the real Gateway -> Lambda path,
not the Lambda directly.
Auth is AWS_IAM: every MCP request is SigV4-signed for the `bedrock-agentcore`
service with your local creds. MCP uses streamable HTTP (JSON-RPC, responses may
come back as SSE), so we do initialize -> tools/list -> tools/call.
Usage:
REGION=ap-southeast-1 AGENTCORE_GATEWAY_URL=<url> \
python3 scripts/gateway_invoke_test.py CUST_000123
Requires botocore (pip install boto3). Depends on nothing else.
"""
import json
import os
import sys
import urllib.request
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
from botocore.session import Session
REGION = os.environ["REGION"]
URL = os.environ["AGENTCORE_GATEWAY_URL"]
TOKEN = sys.argv[1] if len(sys.argv) > 1 else "CUST_000123"
_creds = Session().get_credentials()
_session_id = None # set from the initialize response
def _rpc(method, params=None, notify=False):
"""One SigV4-signed MCP JSON-RPC call. Returns the parsed result (or None)."""
global _session_id
body = {"jsonrpc": "2.0", "method": method}
if not notify:
body["id"] = 1
if params is not None:
body["params"] = params
data = json.dumps(body).encode()
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
if _session_id:
headers["Mcp-Session-Id"] = _session_id
req = AWSRequest(method="POST", url=URL, data=data, headers=headers)
SigV4Auth(_creds, "bedrock-agentcore", REGION).add_auth(req)
try:
resp = urllib.request.urlopen(urllib.request.Request(URL, data=data, headers=dict(req.headers)), timeout=40)
except urllib.error.HTTPError as e:
print(f"HTTP {e.code} on {method}: {e.read().decode()[:400]}", file=sys.stderr)
raise
sid = resp.headers.get("Mcp-Session-Id")
if sid:
_session_id = sid
raw = resp.read().decode()
if notify:
return None
# Response is either JSON or SSE (event-stream). Extract the JSON-RPC object.
payload = raw
if "text/event-stream" in (resp.headers.get("Content-Type") or "") or raw.lstrip().startswith("event:"):
for line in raw.splitlines():
if line.startswith("data:"):
payload = line[5:].strip()
obj = json.loads(payload)
if "error" in obj:
raise RuntimeError(f"MCP error on {method}: {obj['error']}")
return obj.get("result")
def main():
_rpc("initialize", {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "hncb-t4-test", "version": "1.0"},
})
_rpc("notifications/initialized", notify=True)
tools = _rpc("tools/list").get("tools", [])
names = [t["name"] for t in tools]
print("tools exposed by gateway:", names)
# Gateway namespaces tool names (e.g. rag___get_customer_activity_summary).
tool = next((n for n in names if n.endswith("get_customer_activity_summary")), None)
if not tool:
sys.exit("get_customer_activity_summary not found on the gateway")
result = _rpc("tools/call", {"name": tool, "arguments": {"customer_token": TOKEN}})
print(f"\n=== evidence package for {TOKEN} (via Gateway MCP) ===")
# MCP tool results come back as content blocks; print text blocks.
for block in result.get("content", []):
if block.get("type") == "text":
try:
print(json.dumps(json.loads(block["text"]), indent=2, ensure_ascii=False))
except json.JSONDecodeError:
print(block["text"])
if __name__ == "__main__":
main()

View File

@@ -6,14 +6,47 @@ cd "$(dirname "$0")/.."
REGION="${REGION:-us-east-1}" REGION="${REGION:-us-east-1}"
echo "==> Delete AgentCore resources (adjust names to what agentcore_setup created)" echo "==> Delete AgentCore Gateway + targets (not in Terraform state)"
echo " agentcore runtime delete --name hncb-advisor-agent --region ${REGION} || true" export AWS_PAGER=""
echo " agentcore gateway delete --name hncb-rag-gateway --region ${REGION} || true" GW_ID="$(aws bedrock-agentcore-control list-gateways --region "$REGION" \
--query "items[?name=='hncb-rag-gateway'].gatewayId | [0]" --output text 2>/dev/null || echo None)"
if [ "$GW_ID" != "None" ] && [ -n "$GW_ID" ]; then
for TID in $(aws bedrock-agentcore-control list-gateway-targets --region "$REGION" \
--gateway-identifier "$GW_ID" --query 'items[].targetId' --output text 2>/dev/null); do
aws bedrock-agentcore-control delete-gateway-target --region "$REGION" \
--gateway-identifier "$GW_ID" --target-id "$TID" || true
done
aws bedrock-agentcore-control delete-gateway --region "$REGION" --gateway-identifier "$GW_ID" || true
echo " deleted gateway $GW_ID"
fi
echo "==> Delete AgentCore Runtime + Memory (T5; not in Terraform state)"
for RT in $(aws bedrock-agentcore-control list-agent-runtimes --region "$REGION" \
--query "agentRuntimes[?starts_with(agentRuntimeName,'hncb')].agentRuntimeId" --output text 2>/dev/null); do
aws bedrock-agentcore-control delete-agent-runtime --region "$REGION" --agent-runtime-id "$RT" || true
echo " deleted runtime $RT"
done
for MEM in $(aws bedrock-agentcore-control list-memories --region "$REGION" \
--query "memories[?starts_with(id,'hncb')].id" --output text 2>/dev/null); do
aws bedrock-agentcore-control delete-memory --region "$REGION" --memory-id "$MEM" || true
echo " deleted memory $MEM"
done
echo " (agentcore launch also auto-created an ECR repo, a CodeBuild project, and an"
echo " AmazonBedrockAgentCoreSDKRuntime-* execution role -- delete by hand if desired.)"
echo "==> Terraform destroy (DynamoDB, Lambda, ECS, IAM)" echo "==> Terraform destroy (DynamoDB, Lambda, ECS, IAM)"
terraform -chdir=terraform destroy -auto-approve terraform -chdir=terraform destroy -auto-approve
echo "==> (optional) delete the Presidio ECR repo" echo "==> Delete ECR repos (Presidio + the agentcore-built agent image; not in TF state)"
echo " aws ecr delete-repository --repository-name hncb-presidio --force --region ${REGION} || true" for REPO in hncb-presidio bedrock-agentcore-hncb_advisor_agent; do
aws ecr delete-repository --repository-name "$REPO" --force --region "$REGION" 2>/dev/null \
&& echo " deleted ECR $REPO" || true
done
echo " (also free/leftover: the AmazonBedrockAgentCoreSDKRuntime-* role and a CodeBuild"
echo " project agentcore created -- no cost idle; delete by hand if you want it spotless.)"
echo "Teardown complete (mind any S3/CloudFront you created for the UI)." echo "==> (manual) default VPC created by hand for the T1 live deploy (ap-southeast-1)"
echo " Not in Terraform state; tagged maintenance=manual-cleanup-required. Free while"
echo " empty, so usually leave it. To remove: aws ec2 delete-vpc --vpc-id vpc-0e97f4fdb643c3e26"
echo "Teardown complete. (UI S3+CloudFront are Terraform-managed now, so the destroy"
echo " above removes them; the S3 bucket has force_destroy=true.)"

46
terraform/.terraform.lock.hcl generated Normal file
View File

@@ -0,0 +1,46 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/archive" {
version = "2.8.0"
constraints = "~> 2.0"
hashes = [
"h1:WB6H5ksIZiyq1lQlD/PWeh+tn4FLsbSjVnRW3+4xe2Y=",
"zh:0d14713fdc259fb377d0b899ad3c650a34194bd52194c863303ef22a65a580e2",
"zh:369b56040c7a8085d04e7e8ffac1e2b321a3170e502f788819bc34b868ec016f",
"zh:4d1a3b983ed6af5a52bfe12794674ae55cbadfa6021b37106ade68b433ad216a",
"zh:5c547549e26e083573c78a966ca68ce6d7df6bb8f3948f66a575f07da46b74ea",
"zh:6de093e62a975eb19a5e3017ce38e6e3cb639c17b79648d2000e0a8348f0e997",
"zh:7267936c2cdbc448efeb594d73e6b56a53d6a7ae14fe88cdd2a4133adc3302f0",
"zh:7482f023050ed426b4b45116e1761643bc33b1fd4ce4a6fab207ae2571f35940",
"zh:76bbd93b234e5a2927d98b511d86565700f549b570871a194c35f944b96cefb7",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:c6afc4bc1f002bac9c173007dd4da05fde788cd14c2916089f958c33fedb0dfa",
"zh:d3ba40bd806a3a08e9237dece679193c99afb2085de6b45d7f5d1f673cfcd368",
"zh:e1ad7ded53ecd6f0e5b473a3b44eae2b2e885653a56050ab583d387332be02e4",
"zh:e93e78575ce82be6084cc153c24ba8f385dc8d6880888ee66e918460c870953d",
]
}
provider "registry.terraform.io/hashicorp/aws" {
version = "5.100.0"
constraints = "~> 5.0"
hashes = [
"h1:Ijt7pOlB7Tr7maGQIqtsLFbl7pSMIj06TVdkoSBcYOw=",
"zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
"zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
"zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
"zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
"zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
"zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
"zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
"zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
"zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
"zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
"zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
"zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
"zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
"zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
]
}

41
terraform/agentcore.tf Normal file
View File

@@ -0,0 +1,41 @@
# ---------------------------------------------------------------------------
# AgentCore Gateway support (task T4). Terraform's AgentCore coverage lags, so the
# Gateway + target themselves are created by scripts/agentcore_setup.sh via
# `aws bedrock-agentcore-control`. What IS declarative here is the IAM role the
# Gateway assumes to invoke the RAG tool Lambda -- kept in Terraform so it is
# tagged, auditable, and torn down with the rest of the stack.
# ---------------------------------------------------------------------------
resource "aws_iam_role" "agentcore_gateway" {
name = "${local.name}-gateway-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "bedrock-agentcore.amazonaws.com" }
Action = "sts:AssumeRole"
Condition = {
StringEquals = { "aws:SourceAccount" = data.aws_caller_identity.current.account_id }
}
}]
})
tags = local.cloud_tag # the Gateway is a cloud-zone component
}
resource "aws_iam_role_policy" "agentcore_gateway" {
name = "${local.name}-gateway-policy"
role = aws_iam_role.agentcore_gateway.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
# The Gateway only needs to invoke the one RAG tool Lambda.
Effect = "Allow"
Action = ["lambda:InvokeFunction"]
Resource = [aws_lambda_function.rag.arn]
}]
})
}
data "aws_caller_identity" "current" {}
output "gateway_role_arn" { value = aws_iam_role.agentcore_gateway.arn }

View File

@@ -1,9 +1,10 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Hosting for the Presidio detector only. Fusion is a shared SaaS instance and is # Hosting for the Presidio detector only. Fusion is a shared SaaS instance and is
# NOT deployed here. Because Fusion SaaS calls the detector over the internet, the # NOT deployed here. The detector is PRIVATE (task T3): its port 5001 is reachable
# analyzer port is public in this demo (lock it down / add auth for anything real; # only from the /tokenize Lambda's security group, not the internet. The only
# better: fold detection behind the /tokenize endpoint so only that is public -- # public surface is the API Gateway (/tokenize + /restore). The task keeps a public
# see CLAUDE.md task T3). # IP purely so Fargate can pull the image from ECR (there's no NAT); inbound is
# still SG-locked, so nothing is reachable from outside the VPC.
# #
# For a laptop-only rehearsal you can skip this file and run Presidio via docker. # For a laptop-only rehearsal you can skip this file and run Presidio via docker.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -17,21 +18,42 @@ data "aws_subnets" "default" {
} }
} }
# Route tables of the default VPC -- needed to attach the DynamoDB gateway endpoint
# so the (now VPC-attached) tokenize Lambda can still reach the vault/customers tables.
data "aws_route_tables" "default" {
vpc_id = data.aws_vpc.default.id
}
resource "aws_ecs_cluster" "this" { resource "aws_ecs_cluster" "this" {
name = "${local.name}-cluster" name = "${local.name}-cluster"
tags = local.onprem_tag tags = local.onprem_tag
} }
# SG the tokenize Lambda runs in. No ingress; egress open so it can reach Presidio
# (5001) and the DynamoDB endpoint. Presidio trusts this SG (below).
resource "aws_security_group" "tokenize_lambda" {
name = "${local.name}-tokenize-lambda-sg"
description = "tokenize Lambda ENIs"
vpc_id = data.aws_vpc.default.id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = local.onprem_tag
}
resource "aws_security_group" "svc" { resource "aws_security_group" "svc" {
name = "${local.name}-svc-sg" name = "${local.name}-svc-sg"
description = "Demo services SG" description = "Demo services SG"
vpc_id = data.aws_vpc.default.id vpc_id = data.aws_vpc.default.id
ingress { ingress {
description = "Presidio analyzer (public for SaaS Fusion demo - restrict for real use)" description = "Presidio analyzer -- only the tokenize Lambda may call it"
from_port = 5001 from_port = 5001
to_port = 5001 to_port = 5001
protocol = "tcp" protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] security_groups = [aws_security_group.tokenize_lambda.id]
} }
egress { egress {
from_port = 0 from_port = 0
@@ -42,6 +64,15 @@ resource "aws_security_group" "svc" {
tags = local.onprem_tag tags = local.onprem_tag
} }
# Gateway endpoint so VPC-attached Lambdas reach DynamoDB without a NAT / public route.
resource "aws_vpc_endpoint" "dynamodb" {
vpc_id = data.aws_vpc.default.id
service_name = "com.amazonaws.${var.region}.dynamodb"
vpc_endpoint_type = "Gateway"
route_table_ids = data.aws_route_tables.default.ids
tags = merge(local.onprem_tag, { Name = "${local.name}-dynamodb" })
}
resource "aws_iam_role" "ecs_exec" { resource "aws_iam_role" "ecs_exec" {
name = "${local.name}-ecs-exec" name = "${local.name}-ecs-exec"
assume_role_policy = jsonencode({ assume_role_policy = jsonencode({
@@ -64,17 +95,37 @@ resource "aws_ecs_task_definition" "presidio" {
requires_compatibilities = ["FARGATE"] requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc" network_mode = "awsvpc"
cpu = 512 cpu = 512
memory = 1024 # zh_core_web_lg (~600MB) plus presidio/spacy needs headroom; 1GB OOMs on load.
memory = 2048
execution_role_arn = aws_iam_role.ecs_exec.arn execution_role_arn = aws_iam_role.ecs_exec.arn
# Built natively on Apple Silicon (podman) -> run on Fargate Graviton.
runtime_platform {
cpu_architecture = "ARM64"
operating_system_family = "LINUX"
}
container_definitions = jsonencode([{ container_definitions = jsonencode([{
name = "presidio" name = "presidio"
image = var.presidio_image_uri image = var.presidio_image_uri
essential = true essential = true
portMappings = [{ containerPort = 5001 }] portMappings = [{ containerPort = 5001 }]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.presidio.name
"awslogs-region" = var.region
"awslogs-stream-prefix" = "presidio"
}
}
}]) }])
tags = local.onprem_tag tags = local.onprem_tag
} }
resource "aws_cloudwatch_log_group" "presidio" {
name = "/ecs/${local.name}-presidio"
retention_in_days = 7
tags = local.onprem_tag
}
resource "aws_ecs_service" "presidio" { resource "aws_ecs_service" "presidio" {
name = "${local.name}-presidio" name = "${local.name}-presidio"
cluster = aws_ecs_cluster.this.id cluster = aws_ecs_cluster.this.id

296
terraform/gateway.tf Normal file
View File

@@ -0,0 +1,296 @@
# ---------------------------------------------------------------------------
# Public HTTPS endpoints that Fusion SaaS calls: POST /tokenize (T1) and
# POST /restore (T2), both on one API Gateway HTTP API. Fusion is shared SaaS, so
# it can only reach PUBLIC HTTPS with auth -- each handler checks a shared secret
# (tokenize_api_key, sent as x-api-key / bearer). These live in the "on-prem" zone
# (tags): they own the reversible token<->PII map, which must never leave it.
# (API Gateway, not a Lambda Function URL: this org's SCP blocks unauthenticated
# Function URLs.)
# ---------------------------------------------------------------------------
data "archive_file" "tokenize" {
type = "zip"
source_dir = "${path.module}/../gateway_api/tokenize"
output_path = "${path.module}/tokenize_lambda.zip"
}
resource "aws_iam_role" "tokenize" {
name = "${local.name}-tokenize-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "tokenize" {
name = "${local.name}-tokenize-policy"
role = aws_iam_role.tokenize.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
# Mint into the vault; scan customers for best-effort PERSON -> customer_id.
Effect = "Allow"
Action = ["dynamodb:PutItem"]
Resource = [aws_dynamodb_table.vault.arn]
},
{
Effect = "Allow"
Action = ["dynamodb:Scan"]
Resource = [aws_dynamodb_table.customers.arn]
},
{
Effect = "Allow"
Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "arn:aws:logs:*:*:*"
}
]
})
}
resource "aws_lambda_function" "tokenize" {
function_name = "${local.name}-tokenize"
role = aws_iam_role.tokenize.arn
runtime = "python3.12"
handler = "handler.lambda_handler"
filename = data.archive_file.tokenize.output_path
source_code_hash = data.archive_file.tokenize.output_base64sha256
timeout = 15
environment {
variables = {
VAULT_TABLE = aws_dynamodb_table.vault.name
CUSTOMERS_TABLE = aws_dynamodb_table.customers.name
PRESIDIO_URL = var.presidio_url
TOKENIZE_API_KEY = var.tokenize_api_key
VAULT_TTL_SECONDS = "3600"
}
}
# In-VPC so it can reach the now-private Presidio over 5001. DynamoDB is reached
# via the gateway endpoint (see ecs.tf); no NAT needed.
vpc_config {
subnet_ids = data.aws_subnets.default.ids
security_group_ids = [aws_security_group.tokenize_lambda.id]
}
tags = local.onprem_tag
}
# VPC-attached Lambdas need ENI management permissions.
resource "aws_iam_role_policy_attachment" "tokenize_vpc" {
role = aws_iam_role.tokenize.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
}
# Public HTTPS front door. We use an API Gateway HTTP API rather than a Lambda
# Function URL because this org's SCP blocks unauthenticated (auth_type=NONE)
# Function URLs. The API is public; auth is the shared secret checked in-handler
# (Fusion sends it as x-api-key / bearer). T2 adds a POST /restore route here.
resource "aws_apigatewayv2_api" "gateway" {
name = "${local.name}-gateway"
protocol_type = "HTTP"
# The static UI (CloudFront origin) calls /demo cross-origin -> allow CORS.
cors_configuration {
allow_origins = ["*"]
allow_methods = ["POST", "OPTIONS"]
allow_headers = ["content-type", "x-api-key"]
}
tags = local.onprem_tag
}
resource "aws_apigatewayv2_integration" "tokenize" {
api_id = aws_apigatewayv2_api.gateway.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.tokenize.invoke_arn
payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "tokenize" {
api_id = aws_apigatewayv2_api.gateway.id
route_key = "POST /tokenize"
target = "integrations/${aws_apigatewayv2_integration.tokenize.id}"
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.gateway.id
name = "$default"
auto_deploy = true
tags = local.onprem_tag
}
resource "aws_lambda_permission" "tokenize_apigw" {
statement_id = "AllowApiGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.tokenize.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.gateway.execution_arn}/*/*"
}
########################################
# T2: /restore -- egress re-identification (vault lookup by session_id)
########################################
data "archive_file" "restore" {
type = "zip"
source_dir = "${path.module}/../gateway_api/restore"
output_path = "${path.module}/restore_lambda.zip"
}
resource "aws_iam_role" "restore" {
name = "${local.name}-restore-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "restore" {
name = "${local.name}-restore-policy"
role = aws_iam_role.restore.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
# Read-only: look up this session's tokens to re-attach identity.
Effect = "Allow"
Action = ["dynamodb:Scan"]
Resource = [aws_dynamodb_table.vault.arn]
},
{
Effect = "Allow"
Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "arn:aws:logs:*:*:*"
}
]
})
}
resource "aws_lambda_function" "restore" {
function_name = "${local.name}-restore"
role = aws_iam_role.restore.arn
runtime = "python3.12"
handler = "handler.lambda_handler"
filename = data.archive_file.restore.output_path
source_code_hash = data.archive_file.restore.output_base64sha256
timeout = 15
environment {
variables = {
VAULT_TABLE = aws_dynamodb_table.vault.name
TOKENIZE_API_KEY = var.tokenize_api_key
}
}
tags = local.onprem_tag
}
resource "aws_apigatewayv2_integration" "restore" {
api_id = aws_apigatewayv2_api.gateway.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.restore.invoke_arn
payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "restore" {
api_id = aws_apigatewayv2_api.gateway.id
route_key = "POST /restore"
target = "integrations/${aws_apigatewayv2_integration.restore.id}"
}
resource "aws_lambda_permission" "restore_apigw" {
statement_id = "AllowApiGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.restore.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.gateway.execution_arn}/*/*"
}
########################################
# T6: /demo -- Fusion-less orchestrator the static UI calls (tokenize->agent->restore)
########################################
data "archive_file" "orchestrator" {
type = "zip"
source_dir = "${path.module}/../gateway_api/orchestrator"
output_path = "${path.module}/orchestrator_lambda.zip"
}
resource "aws_iam_role" "orchestrator" {
name = "${local.name}-orchestrator-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "orchestrator" {
name = "${local.name}-orchestrator-policy"
role = aws_iam_role.orchestrator.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
# Invoke the AgentCore Runtime. Scoped to the configured runtime ARN when set.
Effect = "Allow"
Action = ["bedrock-agentcore:InvokeAgentRuntime"]
Resource = var.agent_runtime_arn != "" ? [var.agent_runtime_arn, "${var.agent_runtime_arn}/*"] : ["*"]
},
{
Effect = "Allow"
Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "arn:aws:logs:*:*:*"
}
]
})
}
resource "aws_lambda_function" "orchestrator" {
function_name = "${local.name}-orchestrator"
role = aws_iam_role.orchestrator.arn
runtime = "python3.12"
handler = "handler.lambda_handler"
filename = data.archive_file.orchestrator.output_path
source_code_hash = data.archive_file.orchestrator.output_base64sha256
timeout = 120
environment {
variables = {
REGION = var.region
TOKENIZE_URL = "${trimsuffix(aws_apigatewayv2_stage.default.invoke_url, "/")}/tokenize"
RESTORE_URL = "${trimsuffix(aws_apigatewayv2_stage.default.invoke_url, "/")}/restore"
TOKENIZE_API_KEY = var.tokenize_api_key
AGENT_RUNTIME_ARN = var.agent_runtime_arn
}
}
tags = local.onprem_tag
}
resource "aws_apigatewayv2_integration" "orchestrator" {
api_id = aws_apigatewayv2_api.gateway.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.orchestrator.invoke_arn
payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "orchestrator" {
api_id = aws_apigatewayv2_api.gateway.id
route_key = "POST /demo"
target = "integrations/${aws_apigatewayv2_integration.orchestrator.id}"
}
resource "aws_lambda_permission" "orchestrator_apigw" {
statement_id = "AllowApiGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.orchestrator.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.gateway.execution_arn}/*/*"
}
output "tokenize_url" { value = "${trimsuffix(aws_apigatewayv2_stage.default.invoke_url, "/")}/tokenize" }
output "restore_url" { value = "${trimsuffix(aws_apigatewayv2_stage.default.invoke_url, "/")}/restore" }
output "demo_url" { value = "${trimsuffix(aws_apigatewayv2_stage.default.invoke_url, "/")}/demo" }

View File

@@ -8,6 +8,14 @@ terraform {
provider "aws" { provider "aws" {
region = var.region region = var.region
# Every taggable resource created by this stack carries this tag, on top of any
# per-resource tags (e.g. the on-prem/cloud Zone tags).
default_tags {
tags = {
Owner = "conan hncb demo"
}
}
} }
locals { locals {

85
terraform/ui.tf Normal file
View File

@@ -0,0 +1,85 @@
# ---------------------------------------------------------------------------
# T6: host the advisor UI on S3 + CloudFront. The bucket is private; CloudFront
# reaches it via Origin Access Control. index.html is static and committed; the
# live endpoint is injected via a generated config.js so we never bake an
# ephemeral URL into the repo.
# ---------------------------------------------------------------------------
resource "aws_s3_bucket" "ui" {
bucket_prefix = "${local.name}-ui-"
force_destroy = true
tags = local.onprem_tag
}
resource "aws_cloudfront_origin_access_control" "ui" {
name = "${local.name}-ui-oac"
origin_access_control_origin_type = "s3"
signing_behavior = "always"
signing_protocol = "sigv4"
}
resource "aws_cloudfront_distribution" "ui" {
enabled = true
default_root_object = "index.html"
comment = "${local.name} advisor UI"
origin {
domain_name = aws_s3_bucket.ui.bucket_regional_domain_name
origin_id = "ui-s3"
origin_access_control_id = aws_cloudfront_origin_access_control.ui.id
}
default_cache_behavior {
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "ui-s3"
viewer_protocol_policy = "redirect-to-https"
forwarded_values {
query_string = false
cookies { forward = "none" }
}
}
restrictions {
geo_restriction { restriction_type = "none" }
}
viewer_certificate {
cloudfront_default_certificate = true
}
tags = local.cloud_tag
}
resource "aws_s3_bucket_policy" "ui" {
bucket = aws_s3_bucket.ui.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "cloudfront.amazonaws.com" }
Action = "s3:GetObject"
Resource = "${aws_s3_bucket.ui.arn}/*"
Condition = { StringEquals = { "AWS:SourceArn" = aws_cloudfront_distribution.ui.arn } }
}]
})
}
resource "aws_s3_object" "index" {
bucket = aws_s3_bucket.ui.id
key = "index.html"
source = "${path.module}/../ui/index.html"
etag = filemd5("${path.module}/../ui/index.html")
content_type = "text/html"
}
# Injected config: the demo orchestrator endpoint the UI calls.
resource "aws_s3_object" "config" {
bucket = aws_s3_bucket.ui.id
key = "config.js"
content = "window.DEMO_ENDPOINT = \"${trimsuffix(aws_apigatewayv2_stage.default.invoke_url, "/")}/demo\";\n"
content_type = "application/javascript"
etag = md5("${trimsuffix(aws_apigatewayv2_stage.default.invoke_url, "/")}/demo")
}
output "ui_url" { value = "https://${aws_cloudfront_distribution.ui.domain_name}" }

View File

@@ -23,3 +23,30 @@ variable "presidio_image_uri" {
type = string type = string
default = "REPLACE_ME_PRESIDIO_IMAGE_URI" default = "REPLACE_ME_PRESIDIO_IMAGE_URI"
} }
# Where the /tokenize Lambda reaches the Presidio detector. After deploy this is
# the Presidio Fargate task's public endpoint (http://<public-ip>:5001). Kept a
# variable so a laptop rehearsal can point at a local/ngrok detector.
variable "presidio_url" {
description = "Base URL of the Presidio detector /analyze service."
type = string
default = "http://localhost:5001"
}
# Shared secret Fusion SaaS presents to the public endpoints (bearer / x-api-key).
# NEVER commit a real value -- pass via TF_VAR_tokenize_api_key or a .tfvars file
# that is gitignored. Empty default leaves the endpoint open (dev only).
variable "tokenize_api_key" {
description = "Shared secret required on /tokenize (and /restore) requests."
type = string
default = ""
sensitive = true
}
# AgentCore Runtime ARN (created out-of-band by `agentcore launch`, see T5). The
# demo orchestrator (T6) invokes it. Empty -> orchestrator skips the agent step.
variable "agent_runtime_arn" {
description = "Bedrock AgentCore Runtime ARN the demo orchestrator invokes."
type = string
default = ""
}

View File

@@ -40,19 +40,26 @@
<div class="card cloud"> <div class="card cloud">
<h2>What the cloud actually saw <span class="tag">tokenized</span></h2> <h2>What the cloud actually saw <span class="tag">tokenized</span></h2>
<pre id="deid"></pre> <pre id="deid"></pre>
<h2 style="margin-top:12px">Agent talking points <span class="tag">tokenized</span></h2>
<pre id="agent"></pre>
</div> </div>
</div> </div>
</div> </div>
<!-- Injected at deploy time (S3) with the live demo orchestrator URL. -->
<script src="config.js"></script>
<script> <script>
// TODO: point this at your Fusion gateway endpoint after deploy. // Fusion is shared SaaS; for a self-contained dry run the UI calls the demo
const GATEWAY_URL = "http://REPLACE_ME_FUSION_HOST:8080/advisor"; // orchestrator (/demo), which runs tokenize -> agent -> restore server-side.
// In production, point this at the Fusion gateway entrypoint instead.
const GATEWAY_URL = window.DEMO_ENDPOINT || "http://REPLACE_ME_FUSION_HOST:8080/advisor";
async function run() { async function run() {
const btn = document.getElementById("go"); const btn = document.getElementById("go");
btn.disabled = true; btn.disabled = true;
document.getElementById("final").textContent = "…thinking…"; document.getElementById("final").textContent = "…thinking…";
document.getElementById("deid").textContent = "…"; document.getElementById("deid").textContent = "…";
document.getElementById("agent").textContent = "…";
try { try {
const res = await fetch(GATEWAY_URL, { const res = await fetch(GATEWAY_URL, {
method: "POST", method: "POST",
@@ -60,9 +67,10 @@
body: JSON.stringify({ query: document.getElementById("q").value }), body: JSON.stringify({ query: document.getElementById("q").value }),
}); });
const data = await res.json(); const data = await res.json();
// Fusion is expected to return { final, deidentified_prompt }. // Orchestrator (or Fusion) returns { final, deidentified_prompt, agent_tokenized }.
document.getElementById("final").textContent = data.final || JSON.stringify(data, null, 2); document.getElementById("final").textContent = data.final || JSON.stringify(data, null, 2);
document.getElementById("deid").textContent = data.deidentified_prompt || "(gateway did not echo the de-identified prompt)"; document.getElementById("deid").textContent = data.deidentified_prompt || "(no de-identified prompt echoed)";
document.getElementById("agent").textContent = data.agent_tokenized || "(no agent answer)";
} catch (e) { } catch (e) {
document.getElementById("final").textContent = "Error: " + e.message; document.getElementById("final").textContent = "Error: " + e.message;
} finally { } finally {