"""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" # The docs page template carries this placeholder where the spec JSON is # inlined, so /docs is a single token-gated request (a browser can't attach # the auth header to a follow-up asset fetch). _SPEC_PLACEHOLDER = "__OPENAPI_SPEC_JSON__" _spec_cache = None _docs_cache = None _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: """Raw spec bytes, served verbatim at /openapi.json (byte-faithful JSON).""" global _spec_cache if _spec_cache is None: _spec_cache = _SPEC_PATH.read_text(encoding="utf-8") return _spec_cache def _load_docs_html() -> str: global _docs_cache if _docs_cache is None: # Neutralize any " block # where the HTML parser ends the element at the first literal "<" # sequence regardless of JSON quoting. "<" is the same JSON # string value ("<") to JSON.parse but can never close the script tag. inlined_spec = _load_spec().replace("<", "\\u003c") _docs_cache = _DOCS_PATH.read_text(encoding="utf-8").replace( _SPEC_PLACEHOLDER, inlined_spec ) return _docs_cache 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": _load_spec(), } return { "statusCode": 200, "headers": { "Content-Type": "text/html; charset=utf-8", **_DOCS_SECURITY_HEADERS, }, "body": _load_docs_html(), } 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")