mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 18:53:14 +00:00
* docs(webhook): revise SHOC webhook contract and plan for post-migration reality Branch re-cut on main 2026-07-23 (old base carried stale PR #99 commits). Contract Rev 2026-07-23: - Producer account corrected: seahaven-prod (011934824531); mgmt frozen - Reconciliation backstop is the new procurement read API, not SyncController - wo_status "unknown" is real; SHOC must map it (checklist item added) - write_origin forward-compat note for phase-2 write-back echo suppression - SyncVendorReplies retirement flagged (dead table, no vendor_reply event) Plan updates: - Account gate: seahaven-prod only; never enable streams on mgmt tables - Emitter ships DARK (ESMs enabled=False); activation is a deliberate flip after the SHOC receiver passes shared HMAC vectors - Post-refactor conventions: common.py helpers, bundle-consistency AST pins, pytest.ini --cov additions, consolidated test roots - Dedicated-CMK rationale, secret-ARN handooff step, consumer audit refreshed (slack-bot decommissioned), enum golden test, write_origin skip-branch test * feat(webhook): SHOC WO webhook emitter — dark-ship streams, HMAC secret + rotation Implements docs/shoc-webhook-plan.md Phases 1-5 (PR-2 of the SHOC call-and-be-called effort). Everything ships DARK: both DynamoDB event source mappings deploy enabled=False; activation is a deliberate one-line follow-up PR gated on the SHOC receiver passing the shared HMAC test vectors. - Streams: NEW_AND_OLD_IMAGES on WorkOrders + WorkOrderComments (in-place update, RETAIN + logical IDs untouched; no existing consumers — verified live, neither table had a stream). - workorder-shoc-emitter (Py3.12/ARM64): stream -> envelope -> HMAC-signed POST per docs/shoc-webhook-contract.md; strict per-shard ordering (parallelization 1, bisect off, retry until 24h age, ReportBatchItemFailures); 429/5xx/timeout block the shard in order, other 4xx park to workorder-shoc-emitter-rejected; ESM failures -> workorder-shoc-emitter-failures (metadata; replay rebuilds from DynamoDB). Echo guard skips write_origin=shoc-write-api. - Secret workorder-ingest/shoc-webhook-hmac on a dedicated CMK (alias workorder-ingest-shoc-webhook-kms); cross-account GetSecretValue/DescribeSecret + kms:Decrypt granted to exactly arn:aws:iam::396287094661:role/shoc-backend-dev. RemovalPolicy DESTROY deliberately (machine-generated material; avoids the fixed-name RETAIN-orphan deadlock). - workorder-shoc-hmac-rotator: 30-day rotation, dual-key overlap, 64-hex keys, kid = UTC %Y-%m-%dT%H. - Alarms (ALARM-only -> site-alerts): emitter errors/throttles/ duration + iterator-age (>=10 min) + failures/rejected queue depth; rotator standard trio. - scripts/replay_shoc_webhooks.py: dry-run-default operator replay (rebuilds from tables, replay:true envelopes). - Tests: 742 passing, 85.56% aggregate; golden HMAC vectors shared with SHOC in docs/shoc-webhook-test-vectors.json (emitter + replay signing pinned to identical vectors); bundle-consistency AST pins for both new bundles. - README: WO stack + webhook feed section, alarm table, runbooks; removed stale seahaven-slack-bot consumer references. * 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. * harden(webhook): resolve /sh-security-review findings (1 confirmed medium + cheap fixes) High-recall detector fan-out (injection/authz/secrets-crypto/iac-iam/logic) + proof-or-kill verifier. Gate PASSES: 1 confirmed medium, 0 confirmed critical/high. Confirmed finding fixed; several unverified-but-cheap hardenings applied since the emitter ships dark and activation is weeks out. - CONFIRMED medium (confused deputy): the rotation Lambda's generated invoke permission for secretsmanager.amazonaws.com carried no SourceAccount/SourceArn, so any account's Secrets Manager could invoke the rotator. Patched the generated CfnPermission in place (a second permission would be additive, not restrictive) to pin account + this secret ARN. - delivery + replay: refuse to follow receiver 3xx redirects (no-redirect opener) so live X-SH-* auth headers can't be forwarded to a receiver-chosen Location and an http:// Location can't slip past the https guard. Fixed the "unfollowed 3xx" comment that was factually wrong. - delivery: classify 401/403 as retryable (invalidate key cache + retry in order) instead of parking -- transient auth failures (rotation outran the TTL cache, clock skew) are availability events, not contract bugs. - envelope: build_event now genuinely total (guarded eventID / ApproximateCreationDateTime subscripts) per its own never-raise contract. - handler: catch-all so an unexpected per-record error (e.g. SQS park failure) reports only that record instead of failing the whole batch (which would re-deliver every earlier success for 24h); per-invocation emit/skip batch summary so a systemic silent drop is queryable/alarmable. - rotator: narrow the AWSCURRENT-read except to ResourceNotFound/JSONDecode (transient SM/KMS errors re-raise so the overlap key isn't silently dropped); kid uniqueness checked against ALL retained kids with a random suffix on collision (never reissue a kid for a different secret). - contract: skeleton-upsert required on ANY unknown work_order_id (not just comment-before-create) + monotonicity guard (ignore older updated_at), so a parked created or an out-of-order replay can't corrupt receiver state. Unverified/refuted findings left as-is with rationale: the two "high" logic claims (whole-batch crash triggers, ordering violation) were refuted on reachability (real stream records carry required fields; persistence writes strings only; full-state idempotent upsert absorbs the ordering gap). Signed kid/version binding (AUTHZ-002) declined: coordinated contract change, not cheap, no exploit with one algorithm/key. * fix(webhook): drop kid from rotator test_ok log (CodeQL clear-text-logging FP) GHAS CodeQL flagged py/clear-text-logging-sensitive-data (high) at _test_secret's success log because head["kid"] is subscripted from the same parsed-secret dict that holds head["secret"] — the taint tracker can't tell the non-secret key id from the secret. The secret value is never logged. Rather than dismiss the alert (fragile; re-alerts on line moves), remove the flow: kid is already logged at stage time in _create_secret and version_id correlates the steps, so the test_ok log keeps only event + version_id. Also hardens against a future edit that swaps the logged field.
167 lines
11 KiB
Markdown
167 lines
11 KiB
Markdown
# SHOC Work-Order Webhook — Delivery Contract (v1)
|
|
|
|
**Status:** DRAFT — for SHOC team (Luby) review. **Rev 2026-07-23** (supersedes the 2026-07-16 draft: producer account corrected to seahaven-prod; reconciliation backstop changed to the new read API; §4.1 `unknown` status note; §3 `write_origin` forward-compat note. Sections 2, 5, 6, 7, and 10 are unchanged from the 07-16 draft).
|
|
**Producer:** `workorder-shoc-emitter` Lambda, Sea Haven **seahaven-prod** AWS account (**011934824531**, us-east-1). The former management account (328440206208) is frozen/rollback-only and will never host this feed, its secret, or its streams.
|
|
**Consumer:** SHOC backend (.NET 8), initially `https://api.dev.seahaven.com`. Endpoint path is SHOC's choice — suggested `POST /api/webhooks/work-orders`; map entities onto the work-orders domain from shoc-backend PR #10.
|
|
|
|
## 1. Overview
|
|
|
|
Every work-order mutation the procurement-ingest pipeline writes to DynamoDB is pushed to SHOC as an HTTPS POST within seconds. The feed is driven by DynamoDB Streams, so events are emitted **in the exact order the pipeline committed them**, per work order. DynamoDB remains the source of truth; this webhook is a realtime feed. The reconciliation backstop (and the initial-history load — the feed starts at activation time, not from history) is the **procurement read API** (`GET /work-orders`, `GET /work-orders/{id}/comments`, IAM SigV4 — see its OpenAPI doc). SHOC's existing SyncController DynamoDB scan is transitional: it points at the old management account and retires when those stacks are decommissioned. `SyncVendorReplies` should be dropped on the SHOC side — the `VendorReplies` table is dead (its writer was deleted), and no `vendor_reply` webhook event exists or is planned.
|
|
|
|
## 2. Transport
|
|
|
|
- HTTPS POST, `Content-Type: application/json; charset=utf-8`, body ≤ 256 KB.
|
|
- Producer timeout is **10 seconds**. Respond `2xx` as fast as possible; if your processing is slow, accept-and-enqueue internally rather than processing inline.
|
|
|
|
## 3. Event envelope
|
|
|
|
```json
|
|
{
|
|
"schema_version": 1,
|
|
"delivery_id": "4b7c2f0e-...",
|
|
"event_type": "work_order.updated",
|
|
"occurred_at": "2026-07-16T14:03:22.114208+00:00",
|
|
"source": "procurement-ingest/workorder-shoc-emitter",
|
|
"replay": false,
|
|
"data": { ... }
|
|
}
|
|
```
|
|
|
|
| Field | Meaning |
|
|
|---|---|
|
|
| `schema_version` | Integer. Breaking payload changes increment it; SHOC should reject versions it doesn't know. |
|
|
| `delivery_id` | Unique per source event, **stable across producer retries** — your idempotency key. Persist it; ignore any delivery whose id you've already processed. |
|
|
| `event_type` | See §4. |
|
|
| `occurred_at` | ISO 8601 **with UTC offset** (`+00:00`), when the pipeline committed the write. |
|
|
| `replay` | `true` when re-sent by the operator replay tool after an outage. Same idempotency rules apply. |
|
|
| `data` | Event-type-specific body, §4. |
|
|
|
|
> **Forward-compat (phase-2 write-back):** when SHOC later gains write endpoints on the read API, records SHOC itself wrote will carry a `write_origin` attribute (e.g. `"shoc-write-api"`), and the emitter will skip them so SHOC never receives an echo of its own write. Nothing to build now — just don't reject envelopes if a `data.write_origin` field appears later.
|
|
|
|
## 4. Event types
|
|
|
|
### 4.1 `work_order.created` / `work_order.updated` / `work_order.cancelled`
|
|
|
|
Emitted from `WorkOrders` table writes: `created` on first insert, `updated` on any subsequent change, `cancelled` when `wo_status` transitions to `cancelled` (a `cancelled` event is a specialization of `updated` — same body). `data` is the **full current work-order state** (not a diff); fields absent from the source email are `null`:
|
|
|
|
```json
|
|
{
|
|
"work_order_id": "11144580730",
|
|
"wo_status": "assigned",
|
|
"description": "Dock door 14 won't close",
|
|
"customer": "AMAZON",
|
|
"site_code": "JFK8",
|
|
"building": "JFK8",
|
|
"address": "546 Gulf Ave, Staten Island, NY 10314",
|
|
"severity": "3-Normal",
|
|
"priority": "Medium",
|
|
"assigned_to": "Sea Haven Industries",
|
|
"date_reported": "2026-07-14T09:12:00",
|
|
"scheduled_start": "2026-07-17T08:00:00",
|
|
"due_date": "2026-07-21T17:00:00",
|
|
"record_type": "new_work_order",
|
|
"created_at": "2026-07-16T14:03:22.114208+00:00",
|
|
"updated_at": "2026-07-16T14:03:22.114208+00:00"
|
|
}
|
|
```
|
|
|
|
`wo_status` ∈ `new | assigned | in_progress | on_hold | completed | cancelled | unknown`. `record_type` (the triggering email's type) ∈ `new_work_order | update | comment | cancellation`.
|
|
|
|
> **`unknown` is a real value** the extractor emits when the source email doesn't state a status — SHOC must define an explicit mapping for it (and defensively for any unrecognized future value) rather than letting it fall through a switch. Field names and enums in this section mirror the producer's write path, `lambdas/wo/email_processor/persistence.py` (+ `prompts.py` for enums) on `main` — that code is the authoritative source if this doc ever drifts.
|
|
|
|
### 4.2 `work_order.comment_added`
|
|
|
|
Emitted from `WorkOrderComments` inserts — one per source email (comments, but also update/cancellation event records):
|
|
|
|
```json
|
|
{
|
|
"work_order_id": "11144580730",
|
|
"comment_id": "11144580730#2026-04-27T23:51:48#a1b2c3d4e5f6",
|
|
"record_type": "comment",
|
|
"commenter": "APM Technician",
|
|
"text": "Vendor dispatched, ETA tomorrow AM.",
|
|
"created_at": "2026-04-27T23:51:48",
|
|
"ingested_at": "2026-07-16T14:03:22.114208+00:00"
|
|
}
|
|
```
|
|
|
|
`comment_id` is unique per source email and stable across retries — use it (or `delivery_id`) for dedupe.
|
|
|
|
## 5. Ordering and delivery semantics
|
|
|
|
- **At-least-once.** Duplicates are possible on retry; dedupe on `delivery_id`.
|
|
- **Per-work-order, per-event-family ordering is guaranteed on the live feed:** all `work_order.*` state events for a given `work_order_id` arrive in commit order (a `cancelled` never precedes its `created`). Comment events are likewise ordered among themselves per work order. **Caveat:** this holds for delivered events. A rare non-retryable `4xx` on one event (a contract bug) parks *that* event and lets later events proceed, so the receiver can in principle see a `work_order.updated`/`cancelled` for a WO whose `created` was parked. Because every `work_order.*` body is **full current state** (not a diff), the two requirements below make this safe regardless.
|
|
- **Requirement — skeleton-upsert on ANY unknown `work_order_id`:** on any `work_order.*` event (state or comment) referencing a `work_order_id` you have not seen, upsert the record from the event's own `data` (or a skeleton for a bare comment) rather than dropping it. Full-state bodies mean a later event fully reconstructs a WO whose `created` never arrived — the same out-of-order semantics the pipeline itself uses for source emails.
|
|
- **Requirement — monotonicity (do not regress state):** ignore any `work_order.*` state event whose `data.updated_at` is **older** than the `updated_at` already stored for that WO. This makes an operator replay (see §8) or a rare out-of-order delivery idempotent and non-regressing: a stale snapshot can never overwrite newer state. Comments are append-only (dedupe on `comment_id`), so this applies only to WO state events.
|
|
- **Cross-family ordering is NOT guaranteed:** a `comment_added` may occasionally arrive before the `created` for its work order (separate streams) — covered by the skeleton-upsert requirement above.
|
|
- Expected volume ≈ 22,900 events/month (~760/day); >90% are comment events. Bursts of a few events/second are possible.
|
|
|
|
## 6. Authentication — HMAC signature
|
|
|
|
Every request carries:
|
|
|
|
```
|
|
X-SH-Timestamp: 1784642602 (unix seconds, producer clock)
|
|
X-SH-Key-Id: 2026-07-20T00
|
|
X-SH-Signature: v1=hex(HMAC_SHA256(secret, "{timestamp}.{raw_body}"))
|
|
```
|
|
|
|
Verification requirements (fail closed — reject with `401` on any failure):
|
|
|
|
1. Reject if `|now - X-SH-Timestamp| > 300s` (replay window).
|
|
2. Look up the secret for `X-SH-Key-Id` from the shared secret material (§6.1). Reject unknown key ids.
|
|
3. Recompute `HMAC-SHA256(secret, timestamp + "." + raw_body)` over the **raw request bytes** (before any JSON parsing/re-serialization) and compare **constant-time** against the `v1=` value.
|
|
|
|
### 6.1 Key material and rotation
|
|
|
|
The secret lives in **seahaven-prod (011934824531)** Secrets Manager: **`workorder-ingest/shoc-webhook-hmac`**, encrypted with a dedicated customer-managed KMS key so it is readable cross-account. The SHOC backend role (`arn:aws:iam::396287094661:role/shoc-backend-dev`) is granted `secretsmanager:GetSecretValue` + `kms:Decrypt` via resource policies — exact role ARN only; future `shoc-backend-staging`/`-prod` roles are each a deliberate, individually-reviewed policy addition (no wildcard/prefix trust). Secret value shape:
|
|
|
|
```json
|
|
{
|
|
"keys": [
|
|
{ "kid": "2026-07-20T00", "secret": "<64 hex chars>" },
|
|
{ "kid": "2026-06-20T00", "secret": "<64 hex chars>" }
|
|
]
|
|
}
|
|
```
|
|
|
|
- Rotation is automated (30-day schedule): a new key is prepended, the previous key is kept for one overlap cycle.
|
|
- The producer always signs with `keys[0]`. The receiver must accept **any** listed `kid`.
|
|
- **Receiver must re-fetch the secret at most every 5 minutes** (cache TTL ≤ 300s) so rotation propagates with zero downtime. Also re-fetch immediately on an unknown-`kid` before rejecting.
|
|
|
|
## 7. Response contract
|
|
|
|
| Receiver response | Producer behavior |
|
|
|---|---|
|
|
| `2xx` | Delivered. Body ignored. |
|
|
| `429`, `5xx`, timeout, connection error | Retried with in-order blocking (that work order's events queue behind it) for up to **24 hours**; then parked for operator replay. |
|
|
| Any other `4xx` | Not retried — parked immediately and alarmed on our side. A `4xx` means a contract bug; expect us to call you. |
|
|
|
|
## 8. Backstop / replay
|
|
|
|
If SHOC is down longer than the retry window or deliveries are parked, an operator replay tool re-sends events rebuilt from DynamoDB (marked `"replay": true`). Because DynamoDB stays the source of truth, the procurement read API can also fully reconcile at any time (paginated `GET /work-orders` + per-WO comments). Missed webhooks are therefore an availability inconvenience, never data loss.
|
|
|
|
## 9. SHOC-side checklist
|
|
|
|
- [ ] Endpoint path confirmed + implemented against PR #10 entity model
|
|
- [ ] Raw-body HMAC verification, constant-time compare, ±300s timestamp window, fail-closed
|
|
- [ ] Cross-account secret fetch with ≤5-min cache + refresh-on-unknown-kid
|
|
- [ ] Dedupe store on `delivery_id` (and/or `comment_id`)
|
|
- [ ] Skeleton-upsert on ANY unknown `work_order_id` (state events too, not just comment-before-create)
|
|
- [ ] Monotonicity guard: ignore WO state events whose `data.updated_at` is older than stored (§5)
|
|
- [ ] Fast `2xx` ack (<10s), internal queue if processing is slow
|
|
- [ ] Timestamp parsing accepts `+00:00` offsets
|
|
- [ ] Reject unknown `schema_version`
|
|
- [ ] Explicit mapping for `wo_status: unknown` (and a safe default for unrecognized future values)
|
|
- [ ] Initial history loaded via the read API before relying on the feed (feed starts at activation, not from history)
|
|
- [ ] `SyncVendorReplies` dropped (dead table; no vendor-reply event exists)
|
|
|
|
## 10. Environments
|
|
|
|
| Env | URL | Status |
|
|
|---|---|---|
|
|
| dev | `https://api.dev.seahaven.com/<path TBD>` | first target (prod data in dev accepted 2026-07-16) |
|
|
| staging | `https://api.staging.seahaven.com/<path TBD>` | when staging deploys |
|
|
| prod | TBD | when SHOC prod exists |
|
|
|
|
The target URL is producer-side config (per-env), so promotion is a config change, not a code change.
|