up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to enqueue a dead-letter entry.
|
||||
/// </summary>
|
||||
public sealed record EnqueueDeadLetterRequest
|
||||
{
|
||||
public required string DeliveryId { get; init; }
|
||||
public required string EventId { get; init; }
|
||||
public required string ChannelId { get; init; }
|
||||
public required string ChannelType { get; init; }
|
||||
public required string FailureReason { get; init; }
|
||||
public string? FailureDetails { get; init; }
|
||||
public int AttemptCount { get; init; }
|
||||
public DateTimeOffset? LastAttemptAt { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
public string? OriginalPayload { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for dead-letter entry operations.
|
||||
/// </summary>
|
||||
public sealed record DeadLetterEntryResponse
|
||||
{
|
||||
public required string EntryId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string DeliveryId { get; init; }
|
||||
public required string EventId { get; init; }
|
||||
public required string ChannelId { get; init; }
|
||||
public required string ChannelType { get; init; }
|
||||
public required string FailureReason { get; init; }
|
||||
public string? FailureDetails { get; init; }
|
||||
public required int AttemptCount { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastAttemptAt { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public int RetryCount { get; init; }
|
||||
public DateTimeOffset? LastRetryAt { get; init; }
|
||||
public string? Resolution { get; init; }
|
||||
public string? ResolvedBy { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to list dead-letter entries.
|
||||
/// </summary>
|
||||
public sealed record ListDeadLetterRequest
|
||||
{
|
||||
public string? Status { get; init; }
|
||||
public string? ChannelId { get; init; }
|
||||
public string? ChannelType { get; init; }
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
public DateTimeOffset? Until { get; init; }
|
||||
public int Limit { get; init; } = 50;
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for listing dead-letter entries.
|
||||
/// </summary>
|
||||
public sealed record ListDeadLetterResponse
|
||||
{
|
||||
public required IReadOnlyList<DeadLetterEntryResponse> Entries { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to retry dead-letter entries.
|
||||
/// </summary>
|
||||
public sealed record RetryDeadLetterRequest
|
||||
{
|
||||
public required IReadOnlyList<string> EntryIds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for retry operations.
|
||||
/// </summary>
|
||||
public sealed record RetryDeadLetterResponse
|
||||
{
|
||||
public required IReadOnlyList<DeadLetterRetryResultItem> Results { get; init; }
|
||||
public required int SuccessCount { get; init; }
|
||||
public required int FailureCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Individual retry result.
|
||||
/// </summary>
|
||||
public sealed record DeadLetterRetryResultItem
|
||||
{
|
||||
public required string EntryId { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public DateTimeOffset? RetriedAt { get; init; }
|
||||
public string? NewDeliveryId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve a dead-letter entry.
|
||||
/// </summary>
|
||||
public sealed record ResolveDeadLetterRequest
|
||||
{
|
||||
public required string Resolution { get; init; }
|
||||
public string? ResolvedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for dead-letter statistics.
|
||||
/// </summary>
|
||||
public sealed record DeadLetterStatsResponse
|
||||
{
|
||||
public required int TotalCount { get; init; }
|
||||
public required int PendingCount { get; init; }
|
||||
public required int RetryingCount { get; init; }
|
||||
public required int RetriedCount { get; init; }
|
||||
public required int ResolvedCount { get; init; }
|
||||
public required int ExhaustedCount { get; init; }
|
||||
public required IReadOnlyDictionary<string, int> ByChannel { get; init; }
|
||||
public required IReadOnlyDictionary<string, int> ByReason { get; init; }
|
||||
public DateTimeOffset? OldestEntryAt { get; init; }
|
||||
public DateTimeOffset? NewestEntryAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to purge expired entries.
|
||||
/// </summary>
|
||||
public sealed record PurgeDeadLetterRequest
|
||||
{
|
||||
public int MaxAgeDays { get; init; } = 30;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for purge operation.
|
||||
/// </summary>
|
||||
public sealed record PurgeDeadLetterResponse
|
||||
{
|
||||
public required int PurgedCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Retention policy configuration request/response.
|
||||
/// </summary>
|
||||
public sealed record RetentionPolicyDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Retention period for delivery records in days.
|
||||
/// </summary>
|
||||
public int DeliveryRetentionDays { get; init; } = 90;
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for audit log entries in days.
|
||||
/// </summary>
|
||||
public int AuditRetentionDays { get; init; } = 365;
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for dead-letter entries in days.
|
||||
/// </summary>
|
||||
public int DeadLetterRetentionDays { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for storm tracking data in days.
|
||||
/// </summary>
|
||||
public int StormDataRetentionDays { get; init; } = 7;
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for inbox messages in days.
|
||||
/// </summary>
|
||||
public int InboxRetentionDays { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for event history in days.
|
||||
/// </summary>
|
||||
public int EventHistoryRetentionDays { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Whether automatic cleanup is enabled.
|
||||
/// </summary>
|
||||
public bool AutoCleanupEnabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Cron expression for automatic cleanup schedule.
|
||||
/// </summary>
|
||||
public string CleanupSchedule { get; init; } = "0 2 * * *";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum records to delete per cleanup run.
|
||||
/// </summary>
|
||||
public int MaxDeletesPerRun { get; init; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to keep resolved/acknowledged deliveries longer.
|
||||
/// </summary>
|
||||
public bool ExtendResolvedRetention { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Extension multiplier for resolved items.
|
||||
/// </summary>
|
||||
public double ResolvedRetentionMultiplier { get; init; } = 2.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to update retention policy.
|
||||
/// </summary>
|
||||
public sealed record UpdateRetentionPolicyRequest
|
||||
{
|
||||
public required RetentionPolicyDto Policy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for retention policy operations.
|
||||
/// </summary>
|
||||
public sealed record RetentionPolicyResponse
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required RetentionPolicyDto Policy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for retention cleanup execution.
|
||||
/// </summary>
|
||||
public sealed record RetentionCleanupResponse
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public required DateTimeOffset ExecutedAt { get; init; }
|
||||
public required double DurationMs { get; init; }
|
||||
public required RetentionCleanupCountsDto Counts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleanup counts DTO.
|
||||
/// </summary>
|
||||
public sealed record RetentionCleanupCountsDto
|
||||
{
|
||||
public int Deliveries { get; init; }
|
||||
public int AuditEntries { get; init; }
|
||||
public int DeadLetterEntries { get; init; }
|
||||
public int StormData { get; init; }
|
||||
public int InboxMessages { get; init; }
|
||||
public int Events { get; init; }
|
||||
public int Total { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for cleanup preview.
|
||||
/// </summary>
|
||||
public sealed record RetentionCleanupPreviewResponse
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required DateTimeOffset PreviewedAt { get; init; }
|
||||
public required RetentionCleanupCountsDto EstimatedCounts { get; init; }
|
||||
public required RetentionPolicyDto PolicyApplied { get; init; }
|
||||
public required IReadOnlyDictionary<string, DateTimeOffset> CutoffDates { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for last cleanup execution.
|
||||
/// </summary>
|
||||
public sealed record RetentionCleanupExecutionResponse
|
||||
{
|
||||
public required string ExecutionId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public RetentionCleanupCountsDto? Counts { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for cleanup all tenants.
|
||||
/// </summary>
|
||||
public sealed record RetentionCleanupAllResponse
|
||||
{
|
||||
public required IReadOnlyList<RetentionCleanupResponse> Results { get; init; }
|
||||
public required int SuccessCount { get; init; }
|
||||
public required int FailureCount { get; init; }
|
||||
public required int TotalDeleted { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to acknowledge a notification via signed token.
|
||||
/// </summary>
|
||||
public sealed record AckRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional comment for the acknowledgement.
|
||||
/// </summary>
|
||||
public string? Comment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata to include with the acknowledgement.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from acknowledging a notification.
|
||||
/// </summary>
|
||||
public sealed record AckResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the acknowledgement was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The delivery ID that was acknowledged.
|
||||
/// </summary>
|
||||
public string? DeliveryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The action that was performed.
|
||||
/// </summary>
|
||||
public string? Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the acknowledgement was processed.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ProcessedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if unsuccessful.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an acknowledgement token.
|
||||
/// </summary>
|
||||
public sealed record CreateAckTokenRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The delivery ID to create an ack token for.
|
||||
/// </summary>
|
||||
public string? DeliveryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The action to acknowledge (e.g., "ack", "resolve", "escalate").
|
||||
/// </summary>
|
||||
public string? Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional expiration in hours. Default: 168 (7 days).
|
||||
/// </summary>
|
||||
public int? ExpirationHours { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata to embed in the token.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing the created ack token.
|
||||
/// </summary>
|
||||
public sealed record CreateAckTokenResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The signed token string.
|
||||
/// </summary>
|
||||
public required string Token { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The full acknowledgement URL.
|
||||
/// </summary>
|
||||
public required string AckUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the token expires.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to verify an ack token.
|
||||
/// </summary>
|
||||
public sealed record VerifyAckTokenRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The token to verify.
|
||||
/// </summary>
|
||||
public string? Token { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from token verification.
|
||||
/// </summary>
|
||||
public sealed record VerifyAckTokenResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the token is valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The delivery ID embedded in the token.
|
||||
/// </summary>
|
||||
public string? DeliveryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The action embedded in the token.
|
||||
/// </summary>
|
||||
public string? Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the token expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Failure reason if invalid.
|
||||
/// </summary>
|
||||
public string? FailureReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to validate HTML content.
|
||||
/// </summary>
|
||||
public sealed record ValidateHtmlRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The HTML content to validate.
|
||||
/// </summary>
|
||||
public string? Html { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from HTML validation.
|
||||
/// </summary>
|
||||
public sealed record ValidateHtmlResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the HTML is safe.
|
||||
/// </summary>
|
||||
public required bool IsSafe { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of security issues found.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<HtmlIssue> Issues { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about the HTML content.
|
||||
/// </summary>
|
||||
public HtmlStats? Stats { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An HTML security issue.
|
||||
/// </summary>
|
||||
public sealed record HtmlIssue
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of issue.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the issue.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The element name if applicable.
|
||||
/// </summary>
|
||||
public string? Element { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The attribute name if applicable.
|
||||
/// </summary>
|
||||
public string? Attribute { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HTML content statistics.
|
||||
/// </summary>
|
||||
public sealed record HtmlStats
|
||||
{
|
||||
/// <summary>
|
||||
/// Total character count.
|
||||
/// </summary>
|
||||
public int CharacterCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of HTML elements.
|
||||
/// </summary>
|
||||
public int ElementCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum nesting depth.
|
||||
/// </summary>
|
||||
public int MaxDepth { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of links.
|
||||
/// </summary>
|
||||
public int LinkCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of images.
|
||||
/// </summary>
|
||||
public int ImageCount { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to sanitize HTML content.
|
||||
/// </summary>
|
||||
public sealed record SanitizeHtmlRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The HTML content to sanitize.
|
||||
/// </summary>
|
||||
public string? Html { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow data: URLs. Default: false.
|
||||
/// </summary>
|
||||
public bool AllowDataUrls { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional tags to allow.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string>? AdditionalAllowedTags { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing sanitized HTML.
|
||||
/// </summary>
|
||||
public sealed record SanitizeHtmlResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The sanitized HTML content.
|
||||
/// </summary>
|
||||
public required string SanitizedHtml { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether any changes were made.
|
||||
/// </summary>
|
||||
public required bool WasModified { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to rotate a webhook secret.
|
||||
/// </summary>
|
||||
public sealed record RotateWebhookSecretRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The channel ID to rotate the secret for.
|
||||
/// </summary>
|
||||
public string? ChannelId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from webhook secret rotation.
|
||||
/// </summary>
|
||||
public sealed record RotateWebhookSecretResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether rotation succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The new secret (only shown once).
|
||||
/// </summary>
|
||||
public string? NewSecret { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the new secret becomes active.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ActiveAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the old secret expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? OldSecretExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if unsuccessful.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
@@ -12,7 +12,11 @@ using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notifier.WebService.Services;
|
||||
using StellaOps.Notifier.WebService.Setup;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
using StellaOps.Notifier.Worker.StormBreaker;
|
||||
using StellaOps.Notifier.Worker.DeadLetter;
|
||||
using StellaOps.Notifier.Worker.Retention;
|
||||
using StellaOps.Notifier.Worker.Observability;
|
||||
using StellaOps.Notify.Storage.Mongo;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
@@ -53,6 +57,20 @@ builder.Services.AddSingleton<ILocalizationResolver, DefaultLocalizationResolver
|
||||
builder.Services.Configure<StormBreakerConfig>(builder.Configuration.GetSection("notifier:stormBreaker"));
|
||||
builder.Services.AddSingleton<IStormBreaker, DefaultStormBreaker>();
|
||||
|
||||
// Security services (NOTIFY-SVC-40-003)
|
||||
builder.Services.Configure<AckTokenOptions>(builder.Configuration.GetSection("notifier:security:ackToken"));
|
||||
builder.Services.AddSingleton<IAckTokenService, HmacAckTokenService>();
|
||||
builder.Services.Configure<WebhookSecurityOptions>(builder.Configuration.GetSection("notifier:security:webhook"));
|
||||
builder.Services.AddSingleton<IWebhookSecurityService, DefaultWebhookSecurityService>();
|
||||
builder.Services.AddSingleton<IHtmlSanitizer, DefaultHtmlSanitizer>();
|
||||
builder.Services.Configure<TenantIsolationOptions>(builder.Configuration.GetSection("notifier:security:tenantIsolation"));
|
||||
builder.Services.AddSingleton<ITenantIsolationValidator, DefaultTenantIsolationValidator>();
|
||||
|
||||
// Observability, dead-letter, and retention services (NOTIFY-SVC-40-004)
|
||||
builder.Services.AddSingleton<INotifyMetrics, DefaultNotifyMetrics>();
|
||||
builder.Services.AddSingleton<IDeadLetterService, InMemoryDeadLetterService>();
|
||||
builder.Services.AddSingleton<IRetentionPolicyService, DefaultRetentionPolicyService>();
|
||||
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
var app = builder.Build();
|
||||
@@ -2165,6 +2183,712 @@ app.MapPost("/api/v2/notify/storms/{stormKey}/summary", async (
|
||||
return Results.Ok(summary);
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Security API (NOTIFY-SVC-40-003)
|
||||
// =============================================
|
||||
|
||||
// Acknowledge notification via signed token
|
||||
app.MapGet("/api/v1/ack/{token}", async (
|
||||
HttpContext context,
|
||||
string token,
|
||||
IAckTokenService ackTokenService,
|
||||
INotifyAuditRepository auditRepository,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var verification = ackTokenService.VerifyToken(token);
|
||||
|
||||
if (!verification.IsValid)
|
||||
{
|
||||
return Results.BadRequest(new AckResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = verification.FailureReason?.ToString() ?? "Invalid token"
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var auditEntry = new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = verification.Token!.TenantId,
|
||||
Actor = "ack-link",
|
||||
Action = $"delivery.{verification.Token.Action}",
|
||||
EntityId = verification.Token.DeliveryId,
|
||||
EntityType = "delivery",
|
||||
Timestamp = timeProvider.GetUtcNow()
|
||||
};
|
||||
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
catch { }
|
||||
|
||||
return Results.Ok(new AckResponse
|
||||
{
|
||||
Success = true,
|
||||
DeliveryId = verification.Token!.DeliveryId,
|
||||
Action = verification.Token.Action,
|
||||
ProcessedAt = timeProvider.GetUtcNow()
|
||||
});
|
||||
});
|
||||
|
||||
app.MapPost("/api/v1/ack/{token}", async (
|
||||
HttpContext context,
|
||||
string token,
|
||||
AckRequest? request,
|
||||
IAckTokenService ackTokenService,
|
||||
INotifyAuditRepository auditRepository,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var verification = ackTokenService.VerifyToken(token);
|
||||
|
||||
if (!verification.IsValid)
|
||||
{
|
||||
return Results.BadRequest(new AckResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = verification.FailureReason?.ToString() ?? "Invalid token"
|
||||
});
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var auditEntry = new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = verification.Token!.TenantId,
|
||||
Actor = "ack-link",
|
||||
Action = $"delivery.{verification.Token.Action}",
|
||||
EntityId = verification.Token.DeliveryId,
|
||||
EntityType = "delivery",
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
|
||||
JsonSerializer.Serialize(new { comment = request?.Comment, metadata = request?.Metadata }))
|
||||
};
|
||||
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
catch { }
|
||||
|
||||
return Results.Ok(new AckResponse
|
||||
{
|
||||
Success = true,
|
||||
DeliveryId = verification.Token!.DeliveryId,
|
||||
Action = verification.Token.Action,
|
||||
ProcessedAt = timeProvider.GetUtcNow()
|
||||
});
|
||||
});
|
||||
|
||||
app.MapPost("/api/v2/notify/security/ack-tokens", (
|
||||
HttpContext context,
|
||||
CreateAckTokenRequest request,
|
||||
IAckTokenService ackTokenService) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.DeliveryId) || string.IsNullOrWhiteSpace(request.Action))
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_request", "deliveryId and action are required.", context));
|
||||
}
|
||||
|
||||
var expiration = request.ExpirationHours.HasValue
|
||||
? TimeSpan.FromHours(request.ExpirationHours.Value)
|
||||
: (TimeSpan?)null;
|
||||
|
||||
var token = ackTokenService.CreateToken(
|
||||
tenantId,
|
||||
request.DeliveryId,
|
||||
request.Action,
|
||||
expiration,
|
||||
request.Metadata);
|
||||
|
||||
return Results.Ok(new CreateAckTokenResponse
|
||||
{
|
||||
Token = token.TokenString,
|
||||
AckUrl = ackTokenService.CreateAckUrl(token),
|
||||
ExpiresAt = token.ExpiresAt
|
||||
});
|
||||
});
|
||||
|
||||
app.MapPost("/api/v2/notify/security/ack-tokens/verify", (
|
||||
HttpContext context,
|
||||
VerifyAckTokenRequest request,
|
||||
IAckTokenService ackTokenService) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Token))
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_request", "token is required.", context));
|
||||
}
|
||||
|
||||
var verification = ackTokenService.VerifyToken(request.Token);
|
||||
|
||||
return Results.Ok(new VerifyAckTokenResponse
|
||||
{
|
||||
IsValid = verification.IsValid,
|
||||
DeliveryId = verification.Token?.DeliveryId,
|
||||
Action = verification.Token?.Action,
|
||||
ExpiresAt = verification.Token?.ExpiresAt,
|
||||
FailureReason = verification.FailureReason?.ToString()
|
||||
});
|
||||
});
|
||||
|
||||
app.MapPost("/api/v2/notify/security/html/validate", (
|
||||
HttpContext context,
|
||||
ValidateHtmlRequest request,
|
||||
IHtmlSanitizer htmlSanitizer) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Html))
|
||||
{
|
||||
return Results.Ok(new ValidateHtmlResponse
|
||||
{
|
||||
IsSafe = true,
|
||||
Issues = []
|
||||
});
|
||||
}
|
||||
|
||||
var result = htmlSanitizer.Validate(request.Html);
|
||||
|
||||
return Results.Ok(new ValidateHtmlResponse
|
||||
{
|
||||
IsSafe = result.IsSafe,
|
||||
Issues = result.Issues.Select(i => new HtmlIssue
|
||||
{
|
||||
Type = i.Type.ToString(),
|
||||
Description = i.Description,
|
||||
Element = i.ElementName,
|
||||
Attribute = i.AttributeName
|
||||
}).ToArray(),
|
||||
Stats = result.Stats is not null ? new HtmlStats
|
||||
{
|
||||
CharacterCount = result.Stats.CharacterCount,
|
||||
ElementCount = result.Stats.ElementCount,
|
||||
MaxDepth = result.Stats.MaxDepth,
|
||||
LinkCount = result.Stats.LinkCount,
|
||||
ImageCount = result.Stats.ImageCount
|
||||
} : null
|
||||
});
|
||||
});
|
||||
|
||||
app.MapPost("/api/v2/notify/security/html/sanitize", (
|
||||
HttpContext context,
|
||||
SanitizeHtmlRequest request,
|
||||
IHtmlSanitizer htmlSanitizer) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Html))
|
||||
{
|
||||
return Results.Ok(new SanitizeHtmlResponse
|
||||
{
|
||||
SanitizedHtml = string.Empty,
|
||||
WasModified = false
|
||||
});
|
||||
}
|
||||
|
||||
var options = new HtmlSanitizeOptions
|
||||
{
|
||||
AllowDataUrls = request.AllowDataUrls,
|
||||
AdditionalAllowedTags = request.AdditionalAllowedTags?.ToHashSet()
|
||||
};
|
||||
|
||||
var sanitized = htmlSanitizer.Sanitize(request.Html, options);
|
||||
|
||||
return Results.Ok(new SanitizeHtmlResponse
|
||||
{
|
||||
SanitizedHtml = sanitized,
|
||||
WasModified = !string.Equals(request.Html, sanitized, StringComparison.Ordinal)
|
||||
});
|
||||
});
|
||||
|
||||
app.MapPost("/api/v2/notify/security/webhook/{channelId}/rotate", async (
|
||||
HttpContext context,
|
||||
string channelId,
|
||||
IWebhookSecurityService webhookSecurityService,
|
||||
INotifyAuditRepository auditRepository,
|
||||
TimeProvider timeProvider) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(actor)) actor = "api";
|
||||
|
||||
var result = await webhookSecurityService.RotateSecretAsync(tenantId, channelId, context.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var auditEntry = new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Actor = actor,
|
||||
Action = "webhook.secret.rotated",
|
||||
EntityId = channelId,
|
||||
EntityType = "channel",
|
||||
Timestamp = timeProvider.GetUtcNow()
|
||||
};
|
||||
await auditRepository.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
catch { }
|
||||
|
||||
return Results.Ok(new RotateWebhookSecretResponse
|
||||
{
|
||||
Success = result.Success,
|
||||
NewSecret = result.NewSecret,
|
||||
ActiveAt = result.ActiveAt,
|
||||
OldSecretExpiresAt = result.OldSecretExpiresAt,
|
||||
Error = result.Error
|
||||
});
|
||||
});
|
||||
|
||||
app.MapGet("/api/v2/notify/security/webhook/{channelId}/secret", (
|
||||
HttpContext context,
|
||||
string channelId,
|
||||
IWebhookSecurityService webhookSecurityService) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var maskedSecret = webhookSecurityService.GetMaskedSecret(tenantId, channelId);
|
||||
|
||||
return Results.Ok(new { channelId, maskedSecret });
|
||||
});
|
||||
|
||||
app.MapGet("/api/v2/notify/security/isolation/violations", (
|
||||
HttpContext context,
|
||||
ITenantIsolationValidator isolationValidator,
|
||||
int? limit) =>
|
||||
{
|
||||
var violations = isolationValidator.GetRecentViolations(limit ?? 100);
|
||||
|
||||
return Results.Ok(new { items = violations, count = violations.Count });
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Dead-Letter API (NOTIFY-SVC-40-004)
|
||||
// =============================================
|
||||
|
||||
app.MapPost("/api/v2/notify/dead-letter", async (
|
||||
HttpContext context,
|
||||
EnqueueDeadLetterRequest request,
|
||||
IDeadLetterService deadLetterService) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var enqueueRequest = new DeadLetterEnqueueRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
DeliveryId = request.DeliveryId,
|
||||
EventId = request.EventId,
|
||||
ChannelId = request.ChannelId,
|
||||
ChannelType = request.ChannelType,
|
||||
FailureReason = request.FailureReason,
|
||||
FailureDetails = request.FailureDetails,
|
||||
AttemptCount = request.AttemptCount,
|
||||
LastAttemptAt = request.LastAttemptAt,
|
||||
Metadata = request.Metadata,
|
||||
OriginalPayload = request.OriginalPayload
|
||||
};
|
||||
|
||||
var entry = await deadLetterService.EnqueueAsync(enqueueRequest, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v2/notify/dead-letter/{entry.EntryId}", new DeadLetterEntryResponse
|
||||
{
|
||||
EntryId = entry.EntryId,
|
||||
TenantId = entry.TenantId,
|
||||
DeliveryId = entry.DeliveryId,
|
||||
EventId = entry.EventId,
|
||||
ChannelId = entry.ChannelId,
|
||||
ChannelType = entry.ChannelType,
|
||||
FailureReason = entry.FailureReason,
|
||||
FailureDetails = entry.FailureDetails,
|
||||
AttemptCount = entry.AttemptCount,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
LastAttemptAt = entry.LastAttemptAt,
|
||||
Status = entry.Status.ToString(),
|
||||
RetryCount = entry.RetryCount,
|
||||
LastRetryAt = entry.LastRetryAt,
|
||||
Resolution = entry.Resolution,
|
||||
ResolvedBy = entry.ResolvedBy,
|
||||
ResolvedAt = entry.ResolvedAt
|
||||
});
|
||||
});
|
||||
|
||||
app.MapGet("/api/v2/notify/dead-letter", async (
|
||||
HttpContext context,
|
||||
IDeadLetterService deadLetterService,
|
||||
string? status,
|
||||
string? channelId,
|
||||
string? channelType,
|
||||
DateTimeOffset? since,
|
||||
DateTimeOffset? until,
|
||||
int? limit,
|
||||
int? offset) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var options = new DeadLetterListOptions
|
||||
{
|
||||
Status = Enum.TryParse<DeadLetterStatus>(status, true, out var s) ? s : null,
|
||||
ChannelId = channelId,
|
||||
ChannelType = channelType,
|
||||
Since = since,
|
||||
Until = until,
|
||||
Limit = limit ?? 50,
|
||||
Offset = offset ?? 0
|
||||
};
|
||||
|
||||
var entries = await deadLetterService.ListAsync(tenantId, options, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new ListDeadLetterResponse
|
||||
{
|
||||
Entries = entries.Select(e => new DeadLetterEntryResponse
|
||||
{
|
||||
EntryId = e.EntryId,
|
||||
TenantId = e.TenantId,
|
||||
DeliveryId = e.DeliveryId,
|
||||
EventId = e.EventId,
|
||||
ChannelId = e.ChannelId,
|
||||
ChannelType = e.ChannelType,
|
||||
FailureReason = e.FailureReason,
|
||||
FailureDetails = e.FailureDetails,
|
||||
AttemptCount = e.AttemptCount,
|
||||
CreatedAt = e.CreatedAt,
|
||||
LastAttemptAt = e.LastAttemptAt,
|
||||
Status = e.Status.ToString(),
|
||||
RetryCount = e.RetryCount,
|
||||
LastRetryAt = e.LastRetryAt,
|
||||
Resolution = e.Resolution,
|
||||
ResolvedBy = e.ResolvedBy,
|
||||
ResolvedAt = e.ResolvedAt
|
||||
}).ToList(),
|
||||
TotalCount = entries.Count
|
||||
});
|
||||
});
|
||||
|
||||
app.MapGet("/api/v2/notify/dead-letter/{entryId}", async (
|
||||
HttpContext context,
|
||||
string entryId,
|
||||
IDeadLetterService deadLetterService) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var entry = await deadLetterService.GetAsync(tenantId, entryId, context.RequestAborted).ConfigureAwait(false);
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound(Error("entry_not_found", $"Dead-letter entry {entryId} not found.", context));
|
||||
}
|
||||
|
||||
return Results.Ok(new DeadLetterEntryResponse
|
||||
{
|
||||
EntryId = entry.EntryId,
|
||||
TenantId = entry.TenantId,
|
||||
DeliveryId = entry.DeliveryId,
|
||||
EventId = entry.EventId,
|
||||
ChannelId = entry.ChannelId,
|
||||
ChannelType = entry.ChannelType,
|
||||
FailureReason = entry.FailureReason,
|
||||
FailureDetails = entry.FailureDetails,
|
||||
AttemptCount = entry.AttemptCount,
|
||||
CreatedAt = entry.CreatedAt,
|
||||
LastAttemptAt = entry.LastAttemptAt,
|
||||
Status = entry.Status.ToString(),
|
||||
RetryCount = entry.RetryCount,
|
||||
LastRetryAt = entry.LastRetryAt,
|
||||
Resolution = entry.Resolution,
|
||||
ResolvedBy = entry.ResolvedBy,
|
||||
ResolvedAt = entry.ResolvedAt
|
||||
});
|
||||
});
|
||||
|
||||
app.MapPost("/api/v2/notify/dead-letter/retry", async (
|
||||
HttpContext context,
|
||||
RetryDeadLetterRequest request,
|
||||
IDeadLetterService deadLetterService) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var results = await deadLetterService.RetryBatchAsync(tenantId, request.EntryIds, context.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new RetryDeadLetterResponse
|
||||
{
|
||||
Results = results.Select(r => new DeadLetterRetryResultItem
|
||||
{
|
||||
EntryId = r.EntryId,
|
||||
Success = r.Success,
|
||||
Error = r.Error,
|
||||
RetriedAt = r.RetriedAt,
|
||||
NewDeliveryId = r.NewDeliveryId
|
||||
}).ToList(),
|
||||
SuccessCount = results.Count(r => r.Success),
|
||||
FailureCount = results.Count(r => !r.Success)
|
||||
});
|
||||
});
|
||||
|
||||
app.MapPost("/api/v2/notify/dead-letter/{entryId}/resolve", async (
|
||||
HttpContext context,
|
||||
string entryId,
|
||||
ResolveDeadLetterRequest request,
|
||||
IDeadLetterService deadLetterService) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
await deadLetterService.ResolveAsync(tenantId, entryId, request.Resolution, request.ResolvedBy, context.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
app.MapGet("/api/v2/notify/dead-letter/stats", async (
|
||||
HttpContext context,
|
||||
IDeadLetterService deadLetterService) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var stats = await deadLetterService.GetStatsAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new DeadLetterStatsResponse
|
||||
{
|
||||
TotalCount = stats.TotalCount,
|
||||
PendingCount = stats.PendingCount,
|
||||
RetryingCount = stats.RetryingCount,
|
||||
RetriedCount = stats.RetriedCount,
|
||||
ResolvedCount = stats.ResolvedCount,
|
||||
ExhaustedCount = stats.ExhaustedCount,
|
||||
ByChannel = stats.ByChannel,
|
||||
ByReason = stats.ByReason,
|
||||
OldestEntryAt = stats.OldestEntryAt,
|
||||
NewestEntryAt = stats.NewestEntryAt
|
||||
});
|
||||
});
|
||||
|
||||
app.MapPost("/api/v2/notify/dead-letter/purge", async (
|
||||
HttpContext context,
|
||||
PurgeDeadLetterRequest request,
|
||||
IDeadLetterService deadLetterService) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var maxAge = TimeSpan.FromDays(request.MaxAgeDays);
|
||||
var purgedCount = await deadLetterService.PurgeExpiredAsync(tenantId, maxAge, context.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PurgeDeadLetterResponse { PurgedCount = purgedCount });
|
||||
});
|
||||
|
||||
// =============================================
|
||||
// Retention Policy API (NOTIFY-SVC-40-004)
|
||||
// =============================================
|
||||
|
||||
app.MapGet("/api/v2/notify/retention/policy", async (
|
||||
HttpContext context,
|
||||
IRetentionPolicyService retentionService) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var policy = await retentionService.GetPolicyAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new RetentionPolicyResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Policy = new RetentionPolicyDto
|
||||
{
|
||||
DeliveryRetentionDays = (int)policy.DeliveryRetention.TotalDays,
|
||||
AuditRetentionDays = (int)policy.AuditRetention.TotalDays,
|
||||
DeadLetterRetentionDays = (int)policy.DeadLetterRetention.TotalDays,
|
||||
StormDataRetentionDays = (int)policy.StormDataRetention.TotalDays,
|
||||
InboxRetentionDays = (int)policy.InboxRetention.TotalDays,
|
||||
EventHistoryRetentionDays = (int)policy.EventHistoryRetention.TotalDays,
|
||||
AutoCleanupEnabled = policy.AutoCleanupEnabled,
|
||||
CleanupSchedule = policy.CleanupSchedule,
|
||||
MaxDeletesPerRun = policy.MaxDeletesPerRun,
|
||||
ExtendResolvedRetention = policy.ExtendResolvedRetention,
|
||||
ResolvedRetentionMultiplier = policy.ResolvedRetentionMultiplier
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.MapPut("/api/v2/notify/retention/policy", async (
|
||||
HttpContext context,
|
||||
UpdateRetentionPolicyRequest request,
|
||||
IRetentionPolicyService retentionService) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var policy = new RetentionPolicy
|
||||
{
|
||||
DeliveryRetention = TimeSpan.FromDays(request.Policy.DeliveryRetentionDays),
|
||||
AuditRetention = TimeSpan.FromDays(request.Policy.AuditRetentionDays),
|
||||
DeadLetterRetention = TimeSpan.FromDays(request.Policy.DeadLetterRetentionDays),
|
||||
StormDataRetention = TimeSpan.FromDays(request.Policy.StormDataRetentionDays),
|
||||
InboxRetention = TimeSpan.FromDays(request.Policy.InboxRetentionDays),
|
||||
EventHistoryRetention = TimeSpan.FromDays(request.Policy.EventHistoryRetentionDays),
|
||||
AutoCleanupEnabled = request.Policy.AutoCleanupEnabled,
|
||||
CleanupSchedule = request.Policy.CleanupSchedule,
|
||||
MaxDeletesPerRun = request.Policy.MaxDeletesPerRun,
|
||||
ExtendResolvedRetention = request.Policy.ExtendResolvedRetention,
|
||||
ResolvedRetentionMultiplier = request.Policy.ResolvedRetentionMultiplier
|
||||
};
|
||||
|
||||
await retentionService.SetPolicyAsync(tenantId, policy, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
app.MapPost("/api/v2/notify/retention/cleanup", async (
|
||||
HttpContext context,
|
||||
IRetentionPolicyService retentionService) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var result = await retentionService.ExecuteCleanupAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new RetentionCleanupResponse
|
||||
{
|
||||
TenantId = result.TenantId,
|
||||
Success = result.Success,
|
||||
Error = result.Error,
|
||||
ExecutedAt = result.ExecutedAt,
|
||||
DurationMs = result.Duration.TotalMilliseconds,
|
||||
Counts = new RetentionCleanupCountsDto
|
||||
{
|
||||
Deliveries = result.Counts.Deliveries,
|
||||
AuditEntries = result.Counts.AuditEntries,
|
||||
DeadLetterEntries = result.Counts.DeadLetterEntries,
|
||||
StormData = result.Counts.StormData,
|
||||
InboxMessages = result.Counts.InboxMessages,
|
||||
Events = result.Counts.Events,
|
||||
Total = result.Counts.Total
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.MapGet("/api/v2/notify/retention/cleanup/preview", async (
|
||||
HttpContext context,
|
||||
IRetentionPolicyService retentionService) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var preview = await retentionService.PreviewCleanupAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new RetentionCleanupPreviewResponse
|
||||
{
|
||||
TenantId = preview.TenantId,
|
||||
PreviewedAt = preview.PreviewedAt,
|
||||
EstimatedCounts = new RetentionCleanupCountsDto
|
||||
{
|
||||
Deliveries = preview.EstimatedCounts.Deliveries,
|
||||
AuditEntries = preview.EstimatedCounts.AuditEntries,
|
||||
DeadLetterEntries = preview.EstimatedCounts.DeadLetterEntries,
|
||||
StormData = preview.EstimatedCounts.StormData,
|
||||
InboxMessages = preview.EstimatedCounts.InboxMessages,
|
||||
Events = preview.EstimatedCounts.Events,
|
||||
Total = preview.EstimatedCounts.Total
|
||||
},
|
||||
PolicyApplied = new RetentionPolicyDto
|
||||
{
|
||||
DeliveryRetentionDays = (int)preview.PolicyApplied.DeliveryRetention.TotalDays,
|
||||
AuditRetentionDays = (int)preview.PolicyApplied.AuditRetention.TotalDays,
|
||||
DeadLetterRetentionDays = (int)preview.PolicyApplied.DeadLetterRetention.TotalDays,
|
||||
StormDataRetentionDays = (int)preview.PolicyApplied.StormDataRetention.TotalDays,
|
||||
InboxRetentionDays = (int)preview.PolicyApplied.InboxRetention.TotalDays,
|
||||
EventHistoryRetentionDays = (int)preview.PolicyApplied.EventHistoryRetention.TotalDays,
|
||||
AutoCleanupEnabled = preview.PolicyApplied.AutoCleanupEnabled,
|
||||
CleanupSchedule = preview.PolicyApplied.CleanupSchedule,
|
||||
MaxDeletesPerRun = preview.PolicyApplied.MaxDeletesPerRun,
|
||||
ExtendResolvedRetention = preview.PolicyApplied.ExtendResolvedRetention,
|
||||
ResolvedRetentionMultiplier = preview.PolicyApplied.ResolvedRetentionMultiplier
|
||||
},
|
||||
CutoffDates = preview.CutoffDates
|
||||
});
|
||||
});
|
||||
|
||||
app.MapGet("/api/v2/notify/retention/cleanup/last", async (
|
||||
HttpContext context,
|
||||
IRetentionPolicyService retentionService) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var execution = await retentionService.GetLastExecutionAsync(tenantId, context.RequestAborted).ConfigureAwait(false);
|
||||
if (execution is null)
|
||||
{
|
||||
return Results.NotFound(Error("no_execution", "No cleanup execution found.", context));
|
||||
}
|
||||
|
||||
return Results.Ok(new RetentionCleanupExecutionResponse
|
||||
{
|
||||
ExecutionId = execution.ExecutionId,
|
||||
TenantId = execution.TenantId,
|
||||
StartedAt = execution.StartedAt,
|
||||
CompletedAt = execution.CompletedAt,
|
||||
Status = execution.Status.ToString(),
|
||||
Counts = execution.Counts is not null ? new RetentionCleanupCountsDto
|
||||
{
|
||||
Deliveries = execution.Counts.Deliveries,
|
||||
AuditEntries = execution.Counts.AuditEntries,
|
||||
DeadLetterEntries = execution.Counts.DeadLetterEntries,
|
||||
StormData = execution.Counts.StormData,
|
||||
InboxMessages = execution.Counts.InboxMessages,
|
||||
Events = execution.Counts.Events,
|
||||
Total = execution.Counts.Total
|
||||
} : null,
|
||||
Error = execution.Error
|
||||
});
|
||||
});
|
||||
|
||||
app.MapGet("/.well-known/openapi", (HttpContext context) =>
|
||||
{
|
||||
context.Response.Headers["X-OpenAPI-Scope"] = "notify";
|
||||
@@ -2178,6 +2902,7 @@ info:
|
||||
paths:
|
||||
/api/v1/notify/quiet-hours: {}
|
||||
/api/v1/notify/incidents: {}
|
||||
/api/v1/ack/{token}: {}
|
||||
/api/v2/notify/templates: {}
|
||||
/api/v2/notify/rules: {}
|
||||
/api/v2/notify/channels: {}
|
||||
@@ -2195,6 +2920,23 @@ paths:
|
||||
/api/v2/notify/localization/locales: {}
|
||||
/api/v2/notify/localization/resolve: {}
|
||||
/api/v2/notify/storms: {}
|
||||
/api/v2/notify/security/ack-tokens: {}
|
||||
/api/v2/notify/security/ack-tokens/verify: {}
|
||||
/api/v2/notify/security/html/validate: {}
|
||||
/api/v2/notify/security/html/sanitize: {}
|
||||
/api/v2/notify/security/webhook/{channelId}/rotate: {}
|
||||
/api/v2/notify/security/webhook/{channelId}/secret: {}
|
||||
/api/v2/notify/security/isolation/violations: {}
|
||||
/api/v2/notify/dead-letter: {}
|
||||
/api/v2/notify/dead-letter/{entryId}: {}
|
||||
/api/v2/notify/dead-letter/retry: {}
|
||||
/api/v2/notify/dead-letter/{entryId}/resolve: {}
|
||||
/api/v2/notify/dead-letter/stats: {}
|
||||
/api/v2/notify/dead-letter/purge: {}
|
||||
/api/v2/notify/retention/policy: {}
|
||||
/api/v2/notify/retention/cleanup: {}
|
||||
/api/v2/notify/retention/cleanup/preview: {}
|
||||
/api/v2/notify/retention/cleanup/last: {}
|
||||
""";
|
||||
|
||||
return Results.Text(stub, "application/yaml", Encoding.UTF8);
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for webhook (HTTP POST) delivery with retry support.
|
||||
/// Channel adapter for webhook (HTTP POST) delivery with retry support and HMAC signing.
|
||||
/// </summary>
|
||||
public sealed class WebhookChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IWebhookSecurityService? _securityService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<WebhookChannelAdapter> _logger;
|
||||
|
||||
public WebhookChannelAdapter(HttpClient httpClient, ILogger<WebhookChannelAdapter> logger)
|
||||
public WebhookChannelAdapter(
|
||||
HttpClient httpClient,
|
||||
ILogger<WebhookChannelAdapter> logger,
|
||||
IWebhookSecurityService? securityService = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_securityService = securityService;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Webhook;
|
||||
@@ -52,17 +62,30 @@ public sealed class WebhookChannelAdapter : INotifyChannelAdapter
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var jsonOptions = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
var payloadJson = JsonSerializer.Serialize(payload, jsonOptions);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = JsonContent.Create(payload, options: new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
request.Content = new StringContent(payloadJson, Encoding.UTF8, "application/json");
|
||||
|
||||
// Add HMAC signature header if secret is available (placeholder for KMS integration)
|
||||
// Add version header
|
||||
request.Headers.Add("X-StellaOps-Notifier", "1.0");
|
||||
|
||||
// Add HMAC signature if security service is available
|
||||
if (_securityService is not null)
|
||||
{
|
||||
var timestamp = _timeProvider.GetUtcNow();
|
||||
var signature = _securityService.SignPayload(
|
||||
channel.TenantId,
|
||||
channel.ChannelId,
|
||||
payloadBytes,
|
||||
timestamp);
|
||||
request.Headers.Add("X-StellaOps-Signature", signature);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.DeadLetter;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing dead-letter entries for failed notification deliveries.
|
||||
/// </summary>
|
||||
public interface IDeadLetterService
|
||||
{
|
||||
/// <summary>
|
||||
/// Enqueues a failed delivery to the dead-letter queue.
|
||||
/// </summary>
|
||||
Task<DeadLetterEntry> EnqueueAsync(
|
||||
DeadLetterEnqueueRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a dead-letter entry by ID.
|
||||
/// </summary>
|
||||
Task<DeadLetterEntry?> GetAsync(
|
||||
string tenantId,
|
||||
string entryId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists dead-letter entries with optional filtering.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DeadLetterEntry>> ListAsync(
|
||||
string tenantId,
|
||||
DeadLetterListOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retries a dead-letter entry.
|
||||
/// </summary>
|
||||
Task<DeadLetterRetryResult> RetryAsync(
|
||||
string tenantId,
|
||||
string entryId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Retries multiple dead-letter entries.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<DeadLetterRetryResult>> RetryBatchAsync(
|
||||
string tenantId,
|
||||
IEnumerable<string> entryIds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Marks a dead-letter entry as resolved/dismissed.
|
||||
/// </summary>
|
||||
Task ResolveAsync(
|
||||
string tenantId,
|
||||
string entryId,
|
||||
string resolution,
|
||||
string? resolvedBy = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes old dead-letter entries based on retention policy.
|
||||
/// </summary>
|
||||
Task<int> PurgeExpiredAsync(
|
||||
string tenantId,
|
||||
TimeSpan maxAge,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets statistics about dead-letter entries.
|
||||
/// </summary>
|
||||
Task<DeadLetterStats> GetStatsAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to enqueue a dead-letter entry.
|
||||
/// </summary>
|
||||
public sealed record DeadLetterEnqueueRequest
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required string DeliveryId { get; init; }
|
||||
public required string EventId { get; init; }
|
||||
public required string ChannelId { get; init; }
|
||||
public required string ChannelType { get; init; }
|
||||
public required string FailureReason { get; init; }
|
||||
public string? FailureDetails { get; init; }
|
||||
public int AttemptCount { get; init; }
|
||||
public DateTimeOffset? LastAttemptAt { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original payload for retry purposes.
|
||||
/// </summary>
|
||||
public string? OriginalPayload { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A dead-letter queue entry.
|
||||
/// </summary>
|
||||
public sealed record DeadLetterEntry
|
||||
{
|
||||
public required string EntryId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string DeliveryId { get; init; }
|
||||
public required string EventId { get; init; }
|
||||
public required string ChannelId { get; init; }
|
||||
public required string ChannelType { get; init; }
|
||||
public required string FailureReason { get; init; }
|
||||
public string? FailureDetails { get; init; }
|
||||
public required int AttemptCount { get; init; }
|
||||
public required DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastAttemptAt { get; init; }
|
||||
public required DeadLetterStatus Status { get; init; }
|
||||
public int RetryCount { get; init; }
|
||||
public DateTimeOffset? LastRetryAt { get; init; }
|
||||
public string? Resolution { get; init; }
|
||||
public string? ResolvedBy { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
|
||||
public string? OriginalPayload { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a dead-letter entry.
|
||||
/// </summary>
|
||||
public enum DeadLetterStatus
|
||||
{
|
||||
/// <summary>Entry is pending retry or resolution.</summary>
|
||||
Pending,
|
||||
|
||||
/// <summary>Entry is being retried.</summary>
|
||||
Retrying,
|
||||
|
||||
/// <summary>Entry was successfully retried.</summary>
|
||||
Retried,
|
||||
|
||||
/// <summary>Entry was manually resolved/dismissed.</summary>
|
||||
Resolved,
|
||||
|
||||
/// <summary>Entry exceeded max retries.</summary>
|
||||
Exhausted
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for listing dead-letter entries.
|
||||
/// </summary>
|
||||
public sealed record DeadLetterListOptions
|
||||
{
|
||||
public DeadLetterStatus? Status { get; init; }
|
||||
public string? ChannelId { get; init; }
|
||||
public string? ChannelType { get; init; }
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
public DateTimeOffset? Until { get; init; }
|
||||
public int Limit { get; init; } = 50;
|
||||
public int Offset { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a dead-letter retry attempt.
|
||||
/// </summary>
|
||||
public sealed record DeadLetterRetryResult
|
||||
{
|
||||
public required string EntryId { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public DateTimeOffset? RetriedAt { get; init; }
|
||||
public string? NewDeliveryId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about dead-letter entries.
|
||||
/// </summary>
|
||||
public sealed record DeadLetterStats
|
||||
{
|
||||
public required int TotalCount { get; init; }
|
||||
public required int PendingCount { get; init; }
|
||||
public required int RetryingCount { get; init; }
|
||||
public required int RetriedCount { get; init; }
|
||||
public required int ResolvedCount { get; init; }
|
||||
public required int ExhaustedCount { get; init; }
|
||||
public required IReadOnlyDictionary<string, int> ByChannel { get; init; }
|
||||
public required IReadOnlyDictionary<string, int> ByReason { get; init; }
|
||||
public DateTimeOffset? OldestEntryAt { get; init; }
|
||||
public DateTimeOffset? NewestEntryAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notifier.Worker.Observability;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.DeadLetter;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of dead-letter service.
|
||||
/// For production, use a persistent storage implementation.
|
||||
/// </summary>
|
||||
public sealed class InMemoryDeadLetterService : IDeadLetterService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DeadLetterEntry> _entries = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly INotifyMetrics? _metrics;
|
||||
private readonly ILogger<InMemoryDeadLetterService> _logger;
|
||||
|
||||
public InMemoryDeadLetterService(
|
||||
TimeProvider timeProvider,
|
||||
ILogger<InMemoryDeadLetterService> logger,
|
||||
INotifyMetrics? metrics = null)
|
||||
{
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public Task<DeadLetterEntry> EnqueueAsync(
|
||||
DeadLetterEnqueueRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var entryId = Guid.NewGuid().ToString("N");
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var entry = new DeadLetterEntry
|
||||
{
|
||||
EntryId = entryId,
|
||||
TenantId = request.TenantId,
|
||||
DeliveryId = request.DeliveryId,
|
||||
EventId = request.EventId,
|
||||
ChannelId = request.ChannelId,
|
||||
ChannelType = request.ChannelType,
|
||||
FailureReason = request.FailureReason,
|
||||
FailureDetails = request.FailureDetails,
|
||||
AttemptCount = request.AttemptCount,
|
||||
CreatedAt = now,
|
||||
LastAttemptAt = request.LastAttemptAt ?? now,
|
||||
Status = DeadLetterStatus.Pending,
|
||||
Metadata = request.Metadata?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty,
|
||||
OriginalPayload = request.OriginalPayload
|
||||
};
|
||||
|
||||
_entries[GetKey(request.TenantId, entryId)] = entry;
|
||||
|
||||
_metrics?.RecordDeadLetter(request.TenantId, request.FailureReason, request.ChannelType);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Dead-lettered delivery {DeliveryId} for tenant {TenantId}: {Reason}",
|
||||
request.DeliveryId, request.TenantId, request.FailureReason);
|
||||
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
public Task<DeadLetterEntry?> GetAsync(
|
||||
string tenantId,
|
||||
string entryId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(entryId);
|
||||
|
||||
_entries.TryGetValue(GetKey(tenantId, entryId), out var entry);
|
||||
return Task.FromResult(entry);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<DeadLetterEntry>> ListAsync(
|
||||
string tenantId,
|
||||
DeadLetterListOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
options ??= new DeadLetterListOptions();
|
||||
|
||||
var query = _entries.Values
|
||||
.Where(e => e.TenantId == tenantId);
|
||||
|
||||
if (options.Status.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.Status == options.Status.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ChannelId))
|
||||
{
|
||||
query = query.Where(e => e.ChannelId == options.ChannelId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ChannelType))
|
||||
{
|
||||
query = query.Where(e => e.ChannelType == options.ChannelType);
|
||||
}
|
||||
|
||||
if (options.Since.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.CreatedAt >= options.Since.Value);
|
||||
}
|
||||
|
||||
if (options.Until.HasValue)
|
||||
{
|
||||
query = query.Where(e => e.CreatedAt <= options.Until.Value);
|
||||
}
|
||||
|
||||
var result = query
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.Skip(options.Offset)
|
||||
.Take(options.Limit)
|
||||
.ToArray();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<DeadLetterEntry>>(result);
|
||||
}
|
||||
|
||||
public Task<DeadLetterRetryResult> RetryAsync(
|
||||
string tenantId,
|
||||
string entryId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(entryId);
|
||||
|
||||
var key = GetKey(tenantId, entryId);
|
||||
if (!_entries.TryGetValue(key, out var entry))
|
||||
{
|
||||
return Task.FromResult(new DeadLetterRetryResult
|
||||
{
|
||||
EntryId = entryId,
|
||||
Success = false,
|
||||
Error = "Entry not found"
|
||||
});
|
||||
}
|
||||
|
||||
if (entry.Status is DeadLetterStatus.Retried or DeadLetterStatus.Resolved)
|
||||
{
|
||||
return Task.FromResult(new DeadLetterRetryResult
|
||||
{
|
||||
EntryId = entryId,
|
||||
Success = false,
|
||||
Error = $"Entry is already {entry.Status}"
|
||||
});
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Update entry status
|
||||
var updatedEntry = entry with
|
||||
{
|
||||
Status = DeadLetterStatus.Retried,
|
||||
RetryCount = entry.RetryCount + 1,
|
||||
LastRetryAt = now
|
||||
};
|
||||
|
||||
_entries[key] = updatedEntry;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Retried dead-letter entry {EntryId} for tenant {TenantId}",
|
||||
entryId, tenantId);
|
||||
|
||||
// In a real implementation, this would re-queue the delivery
|
||||
return Task.FromResult(new DeadLetterRetryResult
|
||||
{
|
||||
EntryId = entryId,
|
||||
Success = true,
|
||||
RetriedAt = now,
|
||||
NewDeliveryId = Guid.NewGuid().ToString("N")
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DeadLetterRetryResult>> RetryBatchAsync(
|
||||
string tenantId,
|
||||
IEnumerable<string> entryIds,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(entryIds);
|
||||
|
||||
var results = new List<DeadLetterRetryResult>();
|
||||
foreach (var entryId in entryIds)
|
||||
{
|
||||
var result = await RetryAsync(tenantId, entryId, cancellationToken).ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public Task ResolveAsync(
|
||||
string tenantId,
|
||||
string entryId,
|
||||
string resolution,
|
||||
string? resolvedBy = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(entryId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(resolution);
|
||||
|
||||
var key = GetKey(tenantId, entryId);
|
||||
if (_entries.TryGetValue(key, out var entry))
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
_entries[key] = entry with
|
||||
{
|
||||
Status = DeadLetterStatus.Resolved,
|
||||
Resolution = resolution,
|
||||
ResolvedBy = resolvedBy,
|
||||
ResolvedAt = now
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Resolved dead-letter entry {EntryId} for tenant {TenantId}: {Resolution}",
|
||||
entryId, tenantId, resolution);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<int> PurgeExpiredAsync(
|
||||
string tenantId,
|
||||
TimeSpan maxAge,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var cutoff = _timeProvider.GetUtcNow() - maxAge;
|
||||
var toRemove = _entries
|
||||
.Where(kv => kv.Value.TenantId == tenantId && kv.Value.CreatedAt < cutoff)
|
||||
.Select(kv => kv.Key)
|
||||
.ToArray();
|
||||
|
||||
var count = 0;
|
||||
foreach (var key in toRemove)
|
||||
{
|
||||
if (_entries.TryRemove(key, out _))
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Purged {Count} expired dead-letter entries for tenant {TenantId}",
|
||||
count, tenantId);
|
||||
}
|
||||
|
||||
return Task.FromResult(count);
|
||||
}
|
||||
|
||||
public Task<DeadLetterStats> GetStatsAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var entries = _entries.Values.Where(e => e.TenantId == tenantId).ToArray();
|
||||
|
||||
var byChannel = entries
|
||||
.GroupBy(e => e.ChannelType)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var byReason = entries
|
||||
.GroupBy(e => e.FailureReason)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var stats = new DeadLetterStats
|
||||
{
|
||||
TotalCount = entries.Length,
|
||||
PendingCount = entries.Count(e => e.Status == DeadLetterStatus.Pending),
|
||||
RetryingCount = entries.Count(e => e.Status == DeadLetterStatus.Retrying),
|
||||
RetriedCount = entries.Count(e => e.Status == DeadLetterStatus.Retried),
|
||||
ResolvedCount = entries.Count(e => e.Status == DeadLetterStatus.Resolved),
|
||||
ExhaustedCount = entries.Count(e => e.Status == DeadLetterStatus.Exhausted),
|
||||
ByChannel = byChannel,
|
||||
ByReason = byReason,
|
||||
OldestEntryAt = entries.MinBy(e => e.CreatedAt)?.CreatedAt,
|
||||
NewestEntryAt = entries.MaxBy(e => e.CreatedAt)?.CreatedAt
|
||||
};
|
||||
|
||||
return Task.FromResult(stats);
|
||||
}
|
||||
|
||||
private static string GetKey(string tenantId, string entryId) => $"{tenantId}:{entryId}";
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of notification metrics using System.Diagnostics.Metrics.
|
||||
/// </summary>
|
||||
public sealed class DefaultNotifyMetrics : INotifyMetrics
|
||||
{
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.Notifier", "1.0.0");
|
||||
private static readonly Meter Meter = new("StellaOps.Notifier", "1.0.0");
|
||||
|
||||
// Counters
|
||||
private readonly Counter<long> _deliveryAttempts;
|
||||
private readonly Counter<long> _escalationEvents;
|
||||
private readonly Counter<long> _deadLetterEntries;
|
||||
private readonly Counter<long> _ruleEvaluations;
|
||||
private readonly Counter<long> _templateRenders;
|
||||
private readonly Counter<long> _stormEvents;
|
||||
private readonly Counter<long> _retentionCleanups;
|
||||
|
||||
// Histograms
|
||||
private readonly Histogram<double> _deliveryDuration;
|
||||
private readonly Histogram<double> _ruleEvaluationDuration;
|
||||
private readonly Histogram<double> _templateRenderDuration;
|
||||
|
||||
// Gauges (using ObservableGauge pattern)
|
||||
private readonly Dictionary<string, int> _queueDepths = new();
|
||||
private readonly object _queueDepthLock = new();
|
||||
|
||||
public DefaultNotifyMetrics()
|
||||
{
|
||||
// Initialize counters
|
||||
_deliveryAttempts = Meter.CreateCounter<long>(
|
||||
NotifyMetricNames.DeliveryAttempts,
|
||||
unit: "{attempts}",
|
||||
description: "Total number of notification delivery attempts");
|
||||
|
||||
_escalationEvents = Meter.CreateCounter<long>(
|
||||
NotifyMetricNames.EscalationEvents,
|
||||
unit: "{events}",
|
||||
description: "Total number of escalation events");
|
||||
|
||||
_deadLetterEntries = Meter.CreateCounter<long>(
|
||||
NotifyMetricNames.DeadLetterEntries,
|
||||
unit: "{entries}",
|
||||
description: "Total number of dead-letter entries");
|
||||
|
||||
_ruleEvaluations = Meter.CreateCounter<long>(
|
||||
NotifyMetricNames.RuleEvaluations,
|
||||
unit: "{evaluations}",
|
||||
description: "Total number of rule evaluations");
|
||||
|
||||
_templateRenders = Meter.CreateCounter<long>(
|
||||
NotifyMetricNames.TemplateRenders,
|
||||
unit: "{renders}",
|
||||
description: "Total number of template render operations");
|
||||
|
||||
_stormEvents = Meter.CreateCounter<long>(
|
||||
NotifyMetricNames.StormEvents,
|
||||
unit: "{events}",
|
||||
description: "Total number of storm detection events");
|
||||
|
||||
_retentionCleanups = Meter.CreateCounter<long>(
|
||||
NotifyMetricNames.RetentionCleanups,
|
||||
unit: "{cleanups}",
|
||||
description: "Total number of retention cleanup operations");
|
||||
|
||||
// Initialize histograms
|
||||
_deliveryDuration = Meter.CreateHistogram<double>(
|
||||
NotifyMetricNames.DeliveryDuration,
|
||||
unit: "ms",
|
||||
description: "Duration of delivery attempts in milliseconds");
|
||||
|
||||
_ruleEvaluationDuration = Meter.CreateHistogram<double>(
|
||||
NotifyMetricNames.RuleEvaluationDuration,
|
||||
unit: "ms",
|
||||
description: "Duration of rule evaluations in milliseconds");
|
||||
|
||||
_templateRenderDuration = Meter.CreateHistogram<double>(
|
||||
NotifyMetricNames.TemplateRenderDuration,
|
||||
unit: "ms",
|
||||
description: "Duration of template renders in milliseconds");
|
||||
|
||||
// Initialize observable gauge for queue depths
|
||||
Meter.CreateObservableGauge(
|
||||
NotifyMetricNames.QueueDepth,
|
||||
observeValues: ObserveQueueDepths,
|
||||
unit: "{messages}",
|
||||
description: "Current queue depth per channel");
|
||||
}
|
||||
|
||||
public void RecordDeliveryAttempt(string tenantId, string channelType, string status, TimeSpan duration)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ NotifyMetricTags.TenantId, tenantId },
|
||||
{ NotifyMetricTags.ChannelType, channelType },
|
||||
{ NotifyMetricTags.Status, status }
|
||||
};
|
||||
|
||||
_deliveryAttempts.Add(1, tags);
|
||||
_deliveryDuration.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
|
||||
public void RecordEscalation(string tenantId, int level, string outcome)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ NotifyMetricTags.TenantId, tenantId },
|
||||
{ NotifyMetricTags.Level, level.ToString() },
|
||||
{ NotifyMetricTags.Outcome, outcome }
|
||||
};
|
||||
|
||||
_escalationEvents.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordDeadLetter(string tenantId, string reason, string channelType)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ NotifyMetricTags.TenantId, tenantId },
|
||||
{ NotifyMetricTags.Reason, reason },
|
||||
{ NotifyMetricTags.ChannelType, channelType }
|
||||
};
|
||||
|
||||
_deadLetterEntries.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordRuleEvaluation(string tenantId, string ruleId, bool matched, TimeSpan duration)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ NotifyMetricTags.TenantId, tenantId },
|
||||
{ NotifyMetricTags.RuleId, ruleId },
|
||||
{ NotifyMetricTags.Matched, matched.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
_ruleEvaluations.Add(1, tags);
|
||||
_ruleEvaluationDuration.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
|
||||
public void RecordTemplateRender(string tenantId, string templateKey, bool success, TimeSpan duration)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ NotifyMetricTags.TenantId, tenantId },
|
||||
{ NotifyMetricTags.TemplateKey, templateKey },
|
||||
{ NotifyMetricTags.Success, success.ToString().ToLowerInvariant() }
|
||||
};
|
||||
|
||||
_templateRenders.Add(1, tags);
|
||||
_templateRenderDuration.Record(duration.TotalMilliseconds, tags);
|
||||
}
|
||||
|
||||
public void RecordStormEvent(string tenantId, string stormKey, string decision)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ NotifyMetricTags.TenantId, tenantId },
|
||||
{ NotifyMetricTags.StormKey, stormKey },
|
||||
{ NotifyMetricTags.Decision, decision }
|
||||
};
|
||||
|
||||
_stormEvents.Add(1, tags);
|
||||
}
|
||||
|
||||
public void RecordRetentionCleanup(string tenantId, string entityType, int deletedCount)
|
||||
{
|
||||
var tags = new TagList
|
||||
{
|
||||
{ NotifyMetricTags.TenantId, tenantId },
|
||||
{ NotifyMetricTags.EntityType, entityType }
|
||||
};
|
||||
|
||||
_retentionCleanups.Add(deletedCount, tags);
|
||||
}
|
||||
|
||||
public void RecordQueueDepth(string tenantId, string channelType, int depth)
|
||||
{
|
||||
var key = $"{tenantId}:{channelType}";
|
||||
lock (_queueDepthLock)
|
||||
{
|
||||
_queueDepths[key] = depth;
|
||||
}
|
||||
}
|
||||
|
||||
public Activity? StartDeliveryActivity(string tenantId, string deliveryId, string channelType)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("notify.delivery", ActivityKind.Internal);
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetTag(NotifyMetricTags.TenantId, tenantId);
|
||||
activity.SetTag("delivery_id", deliveryId);
|
||||
activity.SetTag(NotifyMetricTags.ChannelType, channelType);
|
||||
}
|
||||
return activity;
|
||||
}
|
||||
|
||||
public Activity? StartEscalationActivity(string tenantId, string incidentId, int level)
|
||||
{
|
||||
var activity = ActivitySource.StartActivity("notify.escalation", ActivityKind.Internal);
|
||||
if (activity is not null)
|
||||
{
|
||||
activity.SetTag(NotifyMetricTags.TenantId, tenantId);
|
||||
activity.SetTag("incident_id", incidentId);
|
||||
activity.SetTag(NotifyMetricTags.Level, level);
|
||||
}
|
||||
return activity;
|
||||
}
|
||||
|
||||
private IEnumerable<Measurement<int>> ObserveQueueDepths()
|
||||
{
|
||||
lock (_queueDepthLock)
|
||||
{
|
||||
foreach (var (key, depth) in _queueDepths)
|
||||
{
|
||||
var parts = key.Split(':');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
yield return new Measurement<int>(
|
||||
depth,
|
||||
new TagList
|
||||
{
|
||||
{ NotifyMetricTags.TenantId, parts[0] },
|
||||
{ NotifyMetricTags.ChannelType, parts[1] }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Observability;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for notification system metrics and tracing.
|
||||
/// </summary>
|
||||
public interface INotifyMetrics
|
||||
{
|
||||
/// <summary>
|
||||
/// Records a notification delivery attempt.
|
||||
/// </summary>
|
||||
void RecordDeliveryAttempt(string tenantId, string channelType, string status, TimeSpan duration);
|
||||
|
||||
/// <summary>
|
||||
/// Records an escalation event.
|
||||
/// </summary>
|
||||
void RecordEscalation(string tenantId, int level, string outcome);
|
||||
|
||||
/// <summary>
|
||||
/// Records a dead-letter entry.
|
||||
/// </summary>
|
||||
void RecordDeadLetter(string tenantId, string reason, string channelType);
|
||||
|
||||
/// <summary>
|
||||
/// Records rule evaluation.
|
||||
/// </summary>
|
||||
void RecordRuleEvaluation(string tenantId, string ruleId, bool matched, TimeSpan duration);
|
||||
|
||||
/// <summary>
|
||||
/// Records template rendering.
|
||||
/// </summary>
|
||||
void RecordTemplateRender(string tenantId, string templateKey, bool success, TimeSpan duration);
|
||||
|
||||
/// <summary>
|
||||
/// Records storm detection event.
|
||||
/// </summary>
|
||||
void RecordStormEvent(string tenantId, string stormKey, string decision);
|
||||
|
||||
/// <summary>
|
||||
/// Records retention cleanup.
|
||||
/// </summary>
|
||||
void RecordRetentionCleanup(string tenantId, string entityType, int deletedCount);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current queue depth for a channel.
|
||||
/// </summary>
|
||||
void RecordQueueDepth(string tenantId, string channelType, int depth);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an activity for distributed tracing.
|
||||
/// </summary>
|
||||
Activity? StartDeliveryActivity(string tenantId, string deliveryId, string channelType);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an activity for escalation tracing.
|
||||
/// </summary>
|
||||
Activity? StartEscalationActivity(string tenantId, string incidentId, int level);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metric tag names for consistency.
|
||||
/// </summary>
|
||||
public static class NotifyMetricTags
|
||||
{
|
||||
public const string TenantId = "tenant_id";
|
||||
public const string ChannelType = "channel_type";
|
||||
public const string Status = "status";
|
||||
public const string Outcome = "outcome";
|
||||
public const string Level = "level";
|
||||
public const string Reason = "reason";
|
||||
public const string RuleId = "rule_id";
|
||||
public const string Matched = "matched";
|
||||
public const string TemplateKey = "template_key";
|
||||
public const string Success = "success";
|
||||
public const string StormKey = "storm_key";
|
||||
public const string Decision = "decision";
|
||||
public const string EntityType = "entity_type";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metric names for the notification system.
|
||||
/// </summary>
|
||||
public static class NotifyMetricNames
|
||||
{
|
||||
public const string DeliveryAttempts = "notify.delivery.attempts";
|
||||
public const string DeliveryDuration = "notify.delivery.duration";
|
||||
public const string EscalationEvents = "notify.escalation.events";
|
||||
public const string DeadLetterEntries = "notify.deadletter.entries";
|
||||
public const string RuleEvaluations = "notify.rule.evaluations";
|
||||
public const string RuleEvaluationDuration = "notify.rule.evaluation.duration";
|
||||
public const string TemplateRenders = "notify.template.renders";
|
||||
public const string TemplateRenderDuration = "notify.template.render.duration";
|
||||
public const string StormEvents = "notify.storm.events";
|
||||
public const string RetentionCleanups = "notify.retention.cleanups";
|
||||
public const string QueueDepth = "notify.queue.depth";
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notifier.Worker.DeadLetter;
|
||||
using StellaOps.Notifier.Worker.Observability;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Retention;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of retention policy service.
|
||||
/// </summary>
|
||||
public sealed class DefaultRetentionPolicyService : IRetentionPolicyService
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, RetentionPolicy> _policies = new();
|
||||
private readonly ConcurrentDictionary<string, RetentionCleanupExecution> _lastExecutions = new();
|
||||
private readonly IDeadLetterService _deadLetterService;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly INotifyMetrics? _metrics;
|
||||
private readonly ILogger<DefaultRetentionPolicyService> _logger;
|
||||
|
||||
public DefaultRetentionPolicyService(
|
||||
IDeadLetterService deadLetterService,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DefaultRetentionPolicyService> logger,
|
||||
INotifyMetrics? metrics = null)
|
||||
{
|
||||
_deadLetterService = deadLetterService ?? throw new ArgumentNullException(nameof(deadLetterService));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_metrics = metrics;
|
||||
}
|
||||
|
||||
public Task<RetentionPolicy> GetPolicyAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var policy = _policies.GetValueOrDefault(tenantId, RetentionPolicy.Default);
|
||||
return Task.FromResult(policy);
|
||||
}
|
||||
|
||||
public Task SetPolicyAsync(
|
||||
string tenantId,
|
||||
RetentionPolicy policy,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
|
||||
_policies[tenantId] = policy;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Updated retention policy for tenant {TenantId}: DeliveryRetention={DeliveryRetention}, AuditRetention={AuditRetention}",
|
||||
tenantId, policy.DeliveryRetention, policy.AuditRetention);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<RetentionCleanupResult> ExecuteCleanupAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var executionId = Guid.NewGuid().ToString("N");
|
||||
var startedAt = _timeProvider.GetUtcNow();
|
||||
var policy = await GetPolicyAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var execution = new RetentionCleanupExecution
|
||||
{
|
||||
ExecutionId = executionId,
|
||||
TenantId = tenantId,
|
||||
StartedAt = startedAt,
|
||||
Status = RetentionCleanupStatus.Running,
|
||||
PolicyUsed = policy
|
||||
};
|
||||
|
||||
_lastExecutions[tenantId] = execution;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting retention cleanup {ExecutionId} for tenant {TenantId}",
|
||||
executionId, tenantId);
|
||||
|
||||
try
|
||||
{
|
||||
var counts = await ExecuteCleanupInternalAsync(tenantId, policy, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var completedAt = _timeProvider.GetUtcNow();
|
||||
var duration = completedAt - startedAt;
|
||||
|
||||
execution = execution with
|
||||
{
|
||||
CompletedAt = completedAt,
|
||||
Status = RetentionCleanupStatus.Completed,
|
||||
Counts = counts
|
||||
};
|
||||
|
||||
_lastExecutions[tenantId] = execution;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Completed retention cleanup {ExecutionId} for tenant {TenantId}: {Total} items deleted in {Duration}ms",
|
||||
executionId, tenantId, counts.Total, duration.TotalMilliseconds);
|
||||
|
||||
return new RetentionCleanupResult
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Success = true,
|
||||
ExecutedAt = startedAt,
|
||||
Duration = duration,
|
||||
Counts = counts
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
execution = execution with
|
||||
{
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
Status = RetentionCleanupStatus.Cancelled,
|
||||
Error = "Operation was cancelled"
|
||||
};
|
||||
|
||||
_lastExecutions[tenantId] = execution;
|
||||
|
||||
_logger.LogWarning(
|
||||
"Retention cleanup {ExecutionId} for tenant {TenantId} was cancelled",
|
||||
executionId, tenantId);
|
||||
|
||||
return new RetentionCleanupResult
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Success = false,
|
||||
Error = "Operation was cancelled",
|
||||
ExecutedAt = startedAt,
|
||||
Duration = _timeProvider.GetUtcNow() - startedAt,
|
||||
Counts = new RetentionCleanupCounts()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
execution = execution with
|
||||
{
|
||||
CompletedAt = _timeProvider.GetUtcNow(),
|
||||
Status = RetentionCleanupStatus.Failed,
|
||||
Error = ex.Message
|
||||
};
|
||||
|
||||
_lastExecutions[tenantId] = execution;
|
||||
|
||||
_logger.LogError(ex,
|
||||
"Retention cleanup {ExecutionId} for tenant {TenantId} failed",
|
||||
executionId, tenantId);
|
||||
|
||||
return new RetentionCleanupResult
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Success = false,
|
||||
Error = ex.Message,
|
||||
ExecutedAt = startedAt,
|
||||
Duration = _timeProvider.GetUtcNow() - startedAt,
|
||||
Counts = new RetentionCleanupCounts()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RetentionCleanupResult>> ExecuteCleanupAllAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tenantIds = _policies.Keys.ToArray();
|
||||
var results = new List<RetentionCleanupResult>();
|
||||
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = await ExecuteCleanupAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Completed retention cleanup for {Count} tenants: {Successful} successful, {Failed} failed",
|
||||
results.Count, results.Count(r => r.Success), results.Count(r => !r.Success));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public Task<RetentionCleanupExecution?> GetLastExecutionAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
_lastExecutions.TryGetValue(tenantId, out var execution);
|
||||
return Task.FromResult(execution);
|
||||
}
|
||||
|
||||
public async Task<RetentionCleanupPreview> PreviewCleanupAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
|
||||
var policy = await GetPolicyAsync(tenantId, cancellationToken).ConfigureAwait(false);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
var cutoffDates = new Dictionary<string, DateTimeOffset>
|
||||
{
|
||||
["Deliveries"] = now - policy.DeliveryRetention,
|
||||
["AuditEntries"] = now - policy.AuditRetention,
|
||||
["DeadLetterEntries"] = now - policy.DeadLetterRetention,
|
||||
["StormData"] = now - policy.StormDataRetention,
|
||||
["InboxMessages"] = now - policy.InboxRetention,
|
||||
["Events"] = now - policy.EventHistoryRetention
|
||||
};
|
||||
|
||||
// Get estimated dead-letter count
|
||||
var deadLetterStats = await _deadLetterService.GetStatsAsync(tenantId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Estimate counts based on age distribution (simplified - in production would query actual counts)
|
||||
var estimatedCounts = new RetentionCleanupCounts
|
||||
{
|
||||
DeadLetterEntries = EstimateExpiredCount(deadLetterStats, policy.DeadLetterRetention, now)
|
||||
};
|
||||
|
||||
return new RetentionCleanupPreview
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PreviewedAt = now,
|
||||
EstimatedCounts = estimatedCounts,
|
||||
PolicyApplied = policy,
|
||||
CutoffDates = cutoffDates
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<RetentionCleanupCounts> ExecuteCleanupInternalAsync(
|
||||
string tenantId,
|
||||
RetentionPolicy policy,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var deadLetterCount = 0;
|
||||
|
||||
// Purge expired dead-letter entries
|
||||
deadLetterCount = await _deadLetterService.PurgeExpiredAsync(
|
||||
tenantId,
|
||||
policy.DeadLetterRetention,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (deadLetterCount > 0)
|
||||
{
|
||||
_metrics?.RecordRetentionCleanup(tenantId, "DeadLetter", deadLetterCount);
|
||||
}
|
||||
|
||||
// In a full implementation, we would also clean up:
|
||||
// - Delivery records from delivery store
|
||||
// - Audit log entries from audit store
|
||||
// - Storm tracking data from storm store
|
||||
// - Inbox messages from inbox store
|
||||
// - Event history from event store
|
||||
|
||||
// For now, return counts with just dead-letter cleanup
|
||||
return new RetentionCleanupCounts
|
||||
{
|
||||
DeadLetterEntries = deadLetterCount
|
||||
};
|
||||
}
|
||||
|
||||
private static int EstimateExpiredCount(DeadLetterStats stats, TimeSpan retention, DateTimeOffset now)
|
||||
{
|
||||
if (!stats.OldestEntryAt.HasValue)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var cutoff = now - retention;
|
||||
if (stats.OldestEntryAt.Value >= cutoff)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Rough estimation - assume linear distribution
|
||||
if (!stats.NewestEntryAt.HasValue || stats.TotalCount == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var totalSpan = stats.NewestEntryAt.Value - stats.OldestEntryAt.Value;
|
||||
if (totalSpan.TotalSeconds <= 0)
|
||||
{
|
||||
return stats.TotalCount;
|
||||
}
|
||||
|
||||
var expiredSpan = cutoff - stats.OldestEntryAt.Value;
|
||||
var ratio = Math.Clamp(expiredSpan.TotalSeconds / totalSpan.TotalSeconds, 0, 1);
|
||||
|
||||
return (int)(stats.TotalCount * ratio);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
namespace StellaOps.Notifier.Worker.Retention;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing data retention policies and cleanup.
|
||||
/// </summary>
|
||||
public interface IRetentionPolicyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the retention policy for a tenant.
|
||||
/// </summary>
|
||||
Task<RetentionPolicy> GetPolicyAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sets/updates the retention policy for a tenant.
|
||||
/// </summary>
|
||||
Task SetPolicyAsync(
|
||||
string tenantId,
|
||||
RetentionPolicy policy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Executes retention cleanup for a tenant.
|
||||
/// </summary>
|
||||
Task<RetentionCleanupResult> ExecuteCleanupAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Executes retention cleanup for all tenants.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<RetentionCleanupResult>> ExecuteCleanupAllAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last cleanup execution details.
|
||||
/// </summary>
|
||||
Task<RetentionCleanupExecution?> GetLastExecutionAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Previews what would be cleaned up without actually deleting.
|
||||
/// </summary>
|
||||
Task<RetentionCleanupPreview> PreviewCleanupAsync(
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Data retention policy configuration.
|
||||
/// </summary>
|
||||
public sealed record RetentionPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Retention period for delivery records.
|
||||
/// </summary>
|
||||
public TimeSpan DeliveryRetention { get; init; } = TimeSpan.FromDays(90);
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for audit log entries.
|
||||
/// </summary>
|
||||
public TimeSpan AuditRetention { get; init; } = TimeSpan.FromDays(365);
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for dead-letter entries.
|
||||
/// </summary>
|
||||
public TimeSpan DeadLetterRetention { get; init; } = TimeSpan.FromDays(30);
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for storm tracking data.
|
||||
/// </summary>
|
||||
public TimeSpan StormDataRetention { get; init; } = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for inbox messages.
|
||||
/// </summary>
|
||||
public TimeSpan InboxRetention { get; init; } = TimeSpan.FromDays(30);
|
||||
|
||||
/// <summary>
|
||||
/// Retention period for event history.
|
||||
/// </summary>
|
||||
public TimeSpan EventHistoryRetention { get; init; } = TimeSpan.FromDays(30);
|
||||
|
||||
/// <summary>
|
||||
/// Whether automatic cleanup is enabled.
|
||||
/// </summary>
|
||||
public bool AutoCleanupEnabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Cron expression for automatic cleanup schedule.
|
||||
/// </summary>
|
||||
public string CleanupSchedule { get; init; } = "0 2 * * *"; // Daily at 2 AM
|
||||
|
||||
/// <summary>
|
||||
/// Maximum records to delete per cleanup run.
|
||||
/// </summary>
|
||||
public int MaxDeletesPerRun { get; init; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to keep resolved/acknowledged deliveries longer.
|
||||
/// </summary>
|
||||
public bool ExtendResolvedRetention { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Extension multiplier for resolved items (e.g., 2x = double the retention).
|
||||
/// </summary>
|
||||
public double ResolvedRetentionMultiplier { get; init; } = 2.0;
|
||||
|
||||
/// <summary>
|
||||
/// Default policy with standard retention periods.
|
||||
/// </summary>
|
||||
public static RetentionPolicy Default => new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a retention cleanup execution.
|
||||
/// </summary>
|
||||
public sealed record RetentionCleanupResult
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public required DateTimeOffset ExecutedAt { get; init; }
|
||||
public TimeSpan Duration { get; init; }
|
||||
public required RetentionCleanupCounts Counts { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts of items deleted during retention cleanup.
|
||||
/// </summary>
|
||||
public sealed record RetentionCleanupCounts
|
||||
{
|
||||
public int Deliveries { get; init; }
|
||||
public int AuditEntries { get; init; }
|
||||
public int DeadLetterEntries { get; init; }
|
||||
public int StormData { get; init; }
|
||||
public int InboxMessages { get; init; }
|
||||
public int Events { get; init; }
|
||||
|
||||
public int Total => Deliveries + AuditEntries + DeadLetterEntries + StormData + InboxMessages + Events;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Details of a cleanup execution.
|
||||
/// </summary>
|
||||
public sealed record RetentionCleanupExecution
|
||||
{
|
||||
public required string ExecutionId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public required RetentionCleanupStatus Status { get; init; }
|
||||
public RetentionCleanupCounts? Counts { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public RetentionPolicy PolicyUsed { get; init; } = RetentionPolicy.Default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Status of a cleanup execution.
|
||||
/// </summary>
|
||||
public enum RetentionCleanupStatus
|
||||
{
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Preview of what would be cleaned up.
|
||||
/// </summary>
|
||||
public sealed record RetentionCleanupPreview
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required DateTimeOffset PreviewedAt { get; init; }
|
||||
public required RetentionCleanupCounts EstimatedCounts { get; init; }
|
||||
public required RetentionPolicy PolicyApplied { get; init; }
|
||||
public required IReadOnlyDictionary<string, DateTimeOffset> CutoffDates { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Default HTML sanitizer implementation using regex-based filtering.
|
||||
/// For production, consider using a dedicated library like HtmlSanitizer or AngleSharp.
|
||||
/// </summary>
|
||||
public sealed partial class DefaultHtmlSanitizer : IHtmlSanitizer
|
||||
{
|
||||
private readonly ILogger<DefaultHtmlSanitizer> _logger;
|
||||
|
||||
// Safe elements (whitelist approach)
|
||||
private static readonly HashSet<string> SafeElements = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"p", "div", "span", "br", "hr",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"strong", "b", "em", "i", "u", "s", "strike",
|
||||
"ul", "ol", "li", "dl", "dt", "dd",
|
||||
"table", "thead", "tbody", "tfoot", "tr", "th", "td",
|
||||
"a", "img",
|
||||
"blockquote", "pre", "code",
|
||||
"sub", "sup", "small", "mark",
|
||||
"caption", "figure", "figcaption"
|
||||
};
|
||||
|
||||
// Safe attributes
|
||||
private static readonly HashSet<string> SafeAttributes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"href", "src", "alt", "title", "class", "id",
|
||||
"width", "height", "style",
|
||||
"colspan", "rowspan", "scope",
|
||||
"target", "rel"
|
||||
};
|
||||
|
||||
// Dangerous URL schemes
|
||||
private static readonly HashSet<string> DangerousSchemes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"javascript", "vbscript", "data", "file"
|
||||
};
|
||||
|
||||
// Event handler attributes (all start with "on")
|
||||
private static readonly Regex EventHandlerRegex = EventHandlerPattern();
|
||||
|
||||
// Style-based attacks
|
||||
private static readonly Regex DangerousStyleRegex = DangerousStylePattern();
|
||||
|
||||
public DefaultHtmlSanitizer(ILogger<DefaultHtmlSanitizer> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string Sanitize(string html, HtmlSanitizeOptions? options = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
options ??= new HtmlSanitizeOptions();
|
||||
|
||||
if (html.Length > options.MaxContentLength)
|
||||
{
|
||||
_logger.LogWarning("HTML content exceeds max length {MaxLength}, truncating", options.MaxContentLength);
|
||||
html = html[..options.MaxContentLength];
|
||||
}
|
||||
|
||||
var allowedTags = new HashSet<string>(SafeElements, StringComparer.OrdinalIgnoreCase);
|
||||
if (options.AdditionalAllowedTags is not null)
|
||||
{
|
||||
foreach (var tag in options.AdditionalAllowedTags)
|
||||
{
|
||||
allowedTags.Add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
var allowedAttrs = new HashSet<string>(SafeAttributes, StringComparer.OrdinalIgnoreCase);
|
||||
if (options.AdditionalAllowedAttributes is not null)
|
||||
{
|
||||
foreach (var attr in options.AdditionalAllowedAttributes)
|
||||
{
|
||||
allowedAttrs.Add(attr);
|
||||
}
|
||||
}
|
||||
|
||||
// Process HTML
|
||||
var result = new StringBuilder();
|
||||
var depth = 0;
|
||||
var pos = 0;
|
||||
|
||||
while (pos < html.Length)
|
||||
{
|
||||
var tagStart = html.IndexOf('<', pos);
|
||||
if (tagStart < 0)
|
||||
{
|
||||
// No more tags, append rest
|
||||
result.Append(EncodeText(html[pos..]));
|
||||
break;
|
||||
}
|
||||
|
||||
// Append text before tag
|
||||
if (tagStart > pos)
|
||||
{
|
||||
result.Append(EncodeText(html[pos..tagStart]));
|
||||
}
|
||||
|
||||
var tagEnd = html.IndexOf('>', tagStart);
|
||||
if (tagEnd < 0)
|
||||
{
|
||||
// Malformed, skip rest
|
||||
break;
|
||||
}
|
||||
|
||||
var tagContent = html[(tagStart + 1)..tagEnd];
|
||||
var isClosing = tagContent.StartsWith('/');
|
||||
var tagName = ExtractTagName(tagContent);
|
||||
|
||||
if (isClosing)
|
||||
{
|
||||
depth--;
|
||||
}
|
||||
|
||||
if (allowedTags.Contains(tagName))
|
||||
{
|
||||
if (isClosing)
|
||||
{
|
||||
result.Append($"</{tagName}>");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Process attributes
|
||||
var sanitizedTag = SanitizeTag(tagContent, tagName, allowedAttrs, options);
|
||||
result.Append($"<{sanitizedTag}>");
|
||||
|
||||
if (!IsSelfClosing(tagName) && !tagContent.EndsWith('/'))
|
||||
{
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Stripped disallowed tag: {TagName}", tagName);
|
||||
}
|
||||
|
||||
if (depth > options.MaxNestingDepth)
|
||||
{
|
||||
_logger.LogWarning("HTML nesting depth exceeds max {MaxDepth}, truncating", options.MaxNestingDepth);
|
||||
break;
|
||||
}
|
||||
|
||||
pos = tagEnd + 1;
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
public HtmlValidationResult Validate(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return HtmlValidationResult.Safe(new HtmlContentStats());
|
||||
}
|
||||
|
||||
var issues = new List<HtmlSecurityIssue>();
|
||||
var stats = new HtmlContentStats
|
||||
{
|
||||
CharacterCount = html.Length
|
||||
};
|
||||
|
||||
var pos = 0;
|
||||
var depth = 0;
|
||||
var maxDepth = 0;
|
||||
var elementCount = 0;
|
||||
var linkCount = 0;
|
||||
var imageCount = 0;
|
||||
|
||||
// Check for script tags
|
||||
if (ScriptTagRegex().IsMatch(html))
|
||||
{
|
||||
issues.Add(new HtmlSecurityIssue
|
||||
{
|
||||
Type = HtmlSecurityIssueType.ScriptInjection,
|
||||
Description = "Script tags are not allowed"
|
||||
});
|
||||
}
|
||||
|
||||
// Check for event handlers
|
||||
var eventMatches = EventHandlerRegex.Matches(html);
|
||||
foreach (Match match in eventMatches)
|
||||
{
|
||||
issues.Add(new HtmlSecurityIssue
|
||||
{
|
||||
Type = HtmlSecurityIssueType.EventHandler,
|
||||
Description = "Event handler attributes are not allowed",
|
||||
AttributeName = match.Value,
|
||||
Position = match.Index
|
||||
});
|
||||
}
|
||||
|
||||
// Check for dangerous URLs
|
||||
var hrefMatches = DangerousUrlRegex().Matches(html);
|
||||
foreach (Match match in hrefMatches)
|
||||
{
|
||||
issues.Add(new HtmlSecurityIssue
|
||||
{
|
||||
Type = HtmlSecurityIssueType.DangerousUrl,
|
||||
Description = "Dangerous URL scheme detected",
|
||||
Position = match.Index
|
||||
});
|
||||
}
|
||||
|
||||
// Check for dangerous style content
|
||||
var styleMatches = DangerousStyleRegex.Matches(html);
|
||||
foreach (Match match in styleMatches)
|
||||
{
|
||||
issues.Add(new HtmlSecurityIssue
|
||||
{
|
||||
Type = HtmlSecurityIssueType.StyleInjection,
|
||||
Description = "Dangerous style content detected",
|
||||
Position = match.Index
|
||||
});
|
||||
}
|
||||
|
||||
// Check for dangerous elements
|
||||
var dangerousElements = new[] { "iframe", "object", "embed", "form", "input", "button", "meta", "link", "base" };
|
||||
foreach (var element in dangerousElements)
|
||||
{
|
||||
var elementRegex = new Regex($@"<{element}\b", RegexOptions.IgnoreCase);
|
||||
if (elementRegex.IsMatch(html))
|
||||
{
|
||||
issues.Add(new HtmlSecurityIssue
|
||||
{
|
||||
Type = HtmlSecurityIssueType.DangerousElement,
|
||||
Description = $"Dangerous element '{element}' is not allowed",
|
||||
ElementName = element
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Count elements and check nesting
|
||||
while (pos < html.Length)
|
||||
{
|
||||
var tagStart = html.IndexOf('<', pos);
|
||||
if (tagStart < 0) break;
|
||||
|
||||
var tagEnd = html.IndexOf('>', tagStart);
|
||||
if (tagEnd < 0) break;
|
||||
|
||||
var tagContent = html[(tagStart + 1)..tagEnd];
|
||||
var isClosing = tagContent.StartsWith('/');
|
||||
var tagName = ExtractTagName(tagContent);
|
||||
|
||||
if (!isClosing && !string.IsNullOrEmpty(tagName) && !tagContent.EndsWith('/'))
|
||||
{
|
||||
if (!IsSelfClosing(tagName))
|
||||
{
|
||||
depth++;
|
||||
maxDepth = Math.Max(maxDepth, depth);
|
||||
}
|
||||
elementCount++;
|
||||
|
||||
if (tagName.Equals("a", StringComparison.OrdinalIgnoreCase)) linkCount++;
|
||||
if (tagName.Equals("img", StringComparison.OrdinalIgnoreCase)) imageCount++;
|
||||
}
|
||||
else if (isClosing)
|
||||
{
|
||||
depth--;
|
||||
}
|
||||
|
||||
pos = tagEnd + 1;
|
||||
}
|
||||
|
||||
stats = stats with
|
||||
{
|
||||
ElementCount = elementCount,
|
||||
MaxDepth = maxDepth,
|
||||
LinkCount = linkCount,
|
||||
ImageCount = imageCount
|
||||
};
|
||||
|
||||
return issues.Count == 0
|
||||
? HtmlValidationResult.Safe(stats)
|
||||
: HtmlValidationResult.Unsafe(issues, stats);
|
||||
}
|
||||
|
||||
public string StripHtml(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Remove all tags
|
||||
var text = HtmlTagRegex().Replace(html, " ");
|
||||
|
||||
// Decode entities
|
||||
text = System.Net.WebUtility.HtmlDecode(text);
|
||||
|
||||
// Normalize whitespace
|
||||
text = WhitespaceRegex().Replace(text, " ").Trim();
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
private static string SanitizeTag(
|
||||
string tagContent,
|
||||
string tagName,
|
||||
HashSet<string> allowedAttrs,
|
||||
HtmlSanitizeOptions options)
|
||||
{
|
||||
var result = new StringBuilder(tagName);
|
||||
|
||||
// Extract and sanitize attributes
|
||||
var attrMatches = AttributeRegex().Matches(tagContent);
|
||||
foreach (Match match in attrMatches)
|
||||
{
|
||||
var attrName = match.Groups[1].Value;
|
||||
var attrValue = match.Groups[2].Value;
|
||||
|
||||
if (!allowedAttrs.Contains(attrName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip event handlers
|
||||
if (EventHandlerRegex.IsMatch(attrName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Sanitize href/src values
|
||||
if (attrName.Equals("href", StringComparison.OrdinalIgnoreCase) ||
|
||||
attrName.Equals("src", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
attrValue = SanitizeUrl(attrValue, options);
|
||||
if (string.IsNullOrEmpty(attrValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize style values
|
||||
if (attrName.Equals("style", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
attrValue = SanitizeStyle(attrValue);
|
||||
if (string.IsNullOrEmpty(attrValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
result.Append($" {attrName}=\"{EncodeAttributeValue(attrValue)}\"");
|
||||
}
|
||||
|
||||
// Add rel="noopener noreferrer" to links with target
|
||||
if (tagName.Equals("a", StringComparison.OrdinalIgnoreCase) &&
|
||||
tagContent.Contains("target=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!tagContent.Contains("rel=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(" rel=\"noopener noreferrer\"");
|
||||
}
|
||||
}
|
||||
|
||||
if (tagContent.TrimEnd().EndsWith('/'))
|
||||
{
|
||||
result.Append(" /");
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private static string SanitizeUrl(string url, HtmlSanitizeOptions options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
url = url.Trim();
|
||||
|
||||
// Check for dangerous schemes
|
||||
var colonIndex = url.IndexOf(':');
|
||||
if (colonIndex > 0 && colonIndex < 10)
|
||||
{
|
||||
var scheme = url[..colonIndex].ToLowerInvariant();
|
||||
if (DangerousSchemes.Contains(scheme))
|
||||
{
|
||||
if (scheme == "data" && options.AllowDataUrls)
|
||||
{
|
||||
// Allow data URLs if explicitly enabled
|
||||
return url;
|
||||
}
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow relative URLs and safe absolute URLs
|
||||
if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.StartsWith("tel:", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.StartsWith('/') ||
|
||||
url.StartsWith('#') ||
|
||||
!url.Contains(':'))
|
||||
{
|
||||
return url;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static string SanitizeStyle(string style)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(style))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Remove dangerous CSS
|
||||
if (DangerousStyleRegex.IsMatch(style))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Only allow simple property:value pairs
|
||||
var safeProperties = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"color", "background-color", "font-size", "font-weight", "font-style",
|
||||
"text-align", "text-decoration", "margin", "padding", "border",
|
||||
"width", "height", "max-width", "max-height", "display"
|
||||
};
|
||||
|
||||
var result = new StringBuilder();
|
||||
var pairs = style.Split(';', StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var pair in pairs)
|
||||
{
|
||||
var colonIndex = pair.IndexOf(':');
|
||||
if (colonIndex <= 0) continue;
|
||||
|
||||
var property = pair[..colonIndex].Trim().ToLowerInvariant();
|
||||
var value = pair[(colonIndex + 1)..].Trim();
|
||||
|
||||
if (safeProperties.Contains(property) && !value.Contains("url(", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (result.Length > 0) result.Append("; ");
|
||||
result.Append($"{property}: {value}");
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToString();
|
||||
}
|
||||
|
||||
private static string ExtractTagName(string tagContent)
|
||||
{
|
||||
var content = tagContent.TrimStart('/').Trim();
|
||||
var spaceIndex = content.IndexOfAny([' ', '\t', '\n', '\r', '/']);
|
||||
return spaceIndex > 0 ? content[..spaceIndex] : content;
|
||||
}
|
||||
|
||||
private static bool IsSelfClosing(string tagName)
|
||||
{
|
||||
return tagName.Equals("br", StringComparison.OrdinalIgnoreCase) ||
|
||||
tagName.Equals("hr", StringComparison.OrdinalIgnoreCase) ||
|
||||
tagName.Equals("img", StringComparison.OrdinalIgnoreCase) ||
|
||||
tagName.Equals("input", StringComparison.OrdinalIgnoreCase) ||
|
||||
tagName.Equals("meta", StringComparison.OrdinalIgnoreCase) ||
|
||||
tagName.Equals("link", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string EncodeText(string text)
|
||||
{
|
||||
return System.Net.WebUtility.HtmlEncode(text);
|
||||
}
|
||||
|
||||
private static string EncodeAttributeValue(string value)
|
||||
{
|
||||
return value
|
||||
.Replace("&", "&")
|
||||
.Replace("\"", """)
|
||||
.Replace("<", "<")
|
||||
.Replace(">", ">");
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\bon\w+\s*=", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex EventHandlerPattern();
|
||||
|
||||
[GeneratedRegex(@"expression\s*\(|behavior\s*:|@import|@charset|binding\s*:", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex DangerousStylePattern();
|
||||
|
||||
[GeneratedRegex(@"<script\b", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ScriptTagRegex();
|
||||
|
||||
[GeneratedRegex(@"(javascript|vbscript|data)\s*:", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex DangerousUrlRegex();
|
||||
|
||||
[GeneratedRegex(@"<[^>]*>")]
|
||||
private static partial Regex HtmlTagRegex();
|
||||
|
||||
[GeneratedRegex(@"\s+")]
|
||||
private static partial Regex WhitespaceRegex();
|
||||
|
||||
[GeneratedRegex(@"(\w+)\s*=\s*""([^""]*)""", RegexOptions.Compiled)]
|
||||
private static partial Regex AttributeRegex();
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of tenant isolation validation.
|
||||
/// </summary>
|
||||
public sealed partial class DefaultTenantIsolationValidator : ITenantIsolationValidator
|
||||
{
|
||||
private readonly TenantIsolationOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DefaultTenantIsolationValidator> _logger;
|
||||
private readonly ConcurrentQueue<TenantIsolationViolation> _violations = new();
|
||||
|
||||
// Valid tenant ID pattern: alphanumeric, hyphens, underscores, 3-64 chars
|
||||
private static readonly Regex TenantIdPattern = TenantIdRegex();
|
||||
|
||||
public DefaultTenantIsolationValidator(
|
||||
IOptions<TenantIsolationOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DefaultTenantIsolationValidator> logger)
|
||||
{
|
||||
_options = options?.Value ?? new TenantIsolationOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public TenantIsolationResult ValidateAccess(
|
||||
string requestTenantId,
|
||||
string resourceTenantId,
|
||||
string resourceType,
|
||||
string resourceId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(requestTenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(resourceTenantId);
|
||||
|
||||
// Normalize tenant IDs
|
||||
var normalizedRequest = NormalizeTenantId(requestTenantId);
|
||||
var normalizedResource = NormalizeTenantId(resourceTenantId);
|
||||
|
||||
// Check for exact match
|
||||
if (string.Equals(normalizedRequest, normalizedResource, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return TenantIsolationResult.Allow(requestTenantId, resourceTenantId);
|
||||
}
|
||||
|
||||
// Check for cross-tenant access exceptions (admin tenants, shared resources)
|
||||
if (_options.AllowCrossTenantAccess &&
|
||||
_options.CrossTenantAllowedPairs.Contains($"{normalizedRequest}:{normalizedResource}"))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Cross-tenant access allowed: {RequestTenant} -> {ResourceTenant} for {ResourceType}",
|
||||
requestTenantId, resourceTenantId, resourceType);
|
||||
return TenantIsolationResult.Allow(requestTenantId, resourceTenantId);
|
||||
}
|
||||
|
||||
// Check if request tenant is an admin tenant
|
||||
if (_options.AdminTenants.Contains(normalizedRequest))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Admin tenant {AdminTenant} accessing resource from {ResourceTenant}",
|
||||
requestTenantId, resourceTenantId);
|
||||
return TenantIsolationResult.Allow(requestTenantId, resourceTenantId);
|
||||
}
|
||||
|
||||
// Violation detected
|
||||
var violation = new TenantIsolationViolation
|
||||
{
|
||||
OccurredAt = _timeProvider.GetUtcNow(),
|
||||
RequestTenantId = requestTenantId,
|
||||
ResourceTenantId = resourceTenantId,
|
||||
ResourceType = resourceType,
|
||||
ResourceId = resourceId,
|
||||
Operation = "access"
|
||||
};
|
||||
|
||||
RecordViolation(violation);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Tenant isolation violation: {RequestTenant} attempted to access {ResourceType}/{ResourceId} belonging to {ResourceTenant}",
|
||||
requestTenantId, resourceType, resourceId, resourceTenantId);
|
||||
|
||||
return TenantIsolationResult.Deny(
|
||||
requestTenantId,
|
||||
resourceTenantId,
|
||||
"Cross-tenant access denied",
|
||||
resourceType,
|
||||
resourceId);
|
||||
}
|
||||
|
||||
public IReadOnlyList<TenantIsolationResult> ValidateBatch(
|
||||
string requestTenantId,
|
||||
IEnumerable<TenantResource> resources)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(requestTenantId);
|
||||
ArgumentNullException.ThrowIfNull(resources);
|
||||
|
||||
return resources
|
||||
.Select(r => ValidateAccess(requestTenantId, r.TenantId, r.ResourceType, r.ResourceId))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public string? SanitizeTenantId(string? tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sanitized = tenantId.Trim();
|
||||
|
||||
// Remove any control characters
|
||||
sanitized = ControlCharsRegex().Replace(sanitized, "");
|
||||
|
||||
// Check format
|
||||
if (!TenantIdPattern.IsMatch(sanitized))
|
||||
{
|
||||
_logger.LogWarning("Invalid tenant ID format: {TenantId}", tenantId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
public bool IsValidTenantIdFormat(string? tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TenantIdPattern.IsMatch(tenantId.Trim());
|
||||
}
|
||||
|
||||
public void RecordViolation(TenantIsolationViolation violation)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(violation);
|
||||
|
||||
_violations.Enqueue(violation);
|
||||
|
||||
// Keep only recent violations
|
||||
while (_violations.Count > _options.MaxStoredViolations)
|
||||
{
|
||||
_violations.TryDequeue(out _);
|
||||
}
|
||||
|
||||
// Emit metrics
|
||||
TenantIsolationMetrics.RecordViolation(
|
||||
violation.RequestTenantId,
|
||||
violation.ResourceTenantId,
|
||||
violation.ResourceType);
|
||||
}
|
||||
|
||||
public IReadOnlyList<TenantIsolationViolation> GetRecentViolations(int limit = 100)
|
||||
{
|
||||
return _violations.TakeLast(Math.Min(limit, _options.MaxStoredViolations)).ToArray();
|
||||
}
|
||||
|
||||
private static string NormalizeTenantId(string tenantId)
|
||||
{
|
||||
return tenantId.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[a-zA-Z0-9][a-zA-Z0-9_-]{2,63}$")]
|
||||
private static partial Regex TenantIdRegex();
|
||||
|
||||
[GeneratedRegex(@"[\x00-\x1F\x7F]")]
|
||||
private static partial Regex ControlCharsRegex();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for tenant isolation.
|
||||
/// </summary>
|
||||
public sealed class TenantIsolationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether to allow any cross-tenant access.
|
||||
/// </summary>
|
||||
public bool AllowCrossTenantAccess { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Pairs of tenants allowed to access each other's resources.
|
||||
/// Format: "tenant1:tenant2" means tenant1 can access tenant2's resources.
|
||||
/// </summary>
|
||||
public HashSet<string> CrossTenantAllowedPairs { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Tenants with admin access to all resources.
|
||||
/// </summary>
|
||||
public HashSet<string> AdminTenants { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of violations to store in memory.
|
||||
/// </summary>
|
||||
public int MaxStoredViolations { get; set; } = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to throw exceptions on violations (vs returning result).
|
||||
/// </summary>
|
||||
public bool ThrowOnViolation { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metrics for tenant isolation.
|
||||
/// </summary>
|
||||
internal static class TenantIsolationMetrics
|
||||
{
|
||||
// In a real implementation, these would emit to metrics system
|
||||
private static long _violationCount;
|
||||
|
||||
public static void RecordViolation(string requestTenant, string resourceTenant, string resourceType)
|
||||
{
|
||||
Interlocked.Increment(ref _violationCount);
|
||||
// In production: emit to Prometheus/StatsD/etc.
|
||||
}
|
||||
|
||||
public static long GetViolationCount() => _violationCount;
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of webhook security service using HMAC-SHA256.
|
||||
/// </summary>
|
||||
public sealed class DefaultWebhookSecurityService : IWebhookSecurityService
|
||||
{
|
||||
private const string SignaturePrefix = "v1";
|
||||
private const int TimestampToleranceSeconds = 300; // 5 minutes
|
||||
|
||||
private readonly WebhookSecurityOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<DefaultWebhookSecurityService> _logger;
|
||||
|
||||
// In-memory storage for channel secrets (in production, use persistent storage)
|
||||
private readonly ConcurrentDictionary<string, ChannelSecurityConfig> _channelConfigs = new();
|
||||
|
||||
public DefaultWebhookSecurityService(
|
||||
IOptions<WebhookSecurityOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<DefaultWebhookSecurityService> logger)
|
||||
{
|
||||
_options = options?.Value ?? new WebhookSecurityOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string SignPayload(string tenantId, string channelId, ReadOnlySpan<byte> payload, DateTimeOffset timestamp)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(channelId);
|
||||
|
||||
var config = GetOrCreateConfig(tenantId, channelId);
|
||||
var timestampUnix = timestamp.ToUnixTimeSeconds();
|
||||
|
||||
// Create signed payload: timestamp.payload
|
||||
var signedData = CreateSignedData(timestampUnix, payload);
|
||||
|
||||
using var hmac = new HMACSHA256(config.SecretBytes);
|
||||
var signature = hmac.ComputeHash(signedData);
|
||||
var signatureHex = Convert.ToHexString(signature).ToLowerInvariant();
|
||||
|
||||
// Format: v1=timestamp,signature
|
||||
return $"{SignaturePrefix}={timestampUnix},{signatureHex}";
|
||||
}
|
||||
|
||||
public bool VerifySignature(string tenantId, string channelId, ReadOnlySpan<byte> payload, string signatureHeader)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(channelId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(signatureHeader))
|
||||
{
|
||||
_logger.LogWarning("Missing signature header for webhook callback");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse header: v1=timestamp,signature
|
||||
if (!signatureHeader.StartsWith($"{SignaturePrefix}=", StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning("Invalid signature prefix in header");
|
||||
return false;
|
||||
}
|
||||
|
||||
var parts = signatureHeader[(SignaturePrefix.Length + 1)..].Split(',');
|
||||
if (parts.Length != 2)
|
||||
{
|
||||
_logger.LogWarning("Invalid signature format in header");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!long.TryParse(parts[0], out var timestampUnix))
|
||||
{
|
||||
_logger.LogWarning("Invalid timestamp in signature header");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check timestamp is within tolerance
|
||||
var now = _timeProvider.GetUtcNow().ToUnixTimeSeconds();
|
||||
if (Math.Abs(now - timestampUnix) > TimestampToleranceSeconds)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Signature timestamp {Timestamp} is outside tolerance window (now: {Now})",
|
||||
timestampUnix, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] providedSignature;
|
||||
try
|
||||
{
|
||||
providedSignature = Convert.FromHexString(parts[1]);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
_logger.LogWarning("Invalid signature hex encoding");
|
||||
return false;
|
||||
}
|
||||
|
||||
var config = GetOrCreateConfig(tenantId, channelId);
|
||||
var signedData = CreateSignedData(timestampUnix, payload);
|
||||
|
||||
using var hmac = new HMACSHA256(config.SecretBytes);
|
||||
var expectedSignature = hmac.ComputeHash(signedData);
|
||||
|
||||
// Also check previous secret if within rotation window
|
||||
if (!CryptographicOperations.FixedTimeEquals(expectedSignature, providedSignature))
|
||||
{
|
||||
if (config.PreviousSecretBytes is not null &&
|
||||
config.PreviousSecretExpiresAt.HasValue &&
|
||||
_timeProvider.GetUtcNow() < config.PreviousSecretExpiresAt.Value)
|
||||
{
|
||||
using var hmacPrev = new HMACSHA256(config.PreviousSecretBytes);
|
||||
var prevSignature = hmacPrev.ComputeHash(signedData);
|
||||
return CryptographicOperations.FixedTimeEquals(prevSignature, providedSignature);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public IpValidationResult ValidateIp(string tenantId, string channelId, IPAddress ipAddress)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(channelId);
|
||||
ArgumentNullException.ThrowIfNull(ipAddress);
|
||||
|
||||
var config = GetOrCreateConfig(tenantId, channelId);
|
||||
|
||||
if (config.IpAllowlist.Count == 0)
|
||||
{
|
||||
// No allowlist configured - allow all
|
||||
return IpValidationResult.Allow(hasAllowlist: false);
|
||||
}
|
||||
|
||||
foreach (var entry in config.IpAllowlist)
|
||||
{
|
||||
if (IsIpInRange(ipAddress, entry.CidrOrIp))
|
||||
{
|
||||
return IpValidationResult.Allow(entry.CidrOrIp, hasAllowlist: true);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"IP {IpAddress} not in allowlist for channel {ChannelId}",
|
||||
ipAddress, channelId);
|
||||
|
||||
return IpValidationResult.Deny($"IP {ipAddress} not in allowlist");
|
||||
}
|
||||
|
||||
public string GetMaskedSecret(string tenantId, string channelId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(channelId);
|
||||
|
||||
var config = GetOrCreateConfig(tenantId, channelId);
|
||||
var secret = config.Secret;
|
||||
|
||||
if (secret.Length <= 8)
|
||||
{
|
||||
return "****";
|
||||
}
|
||||
|
||||
return $"{secret[..4]}...{secret[^4..]}";
|
||||
}
|
||||
|
||||
public Task<WebhookSecretRotationResult> RotateSecretAsync(
|
||||
string tenantId,
|
||||
string channelId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(channelId);
|
||||
|
||||
var key = GetConfigKey(tenantId, channelId);
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var newSecret = GenerateSecret();
|
||||
|
||||
var result = _channelConfigs.AddOrUpdate(
|
||||
key,
|
||||
_ => new ChannelSecurityConfig(newSecret),
|
||||
(_, existing) =>
|
||||
{
|
||||
return new ChannelSecurityConfig(newSecret)
|
||||
{
|
||||
PreviousSecret = existing.Secret,
|
||||
PreviousSecretBytes = existing.SecretBytes,
|
||||
PreviousSecretExpiresAt = now.Add(_options.SecretRotationGracePeriod),
|
||||
IpAllowlist = existing.IpAllowlist
|
||||
};
|
||||
});
|
||||
|
||||
_logger.LogInformation(
|
||||
"Rotated webhook secret for channel {ChannelId}, old secret valid until {ExpiresAt}",
|
||||
channelId, result.PreviousSecretExpiresAt);
|
||||
|
||||
return Task.FromResult(new WebhookSecretRotationResult
|
||||
{
|
||||
Success = true,
|
||||
NewSecret = newSecret,
|
||||
ActiveAt = now,
|
||||
OldSecretExpiresAt = result.PreviousSecretExpiresAt
|
||||
});
|
||||
}
|
||||
|
||||
private ChannelSecurityConfig GetOrCreateConfig(string tenantId, string channelId)
|
||||
{
|
||||
var key = GetConfigKey(tenantId, channelId);
|
||||
return _channelConfigs.GetOrAdd(key, _ => new ChannelSecurityConfig(GenerateSecret()));
|
||||
}
|
||||
|
||||
private static string GetConfigKey(string tenantId, string channelId)
|
||||
=> $"{tenantId}:{channelId}";
|
||||
|
||||
private static string GenerateSecret()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
private static byte[] CreateSignedData(long timestamp, ReadOnlySpan<byte> payload)
|
||||
{
|
||||
var timestampBytes = Encoding.UTF8.GetBytes(timestamp.ToString());
|
||||
var result = new byte[timestampBytes.Length + 1 + payload.Length];
|
||||
timestampBytes.CopyTo(result, 0);
|
||||
result[timestampBytes.Length] = (byte)'.';
|
||||
payload.CopyTo(result.AsSpan(timestampBytes.Length + 1));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsIpInRange(IPAddress ip, string cidrOrIp)
|
||||
{
|
||||
if (cidrOrIp.Contains('/'))
|
||||
{
|
||||
// CIDR notation
|
||||
var parts = cidrOrIp.Split('/');
|
||||
if (!IPAddress.TryParse(parts[0], out var networkAddress) ||
|
||||
!int.TryParse(parts[1], out var prefixLength))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return IsInSubnet(ip, networkAddress, prefixLength);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Single IP
|
||||
return IPAddress.TryParse(cidrOrIp, out var singleIp) && ip.Equals(singleIp);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInSubnet(IPAddress ip, IPAddress network, int prefixLength)
|
||||
{
|
||||
var ipBytes = ip.GetAddressBytes();
|
||||
var networkBytes = network.GetAddressBytes();
|
||||
|
||||
if (ipBytes.Length != networkBytes.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fullBytes = prefixLength / 8;
|
||||
var remainingBits = prefixLength % 8;
|
||||
|
||||
for (var i = 0; i < fullBytes; i++)
|
||||
{
|
||||
if (ipBytes[i] != networkBytes[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (remainingBits > 0 && fullBytes < ipBytes.Length)
|
||||
{
|
||||
var mask = (byte)(0xFF << (8 - remainingBits));
|
||||
if ((ipBytes[fullBytes] & mask) != (networkBytes[fullBytes] & mask))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed class ChannelSecurityConfig
|
||||
{
|
||||
public ChannelSecurityConfig(string secret)
|
||||
{
|
||||
Secret = secret;
|
||||
SecretBytes = Encoding.UTF8.GetBytes(secret);
|
||||
}
|
||||
|
||||
public string Secret { get; }
|
||||
public byte[] SecretBytes { get; }
|
||||
public string? PreviousSecret { get; init; }
|
||||
public byte[]? PreviousSecretBytes { get; init; }
|
||||
public DateTimeOffset? PreviousSecretExpiresAt { get; init; }
|
||||
public List<IpAllowlistEntry> IpAllowlist { get; init; } = [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for webhook security.
|
||||
/// </summary>
|
||||
public sealed class WebhookSecurityOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Grace period during which both old and new secrets are valid after rotation.
|
||||
/// </summary>
|
||||
public TimeSpan SecretRotationGracePeriod { get; set; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enforce IP allowlists when configured.
|
||||
/// </summary>
|
||||
public bool EnforceIpAllowlist { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Timestamp tolerance for signature verification (in seconds).
|
||||
/// </summary>
|
||||
public int TimestampToleranceSeconds { get; set; } = 300;
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
using System.Buffers.Text;
|
||||
using System.Collections.Immutable;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Security;
|
||||
|
||||
/// <summary>
|
||||
/// HMAC-SHA256 based implementation of acknowledgement token service.
|
||||
/// </summary>
|
||||
public sealed class HmacAckTokenService : IAckTokenService, IDisposable
|
||||
{
|
||||
private const int CurrentVersion = 1;
|
||||
private const string TokenPrefix = "soa1"; // StellaOps Ack v1
|
||||
|
||||
private readonly AckTokenOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<HmacAckTokenService> _logger;
|
||||
private readonly HMACSHA256 _hmac;
|
||||
private bool _disposed;
|
||||
|
||||
public HmacAckTokenService(
|
||||
IOptions<AckTokenOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<HmacAckTokenService> logger)
|
||||
{
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.SigningKey))
|
||||
{
|
||||
throw new InvalidOperationException("AckTokenOptions.SigningKey must be configured.");
|
||||
}
|
||||
|
||||
// Derive key using HKDF for proper key derivation
|
||||
var keyBytes = Encoding.UTF8.GetBytes(_options.SigningKey);
|
||||
var derivedKey = HKDF.DeriveKey(
|
||||
HashAlgorithmName.SHA256,
|
||||
keyBytes,
|
||||
32, // 256 bits
|
||||
info: Encoding.UTF8.GetBytes("StellaOps.AckToken.v1"));
|
||||
|
||||
_hmac = new HMACSHA256(derivedKey);
|
||||
}
|
||||
|
||||
public AckToken CreateToken(
|
||||
string tenantId,
|
||||
string deliveryId,
|
||||
string action,
|
||||
TimeSpan? expiration = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(deliveryId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(action);
|
||||
|
||||
var tokenId = Guid.NewGuid().ToString("N");
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = now.Add(expiration ?? _options.DefaultExpiration);
|
||||
|
||||
var payload = new AckTokenPayload
|
||||
{
|
||||
Version = CurrentVersion,
|
||||
TokenId = tokenId,
|
||||
TenantId = tenantId,
|
||||
DeliveryId = deliveryId,
|
||||
Action = action,
|
||||
IssuedAt = now.ToUnixTimeSeconds(),
|
||||
ExpiresAt = expiresAt.ToUnixTimeSeconds(),
|
||||
Metadata = metadata?.ToDictionary(k => k.Key, k => k.Value) ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var payloadJson = JsonSerializer.Serialize(payload, AckTokenJsonContext.Default.AckTokenPayload);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payloadJson);
|
||||
|
||||
// Sign the payload
|
||||
var signature = _hmac.ComputeHash(payloadBytes);
|
||||
|
||||
// Combine: prefix.payload.signature (all base64url)
|
||||
var payloadB64 = Base64UrlEncode(payloadBytes);
|
||||
var signatureB64 = Base64UrlEncode(signature);
|
||||
var tokenString = $"{TokenPrefix}.{payloadB64}.{signatureB64}";
|
||||
|
||||
_logger.LogDebug(
|
||||
"Created ack token {TokenId} for delivery {DeliveryId} expiring at {ExpiresAt}",
|
||||
tokenId, deliveryId, expiresAt);
|
||||
|
||||
return new AckToken
|
||||
{
|
||||
TokenId = tokenId,
|
||||
TenantId = tenantId,
|
||||
DeliveryId = deliveryId,
|
||||
Action = action,
|
||||
IssuedAt = now,
|
||||
ExpiresAt = expiresAt,
|
||||
Metadata = metadata?.ToImmutableDictionary() ?? ImmutableDictionary<string, string>.Empty,
|
||||
TokenString = tokenString
|
||||
};
|
||||
}
|
||||
|
||||
public AckTokenVerification VerifyToken(string token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return AckTokenVerification.Fail(AckTokenFailureReason.InvalidFormat, "Token is empty");
|
||||
}
|
||||
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length != 3)
|
||||
{
|
||||
return AckTokenVerification.Fail(AckTokenFailureReason.InvalidFormat, "Invalid token structure");
|
||||
}
|
||||
|
||||
var prefix = parts[0];
|
||||
var payloadB64 = parts[1];
|
||||
var signatureB64 = parts[2];
|
||||
|
||||
// Check version prefix
|
||||
if (prefix != TokenPrefix)
|
||||
{
|
||||
return AckTokenVerification.Fail(AckTokenFailureReason.UnsupportedVersion, $"Unknown prefix: {prefix}");
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
byte[] payloadBytes;
|
||||
try
|
||||
{
|
||||
payloadBytes = Base64UrlDecode(payloadB64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return AckTokenVerification.Fail(AckTokenFailureReason.InvalidFormat, "Invalid payload encoding");
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
byte[] providedSignature;
|
||||
try
|
||||
{
|
||||
providedSignature = Base64UrlDecode(signatureB64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return AckTokenVerification.Fail(AckTokenFailureReason.InvalidFormat, "Invalid signature encoding");
|
||||
}
|
||||
|
||||
var expectedSignature = _hmac.ComputeHash(payloadBytes);
|
||||
if (!CryptographicOperations.FixedTimeEquals(expectedSignature, providedSignature))
|
||||
{
|
||||
_logger.LogWarning("Invalid signature for ack token");
|
||||
return AckTokenVerification.Fail(AckTokenFailureReason.InvalidSignature);
|
||||
}
|
||||
|
||||
// Parse payload
|
||||
AckTokenPayload payload;
|
||||
try
|
||||
{
|
||||
payload = JsonSerializer.Deserialize(payloadBytes, AckTokenJsonContext.Default.AckTokenPayload)
|
||||
?? throw new JsonException("Null payload");
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return AckTokenVerification.Fail(AckTokenFailureReason.MalformedPayload, ex.Message);
|
||||
}
|
||||
|
||||
// Check version
|
||||
if (payload.Version != CurrentVersion)
|
||||
{
|
||||
return AckTokenVerification.Fail(AckTokenFailureReason.UnsupportedVersion, $"Version {payload.Version} not supported");
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = DateTimeOffset.FromUnixTimeSeconds(payload.ExpiresAt);
|
||||
if (now > expiresAt)
|
||||
{
|
||||
return AckTokenVerification.Fail(AckTokenFailureReason.Expired, $"Token expired at {expiresAt}");
|
||||
}
|
||||
|
||||
var ackToken = new AckToken
|
||||
{
|
||||
TokenId = payload.TokenId,
|
||||
TenantId = payload.TenantId,
|
||||
DeliveryId = payload.DeliveryId,
|
||||
Action = payload.Action,
|
||||
IssuedAt = DateTimeOffset.FromUnixTimeSeconds(payload.IssuedAt),
|
||||
ExpiresAt = expiresAt,
|
||||
Metadata = payload.Metadata.ToImmutableDictionary(),
|
||||
TokenString = token
|
||||
};
|
||||
|
||||
return AckTokenVerification.Success(ackToken);
|
||||
}
|
||||
|
||||
public string CreateAckUrl(AckToken token)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(token);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.BaseUrl))
|
||||
{
|
||||
throw new InvalidOperationException("AckTokenOptions.BaseUrl must be configured.");
|
||||
}
|
||||
|
||||
var baseUrl = _options.BaseUrl.TrimEnd('/');
|
||||
return $"{baseUrl}/api/v1/ack/{Uri.EscapeDataString(token.TokenString)}";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_hmac.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Base64UrlEncode(byte[] data)
|
||||
{
|
||||
return Convert.ToBase64String(data)
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_')
|
||||
.TrimEnd('=');
|
||||
}
|
||||
|
||||
private static byte[] Base64UrlDecode(string input)
|
||||
{
|
||||
var padded = input
|
||||
.Replace('-', '+')
|
||||
.Replace('_', '/');
|
||||
|
||||
switch (padded.Length % 4)
|
||||
{
|
||||
case 2: padded += "=="; break;
|
||||
case 3: padded += "="; break;
|
||||
}
|
||||
|
||||
return Convert.FromBase64String(padded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal payload structure for serialization.
|
||||
/// </summary>
|
||||
internal sealed class AckTokenPayload
|
||||
{
|
||||
public int Version { get; set; }
|
||||
public string TokenId { get; set; } = string.Empty;
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
public string DeliveryId { get; set; } = string.Empty;
|
||||
public string Action { get; set; } = string.Empty;
|
||||
public long IssuedAt { get; set; }
|
||||
public long ExpiresAt { get; set; }
|
||||
public Dictionary<string, string> Metadata { get; set; } = new();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for ack token service.
|
||||
/// </summary>
|
||||
public sealed class AckTokenOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The signing key for HMAC. Should be at least 32 characters.
|
||||
/// In production, this should come from KMS/Key Vault.
|
||||
/// </summary>
|
||||
public string SigningKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Base URL for generating acknowledgement URLs.
|
||||
/// </summary>
|
||||
public string BaseUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Default token expiration if not specified.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultExpiration { get; set; } = TimeSpan.FromDays(7);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed token expiration.
|
||||
/// </summary>
|
||||
public TimeSpan MaxExpiration { get; set; } = TimeSpan.FromDays(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON serialization context for AOT compatibility.
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonSerializable(typeof(HmacAckTokenService.AckTokenPayload))]
|
||||
internal partial class AckTokenJsonContext : System.Text.Json.Serialization.JsonSerializerContext
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Service for creating and verifying signed acknowledgement tokens.
|
||||
/// </summary>
|
||||
public interface IAckTokenService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a signed acknowledgement token for a notification.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="deliveryId">The delivery ID being acknowledged.</param>
|
||||
/// <param name="action">The action being acknowledged (e.g., "ack", "resolve", "escalate").</param>
|
||||
/// <param name="expiration">Optional expiration time. Defaults to 7 days.</param>
|
||||
/// <param name="metadata">Optional metadata to embed in the token.</param>
|
||||
/// <returns>The signed token.</returns>
|
||||
AckToken CreateToken(
|
||||
string tenantId,
|
||||
string deliveryId,
|
||||
string action,
|
||||
TimeSpan? expiration = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a signed acknowledgement token.
|
||||
/// </summary>
|
||||
/// <param name="token">The token string to verify.</param>
|
||||
/// <returns>The verification result.</returns>
|
||||
AckTokenVerification VerifyToken(string token);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a full acknowledgement URL with the signed token.
|
||||
/// </summary>
|
||||
/// <param name="token">The token to embed.</param>
|
||||
/// <returns>The full URL.</returns>
|
||||
string CreateAckUrl(AckToken token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A signed acknowledgement token.
|
||||
/// </summary>
|
||||
public sealed record AckToken
|
||||
{
|
||||
/// <summary>
|
||||
/// The unique token identifier.
|
||||
/// </summary>
|
||||
public required string TokenId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The delivery ID being acknowledged.
|
||||
/// </summary>
|
||||
public required string DeliveryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The action being acknowledged.
|
||||
/// </summary>
|
||||
public required string Action { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the token was issued.
|
||||
/// </summary>
|
||||
public required DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the token expires.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional embedded metadata.
|
||||
/// </summary>
|
||||
public ImmutableDictionary<string, string> Metadata { get; init; } = ImmutableDictionary<string, string>.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The signed token string (base64url encoded).
|
||||
/// </summary>
|
||||
public required string TokenString { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of token verification.
|
||||
/// </summary>
|
||||
public sealed record AckTokenVerification
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the token is valid.
|
||||
/// </summary>
|
||||
public required bool IsValid { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The parsed token if valid, null otherwise.
|
||||
/// </summary>
|
||||
public AckToken? Token { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The failure reason if invalid.
|
||||
/// </summary>
|
||||
public AckTokenFailureReason? FailureReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional failure details.
|
||||
/// </summary>
|
||||
public string? FailureDetails { get; init; }
|
||||
|
||||
public static AckTokenVerification Success(AckToken token)
|
||||
=> new() { IsValid = true, Token = token };
|
||||
|
||||
public static AckTokenVerification Fail(AckTokenFailureReason reason, string? details = null)
|
||||
=> new() { IsValid = false, FailureReason = reason, FailureDetails = details };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reasons for token verification failure.
|
||||
/// </summary>
|
||||
public enum AckTokenFailureReason
|
||||
{
|
||||
/// <summary>Token format is invalid.</summary>
|
||||
InvalidFormat,
|
||||
|
||||
/// <summary>Token signature is invalid.</summary>
|
||||
InvalidSignature,
|
||||
|
||||
/// <summary>Token has expired.</summary>
|
||||
Expired,
|
||||
|
||||
/// <summary>Token has been revoked.</summary>
|
||||
Revoked,
|
||||
|
||||
/// <summary>Token payload is malformed.</summary>
|
||||
MalformedPayload,
|
||||
|
||||
/// <summary>Token version is unsupported.</summary>
|
||||
UnsupportedVersion
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
namespace StellaOps.Notifier.Worker.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Service for sanitizing HTML content in notification templates.
|
||||
/// </summary>
|
||||
public interface IHtmlSanitizer
|
||||
{
|
||||
/// <summary>
|
||||
/// Sanitizes HTML content, removing potentially dangerous elements and attributes.
|
||||
/// </summary>
|
||||
/// <param name="html">The HTML content to sanitize.</param>
|
||||
/// <param name="options">Optional sanitization options.</param>
|
||||
/// <returns>The sanitized HTML.</returns>
|
||||
string Sanitize(string html, HtmlSanitizeOptions? options = null);
|
||||
|
||||
/// <summary>
|
||||
/// Validates HTML content and returns any security issues found.
|
||||
/// </summary>
|
||||
/// <param name="html">The HTML content to validate.</param>
|
||||
/// <returns>Validation result with any issues found.</returns>
|
||||
HtmlValidationResult Validate(string html);
|
||||
|
||||
/// <summary>
|
||||
/// Strips all HTML tags, leaving only text content.
|
||||
/// </summary>
|
||||
/// <param name="html">The HTML content.</param>
|
||||
/// <returns>Plain text content.</returns>
|
||||
string StripHtml(string html);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for HTML sanitization.
|
||||
/// </summary>
|
||||
public sealed class HtmlSanitizeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Additional tags to allow beyond the default set.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string>? AdditionalAllowedTags { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional attributes to allow beyond the default set.
|
||||
/// </summary>
|
||||
public IReadOnlySet<string>? AdditionalAllowedAttributes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow data: URLs in src attributes. Default: false.
|
||||
/// </summary>
|
||||
public bool AllowDataUrls { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow external URLs. Default: true.
|
||||
/// </summary>
|
||||
public bool AllowExternalUrls { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum allowed depth of nested elements. Default: 50.
|
||||
/// </summary>
|
||||
public int MaxNestingDepth { get; init; } = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum content length. Default: 1MB.
|
||||
/// </summary>
|
||||
public int MaxContentLength { get; init; } = 1024 * 1024;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of HTML validation.
|
||||
/// </summary>
|
||||
public sealed record HtmlValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the HTML is safe.
|
||||
/// </summary>
|
||||
public required bool IsSafe { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// List of security issues found.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<HtmlSecurityIssue> Issues { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about the HTML content.
|
||||
/// </summary>
|
||||
public HtmlContentStats? Stats { get; init; }
|
||||
|
||||
public static HtmlValidationResult Safe(HtmlContentStats? stats = null)
|
||||
=> new() { IsSafe = true, Issues = [], Stats = stats };
|
||||
|
||||
public static HtmlValidationResult Unsafe(IReadOnlyList<HtmlSecurityIssue> issues, HtmlContentStats? stats = null)
|
||||
=> new() { IsSafe = false, Issues = issues, Stats = stats };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A security issue found in HTML content.
|
||||
/// </summary>
|
||||
public sealed record HtmlSecurityIssue
|
||||
{
|
||||
/// <summary>
|
||||
/// The type of security issue.
|
||||
/// </summary>
|
||||
public required HtmlSecurityIssueType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Description of the issue.
|
||||
/// </summary>
|
||||
public required string Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The problematic element or attribute name.
|
||||
/// </summary>
|
||||
public string? ElementName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The problematic attribute name.
|
||||
/// </summary>
|
||||
public string? AttributeName { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Approximate location in the content.
|
||||
/// </summary>
|
||||
public int? Position { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types of HTML security issues.
|
||||
/// </summary>
|
||||
public enum HtmlSecurityIssueType
|
||||
{
|
||||
/// <summary>Script element or inline script.</summary>
|
||||
ScriptInjection,
|
||||
|
||||
/// <summary>Event handler attribute (onclick, onerror, etc.).</summary>
|
||||
EventHandler,
|
||||
|
||||
/// <summary>Dangerous URL scheme (javascript:, data:, etc.).</summary>
|
||||
DangerousUrl,
|
||||
|
||||
/// <summary>Potentially dangerous element (iframe, object, embed, etc.).</summary>
|
||||
DangerousElement,
|
||||
|
||||
/// <summary>Style-based attack (expression, behavior, etc.).</summary>
|
||||
StyleInjection,
|
||||
|
||||
/// <summary>Form-based attack (action hijacking).</summary>
|
||||
FormHijacking,
|
||||
|
||||
/// <summary>Content exceeds size limits.</summary>
|
||||
ContentTooLarge,
|
||||
|
||||
/// <summary>Excessive nesting depth.</summary>
|
||||
ExcessiveNesting,
|
||||
|
||||
/// <summary>Malformed HTML that could be used to bypass filters.</summary>
|
||||
MalformedHtml
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics about HTML content.
|
||||
/// </summary>
|
||||
public sealed record HtmlContentStats
|
||||
{
|
||||
/// <summary>Total character count.</summary>
|
||||
public int CharacterCount { get; init; }
|
||||
|
||||
/// <summary>Number of HTML elements.</summary>
|
||||
public int ElementCount { get; init; }
|
||||
|
||||
/// <summary>Maximum nesting depth.</summary>
|
||||
public int MaxDepth { get; init; }
|
||||
|
||||
/// <summary>Number of links.</summary>
|
||||
public int LinkCount { get; init; }
|
||||
|
||||
/// <summary>Number of images.</summary>
|
||||
public int ImageCount { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
namespace StellaOps.Notifier.Worker.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Service for validating tenant isolation across operations.
|
||||
/// </summary>
|
||||
public interface ITenantIsolationValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates that a resource belongs to the specified tenant.
|
||||
/// </summary>
|
||||
/// <param name="requestTenantId">The tenant ID from the request.</param>
|
||||
/// <param name="resourceTenantId">The tenant ID of the resource being accessed.</param>
|
||||
/// <param name="resourceType">The type of resource being accessed.</param>
|
||||
/// <param name="resourceId">The ID of the resource being accessed.</param>
|
||||
/// <returns>Validation result.</returns>
|
||||
TenantIsolationResult ValidateAccess(
|
||||
string requestTenantId,
|
||||
string resourceTenantId,
|
||||
string resourceType,
|
||||
string resourceId);
|
||||
|
||||
/// <summary>
|
||||
/// Validates a batch of resources belong to the specified tenant.
|
||||
/// </summary>
|
||||
/// <param name="requestTenantId">The tenant ID from the request.</param>
|
||||
/// <param name="resources">The resources to validate.</param>
|
||||
/// <returns>Validation result for each resource.</returns>
|
||||
IReadOnlyList<TenantIsolationResult> ValidateBatch(
|
||||
string requestTenantId,
|
||||
IEnumerable<TenantResource> resources);
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a tenant ID for safe use.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID to sanitize.</param>
|
||||
/// <returns>The sanitized tenant ID or null if invalid.</returns>
|
||||
string? SanitizeTenantId(string? tenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Validates tenant ID format.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID to validate.</param>
|
||||
/// <returns>True if valid format.</returns>
|
||||
bool IsValidTenantIdFormat(string? tenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a tenant isolation violation for monitoring.
|
||||
/// </summary>
|
||||
/// <param name="violation">The violation details.</param>
|
||||
void RecordViolation(TenantIsolationViolation violation);
|
||||
|
||||
/// <summary>
|
||||
/// Gets recent violations for monitoring purposes.
|
||||
/// </summary>
|
||||
/// <param name="limit">Maximum number of violations to return.</param>
|
||||
/// <returns>Recent violations.</returns>
|
||||
IReadOnlyList<TenantIsolationViolation> GetRecentViolations(int limit = 100);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A resource with tenant information.
|
||||
/// </summary>
|
||||
public sealed record TenantResource
|
||||
{
|
||||
/// <summary>
|
||||
/// The tenant ID of the resource.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The type of resource.
|
||||
/// </summary>
|
||||
public required string ResourceType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The resource ID.
|
||||
/// </summary>
|
||||
public required string ResourceId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of tenant isolation validation.
|
||||
/// </summary>
|
||||
public sealed record TenantIsolationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether access is allowed.
|
||||
/// </summary>
|
||||
public required bool IsAllowed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The request tenant ID.
|
||||
/// </summary>
|
||||
public required string RequestTenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The resource tenant ID.
|
||||
/// </summary>
|
||||
public required string ResourceTenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The resource type.
|
||||
/// </summary>
|
||||
public string? ResourceType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The resource ID.
|
||||
/// </summary>
|
||||
public string? ResourceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rejection reason if not allowed.
|
||||
/// </summary>
|
||||
public string? RejectionReason { get; init; }
|
||||
|
||||
public static TenantIsolationResult Allow(string requestTenantId, string resourceTenantId)
|
||||
=> new()
|
||||
{
|
||||
IsAllowed = true,
|
||||
RequestTenantId = requestTenantId,
|
||||
ResourceTenantId = resourceTenantId
|
||||
};
|
||||
|
||||
public static TenantIsolationResult Deny(
|
||||
string requestTenantId,
|
||||
string resourceTenantId,
|
||||
string reason,
|
||||
string? resourceType = null,
|
||||
string? resourceId = null)
|
||||
=> new()
|
||||
{
|
||||
IsAllowed = false,
|
||||
RequestTenantId = requestTenantId,
|
||||
ResourceTenantId = resourceTenantId,
|
||||
RejectionReason = reason,
|
||||
ResourceType = resourceType,
|
||||
ResourceId = resourceId
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record of a tenant isolation violation.
|
||||
/// </summary>
|
||||
public sealed record TenantIsolationViolation
|
||||
{
|
||||
/// <summary>
|
||||
/// When the violation occurred.
|
||||
/// </summary>
|
||||
public required DateTimeOffset OccurredAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The request tenant ID.
|
||||
/// </summary>
|
||||
public required string RequestTenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The resource tenant ID.
|
||||
/// </summary>
|
||||
public required string ResourceTenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The type of resource accessed.
|
||||
/// </summary>
|
||||
public required string ResourceType { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The resource ID accessed.
|
||||
/// </summary>
|
||||
public required string ResourceId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The operation being performed.
|
||||
/// </summary>
|
||||
public string? Operation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Source IP address of the request.
|
||||
/// </summary>
|
||||
public string? SourceIp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// User agent of the request.
|
||||
/// </summary>
|
||||
public string? UserAgent { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional context about the violation.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string>? Context { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Net;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Service for webhook security including HMAC signing and IP validation.
|
||||
/// </summary>
|
||||
public interface IWebhookSecurityService
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs a webhook payload and returns the signature header value.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="channelId">The channel ID.</param>
|
||||
/// <param name="payload">The payload bytes to sign.</param>
|
||||
/// <param name="timestamp">The timestamp to include in signature.</param>
|
||||
/// <returns>The signature header value.</returns>
|
||||
string SignPayload(string tenantId, string channelId, ReadOnlySpan<byte> payload, DateTimeOffset timestamp);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an incoming webhook callback signature.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="channelId">The channel ID.</param>
|
||||
/// <param name="payload">The payload bytes.</param>
|
||||
/// <param name="signatureHeader">The signature header value.</param>
|
||||
/// <returns>True if signature is valid.</returns>
|
||||
bool VerifySignature(string tenantId, string channelId, ReadOnlySpan<byte> payload, string signatureHeader);
|
||||
|
||||
/// <summary>
|
||||
/// Validates if an IP address is allowed for a channel.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="channelId">The channel ID.</param>
|
||||
/// <param name="ipAddress">The IP address to check.</param>
|
||||
/// <returns>Validation result.</returns>
|
||||
IpValidationResult ValidateIp(string tenantId, string channelId, IPAddress ipAddress);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current webhook secret for a channel (for configuration display).
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="channelId">The channel ID.</param>
|
||||
/// <returns>A masked version of the secret.</returns>
|
||||
string GetMaskedSecret(string tenantId, string channelId);
|
||||
|
||||
/// <summary>
|
||||
/// Rotates the webhook secret for a channel.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">The tenant ID.</param>
|
||||
/// <param name="channelId">The channel ID.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The new secret.</returns>
|
||||
Task<WebhookSecretRotationResult> RotateSecretAsync(
|
||||
string tenantId,
|
||||
string channelId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of IP validation.
|
||||
/// </summary>
|
||||
public sealed record IpValidationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the IP is allowed.
|
||||
/// </summary>
|
||||
public required bool IsAllowed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The reason for rejection if not allowed.
|
||||
/// </summary>
|
||||
public string? RejectionReason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The matched allowlist entry if allowed.
|
||||
/// </summary>
|
||||
public string? MatchedEntry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether an allowlist is configured for this channel.
|
||||
/// </summary>
|
||||
public bool HasAllowlist { get; init; }
|
||||
|
||||
public static IpValidationResult Allow(string? matchedEntry = null, bool hasAllowlist = false)
|
||||
=> new() { IsAllowed = true, MatchedEntry = matchedEntry, HasAllowlist = hasAllowlist };
|
||||
|
||||
public static IpValidationResult Deny(string reason, bool hasAllowlist = true)
|
||||
=> new() { IsAllowed = false, RejectionReason = reason, HasAllowlist = hasAllowlist };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of secret rotation.
|
||||
/// </summary>
|
||||
public sealed record WebhookSecretRotationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether rotation succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The new secret (only available immediately after rotation).
|
||||
/// </summary>
|
||||
public string? NewSecret { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Error message if rotation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the new secret becomes active.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ActiveAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the old secret expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? OldSecretExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for an IP allowlist entry.
|
||||
/// </summary>
|
||||
public sealed record IpAllowlistEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// The CIDR notation or single IP address.
|
||||
/// </summary>
|
||||
public required string CidrOrIp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional description for this entry.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When this entry was added.
|
||||
/// </summary>
|
||||
public DateTimeOffset AddedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Who added this entry.
|
||||
/// </summary>
|
||||
public string? AddedBy { get; init; }
|
||||
}
|
||||
Reference in New Issue
Block a user