Files
multica/server/internal/handler/autopilot_webhook.go
Raúl Anatol 2b5696703f MUL-2703: feat(autopilots): webhook event filters per trigger (MUL-2334 follow-up) (#3231)
* feat(autopilots): webhook event filters per trigger (MUL-2334 follow-up)

Adds schema-backed event/action filtering to webhook triggers so operators
can declare exactly which GitHub (or generic) events should spawn autopilot
runs. Events outside the declared scope are recorded as ignored with reason
'event_filtered' — visible in the delivery log but without expensive run/task
creation.

Closes #3093 (supersedes the description-parsing approach from that PR).

Backend:
- Migration 108 adds event_filters JSONB to autopilot_trigger
- sqlc queries updated for CREATE / UPDATE / LIST / GET
- HandleAutopilotWebhook filters against trigger.event_filters before dispatch
- Create/Update trigger handlers accept event_filters in the request body
- Response shape includes event_filters so the UI can render it

Frontend:
- New WebhookEventFilterSection component in the autopilot dialog
- Inputs for event name + comma-separated actions
- i18n strings added (en + zh-Hans)

Tests:
- Unit tests for splitWebhookEvent and webhookEventAllowedByTriggerScope
- Handler-level integration tests for filtered / allowed / no-filter paths

co-authored-by: ZephaniaCN <agent/autopilot-webhook-filter>

* fix: recognize gitlab/bitbucket/gitea as providers in splitWebhookEvent

TestSplitWebhookEvent failed because only 'github' was recognized as a
provider prefix. Extract isKnownProvider() to handle gitlab, bitbucket,
and gitea as well.

* fix(autopilots): address PR #3231 review for webhook event filters

Must-fix from PR #3231 review:

1. event_filters now uses typed []WebhookEventFilter at the HTTP boundary
   instead of []byte. encoding/json was base64-encoding the field on the
   way out, so the UI could not .map() the response, and a real JSON
   array on the way in failed to decode. Response field also decodes the
   stored JSONB into a typed slice before serialising back.

2. UpdateAutopilotTriggerRequest.EventFilters is *[]WebhookEventFilter
   with tri-state PATCH semantics: nil pointer = leave alone, [] =
   clear, [...] = replace. The handler marshals an explicit empty slice
   to the JSONB literal `[]` so COALESCE overwrites instead of preserves.
   AutopilotDialog now PATCHes the webhook trigger when event_filters
   change in edit mode (previously the toast said "updated" while the
   backend was unchanged).

3. webhookEventAllowedByTriggerScope no longer short-circuits to false
   on the first event-name match whose actions don't line up. Earlier
   code silently shadowed any later filter that shared the same event
   name with disjoint actions.

Robustness: validateWebhookEventFilters rejects empty event names /
actions at write time, and the matcher fails closed on malformed stored
bytes instead of widening the allowlist.

Tests: handler tests now post real JSON arrays (the prior []byte path
masked the contract bug). Adds round-trip / clear-with-[] / preserve-
when-omitted / replace / invalid-filter / filters-on-schedule coverage,
plus matcher tests for same-event multi-filter and malformed-deny.

Migration renamed 108 → 110 to avoid colliding with main's
108_task_token (came in via the merge from main).
2026-05-27 15:47:36 +08:00

950 lines
34 KiB
Go

package handler
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/netip"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/middleware"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// maxWebhookBodyBytes is the request body size cap for webhook ingress.
// 256 KiB is plenty for normal provider webhooks (a max-size GitHub PR
// payload comes in well under this) and small enough that an attacker
// cannot wedge agent context windows by sending megabytes of arbitrary JSON.
const maxWebhookBodyBytes = 256 * 1024
// webhookTokenPrefix makes a leaked token recognisable in logs / audit trails
// without revealing the entropy bytes themselves. 32 random bytes encoded as
// URL-safe base64 (no padding) is 43 chars, so a full token is "awt_" + 43 = 47
// chars. URL-safe base64 keeps the token URL-friendly without escaping.
const webhookTokenPrefix = "awt_"
// generateWebhookToken returns a cryptographically random bearer token used as
// the public webhook URL secret. Format: "awt_" + URL-safe base64(32 bytes,
// no padding). UUIDs are intentionally not used here — they are lower entropy
// (122 bits vs 256) and visually overlap with internal IDs, which made
// accidental token-vs-ID confusion easy in early prototypes.
func generateWebhookToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("rand: %w", err)
}
return webhookTokenPrefix + base64.RawURLEncoding.EncodeToString(b), nil
}
// signature_status values mirror the CHECK constraint on webhook_delivery.
const (
sigStatusNotRequired = "not_required"
sigStatusValid = "valid"
sigStatusInvalid = "invalid"
sigStatusMissing = "missing"
)
// delivery status values mirror the CHECK constraint on webhook_delivery.
//
// "Duplicate" is a *response* status, not a delivery status — duplicates
// don't get their own row; they bump attempt_count on the existing dedupe
// target. Likewise "skipped" is a *response* status reported when the
// autopilot service skipped the run (e.g. runtime offline); the delivery
// row itself records `dispatched` and links the skipped run via
// autopilot_run_id, because from the ingress's perspective we DID hand
// the payload to the autopilot machinery.
const (
deliveryStatusQueued = "queued"
deliveryStatusDispatched = "dispatched"
deliveryStatusRejected = "rejected"
deliveryStatusIgnored = "ignored"
deliveryStatusFailed = "failed"
)
// ── Payload normalization ───────────────────────────────────────────────────
// WebhookEnvelope is the canonical shape stored in autopilot_run.trigger_payload
// and surfaced to the agent. The handler normalises arbitrary JSON bodies into
// this shape so downstream consumers (run_only daemon prompt, create_issue
// description appendix) can rely on a stable schema regardless of which
// provider sent the webhook.
type WebhookEnvelope struct {
Event string `json:"event"`
EventPayload json.RawMessage `json:"eventPayload"`
Request WebhookRequest `json:"request"`
}
type WebhookRequest struct {
ReceivedAt string `json:"receivedAt"`
ContentType string `json:"contentType,omitempty"`
}
// normalizeWebhookPayload parses an incoming webhook body and returns a
// WebhookEnvelope. Rules:
//
// 1. Body must be a valid JSON object or array. Scalars / invalid JSON
// return an error so the handler can respond 400.
// 2. If the body is an object containing a string `event` and any
// `eventPayload`, those are preserved as-is.
// 3. Otherwise `event` is inferred from headers/body fields, and the entire
// original body becomes `eventPayload`.
// 4. The default event is `webhook.received`.
//
// Inference order:
//
// X-GitHub-Event (combined with body.action when present),
// X-Gitlab-Event, X-Event-Type, body.event, body.type, body.action.
func normalizeWebhookPayload(body []byte, headers http.Header) (WebhookEnvelope, error) {
body = stripBOM(body)
if len(body) == 0 {
return WebhookEnvelope{}, errors.New("empty body")
}
// First, validate JSON shape (object or array). Reject scalars early —
// `"hello"` is technically valid JSON but has no useful interpretation
// as a webhook payload and would land in the agent prompt as a bare
// string.
var asAny any
if err := json.Unmarshal(body, &asAny); err != nil {
return WebhookEnvelope{}, fmt.Errorf("invalid json: %w", err)
}
switch asAny.(type) {
case map[string]any, []any:
// ok
default:
return WebhookEnvelope{}, errors.New("body must be a JSON object or array")
}
now := time.Now().UTC().Format(time.RFC3339)
contentType := headers.Get("Content-Type")
if i := strings.Index(contentType, ";"); i >= 0 {
contentType = strings.TrimSpace(contentType[:i])
}
env := WebhookEnvelope{
Request: WebhookRequest{
ReceivedAt: now,
ContentType: contentType,
},
}
// 1. Caller-provided envelope.
if obj, ok := asAny.(map[string]any); ok {
if eventStr, ok := obj["event"].(string); ok && eventStr != "" {
if rawPayload, ok := obj["eventPayload"]; ok {
inner, err := json.Marshal(rawPayload)
if err == nil {
env.Event = eventStr
env.EventPayload = inner
return env, nil
}
}
// `event` present but no eventPayload: still preserve event
// string, fall through to use whole body as payload.
env.Event = eventStr
env.EventPayload = json.RawMessage(body)
return env, nil
}
}
// 2. Inferred event.
event := inferEvent(headers, asAny)
env.Event = event
env.EventPayload = json.RawMessage(body)
return env, nil
}
// inferEvent returns a best-effort event identifier from headers and body.
func inferEvent(headers http.Header, body any) string {
if gh := headers.Get("X-GitHub-Event"); gh != "" {
if obj, ok := body.(map[string]any); ok {
if action, ok := obj["action"].(string); ok && action != "" {
return "github." + gh + "." + action
}
}
return "github." + gh
}
if gl := headers.Get("X-Gitlab-Event"); gl != "" {
return "gitlab." + gl
}
if xe := headers.Get("X-Event-Type"); xe != "" {
return xe
}
if obj, ok := body.(map[string]any); ok {
if e, ok := obj["event"].(string); ok && e != "" {
return e
}
if t, ok := obj["type"].(string); ok && t != "" {
return t
}
if a, ok := obj["action"].(string); ok && a != "" {
return a
}
}
return "webhook.received"
}
// stripBOM removes a leading UTF-8 byte-order-mark, which some clients
// (notably PowerShell-based scripts) prepend to JSON bodies.
func stripBOM(b []byte) []byte {
if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
return b[3:]
}
return b
}
// ── Dedupe + signature helpers ──────────────────────────────────────────────
// extractDedupeKey returns the provider-specific idempotency identifier from
// request headers, plus a short tag naming the header it came from. Returns
// ("", "") when no recognised header is present.
//
// github -> X-GitHub-Delivery
// generic -> Idempotency-Key
//
// Other providers fall back to the generic header to keep manual replays from
// Postman / curl behaving the same way regardless of trigger config.
func extractDedupeKey(provider string, headers http.Header) (string, string) {
if v := strings.TrimSpace(headers.Get("X-GitHub-Delivery")); v != "" && provider == "github" {
return v, "x-github-delivery"
}
if v := strings.TrimSpace(headers.Get("Idempotency-Key")); v != "" {
return v, "idempotency-key"
}
if v := strings.TrimSpace(headers.Get("X-GitHub-Delivery")); v != "" {
return v, "x-github-delivery"
}
return "", ""
}
// verifyWebhookSignatureForProvider returns one of sigStatus* describing the
// outcome of HMAC verification for the configured trigger.
//
// When no signing secret is configured the result is `not_required` — the
// trigger has opted into bearer-token-only authentication. When a secret IS
// configured the request must carry the expected header; otherwise the
// outcome is `missing` (caller still records a rejected delivery).
//
// github -> X-Hub-Signature-256: sha256=<hex>
// generic -> X-Hub-Signature-256 (same shape; lets curl/Postman opt in)
func verifyWebhookSignatureForProvider(provider, secret string, headers http.Header, rawBody []byte) string {
if secret == "" {
return sigStatusNotRequired
}
sig := headers.Get("X-Hub-Signature-256")
if sig == "" {
return sigStatusMissing
}
if !verifyHubSignature(secret, sig, rawBody) {
return sigStatusInvalid
}
_ = provider
return sigStatusValid
}
// verifyHubSignature implements the GitHub-compatible HMAC-SHA256 scheme:
// `X-Hub-Signature-256: sha256=<hex(hmac(body, secret))>`. The hmac.Equal
// comparison is constant-time so partial-prefix attacks cannot leak timing.
func verifyHubSignature(secret, header string, body []byte) bool {
const prefix = "sha256="
if !strings.HasPrefix(header, prefix) {
return false
}
want, err := hex.DecodeString(strings.TrimPrefix(header, prefix))
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
return hmac.Equal(mac.Sum(nil), want)
}
// selectedHeadersJSON returns the small, debugging-friendly subset of request
// headers we persist on a delivery row. Signature header is recorded as
// present/absent only — never the value, so a delivery dump cannot leak the
// HMAC of a sensitive body.
func selectedHeadersJSON(headers http.Header) []byte {
out := map[string]any{}
add := func(name string) {
if v := headers.Get(name); v != "" {
out[strings.ToLower(name)] = v
}
}
add("User-Agent")
add("X-GitHub-Event")
add("X-GitHub-Delivery")
add("X-Gitlab-Event")
add("X-Event-Type")
add("Idempotency-Key")
if v := headers.Get("X-Hub-Signature-256"); v != "" {
out["x-hub-signature-256-present"] = true
}
b, err := json.Marshal(out)
if err != nil {
return []byte("{}")
}
return b
}
// ── Public ingress ──────────────────────────────────────────────────────────
// HandleAutopilotWebhook is the public entry point for webhook-triggered
// autopilots. It runs OUTSIDE the authenticated route group: the bearer
// token in the URL path IS the credential.
//
// Flow (persist-first, sync-dispatch):
//
// 1. Per-IP rate limit (gate before any DB I/O).
// 2. Token lookup. ErrNoRows → 404; other DB errors → 500.
// 3. Per-token rate limit.
// 4. Read raw body (capped). Oversized → 413.
// 5. Normalize JSON envelope. Invalid → 400 (no persistence — there is no
// dedupe identifier we can trust from an unparsable body).
// 6. Extract dedupe key from headers per provider.
// 7. Verify signature (or `not_required` when no secret is configured).
// 8. INSERT webhook_delivery row (status=queued). On dedupe collision (23505
// against `(trigger_id, dedupe_key)`) treat as duplicate: bump
// attempt_count on the existing row and return its delivery_id +
// autopilot_run_id with 200.
// 9. If signature invalid/missing: UPDATE delivery → rejected, return 401.
// 10. If trigger disabled / autopilot paused / archived: UPDATE delivery →
// ignored, return 200.
// 11. Dispatch the autopilot synchronously. UPDATE delivery → dispatched
// (with autopilot_run_id) or failed. Return 200 (skipped runs surface
// their `reason`).
// 12. Bump last_fired_at after dispatch — even on the skipped path — so the
// trigger's "last seen" is accurate.
//
// Response shapes:
// - 200 {"status":"accepted", "delivery_id", "run_id", "autopilot_id", "trigger_id"}
// - 200 {"status":"skipped", "delivery_id", "run_id", "reason"}
// - 200 {"status":"ignored", "delivery_id", "reason"}
// - 200 {"status":"duplicate", "delivery_id", "run_id?"}
// - 400 {"error":"..."} — invalid JSON / scalar / empty
// - 401 {"status":"rejected", "delivery_id", "reason":"..."} — signature failure
// - 404 {"error":"webhook not found"} — unknown token
// - 413 {"error":"payload too large"} — body exceeded cap
// - 429 {"error":"rate limit exceeded"} — over per-IP/token budget
// - 500 {"error":"..."} — internal failure
func (h *Handler) HandleAutopilotWebhook(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
if token == "" {
writeError(w, http.StatusNotFound, "webhook not found")
return
}
// 1. Per-IP rate limit BEFORE we hit Postgres. Bounds the DB-probe blast
// radius for an attacker spraying random tokens. A spray of bad
// signatures still counts here — fast-path 429 stops budget burn.
if h.WebhookIPRateLimiter != nil {
if ip := h.clientIPForRateLimit(r); ip != "" {
if !h.WebhookIPRateLimiter.Allow(r.Context(), ip) {
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
}
}
// 2. Token lookup. Distinguish "no row" from "DB error": collapsing both
// to 404 means a transient DB blip silently drops real deliveries
// (providers like GitHub don't retry on 404). For no-row we still
// return a generic message so we don't leak which tokens existed.
trigRow, err := h.Queries.GetWebhookTriggerByToken(r.Context(), pgtype.Text{String: token, Valid: true})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "webhook not found")
return
}
slog.Error("webhook: token lookup failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
middleware.SetWebhookTriggerID(r, uuidToString(trigRow.ID))
// 3. Per-token rate limit.
if h.WebhookRateLimiter != nil {
if !h.WebhookRateLimiter.Allow(r.Context(), token) {
writeError(w, http.StatusTooManyRequests, "rate limit exceeded")
return
}
}
// 4. Body size cap + JSON validation. http.MaxBytesReader stops the read
// mid-stream once the cap is exceeded so an oversized payload is
// rejected before being fully buffered.
r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodyBytes)
body, err := io.ReadAll(r.Body)
if err != nil {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
writeError(w, http.StatusRequestEntityTooLarge, "payload too large")
return
}
writeError(w, http.StatusBadRequest, "failed to read request body")
return
}
// 5. Cross-check autopilot/workspace consistency BEFORE we persist the
// delivery — webhook_delivery.workspace_id is NOT NULL and a stale FK
// row would otherwise fail INSERT after we've already paid the body
// read. Same ErrNoRows-vs-DB-error split as token lookup.
autopilot, err := h.Queries.GetAutopilot(r.Context(), trigRow.AutopilotID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "webhook not found")
return
}
slog.Error("webhook: autopilot lookup failed",
"error", err,
"trigger_id", uuidToString(trigRow.ID),
)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
if uuidToString(autopilot.WorkspaceID) != uuidToString(trigRow.AutopilotWorkspaceID) {
slog.Warn("webhook: trigger workspace mismatch",
"trigger_id", uuidToString(trigRow.ID),
"autopilot_id", uuidToString(autopilot.ID),
)
writeError(w, http.StatusNotFound, "webhook not found")
return
}
// 6. Normalize body. Invalid JSON → 400 without persistence: we have no
// dedupe identifier from the body, and replaying an unparsable payload
// is not useful.
envelope, err := normalizeWebhookPayload(body, r.Header)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
envelopeBytes, err := json.Marshal(envelope)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to encode envelope")
return
}
// 7. Provider + dedupe + signature.
provider := trigRow.Provider
if provider == "" {
provider = "generic"
}
dedupeKey, dedupeSource := extractDedupeKey(provider, r.Header)
sigStatus := verifyWebhookSignatureForProvider(provider, trigRow.SigningSecret.String, r.Header, body)
// 8. Persist (INSERT delivery). Dedupe collision → bump existing row.
delivery, dup, err := h.persistInboundDelivery(r, persistDeliveryInput{
WorkspaceID: autopilot.WorkspaceID,
AutopilotID: autopilot.ID,
TriggerID: trigRow.ID,
Provider: provider,
Event: envelope.Event,
DedupeKey: dedupeKey,
DedupeSource: dedupeSource,
SignatureStatus: sigStatus,
ContentType: envelope.Request.ContentType,
RawBody: body,
SelectedHeaders: selectedHeadersJSON(r.Header),
})
if err != nil {
slog.Error("webhook: persist delivery failed",
"error", err,
"trigger_id", uuidToString(trigRow.ID),
)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
if dup {
// A previous delivery already covered this dedupe key. Return the
// original delivery_id + (possibly empty) run_id with 200 so the
// caller can correlate.
resp := map[string]any{
"status": "duplicate",
"delivery_id": uuidToString(delivery.ID),
}
if delivery.AutopilotRunID.Valid {
resp["run_id"] = uuidToString(delivery.AutopilotRunID)
}
writeJSON(w, http.StatusOK, resp)
return
}
// 9. Signature failure → rejected delivery + 401. No dispatch, no replay.
// Providers will look for 4xx feedback when their secret is wrong.
if sigStatus == sigStatusInvalid || sigStatus == sigStatusMissing {
reason := "invalid_signature"
if sigStatus == sigStatusMissing {
reason = "missing_signature"
}
respBody := map[string]any{
"status": "rejected",
"delivery_id": uuidToString(delivery.ID),
"reason": reason,
}
h.finaliseDeliveryTerminal(r, delivery.ID, deliveryStatusRejected, http.StatusUnauthorized, respBody, reason)
writeJSON(w, http.StatusUnauthorized, respBody)
return
}
// 10. Trigger disabled / autopilot paused / archived → ignored. We return
// 200 so the sender's webhook-retry machinery doesn't keep hammering
// us; the "ignored" status + delivery row makes the no-op visible if
// the operator inspects the delivery log.
if !trigRow.Enabled {
respBody := map[string]any{"status": "ignored", "delivery_id": uuidToString(delivery.ID), "reason": "trigger_disabled"}
h.finaliseDeliveryTerminal(r, delivery.ID, deliveryStatusIgnored, http.StatusOK, respBody, "trigger_disabled")
writeJSON(w, http.StatusOK, respBody)
return
}
if autopilot.Status == "archived" {
respBody := map[string]any{"status": "ignored", "delivery_id": uuidToString(delivery.ID), "reason": "autopilot_archived"}
h.finaliseDeliveryTerminal(r, delivery.ID, deliveryStatusIgnored, http.StatusOK, respBody, "autopilot_archived")
writeJSON(w, http.StatusOK, respBody)
return
}
if autopilot.Status != "active" {
respBody := map[string]any{"status": "ignored", "delivery_id": uuidToString(delivery.ID), "reason": "autopilot_paused"}
h.finaliseDeliveryTerminal(r, delivery.ID, deliveryStatusIgnored, http.StatusOK, respBody, "autopilot_paused")
writeJSON(w, http.StatusOK, respBody)
return
}
// 11. Event filter scope → ignored. If the trigger declares a concrete
// event_filters list and the incoming event is outside that scope,
// record an ignored delivery without creating an expensive run/task.
if !webhookEventAllowedByTriggerScope(trigRow.EventFilters, envelope) {
respBody := map[string]any{
"status": "ignored",
"delivery_id": uuidToString(delivery.ID),
"reason": "event_filtered",
"event": envelope.Event,
}
h.finaliseDeliveryTerminal(r, delivery.ID, deliveryStatusIgnored, http.StatusOK, respBody, "event_filtered")
writeJSON(w, http.StatusOK, respBody)
return
}
// 12. Dispatch synchronously. DispatchAutopilot publishes WS events,
// persists trigger_payload on autopilot_run, runs the admission
// check (offline runtime → skipped), and bumps last_run_at.
run, err := h.AutopilotService.DispatchAutopilot(
r.Context(),
autopilot,
trigRow.ID,
"webhook",
envelopeBytes,
)
if err != nil {
slog.Warn("webhook dispatch failed",
"trigger_id", uuidToString(trigRow.ID),
"autopilot_id", uuidToString(autopilot.ID),
"error", err,
)
respBody := map[string]any{"error": "failed to dispatch autopilot"}
// DispatchAutopilot may return a non-nil run alongside an error
// (e.g. when the run row was created but the downstream dispatch
// failed). Link the run on the delivery anyway so the Deliveries
// UI can show which run row corresponds to the failure.
if run != nil {
h.finaliseDeliveryWithRun(r, delivery.ID, deliveryStatusFailed, run.ID, http.StatusInternalServerError, respBody)
} else {
h.finaliseDeliveryTerminal(r, delivery.ID, deliveryStatusFailed, http.StatusInternalServerError, respBody, err.Error())
}
writeJSON(w, http.StatusInternalServerError, respBody)
return
}
// 13. Bump last_fired_at after dispatch returns — including the skipped
// path — so paused early-returns above don't corrupt "last fired".
if err := h.Queries.TouchAutopilotTriggerFiredAt(r.Context(), trigRow.ID); err != nil {
slog.Warn("webhook: failed to touch last_fired_at",
"trigger_id", uuidToString(trigRow.ID),
"error", err,
)
}
// 14. Persist the linkage delivery → run.
//
// The delivery row is always `dispatched` once we reach here: from the
// ingress's perspective we handed the payload off to the autopilot
// machinery and got a run id back. The autopilot may have skipped the
// run (e.g. runtime offline) — that's reflected in the response status
// + reason and in the linked run row, not in the delivery status. This
// keeps the delivery enum tight and the Deliveries UI unambiguous
// (`run.status` is the source of truth for what the run did).
respBody := map[string]any{
"status": "accepted",
"delivery_id": uuidToString(delivery.ID),
"run_id": uuidToString(run.ID),
"autopilot_id": uuidToString(autopilot.ID),
"trigger_id": uuidToString(trigRow.ID),
}
if run.Status == "skipped" {
respBody = map[string]any{
"status": "skipped",
"delivery_id": uuidToString(delivery.ID),
"run_id": uuidToString(run.ID),
}
if run.FailureReason.Valid {
respBody["reason"] = run.FailureReason.String
}
}
h.finaliseDeliveryWithRun(r, delivery.ID, deliveryStatusDispatched, run.ID, http.StatusOK, respBody)
writeJSON(w, http.StatusOK, respBody)
}
// ── Event filter helpers ────────────────────────────────────────────────────
// WebhookEventFilter declares one event and an optional list of actions.
// A nil/empty Actions means "any action" for this event.
type WebhookEventFilter struct {
Event string `json:"event"`
Actions []string `json:"actions,omitempty"`
}
// validateWebhookEventFilters enforces the contract at the HTTP boundary so
// that malformed shapes never reach the database. The matcher (read path)
// trusts whatever is stored — see webhookEventAllowedByTriggerScope.
func validateWebhookEventFilters(filters []WebhookEventFilter) error {
for i, f := range filters {
if strings.TrimSpace(f.Event) == "" {
return fmt.Errorf("event_filters[%d].event must not be empty", i)
}
for j, a := range f.Actions {
if strings.TrimSpace(a) == "" {
return fmt.Errorf("event_filters[%d].actions[%d] must not be empty", i, j)
}
}
}
return nil
}
// encodeWebhookEventFilters returns the JSONB bytes to persist for a CREATE.
// nil/empty input maps to nil bytes (column stays NULL → matcher allows
// every event), so we never write an explicit `[]` on create.
func encodeWebhookEventFilters(filters []WebhookEventFilter) ([]byte, error) {
if len(filters) == 0 {
return nil, nil
}
return json.Marshal(filters)
}
// encodeWebhookEventFiltersAlways always returns non-nil bytes, even for an
// empty slice (`[]byte("[]")`). The UPDATE handler uses this so an explicit
// empty array in the PATCH body can overwrite (via COALESCE) the existing
// row to a cleared state — passing nil would be indistinguishable from
// "field omitted, leave alone".
func encodeWebhookEventFiltersAlways(filters []WebhookEventFilter) ([]byte, error) {
if filters == nil {
filters = []WebhookEventFilter{}
}
return json.Marshal(filters)
}
// webhookEventAllowedByTriggerScope returns true when the trigger has no
// filters (NULL / empty) or when the incoming envelope matches at least one
// declared filter.
func webhookEventAllowedByTriggerScope(eventFilters []byte, envelope WebhookEnvelope) bool {
if len(eventFilters) == 0 {
return true
}
var filters []WebhookEventFilter
if err := json.Unmarshal(eventFilters, &filters); err != nil {
// Strict write-time validation should prevent malformed bytes
// from ever reaching this branch. If a corrupt row somehow
// exists, fail closed — silently widening the allowlist on a
// "only allow X" policy is worse than dropping events until an
// operator notices.
slog.Warn("webhook: malformed event_filters, denying", "error", err)
return false
}
if len(filters) == 0 {
return true
}
_, eventName, eventAction := splitWebhookEvent(envelope.Event)
actionCandidates := webhookActionCandidates(eventAction, envelope.EventPayload)
for _, f := range filters {
if f.Event != eventName {
continue
}
if len(f.Actions) == 0 {
return true
}
for _, action := range actionCandidates {
for _, allowed := range f.Actions {
if action == allowed {
return true
}
}
}
// Intentionally do NOT return false here: the UI allows several
// filters that share the same event name (e.g. two workflow_run
// rows covering disjoint actions). Earlier code short-circuited
// on the first event-name hit, which made one row silently shadow
// the others depending on iteration order — see PR #3231 review.
// Keep scanning so any later filter still gets its chance.
}
return false
}
// splitWebhookEvent splits a normalized event like "github.workflow_run.completed"
// into (provider, eventName, action). For unqualified events it returns ("", event, "").
func splitWebhookEvent(event string) (provider, name, action string) {
parts := strings.Split(event, ".")
if isKnownProvider(parts[0]) {
if len(parts) >= 3 {
return parts[0], parts[1], strings.Join(parts[2:], ".")
}
if len(parts) == 2 {
return parts[0], parts[1], ""
}
return parts[0], "", ""
}
if len(parts) >= 2 {
return "", parts[0], strings.Join(parts[1:], ".")
}
return "", event, ""
}
func isKnownProvider(prefix string) bool {
switch prefix {
case "github", "gitlab", "bitbucket", "gitea":
return true
}
return false
}
// webhookActionCandidates extracts possible action values from the event
// action suffix and from well-known payload fields.
func webhookActionCandidates(eventAction string, payload json.RawMessage) []string {
seen := map[string]struct{}{}
add := func(v string) {
v = strings.TrimSpace(v)
if v == "" {
return
}
seen[v] = struct{}{}
}
add(eventAction)
var obj map[string]any
if err := json.Unmarshal(payload, &obj); err == nil {
for _, key := range []string{"action", "state", "conclusion", "status"} {
if v, ok := obj[key].(string); ok {
add(v)
}
}
}
out := make([]string, 0, len(seen))
for v := range seen {
out = append(out, v)
}
return out
}
// ── Persistence helpers ─────────────────────────────────────────────────────
type persistDeliveryInput struct {
WorkspaceID pgtype.UUID
AutopilotID pgtype.UUID
TriggerID pgtype.UUID
Provider string
Event string
DedupeKey string
DedupeSource string
SignatureStatus string
ContentType string
RawBody []byte
SelectedHeaders []byte
}
// persistInboundDelivery INSERTs a fresh `queued` delivery, returning (row,
// false, nil) on the happy path. On dedupe-key unique-violation it returns
// (existing-row, true, nil) after bumping attempt_count on the prior row.
// Any other error bubbles up so the handler can 500 cleanly.
func (h *Handler) persistInboundDelivery(r *http.Request, in persistDeliveryInput) (db.WebhookDelivery, bool, error) {
params := db.CreateWebhookDeliveryParams{
WorkspaceID: in.WorkspaceID,
AutopilotID: in.AutopilotID,
TriggerID: in.TriggerID,
Provider: in.Provider,
Event: in.Event,
SignatureStatus: in.SignatureStatus,
Status: deliveryStatusQueued,
SelectedHeaders: in.SelectedHeaders,
RawBody: in.RawBody,
}
if in.DedupeKey != "" {
params.DedupeKey = pgtype.Text{String: in.DedupeKey, Valid: true}
params.DedupeSource = pgtype.Text{String: in.DedupeSource, Valid: true}
}
if in.ContentType != "" {
params.ContentType = pgtype.Text{String: in.ContentType, Valid: true}
}
delivery, err := h.Queries.CreateWebhookDelivery(r.Context(), params)
if err == nil {
return delivery, false, nil
}
if !isUniqueViolation(err) || in.DedupeKey == "" {
return db.WebhookDelivery{}, false, err
}
// Dedupe collision: fetch the original row, bump attempt count.
existing, lookupErr := h.Queries.GetWebhookDeliveryByTriggerAndDedupe(r.Context(), db.GetWebhookDeliveryByTriggerAndDedupeParams{
TriggerID: in.TriggerID,
DedupeKey: pgtype.Text{String: in.DedupeKey, Valid: true},
})
if lookupErr != nil {
return db.WebhookDelivery{}, false, fmt.Errorf("lookup duplicate delivery: %w", lookupErr)
}
bumped, bumpErr := h.Queries.BumpWebhookDeliveryAttempt(r.Context(), existing.ID)
if bumpErr != nil {
// Still treat as duplicate; just log the bump failure so the
// operator can investigate, returning the row we DID read.
slog.Warn("webhook: failed to bump attempt_count",
"delivery_id", uuidToString(existing.ID),
"error", bumpErr,
)
return existing, true, nil
}
return bumped, true, nil
}
// finaliseDeliveryTerminal records a non-dispatched outcome (rejected,
// ignored, failed). HTTP status and full response body are captured so a
// future Deliveries UI can show exactly what we returned.
func (h *Handler) finaliseDeliveryTerminal(
r *http.Request,
id pgtype.UUID,
status string,
httpStatus int,
responseBody any,
errMsg string,
) {
bodyJSON, _ := json.Marshal(responseBody)
params := db.UpdateWebhookDeliveryTerminalParams{
ID: id,
Status: status,
ResponseStatus: pgtype.Int4{Int32: int32(httpStatus), Valid: true},
ResponseBody: pgtype.Text{String: string(bodyJSON), Valid: true},
}
if errMsg != "" {
params.Error = pgtype.Text{String: errMsg, Valid: true}
}
if _, err := h.Queries.UpdateWebhookDeliveryTerminal(r.Context(), params); err != nil {
slog.Warn("webhook: finalise terminal failed",
"delivery_id", uuidToString(id),
"status", status,
"error", err,
)
}
}
// finaliseDeliveryWithRun records a delivery that produced (or was admission-
// skipped to) an autopilot_run. Same response-capture as the terminal path.
func (h *Handler) finaliseDeliveryWithRun(
r *http.Request,
id pgtype.UUID,
status string,
runID pgtype.UUID,
httpStatus int,
responseBody any,
) {
bodyJSON, _ := json.Marshal(responseBody)
params := db.UpdateWebhookDeliveryDispatchedParams{
ID: id,
Status: status,
AutopilotRunID: runID,
ResponseStatus: pgtype.Int4{Int32: int32(httpStatus), Valid: true},
ResponseBody: pgtype.Text{String: string(bodyJSON), Valid: true},
}
if _, err := h.Queries.UpdateWebhookDeliveryDispatched(r.Context(), params); err != nil {
slog.Warn("webhook: finalise with run failed",
"delivery_id", uuidToString(id),
"run_id", uuidToString(runID),
"error", err,
)
}
}
// ── Rate-limit / IP plumbing ────────────────────────────────────────────────
// clientIPForRateLimit returns the IP used as a rate-limit bucket key.
//
// Default behaviour: use the host portion of r.RemoteAddr. Forwarded
// headers (X-Forwarded-For, X-Real-IP) are IGNORED unless the operator
// has explicitly opted in via MULTICA_TRUSTED_PROXIES — and even then
// only when r.RemoteAddr is itself inside one of the listed CIDRs.
func (h *Handler) clientIPForRateLimit(r *http.Request) string {
remoteIP := remoteAddrHost(r.RemoteAddr)
if len(h.cfg.TrustedProxies) == 0 {
return remoteIP
}
remoteAddr, ok := parseNetIPAddr(remoteIP)
if !ok || !addrInPrefixes(remoteAddr, h.cfg.TrustedProxies) {
// Source isn't a trusted proxy — headers can't be believed.
return remoteIP
}
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i >= 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return strings.TrimSpace(xri)
}
return remoteIP
}
func remoteAddrHost(remote string) string {
if remote == "" {
return ""
}
if strings.HasPrefix(remote, "[") {
if end := strings.IndexByte(remote, ']'); end > 0 {
return remote[1:end]
}
}
if i := strings.LastIndexByte(remote, ':'); i >= 0 && !strings.Contains(remote, "]") {
if strings.Count(remote, ":") == 1 {
return remote[:i]
}
}
return remote
}
func parseNetIPAddr(s string) (netip.Addr, bool) {
if s == "" {
return netip.Addr{}, false
}
addr, err := netip.ParseAddr(s)
if err != nil {
return netip.Addr{}, false
}
return addr.Unmap(), true
}
func addrInPrefixes(addr netip.Addr, prefixes []netip.Prefix) bool {
for _, p := range prefixes {
if p.Contains(addr) {
return true
}
}
return false
}