Skip to content

feat(backend): add webhook trigger#30

Open
LukasHirt wants to merge 2 commits into
mainfrom
feat/webhook-trigger
Open

feat(backend): add webhook trigger#30
LukasHirt wants to merge 2 commits into
mainfrom
feat/webhook-trigger

Conversation

@LukasHirt

Copy link
Copy Markdown
Collaborator

Summary

Implements the "Webhook Trigger" proposal end to end: a trigger, alongside manual/schedule/event, that fires when an external HTTP request hits a per-workflow URL — POST /hooks/{workflowId}/{token} — so something outside oCIS's own event bus (a CI pipeline, a form submission, another SaaS's outgoing webhook) can kick off a workflow without inventing new oCIS event types.

  • Body → vars: the request body is exposed as vars["webhook.body"] (raw string), plus flattened vars["webhook.body.<key>"] for each top-level key when it parses as a JSON object. Non-JSON/malformed/array/empty bodies still fire the trigger with just the raw string — no crash, no flattening.
  • Resource path: stays empty unless the caller passes ?path=/foo/bar.txt explicitly.
  • Token lifecycle: generated server-side on first save of a webhook trigger, preserved across ordinary edits/enable-disable toggles (only an explicit "rotate" replaces it), and dropped if the trigger type changes away from webhook.
  • Auth reuse: running a webhook-triggered workflow reuses the exact "Basic " + base64(automation.Username + ":" + automation.AppPassword) construction the cron scheduler and SSE consumer manager already build for running without a live user session — no new "run headlessly" auth path was invented.

Security model (the part the original proposal flagged as the open risk)

