save progress
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Policy.Unknowns.Models;
|
||||
using StellaOps.Policy.Unknowns.Repositories;
|
||||
using StellaOps.Policy.Unknowns.Services;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for managing the Unknowns Registry.
|
||||
/// </summary>
|
||||
internal static class UnknownsEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapUnknowns(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/v1/policy/unknowns")
|
||||
.RequireAuthorization()
|
||||
.WithTags("Unknowns Registry");
|
||||
|
||||
group.MapGet(string.Empty, ListUnknowns)
|
||||
.WithName("ListUnknowns")
|
||||
.WithSummary("List unknowns with optional band filtering.")
|
||||
.Produces<UnknownsListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapGet("/summary", GetSummary)
|
||||
.WithName("GetUnknownsSummary")
|
||||
.WithSummary("Get summary counts of unknowns by band.")
|
||||
.Produces<UnknownsSummaryResponse>(StatusCodes.Status200OK);
|
||||
|
||||
group.MapGet("/{id:guid}", GetById)
|
||||
.WithName("GetUnknownById")
|
||||
.WithSummary("Get a specific unknown by ID.")
|
||||
.Produces<UnknownResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{id:guid}/escalate", Escalate)
|
||||
.WithName("EscalateUnknown")
|
||||
.WithSummary("Escalate an unknown and trigger a rescan.")
|
||||
.Produces<UnknownResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPost("/{id:guid}/resolve", Resolve)
|
||||
.WithName("ResolveUnknown")
|
||||
.WithSummary("Mark an unknown as resolved with a reason.")
|
||||
.Produces<UnknownResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<UnknownsListResponse>, ProblemHttpResult>> ListUnknowns(
|
||||
HttpContext httpContext,
|
||||
[FromQuery] string? band,
|
||||
[FromQuery] int limit = 100,
|
||||
[FromQuery] int offset = 0,
|
||||
IUnknownsRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
if (tenantId == Guid.Empty)
|
||||
return TypedResults.Problem("Tenant ID is required.", statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
IReadOnlyList<Unknown> unknowns;
|
||||
|
||||
if (!string.IsNullOrEmpty(band) && Enum.TryParse<UnknownBand>(band, ignoreCase: true, out var parsedBand))
|
||||
{
|
||||
unknowns = await repository.GetByBandAsync(tenantId, parsedBand, limit, offset, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Get all bands, prioritized
|
||||
var hot = await repository.GetByBandAsync(tenantId, UnknownBand.Hot, limit, 0, ct);
|
||||
var warm = await repository.GetByBandAsync(tenantId, UnknownBand.Warm, limit, 0, ct);
|
||||
var cold = await repository.GetByBandAsync(tenantId, UnknownBand.Cold, limit, 0, ct);
|
||||
|
||||
unknowns = hot.Concat(warm).Concat(cold).Take(limit).ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
var items = unknowns.Select(u => new UnknownDto(
|
||||
u.Id,
|
||||
u.PackageId,
|
||||
u.PackageVersion,
|
||||
u.Band.ToString().ToLowerInvariant(),
|
||||
u.Score,
|
||||
u.UncertaintyFactor,
|
||||
u.ExploitPressure,
|
||||
u.FirstSeenAt,
|
||||
u.LastEvaluatedAt,
|
||||
u.ResolutionReason,
|
||||
u.ResolvedAt)).ToList();
|
||||
|
||||
return TypedResults.Ok(new UnknownsListResponse(items, items.Count));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<UnknownsSummaryResponse>, ProblemHttpResult>> GetSummary(
|
||||
HttpContext httpContext,
|
||||
IUnknownsRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
if (tenantId == Guid.Empty)
|
||||
return TypedResults.Problem("Tenant ID is required.", statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
var summary = await repository.GetSummaryAsync(tenantId, ct);
|
||||
|
||||
return TypedResults.Ok(new UnknownsSummaryResponse(
|
||||
summary.Hot,
|
||||
summary.Warm,
|
||||
summary.Cold,
|
||||
summary.Resolved,
|
||||
summary.Hot + summary.Warm + summary.Cold + summary.Resolved));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<UnknownResponse>, ProblemHttpResult>> GetById(
|
||||
HttpContext httpContext,
|
||||
Guid id,
|
||||
IUnknownsRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
if (tenantId == Guid.Empty)
|
||||
return TypedResults.Problem("Tenant ID is required.", statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
var unknown = await repository.GetByIdAsync(tenantId, id, ct);
|
||||
|
||||
if (unknown is null)
|
||||
return TypedResults.Problem($"Unknown with ID {id} not found.", statusCode: StatusCodes.Status404NotFound);
|
||||
|
||||
return TypedResults.Ok(new UnknownResponse(ToDto(unknown)));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<UnknownResponse>, ProblemHttpResult>> Escalate(
|
||||
HttpContext httpContext,
|
||||
Guid id,
|
||||
[FromBody] EscalateUnknownRequest request,
|
||||
IUnknownsRepository repository = null!,
|
||||
IUnknownRanker ranker = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
if (tenantId == Guid.Empty)
|
||||
return TypedResults.Problem("Tenant ID is required.", statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
var unknown = await repository.GetByIdAsync(tenantId, id, ct);
|
||||
|
||||
if (unknown is null)
|
||||
return TypedResults.Problem($"Unknown with ID {id} not found.", statusCode: StatusCodes.Status404NotFound);
|
||||
|
||||
// Re-rank with updated information (if provided)
|
||||
// For now, just bump to HOT band if not already
|
||||
if (unknown.Band != UnknownBand.Hot)
|
||||
{
|
||||
var updated = unknown with
|
||||
{
|
||||
Band = UnknownBand.Hot,
|
||||
Score = 75.0m, // Minimum HOT threshold
|
||||
LastEvaluatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
await repository.UpdateAsync(updated, ct);
|
||||
unknown = updated;
|
||||
}
|
||||
|
||||
// TODO: T6 - Trigger rescan job via Scheduler integration
|
||||
// await scheduler.CreateRescanJobAsync(unknown.PackageId, unknown.PackageVersion, ct);
|
||||
|
||||
return TypedResults.Ok(new UnknownResponse(ToDto(unknown)));
|
||||
}
|
||||
|
||||
private static async Task<Results<Ok<UnknownResponse>, ProblemHttpResult>> Resolve(
|
||||
HttpContext httpContext,
|
||||
Guid id,
|
||||
[FromBody] ResolveUnknownRequest request,
|
||||
IUnknownsRepository repository = null!,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var tenantId = ResolveTenantId(httpContext);
|
||||
if (tenantId == Guid.Empty)
|
||||
return TypedResults.Problem("Tenant ID is required.", statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Reason))
|
||||
return TypedResults.Problem("Resolution reason is required.", statusCode: StatusCodes.Status400BadRequest);
|
||||
|
||||
var success = await repository.ResolveAsync(tenantId, id, request.Reason, ct);
|
||||
|
||||
if (!success)
|
||||
return TypedResults.Problem($"Unknown with ID {id} not found.", statusCode: StatusCodes.Status404NotFound);
|
||||
|
||||
var unknown = await repository.GetByIdAsync(tenantId, id, ct);
|
||||
|
||||
return TypedResults.Ok(new UnknownResponse(ToDto(unknown!)));
|
||||
}
|
||||
|
||||
private static Guid ResolveTenantId(HttpContext context)
|
||||
{
|
||||
// First check header
|
||||
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantHeader) &&
|
||||
!string.IsNullOrWhiteSpace(tenantHeader) &&
|
||||
Guid.TryParse(tenantHeader.ToString(), out var headerTenantId))
|
||||
{
|
||||
return headerTenantId;
|
||||
}
|
||||
|
||||
// Then check claims
|
||||
var tenantClaim = context.User?.FindFirst("tenant_id")?.Value;
|
||||
if (!string.IsNullOrEmpty(tenantClaim) && Guid.TryParse(tenantClaim, out var claimTenantId))
|
||||
{
|
||||
return claimTenantId;
|
||||
}
|
||||
|
||||
return Guid.Empty;
|
||||
}
|
||||
|
||||
private static UnknownDto ToDto(Unknown u) => new(
|
||||
u.Id,
|
||||
u.PackageId,
|
||||
u.PackageVersion,
|
||||
u.Band.ToString().ToLowerInvariant(),
|
||||
u.Score,
|
||||
u.UncertaintyFactor,
|
||||
u.ExploitPressure,
|
||||
u.FirstSeenAt,
|
||||
u.LastEvaluatedAt,
|
||||
u.ResolutionReason,
|
||||
u.ResolvedAt);
|
||||
}
|
||||
|
||||
#region DTOs
|
||||
|
||||
/// <summary>Data transfer object for an unknown entry.</summary>
|
||||
public sealed record UnknownDto(
|
||||
Guid Id,
|
||||
string PackageId,
|
||||
string PackageVersion,
|
||||
string Band,
|
||||
decimal Score,
|
||||
decimal UncertaintyFactor,
|
||||
decimal ExploitPressure,
|
||||
DateTimeOffset FirstSeenAt,
|
||||
DateTimeOffset LastEvaluatedAt,
|
||||
string? ResolutionReason,
|
||||
DateTimeOffset? ResolvedAt);
|
||||
|
||||
/// <summary>Response containing a list of unknowns.</summary>
|
||||
public sealed record UnknownsListResponse(IReadOnlyList<UnknownDto> Items, int TotalCount);
|
||||
|
||||
/// <summary>Response containing a single unknown.</summary>
|
||||
public sealed record UnknownResponse(UnknownDto Unknown);
|
||||
|
||||
/// <summary>Response containing unknowns summary by band.</summary>
|
||||
public sealed record UnknownsSummaryResponse(int Hot, int Warm, int Cold, int Resolved, int Total);
|
||||
|
||||
/// <summary>Request to escalate an unknown.</summary>
|
||||
public sealed record EscalateUnknownRequest(string? Notes = null);
|
||||
|
||||
/// <summary>Request to resolve an unknown.</summary>
|
||||
public sealed record ResolveUnknownRequest(string Reason);
|
||||
|
||||
#endregion
|
||||
@@ -25,6 +25,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Messaging/StellaOps.Messaging.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Policy.Unknowns/StellaOps.Policy.Unknowns.csproj" />
|
||||
<ProjectReference Include="../StellaOps.PolicyDsl/StellaOps.PolicyDsl.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user