Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Middleware/IdempotencyMiddleware.cs
StellaOps Bot 3698ebf4a8 Complete Entrypoint Detection Re-Engineering Program (Sprints 0410-0415) and Sprint 3500.0002.0003 (Proof Replay + API)
Entrypoint Detection Program (100% complete):
- Sprint 0411: Semantic Entrypoint Engine - all 25 tasks DONE
- Sprint 0412: Temporal & Mesh Entrypoint - all 19 tasks DONE
- Sprint 0413: Speculative Execution Engine - all 19 tasks DONE
- Sprint 0414: Binary Intelligence - all 19 tasks DONE
- Sprint 0415: Predictive Risk Scoring - all tasks DONE

Key deliverables:
- SemanticEntrypoint schema with ApplicationIntent/CapabilityClass
- TemporalEntrypointGraph and MeshEntrypointGraph
- ShellSymbolicExecutor with PathEnumerator and PathConfidenceScorer
- CodeFingerprint index with symbol recovery
- RiskScore with multi-dimensional risk assessment

Sprint 3500.0002.0003 (Proof Replay + API):
- ManifestEndpoints with DSSE content negotiation
- Proof bundle endpoints by root hash
- IdempotencyMiddleware with RFC 9530 Content-Digest
- Rate limiting (100 req/hr per tenant)
- OpenAPI documentation updates

Tests: 357 EntryTrace tests pass, WebService tests blocked by pre-existing infrastructure issue
2025-12-20 17:46:27 +02:00

268 lines
9.3 KiB
C#

