Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ Every model call goes through `IModelGateway` in `FluentCMS.Core.Models` (ADR-00
- **Records.** Every call is kept in `ModelCalls` with its provider, model and version, verbatim prompt and response, tokens, cost and outcome (MDL-04, CHK-05). Audit events carry its prompt hash, and `IModelCallLog.FindByPromptHashAsync` finds the prompt again. A provider's error stays in the record; the caller gets the record's id. A call is made outside a unit of work, so the database is not held while a provider answers, and making one inside one throws.
- **Retention.** Prompts and responses are kept for `Models__PromptRetention`, 90 days by default. A person runs `IModelCallLog.PurgeAsync`, which clears the prompt, response and error of older calls, keeps the rest of each record, and appends a `model_call.purged` event for each call (AUD-04, AUD-N2).

## Webhooks

An administrator adds a webhook on `/webhooks` with the URL of an endpoint and the events it is told about, `change_set.published` and `change_set.reverted` (API-05). Only a person lists, adds or disables one; an agent is refused (SEC-02).

- **URL.** It is https, or http to an endpoint on the same machine, and its host is on `Outbound__AllowedHosts`, the allow-list the model gateway uses. Nothing can be added until an administrator names a host (SEC-05). A URL with a user name or password in it is refused. A URL is never edited: disable the webhook and add a new one.
- **Secret.** Each webhook gets a signing secret, 32 random bytes written as `whsec_` and base64, shown once in the response that adds it. It is stored encrypted with the Data Protection key ring, so losing `DataProtection__KeysDirectory` loses the secrets too. It never appears in a list, a log or the audit log (SEC-07).
- **Audit.** Adding and disabling append `webhook.added` and `webhook.disabled`, which name the subscription.

Deliveries, their signature and how a receiver checks it come with the next slice.

## Extension points

Every pluggable part of the product is an interface in `FluentCMS.Core` marked `[ExtensionPoint]`. Implementations register through dependency injection, either explicitly with `AddFluentCmsExtension<TContract, TImplementation>()` or by discovery with `AddFluentCmsExtensions(assembly)`, which picks up every concrete class implementing an extension-point interface in that assembly. Both paths produce the same `IExtensionCatalog` entry and the same `IEnumerable<TContract>` resolution, so built-ins under `FluentCMS.Core.Builtin` and future plugins look identical to the rest of the product. Each interface is documented where it is defined, together with the conformance tests an implementation must pass.
Expand Down
1 change: 1 addition & 0 deletions src/FluentCMS.Admin/Components/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<a href="audit" class="text-sm text-muted-foreground hover:text-foreground">@L["Audit log"]</a>
<a href="users" class="text-sm text-muted-foreground hover:text-foreground">@L["Users"]</a>
<a href="agents" class="text-sm text-muted-foreground hover:text-foreground">@L["Agents"]</a>
<a href="webhooks" class="text-sm text-muted-foreground hover:text-foreground">@L["Webhooks"]</a>
@* A plain form, so it posts to the sign-out page's handler from any page, static or interactive. *@
<form method="post" action="sign-out" class="ms-auto flex items-center gap-3">
<AntiforgeryToken />
Expand Down
197 changes: 197 additions & 0 deletions src/FluentCMS.Admin/Components/Pages/Webhooks.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
@page "/webhooks"
@using System.Globalization
@using FluentCMS.Admin.Components.Webhooks
@using FluentCMS.Core.Webhooks
@inject IWebhookDirectory Directory
@inject IStringLocalizer<Webhooks> L

<PageTitle>@L["Webhooks"]</PageTitle>

<div>
<h1 id="webhooks-heading" class="text-2xl font-semibold tracking-tight">@L["Webhooks"]</h1>
<p class="mt-1 text-sm text-muted-foreground">@L["Each endpoint is told when a change set is published or reverted. Its host must be on the outbound allow-list."]</p>
</div>

@if (_message is not null)
{
<p role="status" class="mt-4 rounded-md border bg-muted/50 px-3 py-2 text-sm" data-testid="webhooks-message">@_message</p>
}

@* Shown in the response to the post that added it and nowhere else, so the secret never sits in a URL, a redirect or the
browser's history, and no later page can show it again. *@
@if (_added is { } added)
{
<Card Class="mt-6 max-w-xl">
<CardHeader>
<CardTitle><h2>@L["Signing secret for {0}", added.Subscription.Url]</h2></CardTitle>
<CardDescription>@L["Copy the secret now and store it with the endpoint, which checks each delivery's signature with it. It is stored encrypted and cannot be shown again."]</CardDescription>
</CardHeader>
<CardContent>
<div role="status" data-testid="webhook-secret">
<FormField Label="@L["Secret"]">
<Input value="@added.Secret" readonly dir="ltr" autocomplete="off" spellcheck="false" Class="font-mono" />
</FormField>
</div>
</CardContent>
</Card>
}

<Card Class="mt-6 max-w-xl">
<CardHeader>
<CardTitle><h2>@L["Add webhook"]</h2></CardTitle>
</CardHeader>
<CardContent>
<form method="post" @onsubmit="AddAsync" @formname="add-webhook" class="grid gap-6">
<AntiforgeryToken />
@if (_errors.Form is not null)
{
<p role="alert" class="text-sm text-destructive">@_errors.Form</p>
}
<FormField Label="@L["URL"]" Description="@L["The endpoint's full https address, such as https://hooks.example.com/fluentcms."]" Error="@_errors.For(nameof(NewWebhookForm.Url))">
<Input Type="url" name="Form.Url" value="@Form.Url" autocomplete="off" dir="ltr" required />
</FormField>
<fieldset class="grid gap-3" aria-describedby="@(_errors.For(NewWebhookErrors.Events) is null ? null : "events-error")">
<legend class="mb-2 text-sm leading-none font-medium">@L["Events"]</legend>
<div class="flex flex-wrap gap-x-6 gap-y-3">
<label class="flex items-center gap-2 text-sm">
<Checkbox name="Form.Published" value="true" checked="@Form.Published" />
@L["A change set is published"]
</label>
<label class="flex items-center gap-2 text-sm">
<Checkbox name="Form.Reverted" value="true" checked="@Form.Reverted" />
@L["A change set is reverted"]
</label>
</div>
@if (_errors.For(NewWebhookErrors.Events) is { } eventsError)
{
<p id="events-error" class="text-sm text-destructive">@eventsError</p>
}
</fieldset>
<div>
<Button Type="submit">@L["Add webhook"]</Button>
</div>
</form>
</CardContent>
</Card>

<div class="mt-6 rounded-md border">
<Table aria-labelledby="webhooks-heading">
<TableHeader>
<TableRow>
<TableHead>@L["URL"]</TableHead>
<TableHead>@L["Events"]</TableHead>
<TableHead>@L["Added"]</TableHead>
<TableHead>@L["Status"]</TableHead>
<TableHead Class="text-end"><span class="sr-only">@L["Actions"]</span></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@if (_subscriptions.Count == 0)
{
<TableRow>
<TableCell colspan="5" Class="text-muted-foreground">@L["No webhook is set up yet."]</TableCell>
</TableRow>
}
@foreach (var subscription in _subscriptions)
{
<TableRow data-webhook="@subscription.Id.ToString("N")">
<TableCell Class="font-medium break-all"><span dir="ltr">@subscription.Url</span></TableCell>
<TableCell>@string.Join(", ", subscription.Events.Select(name => L[name].Value))</TableCell>
<TableCell>
<time datetime="@subscription.AddedAt.ToString("O", CultureInfo.InvariantCulture)" dir="ltr">@subscription.AddedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture) UTC</time>
<span class="text-muted-foreground">@L["by {0}", subscription.AddedBy.DisplayName]</span>
</TableCell>
<TableCell>
@if (subscription.Enabled)
{
<Badge Variant="secondary" data-status="enabled">@L["Enabled"]</Badge>
}
else
{
<Badge Variant="outline" data-status="disabled">@L["Disabled"]</Badge>
}
</TableCell>
<TableCell Class="text-end">
@if (subscription.Enabled)
{
@* Disabling cannot be undone, so the posted form sits in a dialog that asks first. The form is named after the
subscription, so the action reaches the subscription it was rendered for. *@
<Dialog>
<DialogTrigger Variant="outline" Size="sm">@L["Disable"]<span class="sr-only"> @subscription.Url</span></DialogTrigger>
<DialogContent Class="text-start whitespace-normal">
<DialogHeader>
<DialogTitle>@L["Disable the webhook to {0}?", subscription.Url]</DialogTitle>
<DialogDescription>@L["The endpoint is told about nothing from now on. This cannot be undone; add the webhook again to start over, with a new secret."]</DialogDescription>
</DialogHeader>
<form method="post" @onsubmit="() => DisableAsync(subscription)" @formname="@($"disable-{subscription.Id:N}")">
<AntiforgeryToken />
<DialogFooter>
<DialogClose>@L["Cancel"]</DialogClose>
<Button Type="submit" Variant="destructive">@L["Disable"]</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
}
</TableCell>
</TableRow>
}
</TableBody>
</Table>
</div>

