"""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 sentry_init # noqa: F401 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. 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: """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