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

This commit is contained in:
master
2025-11-27 15:05:48 +02:00
parent 4831c7fcb0
commit e950474a77
278 changed files with 81498 additions and 672 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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