Add Duo flow webhook callback delivery
What does this MR do and why?
Adds the machinery that delivers Duo flow lifecycle events to a webhook: a messaging adapter that builds the payload and a Sidekiq worker that sends it.
Nothing constructs the adapter yet — the create-flow wiring that persists a callback context lands in !249147 (merged) — so this ships inert.
Delivery reuses the existing webhook stack (WebHookService), so HMAC signing, SSRF protection and the delivery log come for free.
Why a dedicated worker rather than WebHook#async_execute: the standard async path swallows HTTP failures and never redelivers. That is fine for event-stream webhooks, which fire again on the next event, but a dropped flow.completed leaves a client waiting forever. So WebHookService runs here and raises on a non-success response, which gets us Sidekiq retries with backoff.
event_id is a UUIDv5 over (workflow, event, hook). It is stable across retries, so clients can dedupe on it, and distinct per hook when two hooks watch the same flow. A failure that carries no workflow — the sync-failure fallback — gets a random id instead, so two unrelated failures are not deduped into one.
What is checked when
Eligibility is settled when a flow subscribes, not on every delivery:
| When | What is checked |
|---|---|
| Trigger, in !249147 (merged) | the caller may run a flow in the container; the duo_flow_callback_hooks flag; the hook has duo_flow_callback_enabled; the hook belongs to the project or one of its ancestor groups; group_webhooks licensing for group hooks |
| Every delivery, here | the hook still exists, still has duo_flow_callback_enabled, and is a project or group hook |
So unticking the checkbox, or deleting the hook, stops a running flow's callbacks — including a delivery already queued or being retried, because the worker reads the column on the row it loads anyway. Flipping the feature flag only affects flows that start afterwards; this path does not read the flag at all.
Known limitation: containment is not re-checked per delivery. If a project is transferred to another namespace mid-flow, an ancestor group hook from the old hierarchy keeps receiving that flow's remaining events. Mid-flow transfers are rare and flows are short-lived, so this is accepted for the experimental release rather than paying for a containment check on every event.
Payload
{
"object_kind": "duo_workflow",
"version": "1",
"event": "flow.completed",
"event_id": "3f2b…",
"client_reference": "run-abc123",
"project": { "...Project#hook_attrs..." },
"user": { "...User#hook_attrs..." },
"workflow": { "id": 2722, "status": "finished", "web_url": "…" },
"result": { "message": "Done!" }
}result on flow.completed, error ({reason, message}) on flow.failed. project and user use hook_attrs, so they match every other GitLab webhook. Changes to the payload are additive only; flow.progress is added in !251157.
Where this fits
Part of Duo flow webhook callbacks — letting an external client subscribe to a Duo flow's lifecycle over a normal webhook instead of polling.
The duo_flow_callback_enabled column landed in !249141 (merged). Two MRs branch off this one: progress (!251157) and the create-flow wiring (!249147 (merged)). The hooks API plus settings checkbox (!249149 (merged)) is stacked on the wiring MR, because that is where the feature flag is defined. The feature is usable once the wiring and the API land.
References
- Issue: #606804 (closed)
- Feature flag: defined in !249147 (merged), rollout issue #608649
- Reference implementation this was split from (closed PoC): !246849 (closed)
How to set up and validate locally
Not exercisable end to end yet — no code path sets a callback context until !249147 (merged). To sanity-check delivery in a console:
group = Group.find_by_full_path('my-group')
hook = GroupHook.create!(group: group, url: 'https://example.com/callback',
duo_flow_callback_enabled: true, filter: {})
Ai::Messaging::WebhookDeliveryWorker.new.perform(
hook.id, { 'event' => 'flow.started', 'event_id' => SecureRandom.uuid }
)Then untick the capability flag and confirm nothing is delivered:
hook.update!(duo_flow_callback_enabled: false)
Ai::Messaging::WebhookDeliveryWorker.new.perform(
hook.id, { 'event' => 'flow.started', 'event_id' => SecureRandom.uuid }
)MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.