Skip to content

Add webhook subscriptions with allow-listed URLs, a secret shown once and the admin page - #2608

Merged
pournasserian merged 5 commits into
v2from
2567-webhook-subscriptions
Oct 19, 2026
Merged

pournasserian merged 5 commits into
v2from
2567-webhook-subscriptions

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Closes #2567

What

The first slice of F-WHK (#2431): webhook subscriptions with allow-listed URLs and a signing secret shown once, plus the admin page that adds, lists and disables them (API-05, SEC-05, SEC-07).

  • Core (FluentCMS.Core.Webhooks):
    • IWebhookDirectory: ListAsync, AddAsync(NewWebhookSubscription) and DisableAsync(id), each returning a Result. Only a signed-in person may call them. An agent, an engine or no one gets webhook_people_only (SEC-02).
    • WebhookSubscription has a URL, its events (change_set.published, change_set.reverted), when and by whom it was added, and when it was disabled. It is never edited; it is disabled and added again.
    • URL rules, checked when it is added:
      • absolute and https, or http to a loopback host for a local endpoint;
      • no user name or password;
      • at most 2,048 characters;
      • its host is on the outbound allow-list.
        Each failed rule returns its own code with a message that says what to change.
    • WebhookSecrets.Generate(): 32 random bytes in the Standard Webhooks form, whsec_ and base64.
  • ISecretProtector (FluentCMS.Core.Security): Protect and Unprotect under a purpose. The host implements it with its Data Protection key ring, which both the web and stdio hosts share.
  • Infrastructure:
    • A WebhookSubscriptions table, with migrations for both providers. The secret is stored only as ciphertext.
    • webhook.added and webhook.disabled are appended in the write's unit of work. They name the subscription and never its secret.
    • Of two concurrent disables, the second changes nothing.
  • Admin: /webhooks, with a link in the header.
    • It lists each subscription's URL, events, who added it and its state.
    • A form adds one and shows the secret once, in the response to the post.
    • Disabling is a confirmed action in a dialog.
  • Tests: the log capture and the "does the database store this text" scan move from the agent credential scenario to shared test helpers, so the webhook tests reuse them.
  • CONTRIBUTING.md: a Webhooks section on the URL rules, the allow-list, the secret and the audit events.

Why

API-05 asks for an outbound webhook on publish and revert, and SEC-05 for outbound access only to hosts the administrator allow-lists. Delivery needs somewhere to send and a secret to sign with, so subscriptions come first.

Choices worth a look:

  • One allow-list. The issue first named a separate Webhooks__AllowedHosts with *.example.com entries. The model gateway already has Outbound__AllowedHosts behind IOutboundHosts, where a host also allows its subdomains, so webhooks use that one. An administrator manages one list for SEC-05. I updated the issue to match.
  • The secret is encrypted, not hashed, unlike an agent key. Delivery has to read it back to sign each request. It is protected under its own purpose, FluentCMS.Webhooks.SigningSecret, so no other protected value opens it. Losing the key ring loses the secrets as it loses sign-ins, and CONTRIBUTING says so.
  • The Standard Webhooks secret form, so a receiver can use an existing verifier. The signature itself comes with delivery.
  • Listing is people-only too, since a URL can reveal internal infrastructure. Delivery will read subscriptions through its own query, as the system.
  • Not in this PR: delivery, the signature, the payload, retries, the delivery log and a test send (next slice), other events, and editing a subscription.
  • Known, shared with /agents (Admin revoke and disable: a repeated post shows a blank page, and a lost race fails inside the unit of work #2610): a second disable post for the same webhook shows a blank page rather than "already disabled", and a disable that loses a race reports it inside its unit of work.

Tests

Integration, each on SQLite and PostgreSQL:

  • A person adds a subscription. The result carries the secret once, the list shows the subscription without it, and webhook.added names the subscription and the person.
    • The stored value is ciphertext that unprotects to the secret.
    • No table holds the secret's text, and no log line contains it, while the logs do show the insert.
  • With example.com and localhost:5005 allowed, example.com, a deeper subdomain and http://localhost:5005 are accepted.
  • These are refused with their codes, and nothing is stored: example.com.evil.test, another host, plain http on a public host, another local port, a user name and password, a URL without a scheme, ftp, a URL over 2,048 characters, no events and an unknown event. A URL that breaks several rules reports each.
  • Disabling marks the subscription disabled with its time and appends webhook.disabled. A second disable and an unknown id are refused.
  • An agent, the check engine and no initiator can't list, add or disable.
  • The migration's Down drops the table and Up adds it again.

Integration, admin page:

  • /webhooks sends an anonymous request to sign-in.
  • A signed-in administrator adds a webhook. The response shows the secret once, no later page shows it, and the stored row unprotects to it with the host's own key ring.
  • A URL off the allow-list and no events are reported next to their fields, and nothing is added.
  • Disabling from the list confirms it and shows the webhook as disabled.

The full suite ran locally against a postgres:17 container as well as SQLite, and markdownlint passes. No new package and no new project reference.

Checklist

  • Linked to an issue a maintainer has confirmed
  • Targets v2
  • Tests or docs updated where behaviour changed
  • No tool-generated co-author trailers or footers

@pournasserian
pournasserian merged commit 9c9f176 into v2 Oct 19, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2567-webhook-subscriptions branch October 19, 2026 08:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:api Delivery API (API) type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Webhook subscriptions: allow-listed URLs, a signing secret shown once, and the admin page

1 participant