mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-10-07 16:08:54 +00:00
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.
243 lines
9.7 KiB
Python
243 lines
9.7 KiB
Python
"""Procurement API Lambda (API Gateway REST proxy integration).
|
|
|
|
Auth is split by route class and enforced at two layers:
|
|
- Data routes: AWS_IAM at the gateway (SigV4; cross-account callers allowed by
|
|
the API resource policy). The handler does NOT re-check a token there --
|
|
authorization is API Gateway's job on those routes.
|
|
- Docs routes (/docs, /openapi.json): reachable at the gateway (auth NONE +
|
|
resource-policy carve-out) but the handler fails closed on the shared
|
|
header token via web_ui_auth (same secret + constant-time compare as the
|
|
web UIs), so they are never an unauthenticated data path (INFRA-74).
|
|
"""
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
import po_repo
|
|
import wo_repo
|
|
from botocore.exceptions import ClientError
|
|
from pagination import BadCursor, clamp_limit
|
|
from router import DATA_ROUTES, DOCS_ROUTES, PLANNED_ROUTES
|
|
from serialization import error_response, json_response
|
|
from web_ui_auth import is_authenticated
|
|
|
|
logger = logging.getLogger()
|
|
logger.setLevel(logging.INFO)
|
|
|
|
_MODULE_DIR = Path(__file__).resolve().parent
|
|
_SPEC_PATH = _MODULE_DIR / "openapi.json"
|
|
_DOCS_PATH = _MODULE_DIR / "docs.html"
|
|
_REDOC_JS_PATH = _MODULE_DIR / "redoc.standalone.js"
|
|
_FONTS_CSS_PATH = _MODULE_DIR / "fonts.css"
|
|
# The docs page template carries these placeholders, filled server-side so
|
|
# /docs is a single token-gated request (a browser can't attach the auth
|
|
# header to a follow-up asset fetch) with the vendored Redoc bundle, the
|
|
# design-system fonts, and the spec inlined, no CDN.
|
|
_SPEC_PLACEHOLDER = "__OPENAPI_SPEC_JSON__"
|
|
_JS_PLACEHOLDER = "__REDOC_JS__"
|
|
_FONTS_PLACEHOLDER = "__FONTS_CSS__"
|
|
|
|
_spec_cache = None
|
|
_docs_shell_cache = None
|
|
# The server URL committed in openapi.json; swapped for the live invoke URL
|
|
# (derived from the request) when the spec is served, so the docs page shows a
|
|
# correct, current endpoint even if the RestApi is recreated.
|
|
_COMMITTED_SERVER_URL = "https://mvul1efda2.execute-api.us-east-1.amazonaws.com/prod"
|
|
|
|
_REPO_FUNCS = {
|
|
"list_work_orders": wo_repo.list_work_orders,
|
|
"get_work_order": wo_repo.get_work_order,
|
|
"list_comments": wo_repo.list_comments,
|
|
"list_purchase_orders": po_repo.list_purchase_orders,
|
|
"get_purchase_order": po_repo.get_purchase_order,
|
|
"list_verified_sites": po_repo.list_verified_sites,
|
|
"get_verified_site": po_repo.get_verified_site,
|
|
}
|
|
|
|
_PATH_PARAM_BY_RESOURCE = {
|
|
"/work-orders/{workOrderId}": "workOrderId",
|
|
"/work-orders/{workOrderId}/comments": "workOrderId",
|
|
"/purchase-orders/{poNumber}": "poNumber",
|
|
"/verified-sites/{siteCode}": "siteCode",
|
|
}
|
|
|
|
|
|
# Headers on the docs responses: the token rides in the ?token= query shim,
|
|
# so keep the token-keyed URL and page out of shared/browser caches and out of
|
|
# any Referer sent to a followed link.
|
|
_DOCS_SECURITY_HEADERS = {
|
|
"Cache-Control": "no-store",
|
|
"Referrer-Policy": "no-referrer",
|
|
}
|
|
|
|
|
|
def _load_spec() -> str:
|
|
"""The committed spec text (cached)."""
|
|
global _spec_cache
|
|
if _spec_cache is None:
|
|
_spec_cache = _SPEC_PATH.read_text(encoding="utf-8")
|
|
return _spec_cache
|
|
|
|
|
|
def _base_url(event: dict) -> str | None:
|
|
rc = event.get("requestContext") or {}
|
|
domain, stage = rc.get("domainName"), rc.get("stage")
|
|
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.
|
|
if ".execute-api." in domain:
|
|
return f"https://{domain}/{stage}" if stage else None
|
|
return f"https://{domain}"
|
|
|
|
|
|
def _spec_for_request(event: dict) -> str:
|
|
"""Committed spec with servers[0].url set to the live invoke URL when the
|
|
request context provides it (falls back to the committed literal)."""
|
|
raw = _load_spec()
|
|
base = _base_url(event)
|
|
return raw.replace(_COMMITTED_SERVER_URL, base) if base else raw
|
|
|
|
|
|
def _docs_shell() -> str:
|
|
"""The Redoc page with the vendored bundle inlined; the SPEC placeholder
|
|
is left intact so the per-request server-injected spec splices in cheaply
|
|
(the heavy ~1.1MB bundle is assembled once and cached).
|
|
|
|
Each blob is spliced into a <style>/<script> block, where the HTML parser
|
|
ends the element at the first literal "</style"/"</script" regardless of
|
|
quoting, so guard against a breakout a future asset update could add (the
|
|
pinned assets have none today): JS "</script" -> "<\\/script" (equivalent
|
|
inside a JS string/regex), CSS "</style" -> "<\\/style".
|
|
"""
|
|
global _docs_shell_cache
|
|
if _docs_shell_cache is None:
|
|
js = _REDOC_JS_PATH.read_text(encoding="utf-8").replace(
|
|
"</script", "<\\/script"
|
|
)
|
|
fonts = _FONTS_CSS_PATH.read_text(encoding="utf-8").replace(
|
|
"</style", "<\\/style"
|
|
)
|
|
_docs_shell_cache = (
|
|
_DOCS_PATH.read_text(encoding="utf-8")
|
|
.replace(_JS_PLACEHOLDER, js)
|
|
.replace(_FONTS_PLACEHOLDER, fonts)
|
|
)
|
|
return _docs_shell_cache
|
|
|
|
|
|
def _render_docs_html(event: dict) -> str:
|
|
# "<" -> < so a spec string can never close the <script> block (same
|
|
# value to JSON.parse); the spec object is inlined into Redoc.init.
|
|
spec = _spec_for_request(event).replace("<", "\\u003c")
|
|
return _docs_shell().replace(_SPEC_PLACEHOLDER, spec)
|
|
|
|
|
|
def _with_token_shim(event: dict) -> dict:
|
|
"""Copy a ?token= query parameter into an x-auth-token header.
|
|
|
|
Browsers can't set headers on plain navigation, so /docs accepts the
|
|
shared token as a query parameter too. The constant-time compare still
|
|
happens inside web_ui_auth -- this only synthesizes the header on a
|
|
shallow copy. Acceptable only while API Gateway access logging stays off
|
|
(nothing at the gateway records the query string); rotate the token if
|
|
access logging is ever enabled.
|
|
"""
|
|
qs = event.get("queryStringParameters") or {}
|
|
token = qs.get("token")
|
|
if not token:
|
|
return event
|
|
shimmed = dict(event)
|
|
headers = dict(event.get("headers") or {})
|
|
headers["x-auth-token"] = token
|
|
shimmed["headers"] = headers
|
|
return shimmed
|
|
|
|
|
|
def _docs_response(resource: str, event: dict) -> dict:
|
|
# SECURITY INVARIANT: these routes serve ONLY the committed spec and the
|
|
# static docs page -- never table data. The gateway resource policy allows
|
|
# Principal "*" on exactly these two GETs on the strength of that; serving
|
|
# anything dynamic here requires a resource-policy + security re-review.
|
|
if not is_authenticated(_with_token_shim(event)):
|
|
return error_response(401, "unauthorized")
|
|
if resource == "/openapi.json":
|
|
return {
|
|
"statusCode": 200,
|
|
"headers": {"Content-Type": "application/json", **_DOCS_SECURITY_HEADERS},
|
|
"body": _spec_for_request(event),
|
|
}
|
|
return {
|
|
"statusCode": 200,
|
|
"headers": {
|
|
"Content-Type": "text/html; charset=utf-8",
|
|
**_DOCS_SECURITY_HEADERS,
|
|
},
|
|
"body": _render_docs_html(event),
|
|
}
|
|
|
|
|
|
def _data_response(route_key: tuple, event: dict) -> dict:
|
|
method, resource = route_key
|
|
func = _REPO_FUNCS[DATA_ROUTES[route_key]]
|
|
qs = event.get("queryStringParameters") or {}
|
|
path_params = event.get("pathParameters") or {}
|
|
|
|
if DATA_ROUTES[route_key].startswith("list_"):
|
|
limit = clamp_limit(qs.get("limit"))
|
|
cursor = qs.get("cursor")
|
|
if resource in _PATH_PARAM_BY_RESOURCE:
|
|
entity_id = path_params.get(_PATH_PARAM_BY_RESOURCE[resource], "")
|
|
items, next_cursor = func(entity_id, limit, cursor)
|
|
else:
|
|
items, next_cursor = func(limit, cursor)
|
|
return json_response(200, {"items": items, "next_cursor": next_cursor})
|
|
|
|
entity_id = path_params.get(_PATH_PARAM_BY_RESOURCE[resource], "")
|
|
item = func(entity_id)
|
|
if item is None:
|
|
return error_response(404, "not found")
|
|
return json_response(200, item)
|
|
|
|
|
|
def _dispatch(route_key: tuple, event: dict) -> dict:
|
|
if route_key in PLANNED_ROUTES:
|
|
return error_response(501, "planned endpoint - not implemented (phase 2)")
|
|
if route_key in DOCS_ROUTES:
|
|
return _docs_response(route_key[1], event)
|
|
if route_key in DATA_ROUTES:
|
|
return _data_response(route_key, event)
|
|
return error_response(404, "not found")
|
|
|
|
|
|
def handler(event, context):
|
|
# Deploy-guard healthcheck: a direct-invoke {"healthcheck": true} probe
|
|
# returns before any routing/auth so the post-deploy smoke gate can verify
|
|
# the bundle imports and the runtime boots.
|
|
if isinstance(event, dict) and event.get("healthcheck") is True:
|
|
return {"healthcheck": "ok"}
|
|
|
|
method = (event.get("httpMethod") or "").upper()
|
|
resource = event.get("resource") or ""
|
|
|
|
try:
|
|
return _dispatch((method, resource), event)
|
|
except BadCursor as exc:
|
|
return error_response(400, str(exc))
|
|
except ClientError as exc:
|
|
# A client-supplied cursor that survives validation but is still
|
|
# inconsistent at the data layer makes DynamoDB raise
|
|
# ValidationException; map it to 400, not 500, so a crafted cursor
|
|
# can't drive the 5xx alarm. Any other AWS error is a real 500.
|
|
if exc.response.get("Error", {}).get("Code") == "ValidationException":
|
|
return error_response(400, "cursor is not valid")
|
|
logger.exception("AWS error serving %s %s", method, resource)
|
|
return error_response(500, "internal error")
|
|
except Exception:
|
|
# A raised exception would surface as an opaque 502 from the proxy
|
|
# integration; return a clean 500 instead. The API Gateway 5XX alarm
|
|
# pages on these; the exception (never the request token) is logged.
|
|
logger.exception("Unhandled error serving %s %s", method, resource)
|
|
return error_response(500, "internal error")
|