feat: Implement vulnerability token signing and verification utilities
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Added VulnTokenSigner for signing JWT tokens with specified algorithms and keys. - Introduced VulnTokenUtilities for resolving tenant and subject claims, and sanitizing context dictionaries. - Created VulnTokenVerificationUtilities for parsing tokens, verifying signatures, and deserializing payloads. - Developed VulnWorkflowAntiForgeryTokenIssuer for issuing anti-forgery tokens with configurable options. - Implemented VulnWorkflowAntiForgeryTokenVerifier for verifying anti-forgery tokens and validating payloads. - Added AuthorityVulnerabilityExplorerOptions to manage configuration for vulnerability explorer features. - Included tests for FilesystemPackRunDispatcher to ensure proper job handling under egress policy restrictions.
This commit is contained in:
@@ -1,173 +1,173 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public static class EventWebhookEndpointExtensions
|
||||
{
|
||||
public static void MapSchedulerEventWebhookEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/events");
|
||||
|
||||
group.MapPost("/feedser-export", HandleFeedserExportAsync);
|
||||
group.MapPost("/vexer-export", HandleVexerExportAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleFeedserExportAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
[FromServices] IWebhookRequestAuthenticator authenticator,
|
||||
[FromServices] IWebhookRateLimiter rateLimiter,
|
||||
[FromServices] IInboundExportEventSink sink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookOptions = options.CurrentValue.Webhooks.Feedser;
|
||||
if (!webhookOptions.Enabled)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var readResult = await ReadPayloadAsync<FeedserExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
|
||||
if (!readResult.Succeeded)
|
||||
{
|
||||
return readResult.ErrorResult!;
|
||||
}
|
||||
|
||||
if (!rateLimiter.TryAcquire("feedser", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
|
||||
{
|
||||
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
if (retryAfter > TimeSpan.Zero)
|
||||
{
|
||||
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
return authResult.ToResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await sink.HandleFeedserAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted(value: new { status = "accepted" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleVexerExportAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
[FromServices] IWebhookRequestAuthenticator authenticator,
|
||||
[FromServices] IWebhookRateLimiter rateLimiter,
|
||||
[FromServices] IInboundExportEventSink sink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookOptions = options.CurrentValue.Webhooks.Vexer;
|
||||
if (!webhookOptions.Enabled)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var readResult = await ReadPayloadAsync<VexerExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
|
||||
if (!readResult.Succeeded)
|
||||
{
|
||||
return readResult.ErrorResult!;
|
||||
}
|
||||
|
||||
if (!rateLimiter.TryAcquire("vexer", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
|
||||
{
|
||||
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
if (retryAfter > TimeSpan.Zero)
|
||||
{
|
||||
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
return authResult.ToResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await sink.HandleVexerAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted(value: new { status = "accepted" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RequestPayload<T>> ReadPayloadAsync<T>(HttpContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Request.EnableBuffering();
|
||||
|
||||
await using var buffer = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
var bodyBytes = buffer.ToArray();
|
||||
context.Request.Body.Position = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var payload = JsonSerializer.Deserialize<T>(bodyBytes, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
if (payload is null)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = "Request payload cannot be empty." }));
|
||||
}
|
||||
|
||||
return RequestPayload<T>.Success(payload, bodyBytes);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct RequestPayload<T>
|
||||
{
|
||||
private RequestPayload(T? payload, byte[] rawBody, IResult? error, bool succeeded)
|
||||
{
|
||||
Payload = payload;
|
||||
RawBody = rawBody;
|
||||
ErrorResult = error;
|
||||
Succeeded = succeeded;
|
||||
}
|
||||
|
||||
public T? Payload { get; }
|
||||
|
||||
public byte[] RawBody { get; }
|
||||
|
||||
public IResult? ErrorResult { get; }
|
||||
|
||||
public bool Succeeded { get; }
|
||||
|
||||
public static RequestPayload<T> Success(T payload, byte[] rawBody)
|
||||
=> new(payload, rawBody, null, true);
|
||||
|
||||
public static RequestPayload<T> Failed(IResult error)
|
||||
=> new(default, Array.Empty<byte>(), error, false);
|
||||
}
|
||||
}
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public static class EventWebhookEndpointExtensions
|
||||
{
|
||||
public static void MapSchedulerEventWebhookEndpoints(this IEndpointRouteBuilder builder)
|
||||
{
|
||||
var group = builder.MapGroup("/events");
|
||||
|
||||
group.MapPost("/conselier-export", HandleConselierExportAsync);
|
||||
group.MapPost("/excitor-export", HandleExcitorExportAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleConselierExportAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
[FromServices] IWebhookRequestAuthenticator authenticator,
|
||||
[FromServices] IWebhookRateLimiter rateLimiter,
|
||||
[FromServices] IInboundExportEventSink sink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookOptions = options.CurrentValue.Webhooks.Conselier;
|
||||
if (!webhookOptions.Enabled)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var readResult = await ReadPayloadAsync<ConselierExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
|
||||
if (!readResult.Succeeded)
|
||||
{
|
||||
return readResult.ErrorResult!;
|
||||
}
|
||||
|
||||
if (!rateLimiter.TryAcquire("conselier", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
|
||||
{
|
||||
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
if (retryAfter > TimeSpan.Zero)
|
||||
{
|
||||
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
return authResult.ToResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await sink.HandleConselierAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted(value: new { status = "accepted" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleExcitorExportAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] IOptionsMonitor<SchedulerEventsOptions> options,
|
||||
[FromServices] IWebhookRequestAuthenticator authenticator,
|
||||
[FromServices] IWebhookRateLimiter rateLimiter,
|
||||
[FromServices] IInboundExportEventSink sink,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookOptions = options.CurrentValue.Webhooks.Excitor;
|
||||
if (!webhookOptions.Enabled)
|
||||
{
|
||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
var readResult = await ReadPayloadAsync<ExcitorExportEventRequest>(httpContext, cancellationToken).ConfigureAwait(false);
|
||||
if (!readResult.Succeeded)
|
||||
{
|
||||
return readResult.ErrorResult!;
|
||||
}
|
||||
|
||||
if (!rateLimiter.TryAcquire("excitor", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter))
|
||||
{
|
||||
var response = Results.StatusCode(StatusCodes.Status429TooManyRequests);
|
||||
if (retryAfter > TimeSpan.Zero)
|
||||
{
|
||||
httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString();
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false);
|
||||
if (!authResult.Succeeded)
|
||||
{
|
||||
return authResult.ToResult();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await sink.HandleExcitorAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Accepted(value: new { status = "accepted" });
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RequestPayload<T>> ReadPayloadAsync<T>(HttpContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
context.Request.EnableBuffering();
|
||||
|
||||
await using var buffer = new MemoryStream();
|
||||
await context.Request.Body.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
var bodyBytes = buffer.ToArray();
|
||||
context.Request.Body.Position = 0;
|
||||
|
||||
try
|
||||
{
|
||||
var payload = JsonSerializer.Deserialize<T>(bodyBytes, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
if (payload is null)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = "Request payload cannot be empty." }));
|
||||
}
|
||||
|
||||
return RequestPayload<T>.Success(payload, bodyBytes);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
|
||||
}
|
||||
catch (ValidationException ex)
|
||||
{
|
||||
return RequestPayload<T>.Failed(Results.BadRequest(new { error = ex.Message }));
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct RequestPayload<T>
|
||||
{
|
||||
private RequestPayload(T? payload, byte[] rawBody, IResult? error, bool succeeded)
|
||||
{
|
||||
Payload = payload;
|
||||
RawBody = rawBody;
|
||||
ErrorResult = error;
|
||||
Succeeded = succeeded;
|
||||
}
|
||||
|
||||
public T? Payload { get; }
|
||||
|
||||
public byte[] RawBody { get; }
|
||||
|
||||
public IResult? ErrorResult { get; }
|
||||
|
||||
public bool Succeeded { get; }
|
||||
|
||||
public static RequestPayload<T> Success(T payload, byte[] rawBody)
|
||||
=> new(payload, rawBody, null, true);
|
||||
|
||||
public static RequestPayload<T> Failed(IResult error)
|
||||
=> new(default, Array.Empty<byte>(), error, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public interface IInboundExportEventSink
|
||||
{
|
||||
Task HandleFeedserAsync(FeedserExportEventRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task HandleVexerAsync(VexerExportEventRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public interface IInboundExportEventSink
|
||||
{
|
||||
Task HandleConselierAsync(ConselierExportEventRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task HandleExcitorAsync(ExcitorExportEventRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
internal sealed class LoggingExportEventSink : IInboundExportEventSink
|
||||
{
|
||||
private readonly ILogger<LoggingExportEventSink> _logger;
|
||||
|
||||
public LoggingExportEventSink(ILogger<LoggingExportEventSink> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task HandleFeedserAsync(FeedserExportEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Received Feedser export webhook {ExportId} with {ChangedProducts} product keys.",
|
||||
request.ExportId,
|
||||
request.ChangedProductKeys.Count);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task HandleVexerAsync(VexerExportEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Received Vexer export webhook {ExportId} with {ChangedClaims} claim changes.",
|
||||
request.ExportId,
|
||||
request.ChangedClaims.Count);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
internal sealed class LoggingExportEventSink : IInboundExportEventSink
|
||||
{
|
||||
private readonly ILogger<LoggingExportEventSink> _logger;
|
||||
|
||||
public LoggingExportEventSink(ILogger<LoggingExportEventSink> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task HandleConselierAsync(ConselierExportEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Received Conselier export webhook {ExportId} with {ChangedProducts} product keys.",
|
||||
request.ExportId,
|
||||
request.ChangedProductKeys.Count);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task HandleExcitorAsync(ExcitorExportEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Received Excitor export webhook {ExportId} with {ChangedClaims} claim changes.",
|
||||
request.ExportId,
|
||||
request.ChangedClaims.Count);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public sealed record FeedserExportEventRequest(
|
||||
string ExportId,
|
||||
IReadOnlyList<string> ChangedProductKeys,
|
||||
IReadOnlyList<string>? Kev,
|
||||
WebhookEventWindow? Window)
|
||||
{
|
||||
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
|
||||
|
||||
public IReadOnlyList<string> ChangedProductKeys { get; } = NormalizeList(ChangedProductKeys, nameof(ChangedProductKeys));
|
||||
|
||||
public IReadOnlyList<string> Kev { get; } = NormalizeList(Kev, nameof(Kev), allowEmpty: true);
|
||||
|
||||
public WebhookEventWindow? Window { get; } = Window;
|
||||
|
||||
private static IReadOnlyList<string> NormalizeList(IReadOnlyList<string>? source, string propertyName, bool allowEmpty = false)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
if (allowEmpty)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
throw new ValidationException($"{propertyName} must be specified.");
|
||||
}
|
||||
|
||||
var cleaned = source
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(item => item.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (!allowEmpty && cleaned.Length == 0)
|
||||
{
|
||||
throw new ValidationException($"{propertyName} must contain at least one value.");
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record VexerExportEventRequest(
|
||||
string ExportId,
|
||||
IReadOnlyList<VexerClaimChange> ChangedClaims,
|
||||
WebhookEventWindow? Window)
|
||||
{
|
||||
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
|
||||
|
||||
public IReadOnlyList<VexerClaimChange> ChangedClaims { get; } = NormalizeClaims(ChangedClaims);
|
||||
|
||||
public WebhookEventWindow? Window { get; } = Window;
|
||||
|
||||
private static IReadOnlyList<VexerClaimChange> NormalizeClaims(IReadOnlyList<VexerClaimChange>? claims)
|
||||
{
|
||||
if (claims is null || claims.Count == 0)
|
||||
{
|
||||
throw new ValidationException("changedClaims must contain at least one entry.");
|
||||
}
|
||||
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
claim.Validate();
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record VexerClaimChange(
|
||||
string ProductKey,
|
||||
string VulnerabilityId,
|
||||
string Status)
|
||||
{
|
||||
public string ProductKey { get; } = Normalize(ProductKey, nameof(ProductKey));
|
||||
|
||||
public string VulnerabilityId { get; } = Normalize(VulnerabilityId, nameof(VulnerabilityId));
|
||||
|
||||
public string Status { get; } = Normalize(Status, nameof(Status));
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
_ = ProductKey;
|
||||
_ = VulnerabilityId;
|
||||
_ = Status;
|
||||
}
|
||||
|
||||
private static string Normalize(string value, string propertyName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ValidationException($"{propertyName} must be provided.");
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WebhookEventWindow(DateTimeOffset? From, DateTimeOffset? To);
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
|
||||
public sealed record ConselierExportEventRequest(
|
||||
string ExportId,
|
||||
IReadOnlyList<string> ChangedProductKeys,
|
||||
IReadOnlyList<string>? Kev,
|
||||
WebhookEventWindow? Window)
|
||||
{
|
||||
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
|
||||
|
||||
public IReadOnlyList<string> ChangedProductKeys { get; } = NormalizeList(ChangedProductKeys, nameof(ChangedProductKeys));
|
||||
|
||||
public IReadOnlyList<string> Kev { get; } = NormalizeList(Kev, nameof(Kev), allowEmpty: true);
|
||||
|
||||
public WebhookEventWindow? Window { get; } = Window;
|
||||
|
||||
private static IReadOnlyList<string> NormalizeList(IReadOnlyList<string>? source, string propertyName, bool allowEmpty = false)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
if (allowEmpty)
|
||||
{
|
||||
return ImmutableArray<string>.Empty;
|
||||
}
|
||||
|
||||
throw new ValidationException($"{propertyName} must be specified.");
|
||||
}
|
||||
|
||||
var cleaned = source
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Select(item => item.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
if (!allowEmpty && cleaned.Length == 0)
|
||||
{
|
||||
throw new ValidationException($"{propertyName} must contain at least one value.");
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ExcitorExportEventRequest(
|
||||
string ExportId,
|
||||
IReadOnlyList<ExcitorClaimChange> ChangedClaims,
|
||||
WebhookEventWindow? Window)
|
||||
{
|
||||
public string ExportId { get; } = ExportId?.Trim() ?? throw new ArgumentNullException(nameof(ExportId));
|
||||
|
||||
public IReadOnlyList<ExcitorClaimChange> ChangedClaims { get; } = NormalizeClaims(ChangedClaims);
|
||||
|
||||
public WebhookEventWindow? Window { get; } = Window;
|
||||
|
||||
private static IReadOnlyList<ExcitorClaimChange> NormalizeClaims(IReadOnlyList<ExcitorClaimChange>? claims)
|
||||
{
|
||||
if (claims is null || claims.Count == 0)
|
||||
{
|
||||
throw new ValidationException("changedClaims must contain at least one entry.");
|
||||
}
|
||||
|
||||
foreach (var claim in claims)
|
||||
{
|
||||
claim.Validate();
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ExcitorClaimChange(
|
||||
string ProductKey,
|
||||
string VulnerabilityId,
|
||||
string Status)
|
||||
{
|
||||
public string ProductKey { get; } = Normalize(ProductKey, nameof(ProductKey));
|
||||
|
||||
public string VulnerabilityId { get; } = Normalize(VulnerabilityId, nameof(VulnerabilityId));
|
||||
|
||||
public string Status { get; } = Normalize(Status, nameof(Status));
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
_ = ProductKey;
|
||||
_ = VulnerabilityId;
|
||||
_ = Status;
|
||||
}
|
||||
|
||||
private static string Normalize(string value, string propertyName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new ValidationException($"{propertyName} must be provided.");
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record WebhookEventWindow(DateTimeOffset? From, DateTimeOffset? To);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
namespace StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduler WebService event options (outbound + inbound).
|
||||
/// </summary>
|
||||
namespace StellaOps.Scheduler.WebService.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduler WebService event options (outbound + inbound).
|
||||
/// </summary>
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
public sealed class SchedulerEventsOptions
|
||||
{
|
||||
public GraphJobEventsOptions GraphJobs { get; set; } = new();
|
||||
|
||||
public SchedulerInboundWebhooksOptions Webhooks { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
public sealed class SchedulerEventsOptions
|
||||
{
|
||||
public GraphJobEventsOptions GraphJobs { get; set; } = new();
|
||||
|
||||
public SchedulerInboundWebhooksOptions Webhooks { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class GraphJobEventsOptions
|
||||
{
|
||||
/// <summary>
|
||||
@@ -50,91 +50,91 @@ public sealed class GraphJobEventsOptions
|
||||
/// </summary>
|
||||
public IDictionary<string, string> DriverSettings { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public sealed class SchedulerInboundWebhooksOptions
|
||||
{
|
||||
public SchedulerWebhookOptions Feedser { get; set; } = SchedulerWebhookOptions.CreateDefault("feedser");
|
||||
|
||||
public SchedulerWebhookOptions Vexer { get; set; } = SchedulerWebhookOptions.CreateDefault("vexer");
|
||||
}
|
||||
|
||||
public sealed class SchedulerWebhookOptions
|
||||
{
|
||||
private const string DefaultSignatureHeader = "X-Scheduler-Signature";
|
||||
|
||||
public SchedulerWebhookOptions()
|
||||
{
|
||||
SignatureHeader = DefaultSignatureHeader;
|
||||
}
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Require a client certificate to be presented (mTLS). Optional when HMAC is configured.
|
||||
/// </summary>
|
||||
public bool RequireClientCertificate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret (Base64 or raw text) for HMAC-SHA256 signatures. Required if <see cref="RequireClientCertificate"/> is false.
|
||||
/// </summary>
|
||||
public string? HmacSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Header name carrying the webhook signature (defaults to <c>X-Scheduler-Signature</c>).
|
||||
/// </summary>
|
||||
public string SignatureHeader { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of accepted requests per sliding window.
|
||||
/// </summary>
|
||||
public int RateLimitRequests { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Sliding window duration in seconds for the rate limiter.
|
||||
/// </summary>
|
||||
public int RateLimitWindowSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Optional label used for logging/diagnostics; populated via <see cref="CreateDefault"/>.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public static SchedulerWebhookOptions CreateDefault(string name)
|
||||
=> new()
|
||||
{
|
||||
Name = name,
|
||||
SignatureHeader = DefaultSignatureHeader,
|
||||
RateLimitRequests = 120,
|
||||
RateLimitWindowSeconds = 60
|
||||
};
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SignatureHeader))
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must specify a signature header when enabled.");
|
||||
}
|
||||
|
||||
if (!RequireClientCertificate && string.IsNullOrWhiteSpace(HmacSecret))
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure either HMAC secret or mTLS enforcement.");
|
||||
}
|
||||
|
||||
if (RateLimitRequests <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure a positive rate limit.");
|
||||
}
|
||||
|
||||
if (RateLimitWindowSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure a rate limit window greater than zero seconds.");
|
||||
}
|
||||
}
|
||||
|
||||
public TimeSpan GetRateLimitWindow() => TimeSpan.FromSeconds(RateLimitWindowSeconds <= 0 ? 60 : RateLimitWindowSeconds);
|
||||
}
|
||||
|
||||
public sealed class SchedulerInboundWebhooksOptions
|
||||
{
|
||||
public SchedulerWebhookOptions Conselier { get; set; } = SchedulerWebhookOptions.CreateDefault("conselier");
|
||||
|
||||
public SchedulerWebhookOptions Excitor { get; set; } = SchedulerWebhookOptions.CreateDefault("excitor");
|
||||
}
|
||||
|
||||
public sealed class SchedulerWebhookOptions
|
||||
{
|
||||
private const string DefaultSignatureHeader = "X-Scheduler-Signature";
|
||||
|
||||
public SchedulerWebhookOptions()
|
||||
{
|
||||
SignatureHeader = DefaultSignatureHeader;
|
||||
}
|
||||
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Require a client certificate to be presented (mTLS). Optional when HMAC is configured.
|
||||
/// </summary>
|
||||
public bool RequireClientCertificate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Shared secret (Base64 or raw text) for HMAC-SHA256 signatures. Required if <see cref="RequireClientCertificate"/> is false.
|
||||
/// </summary>
|
||||
public string? HmacSecret { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Header name carrying the webhook signature (defaults to <c>X-Scheduler-Signature</c>).
|
||||
/// </summary>
|
||||
public string SignatureHeader { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of accepted requests per sliding window.
|
||||
/// </summary>
|
||||
public int RateLimitRequests { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Sliding window duration in seconds for the rate limiter.
|
||||
/// </summary>
|
||||
public int RateLimitWindowSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Optional label used for logging/diagnostics; populated via <see cref="CreateDefault"/>.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public static SchedulerWebhookOptions CreateDefault(string name)
|
||||
=> new()
|
||||
{
|
||||
Name = name,
|
||||
SignatureHeader = DefaultSignatureHeader,
|
||||
RateLimitRequests = 120,
|
||||
RateLimitWindowSeconds = 60
|
||||
};
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(SignatureHeader))
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must specify a signature header when enabled.");
|
||||
}
|
||||
|
||||
if (!RequireClientCertificate && string.IsNullOrWhiteSpace(HmacSecret))
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure either HMAC secret or mTLS enforcement.");
|
||||
}
|
||||
|
||||
if (RateLimitRequests <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure a positive rate limit.");
|
||||
}
|
||||
|
||||
if (RateLimitWindowSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"Scheduler webhook '{Name}' must configure a rate limit window greater than zero seconds.");
|
||||
}
|
||||
}
|
||||
|
||||
public TimeSpan GetRateLimitWindow() => TimeSpan.FromSeconds(RateLimitWindowSeconds <= 0 ? 60 : RateLimitWindowSeconds);
|
||||
}
|
||||
|
||||
@@ -1,203 +1,203 @@
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using StellaOps.Scheduler.WebService.Hosting;
|
||||
using StellaOps.Scheduler.ImpactIndex;
|
||||
using StellaOps.Scheduler.Storage.Mongo;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Services;
|
||||
using StellaOps.Scheduler.WebService;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
using StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
using StellaOps.Scheduler.WebService.GraphJobs;
|
||||
using StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
using StellaOps.Scheduler.WebService.Schedules;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
using StellaOps.Scheduler.WebService.Runs;
|
||||
using StellaOps.Scheduler.WebService.PolicyRuns;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddSingleton<StellaOps.Scheduler.WebService.ISystemClock, StellaOps.Scheduler.WebService.SystemClock>();
|
||||
builder.Services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
var authorityOptions = new SchedulerAuthorityOptions();
|
||||
builder.Configuration.GetSection("Scheduler:Authority").Bind(authorityOptions);
|
||||
|
||||
if (!authorityOptions.RequiredScopes.Any(scope => string.Equals(scope, StellaOpsScopes.GraphRead, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
authorityOptions.RequiredScopes.Add(StellaOpsScopes.GraphRead);
|
||||
}
|
||||
|
||||
if (!authorityOptions.RequiredScopes.Any(scope => string.Equals(scope, StellaOpsScopes.GraphWrite, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
authorityOptions.RequiredScopes.Add(StellaOpsScopes.GraphWrite);
|
||||
}
|
||||
|
||||
if (authorityOptions.Audiences.Count == 0)
|
||||
{
|
||||
authorityOptions.Audiences.Add("api://scheduler");
|
||||
}
|
||||
|
||||
authorityOptions.Validate();
|
||||
builder.Services.AddSingleton(authorityOptions);
|
||||
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
using StellaOps.Scheduler.WebService.Hosting;
|
||||
using StellaOps.Scheduler.ImpactIndex;
|
||||
using StellaOps.Scheduler.Storage.Mongo;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Repositories;
|
||||
using StellaOps.Scheduler.Storage.Mongo.Services;
|
||||
using StellaOps.Scheduler.WebService;
|
||||
using StellaOps.Scheduler.WebService.Auth;
|
||||
using StellaOps.Scheduler.WebService.EventWebhooks;
|
||||
using StellaOps.Scheduler.WebService.GraphJobs;
|
||||
using StellaOps.Scheduler.WebService.GraphJobs.Events;
|
||||
using StellaOps.Scheduler.WebService.Schedules;
|
||||
using StellaOps.Scheduler.WebService.Options;
|
||||
using StellaOps.Scheduler.WebService.Runs;
|
||||
using StellaOps.Scheduler.WebService.PolicyRuns;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddRouting(options => options.LowercaseUrls = true);
|
||||
builder.Services.AddSingleton<StellaOps.Scheduler.WebService.ISystemClock, StellaOps.Scheduler.WebService.SystemClock>();
|
||||
builder.Services.TryAddSingleton(TimeProvider.System);
|
||||
|
||||
var authorityOptions = new SchedulerAuthorityOptions();
|
||||
builder.Configuration.GetSection("Scheduler:Authority").Bind(authorityOptions);
|
||||
|
||||
if (!authorityOptions.RequiredScopes.Any(scope => string.Equals(scope, StellaOpsScopes.GraphRead, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
authorityOptions.RequiredScopes.Add(StellaOpsScopes.GraphRead);
|
||||
}
|
||||
|
||||
if (!authorityOptions.RequiredScopes.Any(scope => string.Equals(scope, StellaOpsScopes.GraphWrite, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
authorityOptions.RequiredScopes.Add(StellaOpsScopes.GraphWrite);
|
||||
}
|
||||
|
||||
if (authorityOptions.Audiences.Count == 0)
|
||||
{
|
||||
authorityOptions.Audiences.Add("api://scheduler");
|
||||
}
|
||||
|
||||
authorityOptions.Validate();
|
||||
builder.Services.AddSingleton(authorityOptions);
|
||||
|
||||
builder.Services.AddOptions<SchedulerEventsOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Scheduler:Events"))
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
options.Webhooks ??= new SchedulerInboundWebhooksOptions();
|
||||
options.Webhooks.Feedser ??= SchedulerWebhookOptions.CreateDefault("feedser");
|
||||
options.Webhooks.Vexer ??= SchedulerWebhookOptions.CreateDefault("vexer");
|
||||
|
||||
options.Webhooks.Feedser.Name = string.IsNullOrWhiteSpace(options.Webhooks.Feedser.Name)
|
||||
? "feedser"
|
||||
: options.Webhooks.Feedser.Name;
|
||||
options.Webhooks.Vexer.Name = string.IsNullOrWhiteSpace(options.Webhooks.Vexer.Name)
|
||||
? "vexer"
|
||||
: options.Webhooks.Vexer.Name;
|
||||
|
||||
options.Webhooks.Feedser.Validate();
|
||||
options.Webhooks.Vexer.Validate();
|
||||
});
|
||||
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
options.Webhooks ??= new SchedulerInboundWebhooksOptions();
|
||||
options.Webhooks.Conselier ??= SchedulerWebhookOptions.CreateDefault("conselier");
|
||||
options.Webhooks.Excitor ??= SchedulerWebhookOptions.CreateDefault("excitor");
|
||||
|
||||
options.Webhooks.Conselier.Name = string.IsNullOrWhiteSpace(options.Webhooks.Conselier.Name)
|
||||
? "conselier"
|
||||
: options.Webhooks.Conselier.Name;
|
||||
options.Webhooks.Excitor.Name = string.IsNullOrWhiteSpace(options.Webhooks.Excitor.Name)
|
||||
? "excitor"
|
||||
: options.Webhooks.Excitor.Name;
|
||||
|
||||
options.Webhooks.Conselier.Validate();
|
||||
options.Webhooks.Excitor.Validate();
|
||||
});
|
||||
|
||||
builder.Services.AddMemoryCache();
|
||||
builder.Services.AddSingleton<IWebhookRateLimiter, InMemoryWebhookRateLimiter>();
|
||||
builder.Services.AddSingleton<IWebhookRequestAuthenticator, WebhookRequestAuthenticator>();
|
||||
builder.Services.AddSingleton<IInboundExportEventSink, LoggingExportEventSink>();
|
||||
builder.Services.AddSingleton<IRedisConnectionFactory, RedisConnectionFactory>();
|
||||
|
||||
var cartographerOptions = builder.Configuration.GetSection("Scheduler:Cartographer").Get<SchedulerCartographerOptions>() ?? new SchedulerCartographerOptions();
|
||||
builder.Services.AddSingleton(cartographerOptions);
|
||||
builder.Services.AddOptions<SchedulerCartographerOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Scheduler:Cartographer"));
|
||||
|
||||
var storageSection = builder.Configuration.GetSection("Scheduler:Storage");
|
||||
if (storageSection.Exists())
|
||||
{
|
||||
builder.Services.AddSchedulerMongoStorage(storageSection);
|
||||
builder.Services.AddSingleton<IGraphJobStore, MongoGraphJobStore>();
|
||||
builder.Services.AddSingleton<IPolicyRunService, PolicyRunService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<IGraphJobStore, InMemoryGraphJobStore>();
|
||||
builder.Services.AddSingleton<IScheduleRepository, InMemoryScheduleRepository>();
|
||||
builder.Services.AddSingleton<IRunRepository, InMemoryRunRepository>();
|
||||
builder.Services.AddSingleton<IRunSummaryService, InMemoryRunSummaryService>();
|
||||
builder.Services.AddSingleton<ISchedulerAuditService, InMemorySchedulerAuditService>();
|
||||
builder.Services.AddSingleton<IPolicyRunService, InMemoryPolicyRunService>();
|
||||
}
|
||||
builder.Services.AddSingleton<IGraphJobCompletionPublisher, GraphJobEventPublisher>();
|
||||
if (cartographerOptions.Webhook.Enabled)
|
||||
{
|
||||
builder.Services.AddHttpClient<ICartographerWebhookClient, CartographerWebhookClient>((serviceProvider, client) =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<IOptionsMonitor<SchedulerCartographerOptions>>().CurrentValue;
|
||||
client.Timeout = TimeSpan.FromSeconds(options.Webhook.TimeoutSeconds <= 0 ? 10 : options.Webhook.TimeoutSeconds);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<ICartographerWebhookClient, NullCartographerWebhookClient>();
|
||||
}
|
||||
builder.Services.AddScoped<IGraphJobService, GraphJobService>();
|
||||
builder.Services.AddImpactIndexStub();
|
||||
|
||||
var schedulerOptions = builder.Configuration.GetSection("Scheduler").Get<SchedulerOptions>() ?? new SchedulerOptions();
|
||||
schedulerOptions.Validate();
|
||||
builder.Services.AddSingleton(schedulerOptions);
|
||||
builder.Services.AddOptions<SchedulerOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Scheduler"))
|
||||
.PostConfigure(options => options.Validate());
|
||||
|
||||
var pluginHostOptions = SchedulerPluginHostFactory.Build(schedulerOptions.Plugins, builder.Environment.ContentRootPath);
|
||||
builder.Services.AddSingleton(pluginHostOptions);
|
||||
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
|
||||
|
||||
if (authorityOptions.Enabled)
|
||||
{
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: null,
|
||||
configure: resourceOptions =>
|
||||
{
|
||||
resourceOptions.Authority = authorityOptions.Issuer;
|
||||
resourceOptions.RequireHttpsMetadata = authorityOptions.RequireHttpsMetadata;
|
||||
resourceOptions.MetadataAddress = authorityOptions.MetadataAddress;
|
||||
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(authorityOptions.BackchannelTimeoutSeconds);
|
||||
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(authorityOptions.TokenClockSkewSeconds);
|
||||
|
||||
foreach (var audience in authorityOptions.Audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
|
||||
foreach (var scope in authorityOptions.RequiredScopes)
|
||||
{
|
||||
resourceOptions.RequiredScopes.Add(scope);
|
||||
}
|
||||
|
||||
foreach (var tenant in authorityOptions.RequiredTenants)
|
||||
{
|
||||
resourceOptions.RequiredTenants.Add(tenant);
|
||||
}
|
||||
|
||||
foreach (var network in authorityOptions.BypassNetworks)
|
||||
{
|
||||
resourceOptions.BypassNetworks.Add(network);
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddScoped<ITenantContextAccessor, ClaimsTenantContextAccessor>();
|
||||
builder.Services.AddScoped<IScopeAuthorizer, TokenScopeAuthorizer>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = "Anonymous";
|
||||
options.DefaultChallengeScheme = "Anonymous";
|
||||
}).AddScheme<AuthenticationSchemeOptions, AnonymousAuthenticationHandler>("Anonymous", static _ => { });
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddScoped<ITenantContextAccessor, HeaderTenantContextAccessor>();
|
||||
builder.Services.AddScoped<IScopeAuthorizer, HeaderScopeAuthorizer>();
|
||||
}
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
if (!authorityOptions.Enabled)
|
||||
{
|
||||
app.Logger.LogWarning("Scheduler Authority authentication is disabled; relying on header-based development fallback.");
|
||||
}
|
||||
else if (authorityOptions.AllowAnonymousFallback)
|
||||
{
|
||||
app.Logger.LogWarning("Scheduler Authority authentication is enabled but anonymous fallback remains allowed. Disable fallback before production rollout.");
|
||||
}
|
||||
|
||||
app.MapGet("/healthz", () => Results.Json(new { status = "ok" }));
|
||||
app.MapGet("/readyz", () => Results.Json(new { status = "ready" }));
|
||||
|
||||
app.MapGraphJobEndpoints();
|
||||
app.MapScheduleEndpoints();
|
||||
app.MapRunEndpoints();
|
||||
app.MapPolicyRunEndpoints();
|
||||
app.MapSchedulerEventWebhookEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program;
|
||||
|
||||
var cartographerOptions = builder.Configuration.GetSection("Scheduler:Cartographer").Get<SchedulerCartographerOptions>() ?? new SchedulerCartographerOptions();
|
||||
builder.Services.AddSingleton(cartographerOptions);
|
||||
builder.Services.AddOptions<SchedulerCartographerOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Scheduler:Cartographer"));
|
||||
|
||||
var storageSection = builder.Configuration.GetSection("Scheduler:Storage");
|
||||
if (storageSection.Exists())
|
||||
{
|
||||
builder.Services.AddSchedulerMongoStorage(storageSection);
|
||||
builder.Services.AddSingleton<IGraphJobStore, MongoGraphJobStore>();
|
||||
builder.Services.AddSingleton<IPolicyRunService, PolicyRunService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<IGraphJobStore, InMemoryGraphJobStore>();
|
||||
builder.Services.AddSingleton<IScheduleRepository, InMemoryScheduleRepository>();
|
||||
builder.Services.AddSingleton<IRunRepository, InMemoryRunRepository>();
|
||||
builder.Services.AddSingleton<IRunSummaryService, InMemoryRunSummaryService>();
|
||||
builder.Services.AddSingleton<ISchedulerAuditService, InMemorySchedulerAuditService>();
|
||||
builder.Services.AddSingleton<IPolicyRunService, InMemoryPolicyRunService>();
|
||||
}
|
||||
builder.Services.AddSingleton<IGraphJobCompletionPublisher, GraphJobEventPublisher>();
|
||||
if (cartographerOptions.Webhook.Enabled)
|
||||
{
|
||||
builder.Services.AddHttpClient<ICartographerWebhookClient, CartographerWebhookClient>((serviceProvider, client) =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<IOptionsMonitor<SchedulerCartographerOptions>>().CurrentValue;
|
||||
client.Timeout = TimeSpan.FromSeconds(options.Webhook.TimeoutSeconds <= 0 ? 10 : options.Webhook.TimeoutSeconds);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddSingleton<ICartographerWebhookClient, NullCartographerWebhookClient>();
|
||||
}
|
||||
builder.Services.AddScoped<IGraphJobService, GraphJobService>();
|
||||
builder.Services.AddImpactIndexStub();
|
||||
|
||||
var schedulerOptions = builder.Configuration.GetSection("Scheduler").Get<SchedulerOptions>() ?? new SchedulerOptions();
|
||||
schedulerOptions.Validate();
|
||||
builder.Services.AddSingleton(schedulerOptions);
|
||||
builder.Services.AddOptions<SchedulerOptions>()
|
||||
.Bind(builder.Configuration.GetSection("Scheduler"))
|
||||
.PostConfigure(options => options.Validate());
|
||||
|
||||
var pluginHostOptions = SchedulerPluginHostFactory.Build(schedulerOptions.Plugins, builder.Environment.ContentRootPath);
|
||||
builder.Services.AddSingleton(pluginHostOptions);
|
||||
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
|
||||
|
||||
if (authorityOptions.Enabled)
|
||||
{
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: null,
|
||||
configure: resourceOptions =>
|
||||
{
|
||||
resourceOptions.Authority = authorityOptions.Issuer;
|
||||
resourceOptions.RequireHttpsMetadata = authorityOptions.RequireHttpsMetadata;
|
||||
resourceOptions.MetadataAddress = authorityOptions.MetadataAddress;
|
||||
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(authorityOptions.BackchannelTimeoutSeconds);
|
||||
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(authorityOptions.TokenClockSkewSeconds);
|
||||
|
||||
foreach (var audience in authorityOptions.Audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
|
||||
foreach (var scope in authorityOptions.RequiredScopes)
|
||||
{
|
||||
resourceOptions.RequiredScopes.Add(scope);
|
||||
}
|
||||
|
||||
foreach (var tenant in authorityOptions.RequiredTenants)
|
||||
{
|
||||
resourceOptions.RequiredTenants.Add(tenant);
|
||||
}
|
||||
|
||||
foreach (var network in authorityOptions.BypassNetworks)
|
||||
{
|
||||
resourceOptions.BypassNetworks.Add(network);
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddScoped<ITenantContextAccessor, ClaimsTenantContextAccessor>();
|
||||
builder.Services.AddScoped<IScopeAuthorizer, TokenScopeAuthorizer>();
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = "Anonymous";
|
||||
options.DefaultChallengeScheme = "Anonymous";
|
||||
}).AddScheme<AuthenticationSchemeOptions, AnonymousAuthenticationHandler>("Anonymous", static _ => { });
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddScoped<ITenantContextAccessor, HeaderTenantContextAccessor>();
|
||||
builder.Services.AddScoped<IScopeAuthorizer, HeaderScopeAuthorizer>();
|
||||
}
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
if (!authorityOptions.Enabled)
|
||||
{
|
||||
app.Logger.LogWarning("Scheduler Authority authentication is disabled; relying on header-based development fallback.");
|
||||
}
|
||||
else if (authorityOptions.AllowAnonymousFallback)
|
||||
{
|
||||
app.Logger.LogWarning("Scheduler Authority authentication is enabled but anonymous fallback remains allowed. Disable fallback before production rollout.");
|
||||
}
|
||||
|
||||
app.MapGet("/healthz", () => Results.Json(new { status = "ok" }));
|
||||
app.MapGet("/readyz", () => Results.Json(new { status = "ready" }));
|
||||
|
||||
app.MapGraphJobEndpoints();
|
||||
app.MapScheduleEndpoints();
|
||||
app.MapRunEndpoints();
|
||||
app.MapPolicyRunEndpoints();
|
||||
app.MapSchedulerEventWebhookEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
public partial class Program;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
| SCHED-WEB-16-101 | DONE (2025-10-27) | Scheduler WebService Guild | SCHED-MODELS-16-101 | Bootstrap Minimal API host with Authority OpTok + DPoP, health endpoints, plug-in discovery per architecture §§1–2. | Service boots with config validation; `/healthz`/`/readyz` pass; restart-only plug-ins enforced. |
|
||||
| SCHED-WEB-16-102 | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-16-101 | Implement schedules CRUD (tenant-scoped) with cron validation, pause/resume, audit logging. | CRUD operations tested; invalid cron inputs rejected; audit entries persisted. |
|
||||
| SCHED-WEB-16-103 | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-16-102 | Runs API (list/detail/cancel), ad-hoc run POST, and impact preview endpoints. | Integration tests cover run lifecycle; preview returns counts/sample; cancellation honoured. |
|
||||
| SCHED-WEB-16-104 | DONE (2025-10-27) | Scheduler WebService Guild | SCHED-QUEUE-16-401, SCHED-STORAGE-16-201 | Webhook endpoints for Feeder/Vexer exports with mTLS/HMAC validation and rate limiting. | Webhooks validated via tests; invalid signatures rejected; rate limits documented. |
|
||||
| SCHED-WEB-16-104 | DONE (2025-10-27) | Scheduler WebService Guild | SCHED-QUEUE-16-401, SCHED-STORAGE-16-201 | Webhook endpoints for Feeder/Excitor exports with mTLS/HMAC validation and rate limiting. | Webhooks validated via tests; invalid signatures rejected; rate limits documented. |
|
||||
| SCHED-WEB-20-001 | DONE (2025-10-29) | Scheduler WebService Guild, Policy Guild | SCHED-WEB-16-101, POLICY-ENGINE-20-000 | Expose policy run scheduling APIs (`POST /policy/runs`, `GET /policy/runs`) with tenant scoping and RBAC enforcement for `policy:run`. | Endpoints documented; integration tests cover run creation/status; unauthorized access blocked. |
|
||||
| SCHED-WEB-21-001 | DONE (2025-10-26) | Scheduler WebService Guild, Cartographer Guild | SCHED-WEB-16-101, SCHED-MODELS-21-001 | Expose graph build/overlay job APIs (`POST /graphs/build`, `GET /graphs/jobs`) with `graph:write`/`graph:read` enforcement and tenant scoping. | APIs documented in `docs/SCHED-WEB-21-001-GRAPH-APIS.md`; integration tests cover submission/status; unauthorized requests blocked; scope checks now reference `StellaOpsScopes`. |
|
||||
| SCHED-WEB-21-002 | DONE (2025-10-26) | Scheduler WebService Guild | SCHED-WEB-21-001, CARTO-GRAPH-21-007 | Provide overlay lag metrics endpoint and webhook to notify Cartographer of job completions; include correlation IDs. | `POST /graphs/hooks/completed` + `GET /graphs/overlays/lag` documented in `docs/SCHED-WEB-21-001-GRAPH-APIS.md`; integration tests cover completion + metrics. |
|
||||
|
||||
@@ -1,58 +1,58 @@
|
||||
# SCHED-WEB-16-104 · Feedser/Vexer Webhook Endpoints
|
||||
|
||||
## Overview
|
||||
|
||||
Scheduler.WebService exposes inbound webhooks that allow Feedser and Vexer to
|
||||
notify the planner when new exports are available. Each webhook validates the
|
||||
payload, enforces signature requirements, and applies a per-endpoint rate
|
||||
limit before queuing downstream processing.
|
||||
|
||||
| Endpoint | Description | AuthZ |
|
||||
|----------|-------------|-------|
|
||||
| `POST /events/feedser-export` | Ingest Feedser export metadata (`exportId`, `changedProductKeys`, optional KEV & window). | HMAC `X-Scheduler-Signature` and/or mTLS client certificate |
|
||||
| `POST /events/vexer-export` | Ingest Vexer export delta summary (`changedClaims`). | HMAC `X-Scheduler-Signature` and/or mTLS client certificate |
|
||||
|
||||
## Security
|
||||
|
||||
* Webhooks require either:
|
||||
* mTLS with trusted client certificates; **or**
|
||||
* an HMAC-SHA256 signature in the `X-Scheduler-Signature` header. The
|
||||
signature must be computed as `sha256=<hex>` over the raw request body.
|
||||
* Requests without the required signature/certificate return `401`.
|
||||
* Secrets are configured under `Scheduler:Events:Webhooks:{Feedser|Vexer}:HmacSecret`.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
* Each webhook enforces a sliding-window limit (`RateLimitRequests` over
|
||||
`RateLimitWindowSeconds`).
|
||||
* Requests over the limit return `429` and include a `Retry-After` header.
|
||||
* Defaults: 120 requests / 60 seconds. Adjust via configuration.
|
||||
|
||||
## Configuration
|
||||
|
||||
```
|
||||
Scheduler:
|
||||
Events:
|
||||
Webhooks:
|
||||
Feedser:
|
||||
Enabled: true
|
||||
HmacSecret: feedser-secret
|
||||
RequireClientCertificate: false
|
||||
RateLimitRequests: 120
|
||||
RateLimitWindowSeconds: 60
|
||||
Vexer:
|
||||
Enabled: true
|
||||
HmacSecret: vexer-secret
|
||||
RequireClientCertificate: false
|
||||
```
|
||||
|
||||
## Response envelope
|
||||
|
||||
On success the webhook returns `202 Accepted` and a JSON body:
|
||||
|
||||
```
|
||||
{ "status": "accepted" }
|
||||
```
|
||||
|
||||
Failures return problem JSON with `error` describing the violation.
|
||||
|
||||
# SCHED-WEB-16-104 · Conselier/Excitor Webhook Endpoints
|
||||
|
||||
## Overview
|
||||
|
||||
Scheduler.WebService exposes inbound webhooks that allow Conselier and Excitor to
|
||||
notify the planner when new exports are available. Each webhook validates the
|
||||
payload, enforces signature requirements, and applies a per-endpoint rate
|
||||
limit before queuing downstream processing.
|
||||
|
||||
| Endpoint | Description | AuthZ |
|
||||
|----------|-------------|-------|
|
||||
| `POST /events/conselier-export` | Ingest Conselier export metadata (`exportId`, `changedProductKeys`, optional KEV & window). | HMAC `X-Scheduler-Signature` and/or mTLS client certificate |
|
||||
| `POST /events/excitor-export` | Ingest Excitor export delta summary (`changedClaims`). | HMAC `X-Scheduler-Signature` and/or mTLS client certificate |
|
||||
|
||||
## Security
|
||||
|
||||
* Webhooks require either:
|
||||
* mTLS with trusted client certificates; **or**
|
||||
* an HMAC-SHA256 signature in the `X-Scheduler-Signature` header. The
|
||||
signature must be computed as `sha256=<hex>` over the raw request body.
|
||||
* Requests without the required signature/certificate return `401`.
|
||||
* Secrets are configured under `Scheduler:Events:Webhooks:{Conselier|Excitor}:HmacSecret`.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
* Each webhook enforces a sliding-window limit (`RateLimitRequests` over
|
||||
`RateLimitWindowSeconds`).
|
||||
* Requests over the limit return `429` and include a `Retry-After` header.
|
||||
* Defaults: 120 requests / 60 seconds. Adjust via configuration.
|
||||
|
||||
## Configuration
|
||||
|
||||
```
|
||||
Scheduler:
|
||||
Events:
|
||||
Webhooks:
|
||||
Conselier:
|
||||
Enabled: true
|
||||
HmacSecret: conselier-secret
|
||||
RequireClientCertificate: false
|
||||
RateLimitRequests: 120
|
||||
RateLimitWindowSeconds: 60
|
||||
Excitor:
|
||||
Enabled: true
|
||||
HmacSecret: excitor-secret
|
||||
RequireClientCertificate: false
|
||||
```
|
||||
|
||||
## Response envelope
|
||||
|
||||
On success the webhook returns `202 Accepted` and a JSON body:
|
||||
|
||||
```
|
||||
{ "status": "accepted" }
|
||||
```
|
||||
|
||||
Failures return problem JSON with `error` describing the violation.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user