343 lines
13 KiB
C#
343 lines
13 KiB
C#
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Logging;
|
|
using StellaOps.EvidenceLocker.Storage;
|
|
|
|
namespace StellaOps.EvidenceLocker.Api;
|
|
|
|
/// <summary>
|
|
/// Logging category for verdict endpoints.
|
|
/// </summary>
|
|
internal sealed class VerdictEndpointsLogger;
|
|
|
|
/// <summary>
|
|
/// Minimal API endpoints for verdict attestations.
|
|
/// </summary>
|
|
public static class VerdictEndpoints
|
|
{
|
|
public static void MapVerdictEndpoints(this WebApplication app)
|
|
{
|
|
var group = app.MapGroup("/api/v1/verdicts")
|
|
.WithTags("Verdicts");
|
|
|
|
// POST /api/v1/verdicts
|
|
group.MapPost("/", StoreVerdictAsync)
|
|
.WithName("StoreVerdict")
|
|
.WithSummary("Store a verdict attestation")
|
|
.Produces<StoreVerdictResponse>(StatusCodes.Status201Created)
|
|
.Produces(StatusCodes.Status400BadRequest)
|
|
.Produces(StatusCodes.Status500InternalServerError);
|
|
|
|
// GET /api/v1/verdicts/{verdictId}
|
|
group.MapGet("/{verdictId}", GetVerdictAsync)
|
|
.WithName("GetVerdict")
|
|
.WithSummary("Retrieve a verdict attestation by ID")
|
|
.Produces<GetVerdictResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status500InternalServerError);
|
|
|
|
// GET /api/v1/runs/{runId}/verdicts
|
|
app.MapGet("/api/v1/runs/{runId}/verdicts", ListVerdictsForRunAsync)
|
|
.WithName("ListVerdictsForRun")
|
|
.WithTags("Verdicts")
|
|
.WithSummary("List verdict attestations for a policy run")
|
|
.Produces<ListVerdictsResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status500InternalServerError);
|
|
|
|
// POST /api/v1/verdicts/{verdictId}/verify
|
|
group.MapPost("/{verdictId}/verify", VerifyVerdictAsync)
|
|
.WithName("VerifyVerdict")
|
|
.WithSummary("Verify verdict attestation signature")
|
|
.Produces<VerifyVerdictResponse>(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status500InternalServerError);
|
|
|
|
// GET /api/v1/verdicts/{verdictId}/envelope - SPRINT_4000_0100_0001
|
|
group.MapGet("/{verdictId}/envelope", DownloadEnvelopeAsync)
|
|
.WithName("DownloadEnvelope")
|
|
.WithSummary("Download DSSE envelope for verdict")
|
|
.Produces(StatusCodes.Status200OK, contentType: "application/json")
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status500InternalServerError);
|
|
}
|
|
|
|
private static async Task<IResult> StoreVerdictAsync(
|
|
[FromBody] StoreVerdictRequest request,
|
|
[FromServices] IVerdictRepository repository,
|
|
[FromServices] TimeProvider timeProvider,
|
|
[FromServices] ILogger<VerdictEndpointsLogger> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
logger.LogInformation("Storing verdict attestation {VerdictId}", request.VerdictId);
|
|
|
|
// Validate request
|
|
if (string.IsNullOrWhiteSpace(request.VerdictId))
|
|
{
|
|
return Results.BadRequest(new { error = "verdict_id is required" });
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(request.FindingId))
|
|
{
|
|
return Results.BadRequest(new { error = "finding_id is required" });
|
|
}
|
|
|
|
// Serialize envelope to JSON string
|
|
var envelopeJson = JsonSerializer.Serialize(request.Envelope);
|
|
|
|
// Create repository record
|
|
var record = new VerdictAttestationRecord
|
|
{
|
|
VerdictId = request.VerdictId,
|
|
TenantId = request.TenantId,
|
|
RunId = request.PolicyRunId,
|
|
PolicyId = request.PolicyId,
|
|
PolicyVersion = request.PolicyVersion,
|
|
FindingId = request.FindingId,
|
|
VerdictStatus = request.VerdictStatus,
|
|
VerdictSeverity = request.VerdictSeverity,
|
|
VerdictScore = request.VerdictScore,
|
|
EvaluatedAt = request.EvaluatedAt,
|
|
Envelope = envelopeJson,
|
|
PredicateDigest = request.PredicateDigest,
|
|
DeterminismHash = request.DeterminismHash,
|
|
RekorLogIndex = request.RekorLogIndex,
|
|
CreatedAt = timeProvider.GetUtcNow()
|
|
};
|
|
|
|
// Store in repository
|
|
var storedVerdictId = await repository.StoreVerdictAsync(record, cancellationToken);
|
|
|
|
logger.LogInformation("Successfully stored verdict attestation {VerdictId}", storedVerdictId);
|
|
|
|
var response = new StoreVerdictResponse
|
|
{
|
|
VerdictId = storedVerdictId,
|
|
CreatedAt = record.CreatedAt,
|
|
Stored = true
|
|
};
|
|
|
|
return Results.Created($"/api/v1/verdicts/{storedVerdictId}", response);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error storing verdict attestation {VerdictId}", request.VerdictId);
|
|
return Results.Problem(
|
|
title: "Internal server error",
|
|
detail: "Failed to store verdict attestation",
|
|
statusCode: StatusCodes.Status500InternalServerError
|
|
);
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> GetVerdictAsync(
|
|
string verdictId,
|
|
[FromServices] IVerdictRepository repository,
|
|
[FromServices] ILogger<VerdictEndpointsLogger> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
logger.LogInformation("Retrieving verdict attestation {VerdictId}", verdictId);
|
|
|
|
var record = await repository.GetVerdictAsync(verdictId, cancellationToken);
|
|
|
|
if (record is null)
|
|
{
|
|
logger.LogWarning("Verdict attestation {VerdictId} not found", verdictId);
|
|
return Results.NotFound(new { error = "Verdict not found", verdict_id = verdictId });
|
|
}
|
|
|
|
// Parse envelope JSON
|
|
var envelope = JsonSerializer.Deserialize<object>(record.Envelope);
|
|
|
|
var response = new GetVerdictResponse
|
|
{
|
|
VerdictId = record.VerdictId,
|
|
TenantId = record.TenantId,
|
|
PolicyRunId = record.RunId,
|
|
PolicyId = record.PolicyId,
|
|
PolicyVersion = record.PolicyVersion,
|
|
FindingId = record.FindingId,
|
|
VerdictStatus = record.VerdictStatus,
|
|
VerdictSeverity = record.VerdictSeverity,
|
|
VerdictScore = record.VerdictScore,
|
|
EvaluatedAt = record.EvaluatedAt,
|
|
Envelope = envelope!,
|
|
PredicateDigest = record.PredicateDigest,
|
|
DeterminismHash = record.DeterminismHash,
|
|
RekorLogIndex = record.RekorLogIndex,
|
|
CreatedAt = record.CreatedAt
|
|
};
|
|
|
|
return Results.Ok(response);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error retrieving verdict attestation {VerdictId}", verdictId);
|
|
return Results.Problem(
|
|
title: "Internal server error",
|
|
detail: "Failed to retrieve verdict attestation",
|
|
statusCode: StatusCodes.Status500InternalServerError
|
|
);
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> ListVerdictsForRunAsync(
|
|
string runId,
|
|
[FromServices] IVerdictRepository repository,
|
|
[FromServices] ILogger<VerdictEndpointsLogger> logger,
|
|
CancellationToken cancellationToken,
|
|
[FromQuery] string? status = null,
|
|
[FromQuery] string? severity = null,
|
|
[FromQuery] int limit = 50,
|
|
[FromQuery] int offset = 0)
|
|
{
|
|
try
|
|
{
|
|
logger.LogInformation(
|
|
"Listing verdicts for run {RunId} (status={Status}, severity={Severity}, limit={Limit}, offset={Offset})",
|
|
runId,
|
|
status,
|
|
severity,
|
|
limit,
|
|
offset);
|
|
|
|
var options = new VerdictListOptions
|
|
{
|
|
Status = status,
|
|
Severity = severity,
|
|
Limit = Math.Min(limit, 200), // Cap at 200
|
|
Offset = Math.Max(offset, 0)
|
|
};
|
|
|
|
var verdicts = await repository.ListVerdictsForRunAsync(runId, options, cancellationToken);
|
|
var total = await repository.CountVerdictsForRunAsync(runId, options, cancellationToken);
|
|
|
|
var response = new ListVerdictsResponse
|
|
{
|
|
Verdicts = verdicts.Select(v => new VerdictSummary
|
|
{
|
|
VerdictId = v.VerdictId,
|
|
FindingId = v.FindingId,
|
|
VerdictStatus = v.VerdictStatus,
|
|
VerdictSeverity = v.VerdictSeverity,
|
|
VerdictScore = v.VerdictScore,
|
|
EvaluatedAt = v.EvaluatedAt,
|
|
DeterminismHash = v.DeterminismHash
|
|
}).ToList(),
|
|
Pagination = new PaginationInfo
|
|
{
|
|
Total = total,
|
|
Limit = options.Limit,
|
|
Offset = options.Offset
|
|
}
|
|
};
|
|
|
|
return Results.Ok(response);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error listing verdicts for run {RunId}", runId);
|
|
return Results.Problem(
|
|
title: "Internal server error",
|
|
detail: "Failed to list verdicts",
|
|
statusCode: StatusCodes.Status500InternalServerError
|
|
);
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> VerifyVerdictAsync(
|
|
string verdictId,
|
|
[FromServices] IVerdictRepository repository,
|
|
[FromServices] TimeProvider timeProvider,
|
|
[FromServices] ILogger<VerdictEndpointsLogger> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
logger.LogInformation("Verifying verdict attestation {VerdictId}", verdictId);
|
|
|
|
var record = await repository.GetVerdictAsync(verdictId, cancellationToken);
|
|
|
|
if (record is null)
|
|
{
|
|
logger.LogWarning("Verdict attestation {VerdictId} not found", verdictId);
|
|
return Results.NotFound(new { error = "Verdict not found", verdict_id = verdictId });
|
|
}
|
|
|
|
// TODO: Implement actual signature verification
|
|
// For now, return a placeholder response
|
|
var now = timeProvider.GetUtcNow();
|
|
var response = new VerifyVerdictResponse
|
|
{
|
|
VerdictId = verdictId,
|
|
SignatureValid = true, // TODO: Implement verification
|
|
VerifiedAt = now,
|
|
Verifications = new[]
|
|
{
|
|
new SignatureVerification
|
|
{
|
|
KeyId = "placeholder",
|
|
Algorithm = "ed25519",
|
|
Valid = true
|
|
}
|
|
},
|
|
RekorVerification = record.RekorLogIndex.HasValue
|
|
? new RekorVerification
|
|
{
|
|
LogIndex = record.RekorLogIndex.Value,
|
|
InclusionProofValid = true, // TODO: Implement verification
|
|
VerifiedAt = now
|
|
}
|
|
: null
|
|
};
|
|
|
|
return Results.Ok(response);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error verifying verdict attestation {VerdictId}", verdictId);
|
|
return Results.Problem(
|
|
title: "Internal server error",
|
|
detail: "Failed to verify verdict attestation",
|
|
statusCode: StatusCodes.Status500InternalServerError
|
|
);
|
|
}
|
|
}
|
|
|
|
private static async Task<IResult> DownloadEnvelopeAsync(
|
|
string verdictId,
|
|
[FromServices] IVerdictRepository repository,
|
|
[FromServices] ILogger<VerdictEndpointsLogger> logger,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
logger.LogInformation("Downloading envelope for verdict {VerdictId}", verdictId);
|
|
var record = await repository.GetVerdictAsync(verdictId, cancellationToken);
|
|
if (record is null)
|
|
{
|
|
return Results.NotFound(new { error = "Verdict not found", verdict_id = verdictId });
|
|
}
|
|
|
|
var envelopeBytes = System.Text.Encoding.UTF8.GetBytes(record.Envelope);
|
|
var fileName = $"verdict-{verdictId.Replace(':', '-')}-envelope.json";
|
|
return Results.File(envelopeBytes, contentType: "application/json", fileDownloadName: fileName);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Error downloading envelope for verdict {VerdictId}", verdictId);
|
|
return Results.Problem(
|
|
title: "Internal server error",
|
|
detail: "Failed to download verdict envelope",
|
|
statusCode: StatusCodes.Status500InternalServerError
|
|
);
|
|
}
|
|
}
|
|
}
|