Reversible PII de-identification round trip (T1–T7) #1

Merged
cscott merged 2 commits from feat/deid-round-trip-t1-t7 into main 2026-07-01 07:22:09 +00:00
21 changed files with 1439 additions and 107 deletions
Showing only changes of commit 78d67a2469 - Show all commits

17
.gitignore vendored
View File

@@ -37,3 +37,20 @@ override.tf.json
.terraformrc
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)
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
calls with `{query}`. Steps: POST query to Presidio `/analyze`; for each finding
mint a random token (`CUST_<rand>` for PERSON→customer, `TW_<rand>` for TW_ROC_ID);
write vault items `{token, type, value, session_id, expires_at}`; splice tokens
into the text. Return `{deidentified_prompt, session_id}`.
*Accept:* real name in → tokenized text + session_id out; vault rows written;
same input yields different tokens on repeat calls.
- **T2 — `/restore` endpoint** (`gateway_api/restore/`). Input `{session_id, text}`.
Look up this session's tokens in the vault, re-attach identity (envelope-level:
prepend the real name; also swap any inline tokens). Return `{final}`.
*Accept:* tokenized answer + session_id → answer with `王小明` restored.
- **T3 — reachability from SaaS.** Make Presidio (or fold detection into T1 so only
`/tokenize` + `/restore` are public) reachable from Fusion SaaS with auth.
*Accept:* Fusion SaaS can call the endpoints over HTTPS; nothing else is public.
- **T4 — AgentCore Gateway target.** Wire the RAG Lambda as the MCP tool; confirm
the agent can call `get_customer_activity_summary(CUST_000123)` and get the package.
*Accept:* a manual `agentcore` invoke returns the evidence package.
- **T5 — Agent runtime.** Pin deps, `agentcore launch`, capture the Runtime ARN.
*Accept:* invoking the runtime with a tokenized prompt returns talking points.
- **T6 — UI wiring.** Point `ui/index.html` GATEWAY_URL at the Fusion SaaS entry
(or `/tokenize` for a Fusion-less dry run); host on S3+CloudFront.
*Accept:* the split view renders restored vs tokenized.
- **T7 — E2E rehearsal.** Run the full script in README; confirm the Bedrock/
AgentCore trace shows only tokens. Then verify `teardown.sh` leaves nothing paid-for.
- **T1 — `/tokenize` endpoint** **done + live-verified** (`gateway_api/tokenize/handler.py`,
terraform `terraform/gateway.tf`). Lambda behind a **public API Gateway HTTP API**
(not a Function URL — this account's SCP blocks unauthenticated Function URLs),
shared-secret auth (`tokenize_api_key`, sent as `x-api-key` / bearer, checked
in-handler). Calls Presidio `/analyze`, resolves overlapping findings (specific
entity wins, so the ROC ID stays `TW_ROC_ID` not `PERSON`), mints random
`CUST_<rand>`/`TW_<rand>` tokens, writes vault rows
`{token, type, value, session_id, expires_at [, customer_id]}` (`value` = original
PII for /restore; `customer_id` best-effort resolved for the RAG tool), splices
right-to-left, returns `{deidentified_prompt, session_id}`. **Verified live** in
`ap-southeast-1`: `王小明``CUST_*`, `A123456789``TW_*`, clean prompt, vault rows
written with resolved `customer_id`, repeat→different tokens, no/bad key→401.
Infra changes made while deploying: Presidio task bumped to **2GB** (1GB OOM'd on
the 603MB zh model → connection refused), **awslogs** added, **ARM64/Graviton**
runtime (built on Apple Silicon via podman). All AWS objects tagged
`Owner="conan hncb demo"`. *Caveats:* Presidio has no stable endpoint — its
Fargate public IP changes per task launch, so `presidio_url` in
`terraform/local.auto.tfvars` must be refreshed and the tokenize Lambda re-applied
(an ALB/Cloud Map would fix this; out of scope for the demo). The default VPC was
created by hand and tagged `maintenance=manual-cleanup-required` (Terraform doesn't
own it, so `teardown.sh` won't remove it).
- **T2 — `/restore` endpoint** ✅ **done + live-verified** (`gateway_api/restore/handler.py`,
terraform in `terraform/gateway.tf`). `POST /restore` on the same API Gateway,
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)
- 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
`aws-cli` configured (creds + region), `terraform >= 1.5`, `docker`, `python3`,
the AgentCore CLI (`npm i -g @aws/agentcore`), Bedrock model access enabled for
`bedrock_model_id`, and access to the shared **Fusion SaaS** instance.
`aws-cli` (creds + region — this demo runs in `ap-southeast-1`), `terraform >= 1.5`,
`podman` or `docker` (Presidio image), `python3` + a venv with `boto3`,
`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
```bash
bash scripts/deploy.sh # infra -> presidio image -> ECS -> seed -> AgentCore
# then: build /tokenize + /restore (gateway_api/, tasks T1/T2)
# then: configure the shared Fusion SaaS instance (fusion/POLICY_SETUP.md)
# then: point ui/index.html GATEWAY_URL at the Fusion SaaS entrypoint and open it
```
The live stack was brought up in this order (authoritative runbook: the task notes
in `CLAUDE.md`, which record the exact sequencing and gotchas):
1. `terraform -chdir=terraform apply` core infra (DynamoDB, RAG + tokenize/restore/
orchestrator Lambdas, API Gateway, Presidio on Fargate, UI on S3+CloudFront).
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)
1. Advisor UI: submit *"請幫我整理王小明最近三個月的理財往來,並給我下次拜訪話術。"*
2. Fusion (via `/tokenize`) detects `王小明` + `A123456789`, tokenizes, logs tokens only.
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.
7. Agent writes talking points (no PII).
8. Fusion (via `/restore`) restores `王小明`; the UI shows restored beside tokenized.
## Demo script (Fusion-less dry run — maps to the 8 steps)
```bash
cd terraform && DEMO=$(terraform output -raw demo_url) && UI=$(terraform output -raw ui_url); cd ..
# 1-2, 4-8: advisor query -> tokenize -> agent (on tokens) -> restore, in one call:
curl -s -X POST "$DEMO" -H 'content-type: application/json' \
-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
```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
- **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.
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.
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 httpx
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
from botocore.session import Session
from bedrock_agentcore.runtime import BedrockAgentCoreApp
from strands import Agent
from strands.models import BedrockModel
@@ -18,9 +27,12 @@ from mcp.client.streamable_http import streamablehttp_client
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", "")
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 = (
"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():
headers = {"Authorization": f"Bearer {GATEWAY_TOKEN}"} if GATEWAY_TOKEN else {}
return MCPClient(lambda: streamablehttp_client(GATEWAY_URL, headers=headers))
if GATEWAY_TOKEN:
headers = {"Authorization": f"Bearer {GATEWAY_TOKEN}"}
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

View File

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

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.
## Endpoints Fusion calls
- `TOKENIZE_URL` = `https://<...>/tokenize` (ingress: detect + mint + vault-write + splice)
- `RESTORE_URL` = `https://<...>/restore` (egress: vault lookup + re-attach identity)
- `TOKENIZE_URL` = `https://<api-id>.execute-api.<region>.amazonaws.com/tokenize`
(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`
- 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)
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.
## 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> }
```
(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
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
# 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
# shape -- CHECK against `agentcore --help`, the surface moves fast.
# stage uses the AWS CLI's `bedrock-agentcore-control` API. Verified against the
# 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
cd "$(dirname "$0")"
: "${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}"
GW_NAME="${GW_NAME:-hncb-rag-gateway}"
TAG='conan hncb demo'
export AWS_PAGER=""
echo "==> 1. Install the AgentCore CLI (recommended tooling)"
echo " npm install -g @aws/agentcore # or: pip install bedrock-agentcore-starter-toolkit"
echo "==> 1. Create the MCP Gateway (AWS_IAM inbound auth)"
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 " AgentCore Gateway turns a Lambda into an MCP tool with no code changes."
echo "==> 2. Wait for the Gateway to be READY"
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
agentcore gateway create \\
--name hncb-rag-gateway \\
--region ${REGION} \\
--target-type lambda \\
--target-arn ${RAG_LAMBDA_ARN} \\
--tool-name get_customer_activity_summary \\
--tool-description "Return a de-identified activity summary for a customer TOKEN" \\
--tool-schema file://../agent/tool_schema.json
# Note the printed Gateway MCP URL -> export AGENTCORE_GATEWAY_URL=...
cd ../agent # requirements.txt is version-pinned
pip install bedrock-agentcore-starter-toolkit # provides 'agentcore'
agentcore configure -e agent.py -n hncb_advisor_agent -r ${REGION} \\
-rf requirements.txt --non-interactive
agentcore launch --env AGENTCORE_GATEWAY_URL=${GW_URL} --env BEDROCK_MODEL_ID=${APAC_MODEL}
# -> note the printed Runtime ARN (Fusion / the UI will invoke this).
# REQUIRED post-launch: the auto-created execution role can't call the Gateway
# 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
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."
echo "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"
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"
docker build -t "$ECR/hncb-presidio:latest" ./presidio
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}"
echo "==> Delete AgentCore resources (adjust names to what agentcore_setup created)"
echo " agentcore runtime delete --name hncb-advisor-agent --region ${REGION} || true"
echo " agentcore gateway delete --name hncb-rag-gateway --region ${REGION} || true"
echo "==> Delete AgentCore Gateway + targets (not in Terraform state)"
export AWS_PAGER=""
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)"
terraform -chdir=terraform destroy -auto-approve
echo "==> (optional) delete the Presidio ECR repo"
echo " aws ecr delete-repository --repository-name hncb-presidio --force --region ${REGION} || true"
echo "==> Delete ECR repos (Presidio + the agentcore-built agent image; not in TF state)"
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
# NOT deployed here. Because Fusion SaaS calls the detector over the internet, the
# analyzer port is public in this demo (lock it down / add auth for anything real;
# better: fold detection behind the /tokenize endpoint so only that is public --
# see CLAUDE.md task T3).
# NOT deployed here. The detector is PRIVATE (task T3): its port 5001 is reachable
# only from the /tokenize Lambda's security group, not the internet. The only
# public surface is the API Gateway (/tokenize + /restore). The task keeps a public
# 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.
# ---------------------------------------------------------------------------
@@ -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" {
name = "${local.name}-cluster"
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" {
name = "${local.name}-svc-sg"
description = "Demo services SG"
vpc_id = data.aws_vpc.default.id
ingress {
description = "Presidio analyzer (public for SaaS Fusion demo - restrict for real use)"
from_port = 5001
to_port = 5001
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
description = "Presidio analyzer -- only the tokenize Lambda may call it"
from_port = 5001
to_port = 5001
protocol = "tcp"
security_groups = [aws_security_group.tokenize_lambda.id]
}
egress {
from_port = 0
@@ -42,6 +64,15 @@ resource "aws_security_group" "svc" {
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" {
name = "${local.name}-ecs-exec"
assume_role_policy = jsonencode({
@@ -64,17 +95,37 @@ resource "aws_ecs_task_definition" "presidio" {
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 512
memory = 1024
execution_role_arn = aws_iam_role.ecs_exec.arn
# 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
# Built natively on Apple Silicon (podman) -> run on Fargate Graviton.
runtime_platform {
cpu_architecture = "ARM64"
operating_system_family = "LINUX"
}
container_definitions = jsonencode([{
name = "presidio"
image = var.presidio_image_uri
essential = true
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
}
resource "aws_cloudwatch_log_group" "presidio" {
name = "/ecs/${local.name}-presidio"
retention_in_days = 7
tags = local.onprem_tag
}
resource "aws_ecs_service" "presidio" {
name = "${local.name}-presidio"
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" {
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 {

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
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">
<h2>What the cloud actually saw <span class="tag">tokenized</span></h2>
<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>
<!-- Injected at deploy time (S3) with the live demo orchestrator URL. -->
<script src="config.js"></script>
<script>
// TODO: point this at your Fusion gateway endpoint after deploy.
const GATEWAY_URL = "http://REPLACE_ME_FUSION_HOST:8080/advisor";
// Fusion is shared SaaS; for a self-contained dry run the UI calls the demo
// 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() {
const btn = document.getElementById("go");
btn.disabled = true;
document.getElementById("final").textContent = "…thinking…";
document.getElementById("deid").textContent = "…";
document.getElementById("agent").textContent = "…";
try {
const res = await fetch(GATEWAY_URL, {
method: "POST",
@@ -60,9 +67,10 @@
body: JSON.stringify({ query: document.getElementById("q").value }),
});
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("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) {
document.getElementById("final").textContent = "Error: " + e.message;
} finally {