mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(ratelimit): unify device+user limits as global default + per-tenant override
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>
This commit is contained in:
+21
-58
@@ -9,71 +9,30 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that <see cref="AdminConfigurationService.SaveConfigurationAsync"/>'s background
|
||||
/// rate-limit sync does NOT mutate <see cref="TenantConfiguration.UpdatedBy"/>.
|
||||
/// Rate limits are no longer mirrored into every tenant row. The effective per-tenant
|
||||
/// limit is computed at read time as <c>tenantOverride ?? global</c> (see SecurityValidator
|
||||
/// for the device path and UserRateLimitMiddleware for the user path). These tests pin the
|
||||
/// removal of the former background sync job:
|
||||
/// <list type="bullet">
|
||||
/// <item>Saving the admin config must NOT enumerate or write ANY tenant configuration.</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Regression: the sync used to overwrite UpdatedBy with the sentinel string
|
||||
/// <c>"System (Global Rate Limit Sync)"</c>. Downstream code in
|
||||
/// <c>PreviewWhitelistFunction</c> reads UpdatedBy as the tenant's onboarding-requester
|
||||
/// UPN and wrote it verbatim into TenantAdmins on preview approval — corrupting 10
|
||||
/// tenants before the bug was caught.
|
||||
/// Historical context: the sync used to copy <c>GlobalRateLimitRequestsPerMinute</c> into every
|
||||
/// tenant's base <c>RateLimitRequestsPerMinute</c> field (skipping tenants with a custom override).
|
||||
/// That both clobbered per-tenant edits to the base field on the next global save AND, in an even
|
||||
/// earlier incarnation, corrupted <c>UpdatedBy</c> on 10 tenants. Removing the sync entirely
|
||||
/// eliminates both foot-guns. The compute-at-read model makes a global change take effect for all
|
||||
/// non-override tenants without touching a single tenant row.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class AdminConfigurationRateLimitSyncTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveConfigurationAsync_RateLimitSync_PreservesUpdatedBy()
|
||||
public async Task SaveConfigurationAsync_DoesNotEnumerateOrWriteTenantConfigs()
|
||||
{
|
||||
var savedTenantConfigs = new List<TenantConfiguration>();
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
var repo = new Mock<IConfigRepository>(MockBehavior.Strict);
|
||||
repo.Setup(r => r.SaveAdminConfigurationAsync(It.IsAny<AdminConfiguration>()))
|
||||
.ReturnsAsync(true);
|
||||
repo.Setup(r => r.GetAllTenantConfigurationsAsync())
|
||||
.ReturnsAsync(new List<TenantConfiguration>
|
||||
{
|
||||
new() { TenantId = "t1", UpdatedBy = "onboarder.t1@contoso.com", RateLimitRequestsPerMinute = 50 },
|
||||
new() { TenantId = "t2", UpdatedBy = "real.user.t2@example.com", RateLimitRequestsPerMinute = 50 },
|
||||
});
|
||||
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
.Callback<TenantConfiguration>(c => savedTenantConfigs.Add(c))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var sut = new AdminConfigurationService(
|
||||
repo.Object,
|
||||
NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
var adminConfig = new AdminConfiguration
|
||||
{
|
||||
UpdatedBy = "global-admin@contoso.com",
|
||||
GlobalRateLimitRequestsPerMinute = 200,
|
||||
};
|
||||
|
||||
await sut.SaveConfigurationAsync(adminConfig);
|
||||
|
||||
Assert.Equal(2, savedTenantConfigs.Count);
|
||||
Assert.Equal("onboarder.t1@contoso.com", savedTenantConfigs[0].UpdatedBy);
|
||||
Assert.Equal("real.user.t2@example.com", savedTenantConfigs[1].UpdatedBy);
|
||||
// And the rate limit IS synced — sanity check the sync still runs
|
||||
Assert.All(savedTenantConfigs, c => Assert.Equal(200, c.RateLimitRequestsPerMinute));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveConfigurationAsync_RateLimitSync_SkipsTenantsWithCustomOverride()
|
||||
{
|
||||
var savedTenantConfigs = new List<TenantConfiguration>();
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.SaveAdminConfigurationAsync(It.IsAny<AdminConfiguration>()))
|
||||
.ReturnsAsync(true);
|
||||
repo.Setup(r => r.GetAllTenantConfigurationsAsync())
|
||||
.ReturnsAsync(new List<TenantConfiguration>
|
||||
{
|
||||
new() { TenantId = "custom-tenant", UpdatedBy = "owner@contoso.com",
|
||||
RateLimitRequestsPerMinute = 1000, CustomRateLimitRequestsPerMinute = 1000 },
|
||||
});
|
||||
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
|
||||
.Callback<TenantConfiguration>(c => savedTenantConfigs.Add(c))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var sut = new AdminConfigurationService(
|
||||
repo.Object,
|
||||
@@ -84,9 +43,13 @@ public class AdminConfigurationRateLimitSyncTests
|
||||
{
|
||||
UpdatedBy = "global-admin@contoso.com",
|
||||
GlobalRateLimitRequestsPerMinute = 200,
|
||||
UserRateLimitRequestsPerMinute = 240,
|
||||
});
|
||||
|
||||
// Tenant with custom override is skipped — UpdatedBy untouched either way
|
||||
Assert.Empty(savedTenantConfigs);
|
||||
// The admin config row is written...
|
||||
repo.Verify(r => r.SaveAdminConfigurationAsync(It.IsAny<AdminConfiguration>()), Times.Once);
|
||||
// ...but NO tenant configuration is enumerated or mutated (sync removed).
|
||||
repo.Verify(r => r.GetAllTenantConfigurationsAsync(), Times.Never);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,16 @@ public sealed class BootstrapAuthDisabledTenantTests
|
||||
.Setup(x => x.TryGetConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((config, true));
|
||||
|
||||
var adminConfigServiceMock = new Mock<AdminConfigurationService>(
|
||||
configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
|
||||
{ CallBase = false };
|
||||
adminConfigServiceMock
|
||||
.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration());
|
||||
|
||||
return new SecurityValidator(
|
||||
configService: configServiceMock.Object,
|
||||
adminConfigService: adminConfigServiceMock.Object,
|
||||
rateLimitService: null!,
|
||||
autopilotDeviceValidator: null!,
|
||||
corporateIdentifierValidator: null!,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the "effective rate limit" precedence used by both the device path (SecurityValidator)
|
||||
/// and the user path (UserRateLimitMiddleware). Rate limits are a global default plus an optional
|
||||
/// per-tenant override, resolved at read time (no denormalized mirror, no sync job).
|
||||
/// </summary>
|
||||
public class RateLimitResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveDeviceLimit_NoOverride_UsesGlobalDefault()
|
||||
{
|
||||
Assert.Equal(100, RateLimitResolver.ResolveDeviceLimit(tenantOverride: null, globalDefault: 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDeviceLimit_OverrideSet_WinsOverGlobal()
|
||||
{
|
||||
Assert.Equal(250, RateLimitResolver.ResolveDeviceLimit(tenantOverride: 250, globalDefault: 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveUserLimit_StandardUser_NoOverride_UsesGlobalUserDefault()
|
||||
{
|
||||
var limit = RateLimitResolver.ResolveUserLimit(
|
||||
isGlobalAdmin: false, tenantUserOverride: null, globalUserDefault: 120, globalAdminDefault: 600);
|
||||
Assert.Equal(120, limit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveUserLimit_StandardUser_OverrideSet_WinsOverGlobalUser()
|
||||
{
|
||||
var limit = RateLimitResolver.ResolveUserLimit(
|
||||
isGlobalAdmin: false, tenantUserOverride: 300, globalUserDefault: 120, globalAdminDefault: 600);
|
||||
Assert.Equal(300, limit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveUserLimit_GlobalAdmin_UsesGlobalAdminDefault_IgnoringTenantOverride()
|
||||
{
|
||||
// A per-tenant user override must NOT apply to a Global Admin (cross-tenant identity).
|
||||
var limit = RateLimitResolver.ResolveUserLimit(
|
||||
isGlobalAdmin: true, tenantUserOverride: 300, globalUserDefault: 120, globalAdminDefault: 600);
|
||||
Assert.Equal(600, limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A zero/negative rate limit (from a corrupt row or an un-validated write) must not crash the
|
||||
/// sliding-window logic — `Count >= max` would be true at Count==0 and `requestHistory.Min()`
|
||||
/// would throw on the empty history. CheckRateLimit clamps the limit to a floor of 1.
|
||||
/// </summary>
|
||||
public class RateLimitServiceClampTests
|
||||
{
|
||||
private static RateLimitService NewService() =>
|
||||
new(new MemoryCache(new MemoryCacheOptions()), NullLogger<RateLimitService>.Instance);
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-5)]
|
||||
public void CheckRateLimit_ZeroOrNegativeLimit_DoesNotThrow_AndClampsToOne(int badLimit)
|
||||
{
|
||||
var sut = NewService();
|
||||
|
||||
// First request is allowed (floor of 1, history empty → Count 0 < 1).
|
||||
var first = sut.CheckRateLimit("thumb-A", badLimit);
|
||||
Assert.True(first.IsAllowed);
|
||||
Assert.Equal(1, first.MaxRequests);
|
||||
|
||||
// Second request within the window exceeds the clamped limit — throttled, no exception.
|
||||
var second = sut.CheckRateLimit("thumb-A", badLimit);
|
||||
Assert.False(second.IsAllowed);
|
||||
Assert.Equal(1, second.MaxRequests);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -335,8 +335,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "Disabled", config.Disabled },
|
||||
{ "DisabledReason", config.DisabledReason },
|
||||
{ "DisabledUntil", config.DisabledUntil },
|
||||
{ "RateLimitRequestsPerMinute", config.RateLimitRequestsPerMinute },
|
||||
{ "CustomRateLimitRequestsPerMinute", config.CustomRateLimitRequestsPerMinute },
|
||||
{ "CustomUserRateLimitRequestsPerMinute", config.CustomUserRateLimitRequestsPerMinute },
|
||||
{ "ManufacturerWhitelist", config.ManufacturerWhitelist },
|
||||
{ "ModelWhitelist", config.ModelWhitelist },
|
||||
{ "ValidateAutopilotDevice", config.ValidateAutopilotDevice },
|
||||
@@ -424,8 +424,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
Disabled = entity.GetBoolean("Disabled") ?? false,
|
||||
DisabledReason = entity.GetString("DisabledReason"),
|
||||
DisabledUntil = entity.GetDateTime("DisabledUntil"),
|
||||
RateLimitRequestsPerMinute = entity.GetInt32("RateLimitRequestsPerMinute") ?? 100,
|
||||
CustomRateLimitRequestsPerMinute = entity.GetInt32("CustomRateLimitRequestsPerMinute"),
|
||||
CustomUserRateLimitRequestsPerMinute = entity.GetInt32("CustomUserRateLimitRequestsPerMinute"),
|
||||
ManufacturerWhitelist = entity.GetString("ManufacturerWhitelist") ?? "Dell*,HP*,Lenovo*,Microsoft Corporation",
|
||||
ModelWhitelist = entity.GetString("ModelWhitelist") ?? "*",
|
||||
ValidateAutopilotDevice = entity.GetBoolean("ValidateAutopilotDevice") ?? entity.GetBoolean("ValidateSerialNumber") ?? false,
|
||||
|
||||
+4
-1
@@ -18,6 +18,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
private readonly ILogger<BootstrapGetAgentConfigFunction> _logger;
|
||||
private readonly GetAgentConfigFunction _inner;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||||
@@ -28,6 +29,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
ILogger<BootstrapGetAgentConfigFunction> logger,
|
||||
GetAgentConfigFunction inner,
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
RateLimitService rateLimitService,
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
@@ -37,6 +39,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
_logger = logger;
|
||||
_inner = inner;
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
@@ -76,7 +79,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
}
|
||||
|
||||
var (validation, errorResponse) = await req.ValidateSecurityAsync(
|
||||
tenantId, _configService, _rateLimitService,
|
||||
tenantId, _configService, _adminConfigService, _rateLimitService,
|
||||
_autopilotDeviceValidator, _corporateIdentifierValidator,
|
||||
_logger, bootstrapSessionService: _bootstrapSessionService,
|
||||
deviceAssociationValidator: _deviceAssociationValidator);
|
||||
|
||||
+4
-1
@@ -21,6 +21,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
private readonly ILogger<BootstrapRegisterSessionFunction> _logger;
|
||||
private readonly RegisterSessionFunction _inner;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||||
@@ -31,6 +32,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
ILogger<BootstrapRegisterSessionFunction> logger,
|
||||
RegisterSessionFunction inner,
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
RateLimitService rateLimitService,
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
@@ -40,6 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
_logger = logger;
|
||||
_inner = inner;
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
@@ -91,7 +94,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
}
|
||||
|
||||
var (validation, errorResponse) = await req.ValidateSecurityAsync(
|
||||
registration.TenantId, _configService, _rateLimitService,
|
||||
registration.TenantId, _configService, _adminConfigService, _rateLimitService,
|
||||
_autopilotDeviceValidator, _corporateIdentifierValidator,
|
||||
_logger, registration.SessionId,
|
||||
bootstrapSessionService: _bootstrapSessionService,
|
||||
|
||||
+4
-1
@@ -18,6 +18,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
private readonly ILogger<BootstrapReportAgentErrorFunction> _logger;
|
||||
private readonly ReportAgentErrorFunction _inner;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||||
@@ -28,6 +29,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
ILogger<BootstrapReportAgentErrorFunction> logger,
|
||||
ReportAgentErrorFunction inner,
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
RateLimitService rateLimitService,
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
@@ -37,6 +39,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
_logger = logger;
|
||||
_inner = inner;
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
@@ -75,7 +78,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
}
|
||||
|
||||
var (_, errorResponse) = await req.ValidateSecurityAsync(
|
||||
tenantId, _configService, _rateLimitService,
|
||||
tenantId, _configService, _adminConfigService, _rateLimitService,
|
||||
_autopilotDeviceValidator, _corporateIdentifierValidator,
|
||||
_logger, bootstrapSessionService: _bootstrapSessionService,
|
||||
deviceAssociationValidator: _deviceAssociationValidator);
|
||||
|
||||
@@ -78,6 +78,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
var (validation, errorResponse) = await req.ValidateSecurityAsync(
|
||||
tenantId,
|
||||
_configService,
|
||||
_adminConfigService,
|
||||
_rateLimitService,
|
||||
_autopilotDeviceValidator,
|
||||
_corporateIdentifierValidator,
|
||||
|
||||
+14
@@ -60,6 +60,20 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// Rate limits must be positive: a zero/negative value would throttle every request
|
||||
// (RateLimitService clamps as a last resort, but reject at the edge for a clear error).
|
||||
var rateLimitError =
|
||||
config.GlobalRateLimitRequestsPerMinute < 1 ? "Global Device API Rate Limit" :
|
||||
config.UserRateLimitRequestsPerMinute < 1 ? "Global User API Rate Limit" :
|
||||
config.GlobalAdminRateLimitRequestsPerMinute < 1 ? "Global Admin API Rate Limit" :
|
||||
null;
|
||||
if (rateLimitError != null)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = $"{rateLimitError} must be at least 1 request per minute." });
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// Set the actual user identifier for audit logging
|
||||
config.UpdatedBy = userIdentifier;
|
||||
|
||||
|
||||
+15
-2
@@ -63,6 +63,19 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// Per-tenant rate-limit overrides are optional (null = inherit global), but if provided
|
||||
// they must be positive — a zero/negative override would throttle every request.
|
||||
var customLimitError =
|
||||
config.CustomRateLimitRequestsPerMinute is int dev && dev < 1 ? "Device API Rate Limit" :
|
||||
config.CustomUserRateLimitRequestsPerMinute is int usr && usr < 1 ? "User API Rate Limit" :
|
||||
null;
|
||||
if (customLimitError != null)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = $"{customLimitError} override must be at least 1 request per minute (or left blank to inherit the global default)." });
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// Load the stored config up-front so we can (1) restore any redacted secret placeholders
|
||||
// before validation/save and (2) protect GA-only fields below.
|
||||
var existingConfig = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
@@ -127,7 +140,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
config.UnrestrictedModeEnabled != existingConfig.UnrestrictedModeEnabled ||
|
||||
config.EntraAppRolesEnabled != existingConfig.EntraAppRolesEnabled ||
|
||||
config.CustomRateLimitRequestsPerMinute != existingConfig.CustomRateLimitRequestsPerMinute ||
|
||||
config.RateLimitRequestsPerMinute != existingConfig.RateLimitRequestsPerMinute ||
|
||||
config.CustomUserRateLimitRequestsPerMinute != existingConfig.CustomUserRateLimitRequestsPerMinute ||
|
||||
config.Disabled != existingConfig.Disabled ||
|
||||
config.ValidateDeviceAssociation != existingConfig.ValidateDeviceAssociation)
|
||||
{
|
||||
@@ -141,7 +154,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
config.UnrestrictedModeEnabled = existingConfig.UnrestrictedModeEnabled;
|
||||
config.EntraAppRolesEnabled = existingConfig.EntraAppRolesEnabled;
|
||||
config.CustomRateLimitRequestsPerMinute = existingConfig.CustomRateLimitRequestsPerMinute;
|
||||
config.RateLimitRequestsPerMinute = existingConfig.RateLimitRequestsPerMinute;
|
||||
config.CustomUserRateLimitRequestsPerMinute = existingConfig.CustomUserRateLimitRequestsPerMinute;
|
||||
config.Disabled = existingConfig.Disabled;
|
||||
config.DisabledReason = existingConfig.DisabledReason;
|
||||
config.DisabledUntil = existingConfig.DisabledUntil;
|
||||
|
||||
+4
@@ -47,6 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
|
||||
private readonly ILogger<GetDiagnosticsUploadUrlFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||||
@@ -57,6 +58,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
public GetDiagnosticsUploadUrlFunction(
|
||||
ILogger<GetDiagnosticsUploadUrlFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
RateLimitService rateLimitService,
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
@@ -66,6 +68,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
@@ -114,6 +117,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
var (validation, errorResponse) = await req.ValidateSecurityAsync(
|
||||
requestBody.TenantId,
|
||||
_configService,
|
||||
_adminConfigService,
|
||||
_rateLimitService,
|
||||
_autopilotDeviceValidator,
|
||||
_corporateIdentifierValidator,
|
||||
|
||||
@@ -44,6 +44,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
private readonly IIndexReconcileProducer _indexReconcileProducer;
|
||||
private readonly EventIngestProcessor _eventProcessor;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||||
@@ -60,6 +61,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
IIndexReconcileProducer indexReconcileProducer,
|
||||
EventIngestProcessor eventProcessor,
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
RateLimitService rateLimitService,
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
@@ -75,6 +77,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
_indexReconcileProducer = indexReconcileProducer;
|
||||
_eventProcessor = eventProcessor;
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
@@ -102,6 +105,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
var (validation, errorResponse) = await req.ValidateSecurityAsync(
|
||||
tenantIdHeader,
|
||||
_configService,
|
||||
_adminConfigService,
|
||||
_rateLimitService,
|
||||
_autopilotDeviceValidator,
|
||||
_corporateIdentifierValidator,
|
||||
|
||||
@@ -26,6 +26,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
{
|
||||
private readonly ILogger<ReportAgentErrorFunction> _logger;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||||
@@ -36,6 +37,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
public ReportAgentErrorFunction(
|
||||
ILogger<ReportAgentErrorFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
RateLimitService rateLimitService,
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
@@ -45,6 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
{
|
||||
_logger = logger;
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
@@ -76,6 +79,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
var (_, errorResponse) = await req.ValidateSecurityAsync(
|
||||
tenantId,
|
||||
_configService,
|
||||
_adminConfigService,
|
||||
_rateLimitService,
|
||||
_autopilotDeviceValidator,
|
||||
_corporateIdentifierValidator,
|
||||
|
||||
@@ -32,12 +32,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
private readonly BootstrapSessionService _bootstrapSessionService;
|
||||
private readonly WebhookNotificationService _webhookNotificationService;
|
||||
private readonly SessionDeletionGuard _deletionGuard;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
|
||||
public RegisterSessionFunction(
|
||||
ILogger<RegisterSessionFunction> logger,
|
||||
ISessionRepository sessionRepo,
|
||||
IMetricsRepository metricsRepo,
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
RateLimitService rateLimitService,
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
@@ -50,6 +52,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
_sessionRepo = sessionRepo;
|
||||
_metricsRepo = metricsRepo;
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
@@ -90,6 +93,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
var (validation, errorResponse2) = await req.ValidateSecurityAsync(
|
||||
registration.TenantId,
|
||||
_configService,
|
||||
_adminConfigService,
|
||||
_rateLimitService,
|
||||
_autopilotDeviceValidator,
|
||||
_corporateIdentifierValidator,
|
||||
|
||||
@@ -17,15 +17,18 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
private readonly ILogger<UserRateLimitMiddleware> _logger;
|
||||
|
||||
public UserRateLimitMiddleware(
|
||||
RateLimitService rateLimitService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
TenantConfigurationService tenantConfigService,
|
||||
ILogger<UserRateLimitMiddleware> logger)
|
||||
{
|
||||
_rateLimitService = rateLimitService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -52,9 +55,30 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
try
|
||||
{
|
||||
var config = await _adminConfigService.GetConfigurationAsync();
|
||||
var limit = requestContext.IsGlobalAdmin
|
||||
? config.GlobalAdminRateLimitRequestsPerMinute
|
||||
: config.UserRateLimitRequestsPerMinute;
|
||||
|
||||
// Per-tenant overrides apply ONLY to genuinely tenant-scoped callers. Platform roles with
|
||||
// cross-tenant scope (Global Admin AND Global Reader — HasGlobalScope) must not be limited by
|
||||
// any single tenant's override, since they aren't bound to one tenant. Delegated admins remain
|
||||
// scoped to their target tenant, so they still observe that tenant's override.
|
||||
// The tenant-config read is served from a 5-minute in-memory cache. Use the strict point-read
|
||||
// (GetConfigurationIfExistsAsync) — NOT GetConfigurationAsync, which auto-creates+persists a
|
||||
// default row. A valid JWT from an unregistered tenant hitting a read/self-service endpoint must
|
||||
// never materialize a tenant config row. No row → inherit global.
|
||||
int? tenantOverride = null;
|
||||
if (!requestContext.HasGlobalScope && !string.IsNullOrEmpty(requestContext.TargetTenantId))
|
||||
{
|
||||
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestContext.TargetTenantId);
|
||||
tenantOverride = tenantConfig?.CustomUserRateLimitRequestsPerMinute;
|
||||
}
|
||||
|
||||
// Base limit: Global Admins get the GA budget; everyone else (standard users AND read-only
|
||||
// Global Readers) gets the standard user default (with the tenant override applied above only
|
||||
// for tenant-scoped callers).
|
||||
var limit = RateLimitResolver.ResolveUserLimit(
|
||||
requestContext.IsGlobalAdmin,
|
||||
tenantOverride,
|
||||
config.UserRateLimitRequestsPerMinute,
|
||||
config.GlobalAdminRateLimitRequestsPerMinute);
|
||||
|
||||
var key = $"user_ratelimit_{requestContext.UserPrincipalName.ToLowerInvariant()}";
|
||||
result = _rateLimitService.CheckRateLimit(key, limit);
|
||||
|
||||
@@ -26,6 +26,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
this HttpRequestData req,
|
||||
string tenantId,
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
RateLimitService rateLimitService,
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
@@ -34,7 +35,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
BootstrapSessionService? bootstrapSessionService = null,
|
||||
DeviceAssociationValidator? deviceAssociationValidator = null)
|
||||
{
|
||||
var validator = new SecurityValidator(configService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger, bootstrapSessionService, deviceAssociationValidator);
|
||||
var validator = new SecurityValidator(configService, adminConfigService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger, bootstrapSessionService, deviceAssociationValidator);
|
||||
var validation = await validator.ValidateRequestAsync(req, tenantId, sessionId);
|
||||
|
||||
if (!validation.IsValid)
|
||||
|
||||
@@ -42,6 +42,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||||
@@ -51,6 +52,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
|
||||
public SecurityValidator(
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
RateLimitService rateLimitService,
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
@@ -59,6 +61,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
DeviceAssociationValidator? deviceAssociationValidator = null)
|
||||
{
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_rateLimitService = rateLimitService;
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
@@ -67,6 +70,21 @@ namespace AutopilotMonitor.Functions.Security
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective device (agent/cert) rate limit for a tenant:
|
||||
/// the per-tenant override if set, otherwise the global default. The global
|
||||
/// AdminConfiguration read is served from a 5-minute in-memory cache.
|
||||
/// </summary>
|
||||
private async Task<int> ResolveDeviceRateLimitAsync(TenantConfiguration config)
|
||||
{
|
||||
// The global read is served from a 5-minute in-memory cache (O(1) dictionary hit on the
|
||||
// hot path), so we always take it rather than branching on the override.
|
||||
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
||||
return RateLimitResolver.ResolveDeviceLimit(
|
||||
config.CustomRateLimitRequestsPerMinute,
|
||||
adminConfig.GlobalRateLimitRequestsPerMinute);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates request security (certificate, rate limit, hardware whitelist)
|
||||
/// </summary>
|
||||
@@ -177,7 +195,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
tenantId, bootstrapSession.ShortCode);
|
||||
|
||||
// Rate limit check for bootstrap auth (DoS protection)
|
||||
var bsRateLimitValue = config.CustomRateLimitRequestsPerMinute ?? config.RateLimitRequestsPerMinute;
|
||||
var bsRateLimitValue = await ResolveDeviceRateLimitAsync(config);
|
||||
var bsRateLimitResult = _rateLimitService.CheckRateLimit(
|
||||
$"bootstrap:{bootstrapTokenHeader}",
|
||||
bsRateLimitValue
|
||||
@@ -248,8 +266,8 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
|
||||
// 2. Check rate limit (DoS protection)
|
||||
// Use custom tenant rate limit if set, otherwise use the synced global rate limit
|
||||
var rateLimitValue = config.CustomRateLimitRequestsPerMinute ?? config.RateLimitRequestsPerMinute;
|
||||
// Effective limit = per-tenant override if set, otherwise the global default.
|
||||
var rateLimitValue = await ResolveDeviceRateLimitAsync(config);
|
||||
|
||||
var rateLimitResult = _rateLimitService.CheckRateLimit(
|
||||
certValidation.Thumbprint!,
|
||||
|
||||
@@ -136,8 +136,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
_logger.LogInformation($"Admin configuration saved by {config.UpdatedBy}");
|
||||
|
||||
// Sync global rate limit to all tenant configurations (only if not custom)
|
||||
await SyncRateLimitToTenantsAsync(config.GlobalRateLimitRequestsPerMinute);
|
||||
// NOTE: Rate limits are no longer mirrored into every tenant row. The effective
|
||||
// per-tenant limit is computed at read time as `tenantOverride ?? global`
|
||||
// (see SecurityValidator for the device path and UserRateLimitMiddleware for the
|
||||
// user path). This removes the former background sync job — and the clobbering
|
||||
// foot-gun where a per-tenant edit to the base field was overwritten on the next
|
||||
// global save.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -146,55 +150,6 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Syncs the global rate limit to all tenant configurations that don't have a custom override
|
||||
/// </summary>
|
||||
private async Task SyncRateLimitToTenantsAsync(int globalRateLimit)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation($"Syncing global rate limit ({globalRateLimit}) to all tenant configurations...");
|
||||
|
||||
var tenantsUpdated = 0;
|
||||
var tenantsSkipped = 0;
|
||||
|
||||
// Get all tenant configurations via repository
|
||||
var allConfigs = await _configRepo.GetAllTenantConfigurationsAsync();
|
||||
|
||||
foreach (var tenantConfig in allConfigs)
|
||||
{
|
||||
// Check if tenant has a custom rate limit override
|
||||
if (tenantConfig.CustomRateLimitRequestsPerMinute.HasValue)
|
||||
{
|
||||
// Skip tenants with custom rate limit
|
||||
tenantsSkipped++;
|
||||
_logger.LogDebug($"Skipping tenant {tenantConfig.TenantId} - has custom rate limit: {tenantConfig.CustomRateLimitRequestsPerMinute}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Update tenant's rate limit to match global default.
|
||||
// Deliberately do NOT touch UpdatedBy: this is a background sync, not an
|
||||
// edit by a real user, and downstream code (e.g. PreviewWhitelistFunction
|
||||
// auto-promote) treats UpdatedBy as the tenant's onboarding-requester UPN.
|
||||
// Clobbering it with a sentinel string corrupted 10 tenants' TenantAdmins
|
||||
// rows before this was caught. Audit trail stays via the LogInformation
|
||||
// line below and via LastUpdated.
|
||||
tenantConfig.RateLimitRequestsPerMinute = globalRateLimit;
|
||||
tenantConfig.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
await _configRepo.SaveTenantConfigurationAsync(tenantConfig);
|
||||
tenantsUpdated++;
|
||||
}
|
||||
|
||||
_logger.LogInformation($"Rate limit sync completed: {tenantsUpdated} tenants updated, {tenantsSkipped} tenants skipped (custom rate limit)");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error syncing rate limit to tenant configurations");
|
||||
// Don't throw - we don't want to fail the admin config save if sync fails
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates cache (forces reload on next request)
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Single source of truth for the "effective rate limit" precedence. Rate limits are stored as a
|
||||
/// global default (AdminConfiguration) plus an optional per-tenant override (TenantConfiguration);
|
||||
/// the effective value is computed at read time — there is no denormalized per-tenant mirror and
|
||||
/// no background sync job. Both the device path (<see cref="SecurityValidator"/>) and the user path
|
||||
/// (<c>UserRateLimitMiddleware</c>) resolve through here so the precedence stays consistent.
|
||||
/// </summary>
|
||||
public static class RateLimitResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Device (agent/cert) limit: per-tenant override if set, otherwise the global default.
|
||||
/// </summary>
|
||||
public static int ResolveDeviceLimit(int? tenantOverride, int globalDefault)
|
||||
=> tenantOverride ?? globalDefault;
|
||||
|
||||
/// <summary>
|
||||
/// User (portal/JWT) limit. Global Admins are limited by the global GA budget (cross-tenant;
|
||||
/// no per-tenant override applies). Standard users get the per-tenant override if set,
|
||||
/// otherwise the global user default.
|
||||
/// </summary>
|
||||
public static int ResolveUserLimit(bool isGlobalAdmin, int? tenantUserOverride, int globalUserDefault, int globalAdminDefault)
|
||||
=> isGlobalAdmin
|
||||
? globalAdminDefault
|
||||
: (tenantUserOverride ?? globalUserDefault);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
|
||||
@@ -76,19 +76,21 @@ namespace AutopilotMonitor.Shared.Models
|
||||
// ===== SECURITY SETTINGS =====
|
||||
|
||||
/// <summary>
|
||||
/// Rate limit: Maximum requests per minute per device
|
||||
/// This value is synchronized from the global AdminConfiguration
|
||||
/// Default: 100
|
||||
/// </summary>
|
||||
public int RateLimitRequestsPerMinute { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom rate limit for this tenant (overrides RateLimitRequestsPerMinute)
|
||||
/// If set (not null), this custom value takes precedence over the global default
|
||||
/// Note: This is only configurable by Global Admins directly in the database
|
||||
/// Optional per-tenant override for the device (agent/cert) API rate limit.
|
||||
/// If null, the effective limit is the global <c>AdminConfiguration.GlobalRateLimitRequestsPerMinute</c>.
|
||||
/// If set, this value takes precedence. Global-Admin-only (see UpdateTenantConfigurationFunction GA-gate).
|
||||
/// </summary>
|
||||
public int? CustomRateLimitRequestsPerMinute { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-tenant override for the user (portal/JWT) API rate limit applied to
|
||||
/// standard users (Tenant Admins, Operators, Viewers). If null, the effective limit is the
|
||||
/// global <c>AdminConfiguration.UserRateLimitRequestsPerMinute</c>. Global-Admin-only.
|
||||
/// Note: Global Admins are rate-limited by the global GlobalAdminRateLimitRequestsPerMinute
|
||||
/// (cross-tenant), so this override does not apply to them.
|
||||
/// </summary>
|
||||
public int? CustomUserRateLimitRequestsPerMinute { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant plan tier. Determines default API rate limits and feature gates.
|
||||
/// Values: "free", "pro", "enterprise". Default: "free".
|
||||
@@ -797,8 +799,8 @@ namespace AutopilotMonitor.Shared.Models
|
||||
Disabled = false,
|
||||
DisabledReason = null,
|
||||
DisabledUntil = null,
|
||||
RateLimitRequestsPerMinute = 100,
|
||||
CustomRateLimitRequestsPerMinute = null,
|
||||
CustomUserRateLimitRequestsPerMinute = null,
|
||||
ManufacturerWhitelist = "Dell*,HP*,Lenovo*,Microsoft Corporation",
|
||||
ModelWhitelist = "*",
|
||||
ValidateAutopilotDevice = false,
|
||||
|
||||
@@ -20,6 +20,10 @@ interface AdminConfigContextValue {
|
||||
// Config field state
|
||||
globalRateLimit: number;
|
||||
setGlobalRateLimit: (value: number) => void;
|
||||
userRateLimit: number;
|
||||
setUserRateLimit: (value: number) => void;
|
||||
globalAdminRateLimit: number;
|
||||
setGlobalAdminRateLimit: (value: number) => void;
|
||||
platformStatsBlobSasUrl: string;
|
||||
setPlatformStatsBlobSasUrl: (value: string) => void;
|
||||
collectorIdleTimeoutMinutes: number;
|
||||
@@ -123,6 +127,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
const [loadingConfig, setLoadingConfig] = useState(false);
|
||||
const [savingConfig, setSavingConfig] = useState(false);
|
||||
const [globalRateLimit, setGlobalRateLimit] = useState(100);
|
||||
const [userRateLimit, setUserRateLimit] = useState(120);
|
||||
const [globalAdminRateLimit, setGlobalAdminRateLimit] = useState(600);
|
||||
const [platformStatsBlobSasUrl, setPlatformStatsBlobSasUrl] = useState("");
|
||||
const [collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes] = useState(15);
|
||||
const [desktopDetectorNoCandidateTimeoutMinutes, setDesktopDetectorNoCandidateTimeoutMinutes] = useState(10);
|
||||
@@ -182,6 +188,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
const data: AdminConfiguration = await response.json();
|
||||
setAdminConfig(data);
|
||||
setGlobalRateLimit(data.globalRateLimitRequestsPerMinute);
|
||||
setUserRateLimit(data.userRateLimitRequestsPerMinute ?? 120);
|
||||
setGlobalAdminRateLimit(data.globalAdminRateLimitRequestsPerMinute ?? 600);
|
||||
setPlatformStatsBlobSasUrl(data.platformStatsBlobSasUrl ?? "");
|
||||
setCollectorIdleTimeoutMinutes(data.collectorIdleTimeoutMinutes ?? 15);
|
||||
setDesktopDetectorNoCandidateTimeoutMinutes(data.desktopDetectorNoCandidateTimeoutMinutes ?? 10);
|
||||
@@ -285,6 +293,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
const updatedConfig: AdminConfiguration = {
|
||||
...adminConfig,
|
||||
globalRateLimitRequestsPerMinute: globalRateLimit,
|
||||
userRateLimitRequestsPerMinute: userRateLimit,
|
||||
globalAdminRateLimitRequestsPerMinute: globalAdminRateLimit,
|
||||
platformStatsBlobSasUrl: platformStatsBlobSasUrl.trim(),
|
||||
collectorIdleTimeoutMinutes,
|
||||
desktopDetectorNoCandidateTimeoutMinutes,
|
||||
@@ -322,12 +332,14 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
} finally {
|
||||
setSavingConfig(false);
|
||||
}
|
||||
}, [isGlobalAdmin, adminConfig, globalRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, getAccessToken]);
|
||||
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, getAccessToken]);
|
||||
|
||||
// Reset admin config
|
||||
const handleResetAdminConfig = useCallback(() => {
|
||||
if (!adminConfig) return;
|
||||
setGlobalRateLimit(adminConfig.globalRateLimitRequestsPerMinute);
|
||||
setUserRateLimit(adminConfig.userRateLimitRequestsPerMinute ?? 120);
|
||||
setGlobalAdminRateLimit(adminConfig.globalAdminRateLimitRequestsPerMinute ?? 600);
|
||||
setPlatformStatsBlobSasUrl(adminConfig.platformStatsBlobSasUrl ?? "");
|
||||
setCollectorIdleTimeoutMinutes(adminConfig.collectorIdleTimeoutMinutes ?? 15);
|
||||
setDesktopDetectorNoCandidateTimeoutMinutes(adminConfig.desktopDetectorNoCandidateTimeoutMinutes ?? 10);
|
||||
@@ -470,6 +482,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
|
||||
<AdminConfigContext.Provider value={{
|
||||
adminConfig, setAdminConfig, loadingConfig, savingConfig, setSavingConfig,
|
||||
globalRateLimit, setGlobalRateLimit,
|
||||
userRateLimit, setUserRateLimit,
|
||||
globalAdminRateLimit, setGlobalAdminRateLimit,
|
||||
platformStatsBlobSasUrl, setPlatformStatsBlobSasUrl,
|
||||
collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes,
|
||||
desktopDetectorNoCandidateTimeoutMinutes, setDesktopDetectorNoCandidateTimeoutMinutes,
|
||||
|
||||
@@ -8,6 +8,10 @@ interface AdminConfigSettingsSectionProps {
|
||||
adminConfig: AdminConfiguration | null;
|
||||
globalRateLimit: number;
|
||||
setGlobalRateLimit: (value: number) => void;
|
||||
userRateLimit: number;
|
||||
setUserRateLimit: (value: number) => void;
|
||||
globalAdminRateLimit: number;
|
||||
setGlobalAdminRateLimit: (value: number) => void;
|
||||
platformStatsBlobSasUrl: string;
|
||||
setPlatformStatsBlobSasUrl: (value: string) => void;
|
||||
collectorIdleTimeoutMinutes: number;
|
||||
@@ -34,6 +38,10 @@ export function AdminConfigSettingsSection({
|
||||
adminConfig,
|
||||
globalRateLimit,
|
||||
setGlobalRateLimit,
|
||||
userRateLimit,
|
||||
setUserRateLimit,
|
||||
globalAdminRateLimit,
|
||||
setGlobalAdminRateLimit,
|
||||
platformStatsBlobSasUrl,
|
||||
setPlatformStatsBlobSasUrl,
|
||||
collectorIdleTimeoutMinutes,
|
||||
@@ -76,11 +84,11 @@ export function AdminConfigSettingsSection({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Global Rate Limit (Requests per Minute per Device)</span>
|
||||
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Global Device API Rate Limit (Requests per Minute per Device)</span>
|
||||
<p className="text-sm text-indigo-800 dark:text-gray-300 mb-2">
|
||||
Configure default DoS protection limits for all tenants. Normal enrollment generates ~10-30 requests/min.
|
||||
Default DoS protection limit for agent/device traffic (keyed per device certificate). Normal enrollment generates ~10-30 requests/min.
|
||||
<br />
|
||||
<strong className="text-indigo-900 dark:text-indigo-100">Note:</strong> Tenants cannot change this value. Only Global Admins can override per tenant in tenant management section.
|
||||
<strong className="text-indigo-900 dark:text-indigo-100">Note:</strong> Tenants cannot change this value. Global Admins can override it per tenant in the tenant management section (leave the tenant field blank to inherit this global default).
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
@@ -93,6 +101,40 @@ export function AdminConfigSettingsSection({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Global User API Rate Limit (Requests per Minute per User)</span>
|
||||
<p className="text-sm text-indigo-800 dark:text-gray-300 mb-2">
|
||||
Default rate limit for authenticated portal/API traffic from standard users (Tenant Admins, Operators, Viewers), keyed per user (UPN). Global Admins can override it per tenant (blank = inherit this global default).
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
value={userRateLimit}
|
||||
onChange={(e) => setUserRateLimit(parseInt(e.target.value) || 120)}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Global Admin API Rate Limit (Requests per Minute per Global Admin)</span>
|
||||
<p className="text-sm text-indigo-800 dark:text-gray-300 mb-2">
|
||||
Rate limit for authenticated portal/API traffic from Global Admins, keyed per user (UPN). Higher budget for cross-tenant work; not exempt. Applies platform-wide (no per-tenant override).
|
||||
</p>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
value={globalAdminRateLimit}
|
||||
onChange={(e) => setGlobalAdminRateLimit(parseInt(e.target.value) || 600)}
|
||||
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Platform Stats Blob Container SAS URL</span>
|
||||
|
||||
@@ -14,7 +14,10 @@ export interface TenantConfiguration {
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
disabledUntil?: string;
|
||||
rateLimitRequestsPerMinute: number;
|
||||
/** Per-tenant device API rate-limit override (null/undefined = inherit global). GA-only. */
|
||||
customRateLimitRequestsPerMinute?: number | null;
|
||||
/** Per-tenant user API rate-limit override for standard users (null/undefined = inherit global). GA-only. */
|
||||
customUserRateLimitRequestsPerMinute?: number | null;
|
||||
manufacturerWhitelist: string;
|
||||
modelWhitelist: string;
|
||||
validateAutopilotDevice: boolean;
|
||||
@@ -544,17 +547,40 @@ export function TenantManagementSection({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Rate Limit */}
|
||||
{/* Device API Rate Limit override */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Rate Limit (Requests/Min)</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Device API Rate Limit (Requests/Min)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
value={editingTenant.rateLimitRequestsPerMinute}
|
||||
onChange={(e) => setEditingTenant({ ...editingTenant, rateLimitRequestsPerMinute: parseInt(e.target.value) || 100 })}
|
||||
max="10000"
|
||||
placeholder="Blank = inherit global default"
|
||||
value={editingTenant.customRateLimitRequestsPerMinute ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
setEditingTenant({ ...editingTenant, customRateLimitRequestsPerMinute: v === "" ? null : (parseInt(v) || null) });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Per-device (agent/cert) limit. Leave blank to inherit the global default.</p>
|
||||
</div>
|
||||
|
||||
{/* User API Rate Limit override */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">User API Rate Limit (Requests/Min)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
placeholder="Blank = inherit global default"
|
||||
value={editingTenant.customUserRateLimitRequestsPerMinute ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
setEditingTenant({ ...editingTenant, customUserRateLimitRequestsPerMinute: v === "" ? null : (parseInt(v) || null) });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Per-user (portal) limit for standard users. Leave blank to inherit the global default. Does not apply to Global Admins.</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
|
||||
@@ -8,6 +8,8 @@ export function SectionGlobalSettings() {
|
||||
const {
|
||||
loadingConfig, savingConfig, adminConfig,
|
||||
globalRateLimit, setGlobalRateLimit,
|
||||
userRateLimit, setUserRateLimit,
|
||||
globalAdminRateLimit, setGlobalAdminRateLimit,
|
||||
platformStatsBlobSasUrl, setPlatformStatsBlobSasUrl,
|
||||
collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes,
|
||||
desktopDetectorNoCandidateTimeoutMinutes, setDesktopDetectorNoCandidateTimeoutMinutes,
|
||||
@@ -28,6 +30,10 @@ export function SectionGlobalSettings() {
|
||||
adminConfig={adminConfig}
|
||||
globalRateLimit={globalRateLimit}
|
||||
setGlobalRateLimit={setGlobalRateLimit}
|
||||
userRateLimit={userRateLimit}
|
||||
setUserRateLimit={setUserRateLimit}
|
||||
globalAdminRateLimit={globalAdminRateLimit}
|
||||
setGlobalAdminRateLimit={setGlobalAdminRateLimit}
|
||||
platformStatsBlobSasUrl={platformStatsBlobSasUrl}
|
||||
setPlatformStatsBlobSasUrl={setPlatformStatsBlobSasUrl}
|
||||
collectorIdleTimeoutMinutes={collectorIdleTimeoutMinutes}
|
||||
|
||||
@@ -22,7 +22,7 @@ const DEFAULT_TIER: PlanTierDefinition = {
|
||||
|
||||
export function SectionUsagePlans() {
|
||||
const { getAccessToken } = useAuth();
|
||||
const { tenants } = useAdminConfig();
|
||||
const { tenants, userRateLimit } = useAdminConfig();
|
||||
const [tiers, setTiers] = useState<PlanTierDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -169,8 +169,8 @@ export function SectionUsagePlans() {
|
||||
{tenants.length > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
Users without a per-user plan override inherit their tenant's default rate limit
|
||||
({tenants[0]?.rateLimitRequestsPerMinute ?? 100} req/min).
|
||||
Users without a per-user plan override inherit their tenant's effective user rate limit
|
||||
({tenants[0]?.customUserRateLimitRequestsPerMinute ?? userRateLimit} req/min).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+4
-4
@@ -26,8 +26,8 @@ interface TenantConfig {
|
||||
disabledUntil?: string;
|
||||
|
||||
// Security
|
||||
rateLimitRequestsPerMinute?: number;
|
||||
customRateLimitRequestsPerMinute?: number | null;
|
||||
customUserRateLimitRequestsPerMinute?: number | null;
|
||||
manufacturerWhitelist?: string;
|
||||
modelWhitelist?: string;
|
||||
validateAutopilotDevice?: boolean;
|
||||
@@ -103,8 +103,8 @@ interface TenantConfig {
|
||||
|
||||
const DEFAULTS: Record<string, unknown> = {
|
||||
disabled: false,
|
||||
rateLimitRequestsPerMinute: 100,
|
||||
customRateLimitRequestsPerMinute: null,
|
||||
customUserRateLimitRequestsPerMinute: null,
|
||||
manufacturerWhitelist: 'Dell*,HP*,Lenovo*,Microsoft Corporation',
|
||||
modelWhitelist: '*',
|
||||
validateAutopilotDevice: false,
|
||||
@@ -522,8 +522,8 @@ export function SectionTenantConfigReport() {
|
||||
<ConfigRow label="Validate Autopilot Device" value={config.validateAutopilotDevice} configKey="validateAutopilotDevice" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Validate Corporate Identifier" value={config.validateCorporateIdentifier} configKey="validateCorporateIdentifier" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Allow Insecure Agent Requests" value={config.allowInsecureAgentRequests} configKey="allowInsecureAgentRequests" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Rate Limit (req/min)" value={config.rateLimitRequestsPerMinute} configKey="rateLimitRequestsPerMinute" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Custom Rate Limit" value={config.customRateLimitRequestsPerMinute} configKey="customRateLimitRequestsPerMinute" defaults={DEFAULTS} />
|
||||
<ConfigRow label="Device API Rate Limit Override" value={config.customRateLimitRequestsPerMinute} configKey="customRateLimitRequestsPerMinute" defaults={DEFAULTS} />
|
||||
<ConfigRow label="User API Rate Limit Override" value={config.customUserRateLimitRequestsPerMinute} configKey="customUserRateLimitRequestsPerMinute" defaults={DEFAULTS} />
|
||||
</Section>
|
||||
|
||||
<Section title="Hardware Whitelist">
|
||||
|
||||
@@ -4,6 +4,10 @@ export interface AdminConfiguration {
|
||||
lastUpdated: string;
|
||||
updatedBy: string;
|
||||
globalRateLimitRequestsPerMinute: number;
|
||||
/** Per-user (portal/JWT) rate limit for standard users (Tenant Admins, Operators, Viewers). Default 120. */
|
||||
userRateLimitRequestsPerMinute?: number;
|
||||
/** Per-user (portal/JWT) rate limit for Global Admins. Default 600. */
|
||||
globalAdminRateLimitRequestsPerMinute?: number;
|
||||
platformStatsBlobSasUrl?: string;
|
||||
collectorIdleTimeoutMinutes?: number;
|
||||
desktopDetectorNoCandidateTimeoutMinutes?: number;
|
||||
|
||||
Reference in New Issue
Block a user