POST /hooks/{workflowId}/{token} is deliberately mounted outside the /api/v1beta1 route group and its Validator.Middleware bearer-token gate — an external caller has no oCIS session to present a bearer token from, so the token embedded in the URL is the credential. Compensating controls:

  1. Constant-time comparison: the caller-supplied token is checked with crypto/subtle.ConstantTimeCompare, never ==.
  2. No oracle: an unknown workflow id, a non-webhook trigger, a not-yet-generated token, and a wrong token all return the identical 401 unauthenticated — a prober can't distinguish "wrong token" from "wrong/invalid workflow id".
  3. Rate limiting: a new pkg/ratelimit in-memory fixed-window limiter (30 req/token/minute by default) caps abuse of a single token, checked before any DB/oCIS API work.
  4. Secret hygiene: the token is stored encrypted at rest via the same secretbox mechanism as Automation.AppPassword (localdb.TriggerIndexEntry.WebhookToken), and is never included in the normal workflow GET/List/Patch responses — only readable via new, ordinary-bearer-auth-gated GET/POST /me/workflows/{id}/webhook-token(/rotate) endpoints (the NDV's "reveal"/"rotate" actions).
  5. No enabled-state leak: a disabled workflow still responds 202 for a valid token (just doesn't run), so a valid-token caller can't distinguish "ran" from "currently disabled".

Frontend

  • New trigger-webhook entry in nodeTypes.ts under TRIGGER_CATEGORY.
  • NDV: selecting the Webhook trigger type shows a "save first" hint until the workflow has an id (same constraint the manual run/executions actions already have), then shows a masked URL with Reveal/Copy/Rotate actions — mirroring the connect/disconnect interaction style of the existing automation affordance in WorkflowList.vue.
  • useWorkflowsApi gains getWebhookToken/rotateWebhookToken, combining the backend's returned path with the frontend's own non-/api/v1beta1 base URL (the webhook route lives outside it).

Other

docker-compose.yml's Traefik rule for workflows-backend is widened to also match /workflows/hooks, so the new public route is actually reachable in the local dev stack.

Test plan

Backend (cd backend && go test ./..., all green):

  • pkg/service/hooks_test.go — wrong/missing/mismatched token → 401 (no run); unknown workflow id → 401 (no oracle); non-webhook trigger entry → 401; valid token → 202 + run with flattened JSON vars + execution record stored; ?path= honored, empty by default; non-JSON/empty/JSON-array bodies → 202, raw string only, no flattening, no crash; disabled workflow → 202, no run; owner with no automation connected → 503, no run; rate limiting → 202×N then 429, independent budgets per token.
  • pkg/service/workflows_test.go — webhook token generated on first save, preserved across updates and across enable/disable toggles, index entry removed when trigger type changes away from webhook; pre-existing schedule-trigger disable-deletes-entry behavior unchanged.
  • pkg/executor/executor_test.go — new RunWithVars seeds extra vars before execution (webhook body used in a template), and behaves like Run when extraVars is nil.
  • pkg/ratelimit/ratelimit_test.go — allows up to max then blocks, independent per-key budgets, resets after the window elapses.
  • pkg/localdb/localdb_test.go — webhook token round-trips encrypted at rest (raw column isn't plaintext), rotation replaces rather than appends, GetTriggerIndexEntry not-found case, non-webhook entries with an empty webhook_token don't break decryption (migration-safety regression test), NewWebhookToken uniqueness.
  • pkg/server/http/server_test.go (new) — /hooks/... reaches HooksHandler with no Authorization header and rejects a wrong token there (proving it bypasses Validator.Middleware), while /api/v1beta1/... still 401s without a bearer token.
  • go vet ./..., gofmt -l . clean.

Frontend (npm run test:unit, npm run check:types, npm run lint, all green):

  • tests/unit/nodeTypes.spec.ts (new) — trigger-webhook registered under TRIGGER_CATEGORY, resolvable by id and by existing-node lookup.
  • tests/unit/useWorkflowsApi.spec.tsgetWebhookToken/rotateWebhookToken hit the right endpoints and build the full URL from the returned path.
  • tests/unit/NodeDetailsPanel.spec.ts (new) — shows a "save first" hint when unsaved; fetches and masks the URL once saved; Reveal shows it; Copy copies without forcing reveal; Rotate replaces the displayed value.

Not covered by automated tests (documented tradeoff): the new WorkflowsHandler.WebhookToken/RotateWebhookToken HTTP handlers aren't unit-tested at the HTTP layer, since WorkflowsHandler.store is a concrete *webdavstore.Store (no interface seam) rather than mockable — consistent with every other existing WorkflowsHandler method, none of which have unit tests either (only e2e coverage against a real stack). The security-critical logic they depend on (syncTriggerIndex's token generation/preservation) is fully unit-tested directly.

@LukasHirt
LukasHirt requested a review from a team as a code owner July 24, 2026 16:33
@LukasHirt LukasHirt self-assigned this Jul 24, 2026
Add a webhook trigger alongside manual/schedule/event: an external
caller (a CI pipeline, a form submission, another SaaS's outgoing
webhook) can now kick off a workflow via
POST /hooks/{workflowId}/{token}, without inventing new oCIS event
types. The request body is exposed to the graph as
vars["webhook.body"] (raw string), plus flattened
vars["webhook.body.<key>"] when it parses as a JSON object; a
malformed or non-object body degrades gracefully instead of failing
the run. The resource path stays empty unless the caller passes
?path=.

The webhook token is generated server-side (crypto/rand, 32 bytes),
stored via the same secretbox encryption already used for automation
app-passwords (localdb.TriggerIndexEntry gains a WebhookToken
column), and never included in ordinary workflow GET/List/Patch
responses — it's only readable through new, bearer-token-guarded
GET/POST /me/workflows/{id}/webhook-token(/rotate) endpoints. Running
a webhook-triggered workflow reuses the exact same
"Basic "+base64(automation.Username+":"+automation.AppPassword) auth
construction the cron scheduler and SSE consumer manager already use
for running without a live user session — no new auth plumbing.

Because POST /hooks/{workflowId}/{token} is intentionally mounted
outside the /api/v1beta1 route group (and its Validator.Middleware
bearer-token gate), the token itself is the only credential: it's
checked with crypto/subtle.ConstantTimeCompare rather than ==, unknown
workflow ids and non-webhook trigger types are rejected with the same
401 as a wrong token (no oracle for probing valid ids), and a new
pkg/ratelimit fixed-window limiter caps requests per token as a
compensating control against flooding/guessing.

Tests cover: wrong/missing/mismatched token rejection, a valid token
running with flattened JSON vars, non-JSON/empty/array bodies firing
without flattening or crashing, a disabled workflow not running,
missing automation failing gracefully, rate limiting kicking in after
N requests, webhook token generation/preservation/rotation semantics
in the trigger index sync, and that the /hooks route reaches its
handler without a bearer token while /api/v1beta1 routes still
require one.

docker-compose.yml's Traefik rule for workflows-backend is widened to
also match /workflows/hooks, matching the new public route.

Signed-off-by: Lukas Hirt <info@hirt.cz>
Add a "Webhook Trigger" entry (trigger-webhook) under the Triggers
category in nodeTypes.ts, matching the new backend trigger type.

The NDV's trigger type select gains a Webhook option. Once a workflow
has been saved (same "need a saved workflow id first" constraint the
manual run/executions actions already have), selecting it fetches and
displays the generated webhook URL, masked by default with a Reveal
toggle, a one-click Copy-to-clipboard action, and a Rotate action that
replaces the token — mirroring the connect/disconnect interaction
style of the existing automation affordance in WorkflowList.vue.
Before saving, it shows a hint to save first instead.

useWorkflowsApi gains getWebhookToken/rotateWebhookToken, which call
the new GET/POST /me/workflows/{id}/webhook-token(/rotate) endpoints
and combine the returned path with the backend's own non-API base URL
(stripping the /api/v1beta1 suffix) to build the full webhook URL,
since the webhook route itself lives outside /api/v1beta1.

Signed-off-by: Lukas Hirt <info@hirt.cz>
@LukasHirt
LukasHirt force-pushed the feat/webhook-trigger branch from 9b2862b to d9c6196 Compare July 24, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant