diff --git a/.proofs/081-arn-168-authenticated-webhook-admission.md b/.proofs/081-arn-168-authenticated-webhook-admission.md new file mode 100644 index 000000000..c00fd12f6 --- /dev/null +++ b/.proofs/081-arn-168-authenticated-webhook-admission.md @@ -0,0 +1,143 @@ +# Proof Report: 081 — ARN-168 Authenticated Webhook Admission + +## Date + +2026-07-11 + +## Branch / Commit + +- Repository: `nerdsane/temperpaw` +- Existing PR: #451 +- Remote branch: `claude/arn-168-webhook-hmac` +- Local review branch: `codex/pr451-security-review` +- Commit: pending final review gate at proof capture time + +## What Was Done + +- Moved HMAC verification to the public HTTP trigger so invalid traffic creates + no entity. +- Injected a tenant-scoped in-process vault resolver so webhook signing secrets + never traverse the setup HTTP API. +- Required explicit HMAC scheme, vault reference, signature header, delivery-ID + header, body budget, and rate budget on every active route. +- Replaced literal/empty shipped secrets with governed vault references and + exposed all required references in setup/readiness. +- Derived deterministic WebhookEvent IDs from tenant, route ID, and provider + delivery ID. The atomic get-or-create response is validated against the + stored payload/route fingerprint before any dispatch. +- Required authenticated bodies to be JSON objects, preserved exact raw bytes, + and passed canonical JSON downstream; malformed/scalar requests fail before + persistence. +- Snapshotted the route target capability and its digest into `Received`, then + removed the downstream mutable route lookup. +- Restricted WebhookRoute and WebhookEvent access to Admin plus the named WASM + transition owners. +- Removed the duplicate `validate_webhook` WASM module and its HMAC/SHA/hex/ + subtle dependency set. +- Updated the webhook smoke harness to use the governed seeded routes, sign raw + payloads, prove forged/malformed rejection, prove changed-content replay + conflict, and prove exact replay suppression. + +## Verification Flow + +1. Start a local TemperPaw server with a fresh Turso database and built WASM. +2. Resolve the four seeded Patrol webhook routes. +3. Store their five referenced signing keys in the tenant vault. +4. POST forged, signed-malformed, and signed-scalar requests and compare + WebhookEvent count before and after. +5. POST a signed request, then reuse its delivery ID with changed signed content + and require HTTP 409 with no event or dispatch. +6. Replay the exact signed request and require the original event ID with + `status=duplicate`. +7. POST signed Datadog, GitHub, and Discord payloads with unique + delivery IDs. +8. Wait for every WebhookEvent to reach `Processed` and every WorkRequest/Signal + to reach `Linked`. + +## Verification Results + +| Step | Expected | Actual | Status | +|------|----------|--------|--------| +| Forged HTTP delivery | 401 and no durable event | 401; event count stayed 0 | PASS | +| Signed malformed/scalar body | 400 and no durable event | Both 400; event count stayed 0 | PASS | +| Signed request route | Processed -> WorkRequest Linked | Processed; WorkRequest Linked | PASS | +| Signed Datadog route | Processed -> Signal Linked | Processed; Signal Linked | PASS | +| Signed GitHub route | Processed -> Signal Linked | Processed; Signal Linked | PASS | +| Signed Discord route | Processed -> Signal Linked | Processed; Signal Linked | PASS | +| Exact replay | Original event, no redispatch | Same deterministic ID; `duplicate` | PASS | +| Changed body, consumed delivery ID | 409, no new event/dispatch | 409; original remained sole event | PASS | +| Mutation TOCTOU test | Accepted target stays immutable | Original target dispatched after route mutation | PASS | +| Webhook trigger tests | Crypto, replay, HTTP, budgets, logging | 10 passed, 0 failed across focused modules | PASS | +| Full paw-transport crate | No transport regression | 45 passed, 0 failed | PASS | +| Paw Patrol contracts | Manifest/seed/boundary + Cedar matrix | 2 passed, 0 failed focused; full suite green | PASS | +| WASM native + release | route/process compile for host and wasm32 | PASS | PASS | +| Required-secret setup schema | All five required references visible | 1 passed, 0 failed | PASS | + +## What Worked + +- The deterministic event ID was honored by the live OData create path. The + replay returned `wh-a8db5fd0...506ac` without dispatching again. +- The real get-or-create response retained the original payload fingerprint; + changed signed content under the same delivery ID returned HTTP 409. +- The immutable envelope flowed through real route/process WASM into Patrol, + producing linked WorkRequest/Signal entities and FactoryCase/WorkCycle state. +- Raw headers were unnecessary after admission and are no longer persisted. + +## What Didn't Work + +- The original smoke script created duplicate routes even though Patrol already + seeds them. The new fail-closed route lookup surfaced this as a configuration + error. The harness now resolves and exercises the real seeded routes. +- Its original five-minute startup window expired while compiling every OS app, + and load-only correctly rejected unrelated missing required artifacts. The + run built missing artifacts once and then completed against persisted WASM. +- An intermediate implementation assumed duplicate entity POST returned HTTP + 409. Live Temper correctly returns 201 with the authoritative existing state. + Admission now validates that atomic response before dispatch; the final live + replay-mismatch test passes. + +## Limitations + +- The per-route rate window is process-local because TemperPaw currently runs a + single webhook trigger service. It evicts expired entries and fails closed at + a 4,096-route tracking budget. Durable replay is not local: the shared store's + atomic get-or-create selects the stored fingerprint. A future horizontally + scaled trigger should move the rate counter into a shared Temper admission + primitive. +- Static kernel `[[webhook]]` declarations use Temper PR #340. Dynamic + WebhookRoute entities cannot use that static lookup directly, but follow the + same authenticate/authorize/idempotency/dispatch ordering. + +## What Still Doesn't Work + +- No production deploy was performed because PR #451 must remain open. Live + Railway/Datadog verification is therefore pending merge and deployment. +- All five configured webhook secret references must be populated in the + deployment vault; `/readyz` remains degraded and admission fails closed while + any are missing. + +## Artifacts + +- Executable proof: `crates/paw-codex-worker/scripts/webhook-intake-smoke.sh` +- Machine summary: `/tmp/paw-patrol-webhook-smoke-proof-4531-56005/summary.json` +- Entity snapshots and visual proof: `/tmp/paw-patrol-webhook-smoke-proof-4531-56005/` +- Server log: `/tmp/paw-patrol-webhook-smoke-server.log` +- WASM build log: `/tmp/paw-patrol-webhook-smoke-wasm-build.log` + +## Architecture Diagram + +```text +Public POST + -> bounded raw body + -> unique governed route snapshot + -> vault secret resolution + -> in-process tenant vault secret + -> HMAC-SHA256 verify_slice + -> delivery/rate/JSON-object budgets + -> deterministic WebhookEvent atomic get-or-create + -> authoritative fingerprint comparison + -> Received(immutable target + digests) + -> route_webhook WASM + -> process_webhook WASM + -> WorkRequest / Signal +``` diff --git a/crates/paw-codex-worker/scripts/webhook-intake-smoke.sh b/crates/paw-codex-worker/scripts/webhook-intake-smoke.sh index 31d0171ae..885da7766 100755 --- a/crates/paw-codex-worker/scripts/webhook-intake-smoke.sh +++ b/crates/paw-codex-worker/scripts/webhook-intake-smoke.sh @@ -40,9 +40,12 @@ WEBHOOK_PORT="$((PORT + 12))" WEBHOOK_URL="${WEBHOOK_URL:-http://127.0.0.1:${WEBHOOK_PORT}}" TENANT="${TEMPER_TENANT:-patrol_webhook_smoke}" API_KEY="${TEMPER_API_KEY:-patrol-webhook-smoke}" +WORKER_ID="${LOCAL_CODEX_WORKER_ID:-webhook-smoke-worker}" +WORKSPACE_ROOT="${LOCAL_CODEX_WORKTREE_ROOT:-$(dirname "$ROOT")}" DB_PATH="${DB_PATH:-/tmp/paw-patrol-webhook-smoke-${PORT}-$$.db}" READY_ATTEMPTS="${READY_ATTEMPTS:-300}" PROOF_DIR="${PROOF_DIR:-/tmp/paw-patrol-webhook-smoke-proof-${PORT}-$$}" +WEBHOOK_SECRET="${WEBHOOK_SECRET:-patrol-webhook-smoke-signing-secret}" INGEST_WASM_BUILD="os-apps/paw-ingest/wasm/build.sh" PATROL_WASM_BUILD="os-apps/paw-patrol/wasm/build.sh" @@ -127,45 +130,36 @@ wait_for_status() { exit 1 } -register_route() { +find_seeded_route_id() { local route_key="$1" - local source_type="$2" - local target_entity_type="$3" - local target_action="$4" - local route_id - - route_id="$(post_json "${TEMPER_URL}/tdata/WebhookRoutes" '{}' | jq -r '.entity_id')" - post_json \ - "$(entity_url WebhookRoutes "$route_id")/TemperPaw.Ingest.Register" \ - "$(jq -n \ - --arg route_key "$route_key" \ - --arg source_type "$source_type" \ - --arg target_entity_type "$target_entity_type" \ - --arg target_action "$target_action" \ - '{ - route_key: $route_key, - source_type: $source_type, - event_filter: "*", - target_entity_type: $target_entity_type, - target_action: $target_action, - webhook_secret: "", - monitor_resolution_enabled: "false", - dedup_enabled: "false", - dedup_window_minutes: "60" - }')" \ - >/dev/null - printf '%s' "$route_id" + local result count + result="$(curl_json "${TEMPER_URL}/tdata/WebhookRoutes?\$filter=route_key%20eq%20%27${route_key}%27%20and%20Status%20eq%20%27Active%27&\$top=2")" + count="$(jq '.value | length' <<<"$result")" + if [[ "$count" != "1" ]]; then + log "expected exactly one active seeded route for ${route_key}, found ${count}" + jq . <<<"$result" + exit 1 + fi + jq -r '.value[0].entity_id // .value[0].Id' <<<"$result" } post_webhook() { local route_key="$1" local body="$2" - local attempts="${3:-60}" + local signature_header="$3" + local delivery_id_header="$4" + local delivery_id="$5" + local attempts="${6:-60}" local response + local signature + + signature="$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | awk '{print $NF}')" for _ in $(seq 1 "$attempts"); do if response="$(curl -fsS \ -H "Content-Type: application/json" \ + -H "${signature_header}: sha256=${signature}" \ + -H "${delivery_id_header}: ${delivery_id}" \ -X POST \ "${WEBHOOK_URL}/triggers/webhook/${route_key}" \ -d "$body" 2>/dev/null)"; then @@ -179,6 +173,69 @@ post_webhook() { exit 1 } +webhook_event_count() { + curl_json "${TEMPER_URL}/tdata/WebhookEvents?\$top=1000" | jq '.value | length' +} + +assert_forged_webhook_creates_no_event() { + local before after status + before="$(webhook_event_count)" + status="$(curl -sS -o /tmp/paw-patrol-forged-webhook-response.json -w '%{http_code}' \ + -H "Content-Type: application/json" \ + -H "x-temper-signature: sha256=deadbeef" \ + -H "x-temper-delivery-id: forged-smoke-1" \ + -X POST \ + "${WEBHOOK_URL}/triggers/webhook/patrol-request" \ + -d "$1")" + after="$(webhook_event_count)" + if [[ "$status" != "401" || "$before" != "$after" ]]; then + log "forged webhook must return 401 without durable WebhookEvent (status=${status}, before=${before}, after=${after})" + jq . /tmp/paw-patrol-forged-webhook-response.json 2>/dev/null || true + exit 1 + fi +} + +assert_delivery_id_payload_mismatch_rejected() { + local body="$1" + local before after signature status + signature="$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | awk '{print $NF}')" + before="$(webhook_event_count)" + status="$(curl -sS -o /tmp/paw-patrol-replay-mismatch-response.json -w '%{http_code}' \ + -H "Content-Type: application/json" \ + -H "x-temper-signature: sha256=${signature}" \ + -H "x-temper-delivery-id: smoke-request-1" \ + -X POST \ + "${WEBHOOK_URL}/triggers/webhook/patrol-request" \ + -d "$body")" + after="$(webhook_event_count)" + if [[ "$status" != "409" || "$before" != "$after" ]]; then + log "delivery ID reuse with changed payload must return 409 without creating an event (status=${status}, before=${before}, after=${after})" + jq . /tmp/paw-patrol-replay-mismatch-response.json 2>/dev/null || true + exit 1 + fi +} + +assert_signed_invalid_payload_creates_no_event() { + local body="$1" + local delivery_id="$2" + local before after signature status + signature="$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | awk '{print $NF}')" + before="$(webhook_event_count)" + status="$(curl -sS -o /tmp/paw-patrol-invalid-webhook-response.json -w '%{http_code}' \ + -H "Content-Type: application/json" \ + -H "x-temper-signature: sha256=${signature}" \ + -H "x-temper-delivery-id: ${delivery_id}" \ + -X POST \ + "${WEBHOOK_URL}/triggers/webhook/patrol-request" \ + -d "$body")" + after="$(webhook_event_count)" + if [[ "$status" != "400" || "$before" != "$after" ]]; then + log "signed malformed/non-object payload must return 400 without durable WebhookEvent (status=${status}, before=${before}, after=${after})" + jq . /tmp/paw-patrol-invalid-webhook-response.json 2>/dev/null || true + exit 1 + fi +} + write_proof_bundle() { local summary_json="$1" local request_event_body="$2" @@ -214,7 +271,7 @@ write_proof_bundle() { WebhookEvent Processed - PatrolRequest + WorkRequest Linked FactoryCase @@ -305,7 +362,7 @@ flowchart LR ## OData Links - Request WebhookEvent: ${TEMPER_URL}/tdata/WebhookEvents('$(jq -r '.entities.request_event' <<<"$summary_json")') -- Request PatrolRequest: ${TEMPER_URL}/tdata/PatrolRequests('$(jq -r '.entities.patrol_request' <<<"$summary_json")') +- Request WorkRequest: ${TEMPER_URL}/tdata/WorkRequests('$(jq -r '.entities.patrol_request' <<<"$summary_json")') - Request FactoryCase: ${TEMPER_URL}/tdata/FactoryCases('$(jq -r '.entities.request_factory_case' <<<"$summary_json")') - Request WorkCycle: ${TEMPER_URL}/tdata/WorkCycles('$(jq -r '.entities.request_work_cycle' <<<"$summary_json")') - Datadog WebhookEvent: ${TEMPER_URL}/tdata/WebhookEvents('$(jq -r '.entities.datadog_event' <<<"$summary_json")') @@ -340,6 +397,7 @@ require_cmd cargo require_cmd curl require_cmd git require_cmd jq +require_cmd openssl log "repo root: ${ROOT}" log "odata server: ${TEMPER_URL}" @@ -351,7 +409,10 @@ log "building current paw-ingest and paw-patrol WASM modules" (cd "$ROOT/$(dirname "$PATROL_WASM_BUILD")" && bash "$(basename "$PATROL_WASM_BUILD")") } >/tmp/paw-patrol-webhook-smoke-wasm-build.log 2>&1 -TEMPERPAW_WASM_STARTUP_POLICY=build \ +# The affected ingest/patrol modules were built immediately above. Loading the +# persisted artifacts avoids rebuilding every unrelated OS app inside the +# server's readiness window. +TEMPERPAW_WASM_STARTUP_POLICY="${TEMPERPAW_WASM_STARTUP_POLICY:-load-only}" \ PORT="$PORT" \ TEMPER_API_KEY="$API_KEY" \ PAW_TENANT="$TENANT" \ @@ -364,47 +425,70 @@ SERVER_PID="$!" wait_for_metadata log "control plane ready" -request_route_id="$(register_route \ - patrol-request \ - patrol-request \ - PatrolRequest \ - TemperPaw.Patrol.Submit)" -datadog_route_id="$(register_route \ - patrol-datadog \ - datadog \ - Signal \ - TemperPaw.Patrol.Ingest)" -github_route_id="$(register_route \ - patrol-github \ - github \ - Signal \ - TemperPaw.Patrol.Ingest)" -discord_route_id="$(register_route \ - patrol-discord \ - discord \ - Signal \ - TemperPaw.Patrol.Ingest)" -log "registered routes ${request_route_id}, ${datadog_route_id}, ${github_route_id}, and ${discord_route_id}" +for secret_ref in \ + patrol_request_webhook_secret \ + patrol_signal_webhook_secret \ + datadog_webhook_secret \ + github_webhook_secret \ + patrol_discord_webhook_secret; do + post_json "${TEMPER_URL}/paw/setup/secrets" "$(jq -n \ + --arg key "$secret_ref" \ + --arg value "$WEBHOOK_SECRET" \ + '{key: $key, value: $value}')" >/dev/null +done +log "configured all governed seeded webhook signing references" + +request_route_id="$(find_seeded_route_id patrol-request)" +datadog_route_id="$(find_seeded_route_id patrol-datadog)" +github_route_id="$(find_seeded_route_id patrol-github)" +discord_route_id="$(find_seeded_route_id patrol-discord)" +log "resolved seeded routes ${request_route_id}, ${datadog_route_id}, ${github_route_id}, and ${discord_route_id}" + +request_payload="$(jq -n '{ + source: "webhook-smoke", + request_text: "Webhook smoke request should enter Paw Patrol and create work.", + requester_id: "codex-webhook-smoke" +}')" +assert_forged_webhook_creates_no_event "$request_payload" +log "forged webhook rejected before persistence" +assert_signed_invalid_payload_creates_no_event 'not-json' smoke-malformed-1 +assert_signed_invalid_payload_creates_no_event '"scalar"' smoke-scalar-1 +log "signed malformed and non-object webhook bodies rejected before persistence" request_event_response="$(post_webhook \ patrol-request \ - "$(jq -n '{ - source: "webhook-smoke", - request_text: "Webhook smoke request should enter Paw Patrol and create work.", - requester_id: "codex-webhook-smoke" - }')")" + "$request_payload" \ + x-temper-signature \ + x-temper-delivery-id \ + smoke-request-1)" request_event_id="$(jq -r '.event_id' <<<"$request_event_response")" request_event_body="$(wait_for_status WebhookEvents "$request_event_id" Processed 120)" +altered_request_payload="$(jq '.request_text = "Changed content under a reused delivery identity must be rejected."' <<<"$request_payload")" +assert_delivery_id_payload_mismatch_rejected "$altered_request_payload" +log "changed payload under consumed delivery ID rejected without dispatch" +request_replay_response="$(post_webhook \ + patrol-request \ + "$request_payload" \ + x-temper-signature \ + x-temper-delivery-id \ + smoke-request-1)" +if [[ "$(jq -r '.event_id' <<<"$request_replay_response")" != "$request_event_id" \ + || "$(jq -r '.status' <<<"$request_replay_response")" != "duplicate" ]]; then + log "exact signed replay was not suppressed" + jq . <<<"$request_replay_response" + exit 1 +fi +log "exact signed replay returned the original event without redispatch" request_target_type="$(field target_entity_type <<<"$request_event_body")" request_target_id="$(field target_entity_id <<<"$request_event_body")" -if [[ "$request_target_type" != "PatrolRequest" || -z "$request_target_id" ]]; then +if [[ "$request_target_type" != "WorkRequest" || -z "$request_target_id" ]]; then log "request webhook routed to unexpected target '${request_target_type}' '${request_target_id}'" jq . <<<"$request_event_body" exit 1 fi -request_body="$(wait_for_status PatrolRequests "$request_target_id" Linked 120)" +request_body="$(wait_for_status WorkRequests "$request_target_id" Linked 120)" request_case_id="$(field factory_case_id <<<"$request_body")" request_pm_issue_id="$(field pm_issue_id <<<"$request_body")" request_case_body="$(curl_json "$(entity_url FactoryCases "$request_case_id")")" @@ -419,7 +503,10 @@ datadog_event_response="$(post_webhook \ title: "Webhook smoke Datadog signal", message: "Discord DM surfaced a trace and needs Patrol triage.", source_url: "https://example.invalid/datadog/webhook-smoke" - }')")" + }')" \ + x-datadog-signature \ + x-temper-delivery-id \ + smoke-datadog-1)" datadog_event_id="$(jq -r '.event_id' <<<"$datadog_event_response")" datadog_event_body="$(wait_for_status WebhookEvents "$datadog_event_id" Processed 120)" datadog_target_type="$(field target_entity_type <<<"$datadog_event_body")" @@ -445,7 +532,10 @@ github_event_response="$(post_webhook \ title: "Webhook smoke GitHub signal", message: "A failing pull request check should enter Patrol as a GitHub signal.", source_url: "https://github.com/nerdsane/temperpaw/actions/runs/webhook-smoke" - }')")" + }')" \ + x-hub-signature-256 \ + x-github-delivery \ + smoke-github-1)" github_event_id="$(jq -r '.event_id' <<<"$github_event_response")" github_event_body="$(wait_for_status WebhookEvents "$github_event_id" Processed 120)" github_target_type="$(field target_entity_type <<<"$github_event_body")" @@ -471,7 +561,10 @@ discord_event_response="$(post_webhook \ title: "Webhook smoke Discord DM signal", message: "A Discord DM exposed a Rust trace to the user and needs Patrol triage.", source_url: "discord://dm/webhook-smoke" - }')")" + }')" \ + x-temper-signature \ + x-temper-delivery-id \ + smoke-discord-1)" discord_event_id="$(jq -r '.event_id' <<<"$discord_event_response")" discord_event_body="$(wait_for_status WebhookEvents "$discord_event_id" Processed 120)" discord_target_type="$(field target_entity_type <<<"$discord_event_body")" diff --git a/crates/paw-transport/src/webhook/admission.rs b/crates/paw-transport/src/webhook/admission.rs new file mode 100644 index 000000000..7c3ce60d6 --- /dev/null +++ b/crates/paw-transport/src/webhook/admission.rs @@ -0,0 +1,272 @@ +//! Pure webhook admission configuration, authentication, and identity helpers. + +use std::time::{Duration, Instant}; + +use axum::http::{HeaderMap, HeaderName}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +pub(super) const HARD_MAX_BODY_BYTES: usize = 1024 * 1024; +pub(super) const MAX_DELIVERY_ID_BYTES: usize = 256; +const MAX_DELIVERIES_PER_MINUTE: u32 = 10_000; +const MAX_DEDUP_WINDOW_MINUTES: usize = 10_080; +pub(super) const MAX_IN_FLIGHT_ADMISSIONS: usize = 32; +pub(super) const RATE_WINDOW: Duration = Duration::from_secs(60); + +#[derive(Debug)] +pub(super) struct RateWindow { + pub(super) started_at: Instant, + pub(super) accepted: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum WebhookAuthScheme { + HmacSha256, +} + +#[derive(Debug, Clone)] +pub(super) struct WebhookRouteSnapshot { + pub(super) route_id: String, + pub(super) route_key: String, + pub(super) source_type: String, + pub(super) target_entity_type: String, + pub(super) target_action: String, + pub(super) auth_scheme: WebhookAuthScheme, + pub(super) secret_ref: String, + pub(super) signature_header: HeaderName, + pub(super) delivery_id_header: HeaderName, + pub(super) max_body_bytes: usize, + pub(super) max_deliveries_per_minute: u32, + pub(super) monitor_resolution_enabled: String, + pub(super) dedup_enabled: String, + pub(super) dedup_window_minutes: String, +} + +impl WebhookRouteSnapshot { + pub(super) fn from_entity(entity: &Value) -> Result { + let required = |name: &str| { + route_field(entity, name) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| format!("webhook route is missing {name}")) + }; + + let route_id = entity + .get("entity_id") + .or_else(|| entity.get("Id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| "webhook route is missing its entity ID".to_string())?; + let route_key = required("route_key")?; + if !valid_token(&route_key, 128) { + return Err("webhook route_key is not a valid route token".into()); + } + let source_type = required("source_type")?; + if !valid_token(&source_type, 128) { + return Err("webhook source_type is not a valid source token".into()); + } + let target_entity_type = required("target_entity_type")?; + if !valid_identifier(&target_entity_type, 128) { + return Err("webhook target_entity_type is not a valid identifier".into()); + } + let target_action = required("target_action")?; + if target_action.len() > 256 + || !target_action + .split('.') + .all(|segment| valid_identifier(segment, 64)) + { + return Err("webhook target_action is not a valid qualified action".into()); + } + + let auth_scheme = match required("auth_scheme")?.as_str() { + "hmac-sha256" => WebhookAuthScheme::HmacSha256, + value => return Err(format!("unsupported webhook auth scheme '{value}'")), + }; + let secret_ref = required("secret_ref")?; + if secret_ref.len() > 128 + || !secret_ref + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) + { + return Err("webhook secret_ref must be a vault key, not a value or template".into()); + } + + let signature_header = parse_header_name(&required("signature_header")?)?; + let delivery_id_header = parse_header_name(&required("delivery_id_header")?)?; + let max_body_bytes = parse_budget( + route_field(entity, "max_body_bytes").unwrap_or("262144"), + "max_body_bytes", + HARD_MAX_BODY_BYTES, + )?; + let max_deliveries_per_minute = parse_budget( + route_field(entity, "max_deliveries_per_minute").unwrap_or("120"), + "max_deliveries_per_minute", + MAX_DELIVERIES_PER_MINUTE as usize, + )? as u32; + let monitor_resolution_enabled = parse_bool( + route_field(entity, "monitor_resolution_enabled").unwrap_or("false"), + "monitor_resolution_enabled", + )?; + let dedup_enabled = parse_bool( + route_field(entity, "dedup_enabled").unwrap_or("false"), + "dedup_enabled", + )?; + let dedup_window_minutes = parse_budget( + route_field(entity, "dedup_window_minutes").unwrap_or("60"), + "dedup_window_minutes", + MAX_DEDUP_WINDOW_MINUTES, + )? + .to_string(); + + Ok(Self { + route_id, + route_key, + source_type, + target_entity_type, + target_action, + auth_scheme, + secret_ref, + signature_header, + delivery_id_header, + max_body_bytes, + max_deliveries_per_minute, + monitor_resolution_enabled, + dedup_enabled, + dedup_window_minutes, + }) + } + + pub(super) fn digest(&self) -> String { + let mut hasher = Sha256::new(); + update_hash_part(&mut hasher, b"hmac-sha256"); + for value in [ + self.route_id.as_str(), + self.route_key.as_str(), + self.source_type.as_str(), + self.target_entity_type.as_str(), + self.target_action.as_str(), + self.secret_ref.as_str(), + self.signature_header.as_str(), + self.delivery_id_header.as_str(), + self.monitor_resolution_enabled.as_str(), + self.dedup_enabled.as_str(), + self.dedup_window_minutes.as_str(), + ] { + update_hash_part(&mut hasher, value.as_bytes()); + } + update_hash_part(&mut hasher, &self.max_body_bytes.to_be_bytes()); + update_hash_part(&mut hasher, &self.max_deliveries_per_minute.to_be_bytes()); + hex::encode(hasher.finalize()) + } +} + +pub(super) fn route_field<'a>(entity: &'a Value, name: &str) -> Option<&'a str> { + entity + .get("fields") + .and_then(|fields| fields.get(name)) + .or_else(|| entity.get(name)) + .and_then(Value::as_str) +} + +fn parse_budget(value: &str, name: &str, maximum: usize) -> Result { + let parsed = value + .parse::() + .map_err(|_| format!("webhook route has invalid {name}"))?; + if parsed == 0 || parsed > maximum { + return Err(format!( + "webhook route {name} is outside its supported budget" + )); + } + Ok(parsed) +} + +fn parse_header_name(value: &str) -> Result { + HeaderName::from_bytes(value.as_bytes()) + .map_err(|_| format!("webhook route has invalid header name '{value}'")) +} + +fn parse_bool(value: &str, name: &str) -> Result { + match value { + "true" | "false" => Ok(value.to_string()), + _ => Err(format!("webhook route has invalid {name}")), + } +} + +fn valid_identifier(value: &str, maximum: usize) -> bool { + let mut bytes = value.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + value.len() <= maximum + && (first.is_ascii_alphabetic() || first == b'_') + && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') +} + +fn valid_token(value: &str, maximum: usize) -> bool { + !value.is_empty() + && value.len() <= maximum + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} + +pub(super) fn required_header( + headers: &HeaderMap, + name: &HeaderName, + purpose: &str, +) -> Result { + let mut values = headers.get_all(name).iter(); + let value = values + .next() + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| format!("missing webhook {purpose} header '{}'", name.as_str()))?; + if values.next().is_some() { + return Err(format!( + "multiple webhook {purpose} headers '{}' are not allowed", + name.as_str() + )); + } + Ok(value) +} + +pub(super) fn signature_matches(secret: &[u8], body: &[u8], provided: &str) -> bool { + use hmac::{Hmac, Mac}; + + let normalized = provided.trim().to_ascii_lowercase(); + let provided_hex = normalized + .strip_prefix("sha256=") + .unwrap_or(normalized.as_str()) + .trim(); + let Ok(provided_bytes) = hex::decode(provided_hex) else { + return false; + }; + let Ok(mut mac) = Hmac::::new_from_slice(secret) else { + return false; + }; + mac.update(body); + mac.verify_slice(&provided_bytes).is_ok() +} + +pub(super) fn webhook_event_id(tenant: &str, route_id: &str, delivery_id: &str) -> String { + let mut hasher = Sha256::new(); + for part in [ + b"temperpaw-webhook-v1".as_slice(), + tenant.as_bytes(), + route_id.as_bytes(), + delivery_id.as_bytes(), + ] { + update_hash_part(&mut hasher, part); + } + format!("wh-{}", hex::encode(hasher.finalize())) +} + +fn update_hash_part(hasher: &mut Sha256, part: &[u8]) { + hasher.update(part.len().to_be_bytes()); + hasher.update(part); +} diff --git a/crates/paw-transport/src/webhook/mod.rs b/crates/paw-transport/src/webhook/mod.rs index 55e664ff4..38e3a93e9 100644 --- a/crates/paw-transport/src/webhook/mod.rs +++ b/crates/paw-transport/src/webhook/mod.rs @@ -6,6 +6,7 @@ //! //! This is a Paw OData API client — no dependency on paw-server internals. +mod admission; mod trigger; -pub use trigger::{WebhookTrigger, WebhookTriggerConfig, router}; +pub use trigger::{WebhookSecretResolver, WebhookTrigger, WebhookTriggerConfig, router}; diff --git a/crates/paw-transport/src/webhook/trigger.rs b/crates/paw-transport/src/webhook/trigger.rs index 74501b857..0ef34e656 100644 --- a/crates/paw-transport/src/webhook/trigger.rs +++ b/crates/paw-transport/src/webhook/trigger.rs @@ -1,20 +1,41 @@ -//! Webhook trigger — thin HTTP endpoint for external webhook ingestion. +//! Webhook trigger — authenticated HTTP admission for external webhook events. //! -//! ONE entity, ONE action. Creates a WebhookEvent entity and dispatches -//! the Received action. Everything else (validation, routing, processing) -//! is handled by WASM integrations on WebhookEvent state transitions. +//! The trigger resolves a governed route, authenticates the exact request +//! bytes, applies replay and resource budgets, then creates one WebhookEvent +//! and dispatches one Received action. Routing and processing remain WASM +//! integrations on WebhookEvent state transitions. +use std::collections::BTreeMap; use std::net::SocketAddr; use std::sync::Arc; +use std::time::Instant; -use axum::extract::{Path, State}; +use axum::body::Bytes; +use axum::extract::{DefaultBodyLimit, Path, State}; use axum::http::{HeaderMap, StatusCode}; use axum::routing::post; use axum::{Json, Router}; use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use tokio::sync::{Mutex, Semaphore}; use crate::PawApiClient; +use super::admission::{ + HARD_MAX_BODY_BYTES, MAX_DELIVERY_ID_BYTES, MAX_IN_FLIGHT_ADMISSIONS, RATE_WINDOW, RateWindow, + WebhookAuthScheme, WebhookRouteSnapshot, required_header, route_field, signature_matches, + webhook_event_id, +}; + +const MAX_TRACKED_ROUTE_WINDOWS: usize = 4096; + +/// Tenant-scoped in-process capability for resolving a validated webhook key. +/// +/// Startup owns the backing vault and closes over the active tenant. The +/// public webhook boundary receives only this narrow read capability; signing +/// secrets never traverse an HTTP endpoint. +pub type WebhookSecretResolver = Arc Option + Send + Sync>; + /// Configuration for the webhook trigger. #[derive(Debug, Clone)] pub struct WebhookTriggerConfig { @@ -25,6 +46,54 @@ pub struct WebhookTriggerConfig { /// Webhook trigger state shared across request handlers. struct TriggerState { api: PawApiClient, + secrets: WebhookSecretResolver, + rate_windows: Mutex>, + in_flight: Arc, +} + +#[derive(Debug)] +struct WebhookAdmissionIdentity { + event_id: String, + route_id: String, + route_key: String, + delivery_id: String, + payload_digest: String, + route_snapshot_digest: String, +} + +impl WebhookAdmissionIdentity { + fn create_fields(&self) -> Value { + json!({ + "Id": self.event_id, + "route_key": self.route_key, + "webhook_route_id": self.route_id, + "delivery_id": self.delivery_id, + "payload_digest": self.payload_digest, + "route_snapshot_digest": self.route_snapshot_digest, + "authentication_scheme": "hmac-sha256", + }) + } + + fn matches_stable_identity(&self, entity: &Value) -> bool { + entity + .get("entity_id") + .or_else(|| entity.get("Id")) + .and_then(Value::as_str) + == Some(self.event_id.as_str()) + && [ + ("route_key", self.route_key.as_str()), + ("webhook_route_id", self.route_id.as_str()), + ("delivery_id", self.delivery_id.as_str()), + ("payload_digest", self.payload_digest.as_str()), + ("authentication_scheme", "hmac-sha256"), + ] + .into_iter() + .all(|(name, expected)| route_field(entity, name) == Some(expected)) + } + + fn matches_route_snapshot(&self, entity: &Value) -> bool { + route_field(entity, "route_snapshot_digest") == Some(self.route_snapshot_digest.as_str()) + } } #[derive(Clone, Copy)] @@ -56,24 +125,39 @@ fn log_webhook_event(event: WebhookEventLog<'_>) { pub struct WebhookTrigger { config: WebhookTriggerConfig, api: PawApiClient, + secrets: WebhookSecretResolver, } /// Build the webhook trigger router. /// /// This is used both by the standalone trigger listener and by production /// deployments that expose the trigger on the primary HTTP port. -pub fn router(api: PawApiClient) -> Router { - let state = Arc::new(TriggerState { api }); +pub fn router(api: PawApiClient, secrets: WebhookSecretResolver) -> Router { + let state = Arc::new(TriggerState { + api, + secrets, + rate_windows: Mutex::new(BTreeMap::new()), + in_flight: Arc::new(Semaphore::new(MAX_IN_FLIGHT_ADMISSIONS)), + }); Router::new() .route("/triggers/webhook/{route_key}", post(handle_webhook)) + .layer(DefaultBodyLimit::max(HARD_MAX_BODY_BYTES)) .with_state(state) } impl WebhookTrigger { /// Create a new webhook trigger. - pub fn new(config: WebhookTriggerConfig, api: PawApiClient) -> Self { - Self { config, api } + pub fn new( + config: WebhookTriggerConfig, + api: PawApiClient, + secrets: WebhookSecretResolver, + ) -> Self { + Self { + config, + api, + secrets, + } } /// Start the webhook trigger HTTP listener. @@ -82,7 +166,7 @@ impl WebhookTrigger { /// For each request: creates ONE WebhookEvent entity, dispatches ONE /// Received action, returns the event ID. pub async fn run(&self) -> Result<(), String> { - let app = router(self.api.clone()); + let app = router(self.api.clone(), self.secrets.clone()); let addr = SocketAddr::from(([0, 0, 0, 0], self.config.port)); tracing::info!( @@ -106,93 +190,201 @@ impl WebhookTrigger { /// Handle an incoming webhook POST. /// -/// ONE entity, ONE action. Everything else is WASM. +/// Authenticate first, then create one entity and dispatch one action. async fn handle_webhook( State(state): State>, Path(route_key): Path, headers: HeaderMap, - body: String, + body: Bytes, ) -> Result, (StatusCode, Json)> { - // Serialize headers to JSON for the WASM integration to inspect. - let headers_json = serialize_headers(&headers); let payload_bytes = body.len(); + let _admission_permit = state + .in_flight + .clone() + .try_acquire_owned() + .map_err(|_| rejection(StatusCode::TOO_MANY_REQUESTS, "webhook admission is busy"))?; + + let route = match load_route(&state.api, &route_key).await { + Ok(Some(route)) => route, + Ok(None) => { + return Err(rejection( + StatusCode::NOT_FOUND, + "webhook route was not found", + )); + } + Err(error) => return Err(rejection(StatusCode::SERVICE_UNAVAILABLE, &error)), + }; + if route.route_key != route_key { + return Err(rejection( + StatusCode::SERVICE_UNAVAILABLE, + "webhook route lookup returned a mismatched route", + )); + } + if payload_bytes > route.max_body_bytes { + return Err(rejection( + StatusCode::PAYLOAD_TOO_LARGE, + "webhook payload exceeds the route budget", + )); + } + + let signature = required_header(&headers, &route.signature_header, "signature") + .map_err(|error| rejection(StatusCode::UNAUTHORIZED, &error))?; + let delivery_id = required_header(&headers, &route.delivery_id_header, "delivery ID") + .map_err(|error| rejection(StatusCode::BAD_REQUEST, &error))?; + if delivery_id.len() > MAX_DELIVERY_ID_BYTES { + return Err(rejection( + StatusCode::BAD_REQUEST, + "webhook delivery ID exceeds its budget", + )); + } + + let secret = (state.secrets)(&route.secret_ref) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + rejection( + StatusCode::SERVICE_UNAVAILABLE, + "webhook signing secret is unavailable", + ) + })?; + if route.auth_scheme != WebhookAuthScheme::HmacSha256 + || !signature_matches(secret.as_bytes(), &body, &signature) + { + return Err(rejection( + StatusCode::UNAUTHORIZED, + "webhook signature verification failed", + )); + } + + if !consume_rate_budget(&state, &route).await { + return Err(rejection( + StatusCode::TOO_MANY_REQUESTS, + "webhook route admission budget exhausted", + )); + } - // ONE entity: create WebhookEvent. - let entity = match state.api.create_entity("WebhookEvents", json!({})).await { - Ok(entity) => entity, + let raw_payload = std::str::from_utf8(&body).map_err(|_| { + rejection( + StatusCode::BAD_REQUEST, + "webhook payload must be valid UTF-8", + ) + })?; + let normalized_payload = normalize_json_object(raw_payload) + .map_err(|error| rejection(StatusCode::BAD_REQUEST, error))?; + + let identity = WebhookAdmissionIdentity { + event_id: webhook_event_id(&state.api.config().tenant, &route.route_id, &delivery_id), + route_id: route.route_id.clone(), + route_key: route.route_key.clone(), + delivery_id, + payload_digest: hex::encode(Sha256::digest(&body)), + route_snapshot_digest: route.digest(), + }; + + let existing = match state + .api + .create_entity("WebhookEvents", identity.create_fields()) + .await + { + Ok(existing) => existing, Err(e) => { log_webhook_event(WebhookEventLog { operation: "create_entity", outcome: "error", route_key: &route_key, - event_id: "", + event_id: &identity.event_id, status: 500, payload_bytes, error: &e, }); return Err(( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": format!("create WebhookEvent failed: {e}") })), + Json(json!({ "error": "create WebhookEvent failed" })), )); } }; - let event_id = entity - .get("entity_id") - .or_else(|| entity.get("Id")) - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - - if event_id.is_empty() { - log_webhook_event(WebhookEventLog { - operation: "create_entity", - outcome: "error", - route_key: &route_key, - event_id: "", - status: 500, - payload_bytes, - error: "WebhookEvent created but no entity_id returned", - }); - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "WebhookEvent created but no entity_id returned" })), + // Temper collection POST is an atomic get-or-create: every successful + // response contains the authoritative stored winner. Compare that response + // before dispatch so concurrent different-content reservations cannot race + // through a separate read. + if !identity.matches_stable_identity(&existing) { + return Err(rejection( + StatusCode::CONFLICT, + "webhook delivery ID is already bound to different admission content", + )); + } + if entity_status(&existing) != Some("Created") { + return Ok(Json(json!({ + "event_id": identity.event_id, + "status": "duplicate", + }))); + } + if !identity.matches_route_snapshot(&existing) { + return Err(rejection( + StatusCode::CONFLICT, + "webhook route changed after delivery reservation", )); } - // ONE action: dispatch Received. + // ONE action: dispatch the authenticated immutable envelope. let dispatch_result = state .api .dispatch_action( "WebhookEvents", - &event_id, + &identity.event_id, "TemperPaw.Ingest.Received", json!({ - "raw_payload": body, - "raw_headers": headers_json, - "route_key": route_key.clone(), + "raw_payload": raw_payload, + "normalized_payload": normalized_payload, + "route_key": route.route_key, + "source_type": route.source_type, + "target_entity_type": route.target_entity_type, + "target_action": route.target_action, + "webhook_route_id": route.route_id, + "route_snapshot_digest": identity.route_snapshot_digest, + "payload_digest": identity.payload_digest, + "delivery_id": identity.delivery_id, + "authentication_scheme": "hmac-sha256", + "monitor_resolution_enabled": route.monitor_resolution_enabled, + "dedup_enabled": route.dedup_enabled, + "dedup_window_minutes": route.dedup_window_minutes, }), ) .await; if let Err(e) = dispatch_result { + let transitioned = state + .api + .get_entity("WebhookEvents", &identity.event_id) + .await + .ok() + .and_then(|entity| entity_status(&entity).map(str::to_string)) + .is_some_and(|status| status != "Created"); + if transitioned { + return Ok(Json(json!({ + "event_id": identity.event_id, + "status": "duplicate", + }))); + } log_webhook_event(WebhookEventLog { operation: "dispatch_received", outcome: "error", route_key: &route_key, - event_id: &event_id, - status: 202, + event_id: &identity.event_id, + status: 503, payload_bytes, error: &e, }); - // Entity was created; WASM will handle error state. - // Do not fail the HTTP response; the event exists for audit. + return Err(rejection( + StatusCode::SERVICE_UNAVAILABLE, + "webhook event was created but admission dispatch failed; retry this delivery", + )); } else { log_webhook_event(WebhookEventLog { operation: "receive", outcome: "success", route_key: &route_key, - event_id: &event_id, + event_id: &identity.event_id, status: 200, payload_bytes, error: "", @@ -200,99 +392,81 @@ async fn handle_webhook( } Ok(Json(json!({ - "event_id": event_id, - "status": "received", + "event_id": identity.event_id, + "status": "accepted", }))) } -/// Serialize HTTP headers to a JSON string. -fn serialize_headers(headers: &HeaderMap) -> String { - let map: serde_json::Map = headers - .iter() - .map(|(k, v)| { - ( - k.as_str().to_string(), - Value::String(v.to_str().unwrap_or("").to_string()), - ) - }) - .collect(); - serde_json::to_string(&Value::Object(map)).unwrap_or_else(|_| "{}".to_string()) -} - -#[cfg(test)] -mod tests { - use std::io; - use std::sync::{Arc, Mutex}; - - use tracing_subscriber::fmt::MakeWriter; - - use super::*; - - #[derive(Clone, Default)] - struct SharedWriter { - buffer: Arc>>, +async fn load_route( + api: &PawApiClient, + route_key: &str, +) -> Result, String> { + if route_key.is_empty() || route_key.len() > 128 { + return Ok(None); } - - impl SharedWriter { - fn output(&self) -> String { - String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap_or_default() - } + let escaped = route_key.replace('\'', "''"); + let routes = api + .query_entities( + "WebhookRoutes", + &format!("route_key eq '{escaped}' and Status eq 'Active'"), + 2, + ) + .await + .map_err(|_| "webhook route lookup failed".to_string())?; + if routes.is_empty() { + return Ok(None); } - - struct SharedLogGuard { - buffer: Arc>>, + if routes.len() != 1 { + return Err("webhook route key is not unique".into()); } + WebhookRouteSnapshot::from_entity(&routes[0]).map(Some) +} - impl io::Write for SharedLogGuard { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.buffer.lock().unwrap().extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } +async fn consume_rate_budget(state: &TriggerState, route: &WebhookRouteSnapshot) -> bool { + let now = Instant::now(); + let mut windows = state.rate_windows.lock().await; + windows.retain(|_, window| now.duration_since(window.started_at) < RATE_WINDOW); + if !windows.contains_key(&route.route_id) && windows.len() >= MAX_TRACKED_ROUTE_WINDOWS { + return false; + } + let window = windows + .entry(route.route_id.clone()) + .or_insert_with(|| RateWindow { + started_at: now, + accepted: 0, + }); + if window.accepted >= route.max_deliveries_per_minute { + return false; } + window.accepted += 1; + true +} - impl<'a> MakeWriter<'a> for SharedWriter { - type Writer = SharedLogGuard; +fn entity_status(entity: &Value) -> Option<&str> { + route_field(entity, "status").or_else(|| entity.get("Status").and_then(Value::as_str)) +} - fn make_writer(&'a self) -> Self::Writer { - SharedLogGuard { - buffer: self.buffer.clone(), - } - } +fn normalize_json_object(raw_payload: &str) -> Result { + let payload: Value = + serde_json::from_str(raw_payload).map_err(|_| "webhook payload must be valid JSON")?; + if !payload.is_object() { + return Err("webhook payload must be a JSON object"); } + serde_json::to_string(&payload).map_err(|_| "webhook payload normalization failed") +} + +fn rejection(status: StatusCode, message: &str) -> (StatusCode, Json) { + (status, Json(json!({ "error": message }))) +} - #[test] - fn webhook_logging_uses_structured_tracing_without_payload_body() { - let writer = SharedWriter::default(); - let subscriber = tracing_subscriber::fmt() - .with_ansi(false) - .without_time() - .with_writer(writer.clone()) - .finish(); +#[cfg(test)] +#[path = "trigger_tests.rs"] +mod tests; - tracing::subscriber::with_default(subscriber, || { - log_webhook_event(WebhookEventLog { - operation: "receive", - outcome: "success", - route_key: "github", - event_id: "wh-123", - status: 200, - payload_bytes: "secret webhook body".len(), - error: "", - }); - }); +#[cfg(test)] +#[path = "trigger_budget_tests.rs"] +mod budget_tests; - let output = writer.output(); - assert!(output.contains("observability_event=\"temperpaw.webhook\"")); - assert!(output.contains("webhook.route_key=\"github\"")); - assert!(output.contains("webhook.event_id=\"wh-123\"")); - assert!(output.contains("webhook.status=200")); - assert!( - !output.contains("secret webhook body"), - "webhook logs must not emit payload bodies, got: {output:?}" - ); - } -} +#[cfg(test)] +#[path = "trigger_logging_tests.rs"] +mod logging_tests; diff --git a/crates/paw-transport/src/webhook/trigger_budget_tests.rs b/crates/paw-transport/src/webhook/trigger_budget_tests.rs new file mode 100644 index 000000000..7648b55bc --- /dev/null +++ b/crates/paw-transport/src/webhook/trigger_budget_tests.rs @@ -0,0 +1,78 @@ +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Instant; + +use serde_json::json; + +use super::tests::governed_route; +use super::*; +use crate::PawApiConfig; + +#[test] +fn duplicate_security_headers_are_rejected() { + let name = axum::http::HeaderName::from_static("x-temper-signature"); + let mut headers = HeaderMap::new(); + headers.append(&name, "sha256=00".parse().unwrap()); + headers.append(&name, "sha256=11".parse().unwrap()); + assert!(required_header(&headers, &name, "signature").is_err()); +} + +#[tokio::test] +async fn configured_body_and_rate_budgets_fail_before_entity_creation() { + let api = PawApiClient::new(PawApiConfig { + base_url: "http://127.0.0.1:1".to_string(), + tenant: "tenant-a".to_string(), + api_key: None, + }); + let state = TriggerState { + api, + secrets: Arc::new(|_| None), + rate_windows: tokio::sync::Mutex::new(BTreeMap::new()), + in_flight: Arc::new(Semaphore::new(MAX_IN_FLIGHT_ADMISSIONS)), + }; + let mut route = WebhookRouteSnapshot::from_entity(&governed_route()).unwrap(); + route.max_deliveries_per_minute = 1; + assert!(consume_rate_budget(&state, &route).await); + assert!(!consume_rate_budget(&state, &route).await); + + let now = Instant::now(); + { + let mut windows = state.rate_windows.lock().await; + windows.clear(); + windows.insert( + "expired-route".to_string(), + RateWindow { + started_at: now - RATE_WINDOW, + accepted: 1, + }, + ); + } + assert!(consume_rate_budget(&state, &route).await); + assert!( + !state + .rate_windows + .lock() + .await + .contains_key("expired-route") + ); + + { + let mut windows = state.rate_windows.lock().await; + windows.clear(); + for index in 0..MAX_TRACKED_ROUTE_WINDOWS { + windows.insert( + format!("route-{index}"), + RateWindow { + started_at: now, + accepted: 0, + }, + ); + } + } + route.route_id = "overflow-route".to_string(); + assert!(!consume_rate_budget(&state, &route).await); + + let mut oversized = governed_route(); + oversized["fields"]["max_body_bytes"] = json!((HARD_MAX_BODY_BYTES + 1).to_string()); + assert!(WebhookRouteSnapshot::from_entity(&oversized).is_err()); +} diff --git a/crates/paw-transport/src/webhook/trigger_logging_tests.rs b/crates/paw-transport/src/webhook/trigger_logging_tests.rs new file mode 100644 index 000000000..d17c89368 --- /dev/null +++ b/crates/paw-transport/src/webhook/trigger_logging_tests.rs @@ -0,0 +1,74 @@ +use std::io; +use std::sync::{Arc, Mutex}; + +use tracing_subscriber::fmt::MakeWriter; + +use super::*; + +#[derive(Clone, Default)] +struct SharedWriter { + buffer: Arc>>, +} + +impl SharedWriter { + fn output(&self) -> String { + String::from_utf8(self.buffer.lock().unwrap().clone()).unwrap_or_default() + } +} + +struct SharedLogGuard { + buffer: Arc>>, +} + +impl io::Write for SharedLogGuard { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.buffer.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl<'a> MakeWriter<'a> for SharedWriter { + type Writer = SharedLogGuard; + + fn make_writer(&'a self) -> Self::Writer { + SharedLogGuard { + buffer: self.buffer.clone(), + } + } +} + +#[test] +fn webhook_logging_uses_structured_tracing_without_payload_body() { + let writer = SharedWriter::default(); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .without_time() + .with_writer(writer.clone()) + .finish(); + + tracing::subscriber::with_default(subscriber, || { + log_webhook_event(WebhookEventLog { + operation: "receive", + outcome: "success", + route_key: "github", + event_id: "wh-123", + status: 200, + payload_bytes: "secret webhook body".len(), + error: "", + }); + }); + + let output = writer.output(); + assert!(output.contains("observability_event=\"temperpaw.webhook\"")); + assert!(output.contains("webhook.route_key=\"github\"")); + assert!(output.contains("webhook.event_id=\"wh-123\"")); + assert!(output.contains("webhook.status=200")); + assert!( + !output.contains("secret webhook body"), + "webhook logs must not emit payload bodies, got: {output:?}" + ); +} diff --git a/crates/paw-transport/src/webhook/trigger_tests.rs b/crates/paw-transport/src/webhook/trigger_tests.rs new file mode 100644 index 000000000..2d817676b --- /dev/null +++ b/crates/paw-transport/src/webhook/trigger_tests.rs @@ -0,0 +1,500 @@ +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use axum::body::Bytes; +use axum::extract::State; +use axum::http::{Method, StatusCode, Uri}; +use axum::response::{IntoResponse, Response}; +use axum::routing::any; +use tokio::net::TcpListener; + +use super::*; +use crate::PawApiConfig; + +pub(super) fn governed_route() -> Value { + json!({ + "entity_id": "route-1", + "fields": { + "route_key": "patrol-github", + "source_type": "github", + "target_entity_type": "Signal", + "target_action": "TemperPaw.Patrol.Ingest", + "auth_scheme": "hmac-sha256", + "secret_ref": "patrol_github_webhook_secret", + "signature_header": "x-hub-signature-256", + "delivery_id_header": "x-github-delivery", + "max_body_bytes": "262144", + "max_deliveries_per_minute": "120", + "monitor_resolution_enabled": "false", + "dedup_enabled": "true", + "dedup_window_minutes": "60" + } + }) +} + +#[test] +fn route_snapshot_requires_governed_authentication_configuration() { + let snapshot = WebhookRouteSnapshot::from_entity(&governed_route()).unwrap(); + assert_eq!(snapshot.route_id, "route-1"); + assert_eq!(snapshot.auth_scheme, WebhookAuthScheme::HmacSha256); + assert_eq!(snapshot.secret_ref, "patrol_github_webhook_secret"); + + for field in [ + "secret_ref", + "signature_header", + "delivery_id_header", + "target_entity_type", + "target_action", + ] { + let mut route = governed_route(); + route["fields"][field] = Value::String(String::new()); + assert!( + WebhookRouteSnapshot::from_entity(&route).is_err(), + "empty {field} must fail closed" + ); + } + + let mut route = governed_route(); + route["fields"]["auth_scheme"] = json!("none"); + assert!(WebhookRouteSnapshot::from_entity(&route).is_err()); + + let mut route = governed_route(); + route["fields"]["secret_ref"] = json!("{secret:literal-confusion}"); + assert!(WebhookRouteSnapshot::from_entity(&route).is_err()); + + for (field, value) in [ + ("route_key", "bad/route"), + ("source_type", "bad source"), + ("target_entity_type", "../../Admin"), + ("target_action", "TemperPaw/Patrol/Submit"), + ("monitor_resolution_enabled", "TRUE"), + ("dedup_enabled", "yes"), + ("dedup_window_minutes", "0"), + ("dedup_window_minutes", "10081"), + ] { + let mut invalid = governed_route(); + invalid["fields"][field] = json!(value); + assert!( + WebhookRouteSnapshot::from_entity(&invalid).is_err(), + "invalid {field}={value:?} must fail closed" + ); + } +} + +#[test] +fn hmac_verification_uses_raw_bytes_and_rejects_invalid_hex() { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + let body = br#"{"action":"opened"}"#; + let mut mac = Hmac::::new_from_slice(b"correct-secret").unwrap(); + mac.update(body); + let signature = format!("sha256={}", hex::encode(mac.finalize().into_bytes())); + + assert!(signature_matches(b"correct-secret", body, &signature)); + assert!(!signature_matches( + b"correct-secret", + br#"{"action":"closed"}"#, + &signature + )); + assert!(!signature_matches( + b"correct-secret", + body, + "sha256=not-hex" + )); + assert!(!signature_matches(b"correct-secret", body, "sha256=00")); +} + +#[test] +fn webhook_payload_normalization_requires_a_json_object() { + assert_eq!( + normalize_json_object(r#"{ "action": "opened" }"#).unwrap(), + r#"{"action":"opened"}"# + ); + assert!(normalize_json_object("not-json").is_err()); + assert!(normalize_json_object(r#""scalar""#).is_err()); + assert!(normalize_json_object("[]").is_err()); +} + +#[test] +fn replay_identity_is_stable_and_route_scoped() { + let first = webhook_event_id("tenant-a", "route-a", "delivery-1"); + assert_eq!(first, webhook_event_id("tenant-a", "route-a", "delivery-1")); + assert_ne!(first, webhook_event_id("tenant-a", "route-b", "delivery-1")); + assert_ne!(first, webhook_event_id("tenant-b", "route-a", "delivery-1")); + assert_ne!(first, webhook_event_id("tenant-a", "route-a", "delivery-2")); + assert!(first.starts_with("wh-")); +} + +#[test] +fn immutable_snapshot_digest_covers_target_capability() { + let original = WebhookRouteSnapshot::from_entity(&governed_route()).unwrap(); + let mut mutated_route = governed_route(); + mutated_route["fields"]["target_action"] = json!("TemperPaw.Admin.Escalate"); + let mutated = WebhookRouteSnapshot::from_entity(&mutated_route).unwrap(); + + assert_ne!(original.digest(), mutated.digest()); + assert_eq!(original.target_action, "TemperPaw.Patrol.Ingest"); +} + +#[test] +fn persisted_admission_identity_rejects_changed_payload_or_route() { + let route = WebhookRouteSnapshot::from_entity(&governed_route()).unwrap(); + let identity = WebhookAdmissionIdentity { + event_id: webhook_event_id("tenant-a", &route.route_id, "delivery-1"), + route_id: route.route_id.clone(), + route_key: route.route_key.clone(), + delivery_id: "delivery-1".to_string(), + payload_digest: hex::encode(Sha256::digest(br#"{"action":"opened"}"#)), + route_snapshot_digest: route.digest(), + }; + let mut fields = identity.create_fields(); + fields.as_object_mut().unwrap().remove("Id"); + let mut entity = json!({ + "entity_id": identity.event_id, + "fields": fields, + }); + assert!(identity.matches_stable_identity(&entity)); + assert!(identity.matches_route_snapshot(&entity)); + + entity["fields"]["payload_digest"] = json!("changed"); + assert!(!identity.matches_stable_identity(&entity)); + entity["fields"]["payload_digest"] = json!(identity.payload_digest); + entity["fields"]["webhook_route_id"] = json!("route-2"); + assert!(!identity.matches_stable_identity(&entity)); + entity["fields"]["webhook_route_id"] = json!(identity.route_id); + entity["fields"]["route_snapshot_digest"] = json!("changed"); + assert!(identity.matches_stable_identity(&entity)); + assert!(!identity.matches_route_snapshot(&entity)); +} + +#[derive(Clone)] +struct MockWebhookApi { + route: Arc>, + events: Arc>>, + dispatches: Arc>>, + create_attempts: Arc, + secret_http_reads: Arc, + mutate_after_next_route_read: Arc, + fail_next_dispatch: Arc, +} + +#[derive(Clone)] +struct MockEvent { + status: String, + fields: Value, +} + +fn mock_event_entity(id: &str, event: &MockEvent) -> Value { + let mut fields = event.fields.clone(); + fields["status"] = json!(event.status); + json!({ + "entity_id": id, + "fields": fields, + }) +} + +impl Default for MockWebhookApi { + fn default() -> Self { + Self { + route: Arc::new(Mutex::new(governed_route())), + events: Arc::new(Mutex::new(BTreeMap::new())), + dispatches: Arc::new(Mutex::new(Vec::new())), + create_attempts: Arc::new(AtomicUsize::new(0)), + secret_http_reads: Arc::new(AtomicUsize::new(0)), + mutate_after_next_route_read: Arc::new(AtomicBool::new(false)), + fail_next_dispatch: Arc::new(AtomicBool::new(false)), + } + } +} + +async fn spawn_server(app: Router) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{address}") +} + +fn event_id_from_path(path: &str) -> Option<&str> { + path.strip_prefix("/tdata/WebhookEvents('")? + .split_once("')") + .map(|(id, _)| id) +} + +async fn mock_webhook_api( + State(state): State, + method: Method, + uri: Uri, + body: Bytes, +) -> Response { + let path = uri.path(); + if method == Method::GET && path == "/tdata/WebhookRoutes" { + let route = state.route.lock().unwrap().clone(); + if state + .mutate_after_next_route_read + .swap(false, Ordering::SeqCst) + { + state.route.lock().unwrap()["fields"]["target_action"] = + json!("TemperPaw.Admin.Escalate"); + } + return Json(json!({ "value": [route] })).into_response(); + } + if method == Method::GET && path.starts_with("/paw/setup/secrets/") { + state.secret_http_reads.fetch_add(1, Ordering::SeqCst); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + if method == Method::POST && path == "/tdata/WebhookEvents" { + state.create_attempts.fetch_add(1, Ordering::SeqCst); + let mut value: Value = serde_json::from_slice(&body).unwrap(); + let id = value["Id"].as_str().unwrap().to_string(); + let mut events = state.events.lock().unwrap(); + if let Some(existing) = events.get(&id) { + // Temper collection POST is get-or-create and returns success for + // an existing caller-selected ID, including the authoritative + // stored state selected by the atomic operation. + return (StatusCode::CREATED, Json(mock_event_entity(&id, existing))).into_response(); + } + value.as_object_mut().unwrap().remove("Id"); + let event = MockEvent { + status: "Created".to_string(), + fields: value, + }; + let response = mock_event_entity(&id, &event); + events.insert(id, event); + return (StatusCode::CREATED, Json(response)).into_response(); + } + if let Some(id) = event_id_from_path(path) { + if method == Method::GET { + let event = state.events.lock().unwrap().get(id).cloned().unwrap(); + return Json(mock_event_entity(id, &event)).into_response(); + } + if method == Method::POST && path.ends_with("/TemperPaw.Ingest.Received") { + if state.fail_next_dispatch.swap(false, Ordering::SeqCst) { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + let value: Value = serde_json::from_slice(&body).unwrap(); + state.dispatches.lock().unwrap().push(value); + state.events.lock().unwrap().get_mut(id).unwrap().status = "Routing".to_string(); + return Json(json!({ "entity_id": id, "status": "Routing" })).into_response(); + } + } + StatusCode::NOT_FOUND.into_response() +} + +fn signed_request( + client: &reqwest::Client, + base_url: &str, + body: &str, + delivery_id: &str, + secret: &str, +) -> reqwest::RequestBuilder { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body.as_bytes()); + let signature = format!("sha256={}", hex::encode(mac.finalize().into_bytes())); + client + .post(format!("{base_url}/triggers/webhook/patrol-github")) + .header("x-hub-signature-256", signature) + .header("x-github-delivery", delivery_id) + .body(body.to_string()) +} + +#[tokio::test] +async fn http_admission_rejects_before_persistence_and_suppresses_replay() { + let backend_state = MockWebhookApi::default(); + let backend_url = spawn_server( + Router::new() + .fallback(any(mock_webhook_api)) + .with_state(backend_state.clone()), + ) + .await; + let api = PawApiClient::new(PawApiConfig { + base_url: backend_url, + tenant: "tenant-a".to_string(), + api_key: None, + }); + let secrets: WebhookSecretResolver = Arc::new(|key| { + (key == "patrol_github_webhook_secret").then(|| "correct-secret".to_string()) + }); + let trigger_url = spawn_server(router(api, secrets)).await; + let client = reqwest::Client::new(); + let body = r#"{ "action": "opened" }"#; + + backend_state.route.lock().unwrap()["fields"]["max_body_bytes"] = json!("4"); + let route_oversized = signed_request( + &client, + &trigger_url, + body, + "delivery-route-oversized", + "correct-secret", + ) + .send() + .await + .unwrap(); + assert_eq!(route_oversized.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(backend_state.create_attempts.load(Ordering::SeqCst), 0); + backend_state.route.lock().unwrap()["fields"]["max_body_bytes"] = json!("262144"); + + let globally_oversized = format!(r#"{{"data":"{}"}}"#, "a".repeat(HARD_MAX_BODY_BYTES)); + let global_rejection = signed_request( + &client, + &trigger_url, + &globally_oversized, + "delivery-global-oversized", + "correct-secret", + ) + .send() + .await + .unwrap(); + assert_eq!(global_rejection.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(backend_state.create_attempts.load(Ordering::SeqCst), 0); + + let unsigned = client + .post(format!("{trigger_url}/triggers/webhook/patrol-github")) + .body(body) + .send() + .await + .unwrap(); + assert_eq!(unsigned.status(), StatusCode::UNAUTHORIZED); + assert_eq!(backend_state.create_attempts.load(Ordering::SeqCst), 0); + + let forged = signed_request( + &client, + &trigger_url, + body, + "delivery-forged", + "wrong-secret", + ) + .send() + .await + .unwrap(); + assert_eq!(forged.status(), StatusCode::UNAUTHORIZED); + assert_eq!(backend_state.create_attempts.load(Ordering::SeqCst), 0); + + backend_state.route.lock().unwrap()["fields"]["max_deliveries_per_minute"] = json!("2"); + for (delivery_id, invalid_body) in [ + ("delivery-malformed", "not-json"), + ("delivery-scalar", r#""scalar""#), + ] { + let invalid = signed_request( + &client, + &trigger_url, + invalid_body, + delivery_id, + "correct-secret", + ) + .send() + .await + .unwrap(); + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); + assert_eq!(backend_state.create_attempts.load(Ordering::SeqCst), 0); + } + let exhausted = signed_request( + &client, + &trigger_url, + body, + "delivery-after-invalid-budget", + "correct-secret", + ) + .send() + .await + .unwrap(); + assert_eq!(exhausted.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(backend_state.create_attempts.load(Ordering::SeqCst), 0); + backend_state.route.lock().unwrap()["fields"]["max_deliveries_per_minute"] = json!("120"); + + backend_state + .mutate_after_next_route_read + .store(true, Ordering::SeqCst); + let accepted = signed_request(&client, &trigger_url, body, "delivery-1", "correct-secret") + .send() + .await + .unwrap(); + assert_eq!(accepted.status(), StatusCode::OK); + let accepted_body: Value = accepted.json().await.unwrap(); + assert_eq!(accepted_body["status"], "accepted"); + + let replay = signed_request(&client, &trigger_url, body, "delivery-1", "correct-secret") + .send() + .await + .unwrap(); + assert_eq!(replay.status(), StatusCode::OK); + let replay_body: Value = replay.json().await.unwrap(); + assert_eq!(replay_body["status"], "duplicate"); + assert_eq!(replay_body["event_id"], accepted_body["event_id"]); + + let altered = signed_request( + &client, + &trigger_url, + r#"{"action":"closed"}"#, + "delivery-1", + "correct-secret", + ) + .send() + .await + .unwrap(); + assert_eq!(altered.status(), StatusCode::CONFLICT); + + backend_state + .fail_next_dispatch + .store(true, Ordering::SeqCst); + let interrupted = signed_request( + &client, + &trigger_url, + body, + "delivery-interrupted", + "correct-secret", + ) + .send() + .await + .unwrap(); + assert_eq!(interrupted.status(), StatusCode::SERVICE_UNAVAILABLE); + let interrupted_body: Value = interrupted.json().await.unwrap(); + assert_eq!( + interrupted_body["error"], + "webhook event was created but admission dispatch failed; retry this delivery" + ); + + let recovered = signed_request( + &client, + &trigger_url, + body, + "delivery-interrupted", + "correct-secret", + ) + .send() + .await + .unwrap(); + assert_eq!(recovered.status(), StatusCode::OK); + let recovered_body: Value = recovered.json().await.unwrap(); + assert_eq!(recovered_body["status"], "accepted"); + + assert_eq!(backend_state.create_attempts.load(Ordering::SeqCst), 5); + assert_eq!( + backend_state.secret_http_reads.load(Ordering::SeqCst), + 0, + "webhook signing secrets must be resolved in-process, never over HTTP" + ); + let dispatches = backend_state.dispatches.lock().unwrap(); + assert_eq!(dispatches.len(), 2); + assert_eq!( + dispatches[0]["target_action"], "TemperPaw.Patrol.Ingest", + "route mutation after authentication must not change the accepted capability" + ); + assert_eq!(dispatches[0]["delivery_id"], "delivery-1"); + assert_eq!(dispatches[0]["authentication_scheme"], "hmac-sha256"); + assert_eq!(dispatches[0]["raw_payload"], body); + assert_eq!( + dispatches[0]["normalized_payload"], + r#"{"action":"opened"}"# + ); + assert!( + dispatches[0]["route_snapshot_digest"] + .as_str() + .is_some_and(|digest| digest.len() == 64) + ); + assert_eq!(dispatches[1]["delivery_id"], "delivery-interrupted"); +} diff --git a/crates/temperpaw/src/setup_api.rs b/crates/temperpaw/src/setup_api.rs index cb581b743..ef0ddcf49 100644 --- a/crates/temperpaw/src/setup_api.rs +++ b/crates/temperpaw/src/setup_api.rs @@ -49,6 +49,13 @@ const DATADOG_RUNTIME_AGENT_SERVICE_NAME: &str = "datadog-runtime-agent"; const DATADOG_RUNTIME_AGENT_IMAGE: &str = "datadog/agent:7"; const DATADOG_RUNTIME_AGENT_HOST: &str = "datadog-runtime-agent.railway.internal"; const DEFAULT_GENESIS_REGISTRY_URL: &str = "https://genesis-production-164d.up.railway.app"; +const REQUIRED_WEBHOOK_SECRET_REFS: [&str; 5] = [ + "patrol_request_webhook_secret", + "patrol_signal_webhook_secret", + "datadog_webhook_secret", + "github_webhook_secret", + "patrol_discord_webhook_secret", +]; /// Shared state for the setup API. #[derive(Clone)] @@ -94,6 +101,11 @@ fn allowed_secret_keys() -> HashSet<&'static str> { "slack_app_token", "slack_bot_token", "slack_signing_secret", + "patrol_request_webhook_secret", + "patrol_signal_webhook_secret", + "datadog_webhook_secret", + "github_webhook_secret", + "patrol_discord_webhook_secret", "github_token", "exa_api_key", "tensorlake_api_key", @@ -334,6 +346,41 @@ fn secrets_schema() -> Vec { required: false, description: "Webhook signature verification", }, + SecretSchema { + key: "patrol_request_webhook_secret", + category: "webhook", + label: "Patrol Request Webhook Secret", + required: true, + description: "HMAC-SHA256 key for the seeded patrol-request ingress route", + }, + SecretSchema { + key: "patrol_signal_webhook_secret", + category: "webhook", + label: "Patrol Signal Webhook Secret", + required: true, + description: "HMAC-SHA256 key for the seeded patrol-signal ingress route", + }, + SecretSchema { + key: "datadog_webhook_secret", + category: "webhook", + label: "Datadog Webhook Secret", + required: true, + description: "HMAC-SHA256 key configured on the Datadog webhook sender", + }, + SecretSchema { + key: "github_webhook_secret", + category: "webhook", + label: "GitHub Webhook Secret", + required: true, + description: "HMAC-SHA256 secret configured on the GitHub webhook", + }, + SecretSchema { + key: "patrol_discord_webhook_secret", + category: "webhook", + label: "Patrol Discord Webhook Secret", + required: true, + description: "HMAC-SHA256 key for the seeded patrol-discord signal route", + }, SecretSchema { key: "exa_api_key", category: "web_search", @@ -479,6 +526,18 @@ struct SetupStatus { discord_connected: bool, slack_connected: bool, discord_interaction_url: Option, + webhook_ready: bool, + missing_webhook_secrets: Vec<&'static str>, +} + +fn missing_required_webhook_secrets(mut get_secret: F) -> Vec<&'static str> +where + F: FnMut(&str) -> Option, +{ + REQUIRED_WEBHOOK_SECRET_REFS + .into_iter() + .filter(|key| !secret_is_configured(get_secret(key))) + .collect() } #[derive(Debug, Clone, Serialize)] @@ -930,6 +989,9 @@ async fn get_setup_status(State(state): State) -> Json) -> Json) -> impl IntoR let connection_state = discord_connection .as_ref() .map(|snapshot| snapshot.status.as_str()); - let (status, mut body) = discord_readyz_response( + let (mut status, mut body) = discord_readyz_response( has_discord, &runtime.discord, desired_state, @@ -3121,6 +3185,19 @@ pub(crate) async fn get_readyz(State(state): State) -> impl IntoR .unwrap_or(serde_json::Value::Null); } + let missing_webhook_secrets = missing_required_webhook_secrets(|key| { + vault.and_then(|vault| vault.get_secret(&state.tenant, key)) + }); + let webhook_ready = missing_webhook_secrets.is_empty(); + body["webhook"] = serde_json::json!({ + "status": if webhook_ready { "ready" } else { "degraded" }, + "missing_secret_refs": missing_webhook_secrets, + }); + if !webhook_ready { + status = StatusCode::SERVICE_UNAVAILABLE; + body["status"] = serde_json::json!("degraded"); + } + (status, Json(body)) } @@ -3560,9 +3637,9 @@ mod tests { InstallFromGenesisRequest, allowed_secret_keys, datadog_enhanced_app_railway_vars, datadog_runtime_agent_railway_vars, discord_connect_params_for_secret_update, discord_readyz_response, discord_start_error_is_retryable, - genesis_install_request_from_setup, is_discord_ping, persist_discord_public_key, - personalized_soul_flag_value, secrets_schema, transport_status_report, - validate_setup_secret_key, verify_discord_signature, + genesis_install_request_from_setup, is_discord_ping, missing_required_webhook_secrets, + persist_discord_public_key, personalized_soul_flag_value, secrets_schema, + transport_status_report, validate_setup_secret_key, verify_discord_signature, }; use crate::transport_manager::TransportStatus; use axum::http::StatusCode; @@ -3967,6 +4044,31 @@ mod tests { assert!(validate_setup_secret_key("bad/key").is_err()); } + #[test] + fn governed_webhook_secrets_are_required_in_setup_schema() { + let schema = secrets_schema(); + for key in [ + "patrol_request_webhook_secret", + "patrol_signal_webhook_secret", + "datadog_webhook_secret", + "github_webhook_secret", + "patrol_discord_webhook_secret", + ] { + let entry = schema + .iter() + .find(|secret| secret.key == key) + .unwrap_or_else(|| panic!("missing webhook secret schema for {key}")); + assert!(entry.required, "{key} must block webhook readiness/setup"); + assert_eq!(entry.category, "webhook"); + } + + let missing = missing_required_webhook_secrets(|key| { + (key != "github_webhook_secret").then(|| "configured".to_string()) + }); + assert_eq!(missing, vec!["github_webhook_secret"]); + assert!(missing_required_webhook_secrets(|_| Some("configured".to_string())).is_empty()); + } + #[test] fn openai_codex_secret_schema_points_to_managed_oauth_not_codex_cli_import() { let codex = secrets_schema() diff --git a/crates/temperpaw/src/startup.rs b/crates/temperpaw/src/startup.rs index a0bd74f1c..cc19123c1 100644 --- a/crates/temperpaw/src/startup.rs +++ b/crates/temperpaw/src/startup.rs @@ -1726,18 +1726,22 @@ pub async fn run(mut config: Config, force_soul_setup: bool) -> Result<()> { .as_deref() .map(|url| url.starts_with("https://")) .unwrap_or(false); + let secrets_vault = state + .server + .secrets_vault + .as_ref() + .context("Vault must be initialized before auth and webhook admission")? + .clone(); let auth_state = crate::auth::AuthState::new( storage.clone(), - state - .server - .secrets_vault - .as_ref() - .context("Vault must be initialized before auth")? - .clone(), + secrets_vault.clone(), vault_key_bytes.to_vec(), tenant.clone(), cookie_secure, ); + let webhook_secret_tenant = tenant.clone(); + let webhook_secrets: paw_transport::webhook::WebhookSecretResolver = + Arc::new(move |key| secrets_vault.get_secret(&webhook_secret_tenant, key)); let router = build_platform_router(state.clone()); let setup_state = crate::setup_api::SetupApiState { @@ -1758,7 +1762,10 @@ pub async fn run(mut config: Config, force_soul_setup: bool) -> Result<()> { let router = router .merge(crate::setup_api::router(setup_state.clone())) .merge(crate::auth::router(auth_state.clone())) - .merge(paw_transport::webhook::router(webhook_api)); + .merge(paw_transport::webhook::router( + webhook_api, + webhook_secrets.clone(), + )); let router = router.layer(axum::extract::DefaultBodyLimit::max(50 * 1024 * 1024)); @@ -2021,7 +2028,12 @@ pub async fn run(mut config: Config, force_soul_setup: bool) -> Result<()> { tracing::info!("Phase 9: Finalizing runtime bring-up..."); // Spawn webhook trigger (ONE entity, ONE action per request). - spawn_webhook_trigger(&tenant, actual_port, config.temper_api_key.clone()); + spawn_webhook_trigger( + &tenant, + actual_port, + config.temper_api_key.clone(), + webhook_secrets, + ); // Cron scheduling is now handled by the platform's schedule_at effect — // CronJob entities self-schedule via ActivateComplete/TriggerComplete. @@ -3889,7 +3901,12 @@ fn find_wasm_binary(module_dir: &Path, module_name: &str) -> Option { /// /// Listens on port+12 for POST /triggers/webhook/{route_key}. /// ONE entity, ONE action — everything else is WASM integrations. -fn spawn_webhook_trigger(tenant: &str, port: u16, api_key: Option) { +fn spawn_webhook_trigger( + tenant: &str, + port: u16, + api_key: Option, + secrets: paw_transport::webhook::WebhookSecretResolver, +) { use paw_transport::PawApiConfig; use paw_transport::webhook::{WebhookTrigger, WebhookTriggerConfig}; @@ -3905,7 +3922,7 @@ fn spawn_webhook_trigger(tenant: &str, port: u16, api_key: Option) { api_key, }); let config = WebhookTriggerConfig { port: trigger_port }; - let trigger = WebhookTrigger::new(config, api); + let trigger = WebhookTrigger::new(config, api, secrets); if let Err(e) = trigger.run().await { tracing::error!("Webhook trigger fatal error: {e}"); } diff --git a/crates/temperpaw/tests/paw_patrol_foundation.rs b/crates/temperpaw/tests/paw_patrol_foundation.rs index 9fc31a0af..2f0027cac 100644 --- a/crates/temperpaw/tests/paw_patrol_foundation.rs +++ b/crates/temperpaw/tests/paw_patrol_foundation.rs @@ -1413,10 +1413,11 @@ fn webhook_intake_smoke_exercises_the_trigger_boundary() { "/triggers/webhook/${route_key}", "WebhookEvents", "TemperPaw.Ingest.Received", - "TemperPaw.Ingest.Register", - "TemperPaw.Patrol.Submit", - "TemperPaw.Patrol.Ingest", - "PatrolRequests", + "forged webhook rejected before persistence", + "status' <<<\"$request_replay_response\")\" != \"duplicate\"", + "x-temper-signature", + "x-temper-delivery-id", + "WorkRequests", "Signals", "FactoryCases", "WorkCycles", @@ -2094,6 +2095,13 @@ fn paw_patrol_has_webhook_intake_routes_through_paw_ingest() { "route_key = \"patrol-datadog\"", "route_key = \"patrol-github\"", "route_key = \"patrol-discord\"", + "auth_scheme = \"hmac-sha256\"", + "secret_ref = \"datadog_webhook_secret\"", + "secret_ref = \"github_webhook_secret\"", + "signature_header = \"x-hub-signature-256\"", + "delivery_id_header = \"x-github-delivery\"", + "max_body_bytes = \"262144\"", + "max_deliveries_per_minute = \"120\"", ] { assert!( routes.contains(needle), @@ -2120,7 +2128,6 @@ fn paw_patrol_has_webhook_intake_routes_through_paw_ingest() { let ingest_manifest = read(root.join("os-apps/paw-ingest/app.toml")); for needle in [ - "name = \"validate_webhook\"", "name = \"route_webhook\"", "name = \"process_webhook\"", "criticality = \"app-required\"", @@ -2134,7 +2141,6 @@ fn paw_patrol_has_webhook_intake_routes_through_paw_ingest() { let ingest_build = read(root.join("os-apps/paw-ingest/wasm/build.sh")); for needle in [ - "validate_webhook", "route_webhook", "process_webhook", "cargo build --target wasm32-unknown-unknown --release", @@ -2144,6 +2150,39 @@ fn paw_patrol_has_webhook_intake_routes_through_paw_ingest() { "paw-ingest build.sh should build {needle}" ); } + assert!( + !routes.contains("webhook_secret = \"\"") + && !ingest_manifest.contains("validate_webhook") + && !ingest_build.contains("validate_webhook"), + "webhook admission must not retain unsigned seed routes or a duplicate downstream verifier" + ); + + let trigger = read(root.join("crates/paw-transport/src/webhook/trigger.rs")); + for needle in [ + "atomic get-or-create", + "WebhookSecretResolver", + "signature_matches", + "webhook_event_id", + "route_snapshot_digest", + "normalize_json_object", + "DefaultBodyLimit::max", + "max_deliveries_per_minute", + ] { + assert!( + trigger.contains(needle), + "webhook HTTP boundary should enforce authenticated admission: {needle}" + ); + } + assert!( + !trigger.contains("/paw/setup/secrets/"), + "webhook admission must resolve secrets through its in-process vault capability, never HTTP" + ); + + let route_webhook = read(root.join("os-apps/paw-ingest/wasm/route_webhook/src/lib.rs")); + assert!( + !route_webhook.contains("/tdata/WebhookRoutes"), + "route_webhook must consume the admitted immutable snapshot instead of re-reading mutable route state" + ); let app_doc = read(root.join("os-apps/paw-patrol/APP.md")); for needle in [ @@ -2164,6 +2203,88 @@ fn paw_patrol_has_webhook_intake_routes_through_paw_ingest() { ); } +#[test] +fn paw_ingest_cedar_enforces_webhook_capability_owners() { + let root = repo_root(); + let policy = read(root.join("os-apps/paw-ingest/policies/webhook.cedar")); + let engine = AuthzEngine::new(&policy).expect("webhook.cedar should parse"); + let attrs = resource_attrs(&[("id", serde_json::json!("webhook-security-test"))]); + let admin = SecurityContext::from_headers(&[ + ("X-Temper-Principal-Id".to_string(), "admin-1".to_string()), + ("X-Temper-Principal-Kind".to_string(), "admin".to_string()), + ]); + let agent = agent_context("untrusted-agent", "agent"); + + for (entity, actions) in [ + ( + "WebhookRoute", + &[ + "create", "read", "list", "Register", "Update", "Disable", "Enable", + ][..], + ), + ( + "WebhookEvent", + &[ + "create", + "read", + "list", + "Received", + "Routed", + "Processed", + "RouteFailed", + "ProcessFailed", + ][..], + ), + ] { + for action in actions { + assert!( + engine + .authorize(&admin, action, entity, &attrs) + .is_allowed(), + "Admin must own {entity}.{action}" + ); + assert!( + !engine + .authorize(&agent, action, entity, &attrs) + .is_allowed(), + "plain Agent must not own {entity}.{action}" + ); + } + } + + let mut route_module = agent_context("route-webhook", "agent"); + route_module + .context_attrs + .insert("module".to_string(), serde_json::json!("route_webhook")); + for action in ["Routed", "RouteFailed"] { + assert!( + engine + .authorize(&route_module, action, "WebhookEvent", &attrs) + .is_allowed(), + "route_webhook must own WebhookEvent.{action}" + ); + } + assert!( + !engine + .authorize(&route_module, "Processed", "WebhookEvent", &attrs) + .is_allowed(), + "route_webhook must not impersonate process_webhook" + ); + + let mut process_module = agent_context("process-webhook", "agent"); + process_module + .context_attrs + .insert("module".to_string(), serde_json::json!("process_webhook")); + for action in ["Processed", "ProcessFailed"] { + assert!( + engine + .authorize(&process_module, action, "WebhookEvent", &attrs) + .is_allowed(), + "process_webhook must own WebhookEvent.{action}" + ); + } +} + #[test] fn patrol_schedule_recurs_sweeps_and_daily_briefs_inside_patrol() { let root = repo_root(); diff --git a/os-apps/paw-ingest/APP.md b/os-apps/paw-ingest/APP.md index fd45233c7..854efdf75 100644 --- a/os-apps/paw-ingest/APP.md +++ b/os-apps/paw-ingest/APP.md @@ -1,24 +1,31 @@ # paw-ingest -Webhook ingress pipeline. Receives external webhooks, validates signatures, routes to target entities, and processes the dispatched action. +Authenticated webhook pipeline. The Rust protocol trigger verifies the exact +request bytes and consumes replay/resource budgets before persisting an +immutable accepted envelope. Entity/WASM transitions then route and process it. ## Entity Types ### WebhookEvent One incoming webhook flowing through validation, routing, and processing. -- **States**: Created -> Validating -> Routing -> Processing -> Processed / Rejected -- **Key actions**: `Received` (raw_payload, raw_headers, route_key), `Validated` (source_type, hmac_verified), `Routed` (target_entity_type, target_entity_id, target_action), `Processed` -- **Failure actions**: `ValidationFailed`, `RouteFailed`, `ProcessFailed` — all transition to Rejected -- **WASM**: `validate_webhook` (HMAC verification, normalization), `route_webhook` (match route key to target), `process_webhook` (dispatch action on target entity) +- **States**: Created -> Routing -> Processing -> Processed / Rejected +- **Key actions**: `Received` (authenticated payload + immutable route snapshot), `Routed` (created target entity), `Processed` +- **Failure actions**: `RouteFailed`, `ProcessFailed` — transition to Rejected +- **WASM**: `route_webhook` (uses the accepted snapshot; never re-reads the route), `process_webhook` (dispatches the target action) ### WebhookRoute Configuration entity mapping route keys to target entities and actions. - **States**: Active <-> Disabled -- **Key actions**: `Register` (route_key, source_type, event_filter, target_entity_type, target_action, webhook_secret), `Update`, `Disable`, `Enable` -- **Options**: `monitor_resolution_enabled`, `dedup_enabled`, `dedup_window_minutes` +- **Key actions**: `Register` (unique route key, target capability, HMAC scheme, vault reference, signature/delivery headers, budgets), `Update`, `Disable`, `Enable` +- **Security**: Admin-only governance; secret values are never stored on the entity or fetched through HTTP admission +- **Options**: `monitor_resolution_enabled`, semantic `dedup_enabled`, `dedup_window_minutes` ## Setup -No dependencies. Register WebhookRoute entities for each external source, then POST webhooks to the ingress endpoint with the route key. +Register WebhookRoute entities for each external source and configure every +referenced vault secret. Providers must send a JSON-object body, the configured +signature over its exact bytes, and the configured delivery-ID header. Unsigned, +malformed, replay-mismatched, unconfigured, or over-budget requests do not +create a new WebhookEvent. diff --git a/os-apps/paw-ingest/adrs/001-webhook-hmac-verification.md b/os-apps/paw-ingest/adrs/001-webhook-hmac-verification.md new file mode 100644 index 000000000..9ab1b83cd --- /dev/null +++ b/os-apps/paw-ingest/adrs/001-webhook-hmac-verification.md @@ -0,0 +1,155 @@ +# ADR-001: Authenticated webhook admission + +**Status:** Accepted +**Scope:** `paw-transport` webhook trigger and `paw-ingest` +**Date:** 2026-07-07 (revised 2026-07-11) +**Tracking:** ARN-168 (Class B, epic ARN-165); paired with Temper ARN-171 / PR #340 + +## Context + +`POST /triggers/webhook/{route_key}` is public because external providers cannot +present a Temper bearer credential. The original pipeline created a durable +`WebhookEvent` first and deferred verification to `validate_webhook` WASM. That +module only checked for a signature header. PR #451 replaced that check with a +real HMAC, but still left the security boundary structurally incomplete: + +- every shipped route configured an empty secret and therefore skipped HMAC; +- an invalid request was persisted before authentication; +- the validator and router independently re-read mutable `WebhookRoute` state, + so validation and execution could observe different targets; +- exact signed deliveries could be replayed; +- literal secrets and route governance were broadly readable and mutable; and +- the public body inherited the application's 50 MiB body allowance. + +The Temper kernel receiver in PR #340 establishes the correct order for static +`[[webhook]]` declarations: authenticate raw bytes, authorize, derive durable +idempotency, and only then dispatch. TemperPaw routes are dynamic entities, so +they cannot use the kernel's static route lookup directly, but they must use the +same boundary ordering and must not retain a second downstream verifier. + +## Decision + +The HTTP trigger is the sole webhook admission boundary. It remains a protocol +bridge: after read-only route/secret resolution it creates one entity and +dispatches one action. Business processing remains in entity transitions and +WASM integrations. + +### Governed route capability + +Every active route must declare all of the following: + +- `auth_scheme = "hmac-sha256"`; +- a non-empty `secret_ref` containing only a vault key, never a secret value or + `{secret:...}` template; +- an explicit `signature_header`; +- an explicit `delivery_id_header` supplied by the provider; +- bounded `max_body_bytes` and `max_deliveries_per_minute` budgets; and +- a fixed target entity type and action. + +Route/source names and target identifiers must satisfy path-safe identifier +grammars; target actions must be dot-qualified identifiers. Boolean options are +exactly `true` or `false`, and the deduplication window is a positive bounded +budget. Configuration typos therefore fail admission instead of silently +disabling controls or becoming internal URL fragments. Duplicate signature or +delivery-ID headers are rejected as ambiguous. + +`route_key` is a declared unique key. Route creation, reads, updates, and state +changes are Admin-only. Shipped seed routes use governed vault references and +fail readiness/admission when their referenced secret is absent. + +Startup injects a tenant-scoped in-process secret resolver into the trigger. +The resolver closes over the active tenant and vault, and accepts only the +validated route key. Webhook admission never retrieves signing secrets through +the setup HTTP API, so public trigger authentication cannot be converted into +a secret-exfiltration dependency. + +### Admission order + +For each request the trigger: + +1. applies a hard global body ceiling before extraction; +2. resolves exactly one active route through the bounded keyed lookup; +3. validates route configuration and its per-route body budget; +4. resolves the referenced secret through its injected tenant-vault + capability; +5. verifies `HMAC-SHA256(secret, raw_body)` using decoded bytes and + `Mac::verify_slice`; +6. requires a bounded, non-empty provider delivery ID; +7. consumes the route's admission-rate budget, including for authenticated + malformed traffic; +8. requires the authenticated UTF-8 body to parse as a JSON object and derives + a canonical `normalized_payload` without changing `raw_payload`; +9. creates the deterministic `WebhookEvent` with the provider delivery ID, + payload digest, route ID, route key, authentication scheme, and route- + snapshot digest atomically bound into its initial fields, then dispatches + `Received` once. + +Unknown, malformed, unsigned, mis-signed, non-object, over-budget, or +unconfigured requests create no durable entity. + +### Replay boundary + +The event ID is derived from a domain-separated SHA-256 hash of tenant, route +entity ID, and provider delivery ID. Entity creation is the durable compare-and- +set. Temper's collection POST is an atomic get-or-create and returns the +authoritative stored winner even when the caller-selected ID already exists. +The trigger compares that response before any dispatch. The initial create +atomically stores the immutable admission fingerprint, including payload and +route-snapshot digests. The stable request identity is the event/route identity, +delivery ID, payload digest, and authentication scheme: + +- any stable-identity mismatch returns HTTP 409 and never dispatches; +- a stable match after transition returns the existing event as a duplicate, + even if an administrator has since changed the route; +- a stable match still in `Created` may retry the interrupted `Received` + dispatch only when the current route-snapshot digest also matches the stored + digest; otherwise it returns HTTP 409 rather than dispatching a changed + capability. + +The payload digest is deliberately not part of the event ID because a provider +delivery ID identifies one delivery. Binding the digest inside that reserved +identity lets the server distinguish an exact retry from changed content trying +to reuse the same delivery ID. + +### Immutable accepted envelope + +The trigger snapshots the governed route fields into `WebhookEvent.Received`, +including the route ID, target capability, source type, operational options, +payload digest, delivery ID, and a digest of the route snapshot. Downstream +WASM never re-reads `WebhookRoute`. A concurrent route mutation can affect the +next delivery but cannot change the capability already admitted for this one. + +Raw request headers are not persisted. The exact accepted raw body remains +available to the entity pipeline because it is functional input. The normalized +body is a canonical JSON object, and downstream routing fails closed if a +manually created or corrupted event violates that contract. WebhookEvent reads +are Admin-only. + +### Kernel relationship + +This is not an alternative to Temper PR #340. The two routes serve different +configuration models (static spec declarations versus dynamic route entities) +but share the same security contract and cryptographic primitive. TemperPaw +pins the current kernel and keeps the custom trigger only for the dynamic +entity-first ingress model. The obsolete `validate_webhook` WASM verifier is +removed so authentication has one owner. + +## Consequences + +- Unsigned shipped routes no longer work. Operators must configure the named + vault secrets and providers must send both signature and delivery headers. +- Route changes are governance operations, and existing literal-secret routes + must be migrated to secret references before traffic is accepted. +- Exact replay is durable across restarts because it is represented by entity + identity; the in-process rate budget protects the single deployed trigger + service from authenticated floods, while the platform's request admission + controller remains the outer concurrency boundary. +- Expired route-rate windows are evicted, and the trigger fails closed once its + bounded tracked-route budget is full, so governed route churn cannot grow the + process map without bound. +- Tests must prove no persistence before authentication, duplicate delivery + suppression, rejection of changed content under a consumed delivery ID, + recovery after an interrupted create-before-dispatch, snapshot stability + under route mutation, no HTTP secret-fetch dependency, malformed/non-object + rejection, Cedar restrictions, bounded bodies/rates, and a signed local + HTTP-to-entity flow. diff --git a/os-apps/paw-ingest/app.toml b/os-apps/paw-ingest/app.toml index c84dc9e44..c8dfeb513 100644 --- a/os-apps/paw-ingest/app.toml +++ b/os-apps/paw-ingest/app.toml @@ -3,14 +3,6 @@ description = "Webhook ingress — event routing and processing" version = "0.1.0" dependencies = [] -[[wasm_modules]] -name = "validate_webhook" -target = "wasm32-unknown-unknown" -criticality = "app-required" -startup_loading = "lazy" -provenance = "bundled-artifact" -import_class = "temper-host" - [[wasm_modules]] name = "route_webhook" target = "wasm32-unknown-unknown" diff --git a/os-apps/paw-ingest/policies/webhook.cedar b/os-apps/paw-ingest/policies/webhook.cedar index 751138c77..24fb82c30 100644 --- a/os-apps/paw-ingest/policies/webhook.cedar +++ b/os-apps/paw-ingest/policies/webhook.cedar @@ -1,3 +1,5 @@ +// Webhook ingress is a capability boundary. Only administrators and the two +// named state-machine integrations may inspect or advance accepted envelopes. permit( principal is Admin, action in [ @@ -5,10 +7,8 @@ permit( Action::"read", Action::"list", Action::"Received", - Action::"Validated", Action::"Routed", Action::"Processed", - Action::"ValidationFailed", Action::"RouteFailed", Action::"ProcessFailed" ], @@ -16,22 +16,23 @@ permit( ); permit( - principal, - action in [ - Action::"create", - Action::"read", - Action::"list", - Action::"Received", - Action::"Validated", - Action::"Routed", - Action::"Processed", - Action::"ValidationFailed", - Action::"RouteFailed", - Action::"ProcessFailed" - ], + principal is Agent, + action in [Action::"Routed", Action::"RouteFailed"], resource is WebhookEvent -); +) when { + context.module == "route_webhook" +}; +permit( + principal is Agent, + action in [Action::"Processed", Action::"ProcessFailed"], + resource is WebhookEvent +) when { + context.module == "process_webhook" +}; + +// Route records contain target capabilities and vault references. They are +// governed configuration, not user-readable application data. permit( principal is Admin, action in [ @@ -51,19 +52,5 @@ permit( action == Action::"http_call", resource is HttpEndpoint ) when { - ["validate_webhook", "route_webhook", "process_webhook"].contains(context.module) + ["route_webhook", "process_webhook"].contains(context.module) }; - -permit( - principal, - action in [ - Action::"create", - Action::"read", - Action::"list", - Action::"Register", - Action::"Update", - Action::"Disable", - Action::"Enable" - ], - resource is WebhookRoute -); diff --git a/os-apps/paw-ingest/specs/model.csdl.xml b/os-apps/paw-ingest/specs/model.csdl.xml index db044e378..482b0a68a 100644 --- a/os-apps/paw-ingest/specs/model.csdl.xml +++ b/os-apps/paw-ingest/specs/model.csdl.xml @@ -13,14 +13,19 @@ - - + + + + + + + @@ -35,7 +40,12 @@ - + + + + + + @@ -47,16 +57,19 @@ - + - - - - - - - + + + + + + + + + + @@ -74,12 +87,6 @@ - - - - - - @@ -100,7 +107,12 @@ - + + + + + + @@ -109,9 +121,16 @@ + + - + + + + + + diff --git a/os-apps/paw-ingest/specs/webhook_event.ioa.toml b/os-apps/paw-ingest/specs/webhook_event.ioa.toml index 4a407e021..31c5afb19 100644 --- a/os-apps/paw-ingest/specs/webhook_event.ioa.toml +++ b/os-apps/paw-ingest/specs/webhook_event.ioa.toml @@ -1,18 +1,18 @@ # WebhookEvent — Ingest, validate, route, and process an incoming webhook. # -# Each event flows: Created → Validating → Routing → Processing → Processed. +# Each authenticated event flows: Created → Routing → Processing → Processed. # Failure at any stage transitions to Rejected. [automaton] name = "WebhookEvent" -states = ["Created", "Validating", "Routing", "Processing", "Processed", "Rejected"] +states = ["Created", "Routing", "Processing", "Processed", "Rejected"] initial = "Created" # ADR-0050 migration TODO: each state below needs either a # [[state_timeout]] declaration or a domain-specific justification # for being indefinite. Added as a migration allowlist to unblock # TEMPER_LIVENESS_ENFORCE=true; revisit and tune per state. -allow_indefinite_states = ["Created", "Validating", "Routing", "Processing"] +allow_indefinite_states = ["Created", "Routing", "Processing"] [[state]] name = "route_key" @@ -25,22 +25,32 @@ type = "string" initial = "" [[state]] -name = "raw_headers" +name = "source_type" type = "string" initial = "" [[state]] -name = "source_type" +name = "normalized_payload" type = "string" initial = "" [[state]] -name = "normalized_payload" +name = "authentication_scheme" +type = "string" +initial = "" + +[[state]] +name = "delivery_id" +type = "string" +initial = "" + +[[state]] +name = "payload_digest" type = "string" initial = "" [[state]] -name = "hmac_verified" +name = "route_snapshot_digest" type = "string" initial = "" @@ -64,6 +74,21 @@ name = "webhook_route_id" type = "string" initial = "" +[[state]] +name = "monitor_resolution_enabled" +type = "string" +initial = "false" + +[[state]] +name = "dedup_enabled" +type = "string" +initial = "false" + +[[state]] +name = "dedup_window_minutes" +type = "string" +initial = "60" + [[state]] name = "validation_error" type = "string" @@ -73,31 +98,9 @@ initial = "" name = "Received" kind = "input" from = ["Created"] -to = "Validating" -params = ["raw_payload", "raw_headers", "route_key"] -hint = "Accept a webhook payload and begin validation." -effect = [{ type = "trigger", name = "validate_webhook" }] - -# ADR-0046: inline trigger — migrated from [[integration]] below. -# Post-parse expansion re-synthesizes the equivalent Integration so -# the existing WASM/webhook runtime handles execution unchanged. -[[action.triggers]] -name = "validate_webhook" -kind = "wasm" -module = "validate_webhook" -on_failure = "ValidationFailed" - -[action.triggers.config] -temper_api_url = "{secret:temper_api_url}" - - -[[action]] -name = "Validated" -kind = "input" -from = ["Validating"] to = "Routing" -params = ["source_type", "hmac_verified", "normalized_payload"] -hint = "Payload validated and source identified. Begin routing." +params = ["raw_payload", "normalized_payload", "route_key", "source_type", "target_entity_type", "target_action", "webhook_route_id", "route_snapshot_digest", "payload_digest", "delivery_id", "authentication_scheme", "monitor_resolution_enabled", "dedup_enabled", "dedup_window_minutes"] +hint = "Record an authenticated immutable webhook envelope and begin routing." effect = [{ type = "trigger", name = "route_webhook" }] # ADR-0046: inline trigger — migrated from [[integration]] below. @@ -142,14 +145,6 @@ from = ["Processing"] to = "Processed" hint = "Webhook processing completed successfully." -[[action]] -name = "ValidationFailed" -kind = "input" -from = ["Validating"] -to = "Rejected" -params = ["validation_error"] -hint = "Webhook validation failed (bad HMAC, malformed payload, etc.)." - [[action]] name = "RouteFailed" kind = "input" diff --git a/os-apps/paw-ingest/specs/webhook_route.ioa.toml b/os-apps/paw-ingest/specs/webhook_route.ioa.toml index 5084c7228..a22f14da9 100644 --- a/os-apps/paw-ingest/specs/webhook_route.ioa.toml +++ b/os-apps/paw-ingest/specs/webhook_route.ioa.toml @@ -14,6 +14,12 @@ initial = "Active" # TEMPER_LIVENESS_ENFORCE=true; revisit and tune per state. allow_indefinite_states = ["Active", "Disabled"] +# Public route names are capability addresses and must be unique. The key also +# makes admission lookup bounded instead of falling back to a broad scan. +[[key]] +name = "route_key" +properties = ["RouteKey"] + [[state]] name = "route_key" type = "string" @@ -40,10 +46,35 @@ type = "string" initial = "" [[state]] -name = "webhook_secret" +name = "auth_scheme" +type = "string" +initial = "hmac-sha256" + +[[state]] +name = "secret_ref" type = "string" initial = "" +[[state]] +name = "signature_header" +type = "string" +initial = "x-temper-signature" + +[[state]] +name = "delivery_id_header" +type = "string" +initial = "x-temper-delivery-id" + +[[state]] +name = "max_body_bytes" +type = "string" +initial = "262144" + +[[state]] +name = "max_deliveries_per_minute" +type = "string" +initial = "120" + [[state]] name = "monitor_resolution_enabled" type = "string" @@ -63,14 +94,14 @@ initial = "60" name = "Register" kind = "input" from = ["Active"] -params = ["route_key", "source_type", "event_filter", "target_entity_type", "target_action", "webhook_secret", "monitor_resolution_enabled", "dedup_enabled", "dedup_window_minutes"] +params = ["route_key", "source_type", "event_filter", "target_entity_type", "target_action", "auth_scheme", "secret_ref", "signature_header", "delivery_id_header", "max_body_bytes", "max_deliveries_per_minute", "monitor_resolution_enabled", "dedup_enabled", "dedup_window_minutes"] hint = "Register or fully configure a webhook route." [[action]] name = "Update" kind = "input" from = ["Active"] -params = ["event_filter", "target_action", "webhook_secret", "monitor_resolution_enabled", "dedup_enabled", "dedup_window_minutes"] +params = ["source_type", "event_filter", "target_entity_type", "target_action", "auth_scheme", "secret_ref", "signature_header", "delivery_id_header", "max_body_bytes", "max_deliveries_per_minute", "monitor_resolution_enabled", "dedup_enabled", "dedup_window_minutes"] hint = "Update mutable fields on an active route." [[action]] diff --git a/os-apps/paw-ingest/wasm/build.sh b/os-apps/paw-ingest/wasm/build.sh index ffda9b753..1ceb6bf9d 100755 --- a/os-apps/paw-ingest/wasm/build.sh +++ b/os-apps/paw-ingest/wasm/build.sh @@ -16,7 +16,7 @@ copy_artifact() { fi } -for module in validate_webhook route_webhook process_webhook; do +for module in route_webhook process_webhook; do echo "Building $module..." (cd "$SCRIPT_DIR/$module" && cargo build --target wasm32-unknown-unknown --release) copy_artifact "$module" diff --git a/os-apps/paw-ingest/wasm/route_webhook/src/lib.rs b/os-apps/paw-ingest/wasm/route_webhook/src/lib.rs index c0c639215..97837313d 100644 --- a/os-apps/paw-ingest/wasm/route_webhook/src/lib.rs +++ b/os-apps/paw-ingest/wasm/route_webhook/src/lib.rs @@ -1,8 +1,8 @@ -//! Route Webhook — WASM module for routing validated webhooks to target entities. +//! Route Webhook — route an authenticated immutable webhook envelope. //! -//! Triggered by WebhookEvent.Validated action. Looks up the WebhookRoute to -//! determine the target entity type and action, optionally resolves monitors -//! and checks for duplicates, then creates the target entity. +//! Triggered by `WebhookEvent.Received`. The HTTP admission boundary snapshots +//! the governed target capability into the event after HMAC verification. This +//! module deliberately never re-reads mutable WebhookRoute state (ARN-168). //! //! Build: `cargo build --target wasm32-unknown-unknown --release` @@ -17,7 +17,7 @@ pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 { let fields = ctx.entity_state.get("fields").cloned().unwrap_or(json!({})); - // Read state set by prior actions + // Read the immutable envelope set atomically by Received. let source_type = fields .get("source_type") .and_then(|v| v.as_str()) @@ -32,88 +32,66 @@ pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 { .get("route_key") .and_then(|v| v.as_str()) .unwrap_or(""); - - if route_key.is_empty() { - set_success_result("RouteFailed", &json!({ - "validation_error": "route_key missing from entity state" - })); - return Ok(()); - } - - let temper_api_url = resolve_api_url(&ctx); - let tenant = &ctx.tenant; - let headers = odata_headers(&ctx, tenant); - - // Query WebhookRoute by route_key to get routing config - let filter = format!( - "route_key eq '{}' and Status eq 'Active'", - route_key.replace('\'', "''") - ); - let query_url = format!( - "{}/tdata/WebhookRoutes?$filter={}", - temper_api_url, - urlencoded(&filter) - ); - - let resp = ctx.http_call("GET", &query_url, &headers, "")?; - if resp.status < 200 || resp.status >= 300 { - set_success_result("RouteFailed", &json!({ - "validation_error": format!("route lookup failed (HTTP {})", resp.status) - })); - return Ok(()); - } - - let body: Value = serde_json::from_str(&resp.body) - .map_err(|e| format!("parse route response: {e}"))?; - - let routes = body - .get("value") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - - if routes.is_empty() { - set_success_result("RouteFailed", &json!({ - "validation_error": "no matching route for routing" - })); - return Ok(()); - } - - let route = &routes[0]; - let route_fields = route.get("fields").cloned().unwrap_or(json!({})); - - let route_id = route - .get("entity_id") + let route_id = fields + .get("webhook_route_id") .and_then(|v| v.as_str()) .unwrap_or(""); - - let target_entity_type = route_fields + let route_snapshot_digest = fields + .get("route_snapshot_digest") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let payload_digest = fields + .get("payload_digest") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let delivery_id = fields + .get("delivery_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let authentication_scheme = fields + .get("authentication_scheme") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let target_entity_type = fields .get("target_entity_type") .and_then(|v| v.as_str()) .unwrap_or(""); - - let target_action = route_fields + let target_action = fields .get("target_action") .and_then(|v| v.as_str()) .unwrap_or(""); - - let monitor_resolution_enabled = route_fields + let monitor_resolution_enabled = fields .get("monitor_resolution_enabled") .and_then(|v| v.as_str()) .unwrap_or("false"); - - let dedup_enabled = route_fields + let dedup_enabled = fields .get("dedup_enabled") .and_then(|v| v.as_str()) .unwrap_or("false"); - if target_entity_type.is_empty() || target_action.is_empty() { + if [ + route_key, + route_id, + route_snapshot_digest, + payload_digest, + delivery_id, + target_entity_type, + target_action, + ] + .iter() + .any(|value| value.is_empty()) + || authentication_scheme != "hmac-sha256" + { set_success_result("RouteFailed", &json!({ - "validation_error": "route missing target_entity_type or target_action" + "validation_error": "authenticated webhook envelope is incomplete" })); return Ok(()); } + let temper_api_url = resolve_api_url(&ctx); + let tenant = &ctx.tenant; + let headers = odata_headers(&ctx, tenant); + ctx.log( "info", &format!( @@ -123,8 +101,23 @@ pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 { ), ); - // Parse the normalized payload for monitor/dedup logic - let payload: Value = serde_json::from_str(normalized_payload).unwrap_or(json!({})); + // Admission guarantees a canonical JSON object. Fail closed if a + // corrupted or manually created event violates that envelope contract. + let payload: Value = match serde_json::from_str(normalized_payload) { + Ok(payload @ Value::Object(_)) => payload, + Ok(_) => { + set_success_result("RouteFailed", &json!({ + "validation_error": "normalized webhook payload is not a JSON object" + })); + return Ok(()); + } + Err(error) => { + set_success_result("RouteFailed", &json!({ + "validation_error": format!("normalized webhook payload is invalid: {error}") + })); + return Ok(()); + } + }; // Monitor resolution: if enabled and source is datadog, ensure Monitor entity exists if monitor_resolution_enabled == "true" && source_type == "datadog" { diff --git a/os-apps/paw-ingest/wasm/validate_webhook/Cargo.toml b/os-apps/paw-ingest/wasm/validate_webhook/Cargo.toml deleted file mode 100644 index 37d1a595a..000000000 --- a/os-apps/paw-ingest/wasm/validate_webhook/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "validate-webhook" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[workspace] - -[dependencies] -temper-wasm-sdk = { git = "https://github.com/nerdsane/temper.git", rev = "a28fdb2ed8b0e1e0ea15a99c3b4a1dfba20e160e" } diff --git a/os-apps/paw-ingest/wasm/validate_webhook/src/lib.rs b/os-apps/paw-ingest/wasm/validate_webhook/src/lib.rs deleted file mode 100644 index 09d9d73a9..000000000 --- a/os-apps/paw-ingest/wasm/validate_webhook/src/lib.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Validate Webhook — WASM module for validating incoming webhook payloads. -//! -//! Triggered by WebhookEvent.Received action. Looks up the WebhookRoute by -//! route_key, verifies HMAC signature if a secret is configured, and transitions -//! to Validated or ValidationFailed. -//! -//! Build: `cargo build --target wasm32-unknown-unknown --release` - -use temper_wasm_sdk::prelude::*; - -/// Entry point. -#[unsafe(no_mangle)] -pub extern "C" fn run(_ctx_ptr: i32, _ctx_len: i32) -> i32 { - let result = (|| -> Result<(), String> { - let ctx = Context::from_host()?; - ctx.log("info", "validate_webhook: starting"); - - let fields = ctx.entity_state.get("fields").cloned().unwrap_or(json!({})); - - // Read route_key from trigger params (set by Received action) - let route_key = fields - .get("route_key") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - if route_key.is_empty() { - set_success_result("ValidationFailed", &json!({ - "validation_error": "route_key is empty" - })); - return Ok(()); - } - - let raw_payload = fields - .get("raw_payload") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let raw_headers = fields - .get("raw_headers") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - // Resolve Temper API URL from config - let temper_api_url = resolve_api_url(&ctx); - - let tenant = &ctx.tenant; - let headers = odata_headers(&ctx, tenant); - - // Query WebhookRoutes by route_key and Active status - let filter = format!( - "route_key eq '{}' and Status eq 'Active'", - route_key.replace('\'', "''") - ); - let query_url = format!( - "{}/tdata/WebhookRoutes?$filter={}", - temper_api_url, - urlencoded(&filter) - ); - - ctx.log("info", &format!("validate_webhook: querying routes: {query_url}")); - - let resp = ctx.http_call("GET", &query_url, &headers, "")?; - if resp.status < 200 || resp.status >= 300 { - set_success_result("ValidationFailed", &json!({ - "validation_error": format!("route lookup failed (HTTP {})", resp.status) - })); - return Ok(()); - } - - let body: Value = serde_json::from_str(&resp.body) - .map_err(|e| format!("parse route response: {e}"))?; - - let routes = body - .get("value") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - - if routes.is_empty() { - set_success_result("ValidationFailed", &json!({ - "validation_error": "no matching route" - })); - return Ok(()); - } - - let route = &routes[0]; - let route_fields = route.get("fields").cloned().unwrap_or(json!({})); - - let webhook_secret = route_fields - .get("webhook_secret") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - let source_type = route_fields - .get("source_type") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - // HMAC verification (simplified — check header presence if secret is configured) - let hmac_verified = if webhook_secret.is_empty() { - "skipped" - } else { - verify_signature_header(raw_headers, source_type) - }; - - ctx.log( - "info", - &format!( - "validate_webhook: route matched, source_type={}, hmac={}", - source_type, hmac_verified - ), - ); - - set_success_result("Validated", &json!({ - "source_type": source_type, - "hmac_verified": hmac_verified, - "normalized_payload": raw_payload, - })); - - Ok(()) - })(); - - if let Err(e) = result { - set_error_result(&e); - } - 0 -} - -/// Resolve the Temper API URL from integration config or fall back to localhost. -fn resolve_api_url(ctx: &Context) -> String { - ctx.config - .get("temper_api_url") - .filter(|s| !s.is_empty() && !s.contains("{secret:")) - .cloned() - .unwrap_or_else(|| "http://127.0.0.1:3000".to_string()) -} - -/// Build standard OData request headers. -fn odata_headers(ctx: &Context, tenant: &str) -> Vec<(String, String)> { - vec![ - ("content-type".to_string(), "application/json".to_string()), - ("x-tenant-id".to_string(), tenant.to_string()), - ("x-temper-principal-kind".to_string(), "agent".to_string()), - ("x-temper-principal-id".to_string(), ctx.entity_id.clone()), - ("x-temper-agent-type".to_string(), "system".to_string()), - ] -} - -/// Minimal URL-encoding for OData filter values. -fn urlencoded(s: &str) -> String { - s.replace(' ', "%20") - .replace('\'', "%27") - .replace('&', "%26") - .replace('=', "%3D") -} - -/// Simplified HMAC signature check: verify the expected signature header exists -/// in the raw headers JSON. Full cryptographic verification can be added later. -fn verify_signature_header(raw_headers: &str, source_type: &str) -> &'static str { - let header_name = match source_type { - "datadog" => "x-datadog-signature", - "github" => "x-hub-signature-256", - _ => "x-hub-signature-256", - }; - - // raw_headers is a JSON object string - let parsed: Result = serde_json::from_str(raw_headers); - match parsed { - Ok(headers_obj) => { - if headers_obj.get(header_name).is_some() { - "true" - } else { - "false" - } - } - Err(_) => "false", - } -} diff --git a/os-apps/paw-patrol/APP.md b/os-apps/paw-patrol/APP.md index 7905a1ead..e845cbcd8 100644 --- a/os-apps/paw-patrol/APP.md +++ b/os-apps/paw-patrol/APP.md @@ -333,6 +333,13 @@ observed failures, alerts, traces, GitHub events, and Discord incidents. PatrolRequest remains a legacy entity set, but new human or manager-agent work flows through `WebhookEvent -> WorkRequest.Submit`. +Every route is HMAC-SHA256 authenticated at the HTTP boundary before a +WebhookEvent exists. Configure its seeded vault reference, send the configured +signature header over the exact body, and include a unique provider delivery +ID. Exact replays return the original event without a second dispatch. Route +records are Admin-only capabilities; downstream WASM uses the immutable target +snapshot accepted with the event rather than re-reading mutable route state. + ## WASM Modules Patrol's business logic lives in WASM integrations on entity actions. The Rust diff --git a/os-apps/paw-patrol/seed-data/webhook_routes.toml b/os-apps/paw-patrol/seed-data/webhook_routes.toml index 027b83ec3..cb4d4b95c 100644 --- a/os-apps/paw-patrol/seed-data/webhook_routes.toml +++ b/os-apps/paw-patrol/seed-data/webhook_routes.toml @@ -12,7 +12,12 @@ source_type = "patrol-request" event_filter = "*" target_entity_type = "WorkRequest" target_action = "TemperPaw.Patrol.Submit" -webhook_secret = "" +auth_scheme = "hmac-sha256" +secret_ref = "patrol_request_webhook_secret" +signature_header = "x-temper-signature" +delivery_id_header = "x-temper-delivery-id" +max_body_bytes = "262144" +max_deliveries_per_minute = "120" monitor_resolution_enabled = "false" dedup_enabled = "false" dedup_window_minutes = "60" @@ -27,7 +32,12 @@ source_type = "patrol-signal" event_filter = "*" target_entity_type = "Signal" target_action = "TemperPaw.Patrol.Ingest" -webhook_secret = "" +auth_scheme = "hmac-sha256" +secret_ref = "patrol_signal_webhook_secret" +signature_header = "x-temper-signature" +delivery_id_header = "x-temper-delivery-id" +max_body_bytes = "262144" +max_deliveries_per_minute = "120" monitor_resolution_enabled = "false" dedup_enabled = "false" dedup_window_minutes = "60" @@ -42,7 +52,12 @@ source_type = "datadog" event_filter = "*" target_entity_type = "Signal" target_action = "TemperPaw.Patrol.Ingest" -webhook_secret = "" +auth_scheme = "hmac-sha256" +secret_ref = "datadog_webhook_secret" +signature_header = "x-datadog-signature" +delivery_id_header = "x-temper-delivery-id" +max_body_bytes = "262144" +max_deliveries_per_minute = "120" monitor_resolution_enabled = "false" dedup_enabled = "false" dedup_window_minutes = "60" @@ -57,7 +72,12 @@ source_type = "github" event_filter = "*" target_entity_type = "Signal" target_action = "TemperPaw.Patrol.Ingest" -webhook_secret = "" +auth_scheme = "hmac-sha256" +secret_ref = "github_webhook_secret" +signature_header = "x-hub-signature-256" +delivery_id_header = "x-github-delivery" +max_body_bytes = "262144" +max_deliveries_per_minute = "120" monitor_resolution_enabled = "false" dedup_enabled = "false" dedup_window_minutes = "60" @@ -72,7 +92,12 @@ source_type = "discord" event_filter = "*" target_entity_type = "Signal" target_action = "TemperPaw.Patrol.Ingest" -webhook_secret = "" +auth_scheme = "hmac-sha256" +secret_ref = "patrol_discord_webhook_secret" +signature_header = "x-temper-signature" +delivery_id_header = "x-temper-delivery-id" +max_body_bytes = "262144" +max_deliveries_per_minute = "120" monitor_resolution_enabled = "false" dedup_enabled = "false" dedup_window_minutes = "60"