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

This commit is contained in:
StellaOps Bot
2025-11-27 08:51:10 +02:00
parent ea970ead2a
commit c34fb7256d
126 changed files with 18553 additions and 693 deletions

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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; }
}

View File

@@ -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}";
}

View File

@@ -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] }
});
}
}
}
}
}

View File

@@ -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";
}

View File

@@ -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);
}
}

View File

@@ -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; }
}

View File

@@ -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("&", "&amp;")
.Replace("\"", "&quot;")
.Replace("<", "&lt;")
.Replace(">", "&gt;");
}
[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();
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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
{
}

View File

@@ -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
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}