mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
4a33b96db2
Replace the denormalized per-tenant RateLimitRequestsPerMinute mirror (and its SaveConfigurationAsync sync job) with compute-at-read resolution: effective limit = per-tenant override ?? global default. Fixes the clobbering bug where a per-tenant edit to the base field was overwritten on the next global config save. - Model: drop TenantConfiguration.RateLimitRequestsPerMinute; add nullable CustomUserRateLimitRequestsPerMinute (device override CustomRateLimit... kept). - RateLimitResolver centralizes precedence (device + user); both paths call it. - SecurityValidator reads the global default (AdminConfigurationService threaded through ValidateSecurityAsync + all device-auth callers); 5-min cached. - UserRateLimitMiddleware: per-tenant user override for tenant-scoped callers only (skipped for HasGlobalScope = GlobalAdmin|GlobalReader); GA -> GA budget, others -> user default. Uses GetConfigurationIfExistsAsync so an unregistered-tenant JWT can no longer auto-create a config row via read endpoints. - Validation: reject rate limits < 1 at the write functions; RateLimitService clamps < 1 to 1 as a safety net (avoids Min()-on-empty / throttle-everything). - Web: Global Settings exposes User + GlobalAdmin limits; tenant modal writes the override fields (blank = inherit); report + usage-plans updated. Legacy RateLimitRequestsPerMinute Table column is ignored on read and dropped on the next Replace save (self-cleaning; no migration). Backend 2577 xUnit green, tsc clean, web 506 vitest green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
161 lines
6.5 KiB
C#
161 lines
6.5 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace AutopilotMonitor.Functions.Services
|
|
{
|
|
/// <summary>
|
|
/// Rate limiting service to prevent DoS attacks
|
|
/// Uses sliding window algorithm for fair rate limiting per device
|
|
/// </summary>
|
|
public class RateLimitService
|
|
{
|
|
private readonly IMemoryCache _cache;
|
|
private readonly ILogger<RateLimitService> _logger;
|
|
private readonly TimeSpan _windowDuration;
|
|
|
|
// Dedicated lock objects per device to avoid race conditions when cache evicts the request history list.
|
|
// Using the list itself as a lock target is unsafe because cache eviction can create a new list instance,
|
|
// causing different threads to lock on different objects.
|
|
private readonly ConcurrentDictionary<string, object> _locks = new();
|
|
|
|
public RateLimitService(IMemoryCache cache, ILogger<RateLimitService> logger)
|
|
{
|
|
_cache = cache;
|
|
_logger = logger;
|
|
_windowDuration = TimeSpan.FromMinutes(1);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if a device has exceeded its rate limit
|
|
/// </summary>
|
|
/// <param name="deviceThumbprint">Certificate thumbprint identifying the device</param>
|
|
/// <param name="maxRequestsPerMinute">Maximum requests per minute (from tenant configuration)</param>
|
|
/// <returns>Rate limit result with details</returns>
|
|
public RateLimitResult CheckRateLimit(string deviceThumbprint, int maxRequestsPerMinute = 100)
|
|
{
|
|
if (string.IsNullOrEmpty(deviceThumbprint))
|
|
{
|
|
return new RateLimitResult
|
|
{
|
|
IsAllowed = false,
|
|
Message = "No device identifier provided"
|
|
};
|
|
}
|
|
|
|
// Defense-in-depth: the sliding-window logic below assumes a positive limit. A value of 0
|
|
// (or negative) makes `Count >= max` true even at Count==0, then `requestHistory.Min()`
|
|
// throws on the empty history — 500 on the device path, fail-open on the user path. Clamp
|
|
// any bad stored/config value to a sane floor so a misconfiguration can never break traffic.
|
|
if (maxRequestsPerMinute < 1)
|
|
maxRequestsPerMinute = 1;
|
|
|
|
var cacheKey = $"ratelimit_{deviceThumbprint}";
|
|
var now = DateTime.UtcNow;
|
|
|
|
// Get or create request history for this device
|
|
var lockObj = _locks.GetOrAdd(cacheKey, _ => new object());
|
|
var requestHistory = _cache.GetOrCreate(cacheKey, entry =>
|
|
{
|
|
entry.SlidingExpiration = _windowDuration;
|
|
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
|
|
entry.RegisterPostEvictionCallback((key, _, _, _) => _locks.TryRemove((string)key, out _));
|
|
return new List<DateTime>();
|
|
})!;
|
|
|
|
lock (lockObj)
|
|
{
|
|
// Remove requests outside the sliding window
|
|
var windowStart = now.Subtract(_windowDuration);
|
|
requestHistory.RemoveAll(timestamp => timestamp < windowStart);
|
|
|
|
// Check if limit exceeded
|
|
if (requestHistory.Count >= maxRequestsPerMinute)
|
|
{
|
|
var oldestRequest = requestHistory.Min();
|
|
var retryAfter = oldestRequest.Add(_windowDuration).Subtract(now);
|
|
|
|
// Structured fields so a 429 is queryable in App Insights (count vs limit vs
|
|
// window). The key may be a cert thumbprint, a bootstrap token, or a UPN
|
|
// depending on caller, so only an 8-char prefix is logged — never the full key
|
|
// (avoids leaking tokens/PII). Device/tenant/IP identity is on the paired 429
|
|
// request row (same operation_Id), enriched in RequestTelemetryMiddleware.
|
|
_logger.LogWarning(
|
|
"Rate limit exceeded: {RequestsInWindow}/{MaxRequests} requests in {WindowSeconds:F0}s window (key prefix {RateLimitKeyPrefix})",
|
|
requestHistory.Count,
|
|
maxRequestsPerMinute,
|
|
_windowDuration.TotalSeconds,
|
|
deviceThumbprint.Length >= 8 ? deviceThumbprint.Substring(0, 8) : deviceThumbprint);
|
|
|
|
return new RateLimitResult
|
|
{
|
|
IsAllowed = false,
|
|
Message = $"Rate limit exceeded: {maxRequestsPerMinute} requests per minute",
|
|
RequestsInWindow = requestHistory.Count,
|
|
MaxRequests = maxRequestsPerMinute,
|
|
RetryAfter = retryAfter,
|
|
WindowDuration = _windowDuration
|
|
};
|
|
}
|
|
|
|
// Add current request to history
|
|
requestHistory.Add(now);
|
|
|
|
// Update cache
|
|
_cache.Set(cacheKey, requestHistory, new MemoryCacheEntryOptions
|
|
{
|
|
SlidingExpiration = _windowDuration,
|
|
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
|
|
});
|
|
|
|
return new RateLimitResult
|
|
{
|
|
IsAllowed = true,
|
|
RequestsInWindow = requestHistory.Count,
|
|
MaxRequests = maxRequestsPerMinute,
|
|
WindowDuration = _windowDuration
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Result of rate limit check
|
|
/// </summary>
|
|
public class RateLimitResult
|
|
{
|
|
/// <summary>
|
|
/// Whether the request is allowed
|
|
/// </summary>
|
|
public bool IsAllowed { get; set; }
|
|
|
|
/// <summary>
|
|
/// Error message if rate limit exceeded
|
|
/// </summary>
|
|
public string? Message { get; set; }
|
|
|
|
/// <summary>
|
|
/// Number of requests in current window
|
|
/// </summary>
|
|
public int RequestsInWindow { get; set; }
|
|
|
|
/// <summary>
|
|
/// Maximum allowed requests
|
|
/// </summary>
|
|
public int MaxRequests { get; set; }
|
|
|
|
/// <summary>
|
|
/// Time to wait before retrying (if rate limited)
|
|
/// </summary>
|
|
public TimeSpan? RetryAfter { get; set; }
|
|
|
|
/// <summary>
|
|
/// Window duration for rate limiting
|
|
/// </summary>
|
|
public TimeSpan WindowDuration { get; set; }
|
|
}
|
|
}
|