// ----------------------------------------------------------------------------- // 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; /// /// Minimal API endpoints for Grey Queue service. /// public static class GreyQueueEndpoints { /// /// Maps all Grey Queue endpoints. /// 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, NotFound, BadRequest>> 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, NotFound, BadRequest>> 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, NotFound, BadRequest>> 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, NotFound, BadRequest>> 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, 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> 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 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, 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, 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> 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> 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> 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> 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> 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, 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, 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, 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, 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> 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> 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 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 TriggerCveIds { get; init; } public required IReadOnlyList 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 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? Feeds { get; init; } public IReadOnlyList? Tools { get; init; } public IReadOnlyList? CveIds { get; init; } public IReadOnlyList? 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 { /// Required: The assignee for review. public required string Assignee { get; init; } /// Optional notes for the reviewer. public string? Notes { get; init; } } public sealed record EscalateRequest { /// Reason for escalation. public required string Reason { get; init; } } public sealed record RejectRequest { /// Reason for rejection. public required string Reason { get; init; } /// Who rejected the entry. public required string RejectedBy { get; init; } } public sealed record ReopenRequest { /// Reason for reopening. public required string Reason { get; init; } } public sealed record ValidTransitionsResponse { /// Current state of the entry. public required string CurrentState { get; init; } /// Valid next states from current state. public required List 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 notification, CancellationToken ct = default); }