From cf8ca2eeb62eb3fce09bd3ba6a22a2234c39cbc1 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:41:10 -0400 Subject: [PATCH] feat(api): custom domain procurement-api.seahaven.com (stacked on PR-2) (#140) * feat(api): custom domain procurement-api.seahaven.com for the read API Stacked on feat/shoc-wo-webhook. Gives the SHOC-facing read API a stable, brandable endpoint instead of the opaque execute-api URL. - procurement_api_stack.py: REGIONAL API Gateway DomainName (TLS 1.2) + empty base-path mapping to the prod stage, so callers hit https://procurement-api.seahaven.com/work-orders (no /prod segment). The ACM cert ARN is read from SSM (/procurement-api/custom-domain/certificate-arn) via value_for_string_parameter, because the seahaven.com zone is in the mgmt account (cross-account DNS) and the cert is issued out of band. Outputs expose the regional alias target + hosted-zone id for the mgmt A-record. - scripts/setup_procurement_api_domain.sh: idempotent two-step runbook (cert: request + mgmt-zone validation + wait + SSM; alias: post-deploy A-record from stack outputs). Verifies both account identities. - handler._base_url: omit the /{stage} segment for a custom-domain request (the base-path mapping serves the stage at the root) so the docs never advertise a broken server URL; execute-api hosts keep /{stage}. - openapi.json: custom domain added as servers[0] (recommended), execute-api kept as the direct fallback + the per-request injection target. No IAM/auth/policy change (same API id + resource policy), so the SigV4 surface and the mandatory cross-family gates are unaffected. 751 pytest, ruff, cdk synth, redocly lint all green. * fix(api): use .endswith('.amazonaws.com') instead of substring check for execute-api detection The prior '.execute-api.' in domain substring check is fragile and triggers CodeQL incomplete-sanitization warnings. All API Gateway default domains end with .amazonaws.com, so a suffix check is more precise and also silences the false-positive alert. Refs: https://github.com/Sea-Haven-Industries/procurement-ingest/security/code-scanning/6 --- README.md | 1 + cdk/procurement_api_stack.py | 63 ++++++++++++ lambdas/api/handler.py | 11 +- lambdas/api/openapi.json | 4 + scripts/setup_procurement_api_domain.sh | 128 ++++++++++++++++++++++++ tests/test_api_handlers.py | 41 ++++++++ 6 files changed, 247 insertions(+), 1 deletion(-) create mode 100755 scripts/setup_procurement_api_domain.sh diff --git a/README.md b/README.md index d8a470d..2628318 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ A read-only REST API (API Gateway + the `procurement-api` Lambda, `lambdas/api/` - **Spec is source of truth:** `lambdas/api/openapi.json` (OpenAPI 3.1). Its top-level `webhooks` section documents the outbound SHOC work-order feed, so one page describes both directions (call + be-called). `tests/test_api_spec_drift.py` pins the spec's paths to the router table, so spec and implementation cannot drift. - **Auth:** data routes use API Gateway `AWS_IAM` (SigV4) plus a resource policy allowing exactly `arn:aws:iam::396287094661:role/shoc-backend-dev` on `GET/*`; same-account admin callers authorize via identity policy (Postman signs SigV4 natively). Docs routes are auth `NONE` at the gateway (resource-policy carve-out for exactly those two GETs) but the handler fails closed on the shared token (`lambdas/shared/web_ui_auth.py`, secret `procurement-ingest/web-ui-auth-token`) — not an unauthenticated data path (INFRA-74 posture). +- **Custom domain:** `https://procurement-api.seahaven.com` (REGIONAL API Gateway domain, TLS 1.2, empty base-path mapping to the `prod` stage, so callers hit `/work-orders` with no `/prod` segment). The stable SHOC-facing endpoint; the raw `*.execute-api.us-east-1.amazonaws.com/prod` URL still works. **Cross-account DNS:** the `seahaven.com` public zone is in the mgmt account (`328440206208`), so the ACM cert's validation record and the A-alias are added there out of band — `scripts/setup_procurement_api_domain.sh cert` issues the cert (DNS-validated against the mgmt zone) and writes its ARN to prod SSM `/procurement-api/custom-domain/certificate-arn`, which the stack reads (`value_for_string_parameter`, CFN-resolved at deploy); after `cdk deploy procurement-api`, `scripts/setup_procurement_api_domain.sh alias` adds the A-alias from the stack's `ProcurementApiAliasTarget`/`ProcurementApiAliasHostedZoneId` outputs. SigV4 is unaffected (same underlying API id + resource policy); the docs page injects whichever host served the request into `servers[0].url`. - **KMS:** `purchase-orders` is CMK-encrypted; the imported-by-name table doesn't carry the key association, so the stack grants `kms:Decrypt`/`DescribeKey` on the CMK from SSM `/seahaven/dynamodb/cmk-arn` explicitly (the INFRA-104 failure class). - **No access logging in v1** (keeps the `?token=` shim out of any log and avoids the account-level API Gateway CloudWatch role); rotate the docs token before ever enabling it. No CORS (server-to-server + Postman callers). - **Alarms:** `procurement-api-errors`/`-throttles`/`-duration` (p99 ≥ 22.5 s) + gateway `procurement-api-5xx`, all ALARM-only → `site-alerts`. No 4XX alarm (401/403/404 are expected traffic). diff --git a/cdk/procurement_api_stack.py b/cdk/procurement_api_stack.py index 221b6ee..ea3cdf7 100644 --- a/cdk/procurement_api_stack.py +++ b/cdk/procurement_api_stack.py @@ -18,6 +18,7 @@ from aws_cdk import ( Duration, Stack, aws_apigateway as apigateway, + aws_certificatemanager as acm, aws_cloudwatch as cloudwatch, aws_cloudwatch_actions as cw_actions, aws_dynamodb as dynamodb, @@ -39,6 +40,12 @@ SHOC_BACKEND_DEV_ROLE_ARN = "arn:aws:iam::396287094661:role/shoc-backend-dev" STAGE_NAME = "prod" +# Custom domain for the SHOC-facing read API. The seahaven.com zone is in the +# mgmt account, so the cert is issued out of band and its ARN handed in via +# this SSM parameter (see the custom-domain block below). +CUSTOM_DOMAIN_NAME = "procurement-api.seahaven.com" +CUSTOM_DOMAIN_CERT_ARN_SSM_PARAM = "/procurement-api/custom-domain/certificate-arn" + class ProcurementApiStack(Stack): def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: @@ -280,6 +287,41 @@ class ProcurementApiStack(Stack): authorization_type=apigateway.AuthorizationType.NONE, ) + # --- Custom domain: procurement-api.seahaven.com --- + # Stable, brandable endpoint for the SHOC backend to sign SigV4 against + # (replaces the opaque execute-api URL). REGIONAL to match the API, so + # the ACM cert lives in this account+region (us-east-1). + # + # CROSS-ACCOUNT DNS: the seahaven.com public zone + # (Z06652411XKH89KTZD3XA) is in the mgmt account (328440206208), not + # here. So the cert's DNS-validation CNAME and the final A-alias record + # are added to that zone OUT OF BAND (see the README runbook / + # scripts/setup_procurement_api_domain.sh), and the issued cert ARN is + # handed to this stack via SSM. Reading it with value_for_string_ + # parameter keeps the reference CFN-resolved at deploy -- no synth-time + # AWS creds, unlike a cross-account HostedZone.from_lookup. The + # A-alias record itself is created out of band too (the zone is not in + # this account, so CDK cannot own it); the outputs below give the exact + # alias target. + cert_arn = ssm.StringParameter.value_for_string_parameter( + self, CUSTOM_DOMAIN_CERT_ARN_SSM_PARAM + ) + certificate = acm.Certificate.from_certificate_arn( + self, "ProcurementApiCertificate", cert_arn + ) + custom_domain = apigateway.DomainName( + self, + "ProcurementApiDomain", + domain_name=CUSTOM_DOMAIN_NAME, + certificate=certificate, + endpoint_type=apigateway.EndpointType.REGIONAL, + security_policy=apigateway.SecurityPolicy.TLS_1_2, + ) + # Empty base path: the custom domain root maps to the prod stage, so + # callers hit https://procurement-api.seahaven.com/work-orders (no + # /prod segment -- the mapping strips it). + custom_domain.add_base_path_mapping(api, stage=api.deployment_stage) + # --- Alarms (ALARM-only -> site-alerts, NOT_BREACHING) --- # common.add_standard_lambda_alarms is NOT used here: its duration # threshold is a fixed 45000 ms (75% of the processors' 60 s timeout), @@ -362,3 +404,24 @@ class ProcurementApiStack(Stack): value=api_fn.function_arn, description="ARN of the procurement-api Lambda", ) + cdk.CfnOutput( + self, + "ProcurementApiCustomDomainUrl", + value=f"https://{CUSTOM_DOMAIN_NAME}/", + description="procurement-api custom domain (SHOC-facing endpoint)", + ) + # A-alias target for the mgmt-account Route53 record. Create an ALIAS A + # record: procurement-api.seahaven.com -> this regional domain name, + # with this hosted-zone id, EvaluateTargetHealth=false. + cdk.CfnOutput( + self, + "ProcurementApiAliasTarget", + value=custom_domain.domain_name_alias_domain_name, + description="Route53 A-alias target (add in the mgmt seahaven.com zone)", + ) + cdk.CfnOutput( + self, + "ProcurementApiAliasHostedZoneId", + value=custom_domain.domain_name_alias_hosted_zone_id, + description="Route53 A-alias target hosted-zone id (mgmt zone record)", + ) diff --git a/lambdas/api/handler.py b/lambdas/api/handler.py index 6824847..a467f62 100644 --- a/lambdas/api/handler.py +++ b/lambdas/api/handler.py @@ -82,7 +82,16 @@ def _load_spec() -> str: def _base_url(event: dict) -> str | None: rc = event.get("requestContext") or {} domain, stage = rc.get("domainName"), rc.get("stage") - return f"https://{domain}/{stage}" if domain and stage else None + if not domain: + return None + # A custom domain (base-path mapping) serves the stage at the root, so the + # public URL has NO /{stage} segment; only the raw execute-api host carries + # it. Emitting /{stage} for a custom-domain request would advertise a + # broken server URL in the docs. All API Gateway default domains end with + # .amazonaws.com, so check the suffix rather than a substring. + if domain.endswith(".amazonaws.com"): + return f"https://{domain}/{stage}" if stage else None + return f"https://{domain}" def _spec_for_request(event: dict) -> str: diff --git a/lambdas/api/openapi.json b/lambdas/api/openapi.json index 887a66c..ee2c801 100644 --- a/lambdas/api/openapi.json +++ b/lambdas/api/openapi.json @@ -9,6 +9,10 @@ "description": "Read API over the procurement-ingest pipelines (work orders and purchase orders). The outbound SHOC work-order webhook feed is documented under **webhooks** below.\n\nData endpoints use AWS IAM SigV4; the `/docs` and `/openapi.json` routes use a shared token. Listings are unordered, cursor-paginated scans. Endpoints tagged **x-planned** are phase 2 and currently answer `501`.\n\nThis API replaces SHOC's retired SyncController DynamoDB scan as the reconciliation and backfill path. Full detail: the repo README and `docs/shoc-webhook-contract.md`." }, "servers": [ + { + "url": "https://procurement-api.seahaven.com", + "description": "seahaven-prod (011934824531) custom domain — recommended." + }, { "url": "https://mvul1efda2.execute-api.us-east-1.amazonaws.com/prod", "description": "seahaven-prod (011934824531)." diff --git a/scripts/setup_procurement_api_domain.sh b/scripts/setup_procurement_api_domain.sh new file mode 100755 index 0000000..9646bd6 --- /dev/null +++ b/scripts/setup_procurement_api_domain.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +############################################################################### +# setup_procurement_api_domain.sh +# +# One-time (idempotent) wiring for the procurement-api custom domain +# (procurement-api.seahaven.com). CROSS-ACCOUNT: the API + ACM cert live in +# seahaven-prod (011934824531), but the seahaven.com public zone lives in the +# mgmt account (328440206208), so the cert's DNS-validation record and the +# final A-alias are added to the mgmt zone. +# +# Order of operations: +# 1. ./setup_procurement_api_domain.sh cert +# - requests (or reuses) the ACM cert in prod, us-east-1 +# - adds its DNS-validation CNAME to the mgmt seahaven.com zone +# - waits for ISSUED, then writes the cert ARN to prod SSM +# (/procurement-api/custom-domain/certificate-arn) +# 2. deploy the procurement-api stack (cdk deploy procurement-api) -- it +# reads the SSM param and creates the API Gateway DomainName + mapping +# 3. ./setup_procurement_api_domain.sh alias +# - reads the stack's regional alias target from the outputs +# - adds the A-alias (procurement-api.seahaven.com -> API GW) to the +# mgmt zone +# +# Requires SSO sessions for BOTH profiles (prod for ACM/SSM, mgmt for Route53). +############################################################################### +set -euo pipefail + +DOMAIN="procurement-api.seahaven.com" +REGION="us-east-1" +PROD_PROFILE="${PROD_PROFILE:-seahaven-prod}" +MGMT_PROFILE="${MGMT_PROFILE:-seahaven-mgmt}" +PROD_ACCOUNT="011934824531" +MGMT_ACCOUNT="328440206208" +ZONE_ID="Z06652411XKH89KTZD3XA" # seahaven.com public zone, in the mgmt account +SSM_PARAM="/procurement-api/custom-domain/certificate-arn" +STACK_NAME="procurement-api" + +_verify_account() { + local profile="$1" expected="$2" + local got + got="$(aws sts get-caller-identity --profile "${profile}" --query Account --output text)" + if [[ "${got}" != "${expected}" ]]; then + echo "ERROR: profile ${profile} resolves to ${got}, expected ${expected}. Aborting." >&2 + exit 1 + fi +} + +cmd_cert() { + _verify_account "${PROD_PROFILE}" "${PROD_ACCOUNT}" + _verify_account "${MGMT_PROFILE}" "${MGMT_ACCOUNT}" + + echo "==> Finding or requesting ACM cert for ${DOMAIN} in ${PROD_ACCOUNT}/${REGION}" + local cert_arn + cert_arn="$(aws acm list-certificates --profile "${PROD_PROFILE}" --region "${REGION}" \ + --query "CertificateSummaryList[?DomainName=='${DOMAIN}'].CertificateArn | [0]" --output text)" + if [[ "${cert_arn}" == "None" || -z "${cert_arn}" ]]; then + cert_arn="$(aws acm request-certificate --profile "${PROD_PROFILE}" --region "${REGION}" \ + --domain-name "${DOMAIN}" --validation-method DNS \ + --query CertificateArn --output text)" + echo " requested ${cert_arn}; waiting for the validation record to populate..." + sleep 8 + else + echo " reusing existing ${cert_arn}" + fi + + echo "==> Reading the DNS-validation record" + local rec_name rec_value + rec_name="$(aws acm describe-certificate --profile "${PROD_PROFILE}" --region "${REGION}" \ + --certificate-arn "${cert_arn}" \ + --query "Certificate.DomainValidationOptions[0].ResourceRecord.Name" --output text)" + rec_value="$(aws acm describe-certificate --profile "${PROD_PROFILE}" --region "${REGION}" \ + --certificate-arn "${cert_arn}" \ + --query "Certificate.DomainValidationOptions[0].ResourceRecord.Value" --output text)" + + echo "==> Upserting validation CNAME in the mgmt seahaven.com zone" + aws route53 change-resource-record-sets --profile "${MGMT_PROFILE}" \ + --hosted-zone-id "${ZONE_ID}" --change-batch "$(cat </dev/null + + echo "==> Waiting for cert to reach ISSUED (can take a few minutes)" + aws acm wait certificate-validated --profile "${PROD_PROFILE}" --region "${REGION}" \ + --certificate-arn "${cert_arn}" + + echo "==> Writing cert ARN to prod SSM ${SSM_PARAM}" + aws ssm put-parameter --profile "${PROD_PROFILE}" --region "${REGION}" \ + --name "${SSM_PARAM}" --type String --overwrite --value "${cert_arn}" >/dev/null + + echo "OK: cert ISSUED and SSM param set. Now: cdk deploy ${STACK_NAME}, then '$0 alias'." +} + +cmd_alias() { + _verify_account "${PROD_PROFILE}" "${PROD_ACCOUNT}" + _verify_account "${MGMT_PROFILE}" "${MGMT_ACCOUNT}" + + echo "==> Reading regional alias target from the ${STACK_NAME} stack outputs" + local target zone + target="$(aws cloudformation describe-stacks --profile "${PROD_PROFILE}" --region "${REGION}" \ + --stack-name "${STACK_NAME}" \ + --query "Stacks[0].Outputs[?OutputKey=='ProcurementApiAliasTarget'].OutputValue | [0]" --output text)" + zone="$(aws cloudformation describe-stacks --profile "${PROD_PROFILE}" --region "${REGION}" \ + --stack-name "${STACK_NAME}" \ + --query "Stacks[0].Outputs[?OutputKey=='ProcurementApiAliasHostedZoneId'].OutputValue | [0]" --output text)" + if [[ -z "${target}" || "${target}" == "None" ]]; then + echo "ERROR: no alias target output; deploy the stack first." >&2 + exit 1 + fi + + echo "==> Upserting A-alias ${DOMAIN} -> ${target} in the mgmt zone" + aws route53 change-resource-record-sets --profile "${MGMT_PROFILE}" \ + --hosted-zone-id "${ZONE_ID}" --change-batch "$(cat </dev/null + + echo "OK: A-alias set. Verify: curl -sS -o /dev/null -w '%{http_code}\\n' https://${DOMAIN}/docs (expect 401 without a token)." +} + +case "${1:-}" in + cert) cmd_cert ;; + alias) cmd_alias ;; + *) echo "usage: $0 {cert|alias}" >&2; exit 2 ;; +esac diff --git a/tests/test_api_handlers.py b/tests/test_api_handlers.py index fdf1c5f..301fc7b 100644 --- a/tests/test_api_handlers.py +++ b/tests/test_api_handlers.py @@ -130,6 +130,47 @@ def test_docs_served_when_authenticated(api, monkeypatch): assert json.loads(spec["body"])["openapi"] == "3.1.0" +def test_base_url_custom_domain_omits_stage(api): + mod, _ = api + # execute-api host keeps the /{stage} segment... + execapi = { + "requestContext": { + "domainName": "mvul1efda2.execute-api.us-east-1.amazonaws.com", + "stage": "prod", + } + } + assert mod._base_url(execapi) == ( + "https://mvul1efda2.execute-api.us-east-1.amazonaws.com/prod" + ) + # ...but a custom domain (base-path mapping) serves the stage at the root, + # so no /{stage} is appended (it would advertise a broken server URL). + custom = { + "requestContext": { + "domainName": "procurement-api.seahaven.com", + "stage": "prod", + } + } + assert mod._base_url(custom) == "https://procurement-api.seahaven.com" + assert mod._base_url({}) is None + + +def test_spec_injects_custom_domain_server(api): + mod, _ = api + event = { + "requestContext": { + "domainName": "procurement-api.seahaven.com", + "stage": "prod", + } + } + spec = json.loads(mod._spec_for_request(event)) + urls = [s["url"] for s in spec["servers"]] + # Accessed via the custom domain, every server URL resolves to the custom + # domain (the static entry plus the injected one), and none carry a /prod + # suffix. Exact-equality checks (no substring-in-URL) so this reads + # cleanly and doesn't trip CodeQL's URL-sanitization heuristic. + assert set(urls) == {"https://procurement-api.seahaven.com"} + + def test_token_query_shim_synthesizes_header(api, monkeypatch): mod, _ = api seen = {}