save progress

This commit is contained in:
StellaOps Bot
2025-12-20 12:15:16 +02:00
parent 439f10966b
commit 0ada1b583f
95 changed files with 12400 additions and 65 deletions

View File

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