up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user