up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Incident list query parameters.
|
||||
/// </summary>
|
||||
public sealed record IncidentListQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Filter by status (open, acknowledged, resolved).
|
||||
/// </summary>
|
||||
public string? Status { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter by event kind prefix.
|
||||
/// </summary>
|
||||
public string? EventKindPrefix { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter incidents after this timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Since { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Filter incidents before this timestamp.
|
||||
/// </summary>
|
||||
public DateTimeOffset? Until { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of results.
|
||||
/// </summary>
|
||||
public int? Limit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cursor for pagination.
|
||||
/// </summary>
|
||||
public string? Cursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incident response DTO.
|
||||
/// </summary>
|
||||
public sealed record IncidentResponse
|
||||
{
|
||||
public required string IncidentId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string EventKind { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required string Severity { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required int EventCount { get; init; }
|
||||
public required DateTimeOffset FirstOccurrence { get; init; }
|
||||
public required DateTimeOffset LastOccurrence { get; init; }
|
||||
public string? AcknowledgedBy { get; init; }
|
||||
public DateTimeOffset? AcknowledgedAt { get; init; }
|
||||
public string? ResolvedBy { get; init; }
|
||||
public DateTimeOffset? ResolvedAt { get; init; }
|
||||
public List<string>? Labels { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Incident list response with pagination.
|
||||
/// </summary>
|
||||
public sealed record IncidentListResponse
|
||||
{
|
||||
public required List<IncidentResponse> Incidents { get; init; }
|
||||
public required int TotalCount { get; init; }
|
||||
public string? NextCursor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to acknowledge an incident.
|
||||
/// </summary>
|
||||
public sealed record IncidentAckRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Actor performing the acknowledgement.
|
||||
/// </summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional comment.
|
||||
/// </summary>
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve an incident.
|
||||
/// </summary>
|
||||
public sealed record IncidentResolveRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Actor resolving the incident.
|
||||
/// </summary>
|
||||
public string? Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolution reason.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional comment.
|
||||
/// </summary>
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery history item for an incident.
|
||||
/// </summary>
|
||||
public sealed record DeliveryHistoryItem
|
||||
{
|
||||
public required string DeliveryId { get; init; }
|
||||
public required string ChannelType { get; init; }
|
||||
public required string ChannelName { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
public string? ErrorMessage { get; init; }
|
||||
public int Attempts { get; init; }
|
||||
}
|
||||
@@ -1,9 +1,35 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request payload for acknowledging a pack approval decision.
|
||||
/// </summary>
|
||||
public sealed class PackApprovalAckRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Acknowledgement token from the notification.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[JsonPropertyName("ackToken")]
|
||||
public string AckToken { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Approval decision: "approved" or "rejected".
|
||||
/// </summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public string? Decision { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional comment for audit trail.
|
||||
/// </summary>
|
||||
[JsonPropertyName("comment")]
|
||||
public string? Comment { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Identity acknowledging the approval.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actor")]
|
||||
public string? Actor { get; init; }
|
||||
}
|
||||
|
||||
@@ -2,44 +2,87 @@ using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request payload for pack approval events from Task Runner.
|
||||
/// See: docs/notifications/pack-approvals-contract.md
|
||||
/// </summary>
|
||||
public sealed class PackApprovalRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique event identifier for deduplication.
|
||||
/// </summary>
|
||||
[JsonPropertyName("eventId")]
|
||||
public Guid EventId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event timestamp in UTC (ISO 8601).
|
||||
/// </summary>
|
||||
[JsonPropertyName("issuedAt")]
|
||||
public DateTimeOffset IssuedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event type: pack.approval.requested, pack.approval.updated, pack.policy.hold, pack.policy.released.
|
||||
/// </summary>
|
||||
[JsonPropertyName("kind")]
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Package identifier in PURL format.
|
||||
/// </summary>
|
||||
[JsonPropertyName("packId")]
|
||||
public string PackId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Policy metadata (id and version).
|
||||
/// </summary>
|
||||
[JsonPropertyName("policy")]
|
||||
public PackApprovalPolicy? Policy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current approval state: pending, approved, rejected, hold, expired.
|
||||
/// </summary>
|
||||
[JsonPropertyName("decision")]
|
||||
public string Decision { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Identity that triggered the event.
|
||||
/// </summary>
|
||||
[JsonPropertyName("actor")]
|
||||
public string Actor { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Opaque token for Task Runner resume flow. Echoed in X-Resume-After header.
|
||||
/// </summary>
|
||||
[JsonPropertyName("resumeToken")]
|
||||
public string? ResumeToken { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable summary for notifications.
|
||||
/// </summary>
|
||||
[JsonPropertyName("summary")]
|
||||
public string? Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Custom key-value metadata labels.
|
||||
/// </summary>
|
||||
[JsonPropertyName("labels")]
|
||||
public Dictionary<string, string>? Labels { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Policy metadata associated with a pack approval.
|
||||
/// </summary>
|
||||
public sealed class PackApprovalPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Policy identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public string? Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Policy version.
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public string? Version { get; init; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a notification rule.
|
||||
/// </summary>
|
||||
public sealed record RuleCreateRequest
|
||||
{
|
||||
public required string RuleId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public bool Enabled { get; init; } = true;
|
||||
public required RuleMatchRequest Match { get; init; }
|
||||
public required List<RuleActionRequest> Actions { get; init; }
|
||||
public Dictionary<string, string>? Labels { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to update an existing rule.
|
||||
/// </summary>
|
||||
public sealed record RuleUpdateRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public RuleMatchRequest? Match { get; init; }
|
||||
public List<RuleActionRequest>? Actions { get; init; }
|
||||
public Dictionary<string, string>? Labels { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule match criteria.
|
||||
/// </summary>
|
||||
public sealed record RuleMatchRequest
|
||||
{
|
||||
public List<string>? EventKinds { get; init; }
|
||||
public List<string>? Namespaces { get; init; }
|
||||
public List<string>? Repositories { get; init; }
|
||||
public List<string>? Digests { get; init; }
|
||||
public List<string>? Labels { get; init; }
|
||||
public List<string>? ComponentPurls { get; init; }
|
||||
public string? MinSeverity { get; init; }
|
||||
public List<string>? Verdicts { get; init; }
|
||||
public bool? KevOnly { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule action configuration.
|
||||
/// </summary>
|
||||
public sealed record RuleActionRequest
|
||||
{
|
||||
public required string ActionId { get; init; }
|
||||
public required string Channel { get; init; }
|
||||
public string? Template { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public string? Throttle { get; init; } // ISO 8601 duration
|
||||
public string? Locale { get; init; }
|
||||
public bool Enabled { get; init; } = true;
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule response DTO.
|
||||
/// </summary>
|
||||
public sealed record RuleResponse
|
||||
{
|
||||
public required string RuleId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public required bool Enabled { get; init; }
|
||||
public required RuleMatchResponse Match { get; init; }
|
||||
public required List<RuleActionResponse> Actions { get; init; }
|
||||
public Dictionary<string, string>? Labels { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? UpdatedBy { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule match response.
|
||||
/// </summary>
|
||||
public sealed record RuleMatchResponse
|
||||
{
|
||||
public List<string> EventKinds { get; init; } = [];
|
||||
public List<string> Namespaces { get; init; } = [];
|
||||
public List<string> Repositories { get; init; } = [];
|
||||
public List<string> Digests { get; init; } = [];
|
||||
public List<string> Labels { get; init; } = [];
|
||||
public List<string> ComponentPurls { get; init; } = [];
|
||||
public string? MinSeverity { get; init; }
|
||||
public List<string> Verdicts { get; init; } = [];
|
||||
public bool KevOnly { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule action response.
|
||||
/// </summary>
|
||||
public sealed record RuleActionResponse
|
||||
{
|
||||
public required string ActionId { get; init; }
|
||||
public required string Channel { get; init; }
|
||||
public string? Template { get; init; }
|
||||
public string? Digest { get; init; }
|
||||
public string? Throttle { get; init; }
|
||||
public string? Locale { get; init; }
|
||||
public required bool Enabled { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to preview a template rendering.
|
||||
/// </summary>
|
||||
public sealed record TemplatePreviewRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Template ID to preview (mutually exclusive with TemplateBody).
|
||||
/// </summary>
|
||||
public string? TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw template body to preview (mutually exclusive with TemplateId).
|
||||
/// </summary>
|
||||
public string? TemplateBody { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sample event payload for rendering.
|
||||
/// </summary>
|
||||
public JsonObject? SamplePayload { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Event kind for context.
|
||||
/// </summary>
|
||||
public string? EventKind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Sample attributes.
|
||||
/// </summary>
|
||||
public Dictionary<string, string>? SampleAttributes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output format override.
|
||||
/// </summary>
|
||||
public string? OutputFormat { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response from template preview.
|
||||
/// </summary>
|
||||
public sealed record TemplatePreviewResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Rendered body content.
|
||||
/// </summary>
|
||||
public required string RenderedBody { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Rendered subject (if applicable).
|
||||
/// </summary>
|
||||
public string? RenderedSubject { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Content hash for deduplication.
|
||||
/// </summary>
|
||||
public required string BodyHash { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Output format used.
|
||||
/// </summary>
|
||||
public required string Format { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Validation warnings (if any).
|
||||
/// </summary>
|
||||
public List<string>? Warnings { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a template.
|
||||
/// </summary>
|
||||
public sealed record TemplateCreateRequest
|
||||
{
|
||||
public required string TemplateId { get; init; }
|
||||
public required string Key { get; init; }
|
||||
public required string ChannelType { get; init; }
|
||||
public required string Locale { get; init; }
|
||||
public required string Body { get; init; }
|
||||
public string? RenderMode { get; init; }
|
||||
public string? Format { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Template response DTO.
|
||||
/// </summary>
|
||||
public sealed record TemplateResponse
|
||||
{
|
||||
public required string TemplateId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Key { get; init; }
|
||||
public required string ChannelType { get; init; }
|
||||
public required string Locale { get; init; }
|
||||
public required string Body { get; init; }
|
||||
public required string RenderMode { get; init; }
|
||||
public required string Format { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
public string? CreatedBy { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public string? UpdatedBy { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Template list query parameters.
|
||||
/// </summary>
|
||||
public sealed record TemplateListQuery
|
||||
{
|
||||
public string? KeyPrefix { get; init; }
|
||||
public string? ChannelType { get; init; }
|
||||
public string? Locale { get; init; }
|
||||
public int? Limit { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,796 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Notifier.Worker.Escalation;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for escalation management.
|
||||
/// </summary>
|
||||
public static class EscalationEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps escalation endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapEscalationEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// Escalation Policies
|
||||
var policies = app.MapGroup("/api/v2/escalation-policies")
|
||||
.WithTags("Escalation Policies")
|
||||
.WithOpenApi();
|
||||
|
||||
policies.MapGet("/", ListPoliciesAsync)
|
||||
.WithName("ListEscalationPolicies")
|
||||
.WithSummary("List escalation policies");
|
||||
|
||||
policies.MapGet("/{policyId}", GetPolicyAsync)
|
||||
.WithName("GetEscalationPolicy")
|
||||
.WithSummary("Get an escalation policy");
|
||||
|
||||
policies.MapPost("/", CreatePolicyAsync)
|
||||
.WithName("CreateEscalationPolicy")
|
||||
.WithSummary("Create an escalation policy");
|
||||
|
||||
policies.MapPut("/{policyId}", UpdatePolicyAsync)
|
||||
.WithName("UpdateEscalationPolicy")
|
||||
.WithSummary("Update an escalation policy");
|
||||
|
||||
policies.MapDelete("/{policyId}", DeletePolicyAsync)
|
||||
.WithName("DeleteEscalationPolicy")
|
||||
.WithSummary("Delete an escalation policy");
|
||||
|
||||
// On-Call Schedules
|
||||
var schedules = app.MapGroup("/api/v2/oncall-schedules")
|
||||
.WithTags("On-Call Schedules")
|
||||
.WithOpenApi();
|
||||
|
||||
schedules.MapGet("/", ListSchedulesAsync)
|
||||
.WithName("ListOnCallSchedules")
|
||||
.WithSummary("List on-call schedules");
|
||||
|
||||
schedules.MapGet("/{scheduleId}", GetScheduleAsync)
|
||||
.WithName("GetOnCallSchedule")
|
||||
.WithSummary("Get an on-call schedule");
|
||||
|
||||
schedules.MapPost("/", CreateScheduleAsync)
|
||||
.WithName("CreateOnCallSchedule")
|
||||
.WithSummary("Create an on-call schedule");
|
||||
|
||||
schedules.MapPut("/{scheduleId}", UpdateScheduleAsync)
|
||||
.WithName("UpdateOnCallSchedule")
|
||||
.WithSummary("Update an on-call schedule");
|
||||
|
||||
schedules.MapDelete("/{scheduleId}", DeleteScheduleAsync)
|
||||
.WithName("DeleteOnCallSchedule")
|
||||
.WithSummary("Delete an on-call schedule");
|
||||
|
||||
schedules.MapGet("/{scheduleId}/oncall", GetCurrentOnCallAsync)
|
||||
.WithName("GetCurrentOnCall")
|
||||
.WithSummary("Get current on-call users");
|
||||
|
||||
schedules.MapPost("/{scheduleId}/overrides", CreateOverrideAsync)
|
||||
.WithName("CreateOnCallOverride")
|
||||
.WithSummary("Create an on-call override");
|
||||
|
||||
schedules.MapDelete("/{scheduleId}/overrides/{overrideId}", DeleteOverrideAsync)
|
||||
.WithName("DeleteOnCallOverride")
|
||||
.WithSummary("Delete an on-call override");
|
||||
|
||||
// Active Escalations
|
||||
var escalations = app.MapGroup("/api/v2/escalations")
|
||||
.WithTags("Escalations")
|
||||
.WithOpenApi();
|
||||
|
||||
escalations.MapGet("/", ListActiveEscalationsAsync)
|
||||
.WithName("ListActiveEscalations")
|
||||
.WithSummary("List active escalations");
|
||||
|
||||
escalations.MapGet("/{incidentId}", GetEscalationStateAsync)
|
||||
.WithName("GetEscalationState")
|
||||
.WithSummary("Get escalation state for an incident");
|
||||
|
||||
escalations.MapPost("/{incidentId}/start", StartEscalationAsync)
|
||||
.WithName("StartEscalation")
|
||||
.WithSummary("Start escalation for an incident");
|
||||
|
||||
escalations.MapPost("/{incidentId}/escalate", ManualEscalateAsync)
|
||||
.WithName("ManualEscalate")
|
||||
.WithSummary("Manually escalate to next level");
|
||||
|
||||
escalations.MapPost("/{incidentId}/stop", StopEscalationAsync)
|
||||
.WithName("StopEscalation")
|
||||
.WithSummary("Stop escalation");
|
||||
|
||||
// Ack Bridge
|
||||
var ack = app.MapGroup("/api/v2/ack")
|
||||
.WithTags("Acknowledgment")
|
||||
.WithOpenApi();
|
||||
|
||||
ack.MapPost("/", ProcessAckAsync)
|
||||
.WithName("ProcessAck")
|
||||
.WithSummary("Process an acknowledgment");
|
||||
|
||||
ack.MapGet("/", ProcessAckLinkAsync)
|
||||
.WithName("ProcessAckLink")
|
||||
.WithSummary("Process an acknowledgment link");
|
||||
|
||||
ack.MapPost("/webhook/pagerduty", ProcessPagerDutyWebhookAsync)
|
||||
.WithName("PagerDutyWebhook")
|
||||
.WithSummary("Process PagerDuty webhook");
|
||||
|
||||
ack.MapPost("/webhook/opsgenie", ProcessOpsGenieWebhookAsync)
|
||||
.WithName("OpsGenieWebhook")
|
||||
.WithSummary("Process OpsGenie webhook");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
#region Policy Endpoints
|
||||
|
||||
private static async Task<IResult> ListPoliciesAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IEscalationPolicyService policyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var policies = await policyService.ListPoliciesAsync(tenantId, cancellationToken);
|
||||
return Results.Ok(policies);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetPolicyAsync(
|
||||
string policyId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IEscalationPolicyService policyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var policy = await policyService.GetPolicyAsync(tenantId, policyId, cancellationToken);
|
||||
return policy is null
|
||||
? Results.NotFound(new { error = $"Policy '{policyId}' not found." })
|
||||
: Results.Ok(policy);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreatePolicyAsync(
|
||||
[FromBody] EscalationPolicyApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IEscalationPolicyService policyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Policy name is required." });
|
||||
}
|
||||
|
||||
if (request.Levels is null || request.Levels.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "At least one escalation level is required." });
|
||||
}
|
||||
|
||||
var policy = MapToPolicy(request, tenantId);
|
||||
var created = await policyService.UpsertPolicyAsync(policy, actor, cancellationToken);
|
||||
|
||||
return Results.Created($"/api/v2/escalation-policies/{created.PolicyId}", created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdatePolicyAsync(
|
||||
string policyId,
|
||||
[FromBody] EscalationPolicyApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IEscalationPolicyService policyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required." });
|
||||
}
|
||||
|
||||
var existing = await policyService.GetPolicyAsync(tenantId, policyId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Policy '{policyId}' not found." });
|
||||
}
|
||||
|
||||
var policy = MapToPolicy(request, tenantId) with
|
||||
{
|
||||
PolicyId = policyId,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
CreatedBy = existing.CreatedBy
|
||||
};
|
||||
|
||||
var updated = await policyService.UpsertPolicyAsync(policy, actor, cancellationToken);
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeletePolicyAsync(
|
||||
string policyId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IEscalationPolicyService policyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var deleted = await policyService.DeletePolicyAsync(tenantId, policyId, actor, cancellationToken);
|
||||
return deleted ? Results.NoContent() : Results.NotFound(new { error = $"Policy '{policyId}' not found." });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Schedule Endpoints
|
||||
|
||||
private static async Task<IResult> ListSchedulesAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var schedules = await scheduleService.ListSchedulesAsync(tenantId, cancellationToken);
|
||||
return Results.Ok(schedules);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetScheduleAsync(
|
||||
string scheduleId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var schedule = await scheduleService.GetScheduleAsync(tenantId, scheduleId, cancellationToken);
|
||||
return schedule is null
|
||||
? Results.NotFound(new { error = $"Schedule '{scheduleId}' not found." })
|
||||
: Results.Ok(schedule);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateScheduleAsync(
|
||||
[FromBody] OnCallScheduleApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Schedule name is required." });
|
||||
}
|
||||
|
||||
var schedule = MapToSchedule(request, tenantId);
|
||||
var created = await scheduleService.UpsertScheduleAsync(schedule, actor, cancellationToken);
|
||||
|
||||
return Results.Created($"/api/v2/oncall-schedules/{created.ScheduleId}", created);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateScheduleAsync(
|
||||
string scheduleId,
|
||||
[FromBody] OnCallScheduleApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required." });
|
||||
}
|
||||
|
||||
var existing = await scheduleService.GetScheduleAsync(tenantId, scheduleId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Schedule '{scheduleId}' not found." });
|
||||
}
|
||||
|
||||
var schedule = MapToSchedule(request, tenantId) with
|
||||
{
|
||||
ScheduleId = scheduleId,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
CreatedBy = existing.CreatedBy
|
||||
};
|
||||
|
||||
var updated = await scheduleService.UpsertScheduleAsync(schedule, actor, cancellationToken);
|
||||
return Results.Ok(updated);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteScheduleAsync(
|
||||
string scheduleId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var deleted = await scheduleService.DeleteScheduleAsync(tenantId, scheduleId, actor, cancellationToken);
|
||||
return deleted ? Results.NoContent() : Results.NotFound(new { error = $"Schedule '{scheduleId}' not found." });
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetCurrentOnCallAsync(
|
||||
string scheduleId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromQuery] DateTimeOffset? atTime,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var users = await scheduleService.GetCurrentOnCallAsync(tenantId, scheduleId, atTime, cancellationToken);
|
||||
return Results.Ok(users);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateOverrideAsync(
|
||||
string scheduleId,
|
||||
[FromBody] OnCallOverrideApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var @override = new OnCallOverride
|
||||
{
|
||||
OverrideId = request.OverrideId ?? Guid.NewGuid().ToString("N")[..16],
|
||||
User = new OnCallUser
|
||||
{
|
||||
UserId = request.UserId ?? "",
|
||||
Name = request.UserName ?? request.UserId ?? ""
|
||||
},
|
||||
StartsAt = request.StartsAt ?? DateTimeOffset.UtcNow,
|
||||
EndsAt = request.EndsAt ?? DateTimeOffset.UtcNow.AddHours(8),
|
||||
Reason = request.Reason
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var created = await scheduleService.CreateOverrideAsync(tenantId, scheduleId, @override, actor, cancellationToken);
|
||||
return Results.Created($"/api/v2/oncall-schedules/{scheduleId}/overrides/{created.OverrideId}", created);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.NotFound(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteOverrideAsync(
|
||||
string scheduleId,
|
||||
string overrideId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IOnCallScheduleService scheduleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var deleted = await scheduleService.DeleteOverrideAsync(tenantId, scheduleId, overrideId, actor, cancellationToken);
|
||||
return deleted ? Results.NoContent() : Results.NotFound(new { error = "Override not found." });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Escalation Endpoints
|
||||
|
||||
private static async Task<IResult> ListActiveEscalationsAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IEscalationEngine escalationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var escalations = await escalationEngine.ListActiveEscalationsAsync(tenantId, cancellationToken);
|
||||
return Results.Ok(escalations);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetEscalationStateAsync(
|
||||
string incidentId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IEscalationEngine escalationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var state = await escalationEngine.GetEscalationStateAsync(tenantId, incidentId, cancellationToken);
|
||||
return state is null
|
||||
? Results.NotFound(new { error = $"No escalation found for incident '{incidentId}'." })
|
||||
: Results.Ok(state);
|
||||
}
|
||||
|
||||
private static async Task<IResult> StartEscalationAsync(
|
||||
string incidentId,
|
||||
[FromBody] StartEscalationApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IEscalationEngine escalationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.PolicyId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Policy ID is required." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var state = await escalationEngine.StartEscalationAsync(tenantId, incidentId, request.PolicyId, cancellationToken);
|
||||
return Results.Created($"/api/v2/escalations/{incidentId}", state);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> ManualEscalateAsync(
|
||||
string incidentId,
|
||||
[FromBody] ManualEscalateApiRequest? request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IEscalationEngine escalationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var state = await escalationEngine.EscalateAsync(tenantId, incidentId, request?.Reason, actor, cancellationToken);
|
||||
return state is null
|
||||
? Results.NotFound(new { error = $"No active escalation found for incident '{incidentId}'." })
|
||||
: Results.Ok(state);
|
||||
}
|
||||
|
||||
private static async Task<IResult> StopEscalationAsync(
|
||||
string incidentId,
|
||||
[FromBody] StopEscalationApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IEscalationEngine escalationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var stopped = await escalationEngine.StopEscalationAsync(
|
||||
tenantId, incidentId, request.Reason ?? "Manually stopped", actor, cancellationToken);
|
||||
|
||||
return stopped
|
||||
? Results.NoContent()
|
||||
: Results.NotFound(new { error = $"No active escalation found for incident '{incidentId}'." });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ack Endpoints
|
||||
|
||||
private static async Task<IResult> ProcessAckAsync(
|
||||
[FromBody] AckApiRequest request,
|
||||
[FromServices] IAckBridge ackBridge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var bridgeRequest = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.Api,
|
||||
TenantId = request.TenantId,
|
||||
IncidentId = request.IncidentId,
|
||||
AcknowledgedBy = request.AcknowledgedBy ?? "api",
|
||||
Comment = request.Comment
|
||||
};
|
||||
|
||||
var result = await ackBridge.ProcessAckAsync(bridgeRequest, cancellationToken);
|
||||
return result.Success
|
||||
? Results.Ok(result)
|
||||
: Results.BadRequest(new { error = result.Error });
|
||||
}
|
||||
|
||||
private static async Task<IResult> ProcessAckLinkAsync(
|
||||
[FromQuery] string token,
|
||||
[FromServices] IAckBridge ackBridge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var validation = await ackBridge.ValidateTokenAsync(token, cancellationToken);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(new { error = validation.Error });
|
||||
}
|
||||
|
||||
var bridgeRequest = new AckBridgeRequest
|
||||
{
|
||||
Source = AckSource.SignedLink,
|
||||
Token = token,
|
||||
AcknowledgedBy = validation.TargetId ?? "link"
|
||||
};
|
||||
|
||||
var result = await ackBridge.ProcessAckAsync(bridgeRequest, cancellationToken);
|
||||
return result.Success
|
||||
? Results.Ok(new { message = "Acknowledged successfully", incidentId = result.IncidentId })
|
||||
: Results.BadRequest(new { error = result.Error });
|
||||
}
|
||||
|
||||
private static async Task<IResult> ProcessPagerDutyWebhookAsync(
|
||||
HttpContext context,
|
||||
[FromServices] IEnumerable<IExternalIntegrationAdapter> adapters,
|
||||
[FromServices] IAckBridge ackBridge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var pagerDutyAdapter = adapters.OfType<PagerDutyAdapter>().FirstOrDefault();
|
||||
if (pagerDutyAdapter is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "PagerDuty integration not configured." });
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var payload = await reader.ReadToEndAsync(cancellationToken);
|
||||
|
||||
var request = pagerDutyAdapter.ParseWebhook(payload);
|
||||
if (request is null)
|
||||
{
|
||||
return Results.Ok(new { message = "Webhook received but no action taken." });
|
||||
}
|
||||
|
||||
var result = await ackBridge.ProcessAckAsync(request, cancellationToken);
|
||||
return Results.Ok(new { processed = result.Success });
|
||||
}
|
||||
|
||||
private static async Task<IResult> ProcessOpsGenieWebhookAsync(
|
||||
HttpContext context,
|
||||
[FromServices] IEnumerable<IExternalIntegrationAdapter> adapters,
|
||||
[FromServices] IAckBridge ackBridge,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var opsGenieAdapter = adapters.OfType<OpsGenieAdapter>().FirstOrDefault();
|
||||
if (opsGenieAdapter is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "OpsGenie integration not configured." });
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(context.Request.Body);
|
||||
var payload = await reader.ReadToEndAsync(cancellationToken);
|
||||
|
||||
var request = opsGenieAdapter.ParseWebhook(payload);
|
||||
if (request is null)
|
||||
{
|
||||
return Results.Ok(new { message = "Webhook received but no action taken." });
|
||||
}
|
||||
|
||||
var result = await ackBridge.ProcessAckAsync(request, cancellationToken);
|
||||
return Results.Ok(new { processed = result.Success });
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mapping
|
||||
|
||||
private static EscalationPolicy MapToPolicy(EscalationPolicyApiRequest request, string tenantId) => new()
|
||||
{
|
||||
PolicyId = request.PolicyId ?? Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Name = request.Name!,
|
||||
Description = request.Description,
|
||||
IsDefault = request.IsDefault ?? false,
|
||||
Enabled = request.Enabled ?? true,
|
||||
EventKinds = request.EventKinds,
|
||||
MinSeverity = request.MinSeverity,
|
||||
Levels = request.Levels!.Select((l, i) => new EscalationLevel
|
||||
{
|
||||
Level = l.Level ?? i + 1,
|
||||
Name = l.Name,
|
||||
EscalateAfter = TimeSpan.FromMinutes(l.EscalateAfterMinutes ?? 15),
|
||||
Targets = l.Targets?.Select(t => new EscalationTarget
|
||||
{
|
||||
Type = Enum.TryParse<EscalationTargetType>(t.Type, true, out var type) ? type : EscalationTargetType.User,
|
||||
TargetId = t.TargetId ?? "",
|
||||
Name = t.Name,
|
||||
ChannelId = t.ChannelId
|
||||
}).ToList() ?? [],
|
||||
NotifyMode = Enum.TryParse<EscalationNotifyMode>(l.NotifyMode, true, out var mode) ? mode : EscalationNotifyMode.All,
|
||||
StopOnAck = l.StopOnAck ?? true
|
||||
}).ToList(),
|
||||
ExhaustedAction = Enum.TryParse<EscalationExhaustedAction>(request.ExhaustedAction, true, out var action)
|
||||
? action : EscalationExhaustedAction.RepeatLastLevel,
|
||||
MaxCycles = request.MaxCycles ?? 3
|
||||
};
|
||||
|
||||
private static OnCallSchedule MapToSchedule(OnCallScheduleApiRequest request, string tenantId) => new()
|
||||
{
|
||||
ScheduleId = request.ScheduleId ?? Guid.NewGuid().ToString("N")[..16],
|
||||
TenantId = tenantId,
|
||||
Name = request.Name!,
|
||||
Description = request.Description,
|
||||
Timezone = request.Timezone ?? "UTC",
|
||||
Enabled = request.Enabled ?? true,
|
||||
Layers = request.Layers?.Select(l => new RotationLayer
|
||||
{
|
||||
Name = l.Name ?? "Default",
|
||||
Priority = l.Priority ?? 100,
|
||||
Users = l.Users?.Select((u, i) => new OnCallUser
|
||||
{
|
||||
UserId = u.UserId ?? "",
|
||||
Name = u.Name ?? u.UserId ?? "",
|
||||
Email = u.Email,
|
||||
Phone = u.Phone,
|
||||
PreferredChannelId = u.PreferredChannelId,
|
||||
Order = u.Order ?? i
|
||||
}).ToList() ?? [],
|
||||
Type = Enum.TryParse<RotationType>(l.RotationType, true, out var type) ? type : RotationType.Weekly,
|
||||
HandoffTime = TimeOnly.TryParse(l.HandoffTime, out var time) ? time : new TimeOnly(9, 0),
|
||||
RotationInterval = TimeSpan.FromDays(l.RotationIntervalDays ?? 7),
|
||||
RotationStart = l.RotationStart ?? DateTimeOffset.UtcNow,
|
||||
Restrictions = l.Restrictions?.Select(r => new ScheduleRestriction
|
||||
{
|
||||
Type = Enum.TryParse<RestrictionType>(r.Type, true, out var rType) ? rType : RestrictionType.DaysOfWeek,
|
||||
DaysOfWeek = r.DaysOfWeek,
|
||||
StartTime = TimeOnly.TryParse(r.StartTime, out var start) ? start : null,
|
||||
EndTime = TimeOnly.TryParse(r.EndTime, out var end) ? end : null
|
||||
}).ToList(),
|
||||
Enabled = l.Enabled ?? true
|
||||
}).ToList() ?? []
|
||||
};
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region API Request Models
|
||||
|
||||
public sealed class EscalationPolicyApiRequest
|
||||
{
|
||||
public string? PolicyId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool? IsDefault { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public List<string>? EventKinds { get; set; }
|
||||
public string? MinSeverity { get; set; }
|
||||
public List<EscalationLevelApiRequest>? Levels { get; set; }
|
||||
public string? ExhaustedAction { get; set; }
|
||||
public int? MaxCycles { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EscalationLevelApiRequest
|
||||
{
|
||||
public int? Level { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public int? EscalateAfterMinutes { get; set; }
|
||||
public List<EscalationTargetApiRequest>? Targets { get; set; }
|
||||
public string? NotifyMode { get; set; }
|
||||
public bool? StopOnAck { get; set; }
|
||||
}
|
||||
|
||||
public sealed class EscalationTargetApiRequest
|
||||
{
|
||||
public string? Type { get; set; }
|
||||
public string? TargetId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? ChannelId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OnCallScheduleApiRequest
|
||||
{
|
||||
public string? ScheduleId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Timezone { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public List<RotationLayerApiRequest>? Layers { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RotationLayerApiRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public int? Priority { get; set; }
|
||||
public List<OnCallUserApiRequest>? Users { get; set; }
|
||||
public string? RotationType { get; set; }
|
||||
public string? HandoffTime { get; set; }
|
||||
public int? RotationIntervalDays { get; set; }
|
||||
public DateTimeOffset? RotationStart { get; set; }
|
||||
public List<ScheduleRestrictionApiRequest>? Restrictions { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OnCallUserApiRequest
|
||||
{
|
||||
public string? UserId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? PreferredChannelId { get; set; }
|
||||
public int? Order { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ScheduleRestrictionApiRequest
|
||||
{
|
||||
public string? Type { get; set; }
|
||||
public List<int>? DaysOfWeek { get; set; }
|
||||
public string? StartTime { get; set; }
|
||||
public string? EndTime { get; set; }
|
||||
}
|
||||
|
||||
public sealed class OnCallOverrideApiRequest
|
||||
{
|
||||
public string? OverrideId { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public string? UserName { get; set; }
|
||||
public DateTimeOffset? StartsAt { get; set; }
|
||||
public DateTimeOffset? EndsAt { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StartEscalationApiRequest
|
||||
{
|
||||
public string? PolicyId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ManualEscalateApiRequest
|
||||
{
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StopEscalationApiRequest
|
||||
{
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AckApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public string? IncidentId { get; set; }
|
||||
public string? AcknowledgedBy { get; set; }
|
||||
public string? Comment { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Fallback;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for fallback handler operations.
|
||||
/// </summary>
|
||||
public static class FallbackEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps fallback API endpoints.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapFallbackEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v2/fallback")
|
||||
.WithTags("Fallback")
|
||||
.WithOpenApi();
|
||||
|
||||
// Get fallback statistics
|
||||
group.MapGet("/statistics", async (
|
||||
int? windowHours,
|
||||
HttpContext context,
|
||||
IFallbackHandler fallbackHandler,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var window = windowHours.HasValue ? TimeSpan.FromHours(windowHours.Value) : (TimeSpan?)null;
|
||||
|
||||
var stats = await fallbackHandler.GetStatisticsAsync(tenantId, window, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
stats.TenantId,
|
||||
window = stats.Window.ToString(),
|
||||
stats.TotalDeliveries,
|
||||
stats.PrimarySuccesses,
|
||||
stats.FallbackAttempts,
|
||||
stats.FallbackSuccesses,
|
||||
stats.ExhaustedDeliveries,
|
||||
successRate = $"{stats.SuccessRate:P1}",
|
||||
fallbackUtilizationRate = $"{stats.FallbackUtilizationRate:P1}",
|
||||
failuresByChannel = stats.FailuresByChannel.ToDictionary(
|
||||
kvp => kvp.Key.ToString(),
|
||||
kvp => kvp.Value)
|
||||
});
|
||||
})
|
||||
.WithName("GetFallbackStatistics")
|
||||
.WithSummary("Gets fallback handling statistics for a tenant");
|
||||
|
||||
// Get fallback chain for a channel
|
||||
group.MapGet("/chains/{channelType}", async (
|
||||
NotifyChannelType channelType,
|
||||
HttpContext context,
|
||||
IFallbackHandler fallbackHandler,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var chain = await fallbackHandler.GetFallbackChainAsync(tenantId, channelType, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
primaryChannel = channelType.ToString(),
|
||||
fallbackChain = chain.Select(c => c.ToString()).ToList(),
|
||||
chainLength = chain.Count
|
||||
});
|
||||
})
|
||||
.WithName("GetFallbackChain")
|
||||
.WithSummary("Gets the fallback chain for a channel type");
|
||||
|
||||
// Set fallback chain for a channel
|
||||
group.MapPut("/chains/{channelType}", async (
|
||||
NotifyChannelType channelType,
|
||||
SetFallbackChainRequest request,
|
||||
HttpContext context,
|
||||
IFallbackHandler fallbackHandler,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
|
||||
|
||||
var chain = request.FallbackChain
|
||||
.Select(s => Enum.TryParse<NotifyChannelType>(s, out var t) ? t : (NotifyChannelType?)null)
|
||||
.Where(t => t.HasValue)
|
||||
.Select(t => t!.Value)
|
||||
.ToList();
|
||||
|
||||
await fallbackHandler.SetFallbackChainAsync(tenantId, channelType, chain, actor, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
message = "Fallback chain updated successfully",
|
||||
primaryChannel = channelType.ToString(),
|
||||
fallbackChain = chain.Select(c => c.ToString()).ToList()
|
||||
});
|
||||
})
|
||||
.WithName("SetFallbackChain")
|
||||
.WithSummary("Sets a custom fallback chain for a channel type");
|
||||
|
||||
// Test fallback resolution
|
||||
group.MapPost("/test", async (
|
||||
TestFallbackRequest request,
|
||||
HttpContext context,
|
||||
IFallbackHandler fallbackHandler,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
if (!Enum.TryParse<NotifyChannelType>(request.FailedChannelType, out var channelType))
|
||||
{
|
||||
return Results.BadRequest(new { error = $"Invalid channel type: {request.FailedChannelType}" });
|
||||
}
|
||||
|
||||
var deliveryId = $"test-{Guid.NewGuid():N}"[..20];
|
||||
|
||||
// Simulate failure recording
|
||||
await fallbackHandler.RecordFailureAsync(
|
||||
tenantId, deliveryId, channelType, "Test failure", cancellationToken);
|
||||
|
||||
// Get fallback result
|
||||
var result = await fallbackHandler.GetFallbackAsync(
|
||||
tenantId, channelType, deliveryId, cancellationToken);
|
||||
|
||||
// Clean up test state
|
||||
await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
testDeliveryId = deliveryId,
|
||||
result.HasFallback,
|
||||
nextChannelType = result.NextChannelType?.ToString(),
|
||||
result.AttemptNumber,
|
||||
result.TotalChannels,
|
||||
result.IsExhausted,
|
||||
result.ExhaustionReason,
|
||||
failedChannels = result.FailedChannels.Select(f => new
|
||||
{
|
||||
channelType = f.ChannelType.ToString(),
|
||||
f.Reason,
|
||||
f.FailedAt,
|
||||
f.AttemptNumber
|
||||
}).ToList()
|
||||
});
|
||||
})
|
||||
.WithName("TestFallback")
|
||||
.WithSummary("Tests fallback resolution without affecting real deliveries");
|
||||
|
||||
// Clear delivery state
|
||||
group.MapDelete("/deliveries/{deliveryId}", async (
|
||||
string deliveryId,
|
||||
HttpContext context,
|
||||
IFallbackHandler fallbackHandler,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
await fallbackHandler.ClearDeliveryStateAsync(tenantId, deliveryId, cancellationToken);
|
||||
|
||||
return Results.Ok(new { message = $"Delivery state for '{deliveryId}' cleared" });
|
||||
})
|
||||
.WithName("ClearDeliveryFallbackState")
|
||||
.WithSummary("Clears fallback state for a specific delivery");
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to set a custom fallback chain.
|
||||
/// </summary>
|
||||
public sealed record SetFallbackChainRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Ordered list of fallback channel types.
|
||||
/// </summary>
|
||||
public required List<string> FallbackChain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to test fallback resolution.
|
||||
/// </summary>
|
||||
public sealed record TestFallbackRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The channel type that "failed".
|
||||
/// </summary>
|
||||
public required string FailedChannelType { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Maps incident (delivery) management endpoints.
|
||||
/// </summary>
|
||||
public static class IncidentEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapIncidentEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/incidents")
|
||||
.WithTags("Incidents");
|
||||
|
||||
group.MapGet("/", ListIncidentsAsync)
|
||||
.WithName("ListIncidents")
|
||||
.WithSummary("Lists notification incidents (deliveries)");
|
||||
|
||||
group.MapGet("/{deliveryId}", GetIncidentAsync)
|
||||
.WithName("GetIncident")
|
||||
.WithSummary("Gets an incident by delivery ID");
|
||||
|
||||
group.MapPost("/{deliveryId}/ack", AcknowledgeIncidentAsync)
|
||||
.WithName("AcknowledgeIncident")
|
||||
.WithSummary("Acknowledges an incident");
|
||||
|
||||
group.MapGet("/stats", GetIncidentStatsAsync)
|
||||
.WithName("GetIncidentStats")
|
||||
.WithSummary("Gets incident statistics");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListIncidentsAsync(
|
||||
HttpContext context,
|
||||
INotifyDeliveryRepository deliveries,
|
||||
string? status = null,
|
||||
string? kind = null,
|
||||
string? ruleId = null,
|
||||
int? limit = null,
|
||||
string? continuationToken = null,
|
||||
DateTimeOffset? since = null)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
// Query deliveries with filtering
|
||||
var queryResult = await deliveries.QueryAsync(
|
||||
tenantId,
|
||||
since,
|
||||
status,
|
||||
limit ?? 50,
|
||||
continuationToken,
|
||||
context.RequestAborted);
|
||||
|
||||
IEnumerable<NotifyDelivery> filtered = queryResult.Items;
|
||||
|
||||
// Apply additional filters not supported by the repository
|
||||
if (!string.IsNullOrWhiteSpace(kind))
|
||||
{
|
||||
filtered = filtered.Where(d => d.Kind.Equals(kind, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ruleId))
|
||||
{
|
||||
filtered = filtered.Where(d => d.RuleId.Equals(ruleId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var response = filtered.Select(MapToDeliveryResponse).ToList();
|
||||
|
||||
// Add continuation token header for pagination
|
||||
if (!string.IsNullOrWhiteSpace(queryResult.ContinuationToken))
|
||||
{
|
||||
context.Response.Headers["X-Continuation-Token"] = queryResult.ContinuationToken;
|
||||
}
|
||||
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetIncidentAsync(
|
||||
HttpContext context,
|
||||
string deliveryId,
|
||||
INotifyDeliveryRepository deliveries)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var delivery = await deliveries.GetAsync(tenantId, deliveryId, context.RequestAborted);
|
||||
if (delivery is null)
|
||||
{
|
||||
return Results.NotFound(Error("incident_not_found", $"Incident '{deliveryId}' not found.", context));
|
||||
}
|
||||
|
||||
return Results.Ok(MapToDeliveryResponse(delivery));
|
||||
}
|
||||
|
||||
private static async Task<IResult> AcknowledgeIncidentAsync(
|
||||
HttpContext context,
|
||||
string deliveryId,
|
||||
DeliveryAckRequest request,
|
||||
INotifyDeliveryRepository deliveries,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
var delivery = await deliveries.GetAsync(tenantId, deliveryId, context.RequestAborted);
|
||||
if (delivery is null)
|
||||
{
|
||||
return Results.NotFound(Error("incident_not_found", $"Incident '{deliveryId}' not found.", context));
|
||||
}
|
||||
|
||||
// Update delivery status based on acknowledgment
|
||||
var newStatus = request.Resolution?.ToLowerInvariant() switch
|
||||
{
|
||||
"resolved" => NotifyDeliveryStatus.Delivered,
|
||||
"dismissed" => NotifyDeliveryStatus.Failed,
|
||||
_ => delivery.Status
|
||||
};
|
||||
|
||||
var attempt = new NotifyDeliveryAttempt(
|
||||
timestamp: timeProvider.GetUtcNow(),
|
||||
status: NotifyDeliveryAttemptStatus.Success,
|
||||
reason: $"Acknowledged by {actor}: {request.Comment ?? request.Resolution ?? "ack"}");
|
||||
|
||||
var updated = delivery with
|
||||
{
|
||||
Status = newStatus,
|
||||
StatusReason = request.Comment ?? $"Acknowledged: {request.Resolution}",
|
||||
CompletedAt = timeProvider.GetUtcNow(),
|
||||
Attempts = delivery.Attempts.Add(attempt)
|
||||
};
|
||||
|
||||
await deliveries.UpdateAsync(updated, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "incident.acknowledged", deliveryId, "incident", new
|
||||
{
|
||||
deliveryId,
|
||||
request.Resolution,
|
||||
request.Comment
|
||||
}, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.Ok(MapToResponse(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetIncidentStatsAsync(
|
||||
HttpContext context,
|
||||
INotifyDeliveryRepository deliveries)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var allDeliveries = await deliveries.ListAsync(tenantId, context.RequestAborted);
|
||||
|
||||
var stats = new DeliveryStatsResponse
|
||||
{
|
||||
Total = allDeliveries.Count,
|
||||
Pending = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Pending),
|
||||
Delivered = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Delivered),
|
||||
Failed = allDeliveries.Count(d => d.Status == NotifyDeliveryStatus.Failed),
|
||||
ByKind = allDeliveries
|
||||
.GroupBy(d => d.Kind)
|
||||
.ToDictionary(g => g.Key, g => g.Count()),
|
||||
ByRule = allDeliveries
|
||||
.GroupBy(d => d.RuleId)
|
||||
.ToDictionary(g => g.Key, g => g.Count())
|
||||
};
|
||||
|
||||
return Results.Ok(stats);
|
||||
}
|
||||
|
||||
private static DeliveryResponse MapToDeliveryResponse(NotifyDelivery delivery)
|
||||
{
|
||||
return new DeliveryResponse
|
||||
{
|
||||
DeliveryId = delivery.DeliveryId,
|
||||
TenantId = delivery.TenantId,
|
||||
RuleId = delivery.RuleId,
|
||||
ActionId = delivery.ActionId,
|
||||
EventId = delivery.EventId.ToString(),
|
||||
Kind = delivery.Kind,
|
||||
Status = delivery.Status.ToString(),
|
||||
StatusReason = delivery.StatusReason,
|
||||
AttemptCount = delivery.Attempts.Length,
|
||||
LastAttempt = delivery.Attempts.Length > 0 ? delivery.Attempts[^1].Timestamp : null,
|
||||
CreatedAt = delivery.CreatedAt,
|
||||
SentAt = delivery.SentAt,
|
||||
CompletedAt = delivery.CompletedAt,
|
||||
Metadata = delivery.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetTenantId(HttpContext context)
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
return string.IsNullOrWhiteSpace(tenantId) ? null : tenantId;
|
||||
}
|
||||
|
||||
private static string GetActor(HttpContext context)
|
||||
{
|
||||
var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
|
||||
return string.IsNullOrWhiteSpace(actor) ? "api" : actor;
|
||||
}
|
||||
|
||||
private static async Task AppendAuditAsync(
|
||||
INotifyAuditRepository audit,
|
||||
string tenantId,
|
||||
string actor,
|
||||
string action,
|
||||
string entityId,
|
||||
string entityType,
|
||||
object payload,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Actor = actor,
|
||||
Action = action,
|
||||
EntityId = entityId,
|
||||
EntityType = entityType,
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
|
||||
JsonSerializer.Serialize(payload))
|
||||
};
|
||||
|
||||
await audit.AppendAsync(entry, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore audit failures
|
||||
}
|
||||
}
|
||||
|
||||
private static object Error(string code, string message, HttpContext context) => new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code,
|
||||
message,
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery acknowledgment request for v2 API.
|
||||
/// </summary>
|
||||
public sealed record DeliveryAckRequest
|
||||
{
|
||||
public string? Resolution { get; init; }
|
||||
public string? Comment { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery response DTO for v2 API.
|
||||
/// </summary>
|
||||
public sealed record DeliveryResponse
|
||||
{
|
||||
public required string DeliveryId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string RuleId { get; init; }
|
||||
public required string ActionId { get; init; }
|
||||
public required string EventId { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? StatusReason { get; init; }
|
||||
public required int AttemptCount { get; init; }
|
||||
public DateTimeOffset? LastAttempt { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? SentAt { get; init; }
|
||||
public DateTimeOffset? CompletedAt { get; init; }
|
||||
public Dictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivery statistics response for v2 API.
|
||||
/// </summary>
|
||||
public sealed record DeliveryStatsResponse
|
||||
{
|
||||
public required int Total { get; init; }
|
||||
public required int Pending { get; init; }
|
||||
public required int Delivered { get; init; }
|
||||
public required int Failed { get; init; }
|
||||
public required Dictionary<string, int> ByKind { get; init; }
|
||||
public required Dictionary<string, int> ByRule { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// WebSocket live feed for real-time incident updates.
|
||||
/// </summary>
|
||||
public static class IncidentLiveFeed
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, ConcurrentBag<WebSocket>> _tenantSubscriptions = new();
|
||||
|
||||
public static IEndpointRouteBuilder MapIncidentLiveFeed(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.Map("/api/v2/incidents/live", HandleWebSocketAsync);
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task HandleWebSocketAsync(HttpContext context)
|
||||
{
|
||||
if (!context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = "websocket_required",
|
||||
message = "This endpoint requires a WebSocket connection.",
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
// Try query string fallback for WebSocket clients that can't set headers
|
||||
tenantId = context.Request.Query["tenant"].ToString();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code = "tenant_missing",
|
||||
message = "X-StellaOps-Tenant header or 'tenant' query parameter is required.",
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
|
||||
var subscriptions = _tenantSubscriptions.GetOrAdd(tenantId, _ => new ConcurrentBag<WebSocket>());
|
||||
subscriptions.Add(webSocket);
|
||||
|
||||
try
|
||||
{
|
||||
// Send connection acknowledgment
|
||||
var ackMessage = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "connected",
|
||||
tenantId,
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
await SendMessageAsync(webSocket, ackMessage, context.RequestAborted);
|
||||
|
||||
// Keep connection alive and handle incoming messages
|
||||
await ReceiveMessagesAsync(webSocket, tenantId, context.RequestAborted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Remove from subscriptions
|
||||
var newBag = new ConcurrentBag<WebSocket>(
|
||||
subscriptions.Where(s => s != webSocket && s.State == WebSocketState.Open));
|
||||
_tenantSubscriptions.TryUpdate(tenantId, newBag, subscriptions);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReceiveMessagesAsync(WebSocket webSocket, string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
|
||||
while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
await webSocket.CloseAsync(
|
||||
WebSocketCloseStatus.NormalClosure,
|
||||
"Client initiated close",
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Text)
|
||||
{
|
||||
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
||||
await HandleClientMessageAsync(webSocket, tenantId, message, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task HandleClientMessageAsync(WebSocket webSocket, string tenantId, string message, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(message);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("type", out var typeElement))
|
||||
{
|
||||
var type = typeElement.GetString();
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "ping":
|
||||
var pongResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "pong",
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
await SendMessageAsync(webSocket, pongResponse, cancellationToken);
|
||||
break;
|
||||
|
||||
case "subscribe":
|
||||
// Handle filter subscriptions (e.g., specific rule IDs, kinds)
|
||||
var subResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "subscribed",
|
||||
tenantId,
|
||||
timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
await SendMessageAsync(webSocket, subResponse, cancellationToken);
|
||||
break;
|
||||
|
||||
default:
|
||||
var errorResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "error",
|
||||
message = $"Unknown message type: {type}"
|
||||
});
|
||||
await SendMessageAsync(webSocket, errorResponse, cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
var errorResponse = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "error",
|
||||
message = "Invalid JSON message"
|
||||
});
|
||||
await SendMessageAsync(webSocket, errorResponse, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendMessageAsync(WebSocket webSocket, string message, CancellationToken cancellationToken)
|
||||
{
|
||||
if (webSocket.State != WebSocketState.Open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(message);
|
||||
await webSocket.SendAsync(
|
||||
new ArraySegment<byte>(bytes),
|
||||
WebSocketMessageType.Text,
|
||||
endOfMessage: true,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcasts an incident update to all connected clients for the specified tenant.
|
||||
/// </summary>
|
||||
public static async Task BroadcastIncidentUpdateAsync(
|
||||
string tenantId,
|
||||
NotifyDelivery delivery,
|
||||
string updateType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "incident_update",
|
||||
updateType, // created, updated, acknowledged, delivered, failed
|
||||
timestamp = DateTimeOffset.UtcNow,
|
||||
incident = new
|
||||
{
|
||||
deliveryId = delivery.DeliveryId,
|
||||
tenantId = delivery.TenantId,
|
||||
ruleId = delivery.RuleId,
|
||||
actionId = delivery.ActionId,
|
||||
eventId = delivery.EventId.ToString(),
|
||||
kind = delivery.Kind,
|
||||
status = delivery.Status.ToString(),
|
||||
statusReason = delivery.StatusReason,
|
||||
attemptCount = delivery.Attempts.Length,
|
||||
createdAt = delivery.CreatedAt,
|
||||
sentAt = delivery.SentAt,
|
||||
completedAt = delivery.CompletedAt
|
||||
}
|
||||
});
|
||||
|
||||
var deadSockets = new List<WebSocket>();
|
||||
|
||||
foreach (var socket in subscriptions)
|
||||
{
|
||||
if (socket.State != WebSocketState.Open)
|
||||
{
|
||||
deadSockets.Add(socket);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await SendMessageAsync(socket, message, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
deadSockets.Add(socket);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up dead sockets
|
||||
if (deadSockets.Count > 0)
|
||||
{
|
||||
var newBag = new ConcurrentBag<WebSocket>(
|
||||
subscriptions.Where(s => !deadSockets.Contains(s)));
|
||||
_tenantSubscriptions.TryUpdate(tenantId, newBag, subscriptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcasts incident statistics update to all connected clients for the specified tenant.
|
||||
/// </summary>
|
||||
public static async Task BroadcastStatsUpdateAsync(
|
||||
string tenantId,
|
||||
int total,
|
||||
int pending,
|
||||
int delivered,
|
||||
int failed,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message = JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "stats_update",
|
||||
timestamp = DateTimeOffset.UtcNow,
|
||||
stats = new
|
||||
{
|
||||
total,
|
||||
pending,
|
||||
delivered,
|
||||
failed
|
||||
}
|
||||
});
|
||||
|
||||
foreach (var socket in subscriptions.Where(s => s.State == WebSocketState.Open))
|
||||
{
|
||||
try
|
||||
{
|
||||
await SendMessageAsync(socket, message, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore send failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of active WebSocket connections for a tenant.
|
||||
/// </summary>
|
||||
public static int GetConnectionCount(string tenantId)
|
||||
{
|
||||
if (_tenantSubscriptions.TryGetValue(tenantId, out var subscriptions))
|
||||
{
|
||||
return subscriptions.Count(s => s.State == WebSocketState.Open);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notifier.Worker.Localization;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for localization operations.
|
||||
/// </summary>
|
||||
public static class LocalizationEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps localization API endpoints.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapLocalizationEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v2/localization")
|
||||
.WithTags("Localization")
|
||||
.WithOpenApi();
|
||||
|
||||
// List bundles
|
||||
group.MapGet("/bundles", async (
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var bundles = await localizationService.ListBundlesAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
bundles = bundles.Select(b => new
|
||||
{
|
||||
b.BundleId,
|
||||
b.TenantId,
|
||||
b.Locale,
|
||||
b.Namespace,
|
||||
stringCount = b.Strings.Count,
|
||||
b.Priority,
|
||||
b.Enabled,
|
||||
b.Source,
|
||||
b.Description,
|
||||
b.CreatedAt,
|
||||
b.UpdatedAt
|
||||
}).ToList(),
|
||||
count = bundles.Count
|
||||
});
|
||||
})
|
||||
.WithName("ListLocalizationBundles")
|
||||
.WithSummary("Lists all localization bundles for a tenant");
|
||||
|
||||
// Get supported locales
|
||||
group.MapGet("/locales", async (
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var locales = await localizationService.GetSupportedLocalesAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
locales,
|
||||
count = locales.Count
|
||||
});
|
||||
})
|
||||
.WithName("GetSupportedLocales")
|
||||
.WithSummary("Gets all supported locales for a tenant");
|
||||
|
||||
// Get bundle contents
|
||||
group.MapGet("/bundles/{locale}", async (
|
||||
string locale,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var strings = await localizationService.GetBundleAsync(tenantId, locale, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
locale,
|
||||
strings,
|
||||
count = strings.Count
|
||||
});
|
||||
})
|
||||
.WithName("GetLocalizationBundle")
|
||||
.WithSummary("Gets all localized strings for a locale");
|
||||
|
||||
// Get single string
|
||||
group.MapGet("/strings/{key}", async (
|
||||
string key,
|
||||
string? locale,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var effectiveLocale = locale ?? "en-US";
|
||||
|
||||
var value = await localizationService.GetStringAsync(tenantId, key, effectiveLocale, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
key,
|
||||
locale = effectiveLocale,
|
||||
value
|
||||
});
|
||||
})
|
||||
.WithName("GetLocalizedString")
|
||||
.WithSummary("Gets a single localized string");
|
||||
|
||||
// Format string with parameters
|
||||
group.MapPost("/strings/{key}/format", async (
|
||||
string key,
|
||||
FormatStringRequest request,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var locale = request.Locale ?? "en-US";
|
||||
|
||||
var parameters = request.Parameters ?? new Dictionary<string, object>();
|
||||
var value = await localizationService.GetFormattedStringAsync(
|
||||
tenantId, key, locale, parameters, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
key,
|
||||
locale,
|
||||
formatted = value
|
||||
});
|
||||
})
|
||||
.WithName("FormatLocalizedString")
|
||||
.WithSummary("Gets a localized string with parameter substitution");
|
||||
|
||||
// Create/update bundle
|
||||
group.MapPut("/bundles", async (
|
||||
CreateBundleRequest request,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
|
||||
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = request.BundleId ?? $"bundle-{Guid.NewGuid():N}"[..20],
|
||||
TenantId = tenantId,
|
||||
Locale = request.Locale,
|
||||
Namespace = request.Namespace ?? "default",
|
||||
Strings = request.Strings,
|
||||
Priority = request.Priority,
|
||||
Enabled = request.Enabled,
|
||||
Description = request.Description,
|
||||
Source = "api"
|
||||
};
|
||||
|
||||
var result = await localizationService.UpsertBundleAsync(bundle, actor, cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(new { error = result.Error });
|
||||
}
|
||||
|
||||
return result.IsNew
|
||||
? Results.Created($"/api/v2/localization/bundles/{bundle.Locale}", new
|
||||
{
|
||||
bundleId = result.BundleId,
|
||||
message = "Bundle created successfully"
|
||||
})
|
||||
: Results.Ok(new
|
||||
{
|
||||
bundleId = result.BundleId,
|
||||
message = "Bundle updated successfully"
|
||||
});
|
||||
})
|
||||
.WithName("UpsertLocalizationBundle")
|
||||
.WithSummary("Creates or updates a localization bundle");
|
||||
|
||||
// Delete bundle
|
||||
group.MapDelete("/bundles/{bundleId}", async (
|
||||
string bundleId,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
|
||||
|
||||
var deleted = await localizationService.DeleteBundleAsync(tenantId, bundleId, actor, cancellationToken);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Bundle '{bundleId}' not found" });
|
||||
}
|
||||
|
||||
return Results.Ok(new { message = $"Bundle '{bundleId}' deleted successfully" });
|
||||
})
|
||||
.WithName("DeleteLocalizationBundle")
|
||||
.WithSummary("Deletes a localization bundle");
|
||||
|
||||
// Validate bundle
|
||||
group.MapPost("/bundles/validate", (
|
||||
CreateBundleRequest request,
|
||||
HttpContext context,
|
||||
ILocalizationService localizationService) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var bundle = new LocalizationBundle
|
||||
{
|
||||
BundleId = request.BundleId ?? "validation",
|
||||
TenantId = tenantId,
|
||||
Locale = request.Locale,
|
||||
Namespace = request.Namespace ?? "default",
|
||||
Strings = request.Strings,
|
||||
Priority = request.Priority,
|
||||
Enabled = request.Enabled,
|
||||
Description = request.Description
|
||||
};
|
||||
|
||||
var result = localizationService.Validate(bundle);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
result.IsValid,
|
||||
result.Errors,
|
||||
result.Warnings
|
||||
});
|
||||
})
|
||||
.WithName("ValidateLocalizationBundle")
|
||||
.WithSummary("Validates a localization bundle without saving");
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to format a localized string.
|
||||
/// </summary>
|
||||
public sealed record FormatStringRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Target locale.
|
||||
/// </summary>
|
||||
public string? Locale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameters for substitution.
|
||||
/// </summary>
|
||||
public Dictionary<string, object>? Parameters { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create/update a localization bundle.
|
||||
/// </summary>
|
||||
public sealed record CreateBundleRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Bundle ID (auto-generated if not provided).
|
||||
/// </summary>
|
||||
public string? BundleId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Locale code.
|
||||
/// </summary>
|
||||
public required string Locale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Namespace/category.
|
||||
/// </summary>
|
||||
public string? Namespace { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Localized strings.
|
||||
/// </summary>
|
||||
public required Dictionary<string, string> Strings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Bundle priority.
|
||||
/// </summary>
|
||||
public int Priority { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether bundle is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Bundle description.
|
||||
/// </summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notifier.Worker.Dispatch;
|
||||
using StellaOps.Notifier.Worker.Templates;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for rules, templates, and incidents management.
|
||||
/// </summary>
|
||||
public static class NotifyApiEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps all Notify API v2 endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapNotifyApiV2(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/notify")
|
||||
.WithTags("Notify")
|
||||
.WithOpenApi();
|
||||
|
||||
// Rules CRUD
|
||||
MapRulesEndpoints(group);
|
||||
|
||||
// Templates CRUD + Preview
|
||||
MapTemplatesEndpoints(group);
|
||||
|
||||
// Incidents
|
||||
MapIncidentsEndpoints(group);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapRulesEndpoints(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/rules", async (
|
||||
HttpContext context,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var rules = await ruleRepository.ListAsync(tenantId, cancellationToken);
|
||||
var response = rules.Select(MapRuleToResponse).ToList();
|
||||
|
||||
return Results.Ok(response);
|
||||
});
|
||||
|
||||
group.MapGet("/rules/{ruleId}", async (
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var rule = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
|
||||
if (rule is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", $"Rule {ruleId} not found.", context));
|
||||
}
|
||||
|
||||
return Results.Ok(MapRuleToResponse(rule));
|
||||
});
|
||||
|
||||
group.MapPost("/rules", async (
|
||||
HttpContext context,
|
||||
RuleCreateRequest request,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
INotifyAuditRepository auditRepository,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var rule = MapRequestToRule(request, tenantId, actor, now);
|
||||
|
||||
await ruleRepository.UpsertAsync(rule, cancellationToken);
|
||||
|
||||
await AuditAsync(auditRepository, tenantId, "rule.created", actor, new Dictionary<string, string>
|
||||
{
|
||||
["ruleId"] = rule.RuleId,
|
||||
["name"] = rule.Name
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.Created($"/api/v2/notify/rules/{rule.RuleId}", MapRuleToResponse(rule));
|
||||
});
|
||||
|
||||
group.MapPut("/rules/{ruleId}", async (
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
RuleUpdateRequest request,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
INotifyAuditRepository auditRepository,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var existing = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", $"Rule {ruleId} not found.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var updated = ApplyRuleUpdate(existing, request, actor, now);
|
||||
|
||||
await ruleRepository.UpsertAsync(updated, cancellationToken);
|
||||
|
||||
await AuditAsync(auditRepository, tenantId, "rule.updated", actor, new Dictionary<string, string>
|
||||
{
|
||||
["ruleId"] = updated.RuleId,
|
||||
["name"] = updated.Name
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.Ok(MapRuleToResponse(updated));
|
||||
});
|
||||
|
||||
group.MapDelete("/rules/{ruleId}", async (
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
INotifyRuleRepository ruleRepository,
|
||||
INotifyAuditRepository auditRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var existing = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", $"Rule {ruleId} not found.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
await ruleRepository.DeleteAsync(tenantId, ruleId, cancellationToken);
|
||||
|
||||
await AuditAsync(auditRepository, tenantId, "rule.deleted", actor, new Dictionary<string, string>
|
||||
{
|
||||
["ruleId"] = ruleId
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
}
|
||||
|
||||
private static void MapTemplatesEndpoints(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/templates", async (
|
||||
HttpContext context,
|
||||
string? keyPrefix,
|
||||
string? channelType,
|
||||
string? locale,
|
||||
int? limit,
|
||||
INotifyTemplateService templateService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
NotifyChannelType? channelTypeEnum = null;
|
||||
if (!string.IsNullOrWhiteSpace(channelType) &&
|
||||
Enum.TryParse<NotifyChannelType>(channelType, true, out var parsed))
|
||||
{
|
||||
channelTypeEnum = parsed;
|
||||
}
|
||||
|
||||
var templates = await templateService.ListAsync(tenantId, new TemplateListOptions
|
||||
{
|
||||
KeyPrefix = keyPrefix,
|
||||
ChannelType = channelTypeEnum,
|
||||
Locale = locale,
|
||||
Limit = limit
|
||||
}, cancellationToken);
|
||||
|
||||
var response = templates.Select(MapTemplateToResponse).ToList();
|
||||
|
||||
return Results.Ok(response);
|
||||
});
|
||||
|
||||
group.MapGet("/templates/{templateId}", async (
|
||||
HttpContext context,
|
||||
string templateId,
|
||||
INotifyTemplateService templateService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var template = await templateService.GetByIdAsync(tenantId, templateId, cancellationToken);
|
||||
if (template is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template {templateId} not found.", context));
|
||||
}
|
||||
|
||||
return Results.Ok(MapTemplateToResponse(template));
|
||||
});
|
||||
|
||||
group.MapPost("/templates", async (
|
||||
HttpContext context,
|
||||
TemplateCreateRequest request,
|
||||
INotifyTemplateService templateService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
if (!Enum.TryParse<NotifyChannelType>(request.ChannelType, true, out var channelType))
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_channel_type", $"Invalid channel type: {request.ChannelType}", context));
|
||||
}
|
||||
|
||||
var renderMode = NotifyTemplateRenderMode.Markdown;
|
||||
if (!string.IsNullOrWhiteSpace(request.RenderMode) &&
|
||||
Enum.TryParse<NotifyTemplateRenderMode>(request.RenderMode, true, out var parsedMode))
|
||||
{
|
||||
renderMode = parsedMode;
|
||||
}
|
||||
|
||||
var format = NotifyDeliveryFormat.Json;
|
||||
if (!string.IsNullOrWhiteSpace(request.Format) &&
|
||||
Enum.TryParse<NotifyDeliveryFormat>(request.Format, true, out var parsedFormat))
|
||||
{
|
||||
format = parsedFormat;
|
||||
}
|
||||
|
||||
var template = NotifyTemplate.Create(
|
||||
templateId: request.TemplateId,
|
||||
tenantId: tenantId,
|
||||
channelType: channelType,
|
||||
key: request.Key,
|
||||
locale: request.Locale,
|
||||
body: request.Body,
|
||||
renderMode: renderMode,
|
||||
format: format,
|
||||
description: request.Description,
|
||||
metadata: request.Metadata);
|
||||
|
||||
var result = await templateService.UpsertAsync(template, actor, cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.BadRequest(Error("template_validation_failed", result.Error ?? "Validation failed.", context));
|
||||
}
|
||||
|
||||
var created = await templateService.GetByIdAsync(tenantId, request.TemplateId, cancellationToken);
|
||||
|
||||
return result.IsNew
|
||||
? Results.Created($"/api/v2/notify/templates/{request.TemplateId}", MapTemplateToResponse(created!))
|
||||
: Results.Ok(MapTemplateToResponse(created!));
|
||||
});
|
||||
|
||||
group.MapDelete("/templates/{templateId}", async (
|
||||
HttpContext context,
|
||||
string templateId,
|
||||
INotifyTemplateService templateService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
var deleted = await templateService.DeleteAsync(tenantId, templateId, actor, cancellationToken);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template {templateId} not found.", context));
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
group.MapPost("/templates/preview", async (
|
||||
HttpContext context,
|
||||
TemplatePreviewRequest request,
|
||||
INotifyTemplateService templateService,
|
||||
INotifyTemplateRenderer templateRenderer,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
NotifyTemplate? template = null;
|
||||
List<string>? warnings = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.TemplateId))
|
||||
{
|
||||
template = await templateService.GetByIdAsync(tenantId, request.TemplateId, cancellationToken);
|
||||
if (template is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template {request.TemplateId} not found.", context));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(request.TemplateBody))
|
||||
{
|
||||
var validation = templateService.Validate(request.TemplateBody);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(Error("template_invalid", string.Join("; ", validation.Errors), context));
|
||||
}
|
||||
warnings = validation.Warnings.ToList();
|
||||
|
||||
var format = NotifyDeliveryFormat.PlainText;
|
||||
if (!string.IsNullOrWhiteSpace(request.OutputFormat) &&
|
||||
Enum.TryParse<NotifyDeliveryFormat>(request.OutputFormat, true, out var parsedFormat))
|
||||
{
|
||||
format = parsedFormat;
|
||||
}
|
||||
|
||||
template = NotifyTemplate.Create(
|
||||
templateId: "preview",
|
||||
tenantId: tenantId,
|
||||
channelType: NotifyChannelType.Custom,
|
||||
key: "preview",
|
||||
locale: "en-us",
|
||||
body: request.TemplateBody,
|
||||
format: format);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Results.BadRequest(Error("template_required", "Either templateId or templateBody must be provided.", context));
|
||||
}
|
||||
|
||||
var sampleEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: request.EventKind ?? "preview.event",
|
||||
tenant: tenantId,
|
||||
ts: DateTimeOffset.UtcNow,
|
||||
payload: request.SamplePayload ?? new JsonObject(),
|
||||
attributes: request.SampleAttributes ?? new Dictionary<string, string>(),
|
||||
actor: "preview",
|
||||
version: "1");
|
||||
|
||||
var rendered = await templateRenderer.RenderAsync(template, sampleEvent, cancellationToken);
|
||||
|
||||
return Results.Ok(new TemplatePreviewResponse
|
||||
{
|
||||
RenderedBody = rendered.Body,
|
||||
RenderedSubject = rendered.Subject,
|
||||
BodyHash = rendered.BodyHash,
|
||||
Format = rendered.Format.ToString(),
|
||||
Warnings = warnings
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/templates/validate", (
|
||||
HttpContext context,
|
||||
TemplatePreviewRequest request,
|
||||
INotifyTemplateService templateService) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.TemplateBody))
|
||||
{
|
||||
return Results.BadRequest(Error("template_body_required", "templateBody is required.", context));
|
||||
}
|
||||
|
||||
var result = templateService.Validate(request.TemplateBody);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
isValid = result.IsValid,
|
||||
errors = result.Errors,
|
||||
warnings = result.Warnings
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static void MapIncidentsEndpoints(RouteGroupBuilder group)
|
||||
{
|
||||
group.MapGet("/incidents", async (
|
||||
HttpContext context,
|
||||
string? status,
|
||||
string? eventKindPrefix,
|
||||
DateTimeOffset? since,
|
||||
DateTimeOffset? until,
|
||||
int? limit,
|
||||
string? cursor,
|
||||
INotifyDeliveryRepository deliveryRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
// For now, return recent deliveries grouped by event kind as "incidents"
|
||||
// Full incident correlation will be implemented in NOTIFY-SVC-39-001
|
||||
var queryResult = await deliveryRepository.QueryAsync(tenantId, since, status, limit ?? 100, cursor, cancellationToken);
|
||||
var deliveries = queryResult.Items;
|
||||
|
||||
var incidents = deliveries
|
||||
.GroupBy(d => d.EventId)
|
||||
.Select(g => new IncidentResponse
|
||||
{
|
||||
IncidentId = g.Key.ToString(),
|
||||
TenantId = tenantId,
|
||||
EventKind = g.First().Kind,
|
||||
Status = g.All(d => d.Status == NotifyDeliveryStatus.Delivered) ? "resolved" : "open",
|
||||
Severity = "medium",
|
||||
Title = $"Notification: {g.First().Kind}",
|
||||
Description = null,
|
||||
EventCount = g.Count(),
|
||||
FirstOccurrence = g.Min(d => d.CreatedAt),
|
||||
LastOccurrence = g.Max(d => d.CreatedAt),
|
||||
Labels = null,
|
||||
Metadata = null
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(new IncidentListResponse
|
||||
{
|
||||
Incidents = incidents,
|
||||
TotalCount = incidents.Count,
|
||||
NextCursor = queryResult.ContinuationToken
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/incidents/{incidentId}/ack", async (
|
||||
HttpContext context,
|
||||
string incidentId,
|
||||
IncidentAckRequest request,
|
||||
INotifyAuditRepository auditRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = request.Actor ?? GetActor(context);
|
||||
|
||||
await AuditAsync(auditRepository, tenantId, "incident.acknowledged", actor, new Dictionary<string, string>
|
||||
{
|
||||
["incidentId"] = incidentId,
|
||||
["comment"] = request.Comment ?? ""
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
group.MapPost("/incidents/{incidentId}/resolve", async (
|
||||
HttpContext context,
|
||||
string incidentId,
|
||||
IncidentResolveRequest request,
|
||||
INotifyAuditRepository auditRepository,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = request.Actor ?? GetActor(context);
|
||||
|
||||
await AuditAsync(auditRepository, tenantId, "incident.resolved", actor, new Dictionary<string, string>
|
||||
{
|
||||
["incidentId"] = incidentId,
|
||||
["reason"] = request.Reason ?? "",
|
||||
["comment"] = request.Comment ?? ""
|
||||
}, cancellationToken);
|
||||
|
||||
return Results.NoContent();
|
||||
});
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static string? GetTenantId(HttpContext context)
|
||||
{
|
||||
var value = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
private static string GetActor(HttpContext context)
|
||||
{
|
||||
return context.Request.Headers["X-StellaOps-Actor"].ToString() is { Length: > 0 } actor
|
||||
? actor
|
||||
: "api";
|
||||
}
|
||||
|
||||
private static object Error(string code, string message, HttpContext context) => new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code,
|
||||
message,
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
};
|
||||
|
||||
private static async Task AuditAsync(
|
||||
INotifyAuditRepository repository,
|
||||
string tenantId,
|
||||
string action,
|
||||
string actor,
|
||||
Dictionary<string, string> metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await repository.AppendAsync(tenantId, action, actor, metadata, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore audit failures
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Mappers
|
||||
|
||||
private static RuleResponse MapRuleToResponse(NotifyRule rule)
|
||||
{
|
||||
return new RuleResponse
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
TenantId = rule.TenantId,
|
||||
Name = rule.Name,
|
||||
Description = rule.Description,
|
||||
Enabled = rule.Enabled,
|
||||
Match = new RuleMatchResponse
|
||||
{
|
||||
EventKinds = rule.Match.EventKinds.ToList(),
|
||||
Namespaces = rule.Match.Namespaces.ToList(),
|
||||
Repositories = rule.Match.Repositories.ToList(),
|
||||
Digests = rule.Match.Digests.ToList(),
|
||||
Labels = rule.Match.Labels.ToList(),
|
||||
ComponentPurls = rule.Match.ComponentPurls.ToList(),
|
||||
MinSeverity = rule.Match.MinSeverity,
|
||||
Verdicts = rule.Match.Verdicts.ToList(),
|
||||
KevOnly = rule.Match.KevOnly
|
||||
},
|
||||
Actions = rule.Actions.Select(a => new RuleActionResponse
|
||||
{
|
||||
ActionId = a.ActionId,
|
||||
Channel = a.Channel,
|
||||
Template = a.Template,
|
||||
Digest = a.Digest,
|
||||
Throttle = a.Throttle?.ToString(),
|
||||
Locale = a.Locale,
|
||||
Enabled = a.Enabled,
|
||||
Metadata = a.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value)
|
||||
}).ToList(),
|
||||
Labels = rule.Labels.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
Metadata = rule.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
CreatedBy = rule.CreatedBy,
|
||||
CreatedAt = rule.CreatedAt,
|
||||
UpdatedBy = rule.UpdatedBy,
|
||||
UpdatedAt = rule.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static NotifyRule MapRequestToRule(
|
||||
RuleCreateRequest request,
|
||||
string tenantId,
|
||||
string actor,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var match = NotifyRuleMatch.Create(
|
||||
eventKinds: request.Match.EventKinds,
|
||||
namespaces: request.Match.Namespaces,
|
||||
repositories: request.Match.Repositories,
|
||||
digests: request.Match.Digests,
|
||||
labels: request.Match.Labels,
|
||||
componentPurls: request.Match.ComponentPurls,
|
||||
minSeverity: request.Match.MinSeverity,
|
||||
verdicts: request.Match.Verdicts,
|
||||
kevOnly: request.Match.KevOnly);
|
||||
|
||||
var actions = request.Actions.Select(a => NotifyRuleAction.Create(
|
||||
actionId: a.ActionId,
|
||||
channel: a.Channel,
|
||||
template: a.Template,
|
||||
digest: a.Digest,
|
||||
throttle: string.IsNullOrWhiteSpace(a.Throttle) ? null : System.Xml.XmlConvert.ToTimeSpan(a.Throttle),
|
||||
locale: a.Locale,
|
||||
enabled: a.Enabled,
|
||||
metadata: a.Metadata));
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: request.RuleId,
|
||||
tenantId: tenantId,
|
||||
name: request.Name,
|
||||
match: match,
|
||||
actions: actions,
|
||||
enabled: request.Enabled,
|
||||
description: request.Description,
|
||||
labels: request.Labels,
|
||||
metadata: request.Metadata,
|
||||
createdBy: actor,
|
||||
createdAt: now,
|
||||
updatedBy: actor,
|
||||
updatedAt: now);
|
||||
}
|
||||
|
||||
private static NotifyRule ApplyRuleUpdate(
|
||||
NotifyRule existing,
|
||||
RuleUpdateRequest request,
|
||||
string actor,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var match = request.Match is not null
|
||||
? NotifyRuleMatch.Create(
|
||||
eventKinds: request.Match.EventKinds ?? existing.Match.EventKinds.ToList(),
|
||||
namespaces: request.Match.Namespaces ?? existing.Match.Namespaces.ToList(),
|
||||
repositories: request.Match.Repositories ?? existing.Match.Repositories.ToList(),
|
||||
digests: request.Match.Digests ?? existing.Match.Digests.ToList(),
|
||||
labels: request.Match.Labels ?? existing.Match.Labels.ToList(),
|
||||
componentPurls: request.Match.ComponentPurls ?? existing.Match.ComponentPurls.ToList(),
|
||||
minSeverity: request.Match.MinSeverity ?? existing.Match.MinSeverity,
|
||||
verdicts: request.Match.Verdicts ?? existing.Match.Verdicts.ToList(),
|
||||
kevOnly: request.Match.KevOnly ?? existing.Match.KevOnly)
|
||||
: existing.Match;
|
||||
|
||||
var actions = request.Actions is not null
|
||||
? request.Actions.Select(a => NotifyRuleAction.Create(
|
||||
actionId: a.ActionId,
|
||||
channel: a.Channel,
|
||||
template: a.Template,
|
||||
digest: a.Digest,
|
||||
throttle: string.IsNullOrWhiteSpace(a.Throttle) ? null : System.Xml.XmlConvert.ToTimeSpan(a.Throttle),
|
||||
locale: a.Locale,
|
||||
enabled: a.Enabled,
|
||||
metadata: a.Metadata))
|
||||
: existing.Actions;
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: existing.RuleId,
|
||||
tenantId: existing.TenantId,
|
||||
name: request.Name ?? existing.Name,
|
||||
match: match,
|
||||
actions: actions,
|
||||
enabled: request.Enabled ?? existing.Enabled,
|
||||
description: request.Description ?? existing.Description,
|
||||
labels: request.Labels ?? existing.Labels.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
metadata: request.Metadata ?? existing.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
createdBy: existing.CreatedBy,
|
||||
createdAt: existing.CreatedAt,
|
||||
updatedBy: actor,
|
||||
updatedAt: now);
|
||||
}
|
||||
|
||||
private static TemplateResponse MapTemplateToResponse(NotifyTemplate template)
|
||||
{
|
||||
return new TemplateResponse
|
||||
{
|
||||
TemplateId = template.TemplateId,
|
||||
TenantId = template.TenantId,
|
||||
Key = template.Key,
|
||||
ChannelType = template.ChannelType.ToString(),
|
||||
Locale = template.Locale,
|
||||
Body = template.Body,
|
||||
RenderMode = template.RenderMode.ToString(),
|
||||
Format = template.Format.ToString(),
|
||||
Description = template.Description,
|
||||
Metadata = template.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
CreatedBy = template.CreatedBy,
|
||||
CreatedAt = template.CreatedAt,
|
||||
UpdatedBy = template.UpdatedBy,
|
||||
UpdatedAt = template.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notifier.Worker.Observability;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for observability services.
|
||||
/// </summary>
|
||||
public static class ObservabilityEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps observability endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapObservabilityEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v1/observability")
|
||||
.WithTags("Observability");
|
||||
|
||||
// Metrics endpoints
|
||||
group.MapGet("/metrics", GetMetricsSnapshot)
|
||||
.WithName("GetMetricsSnapshot")
|
||||
.WithSummary("Gets current metrics snapshot");
|
||||
|
||||
group.MapGet("/metrics/{tenantId}", GetTenantMetrics)
|
||||
.WithName("GetTenantMetrics")
|
||||
.WithSummary("Gets metrics for a specific tenant");
|
||||
|
||||
// Dead letter endpoints
|
||||
group.MapGet("/dead-letters/{tenantId}", GetDeadLetters)
|
||||
.WithName("GetDeadLetters")
|
||||
.WithSummary("Lists dead letter entries for a tenant");
|
||||
|
||||
group.MapGet("/dead-letters/{tenantId}/{entryId}", GetDeadLetterEntry)
|
||||
.WithName("GetDeadLetterEntry")
|
||||
.WithSummary("Gets a specific dead letter entry");
|
||||
|
||||
group.MapPost("/dead-letters/{tenantId}/{entryId}/retry", RetryDeadLetter)
|
||||
.WithName("RetryDeadLetter")
|
||||
.WithSummary("Retries a dead letter entry");
|
||||
|
||||
group.MapPost("/dead-letters/{tenantId}/{entryId}/discard", DiscardDeadLetter)
|
||||
.WithName("DiscardDeadLetter")
|
||||
.WithSummary("Discards a dead letter entry");
|
||||
|
||||
group.MapGet("/dead-letters/{tenantId}/stats", GetDeadLetterStats)
|
||||
.WithName("GetDeadLetterStats")
|
||||
.WithSummary("Gets dead letter statistics");
|
||||
|
||||
group.MapDelete("/dead-letters/{tenantId}/purge", PurgeDeadLetters)
|
||||
.WithName("PurgeDeadLetters")
|
||||
.WithSummary("Purges old dead letter entries");
|
||||
|
||||
// Chaos testing endpoints
|
||||
group.MapGet("/chaos/experiments", ListChaosExperiments)
|
||||
.WithName("ListChaosExperiments")
|
||||
.WithSummary("Lists chaos experiments");
|
||||
|
||||
group.MapGet("/chaos/experiments/{experimentId}", GetChaosExperiment)
|
||||
.WithName("GetChaosExperiment")
|
||||
.WithSummary("Gets a chaos experiment");
|
||||
|
||||
group.MapPost("/chaos/experiments", StartChaosExperiment)
|
||||
.WithName("StartChaosExperiment")
|
||||
.WithSummary("Starts a new chaos experiment");
|
||||
|
||||
group.MapPost("/chaos/experiments/{experimentId}/stop", StopChaosExperiment)
|
||||
.WithName("StopChaosExperiment")
|
||||
.WithSummary("Stops a running chaos experiment");
|
||||
|
||||
group.MapGet("/chaos/experiments/{experimentId}/results", GetChaosResults)
|
||||
.WithName("GetChaosResults")
|
||||
.WithSummary("Gets chaos experiment results");
|
||||
|
||||
// Retention policy endpoints
|
||||
group.MapGet("/retention/policies", ListRetentionPolicies)
|
||||
.WithName("ListRetentionPolicies")
|
||||
.WithSummary("Lists retention policies");
|
||||
|
||||
group.MapGet("/retention/policies/{policyId}", GetRetentionPolicy)
|
||||
.WithName("GetRetentionPolicy")
|
||||
.WithSummary("Gets a retention policy");
|
||||
|
||||
group.MapPost("/retention/policies", CreateRetentionPolicy)
|
||||
.WithName("CreateRetentionPolicy")
|
||||
.WithSummary("Creates a retention policy");
|
||||
|
||||
group.MapPut("/retention/policies/{policyId}", UpdateRetentionPolicy)
|
||||
.WithName("UpdateRetentionPolicy")
|
||||
.WithSummary("Updates a retention policy");
|
||||
|
||||
group.MapDelete("/retention/policies/{policyId}", DeleteRetentionPolicy)
|
||||
.WithName("DeleteRetentionPolicy")
|
||||
.WithSummary("Deletes a retention policy");
|
||||
|
||||
group.MapPost("/retention/execute", ExecuteRetention)
|
||||
.WithName("ExecuteRetention")
|
||||
.WithSummary("Executes retention policies");
|
||||
|
||||
group.MapGet("/retention/policies/{policyId}/preview", PreviewRetention)
|
||||
.WithName("PreviewRetention")
|
||||
.WithSummary("Previews retention policy effects");
|
||||
|
||||
group.MapGet("/retention/policies/{policyId}/history", GetRetentionHistory)
|
||||
.WithName("GetRetentionHistory")
|
||||
.WithSummary("Gets retention execution history");
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
// Metrics handlers
|
||||
private static IResult GetMetricsSnapshot(
|
||||
[FromServices] INotifierMetrics metrics)
|
||||
{
|
||||
var snapshot = metrics.GetSnapshot();
|
||||
return Results.Ok(snapshot);
|
||||
}
|
||||
|
||||
private static IResult GetTenantMetrics(
|
||||
string tenantId,
|
||||
[FromServices] INotifierMetrics metrics)
|
||||
{
|
||||
var snapshot = metrics.GetSnapshot(tenantId);
|
||||
return Results.Ok(snapshot);
|
||||
}
|
||||
|
||||
// Dead letter handlers
|
||||
private static async Task<IResult> GetDeadLetters(
|
||||
string tenantId,
|
||||
[FromQuery] int limit,
|
||||
[FromQuery] int offset,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var entries = await handler.GetEntriesAsync(
|
||||
tenantId,
|
||||
limit: limit > 0 ? limit : 100,
|
||||
offset: offset,
|
||||
ct: ct);
|
||||
return Results.Ok(entries);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetDeadLetterEntry(
|
||||
string tenantId,
|
||||
string entryId,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var entry = await handler.GetEntryAsync(tenantId, entryId, ct);
|
||||
if (entry is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Dead letter entry not found" });
|
||||
}
|
||||
return Results.Ok(entry);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RetryDeadLetter(
|
||||
string tenantId,
|
||||
string entryId,
|
||||
[FromBody] RetryDeadLetterRequest request,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await handler.RetryAsync(tenantId, entryId, request.Actor, ct);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DiscardDeadLetter(
|
||||
string tenantId,
|
||||
string entryId,
|
||||
[FromBody] DiscardDeadLetterRequest request,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await handler.DiscardAsync(tenantId, entryId, request.Reason, request.Actor, ct);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetDeadLetterStats(
|
||||
string tenantId,
|
||||
[FromQuery] int? windowHours,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var window = windowHours.HasValue
|
||||
? TimeSpan.FromHours(windowHours.Value)
|
||||
: (TimeSpan?)null;
|
||||
var stats = await handler.GetStatisticsAsync(tenantId, window, ct);
|
||||
return Results.Ok(stats);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PurgeDeadLetters(
|
||||
string tenantId,
|
||||
[FromQuery] int olderThanDays,
|
||||
[FromServices] IDeadLetterHandler handler,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var olderThan = TimeSpan.FromDays(olderThanDays > 0 ? olderThanDays : 7);
|
||||
var count = await handler.PurgeAsync(tenantId, olderThan, ct);
|
||||
return Results.Ok(new { purged = count });
|
||||
}
|
||||
|
||||
// Chaos testing handlers
|
||||
private static async Task<IResult> ListChaosExperiments(
|
||||
[FromQuery] string? status,
|
||||
[FromQuery] int limit,
|
||||
[FromServices] IChaosTestRunner runner,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ChaosExperimentStatus? parsedStatus = null;
|
||||
if (!string.IsNullOrEmpty(status) && Enum.TryParse<ChaosExperimentStatus>(status, true, out var s))
|
||||
{
|
||||
parsedStatus = s;
|
||||
}
|
||||
|
||||
var experiments = await runner.ListExperimentsAsync(parsedStatus, limit > 0 ? limit : 100, ct);
|
||||
return Results.Ok(experiments);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetChaosExperiment(
|
||||
string experimentId,
|
||||
[FromServices] IChaosTestRunner runner,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var experiment = await runner.GetExperimentAsync(experimentId, ct);
|
||||
if (experiment is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Experiment not found" });
|
||||
}
|
||||
return Results.Ok(experiment);
|
||||
}
|
||||
|
||||
private static async Task<IResult> StartChaosExperiment(
|
||||
[FromBody] ChaosExperimentConfig config,
|
||||
[FromServices] IChaosTestRunner runner,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var experiment = await runner.StartExperimentAsync(config, ct);
|
||||
return Results.Created($"/api/v1/observability/chaos/experiments/{experiment.Id}", experiment);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> StopChaosExperiment(
|
||||
string experimentId,
|
||||
[FromServices] IChaosTestRunner runner,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await runner.StopExperimentAsync(experimentId, ct);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetChaosResults(
|
||||
string experimentId,
|
||||
[FromServices] IChaosTestRunner runner,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var results = await runner.GetResultsAsync(experimentId, ct);
|
||||
return Results.Ok(results);
|
||||
}
|
||||
|
||||
// Retention policy handlers
|
||||
private static async Task<IResult> ListRetentionPolicies(
|
||||
[FromQuery] string? tenantId,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var policies = await service.ListPoliciesAsync(tenantId, ct);
|
||||
return Results.Ok(policies);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRetentionPolicy(
|
||||
string policyId,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var policy = await service.GetPolicyAsync(policyId, ct);
|
||||
if (policy is null)
|
||||
{
|
||||
return Results.NotFound(new { error = "Policy not found" });
|
||||
}
|
||||
return Results.Ok(policy);
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRetentionPolicy(
|
||||
[FromBody] RetentionPolicy policy,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await service.RegisterPolicyAsync(policy, ct);
|
||||
return Results.Created($"/api/v1/observability/retention/policies/{policy.Id}", policy);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Conflict(new { error = ex.Message });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateRetentionPolicy(
|
||||
string policyId,
|
||||
[FromBody] RetentionPolicy policy,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await service.UpdatePolicyAsync(policyId, policy, ct);
|
||||
return Results.Ok(policy);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.NotFound(new { error = "Policy not found" });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteRetentionPolicy(
|
||||
string policyId,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await service.DeletePolicyAsync(policyId, ct);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ExecuteRetention(
|
||||
[FromQuery] string? policyId,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await service.ExecuteRetentionAsync(policyId, ct);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewRetention(
|
||||
string policyId,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var preview = await service.PreviewRetentionAsync(policyId, ct);
|
||||
return Results.Ok(preview);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.NotFound(new { error = "Policy not found" });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRetentionHistory(
|
||||
string policyId,
|
||||
[FromQuery] int limit,
|
||||
[FromServices] IRetentionPolicyService service,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var history = await service.GetExecutionHistoryAsync(policyId, limit > 0 ? limit : 100, ct);
|
||||
return Results.Ok(history);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to retry a dead letter entry.
|
||||
/// </summary>
|
||||
public sealed record RetryDeadLetterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Actor performing the retry.
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to discard a dead letter entry.
|
||||
/// </summary>
|
||||
public sealed record DiscardDeadLetterRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Reason for discarding.
|
||||
/// </summary>
|
||||
public required string Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor performing the discard.
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for operator override management.
|
||||
/// </summary>
|
||||
public static class OperatorOverrideEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps operator override endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapOperatorOverrideEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/overrides")
|
||||
.WithTags("Overrides")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/", ListOverridesAsync)
|
||||
.WithName("ListOperatorOverrides")
|
||||
.WithSummary("List active operator overrides")
|
||||
.WithDescription("Returns all active operator overrides for the tenant.");
|
||||
|
||||
group.MapGet("/{overrideId}", GetOverrideAsync)
|
||||
.WithName("GetOperatorOverride")
|
||||
.WithSummary("Get an operator override")
|
||||
.WithDescription("Returns a specific operator override by ID.");
|
||||
|
||||
group.MapPost("/", CreateOverrideAsync)
|
||||
.WithName("CreateOperatorOverride")
|
||||
.WithSummary("Create an operator override")
|
||||
.WithDescription("Creates a new operator override to bypass quiet hours and/or throttling.");
|
||||
|
||||
group.MapPost("/{overrideId}/revoke", RevokeOverrideAsync)
|
||||
.WithName("RevokeOperatorOverride")
|
||||
.WithSummary("Revoke an operator override")
|
||||
.WithDescription("Revokes an active operator override.");
|
||||
|
||||
group.MapPost("/check", CheckOverrideAsync)
|
||||
.WithName("CheckOperatorOverride")
|
||||
.WithSummary("Check for applicable override")
|
||||
.WithDescription("Checks if an override applies to the given event criteria.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListOverridesAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IOperatorOverrideService overrideService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var overrides = await overrideService.ListActiveOverridesAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(overrides.Select(MapToApiResponse));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetOverrideAsync(
|
||||
string overrideId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IOperatorOverrideService overrideService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var @override = await overrideService.GetOverrideAsync(tenantId, overrideId, cancellationToken);
|
||||
|
||||
if (@override is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Override '{overrideId}' not found." });
|
||||
}
|
||||
|
||||
return Results.Ok(MapToApiResponse(@override));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateOverrideAsync(
|
||||
[FromBody] OperatorOverrideApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actorHeader,
|
||||
[FromServices] IOperatorOverrideService overrideService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
var actor = request.Actor ?? actorHeader;
|
||||
if (string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Actor is required via X-Actor header or request body." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Reason is required." });
|
||||
}
|
||||
|
||||
if (request.DurationMinutes is null or <= 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Duration must be a positive value in minutes." });
|
||||
}
|
||||
|
||||
var createRequest = new OperatorOverrideCreate
|
||||
{
|
||||
Type = MapOverrideType(request.Type),
|
||||
Reason = request.Reason,
|
||||
Duration = TimeSpan.FromMinutes(request.DurationMinutes.Value),
|
||||
EffectiveFrom = request.EffectiveFrom,
|
||||
EventKinds = request.EventKinds,
|
||||
CorrelationKeys = request.CorrelationKeys,
|
||||
MaxUsageCount = request.MaxUsageCount
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var created = await overrideService.CreateOverrideAsync(tenantId, createRequest, actor, cancellationToken);
|
||||
return Results.Created($"/api/v2/overrides/{created.OverrideId}", MapToApiResponse(created));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.Conflict(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeOverrideAsync(
|
||||
string overrideId,
|
||||
[FromBody] RevokeOverrideApiRequest? request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actorHeader,
|
||||
[FromServices] IOperatorOverrideService overrideService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var actor = request?.Actor ?? actorHeader;
|
||||
if (string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Actor is required via X-Actor header or request body." });
|
||||
}
|
||||
|
||||
var revoked = await overrideService.RevokeOverrideAsync(
|
||||
tenantId,
|
||||
overrideId,
|
||||
actor,
|
||||
request?.Reason,
|
||||
cancellationToken);
|
||||
|
||||
if (!revoked)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Override '{overrideId}' not found or already inactive." });
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> CheckOverrideAsync(
|
||||
[FromBody] CheckOverrideApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IOperatorOverrideService overrideService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.EventKind))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Event kind is required." });
|
||||
}
|
||||
|
||||
var result = await overrideService.CheckOverrideAsync(
|
||||
tenantId,
|
||||
request.EventKind,
|
||||
request.CorrelationKey,
|
||||
cancellationToken);
|
||||
|
||||
return Results.Ok(new CheckOverrideApiResponse
|
||||
{
|
||||
HasOverride = result.HasOverride,
|
||||
BypassedTypes = MapOverrideTypeToStrings(result.BypassedTypes),
|
||||
Override = result.Override is not null ? MapToApiResponse(result.Override) : null
|
||||
});
|
||||
}
|
||||
|
||||
private static OverrideType MapOverrideType(string? type) => type?.ToLowerInvariant() switch
|
||||
{
|
||||
"quiethours" or "quiet_hours" => OverrideType.QuietHours,
|
||||
"throttle" => OverrideType.Throttle,
|
||||
"maintenance" => OverrideType.Maintenance,
|
||||
"all" or _ => OverrideType.All
|
||||
};
|
||||
|
||||
private static List<string> MapOverrideTypeToStrings(OverrideType type)
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (type.HasFlag(OverrideType.QuietHours)) result.Add("quiet_hours");
|
||||
if (type.HasFlag(OverrideType.Throttle)) result.Add("throttle");
|
||||
if (type.HasFlag(OverrideType.Maintenance)) result.Add("maintenance");
|
||||
return result;
|
||||
}
|
||||
|
||||
private static OperatorOverrideApiResponse MapToApiResponse(OperatorOverride @override) => new()
|
||||
{
|
||||
OverrideId = @override.OverrideId,
|
||||
TenantId = @override.TenantId,
|
||||
Type = MapOverrideTypeToStrings(@override.Type),
|
||||
Reason = @override.Reason,
|
||||
EffectiveFrom = @override.EffectiveFrom,
|
||||
ExpiresAt = @override.ExpiresAt,
|
||||
EventKinds = @override.EventKinds.ToList(),
|
||||
CorrelationKeys = @override.CorrelationKeys.ToList(),
|
||||
MaxUsageCount = @override.MaxUsageCount,
|
||||
UsageCount = @override.UsageCount,
|
||||
Status = @override.Status.ToString().ToLowerInvariant(),
|
||||
CreatedBy = @override.CreatedBy,
|
||||
CreatedAt = @override.CreatedAt,
|
||||
RevokedBy = @override.RevokedBy,
|
||||
RevokedAt = @override.RevokedAt,
|
||||
RevocationReason = @override.RevocationReason
|
||||
};
|
||||
}
|
||||
|
||||
#region API Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an operator override.
|
||||
/// </summary>
|
||||
public sealed class OperatorOverrideApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public string? Actor { get; set; }
|
||||
public string? Type { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
public int? DurationMinutes { get; set; }
|
||||
public DateTimeOffset? EffectiveFrom { get; set; }
|
||||
public List<string>? EventKinds { get; set; }
|
||||
public List<string>? CorrelationKeys { get; set; }
|
||||
public int? MaxUsageCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to revoke an operator override.
|
||||
/// </summary>
|
||||
public sealed class RevokeOverrideApiRequest
|
||||
{
|
||||
public string? Actor { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to check for applicable override.
|
||||
/// </summary>
|
||||
public sealed class CheckOverrideApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public string? EventKind { get; set; }
|
||||
public string? CorrelationKey { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for an operator override.
|
||||
/// </summary>
|
||||
public sealed class OperatorOverrideApiResponse
|
||||
{
|
||||
public required string OverrideId { get; set; }
|
||||
public required string TenantId { get; set; }
|
||||
public required List<string> Type { get; set; }
|
||||
public required string Reason { get; set; }
|
||||
public required DateTimeOffset EffectiveFrom { get; set; }
|
||||
public required DateTimeOffset ExpiresAt { get; set; }
|
||||
public required List<string> EventKinds { get; set; }
|
||||
public required List<string> CorrelationKeys { get; set; }
|
||||
public int? MaxUsageCount { get; set; }
|
||||
public required int UsageCount { get; set; }
|
||||
public required string Status { get; set; }
|
||||
public required string CreatedBy { get; set; }
|
||||
public required DateTimeOffset CreatedAt { get; set; }
|
||||
public string? RevokedBy { get; set; }
|
||||
public DateTimeOffset? RevokedAt { get; set; }
|
||||
public string? RevocationReason { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for override check.
|
||||
/// </summary>
|
||||
public sealed class CheckOverrideApiResponse
|
||||
{
|
||||
public required bool HasOverride { get; set; }
|
||||
public required List<string> BypassedTypes { get; set; }
|
||||
public OperatorOverrideApiResponse? Override { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,351 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for quiet hours calendar management.
|
||||
/// </summary>
|
||||
public static class QuietHoursEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps quiet hours endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapQuietHoursEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/quiet-hours")
|
||||
.WithTags("QuietHours")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/calendars", ListCalendarsAsync)
|
||||
.WithName("ListQuietHoursCalendars")
|
||||
.WithSummary("List all quiet hours calendars")
|
||||
.WithDescription("Returns all quiet hours calendars for the tenant.");
|
||||
|
||||
group.MapGet("/calendars/{calendarId}", GetCalendarAsync)
|
||||
.WithName("GetQuietHoursCalendar")
|
||||
.WithSummary("Get a quiet hours calendar")
|
||||
.WithDescription("Returns a specific quiet hours calendar by ID.");
|
||||
|
||||
group.MapPost("/calendars", CreateCalendarAsync)
|
||||
.WithName("CreateQuietHoursCalendar")
|
||||
.WithSummary("Create a quiet hours calendar")
|
||||
.WithDescription("Creates a new quiet hours calendar with schedules.");
|
||||
|
||||
group.MapPut("/calendars/{calendarId}", UpdateCalendarAsync)
|
||||
.WithName("UpdateQuietHoursCalendar")
|
||||
.WithSummary("Update a quiet hours calendar")
|
||||
.WithDescription("Updates an existing quiet hours calendar.");
|
||||
|
||||
group.MapDelete("/calendars/{calendarId}", DeleteCalendarAsync)
|
||||
.WithName("DeleteQuietHoursCalendar")
|
||||
.WithSummary("Delete a quiet hours calendar")
|
||||
.WithDescription("Deletes a quiet hours calendar.");
|
||||
|
||||
group.MapPost("/evaluate", EvaluateAsync)
|
||||
.WithName("EvaluateQuietHours")
|
||||
.WithSummary("Evaluate quiet hours")
|
||||
.WithDescription("Checks if quiet hours are currently active for an event kind.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListCalendarsAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var calendars = await calendarService.ListCalendarsAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(calendars.Select(MapToApiResponse));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetCalendarAsync(
|
||||
string calendarId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var calendar = await calendarService.GetCalendarAsync(tenantId, calendarId, cancellationToken);
|
||||
|
||||
if (calendar is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Calendar '{calendarId}' not found." });
|
||||
}
|
||||
|
||||
return Results.Ok(MapToApiResponse(calendar));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateCalendarAsync(
|
||||
[FromBody] QuietHoursCalendarApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Calendar name is required." });
|
||||
}
|
||||
|
||||
if (request.Schedules is null || request.Schedules.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "At least one schedule is required." });
|
||||
}
|
||||
|
||||
var calendarId = request.CalendarId ?? Guid.NewGuid().ToString("N")[..16];
|
||||
|
||||
var calendar = new QuietHoursCalendar
|
||||
{
|
||||
CalendarId = calendarId,
|
||||
TenantId = tenantId,
|
||||
Name = request.Name,
|
||||
Description = request.Description,
|
||||
Enabled = request.Enabled ?? true,
|
||||
Priority = request.Priority ?? 100,
|
||||
Schedules = request.Schedules.Select(MapToScheduleEntry).ToList(),
|
||||
ExcludedEventKinds = request.ExcludedEventKinds,
|
||||
IncludedEventKinds = request.IncludedEventKinds
|
||||
};
|
||||
|
||||
var created = await calendarService.UpsertCalendarAsync(calendar, actor, cancellationToken);
|
||||
|
||||
return Results.Created($"/api/v2/quiet-hours/calendars/{created.CalendarId}", MapToApiResponse(created));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateCalendarAsync(
|
||||
string calendarId,
|
||||
[FromBody] QuietHoursCalendarApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
var existing = await calendarService.GetCalendarAsync(tenantId, calendarId, cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Calendar '{calendarId}' not found." });
|
||||
}
|
||||
|
||||
var calendar = new QuietHoursCalendar
|
||||
{
|
||||
CalendarId = calendarId,
|
||||
TenantId = tenantId,
|
||||
Name = request.Name ?? existing.Name,
|
||||
Description = request.Description ?? existing.Description,
|
||||
Enabled = request.Enabled ?? existing.Enabled,
|
||||
Priority = request.Priority ?? existing.Priority,
|
||||
Schedules = request.Schedules?.Select(MapToScheduleEntry).ToList() ?? existing.Schedules,
|
||||
ExcludedEventKinds = request.ExcludedEventKinds ?? existing.ExcludedEventKinds,
|
||||
IncludedEventKinds = request.IncludedEventKinds ?? existing.IncludedEventKinds,
|
||||
CreatedAt = existing.CreatedAt,
|
||||
CreatedBy = existing.CreatedBy
|
||||
};
|
||||
|
||||
var updated = await calendarService.UpsertCalendarAsync(calendar, actor, cancellationToken);
|
||||
|
||||
return Results.Ok(MapToApiResponse(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteCalendarAsync(
|
||||
string calendarId,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var deleted = await calendarService.DeleteCalendarAsync(tenantId, calendarId, actor, cancellationToken);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Calendar '{calendarId}' not found." });
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> EvaluateAsync(
|
||||
[FromBody] QuietHoursEvaluateApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IQuietHoursCalendarService calendarService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.EventKind))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Event kind is required." });
|
||||
}
|
||||
|
||||
var result = await calendarService.EvaluateAsync(
|
||||
tenantId,
|
||||
request.EventKind,
|
||||
request.EvaluationTime,
|
||||
cancellationToken);
|
||||
|
||||
return Results.Ok(new QuietHoursEvaluateApiResponse
|
||||
{
|
||||
IsActive = result.IsActive,
|
||||
MatchedCalendarId = result.MatchedCalendarId,
|
||||
MatchedCalendarName = result.MatchedCalendarName,
|
||||
MatchedScheduleName = result.MatchedScheduleName,
|
||||
EndsAt = result.EndsAt,
|
||||
Reason = result.Reason
|
||||
});
|
||||
}
|
||||
|
||||
private static QuietHoursScheduleEntry MapToScheduleEntry(QuietHoursScheduleApiRequest request) => new()
|
||||
{
|
||||
Name = request.Name ?? "Unnamed Schedule",
|
||||
StartTime = request.StartTime ?? "00:00",
|
||||
EndTime = request.EndTime ?? "00:00",
|
||||
DaysOfWeek = request.DaysOfWeek,
|
||||
Timezone = request.Timezone,
|
||||
Enabled = request.Enabled ?? true
|
||||
};
|
||||
|
||||
private static QuietHoursCalendarApiResponse MapToApiResponse(QuietHoursCalendar calendar) => new()
|
||||
{
|
||||
CalendarId = calendar.CalendarId,
|
||||
TenantId = calendar.TenantId,
|
||||
Name = calendar.Name,
|
||||
Description = calendar.Description,
|
||||
Enabled = calendar.Enabled,
|
||||
Priority = calendar.Priority,
|
||||
Schedules = calendar.Schedules.Select(s => new QuietHoursScheduleApiResponse
|
||||
{
|
||||
Name = s.Name,
|
||||
StartTime = s.StartTime,
|
||||
EndTime = s.EndTime,
|
||||
DaysOfWeek = s.DaysOfWeek?.ToList(),
|
||||
Timezone = s.Timezone,
|
||||
Enabled = s.Enabled
|
||||
}).ToList(),
|
||||
ExcludedEventKinds = calendar.ExcludedEventKinds?.ToList(),
|
||||
IncludedEventKinds = calendar.IncludedEventKinds?.ToList(),
|
||||
CreatedAt = calendar.CreatedAt,
|
||||
CreatedBy = calendar.CreatedBy,
|
||||
UpdatedAt = calendar.UpdatedAt,
|
||||
UpdatedBy = calendar.UpdatedBy
|
||||
};
|
||||
}
|
||||
|
||||
#region API Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a quiet hours calendar.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursCalendarApiRequest
|
||||
{
|
||||
public string? CalendarId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public int? Priority { get; set; }
|
||||
public List<QuietHoursScheduleApiRequest>? Schedules { get; set; }
|
||||
public List<string>? ExcludedEventKinds { get; set; }
|
||||
public List<string>? IncludedEventKinds { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schedule entry in a quiet hours calendar request.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursScheduleApiRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? StartTime { get; set; }
|
||||
public string? EndTime { get; set; }
|
||||
public List<int>? DaysOfWeek { get; set; }
|
||||
public string? Timezone { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to evaluate quiet hours.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursEvaluateApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public string? EventKind { get; set; }
|
||||
public DateTimeOffset? EvaluationTime { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a quiet hours calendar.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursCalendarApiResponse
|
||||
{
|
||||
public required string CalendarId { get; set; }
|
||||
public required string TenantId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public required bool Enabled { get; set; }
|
||||
public required int Priority { get; set; }
|
||||
public required List<QuietHoursScheduleApiResponse> Schedules { get; set; }
|
||||
public List<string>? ExcludedEventKinds { get; set; }
|
||||
public List<string>? IncludedEventKinds { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schedule entry in a quiet hours calendar response.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursScheduleApiResponse
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required string StartTime { get; set; }
|
||||
public required string EndTime { get; set; }
|
||||
public List<int>? DaysOfWeek { get; set; }
|
||||
public string? Timezone { get; set; }
|
||||
public required bool Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for quiet hours evaluation.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursEvaluateApiResponse
|
||||
{
|
||||
public required bool IsActive { get; set; }
|
||||
public string? MatchedCalendarId { get; set; }
|
||||
public string? MatchedCalendarName { get; set; }
|
||||
public string? MatchedScheduleName { get; set; }
|
||||
public DateTimeOffset? EndsAt { get; set; }
|
||||
public string? Reason { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,406 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Maps rule management endpoints.
|
||||
/// </summary>
|
||||
public static class RuleEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapRuleEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/rules")
|
||||
.WithTags("Rules");
|
||||
|
||||
group.MapGet("/", ListRulesAsync)
|
||||
.WithName("ListRules")
|
||||
.WithSummary("Lists all rules for a tenant");
|
||||
|
||||
group.MapGet("/{ruleId}", GetRuleAsync)
|
||||
.WithName("GetRule")
|
||||
.WithSummary("Gets a rule by ID");
|
||||
|
||||
group.MapPost("/", CreateRuleAsync)
|
||||
.WithName("CreateRule")
|
||||
.WithSummary("Creates a new rule");
|
||||
|
||||
group.MapPut("/{ruleId}", UpdateRuleAsync)
|
||||
.WithName("UpdateRule")
|
||||
.WithSummary("Updates an existing rule");
|
||||
|
||||
group.MapDelete("/{ruleId}", DeleteRuleAsync)
|
||||
.WithName("DeleteRule")
|
||||
.WithSummary("Deletes a rule");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListRulesAsync(
|
||||
HttpContext context,
|
||||
INotifyRuleRepository rules,
|
||||
bool? enabled = null,
|
||||
string? keyPrefix = null,
|
||||
int? limit = null)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var allRules = await rules.ListAsync(tenantId, context.RequestAborted);
|
||||
|
||||
IEnumerable<NotifyRule> filtered = allRules;
|
||||
|
||||
if (enabled.HasValue)
|
||||
{
|
||||
filtered = filtered.Where(r => r.Enabled == enabled.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyPrefix))
|
||||
{
|
||||
filtered = filtered.Where(r => r.Name.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (limit.HasValue && limit.Value > 0)
|
||||
{
|
||||
filtered = filtered.Take(limit.Value);
|
||||
}
|
||||
|
||||
var response = filtered.Select(MapToResponse).ToList();
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetRuleAsync(
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
INotifyRuleRepository rules)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var rule = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
|
||||
if (rule is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", $"Rule '{ruleId}' not found.", context));
|
||||
}
|
||||
|
||||
return Results.Ok(MapToResponse(rule));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateRuleAsync(
|
||||
HttpContext context,
|
||||
RuleCreateRequest request,
|
||||
INotifyRuleRepository rules,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
// Check if rule already exists
|
||||
var existing = await rules.GetAsync(tenantId, request.RuleId, context.RequestAborted);
|
||||
if (existing is not null)
|
||||
{
|
||||
return Results.Conflict(Error("rule_exists", $"Rule '{request.RuleId}' already exists.", context));
|
||||
}
|
||||
|
||||
var rule = MapFromRequest(request, tenantId, actor, timeProvider);
|
||||
|
||||
await rules.UpsertAsync(rule, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "rule.created", request.RuleId, "rule", request, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.Created($"/api/v2/rules/{rule.RuleId}", MapToResponse(rule));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateRuleAsync(
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
RuleUpdateRequest request,
|
||||
INotifyRuleRepository rules,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
var existing = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", $"Rule '{ruleId}' not found.", context));
|
||||
}
|
||||
|
||||
var updated = MergeUpdate(existing, request, actor, timeProvider);
|
||||
|
||||
await rules.UpsertAsync(updated, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "rule.updated", ruleId, "rule", request, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.Ok(MapToResponse(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteRuleAsync(
|
||||
HttpContext context,
|
||||
string ruleId,
|
||||
INotifyRuleRepository rules,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
var existing = await rules.GetAsync(tenantId, ruleId, context.RequestAborted);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("rule_not_found", $"Rule '{ruleId}' not found.", context));
|
||||
}
|
||||
|
||||
await rules.DeleteAsync(tenantId, ruleId, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "rule.deleted", ruleId, "rule", new { ruleId }, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static NotifyRule MapFromRequest(RuleCreateRequest request, string tenantId, string actor, TimeProvider timeProvider)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var match = NotifyRuleMatch.Create(
|
||||
eventKinds: request.Match.EventKinds,
|
||||
namespaces: request.Match.Namespaces,
|
||||
repositories: request.Match.Repositories,
|
||||
digests: request.Match.Digests,
|
||||
labels: request.Match.Labels,
|
||||
componentPurls: request.Match.ComponentPurls,
|
||||
minSeverity: request.Match.MinSeverity,
|
||||
verdicts: request.Match.Verdicts,
|
||||
kevOnly: request.Match.KevOnly);
|
||||
|
||||
var actions = request.Actions.Select(a => NotifyRuleAction.Create(
|
||||
actionId: a.ActionId,
|
||||
channel: a.Channel,
|
||||
template: a.Template,
|
||||
digest: a.Digest,
|
||||
throttle: ParseThrottle(a.Throttle),
|
||||
locale: a.Locale,
|
||||
enabled: a.Enabled,
|
||||
metadata: a.Metadata));
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: request.RuleId,
|
||||
tenantId: tenantId,
|
||||
name: request.Name,
|
||||
match: match,
|
||||
actions: actions,
|
||||
enabled: request.Enabled,
|
||||
description: request.Description,
|
||||
labels: request.Labels,
|
||||
metadata: request.Metadata,
|
||||
createdBy: actor,
|
||||
createdAt: now,
|
||||
updatedBy: actor,
|
||||
updatedAt: now);
|
||||
}
|
||||
|
||||
private static NotifyRule MergeUpdate(NotifyRule existing, RuleUpdateRequest request, string actor, TimeProvider timeProvider)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var match = request.Match is not null
|
||||
? NotifyRuleMatch.Create(
|
||||
eventKinds: request.Match.EventKinds ?? existing.Match.EventKinds,
|
||||
namespaces: request.Match.Namespaces ?? existing.Match.Namespaces,
|
||||
repositories: request.Match.Repositories ?? existing.Match.Repositories,
|
||||
digests: request.Match.Digests ?? existing.Match.Digests,
|
||||
labels: request.Match.Labels ?? existing.Match.Labels,
|
||||
componentPurls: request.Match.ComponentPurls ?? existing.Match.ComponentPurls,
|
||||
minSeverity: request.Match.MinSeverity ?? existing.Match.MinSeverity,
|
||||
verdicts: request.Match.Verdicts ?? existing.Match.Verdicts,
|
||||
kevOnly: request.Match.KevOnly ?? existing.Match.KevOnly)
|
||||
: existing.Match;
|
||||
|
||||
var actions = request.Actions is not null
|
||||
? request.Actions.Select(a => NotifyRuleAction.Create(
|
||||
actionId: a.ActionId,
|
||||
channel: a.Channel,
|
||||
template: a.Template,
|
||||
digest: a.Digest,
|
||||
throttle: ParseThrottle(a.Throttle),
|
||||
locale: a.Locale,
|
||||
enabled: a.Enabled,
|
||||
metadata: a.Metadata))
|
||||
: existing.Actions;
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: existing.RuleId,
|
||||
tenantId: existing.TenantId,
|
||||
name: request.Name ?? existing.Name,
|
||||
match: match,
|
||||
actions: actions,
|
||||
enabled: request.Enabled ?? existing.Enabled,
|
||||
description: request.Description ?? existing.Description,
|
||||
labels: request.Labels ?? existing.Labels,
|
||||
metadata: request.Metadata ?? existing.Metadata,
|
||||
createdBy: existing.CreatedBy,
|
||||
createdAt: existing.CreatedAt,
|
||||
updatedBy: actor,
|
||||
updatedAt: now);
|
||||
}
|
||||
|
||||
private static RuleResponse MapToResponse(NotifyRule rule)
|
||||
{
|
||||
return new RuleResponse
|
||||
{
|
||||
RuleId = rule.RuleId,
|
||||
TenantId = rule.TenantId,
|
||||
Name = rule.Name,
|
||||
Description = rule.Description,
|
||||
Enabled = rule.Enabled,
|
||||
Match = new RuleMatchResponse
|
||||
{
|
||||
EventKinds = rule.Match.EventKinds.ToList(),
|
||||
Namespaces = rule.Match.Namespaces.ToList(),
|
||||
Repositories = rule.Match.Repositories.ToList(),
|
||||
Digests = rule.Match.Digests.ToList(),
|
||||
Labels = rule.Match.Labels.ToList(),
|
||||
ComponentPurls = rule.Match.ComponentPurls.ToList(),
|
||||
MinSeverity = rule.Match.MinSeverity,
|
||||
Verdicts = rule.Match.Verdicts.ToList(),
|
||||
KevOnly = rule.Match.KevOnly ?? false
|
||||
},
|
||||
Actions = rule.Actions.Select(a => new RuleActionResponse
|
||||
{
|
||||
ActionId = a.ActionId,
|
||||
Channel = a.Channel,
|
||||
Template = a.Template,
|
||||
Digest = a.Digest,
|
||||
Throttle = a.Throttle?.ToString(),
|
||||
Locale = a.Locale,
|
||||
Enabled = a.Enabled,
|
||||
Metadata = a.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
|
||||
}).ToList(),
|
||||
Labels = rule.Labels.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
|
||||
Metadata = rule.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
|
||||
CreatedBy = rule.CreatedBy,
|
||||
CreatedAt = rule.CreatedAt,
|
||||
UpdatedBy = rule.UpdatedBy,
|
||||
UpdatedAt = rule.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseThrottle(string? throttle)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(throttle))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try parsing as TimeSpan directly
|
||||
if (TimeSpan.TryParse(throttle, out var ts))
|
||||
{
|
||||
return ts;
|
||||
}
|
||||
|
||||
// Try parsing ISO 8601 duration (simplified: PT1H, PT30M, etc.)
|
||||
if (throttle.StartsWith("PT", StringComparison.OrdinalIgnoreCase) && throttle.Length > 2)
|
||||
{
|
||||
var value = throttle[2..^1];
|
||||
var unit = throttle[^1];
|
||||
|
||||
if (int.TryParse(value, out var num))
|
||||
{
|
||||
return char.ToUpperInvariant(unit) switch
|
||||
{
|
||||
'H' => TimeSpan.FromHours(num),
|
||||
'M' => TimeSpan.FromMinutes(num),
|
||||
'S' => TimeSpan.FromSeconds(num),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetTenantId(HttpContext context)
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
return string.IsNullOrWhiteSpace(tenantId) ? null : tenantId;
|
||||
}
|
||||
|
||||
private static string GetActor(HttpContext context)
|
||||
{
|
||||
var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
|
||||
return string.IsNullOrWhiteSpace(actor) ? "api" : actor;
|
||||
}
|
||||
|
||||
private static async Task AppendAuditAsync(
|
||||
INotifyAuditRepository audit,
|
||||
string tenantId,
|
||||
string actor,
|
||||
string action,
|
||||
string entityId,
|
||||
string entityType,
|
||||
object payload,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Actor = actor,
|
||||
Action = action,
|
||||
EntityId = entityId,
|
||||
EntityType = entityType,
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
|
||||
JsonSerializer.Serialize(payload))
|
||||
};
|
||||
|
||||
await audit.AppendAsync(entry, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore audit failures
|
||||
}
|
||||
}
|
||||
|
||||
private static object Error(string code, string message, HttpContext context) => new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code,
|
||||
message,
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST endpoints for security services.
|
||||
/// </summary>
|
||||
public static class SecurityEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSecurityEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v2/security")
|
||||
.WithTags("Security");
|
||||
|
||||
// Signing endpoints
|
||||
group.MapPost("/tokens/sign", SignTokenAsync)
|
||||
.WithName("SignToken")
|
||||
.WithDescription("Signs a payload and returns a token.");
|
||||
|
||||
group.MapPost("/tokens/verify", VerifyTokenAsync)
|
||||
.WithName("VerifyToken")
|
||||
.WithDescription("Verifies a token and returns the payload if valid.");
|
||||
|
||||
group.MapGet("/tokens/{token}/info", GetTokenInfo)
|
||||
.WithName("GetTokenInfo")
|
||||
.WithDescription("Gets information about a token without verification.");
|
||||
|
||||
group.MapPost("/keys/rotate", RotateKeyAsync)
|
||||
.WithName("RotateSigningKey")
|
||||
.WithDescription("Rotates the signing key.");
|
||||
|
||||
// Webhook security endpoints
|
||||
group.MapPost("/webhooks", RegisterWebhookConfigAsync)
|
||||
.WithName("RegisterWebhookConfig")
|
||||
.WithDescription("Registers webhook security configuration.");
|
||||
|
||||
group.MapGet("/webhooks/{tenantId}/{channelId}", GetWebhookConfigAsync)
|
||||
.WithName("GetWebhookConfig")
|
||||
.WithDescription("Gets webhook security configuration.");
|
||||
|
||||
group.MapPost("/webhooks/validate", ValidateWebhookAsync)
|
||||
.WithName("ValidateWebhook")
|
||||
.WithDescription("Validates a webhook request.");
|
||||
|
||||
group.MapPut("/webhooks/{tenantId}/{channelId}/allowlist", UpdateWebhookAllowlistAsync)
|
||||
.WithName("UpdateWebhookAllowlist")
|
||||
.WithDescription("Updates IP allowlist for a webhook.");
|
||||
|
||||
// HTML sanitization endpoints
|
||||
group.MapPost("/html/sanitize", SanitizeHtmlAsync)
|
||||
.WithName("SanitizeHtml")
|
||||
.WithDescription("Sanitizes HTML content.");
|
||||
|
||||
group.MapPost("/html/validate", ValidateHtmlAsync)
|
||||
.WithName("ValidateHtml")
|
||||
.WithDescription("Validates HTML content.");
|
||||
|
||||
group.MapPost("/html/strip", StripHtmlTagsAsync)
|
||||
.WithName("StripHtmlTags")
|
||||
.WithDescription("Strips all HTML tags from content.");
|
||||
|
||||
// Tenant isolation endpoints
|
||||
group.MapPost("/tenants/validate", ValidateTenantAccessAsync)
|
||||
.WithName("ValidateTenantAccess")
|
||||
.WithDescription("Validates tenant access to a resource.");
|
||||
|
||||
group.MapGet("/tenants/{tenantId}/violations", GetTenantViolationsAsync)
|
||||
.WithName("GetTenantViolations")
|
||||
.WithDescription("Gets tenant isolation violations.");
|
||||
|
||||
group.MapPost("/tenants/fuzz-test", RunTenantFuzzTestAsync)
|
||||
.WithName("RunTenantFuzzTest")
|
||||
.WithDescription("Runs tenant isolation fuzz tests.");
|
||||
|
||||
group.MapPost("/tenants/grants", GrantCrossTenantAccessAsync)
|
||||
.WithName("GrantCrossTenantAccess")
|
||||
.WithDescription("Grants cross-tenant access to a resource.");
|
||||
|
||||
group.MapDelete("/tenants/grants", RevokeCrossTenantAccessAsync)
|
||||
.WithName("RevokeCrossTenantAccess")
|
||||
.WithDescription("Revokes cross-tenant access.");
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
// Signing endpoints
|
||||
private static async Task<IResult> SignTokenAsync(
|
||||
[FromBody] SignTokenRequest request,
|
||||
[FromServices] ISigningService signingService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = new SigningPayload
|
||||
{
|
||||
TokenId = request.TokenId ?? Guid.NewGuid().ToString("N")[..16],
|
||||
Purpose = request.Purpose,
|
||||
TenantId = request.TenantId,
|
||||
Subject = request.Subject,
|
||||
Target = request.Target,
|
||||
ExpiresAt = request.ExpiresAt ?? DateTimeOffset.UtcNow.AddHours(24),
|
||||
Claims = request.Claims ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
var token = await signingService.SignAsync(payload, cancellationToken);
|
||||
return Results.Ok(new { token });
|
||||
}
|
||||
|
||||
private static async Task<IResult> VerifyTokenAsync(
|
||||
[FromBody] VerifyTokenRequest request,
|
||||
[FromServices] ISigningService signingService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await signingService.VerifyAsync(request.Token, cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static IResult GetTokenInfo(
|
||||
string token,
|
||||
[FromServices] ISigningService signingService)
|
||||
{
|
||||
var info = signingService.GetTokenInfo(token);
|
||||
return info is not null ? Results.Ok(info) : Results.NotFound();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RotateKeyAsync(
|
||||
[FromServices] ISigningService signingService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var success = await signingService.RotateKeyAsync(cancellationToken);
|
||||
return success ? Results.Ok(new { message = "Key rotated successfully" }) : Results.Problem("Failed to rotate key");
|
||||
}
|
||||
|
||||
// Webhook security endpoints
|
||||
private static async Task<IResult> RegisterWebhookConfigAsync(
|
||||
[FromBody] WebhookSecurityConfig config,
|
||||
[FromServices] IWebhookSecurityService webhookService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await webhookService.RegisterWebhookAsync(config, cancellationToken);
|
||||
return Results.Created($"/api/v2/security/webhooks/{config.TenantId}/{config.ChannelId}", config);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetWebhookConfigAsync(
|
||||
string tenantId,
|
||||
string channelId,
|
||||
[FromServices] IWebhookSecurityService webhookService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var config = await webhookService.GetConfigAsync(tenantId, channelId, cancellationToken);
|
||||
return config is not null ? Results.Ok(config) : Results.NotFound();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ValidateWebhookAsync(
|
||||
[FromBody] WebhookValidationRequest request,
|
||||
[FromServices] IWebhookSecurityService webhookService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await webhookService.ValidateAsync(request, cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateWebhookAllowlistAsync(
|
||||
string tenantId,
|
||||
string channelId,
|
||||
[FromBody] UpdateAllowlistRequest request,
|
||||
[FromServices] IWebhookSecurityService webhookService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await webhookService.UpdateAllowlistAsync(tenantId, channelId, request.AllowedIps, request.Actor, cancellationToken);
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
// HTML sanitization endpoints
|
||||
private static Task<IResult> SanitizeHtmlAsync(
|
||||
[FromBody] SanitizeHtmlRequest request,
|
||||
[FromServices] IHtmlSanitizer sanitizer)
|
||||
{
|
||||
var profile = request.Profile is not null ? sanitizer.GetProfile(request.Profile) : null;
|
||||
var sanitized = sanitizer.Sanitize(request.Html, profile);
|
||||
return Task.FromResult(Results.Ok(new { sanitized }));
|
||||
}
|
||||
|
||||
private static Task<IResult> ValidateHtmlAsync(
|
||||
[FromBody] ValidateHtmlRequest request,
|
||||
[FromServices] IHtmlSanitizer sanitizer)
|
||||
{
|
||||
var profile = request.Profile is not null ? sanitizer.GetProfile(request.Profile) : null;
|
||||
var result = sanitizer.Validate(request.Html, profile);
|
||||
return Task.FromResult(Results.Ok(result));
|
||||
}
|
||||
|
||||
private static Task<IResult> StripHtmlTagsAsync(
|
||||
[FromBody] StripHtmlRequest request,
|
||||
[FromServices] IHtmlSanitizer sanitizer)
|
||||
{
|
||||
var stripped = sanitizer.StripTags(request.Html);
|
||||
return Task.FromResult(Results.Ok(new { text = stripped }));
|
||||
}
|
||||
|
||||
// Tenant isolation endpoints
|
||||
private static async Task<IResult> ValidateTenantAccessAsync(
|
||||
[FromBody] ValidateTenantAccessRequest request,
|
||||
[FromServices] ITenantIsolationValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await validator.ValidateResourceAccessAsync(
|
||||
request.TenantId,
|
||||
request.ResourceType,
|
||||
request.ResourceId,
|
||||
request.Operation,
|
||||
cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTenantViolationsAsync(
|
||||
string tenantId,
|
||||
[FromQuery] DateTimeOffset? since,
|
||||
[FromServices] ITenantIsolationValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var violations = await validator.GetViolationsAsync(tenantId, since, cancellationToken);
|
||||
return Results.Ok(violations);
|
||||
}
|
||||
|
||||
private static async Task<IResult> RunTenantFuzzTestAsync(
|
||||
[FromBody] TenantFuzzTestConfig config,
|
||||
[FromServices] ITenantIsolationValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await validator.RunFuzzTestAsync(config, cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GrantCrossTenantAccessAsync(
|
||||
[FromBody] CrossTenantGrantRequest request,
|
||||
[FromServices] ITenantIsolationValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await validator.GrantCrossTenantAccessAsync(
|
||||
request.OwnerTenantId,
|
||||
request.TargetTenantId,
|
||||
request.ResourceType,
|
||||
request.ResourceId,
|
||||
request.AllowedOperations,
|
||||
request.ExpiresAt,
|
||||
request.GrantedBy,
|
||||
cancellationToken);
|
||||
return Results.Created();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RevokeCrossTenantAccessAsync(
|
||||
[FromBody] RevokeCrossTenantRequest request,
|
||||
[FromServices] ITenantIsolationValidator validator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await validator.RevokeCrossTenantAccessAsync(
|
||||
request.OwnerTenantId,
|
||||
request.TargetTenantId,
|
||||
request.ResourceType,
|
||||
request.ResourceId,
|
||||
request.RevokedBy,
|
||||
cancellationToken);
|
||||
return Results.NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
// Request DTOs
|
||||
public sealed record SignTokenRequest
|
||||
{
|
||||
public string? TokenId { get; init; }
|
||||
public required string Purpose { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string Subject { get; init; }
|
||||
public string? Target { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public Dictionary<string, string>? Claims { get; init; }
|
||||
}
|
||||
|
||||
public sealed record VerifyTokenRequest
|
||||
{
|
||||
public required string Token { get; init; }
|
||||
}
|
||||
|
||||
public sealed record UpdateAllowlistRequest
|
||||
{
|
||||
public required IReadOnlyList<string> AllowedIps { get; init; }
|
||||
public required string Actor { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SanitizeHtmlRequest
|
||||
{
|
||||
public required string Html { get; init; }
|
||||
public string? Profile { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ValidateHtmlRequest
|
||||
{
|
||||
public required string Html { get; init; }
|
||||
public string? Profile { get; init; }
|
||||
}
|
||||
|
||||
public sealed record StripHtmlRequest
|
||||
{
|
||||
public required string Html { get; init; }
|
||||
}
|
||||
|
||||
public sealed record ValidateTenantAccessRequest
|
||||
{
|
||||
public required string TenantId { get; init; }
|
||||
public required string ResourceType { get; init; }
|
||||
public required string ResourceId { get; init; }
|
||||
public TenantAccessOperation Operation { get; init; } = TenantAccessOperation.Read;
|
||||
}
|
||||
|
||||
public sealed record CrossTenantGrantRequest
|
||||
{
|
||||
public required string OwnerTenantId { get; init; }
|
||||
public required string TargetTenantId { get; init; }
|
||||
public required string ResourceType { get; init; }
|
||||
public required string ResourceId { get; init; }
|
||||
public required TenantAccessOperation AllowedOperations { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public required string GrantedBy { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RevokeCrossTenantRequest
|
||||
{
|
||||
public required string OwnerTenantId { get; init; }
|
||||
public required string TargetTenantId { get; init; }
|
||||
public required string ResourceType { get; init; }
|
||||
public required string ResourceId { get; init; }
|
||||
public required string RevokedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Simulation;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for rule simulation.
|
||||
/// </summary>
|
||||
public static class SimulationEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps simulation endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapSimulationEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/simulate")
|
||||
.WithTags("Simulation")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapPost("/", SimulateAsync)
|
||||
.WithName("SimulateRules")
|
||||
.WithSummary("Simulate rule evaluation against events")
|
||||
.WithDescription("Dry-runs rules against provided or historical events without side effects. Returns matched actions with detailed explanations.");
|
||||
|
||||
group.MapPost("/validate", ValidateRuleAsync)
|
||||
.WithName("ValidateRule")
|
||||
.WithSummary("Validate a rule definition")
|
||||
.WithDescription("Validates a rule definition and returns any errors or warnings.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> SimulateAsync(
|
||||
[FromBody] SimulationApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] ISimulationEngine simulationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
// Convert API events to NotifyEvent
|
||||
var events = request.Events?.Select(MapToNotifyEvent).ToList();
|
||||
|
||||
// Convert API rules to NotifyRule
|
||||
var rules = request.Rules?.Select(r => MapToNotifyRule(r, tenantId)).ToList();
|
||||
|
||||
var simulationRequest = new SimulationRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Events = events,
|
||||
Rules = rules,
|
||||
EnabledRulesOnly = request.EnabledRulesOnly ?? true,
|
||||
HistoricalLookback = request.HistoricalLookbackMinutes.HasValue
|
||||
? TimeSpan.FromMinutes(request.HistoricalLookbackMinutes.Value)
|
||||
: null,
|
||||
MaxEvents = request.MaxEvents ?? 100,
|
||||
EventKindFilter = request.EventKindFilter,
|
||||
IncludeNonMatches = request.IncludeNonMatches ?? false,
|
||||
EvaluationTimestamp = request.EvaluationTimestamp
|
||||
};
|
||||
|
||||
var result = await simulationEngine.SimulateAsync(simulationRequest, cancellationToken);
|
||||
|
||||
return Results.Ok(MapToApiResponse(result));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ValidateRuleAsync(
|
||||
[FromBody] RuleApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] ISimulationEngine simulationEngine,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
var rule = MapToNotifyRule(request, tenantId);
|
||||
var result = await simulationEngine.ValidateRuleAsync(rule, cancellationToken);
|
||||
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static NotifyEvent MapToNotifyEvent(EventApiRequest request)
|
||||
{
|
||||
return NotifyEvent.Create(
|
||||
eventId: request.EventId ?? Guid.NewGuid(),
|
||||
kind: request.Kind ?? "unknown",
|
||||
tenant: request.TenantId ?? "default",
|
||||
ts: request.Timestamp ?? DateTimeOffset.UtcNow,
|
||||
payload: request.Payload is not null
|
||||
? JsonNode.Parse(JsonSerializer.Serialize(request.Payload))
|
||||
: null,
|
||||
scope: request.Scope is not null
|
||||
? NotifyEventScope.Create(
|
||||
@namespace: request.Scope.Namespace,
|
||||
repo: request.Scope.Repo,
|
||||
digest: request.Scope.Digest,
|
||||
component: request.Scope.Component,
|
||||
image: request.Scope.Image,
|
||||
labels: request.Scope.Labels)
|
||||
: null,
|
||||
attributes: request.Attributes);
|
||||
}
|
||||
|
||||
private static NotifyRule MapToNotifyRule(RuleApiRequest request, string tenantId)
|
||||
{
|
||||
var match = NotifyRuleMatch.Create(
|
||||
eventKinds: request.Match?.EventKinds,
|
||||
namespaces: request.Match?.Namespaces,
|
||||
repositories: request.Match?.Repositories,
|
||||
digests: request.Match?.Digests,
|
||||
labels: request.Match?.Labels,
|
||||
componentPurls: request.Match?.ComponentPurls,
|
||||
minSeverity: request.Match?.MinSeverity,
|
||||
verdicts: request.Match?.Verdicts,
|
||||
kevOnly: request.Match?.KevOnly);
|
||||
|
||||
var actions = request.Actions?.Select(a => NotifyRuleAction.Create(
|
||||
actionId: a.ActionId ?? Guid.NewGuid().ToString("N")[..8],
|
||||
channel: a.Channel ?? "default",
|
||||
template: a.Template,
|
||||
throttle: a.ThrottleSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(a.ThrottleSeconds.Value)
|
||||
: null,
|
||||
enabled: a.Enabled ?? true)).ToList() ?? [];
|
||||
|
||||
return NotifyRule.Create(
|
||||
ruleId: request.RuleId ?? Guid.NewGuid().ToString("N")[..16],
|
||||
tenantId: request.TenantId ?? tenantId,
|
||||
name: request.Name ?? "Unnamed Rule",
|
||||
match: match,
|
||||
actions: actions,
|
||||
enabled: request.Enabled ?? true,
|
||||
description: request.Description);
|
||||
}
|
||||
|
||||
private static SimulationApiResponse MapToApiResponse(SimulationResult result)
|
||||
{
|
||||
return new SimulationApiResponse
|
||||
{
|
||||
SimulationId = result.SimulationId,
|
||||
ExecutedAt = result.ExecutedAt,
|
||||
TotalEvents = result.TotalEvents,
|
||||
TotalRules = result.TotalRules,
|
||||
MatchedEvents = result.MatchedEvents,
|
||||
TotalActionsTriggered = result.TotalActionsTriggered,
|
||||
DurationMs = result.Duration.TotalMilliseconds,
|
||||
EventResults = result.EventResults.Select(e => new EventResultApiResponse
|
||||
{
|
||||
EventId = e.EventId,
|
||||
EventKind = e.EventKind,
|
||||
EventTimestamp = e.EventTimestamp,
|
||||
Matched = e.Matched,
|
||||
MatchedRules = e.MatchedRules.Select(r => new RuleMatchApiResponse
|
||||
{
|
||||
RuleId = r.RuleId,
|
||||
RuleName = r.RuleName,
|
||||
MatchedAt = r.MatchedAt,
|
||||
Actions = r.Actions.Select(a => new ActionMatchApiResponse
|
||||
{
|
||||
ActionId = a.ActionId,
|
||||
Channel = a.Channel,
|
||||
Template = a.Template,
|
||||
Enabled = a.Enabled,
|
||||
ThrottleSeconds = a.Throttle?.TotalSeconds,
|
||||
Explanation = a.Explanation
|
||||
}).ToList()
|
||||
}).ToList(),
|
||||
NonMatchedRules = e.NonMatchedRules?.Select(r => new RuleNonMatchApiResponse
|
||||
{
|
||||
RuleId = r.RuleId,
|
||||
RuleName = r.RuleName,
|
||||
Reason = r.Reason,
|
||||
Explanation = r.Explanation
|
||||
}).ToList()
|
||||
}).ToList(),
|
||||
RuleSummaries = result.RuleSummaries.Select(s => new RuleSummaryApiResponse
|
||||
{
|
||||
RuleId = s.RuleId,
|
||||
RuleName = s.RuleName,
|
||||
Enabled = s.Enabled,
|
||||
MatchCount = s.MatchCount,
|
||||
ActionCount = s.ActionCount,
|
||||
MatchPercentage = s.MatchPercentage,
|
||||
TopNonMatchReasons = s.TopNonMatchReasons.Select(r => new NonMatchReasonApiResponse
|
||||
{
|
||||
Reason = r.Reason,
|
||||
Explanation = r.Explanation,
|
||||
Count = r.Count
|
||||
}).ToList()
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#region API Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// Simulation API request.
|
||||
/// </summary>
|
||||
public sealed class SimulationApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public List<EventApiRequest>? Events { get; set; }
|
||||
public List<RuleApiRequest>? Rules { get; set; }
|
||||
public bool? EnabledRulesOnly { get; set; }
|
||||
public int? HistoricalLookbackMinutes { get; set; }
|
||||
public int? MaxEvents { get; set; }
|
||||
public List<string>? EventKindFilter { get; set; }
|
||||
public bool? IncludeNonMatches { get; set; }
|
||||
public DateTimeOffset? EvaluationTimestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event for simulation.
|
||||
/// </summary>
|
||||
public sealed class EventApiRequest
|
||||
{
|
||||
public Guid? EventId { get; set; }
|
||||
public string? Kind { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public DateTimeOffset? Timestamp { get; set; }
|
||||
public Dictionary<string, object>? Payload { get; set; }
|
||||
public EventScopeApiRequest? Scope { get; set; }
|
||||
public Dictionary<string, string>? Attributes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event scope for simulation.
|
||||
/// </summary>
|
||||
public sealed class EventScopeApiRequest
|
||||
{
|
||||
public string? Namespace { get; set; }
|
||||
public string? Repo { get; set; }
|
||||
public string? Digest { get; set; }
|
||||
public string? Component { get; set; }
|
||||
public string? Image { get; set; }
|
||||
public Dictionary<string, string>? Labels { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule for simulation.
|
||||
/// </summary>
|
||||
public sealed class RuleApiRequest
|
||||
{
|
||||
public string? RuleId { get; set; }
|
||||
public string? TenantId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
public RuleMatchApiRequest? Match { get; set; }
|
||||
public List<RuleActionApiRequest>? Actions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule match criteria for simulation.
|
||||
/// </summary>
|
||||
public sealed class RuleMatchApiRequest
|
||||
{
|
||||
public List<string>? EventKinds { get; set; }
|
||||
public List<string>? Namespaces { get; set; }
|
||||
public List<string>? Repositories { get; set; }
|
||||
public List<string>? Digests { get; set; }
|
||||
public List<string>? Labels { get; set; }
|
||||
public List<string>? ComponentPurls { get; set; }
|
||||
public string? MinSeverity { get; set; }
|
||||
public List<string>? Verdicts { get; set; }
|
||||
public bool? KevOnly { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule action for simulation.
|
||||
/// </summary>
|
||||
public sealed class RuleActionApiRequest
|
||||
{
|
||||
public string? ActionId { get; set; }
|
||||
public string? Channel { get; set; }
|
||||
public string? Template { get; set; }
|
||||
public int? ThrottleSeconds { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Simulation API response.
|
||||
/// </summary>
|
||||
public sealed class SimulationApiResponse
|
||||
{
|
||||
public required string SimulationId { get; set; }
|
||||
public required DateTimeOffset ExecutedAt { get; set; }
|
||||
public required int TotalEvents { get; set; }
|
||||
public required int TotalRules { get; set; }
|
||||
public required int MatchedEvents { get; set; }
|
||||
public required int TotalActionsTriggered { get; set; }
|
||||
public required double DurationMs { get; set; }
|
||||
public required List<EventResultApiResponse> EventResults { get; set; }
|
||||
public required List<RuleSummaryApiResponse> RuleSummaries { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event result in simulation response.
|
||||
/// </summary>
|
||||
public sealed class EventResultApiResponse
|
||||
{
|
||||
public required Guid EventId { get; set; }
|
||||
public required string EventKind { get; set; }
|
||||
public required DateTimeOffset EventTimestamp { get; set; }
|
||||
public required bool Matched { get; set; }
|
||||
public required List<RuleMatchApiResponse> MatchedRules { get; set; }
|
||||
public List<RuleNonMatchApiResponse>? NonMatchedRules { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule match in simulation response.
|
||||
/// </summary>
|
||||
public sealed class RuleMatchApiResponse
|
||||
{
|
||||
public required string RuleId { get; set; }
|
||||
public required string RuleName { get; set; }
|
||||
public required DateTimeOffset MatchedAt { get; set; }
|
||||
public required List<ActionMatchApiResponse> Actions { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action match in simulation response.
|
||||
/// </summary>
|
||||
public sealed class ActionMatchApiResponse
|
||||
{
|
||||
public required string ActionId { get; set; }
|
||||
public required string Channel { get; set; }
|
||||
public string? Template { get; set; }
|
||||
public required bool Enabled { get; set; }
|
||||
public double? ThrottleSeconds { get; set; }
|
||||
public required string Explanation { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule non-match in simulation response.
|
||||
/// </summary>
|
||||
public sealed class RuleNonMatchApiResponse
|
||||
{
|
||||
public required string RuleId { get; set; }
|
||||
public required string RuleName { get; set; }
|
||||
public required string Reason { get; set; }
|
||||
public required string Explanation { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rule summary in simulation response.
|
||||
/// </summary>
|
||||
public sealed class RuleSummaryApiResponse
|
||||
{
|
||||
public required string RuleId { get; set; }
|
||||
public required string RuleName { get; set; }
|
||||
public required bool Enabled { get; set; }
|
||||
public required int MatchCount { get; set; }
|
||||
public required int ActionCount { get; set; }
|
||||
public required double MatchPercentage { get; set; }
|
||||
public required List<NonMatchReasonApiResponse> TopNonMatchReasons { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-match reason summary in simulation response.
|
||||
/// </summary>
|
||||
public sealed class NonMatchReasonApiResponse
|
||||
{
|
||||
public required string Reason { get; set; }
|
||||
public required string Explanation { get; set; }
|
||||
public required int Count { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,120 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notifier.Worker.StormBreaker;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for storm breaker operations.
|
||||
/// </summary>
|
||||
public static class StormBreakerEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps storm breaker API endpoints.
|
||||
/// </summary>
|
||||
public static RouteGroupBuilder MapStormBreakerEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v2/storm-breaker")
|
||||
.WithTags("Storm Breaker")
|
||||
.WithOpenApi();
|
||||
|
||||
// List active storms for tenant
|
||||
group.MapGet("/storms", async (
|
||||
HttpContext context,
|
||||
IStormBreaker stormBreaker,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var storms = await stormBreaker.GetActiveStormsAsync(tenantId, cancellationToken);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
tenantId,
|
||||
activeStorms = storms.Select(s => new
|
||||
{
|
||||
s.TenantId,
|
||||
s.StormKey,
|
||||
s.StartedAt,
|
||||
eventCount = s.EventIds.Count,
|
||||
s.SuppressedCount,
|
||||
s.LastActivityAt,
|
||||
s.IsActive
|
||||
}).ToList(),
|
||||
count = storms.Count
|
||||
});
|
||||
})
|
||||
.WithName("ListActiveStorms")
|
||||
.WithSummary("Lists all active notification storms for a tenant");
|
||||
|
||||
// Get specific storm state
|
||||
group.MapGet("/storms/{stormKey}", async (
|
||||
string stormKey,
|
||||
HttpContext context,
|
||||
IStormBreaker stormBreaker,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var state = await stormBreaker.GetStateAsync(tenantId, stormKey, cancellationToken);
|
||||
if (state is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"No storm found with key '{stormKey}'" });
|
||||
}
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
state.TenantId,
|
||||
state.StormKey,
|
||||
state.StartedAt,
|
||||
eventCount = state.EventIds.Count,
|
||||
state.SuppressedCount,
|
||||
state.LastActivityAt,
|
||||
state.LastSummaryAt,
|
||||
state.IsActive,
|
||||
sampleEventIds = state.EventIds.Take(10).ToList()
|
||||
});
|
||||
})
|
||||
.WithName("GetStormState")
|
||||
.WithSummary("Gets the current state of a specific storm");
|
||||
|
||||
// Generate storm summary
|
||||
group.MapPost("/storms/{stormKey}/summary", async (
|
||||
string stormKey,
|
||||
HttpContext context,
|
||||
IStormBreaker stormBreaker,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
var summary = await stormBreaker.GenerateSummaryAsync(tenantId, stormKey, cancellationToken);
|
||||
if (summary is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"No storm found with key '{stormKey}'" });
|
||||
}
|
||||
|
||||
return Results.Ok(summary);
|
||||
})
|
||||
.WithName("GenerateStormSummary")
|
||||
.WithSummary("Generates a summary for an active storm");
|
||||
|
||||
// Clear storm state
|
||||
group.MapDelete("/storms/{stormKey}", async (
|
||||
string stormKey,
|
||||
HttpContext context,
|
||||
IStormBreaker stormBreaker,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "default";
|
||||
|
||||
await stormBreaker.ClearAsync(tenantId, stormKey, cancellationToken);
|
||||
|
||||
return Results.Ok(new { message = $"Storm '{stormKey}' cleared successfully" });
|
||||
})
|
||||
.WithName("ClearStorm")
|
||||
.WithSummary("Clears a storm state manually");
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notifier.Worker.Dispatch;
|
||||
using StellaOps.Notifier.Worker.Templates;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Maps template management endpoints.
|
||||
/// </summary>
|
||||
public static class TemplateEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/templates")
|
||||
.WithTags("Templates");
|
||||
|
||||
group.MapGet("/", ListTemplatesAsync)
|
||||
.WithName("ListTemplates")
|
||||
.WithSummary("Lists all templates for a tenant");
|
||||
|
||||
group.MapGet("/{templateId}", GetTemplateAsync)
|
||||
.WithName("GetTemplate")
|
||||
.WithSummary("Gets a template by ID");
|
||||
|
||||
group.MapPost("/", CreateTemplateAsync)
|
||||
.WithName("CreateTemplate")
|
||||
.WithSummary("Creates a new template");
|
||||
|
||||
group.MapPut("/{templateId}", UpdateTemplateAsync)
|
||||
.WithName("UpdateTemplate")
|
||||
.WithSummary("Updates an existing template");
|
||||
|
||||
group.MapDelete("/{templateId}", DeleteTemplateAsync)
|
||||
.WithName("DeleteTemplate")
|
||||
.WithSummary("Deletes a template");
|
||||
|
||||
group.MapPost("/preview", PreviewTemplateAsync)
|
||||
.WithName("PreviewTemplate")
|
||||
.WithSummary("Previews a template rendering");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListTemplatesAsync(
|
||||
HttpContext context,
|
||||
INotifyTemplateRepository templates,
|
||||
string? keyPrefix = null,
|
||||
string? channelType = null,
|
||||
string? locale = null,
|
||||
int? limit = null)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var allTemplates = await templates.ListAsync(tenantId, context.RequestAborted);
|
||||
|
||||
IEnumerable<NotifyTemplate> filtered = allTemplates;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyPrefix))
|
||||
{
|
||||
filtered = filtered.Where(t => t.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channelType) && Enum.TryParse<NotifyChannelType>(channelType, true, out var ct))
|
||||
{
|
||||
filtered = filtered.Where(t => t.ChannelType == ct);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(locale))
|
||||
{
|
||||
filtered = filtered.Where(t => t.Locale.Equals(locale, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
if (limit.HasValue && limit.Value > 0)
|
||||
{
|
||||
filtered = filtered.Take(limit.Value);
|
||||
}
|
||||
|
||||
var response = filtered.Select(MapToResponse).ToList();
|
||||
return Results.Ok(response);
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTemplateAsync(
|
||||
HttpContext context,
|
||||
string templateId,
|
||||
INotifyTemplateRepository templates)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var template = await templates.GetAsync(tenantId, templateId, context.RequestAborted);
|
||||
if (template is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context));
|
||||
}
|
||||
|
||||
return Results.Ok(MapToResponse(template));
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateTemplateAsync(
|
||||
HttpContext context,
|
||||
TemplateCreateRequest request,
|
||||
INotifyTemplateRepository templates,
|
||||
INotifyTemplateService? templateService,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
// Validate template body
|
||||
if (templateService is not null)
|
||||
{
|
||||
var validation = templateService.Validate(request.Body);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if template already exists
|
||||
var existing = await templates.GetAsync(tenantId, request.TemplateId, context.RequestAborted);
|
||||
if (existing is not null)
|
||||
{
|
||||
return Results.Conflict(Error("template_exists", $"Template '{request.TemplateId}' already exists.", context));
|
||||
}
|
||||
|
||||
var template = MapFromRequest(request, tenantId, actor, timeProvider);
|
||||
|
||||
await templates.UpsertAsync(template, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "template.created", request.TemplateId, "template", request, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.Created($"/api/v2/templates/{template.TemplateId}", MapToResponse(template));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateTemplateAsync(
|
||||
HttpContext context,
|
||||
string templateId,
|
||||
TemplateCreateRequest request,
|
||||
INotifyTemplateRepository templates,
|
||||
INotifyTemplateService? templateService,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
// Validate template body
|
||||
if (templateService is not null)
|
||||
{
|
||||
var validation = templateService.Validate(request.Body);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context));
|
||||
}
|
||||
}
|
||||
|
||||
var existing = await templates.GetAsync(tenantId, templateId, context.RequestAborted);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context));
|
||||
}
|
||||
|
||||
var updated = MapFromRequest(request with { TemplateId = templateId }, tenantId, actor, timeProvider, existing);
|
||||
|
||||
await templates.UpsertAsync(updated, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "template.updated", templateId, "template", request, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.Ok(MapToResponse(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteTemplateAsync(
|
||||
HttpContext context,
|
||||
string templateId,
|
||||
INotifyTemplateRepository templates,
|
||||
INotifyAuditRepository audit,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
var actor = GetActor(context);
|
||||
|
||||
var existing = await templates.GetAsync(tenantId, templateId, context.RequestAborted);
|
||||
if (existing is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context));
|
||||
}
|
||||
|
||||
await templates.DeleteAsync(tenantId, templateId, context.RequestAborted);
|
||||
|
||||
await AppendAuditAsync(audit, tenantId, actor, "template.deleted", templateId, "template", new { templateId }, timeProvider, context.RequestAborted);
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> PreviewTemplateAsync(
|
||||
HttpContext context,
|
||||
TemplatePreviewRequest request,
|
||||
INotifyTemplateRepository templates,
|
||||
INotifyTemplateRenderer renderer,
|
||||
INotifyTemplateService? templateService,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
var tenantId = GetTenantId(context);
|
||||
if (tenantId is null)
|
||||
{
|
||||
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
|
||||
}
|
||||
|
||||
NotifyTemplate? template = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.TemplateId))
|
||||
{
|
||||
template = await templates.GetAsync(tenantId, request.TemplateId, context.RequestAborted);
|
||||
if (template is null)
|
||||
{
|
||||
return Results.NotFound(Error("template_not_found", $"Template '{request.TemplateId}' not found.", context));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(request.TemplateBody))
|
||||
{
|
||||
// Create a temporary template for preview
|
||||
var format = Enum.TryParse<NotifyDeliveryFormat>(request.OutputFormat, true, out var f)
|
||||
? f
|
||||
: NotifyDeliveryFormat.PlainText;
|
||||
|
||||
template = NotifyTemplate.Create(
|
||||
templateId: "preview",
|
||||
tenantId: tenantId,
|
||||
channelType: NotifyChannelType.Custom,
|
||||
key: "preview",
|
||||
locale: "en-us",
|
||||
body: request.TemplateBody,
|
||||
format: format);
|
||||
}
|
||||
else
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_request", "Either templateId or templateBody is required.", context));
|
||||
}
|
||||
|
||||
// Validate template body
|
||||
List<string>? warnings = null;
|
||||
if (templateService is not null)
|
||||
{
|
||||
var validation = templateService.Validate(template.Body);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context));
|
||||
}
|
||||
|
||||
warnings = validation.Warnings.ToList();
|
||||
}
|
||||
|
||||
// Create sample event
|
||||
var sampleEvent = NotifyEvent.Create(
|
||||
eventId: Guid.NewGuid(),
|
||||
kind: request.EventKind ?? "sample.event",
|
||||
tenant: tenantId,
|
||||
ts: timeProvider.GetUtcNow(),
|
||||
payload: request.SamplePayload ?? new JsonObject(),
|
||||
attributes: request.SampleAttributes ?? new Dictionary<string, string>(),
|
||||
actor: "preview",
|
||||
version: "1");
|
||||
|
||||
var rendered = await renderer.RenderAsync(template, sampleEvent, context.RequestAborted);
|
||||
|
||||
return Results.Ok(new TemplatePreviewResponse
|
||||
{
|
||||
RenderedBody = rendered.Body,
|
||||
RenderedSubject = rendered.Subject,
|
||||
BodyHash = rendered.BodyHash,
|
||||
Format = rendered.Format.ToString(),
|
||||
Warnings = warnings?.Count > 0 ? warnings : null
|
||||
});
|
||||
}
|
||||
|
||||
private static NotifyTemplate MapFromRequest(
|
||||
TemplateCreateRequest request,
|
||||
string tenantId,
|
||||
string actor,
|
||||
TimeProvider timeProvider,
|
||||
NotifyTemplate? existing = null)
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
|
||||
var channelType = Enum.TryParse<NotifyChannelType>(request.ChannelType, true, out var ct)
|
||||
? ct
|
||||
: NotifyChannelType.Custom;
|
||||
|
||||
var renderMode = Enum.TryParse<NotifyTemplateRenderMode>(request.RenderMode, true, out var rm)
|
||||
? rm
|
||||
: NotifyTemplateRenderMode.Markdown;
|
||||
|
||||
var format = Enum.TryParse<NotifyDeliveryFormat>(request.Format, true, out var f)
|
||||
? f
|
||||
: NotifyDeliveryFormat.PlainText;
|
||||
|
||||
return NotifyTemplate.Create(
|
||||
templateId: request.TemplateId,
|
||||
tenantId: tenantId,
|
||||
channelType: channelType,
|
||||
key: request.Key,
|
||||
locale: request.Locale,
|
||||
body: request.Body,
|
||||
renderMode: renderMode,
|
||||
format: format,
|
||||
description: request.Description,
|
||||
metadata: request.Metadata,
|
||||
createdBy: existing?.CreatedBy ?? actor,
|
||||
createdAt: existing?.CreatedAt ?? now,
|
||||
updatedBy: actor,
|
||||
updatedAt: now);
|
||||
}
|
||||
|
||||
private static TemplateResponse MapToResponse(NotifyTemplate template)
|
||||
{
|
||||
return new TemplateResponse
|
||||
{
|
||||
TemplateId = template.TemplateId,
|
||||
TenantId = template.TenantId,
|
||||
Key = template.Key,
|
||||
ChannelType = template.ChannelType.ToString(),
|
||||
Locale = template.Locale,
|
||||
Body = template.Body,
|
||||
RenderMode = template.RenderMode.ToString(),
|
||||
Format = template.Format.ToString(),
|
||||
Description = template.Description,
|
||||
Metadata = template.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
|
||||
CreatedBy = template.CreatedBy,
|
||||
CreatedAt = template.CreatedAt,
|
||||
UpdatedBy = template.UpdatedBy,
|
||||
UpdatedAt = template.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetTenantId(HttpContext context)
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
|
||||
return string.IsNullOrWhiteSpace(tenantId) ? null : tenantId;
|
||||
}
|
||||
|
||||
private static string GetActor(HttpContext context)
|
||||
{
|
||||
var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
|
||||
return string.IsNullOrWhiteSpace(actor) ? "api" : actor;
|
||||
}
|
||||
|
||||
private static async Task AppendAuditAsync(
|
||||
INotifyAuditRepository audit,
|
||||
string tenantId,
|
||||
string actor,
|
||||
string action,
|
||||
string entityId,
|
||||
string entityType,
|
||||
object payload,
|
||||
TimeProvider timeProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Actor = actor,
|
||||
Action = action,
|
||||
EntityId = entityId,
|
||||
EntityType = entityType,
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
|
||||
JsonSerializer.Serialize(payload))
|
||||
};
|
||||
|
||||
await audit.AppendAsync(entry, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore audit failures
|
||||
}
|
||||
}
|
||||
|
||||
private static object Error(string code, string message, HttpContext context) => new
|
||||
{
|
||||
error = new
|
||||
{
|
||||
code,
|
||||
message,
|
||||
traceId = context.TraceIdentifier
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Notifier.Worker.Correlation;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for throttle configuration management.
|
||||
/// </summary>
|
||||
public static class ThrottleEndpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps throttle configuration endpoints.
|
||||
/// </summary>
|
||||
public static IEndpointRouteBuilder MapThrottleEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v2/throttles")
|
||||
.WithTags("Throttles")
|
||||
.WithOpenApi();
|
||||
|
||||
group.MapGet("/config", GetConfigurationAsync)
|
||||
.WithName("GetThrottleConfiguration")
|
||||
.WithSummary("Get throttle configuration")
|
||||
.WithDescription("Returns the throttle configuration for the tenant.");
|
||||
|
||||
group.MapPut("/config", UpdateConfigurationAsync)
|
||||
.WithName("UpdateThrottleConfiguration")
|
||||
.WithSummary("Update throttle configuration")
|
||||
.WithDescription("Creates or updates the throttle configuration for the tenant.");
|
||||
|
||||
group.MapDelete("/config", DeleteConfigurationAsync)
|
||||
.WithName("DeleteThrottleConfiguration")
|
||||
.WithSummary("Delete throttle configuration")
|
||||
.WithDescription("Deletes the throttle configuration for the tenant, reverting to defaults.");
|
||||
|
||||
group.MapPost("/evaluate", EvaluateAsync)
|
||||
.WithName("EvaluateThrottle")
|
||||
.WithSummary("Evaluate throttle duration")
|
||||
.WithDescription("Returns the effective throttle duration for an event kind.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetConfigurationAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromServices] IThrottleConfigurationService throttleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var config = await throttleService.GetConfigurationAsync(tenantId, cancellationToken);
|
||||
|
||||
if (config is null)
|
||||
{
|
||||
return Results.Ok(new ThrottleConfigurationApiResponse
|
||||
{
|
||||
TenantId = tenantId,
|
||||
DefaultDurationSeconds = 900, // 15 minutes default
|
||||
Enabled = true,
|
||||
EventKindOverrides = new Dictionary<string, int>(),
|
||||
IsDefault = true
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(MapToApiResponse(config));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UpdateConfigurationAsync(
|
||||
[FromBody] ThrottleConfigurationApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IThrottleConfigurationService throttleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
if (request.DefaultDurationSeconds is <= 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Default duration must be a positive value in seconds." });
|
||||
}
|
||||
|
||||
var config = new ThrottleConfiguration
|
||||
{
|
||||
TenantId = tenantId,
|
||||
DefaultDuration = TimeSpan.FromSeconds(request.DefaultDurationSeconds ?? 900),
|
||||
EventKindOverrides = request.EventKindOverrides?
|
||||
.ToDictionary(kvp => kvp.Key, kvp => TimeSpan.FromSeconds(kvp.Value)),
|
||||
MaxEventsPerWindow = request.MaxEventsPerWindow,
|
||||
BurstWindowDuration = request.BurstWindowDurationSeconds.HasValue
|
||||
? TimeSpan.FromSeconds(request.BurstWindowDurationSeconds.Value)
|
||||
: null,
|
||||
Enabled = request.Enabled ?? true
|
||||
};
|
||||
|
||||
var updated = await throttleService.UpsertConfigurationAsync(config, actor, cancellationToken);
|
||||
|
||||
return Results.Ok(MapToApiResponse(updated));
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteConfigurationAsync(
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantId,
|
||||
[FromHeader(Name = "X-Actor")] string? actor,
|
||||
[FromServices] IThrottleConfigurationService throttleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "X-Tenant-Id header is required." });
|
||||
}
|
||||
|
||||
var deleted = await throttleService.DeleteConfigurationAsync(tenantId, actor, cancellationToken);
|
||||
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(new { error = "No throttle configuration exists for this tenant." });
|
||||
}
|
||||
|
||||
return Results.NoContent();
|
||||
}
|
||||
|
||||
private static async Task<IResult> EvaluateAsync(
|
||||
[FromBody] ThrottleEvaluateApiRequest request,
|
||||
[FromHeader(Name = "X-Tenant-Id")] string? tenantIdHeader,
|
||||
[FromServices] IThrottleConfigurationService throttleService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tenantId = request.TenantId ?? tenantIdHeader;
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant ID is required via X-Tenant-Id header or request body." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.EventKind))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Event kind is required." });
|
||||
}
|
||||
|
||||
var duration = await throttleService.GetEffectiveThrottleDurationAsync(
|
||||
tenantId,
|
||||
request.EventKind,
|
||||
cancellationToken);
|
||||
|
||||
return Results.Ok(new ThrottleEvaluateApiResponse
|
||||
{
|
||||
EventKind = request.EventKind,
|
||||
EffectiveDurationSeconds = (int)duration.TotalSeconds
|
||||
});
|
||||
}
|
||||
|
||||
private static ThrottleConfigurationApiResponse MapToApiResponse(ThrottleConfiguration config) => new()
|
||||
{
|
||||
TenantId = config.TenantId,
|
||||
DefaultDurationSeconds = (int)config.DefaultDuration.TotalSeconds,
|
||||
EventKindOverrides = config.EventKindOverrides?
|
||||
.ToDictionary(kvp => kvp.Key, kvp => (int)kvp.Value.TotalSeconds)
|
||||
?? new Dictionary<string, int>(),
|
||||
MaxEventsPerWindow = config.MaxEventsPerWindow,
|
||||
BurstWindowDurationSeconds = config.BurstWindowDuration.HasValue
|
||||
? (int)config.BurstWindowDuration.Value.TotalSeconds
|
||||
: null,
|
||||
Enabled = config.Enabled,
|
||||
CreatedAt = config.CreatedAt,
|
||||
CreatedBy = config.CreatedBy,
|
||||
UpdatedAt = config.UpdatedAt,
|
||||
UpdatedBy = config.UpdatedBy,
|
||||
IsDefault = false
|
||||
};
|
||||
}
|
||||
|
||||
#region API Request/Response Models
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update throttle configuration.
|
||||
/// </summary>
|
||||
public sealed class ThrottleConfigurationApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public int? DefaultDurationSeconds { get; set; }
|
||||
public Dictionary<string, int>? EventKindOverrides { get; set; }
|
||||
public int? MaxEventsPerWindow { get; set; }
|
||||
public int? BurstWindowDurationSeconds { get; set; }
|
||||
public bool? Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to evaluate throttle duration.
|
||||
/// </summary>
|
||||
public sealed class ThrottleEvaluateApiRequest
|
||||
{
|
||||
public string? TenantId { get; set; }
|
||||
public string? EventKind { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for throttle configuration.
|
||||
/// </summary>
|
||||
public sealed class ThrottleConfigurationApiResponse
|
||||
{
|
||||
public required string TenantId { get; set; }
|
||||
public required int DefaultDurationSeconds { get; set; }
|
||||
public required Dictionary<string, int> EventKindOverrides { get; set; }
|
||||
public int? MaxEventsPerWindow { get; set; }
|
||||
public int? BurstWindowDurationSeconds { get; set; }
|
||||
public required bool Enabled { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public string? CreatedBy { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public string? UpdatedBy { get; set; }
|
||||
public bool IsDefault { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for throttle evaluation.
|
||||
/// </summary>
|
||||
public sealed class ThrottleEvaluateApiResponse
|
||||
{
|
||||
public required string EventKind { get; set; }
|
||||
public required int EffectiveDurationSeconds { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -9,7 +9,15 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using StellaOps.Notifier.WebService.Contracts;
|
||||
using StellaOps.Notifier.WebService.Endpoints;
|
||||
using StellaOps.Notifier.WebService.Setup;
|
||||
using StellaOps.Notifier.Worker.Dispatch;
|
||||
using StellaOps.Notifier.Worker.Escalation;
|
||||
using StellaOps.Notifier.Worker.Observability;
|
||||
using StellaOps.Notifier.Worker.Security;
|
||||
using StellaOps.Notifier.Worker.StormBreaker;
|
||||
using StellaOps.Notifier.Worker.Templates;
|
||||
using StellaOps.Notifier.Worker.Tenancy;
|
||||
using StellaOps.Notify.Storage.Mongo;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
@@ -39,12 +47,46 @@ if (!isTesting)
|
||||
// Fallback no-op event queue for environments that do not configure a real backend.
|
||||
builder.Services.TryAddSingleton<INotifyEventQueue, NullNotifyEventQueue>();
|
||||
|
||||
// Template service for v2 API preview endpoint
|
||||
builder.Services.AddTemplateServices(options =>
|
||||
{
|
||||
var provenanceUrl = builder.Configuration["notifier:provenance:baseUrl"];
|
||||
if (!string.IsNullOrWhiteSpace(provenanceUrl))
|
||||
{
|
||||
options.ProvenanceBaseUrl = provenanceUrl;
|
||||
}
|
||||
});
|
||||
|
||||
// Escalation and on-call services
|
||||
builder.Services.AddEscalationServices(builder.Configuration);
|
||||
|
||||
// Storm breaker, localization, and fallback services
|
||||
builder.Services.AddStormBreakerServices(builder.Configuration);
|
||||
|
||||
// Security services (signing, webhook validation, HTML sanitization, tenant isolation)
|
||||
builder.Services.AddNotifierSecurityServices(builder.Configuration);
|
||||
|
||||
// Observability services (metrics, tracing, dead-letter, chaos testing, retention)
|
||||
builder.Services.AddNotifierObservabilityServices(builder.Configuration);
|
||||
|
||||
// Tenancy services (context accessor, RLS enforcement, channel resolution, notification enrichment)
|
||||
builder.Services.AddNotifierTenancy(builder.Configuration);
|
||||
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Enable WebSocket support for live incident feed
|
||||
app.UseWebSockets(new WebSocketOptions
|
||||
{
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(30)
|
||||
});
|
||||
|
||||
app.MapHealthChecks("/healthz");
|
||||
|
||||
// Tenant context middleware (extracts and validates tenant from headers/query)
|
||||
app.UseTenantContext();
|
||||
|
||||
// Deprecation headers for retiring v1 APIs (RFC 8594 / IETF Sunset)
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
@@ -320,17 +362,26 @@ app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
|
||||
return Results.StatusCode(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
// Use actor from request or fall back to endpoint name
|
||||
var actor = !string.IsNullOrWhiteSpace(request.Actor) ? request.Actor : "pack-approvals-ack";
|
||||
|
||||
try
|
||||
{
|
||||
var auditEntry = new NotifyAuditEntryDocument
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Actor = "pack-approvals-ack",
|
||||
Actor = actor,
|
||||
Action = "pack.approval.acknowledged",
|
||||
EntityId = packId,
|
||||
EntityType = "pack-approval",
|
||||
Timestamp = timeProvider.GetUtcNow(),
|
||||
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(JsonSerializer.Serialize(request))
|
||||
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(JsonSerializer.Serialize(new
|
||||
{
|
||||
request.AckToken,
|
||||
request.Decision,
|
||||
request.Comment,
|
||||
request.Actor
|
||||
}))
|
||||
};
|
||||
|
||||
await audit.AppendAsync(auditEntry, context.RequestAborted).ConfigureAwait(false);
|
||||
@@ -343,6 +394,25 @@ app.MapPost("/api/v1/notify/pack-approvals/{packId}/ack", async (
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
// v2 REST APIs (/api/v2/notify/... for existing consumers)
|
||||
app.MapNotifyApiV2();
|
||||
|
||||
// v2 REST APIs (/api/v2/... simplified paths)
|
||||
app.MapRuleEndpoints();
|
||||
app.MapTemplateEndpoints();
|
||||
app.MapIncidentEndpoints();
|
||||
app.MapIncidentLiveFeed();
|
||||
app.MapSimulationEndpoints();
|
||||
app.MapQuietHoursEndpoints();
|
||||
app.MapThrottleEndpoints();
|
||||
app.MapOperatorOverrideEndpoints();
|
||||
app.MapEscalationEndpoints();
|
||||
app.MapStormBreakerEndpoints();
|
||||
app.MapLocalizationEndpoints();
|
||||
app.MapFallbackEndpoints();
|
||||
app.MapSecurityEndpoints();
|
||||
app.MapObservabilityEndpoints();
|
||||
|
||||
app.MapGet("/.well-known/openapi", (HttpContext context) =>
|
||||
{
|
||||
context.Response.Headers["X-OpenAPI-Scope"] = "notify";
|
||||
@@ -356,6 +426,13 @@ info:
|
||||
paths:
|
||||
/api/v1/notify/quiet-hours: {}
|
||||
/api/v1/notify/incidents: {}
|
||||
/api/v2/notify/rules: {}
|
||||
/api/v2/notify/templates: {}
|
||||
/api/v2/notify/incidents: {}
|
||||
/api/v2/rules: {}
|
||||
/api/v2/templates: {}
|
||||
/api/v2/incidents: {}
|
||||
/api/v2/incidents/live: {}
|
||||
""";
|
||||
|
||||
return Results.Text(stub, "application/yaml", Encoding.UTF8);
|
||||
|
||||
@@ -11,5 +11,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: StellaOps Notifier - Pack Approvals API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
Pack approval notification contract for Task Runner integration.
|
||||
|
||||
## Security Requirements
|
||||
- All endpoints require `Authorization: Bearer <token>` with scope `packs.approve` or `Notifier.Events:Write`
|
||||
- Tenant isolation enforced via `X-StellaOps-Tenant` header
|
||||
- HMAC signature validation available for webhook callbacks (see security schemes)
|
||||
- IP allowlist configurable per environment
|
||||
|
||||
## Resume Token Mechanics
|
||||
- Clients include `resumeToken` in requests to enable Task Runner resume flow
|
||||
- Server responds with `X-Resume-After` header containing the token for cursor-based processing
|
||||
- Tokens are opaque strings; clients must not parse or modify them
|
||||
- Token TTL: 24 hours; expired tokens result in 410 Gone
|
||||
|
||||
servers:
|
||||
- url: /api/v1/notify
|
||||
description: Notifier API v1
|
||||
|
||||
security:
|
||||
- bearerAuth: []
|
||||
|
||||
paths:
|
||||
/pack-approvals:
|
||||
post:
|
||||
operationId: ingestPackApproval
|
||||
summary: Ingest a pack approval event
|
||||
description: |
|
||||
Receives pack approval events from Task Runner. Persists approval state,
|
||||
emits notifications, and returns acknowledgement for resume flow.
|
||||
|
||||
**Idempotency**: Requests with the same `Idempotency-Key` header within 15 minutes
|
||||
return 200 OK without side effects.
|
||||
tags:
|
||||
- Pack Approvals
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantHeader'
|
||||
- $ref: '#/components/parameters/IdempotencyKey'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PackApprovalRequest'
|
||||
examples:
|
||||
approvalRequested:
|
||||
summary: Approval requested for pack deployment
|
||||
value:
|
||||
eventId: "550e8400-e29b-41d4-a716-446655440000"
|
||||
issuedAt: "2025-11-27T10:30:00Z"
|
||||
kind: "pack.approval.requested"
|
||||
packId: "pkg:oci/stellaops/scanner@v2.1.0"
|
||||
policy:
|
||||
id: "policy-prod-deploy"
|
||||
version: "1.2.3"
|
||||
decision: "pending"
|
||||
actor: "ci-pipeline@stellaops.example.com"
|
||||
resumeToken: "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..."
|
||||
summary: "Deployment approval required for production scanner update"
|
||||
labels:
|
||||
environment: "production"
|
||||
team: "security"
|
||||
policyHold:
|
||||
summary: Policy gate hold notification
|
||||
value:
|
||||
eventId: "660e8400-e29b-41d4-a716-446655440001"
|
||||
issuedAt: "2025-11-27T10:35:00Z"
|
||||
kind: "pack.policy.hold"
|
||||
packId: "pkg:oci/stellaops/concelier@v3.0.0"
|
||||
policy:
|
||||
id: "policy-compliance-check"
|
||||
version: "2.0.0"
|
||||
decision: "hold"
|
||||
actor: "policy-engine@stellaops.example.com"
|
||||
summary: "Package held pending compliance attestation"
|
||||
labels:
|
||||
compliance_framework: "SOC2"
|
||||
responses:
|
||||
'202':
|
||||
description: Approval event accepted for processing
|
||||
headers:
|
||||
X-Resume-After:
|
||||
description: Resume token for cursor-based processing
|
||||
schema:
|
||||
type: string
|
||||
'200':
|
||||
description: Duplicate request (idempotent); no action taken
|
||||
'400':
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
examples:
|
||||
missingTenant:
|
||||
value:
|
||||
error:
|
||||
code: "tenant_missing"
|
||||
message: "X-StellaOps-Tenant header is required."
|
||||
traceId: "00-abc123-def456-00"
|
||||
invalidRequest:
|
||||
value:
|
||||
error:
|
||||
code: "invalid_request"
|
||||
message: "eventId, packId, kind, decision, actor are required."
|
||||
traceId: "00-abc123-def456-01"
|
||||
'401':
|
||||
description: Authentication required
|
||||
'403':
|
||||
description: Insufficient permissions (missing scope)
|
||||
'429':
|
||||
description: Rate limited; retry after delay
|
||||
headers:
|
||||
Retry-After:
|
||||
schema:
|
||||
type: integer
|
||||
description: Seconds to wait before retry
|
||||
|
||||
/pack-approvals/{packId}/ack:
|
||||
post:
|
||||
operationId: acknowledgePackApproval
|
||||
summary: Acknowledge a pack approval decision
|
||||
description: |
|
||||
Records approval decision and triggers Task Runner resume callback.
|
||||
|
||||
**Idempotency**: Duplicate acknowledgements for the same packId + ackToken
|
||||
return 200 OK without side effects.
|
||||
tags:
|
||||
- Pack Approvals
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantHeader'
|
||||
- name: packId
|
||||
in: path
|
||||
required: true
|
||||
description: Package identifier (PURL format)
|
||||
schema:
|
||||
type: string
|
||||
example: "pkg:oci/stellaops/scanner@v2.1.0"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PackApprovalAckRequest'
|
||||
examples:
|
||||
approved:
|
||||
summary: Approval granted
|
||||
value:
|
||||
ackToken: "ack-token-abc123"
|
||||
decision: "approved"
|
||||
comment: "Reviewed and approved for production deployment"
|
||||
actor: "admin@stellaops.example.com"
|
||||
rejected:
|
||||
summary: Approval rejected
|
||||
value:
|
||||
ackToken: "ack-token-def456"
|
||||
decision: "rejected"
|
||||
comment: "Missing required attestation"
|
||||
actor: "security-lead@stellaops.example.com"
|
||||
responses:
|
||||
'204':
|
||||
description: Acknowledgement recorded successfully
|
||||
'200':
|
||||
description: Duplicate acknowledgement (idempotent); no action taken
|
||||
'400':
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: Pack approval not found
|
||||
'410':
|
||||
description: Acknowledgement token expired
|
||||
|
||||
/pack-approvals/{packId}:
|
||||
get:
|
||||
operationId: getPackApproval
|
||||
summary: Retrieve pack approval status
|
||||
description: Returns the current state of a pack approval request
|
||||
tags:
|
||||
- Pack Approvals
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantHeader'
|
||||
- name: packId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Pack approval details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PackApprovalResponse'
|
||||
'404':
|
||||
description: Pack approval not found
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: OAuth2 bearer token with `packs.approve` or `Notifier.Events:Write` scope
|
||||
hmacSignature:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-StellaOps-Signature
|
||||
description: |
|
||||
HMAC-SHA256 signature for webhook callbacks.
|
||||
Format: `sha256=<hex-encoded-signature>`
|
||||
Signature covers: `timestamp.body` where timestamp is from X-StellaOps-Timestamp header
|
||||
|
||||
parameters:
|
||||
TenantHeader:
|
||||
name: X-StellaOps-Tenant
|
||||
in: header
|
||||
required: true
|
||||
description: Tenant identifier for multi-tenant isolation
|
||||
schema:
|
||||
type: string
|
||||
example: "tenant-acme-corp"
|
||||
IdempotencyKey:
|
||||
name: Idempotency-Key
|
||||
in: header
|
||||
required: true
|
||||
description: Client-generated idempotency key (UUID recommended)
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
example: "123e4567-e89b-12d3-a456-426614174000"
|
||||
|
||||
schemas:
|
||||
PackApprovalRequest:
|
||||
type: object
|
||||
required:
|
||||
- eventId
|
||||
- issuedAt
|
||||
- kind
|
||||
- packId
|
||||
- decision
|
||||
- actor
|
||||
properties:
|
||||
eventId:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Unique event identifier
|
||||
issuedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Event timestamp (ISO 8601)
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- pack.approval.requested
|
||||
- pack.approval.updated
|
||||
- pack.policy.hold
|
||||
- pack.policy.released
|
||||
description: Event type
|
||||
packId:
|
||||
type: string
|
||||
description: Package identifier (PURL format)
|
||||
example: "pkg:oci/stellaops/scanner@v2.1.0"
|
||||
policy:
|
||||
$ref: '#/components/schemas/PackApprovalPolicy'
|
||||
decision:
|
||||
type: string
|
||||
enum:
|
||||
- pending
|
||||
- approved
|
||||
- rejected
|
||||
- hold
|
||||
- expired
|
||||
description: Current approval state
|
||||
actor:
|
||||
type: string
|
||||
description: Identity that triggered the event
|
||||
resumeToken:
|
||||
type: string
|
||||
description: Opaque token for Task Runner resume flow
|
||||
summary:
|
||||
type: string
|
||||
description: Human-readable summary for notifications
|
||||
labels:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Custom metadata labels
|
||||
|
||||
PackApprovalPolicy:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Policy identifier
|
||||
version:
|
||||
type: string
|
||||
description: Policy version
|
||||
|
||||
PackApprovalAckRequest:
|
||||
type: object
|
||||
required:
|
||||
- ackToken
|
||||
properties:
|
||||
ackToken:
|
||||
type: string
|
||||
description: Acknowledgement token from notification
|
||||
decision:
|
||||
type: string
|
||||
enum:
|
||||
- approved
|
||||
- rejected
|
||||
description: Approval decision
|
||||
comment:
|
||||
type: string
|
||||
description: Optional comment for audit trail
|
||||
actor:
|
||||
type: string
|
||||
description: Identity acknowledging the approval
|
||||
|
||||
PackApprovalResponse:
|
||||
type: object
|
||||
properties:
|
||||
packId:
|
||||
type: string
|
||||
eventId:
|
||||
type: string
|
||||
format: uuid
|
||||
kind:
|
||||
type: string
|
||||
decision:
|
||||
type: string
|
||||
policy:
|
||||
$ref: '#/components/schemas/PackApprovalPolicy'
|
||||
issuedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
acknowledgedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
acknowledgedBy:
|
||||
type: string
|
||||
resumeToken:
|
||||
type: string
|
||||
labels:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: Machine-readable error code
|
||||
message:
|
||||
type: string
|
||||
description: Human-readable error message
|
||||
traceId:
|
||||
type: string
|
||||
description: Request trace ID for debugging
|
||||
Reference in New Issue
Block a user