426 lines
15 KiB
C#
426 lines
15 KiB
C#
// -----------------------------------------------------------------------------
|
|
// UnknownsEndpoints.cs
|
|
// Sprint: SPRINT_20260106_001_005_UNKNOWNS_provenance_hints
|
|
// Tasks: WS-004, WS-005, WS-006 - Implement API endpoints
|
|
// Description: Minimal API endpoints for Unknowns with provenance hints
|
|
// -----------------------------------------------------------------------------
|
|
|
|
using Microsoft.AspNetCore.Http.HttpResults;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using StellaOps.Unknowns.Core.Models;
|
|
using StellaOps.Unknowns.Core.Repositories;
|
|
|
|
namespace StellaOps.Unknowns.WebService.Endpoints;
|
|
|
|
/// <summary>
|
|
/// Minimal API endpoints for Unknowns service.
|
|
/// </summary>
|
|
public static class UnknownsEndpoints
|
|
{
|
|
/// <summary>
|
|
/// Maps all Unknowns endpoints.
|
|
/// </summary>
|
|
public static IEndpointRouteBuilder MapUnknownsEndpoints(this IEndpointRouteBuilder routes)
|
|
{
|
|
var group = routes.MapGroup("/api/unknowns")
|
|
.WithTags("Unknowns")
|
|
.WithOpenApi();
|
|
|
|
// WS-004: GET /api/unknowns - List with pagination
|
|
group.MapGet("/", ListUnknowns)
|
|
.WithName("ListUnknowns")
|
|
.WithSummary("List unknowns with pagination")
|
|
.WithDescription("Returns paginated list of open unknowns. Supports bitemporal query with asOf parameter.");
|
|
|
|
// WS-005: GET /api/unknowns/{id} - Single with hints
|
|
group.MapGet("/{id:guid}", GetUnknownById)
|
|
.WithName("GetUnknownById")
|
|
.WithSummary("Get unknown by ID")
|
|
.WithDescription("Returns a single unknown with full provenance hints.");
|
|
|
|
// WS-006: GET /api/unknowns/{id}/hints - Hints only
|
|
group.MapGet("/{id:guid}/hints", GetUnknownHints)
|
|
.WithName("GetUnknownHints")
|
|
.WithSummary("Get provenance hints for unknown")
|
|
.WithDescription("Returns only the provenance hints for an unknown.");
|
|
|
|
// Additional endpoints
|
|
group.MapGet("/{id:guid}/history", GetUnknownHistory)
|
|
.WithName("GetUnknownHistory")
|
|
.WithSummary("Get bitemporal history for unknown")
|
|
.WithDescription("Returns the bitemporal history of state changes for an unknown.");
|
|
|
|
group.MapGet("/triage/{band}", GetByTriageBand)
|
|
.WithName("GetUnknownsByTriageBand")
|
|
.WithSummary("Get unknowns by triage band")
|
|
.WithDescription("Returns unknowns filtered by triage band (hot, warm, cold).");
|
|
|
|
group.MapGet("/hot-queue", GetHotQueue)
|
|
.WithName("GetHotQueue")
|
|
.WithSummary("Get HOT unknowns for immediate processing")
|
|
.WithDescription("Returns HOT unknowns ordered by composite score descending.");
|
|
|
|
group.MapGet("/high-confidence", GetHighConfidenceHints)
|
|
.WithName("GetHighConfidenceHints")
|
|
.WithSummary("Get unknowns with high-confidence hints")
|
|
.WithDescription("Returns unknowns with provenance hints above confidence threshold.");
|
|
|
|
group.MapGet("/summary", GetSummary)
|
|
.WithName("GetUnknownsSummary")
|
|
.WithSummary("Get unknowns summary statistics")
|
|
.WithDescription("Returns summary counts by kind, severity, and triage band.");
|
|
|
|
return routes;
|
|
}
|
|
|
|
// WS-004: List unknowns with pagination
|
|
private static async Task<Ok<UnknownsListResponse>> ListUnknowns(
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromQuery] int skip = 0,
|
|
[FromQuery] int take = 50,
|
|
[FromQuery] DateTimeOffset? asOf = null,
|
|
[FromQuery] UnknownKind? kind = null,
|
|
[FromQuery] UnknownSeverity? severity = null,
|
|
IUnknownRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
IReadOnlyList<Unknown> unknowns;
|
|
long total;
|
|
|
|
if (asOf.HasValue)
|
|
{
|
|
// Bitemporal query
|
|
unknowns = await repository.AsOfAsync(tenantId, asOf.Value, null, ct);
|
|
total = unknowns.Count;
|
|
unknowns = unknowns.Skip(skip).Take(take).ToList();
|
|
}
|
|
else if (kind.HasValue)
|
|
{
|
|
unknowns = await repository.GetByKindAsync(tenantId, kind.Value, take, ct);
|
|
total = unknowns.Count;
|
|
}
|
|
else if (severity.HasValue)
|
|
{
|
|
unknowns = await repository.GetBySeverityAsync(tenantId, severity.Value, take, ct);
|
|
total = unknowns.Count;
|
|
}
|
|
else
|
|
{
|
|
unknowns = await repository.GetOpenUnknownsAsync(tenantId, take, skip, ct);
|
|
total = await repository.CountOpenAsync(tenantId, ct);
|
|
}
|
|
|
|
var response = new UnknownsListResponse
|
|
{
|
|
Items = unknowns.Select(u => MapToDto(u)).ToList(),
|
|
Total = total,
|
|
Skip = skip,
|
|
Take = take
|
|
};
|
|
|
|
return TypedResults.Ok(response);
|
|
}
|
|
|
|
// WS-005: Get single unknown with hints
|
|
private static async Task<Results<Ok<UnknownDto>, NotFound>> GetUnknownById(
|
|
Guid id,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
IUnknownRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var unknown = await repository.GetByIdAsync(tenantId, id, ct);
|
|
|
|
if (unknown is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
return TypedResults.Ok(MapToDto(unknown));
|
|
}
|
|
|
|
// WS-006: Get hints only
|
|
private static async Task<Results<Ok<ProvenanceHintsResponse>, NotFound>> GetUnknownHints(
|
|
Guid id,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
IUnknownRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var unknown = await repository.GetByIdAsync(tenantId, id, ct);
|
|
|
|
if (unknown is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
var response = new ProvenanceHintsResponse
|
|
{
|
|
UnknownId = unknown.Id,
|
|
Hints = unknown.ProvenanceHints.Select(h => MapHintToDto(h)).ToList(),
|
|
BestHypothesis = unknown.BestHypothesis,
|
|
CombinedConfidence = unknown.CombinedConfidence,
|
|
PrimarySuggestedAction = unknown.PrimarySuggestedAction
|
|
};
|
|
|
|
return TypedResults.Ok(response);
|
|
}
|
|
|
|
// Get bitemporal history
|
|
private static async Task<Results<Ok<UnknownHistoryResponse>, NotFound>> GetUnknownHistory(
|
|
Guid id,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromQuery] DateTimeOffset? from = null,
|
|
[FromQuery] DateTimeOffset? to = null,
|
|
IUnknownRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var unknown = await repository.GetByIdAsync(tenantId, id, ct);
|
|
|
|
if (unknown is null)
|
|
{
|
|
return TypedResults.NotFound();
|
|
}
|
|
|
|
// Note: Full history would require additional repository method
|
|
// For now, return current state as single history entry
|
|
var response = new UnknownHistoryResponse
|
|
{
|
|
UnknownId = id,
|
|
History = [
|
|
new UnknownHistoryEntry
|
|
{
|
|
ValidFrom = unknown.ValidFrom,
|
|
ValidTo = unknown.ValidTo,
|
|
SysFrom = unknown.SysFrom,
|
|
SysTo = unknown.SysTo,
|
|
State = MapToDto(unknown)
|
|
}
|
|
]
|
|
};
|
|
|
|
return TypedResults.Ok(response);
|
|
}
|
|
|
|
// Get by triage band
|
|
private static async Task<Ok<UnknownsListResponse>> GetByTriageBand(
|
|
TriageBand band,
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromQuery] int limit = 50,
|
|
[FromQuery] int offset = 0,
|
|
IUnknownRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var unknowns = await repository.GetByTriageBandAsync(tenantId, band, limit, offset, ct);
|
|
|
|
var response = new UnknownsListResponse
|
|
{
|
|
Items = unknowns.Select(u => MapToDto(u)).ToList(),
|
|
Total = unknowns.Count,
|
|
Skip = offset,
|
|
Take = limit
|
|
};
|
|
|
|
return TypedResults.Ok(response);
|
|
}
|
|
|
|
// Get HOT queue
|
|
private static async Task<Ok<UnknownsListResponse>> GetHotQueue(
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromQuery] int limit = 50,
|
|
IUnknownRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var unknowns = await repository.GetHotQueueAsync(tenantId, limit, ct);
|
|
|
|
var response = new UnknownsListResponse
|
|
{
|
|
Items = unknowns.Select(u => MapToDto(u)).ToList(),
|
|
Total = unknowns.Count,
|
|
Skip = 0,
|
|
Take = limit
|
|
};
|
|
|
|
return TypedResults.Ok(response);
|
|
}
|
|
|
|
// Get high-confidence hints
|
|
private static async Task<Ok<UnknownsListResponse>> GetHighConfidenceHints(
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
[FromQuery] double minConfidence = 0.7,
|
|
[FromQuery] int limit = 50,
|
|
IUnknownRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var unknowns = await repository.GetWithHighConfidenceHintsAsync(
|
|
tenantId, minConfidence, limit, ct);
|
|
|
|
var response = new UnknownsListResponse
|
|
{
|
|
Items = unknowns.Select(u => MapToDto(u)).ToList(),
|
|
Total = unknowns.Count,
|
|
Skip = 0,
|
|
Take = limit
|
|
};
|
|
|
|
return TypedResults.Ok(response);
|
|
}
|
|
|
|
// Get summary statistics
|
|
private static async Task<Ok<UnknownsSummaryResponse>> GetSummary(
|
|
[FromHeader(Name = "X-Tenant-Id")] string tenantId,
|
|
IUnknownRepository repository = null!,
|
|
CancellationToken ct = default)
|
|
{
|
|
var byKind = await repository.CountByKindAsync(tenantId, ct);
|
|
var bySeverity = await repository.CountBySeverityAsync(tenantId, ct);
|
|
var byBand = await repository.CountByTriageBandAsync(tenantId, ct);
|
|
var totalOpen = await repository.CountOpenAsync(tenantId, ct);
|
|
|
|
var response = new UnknownsSummaryResponse
|
|
{
|
|
TotalOpen = totalOpen,
|
|
ByKind = byKind.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value),
|
|
BySeverity = bySeverity.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value),
|
|
ByTriageBand = byBand.ToDictionary(kv => kv.Key.ToString(), kv => kv.Value)
|
|
};
|
|
|
|
return TypedResults.Ok(response);
|
|
}
|
|
|
|
// Mapping helpers
|
|
private static UnknownDto MapToDto(Unknown u) => new()
|
|
{
|
|
Id = u.Id,
|
|
TenantId = u.TenantId,
|
|
SubjectHash = u.SubjectHash,
|
|
SubjectType = u.SubjectType.ToString(),
|
|
SubjectRef = u.SubjectRef,
|
|
Kind = u.Kind.ToString(),
|
|
Severity = u.Severity?.ToString(),
|
|
SourceScanId = u.SourceScanId,
|
|
SourceGraphId = u.SourceGraphId,
|
|
SourceSbomDigest = u.SourceSbomDigest,
|
|
ValidFrom = u.ValidFrom,
|
|
ValidTo = u.ValidTo,
|
|
ResolvedAt = u.ResolvedAt,
|
|
ResolutionType = u.ResolutionType?.ToString(),
|
|
ResolutionRef = u.ResolutionRef,
|
|
CompositeScore = u.CompositeScore,
|
|
TriageBand = u.TriageBand.ToString(),
|
|
IsOpen = u.IsOpen,
|
|
IsResolved = u.IsResolved,
|
|
ProvenanceHints = u.ProvenanceHints.Select(h => MapHintToDto(h)).ToList(),
|
|
BestHypothesis = u.BestHypothesis,
|
|
CombinedConfidence = u.CombinedConfidence,
|
|
PrimarySuggestedAction = u.PrimarySuggestedAction,
|
|
CreatedAt = u.CreatedAt,
|
|
UpdatedAt = u.UpdatedAt
|
|
};
|
|
|
|
private static ProvenanceHintDto MapHintToDto(ProvenanceHint h) => new()
|
|
{
|
|
Id = h.Id,
|
|
Type = h.Type.ToString(),
|
|
Confidence = h.Confidence,
|
|
ConfidenceLevel = h.ConfidenceLevel.ToString(),
|
|
Hypothesis = h.Hypothesis,
|
|
SuggestedActions = h.SuggestedActions.Select(a => new SuggestedActionDto
|
|
{
|
|
Action = a.Action,
|
|
Priority = a.Priority,
|
|
Description = a.Description,
|
|
Url = a.Url
|
|
}).ToList(),
|
|
GeneratedAt = h.GeneratedAt
|
|
};
|
|
}
|
|
|
|
// DTOs
|
|
|
|
public sealed record UnknownsListResponse
|
|
{
|
|
public required IReadOnlyList<UnknownDto> Items { get; init; }
|
|
public required long Total { get; init; }
|
|
public required int Skip { get; init; }
|
|
public required int Take { get; init; }
|
|
}
|
|
|
|
public sealed record UnknownDto
|
|
{
|
|
public required Guid Id { get; init; }
|
|
public required string TenantId { get; init; }
|
|
public required string SubjectHash { get; init; }
|
|
public required string SubjectType { get; init; }
|
|
public required string SubjectRef { get; init; }
|
|
public required string Kind { get; init; }
|
|
public string? Severity { get; init; }
|
|
public Guid? SourceScanId { get; init; }
|
|
public Guid? SourceGraphId { get; init; }
|
|
public string? SourceSbomDigest { get; init; }
|
|
public required DateTimeOffset ValidFrom { get; init; }
|
|
public DateTimeOffset? ValidTo { get; init; }
|
|
public DateTimeOffset? ResolvedAt { get; init; }
|
|
public string? ResolutionType { get; init; }
|
|
public string? ResolutionRef { get; init; }
|
|
public required double CompositeScore { get; init; }
|
|
public required string TriageBand { get; init; }
|
|
public required bool IsOpen { get; init; }
|
|
public required bool IsResolved { get; init; }
|
|
public required IReadOnlyList<ProvenanceHintDto> ProvenanceHints { get; init; }
|
|
public string? BestHypothesis { get; init; }
|
|
public double? CombinedConfidence { get; init; }
|
|
public string? PrimarySuggestedAction { get; init; }
|
|
public required DateTimeOffset CreatedAt { get; init; }
|
|
public required DateTimeOffset UpdatedAt { get; init; }
|
|
}
|
|
|
|
public sealed record ProvenanceHintsResponse
|
|
{
|
|
public required Guid UnknownId { get; init; }
|
|
public required IReadOnlyList<ProvenanceHintDto> Hints { get; init; }
|
|
public string? BestHypothesis { get; init; }
|
|
public double? CombinedConfidence { get; init; }
|
|
public string? PrimarySuggestedAction { get; init; }
|
|
}
|
|
|
|
public sealed record ProvenanceHintDto
|
|
{
|
|
public required string Id { get; init; }
|
|
public required string Type { get; init; }
|
|
public required double Confidence { get; init; }
|
|
public required string ConfidenceLevel { get; init; }
|
|
public required string Hypothesis { get; init; }
|
|
public required IReadOnlyList<SuggestedActionDto> SuggestedActions { get; init; }
|
|
public required DateTimeOffset GeneratedAt { get; init; }
|
|
}
|
|
|
|
public sealed record SuggestedActionDto
|
|
{
|
|
public required string Action { get; init; }
|
|
public required int Priority { get; init; }
|
|
public string? Description { get; init; }
|
|
public string? Url { get; init; }
|
|
}
|
|
|
|
public sealed record UnknownHistoryResponse
|
|
{
|
|
public required Guid UnknownId { get; init; }
|
|
public required IReadOnlyList<UnknownHistoryEntry> History { get; init; }
|
|
}
|
|
|
|
public sealed record UnknownHistoryEntry
|
|
{
|
|
public required DateTimeOffset ValidFrom { get; init; }
|
|
public DateTimeOffset? ValidTo { get; init; }
|
|
public required DateTimeOffset SysFrom { get; init; }
|
|
public DateTimeOffset? SysTo { get; init; }
|
|
public required UnknownDto State { get; init; }
|
|
}
|
|
|
|
public sealed record UnknownsSummaryResponse
|
|
{
|
|
public required long TotalOpen { get; init; }
|
|
public required IReadOnlyDictionary<string, long> ByKind { get; init; }
|
|
public required IReadOnlyDictionary<string, long> BySeverity { get; init; }
|
|
public required IReadOnlyDictionary<string, long> ByTriageBand { get; init; }
|
|
}
|