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; /// /// Logging category for verdict endpoints. /// internal sealed class VerdictEndpointsLogger; /// /// Minimal API endpoints for verdict attestations. /// 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(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(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(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(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 StoreVerdictAsync( [FromBody] StoreVerdictRequest request, [FromServices] IVerdictRepository repository, [FromServices] TimeProvider timeProvider, [FromServices] ILogger 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 GetVerdictAsync( string verdictId, [FromServices] IVerdictRepository repository, [FromServices] ILogger 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(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 ListVerdictsForRunAsync( string runId, [FromServices] IVerdictRepository repository, [FromServices] ILogger 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 VerifyVerdictAsync( string verdictId, [FromServices] IVerdictRepository repository, [FromServices] TimeProvider timeProvider, [FromServices] ILogger 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 DownloadEnvelopeAsync( string verdictId, [FromServices] IVerdictRepository repository, [FromServices] ILogger 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 ); } } }