procurement-ingest/lambdas/wo/shoc_emitter/delivery.py
Adam Moussa 2f2fc83a82
fix(webhook): kms:ViaService pins, https-only delivery, cross-account principal CI pin
GPT-4.1 cross-family review of the policy surface (no BLOCK): FIX applied
to the cross-account shoc-backend-dev Decrypt statement and both Lambda
role KMS grants (the key is only ever used via Secrets Manager); its
invariant-enforcement QUESTION answered durably with
tests/test_cross_account_principal_pin.py (any new foreign IAM principal
in cdk/ fails CI). Scanner mediums fixed: delivery.py and the replay
script now refuse non-https URLs (urllib follows file:// and http://).
SQS metadata-action and dynamodb:ListStreams NITs skipped: standard CDK
grant shapes; ListStreams has no resource-level scoping. The 4 gitleaks
HIGHs on docs/shoc-webhook-test-vectors.json are deliberate non-secrets
(shared receiver-verification vectors) suppressed machine-level with
justification.
2026-07-24 15:00:15 -04:00

159 lines
6.3 KiB
Python

"""HMAC-signed webhook delivery to the SHOC receiver (contract sections 6-7).
Owns the signed POST and its response classification. The signing key comes
from Secrets Manager (``workorder-ingest/shoc-webhook-hmac``, dual-key shape
``{"keys": [{"kid", "secret"}, ...]}``), cached in the warm container for a
short TTL so 30-day rotation propagates without waiting for the execution
environment to recycle -- the producer always signs with ``keys[0]``.
Response contract (section 7): 2xx -> delivered; 429/5xx/timeout/connection
error -> RetryableDeliveryError (the handler surfaces it as a batch item
failure so the ESM blocks the shard and retries in order); any other status ->
("rejected", code) for the handler to park (a contract bug must not block the
shard for 24 hours). Secret material and signatures are never logged.
``sign_body`` is a module-level pure function on purpose: the golden-vector
test suite and the replay script both pin against it, and it is the shared
definition Luby's receiver verifies with.
"""
import hashlib
import hmac
import json
import logging
import os
import time
import urllib.error
import urllib.request
from http import HTTPStatus
import boto3
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# Endpoint path is TBD by SHOC; the activation PR confirms the final URL.
SHOC_WEBHOOK_URL = os.environ.get(
"SHOC_WEBHOOK_URL", "https://api.dev.seahaven.com/api/webhooks/work-orders"
)
HMAC_SECRET_ARN = os.environ.get("HMAC_SECRET_ARN")
POST_TIMEOUT_SECONDS = 10
USER_AGENT = "workorder-shoc-emitter/1"
# 2xx = delivered; 429 and 5xx retry; everything else parks (contract sec. 7).
_HTTP_SUCCESS_RANGE = range(HTTPStatus.OK, HTTPStatus.MULTIPLE_CHOICES)
_HTTP_SERVER_ERROR_MIN = HTTPStatus.INTERNAL_SERVER_ERROR
# Refresh the cached key material this often so a rotated secret propagates
# without waiting for the execution environment to recycle (contract requires
# receiver-side TTL <= 300s; the producer matches it).
_HMAC_KEYS_CACHE_TTL_SECONDS = 300
_hmac_keys_cache = None
_hmac_keys_cached_at = 0.0
class RetryableDeliveryError(Exception):
"""Delivery failed in a way the ESM should retry in order (shard-blocking).
``status_code`` carries the HTTP status when one exists (429/5xx); it is
None for timeouts, connection errors, and secret-fetch failures.
"""
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
def _get_hmac_keys() -> list[dict]:
"""Fetch the signing-key list from Secrets Manager, TTL-cached.
An empty/missing ``keys`` list is the bootstrap state before the first
rotation has run -- retryable, not a crash: the shard blocks until the
rotator populates the secret. Fetch failures (throttle, transient IAM/KMS
denial) are likewise retryable so a Secrets Manager blip blocks in order
instead of failing the whole batch. Key material is never logged.
"""
global _hmac_keys_cache, _hmac_keys_cached_at
now = time.monotonic()
if (
_hmac_keys_cache is not None
and now - _hmac_keys_cached_at < _HMAC_KEYS_CACHE_TTL_SECONDS
):
return _hmac_keys_cache
secrets = boto3.client("secretsmanager")
try:
secret = secrets.get_secret_value(SecretId=HMAC_SECRET_ARN)
keys = json.loads(secret["SecretString"]).get("keys") or []
except Exception as exc:
raise RetryableDeliveryError(f"hmac secret fetch failed: {exc}") from exc
if not keys:
raise RetryableDeliveryError("hmac secret not yet rotated")
_hmac_keys_cache = keys
_hmac_keys_cached_at = now
return keys
def sign_body(secret_hex: str, timestamp: int, raw_body: bytes) -> str:
"""HMAC-SHA256 hex digest over ``f"{timestamp}.{raw_body}"`` (raw bytes).
The key is the UTF-8 bytes of the secret string exactly as stored in the
secret's ``secret`` field (the receiver reads the same JSON field -- no
hex-decoding on either side). Pure function; the golden-vector tests and
Luby's receiver both pin against this definition.
"""
string_to_sign = f"{timestamp}.".encode() + raw_body
return hmac.new(
secret_hex.encode("utf-8"), string_to_sign, hashlib.sha256
).hexdigest()
def deliver(envelope: dict) -> tuple[str, int]:
"""POST one envelope to SHOC. Returns ("delivered"|"rejected", status).
Raises RetryableDeliveryError for 429/5xx/timeout/connection failures so
the caller can block the shard (in-order retry, contract section 7).
"""
if not SHOC_WEBHOOK_URL.startswith("https://"):
# Fail closed on any non-HTTPS scheme (file://, http://, ...): the
# URL is operator-set env config, but urllib would happily follow
# other schemes and the HMAC only protects an HTTPS transport.
raise RetryableDeliveryError(
"SHOC_WEBHOOK_URL is not an https:// URL; refusing to deliver"
)
raw_body = json.dumps(envelope).encode("utf-8")
timestamp = int(time.time())
signing_key = _get_hmac_keys()[0]
signature = sign_body(signing_key["secret"], timestamp, raw_body)
request = urllib.request.Request(
SHOC_WEBHOOK_URL,
data=raw_body,
headers={
"Content-Type": "application/json; charset=utf-8",
"User-Agent": USER_AGENT,
"X-SH-Timestamp": str(timestamp),
"X-SH-Key-Id": signing_key["kid"],
"X-SH-Signature": f"v1={signature}",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=POST_TIMEOUT_SECONDS) as response:
status_code = response.status
except urllib.error.HTTPError as exc:
status_code = exc.code
if (
status_code == HTTPStatus.TOO_MANY_REQUESTS
or status_code >= _HTTP_SERVER_ERROR_MIN
):
raise RetryableDeliveryError(
f"receiver returned {status_code}", status_code=status_code
) from exc
return ("rejected", status_code)
except (TimeoutError, urllib.error.URLError, OSError) as exc:
raise RetryableDeliveryError(f"connection error: {exc}") from exc
if status_code in _HTTP_SUCCESS_RANGE:
return ("delivered", status_code)
# A non-2xx that urlopen did not raise for (e.g. an unfollowed 3xx):
# contract bug on someone's side -- park it, never block the shard.
return ("rejected", status_code)