From 20e93aa892de36671fdbf36c692d05d29a7c142e Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 3 Mar 2026 10:19:06 +0100 Subject: [PATCH] add Corporate Identifier Validation with preview-gated UI Adds a second device validation method alongside existing Autopilot Device Validation. Corporate Identifier Validation checks devices against Intune Corporate Device Identifiers (manufacturer + model + serial number) via the Graph beta searchExistingIdentities endpoint. Backend: Extract GraphTokenService from AutopilotDeviceValidator for shared token/consent logic. Add CorporateIdentifierValidator with caching. Update SecurityValidator with cascading validation (Autopilot first, Corporate Identifier fallback). Wire new validator through all agent endpoints. Frontend: Add second toggle in settings gated behind preview mode. Shared consent flow skips redirect when consent already exists from other toggle. Section renamed to "Enrollment Device Validation" in preview mode. Co-Authored-By: Claude Opus 4.6 --- ...utopilotDeviceValidationConsentFunction.cs | 8 +- .../Functions/GetAgentConfigFunction.cs | 6 +- .../GetDiagnosticsUploadUrlFunction.cs | 6 +- .../Functions/IngestEventsFunction.cs | 4 + .../Functions/RegisterSessionFunction.cs | 6 +- .../Functions/ReportAgentErrorFunction.cs | 4 + .../AutopilotMonitor.Functions/Program.cs | 2 + .../Security/AutopilotDeviceValidator.cs | 119 +---------- .../Security/CorporateIdentifierValidator.cs | 199 ++++++++++++++++++ .../Security/GraphTokenService.cs | 143 +++++++++++++ .../Security/SecurityValidationExtensions.cs | 3 +- .../Security/SecurityValidator.cs | 64 ++++-- .../Services/TenantConfigurationService.cs | 2 + .../Models/TenantConfiguration.cs | 7 + .../components/AutopilotValidationSection.tsx | 72 ++++++- .../app/settings/page.tsx | 56 ++++- 16 files changed, 544 insertions(+), 157 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Security/GraphTokenService.cs diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/AutopilotDeviceValidationConsentFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/AutopilotDeviceValidationConsentFunction.cs index cf32a877..206d229f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/AutopilotDeviceValidationConsentFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/AutopilotDeviceValidationConsentFunction.cs @@ -15,20 +15,20 @@ public class AutopilotDeviceValidationConsentFunction private readonly IConfiguration _configuration; private readonly TenantAdminsService _tenantAdminsService; private readonly GalacticAdminService _galacticAdminService; - private readonly AutopilotDeviceValidator _autopilotDeviceValidator; + private readonly GraphTokenService _graphTokenService; public AutopilotDeviceValidationConsentFunction( ILogger logger, IConfiguration configuration, TenantAdminsService tenantAdminsService, GalacticAdminService galacticAdminService, - AutopilotDeviceValidator autopilotDeviceValidator) + GraphTokenService graphTokenService) { _logger = logger; _configuration = configuration; _tenantAdminsService = tenantAdminsService; _galacticAdminService = galacticAdminService; - _autopilotDeviceValidator = autopilotDeviceValidator; + _graphTokenService = graphTokenService; } [Function("GetAutopilotDeviceValidationConsentUrl")] @@ -85,7 +85,7 @@ public class AutopilotDeviceValidationConsentFunction return authError; } - var result = await _autopilotDeviceValidator.GetConsentStatusAsync(tenantId); + var result = await _graphTokenService.GetConsentStatusAsync(tenantId); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs index 3e7793bc..295fc19d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs @@ -23,6 +23,7 @@ namespace AutopilotMonitor.Functions.Functions private readonly ImeLogPatternService _imeLogPatternService; private readonly RateLimitService _rateLimitService; private readonly AutopilotDeviceValidator _autopilotDeviceValidator; + private readonly CorporateIdentifierValidator _corporateIdentifierValidator; public GetAgentConfigFunction( ILogger logger, @@ -31,7 +32,8 @@ namespace AutopilotMonitor.Functions.Functions GatherRuleService gatherRuleService, ImeLogPatternService imeLogPatternService, RateLimitService rateLimitService, - AutopilotDeviceValidator autopilotDeviceValidator) + AutopilotDeviceValidator autopilotDeviceValidator, + CorporateIdentifierValidator corporateIdentifierValidator) { _logger = logger; _configService = configService; @@ -40,6 +42,7 @@ namespace AutopilotMonitor.Functions.Functions _imeLogPatternService = imeLogPatternService; _rateLimitService = rateLimitService; _autopilotDeviceValidator = autopilotDeviceValidator; + _corporateIdentifierValidator = corporateIdentifierValidator; } [Function("GetAgentConfig")] @@ -68,6 +71,7 @@ namespace AutopilotMonitor.Functions.Functions _configService, _rateLimitService, _autopilotDeviceValidator, + _corporateIdentifierValidator, _logger ); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetDiagnosticsUploadUrlFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetDiagnosticsUploadUrlFunction.cs index 06fe52bc..db72176d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetDiagnosticsUploadUrlFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetDiagnosticsUploadUrlFunction.cs @@ -21,17 +21,20 @@ namespace AutopilotMonitor.Functions.Functions private readonly TenantConfigurationService _configService; private readonly RateLimitService _rateLimitService; private readonly AutopilotDeviceValidator _autopilotDeviceValidator; + private readonly CorporateIdentifierValidator _corporateIdentifierValidator; public GetDiagnosticsUploadUrlFunction( ILogger logger, TenantConfigurationService configService, RateLimitService rateLimitService, - AutopilotDeviceValidator autopilotDeviceValidator) + AutopilotDeviceValidator autopilotDeviceValidator, + CorporateIdentifierValidator corporateIdentifierValidator) { _logger = logger; _configService = configService; _rateLimitService = rateLimitService; _autopilotDeviceValidator = autopilotDeviceValidator; + _corporateIdentifierValidator = corporateIdentifierValidator; } [Function("GetDiagnosticsUploadUrl")] @@ -76,6 +79,7 @@ namespace AutopilotMonitor.Functions.Functions _configService, _rateLimitService, _autopilotDeviceValidator, + _corporateIdentifierValidator, _logger ); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs index 101ebdca..45223a75 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs @@ -21,6 +21,7 @@ namespace AutopilotMonitor.Functions.Functions private readonly TenantConfigurationService _configService; private readonly RateLimitService _rateLimitService; private readonly AutopilotDeviceValidator _autopilotDeviceValidator; + private readonly CorporateIdentifierValidator _corporateIdentifierValidator; private readonly AnalyzeRuleService _analyzeRuleService; private readonly TeamsNotificationService _teamsNotificationService; private readonly BlockedDeviceService _blockedDeviceService; @@ -31,6 +32,7 @@ namespace AutopilotMonitor.Functions.Functions TenantConfigurationService configService, RateLimitService rateLimitService, AutopilotDeviceValidator autopilotDeviceValidator, + CorporateIdentifierValidator corporateIdentifierValidator, AnalyzeRuleService analyzeRuleService, TeamsNotificationService teamsNotificationService, BlockedDeviceService blockedDeviceService) @@ -40,6 +42,7 @@ namespace AutopilotMonitor.Functions.Functions _configService = configService; _rateLimitService = rateLimitService; _autopilotDeviceValidator = autopilotDeviceValidator; + _corporateIdentifierValidator = corporateIdentifierValidator; _analyzeRuleService = analyzeRuleService; _teamsNotificationService = teamsNotificationService; _blockedDeviceService = blockedDeviceService; @@ -71,6 +74,7 @@ namespace AutopilotMonitor.Functions.Functions _configService, _rateLimitService, _autopilotDeviceValidator, + _corporateIdentifierValidator, _logger ); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs index e3bee32e..320c1e04 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs @@ -17,19 +17,22 @@ namespace AutopilotMonitor.Functions.Functions private readonly TenantConfigurationService _configService; private readonly RateLimitService _rateLimitService; private readonly AutopilotDeviceValidator _autopilotDeviceValidator; + private readonly CorporateIdentifierValidator _corporateIdentifierValidator; public RegisterSessionFunction( ILogger logger, TableStorageService storageService, TenantConfigurationService configService, RateLimitService rateLimitService, - AutopilotDeviceValidator autopilotDeviceValidator) + AutopilotDeviceValidator autopilotDeviceValidator, + CorporateIdentifierValidator corporateIdentifierValidator) { _logger = logger; _storageService = storageService; _configService = configService; _rateLimitService = rateLimitService; _autopilotDeviceValidator = autopilotDeviceValidator; + _corporateIdentifierValidator = corporateIdentifierValidator; } [Function("RegisterSession")] @@ -65,6 +68,7 @@ namespace AutopilotMonitor.Functions.Functions _configService, _rateLimitService, _autopilotDeviceValidator, + _corporateIdentifierValidator, _logger, registration.SessionId ); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/ReportAgentErrorFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/ReportAgentErrorFunction.cs index ef68ce57..334fc18b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/ReportAgentErrorFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/ReportAgentErrorFunction.cs @@ -28,6 +28,7 @@ namespace AutopilotMonitor.Functions.Functions private readonly TenantConfigurationService _configService; private readonly RateLimitService _rateLimitService; private readonly AutopilotDeviceValidator _autopilotDeviceValidator; + private readonly CorporateIdentifierValidator _corporateIdentifierValidator; private readonly TelemetryClient _telemetryClient; public ReportAgentErrorFunction( @@ -35,12 +36,14 @@ namespace AutopilotMonitor.Functions.Functions TenantConfigurationService configService, RateLimitService rateLimitService, AutopilotDeviceValidator autopilotDeviceValidator, + CorporateIdentifierValidator corporateIdentifierValidator, TelemetryClient telemetryClient) { _logger = logger; _configService = configService; _rateLimitService = rateLimitService; _autopilotDeviceValidator = autopilotDeviceValidator; + _corporateIdentifierValidator = corporateIdentifierValidator; _telemetryClient = telemetryClient; } @@ -69,6 +72,7 @@ namespace AutopilotMonitor.Functions.Functions _configService, _rateLimitService, _autopilotDeviceValidator, + _corporateIdentifierValidator, _logger ); diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 9961cf16..65be2035 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -110,7 +110,9 @@ builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/AutopilotDeviceValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/AutopilotDeviceValidator.cs index 905bfd0e..38643ca2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/AutopilotDeviceValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/AutopilotDeviceValidator.cs @@ -1,6 +1,7 @@ +using System; +using System.Linq; using System.Net.Http.Headers; -using System.Text; -using Microsoft.Extensions.Configuration; +using System.Threading.Tasks; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -16,23 +17,22 @@ namespace AutopilotMonitor.Functions.Security { private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30); private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5); - private static readonly TimeSpan ConsentStatusTtl = TimeSpan.FromMinutes(2); private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly IMemoryCache _cache; - private readonly IConfiguration _configuration; + private readonly GraphTokenService _graphTokenService; public AutopilotDeviceValidator( ILogger logger, IHttpClientFactory httpClientFactory, IMemoryCache cache, - IConfiguration configuration) + GraphTokenService graphTokenService) { _logger = logger; _httpClientFactory = httpClientFactory; _cache = cache; - _configuration = configuration; + _graphTokenService = graphTokenService; } public async Task ValidateAutopilotDeviceAsync( @@ -58,7 +58,7 @@ namespace AutopilotMonitor.Functions.Security try { - var accessToken = await GetGraphAccessTokenAsync(tenantId); + var accessToken = await _graphTokenService.GetAccessTokenAsync(tenantId); if (string.IsNullOrEmpty(accessToken)) { // Do not cache token-acquisition failures — this is a backend/propagation issue, @@ -162,105 +162,6 @@ namespace AutopilotMonitor.Functions.Security } } - public async Task GetConsentStatusAsync(string tenantId) - { - var cacheKey = $"autopilot-device-validation-consent:{tenantId}"; - if (_cache.TryGetValue(cacheKey, out GraphConsentStatusResult? cached) && cached != null) - { - return cached; - } - - var accessToken = await GetGraphAccessTokenAsync(tenantId); - var result = new GraphConsentStatusResult - { - IsConsented = !string.IsNullOrWhiteSpace(accessToken), - Message = string.IsNullOrWhiteSpace(accessToken) - ? "Admin consent for Graph application permissions is missing or app credentials are invalid." - : "Admin consent is available for this tenant." - }; - - _cache.Set(cacheKey, result, new MemoryCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = ConsentStatusTtl - }); - - return result; - } - - private async Task GetGraphAccessTokenAsync(string tenantId) - { - var clientId = _configuration["EntraId:ClientId"]; - var clientSecret = _configuration["EntraId:ClientSecret"]; - - if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) - { - _logger.LogError( - "Autopilot device validation is enabled but Entra ID app credentials are not configured. Set EntraId:ClientId and EntraId:ClientSecret."); - return null; - } - - var tokenUrl = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"; - - // Retry with backoff to handle Azure AD consent propagation delays. - // After admin consent is granted, the service principal may not be immediately - // available in the tenant — Azure AD typically propagates within 30-90 seconds. - var retryDelays = new[] { TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30) }; - string? responseBody = null; - int attempt = 0; - - while (true) - { - var tokenClient = _httpClientFactory.CreateClient(); - var body = new FormUrlEncodedContent(new Dictionary - { - ["client_id"] = clientId, - ["client_secret"] = clientSecret, - ["scope"] = "https://graph.microsoft.com/.default", - ["grant_type"] = "client_credentials" - }); - - var response = await tokenClient.PostAsync(tokenUrl, body); - responseBody = await response.Content.ReadAsStringAsync(); - - if (response.IsSuccessStatusCode) - { - var tokenJson = JsonConvert.DeserializeObject(responseBody); - return tokenJson?["access_token"]?.ToString(); - } - - // Only retry on transient consent-propagation errors (unauthorized_client, invalid_client). - // Do not retry on permanent errors like invalid credentials or tenant not found. - var isRetryable = response.StatusCode == System.Net.HttpStatusCode.Unauthorized - || response.StatusCode == System.Net.HttpStatusCode.BadRequest - && (responseBody.Contains("unauthorized_client", StringComparison.OrdinalIgnoreCase) - || responseBody.Contains("AADSTS700016", StringComparison.OrdinalIgnoreCase) - || responseBody.Contains("AADSTS7000215", StringComparison.OrdinalIgnoreCase)); - - if (!isRetryable || attempt >= retryDelays.Length) - { - _logger.LogWarning( - "Failed to acquire Graph token for tenant {TenantId} after {Attempts} attempt(s). Status: {StatusCode}. Body: {ResponseBody}", - tenantId, - attempt + 1, - (int)response.StatusCode, - responseBody); - return null; - } - - var delay = retryDelays[attempt]; - _logger.LogInformation( - "Graph token acquisition for tenant {TenantId} returned {StatusCode} (attempt {Attempt}/{MaxAttempts}). Retrying in {Delay}s — likely Azure AD consent propagation delay.", - tenantId, - (int)response.StatusCode, - attempt + 1, - retryDelays.Length + 1, - delay.TotalSeconds); - - await Task.Delay(delay); - attempt++; - } - } - private static string BuildCacheKey(string tenantId, string serialNumber, string? sessionId) { if (!string.IsNullOrWhiteSpace(sessionId)) @@ -293,10 +194,4 @@ namespace AutopilotMonitor.Functions.Security public string? AutopilotDeviceId { get; set; } public string? ErrorMessage { get; set; } } - - public class GraphConsentStatusResult - { - public bool IsConsented { get; set; } - public string? Message { get; set; } - } } diff --git a/src/Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs new file mode 100644 index 00000000..f544d77f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs @@ -0,0 +1,199 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace AutopilotMonitor.Functions.Security +{ + /// + /// Validates devices against Intune Corporate Device Identifiers via Microsoft Graph beta API. + /// Uses the importedDeviceIdentities/searchExistingIdentities endpoint with manufacturerModelSerial type. + /// Caches positive/negative lookups to reduce Graph traffic. + /// + public class CorporateIdentifierValidator + { + private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30); + private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5); + + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IMemoryCache _cache; + private readonly GraphTokenService _graphTokenService; + + public CorporateIdentifierValidator( + ILogger logger, + IHttpClientFactory httpClientFactory, + IMemoryCache cache, + GraphTokenService graphTokenService) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _cache = cache; + _graphTokenService = graphTokenService; + } + + public async Task ValidateAsync( + string tenantId, + string? manufacturer, + string? model, + string? serialNumber, + string? sessionId = null) + { + if (string.IsNullOrWhiteSpace(manufacturer) || string.IsNullOrWhiteSpace(model) || string.IsNullOrWhiteSpace(serialNumber)) + { + return new CorporateIdentifierValidationResult + { + IsValid = false, + ErrorMessage = "Manufacturer, model, or serial number header not provided" + }; + } + + var normalizedManufacturer = manufacturer.Trim(); + var normalizedModel = model.Trim(); + var normalizedSerial = serialNumber.Trim(); + var cacheKey = BuildCacheKey(tenantId, normalizedManufacturer, normalizedModel, normalizedSerial, sessionId); + + if (_cache.TryGetValue(cacheKey, out CorporateIdentifierValidationResult? cached) && cached != null) + { + return cached; + } + + try + { + var accessToken = await _graphTokenService.GetAccessTokenAsync(tenantId); + if (string.IsNullOrEmpty(accessToken)) + { + // Do not cache token-acquisition failures — this is a backend/propagation issue, + // not a device issue. The next enrollment attempt should retry immediately. + return new CorporateIdentifierValidationResult + { + IsValid = false, + ErrorMessage = "Graph access token could not be acquired" + }; + } + + var graphClient = _httpClientFactory.CreateClient(); + graphClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + // POST https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities/searchExistingIdentities + // Body: {"importedDeviceIdentities":[{"importedDeviceIdentityType":"manufacturerModelSerial", + // "importedDeviceIdentifier":"manufacturer,model,serialNumber"}]} + var identifier = $"{normalizedManufacturer},{normalizedModel},{normalizedSerial}"; + var requestBody = new + { + importedDeviceIdentities = new[] + { + new + { + importedDeviceIdentityType = "manufacturerModelSerial", + importedDeviceIdentifier = identifier + } + } + }; + + var content = new StringContent( + JsonConvert.SerializeObject(requestBody), + Encoding.UTF8, + "application/json"); + + var graphUrl = "https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities/searchExistingIdentities"; + var response = await graphClient.PostAsync(graphUrl, content); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "Corporate identifier validation Graph query failed for tenant {TenantId}. Status: {StatusCode}. Body: {ResponseBody}", + tenantId, + (int)response.StatusCode, + responseBody); + + return CacheAndReturn(cacheKey, new CorporateIdentifierValidationResult + { + IsValid = false, + ErrorMessage = $"Graph query failed with status {(int)response.StatusCode}" + }, isPositive: false); + } + + var data = JsonConvert.DeserializeObject(responseBody); + var identities = data?["value"] as JArray; + + if (identities == null || identities.Count == 0) + { + return CacheAndReturn(cacheKey, new CorporateIdentifierValidationResult + { + IsValid = false, + ErrorMessage = $"Device '{identifier}' is not registered as a Corporate Identifier" + }, isPositive: false); + } + + var result = new CorporateIdentifierValidationResult + { + IsValid = true, + Identifier = identifier + }; + + _logger.LogInformation( + "Corporate identifier validation succeeded for tenant {TenantId}, session {SessionId}, identifier {Identifier}", + tenantId, + sessionId ?? "", + identifier); + + return CacheAndReturn(cacheKey, result, isPositive: true); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error during corporate identifier validation for tenant {TenantId}, session {SessionId}, identifier {Manufacturer},{Model},{SerialNumber}", + tenantId, + sessionId ?? "", + normalizedManufacturer, + normalizedModel, + normalizedSerial); + + return CacheAndReturn(cacheKey, new CorporateIdentifierValidationResult + { + IsValid = false, + ErrorMessage = $"Error during corporate identifier validation: {ex.Message}" + }, isPositive: false); + } + } + + private static string BuildCacheKey(string tenantId, string manufacturer, string model, string serialNumber, string? sessionId) + { + var baseKey = $"corporate-id-validation:{tenantId}:{manufacturer}:{model}:{serialNumber}"; + if (!string.IsNullOrWhiteSpace(sessionId)) + { + return $"{baseKey}:{sessionId}"; + } + return baseKey; + } + + private CorporateIdentifierValidationResult CacheAndReturn( + string cacheKey, + CorporateIdentifierValidationResult result, + bool isPositive) + { + var ttl = isPositive ? PositiveCacheTtl : NegativeCacheTtl; + _cache.Set(cacheKey, result, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ttl + }); + + return result; + } + } + + public class CorporateIdentifierValidationResult + { + public bool IsValid { get; set; } + public string? Identifier { get; set; } + public string? ErrorMessage { get; set; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/GraphTokenService.cs b/src/Backend/AutopilotMonitor.Functions/Security/GraphTokenService.cs new file mode 100644 index 00000000..ecb50efd --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Security/GraphTokenService.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace AutopilotMonitor.Functions.Security +{ + /// + /// Shared service for acquiring Microsoft Graph access tokens via client credentials flow. + /// Used by AutopilotDeviceValidator and CorporateIdentifierValidator. + /// + public class GraphTokenService + { + private static readonly TimeSpan ConsentStatusTtl = TimeSpan.FromMinutes(2); + + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IMemoryCache _cache; + private readonly IConfiguration _configuration; + + public GraphTokenService( + ILogger logger, + IHttpClientFactory httpClientFactory, + IMemoryCache cache, + IConfiguration configuration) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _cache = cache; + _configuration = configuration; + } + + public async Task GetConsentStatusAsync(string tenantId) + { + var cacheKey = $"graph-consent-status:{tenantId}"; + if (_cache.TryGetValue(cacheKey, out GraphConsentStatusResult? cached) && cached != null) + { + return cached; + } + + var accessToken = await GetAccessTokenAsync(tenantId); + var result = new GraphConsentStatusResult + { + IsConsented = !string.IsNullOrWhiteSpace(accessToken), + Message = string.IsNullOrWhiteSpace(accessToken) + ? "Admin consent for Graph application permissions is missing or app credentials are invalid." + : "Admin consent is available for this tenant." + }; + + _cache.Set(cacheKey, result, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ConsentStatusTtl + }); + + return result; + } + + public async Task GetAccessTokenAsync(string tenantId) + { + var clientId = _configuration["EntraId:ClientId"]; + var clientSecret = _configuration["EntraId:ClientSecret"]; + + if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) + { + _logger.LogError( + "Device validation is enabled but Entra ID app credentials are not configured. Set EntraId:ClientId and EntraId:ClientSecret."); + return null; + } + + var tokenUrl = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"; + + // Retry with backoff to handle Azure AD consent propagation delays. + // After admin consent is granted, the service principal may not be immediately + // available in the tenant — Azure AD typically propagates within 30-90 seconds. + var retryDelays = new[] { TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15), TimeSpan.FromSeconds(30) }; + string? responseBody = null; + int attempt = 0; + + while (true) + { + var tokenClient = _httpClientFactory.CreateClient(); + var body = new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = clientId, + ["client_secret"] = clientSecret, + ["scope"] = "https://graph.microsoft.com/.default", + ["grant_type"] = "client_credentials" + }); + + var response = await tokenClient.PostAsync(tokenUrl, body); + responseBody = await response.Content.ReadAsStringAsync(); + + if (response.IsSuccessStatusCode) + { + var tokenJson = JsonConvert.DeserializeObject(responseBody); + return tokenJson?["access_token"]?.ToString(); + } + + // Only retry on transient consent-propagation errors (unauthorized_client, invalid_client). + // Do not retry on permanent errors like invalid credentials or tenant not found. + var isRetryable = response.StatusCode == System.Net.HttpStatusCode.Unauthorized + || response.StatusCode == System.Net.HttpStatusCode.BadRequest + && (responseBody.Contains("unauthorized_client", StringComparison.OrdinalIgnoreCase) + || responseBody.Contains("AADSTS700016", StringComparison.OrdinalIgnoreCase) + || responseBody.Contains("AADSTS7000215", StringComparison.OrdinalIgnoreCase)); + + if (!isRetryable || attempt >= retryDelays.Length) + { + _logger.LogWarning( + "Failed to acquire Graph token for tenant {TenantId} after {Attempts} attempt(s). Status: {StatusCode}. Body: {ResponseBody}", + tenantId, + attempt + 1, + (int)response.StatusCode, + responseBody); + return null; + } + + var delay = retryDelays[attempt]; + _logger.LogInformation( + "Graph token acquisition for tenant {TenantId} returned {StatusCode} (attempt {Attempt}/{MaxAttempts}). Retrying in {Delay}s — likely Azure AD consent propagation delay.", + tenantId, + (int)response.StatusCode, + attempt + 1, + retryDelays.Length + 1, + delay.TotalSeconds); + + await Task.Delay(delay); + attempt++; + } + } + } + + public class GraphConsentStatusResult + { + public bool IsConsented { get; set; } + public string? Message { get; set; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs index 03dcceb2..683c645d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs @@ -28,10 +28,11 @@ namespace AutopilotMonitor.Functions.Security TenantConfigurationService configService, RateLimitService rateLimitService, AutopilotDeviceValidator autopilotDeviceValidator, + CorporateIdentifierValidator corporateIdentifierValidator, ILogger logger, string? sessionId = null) { - var validator = new SecurityValidator(configService, rateLimitService, autopilotDeviceValidator, logger); + var validator = new SecurityValidator(configService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger); 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 0b07c42f..ce7ba33c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs @@ -44,17 +44,20 @@ namespace AutopilotMonitor.Functions.Security private readonly TenantConfigurationService _configService; private readonly RateLimitService _rateLimitService; private readonly AutopilotDeviceValidator _autopilotDeviceValidator; + private readonly CorporateIdentifierValidator _corporateIdentifierValidator; private readonly ILogger _logger; public SecurityValidator( TenantConfigurationService configService, RateLimitService rateLimitService, AutopilotDeviceValidator autopilotDeviceValidator, + CorporateIdentifierValidator corporateIdentifierValidator, ILogger logger) { _configService = configService; _rateLimitService = rateLimitService; _autopilotDeviceValidator = autopilotDeviceValidator; + _corporateIdentifierValidator = corporateIdentifierValidator; _logger = logger; } @@ -95,16 +98,16 @@ namespace AutopilotMonitor.Functions.Security } // Security validation is always enforced (no longer configurable per tenant) - // Hard gate: tenant must enable Autopilot device validation before agent traffic is accepted. + // Hard gate: tenant must enable at least one device validation method before agent traffic is accepted. // Galactic Admins can set AllowInsecureAgentRequests=true in the config row for test tenants. - if (!config.ValidateAutopilotDevice && !config.AllowInsecureAgentRequests) + if (!config.ValidateAutopilotDevice && !config.ValidateCorporateIdentifier && !config.AllowInsecureAgentRequests) { return new SecurityValidationResult { IsValid = false, StatusCode = HttpStatusCode.Forbidden, - ErrorMessage = "Autopilot device validation is required", - Details = "Enable 'Autopilot Device Validation' in Configuration before using the agent ingestion endpoints." + ErrorMessage = "Device validation is required", + Details = "Enable 'Autopilot Device Validation' or 'Corporate Identifier Validation' in Configuration before using the agent ingestion endpoints." }; } @@ -179,29 +182,50 @@ namespace AutopilotMonitor.Functions.Security }; } - // 4. Validate device against Intune Autopilot registration (admin consent needs to be given during setup) - string? serialNumber = null; + // 4. Validate device registration (Autopilot and/or Corporate Identifier) + string? serialNumber = req.Headers.Contains("X-Device-SerialNumber") + ? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault() + : null; string? autopilotDeviceId = null; + bool deviceValidated = false; + string? deviceValidationError = null; if (config.ValidateAutopilotDevice) { - serialNumber = req.Headers.Contains("X-Device-SerialNumber") - ? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault() - : null; - - var serialValidation = await _autopilotDeviceValidator.ValidateAutopilotDeviceAsync(tenantId, serialNumber, sessionId); - if (!serialValidation.IsValid) + var autopilotResult = await _autopilotDeviceValidator.ValidateAutopilotDeviceAsync(tenantId, serialNumber, sessionId); + if (autopilotResult.IsValid) { - return new SecurityValidationResult - { - IsValid = false, - StatusCode = HttpStatusCode.Forbidden, - ErrorMessage = "Device not registered in Autopilot", - Details = serialValidation.ErrorMessage - }; + deviceValidated = true; + autopilotDeviceId = autopilotResult.AutopilotDeviceId; } + else + { + deviceValidationError = autopilotResult.ErrorMessage; + } + } - autopilotDeviceId = serialValidation.AutopilotDeviceId; + if (!deviceValidated && config.ValidateCorporateIdentifier) + { + var corpResult = await _corporateIdentifierValidator.ValidateAsync(tenantId, manufacturer, model, serialNumber, sessionId); + if (corpResult.IsValid) + { + deviceValidated = true; + } + else + { + deviceValidationError = corpResult.ErrorMessage; + } + } + + if ((config.ValidateAutopilotDevice || config.ValidateCorporateIdentifier) && !deviceValidated) + { + return new SecurityValidationResult + { + IsValid = false, + StatusCode = HttpStatusCode.Forbidden, + ErrorMessage = "Device not registered", + Details = deviceValidationError + }; } // All checks passed diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs index 398baa63..fc162032 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs @@ -203,6 +203,7 @@ namespace AutopilotMonitor.Functions.Services { "ManufacturerWhitelist", config.ManufacturerWhitelist }, { "ModelWhitelist", config.ModelWhitelist }, { "ValidateAutopilotDevice", config.ValidateAutopilotDevice }, + { "ValidateCorporateIdentifier", config.ValidateCorporateIdentifier }, { "AllowInsecureAgentRequests", config.AllowInsecureAgentRequests }, { "DataRetentionDays", config.DataRetentionDays }, { "SessionTimeoutHours", config.SessionTimeoutHours }, @@ -248,6 +249,7 @@ namespace AutopilotMonitor.Functions.Services ManufacturerWhitelist = entity.GetString("ManufacturerWhitelist") ?? "Dell*,HP*,Lenovo*,Microsoft Corporation", ModelWhitelist = entity.GetString("ModelWhitelist") ?? "*", ValidateAutopilotDevice = entity.GetBoolean("ValidateAutopilotDevice") ?? entity.GetBoolean("ValidateSerialNumber") ?? false, + ValidateCorporateIdentifier = entity.GetBoolean("ValidateCorporateIdentifier") ?? false, AllowInsecureAgentRequests = entity.GetBoolean("AllowInsecureAgentRequests") ?? false, DataRetentionDays = entity.GetInt32("DataRetentionDays") ?? 90, SessionTimeoutHours = entity.GetInt32("SessionTimeoutHours") ?? 5, diff --git a/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs index c0dfd49d..33d8911f 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs @@ -90,6 +90,13 @@ namespace AutopilotMonitor.Shared.Models /// public bool ValidateAutopilotDevice { get; set; } = false; + /// + /// Whether to validate devices against Intune Corporate Device Identifiers + /// (manufacturer + model + serial number via importedDeviceIdentities/searchExistingIdentities). + /// Requires Graph API integration (admin consent for DeviceManagementServiceConfig.ReadWrite.All) + /// + public bool ValidateCorporateIdentifier { get; set; } = false; + /// /// Emergency bypass for agent security gate (Galactic Admin use only). /// If true, agent requests are accepted even when ValidateAutopilotDevice is false. diff --git a/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx index 2761d9b1..58426500 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx @@ -3,32 +3,47 @@ interface AutopilotValidationSectionProps { validateAutopilotDevice: boolean; setValidateAutopilotDevice: (value: boolean) => void; + validateCorporateIdentifier: boolean; + setValidateCorporateIdentifier: (value: boolean) => void; autopilotConsentInProgress: boolean; saving: boolean; - onBeginConsent: () => void; + onBeginConsent: (trigger: 'autopilot' | 'corporate') => void; + previewMode: boolean; } export default function AutopilotValidationSection({ validateAutopilotDevice, setValidateAutopilotDevice, + validateCorporateIdentifier, + setValidateCorporateIdentifier, autopilotConsentInProgress, saving, onBeginConsent, + previewMode, }: AutopilotValidationSectionProps) { + const anyValidationEnabled = validateAutopilotDevice || validateCorporateIdentifier; + return (
-

Autopilot Device Validation

-

Validate devices against Intune Windows Autopilot registration (mandatory for agent ingestion)

+

+ {previewMode ? "Enrollment Device Validation" : "Autopilot Device Validation"} +

+

+ {previewMode + ? "Validate devices against Intune registrations before accepting agent data (mandatory for agent ingestion)" + : "Validate devices against Intune Windows Autopilot registration (mandatory for agent ingestion)"} +

- - {validateAutopilotDevice ? "Enabled" : "Disabled"} + + {anyValidationEnabled ? "Enabled" : "Disabled"}
+ {/* Autopilot Device Validation toggle */}