Files
git.stella-ops.org/src/Findings/StellaOps.Findings.Ledger.WebService/Endpoints/WebhookEndpoints.cs

188 lines
7.7 KiB
C#

using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.ServerIntegration.Tenancy;
using StellaOps.Findings.Ledger.WebService.Contracts;
using StellaOps.Findings.Ledger.WebService.Services;
namespace StellaOps.Findings.Ledger.WebService.Endpoints;
/// <summary>
/// Webhook management endpoints.
/// Sprint: SPRINT_8200_0012_0004 - Wave 6
/// </summary>
public static class WebhookEndpoints
{
// Authorization policy name (must match Program.cs)
private const string ScoringAdminPolicy = "scoring.admin";
public static void MapWebhookEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/scoring/webhooks")
.WithTags("Webhooks")
.RequireTenant();
// POST /api/v1/scoring/webhooks - Register webhook
// Rate limit: 10/min (via API Gateway)
group.MapPost("/", RegisterWebhook)
.WithName("RegisterScoringWebhook")
.WithSummary("Register a webhook for score change notifications")
.WithDescription("Registers an HTTPS callback URL to receive score change notifications. Supports optional HMAC-SHA256 signing via a shared secret, finding pattern filters, minimum score change threshold, and bucket transition triggers. The webhook is activated immediately upon registration.")
.Produces<WebhookResponse>(StatusCodes.Status201Created)
.ProducesValidationProblem()
.RequireAuthorization(ScoringAdminPolicy);
// GET /api/v1/scoring/webhooks - List webhooks
// Rate limit: 10/min (via API Gateway)
group.MapGet("/", ListWebhooks)
.WithName("ListScoringWebhooks")
.WithSummary("List all registered webhooks")
.WithDescription("Returns all currently registered score change webhooks with their configuration, including URL, filter patterns, minimum score change threshold, and creation timestamp. Secrets are not returned in responses.")
.Produces<WebhookListResponse>(StatusCodes.Status200OK)
.RequireAuthorization(ScoringAdminPolicy);
// GET /api/v1/scoring/webhooks/{id} - Get webhook
// Rate limit: 10/min (via API Gateway)
group.MapGet("/{id:guid}", GetWebhook)
.WithName("GetScoringWebhook")
.WithSummary("Get a specific webhook by ID")
.WithDescription("Returns the configuration of a specific webhook by its UUID. Inactive webhooks (soft-deleted) return 404. Secrets are not included in the response body.")
.Produces<WebhookResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScoringAdminPolicy);
// PUT /api/v1/scoring/webhooks/{id} - Update webhook
// Rate limit: 10/min (via API Gateway)
group.MapPut("/{id:guid}", UpdateWebhook)
.WithName("UpdateScoringWebhook")
.WithSummary("Update a webhook configuration")
.WithDescription("Replaces the full configuration of an existing webhook. All fields in the request body are applied as-is; partial updates are not supported. To update a secret, supply the new secret value; omitting the secret field retains the existing secret.")
.Produces<WebhookResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.ProducesValidationProblem()
.RequireAuthorization(ScoringAdminPolicy);
// DELETE /api/v1/scoring/webhooks/{id} - Delete webhook
// Rate limit: 10/min (via API Gateway)
group.MapDelete("/{id:guid}", DeleteWebhook)
.WithName("DeleteScoringWebhook")
.WithSummary("Delete a webhook")
.WithDescription("Permanently removes a webhook registration by its UUID. No further score change notifications will be delivered to the associated URL after deletion. Returns 204 on success, 404 if the webhook does not exist.")
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status404NotFound)
.RequireAuthorization(ScoringAdminPolicy);
}
private static Results<Created<WebhookResponse>, ValidationProblem> RegisterWebhook(
[FromBody] RegisterWebhookRequest request,
[FromServices] IWebhookStore store,
[FromServices] IStellaOpsTenantAccessor tenantAccessor)
{
// Validate URL
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri) ||
(uri.Scheme != "http" && uri.Scheme != "https"))
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["url"] = ["Invalid webhook URL. Must be an absolute HTTP or HTTPS URL."]
});
}
var registration = store.Register(request);
var response = MapToResponse(registration);
return TypedResults.Created($"/api/v1/scoring/webhooks/{registration.Id}", response);
}
private static Ok<WebhookListResponse> ListWebhooks(
[FromServices] IWebhookStore store,
[FromServices] IStellaOpsTenantAccessor tenantAccessor)
{
var webhooks = store.List();
var response = new WebhookListResponse
{
Webhooks = webhooks.Select(MapToResponse).ToList(),
TotalCount = webhooks.Count
};
return TypedResults.Ok(response);
}
private static Results<Ok<WebhookResponse>, NotFound> GetWebhook(
Guid id,
[FromServices] IWebhookStore store,
[FromServices] IStellaOpsTenantAccessor tenantAccessor)
{
var webhook = store.Get(id);
if (webhook is null || !webhook.IsActive)
{
return TypedResults.NotFound();
}
return TypedResults.Ok(MapToResponse(webhook));
}
private static Results<Ok<WebhookResponse>, NotFound, ValidationProblem> UpdateWebhook(
Guid id,
[FromBody] RegisterWebhookRequest request,
[FromServices] IWebhookStore store,
[FromServices] IStellaOpsTenantAccessor tenantAccessor)
{
// Validate URL
if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri) ||
(uri.Scheme != "http" && uri.Scheme != "https"))
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["url"] = ["Invalid webhook URL. Must be an absolute HTTP or HTTPS URL."]
});
}
if (!store.Update(id, request))
{
return TypedResults.NotFound();
}
var updated = store.Get(id);
return TypedResults.Ok(MapToResponse(updated!));
}
private static Results<NoContent, NotFound> DeleteWebhook(
Guid id,
[FromServices] IWebhookStore store,
[FromServices] IStellaOpsTenantAccessor tenantAccessor)
{
if (!store.Delete(id))
{
return TypedResults.NotFound();
}
return TypedResults.NoContent();
}
private static WebhookResponse MapToResponse(WebhookRegistration registration)
{
return new WebhookResponse
{
Id = registration.Id,
Url = registration.Url,
HasSecret = !string.IsNullOrEmpty(registration.Secret),
FindingPatterns = registration.FindingPatterns,
MinScoreChange = registration.MinScoreChange,
TriggerOnBucketChange = registration.TriggerOnBucketChange,
CreatedAt = registration.CreatedAt
};
}
}
/// <summary>
/// Response for listing webhooks.
/// </summary>
public sealed record WebhookListResponse
{
/// <summary>List of webhooks.</summary>
public required IReadOnlyList<WebhookResponse> Webhooks { get; init; }
/// <summary>Total count of webhooks.</summary>
public required int TotalCount { get; init; }
}