// -----------------------------------------------------------------------------
// IdempotencyMiddleware.cs
// Sprint: SPRINT_3500_0002_0003_proof_replay_api
// Task: T3 - Idempotency Middleware
// Description: Middleware for POST endpoint idempotency using Content-Digest header
// -----------------------------------------------------------------------------
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Scanner.Storage.Entities;
using StellaOps.Scanner.Storage.Repositories;
using StellaOps.Scanner.WebService.Options;
namespace StellaOps.Scanner.WebService.Middleware;
/// <summary>
/// Middleware that implements idempotency for POST endpoints using RFC 9530 Content-Digest header.
/// </summary>
public sealed class IdempotencyMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<IdempotencyMiddleware> _logger;
public IdempotencyMiddleware(
RequestDelegate next,
ILogger<IdempotencyMiddleware> logger)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task InvokeAsync(
HttpContext context,
IIdempotencyKeyRepository repository,
IOptions<IdempotencyOptions> options)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(repository);
ArgumentNullException.ThrowIfNull(options);
var opts = options.Value;
// Only apply to POST requests
if (!HttpMethods.IsPost(context.Request.Method))
{
await _next(context).ConfigureAwait(false);
return;
}
// Check if idempotency is enabled
if (!opts.Enabled)
{
await _next(context).ConfigureAwait(false);
return;
}
// Check if this endpoint is in the list of idempotent endpoints
var path = context.Request.Path.Value ?? string.Empty;
if (!IsIdempotentEndpoint(path, opts.IdempotentEndpoints))
{
await _next(context).ConfigureAwait(false);
return;
}
// Get or compute Content-Digest
var contentDigest = await GetOrComputeContentDigestAsync(context.Request).ConfigureAwait(false);
if (string.IsNullOrEmpty(contentDigest))
{
await _next(context).ConfigureAwait(false);
return;
}
// Get tenant ID from claims or use default
var tenantId = GetTenantId(context);
// Check for existing idempotency key
var existingKey = await repository.TryGetAsync(tenantId, contentDigest, path, context.RequestAborted)
.ConfigureAwait(false);
if (existingKey is not null)
{
_logger.LogInformation(
"Returning cached response for idempotency key {KeyId}, tenant {TenantId}",
existingKey.KeyId, tenantId);
await WriteCachedResponseAsync(context, existingKey).ConfigureAwait(false);
return;
}
// Enable response buffering to capture response body
var originalBodyStream = context.Response.Body;
using var responseBuffer = new MemoryStream();
context.Response.Body = responseBuffer;
try
{
await _next(context).ConfigureAwait(false);
// Only cache successful responses (2xx)
if (context.Response.StatusCode >= 200 && context.Response.StatusCode < 300)
{
responseBuffer.Position = 0;
var responseBody = await new StreamReader(responseBuffer).ReadToEndAsync(context.RequestAborted)
.ConfigureAwait(false);
var idempotencyKey = new IdempotencyKeyRow
{
TenantId = tenantId,
ContentDigest = contentDigest,
EndpointPath = path,
ResponseStatus = context.Response.StatusCode,
ResponseBody = responseBody,
ResponseHeaders = SerializeHeaders(context.Response.Headers),
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.Add(opts.Window)
};
try
{
await repository.SaveAsync(idempotencyKey, context.RequestAborted).ConfigureAwait(false);
_logger.LogDebug(
"Cached idempotency key for tenant {TenantId}, digest {ContentDigest}",
tenantId, contentDigest);
}
catch (Exception ex)
{
// Log but don't fail the request if caching fails
_logger.LogWarning(ex, "Failed to cache idempotency key");
}
}
// Copy buffered response to original stream
responseBuffer.Position = 0;
await responseBuffer.CopyToAsync(originalBodyStream, context.RequestAborted).ConfigureAwait(false);
}
finally
{
context.Response.Body = originalBodyStream;
}
}
private static bool IsIdempotentEndpoint(string path, IReadOnlyList<string> idempotentEndpoints)
{
foreach (var pattern in idempotentEndpoints)
{
if (path.StartsWith(pattern, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static async Task<string?> GetOrComputeContentDigestAsync(HttpRequest request)
{
// Check for existing Content-Digest header per RFC 9530
if (request.Headers.TryGetValue("Content-Digest", out var digestHeader) &&
!string.IsNullOrWhiteSpace(digestHeader))
{
return digestHeader.ToString();
}
// Compute digest from request body
if (request.ContentLength is null or 0)
{
return null;
}
request.EnableBuffering();
request.Body.Position = 0;
using var sha256 = SHA256.Create();
var hash = await sha256.ComputeHashAsync(request.Body).ConfigureAwait(false);
request.Body.Position = 0;
var base64Hash = Convert.ToBase64String(hash);
return $"sha-256=:{base64Hash}:";
}
private static string GetTenantId(HttpContext context)
{
// Try to get tenant from claims
var tenantClaim = context.User?.FindFirst("tenant_id")?.Value;
if (!string.IsNullOrEmpty(tenantClaim))
{
return tenantClaim;
}
// Fall back to client IP or default
var clientIp = context.Connection.RemoteIpAddress?.ToString();
return !string.IsNullOrEmpty(clientIp) ? $"ip:{clientIp}" : "default";
}
private static async Task WriteCachedResponseAsync(HttpContext context, IdempotencyKeyRow key)
{
context.Response.StatusCode = key.ResponseStatus;
context.Response.ContentType = "application/json";
// Add idempotency headers
context.Response.Headers["X-Idempotency-Key"] = key.KeyId.ToString();
context.Response.Headers["X-Idempotency-Cached"] = "true";
// Replay cached headers
if (!string.IsNullOrEmpty(key.ResponseHeaders))
{
try
{
var headers = JsonSerializer.Deserialize<Dictionary<string, string>>(key.ResponseHeaders);
if (headers is not null)
{
foreach (var (name, value) in headers)
{
if (!IsRestrictedHeader(name))
{
context.Response.Headers[name] = value;
}
}
}
}
catch
{
// Ignore header deserialization errors
}
}
if (!string.IsNullOrEmpty(key.ResponseBody))
{
await context.Response.WriteAsync(key.ResponseBody).ConfigureAwait(false);
}
}
private static string? SerializeHeaders(IHeaderDictionary headers)
{
var selected = new Dictionary<string, string>();
foreach (var header in headers)
{
if (ShouldCacheHeader(header.Key))
{
selected[header.Key] = header.Value.ToString();
}
}
return selected.Count > 0 ? JsonSerializer.Serialize(selected) : null;
}
private static bool ShouldCacheHeader(string name)
{
// Only cache specific headers
return name.StartsWith("X-", StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "Location", StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "Content-Digest", StringComparison.OrdinalIgnoreCase);
}
private static bool IsRestrictedHeader(string name)
{
// Headers that should not be replayed
return string.Equals(name, "Content-Length", StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "Transfer-Encoding", StringComparison.OrdinalIgnoreCase) ||
string.Equals(name, "Connection", StringComparison.OrdinalIgnoreCase);
}
}