829 lines
30 KiB
C#
829 lines
30 KiB
C#
// -----------------------------------------------------------------------------
|
|
// GreyQueueEndpoints.cs
|
|
// Description: Minimal API endpoints for Grey Queue management.
|
|
// Implements signed, replayable evidence pipeline for ambiguous unknowns.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using Microsoft.AspNetCore.Http.HttpResults;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using StellaOps.Unknowns.Core.Models;
|
|
using StellaOps.Unknowns.Core.Repositories;
|
|
using StellaOps.Unknowns.WebService.Security;
|
|
using StellaOps.Auth.ServerIntegration.Tenancy;
|
|
|
|
namespace StellaOps.Unknowns.WebService.Endpoints;
|
|
|
|
/// <summary>
|
|
/// Minimal API endpoints for Grey Queue service.
|
|
/// </summary>
|
|
public static class GreyQueueEndpoints
|
|
{
|
|
/// <summary>
|
|
/// Maps all Grey Queue endpoints.
|
|
/// </summary>
|
|
public static IEndpointRouteBuilder MapGreyQueueEndpoints(this IEndpointRouteBuilder routes)
|
|
{
|
|
var group = routes.MapGroup("/api/grey-queue")
|
|
.WithTags("GreyQueue")
|
|
.RequireAuthorization(UnknownsPolicies.Read)
|
|
.RequireTenant();
|
|
|
|
// List and query
|
|
group.MapGet("/", ListEntries)
|
|
.WithName("ListGreyQueueEntries")
|
|
.WithSummary("List grey queue entries with pagination")
|
|
.WithDescription("Returns paginated list of grey queue entries. Supports filtering by status and reason.");
|
|
|
|
group.MapGet("/{id:guid}", GetEntryById)
|
|
.WithName("GetGreyQueueEntry")
|
|
.WithSummary("Get grey queue entry by ID")
|
|
.WithDescription("Returns a single grey queue entry with full evidence bundle.");
|
|
|
|
group.MapGet("/by-unknown/{unknownId:guid}", GetByUnknownId)
|
|
.WithName("GetGreyQueueByUnknownId")
|
|
.WithSummary("Get grey queue entry by unknown ID")
|
|
.WithDescription("Returns the grey queue entry for a specific unknown.");
|
|
|
|
group.MapGet("/ready", GetReadyForProcessing)
|
|
.WithName("GetReadyForProcessing")
|
|
.WithSummary("Get entries ready for processing")
|
|
.WithDescription("Returns entries that are ready to be processed (pending, not exhausted, past next processing time).");
|
|
|
|
// Triggers
|
|
group.MapGet("/triggers/feed/{feedId}", GetByFeedTrigger)
|
|
.WithName("GetByFeedTrigger")
|
|
.WithSummary("Get entries triggered by feed update")
|
|
.WithDescription("Returns entries that should be reprocessed due to a feed update.");
|
|
|
|
group.MapGet("/triggers/tool/{toolId}", GetByToolTrigger)
|
|
.WithName("GetByToolTrigger")
|
|
.WithSummary("Get entries triggered by tool update")
|
|
.WithDescription("Returns entries that should be reprocessed due to a tool update.");
|
|
|
|
group.MapGet("/triggers/cve/{cveId}", GetByCveTrigger)
|
|
.WithName("GetByCveTrigger")
|
|
.WithSummary("Get entries triggered by CVE update")
|
|
.WithDescription("Returns entries that should be reprocessed due to a CVE update.");
|
|
|
|
// Actions (require write scope)
|
|
group.MapPost("/", EnqueueEntry)
|
|
.WithName("EnqueueGreyQueueEntry")
|
|
.WithSummary("Enqueue a new grey queue entry")
|
|
.WithDescription("Creates a new grey queue entry with evidence bundle and trigger conditions.")
|
|
.RequireAuthorization(UnknownsPolicies.Write);
|
|
|
|
group.MapPost("/{id:guid}/process", StartProcessing)
|
|
.WithName("StartGreyQueueProcessing")
|
|
.WithSummary("Mark entry as processing")
|
|
.WithDescription("Marks an entry as currently being processed.")
|
|
.RequireAuthorization(UnknownsPolicies.Write);
|
|
|
|
group.MapPost("/{id:guid}/result", RecordResult)
|
|
.WithName("RecordGreyQueueResult")
|
|
.WithSummary("Record processing result")
|
|
.WithDescription("Records the result of a processing attempt.")
|
|
.RequireAuthorization(UnknownsPolicies.Write);
|
|
|
|
group.MapPost("/{id:guid}/resolve", ResolveEntry)
|
|
.WithName("ResolveGreyQueueEntry")
|
|
.WithSummary("Resolve a grey queue entry")
|
|
.WithDescription("Marks an entry as resolved with resolution type and reference.")
|
|
.RequireAuthorization(UnknownsPolicies.Write);
|
|
|
|
group.MapPost("/{id:guid}/dismiss", DismissEntry)
|
|
.WithName("DismissGreyQueueEntry")
|
|
.WithSummary("Dismiss a grey queue entry")
|
|
.WithDescription("Manually dismisses an entry from the queue.")
|
|
.RequireAuthorization(UnknownsPolicies.Write);
|
|
|
|
// Maintenance (require write scope)
|
|
group.MapPost("/expire", ExpireOldEntries)
|
|
.WithName("ExpireGreyQueueEntries")
|
|
.WithSummary("Expire old entries")
|
|
.WithDescription("Expires entries that have exceeded their TTL.")
|
|
.RequireAuthorization(UnknownsPolicies.Write);
|
|
|
|
// Statistics
|
|
group.MapGet("/summary", GetSummary)
|
|
.WithName("GetGreyQueueSummary")
|
|
.WithSummary("Get grey queue summary statistics")
|
|
.WithDescription("Returns summary counts by status, reason, and performance metrics.");
|
|
|
|
// Sprint: SPRINT_20260118_018 (UQ-005) - New state transitions (require write scope)
|
|
group.MapPost("/{id:guid}/assign", AssignForReview)
|
|
.WithName("AssignGreyQueueEntry")
|
|
.WithSummary("Assign entry for review")
|
|
.WithDescription("Assigns an entry to a reviewer, transitioning to UnderReview state.")
|
|
.RequireAuthorization(UnknownsPolicies.Write);
|
|
|
|
group.MapPost("/{id:guid}/escalate", EscalateEntry)
|
|
.WithName("EscalateGreyQueueEntry")
|
|
.WithSummary("Escalate entry to security team")
|
|
.WithDescription("Escalates an entry to the security team, transitioning to Escalated state.")
|
|
.RequireAuthorization(UnknownsPolicies.Write);
|
|
|
|
group.MapPost("/{id:guid}/reject", RejectEntry)
|
|
.WithName("RejectGreyQueueEntry")
|
|
.WithSummary("Reject a grey queue entry")
|
|
.WithDescription("Marks an entry as rejected (invalid or not actionable).")
|
|
.RequireAuthorization(UnknownsPolicies.Write);
|
|
|
|
group.MapPost("/{id:guid}/reopen", ReopenEntry)
|
|
.WithName("ReopenGreyQueueEntry")
|
|
.WithSummary("Reopen a closed entry")
|
|
.WithDescription("Reopens a rejected, failed, or dismissed entry back to pending.")
|
|
.RequireAuthorization(UnknownsPolicies.Write);
|
|
|
|
group.MapGet("/{id:guid}/transitions", GetValidTransitions)
|
|
.WithName("GetValidTransitions")
|
|
.WithSummary("Get valid state transitions")
|
|
.WithDescription("Returns the valid next states for an entry based on current state.");
|
|
|
|
return routes;
|
|
}
|
|
|
|
// Sprint: SPRINT_20260118_018 (UQ-005) - Assign for review
|
|
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound, BadRequest<string>>> AssignForReview(
|
|
Guid id,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromBody] AssignForReviewRequest request,
|
|
IGreyQueueRepository repository = null!,
|
|
INotificationPublisher? notificationPublisher = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
var entry = await repository.GetByIdAsync(tenantId, id, ct);
|
|
if (entry is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
try
|
|
{
|
|
GreyQueueStateMachine.ValidateUnderReviewTransition(entry.Status, request.Assignee);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return TypedResults.BadRequest(ex.Message);
|
|
}
|
|
|
|
var updated = await repository.TransitionToUnderReviewAsync(
|
|
tenantId, id, request.Assignee, ct);
|
|
|
|
return TypedResults.Ok(MapToDto(updated));
|
|
}
|
|
|
|
// Sprint: SPRINT_20260118_018 (UQ-005) - Escalate to security team
|
|
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound, BadRequest<string>>> EscalateEntry(
|
|
Guid id,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromBody] EscalateRequest request,
|
|
IGreyQueueRepository repository = null!,
|
|
INotificationPublisher? notificationPublisher = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
var entry = await repository.GetByIdAsync(tenantId, id, ct);
|
|
if (entry is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
try
|
|
{
|
|
GreyQueueStateMachine.ValidateTransition(entry.Status, GreyQueueStatus.Escalated);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return TypedResults.BadRequest(ex.Message);
|
|
}
|
|
|
|
var updated = await repository.TransitionToEscalatedAsync(
|
|
tenantId, id, request.Reason, ct);
|
|
|
|
// Notify security team
|
|
if (notificationPublisher != null)
|
|
{
|
|
await notificationPublisher.PublishAsync(new EscalationNotification
|
|
{
|
|
EntryId = id,
|
|
BomRef = entry.BomRef ?? string.Empty,
|
|
Reason = request.Reason,
|
|
EscalatedAt = DateTimeOffset.UtcNow
|
|
}, ct);
|
|
}
|
|
|
|
return TypedResults.Ok(MapToDto(updated));
|
|
}
|
|
|
|
// Sprint: SPRINT_20260118_018 (UQ-005) - Reject entry
|
|
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound, BadRequest<string>>> RejectEntry(
|
|
Guid id,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromBody] RejectRequest request,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var entry = await repository.GetByIdAsync(tenantId, id, ct);
|
|
if (entry is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
try
|
|
{
|
|
GreyQueueStateMachine.ValidateTransition(entry.Status, GreyQueueStatus.Rejected);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return TypedResults.BadRequest(ex.Message);
|
|
}
|
|
|
|
var updated = await repository.TransitionToRejectedAsync(
|
|
tenantId, id, request.Reason, request.RejectedBy, ct);
|
|
|
|
return TypedResults.Ok(MapToDto(updated));
|
|
}
|
|
|
|
// Sprint: SPRINT_20260118_018 (UQ-005) - Reopen entry
|
|
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound, BadRequest<string>>> ReopenEntry(
|
|
Guid id,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromBody] ReopenRequest request,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var entry = await repository.GetByIdAsync(tenantId, id, ct);
|
|
if (entry is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
try
|
|
{
|
|
GreyQueueStateMachine.ValidateTransition(entry.Status, GreyQueueStatus.Pending);
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
return TypedResults.BadRequest(ex.Message);
|
|
}
|
|
|
|
var updated = await repository.ReopenAsync(tenantId, id, request.Reason, ct);
|
|
|
|
return TypedResults.Ok(MapToDto(updated));
|
|
}
|
|
|
|
// Sprint: SPRINT_20260118_018 (UQ-005) - Get valid transitions
|
|
private static async Task<Results<Ok<ValidTransitionsResponse>, NotFound>> GetValidTransitions(
|
|
Guid id,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var entry = await repository.GetByIdAsync(tenantId, id, ct);
|
|
if (entry is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
var validStates = GreyQueueStateMachine.GetValidNextStates(entry.Status);
|
|
|
|
var response = new ValidTransitionsResponse
|
|
{
|
|
CurrentState = entry.Status.ToString(),
|
|
ValidNextStates = validStates.Select(s => s.ToString()).ToList()
|
|
};
|
|
|
|
return TypedResults.Ok(response);
|
|
}
|
|
|
|
// List entries with pagination
|
|
private static async Task<Ok<GreyQueueListResponse>> ListEntries(
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromQuery] int skip = 0,
|
|
[FromQuery] int take = 50,
|
|
[FromQuery] GreyQueueStatus? status = null,
|
|
[FromQuery] GreyQueueReason? reason = null,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
IReadOnlyList<GreyQueueEntry> entries;
|
|
|
|
if (status.HasValue)
|
|
{
|
|
entries = await repository.GetByStatusAsync(tenantId, status.Value, take, skip, ct);
|
|
}
|
|
else if (reason.HasValue)
|
|
{
|
|
entries = await repository.GetByReasonAsync(tenantId, reason.Value, take, ct);
|
|
}
|
|
else
|
|
{
|
|
entries = await repository.GetByStatusAsync(tenantId, GreyQueueStatus.Pending, take, skip, ct);
|
|
}
|
|
|
|
var total = await repository.CountPendingAsync(tenantId, ct);
|
|
|
|
var response = new GreyQueueListResponse
|
|
{
|
|
Items = entries.Select(MapToDto).ToList(),
|
|
Total = total,
|
|
Skip = skip,
|
|
Take = take
|
|
};
|
|
|
|
return TypedResults.Ok(response);
|
|
}
|
|
|
|
// Get entry by ID
|
|
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> GetEntryById(
|
|
Guid id,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var entry = await repository.GetByIdAsync(tenantId, id, ct);
|
|
if (entry is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
return TypedResults.Ok(MapToDto(entry));
|
|
}
|
|
|
|
// Get by unknown ID
|
|
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> GetByUnknownId(
|
|
Guid unknownId,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var entry = await repository.GetByUnknownIdAsync(tenantId, unknownId, ct);
|
|
if (entry is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
return TypedResults.Ok(MapToDto(entry));
|
|
}
|
|
|
|
// Get ready for processing
|
|
private static async Task<Ok<GreyQueueListResponse>> GetReadyForProcessing(
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromQuery] int limit = 50,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var entries = await repository.GetReadyForProcessingAsync(tenantId, limit, ct);
|
|
|
|
var response = new GreyQueueListResponse
|
|
{
|
|
Items = entries.Select(MapToDto).ToList(),
|
|
Total = entries.Count,
|
|
Skip = 0,
|
|
Take = limit
|
|
};
|
|
|
|
return TypedResults.Ok(response);
|
|
}
|
|
|
|
// Get by feed trigger
|
|
private static async Task<Ok<GreyQueueListResponse>> GetByFeedTrigger(
|
|
string feedId,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromQuery] string? version = null,
|
|
[FromQuery] int limit = 50,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var entries = await repository.GetByFeedTriggerAsync(tenantId, feedId, version, limit, ct);
|
|
|
|
var response = new GreyQueueListResponse
|
|
{
|
|
Items = entries.Select(MapToDto).ToList(),
|
|
Total = entries.Count,
|
|
Skip = 0,
|
|
Take = limit
|
|
};
|
|
|
|
return TypedResults.Ok(response);
|
|
}
|
|
|
|
// Get by tool trigger
|
|
private static async Task<Ok<GreyQueueListResponse>> GetByToolTrigger(
|
|
string toolId,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromQuery] string? version = null,
|
|
[FromQuery] int limit = 50,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var entries = await repository.GetByToolTriggerAsync(tenantId, toolId, version, limit, ct);
|
|
|
|
var response = new GreyQueueListResponse
|
|
{
|
|
Items = entries.Select(MapToDto).ToList(),
|
|
Total = entries.Count,
|
|
Skip = 0,
|
|
Take = limit
|
|
};
|
|
|
|
return TypedResults.Ok(response);
|
|
}
|
|
|
|
// Get by CVE trigger
|
|
private static async Task<Ok<GreyQueueListResponse>> GetByCveTrigger(
|
|
string cveId,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromQuery] int limit = 50,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var entries = await repository.GetByCveTriggerAsync(tenantId, cveId, limit, ct);
|
|
|
|
var response = new GreyQueueListResponse
|
|
{
|
|
Items = entries.Select(MapToDto).ToList(),
|
|
Total = entries.Count,
|
|
Skip = 0,
|
|
Take = limit
|
|
};
|
|
|
|
return TypedResults.Ok(response);
|
|
}
|
|
|
|
// Enqueue new entry
|
|
private static async Task<Created<GreyQueueEntryDto>> EnqueueEntry(
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromBody] EnqueueGreyQueueRequest request,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var evidence = request.Evidence is not null ? new GreyQueueEvidenceBundle
|
|
{
|
|
SbomSliceJson = request.Evidence.SbomSliceJson,
|
|
AdvisorySnippetJson = request.Evidence.AdvisorySnippetJson,
|
|
VexEvidenceJson = request.Evidence.VexEvidenceJson,
|
|
DiffTracesJson = request.Evidence.DiffTracesJson,
|
|
ReachabilityEvidenceJson = request.Evidence.ReachabilityEvidenceJson
|
|
} : null;
|
|
|
|
var triggers = request.Triggers is not null ? new GreyQueueTriggers
|
|
{
|
|
Feeds = request.Triggers.Feeds?.Select(f => new FeedTrigger(f.FeedId, f.MinVersion)).ToList() ?? [],
|
|
Tools = request.Triggers.Tools?.Select(t => new ToolTrigger(t.ToolId, t.MinVersion)).ToList() ?? [],
|
|
CveIds = request.Triggers.CveIds ?? [],
|
|
PurlPatterns = request.Triggers.PurlPatterns ?? []
|
|
} : null;
|
|
|
|
var entry = await repository.EnqueueAsync(
|
|
tenantId,
|
|
request.UnknownId,
|
|
request.Reason,
|
|
request.ReasonDetail,
|
|
evidence,
|
|
triggers,
|
|
request.Priority,
|
|
request.CreatedBy,
|
|
request.CorrelationId,
|
|
ct);
|
|
|
|
return TypedResults.Created($"/api/grey-queue/{entry.Id}", MapToDto(entry));
|
|
}
|
|
|
|
// Start processing
|
|
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> StartProcessing(
|
|
Guid id,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
var entry = await repository.StartProcessingAsync(tenantId, id, ct);
|
|
return TypedResults.Ok(MapToDto(entry));
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
}
|
|
|
|
// Record result
|
|
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> RecordResult(
|
|
Guid id,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromBody] RecordResultRequest request,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
var entry = await repository.RecordProcessingResultAsync(
|
|
tenantId,
|
|
id,
|
|
request.Success,
|
|
request.Result,
|
|
request.NextProcessingAt,
|
|
ct);
|
|
return TypedResults.Ok(MapToDto(entry));
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
}
|
|
|
|
// Resolve entry
|
|
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> ResolveEntry(
|
|
Guid id,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromBody] ResolveEntryRequest request,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
var entry = await repository.ResolveAsync(
|
|
tenantId,
|
|
id,
|
|
request.Resolution,
|
|
request.ResolutionRef,
|
|
ct);
|
|
return TypedResults.Ok(MapToDto(entry));
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
}
|
|
|
|
// Dismiss entry
|
|
private static async Task<Results<Ok<GreyQueueEntryDto>, NotFound>> DismissEntry(
|
|
Guid id,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromBody] DismissEntryRequest request,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
try
|
|
{
|
|
var entry = await repository.DismissAsync(
|
|
tenantId,
|
|
id,
|
|
request.DismissedBy,
|
|
request.Reason,
|
|
ct);
|
|
return TypedResults.Ok(MapToDto(entry));
|
|
}
|
|
catch (KeyNotFoundException)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
}
|
|
|
|
// Expire old entries
|
|
private static async Task<Ok<ExpireResultResponse>> ExpireOldEntries(
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var count = await repository.ExpireOldEntriesAsync(tenantId, ct);
|
|
return TypedResults.Ok(new ExpireResultResponse { ExpiredCount = count });
|
|
}
|
|
|
|
// Get summary
|
|
private static async Task<Ok<GreyQueueSummaryDto>> GetSummary(
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
IGreyQueueRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var summary = await repository.GetSummaryAsync(tenantId, ct);
|
|
|
|
var response = new GreyQueueSummaryDto
|
|
{
|
|
Total = summary.Total,
|
|
Pending = summary.Pending,
|
|
Processing = summary.Processing,
|
|
Retrying = summary.Retrying,
|
|
Resolved = summary.Resolved,
|
|
Failed = summary.Failed,
|
|
Expired = summary.Expired,
|
|
Dismissed = summary.Dismissed,
|
|
ByReason = summary.ByReason,
|
|
AvgAttemptsToResolve = summary.AvgAttemptsToResolve,
|
|
AvgHoursToResolve = summary.AvgHoursToResolve,
|
|
OldestPendingHours = summary.OldestPendingHours
|
|
};
|
|
|
|
return TypedResults.Ok(response);
|
|
}
|
|
|
|
// Mapping helpers
|
|
private static GreyQueueEntryDto MapToDto(GreyQueueEntry e) => new()
|
|
{
|
|
Id = e.Id,
|
|
TenantId = e.TenantId,
|
|
UnknownId = e.UnknownId,
|
|
Fingerprint = e.Fingerprint,
|
|
Status = e.Status.ToString(),
|
|
Priority = e.Priority,
|
|
Reason = e.Reason.ToString(),
|
|
ReasonDetail = e.ReasonDetail,
|
|
HasSbomSlice = e.SbomSlice is not null,
|
|
HasAdvisorySnippet = e.AdvisorySnippet is not null,
|
|
HasVexEvidence = e.VexEvidence is not null,
|
|
HasDiffTraces = e.DiffTraces is not null,
|
|
HasReachabilityEvidence = e.ReachabilityEvidence is not null,
|
|
ProcessingAttempts = e.ProcessingAttempts,
|
|
MaxAttempts = e.MaxAttempts,
|
|
LastProcessedAt = e.LastProcessedAt,
|
|
LastProcessingResult = e.LastProcessingResult,
|
|
NextProcessingAt = e.NextProcessingAt,
|
|
TriggerCveIds = e.TriggerOnCveUpdate.ToList(),
|
|
TriggerPurlPatterns = e.TriggerOnPurlMatch.ToList(),
|
|
CreatedAt = e.CreatedAt,
|
|
UpdatedAt = e.UpdatedAt,
|
|
ExpiresAt = e.ExpiresAt,
|
|
ResolvedAt = e.ResolvedAt,
|
|
Resolution = e.Resolution?.ToString(),
|
|
ResolutionRef = e.ResolutionRef,
|
|
IsPending = e.IsPending,
|
|
IsExhausted = e.IsExhausted,
|
|
IsReadyForProcessing = e.IsReadyForProcessing
|
|
};
|
|
}
|
|
|
|
// DTOs
|
|
|
|
public sealed record GreyQueueListResponse
|
|
{
|
|
public required IReadOnlyList<GreyQueueEntryDto> Items { get; init; }
|
|
public required long Total { get; init; }
|
|
public required int Skip { get; init; }
|
|
public required int Take { get; init; }
|
|
}
|
|
|
|
public sealed record GreyQueueEntryDto
|
|
{
|
|
public required Guid Id { get; init; }
|
|
public required string TenantId { get; init; }
|
|
public required Guid UnknownId { get; init; }
|
|
public required string Fingerprint { get; init; }
|
|
public required string Status { get; init; }
|
|
public required int Priority { get; init; }
|
|
public required string Reason { get; init; }
|
|
public string? ReasonDetail { get; init; }
|
|
public required bool HasSbomSlice { get; init; }
|
|
public required bool HasAdvisorySnippet { get; init; }
|
|
public required bool HasVexEvidence { get; init; }
|
|
public required bool HasDiffTraces { get; init; }
|
|
public required bool HasReachabilityEvidence { get; init; }
|
|
public required int ProcessingAttempts { get; init; }
|
|
public required int MaxAttempts { get; init; }
|
|
public DateTimeOffset? LastProcessedAt { get; init; }
|
|
public string? LastProcessingResult { get; init; }
|
|
public DateTimeOffset? NextProcessingAt { get; init; }
|
|
public required IReadOnlyList<string> TriggerCveIds { get; init; }
|
|
public required IReadOnlyList<string> TriggerPurlPatterns { get; init; }
|
|
public required DateTimeOffset CreatedAt { get; init; }
|
|
public required DateTimeOffset UpdatedAt { get; init; }
|
|
public DateTimeOffset? ExpiresAt { get; init; }
|
|
public DateTimeOffset? ResolvedAt { get; init; }
|
|
public string? Resolution { get; init; }
|
|
public string? ResolutionRef { get; init; }
|
|
public required bool IsPending { get; init; }
|
|
public required bool IsExhausted { get; init; }
|
|
public required bool IsReadyForProcessing { get; init; }
|
|
}
|
|
|
|
public sealed record GreyQueueSummaryDto
|
|
{
|
|
public required long Total { get; init; }
|
|
public required long Pending { get; init; }
|
|
public required long Processing { get; init; }
|
|
public required long Retrying { get; init; }
|
|
public required long Resolved { get; init; }
|
|
public required long Failed { get; init; }
|
|
public required long Expired { get; init; }
|
|
public required long Dismissed { get; init; }
|
|
public required IReadOnlyDictionary<string, long> ByReason { get; init; }
|
|
public double AvgAttemptsToResolve { get; init; }
|
|
public double AvgHoursToResolve { get; init; }
|
|
public double OldestPendingHours { get; init; }
|
|
}
|
|
|
|
public sealed record EnqueueGreyQueueRequest
|
|
{
|
|
public required Guid UnknownId { get; init; }
|
|
public required GreyQueueReason Reason { get; init; }
|
|
public string? ReasonDetail { get; init; }
|
|
public EvidenceBundleDto? Evidence { get; init; }
|
|
public TriggersDto? Triggers { get; init; }
|
|
public int Priority { get; init; } = 100;
|
|
public required string CreatedBy { get; init; }
|
|
public string? CorrelationId { get; init; }
|
|
}
|
|
|
|
public sealed record EvidenceBundleDto
|
|
{
|
|
public string? SbomSliceJson { get; init; }
|
|
public string? AdvisorySnippetJson { get; init; }
|
|
public string? VexEvidenceJson { get; init; }
|
|
public string? DiffTracesJson { get; init; }
|
|
public string? ReachabilityEvidenceJson { get; init; }
|
|
}
|
|
|
|
public sealed record TriggersDto
|
|
{
|
|
public IReadOnlyList<FeedTriggerDto>? Feeds { get; init; }
|
|
public IReadOnlyList<ToolTriggerDto>? Tools { get; init; }
|
|
public IReadOnlyList<string>? CveIds { get; init; }
|
|
public IReadOnlyList<string>? PurlPatterns { get; init; }
|
|
}
|
|
|
|
public sealed record FeedTriggerDto
|
|
{
|
|
public required string FeedId { get; init; }
|
|
public string? MinVersion { get; init; }
|
|
}
|
|
|
|
public sealed record ToolTriggerDto
|
|
{
|
|
public required string ToolId { get; init; }
|
|
public string? MinVersion { get; init; }
|
|
}
|
|
|
|
public sealed record RecordResultRequest
|
|
{
|
|
public required bool Success { get; init; }
|
|
public required string Result { get; init; }
|
|
public DateTimeOffset? NextProcessingAt { get; init; }
|
|
}
|
|
|
|
public sealed record ResolveEntryRequest
|
|
{
|
|
public required GreyQueueResolution Resolution { get; init; }
|
|
public string? ResolutionRef { get; init; }
|
|
}
|
|
|
|
public sealed record DismissEntryRequest
|
|
{
|
|
public required string DismissedBy { get; init; }
|
|
public string? Reason { get; init; }
|
|
}
|
|
|
|
public sealed record ExpireResultResponse
|
|
{
|
|
public required int ExpiredCount { get; init; }
|
|
}
|
|
// Sprint: SPRINT_20260118_018 (UQ-005) - New DTOs for state transitions
|
|
|
|
public sealed record AssignForReviewRequest
|
|
{
|
|
/// <summary>Required: The assignee for review.</summary>
|
|
public required string Assignee { get; init; }
|
|
|
|
/// <summary>Optional notes for the reviewer.</summary>
|
|
public string? Notes { get; init; }
|
|
}
|
|
|
|
public sealed record EscalateRequest
|
|
{
|
|
/// <summary>Reason for escalation.</summary>
|
|
public required string Reason { get; init; }
|
|
}
|
|
|
|
public sealed record RejectRequest
|
|
{
|
|
/// <summary>Reason for rejection.</summary>
|
|
public required string Reason { get; init; }
|
|
|
|
/// <summary>Who rejected the entry.</summary>
|
|
public required string RejectedBy { get; init; }
|
|
}
|
|
|
|
public sealed record ReopenRequest
|
|
{
|
|
/// <summary>Reason for reopening.</summary>
|
|
public required string Reason { get; init; }
|
|
}
|
|
|
|
public sealed record ValidTransitionsResponse
|
|
{
|
|
/// <summary>Current state of the entry.</summary>
|
|
public required string CurrentState { get; init; }
|
|
|
|
/// <summary>Valid next states from current state.</summary>
|
|
public required List<string> ValidNextStates { get; init; }
|
|
}
|
|
|
|
public sealed record EscalationNotification
|
|
{
|
|
public required Guid EntryId { get; init; }
|
|
public required string BomRef { get; init; }
|
|
public required string Reason { get; init; }
|
|
public DateTimeOffset EscalatedAt { get; init; }
|
|
}
|
|
|
|
// Interface for notification publishing
|
|
public interface INotificationPublisher
|
|
{
|
|
Task PublishAsync<T>(T notification, CancellationToken ct = default);
|
|
} |