188 lines
7.7 KiB
C#
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; }
|
|
}
|