Files
git.stella-ops.org/src/Policy/StellaOps.Policy.Api/Endpoints/ReplayEndpoints.cs

641 lines
22 KiB
C#

// -----------------------------------------------------------------------------
// ReplayEndpoints.cs
// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
// Task: GR-002 - Implement POST /replay endpoint
// Description: API endpoints for policy decision replay and verification
// -----------------------------------------------------------------------------
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using StellaOps.Policy.Persistence.Postgres.Repositories;
namespace StellaOps.Policy.Api.Endpoints;
/// <summary>
/// API endpoints for policy decision replay.
/// Enables "months later you can re-prove why a release passed/failed".
/// </summary>
public static class ReplayEndpoints
{
/// <summary>
/// Maps replay API endpoints.
/// </summary>
public static IEndpointRouteBuilder MapReplayEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/v1/replay")
.WithTags("Replay")
.WithOpenApi();
// POST /api/v1/replay - Replay a policy decision
group.MapPost("/", ReplayDecisionAsync)
.WithName("ReplayDecision")
.WithSummary("Replay a historical policy decision")
.WithDescription("Re-evaluates a policy decision using frozen snapshots to verify determinism")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit))
.Produces<ReplayResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound);
// POST /api/v1/replay/batch - Batch replay
group.MapPost("/batch", BatchReplayAsync)
.WithName("BatchReplay")
.WithSummary("Replay multiple policy decisions")
.WithDescription("Replay a batch of historical policy decisions by verdict hash or Rekor UUID, returning pass/fail and determinism verification results for each item. Used by compliance automation tools to bulk-verify release audit trails.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit))
.Produces<BatchReplayResponse>(StatusCodes.Status200OK);
// GET /api/v1/replay/{replayId} - Get replay result
group.MapGet("/{replayId}", GetReplayResultAsync)
.WithName("GetReplayResult")
.WithSummary("Get the result of a replay operation")
.WithDescription("Retrieve the stored result of a previously executed replay operation by its replay ID, including verdict match status, digest comparison, and replay duration metadata.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit));
// POST /api/v1/replay/verify-determinism - Verify replay determinism
group.MapPost("/verify-determinism", VerifyDeterminismAsync)
.WithName("VerifyDeterminism")
.WithSummary("Verify that a decision can be deterministically replayed")
.WithDescription("Execute multiple replay iterations for a verdict hash and report whether all iterations produced the same digest, confirming deterministic reproducibility. Returns the iteration count, number of unique results, and diagnostic details for any non-determinism detected.")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit));
// GET /api/v1/replay/audit - Query replay audit trail
group.MapGet("/audit", QueryReplayAuditAsync)
.WithName("QueryReplayAudit")
.WithSummary("Query replay audit records")
.WithDescription("Returns paginated list of replay audit records for compliance and debugging")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit));
// GET /api/v1/replay/audit/metrics - Get replay metrics
group.MapGet("/audit/metrics", GetReplayMetricsAsync)
.WithName("GetReplayMetrics")
.WithSummary("Get aggregated replay metrics")
.WithDescription("Returns replay_attempts_total and replay_match_rate metrics")
.RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.PolicyAudit));
return endpoints;
}
private static async Task<IResult> ReplayDecisionAsync(
ReplayRequest request,
IReplayEngine replayEngine,
IVerifierIdentityProvider verifierIdentity,
CancellationToken ct)
{
if (string.IsNullOrEmpty(request.VerdictHash) && string.IsNullOrEmpty(request.RekorUuid))
{
return Results.BadRequest(new ProblemDetails
{
Title = "Invalid request",
Detail = "Either verdictHash or rekorUuid must be provided"
});
}
var result = await replayEngine.ReplayAsync(new ReplayContext
{
VerdictHash = request.VerdictHash,
RekorUuid = request.RekorUuid,
PolicyBundleDigest = request.PolicyBundleDigest,
FeedSnapshotDigest = request.FeedSnapshotDigest,
ExpectedVerdictDigest = request.ExpectedVerdictDigest,
VerifierImageDigest = verifierIdentity.GetImageDigest()
}, ct);
return Results.Ok(new ReplayResponse
{
ReplayId = result.ReplayId,
Success = result.Success,
DeterminismVerified = result.DeterminismVerified,
ComputedVerdictDigest = result.ComputedVerdictDigest,
ExpectedVerdictDigest = result.ExpectedVerdictDigest,
VerdictMatch = result.VerdictMatch,
ReplayDuration = result.Duration,
Details = result.Details
});
}
private static async Task<IResult> BatchReplayAsync(
BatchReplayRequest request,
IReplayEngine replayEngine,
CancellationToken ct)
{
var results = new List<ReplayResponse>();
foreach (var item in request.Items)
{
var result = await replayEngine.ReplayAsync(new ReplayContext
{
VerdictHash = item.VerdictHash,
RekorUuid = item.RekorUuid
}, ct);
results.Add(new ReplayResponse
{
ReplayId = result.ReplayId,
Success = result.Success,
DeterminismVerified = result.DeterminismVerified,
VerdictMatch = result.VerdictMatch
});
}
return Results.Ok(new BatchReplayResponse
{
Total = results.Count,
Successful = results.Count(r => r.Success),
Failed = results.Count(r => !r.Success),
Results = results
});
}
private static async Task<IResult> GetReplayResultAsync(
string replayId,
IReplayAuditStore auditStore,
CancellationToken ct)
{
var result = await auditStore.GetAsync(replayId, ct);
if (result == null)
{
return Results.NotFound();
}
return Results.Ok(result);
}
private static async Task<IResult> VerifyDeterminismAsync(
VerifyDeterminismRequest request,
IReplayEngine replayEngine,
CancellationToken ct)
{
var result = await replayEngine.CheckDeterminismAsync(
request.VerdictHash,
request.NumIterations,
ct);
return Results.Ok(new VerifyDeterminismResponse
{
VerdictHash = request.VerdictHash,
IsDeterministic = result.IsDeterministic,
Iterations = result.Iterations,
UniqueResults = result.UniqueResults,
Details = result.Details
});
}
/// <summary>
/// GET /api/v1/replay/audit
/// Query replay audit records.
/// Sprint: SPRINT_20260118_019 (GR-007)
/// </summary>
private static async Task<IResult> QueryReplayAuditAsync(
string? bom_ref,
string? verdict_hash,
string? rekor_uuid,
string? from_date,
string? to_date,
bool? match_only,
string? actor,
int? limit,
string? continuation_token,
IReplayAuditRepository auditRepository,
HttpContext httpContext,
CancellationToken ct)
{
var tenantId = httpContext.Items.TryGetValue("TenantId", out var tid) && tid is Guid g
? g
: Guid.Empty;
var query = new ReplayAuditQuery
{
TenantId = tenantId,
BomRef = bom_ref,
VerdictHash = verdict_hash,
RekorUuid = rekor_uuid,
FromDate = string.IsNullOrEmpty(from_date) ? null : DateTimeOffset.Parse(from_date),
ToDate = string.IsNullOrEmpty(to_date) ? null : DateTimeOffset.Parse(to_date),
MatchOnly = match_only,
Actor = actor,
Limit = limit ?? 50,
ContinuationToken = continuation_token
};
var result = await auditRepository.QueryAsync(query, ct);
var response = new ReplayAuditResponse
{
Records = result.Records.Select(r => new ReplayAuditRecordDto
{
ReplayId = r.ReplayId,
BomRef = r.BomRef,
VerdictHash = r.VerdictHash,
RekorUuid = r.RekorUuid,
ReplayedAt = new DateTimeOffset(r.ReplayedAt, TimeSpan.Zero),
Match = r.Match,
OriginalHash = r.OriginalHash,
ReplayedHash = r.ReplayedHash,
MismatchReason = r.MismatchReason,
PolicyBundleId = r.PolicyBundleId,
PolicyBundleHash = r.PolicyBundleHash,
VerifierDigest = r.VerifierDigest,
DurationMs = r.DurationMs,
Actor = r.Actor,
Source = r.Source
}).ToList(),
Total = result.Total,
ContinuationToken = result.ContinuationToken
};
return Results.Ok(response);
}
/// <summary>
/// GET /api/v1/replay/audit/metrics
/// Get aggregated replay metrics.
/// Sprint: SPRINT_20260118_019 (GR-007)
/// </summary>
private static async Task<IResult> GetReplayMetricsAsync(
string? from_date,
string? to_date,
IReplayAuditRepository auditRepository,
HttpContext httpContext,
CancellationToken ct)
{
var tenantId = httpContext.Items.TryGetValue("TenantId", out var tid) && tid is Guid g
? g
: Guid.Empty;
var fromDate = string.IsNullOrEmpty(from_date) ? null : (DateTimeOffset?)DateTimeOffset.Parse(from_date);
var toDate = string.IsNullOrEmpty(to_date) ? null : (DateTimeOffset?)DateTimeOffset.Parse(to_date);
var metrics = await auditRepository.GetMetricsAsync(tenantId, fromDate, toDate, ct);
return Results.Ok(new ReplayMetricsResponse
{
TotalAttempts = metrics.TotalAttempts,
SuccessfulMatches = metrics.SuccessfulMatches,
Mismatches = metrics.Mismatches,
MatchRate = metrics.MatchRate,
AverageDurationMs = metrics.AverageDurationMs
});
}
}
/// <summary>
/// Request to replay a policy decision.
/// </summary>
public sealed record ReplayRequest
{
/// <summary>Verdict hash to replay.</summary>
public string? VerdictHash { get; init; }
/// <summary>Rekor UUID to replay.</summary>
public string? RekorUuid { get; init; }
/// <summary>Policy bundle digest to use (optional, uses recorded if null).</summary>
public string? PolicyBundleDigest { get; init; }
/// <summary>Feed snapshot digest to use (optional, uses recorded if null).</summary>
public string? FeedSnapshotDigest { get; init; }
/// <summary>Expected verdict digest for verification.</summary>
public string? ExpectedVerdictDigest { get; init; }
}
/// <summary>
/// Response from replay operation.
/// </summary>
public sealed record ReplayResponse
{
/// <summary>Unique replay ID.</summary>
public required string ReplayId { get; init; }
/// <summary>Whether replay succeeded.</summary>
public required bool Success { get; init; }
/// <summary>Whether determinism was verified.</summary>
public bool DeterminismVerified { get; init; }
/// <summary>Computed verdict digest from replay.</summary>
public string? ComputedVerdictDigest { get; init; }
/// <summary>Expected verdict digest.</summary>
public string? ExpectedVerdictDigest { get; init; }
/// <summary>Whether computed matches expected.</summary>
public bool VerdictMatch { get; init; }
/// <summary>Replay duration.</summary>
public TimeSpan ReplayDuration { get; init; }
/// <summary>Additional details.</summary>
public string? Details { get; init; }
}
/// <summary>
/// Batch replay request.
/// </summary>
public sealed record BatchReplayRequest
{
/// <summary>Items to replay.</summary>
public required IReadOnlyList<ReplayRequest> Items { get; init; }
}
/// <summary>
/// Batch replay response.
/// </summary>
public sealed record BatchReplayResponse
{
/// <summary>Total items processed.</summary>
public int Total { get; init; }
/// <summary>Successful replays.</summary>
public int Successful { get; init; }
/// <summary>Failed replays.</summary>
public int Failed { get; init; }
/// <summary>Individual results.</summary>
public required IReadOnlyList<ReplayResponse> Results { get; init; }
}
/// <summary>
/// Request to verify determinism.
/// </summary>
public sealed record VerifyDeterminismRequest
{
/// <summary>Verdict hash to verify.</summary>
public required string VerdictHash { get; init; }
/// <summary>Number of replay iterations.</summary>
public int NumIterations { get; init; } = 3;
}
/// <summary>
/// Response from determinism verification.
/// </summary>
public sealed record VerifyDeterminismResponse
{
/// <summary>Verdict hash verified.</summary>
public required string VerdictHash { get; init; }
/// <summary>Whether all iterations produced same result.</summary>
public required bool IsDeterministic { get; init; }
/// <summary>Number of iterations run.</summary>
public int Iterations { get; init; }
/// <summary>Number of unique results (should be 1 if deterministic).</summary>
public int UniqueResults { get; init; }
/// <summary>Details about any non-determinism.</summary>
public string? Details { get; init; }
}
// Interfaces
/// <summary>
/// Replay engine interface.
/// </summary>
public interface IReplayEngine
{
/// <summary>Replays a policy decision.</summary>
Task<ReplayResult> ReplayAsync(ReplayContext context, CancellationToken ct = default);
/// <summary>Verifies determinism by running multiple iterations.</summary>
Task<DeterminismCheckResult> CheckDeterminismAsync(string verdictHash, int iterations, CancellationToken ct = default);
}
/// <summary>
/// Verifier identity provider.
/// </summary>
public interface IVerifierIdentityProvider
{
/// <summary>Gets the container image digest of the verifier.</summary>
string? GetImageDigest();
/// <summary>Gets the verifier version.</summary>
string GetVersion();
}
/// <summary>
/// Replay audit store.
/// </summary>
public interface IReplayAuditStore
{
/// <summary>Gets a replay result by ID.</summary>
Task<ReplayResponse?> GetAsync(string replayId, CancellationToken ct = default);
/// <summary>Stores a replay result.</summary>
Task StoreAsync(ReplayResponse result, CancellationToken ct = default);
}
/// <summary>
/// Replay context.
/// </summary>
public sealed record ReplayContext
{
/// <summary>Verdict hash.</summary>
public string? VerdictHash { get; init; }
/// <summary>Rekor UUID.</summary>
public string? RekorUuid { get; init; }
/// <summary>Policy bundle digest.</summary>
public string? PolicyBundleDigest { get; init; }
/// <summary>Feed snapshot digest.</summary>
public string? FeedSnapshotDigest { get; init; }
/// <summary>Expected verdict digest.</summary>
public string? ExpectedVerdictDigest { get; init; }
/// <summary>Verifier image digest.</summary>
public string? VerifierImageDigest { get; init; }
}
/// <summary>
/// Replay result.
/// </summary>
public sealed record ReplayResult
{
/// <summary>Replay ID.</summary>
public required string ReplayId { get; init; }
/// <summary>Success.</summary>
public required bool Success { get; init; }
/// <summary>Determinism verified.</summary>
public bool DeterminismVerified { get; init; }
/// <summary>Computed verdict digest.</summary>
public string? ComputedVerdictDigest { get; init; }
/// <summary>Expected verdict digest.</summary>
public string? ExpectedVerdictDigest { get; init; }
/// <summary>Verdict match.</summary>
public bool VerdictMatch { get; init; }
/// <summary>Duration.</summary>
public TimeSpan Duration { get; init; }
/// <summary>Details.</summary>
public string? Details { get; init; }
}
/// <summary>
/// Determinism check result.
/// </summary>
public sealed record DeterminismCheckResult
{
/// <summary>Is deterministic.</summary>
public required bool IsDeterministic { get; init; }
/// <summary>Iterations run.</summary>
public int Iterations { get; init; }
/// <summary>Unique results count.</summary>
public int UniqueResults { get; init; }
/// <summary>Details.</summary>
public string? Details { get; init; }
}
/// <summary>
/// Problem details for error responses.
/// </summary>
public sealed record ProblemDetails
{
/// <summary>Error title.</summary>
public string? Title { get; init; }
/// <summary>Error detail.</summary>
public string? Detail { get; init; }
/// <summary>Error type.</summary>
public string? Type { get; init; }
/// <summary>HTTP status.</summary>
public int Status { get; init; }
}
#region Replay Audit DTOs (GR-007)
/// <summary>
/// Response for replay audit query.
/// Sprint: SPRINT_20260118_019_Policy_gate_replay_api_exposure
/// Task: GR-007 - Create replay audit trail
/// </summary>
public sealed record ReplayAuditResponse
{
/// <summary>List of replay audit records.</summary>
[JsonPropertyName("records")]
public List<ReplayAuditRecordDto> Records { get; init; } = [];
/// <summary>Total count of matching records.</summary>
[JsonPropertyName("total")]
public long Total { get; init; }
/// <summary>Token for fetching next page.</summary>
[JsonPropertyName("continuation_token")]
public string? ContinuationToken { get; init; }
}
/// <summary>
/// Replay audit record DTO for API response.
/// </summary>
public sealed record ReplayAuditRecordDto
{
/// <summary>Unique replay ID.</summary>
[JsonPropertyName("replay_id")]
public Guid ReplayId { get; init; }
/// <summary>BOM reference.</summary>
[JsonPropertyName("bom_ref")]
public required string BomRef { get; init; }
/// <summary>Verdict hash that was replayed.</summary>
[JsonPropertyName("verdict_hash")]
public required string VerdictHash { get; init; }
/// <summary>Rekor transparency log UUID.</summary>
[JsonPropertyName("rekor_uuid")]
public string? RekorUuid { get; init; }
/// <summary>When the replay was executed.</summary>
[JsonPropertyName("replayed_at")]
public DateTimeOffset ReplayedAt { get; init; }
/// <summary>Whether replayed verdict matched original.</summary>
[JsonPropertyName("match")]
public bool Match { get; init; }
/// <summary>Original verdict hash.</summary>
[JsonPropertyName("original_hash")]
public string? OriginalHash { get; init; }
/// <summary>Computed verdict hash from replay.</summary>
[JsonPropertyName("replayed_hash")]
public string? ReplayedHash { get; init; }
/// <summary>Reason for mismatch if match=false.</summary>
[JsonPropertyName("mismatch_reason")]
public string? MismatchReason { get; init; }
/// <summary>Policy bundle identifier used.</summary>
[JsonPropertyName("policy_bundle_id")]
public string? PolicyBundleId { get; init; }
/// <summary>Policy bundle content hash.</summary>
[JsonPropertyName("policy_bundle_hash")]
public string? PolicyBundleHash { get; init; }
/// <summary>Verifier service image digest.</summary>
[JsonPropertyName("verifier_digest")]
public string? VerifierDigest { get; init; }
/// <summary>Replay duration in milliseconds.</summary>
[JsonPropertyName("duration_ms")]
public int? DurationMs { get; init; }
/// <summary>Actor who triggered the replay.</summary>
[JsonPropertyName("actor")]
public string? Actor { get; init; }
/// <summary>Source of replay request (api, cli, scheduled).</summary>
[JsonPropertyName("source")]
public string? Source { get; init; }
}
/// <summary>
/// Aggregated metrics for replay operations.
/// </summary>
public sealed record ReplayMetricsResponse
{
/// <summary>Total replay attempts.</summary>
[JsonPropertyName("total_attempts")]
public long TotalAttempts { get; init; }
/// <summary>Successful matches.</summary>
[JsonPropertyName("successful_matches")]
public long SuccessfulMatches { get; init; }
/// <summary>Mismatches.</summary>
[JsonPropertyName("mismatches")]
public long Mismatches { get; init; }
/// <summary>Match rate (0.0-1.0).</summary>
[JsonPropertyName("match_rate")]
public double MatchRate { get; init; }
/// <summary>Average duration in milliseconds.</summary>
[JsonPropertyName("average_duration_ms")]
public double? AverageDurationMs { get; init; }
}
#endregion