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 */}