@code {
private readonly NewWebhookErrors _errors = new();
private IReadOnlyList<WebhookSubscription> _subscriptions = [];
private AddedWebhookSubscription? _added;
private string? _message;

[SupplyParameterFromForm(FormName = "add-webhook")]
private NewWebhookForm Form { get; set; } = default!;

protected override async Task OnInitializedAsync()
{
Form ??= NewWebhookForm.Defaults();
await LoadAsync();
}

private async Task AddAsync()
{
var added = await Directory.AddAsync(Form.ToSubscription());
if (!added.IsSuccess)
{
_errors.Report(added.Errors, L);
return;
}

_added = added.Value;
Form = NewWebhookForm.Defaults();
await LoadAsync();
}

private async Task DisableAsync(WebhookSubscription subscription)
{
var disabled = await Directory.DisableAsync(subscription.Id);
_message = disabled.IsSuccess
? L["The webhook to {0} is disabled. It is told about nothing from now on.", subscription.Url]
: string.Join(" ", disabled.Errors.Select(Describe));
await LoadAsync();
}

private async Task LoadAsync()
{
var listed = await Directory.ListAsync();
_subscriptions = listed.IsSuccess ? listed.Value : [];
if (!listed.IsSuccess)
{
_message = string.Join(" ", listed.Errors.Select(Describe));
}
}

// The error code is the resource key; the directory's own message is the text until a resource exists.
private string Describe(FluentCMS.Core.Results.Error error)
{
var localized = L[error.Code];
return localized.ResourceNotFound ? error.Message : localized.Value;
}
}
75 changes: 75 additions & 0 deletions src/FluentCMS.Admin/Components/Webhooks/NewWebhookForm.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using FluentCMS.Core.Results;
using FluentCMS.Core.Webhooks;
using Microsoft.Extensions.Localization;

