save development progress
This commit is contained in:
@@ -114,6 +114,9 @@ public static class ErrorCodes
|
||||
/// <summary>AirGap mode is disabled.</summary>
|
||||
public const string AirGapDisabled = "AIRGAP_DISABLED";
|
||||
|
||||
/// <summary>Federation sync is disabled.</summary>
|
||||
public const string FederationDisabled = "FEDERATION_DISABLED";
|
||||
|
||||
/// <summary>Sealed mode violation.</summary>
|
||||
public const string SealedModeViolation = "SEALED_MODE_VIOLATION";
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// CanonicalAdvisoryEndpointExtensions.cs
|
||||
// Sprint: SPRINT_8200_0012_0003_CONCEL_canonical_advisory_service
|
||||
// Tasks: CANSVC-8200-016 through CANSVC-8200-019
|
||||
// Tasks: CANSVC-8200-016 through CANSVC-8200-019, ISCORE-8200-030
|
||||
// Description: API endpoints for canonical advisory service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Concelier.Core.Canonical;
|
||||
using StellaOps.Concelier.Interest;
|
||||
using StellaOps.Concelier.WebService.Results;
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
|
||||
@@ -29,14 +30,25 @@ internal static class CanonicalAdvisoryEndpointExtensions
|
||||
group.MapGet("/{id:guid}", async (
|
||||
Guid id,
|
||||
ICanonicalAdvisoryService service,
|
||||
IInterestScoringService? scoringService,
|
||||
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));
|
||||
if (canonical is null)
|
||||
{
|
||||
return HttpResults.NotFound(new { error = "Canonical advisory not found", id });
|
||||
}
|
||||
|
||||
// Fetch interest score if scoring service is available
|
||||
Interest.Models.InterestScore? score = null;
|
||||
if (scoringService is not null)
|
||||
{
|
||||
score = await scoringService.GetScoreAsync(id, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return HttpResults.Ok(MapToResponse(canonical, score));
|
||||
})
|
||||
.WithName("GetCanonicalById")
|
||||
.WithSummary("Get canonical advisory by ID")
|
||||
@@ -73,7 +85,7 @@ internal static class CanonicalAdvisoryEndpointExtensions
|
||||
var byCve = await service.GetByCveAsync(cve, ct).ConfigureAwait(false);
|
||||
return HttpResults.Ok(new CanonicalAdvisoryListResponse
|
||||
{
|
||||
Items = byCve.Select(MapToResponse).ToList(),
|
||||
Items = byCve.Select(c => MapToResponse(c)).ToList(),
|
||||
TotalCount = byCve.Count
|
||||
});
|
||||
}
|
||||
@@ -84,7 +96,7 @@ internal static class CanonicalAdvisoryEndpointExtensions
|
||||
var byArtifact = await service.GetByArtifactAsync(artifact, ct).ConfigureAwait(false);
|
||||
return HttpResults.Ok(new CanonicalAdvisoryListResponse
|
||||
{
|
||||
Items = byArtifact.Select(MapToResponse).ToList(),
|
||||
Items = byArtifact.Select(c => MapToResponse(c)).ToList(),
|
||||
TotalCount = byArtifact.Count
|
||||
});
|
||||
}
|
||||
@@ -99,7 +111,7 @@ internal static class CanonicalAdvisoryEndpointExtensions
|
||||
var result = await service.QueryAsync(options, ct).ConfigureAwait(false);
|
||||
return HttpResults.Ok(new CanonicalAdvisoryListResponse
|
||||
{
|
||||
Items = result.Items.Select(MapToResponse).ToList(),
|
||||
Items = result.Items.Select(c => MapToResponse(c)).ToList(),
|
||||
TotalCount = result.TotalCount,
|
||||
Offset = result.Offset,
|
||||
Limit = result.Limit
|
||||
@@ -252,7 +264,9 @@ internal static class CanonicalAdvisoryEndpointExtensions
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
private static CanonicalAdvisoryResponse MapToResponse(CanonicalAdvisory canonical) => new()
|
||||
private static CanonicalAdvisoryResponse MapToResponse(
|
||||
CanonicalAdvisory canonical,
|
||||
Interest.Models.InterestScore? score = null) => new()
|
||||
{
|
||||
Id = canonical.Id,
|
||||
Cve = canonical.Cve,
|
||||
@@ -268,6 +282,13 @@ internal static class CanonicalAdvisoryEndpointExtensions
|
||||
Weaknesses = canonical.Weaknesses,
|
||||
CreatedAt = canonical.CreatedAt,
|
||||
UpdatedAt = canonical.UpdatedAt,
|
||||
InterestScore = score is not null ? new InterestScoreInfo
|
||||
{
|
||||
Score = score.Score,
|
||||
Tier = score.Tier.ToString(),
|
||||
Reasons = score.Reasons,
|
||||
ComputedAt = score.ComputedAt
|
||||
} : null,
|
||||
SourceEdges = canonical.SourceEdges.Select(e => new SourceEdgeResponse
|
||||
{
|
||||
Id = e.Id,
|
||||
@@ -303,9 +324,21 @@ public sealed record CanonicalAdvisoryResponse
|
||||
public IReadOnlyList<string> Weaknesses { get; init; } = [];
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
public InterestScoreInfo? InterestScore { get; init; }
|
||||
public IReadOnlyList<SourceEdgeResponse> SourceEdges { get; init; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interest score information embedded in advisory response.
|
||||
/// </summary>
|
||||
public sealed record InterestScoreInfo
|
||||
{
|
||||
public double Score { get; init; }
|
||||
public required string Tier { get; init; }
|
||||
public IReadOnlyList<string> Reasons { get; init; } = [];
|
||||
public DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a source edge.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Concelier.Federation.Export;
|
||||
using StellaOps.Concelier.Federation.Models;
|
||||
using StellaOps.Concelier.WebService.Options;
|
||||
using StellaOps.Concelier.WebService.Results;
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint extensions for Federation functionality.
|
||||
/// Per SPRINT_8200_0014_0002_CONCEL_delta_bundle_export.
|
||||
/// </summary>
|
||||
internal static class FederationEndpointExtensions
|
||||
{
|
||||
public static void MapConcelierFederationEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/federation")
|
||||
.WithTags("Federation");
|
||||
|
||||
// GET /api/v1/federation/export - Export delta bundle
|
||||
group.MapGet("/export", async (
|
||||
HttpContext context,
|
||||
IBundleExportService exportService,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery(Name = "since_cursor")] string? sinceCursor = null,
|
||||
[FromQuery] bool sign = true,
|
||||
[FromQuery(Name = "max_items")] int maxItems = 10000,
|
||||
[FromQuery(Name = "compress_level")] int compressLevel = 3) =>
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
if (!options.Federation.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.FederationDisabled(context);
|
||||
}
|
||||
|
||||
// Validate parameters
|
||||
if (maxItems < 1 || maxItems > 100_000)
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "max_items must be between 1 and 100000" });
|
||||
}
|
||||
|
||||
if (compressLevel < 1 || compressLevel > 19)
|
||||
{
|
||||
return HttpResults.BadRequest(new { error = "compress_level must be between 1 and 19" });
|
||||
}
|
||||
|
||||
var exportOptions = new BundleExportOptions
|
||||
{
|
||||
Sign = sign,
|
||||
MaxItems = maxItems,
|
||||
CompressionLevel = compressLevel
|
||||
};
|
||||
|
||||
// Set response headers for streaming
|
||||
context.Response.ContentType = "application/zstd";
|
||||
context.Response.Headers.ContentDisposition =
|
||||
$"attachment; filename=\"feedser-bundle-{DateTime.UtcNow:yyyyMMdd-HHmmss}.zst\"";
|
||||
|
||||
// Export directly to response stream
|
||||
var result = await exportService.ExportToStreamAsync(
|
||||
context.Response.Body,
|
||||
sinceCursor,
|
||||
exportOptions,
|
||||
cancellationToken);
|
||||
|
||||
// Add metadata headers
|
||||
context.Response.Headers.Append("X-Bundle-Hash", result.BundleHash);
|
||||
context.Response.Headers.Append("X-Export-Cursor", result.ExportCursor);
|
||||
context.Response.Headers.Append("X-Items-Count", result.Counts.Total.ToString());
|
||||
|
||||
return HttpResults.Empty;
|
||||
})
|
||||
.WithName("ExportFederationBundle")
|
||||
.WithSummary("Export delta bundle for federation sync")
|
||||
.Produces(200, contentType: "application/zstd")
|
||||
.ProducesProblem(400)
|
||||
.ProducesProblem(503);
|
||||
|
||||
// GET /api/v1/federation/export/preview - Preview export statistics
|
||||
group.MapGet("/export/preview", async (
|
||||
HttpContext context,
|
||||
IBundleExportService exportService,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor,
|
||||
CancellationToken cancellationToken,
|
||||
[FromQuery(Name = "since_cursor")] string? sinceCursor = null) =>
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
if (!options.Federation.Enabled)
|
||||
{
|
||||
return ConcelierProblemResultFactory.FederationDisabled(context);
|
||||
}
|
||||
|
||||
var preview = await exportService.PreviewAsync(sinceCursor, cancellationToken);
|
||||
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
since_cursor = sinceCursor,
|
||||
estimated_canonicals = preview.EstimatedCanonicals,
|
||||
estimated_edges = preview.EstimatedEdges,
|
||||
estimated_deletions = preview.EstimatedDeletions,
|
||||
estimated_size_bytes = preview.EstimatedSizeBytes,
|
||||
estimated_size_mb = Math.Round(preview.EstimatedSizeBytes / 1024.0 / 1024.0, 2)
|
||||
});
|
||||
})
|
||||
.WithName("PreviewFederationExport")
|
||||
.WithSummary("Preview export statistics without creating bundle")
|
||||
.Produces<object>(200)
|
||||
.ProducesProblem(503);
|
||||
|
||||
// GET /api/v1/federation/status - Federation status
|
||||
group.MapGet("/status", (
|
||||
HttpContext context,
|
||||
IOptionsMonitor<ConcelierOptions> optionsMonitor) =>
|
||||
{
|
||||
var options = optionsMonitor.CurrentValue;
|
||||
|
||||
return HttpResults.Ok(new
|
||||
{
|
||||
enabled = options.Federation.Enabled,
|
||||
site_id = options.Federation.SiteId,
|
||||
default_compression_level = options.Federation.DefaultCompressionLevel,
|
||||
default_max_items = options.Federation.DefaultMaxItems
|
||||
});
|
||||
})
|
||||
.WithName("GetFederationStatus")
|
||||
.WithSummary("Get federation configuration status")
|
||||
.Produces<object>(200);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// InterestScoreEndpointExtensions.cs
|
||||
// Sprint: SPRINT_8200_0013_0002_CONCEL_interest_scoring
|
||||
// Tasks: ISCORE-8200-029 through ISCORE-8200-031
|
||||
// Description: API endpoints for interest scoring service
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Concelier.Interest;
|
||||
using StellaOps.Concelier.Interest.Models;
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint extensions for interest score operations.
|
||||
/// </summary>
|
||||
internal static class InterestScoreEndpointExtensions
|
||||
{
|
||||
private const string ScoreReadPolicy = "Concelier.Interest.Read";
|
||||
private const string ScoreAdminPolicy = "Concelier.Interest.Admin";
|
||||
|
||||
public static void MapInterestScoreEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1")
|
||||
.WithTags("Interest Scores");
|
||||
|
||||
// GET /api/v1/canonical/{id}/score - Get interest score for a canonical advisory
|
||||
group.MapGet("/canonical/{id:guid}/score", async (
|
||||
Guid id,
|
||||
IInterestScoringService scoringService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var score = await scoringService.GetScoreAsync(id, ct).ConfigureAwait(false);
|
||||
|
||||
return score is null
|
||||
? HttpResults.NotFound(new { error = "Interest score not found", canonicalId = id })
|
||||
: HttpResults.Ok(MapToResponse(score));
|
||||
})
|
||||
.WithName("GetInterestScore")
|
||||
.WithSummary("Get interest score for a canonical advisory")
|
||||
.Produces<InterestScoreResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// GET /api/v1/scores - Query interest scores
|
||||
group.MapGet("/scores", async (
|
||||
[FromQuery] double? minScore,
|
||||
[FromQuery] double? maxScore,
|
||||
[FromQuery] int? offset,
|
||||
[FromQuery] int? limit,
|
||||
IInterestScoreRepository repository,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var scores = await repository.GetAllAsync(offset ?? 0, limit ?? 50, ct).ConfigureAwait(false);
|
||||
|
||||
// Filter by score range if specified
|
||||
var filtered = scores.AsEnumerable();
|
||||
if (minScore.HasValue)
|
||||
{
|
||||
filtered = filtered.Where(s => s.Score >= minScore.Value);
|
||||
}
|
||||
if (maxScore.HasValue)
|
||||
{
|
||||
filtered = filtered.Where(s => s.Score <= maxScore.Value);
|
||||
}
|
||||
|
||||
var items = filtered.Select(MapToResponse).ToList();
|
||||
|
||||
return HttpResults.Ok(new InterestScoreListResponse
|
||||
{
|
||||
Items = items,
|
||||
TotalCount = items.Count,
|
||||
Offset = offset ?? 0,
|
||||
Limit = limit ?? 50
|
||||
});
|
||||
})
|
||||
.WithName("QueryInterestScores")
|
||||
.WithSummary("Query interest scores with optional filtering")
|
||||
.Produces<InterestScoreListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// GET /api/v1/scores/distribution - Get score distribution statistics
|
||||
group.MapGet("/scores/distribution", async (
|
||||
IInterestScoreRepository repository,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var distribution = await repository.GetScoreDistributionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Ok(new ScoreDistributionResponse
|
||||
{
|
||||
HighCount = distribution.HighCount,
|
||||
MediumCount = distribution.MediumCount,
|
||||
LowCount = distribution.LowCount,
|
||||
NoneCount = distribution.NoneCount,
|
||||
TotalCount = distribution.TotalCount,
|
||||
AverageScore = distribution.AverageScore,
|
||||
MedianScore = distribution.MedianScore
|
||||
});
|
||||
})
|
||||
.WithName("GetScoreDistribution")
|
||||
.WithSummary("Get score distribution statistics")
|
||||
.Produces<ScoreDistributionResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// POST /api/v1/canonical/{id}/score/compute - Compute score for a canonical
|
||||
group.MapPost("/canonical/{id:guid}/score/compute", async (
|
||||
Guid id,
|
||||
IInterestScoringService scoringService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var score = await scoringService.ComputeScoreAsync(id, ct).ConfigureAwait(false);
|
||||
await scoringService.UpdateScoreAsync(score, ct).ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Ok(MapToResponse(score));
|
||||
})
|
||||
.WithName("ComputeInterestScore")
|
||||
.WithSummary("Compute and update interest score for a canonical advisory")
|
||||
.Produces<InterestScoreResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// POST /api/v1/scores/recalculate - Admin endpoint to trigger full recalculation
|
||||
group.MapPost("/scores/recalculate", async (
|
||||
[FromBody] RecalculateRequest? request,
|
||||
IInterestScoringService scoringService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
int updated;
|
||||
if (request?.CanonicalIds?.Count > 0)
|
||||
{
|
||||
// Batch recalculation for specific IDs
|
||||
updated = await scoringService.BatchUpdateAsync(request.CanonicalIds, ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Full recalculation
|
||||
updated = await scoringService.RecalculateAllAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return HttpResults.Accepted((string?)null, new RecalculateResponse
|
||||
{
|
||||
Updated = updated,
|
||||
Mode = request?.CanonicalIds?.Count > 0 ? "batch" : "full",
|
||||
StartedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
})
|
||||
.WithName("RecalculateScores")
|
||||
.WithSummary("Trigger interest score recalculation (full or batch)")
|
||||
.Produces<RecalculateResponse>(StatusCodes.Status202Accepted);
|
||||
|
||||
// POST /api/v1/scores/degrade - Admin endpoint to run stub degradation
|
||||
group.MapPost("/scores/degrade", async (
|
||||
[FromBody] DegradeRequest? request,
|
||||
IInterestScoringService scoringService,
|
||||
Microsoft.Extensions.Options.IOptions<InterestScoreOptions> options,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var threshold = request?.Threshold ?? options.Value.DegradationPolicy.DegradationThreshold;
|
||||
|
||||
var degraded = await scoringService.DegradeToStubsAsync(threshold, ct).ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Ok(new DegradeResponse
|
||||
{
|
||||
Degraded = degraded,
|
||||
Threshold = threshold,
|
||||
ExecutedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
})
|
||||
.WithName("DegradeToStubs")
|
||||
.WithSummary("Degrade low-interest advisories to stubs")
|
||||
.Produces<DegradeResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// POST /api/v1/scores/restore - Admin endpoint to restore stubs
|
||||
group.MapPost("/scores/restore", async (
|
||||
[FromBody] RestoreRequest? request,
|
||||
IInterestScoringService scoringService,
|
||||
Microsoft.Extensions.Options.IOptions<InterestScoreOptions> options,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var threshold = request?.Threshold ?? options.Value.DegradationPolicy.RestorationThreshold;
|
||||
|
||||
var restored = await scoringService.RestoreFromStubsAsync(threshold, ct).ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Ok(new RestoreResponse
|
||||
{
|
||||
Restored = restored,
|
||||
Threshold = threshold,
|
||||
ExecutedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
})
|
||||
.WithName("RestoreFromStubs")
|
||||
.WithSummary("Restore stubs with increased interest scores")
|
||||
.Produces<RestoreResponse>(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static InterestScoreResponse MapToResponse(InterestScore score) => new()
|
||||
{
|
||||
CanonicalId = score.CanonicalId,
|
||||
Score = score.Score,
|
||||
Tier = score.Tier.ToString(),
|
||||
Reasons = score.Reasons,
|
||||
LastSeenInBuild = score.LastSeenInBuild,
|
||||
ComputedAt = score.ComputedAt
|
||||
};
|
||||
}
|
||||
|
||||
#region Response DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Response for an interest score.
|
||||
/// </summary>
|
||||
public sealed record InterestScoreResponse
|
||||
{
|
||||
public Guid CanonicalId { get; init; }
|
||||
public double Score { get; init; }
|
||||
public required string Tier { get; init; }
|
||||
public IReadOnlyList<string> Reasons { get; init; } = [];
|
||||
public Guid? LastSeenInBuild { get; init; }
|
||||
public DateTimeOffset ComputedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for a list of interest scores.
|
||||
/// </summary>
|
||||
public sealed record InterestScoreListResponse
|
||||
{
|
||||
public IReadOnlyList<InterestScoreResponse> Items { get; init; } = [];
|
||||
public long TotalCount { get; init; }
|
||||
public int Offset { get; init; }
|
||||
public int Limit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for score distribution.
|
||||
/// </summary>
|
||||
public sealed record ScoreDistributionResponse
|
||||
{
|
||||
public long HighCount { get; init; }
|
||||
public long MediumCount { get; init; }
|
||||
public long LowCount { get; init; }
|
||||
public long NoneCount { get; init; }
|
||||
public long TotalCount { get; init; }
|
||||
public double AverageScore { get; init; }
|
||||
public double MedianScore { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for recalculation operation.
|
||||
/// </summary>
|
||||
public sealed record RecalculateResponse
|
||||
{
|
||||
public int Updated { get; init; }
|
||||
public required string Mode { get; init; }
|
||||
public DateTimeOffset StartedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for degradation operation.
|
||||
/// </summary>
|
||||
public sealed record DegradeResponse
|
||||
{
|
||||
public int Degraded { get; init; }
|
||||
public double Threshold { get; init; }
|
||||
public DateTimeOffset ExecutedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response for restoration operation.
|
||||
/// </summary>
|
||||
public sealed record RestoreResponse
|
||||
{
|
||||
public int Restored { get; init; }
|
||||
public double Threshold { get; init; }
|
||||
public DateTimeOffset ExecutedAt { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Request DTOs
|
||||
|
||||
/// <summary>
|
||||
/// Request for recalculation operation.
|
||||
/// </summary>
|
||||
public sealed record RecalculateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional list of canonical IDs to recalculate.
|
||||
/// If empty or null, full recalculation is performed.
|
||||
/// </summary>
|
||||
public IReadOnlyList<Guid>? CanonicalIds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for degradation operation.
|
||||
/// </summary>
|
||||
public sealed record DegradeRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional threshold override. If not specified, uses configured default.
|
||||
/// </summary>
|
||||
public double? Threshold { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for restoration operation.
|
||||
/// </summary>
|
||||
public sealed record RestoreRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional threshold override. If not specified, uses configured default.
|
||||
/// </summary>
|
||||
public double? Threshold { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -0,0 +1,350 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// SbomEndpointExtensions.cs
|
||||
// Sprint: SPRINT_8200_0013_0003_SCAN_sbom_intersection_scoring
|
||||
// Tasks: SBOM-8200-022 through SBOM-8200-024
|
||||
// Description: API endpoints for SBOM registration and learning
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Concelier.SbomIntegration;
|
||||
using StellaOps.Concelier.SbomIntegration.Models;
|
||||
using HttpResults = Microsoft.AspNetCore.Http.Results;
|
||||
|
||||
namespace StellaOps.Concelier.WebService.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint extensions for SBOM operations.
|
||||
/// </summary>
|
||||
internal static class SbomEndpointExtensions
|
||||
{
|
||||
public static void MapSbomEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1")
|
||||
.WithTags("SBOM Learning");
|
||||
|
||||
// POST /api/v1/learn/sbom - Register and learn from an SBOM
|
||||
group.MapPost("/learn/sbom", async (
|
||||
[FromBody] LearnSbomRequest request,
|
||||
ISbomRegistryService registryService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var input = new SbomRegistrationInput
|
||||
{
|
||||
Digest = request.SbomDigest,
|
||||
Format = ParseSbomFormat(request.Format),
|
||||
SpecVersion = request.SpecVersion ?? "1.6",
|
||||
PrimaryName = request.PrimaryName,
|
||||
PrimaryVersion = request.PrimaryVersion,
|
||||
Purls = request.Purls,
|
||||
Source = request.Source ?? "api",
|
||||
TenantId = request.TenantId,
|
||||
ReachabilityMap = request.ReachabilityMap,
|
||||
DeploymentMap = request.DeploymentMap
|
||||
};
|
||||
|
||||
var result = await registryService.LearnSbomAsync(input, ct).ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Ok(new SbomLearnResponse
|
||||
{
|
||||
SbomDigest = result.Registration.Digest,
|
||||
SbomId = result.Registration.Id,
|
||||
ComponentsProcessed = result.Registration.ComponentCount,
|
||||
AdvisoriesMatched = result.Matches.Count,
|
||||
ScoresUpdated = result.ScoresUpdated,
|
||||
ProcessingTimeMs = result.ProcessingTimeMs
|
||||
});
|
||||
})
|
||||
.WithName("LearnSbom")
|
||||
.WithSummary("Register SBOM and update interest scores for affected advisories")
|
||||
.Produces<SbomLearnResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
// GET /api/v1/sboms/{digest}/affected - Get advisories affecting an SBOM
|
||||
group.MapGet("/sboms/{digest}/affected", async (
|
||||
string digest,
|
||||
ISbomRegistryService registryService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var registration = await registryService.GetByDigestAsync(digest, ct).ConfigureAwait(false);
|
||||
if (registration is null)
|
||||
{
|
||||
return HttpResults.NotFound(new { error = "SBOM not found", digest });
|
||||
}
|
||||
|
||||
var matches = await registryService.GetMatchesAsync(digest, ct).ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Ok(new SbomAffectedResponse
|
||||
{
|
||||
SbomDigest = digest,
|
||||
SbomId = registration.Id,
|
||||
PrimaryName = registration.PrimaryName,
|
||||
PrimaryVersion = registration.PrimaryVersion,
|
||||
ComponentCount = registration.ComponentCount,
|
||||
AffectedCount = matches.Count,
|
||||
Matches = matches.Select(m => new SbomMatchInfo
|
||||
{
|
||||
CanonicalId = m.CanonicalId,
|
||||
Purl = m.Purl,
|
||||
IsReachable = m.IsReachable,
|
||||
IsDeployed = m.IsDeployed,
|
||||
Confidence = m.Confidence,
|
||||
Method = m.Method.ToString(),
|
||||
MatchedAt = m.MatchedAt
|
||||
}).ToList()
|
||||
});
|
||||
})
|
||||
.WithName("GetSbomAffected")
|
||||
.WithSummary("Get advisories affecting an SBOM")
|
||||
.Produces<SbomAffectedResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// GET /api/v1/sboms - List registered SBOMs
|
||||
group.MapGet("/sboms", async (
|
||||
[FromQuery] int? offset,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] string? tenantId,
|
||||
ISbomRegistryService registryService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var registrations = await registryService.ListAsync(
|
||||
offset ?? 0,
|
||||
limit ?? 50,
|
||||
tenantId,
|
||||
ct).ConfigureAwait(false);
|
||||
|
||||
var count = await registryService.CountAsync(tenantId, ct).ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Ok(new SbomListResponse
|
||||
{
|
||||
Items = registrations.Select(r => new SbomSummary
|
||||
{
|
||||
Id = r.Id,
|
||||
Digest = r.Digest,
|
||||
Format = r.Format.ToString(),
|
||||
PrimaryName = r.PrimaryName,
|
||||
PrimaryVersion = r.PrimaryVersion,
|
||||
ComponentCount = r.ComponentCount,
|
||||
AffectedCount = r.AffectedCount,
|
||||
RegisteredAt = r.RegisteredAt,
|
||||
LastMatchedAt = r.LastMatchedAt
|
||||
}).ToList(),
|
||||
TotalCount = count,
|
||||
Offset = offset ?? 0,
|
||||
Limit = limit ?? 50
|
||||
});
|
||||
})
|
||||
.WithName("ListSboms")
|
||||
.WithSummary("List registered SBOMs with pagination")
|
||||
.Produces<SbomListResponse>(StatusCodes.Status200OK);
|
||||
|
||||
// GET /api/v1/sboms/{digest} - Get SBOM registration details
|
||||
group.MapGet("/sboms/{digest}", async (
|
||||
string digest,
|
||||
ISbomRegistryService registryService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var registration = await registryService.GetByDigestAsync(digest, ct).ConfigureAwait(false);
|
||||
|
||||
if (registration is null)
|
||||
{
|
||||
return HttpResults.NotFound(new { error = "SBOM not found", digest });
|
||||
}
|
||||
|
||||
return HttpResults.Ok(new SbomDetailResponse
|
||||
{
|
||||
Id = registration.Id,
|
||||
Digest = registration.Digest,
|
||||
Format = registration.Format.ToString(),
|
||||
SpecVersion = registration.SpecVersion,
|
||||
PrimaryName = registration.PrimaryName,
|
||||
PrimaryVersion = registration.PrimaryVersion,
|
||||
ComponentCount = registration.ComponentCount,
|
||||
AffectedCount = registration.AffectedCount,
|
||||
Source = registration.Source,
|
||||
TenantId = registration.TenantId,
|
||||
RegisteredAt = registration.RegisteredAt,
|
||||
LastMatchedAt = registration.LastMatchedAt
|
||||
});
|
||||
})
|
||||
.WithName("GetSbom")
|
||||
.WithSummary("Get SBOM registration details")
|
||||
.Produces<SbomDetailResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// DELETE /api/v1/sboms/{digest} - Unregister an SBOM
|
||||
group.MapDelete("/sboms/{digest}", async (
|
||||
string digest,
|
||||
ISbomRegistryService registryService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
await registryService.UnregisterAsync(digest, ct).ConfigureAwait(false);
|
||||
return HttpResults.NoContent();
|
||||
})
|
||||
.WithName("UnregisterSbom")
|
||||
.WithSummary("Unregister an SBOM")
|
||||
.Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
// POST /api/v1/sboms/{digest}/rematch - Rematch SBOM against current advisories
|
||||
group.MapPost("/sboms/{digest}/rematch", async (
|
||||
string digest,
|
||||
ISbomRegistryService registryService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await registryService.RematchSbomAsync(digest, ct).ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Ok(new SbomRematchResponse
|
||||
{
|
||||
SbomDigest = digest,
|
||||
PreviousAffectedCount = result.Registration.AffectedCount,
|
||||
NewAffectedCount = result.Matches.Count,
|
||||
ProcessingTimeMs = result.ProcessingTimeMs
|
||||
});
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
|
||||
{
|
||||
return HttpResults.NotFound(new { error = ex.Message });
|
||||
}
|
||||
})
|
||||
.WithName("RematchSbom")
|
||||
.WithSummary("Re-match SBOM against current advisory database")
|
||||
.Produces<SbomRematchResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
// GET /api/v1/sboms/stats - Get SBOM registry statistics
|
||||
group.MapGet("/sboms/stats", async (
|
||||
[FromQuery] string? tenantId,
|
||||
ISbomRegistryService registryService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var stats = await registryService.GetStatsAsync(tenantId, ct).ConfigureAwait(false);
|
||||
|
||||
return HttpResults.Ok(new SbomStatsResponse
|
||||
{
|
||||
TotalSboms = stats.TotalSboms,
|
||||
TotalPurls = stats.TotalPurls,
|
||||
TotalMatches = stats.TotalMatches,
|
||||
AffectedSboms = stats.AffectedSboms,
|
||||
AverageMatchesPerSbom = stats.AverageMatchesPerSbom
|
||||
});
|
||||
})
|
||||
.WithName("GetSbomStats")
|
||||
.WithSummary("Get SBOM registry statistics")
|
||||
.Produces<SbomStatsResponse>(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
private static SbomFormat ParseSbomFormat(string? format)
|
||||
{
|
||||
return format?.ToLowerInvariant() switch
|
||||
{
|
||||
"cyclonedx" => SbomFormat.CycloneDX,
|
||||
"spdx" => SbomFormat.SPDX,
|
||||
_ => SbomFormat.CycloneDX
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#region Request/Response DTOs
|
||||
|
||||
public sealed record LearnSbomRequest
|
||||
{
|
||||
public required string SbomDigest { get; init; }
|
||||
public string? Format { get; init; }
|
||||
public string? SpecVersion { get; init; }
|
||||
public string? PrimaryName { get; init; }
|
||||
public string? PrimaryVersion { get; init; }
|
||||
public required IReadOnlyList<string> Purls { get; init; }
|
||||
public string? Source { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public IReadOnlyDictionary<string, bool>? ReachabilityMap { get; init; }
|
||||
public IReadOnlyDictionary<string, bool>? DeploymentMap { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SbomLearnResponse
|
||||
{
|
||||
public required string SbomDigest { get; init; }
|
||||
public Guid SbomId { get; init; }
|
||||
public int ComponentsProcessed { get; init; }
|
||||
public int AdvisoriesMatched { get; init; }
|
||||
public int ScoresUpdated { get; init; }
|
||||
public double ProcessingTimeMs { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SbomAffectedResponse
|
||||
{
|
||||
public required string SbomDigest { get; init; }
|
||||
public Guid SbomId { get; init; }
|
||||
public string? PrimaryName { get; init; }
|
||||
public string? PrimaryVersion { get; init; }
|
||||
public int ComponentCount { get; init; }
|
||||
public int AffectedCount { get; init; }
|
||||
public required IReadOnlyList<SbomMatchInfo> Matches { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SbomMatchInfo
|
||||
{
|
||||
public Guid CanonicalId { get; init; }
|
||||
public required string Purl { get; init; }
|
||||
public bool IsReachable { get; init; }
|
||||
public bool IsDeployed { get; init; }
|
||||
public double Confidence { get; init; }
|
||||
public required string Method { get; init; }
|
||||
public DateTimeOffset MatchedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SbomListResponse
|
||||
{
|
||||
public required IReadOnlyList<SbomSummary> Items { get; init; }
|
||||
public long TotalCount { get; init; }
|
||||
public int Offset { get; init; }
|
||||
public int Limit { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SbomSummary
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required string Format { get; init; }
|
||||
public string? PrimaryName { get; init; }
|
||||
public string? PrimaryVersion { get; init; }
|
||||
public int ComponentCount { get; init; }
|
||||
public int AffectedCount { get; init; }
|
||||
public DateTimeOffset RegisteredAt { get; init; }
|
||||
public DateTimeOffset? LastMatchedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SbomDetailResponse
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public required string Digest { get; init; }
|
||||
public required string Format { get; init; }
|
||||
public required string SpecVersion { get; init; }
|
||||
public string? PrimaryName { get; init; }
|
||||
public string? PrimaryVersion { get; init; }
|
||||
public int ComponentCount { get; init; }
|
||||
public int AffectedCount { get; init; }
|
||||
public required string Source { get; init; }
|
||||
public string? TenantId { get; init; }
|
||||
public DateTimeOffset RegisteredAt { get; init; }
|
||||
public DateTimeOffset? LastMatchedAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SbomRematchResponse
|
||||
{
|
||||
public required string SbomDigest { get; init; }
|
||||
public int PreviousAffectedCount { get; init; }
|
||||
public int NewAffectedCount { get; init; }
|
||||
public double ProcessingTimeMs { get; init; }
|
||||
}
|
||||
|
||||
public sealed record SbomStatsResponse
|
||||
{
|
||||
public long TotalSboms { get; init; }
|
||||
public long TotalPurls { get; init; }
|
||||
public long TotalMatches { get; init; }
|
||||
public long AffectedSboms { get; init; }
|
||||
public double AverageMatchesPerSbom { get; init; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -38,6 +38,12 @@ public sealed class ConcelierOptions
|
||||
/// </summary>
|
||||
public AirGapOptions AirGap { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Federation sync configuration.
|
||||
/// Per SPRINT_8200_0014_0002_CONCEL_delta_bundle_export.
|
||||
/// </summary>
|
||||
public FederationOptions Federation { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Stella Router integration configuration (disabled by default).
|
||||
/// When enabled, ASP.NET endpoints are automatically registered with the Router.
|
||||
@@ -266,4 +272,35 @@ public sealed class ConcelierOptions
|
||||
[JsonIgnore]
|
||||
public string RootAbsolute { get; internal set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Federation sync options for multi-site deployment.
|
||||
/// </summary>
|
||||
public sealed class FederationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable federation endpoints.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Site identifier for this instance.
|
||||
/// </summary>
|
||||
public string SiteId { get; set; } = "default";
|
||||
|
||||
/// <summary>
|
||||
/// Default ZST compression level (1-19).
|
||||
/// </summary>
|
||||
public int DefaultCompressionLevel { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Default maximum items per export bundle.
|
||||
/// </summary>
|
||||
public int DefaultMaxItems { get; set; } = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Require bundle signatures.
|
||||
/// </summary>
|
||||
public bool RequireSignature { get; set; } = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +305,21 @@ public static class ConcelierProblemResultFactory
|
||||
"AirGap mode is not enabled on this instance.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 503 Service Unavailable response for Federation disabled.
|
||||
/// Per SPRINT_8200_0014_0002_CONCEL_delta_bundle_export.
|
||||
/// </summary>
|
||||
public static IResult FederationDisabled(HttpContext context)
|
||||
{
|
||||
return Problem(
|
||||
context,
|
||||
"https://stellaops.org/problems/federation-disabled",
|
||||
"Federation disabled",
|
||||
StatusCodes.Status503ServiceUnavailable,
|
||||
ErrorCodes.FederationDisabled,
|
||||
"Federation sync is not enabled on this instance.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a 403 Forbidden response for sealed mode violation.
|
||||
/// </summary>
|
||||
|
||||
@@ -23,7 +23,10 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Core/StellaOps.Concelier.Core.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Interest/StellaOps.Concelier.Interest.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Concelier.SbomIntegration/StellaOps.Concelier.SbomIntegration.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Storage.Postgres/StellaOps.Concelier.Storage.Postgres.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Federation/StellaOps.Concelier.Federation.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Models/StellaOps.Concelier.Models.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Connector.Common/StellaOps.Concelier.Connector.Common.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Concelier.Merge/StellaOps.Concelier.Merge.csproj" />
|
||||
|
||||
Reference in New Issue
Block a user