Implement reversible PII de-identification round trip (T1–T7)

Build the AWS side of the HNCB demo end to end (region ap-southeast-1):

- T1 /tokenize + T2 /restore: Lambdas behind a public API Gateway (shared-secret
  auth), Presidio detection, random per-request tokens, DynamoDB vault; overlap
  resolution so a ROC ID stays TW_ROC_ID.
- T3: Presidio made private (SG-locked to the tokenize Lambda in-VPC; DynamoDB
  gateway endpoint); only /tokenize + /restore are public.
- T4: RAG Lambda registered as an MCP tool on an AgentCore Gateway (AWS_IAM/SigV4);
  agentcore_setup.sh + a SigV4 MCP invoke test.
- T5: Strands agent deployed to AgentCore Runtime; SigV4 gateway auth, apac
  inference profile, pinned deps.
- T6: advisor UI on S3+CloudFront with a Fusion-less demo orchestrator (/demo)
  chaining tokenize -> runtime -> restore.
- T7: README runbook + trace check; teardown deletes gateway/runtime/memory/ECR.

Verified live: the cloud AgentCore/Bedrock trace shows only tokens, never the
real name. Secrets stay in gitignored local.auto.tfvars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 17:10:58 +10:00
parent 6b2a051be3
commit 78d67a2469
21 changed files with 1439 additions and 107 deletions

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}"})