Files
git.stella-ops.org/src/__Libraries/StellaOps.Provcache.Api/ProvcacheEndpointExtensions.cs
2025-12-25 23:10:09 +02:00

676 lines
26 KiB
C#

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace StellaOps.Provcache.Api;
/// <summary>
/// Marker class for logging in Provcache API endpoints.
/// </summary>
public sealed class ProvcacheApiEndpoints;
/// <summary>
/// Extension methods for mapping Provcache API endpoints.
/// </summary>
public static partial class ProvcacheEndpointExtensions
{
/// <summary>
/// Maps Provcache API endpoints to the specified route builder.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="prefix">The route prefix (default: "/v1/provcache").</param>
/// <returns>A route group builder for further customization.</returns>
public static RouteGroupBuilder MapProvcacheEndpoints(
this IEndpointRouteBuilder endpoints,
string prefix = "/v1/provcache")
{
var group = endpoints.MapGroup(prefix)
.WithTags("Provcache")
.WithOpenApi();
// GET /v1/provcache/{veriKey}
group.MapGet("/{veriKey}", GetByVeriKey)
.WithName("GetProvcacheEntry")
.WithSummary("Get a cached decision by VeriKey")
.WithDescription("Retrieves a cached evaluation decision by its VeriKey. Returns 200 if found, 204 if not cached, 410 if expired.")
.Produces<ProvcacheGetResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status410Gone)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
// POST /v1/provcache
group.MapPost("/", CreateOrUpdate)
.WithName("CreateOrUpdateProvcacheEntry")
.WithSummary("Store a decision in the cache (idempotent)")
.WithDescription("Stores or updates a cached evaluation decision. This operation is idempotent - storing the same VeriKey multiple times is safe.")
.Accepts<ProvcacheCreateRequest>("application/json")
.Produces<ProvcacheCreateResponse>(StatusCodes.Status201Created)
.Produces<ProvcacheCreateResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
// POST /v1/provcache/invalidate
group.MapPost("/invalidate", Invalidate)
.WithName("InvalidateProvcacheEntries")
.WithSummary("Invalidate cache entries by key or pattern")
.WithDescription("Invalidates one or more cache entries. Can invalidate by exact VeriKey, policy hash, signer set hash, feed epoch, or pattern.")
.Accepts<ProvcacheInvalidateRequest>("application/json")
.Produces<ProvcacheInvalidateResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
// GET /v1/provcache/metrics
group.MapGet("/metrics", GetMetrics)
.WithName("GetProvcacheMetrics")
.WithSummary("Get cache performance metrics")
.WithDescription("Returns current cache metrics including hit rate, miss rate, latency percentiles, and entry counts.")
.Produces<ProvcacheMetricsResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
// GET /v1/provcache/{veriKey}/manifest
group.MapGet("/{veriKey}/manifest", GetInputManifest)
.WithName("GetInputManifest")
.WithSummary("Get input manifest for VeriKey components")
.WithDescription("Returns detailed information about the inputs (SBOM, VEX, policy, signers) that formed a cached decision. Use for transparency and debugging.")
.Produces<InputManifestResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
// Map evidence paging endpoints under /proofs
var proofsGroup = endpoints.MapGroup($"{prefix}/proofs")
.WithTags("Provcache Evidence")
.WithOpenApi();
// GET /v1/provcache/proofs/{proofRoot}
proofsGroup.MapGet("/{proofRoot}", GetEvidenceChunks)
.WithName("GetProofEvidence")
.WithSummary("Get evidence chunks by proof root")
.WithDescription("Retrieves evidence chunks for a proof root with pagination support. Use cursor parameter for subsequent pages.")
.Produces<ProofEvidenceResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
// GET /v1/provcache/proofs/{proofRoot}/manifest
proofsGroup.MapGet("/{proofRoot}/manifest", GetProofManifest)
.WithName("GetProofManifest")
.WithSummary("Get chunk manifest (metadata without data)")
.WithDescription("Retrieves the chunk manifest for lazy evidence fetching. Contains hashes and sizes but no blob data.")
.Produces<ProofManifestResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
// GET /v1/provcache/proofs/{proofRoot}/chunks/{chunkIndex}
proofsGroup.MapGet("/{proofRoot}/chunks/{chunkIndex:int}", GetSingleChunk)
.WithName("GetProofChunk")
.WithSummary("Get a single chunk by index")
.WithDescription("Retrieves a specific chunk by its index within the proof.")
.Produces<ProofChunkResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
// POST /v1/provcache/proofs/{proofRoot}/verify
proofsGroup.MapPost("/{proofRoot}/verify", VerifyProof)
.WithName("VerifyProof")
.WithSummary("Verify Merkle tree integrity")
.WithDescription("Verifies all chunk hashes and the Merkle tree for the proof root.")
.Produces<ProofVerificationResponse>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status404NotFound)
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
return group;
}
/// <summary>
/// GET /v1/provcache/{veriKey}
/// </summary>
private static async Task<IResult> GetByVeriKey(
string veriKey,
bool? bypassCache,
IProvcacheService provcacheService,
ILogger<ProvcacheApiEndpoints> logger,
CancellationToken cancellationToken)
{
logger.LogDebug("GET /v1/provcache/{VeriKey}", veriKey);
try
{
var result = await provcacheService.GetAsync(veriKey, bypassCache ?? false, cancellationToken);
return result.Status switch
{
ProvcacheResultStatus.CacheHit => Results.Ok(new ProvcacheGetResponse
{
VeriKey = result.Entry!.VeriKey,
Entry = result.Entry,
Source = result.Source,
ElapsedMs = result.ElapsedMs,
Status = "hit"
}),
ProvcacheResultStatus.Bypassed => Results.Ok(new ProvcacheGetResponse
{
VeriKey = veriKey,
Entry = null,
Source = null,
ElapsedMs = result.ElapsedMs,
Status = "bypassed"
}),
ProvcacheResultStatus.Expired => Results.StatusCode(StatusCodes.Status410Gone),
_ => Results.NoContent()
};
}
catch (Exception ex)
{
logger.LogError(ex, "Error getting cache entry for VeriKey {VeriKey}", veriKey);
return Results.Problem(
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError,
title: "Cache lookup failed");
}
}
/// <summary>
/// POST /v1/provcache
/// </summary>
private static async Task<IResult> CreateOrUpdate(
ProvcacheCreateRequest request,
IProvcacheService provcacheService,
ILogger<ProvcacheApiEndpoints> logger,
CancellationToken cancellationToken)
{
logger.LogDebug("POST /v1/provcache for VeriKey {VeriKey}", request.Entry?.VeriKey);
if (request.Entry is null)
{
return Results.Problem(
detail: "Request body must contain a valid entry",
statusCode: StatusCodes.Status400BadRequest,
title: "Invalid request");
}
try
{
var success = await provcacheService.SetAsync(request.Entry, cancellationToken);
if (!success)
{
return Results.Problem(
detail: "Failed to store cache entry",
statusCode: StatusCodes.Status500InternalServerError,
title: "Cache write failed");
}
return Results.Created($"/v1/provcache/{request.Entry.VeriKey}", new ProvcacheCreateResponse
{
VeriKey = request.Entry.VeriKey,
Success = true,
ExpiresAt = request.Entry.ExpiresAt
});
}
catch (Exception ex)
{
logger.LogError(ex, "Error storing cache entry for VeriKey {VeriKey}", request.Entry?.VeriKey);
return Results.Problem(
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError,
title: "Cache write failed");
}
}
/// <summary>
/// POST /v1/provcache/invalidate
/// </summary>
private static async Task<IResult> Invalidate(
ProvcacheInvalidateRequest request,
IProvcacheService provcacheService,
ILogger<ProvcacheApiEndpoints> logger,
CancellationToken cancellationToken)
{
logger.LogDebug("POST /v1/provcache/invalidate type={Type} value={Value}", request.Type, request.Value);
try
{
// If single VeriKey invalidation (Type is null = single VeriKey mode)
if (request.Type is null)
{
var success = await provcacheService.InvalidateAsync(request.Value, request.Reason, cancellationToken);
return Results.Ok(new ProvcacheInvalidateResponse
{
EntriesAffected = success ? 1 : 0,
Type = "verikey",
Value = request.Value,
Reason = request.Reason
});
}
// Bulk invalidation
var invalidationRequest = new InvalidationRequest
{
Type = request.Type ?? InvalidationType.Pattern,
Value = request.Value,
Reason = request.Reason,
Actor = request.Actor
};
var result = await provcacheService.InvalidateByAsync(invalidationRequest, cancellationToken);
return Results.Ok(new ProvcacheInvalidateResponse
{
EntriesAffected = result.EntriesAffected,
Type = request.Type?.ToString() ?? "pattern",
Value = request.Value,
Reason = request.Reason
});
}
catch (Exception ex)
{
logger.LogError(ex, "Error invalidating cache entries");
return Results.Problem(
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError,
title: "Cache invalidation failed");
}
}
/// <summary>
/// GET /v1/provcache/metrics
/// </summary>
private static async Task<IResult> GetMetrics(
IProvcacheService provcacheService,
ILogger<ProvcacheApiEndpoints> logger,
CancellationToken cancellationToken)
{
logger.LogDebug("GET /v1/provcache/metrics");
try
{
var metrics = await provcacheService.GetMetricsAsync(cancellationToken);
var hitRate = metrics.TotalRequests > 0
? (double)metrics.TotalHits / metrics.TotalRequests
: 0;
return Results.Ok(new ProvcacheMetricsResponse
{
TotalRequests = metrics.TotalRequests,
TotalHits = metrics.TotalHits,
TotalMisses = metrics.TotalMisses,
TotalInvalidations = metrics.TotalInvalidations,
HitRate = hitRate,
CurrentEntryCount = metrics.CurrentEntryCount,
AvgLatencyMs = metrics.AvgLatencyMs,
P99LatencyMs = metrics.P99LatencyMs,
ValkeyCacheHealthy = metrics.ValkeyCacheHealthy,
PostgresRepositoryHealthy = metrics.PostgresRepositoryHealthy,
CollectedAt = metrics.CollectedAt
});
}
catch (Exception ex)
{
logger.LogError(ex, "Error getting cache metrics");
return Results.Problem(
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError,
title: "Metrics retrieval failed");
}
}
/// <summary>
/// GET /v1/provcache/{veriKey}/manifest
/// </summary>
private static async Task<IResult> GetInputManifest(
string veriKey,
IProvcacheService provcacheService,
TimeProvider timeProvider,
ILogger<ProvcacheApiEndpoints> logger,
CancellationToken cancellationToken)
{
logger.LogDebug("GET /v1/provcache/{VeriKey}/manifest", veriKey);
try
{
// First get the entry to verify it exists
var result = await provcacheService.GetAsync(veriKey, bypassCache: false, cancellationToken);
if (result.Status == ProvcacheResultStatus.CacheMiss)
{
return Results.NotFound(new ProblemDetails
{
Title = "Entry not found",
Detail = $"No cache entry found for VeriKey: {veriKey}",
Status = StatusCodes.Status404NotFound
});
}
if (result.Status == ProvcacheResultStatus.Expired)
{
return Results.NotFound(new ProblemDetails
{
Title = "Entry expired",
Detail = $"Cache entry for VeriKey '{veriKey}' has expired",
Status = StatusCodes.Status404NotFound
});
}
var entry = result.Entry;
if (entry is null)
{
return Results.NotFound(new ProblemDetails
{
Title = "Entry not found",
Detail = $"No cache entry found for VeriKey: {veriKey}",
Status = StatusCodes.Status404NotFound
});
}
// Build the input manifest from the entry metadata
// In a full implementation, we'd resolve these hashes to more detailed metadata
// from their respective stores (SBOM store, VEX store, policy registry, etc.)
var manifest = BuildInputManifest(entry, timeProvider);
return Results.Ok(manifest);
}
catch (Exception ex)
{
logger.LogError(ex, "Error getting input manifest for VeriKey {VeriKey}", veriKey);
return Results.Problem(
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError,
title: "Manifest retrieval failed");
}
}
/// <summary>
/// Builds an InputManifestResponse from a ProvcacheEntry.
/// </summary>
private static InputManifestResponse BuildInputManifest(ProvcacheEntry entry, TimeProvider timeProvider)
{
// Build input manifest from the entry and its embedded DecisionDigest
// The DecisionDigest contains the VeriKey components as hashes
var decision = entry.Decision;
return new InputManifestResponse
{
VeriKey = entry.VeriKey,
SourceArtifact = new SourceArtifactInfo
{
// VeriKey includes source hash as first component
Digest = entry.VeriKey,
},
Sbom = new SbomInfoDto
{
// SBOM hash is embedded in VeriKey computation
// In a full implementation, we'd resolve this from the SBOM store
Hash = $"sha256:{entry.VeriKey[7..39]}...", // Placeholder - actual hash would come from VeriKey decomposition
},
Vex = new VexInfoDto
{
// VEX hash set is embedded in VeriKey computation
HashSetHash = $"sha256:{entry.VeriKey[7..39]}...", // Placeholder
StatementCount = 0, // Would be resolved from VEX store
},
Policy = new PolicyInfoDto
{
Hash = entry.PolicyHash,
},
Signers = new SignerInfoDto
{
SetHash = entry.SignerSetHash,
SignerCount = 0, // Would be resolved from signer registry
},
TimeWindow = new TimeWindowInfoDto
{
Bucket = entry.FeedEpoch,
StartsAt = entry.CreatedAt,
EndsAt = entry.ExpiresAt,
},
GeneratedAt = timeProvider.GetUtcNow(),
};
}
}
/// <summary>
/// Placeholder for problem details when ASP.NET Core's ProblemDetails isn't available.
/// </summary>
internal sealed class ProblemDetails
{
public string? Type { get; set; }
public string? Title { get; set; }
public int? Status { get; set; }
public string? Detail { get; set; }
public string? Instance { get; set; }
}
/// <summary>
/// Marker class for logging in Proofs API endpoints.
/// </summary>
public sealed class ProofsApiEndpoints;
partial class ProvcacheEndpointExtensions
{
private const int DefaultPageSize = 10;
private const int MaxPageSize = 100;
/// <summary>
/// GET /v1/provcache/proofs/{proofRoot}
/// </summary>
private static async Task<IResult> GetEvidenceChunks(
string proofRoot,
int? offset,
int? limit,
bool? includeData,
[FromServices] IEvidenceChunkRepository chunkRepository,
ILogger<ProofsApiEndpoints> logger,
CancellationToken cancellationToken)
{
logger.LogDebug("GET /v1/provcache/proofs/{ProofRoot} offset={Offset} limit={Limit}", proofRoot, offset, limit);
try
{
var startIndex = offset ?? 0;
var pageSize = Math.Min(limit ?? DefaultPageSize, MaxPageSize);
// Get manifest for total count
var manifest = await chunkRepository.GetManifestAsync(proofRoot, cancellationToken);
if (manifest is null)
{
return Results.NotFound();
}
// Get chunk range
var chunks = await chunkRepository.GetChunkRangeAsync(proofRoot, startIndex, pageSize, cancellationToken);
var chunkResponses = chunks.Select(c => new ProofChunkResponse
{
ChunkId = c.ChunkId,
Index = c.ChunkIndex,
Hash = c.ChunkHash,
Size = c.BlobSize,
ContentType = c.ContentType,
Data = includeData == true ? Convert.ToBase64String(c.Blob) : null
}).ToList();
var hasMore = startIndex + chunks.Count < manifest.TotalChunks;
var nextCursor = hasMore ? (startIndex + pageSize).ToString() : null;
return Results.Ok(new ProofEvidenceResponse
{
ProofRoot = proofRoot,
TotalChunks = manifest.TotalChunks,
TotalSize = manifest.TotalSize,
Chunks = chunkResponses,
NextCursor = nextCursor,
HasMore = hasMore
});
}
catch (Exception ex)
{
logger.LogError(ex, "Error getting evidence chunks for proof root {ProofRoot}", proofRoot);
return Results.Problem(
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError,
title: "Evidence retrieval failed");
}
}
/// <summary>
/// GET /v1/provcache/proofs/{proofRoot}/manifest
/// </summary>
private static async Task<IResult> GetProofManifest(
string proofRoot,
[FromServices] IEvidenceChunkRepository chunkRepository,
ILogger<ProofsApiEndpoints> logger,
CancellationToken cancellationToken)
{
logger.LogDebug("GET /v1/provcache/proofs/{ProofRoot}/manifest", proofRoot);
try
{
var manifest = await chunkRepository.GetManifestAsync(proofRoot, cancellationToken);
if (manifest is null)
{
return Results.NotFound();
}
var chunkMetadata = manifest.Chunks.Select(c => new ChunkMetadataResponse
{
ChunkId = c.ChunkId,
Index = c.Index,
Hash = c.Hash,
Size = c.Size,
ContentType = c.ContentType
}).ToList();
return Results.Ok(new ProofManifestResponse
{
ProofRoot = proofRoot,
TotalChunks = manifest.TotalChunks,
TotalSize = manifest.TotalSize,
Chunks = chunkMetadata,
GeneratedAt = manifest.GeneratedAt
});
}
catch (Exception ex)
{
logger.LogError(ex, "Error getting manifest for proof root {ProofRoot}", proofRoot);
return Results.Problem(
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError,
title: "Manifest retrieval failed");
}
}
/// <summary>
/// GET /v1/provcache/proofs/{proofRoot}/chunks/{chunkIndex}
/// </summary>
private static async Task<IResult> GetSingleChunk(
string proofRoot,
int chunkIndex,
[FromServices] IEvidenceChunkRepository chunkRepository,
ILogger<ProofsApiEndpoints> logger,
CancellationToken cancellationToken)
{
logger.LogDebug("GET /v1/provcache/proofs/{ProofRoot}/chunks/{ChunkIndex}", proofRoot, chunkIndex);
try
{
var chunk = await chunkRepository.GetChunkAsync(proofRoot, chunkIndex, cancellationToken);
if (chunk is null)
{
return Results.NotFound();
}
return Results.Ok(new ProofChunkResponse
{
ChunkId = chunk.ChunkId,
Index = chunk.ChunkIndex,
Hash = chunk.ChunkHash,
Size = chunk.BlobSize,
ContentType = chunk.ContentType,
Data = Convert.ToBase64String(chunk.Blob)
});
}
catch (Exception ex)
{
logger.LogError(ex, "Error getting chunk {ChunkIndex} for proof root {ProofRoot}", chunkIndex, proofRoot);
return Results.Problem(
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError,
title: "Chunk retrieval failed");
}
}
/// <summary>
/// POST /v1/provcache/proofs/{proofRoot}/verify
/// </summary>
private static async Task<IResult> VerifyProof(
string proofRoot,
[FromServices] IEvidenceChunkRepository chunkRepository,
[FromServices] IEvidenceChunker chunker,
ILogger<ProofsApiEndpoints> logger,
CancellationToken cancellationToken)
{
logger.LogDebug("POST /v1/provcache/proofs/{ProofRoot}/verify", proofRoot);
try
{
var chunks = await chunkRepository.GetChunksAsync(proofRoot, cancellationToken);
if (chunks.Count == 0)
{
return Results.NotFound();
}
var chunkResults = new List<ChunkVerificationResult>();
var allValid = true;
foreach (var chunk in chunks)
{
var isValid = chunker.VerifyChunk(chunk);
var computedHash = isValid ? chunk.ChunkHash : ComputeChunkHash(chunk.Blob);
chunkResults.Add(new ChunkVerificationResult
{
Index = chunk.ChunkIndex,
IsValid = isValid,
ExpectedHash = chunk.ChunkHash,
ComputedHash = isValid ? null : computedHash
});
if (!isValid)
{
allValid = false;
}
}
// Verify Merkle root
var chunkHashes = chunks.Select(c => c.ChunkHash).ToList();
var computedRoot = chunker.ComputeMerkleRoot(chunkHashes);
var rootMatches = string.Equals(computedRoot, proofRoot, StringComparison.OrdinalIgnoreCase);
return Results.Ok(new ProofVerificationResponse
{
ProofRoot = proofRoot,
IsValid = allValid && rootMatches,
ChunkResults = chunkResults,
Error = !rootMatches ? $"Merkle root mismatch. Expected: {proofRoot}, Computed: {computedRoot}" : null
});
}
catch (Exception ex)
{
logger.LogError(ex, "Error verifying proof root {ProofRoot}", proofRoot);
return Results.Problem(
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError,
title: "Proof verification failed");
}
}
private static string ComputeChunkHash(byte[] data)
{
var hash = System.Security.Cryptography.SHA256.HashData(data);
return $"sha256:{Convert.ToHexStringLower(hash)}";
}
}