diff --git a/.github/workflows/surgical-event-pipeline.yml b/.github/workflows/surgical-event-pipeline.yml new file mode 100644 index 00000000..767f46c0 --- /dev/null +++ b/.github/workflows/surgical-event-pipeline.yml @@ -0,0 +1,85 @@ + +# Store an IAM user access key in GitHub Actions secrets named `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`. +# See the documentation for each action used below for the recommended IAM policies for this IAM user, +# and best practices on handling the access key credentials. +# +# This needs to be copied to and run from the root directory of the repository using the existing directory structure .github/workflows + +name: Update API Gateway model with latest schema + +on: + + # Uncomment the next two lines to run the GitHub Actions workflow on pushes to main branch + push: + branches: [ "main" ] + + # allows you to run workflow manually - https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow + workflow_dispatch: + +env: + AWS_REGION: "us-west-2" # Replace with your AWS Region + REGISTRY_NAME: "discovered-schemas" + SCHEMA_NAME: "scheduling.event@Surgical" + API_ID: "ao5s48kjth" # Replace with your API Gateway ID + API_MODEL_NAME: "surgical" + TEST_FILE_PREFIX: "stage2" + +permissions: + contents: read + +jobs: + + deploy: + name: Update API Gateway model with latest schema + runs-on: ubuntu-latest + container: node:20 + + defaults: + run: + working-directory: apigw-eventbridge-schema-validation/cicd-driven-solution + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ env.AWS_REGION }} + + - name: Install dependencies + run: | + npm install + + - name: Update schema + env: + SchemaName: ${{ env.SCHEMA_NAME }} + ApiId: ${{ env.API_ID }} + ApiModelName: ${{ env.API_MODEL_NAME }} + SchemaRegistry: ${{ env.REGISTRY_NAME }} + run: node src/updateSchema.mjs + + - name: Wait for API Gateway deployment to stage + run: sleep 60 + + - name: integration-tests + continue-on-error: true + id: integration-tests + run: npm test __tests__/${{ env.TEST_FILE_PREFIX }}-integration.test.mjs + + # If any tests fail in previous step, rollback schema + - name: Rollback Schema + if: steps.integration-tests.outcome == 'failure' + continue-on-error: true + env: + SchemaName: ${{ env.SCHEMA_NAME }} + ApiId: ${{ env.API_ID }} + ApiModelName: ${{ env.API_MODEL_NAME }} + SchemaRegistry: ${{ env.REGISTRY_NAME }} + Rollback: true + run: node src/updateSchema.mjs + + + diff --git a/README.md b/README.md index 8972d00b..daeca7a6 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,12 @@ This is example implementation of the Amazon API Gateway routing rules based on [[README]](./apigw-vtl-editor/) +## [apigw-mtls-multi-tenant](./apigw-mtls-multi-tenant) + This sample demonstrates how to build and operate multi-tenant mTLS on Amazon API Gateway at scale. It covers full certificate chain validation, intermediate CA rotation without downtime, expiry enforcement behavior, and tenant routing via Lambda Authorizer. The project includes a deployable SAM stack, an automated CLI test suite validating 7 scenarios, and an interactive demo web app for visual presentation of each mTLS behavior. + + [[README]](./apigw-mtls-multi-tenant) + + ## [das-lambda-java-sam](./das-lambda-java-sam) This sample demonstrates an end-to-end pipeline for processing Amazon Aurora (PostgreSQL-compatible) Database Activity Streams (DAS) using AWS SAM. A Java AWS Lambda function consumes the Database Activity Streams, decrypts the records with AWS KMS, and delivers the audit events to Amazon S3 and Amazon OpenSearch Service for search and visualization. It includes a CloudFront + Application Load Balancer + private Amazon EC2 reverse-proxy architecture for secure access to OpenSearch Dashboards, along with deployment and cleanup automation scripts. diff --git a/apigw-eventbridge-schema-validation/cicd-driven-solution/.github/workflows/surgical-event-pipeline.yml b/apigw-eventbridge-schema-validation/cicd-driven-solution/.github/workflows/surgical-event-pipeline.yml index c426b4bf..6934b1d6 100644 --- a/apigw-eventbridge-schema-validation/cicd-driven-solution/.github/workflows/surgical-event-pipeline.yml +++ b/apigw-eventbridge-schema-validation/cicd-driven-solution/.github/workflows/surgical-event-pipeline.yml @@ -10,8 +10,8 @@ name: Update API Gateway model with latest schema on: # Uncomment the next two lines to run the GitHub Actions workflow on pushes to main branch - # push: - # branches: [ "main" ] + #push: + #branches: [ "main" ] # allows you to run workflow manually - https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow workflow_dispatch: diff --git a/apigw-mtls-multi-tenant/README.md b/apigw-mtls-multi-tenant/README.md new file mode 100644 index 00000000..b3a3b2ac --- /dev/null +++ b/apigw-mtls-multi-tenant/README.md @@ -0,0 +1,286 @@ +# Operating multi-tenant mTLS on API Gateway at scale + +A deployable SAM project for building and operating multi-tenant mTLS on API Gateway. Covers full certificate chain validation, intermediate CA rotation without downtime, expiry enforcement behavior, and tenant routing via Lambda Authorizer. Includes an interactive demo web app for validating each scenario. + +## Background + +In standard TLS (what happens when you visit any HTTPS site), only the server proves its identity to the client. Mutual TLS (mTLS) adds the reverse: the client also presents a certificate to prove its identity to the server. Both sides authenticate each other. + +Certificates are organized in a chain of trust: + +``` +┌─────────────────────────────────────────────────────┐ +│ Root CA │ +│ (Self-signed, ultimate trust anchor) │ +│ Stored offline, rarely used │ +└──────────────────────┬──────────────────────────────┘ + │ signs + ▼ +┌─────────────────────────────────────────────────────┐ +│ Intermediate CA │ +│ (Signed by Root, issues leaf certs) │ +│ Operationally active, can be rotated │ +└──────────────────────┬──────────────────────────────┘ + │ signs + ▼ +┌─────────────────────────────────────────────────────┐ +│ Leaf (Client) Certificate │ +│ (Presented by the client during TLS handshake) │ +│ Contains: subject CN, issuer, validity dates │ +└─────────────────────────────────────────────────────┘ +``` + +Each certificate is signed by the one above it. To verify a leaf certificate, the server needs to walk up this chain and confirm every link. The truststore is the file that tells the server which CAs to trust — in API Gateway's case, a PEM file in S3 containing the intermediate and root CA certificates. +For a deeper introduction to PKI and certificate chains, see the [AWS documentation on mutual TLS authentication](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-mutual-tls.html). + +API Gateway mTLS requires the **full chain of trust** in the truststore — it does not auto-discover intermediate CAs from a root-only truststore. This creates operational considerations around how you build and maintain the truststore over time. + +### This project addresses three areas: + +**Building** — Setting up mTLS with a multi-tenant architecture where tenant identity is determined by client certificate, not URL path. + +**Validating** — Proving the chain validation behavior: what works, what doesn't, and where the documented limits are. + +**Operating** — Demonstrating certificate lifecycle operations: intermediate CA rotation with zero downtime, expiry enforcement behavior, truststore update propagation, and where to integrate revocation checks. + +Reference: [Configuring your truststore](https://docs.aws.amazon.com/apigateway/latest/developerguide/rest-api-mutual-tls.html) — *"You must include the complete chain of trust, starting from the issuing CA certificate, up to the root CA certificate, in your truststore."* + + +## Test Scenarios + +| # | Scenario | Expected | Area | +|---|----------|----------|------| +| 1 | Full-chain truststore (intermediate + root) | ✅ 200 | Validation | +| 2 | Root-only truststore | ❌ 403 | Validation | +| 3 | Expired leaf cert | ❌ 403 | Operations (expiry) | +| 4 | Intermediate CA rotation (both in truststore) | ✅ 200 | Operations (rotation) | +| 5 | No client cert | ❌ Rejected | Validation | +| 6 | Untrusted self-signed cert | ❌ 403 | Validation | +| 7 | Max chain depth (root + 3 intermediates) | ✅ 200 | Limits | + +## Prerequisites + +- AWS CLI v2 with permissions for API Gateway, Lambda, S3, Route 53, CloudWatch Logs, IAM +- [SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) installed +- `openssl` and `curl` available locally +- Python 3.12+ (for SAM build and the demo web app) +- A custom domain with: + - A Route 53 hosted zone + - An ACM certificate (publicly trusted) covering the domain name + +## Getting Started + +> **Security Note:** Running the test scenarios will generate private keys (`*.key` files) in the certs/ directory that can issue trusted certificates against this projects truststore. Do not share, commit, or display these files. You can cleanup by deleting these files after completion of tests. + +### 1. Configure + +```bash +cp config.env.example config.env +``` + +Edit `config.env` with your values: + +| Variable | Description | +|----------|-------------| +| `DOMAIN_NAME` | Custom domain for the API (e.g., `mtls-demo.example.com`) | +| `HOSTED_ZONE_ID` | Route 53 hosted zone ID for the domain | +| `CERTIFICATE_ARN` | ACM certificate ARN for server-side TLS | +| `TRUSTSTORE_BUCKET` | S3 bucket name for the truststore (will be created) | +| `STACK_NAME` | CloudFormation stack name (default: `mtls-demo`) | +| `AWS_REGION` | Deployment region (default: `us-east-1`) | + +### 2. Deploy + +```bash +bash scripts/deploy.sh +``` + +This will: +1. Generate a 3-tier PKI (Root → Intermediate → Leaf) plus tenant certs in `./certs/` +2. Create an S3 bucket and upload the truststore (intermediate + root) +3. Build and deploy the SAM stack + +### 3. Wait for the domain + +The custom domain takes 1-2 minutes to become available after deployment: + +```bash +source config.env +aws apigatewayv2 get-domain-name --domain-name ${DOMAIN_NAME} --region ${AWS_REGION} \ + --query 'DomainNameConfigurations[0].DomainNameStatus' --output text +``` + +Wait for `AVAILABLE` before testing. + +### 4. Run tests + +**CLI test suite (7 automated tests):** + +```bash +bash scripts/test-mtls.sh +``` + +**Visual demo web app:** + +```bash +bash scripts/run-demo.sh +# Open http://localhost:5001 +``` + +### 5. Cleanup + +```bash +bash scripts/teardown.sh +``` + +Removes the CloudFormation stack, empties and deletes the S3 bucket. Local certs are not removed. + +## Demo Web App + +The web app at `demo/` provides a visual interface for running each test scenario interactively: + +- **Left sidebar** — tabbed test scenarios plus multi-tenant demo +- **Certificate panel** — shows truststore contents and client cert being presented (appears immediately when a test starts) +- **Live terminal** — streams real-time output via Server-Sent Events +- **Pass/Fail results** — color-coded badges with summary of what each test proves + +### Multi-Tenant Tab + +Demonstrates certificate-based tenant routing through a shared endpoint: + +- **Tenant A** and **Tenant B** each have unique client certs (both issued by the same intermediate CA) +- Both tenants hit the **same endpoint** (`/api`) — routing is determined by cert identity, not URL path +- The Lambda Authorizer extracts the cert, logs details to CloudWatch, maps the CN to a tenant context +- The backend handler returns tenant-specific data based on the authorizer context +- **Authorizer Logs** button fetches CloudWatch logs showing the full cert processing flow + +## Architecture + + +``` +Client (curl) + │ + │ TLS handshake with client cert + ▼ +API Gateway (Custom Domain + mTLS) + │ + │ Validates client cert against truststore in S3 + │ (full chain required: intermediate + root) + │ + ├── GET / → Handler (no authorizer, returns cert details) + │ + └── GET /api → Lambda Authorizer + │ Extracts cert (subject, issuer, serial, validity) + │ Logs to CloudWatch (revocation check point) + │ Maps CN → tenant identity + ▼ + Tenant Handler + Returns tenant-specific response +``` + +![API Gateway Multi-Tenant Architecture](./apigw-mtls-multi-tenant-architecture.png) + + +## Project Structure + +``` +. +├── template.yaml # SAM template (HTTP API, Lambda Authorizer, mTLS) +├── samconfig.toml # SAM CLI defaults +├── config.env.example # Configuration template +├── src/ +│ ├── handler.py # Basic handler (returns cert details from mTLS) +│ ├── authorizer.py # Lambda Authorizer (cert logging + tenant mapping) +│ └── tenant_handler.py # Tenant-specific response handler +├── scripts/ +│ ├── generate-certs.sh # Generates PKI: root, intermediate, leaf, tenant certs +│ ├── deploy.sh # Full deployment orchestration +│ ├── test-mtls.sh # Automated CLI test suite (7 scenarios) +│ ├── run-demo.sh # Launches the demo web app +│ └── teardown.sh # Removes all deployed resources +├── demo/ +│ ├── app.py # Flask backend with SSE streaming +│ ├── requirements.txt # Python dependencies (flask, cryptography) +│ └── static/ +│ └── index.html # Single-page frontend +└── certs/ # Generated certs (gitignored) +``` + +## Certificate Chain + +``` +┌──────────────────────────────────────┐ +│ Root CA (self-signed, 10 years) │ +│ CN=Demo Root CA │ +└──────────────────┬───────────────────┘ + │ signs +┌──────────────────▼───────────────────┐ +│ Intermediate CA (5 years) │ +│ CN=Demo Intermediate CA │ +│ CA:TRUE, pathlen:0 │ +└────┬──────────┬──────────┬───────────┘ + │ │ │ +┌────▼────┐ ┌──▼─────┐ ┌──▼─────┐ +│ Leaf │ │Tenant A│ │Tenant B│ +│ (1yr) │ │ (1yr) │ │ (1yr) │ +└─────────┘ └────────┘ └────────┘ +``` + +**Truststore** = Intermediate CA + Root CA (2 certs). Clients present their leaf cert only. + +## Empirical Limits + +These limits were tested empirically against the API Gateway service. Understanding them is critical for planning truststore operations at scale: + +| Limit | Value | Operational Impact | +|-------|-------|-------------------| +| Max chain depth | 4 (root + 3 intermediates) | Plan PKI hierarchy accordingly | +| Max truststore file size | < 1000 KB | ~500-900 unique CAs depending on key size | +| Max certs tested in truststore | 929 (at 999 KB) | No separate cert-count limit observed | +| Duplicate subject DNs | Not allowed | Each CA must have a unique subject | +| Expired intermediate in truststore | Accepted with warning, leaf rejected | Rotate intermediates before expiry | +| Truststore propagation | 60-90 seconds | Factor into rotation runbooks | +| Typical capacity (4096-bit CAs) | ~500-550 unique CAs | ~1.8 KB per PEM entry | +| Typical capacity (2048-bit CAs) | ~900 unique CAs | ~1.1 KB per PEM entry | + +## API Gateway mTLS Validation Behavior + +Understanding what API Gateway does and does not check is key to designing your operational procedures: + +| Check | Behavior | Operational Note | +|-------|----------|-----------------| +| X.509 syntax | Enforced | — | +| Signature chain integrity | Full chain must be resolvable via truststore | Include all intermediates | +| Leaf certificate expiry | Enforced | Clients must renew before NotAfter | +| Intermediate CA expiry | Enforced (expired intermediate breaks the chain) | Rotate before expiry — hard failure | +| Max chain depth | 4 levels (root + up to 3 intermediates) | Design PKI within this constraint | +| CRL / OCSP revocation | **Not checked natively** — use Lambda Authorizer | See revocation pattern below | +| Auto-walk from root-only truststore | **Not supported** | Must include full chain explicitly | +| Duplicate subjects in truststore | Rejected at import | Use unique CNs per CA | + +## Lambda Authorizer — Revocation Check Pattern + +API Gateway does not perform CRL or OCSP revocation checking natively. The Lambda Authorizer in this project demonstrates the integration point where these operational checks can be added: + +```python +# Production implementation points (see src/authorizer.py): +# 1. Download CRL from CA's distribution point, check serial number +# 2. Send OCSP request to responder URL from cert's AIA extension +# 3. Query internal denylist (DynamoDB/Redis) for revoked serials +# +# if is_revoked(serial, issuer_dn): +# return {"isAuthorized": False, "context": {"reason": "Certificate revoked"}} +``` + +For a detailed writeup on client certificate revocation, see [How to implement client certificate revocation list checks at scale with API Gateway blog.](https://aws.amazon.com/blogs/security/how-to-implement-client-certificate-revocation-list-checks-at-scale-with-api-gateway/) + +The authorizer logs full cert details (subject, issuer, serial, validity) to CloudWatch on every request, providing an audit trail and the data needed for operational monitoring (e.g., alerting on certs approaching expiry). + +## References + +- [API Gateway mTLS documentation](https://docs.aws.amazon.com/apigateway/latest/developerguide/rest-api-mutual-tls.html) +- [Using a third-party client certificate with mTLS](https://repost.aws/knowledge-center/api-gateway-tls-certificate) +- [Use ACM Private CA for API Gateway mTLS](https://aws.amazon.com/blogs/security/use-acm-private-ca-for-amazon-api-gateway-mutual-tls/) +- [aws-samples/api-gateway-auth](https://github.com/aws-samples/api-gateway-auth) +- [How to implement client certificate revocation list checks at scale with API Gateway blog.](https://aws.amazon.com/blogs/security/how-to-implement-client-certificate-revocation-list-checks-at-scale-with-api-gateway/) + diff --git a/apigw-mtls-multi-tenant/apigw-mtls-multi-tenant-architecture.png b/apigw-mtls-multi-tenant/apigw-mtls-multi-tenant-architecture.png new file mode 100644 index 00000000..cc931fd2 Binary files /dev/null and b/apigw-mtls-multi-tenant/apigw-mtls-multi-tenant-architecture.png differ diff --git a/apigw-mtls-multi-tenant/config.env.example b/apigw-mtls-multi-tenant/config.env.example new file mode 100644 index 00000000..ea13f97b --- /dev/null +++ b/apigw-mtls-multi-tenant/config.env.example @@ -0,0 +1,21 @@ +# config.env — Fill in your values and save as config.env +# This file is sourced by the deploy/test/teardown scripts. + +# Your custom domain name (must match the ACM certificate) +DOMAIN_NAME="your-domain-name.com" + +# Route 53 Hosted Zone ID for the domain above +HOSTED_ZONE_ID="" + +# ACM certificate ARN for the custom domain (server-side TLS) +# This must be a publicly-trusted cert (ACM-issued or imported) for your domain. +CERTIFICATE_ARN="arn:aws:acm:us-east-1:123456789012:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + +# S3 bucket name for the truststore PEM (will be created if it doesn't exist) +TRUSTSTORE_BUCKET="your-trustore-bucket-name-here" + +# CloudFormation stack name +STACK_NAME="mtls-demo" + +# AWS region +AWS_REGION="us-east-1" diff --git a/apigw-mtls-multi-tenant/demo/app.py b/apigw-mtls-multi-tenant/demo/app.py new file mode 100644 index 00000000..9eece33a --- /dev/null +++ b/apigw-mtls-multi-tenant/demo/app.py @@ -0,0 +1,908 @@ +""" +mTLS Demo Web App — Visual test runner for API Gateway mTLS full-chain validation. +Streams test output via Server-Sent Events so the customer sees real-time progress. +""" + +import json +import os +import queue +import re +import subprocess +import tempfile +import threading +import time +from pathlib import Path + +from flask import Flask, Response, jsonify, send_from_directory + +app = Flask(__name__, static_folder="static") + +# Resolve project paths +PROJECT_DIR = Path(__file__).resolve().parent.parent +CERT_DIR = PROJECT_DIR / "certs" +CONFIG_FILE = PROJECT_DIR / "config.env" + +# Hostname validation pattern — only allows valid DNS characters +_HOSTNAME_RE = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?$") + + +def _validate_hostname(domain): + """Validate and return a safe hostname string. + + Raises ValueError if the domain contains characters outside the + allowed DNS hostname set (alphanumeric, hyphens, dots). + """ + if not domain or not _HOSTNAME_RE.match(domain) or len(domain) > 253: + raise ValueError(f"Invalid hostname: {domain!r}") + return domain + + +def load_config(): + """Load config.env as a dict.""" + config = {} + if CONFIG_FILE.exists(): + for line in CONFIG_FILE.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + config[key.strip()] = value.strip().strip('"').strip("'") + return config + + +VALID_SSE_EVENTS = {"start", "output", "certinfo", "result", "error", "done"} + + +def sse_event(event_type, data): + """Format a Server-Sent Event. event_type is validated against an allowlist.""" + if event_type not in VALID_SSE_EVENTS: + event_type = "error" + return f"event: {event_type}\ndata: {json.dumps(data)}\n\n" + + +def get_cert_info(cert_path): + """Extract subject, issuer, dates from a PEM certificate file.""" + result = subprocess.run( + ["openssl", "x509", "-in", str(cert_path), "-noout", + "-subject", "-issuer", "-dates"], + capture_output=True, text=True, + ) + info = {} + for line in result.stdout.strip().splitlines(): + if line.startswith("subject="): + info["subject"] = line.split("=", 1)[1].strip().lstrip("/").replace("/", ", ") + elif line.startswith("issuer="): + info["issuer"] = line.split("=", 1)[1].strip().lstrip("/").replace("/", ", ") + elif line.startswith("notBefore="): + info["notBefore"] = line.split("=", 1)[1].strip() + elif line.startswith("notAfter="): + info["notAfter"] = line.split("=", 1)[1].strip() + return info + + +def get_truststore_certs(pem_path): + """Parse a PEM file with multiple certs and return info for each.""" + certs = [] + pem_text = Path(pem_path).read_text() + # Split into individual PEM blocks + import re + pem_blocks = re.findall( + r"(-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----)", + pem_text, re.DOTALL + ) + for i, block in enumerate(pem_blocks): + tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".pem", delete=False) + tmp.write(block) + tmp.close() + info = get_cert_info(tmp.name) + os.unlink(tmp.name) + info["index"] = i + 1 + certs.append(info) + return certs + + +def emit_cert_context(truststore_path, client_cert_path=None, client_label=None): + """Emit SSE events showing truststore and client cert details.""" + # Truststore info + truststore_certs = get_truststore_certs(truststore_path) + truststore_data = { + "truststore": { + "file": Path(truststore_path).name, + "certCount": len(truststore_certs), + "certs": truststore_certs, + }, + "clientCert": None, + } + + # Client cert info + if client_cert_path and Path(client_cert_path).exists(): + client_info = get_cert_info(client_cert_path) + client_info["label"] = client_label or Path(client_cert_path).name + truststore_data["clientCert"] = client_info + + return sse_event("certinfo", truststore_data) + + +# ─── Routes ───────────────────────────────────────────────────────────────── + + +@app.route("/") +def index(): + return send_from_directory("static", "index.html") + + +@app.route("/api/config") +def get_config(): + """Return current config for display.""" + config = load_config() + return jsonify( + { + "domain": config.get("DOMAIN_NAME", ""), + "bucket": config.get("TRUSTSTORE_BUCKET", ""), + "region": config.get("AWS_REGION", ""), + "certsExist": CERT_DIR.exists() + and (CERT_DIR / "leaf-client.pem").exists(), + } + ) + + +@app.route("/api/chain") +def get_chain(): + """Return certificate chain details.""" + if not CERT_DIR.exists(): + return jsonify({"error": "Certificates not generated yet"}), 404 + + chain = [] + for name, label in [ + ("rootCA.pem", "Root CA"), + ("intermediateCA.pem", "Intermediate CA"), + ("leaf-client.pem", "Leaf (Client)"), + ]: + cert_path = CERT_DIR / name + if cert_path.exists(): + result = subprocess.run( + ["openssl", "x509", "-in", str(cert_path), "-noout", + "-subject", "-issuer", "-dates", "-serial"], + capture_output=True, text=True, + ) + chain.append({"name": label, "file": name, "details": result.stdout.strip()}) + return jsonify(chain) + + +@app.route("/api/test/") +def run_test(test_id): + """Run a specific test scenario and stream results via SSE using a queue.""" + config = load_config() + domain = config.get("DOMAIN_NAME", "") + bucket = config.get("TRUSTSTORE_BUCKET", "") + region = config.get("AWS_REGION", "us-east-1") + + if not domain: + return Response( + sse_event("error", {"text": "config.env not configured"}), + mimetype="text/event-stream", + ) + + test_runners = { + 1: run_test_fullchain, + 2: run_test_rootonly, + 3: run_test_expired, + 4: run_test_rotation, + 5: run_test_nocert, + 6: run_test_untrusted, + 7: run_test_max_depth, + } + + runner = test_runners.get(test_id) + if not runner: + return Response( + sse_event("error", {"text": f"Unknown test: {test_id}"}), + mimetype="text/event-stream", + ) + + # Use a queue so the background thread can push events immediately + q = queue.Queue() + + def run_in_thread(): + try: + q.put(sse_event("start", {"test_id": test_id})) + gen = runner(domain, bucket, region) + result = None + try: + while True: + event = next(gen) + q.put(event) + except StopIteration as e: + result = e.value + if result: + q.put(sse_event("result", result)) + except Exception as e: + q.put(sse_event("error", {"text": str(e)})) + q.put(sse_event("done", {})) + q.put(None) # Sentinel to signal stream end + + threading.Thread(target=run_in_thread, daemon=True).start() + + def generate(): + while True: + try: + event = q.get(timeout=180) + except queue.Empty: + break + if event is None: + break + yield event + + return Response( + generate(), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + "Connection": "keep-alive", + }, + ) + + +# ─── Test Runners ─────────────────────────────────────────────────────────── + + +def curl_with_cert(domain, cert=None, key=None, path="/"): + """Make an HTTPS request and return (http_code, body). + + Uses the requests library instead of subprocess to avoid SAST findings. + Domain is validated against a strict hostname pattern before use. + """ + import requests + validated_domain = _validate_hostname(domain) + url = f"https://{validated_domain}{path}" + try: + if cert and key: + resp = requests.get(url, cert=(str(cert), str(key)), timeout=15) + else: + resp = requests.get(url, timeout=15) + return str(resp.status_code), resp.text + except requests.exceptions.SSLError: + return "403", "" + except requests.exceptions.ConnectionError: + return "000", "" + except requests.exceptions.Timeout: + return "000", "" + + +def update_truststore_and_wait(pem_path, bucket, region, domain): + """Upload truststore and wait for propagation. Yields SSE events.""" + # Upload + yield sse_event("output", {"text": f"Uploading truststore: {Path(pem_path).name}"}) + subprocess.run( + ["aws", "s3", "cp", str(pem_path), f"s3://{bucket}/truststore.pem", + "--region", region, "--quiet"], + capture_output=True, + ) + + # Get version and trigger reimport + yield sse_event("output", {"text": "Triggering domain truststore reimport..."}) + version_result = subprocess.run( + ["aws", "s3api", "head-object", "--bucket", bucket, "--key", "truststore.pem", + "--region", region, "--query", "VersionId", "--output", "text"], + capture_output=True, text=True, + ) + version = version_result.stdout.strip() + + mtls_arg = f"TruststoreUri=s3://{bucket}/truststore.pem" + if version and version != "None": + mtls_arg += f",TruststoreVersion={version}" + + subprocess.run( + ["aws", "apigatewayv2", "update-domain-name", "--domain-name", domain, + "--region", region, "--mutual-tls-authentication", mtls_arg], + capture_output=True, + ) + + # Poll for AVAILABLE + yield sse_event("output", {"text": "Waiting for propagation..."}) + for attempt in range(12): + status_result = subprocess.run( + ["aws", "apigatewayv2", "get-domain-name", "--domain-name", domain, + "--region", region, "--query", + "DomainNameConfigurations[0].DomainNameStatus", "--output", "text"], + capture_output=True, text=True, + ) + status = status_result.stdout.strip() + if status == "AVAILABLE": + yield sse_event("output", {"text": f"Domain status: AVAILABLE ✓"}) + return + yield sse_event("output", {"text": f"Domain status: {status} (attempt {attempt+1}/12)"}) + time.sleep(15) + + yield sse_event("output", {"text": "⚠ Domain did not reach AVAILABLE in time"}) + + +def run_test_fullchain(domain, bucket, region): + """Test 1: Full-chain truststore validates leaf cert.""" + # Show cert context immediately so customer sees what's being tested + yield emit_cert_context(CERT_DIR / "truststore.pem", CERT_DIR / "leaf-client.pem", "leaf-client.pem (valid)") + + yield sse_event("output", {"text": "Setting truststore to: intermediate + root (full chain)"}) + yield from update_truststore_and_wait(CERT_DIR / "truststore.pem", bucket, region, domain) + + yield sse_event("output", {"text": ""}) + yield sse_event("output", {"text": "Sending request with leaf client cert..."}) + yield sse_event("output", {"text": f" curl --cert leaf-client.pem --key leaf-client.key https://{domain}/"}) + + code, body = curl_with_cert(domain, CERT_DIR / "leaf-client.pem", CERT_DIR / "leaf-client.key") + + yield sse_event("output", {"text": f" HTTP {code}"}) + if body: + yield sse_event("output", {"text": ""}) + try: + formatted = json.dumps(json.loads(body), indent=2) + for line in formatted.splitlines(): + yield sse_event("output", {"text": f" {line}"}) + except json.JSONDecodeError: + yield sse_event("output", {"text": f" {body}"}) + + passed = code == "200" + return {"passed": passed, "http_code": code} + + +def run_test_rootonly(domain, bucket, region): + """Test 2: Root-only truststore rejects leaf cert.""" + # Show cert context immediately — root-only truststore + the leaf we'll present + yield emit_cert_context(CERT_DIR / "rootCA.pem", CERT_DIR / "leaf-client.pem", "leaf-client.pem (valid)") + + yield sse_event("output", {"text": "Setting truststore to: root CA ONLY (no intermediate)"}) + yield from update_truststore_and_wait(CERT_DIR / "rootCA.pem", bucket, region, domain) + + yield sse_event("output", {"text": ""}) + yield sse_event("output", {"text": "Sending request with leaf client cert..."}) + yield sse_event("output", {"text": f" curl --cert leaf-client.pem --key leaf-client.key https://{domain}/"}) + + code, body = curl_with_cert(domain, CERT_DIR / "leaf-client.pem", CERT_DIR / "leaf-client.key") + + yield sse_event("output", {"text": f" HTTP {code}"}) + if body: + yield sse_event("output", {"text": f" {body}"}) + + passed = code == "403" or (code.replace("0", "") == "") + + # Restore full chain + yield sse_event("output", {"text": ""}) + yield sse_event("output", {"text": "Restoring full-chain truststore..."}) + yield from update_truststore_and_wait(CERT_DIR / "truststore.pem", bucket, region, domain) + + return {"passed": passed, "http_code": code} + + +def run_test_expired(domain, bucket, region): + """Test 3: Expired leaf cert is rejected.""" + yield sse_event("output", {"text": "Generating expired leaf certificate (using Python cryptography)..."}) + + # Generate expired cert + from cryptography import x509 as cx509 + from cryptography.x509.oid import NameOID + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import rsa + from datetime import datetime, timedelta, timezone + + ca_cert_pem = (CERT_DIR / "intermediateCA.pem").read_bytes() + ca_key_pem = (CERT_DIR / "intermediateCA.key").read_bytes() + ca_cert = cx509.load_pem_x509_certificate(ca_cert_pem) + ca_key = serialization.load_pem_private_key(ca_key_pem, password=None) + + leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + now = datetime.now(timezone.utc) + + cert = ( + cx509.CertificateBuilder() + .subject_name(cx509.Name([ + cx509.NameAttribute(NameOID.COMMON_NAME, "expired-client"), + cx509.NameAttribute(NameOID.ORGANIZATION_NAME, "mTLS Demo"), + cx509.NameAttribute(NameOID.COUNTRY_NAME, "US"), + ])) + .issuer_name(ca_cert.subject) + .public_key(leaf_key.public_key()) + .serial_number(cx509.random_serial_number()) + .not_valid_before(now - timedelta(days=3)) + .not_valid_after(now - timedelta(days=1)) + .sign(ca_key, hashes.SHA256()) + ) + + expired_dir = Path(tempfile.mkdtemp()) + (expired_dir / "expired.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM)) + (expired_dir / "expired.key").write_bytes( + leaf_key.private_bytes(serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption()) + ) + + not_after = (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M UTC") + yield sse_event("output", {"text": f" Cert expired: {not_after}"}) + + yield emit_cert_context(CERT_DIR / "truststore.pem", expired_dir / "expired.pem", "expired.pem (EXPIRED)") + + yield sse_event("output", {"text": ""}) + yield sse_event("output", {"text": "Sending request with expired cert..."}) + yield sse_event("output", {"text": f" curl --cert expired.pem --key expired.key https://{domain}/"}) + + code, body = curl_with_cert(domain, expired_dir / "expired.pem", expired_dir / "expired.key") + + yield sse_event("output", {"text": f" HTTP {code}"}) + if body: + yield sse_event("output", {"text": f" {body}"}) + + # Cleanup + import shutil + shutil.rmtree(expired_dir, ignore_errors=True) + + passed = code == "403" or (code.replace("0", "") == "") + return {"passed": passed, "http_code": code} + + +def run_test_rotation(domain, bucket, region): + """Test 4: Intermediate CA rotation with overlap.""" + yield sse_event("output", {"text": "Generating rotated Intermediate CA v2..."}) + + rotation_dir = Path(tempfile.mkdtemp()) + + # Generate new intermediate + subprocess.run( + ["openssl", "genrsa", "-out", str(rotation_dir / "intCA-v2.key"), "4096"], + capture_output=True, + ) + subprocess.run( + ["openssl", "req", "-new", "-key", str(rotation_dir / "intCA-v2.key"), + "-subj", "/CN=Demo Intermediate CA v2/O=mTLS Demo/C=US", + "-out", str(rotation_dir / "intCA-v2.csr")], + capture_output=True, + ) + subprocess.run( + ["openssl", "x509", "-req", "-in", str(rotation_dir / "intCA-v2.csr"), + "-CA", str(CERT_DIR / "rootCA.pem"), "-CAkey", str(CERT_DIR / "rootCA.key"), + "-CAcreateserial", "-days", "1825", "-sha256", + "-extfile", "/dev/stdin", + "-out", str(rotation_dir / "intCA-v2.pem")], + input="basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign", + capture_output=True, text=True, + ) + + yield sse_event("output", {"text": "Issuing new leaf cert from rotated intermediate..."}) + + subprocess.run( + ["openssl", "genrsa", "-out", str(rotation_dir / "leaf-v2.key"), "2048"], + capture_output=True, + ) + subprocess.run( + ["openssl", "req", "-new", "-key", str(rotation_dir / "leaf-v2.key"), + "-subj", "/CN=rotated-client/O=mTLS Demo/C=US", + "-out", str(rotation_dir / "leaf-v2.csr")], + capture_output=True, + ) + subprocess.run( + ["openssl", "x509", "-req", "-in", str(rotation_dir / "leaf-v2.csr"), + "-CA", str(rotation_dir / "intCA-v2.pem"), + "-CAkey", str(rotation_dir / "intCA-v2.key"), + "-CAcreateserial", "-days", "365", "-sha256", + "-out", str(rotation_dir / "leaf-v2.pem")], + capture_output=True, + ) + + # Build rotation truststore (both intermediates + root) + yield sse_event("output", {"text": "Building truststore with BOTH intermediates + root..."}) + truststore_content = ( + (CERT_DIR / "intermediateCA.pem").read_text() + + (rotation_dir / "intCA-v2.pem").read_text() + + (CERT_DIR / "rootCA.pem").read_text() + ) + rotation_truststore = rotation_dir / "truststore-rotation.pem" + rotation_truststore.write_text(truststore_content) + cert_count = truststore_content.count("BEGIN CERTIFICATE") + yield sse_event("output", {"text": f" Truststore contains {cert_count} certificates"}) + + # Show cert context immediately before waiting + yield emit_cert_context(rotation_truststore, rotation_dir / "leaf-v2.pem", "leaf-v2.pem (new intermediate)") + + yield from update_truststore_and_wait(rotation_truststore, bucket, region, domain) + + yield sse_event("output", {"text": ""}) + yield sse_event("output", {"text": "Testing NEW leaf cert (from rotated intermediate)..."}) + code, body = curl_with_cert(domain, rotation_dir / "leaf-v2.pem", rotation_dir / "leaf-v2.key") + yield sse_event("output", {"text": f" HTTP {code}"}) + + passed = code == "200" + + if passed: + yield sse_event("output", {"text": ""}) + yield sse_event("output", {"text": "Verifying OLD leaf still works during overlap..."}) + code_old, _ = curl_with_cert(domain, CERT_DIR / "leaf-client.pem", CERT_DIR / "leaf-client.key") + yield sse_event("output", {"text": f" Old leaf: HTTP {code_old}"}) + if code_old == "200": + yield sse_event("output", {"text": " ✓ Both old and new CAs active simultaneously"}) + + # Restore + yield sse_event("output", {"text": ""}) + yield sse_event("output", {"text": "Restoring original truststore..."}) + yield from update_truststore_and_wait(CERT_DIR / "truststore.pem", bucket, region, domain) + + import shutil + shutil.rmtree(rotation_dir, ignore_errors=True) + + return {"passed": passed, "http_code": code} + + +def run_test_nocert(domain, bucket, region): + """Test 5: No client cert — rejected.""" + yield emit_cert_context(CERT_DIR / "truststore.pem") + + yield sse_event("output", {"text": "Sending request with NO client certificate..."}) + yield sse_event("output", {"text": f" curl https://{domain}/"}) + + code, body = curl_with_cert(domain) + + yield sse_event("output", {"text": f" HTTP {code}"}) + if body: + yield sse_event("output", {"text": f" {body}"}) + + passed = code == "403" or (code.replace("0", "") == "") + return {"passed": passed, "http_code": code} + + +def run_test_untrusted(domain, bucket, region): + """Test 6: Untrusted self-signed cert — rejected.""" + yield sse_event("output", {"text": "Generating untrusted self-signed certificate..."}) + + rogue_dir = Path(tempfile.mkdtemp()) + subprocess.run( + ["openssl", "req", "-x509", "-newkey", "rsa:2048", + "-keyout", str(rogue_dir / "rogue.key"), + "-out", str(rogue_dir / "rogue.pem"), + "-days", "1", "-nodes", "-subj", "/CN=rogue-client/O=Untrusted Org/C=US"], + capture_output=True, + ) + + yield emit_cert_context(CERT_DIR / "truststore.pem", rogue_dir / "rogue.pem", "rogue.pem (UNTRUSTED self-signed)") + + yield sse_event("output", {"text": "Sending request with untrusted cert..."}) + yield sse_event("output", {"text": f" curl --cert rogue.pem --key rogue.key https://{domain}/"}) + + code, body = curl_with_cert(domain, rogue_dir / "rogue.pem", rogue_dir / "rogue.key") + + yield sse_event("output", {"text": f" HTTP {code}"}) + if body: + yield sse_event("output", {"text": f" {body}"}) + + import shutil + shutil.rmtree(rogue_dir, ignore_errors=True) + + passed = code == "403" or (code.replace("0", "") == "") + return {"passed": passed, "http_code": code} + + +def run_test_max_depth(domain, bucket, region): + """Test 7: Max chain depth (root + 3 intermediates + leaf = depth 4).""" + yield sse_event("output", {"text": "Building max-depth chain: Root → Int1 → Int2 → Int3 → Leaf"}) + yield sse_event("output", {"text": " (API Gateway max chain depth = 4)"}) + yield sse_event("output", {"text": ""}) + + deep_dir = Path(tempfile.mkdtemp()) + + # Use existing root CA + root_pem = CERT_DIR / "rootCA.pem" + root_key = CERT_DIR / "rootCA.key" + + # Generate 3 intermediate CAs chained together + prev_cert = str(root_pem) + prev_key = str(root_key) + int_certs = [] + + for i in range(1, 4): + int_key_path = str(deep_dir / f"int{i}.key") + int_csr_path = str(deep_dir / f"int{i}.csr") + int_pem_path = str(deep_dir / f"int{i}.pem") + + pathlen = 3 - i # int1=2, int2=1, int3=0 + + yield sse_event("output", {"text": f" Generating Intermediate CA {i} (pathlen={pathlen})..."}) + + subprocess.run( + ["openssl", "genrsa", "-out", int_key_path, "4096"], + capture_output=True, + ) + subprocess.run( + ["openssl", "req", "-new", "-key", int_key_path, + "-subj", f"/CN=Demo Intermediate CA L{i}/O=mTLS Demo/C=US", + "-out", int_csr_path], + capture_output=True, + ) + subprocess.run( + ["openssl", "x509", "-req", "-in", int_csr_path, + "-CA", prev_cert, "-CAkey", prev_key, + "-CAcreateserial", "-days", "1825", "-sha256", + "-extfile", "/dev/stdin", + "-out", int_pem_path], + input=f"basicConstraints=critical,CA:TRUE,pathlen:{pathlen}\nkeyUsage=critical,keyCertSign,cRLSign", + capture_output=True, text=True, + ) + + int_certs.append(int_pem_path) + prev_cert = int_pem_path + prev_key = int_key_path + + # Generate leaf signed by int3 + yield sse_event("output", {"text": " Generating leaf cert (signed by Int3)..."}) + leaf_key_path = str(deep_dir / "leaf-deep.key") + leaf_csr_path = str(deep_dir / "leaf-deep.csr") + leaf_pem_path = str(deep_dir / "leaf-deep.pem") + + subprocess.run( + ["openssl", "genrsa", "-out", leaf_key_path, "2048"], + capture_output=True, + ) + subprocess.run( + ["openssl", "req", "-new", "-key", leaf_key_path, + "-subj", "/CN=deep-chain-client/O=mTLS Demo/C=US", + "-out", leaf_csr_path], + capture_output=True, + ) + subprocess.run( + ["openssl", "x509", "-req", "-in", leaf_csr_path, + "-CA", int_certs[-1], "-CAkey", str(deep_dir / "int3.key"), + "-CAcreateserial", "-days", "365", "-sha256", + "-out", leaf_pem_path], + capture_output=True, + ) + + # Build truststore: all 3 intermediates + root + yield sse_event("output", {"text": ""}) + yield sse_event("output", {"text": " Building truststore: Int1 + Int2 + Int3 + Root (4 certs)..."}) + truststore_content = "" + for cert_path in int_certs: + truststore_content += Path(cert_path).read_text() + truststore_content += root_pem.read_text() + + deep_truststore = deep_dir / "truststore-deep.pem" + deep_truststore.write_text(truststore_content) + cert_count = truststore_content.count("BEGIN CERTIFICATE") + yield sse_event("output", {"text": f" Truststore contains {cert_count} certificates"}) + + # Verify chain locally + # Need to concatenate all intermediates as untrusted + untrusted_bundle = deep_dir / "untrusted-bundle.pem" + untrusted_content = "" + for cert_path in int_certs: + untrusted_content += Path(cert_path).read_text() + untrusted_bundle.write_text(untrusted_content) + + verify_result = subprocess.run( + ["openssl", "verify", "-CAfile", str(root_pem), + "-untrusted", str(untrusted_bundle), leaf_pem_path], + capture_output=True, text=True, + ) + if "OK" in verify_result.stdout: + yield sse_event("output", {"text": " Local chain verification: OK ✓"}) + else: + yield sse_event("output", {"text": f" Local chain verification FAILED: {verify_result.stderr.strip()}"}) + + # Show cert context immediately before waiting + yield emit_cert_context(deep_truststore, leaf_pem_path, "leaf-deep.pem (depth=4 chain)") + + # Upload and wait + yield from update_truststore_and_wait(deep_truststore, bucket, region, domain) + + yield sse_event("output", {"text": ""}) + yield sse_event("output", {"text": "Testing leaf cert through 4-level chain..."}) + yield sse_event("output", {"text": f" curl --cert leaf-deep.pem --key leaf-deep.key https://{domain}/"}) + + code, body = curl_with_cert(domain, leaf_pem_path, leaf_key_path) + + yield sse_event("output", {"text": f" HTTP {code}"}) + if body: + try: + formatted = json.dumps(json.loads(body), indent=2) + for line in formatted.splitlines(): + yield sse_event("output", {"text": f" {line}"}) + except json.JSONDecodeError: + yield sse_event("output", {"text": f" {body}"}) + + passed = code == "200" + + # Restore original truststore + yield sse_event("output", {"text": ""}) + yield sse_event("output", {"text": "Restoring original truststore..."}) + yield from update_truststore_and_wait(CERT_DIR / "truststore.pem", bucket, region, domain) + + import shutil + shutil.rmtree(deep_dir, ignore_errors=True) + + return {"passed": passed, "http_code": code} + + +# ─── Multi-Tenant Demo Routes ────────────────────────────────────────────── + + +@app.route("/api/tenant-test/") +def run_tenant_test(tenant): + """Run a multi-tenant test (tenant-a or tenant-b) and stream results via SSE.""" + config = load_config() + domain = config.get("DOMAIN_NAME", "") + region = config.get("AWS_REGION", "us-east-1") + + if tenant not in ("tenant-a", "tenant-b"): + return Response( + sse_event("error", {"text": f"Unknown tenant: {tenant}"}), + mimetype="text/event-stream", + ) + + q = queue.Queue() + + def run_in_thread(): + try: + q.put(sse_event("start", {"tenant": tenant})) + gen = run_tenant_request(domain, region, tenant) + result = None + try: + while True: + event = next(gen) + q.put(event) + except StopIteration as e: + result = e.value + if result: + q.put(sse_event("result", result)) + except Exception as e: + q.put(sse_event("error", {"text": str(e)})) + q.put(sse_event("done", {})) + q.put(None) + + threading.Thread(target=run_in_thread, daemon=True).start() + + def generate(): + while True: + try: + event = q.get(timeout=60) + except queue.Empty: + break + if event is None: + break + yield event + + return Response( + generate(), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache, no-transform", + "X-Accel-Buffering": "no", + "Connection": "keep-alive", + }, + ) + + +@app.route("/api/tenant-logs") +def get_tenant_logs(): + """Fetch recent CloudWatch logs from the authorizer function.""" + config = load_config() + region = config.get("AWS_REGION", "us-east-1") + stack_name = config.get("STACK_NAME", "mtls-demo") + + # Get the authorizer function name from CloudFormation + result = subprocess.run( + ["aws", "cloudformation", "describe-stack-resource", + "--stack-name", stack_name, "--logical-resource-id", "MtlsAuthorizerFunction", + "--region", region, "--query", "StackResourceDetail.PhysicalResourceId", + "--output", "text"], + capture_output=True, text=True, + ) + function_name = result.stdout.strip() + if not function_name: + return jsonify({"error": "Could not find authorizer function"}), 404 + + log_group = f"/aws/lambda/{function_name}" + + # Get the latest log stream + result = subprocess.run( + ["aws", "logs", "describe-log-streams", + "--log-group-name", log_group, + "--region", region, + "--order-by", "LastEventTime", + "--descending", + "--limit", "1", + "--query", "logStreams[0].logStreamName", + "--output", "text"], + capture_output=True, text=True, + ) + stream_name = result.stdout.strip() + if not stream_name or stream_name == "None": + return jsonify({"logGroup": log_group, "messages": [], "note": "No log streams found"}) + + # Get recent log events from the latest stream + result = subprocess.run( + ["aws", "logs", "get-log-events", + "--log-group-name", log_group, + "--log-stream-name", stream_name, + "--region", region, + "--limit", "100", + "--query", "events[].message", + "--output", "json"], + capture_output=True, text=True, + ) + + try: + messages = json.loads(result.stdout) if result.stdout.strip() else [] + except json.JSONDecodeError: + messages = [] + + # Filter to only the interesting authorizer log lines + filtered = [] + for msg in messages: + msg = msg.strip() + if not msg: + continue + # Skip START/END/REPORT lines + if msg.startswith("START ") or msg.startswith("END ") or msg.startswith("REPORT "): + continue + # Strip the Lambda log prefix [INFO] timestamp requestId + if "\t" in msg: + parts = msg.split("\t", 3) + if len(parts) >= 4: + msg = parts[3].strip() + elif len(parts) == 3: + msg = parts[2].strip() + filtered.append(msg) + + return jsonify({"logGroup": log_group, "messages": filtered}) + + +def run_tenant_request(domain, region, tenant): + """Execute a tenant-specific API request and stream output.""" + cert_file = CERT_DIR / f"{tenant}.pem" + key_file = CERT_DIR / f"{tenant}.key" + + if not cert_file.exists(): + yield sse_event("output", {"text": f"ERROR: {tenant} cert not found. Regenerate certs."}) + return {"passed": False, "http_code": "N/A"} + + # Emit cert context + yield emit_cert_context(CERT_DIR / "truststore.pem", cert_file, f"{tenant}.pem") + + tenant_label = "Tenant A" if tenant == "tenant-a" else "Tenant B" + endpoint = f"https://{domain}/api" + + yield sse_event("output", {"text": f"Multi-Tenant Request: {tenant_label}"}) + yield sse_event("output", {"text": f" Endpoint: {endpoint} (same for all tenants)"}) + yield sse_event("output", {"text": f" Client cert: {tenant}.pem (determines tenant identity)"}) + yield sse_event("output", {"text": ""}) + yield sse_event("output", {"text": "Request flow:"}) + yield sse_event("output", {"text": " 1. mTLS handshake (cert validated against truststore)"}) + yield sse_event("output", {"text": " 2. Lambda Authorizer extracts cert, logs to CloudWatch"}) + yield sse_event("output", {"text": " 3. Authorizer maps CN to tenant, returns context"}) + yield sse_event("output", {"text": " 4. Handler reads tenantId from context → returns tenant-specific data"}) + yield sse_event("output", {"text": ""}) + yield sse_event("output", {"text": f" curl --cert {tenant}.pem --key {tenant}.key {endpoint}"}) + yield sse_event("output", {"text": ""}) + + # Make the request + code, body = curl_with_cert(domain, cert_file, key_file, path="/api") + + yield sse_event("output", {"text": f" HTTP {code}"}) + + if code == "200" and body: + yield sse_event("output", {"text": ""}) + try: + formatted = json.dumps(json.loads(body), indent=2) + for line in formatted.splitlines(): + yield sse_event("output", {"text": f" {line}"}) + except json.JSONDecodeError: + yield sse_event("output", {"text": f" {body}"}) + + yield sse_event("output", {"text": ""}) + yield sse_event("output", {"text": " ✓ Lambda Authorizer processed cert and routed to tenant handler"}) + yield sse_event("output", {"text": " ✓ Cert details logged to CloudWatch (check authorizer logs)"}) + elif body: + yield sse_event("output", {"text": f" {body}"}) + + passed = code == "200" + return {"passed": passed, "http_code": code, "tenant": tenant} + + +if __name__ == "__main__": + print("\n mTLS Demo Web App") + print(" http://localhost:5001\n") + app.run(host="127.0.0.1", port=5001, debug=False, threaded=True) diff --git a/apigw-mtls-multi-tenant/demo/requirements.txt b/apigw-mtls-multi-tenant/demo/requirements.txt new file mode 100644 index 00000000..71037712 --- /dev/null +++ b/apigw-mtls-multi-tenant/demo/requirements.txt @@ -0,0 +1,3 @@ +flask==3.1.1 +cryptography>=42.0.0 +requests>=2.31.0 diff --git a/apigw-mtls-multi-tenant/demo/static/index.html b/apigw-mtls-multi-tenant/demo/static/index.html new file mode 100644 index 00000000..c73c0068 --- /dev/null +++ b/apigw-mtls-multi-tenant/demo/static/index.html @@ -0,0 +1,709 @@ + + + + + +API Gateway mTLS — Full-Chain Validation Demo + + + + +
+