namespace FluentCMS.Admin.Components.Webhooks;

// A new subscription as the screen asks for it, bound from the posted form.
public sealed class NewWebhookForm
{
public string? Url { get; set; }

public bool Published { get; set; }

public bool Reverted { get; set; }

// Only for the first render: a posted form starts from nothing, so a box left unticked stays unticked.
public static NewWebhookForm Defaults() => new() { Published = true, Reverted = true };

public NewWebhookSubscription ToSubscription()
{
var events = new List<string>();
if (Published)
{
events.Add(WebhookEvents.ChangeSetPublished);
}

if (Reverted)
{
events.Add(WebhookEvents.ChangeSetReverted);
}

return new NewWebhookSubscription(Url ?? string.Empty, events);
}
}

// What is wrong with a new subscription, by field, plus anything that belongs to no field.
public sealed class NewWebhookErrors
{
public const string Events = "Events";

private readonly Dictionary<string, string> _fields = [];

public string? Form { get; private set; }

public string? For(string field) => _fields.GetValueOrDefault(field);

// Core reports problems by code: the code is the resource key, and Core's own message the text until a resource exists.
public void Report(IEnumerable<Error> errors, IStringLocalizer localizer)
{
foreach (var error in errors)
{
var localized = localizer[error.Code];
var message = localized.ResourceNotFound ? error.Message : localized.Value;
var field = error.Code switch
{
WebhookErrorCodes.UrlInvalid
or WebhookErrorCodes.UrlTooLong
or WebhookErrorCodes.UrlHasCredentials
or WebhookErrorCodes.UrlNotHttps
or WebhookErrorCodes.HostNotAllowed => nameof(NewWebhookForm.Url),
WebhookErrorCodes.EventsMissing or WebhookErrorCodes.EventUnknown => Events,
_ => null,
};

if (field is null)
{
Form = Form is null ? message : Form + " " + message;
}
else
{
_fields[field] = _fields.TryGetValue(field, out var existing) ? existing + " " + message : message;
}
}
}
}
10 changes: 10 additions & 0 deletions src/FluentCMS.Core/Security/ISecretProtector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace FluentCMS.Core.Security;

