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

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