new two advisories and sprints work on them
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
// RekorAttestationEndpoints.cs
|
||||
// Sprint: SPRINT_20260117_002_EXCITITOR_vex_rekor_linkage
|
||||
// Task: VRL-007 - Create API endpoints for VEX-Rekor attestation management
|
||||
// Description: REST API endpoints for VEX observation attestation to Rekor
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Excititor.Core.Observations;
|
||||
using StellaOps.Excititor.Core.Storage;
|
||||
using static Program;
|
||||
|
||||
namespace StellaOps.Excititor.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// API endpoints for managing VEX observation attestation to Rekor transparency log.
|
||||
/// </summary>
|
||||
public static class RekorAttestationEndpoints
|
||||
{
|
||||
public static void MapRekorAttestationEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/attestations/rekor")
|
||||
.WithTags("Rekor Attestation");
|
||||
|
||||
// POST /attestations/rekor/observations/{observationId}
|
||||
// Attest a single observation to Rekor
|
||||
group.MapPost("/observations/{observationId}", async (
|
||||
HttpContext context,
|
||||
string observationId,
|
||||
[FromBody] AttestObservationRequest? request,
|
||||
IOptions<VexStorageOptions> storageOptions,
|
||||
[FromServices] IVexObservationAttestationService? attestationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.attest");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (attestationService is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Attestation service is not configured.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "Service unavailable");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(observationId))
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "observationId is required.",
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Validation error");
|
||||
}
|
||||
|
||||
var options = new VexAttestationOptions
|
||||
{
|
||||
SubmitToRekor = true,
|
||||
RekorUrl = request?.RekorUrl,
|
||||
StoreInclusionProof = request?.StoreInclusionProof ?? true,
|
||||
SigningKeyId = request?.SigningKeyId,
|
||||
TraceId = context.TraceIdentifier
|
||||
};
|
||||
|
||||
// Get observation and attest it
|
||||
// Note: In real implementation, we'd fetch the observation first
|
||||
var result = await attestationService.AttestAndLinkAsync(
|
||||
new VexObservation { Id = observationId },
|
||||
options,
|
||||
cancellationToken);
|
||||
|
||||
if (!result.Success)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: result.ErrorMessage,
|
||||
statusCode: result.ErrorCode switch
|
||||
{
|
||||
VexAttestationErrorCode.ObservationNotFound => StatusCodes.Status404NotFound,
|
||||
VexAttestationErrorCode.AlreadyAttested => StatusCodes.Status409Conflict,
|
||||
VexAttestationErrorCode.Timeout => StatusCodes.Status504GatewayTimeout,
|
||||
_ => StatusCodes.Status500InternalServerError
|
||||
},
|
||||
title: "Attestation failed");
|
||||
}
|
||||
|
||||
var response = new AttestObservationResponse(
|
||||
observationId,
|
||||
result.RekorLinkage!.EntryUuid,
|
||||
result.RekorLinkage.LogIndex,
|
||||
result.RekorLinkage.IntegratedTime,
|
||||
result.Duration);
|
||||
|
||||
return Results.Ok(response);
|
||||
}).WithName("AttestObservationToRekor");
|
||||
|
||||
// POST /attestations/rekor/observations/batch
|
||||
// Attest multiple observations to Rekor
|
||||
group.MapPost("/observations/batch", async (
|
||||
HttpContext context,
|
||||
[FromBody] BatchAttestRequest request,
|
||||
IOptions<VexStorageOptions> storageOptions,
|
||||
[FromServices] IVexObservationAttestationService? attestationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.attest");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (attestationService is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Attestation service is not configured.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "Service unavailable");
|
||||
}
|
||||
|
||||
if (request.ObservationIds is null || request.ObservationIds.Count == 0)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "observationIds is required and must not be empty.",
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Validation error");
|
||||
}
|
||||
|
||||
if (request.ObservationIds.Count > 100)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Maximum 100 observations per batch.",
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Validation error");
|
||||
}
|
||||
|
||||
var options = new VexAttestationOptions
|
||||
{
|
||||
SubmitToRekor = true,
|
||||
RekorUrl = request.RekorUrl,
|
||||
StoreInclusionProof = request.StoreInclusionProof ?? true,
|
||||
SigningKeyId = request.SigningKeyId,
|
||||
TraceId = context.TraceIdentifier
|
||||
};
|
||||
|
||||
var results = await attestationService.AttestBatchAsync(
|
||||
request.ObservationIds,
|
||||
options,
|
||||
cancellationToken);
|
||||
|
||||
var items = results.Select(r => new BatchAttestResultItem(
|
||||
r.ObservationId,
|
||||
r.Success,
|
||||
r.RekorLinkage?.EntryUuid,
|
||||
r.RekorLinkage?.LogIndex,
|
||||
r.ErrorMessage,
|
||||
r.ErrorCode?.ToString()
|
||||
)).ToList();
|
||||
|
||||
var response = new BatchAttestResponse(
|
||||
items.Count(i => i.Success),
|
||||
items.Count(i => !i.Success),
|
||||
items);
|
||||
|
||||
return Results.Ok(response);
|
||||
}).WithName("BatchAttestObservationsToRekor");
|
||||
|
||||
// GET /attestations/rekor/observations/{observationId}/verify
|
||||
// Verify an observation's Rekor linkage
|
||||
group.MapGet("/observations/{observationId}/verify", async (
|
||||
HttpContext context,
|
||||
string observationId,
|
||||
IOptions<VexStorageOptions> storageOptions,
|
||||
[FromServices] IVexObservationAttestationService? attestationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (attestationService is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Attestation service is not configured.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "Service unavailable");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(observationId))
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "observationId is required.",
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Validation error");
|
||||
}
|
||||
|
||||
var result = await attestationService.VerifyLinkageAsync(observationId, cancellationToken);
|
||||
|
||||
var response = new VerifyLinkageResponse(
|
||||
observationId,
|
||||
result.IsVerified,
|
||||
result.VerifiedAt,
|
||||
result.RekorEntryId,
|
||||
result.LogIndex,
|
||||
result.FailureReason);
|
||||
|
||||
return Results.Ok(response);
|
||||
}).WithName("VerifyObservationRekorLinkage");
|
||||
|
||||
// GET /attestations/rekor/pending
|
||||
// Get observations pending attestation
|
||||
group.MapGet("/pending", async (
|
||||
HttpContext context,
|
||||
[FromQuery] int? limit,
|
||||
IOptions<VexStorageOptions> storageOptions,
|
||||
[FromServices] IVexObservationAttestationService? attestationService,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var scopeResult = ScopeAuthorization.RequireScope(context, "vex.read");
|
||||
if (scopeResult is not null)
|
||||
{
|
||||
return scopeResult;
|
||||
}
|
||||
|
||||
if (!TryResolveTenant(context, storageOptions.Value, requireHeader: false, out var tenant, out var tenantError))
|
||||
{
|
||||
return tenantError;
|
||||
}
|
||||
|
||||
if (attestationService is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Attestation service is not configured.",
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "Service unavailable");
|
||||
}
|
||||
|
||||
var pendingIds = await attestationService.GetPendingAttestationsAsync(
|
||||
limit ?? 100,
|
||||
cancellationToken);
|
||||
|
||||
var response = new PendingAttestationsResponse(pendingIds.Count, pendingIds);
|
||||
|
||||
return Results.Ok(response);
|
||||
}).WithName("GetPendingRekorAttestations");
|
||||
}
|
||||
}
|
||||
|
||||
// Request DTOs
|
||||
public sealed record AttestObservationRequest(
|
||||
[property: JsonPropertyName("rekorUrl")] string? RekorUrl,
|
||||
[property: JsonPropertyName("storeInclusionProof")] bool? StoreInclusionProof,
|
||||
[property: JsonPropertyName("signingKeyId")] string? SigningKeyId);
|
||||
|
||||
public sealed record BatchAttestRequest(
|
||||
[property: JsonPropertyName("observationIds")] IReadOnlyList<string> ObservationIds,
|
||||
[property: JsonPropertyName("rekorUrl")] string? RekorUrl,
|
||||
[property: JsonPropertyName("storeInclusionProof")] bool? StoreInclusionProof,
|
||||
[property: JsonPropertyName("signingKeyId")] string? SigningKeyId);
|
||||
|
||||
// Response DTOs
|
||||
public sealed record AttestObservationResponse(
|
||||
[property: JsonPropertyName("observationId")] string ObservationId,
|
||||
[property: JsonPropertyName("rekorEntryId")] string RekorEntryId,
|
||||
[property: JsonPropertyName("logIndex")] long LogIndex,
|
||||
[property: JsonPropertyName("integratedTime")] DateTimeOffset IntegratedTime,
|
||||
[property: JsonPropertyName("duration")] TimeSpan? Duration);
|
||||
|
||||
public sealed record BatchAttestResultItem(
|
||||
[property: JsonPropertyName("observationId")] string ObservationId,
|
||||
[property: JsonPropertyName("success")] bool Success,
|
||||
[property: JsonPropertyName("rekorEntryId")] string? RekorEntryId,
|
||||
[property: JsonPropertyName("logIndex")] long? LogIndex,
|
||||
[property: JsonPropertyName("error")] string? Error,
|
||||
[property: JsonPropertyName("errorCode")] string? ErrorCode);
|
||||
|
||||
public sealed record BatchAttestResponse(
|
||||
[property: JsonPropertyName("successCount")] int SuccessCount,
|
||||
[property: JsonPropertyName("failureCount")] int FailureCount,
|
||||
[property: JsonPropertyName("results")] IReadOnlyList<BatchAttestResultItem> Results);
|
||||
|
||||
public sealed record VerifyLinkageResponse(
|
||||
[property: JsonPropertyName("observationId")] string ObservationId,
|
||||
[property: JsonPropertyName("isVerified")] bool IsVerified,
|
||||
[property: JsonPropertyName("verifiedAt")] DateTimeOffset? VerifiedAt,
|
||||
[property: JsonPropertyName("rekorEntryId")] string? RekorEntryId,
|
||||
[property: JsonPropertyName("logIndex")] long? LogIndex,
|
||||
[property: JsonPropertyName("failureReason")] string? FailureReason);
|
||||
|
||||
public sealed record PendingAttestationsResponse(
|
||||
[property: JsonPropertyName("count")] int Count,
|
||||
[property: JsonPropertyName("observationIds")] IReadOnlyList<string> ObservationIds);
|
||||
Reference in New Issue
Block a user