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