feat: Add CVSS receipt management endpoints and related functionality
- Introduced new API endpoints for creating, retrieving, amending, and listing CVSS receipts. - Updated IPolicyEngineClient interface to include methods for CVSS receipt operations. - Implemented PolicyEngineClient to handle CVSS receipt requests. - Enhanced Program.cs to map new CVSS receipt routes with appropriate authorization. - Added necessary models and contracts for CVSS receipt requests and responses. - Integrated Postgres document store for managing CVSS receipts and related data. - Updated database schema with new migrations for source documents and payload storage. - Refactored existing components to support new CVSS functionality.
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using StellaOps.Auth.Abstractions;
|
||||
using StellaOps.Attestor.Envelope;
|
||||
using StellaOps.Policy.Engine.Services;
|
||||
using StellaOps.Policy.Scoring;
|
||||
using StellaOps.Policy.Scoring.Engine;
|
||||
using StellaOps.Policy.Scoring.Receipts;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal API surface for CVSS v4.0 score receipts (create, read, amend, history).
|
||||
/// </summary>
|
||||
internal static class CvssReceiptEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapCvssReceipts(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/cvss")
|
||||
.RequireAuthorization()
|
||||
.WithTags("CVSS Receipts");
|
||||
|
||||
group.MapPost("/receipts", CreateReceipt)
|
||||
.WithName("CreateCvssReceipt")
|
||||
.WithSummary("Create a CVSS v4.0 receipt with deterministic hashing and optional DSSE attestation.")
|
||||
.Produces<CvssScoreReceipt>(StatusCodes.Status201Created)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status401Unauthorized);
|
||||
|
||||
group.MapGet("/receipts/{receiptId}", GetReceipt)
|
||||
.WithName("GetCvssReceipt")
|
||||
.WithSummary("Retrieve a CVSS v4.0 receipt by ID.")
|
||||
.Produces<CvssScoreReceipt>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPut("/receipts/{receiptId}/amend", AmendReceipt)
|
||||
.WithName("AmendCvssReceipt")
|
||||
.WithSummary("Append an amendment entry to a CVSS receipt history and optionally re-sign.")
|
||||
.Produces<CvssScoreReceipt>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/receipts/{receiptId}/history", GetReceiptHistory)
|
||||
.WithName("GetCvssReceiptHistory")
|
||||
.WithSummary("Return the ordered amendment history for a CVSS receipt.")
|
||||
.Produces<IReadOnlyList<ReceiptHistoryEntry>>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemHttpResult>(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/policies", ListPolicies)
|
||||
.WithName("ListCvssPolicies")
|
||||
.WithSummary("List available CVSS policies configured on this host.")
|
||||
.Produces<IReadOnlyList<CvssPolicy>>(StatusCodes.Status200OK);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<IResult> CreateReceipt(
|
||||
HttpContext context,
|
||||
[FromBody] CreateCvssReceiptRequest request,
|
||||
IReceiptBuilder receiptBuilder,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRun);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
if (request.Policy is null || string.IsNullOrWhiteSpace(request.Policy.Hash))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Policy hash required",
|
||||
Detail = "CvssPolicy with a deterministic hash must be supplied.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenantId(context);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Tenant required",
|
||||
Detail = "Specify tenant via X-Tenant-Id header or tenant_id claim.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var actor = ResolveActorId(context) ?? request.CreatedBy ?? "system";
|
||||
var createdAt = request.CreatedAt ?? DateTimeOffset.UtcNow;
|
||||
|
||||
var createRequest = new CreateReceiptRequest
|
||||
{
|
||||
TenantId = tenantId,
|
||||
VulnerabilityId = request.VulnerabilityId,
|
||||
CreatedBy = actor,
|
||||
CreatedAt = createdAt,
|
||||
Policy = request.Policy,
|
||||
BaseMetrics = request.BaseMetrics,
|
||||
ThreatMetrics = request.ThreatMetrics,
|
||||
EnvironmentalMetrics = request.EnvironmentalMetrics ?? request.Policy.DefaultEnvironmentalMetrics,
|
||||
SupplementalMetrics = request.SupplementalMetrics,
|
||||
Evidence = request.Evidence?.ToImmutableList() ?? ImmutableList<CvssEvidenceItem>.Empty,
|
||||
SigningKey = request.SigningKey
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var receipt = await receiptBuilder.CreateAsync(createRequest, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Created($"/api/cvss/receipts/{receipt.ReceiptId}", receipt);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or ArgumentException)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Failed to create CVSS receipt",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetReceipt(
|
||||
HttpContext context,
|
||||
[FromRoute] string receiptId,
|
||||
IReceiptRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.FindingsRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenantId(context);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Tenant required",
|
||||
Detail = "Specify tenant via X-Tenant-Id header or tenant_id claim.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var receipt = await repository.GetAsync(tenantId, receiptId, cancellationToken).ConfigureAwait(false);
|
||||
if (receipt is null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Receipt not found",
|
||||
Detail = $"CVSS receipt '{receiptId}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
return Results.Ok(receipt);
|
||||
}
|
||||
|
||||
private static async Task<IResult> AmendReceipt(
|
||||
HttpContext context,
|
||||
[FromRoute] string receiptId,
|
||||
[FromBody] AmendCvssReceiptRequest request,
|
||||
IReceiptHistoryService historyService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.PolicyRun);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Request body required.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenantId(context);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Tenant required",
|
||||
Detail = "Specify tenant via X-Tenant-Id header or tenant_id claim.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var actor = ResolveActorId(context) ?? request.Actor ?? "system";
|
||||
|
||||
var amend = new AmendReceiptRequest
|
||||
{
|
||||
ReceiptId = receiptId,
|
||||
TenantId = tenantId,
|
||||
Actor = actor,
|
||||
Field = request.Field,
|
||||
PreviousValue = request.PreviousValue,
|
||||
NewValue = request.NewValue,
|
||||
Reason = request.Reason,
|
||||
ReferenceUri = request.ReferenceUri,
|
||||
SigningKey = request.SigningKey
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var amended = await historyService.AmendAsync(amend, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(amended);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Receipt not found",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException)
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Failed to amend receipt",
|
||||
Detail = ex.Message,
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetReceiptHistory(
|
||||
HttpContext context,
|
||||
[FromRoute] string receiptId,
|
||||
IReceiptRepository repository,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, StellaOpsScopes.FindingsRead);
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
var tenantId = ResolveTenantId(context);
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = "Tenant required",
|
||||
Detail = "Specify tenant via X-Tenant-Id header or tenant_id claim.",
|
||||
Status = StatusCodes.Status400BadRequest
|
||||
});
|
||||
}
|
||||
|
||||
var receipt = await repository.GetAsync(tenantId, receiptId, cancellationToken).ConfigureAwait(false);
|
||||
if (receipt is null)
|
||||
{
|
||||
return Results.NotFound(new ProblemDetails
|
||||
{
|
||||
Title = "Receipt not found",
|
||||
Detail = $"CVSS receipt '{receiptId}' was not found.",
|
||||
Status = StatusCodes.Status404NotFound
|
||||
});
|
||||
}
|
||||
|
||||
var orderedHistory = receipt.History
|
||||
.OrderBy(h => h.Timestamp)
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(orderedHistory);
|
||||
}
|
||||
|
||||
private static IResult ListPolicies()
|
||||
=> Results.Ok(Array.Empty<CvssPolicy>());
|
||||
|
||||
private static string? ResolveTenantId(HttpContext context)
|
||||
{
|
||||
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var tenantHeader) &&
|
||||
!string.IsNullOrWhiteSpace(tenantHeader))
|
||||
{
|
||||
return tenantHeader.ToString();
|
||||
}
|
||||
|
||||
return context.User?.FindFirst("tenant_id")?.Value;
|
||||
}
|
||||
|
||||
private static string? ResolveActorId(HttpContext context)
|
||||
{
|
||||
var user = context.User;
|
||||
return user?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value
|
||||
?? user?.FindFirst("sub")?.Value;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record CreateCvssReceiptRequest(
|
||||
string VulnerabilityId,
|
||||
CvssPolicy Policy,
|
||||
CvssBaseMetrics BaseMetrics,
|
||||
CvssThreatMetrics? ThreatMetrics,
|
||||
CvssEnvironmentalMetrics? EnvironmentalMetrics,
|
||||
CvssSupplementalMetrics? SupplementalMetrics,
|
||||
IReadOnlyList<CvssEvidenceItem>? Evidence,
|
||||
EnvelopeKey? SigningKey,
|
||||
string? CreatedBy,
|
||||
DateTimeOffset? CreatedAt);
|
||||
|
||||
internal sealed record AmendCvssReceiptRequest(
|
||||
string Field,
|
||||
string? PreviousValue,
|
||||
string? NewValue,
|
||||
string Reason,
|
||||
string? ReferenceUri,
|
||||
EnvelopeKey? SigningKey,
|
||||
string? Actor);
|
||||
Reference in New Issue
Block a user