sprints enhancements
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CanonicalAdvisoryEndpointExtensions.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
|
||||
// Tasks: CANSVC-8200-016 through CANSVC-8200-019
|
||||
// Description: API endpoints for canonical advisory service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
using StellaOps.Concelier.WebService.Results;
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint extensions for canonical advisory operations.
|
||||
/// </summary>
|
||||
internal static class CanonicalAdvisoryEndpointExtensions
|
||||
{
|
||||
private const string CanonicalReadPolicy = "Concelier.Canonical.Read";
|
||||
private const string CanonicalIngestPolicy = "Concelier.Canonical.Ingest";
|
||||
|
||||
public static void MapCanonicalAdvisoryEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/canonical")
|
||||
.WithTags("Canonical Advisories");
|
||||
|
||||
// GET /api/v1/canonical/{id} - Get canonical advisory by ID
|
||||
group.MapGet("/{id:guid}", async (
|
||||
Guid id,
|
||||
ICanonicalAdvisoryService service,
|
||||
HttpContext context,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var canonical = await service.GetByIdAsync(id, ct).ConfigureAwait(false);
|
||||
|
||||
return canonical is null
|
||||
? HttpResults.NotFound(new { error = "Canonical advisory not found", id })
|
||||
: HttpResults.Ok(MapToResponse(canonical));
|
||||
})
|
||||
.WithName("GetCanonicalById")
|
||||
.WithSummary("Get canonical advisory by ID")
|
||||
.Produces<CanonicalAdvisoryResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// GET /api/v1/canonical?cve={cve}&artifact={artifact} - Query canonical advisories
|
||||
group.MapGet("/", async (
|
||||
[FromQuery] string? cve,
|
||||
[FromQuery] string? artifact,
|
||||
[FromQuery] string? mergeHash,
|
||||
[FromQuery] int? offset,
|
||||
[FromQuery] int? limit,
|
||||
ICanonicalAdvisoryService service,
|
||||
HttpContext context,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
// Query by merge hash takes precedence
|
||||
if (!string.IsNullOrEmpty(mergeHash))
|
||||
{
|
||||
var byHash = await service.GetByMergeHashAsync(mergeHash, ct).ConfigureAwait(false);
|
||||
return byHash is null
|
||||
? HttpResults.Ok(new CanonicalAdvisoryListResponse { Items = [], TotalCount = 0 })
|
||||
: HttpResults.Ok(new CanonicalAdvisoryListResponse
|
||||
{
|
||||
Items = [MapToResponse(byHash)],
|
||||
TotalCount = 1
|
||||
});
|
||||
}
|
||||
|
||||
// Query by CVE
|
||||
if (!string.IsNullOrEmpty(cve))
|
||||
{
|
||||
var byCve = await service.GetByCveAsync(cve, ct).ConfigureAwait(false);
|
||||
return HttpResults.Ok(new CanonicalAdvisoryListResponse
|
||||
{
|
||||
Items = byCve.Select(MapToResponse).ToList(),
|
||||
TotalCount = byCve.Count
|
||||
});
|
||||
}
|
||||
|
||||
// Query by artifact
|
||||
if (!string.IsNullOrEmpty(artifact))
|
||||
{
|
||||
var byArtifact = await service.GetByArtifactAsync(artifact, ct).ConfigureAwait(false);
|
||||
return HttpResults.Ok(new CanonicalAdvisoryListResponse
|
||||
{
|
||||
Items = byArtifact.Select(MapToResponse).ToList(),
|
||||
TotalCount = byArtifact.Count
|
||||
});
|
||||
}
|
||||
|
||||
// Generic query with pagination
|
||||
var options = new CanonicalQueryOptions
|
||||
{
|
||||
Offset = offset ?? 0,
|
||||
Limit = limit ?? 50
|
||||
};
|
||||
|
||||
var result = await service.QueryAsync(options, ct).ConfigureAwait(false);
|
||||
return HttpResults.Ok(new CanonicalAdvisoryListResponse
|
||||
{
|
||||
Items = result.Items.Select(MapToResponse).ToList(),
|
||||
TotalCount = result.TotalCount,
|
||||
Offset = result.Offset,
|
||||
Limit = result.Limit
|
||||
});
|
||||
})
|
||||
.WithName("QueryCanonical")
|
||||
.WithSummary("Query canonical advisories by CVE, artifact, or merge hash")
|
||||
.Produces<CanonicalAdvisoryListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// POST /api/v1/canonical/ingest/{source} - Ingest raw advisory
|
||||
group.MapPost("/ingest/{source}", async (
|
||||
string source,
|
||||
[FromBody] RawAdvisoryRequest request,
|
||||
ICanonicalAdvisoryService service,
|
||||
HttpContext context,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "Source is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Cve))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "CVE is required" });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.AffectsKey))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "AffectsKey is required" });
|
||||
}
|
||||
|
||||
var rawAdvisory = new RawAdvisory
|
||||
{
|
||||
SourceAdvisoryId = request.SourceAdvisoryId ?? $"{source.ToUpperInvariant()}-{request.Cve}",
|
||||
Cve = request.Cve,
|
||||
AffectsKey = request.AffectsKey,
|
||||
VersionRangeJson = request.VersionRangeJson,
|
||||
Weaknesses = request.Weaknesses ?? [],
|
||||
PatchLineage = request.PatchLineage,
|
||||
Severity = request.Severity,
|
||||
Title = request.Title,
|
||||
Summary = request.Summary,
|
||||
VendorStatus = request.VendorStatus,
|
||||
RawPayloadJson = request.RawPayloadJson,
|
||||
FetchedAt = request.FetchedAt ?? DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var result = await service.IngestAsync(source, rawAdvisory, ct).ConfigureAwait(false);
|
||||
|
||||
var response = new IngestResultResponse
|
||||
{
|
||||
CanonicalId = result.CanonicalId,
|
||||
MergeHash = result.MergeHash,
|
||||
Decision = result.Decision.ToString(),
|
||||
SourceEdgeId = result.SourceEdgeId,
|
||||
SignatureRef = result.SignatureRef,
|
||||
ConflictReason = result.ConflictReason
|
||||
};
|
||||
|
||||
return result.Decision == MergeDecision.Conflict
|
||||
? HttpResults.Conflict(response)
|
||||
: HttpResults.Ok(response);
|
||||
})
|
||||
.WithName("IngestAdvisory")
|
||||
.WithSummary("Ingest raw advisory from source into canonical pipeline")
|
||||
.Produces<IngestResultResponse>(StatusCodes.Status200OK)
|
||||
.Produces<IngestResultResponse>(StatusCodes.Status409Conflict)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
// POST /api/v1/canonical/ingest/{source}/batch - Batch ingest advisories
|
||||
group.MapPost("/ingest/{source}/batch", async (
|
||||
string source,
|
||||
[FromBody] IEnumerable<RawAdvisoryRequest> requests,
|
||||
ICanonicalAdvisoryService service,
|
||||
HttpContext context,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "Source is required" });
|
||||
}
|
||||
|
||||
var rawAdvisories = requests.Select(request => new RawAdvisory
|
||||
{
|
||||
SourceAdvisoryId = request.SourceAdvisoryId ?? $"{source.ToUpperInvariant()}-{request.Cve}",
|
||||
Cve = request.Cve ?? throw new InvalidOperationException("CVE is required"),
|
||||
AffectsKey = request.AffectsKey ?? throw new InvalidOperationException("AffectsKey is required"),
|
||||
VersionRangeJson = request.VersionRangeJson,
|
||||
Weaknesses = request.Weaknesses ?? [],
|
||||
PatchLineage = request.PatchLineage,
|
||||
Severity = request.Severity,
|
||||
Title = request.Title,
|
||||
Summary = request.Summary,
|
||||
VendorStatus = request.VendorStatus,
|
||||
RawPayloadJson = request.RawPayloadJson,
|
||||
FetchedAt = request.FetchedAt ?? DateTimeOffset.UtcNow
|
||||
}).ToList();
|
||||
|
||||
var results = await service.IngestBatchAsync(source, rawAdvisories, ct).ConfigureAwait(false);
|
||||
|
||||
var response = new BatchIngestResultResponse
|
||||
{
|
||||
Results = results.Select(r => new IngestResultResponse
|
||||
{
|
||||
CanonicalId = r.CanonicalId,
|
||||
MergeHash = r.MergeHash,
|
||||
Decision = r.Decision.ToString(),
|
||||
SourceEdgeId = r.SourceEdgeId,
|
||||
SignatureRef = r.SignatureRef,
|
||||
ConflictReason = r.ConflictReason
|
||||
}).ToList(),
|
||||
Summary = new BatchIngestSummary
|
||||
{
|
||||
Total = results.Count,
|
||||
Created = results.Count(r => r.Decision == MergeDecision.Created),
|
||||
Merged = results.Count(r => r.Decision == MergeDecision.Merged),
|
||||
Duplicates = results.Count(r => r.Decision == MergeDecision.Duplicate),
|
||||
Conflicts = results.Count(r => r.Decision == MergeDecision.Conflict)
|
||||
}
|
||||
};
|
||||
|
||||
return HttpResults.Ok(response);
|
||||
})
|
||||
.WithName("IngestAdvisoryBatch")
|
||||
.WithSummary("Batch ingest multiple advisories from source")
|
||||
.Produces<BatchIngestResultResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
// PATCH /api/v1/canonical/{id}/status - Update canonical status
|
||||
group.MapPatch("/{id:guid}/status", async (
|
||||
Guid id,
|
||||
[FromBody] UpdateStatusRequest request,
|
||||
ICanonicalAdvisoryService service,
|
||||
HttpContext context,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (!Enum.TryParse<CanonicalStatus>(request.Status, true, out var status))
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "Invalid status", validValues = Enum.GetNames<CanonicalStatus>() });
|
||||
}
|
||||
|
||||
await service.UpdateStatusAsync(id, status, ct).ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Ok(new { id, status = status.ToString() });
|
||||
})
|
||||
.WithName("UpdateCanonicalStatus")
|
||||
.WithSummary("Update canonical advisory status")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
private static CanonicalAdvisoryResponse MapToResponse(CanonicalAdvisory canonical) => new()
|
||||
{
|
||||
Id = canonical.Id,
|
||||
Cve = canonical.Cve,
|
||||
AffectsKey = canonical.AffectsKey,
|
||||
MergeHash = canonical.MergeHash,
|
||||
Status = canonical.Status.ToString(),
|
||||
Severity = canonical.Severity,
|
||||
EpssScore = canonical.EpssScore,
|
||||
ExploitKnown = canonical.ExploitKnown,
|
||||
Title = canonical.Title,
|
||||
Summary = canonical.Summary,
|
||||
VersionRange = canonical.VersionRange,
|
||||
Weaknesses = canonical.Weaknesses,
|
||||
CreatedAt = canonical.CreatedAt,
|
||||
UpdatedAt = canonical.UpdatedAt,
|
||||
SourceEdges = canonical.SourceEdges.Select(e => new SourceEdgeResponse
|
||||
{
|
||||
Id = e.Id,
|
||||
SourceName = e.SourceName,
|
||||
SourceAdvisoryId = e.SourceAdvisoryId,
|
||||
SourceDocHash = e.SourceDocHash,
|
||||
VendorStatus = e.VendorStatus?.ToString(),
|
||||
PrecedenceRank = e.PrecedenceRank,
|
||||
HasDsseEnvelope = e.DsseEnvelope is not null,
|
||||
FetchedAt = e.FetchedAt
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
#region Response DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Response for a single canonical advisory.
|
||||
/// </summary>
|
||||
public sealed record CanonicalAdvisoryResponse
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string Cve { get; init; }
|
||||
public required string AffectsKey { get; init; }
|
||||
public required string MergeHash { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public decimal? EpssScore { get; init; }
|
||||
public bool ExploitKnown { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public VersionRange? VersionRange { get; init; }
|
||||
public IReadOnlyList<string> Weaknesses { get; init; } = [];
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
public IReadOnlyList<SourceEdgeResponse> SourceEdges { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a source edge.
|
||||
/// </summary>
|
||||
public sealed record SourceEdgeResponse
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string SourceName { get; init; }
|
||||
public required string SourceAdvisoryId { get; init; }
|
||||
public required string SourceDocHash { get; init; }
|
||||
public string? VendorStatus { get; init; }
|
||||
public int PrecedenceRank { get; init; }
|
||||
public bool HasDsseEnvelope { get; init; }
|
||||
public DateTimeOffset FetchedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a list of canonical advisories.
|
||||
/// </summary>
|
||||
public sealed record CanonicalAdvisoryListResponse
|
||||
{
|
||||
public IReadOnlyList<CanonicalAdvisoryResponse> Items { get; init; } = [];
|
||||
public long TotalCount { get; init; }
|
||||
public int Offset { get; init; }
|
||||
public int Limit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for ingest result.
|
||||
/// </summary>
|
||||
public sealed record IngestResultResponse
|
||||
{
|
||||
public Guid CanonicalId { get; init; }
|
||||
public required string MergeHash { get; init; }
|
||||
public required string Decision { get; init; }
|
||||
public Guid? SourceEdgeId { get; init; }
|
||||
public Guid? SignatureRef { get; init; }
|
||||
public string? ConflictReason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for batch ingest.
|
||||
/// </summary>
|
||||
public sealed record BatchIngestResultResponse
|
||||
{
|
||||
public IReadOnlyList<IngestResultResponse> Results { get; init; } = [];
|
||||
public required BatchIngestSummary Summary { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of batch ingest results.
|
||||
/// </summary>
|
||||
public sealed record BatchIngestSummary
|
||||
{
|
||||
public int Total { get; init; }
|
||||
public int Created { get; init; }
|
||||
public int Merged { get; init; }
|
||||
public int Duplicates { get; init; }
|
||||
public int Conflicts { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Request DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Request to ingest a raw advisory.
|
||||
/// </summary>
|
||||
public sealed record RawAdvisoryRequest
|
||||
{
|
||||
public string? SourceAdvisoryId { get; init; }
|
||||
public string? Cve { get; init; }
|
||||
public string? AffectsKey { get; init; }
|
||||
public string? VersionRangeJson { get; init; }
|
||||
public IReadOnlyList<string>? Weaknesses { get; init; }
|
||||
public string? PatchLineage { get; init; }
|
||||
public string? Severity { get; init; }
|
||||
public string? Title { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public VendorStatus? VendorStatus { get; init; }
|
||||
public string? RawPayloadJson { get; init; }
|
||||
public DateTimeOffset? FetchedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to update canonical status.
|
||||
/// </summary>
|
||||
public sealed record UpdateStatusRequest
|
||||
{
|
||||
public required string Status { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -511,6 +511,9 @@ app.UseDeprecationHeaders();
|
||||
|
||||
app.MapConcelierMirrorEndpoints(authorityConfigured, enforceAuthority);
|
||||
|
||||
// Canonical advisory endpoints (Sprint 8200.0012.0003)
|
||||
app.MapCanonicalAdvisoryEndpoints();
|
||||
|
||||
app.MapGet("/.well-known/openapi", ([FromServices] OpenApiDiscoveryDocumentProvider provider, HttpContext context) =>
|
||||
{
|
||||
var (payload, etag) = provider.GetDocument();
|
||||
|
||||
Reference in New Issue
Block a user