// Encrypts a secret the product has to read back, such as a webhook's signing secret, so the database never holds it in plain text
// (SEC-07). The host implements it with its Data Protection key ring; the purpose keeps one kind of secret from opening another.
public interface ISecretProtector
{
string Protect(string purpose, string secret);

string Unprotect(string purpose, string protectedSecret);
}
20 changes: 20 additions & 0 deletions src/FluentCMS.Core/Webhooks/IWebhookDirectory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using FluentCMS.Core.Results;

namespace FluentCMS.Core.Webhooks;

// The endpoints told when a change set is published or reverted (API-05). Adding and disabling each append their audit event in
// the same unit of work (AUD-N1), and only a person may list, add or disable one: a subscription sends content out of the product,
// which no agent or engine decides (SEC-02).
public interface IWebhookDirectory
{
Task<Result<IReadOnlyList<WebhookSubscription>>> ListAsync(CancellationToken cancellationToken = default);

// The signing secret is in the result and nowhere else readable; it is stored encrypted and never listed.
Task<Result<AddedWebhookSubscription>> AddAsync(NewWebhookSubscription subscription, CancellationToken cancellationToken = default);

Task<Result<WebhookSubscription>> DisableAsync(Guid id, CancellationToken cancellationToken = default);
}

public sealed record NewWebhookSubscription(string Url, IReadOnlyList<string> Events);

public sealed record AddedWebhookSubscription(WebhookSubscription Subscription, string Secret);
15 changes: 15 additions & 0 deletions src/FluentCMS.Core/Webhooks/WebhookErrorCodes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace FluentCMS.Core.Webhooks;

public static class WebhookErrorCodes
{
public const string UrlInvalid = "webhook_url_invalid";
public const string UrlTooLong = "webhook_url_too_long";
public const string UrlHasCredentials = "webhook_url_has_credentials";
public const string UrlNotHttps = "webhook_url_not_https";
public const string HostNotAllowed = "webhook_host_not_allowed";
public const string EventsMissing = "webhook_events_missing";
public const string EventUnknown = "webhook_event_unknown";
public const string NotFound = "webhook_not_found";
public const string AlreadyDisabled = "webhook_already_disabled";
public const string PeopleOnly = "webhook_people_only";
}
10 changes: 10 additions & 0 deletions src/FluentCMS.Core/Webhooks/WebhookEvents.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace FluentCMS.Core.Webhooks;

// The events a subscription can ask for in M1 (API-05); others come with M2.
public static class WebhookEvents
{
public const string ChangeSetPublished = "change_set.published";
public const string ChangeSetReverted = "change_set.reverted";

public static IReadOnlyList<string> All { get; } = [ChangeSetPublished, ChangeSetReverted];
}
19 changes: 19 additions & 0 deletions src/FluentCMS.Core/Webhooks/WebhookSecrets.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using System.Security.Cryptography;

namespace FluentCMS.Core.Webhooks;

// A signing secret is 32 random bytes in the Standard Webhooks form, whsec_ and base64, so a receiver's existing verifier reads it.
// It is protected under its own purpose, so no other stored secret opens it.
public static class WebhookSecrets
{
public const string Prefix = "whsec_";

public const string ProtectionPurpose = "FluentCMS.Webhooks.SigningSecret";

private const int SecretBytes = 32;

public static string Generate()
{
return Prefix + Convert.ToBase64String(RandomNumberGenerator.GetBytes(SecretBytes));
}
}
Loading
Loading