API Gateway mTLS — Full-Chain Validation Demo

+

+ mTLS full-chain truststore validation · Root → Intermediate → Leaf · Multi-tenant routing +

+
+ +
+
+
Chain Validation Tests
+
+
Multi-Tenant Demo
+
+ + Tenant Routing +
+
+
+
+ +
+
+

Multi-Tenant mTLS — Lambda Authorizer Demo

+

+ Both tenants use separate client certificates issued by the same Intermediate CA — + sharing a single truststore. The Lambda Authorizer extracts the cert CN, logs full + cert details to CloudWatch (where CRL/OCSP revocation checks could be added in + production), and maps the certificate identity to a tenant context that the backend + handler uses to return tenant-specific responses. All tenants hit the same endpoint. +

+
+ + + + +
+
+
+
+
+

🔒 Shared Truststore (serves both tenants)

+
Run a tenant test to see certs
+
+
+

📄 Tenant Client Certificate

+
Select a tenant above
+
+
+
+
+ Select Tenant A or Tenant B to send an authenticated request.

+ Both tenants hit the same endpoint (/api). The only difference
+ is which client certificate is presented. The Lambda Authorizer:

+ • Extracts the full cert (subject, issuer, serial, validity)
+ • Logs cert details to CloudWatch (revocation check point)
+ • Maps CN → tenant identity (no path-based routing)
+ • Returns tenant context to the backend handler
+ • Handler returns tenant-specific data based on cert identity +
+
+
+
+
+
+ + + + + + diff --git a/apigw-mtls-multi-tenant/scripts/deploy.sh b/apigw-mtls-multi-tenant/scripts/deploy.sh new file mode 100755 index 00000000..493f2af1 --- /dev/null +++ b/apigw-mtls-multi-tenant/scripts/deploy.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# deploy.sh — End-to-end deployment: generate certs, upload truststore, deploy SAM stack. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +CERT_DIR="${PROJECT_DIR}/certs" +CONFIG_FILE="${PROJECT_DIR}/config.env" + +# ─── Load configuration ──────────────────────────────────────────────────── +if [[ ! -f "${CONFIG_FILE}" ]]; then + echo "ERROR: config.env not found. Copy config.env.example and fill in your values." + echo " cp config.env.example config.env" + exit 1 +fi + +source "${CONFIG_FILE}" + +# Validate required vars +for var in DOMAIN_NAME HOSTED_ZONE_ID CERTIFICATE_ARN TRUSTSTORE_BUCKET STACK_NAME AWS_REGION; do + if [[ -z "${!var:-}" ]]; then + echo "ERROR: ${var} is not set in config.env" + exit 1 + fi +done + +echo "╔══════════════════════════════════════════════════════════════╗" +echo "║ API Gateway mTLS Full-Chain Truststore Demo — Deployment ║" +echo "╚══════════════════════════════════════════════════════════════╝" +echo "" +echo " Domain: ${DOMAIN_NAME}" +echo " Region: ${AWS_REGION}" +echo " Stack: ${STACK_NAME}" +echo " Truststore: s3://${TRUSTSTORE_BUCKET}/truststore.pem" +echo "" + +# ─── Step 1: Generate certificates ───────────────────────────────────────── +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "STEP 1: Generate PKI certificates" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +if [[ -f "${CERT_DIR}/truststore.pem" ]]; then + echo " Certificates already exist in ${CERT_DIR}. Skipping generation." + echo " (Delete ${CERT_DIR} to regenerate.)" +else + bash "${SCRIPT_DIR}/generate-certs.sh" "${CERT_DIR}" +fi +echo "" + +# ─── Step 2: Upload truststore to S3 ─────────────────────────────────────── +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "STEP 2: Upload truststore to S3" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +# Create bucket if it doesn't exist +if ! aws s3api head-bucket --bucket "${TRUSTSTORE_BUCKET}" --region "${AWS_REGION}" 2>/dev/null; then + echo " Creating bucket: ${TRUSTSTORE_BUCKET}" + if [[ "${AWS_REGION}" == "us-east-1" ]]; then + aws s3api create-bucket --bucket "${TRUSTSTORE_BUCKET}" --region "${AWS_REGION}" + else + aws s3api create-bucket --bucket "${TRUSTSTORE_BUCKET}" --region "${AWS_REGION}" \ + --create-bucket-configuration LocationConstraint="${AWS_REGION}" + fi + aws s3api put-bucket-versioning --bucket "${TRUSTSTORE_BUCKET}" \ + --versioning-configuration Status=Enabled --region "${AWS_REGION}" +fi + +echo " Uploading truststore.pem..." +aws s3 cp "${CERT_DIR}/truststore.pem" "s3://${TRUSTSTORE_BUCKET}/truststore.pem" \ + --region "${AWS_REGION}" +echo " Done." +echo "" + +# ─── Step 3: SAM build & deploy ──────────────────────────────────────────── +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "STEP 3: SAM build & deploy" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +cd "${PROJECT_DIR}" + +echo " Building..." +sam build --template-file template.yaml + +echo " Deploying..." +sam deploy \ + --stack-name "${STACK_NAME}" \ + --region "${AWS_REGION}" \ + --resolve-s3 \ + --capabilities CAPABILITY_IAM \ + --no-fail-on-empty-changeset \ + --parameter-overrides \ + "DomainName=${DOMAIN_NAME}" \ + "HostedZoneId=${HOSTED_ZONE_ID}" \ + "CertificateArn=${CERTIFICATE_ARN}" \ + "TruststoreBucket=${TRUSTSTORE_BUCKET}" \ + "TruststoreKey=truststore.pem" + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "DEPLOYMENT COMPLETE" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo " API Endpoint: https://${DOMAIN_NAME}/" +echo "" +echo " NOTE: The custom domain may take a few minutes to become AVAILABLE." +echo " Check status with:" +echo " aws apigatewayv2 get-domain-name --domain-name ${DOMAIN_NAME} --region ${AWS_REGION}" +echo "" +echo " Once ready, test with:" +echo " bash scripts/test-mtls.sh" +echo "" diff --git a/apigw-mtls-multi-tenant/scripts/generate-certs.sh b/apigw-mtls-multi-tenant/scripts/generate-certs.sh new file mode 100755 index 00000000..9888621e --- /dev/null +++ b/apigw-mtls-multi-tenant/scripts/generate-certs.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# generate-certs.sh — Build a 3-tier PKI: Root CA → Intermediate CA → Leaf (client) cert +# Also assembles the truststore PEM (intermediate + root) for API Gateway mTLS. +set -euo pipefail + +CERT_DIR="${1:-./certs}" +DAYS_ROOT=3650 +DAYS_INTERMEDIATE=1825 +DAYS_LEAF=365 + +echo "==> Generating certificates in: ${CERT_DIR}" +mkdir -p "${CERT_DIR}" +cd "${CERT_DIR}" + +# ─── Root CA ──────────────────────────────────────────────────────────────── +echo "[1/4] Generating Root CA..." +openssl genrsa -out rootCA.key 4096 2>/dev/null +openssl req -x509 -new -nodes -key rootCA.key -sha256 -days ${DAYS_ROOT} \ + -subj "/CN=Demo Root CA/O=mTLS Demo/C=US" -out rootCA.pem + +# ─── Intermediate CA (signed by Root) ─────────────────────────────────────── +echo "[2/4] Generating Intermediate CA..." +openssl genrsa -out intermediateCA.key 4096 2>/dev/null +openssl req -new -key intermediateCA.key \ + -subj "/CN=Demo Intermediate CA/O=mTLS Demo/C=US" -out intermediateCA.csr +openssl x509 -req -in intermediateCA.csr -CA rootCA.pem -CAkey rootCA.key \ + -CAcreateserial -days ${DAYS_INTERMEDIATE} -sha256 \ + -extfile <(printf "basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign") \ + -out intermediateCA.pem 2>/dev/null + +# ─── Leaf / Client cert (signed by Intermediate) ─────────────────────────── +echo "[3/6] Generating Leaf (client) certificate..." +openssl genrsa -out leaf-client.key 2048 2>/dev/null +openssl req -new -key leaf-client.key \ + -subj "/CN=demo-client/O=mTLS Demo/C=US" -out leaf-client.csr +openssl x509 -req -in leaf-client.csr -CA intermediateCA.pem -CAkey intermediateCA.key \ + -CAcreateserial -days ${DAYS_LEAF} -sha256 -out leaf-client.pem 2>/dev/null + +# ─── Tenant A cert (signed by Intermediate) ───────────────────────────────── +echo "[4/6] Generating Tenant A client certificate..." +openssl genrsa -out tenant-a.key 2048 2>/dev/null +openssl req -new -key tenant-a.key \ + -subj "/CN=tenant-a/O=Tenant A/OU=API-Access/C=US" -out tenant-a.csr +openssl x509 -req -in tenant-a.csr -CA intermediateCA.pem -CAkey intermediateCA.key \ + -CAcreateserial -days ${DAYS_LEAF} -sha256 -out tenant-a.pem 2>/dev/null + +# ─── Tenant B cert (signed by Intermediate) ───────────────────────────────── +echo "[5/6] Generating Tenant B client certificate..." +openssl genrsa -out tenant-b.key 2048 2>/dev/null +openssl req -new -key tenant-b.key \ + -subj "/CN=tenant-b/O=Tenant B/OU=API-Access/C=US" -out tenant-b.csr +openssl x509 -req -in tenant-b.csr -CA intermediateCA.pem -CAkey intermediateCA.key \ + -CAcreateserial -days ${DAYS_LEAF} -sha256 -out tenant-b.pem 2>/dev/null + +# ─── Assemble Truststore (intermediate + root) ───────────────────────────── +echo "[6/6] Assembling truststore PEM (intermediate + root)..." +cat intermediateCA.pem rootCA.pem > truststore.pem + +# ─── Summary ──────────────────────────────────────────────────────────────── +echo "" +echo "==> Certificate generation complete!" +echo " Root CA: ${CERT_DIR}/rootCA.pem" +echo " Intermediate CA: ${CERT_DIR}/intermediateCA.pem" +echo " Client cert: ${CERT_DIR}/leaf-client.pem" +echo " Client key: ${CERT_DIR}/leaf-client.key" +echo " Tenant A cert: ${CERT_DIR}/tenant-a.pem" +echo " Tenant A key: ${CERT_DIR}/tenant-a.key" +echo " Tenant B cert: ${CERT_DIR}/tenant-b.pem" +echo " Tenant B key: ${CERT_DIR}/tenant-b.key" +echo " Truststore: ${CERT_DIR}/truststore.pem" +echo "" +echo " Truststore contains $(grep -c 'BEGIN CERTIFICATE' truststore.pem) certificate(s)" +echo "" + +# Verify the chains +echo "==> Verifying chains:" +echo " leaf-client → intermediate → root" +openssl verify -CAfile rootCA.pem -untrusted intermediateCA.pem leaf-client.pem +echo " tenant-a → intermediate → root" +openssl verify -CAfile rootCA.pem -untrusted intermediateCA.pem tenant-a.pem +echo " tenant-b → intermediate → root" +openssl verify -CAfile rootCA.pem -untrusted intermediateCA.pem tenant-b.pem +echo "" diff --git a/apigw-mtls-multi-tenant/scripts/run-demo.sh b/apigw-mtls-multi-tenant/scripts/run-demo.sh new file mode 100755 index 00000000..449d666f --- /dev/null +++ b/apigw-mtls-multi-tenant/scripts/run-demo.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# run-demo.sh — Start the mTLS demo web app for customer presentation. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +DEMO_DIR="${PROJECT_DIR}/demo" + +echo "╔══════════════════════════════════════════════════════════════╗" +echo "║ API Gateway mTLS — Demo Web App ║" +echo "╚══════════════════════════════════════════════════════════════╝" +echo "" + +# Check config +if [[ ! -f "${PROJECT_DIR}/config.env" ]]; then + echo "ERROR: config.env not found. Run deployment first." + exit 1 +fi + +# Set up venv if needed +if [[ ! -d "${DEMO_DIR}/.venv" ]]; then + echo " Setting up Python virtual environment..." + python3 -m venv "${DEMO_DIR}/.venv" + "${DEMO_DIR}/.venv/bin/pip" install -q -r "${DEMO_DIR}/requirements.txt" + echo " Done." + echo "" +fi + +echo " Starting demo server at http://localhost:5001" +echo " Press Ctrl+C to stop." +echo "" + +"${DEMO_DIR}/.venv/bin/python" "${DEMO_DIR}/app.py" diff --git a/apigw-mtls-multi-tenant/scripts/teardown.sh b/apigw-mtls-multi-tenant/scripts/teardown.sh new file mode 100755 index 00000000..34083a38 --- /dev/null +++ b/apigw-mtls-multi-tenant/scripts/teardown.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# teardown.sh — Remove all deployed resources. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +CONFIG_FILE="${PROJECT_DIR}/config.env" + +if [[ ! -f "${CONFIG_FILE}" ]]; then + echo "ERROR: config.env not found." + exit 1 +fi + +source "${CONFIG_FILE}" + +echo "╔══════════════════════════════════════════════════════════════╗" +echo "║ API Gateway mTLS Demo — Teardown ║" +echo "╚══════════════════════════════════════════════════════════════╝" +echo "" +echo " This will delete:" +echo " - CloudFormation stack: ${STACK_NAME}" +echo " - S3 bucket contents: ${TRUSTSTORE_BUCKET}" +echo " - S3 bucket: ${TRUSTSTORE_BUCKET}" +echo "" +read -p " Continue? (y/N) " -n 1 -r +echo "" + +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo " Aborted." + exit 0 +fi + +echo "" +echo " [1/3] Deleting CloudFormation stack..." +aws cloudformation delete-stack --stack-name "${STACK_NAME}" --region "${AWS_REGION}" +echo " Waiting for stack deletion..." +aws cloudformation wait stack-delete-complete --stack-name "${STACK_NAME}" --region "${AWS_REGION}" || true +echo " Done." + +echo " [2/3] Emptying S3 bucket..." +aws s3 rm "s3://${TRUSTSTORE_BUCKET}" --recursive --region "${AWS_REGION}" 2>/dev/null || true +echo " Done." + +echo " [3/3] Deleting S3 bucket..." +# Delete all object versions (required for versioned buckets) +aws s3api list-object-versions --bucket "${TRUSTSTORE_BUCKET}" --region "${AWS_REGION}" \ + --query 'Versions[].{Key:Key,VersionId:VersionId}' --output json 2>/dev/null | \ + python3 -c " +import json, sys, subprocess +versions = json.load(sys.stdin) +if versions: + for v in versions: + subprocess.run(['aws', 's3api', 'delete-object', + '--bucket', '${TRUSTSTORE_BUCKET}', + '--key', v['Key'], + '--version-id', v['VersionId'], + '--region', '${AWS_REGION}'], check=False) +" 2>/dev/null || true + +aws s3api delete-bucket --bucket "${TRUSTSTORE_BUCKET}" --region "${AWS_REGION}" 2>/dev/null || true +echo " Done." + +echo "" +echo " Teardown complete. Local certs in ./certs/ were NOT removed." +echo " To remove local certs: rm -rf ./certs/" +echo "" diff --git a/apigw-mtls-multi-tenant/scripts/test-mtls.sh b/apigw-mtls-multi-tenant/scripts/test-mtls.sh new file mode 100755 index 00000000..fd647c58 --- /dev/null +++ b/apigw-mtls-multi-tenant/scripts/test-mtls.sh @@ -0,0 +1,429 @@ +#!/usr/bin/env bash +# test-mtls.sh — Comprehensive API Gateway mTLS validation for customer PKI scenario. +# +# Customer context: Multi-tenant APIs with mTLS for client authentication. +# They need to validate: +# (1) Full-chain truststore workaround works with their PKI structure +# (2) Edge cases: size limits, propagation, intermediate rotation +# (3) Leaf certificate expiry enforcement +# +# Tests: +# 1. Full-chain truststore (intermediate + root) → leaf validates ✅ +# 2. Root-only truststore → leaf REJECTED (confirmed gap) +# 3. Expired leaf cert → REJECTED (expiry enforced) +# 4. Rotated intermediate CA → new leaf validates (rotation works) +# 5. No client cert → rejected (mTLS enforced) +# 6. Untrusted cert → rejected (chain-of-trust enforced) +# +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +CERT_DIR="${PROJECT_DIR}/certs" +CONFIG_FILE="${PROJECT_DIR}/config.env" + +if [[ ! -f "${CONFIG_FILE}" ]]; then + echo "ERROR: config.env not found." + exit 1 +fi + +# shellcheck source=/dev/null +source "${CONFIG_FILE}" + +API_URL="https://${DOMAIN_NAME}/" +PASS=0 +FAIL=0 +SKIP=0 + +# ─── Helper: wait for domain to reach AVAILABLE status ────────────────────── +wait_for_domain_available() { + local max_attempts=12 # 12 * 15s = 3 min max + local attempt=0 + local status + + while (( attempt < max_attempts )); do + status=$(aws apigatewayv2 get-domain-name --domain-name "${DOMAIN_NAME}" \ + --region "${AWS_REGION}" --query 'DomainNameConfigurations[0].DomainNameStatus' \ + --output text 2>/dev/null || echo "UNKNOWN") + + if [[ "${status}" == "AVAILABLE" ]]; then + return 0 + fi + + ((attempt++)) + echo " [⏳] Domain status: ${status} (attempt ${attempt}/${max_attempts}, waiting 15s)..." + sleep 15 + done + + echo " [⚠] Domain did not reach AVAILABLE within 3 minutes (last status: ${status})" + return 1 +} + +# ─── Helper: update truststore on S3 and wait for propagation ─────────────── +update_truststore() { + local pem_file="$1" + local description="$2" + + echo " [→] Uploading ${description} to s3://${TRUSTSTORE_BUCKET}/truststore.pem..." + aws s3 cp "${pem_file}" "s3://${TRUSTSTORE_BUCKET}/truststore.pem" \ + --region "${AWS_REGION}" --quiet + + local version + version=$(aws s3api head-object \ + --bucket "${TRUSTSTORE_BUCKET}" --key truststore.pem \ + --region "${AWS_REGION}" --query 'VersionId' --output text 2>/dev/null || echo "") + + echo " [→] Triggering domain truststore reimport..." + if [[ -n "${version}" && "${version}" != "None" ]]; then + aws apigatewayv2 update-domain-name \ + --domain-name "${DOMAIN_NAME}" \ + --region "${AWS_REGION}" \ + --mutual-tls-authentication "TruststoreUri=s3://${TRUSTSTORE_BUCKET}/truststore.pem,TruststoreVersion=${version}" \ + > /dev/null 2>&1 || true + else + aws apigatewayv2 update-domain-name \ + --domain-name "${DOMAIN_NAME}" \ + --region "${AWS_REGION}" \ + --mutual-tls-authentication "TruststoreUri=s3://${TRUSTSTORE_BUCKET}/truststore.pem" \ + > /dev/null 2>&1 || true + fi + + echo " [→] Waiting for truststore propagation..." + wait_for_domain_available +} + +# ─── Helper: test curl call and return HTTP code ──────────────────────────── +call_api() { + if [[ $# -eq 0 ]]; then + curl -s -o /tmp/mtls-test-output.txt -w "%{http_code}" \ + "${API_URL}" 2>/dev/null || echo "000" + else + curl -s -o /tmp/mtls-test-output.txt -w "%{http_code}" \ + "$@" "${API_URL}" 2>/dev/null || echo "000" + fi +} + +echo "╔══════════════════════════════════════════════════════════════════════╗" +echo "║ API Gateway mTLS — Full Certificate Chain Validation Test Suite ║" +echo "╠══════════════════════════════════════════════════════════════════════╣" +echo "║ Multi-tenant mTLS with full-chain truststore validation ║" +echo "║ Validates: workaround, expiry, rotation, enforcement ║" +echo "╚══════════════════════════════════════════════════════════════════════╝" +echo "" +echo " Target: ${API_URL}" +echo " Truststore: s3://${TRUSTSTORE_BUCKET}/truststore.pem" +echo "" + +# ═══════════════════════════════════════════════════════════════════════════════ +# TEST 1: Full-chain truststore validates leaf cert +# ═══════════════════════════════════════════════════════════════════════════════ +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "TEST 1: Full-chain truststore (intermediate + root) → leaf validates" +echo " Proves: the documented workaround works" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +update_truststore "${CERT_DIR}/truststore.pem" "full-chain truststore (intermediate + root)" + +HTTP_CODE=$(call_api --cert "${CERT_DIR}/leaf-client.pem" --key "${CERT_DIR}/leaf-client.key") + +if [[ "${HTTP_CODE}" == "200" ]]; then + echo " ✅ PASS — HTTP 200. Leaf → Intermediate → Root chain resolved." + echo "" + echo " Response:" + python3 -m json.tool /tmp/mtls-test-output.txt 2>/dev/null || cat /tmp/mtls-test-output.txt + ((PASS++)) +else + echo " ❌ FAIL — Expected 200, got ${HTTP_CODE}" + cat /tmp/mtls-test-output.txt 2>/dev/null + ((FAIL++)) +fi +echo "" + +# ═══════════════════════════════════════════════════════════════════════════════ +# TEST 2: Root-only truststore — leaf REJECTED +# ═══════════════════════════════════════════════════════════════════════════════ +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "TEST 2: Root-only truststore → leaf cert REJECTED" +echo " Proves: API GW does NOT auto-walk root → intermediate → leaf" +echo " Proves: API GW does NOT auto-walk root → intermediate → leaf" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +update_truststore "${CERT_DIR}/rootCA.pem" "root-only truststore" + +HTTP_CODE=$(call_api --cert "${CERT_DIR}/leaf-client.pem" --key "${CERT_DIR}/leaf-client.key") + +if [[ "${HTTP_CODE}" == "403" || "${HTTP_CODE}" =~ ^0+$ ]]; then + echo " ✅ PASS — Leaf cert REJECTED with root-only truststore (HTTP ${HTTP_CODE})" + echo "" + echo " ╔═══════════════════════════════════════════════════════════════════╗" + echo " ║ CONFIRMED: API Gateway does NOT auto-walk the certificate chain. ║" + echo " ║ The intermediate CA MUST be present in the truststore. ║" + echo " ╚═══════════════════════════════════════════════════════════════════╝" + ((PASS++)) +else + echo " ❌ FAIL — Expected rejection, got HTTP ${HTTP_CODE}" + echo " (Truststore update may not have fully propagated)" + cat /tmp/mtls-test-output.txt 2>/dev/null + ((FAIL++)) +fi +echo "" + +# Restore full-chain for remaining tests +echo " [→] Restoring full-chain truststore for remaining tests..." +update_truststore "${CERT_DIR}/truststore.pem" "full-chain truststore (restored)" +echo "" + +# ═══════════════════════════════════════════════════════════════════════════════ +# TEST 3: Expired leaf cert — REJECTED +# ═══════════════════════════════════════════════════════════════════════════════ +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "TEST 3: Expired leaf cert → REJECTED" +echo " Proves: API GW checks leaf certificate validity period" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +EXPIRED_DIR=$(mktemp -d) + +echo " [→] Generating expired leaf certificate (using Python cryptography)..." + +# LibreSSL doesn't support backdating certs, so use Python's cryptography library +python3 << PYEOF +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from datetime import datetime, timedelta, timezone +import sys + +# Load intermediate CA cert and key +with open("${CERT_DIR}/intermediateCA.pem", "rb") as f: + ca_cert = x509.load_pem_x509_certificate(f.read()) +with open("${CERT_DIR}/intermediateCA.key", "rb") as f: + ca_key = serialization.load_pem_private_key(f.read(), password=None) + +# Generate a new key for the expired leaf +leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + +# Build a cert that expired yesterday (valid from 3 days ago to 1 day ago) +now = datetime.now(timezone.utc) +subject = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, "expired-client"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "mTLS Demo"), + x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), +]) + +cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(ca_cert.subject) + .public_key(leaf_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(days=3)) + .not_valid_after(now - timedelta(days=1)) + .sign(ca_key, hashes.SHA256()) +) + +# Write cert and key +with open("${EXPIRED_DIR}/expired.pem", "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) +with open("${EXPIRED_DIR}/expired.key", "wb") as f: + f.write(leaf_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.TraditionalOpenSSL, + serialization.NoEncryption(), + )) + +print(" [→] Expired cert created: valid {} to {} (expired)".format( + (now - timedelta(days=3)).strftime("%Y-%m-%d"), + (now - timedelta(days=1)).strftime("%Y-%m-%d"), +)) +PYEOF + +if [[ -f "${EXPIRED_DIR}/expired.pem" ]]; then + # Confirm it's expired + if ! openssl x509 -in "${EXPIRED_DIR}/expired.pem" -checkend 0 > /dev/null 2>&1; then + echo " [→] Expiry confirmed locally. Testing against API Gateway..." + + HTTP_CODE=$(call_api --cert "${EXPIRED_DIR}/expired.pem" --key "${EXPIRED_DIR}/expired.key") + + if [[ "${HTTP_CODE}" == "403" || "${HTTP_CODE}" =~ ^0+$ ]]; then + echo " ✅ PASS — Expired leaf cert REJECTED (HTTP ${HTTP_CODE})" + echo " API Gateway validates certificate NotAfter date." + ((PASS++)) + else + echo " ❌ FAIL — Expected rejection of expired cert, got HTTP ${HTTP_CODE}" + cat /tmp/mtls-test-output.txt 2>/dev/null + ((FAIL++)) + fi + else + echo " ⚠️ SKIP — Generated cert is not actually expired (clock issue?)." + ((SKIP++)) + fi +else + echo " ⚠️ SKIP — Failed to generate expired cert." + echo " Ensure python3 with 'cryptography' package is installed:" + echo " pip3 install cryptography" + ((SKIP++)) +fi + +rm -rf "${EXPIRED_DIR}" +echo "" + +# ═══════════════════════════════════════════════════════════════════════════════ +# TEST 4: Intermediate CA rotation — new leaf from new intermediate validates +# ═══════════════════════════════════════════════════════════════════════════════ +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "TEST 4: Intermediate CA rotation — new intermediate + leaf validates" +echo " Proves: truststore can be updated for CA rotation without" +echo " downtime (CA key rotation scenario)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +ROTATION_DIR=$(mktemp -d) + +echo " [→] Generating new (rotated) Intermediate CA..." +openssl genrsa -out "${ROTATION_DIR}/intermediateCA-v2.key" 4096 2>/dev/null +openssl req -new -key "${ROTATION_DIR}/intermediateCA-v2.key" \ + -subj "/CN=Demo Intermediate CA v2/O=mTLS Demo/C=US" -out "${ROTATION_DIR}/intermediateCA-v2.csr" 2>/dev/null +openssl x509 -req -in "${ROTATION_DIR}/intermediateCA-v2.csr" \ + -CA "${CERT_DIR}/rootCA.pem" -CAkey "${CERT_DIR}/rootCA.key" \ + -CAcreateserial -days 1825 -sha256 \ + -extfile <(printf "basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign") \ + -out "${ROTATION_DIR}/intermediateCA-v2.pem" 2>/dev/null + +echo " [→] Issuing new leaf cert from rotated intermediate..." +openssl genrsa -out "${ROTATION_DIR}/leaf-v2.key" 2048 2>/dev/null +openssl req -new -key "${ROTATION_DIR}/leaf-v2.key" \ + -subj "/CN=rotated-client/O=mTLS Demo/C=US" -out "${ROTATION_DIR}/leaf-v2.csr" 2>/dev/null +openssl x509 -req -in "${ROTATION_DIR}/leaf-v2.csr" \ + -CA "${ROTATION_DIR}/intermediateCA-v2.pem" -CAkey "${ROTATION_DIR}/intermediateCA-v2.key" \ + -CAcreateserial -days 365 -sha256 -out "${ROTATION_DIR}/leaf-v2.pem" 2>/dev/null + +echo " [→] Building truststore with BOTH intermediates (rotation overlap)..." +cat "${CERT_DIR}/intermediateCA.pem" "${ROTATION_DIR}/intermediateCA-v2.pem" "${CERT_DIR}/rootCA.pem" \ + > "${ROTATION_DIR}/truststore-rotation.pem" + +CERT_COUNT=$(grep -c "BEGIN CERTIFICATE" "${ROTATION_DIR}/truststore-rotation.pem") +echo " [→] Truststore contains ${CERT_COUNT} certificates (old intermediate + new intermediate + root)" + +# Verify chain locally +openssl verify -CAfile "${CERT_DIR}/rootCA.pem" -untrusted "${ROTATION_DIR}/intermediateCA-v2.pem" \ + "${ROTATION_DIR}/leaf-v2.pem" > /dev/null 2>&1 && echo " [→] Local chain verification: OK" + +update_truststore "${ROTATION_DIR}/truststore-rotation.pem" "rotation truststore (both intermediates + root)" + +echo " [→] Testing new leaf cert against rotation truststore..." +HTTP_CODE=$(call_api --cert "${ROTATION_DIR}/leaf-v2.pem" --key "${ROTATION_DIR}/leaf-v2.key") + +if [[ "${HTTP_CODE}" == "200" ]]; then + echo " ✅ PASS — New leaf from rotated intermediate validates (HTTP ${HTTP_CODE})" + echo "" + echo " Rotation strategy confirmed:" + echo " 1. Add new intermediate to truststore alongside old one" + echo " 2. Issue new leaf certs from new intermediate" + echo " 3. Once all clients migrated, remove old intermediate" + ((PASS++)) + + # Also verify old leaf still works (overlap period) + echo "" + echo " [→] Verifying OLD leaf still works during overlap..." + HTTP_CODE_OLD=$(call_api --cert "${CERT_DIR}/leaf-client.pem" --key "${CERT_DIR}/leaf-client.key") + if [[ "${HTTP_CODE_OLD}" == "200" ]]; then + echo " ✅ Old leaf also validates — both CAs active simultaneously." + else + echo " ⚠️ Old leaf rejected (HTTP ${HTTP_CODE_OLD}) — check propagation timing." + fi +else + echo " ❌ FAIL — Expected 200 for rotated intermediate, got ${HTTP_CODE}" + cat /tmp/mtls-test-output.txt 2>/dev/null + ((FAIL++)) +fi + +rm -rf "${ROTATION_DIR}" +echo "" + +# Restore original truststore +echo " [→] Restoring original truststore..." +update_truststore "${CERT_DIR}/truststore.pem" "original truststore (restored)" +echo "" + +# ═══════════════════════════════════════════════════════════════════════════════ +# TEST 5: No client cert — mTLS enforced +# ═══════════════════════════════════════════════════════════════════════════════ +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "TEST 5: No client cert (expect rejection)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +HTTP_CODE=$(call_api) + +if [[ "${HTTP_CODE}" == "403" || "${HTTP_CODE}" =~ ^0+$ ]]; then + echo " ✅ PASS — No-cert request rejected (HTTP ${HTTP_CODE})" + ((PASS++)) +else + echo " ❌ FAIL — Expected rejection, got HTTP ${HTTP_CODE}" + ((FAIL++)) +fi +echo "" + +# ═══════════════════════════════════════════════════════════════════════════════ +# TEST 6: Untrusted self-signed cert — rejected +# ═══════════════════════════════════════════════════════════════════════════════ +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "TEST 6: Untrusted self-signed cert (expect rejection)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + +TMPDIR=$(mktemp -d) +openssl req -x509 -newkey rsa:2048 -keyout "${TMPDIR}/rogue.key" \ + -out "${TMPDIR}/rogue.pem" -days 1 -nodes \ + -subj "/CN=rogue-client/O=Untrusted Org/C=US" 2>/dev/null + +HTTP_CODE=$(call_api --cert "${TMPDIR}/rogue.pem" --key "${TMPDIR}/rogue.key") +rm -rf "${TMPDIR}" + +if [[ "${HTTP_CODE}" == "403" || "${HTTP_CODE}" =~ ^0+$ ]]; then + echo " ✅ PASS — Untrusted cert rejected (HTTP ${HTTP_CODE})" + ((PASS++)) +else + echo " ❌ FAIL — Expected rejection, got HTTP ${HTTP_CODE}" + ((FAIL++)) +fi +echo "" + +# ═══════════════════════════════════════════════════════════════════════════════ +# SUMMARY +# ═══════════════════════════════════════════════════════════════════════════════ +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "RESULTS: ${PASS} passed, ${FAIL} failed, ${SKIP} skipped (of 6 tests)" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "╔══════════════════════════════════════════════════════════════════════╗" +echo "║ KEY FINDINGS ║" +echo "╠══════════════════════════════════════════════════════════════════════╣" +echo "║ 1. Full chain workaround WORKS ║" +echo "║ Truststore = intermediate(s) + root → leaf validates ║" +echo "║ ║" +echo "║ 2. Root-only truststore FAILS (confirmed gap) ║" +echo "║ API GW requires explicit intermediate CAs in truststore ║" +echo "║ ║" +echo "║ 3. Leaf expiry IS enforced ║" +echo "║ Expired client certs are rejected at TLS handshake ║" +echo "║ ║" +echo "║ 4. Intermediate rotation is safe ║" +echo "║ Include both old + new intermediates during transition ║" +echo "║ Old and new leaf certs validate simultaneously ║" +echo "╠══════════════════════════════════════════════════════════════════════╣" +echo "║ EDGE CASE NOTES ║" +echo "╠══════════════════════════════════════════════════════════════════════╣" +echo "║ • Truststore size: max 64 KB (PEM), ~40-50 CA certs ║" +echo "║ • Max chain depth: 4 (root + 3 intermediates) ║" +echo "║ • Propagation: polls until AVAILABLE (typically 30-90s) ║" +echo "║ • CRL/OCSP: NOT checked — use Lambda authorizer if needed ║" +echo "║ • S3 versioning: recommended for safe truststore rollback ║" +echo "║ • Rotation: include all active intermediates during transition ║" +echo "╠══════════════════════════════════════════════════════════════════════╣" +echo "║ Docs: https://docs.aws.amazon.com/apigateway/latest/ ║" +echo "║ developerguide/rest-api-mutual-tls.html ║" +echo "╚══════════════════════════════════════════════════════════════════════╝" +echo "" + +if [[ ${FAIL} -gt 0 ]]; then + exit 1 +fi diff --git a/apigw-mtls-multi-tenant/src/authorizer.py b/apigw-mtls-multi-tenant/src/authorizer.py new file mode 100644 index 00000000..3b788f95 --- /dev/null +++ b/apigw-mtls-multi-tenant/src/authorizer.py @@ -0,0 +1,160 @@ +""" +Lambda Authorizer for multi-tenant mTLS. + +Extracts the client certificate from the mTLS handshake, logs full cert details +to CloudWatch (demonstrating where revocation checks could be added), and maps +the certificate CN to a tenant identity. + +Tenant mapping: + CN=tenant-a → Tenant A + CN=tenant-b → Tenant B + (other valid certs) → default tenant + +This authorizer demonstrates: + 1. How to extract and inspect client certs in a Lambda authorizer + 2. Where to add CRL/OCSP revocation checks (not natively supported by API GW) + 3. Multi-tenant routing based on certificate identity +""" + +import json +import logging +import urllib.parse + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# Tenant mapping: CN → tenant config +TENANT_MAP = { + "tenant-a": { + "tenantId": "tenant-a", + "tenantName": "Tenant A", + "allowedPaths": ["/api", "/api/", "/"], + }, + "tenant-b": { + "tenantId": "tenant-b", + "tenantName": "Tenant B", + "allowedPaths": ["/api", "/api/", "/"], + }, +} + + +def lambda_handler(event, context): + """ + REQUEST-type Lambda authorizer for HTTP API. + Receives the full request context including mTLS client cert. + """ + logger.info("=== mTLS Authorizer Invoked ===") + logger.info(f"Request: {json.dumps(event, default=str)}") + + # Extract client certificate from request context + request_context = event.get("requestContext", {}) + auth_context = request_context.get("authentication", {}) + client_cert = auth_context.get("clientCert", {}) + + if not client_cert: + logger.warning("No client certificate found in request context") + return build_deny_response("No client certificate") + + # Log full certificate details (this is where revocation checks would go) + subject_dn = client_cert.get("subjectDN", "") + issuer_dn = client_cert.get("issuerDN", "") + serial = client_cert.get("serialNumber", "") + validity = client_cert.get("validity", {}) + + logger.info("=== Client Certificate Details ===") + logger.info(f" Subject DN: {subject_dn}") + logger.info(f" Issuer DN: {issuer_dn}") + logger.info(f" Serial Number: {serial}") + logger.info(f" Not Before: {validity.get('notBefore', 'N/A')}") + logger.info(f" Not After: {validity.get('notAfter', 'N/A')}") + logger.info("=================================") + + # ─── Revocation Check — NOT IMPLEMENTED (demo only) ─────────────────────── + # ⚠️ SECURITY: This demo does NOT perform certificate revocation checking. + # A compromised client certificate will remain valid until expiry. + # + # In production, implement one or more of the following before proceeding: + # 1. DynamoDB/Redis denylist: query a table of revoked serial numbers + # 2. CRL check: download CRL from CA's distribution point, verify serial + # 3. OCSP check: send request to responder URL from cert's AIA extension + # + # Example: + # if is_revoked(serial, issuer_dn): + # logger.warning(f"REVOKED certificate: serial={serial}") + # return build_deny_response("Certificate revoked") + # + # Without revocation checking, the only remediation for a compromised cert is: + # - Remove the issuing CA from the truststore (affects all certs from that CA) + # - Wait for natural cert expiry + logger.info("[Revocation Check] NOT IMPLEMENTED — demo only") + + # Extract CN from subject DN + cn = extract_cn(subject_dn) + logger.info(f" Extracted CN: {cn}") + + # Map CN to tenant + tenant = TENANT_MAP.get(cn) + if tenant: + logger.info(f" Tenant identified: {tenant['tenantName']} ({tenant['tenantId']})") + else: + logger.warning(f" Unknown CN={cn} — not mapped to any tenant. DENIED.") + return build_deny_response(f"Certificate CN '{cn}' not authorized") + + # Check path authorization + route_key = event.get("routeKey", "") + request_path = event.get("rawPath", "/") + # Strip stage prefix if present (API GW includes /prod/ in rawPath) + for prefix in ["/prod", "/staging", "/dev"]: + if request_path.startswith(prefix): + request_path = request_path[len(prefix):] or "/" + break + logger.info(f" Request path: {request_path}") + logger.info(f" Allowed paths: {tenant['allowedPaths']}") + + # Enforce path-level access control per tenant + if request_path not in tenant["allowedPaths"]: + logger.warning(f" Path {request_path} not in allowed paths for tenant {tenant['tenantId']}") + return build_deny_response(f"Access to {request_path} not authorized for this tenant") + + # Build allow response with tenant context + response = build_allow_response(tenant, subject_dn, issuer_dn, serial) + logger.info(f" Authorization: ALLOW") + logger.info(f" Response context: {json.dumps(response.get('context', {}))}") + + return response + + +def extract_cn(subject_dn): + """Extract CN value from a subject DN string like 'CN=tenant-a-att,O=...'""" + # Handle both formats: "CN=value,O=..." and "/CN=value/O=..." + dn = subject_dn.replace("/", ",").strip(",") + for part in dn.split(","): + part = part.strip() + if part.upper().startswith("CN="): + return part[3:] + return "" + + +def build_allow_response(tenant, subject_dn, issuer_dn, serial): + """Build IAM policy allowing access with tenant context.""" + return { + "isAuthorized": True, + "context": { + "tenantId": tenant["tenantId"], + "tenantName": tenant["tenantName"], + "certSubject": subject_dn, + "certIssuer": issuer_dn, + "certSerial": serial, + }, + } + + +def build_deny_response(reason): + """Build deny response.""" + logger.warning(f" Authorization: DENY — {reason}") + return { + "isAuthorized": False, + "context": { + "reason": reason, + }, + } diff --git a/apigw-mtls-multi-tenant/src/handler.py b/apigw-mtls-multi-tenant/src/handler.py new file mode 100644 index 00000000..8a91f0b4 --- /dev/null +++ b/apigw-mtls-multi-tenant/src/handler.py @@ -0,0 +1,34 @@ +""" +Simple Lambda handler for the mTLS demo. +Returns request context including mTLS client cert info when available. +""" + +import json + + +def lambda_handler(event, context): + """Return 200 with mTLS client certificate details from the request context.""" + + # Extract mTLS authentication context if present + request_context = event.get("requestContext", {}) + auth = request_context.get("authentication", {}) + client_cert = auth.get("clientCert", {}) + + body = { + "message": "mTLS authentication successful", + "clientCert": { + "commonName": client_cert.get("subjectDN", "N/A"), + "issuer": client_cert.get("issuerDN", "N/A"), + "serialNumber": client_cert.get("serialNumber", "N/A"), + "validity": { + "notBefore": client_cert.get("validity", {}).get("notBefore", "N/A"), + "notAfter": client_cert.get("validity", {}).get("notAfter", "N/A"), + }, + }, + } + + return { + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": json.dumps(body, indent=2), + } diff --git a/apigw-mtls-multi-tenant/src/tenant_handler.py b/apigw-mtls-multi-tenant/src/tenant_handler.py new file mode 100644 index 00000000..db2370ea --- /dev/null +++ b/apigw-mtls-multi-tenant/src/tenant_handler.py @@ -0,0 +1,87 @@ +""" +Multi-tenant Lambda handler for the mTLS demo. + +All tenants hit the SAME endpoint (/api). The Lambda Authorizer identifies the +tenant from the client certificate CN and passes tenant context downstream. +This handler uses that context to return tenant-specific responses — demonstrating +cert-based routing without path differentiation. + +Flow: + Client cert (CN=tenant-a) → /api + Client cert (CN=tenant-b) → /api (same path!) + Authorizer maps CN → tenantId → passed as context + This handler reads tenantId from context → returns tenant-specific data +""" + +import json +import logging + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# Simulated tenant data (in production this would come from a database) +TENANT_DATA = { + "tenant-a": { + "name": "Tenant A", + "region": "US-East", + "apiVersion": "v2.1", + "services": ["service-x", "service-y"], + "rateLimit": 10000, + "accountStatus": "active", + }, + "tenant-b": { + "name": "Tenant B", + "region": "US-West", + "apiVersion": "v3.0", + "services": ["service-x", "service-y", "service-z"], + "rateLimit": 15000, + "accountStatus": "active", + }, +} + + +def lambda_handler(event, context): + """Handle multi-tenant API requests — same path, different cert = different tenant.""" + logger.info(f"Tenant handler invoked: {json.dumps(event, default=str)}") + + # Extract tenant context from authorizer (set by the Lambda Authorizer based on cert CN) + request_context = event.get("requestContext", {}) + authorizer = request_context.get("authorizer", {}) + auth_context = authorizer.get("lambda", {}) + + tenant_id = auth_context.get("tenantId", "unknown") + tenant_name = auth_context.get("tenantName", "Unknown") + cert_subject = auth_context.get("certSubject", "N/A") + cert_serial = auth_context.get("certSerial", "N/A") + + # Get tenant-specific data based on authorizer context (not path!) + tenant_data = TENANT_DATA.get(tenant_id, {}) + + body = { + "message": f"Welcome, {tenant_name}!", + "routing": { + "method": "Certificate-based (same endpoint for all tenants)", + "endpoint": "/api", + "tenantDeterminedBy": "Client certificate CN → Lambda Authorizer → context", + }, + "tenant": { + "id": tenant_id, + "name": tenant_name, + }, + "authentication": { + "method": "mTLS + Lambda Authorizer", + "certSubject": cert_subject, + "certSerial": cert_serial, + }, + "tenantConfig": tenant_data if tenant_data else { + "note": "No specific config — unrecognized tenant (default access)", + }, + } + + logger.info(f"Responding to tenant: {tenant_id} ({tenant_name}) on shared /api endpoint") + + return { + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": json.dumps(body, indent=2), + } diff --git a/apigw-mtls-multi-tenant/template.yaml b/apigw-mtls-multi-tenant/template.yaml new file mode 100644 index 00000000..821d4366 --- /dev/null +++ b/apigw-mtls-multi-tenant/template.yaml @@ -0,0 +1,183 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + API Gateway mTLS Full-Chain Truststore Demo. + Deploys an HTTP API with mutual TLS configured against a full-chain truststore + (intermediate + root CAs) stored in S3. + +Parameters: + DomainName: + Type: String + Description: Custom domain name for the API (e.g., mtls-demo.example.com) + + HostedZoneId: + Type: String + Description: Route 53 Hosted Zone ID for the custom domain + + CertificateArn: + Type: String + Description: ARN of the ACM certificate for the custom domain (server-side TLS) + + TruststoreBucket: + Type: String + Description: S3 bucket name containing the truststore PEM + + TruststoreKey: + Type: String + Default: truststore.pem + Description: S3 object key for the truststore PEM file + +Globals: + Function: + Timeout: 10 + Runtime: python3.12 + MemorySize: 128 + +Resources: + # ─── Lambda Backend ───────────────────────────────────────────────────────── + MtlsDemoFunction: + Type: AWS::Serverless::Function + Properties: + Handler: handler.lambda_handler + CodeUri: src/ + Description: Returns 200 with mTLS client certificate details + Events: + RootGet: + Type: HttpApi + Properties: + ApiId: !Ref MtlsHttpApi + Path: / + Method: GET + Auth: + Authorizer: NONE + HealthGet: + Type: HttpApi + Properties: + ApiId: !Ref MtlsHttpApi + Path: /health + Method: GET + Auth: + Authorizer: NONE + + # ─── HTTP API with mTLS ───────────────────────────────────────────────────── + MtlsHttpApi: + Type: AWS::Serverless::HttpApi + Properties: + StageName: prod + Description: HTTP API with mutual TLS authentication and Lambda authorizer + DisableExecuteApiEndpoint: true + Auth: + DefaultAuthorizer: MtlsLambdaAuthorizer + Authorizers: + MtlsLambdaAuthorizer: + FunctionArn: !GetAtt MtlsAuthorizerFunction.Arn + FunctionInvokeRole: !GetAtt AuthorizerInvokeRole.Arn + Identity: + ReauthorizeEvery: 0 + AuthorizerPayloadFormatVersion: "2.0" + EnableSimpleResponses: true + + # ─── Lambda Authorizer Function ───────────────────────────────────────────── + MtlsAuthorizerFunction: + Type: AWS::Serverless::Function + Properties: + Handler: authorizer.lambda_handler + CodeUri: src/ + Description: mTLS Lambda authorizer — logs cert details, maps CN to tenant + Timeout: 10 + + # ─── Authorizer Invoke Role ───────────────────────────────────────────────── + AuthorizerInvokeRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: apigateway.amazonaws.com + Action: sts:AssumeRole + Policies: + - PolicyName: InvokeAuthorizer + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: lambda:InvokeFunction + Resource: !GetAtt MtlsAuthorizerFunction.Arn + + # ─── Tenant-Specific Handler ──────────────────────────────────────────────── + TenantFunction: + Type: AWS::Serverless::Function + Properties: + Handler: tenant_handler.lambda_handler + CodeUri: src/ + Description: Multi-tenant handler — routes by cert identity, not path + Events: + TenantApi: + Type: HttpApi + Properties: + ApiId: !Ref MtlsHttpApi + Path: /api + Method: GET + + # ─── Custom Domain with mTLS ──────────────────────────────────────────────── + ApiDomainName: + Type: AWS::ApiGatewayV2::DomainName + Properties: + DomainName: !Ref DomainName + DomainNameConfigurations: + - CertificateArn: !Ref CertificateArn + EndpointType: REGIONAL + SecurityPolicy: TLS_1_2 + MutualTlsAuthentication: + TruststoreUri: !Sub "s3://${TruststoreBucket}/${TruststoreKey}" + + # ─── API Mapping ──────────────────────────────────────────────────────────── + ApiMapping: + Type: AWS::ApiGatewayV2::ApiMapping + DependsOn: ApiDomainName + Properties: + ApiId: !Ref MtlsHttpApi + DomainName: !Ref DomainName + Stage: !Ref MtlsHttpApi.Stage + + # ─── Route 53 Record ──────────────────────────────────────────────────────── + DnsRecord: + Type: AWS::Route53::RecordSet + Properties: + HostedZoneId: !Ref HostedZoneId + Name: !Ref DomainName + Type: A + AliasTarget: + DNSName: !GetAtt ApiDomainName.RegionalDomainName + HostedZoneId: !GetAtt ApiDomainName.RegionalHostedZoneId + +Outputs: + ApiDomain: + Description: Custom domain URL for the mTLS API + Value: !Sub "https://${DomainName}/" + + ApiId: + Description: HTTP API ID + Value: !Ref MtlsHttpApi + + FunctionArn: + Description: Lambda function ARN + Value: !GetAtt MtlsDemoFunction.Arn + + AuthorizerFunctionArn: + Description: Authorizer Lambda function ARN + Value: !GetAtt MtlsAuthorizerFunction.Arn + + TenantFunctionArn: + Description: Tenant handler Lambda function ARN + Value: !GetAtt TenantFunction.Arn + + TenantAEndpoint: + Description: Multi-tenant API endpoint (same path, routed by cert identity) + Value: !Sub "https://${DomainName}/api" + + TruststoreLocation: + Description: S3 URI of the truststore + Value: !Sub "s3://${TruststoreBucket}/${TruststoreKey}"