From 4a33b96db21c595a45dec99fe0c3d51ebc3d2c14 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 7 Jul 2026 11:26:29 +0200 Subject: [PATCH] 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) --- .../AdminConfigurationRateLimitSyncTests.cs | 79 +++++-------------- .../BootstrapAuthDisabledTenantTests.cs | 8 ++ .../RateLimitResolverTests.cs | 49 ++++++++++++ .../RateLimitServiceClampTests.cs | 35 ++++++++ .../TableStorage/TableConfigRepository.cs | 4 +- .../BootstrapGetAgentConfigFunction.cs | 5 +- .../BootstrapRegisterSessionFunction.cs | 5 +- .../BootstrapReportAgentErrorFunction.cs | 5 +- .../Config/GetAgentConfigFunction.cs | 1 + .../UpdateAdminConfigurationFunction.cs | 14 ++++ .../UpdateTenantConfigurationFunction.cs | 17 +++- .../GetDiagnosticsUploadUrlFunction.cs | 4 + .../Ingest/IngestTelemetryFunction.cs | 4 + .../Ingest/ReportAgentErrorFunction.cs | 4 + .../Sessions/RegisterSessionFunction.cs | 4 + .../Middleware/UserRateLimitMiddleware.cs | 30 ++++++- .../Security/SecurityValidationExtensions.cs | 3 +- .../Security/SecurityValidator.cs | 24 +++++- .../Services/AdminConfigurationService.cs | 57 ++----------- .../Services/RateLimitResolver.cs | 28 +++++++ .../Services/RateLimitService.cs | 7 ++ .../Models/Config/TenantConfiguration.cs | 24 +++--- .../app/admin/AdminConfigContext.tsx | 16 +++- .../components/AdminConfigSettingsSection.tsx | 48 ++++++++++- .../components/TenantManagementSection.tsx | 38 +++++++-- .../sections/SectionGlobalSettings.tsx | 6 ++ .../settings/sections/SectionUsagePlans.tsx | 6 +- .../sections/SectionTenantConfigReport.tsx | 8 +- .../types/adminConfig.ts | 4 + 29 files changed, 386 insertions(+), 151 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/RateLimitResolverTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/RateLimitServiceClampTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/RateLimitResolver.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationRateLimitSyncTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationRateLimitSyncTests.cs index 8d947d08..2a1a407e 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationRateLimitSyncTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationRateLimitSyncTests.cs @@ -9,71 +9,30 @@ using Xunit; namespace AutopilotMonitor.Functions.Tests; /// -/// Tests that 's background -/// rate-limit sync does NOT mutate . +/// 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). These tests pin the +/// removal of the former background sync job: +/// +/// Saving the admin config must NOT enumerate or write ANY tenant configuration. +/// /// -/// Regression: the sync used to overwrite UpdatedBy with the sentinel string -/// "System (Global Rate Limit Sync)". Downstream code in -/// PreviewWhitelistFunction 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 GlobalRateLimitRequestsPerMinute into every +/// tenant's base RateLimitRequestsPerMinute 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 UpdatedBy 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. /// /// public class AdminConfigurationRateLimitSyncTests { [Fact] - public async Task SaveConfigurationAsync_RateLimitSync_PreservesUpdatedBy() + public async Task SaveConfigurationAsync_DoesNotEnumerateOrWriteTenantConfigs() { - var savedTenantConfigs = new List(); - var repo = new Mock(); + var repo = new Mock(MockBehavior.Strict); repo.Setup(r => r.SaveAdminConfigurationAsync(It.IsAny())) .ReturnsAsync(true); - repo.Setup(r => r.GetAllTenantConfigurationsAsync()) - .ReturnsAsync(new List - { - 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())) - .Callback(c => savedTenantConfigs.Add(c)) - .ReturnsAsync(true); - - var sut = new AdminConfigurationService( - repo.Object, - NullLogger.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(); - var repo = new Mock(); - repo.Setup(r => r.SaveAdminConfigurationAsync(It.IsAny())) - .ReturnsAsync(true); - repo.Setup(r => r.GetAllTenantConfigurationsAsync()) - .ReturnsAsync(new List - { - new() { TenantId = "custom-tenant", UpdatedBy = "owner@contoso.com", - RateLimitRequestsPerMinute = 1000, CustomRateLimitRequestsPerMinute = 1000 }, - }); - repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny())) - .Callback(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()), 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()), Times.Never); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapAuthDisabledTenantTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapAuthDisabledTenantTests.cs index f2359842..ba615daa 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapAuthDisabledTenantTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapAuthDisabledTenantTests.cs @@ -40,8 +40,16 @@ public sealed class BootstrapAuthDisabledTenantTests .Setup(x => x.TryGetConfigurationAsync(It.IsAny())) .ReturnsAsync((config, true)); + var adminConfigServiceMock = new Mock( + configRepo, Mock.Of>(), 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!, diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RateLimitResolverTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RateLimitResolverTests.cs new file mode 100644 index 00000000..5a2ffae6 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RateLimitResolverTests.cs @@ -0,0 +1,49 @@ +using AutopilotMonitor.Functions.Services; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// 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). +/// +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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RateLimitServiceClampTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RateLimitServiceClampTests.cs new file mode 100644 index 00000000..edee9020 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RateLimitServiceClampTests.cs @@ -0,0 +1,35 @@ +using AutopilotMonitor.Functions.Services; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// 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. +/// +public class RateLimitServiceClampTests +{ + private static RateLimitService NewService() => + new(new MemoryCache(new MemoryCacheOptions()), NullLogger.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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index c5f0b2bb..69562faa 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -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, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs index 0ed9885a..9941a59e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapGetAgentConfigFunction.cs @@ -18,6 +18,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap private readonly ILogger _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 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); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs index 922ed666..050f5e88 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs @@ -21,6 +21,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap private readonly ILogger _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 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, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs index 9230e08c..d597d074 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapReportAgentErrorFunction.cs @@ -18,6 +18,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap private readonly ILogger _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 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); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index 7b2eeb2f..42b70fbd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -78,6 +78,7 @@ namespace AutopilotMonitor.Functions.Functions.Config var (validation, errorResponse) = await req.ValidateSecurityAsync( tenantId, _configService, + _adminConfigService, _rateLimitService, _autopilotDeviceValidator, _corporateIdentifierValidator, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs index 2fd0ccf7..c4a75c85 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs @@ -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; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs index 6576584c..bb9ef696 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs @@ -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; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs index c15f3937..6a921faa 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs @@ -47,6 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics private readonly ILogger _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 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, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs index 68c87e04..69ff3a0f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs @@ -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, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs index aed967b3..ce1a081c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs @@ -26,6 +26,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest { private readonly ILogger _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 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, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs index afd6110a..0e3d4da8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs @@ -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 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, diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/UserRateLimitMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/UserRateLimitMiddleware.cs index 74bbb981..74a6b9e3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/UserRateLimitMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/UserRateLimitMiddleware.cs @@ -17,15 +17,18 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware { private readonly RateLimitService _rateLimitService; private readonly AdminConfigurationService _adminConfigService; + private readonly TenantConfigurationService _tenantConfigService; private readonly ILogger _logger; public UserRateLimitMiddleware( RateLimitService rateLimitService, AdminConfigurationService adminConfigService, + TenantConfigurationService tenantConfigService, ILogger 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); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs index 7e9b73e9..32215573 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs @@ -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) diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs index f3431cc7..ce2d36a6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs @@ -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; } + /// + /// 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. + /// + private async Task 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); + } + /// /// Validates request security (certificate, rate limit, hardware whitelist) /// @@ -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!, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs index cb03cbdb..ea1476db 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs @@ -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 } } - /// - /// Syncs the global rate limit to all tenant configurations that don't have a custom override - /// - 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 - } - } - /// /// Invalidates cache (forces reload on next request) /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RateLimitResolver.cs b/src/Backend/AutopilotMonitor.Functions/Services/RateLimitResolver.cs new file mode 100644 index 00000000..b083a0f6 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/RateLimitResolver.cs @@ -0,0 +1,28 @@ +namespace AutopilotMonitor.Functions.Services +{ + /// + /// 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 () and the user path + /// (UserRateLimitMiddleware) resolve through here so the precedence stays consistent. + /// + public static class RateLimitResolver + { + /// + /// Device (agent/cert) limit: per-tenant override if set, otherwise the global default. + /// + public static int ResolveDeviceLimit(int? tenantOverride, int globalDefault) + => tenantOverride ?? globalDefault; + + /// + /// 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. + /// + public static int ResolveUserLimit(bool isGlobalAdmin, int? tenantUserOverride, int globalUserDefault, int globalAdminDefault) + => isGlobalAdmin + ? globalAdminDefault + : (tenantUserOverride ?? globalUserDefault); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RateLimitService.cs b/src/Backend/AutopilotMonitor.Functions/Services/RateLimitService.cs index d9f0c3c3..bbc03fbb 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/RateLimitService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/RateLimitService.cs @@ -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; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index 829fa854..4d02811f 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -76,19 +76,21 @@ namespace AutopilotMonitor.Shared.Models // ===== SECURITY SETTINGS ===== /// - /// Rate limit: Maximum requests per minute per device - /// This value is synchronized from the global AdminConfiguration - /// Default: 100 - /// - public int RateLimitRequestsPerMinute { get; set; } = 100; - - /// - /// 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 AdminConfiguration.GlobalRateLimitRequestsPerMinute. + /// If set, this value takes precedence. Global-Admin-only (see UpdateTenantConfigurationFunction GA-gate). /// public int? CustomRateLimitRequestsPerMinute { get; set; } = null; + /// + /// 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 AdminConfiguration.UserRateLimitRequestsPerMinute. Global-Admin-only. + /// Note: Global Admins are rate-limited by the global GlobalAdminRateLimitRequestsPerMinute + /// (cross-tenant), so this override does not apply to them. + /// + public int? CustomUserRateLimitRequestsPerMinute { get; set; } = null; + /// /// 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, diff --git a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx index 7d7a1a5f..c1fb8098 100644 --- a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx @@ -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 }) 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({
+
+ +
+ +
+ +
+
- {/* Rate Limit */} + {/* Device API Rate Limit override */}
- + 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" /> +

Per-device (agent/cert) limit. Leave blank to inherit the global default.

+
+ + {/* User API Rate Limit override */} +
+ + { + 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" + /> +

Per-user (portal) limit for standard users. Leave blank to inherit the global default. Does not apply to Global Admins.