Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Extensions/RateLimitingExtensions.cs
2026-02-01 21:37:40 +02:00

129 lines
4.8 KiB
C#

// -----------------------------------------------------------------------------
// RateLimitingExtensions.cs
// Sprint: SPRINT_3500_0002_0003_proof_replay_api
// Task: T4 - Rate Limiting
// Description: Rate limiting configuration for proof replay endpoints
// -----------------------------------------------------------------------------
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Scanner.WebService.Security;
using System.Threading.RateLimiting;
namespace StellaOps.Scanner.WebService.Extensions;
/// <summary>
/// Extensions for configuring rate limiting on proof replay endpoints.
/// </summary>
public static class RateLimitingExtensions
{
/// <summary>
/// Policy name for proof replay rate limiting (100 req/hr per tenant).
/// </summary>
public const string ProofReplayPolicy = "ProofReplay";
/// <summary>
/// Policy name for scan manifest rate limiting (100 req/hr per tenant).
/// </summary>
public const string ManifestPolicy = "Manifest";
/// <summary>
/// Add rate limiting services for scanner endpoints (proof replay, manifest, etc.).
/// </summary>
public static IServiceCollection AddScannerRateLimiting(this IServiceCollection services)
{
services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Proof replay: 100 requests per hour per tenant
options.AddPolicy(ProofReplayPolicy, context =>
{
var tenantId = GetTenantId(context);
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"proof-replay:{tenantId}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromHours(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0 // No queuing; immediate rejection
});
});
// Manifest: 100 requests per hour per tenant
options.AddPolicy(ManifestPolicy, context =>
{
var tenantId = GetTenantId(context);
return RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"manifest:{tenantId}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromHours(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
});
// Configure rejection response
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
context.HttpContext.Response.Headers.RetryAfter = "3600"; // 1 hour
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter =
((int)retryAfter.TotalSeconds).ToString();
}
await context.HttpContext.Response.WriteAsJsonAsync(new
{
type = "https://stellaops.org/problems/rate-limit",
title = "Too Many Requests",
status = 429,
detail = "Rate limit exceeded. Please wait before making more requests.",
retryAfterSeconds = context.HttpContext.Response.Headers.RetryAfter.ToString()
}, cancellationToken);
};
});
return services;
}
/// <summary>
/// Extract tenant ID from the HTTP context for rate limiting partitioning.
/// </summary>
private static string GetTenantId(HttpContext context)
{
// Try to get tenant from claims
var tenantClaim = context.User?.FindFirst(ScannerClaims.TenantId);
if (tenantClaim is not null && !string.IsNullOrWhiteSpace(tenantClaim.Value))
{
return tenantClaim.Value;
}
// Fallback to tenant header
if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var headerValue) &&
!string.IsNullOrWhiteSpace(headerValue))
{
return headerValue.ToString();
}
// Fallback to IP address for unauthenticated requests
return context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}
}
/// <summary>
/// Scanner claims constants.
/// </summary>
public static class ScannerClaims
{
public const string TenantId = "tenant_id";
}