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

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