using System; using System.Threading.RateLimiting; using GB5Shared.DTO.Framework.Enum; using GB5Shared.DTO.Framework.ResponseStandard; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using System.Text.Json; namespace GB5Shared.RateLimit { /// /// Per-tenant sliding-window rate limiter for all GB5 SL projects. /// /// Usage in Program.cs (add once, before app.UseAuthentication()): /// /// builder.Services.AddGB5RateLimiting(builder.Configuration); /// // ... /// app.UseRateLimiter(); // must be before UseFastEndpoints() /// /// /// appsettings.json configuration (all keys optional — defaults shown): /// /// "RateLimit": { /// "Enabled": true, /// "PermitLimit": 1000, /// "WindowSeconds": 60, /// "SegmentsPerWindow": 6, /// "QueueLimit": 0 /// } /// /// /// On-prem deployments: set "Enabled": false to bypass rate limiting entirely. /// SaaS deployments: tune PermitLimit per tier (e.g., 500 for standard, 2000 for enterprise). /// /// Partitioning: /// Requests are partitioned by the ClientId extracted from the Login JSON header. /// Unauthenticated / malformed-header requests are grouped under "anonymous". /// Dapr sidecar routes (/dapr/*) and health probes are never rate-limited. /// /// Rejection: /// Returns HTTP 429 with a fully-structured ResponseStandardDTO matching all /// other GB5 API error envelopes — the client receives a clear, actionable message. /// public static class GB5RateLimitExtensions { private static readonly JsonSerializerOptions _jsonOpts = new() { PropertyNamingPolicy = null, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }; public static IServiceCollection AddGB5RateLimiting( this IServiceCollection services, IConfiguration configuration) { bool enabled = configuration.GetValue("RateLimit:Enabled", defaultValue: true); int permitLimit = configuration.GetValue("RateLimit:PermitLimit", 1000); int windowSeconds = configuration.GetValue("RateLimit:WindowSeconds", 60); int segmentsPerWin = configuration.GetValue("RateLimit:SegmentsPerWindow", 6); int queueLimit = configuration.GetValue("RateLimit:QueueLimit", 0); if (!enabled) return services; // no-op for on-prem / local dev services.AddRateLimiter(opts => { opts.AddPolicy("per-tenant", context => { // Bypass: Dapr sidecar, health probes — never subject to rate limiting var path = context.Request.Path.Value ?? string.Empty; if (path.StartsWith("/dapr/", StringComparison.OrdinalIgnoreCase) || path.Equals("/healthz", StringComparison.OrdinalIgnoreCase) || path.Equals("/health", StringComparison.OrdinalIgnoreCase)) { return RateLimitPartition.GetNoLimiter("system"); } string partitionKey = ExtractClientId(context); return RateLimitPartition.GetSlidingWindowLimiter( partitionKey, _ => new SlidingWindowRateLimiterOptions { PermitLimit = permitLimit, Window = TimeSpan.FromSeconds(windowSeconds), SegmentsPerWindow = segmentsPerWin, QueueLimit = queueLimit, QueueProcessingOrder = QueueProcessingOrder.OldestFirst, AutoReplenishment = true }); }); opts.RejectionStatusCode = 429; opts.OnRejected = async (ctx, token) => { ctx.HttpContext.Response.StatusCode = 429; ctx.HttpContext.Response.ContentType = "application/json"; var body = new ResponseStandardDTO { Status = FrameworkEnumDTO.ResponseStatus.Failed, ErrorBody = $"Rate limit exceeded. Maximum {permitLimit} requests per " + $"{windowSeconds}s window per tenant. Please retry after a moment.", ErrorInnerException = string.Empty }; await ctx.HttpContext.Response.WriteAsync( JsonSerializer.Serialize(body, _jsonOpts), token); }; }); return services; } /// /// Extract ClientId from the Login JSON header for per-tenant partitioning. /// Falls back to "anonymous" on any parse failure so anonymous traffic is /// grouped and limited together rather than bypassing the limiter. /// private static string ExtractClientId(HttpContext context) { try { if (context.Request.Headers.TryGetValue("Login", out var header) && !string.IsNullOrWhiteSpace(header)) { using var doc = JsonDocument.Parse(header.ToString()); if (doc.RootElement.TryGetProperty("ClientId", out var el) && el.ValueKind == JsonValueKind.Number) { return el.GetInt32().ToString(); } } } catch { // Malformed header — fall through to "anonymous" } return "anonymous"; } } }