procurement-ingest/cdk/procurement_api_stack.py
Adam Moussa cf8ca2eeb6
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
2026-07-24 18:41:10 -04:00

427 lines
20 KiB
Python

"""procurement-api stack: read-only REST API over both pipelines' tables.
Third stack in the app. Serves work orders + comments (WO stack tables) and
purchase orders + verified sites (PO stack tables) to SigV4 callers -- the
primary consumer is the SHOC backend, for which this API replaces the retired
SyncController cross-account DynamoDB scan as the reconciliation/backfill
path. Also hosts the token-gated OpenAPI docs page (/docs, /openapi.json).
Tables are imported by fixed physical name (Table.from_table_name), NOT
passed as cross-stack objects: object passing would synthesize CFN Exports
from the owning stacks and lock them against future changes to the tables.
The one thing name-import does NOT carry is the purchase-orders CMK
association -- see the explicit KMS grant below.
"""
import aws_cdk as cdk
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,
aws_iam as iam,
aws_kms as kms,
aws_lambda as lambda_,
aws_secretsmanager as secretsmanager,
aws_sns as sns,
aws_ssm as ssm,
)
from constructs import Construct
import common
# The only cross-account caller. Grants are to this exact role ARN -- future
# shoc-backend-staging/-prod roles are each a deliberate, individually
# cross-reviewed addition (no wildcard/prefix trust).
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:
super().__init__(scope, construct_id, **kwargs)
alarm_topic = sns.Topic.from_topic_arn(
self,
"SiteAlertsTopic",
f"arn:aws:sns:{self.region}:{self.account}:site-alerts",
)
work_orders_table = dynamodb.Table.from_table_name(
self, "WorkOrdersTable", "WorkOrders"
)
comments_table = dynamodb.Table.from_table_name(
self, "CommentsTable", "WorkOrderComments"
)
po_table = dynamodb.Table.from_table_name(
self, "PurchaseOrdersTable", "purchase-orders"
)
verified_sites_table = dynamodb.Table.from_table_name(
self, "VerifiedSitesTable", "verified-sites"
)
web_ui_auth_secret = secretsmanager.Secret.from_secret_name_v2(
self,
"WebUiAuthToken",
"procurement-ingest/web-ui-auth-token",
)
# purchase-orders is SSE-KMS encrypted with the org DynamoDB CMK. A
# name-imported Table has no encryption-key association, so
# grant_read_data alone leaves the reader without kms:Decrypt and every
# purchase-orders read AccessDenies at runtime (the INFRA-104 failure
# class). Import the key from the same SSM parameter po_stack uses and
# grant it explicitly.
dynamodb_cmk = kms.Key.from_key_arn(
self,
"DynamoDbCmk",
ssm.StringParameter.value_for_string_parameter(
self, "/seahaven/dynamodb/cmk-arn"
),
)
# RETAIN log group (INFRA-114). This is the only stateful resource in
# this otherwise-stateless stack, so it carries the fresh-deploy
# rollback trap: if the FIRST create fails after the group exists,
# rollback deletes everything else but retains the group, and the retry
# CREATE then collides on "/aws/lambda/procurement-api already exists".
# Recovery: delete that log group before re-running a failed first
# deploy (same class as the RETAIN-orphan recovery in the deploy-role
# runbook / reference_cfn_deploy_role_gotchas).
api_log_group = common.make_function_log_group(
self, "ProcurementApi", "procurement-api"
)
api_fn = lambda_.Function(
self,
"ProcurementApi",
function_name="procurement-api",
runtime=lambda_.Runtime.PYTHON_3_12,
architecture=lambda_.Architecture.ARM_64,
handler="handler.handler",
code=lambda_.Code.from_asset(
"../lambdas",
exclude=["**/__pycache__/**"],
bundling=cdk.BundlingOptions(
image=lambda_.Runtime.PYTHON_3_12.bundling_image,
command=[
"bash",
"-c",
# Non-recursive glob ships every api/ sibling (the
# allowlist-omission trap from PR #105/PR #2); the spec,
# docs page, and the vendored Redoc bundle ride along
# because the handler serves them from its own package
# dir. web_ui_auth.py must land FLAT beside handler.py
# for the bare import.
"cp api/*.py /asset-output/ && "
"cp api/openapi.json /asset-output/ && "
"cp api/docs.html /asset-output/ && "
"cp api/redoc.standalone.js /asset-output/ && "
"cp api/fonts.css /asset-output/ && "
"cp shared/web_ui_auth.py /asset-output/ && "
"rm -rf /asset-output/__pycache__",
],
),
),
timeout=Duration.seconds(30),
memory_size=256,
log_group=api_log_group,
environment={
"WORK_ORDERS_TABLE": work_orders_table.table_name,
"COMMENTS_TABLE": comments_table.table_name,
"PO_TABLE": po_table.table_name,
"VERIFIED_SITES_TABLE": verified_sites_table.table_name,
"WEB_UI_AUTH_TOKEN_SECRET_ARN": web_ui_auth_secret.secret_arn,
},
)
work_orders_table.grant_read_data(api_fn)
comments_table.grant_read_data(api_fn)
po_table.grant_read_data(api_fn)
verified_sites_table.grant_read_data(api_fn)
web_ui_auth_secret.grant_read(api_fn)
api_fn.add_to_role_policy(
iam.PolicyStatement(
actions=["kms:Decrypt", "kms:DescribeKey"],
resources=[dynamodb_cmk.key_arn],
# Scope the grant to the DynamoDB data path only: the role can
# decrypt purchase-orders items via DynamoDB, never call
# kms:Decrypt directly on arbitrary ciphertext under the shared
# org CMK.
conditions={
"StringEquals": {
"kms:ViaService": f"dynamodb.{self.region}.amazonaws.com"
}
},
)
)
# Resource policy: once a REST API has one, anything not explicitly
# allowed is denied -- so the docs routes need their own Allow or the
# NONE-auth methods would be black-holed. The docs routes are still
# token-gated inside the Lambda (fail-closed), so this is not an
# unauthenticated data path (INFRA-74 posture).
#
# The SHOC data grant enumerates the exact GET resources rather than
# GET/*: adding a RESOURCE must be as reviewable as adding a PRINCIPAL,
# so a future GET route can't silently inherit cross-account reach
# without a policy diff. (Note: this resource policy binds CROSS-account
# callers only; a same-account principal holding execute-api:Invoke is
# authorized by its own identity policy under AWS union semantics -- it
# is NOT constrained here, including on the planned PATCH/POST methods,
# which is why those also rely on the 501 handler + absent write grant,
# not on this policy, until phase 2.)
shoc_data_resources = [
f"execute-api:/{STAGE_NAME}/GET/work-orders",
f"execute-api:/{STAGE_NAME}/GET/work-orders/*",
f"execute-api:/{STAGE_NAME}/GET/purchase-orders",
f"execute-api:/{STAGE_NAME}/GET/purchase-orders/*",
f"execute-api:/{STAGE_NAME}/GET/verified-sites",
f"execute-api:/{STAGE_NAME}/GET/verified-sites/*",
]
api_policy = iam.PolicyDocument(
statements=[
iam.PolicyStatement(
sid="ShocBackendDevDataRead",
principals=[iam.ArnPrincipal(SHOC_BACKEND_DEV_ROLE_ARN)],
actions=["execute-api:Invoke"],
resources=shoc_data_resources,
),
# SECURITY INVARIANT: this AnyPrincipal allow is safe only
# while /docs//openapi.json serve static docs (handler
# enforces the shared token, fail-closed). Widening these
# routes to dynamic data, or enabling access logging (which
# would record the docs ?token= shim), requires a security
# re-review + docs-token rotation.
iam.PolicyStatement(
sid="DocsTokenGatedRoutes",
principals=[iam.AnyPrincipal()],
actions=["execute-api:Invoke"],
resources=[
f"execute-api:/{STAGE_NAME}/GET/docs",
f"execute-api:/{STAGE_NAME}/GET/openapi.json",
],
),
]
)
# OPERATIONAL NOTE: API Gateway serves the resource policy from the
# deployed stage snapshot, and CDK's Deployment hash is computed from
# resources/methods, not the RestApi Policy. A later policy-ONLY change
# (e.g. revoking the SHOC role) will UPDATE the RestApi but keep serving
# the old policy until a new Deployment is forced (any method/resource
# change, or a salted deployment). When tightening this policy, force a
# redeploy and verify the effective policy post-deploy.
api = apigateway.RestApi(
self,
"ProcurementRestApi",
rest_api_name="procurement-api",
description=(
"Read API over procurement-ingest work orders + purchase "
"orders; token-gated OpenAPI docs at /docs"
),
endpoint_types=[apigateway.EndpointType.REGIONAL],
policy=api_policy,
deploy_options=apigateway.StageOptions(
stage_name=STAGE_NAME,
# Bound the blast radius of the unauthenticated /docs routes
# (and the whole API) below the 10k rps account default -- this
# is a low-volume reconciliation/backfill API, not a hot path.
throttling_rate_limit=50,
throttling_burst_limit=100,
),
# No access/execution logging in v1: avoids the account-level API
# Gateway CloudWatch role prerequisite AND keeps the docs ?token=
# query shim out of any log. Rotate the docs token before ever
# enabling access logging here.
cloud_watch_role=False,
)
integration = apigateway.LambdaIntegration(api_fn)
iam_auth = {"authorization_type": apigateway.AuthorizationType.IAM}
work_orders = api.root.add_resource("work-orders")
work_orders.add_method("GET", integration, **iam_auth)
wo_by_id = work_orders.add_resource("{workOrderId}")
wo_by_id.add_method("GET", integration, **iam_auth)
# Phase-2 planned write endpoints: deployed but the handler answers 501
# and the role holds no DynamoDB write grant. The resource policy denies
# these to the cross-account SHOC role (GET-only enumeration above); a
# same-account caller is NOT blocked by the resource policy, so the 501
# handler + absent write grant are the real gate until phase 2 lands the
# deliberate policy + handler + write-grant change with its own review.
wo_by_id.add_method("PATCH", integration, **iam_auth)
wo_comments = wo_by_id.add_resource("comments")
wo_comments.add_method("GET", integration, **iam_auth)
wo_comments.add_method("POST", integration, **iam_auth)
purchase_orders = api.root.add_resource("purchase-orders")
purchase_orders.add_method("GET", integration, **iam_auth)
purchase_orders.add_resource("{poNumber}").add_method(
"GET", integration, **iam_auth
)
verified_sites = api.root.add_resource("verified-sites")
verified_sites.add_method("GET", integration, **iam_auth)
verified_sites.add_resource("{siteCode}").add_method(
"GET", integration, **iam_auth
)
api.root.add_resource("docs").add_method(
"GET",
integration,
authorization_type=apigateway.AuthorizationType.NONE,
)
api.root.add_resource("openapi.json").add_method(
"GET",
integration,
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),
# which this function's 30 s timeout can never reach. Same idiom,
# right-sized thresholds.
api_fn.metric_errors(period=Duration.minutes(5), statistic="Sum").create_alarm(
self,
"ProcurementApiErrorsAlarm",
alarm_name="procurement-api-errors",
alarm_description=(
"procurement-api Lambda raised (bundle/init failures; the "
"handler catches request errors, so any signal here is "
"structural)"
),
threshold=0,
evaluation_periods=1,
comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING,
).add_alarm_action(cw_actions.SnsAction(alarm_topic))
api_fn.metric_throttles(
period=Duration.minutes(5), statistic="Sum"
).create_alarm(
self,
"ProcurementApiThrottlesAlarm",
alarm_name="procurement-api-throttles",
alarm_description="procurement-api Lambda throttled",
threshold=0,
evaluation_periods=1,
comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING,
).add_alarm_action(cw_actions.SnsAction(alarm_topic))
api_fn.metric_duration(
period=Duration.minutes(5), statistic="p99"
).create_alarm(
self,
"ProcurementApiDurationAlarm",
alarm_name="procurement-api-duration",
alarm_description=(
"procurement-api p99 duration >= 22.5s (75% of the 30s "
"timeout; scans degrading toward timeout)"
),
threshold=22500,
evaluation_periods=3,
datapoints_to_alarm=2,
comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING,
).add_alarm_action(cw_actions.SnsAction(alarm_topic))
# Gateway-side 5xx: catches what the Lambda's own Errors metric can't
# (the handler returns clean 500s; integration faults surface here).
# No 4XX alarm -- 401/403/404 are expected traffic.
cloudwatch.Metric(
namespace="AWS/ApiGateway",
metric_name="5XXError",
dimensions_map={"ApiName": "procurement-api", "Stage": STAGE_NAME},
period=Duration.minutes(5),
statistic="Sum",
).create_alarm(
self,
"ProcurementApi5xxAlarm",
alarm_name="procurement-api-5xx",
alarm_description="procurement-api gateway 5XX responses",
threshold=0,
evaluation_periods=1,
comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
treat_missing_data=cloudwatch.TreatMissingData.NOT_BREACHING,
).add_alarm_action(cw_actions.SnsAction(alarm_topic))
cdk.CfnOutput(
self,
"ApiEndpointUrl",
value=api.url,
description="procurement-api invoke URL (stage prod)",
)
cdk.CfnOutput(
self,
"ProcurementApiFunctionArn",
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)",
)