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:
Oliver Kieselbach
2026-07-07 11:26:29 +02:00
parent 96d2728dc9
commit 4a33b96db2
29 changed files with 386 additions and 151 deletions
@@ -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);
}
}
@@ -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,
@@ -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);
@@ -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,
@@ -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,
@@ -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;
@@ -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;
@@ -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&apos;s default rate limit
({tenants[0]?.rateLimitRequestsPerMinute ?? 100} req/min).
Users without a per-user plan override inherit their tenant&apos;s effective user rate limit
({tenants[0]?.customUserRateLimitRequestsPerMinute ?? userRateLimit} req/min).
</p>
</div>
)}
@@ -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;