From 8858b588a2084c509c8b56de8c98f51215344a79 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 13 Feb 2026 22:14:02 +0100 Subject: [PATCH] SerialNumber Validaion implementation against Intune Auopilot --- .../Bootstrap/Install-AutopilotMonitor.ps1 | 3 + .../Monitoring/Network/BackendApiClient.cs | 12 +- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../Functions/GetAgentConfigFunction.cs | 6 +- .../Functions/IngestEventsFunction.cs | 7 +- .../Functions/RegisterSessionFunction.cs | 9 +- .../SerialValidationConsentFunction.cs | 137 ++++++++ .../UpdateTenantSecurityBypassFunction.cs | 94 ++++++ .../AutopilotMonitor.Functions/Program.cs | 2 + .../Security/SecurityValidationExtensions.cs | 8 +- .../Security/SecurityValidator.cs | 55 +-- .../Security/SerialNumberValidator.cs | 315 +++++++++++------- .../Services/TenantConfigurationService.cs | 2 + .../Models/TenantConfiguration.cs | 8 + 14 files changed, 508 insertions(+), 152 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/SerialValidationConsentFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/UpdateTenantSecurityBypassFunction.cs diff --git a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 index 5a4b2279..e6f121f1 100644 --- a/scripts/Bootstrap/Install-AutopilotMonitor.ps1 +++ b/scripts/Bootstrap/Install-AutopilotMonitor.ps1 @@ -80,6 +80,9 @@ try { # ── Pre-flight: Skip installation on already-enrolled devices ── # Check 1: OS install date must be within threshold (fresh device) + # TODO: issue is the OS might got installed a long time ago but the OOBE just started now (e.g. old image, or re-flash). + # Ideally we want to detect the OOBE start time... need to investigate if there's a reliable way to get that (event log? registry? WMI?). + # For now we just check OS install date as a proxy, which should work in most cases except for old images or re-flashed devices. $osInstallDate = (Get-CimInstance Win32_OperatingSystem).InstallDate $osAge = (Get-Date) - $osInstallDate $osAgeMinutesRounded = [int][Math]::Round($osAge.TotalMinutes) diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/BackendApiClient.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/BackendApiClient.cs index 90e38ccc..74af9bc5 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/BackendApiClient.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Network/BackendApiClient.cs @@ -241,12 +241,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Network _logger?.Debug($" X-Device-Model: {_model}"); } - // TODO: Enable serial number validation when multi-tenant Graph API integration is ready - // This provides the strongest security by validating against Intune Autopilot registration - // if (!string.IsNullOrEmpty(_serialNumber)) - // { - // request.Headers.Add("X-Device-SerialNumber", _serialNumber); - // } + // Add serial number for optional backend validation against Intune Autopilot registration + if (!string.IsNullOrEmpty(_serialNumber)) + { + request.Headers.Add("X-Device-SerialNumber", _serialNumber); + _logger?.Debug($" X-Device-SerialNumber: {_serialNumber}"); + } } public void Dispose() diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index b1bd38b6..98d9bcb7 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -13 +17 diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs index c4643b8d..5129e8e3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/GetAgentConfigFunction.cs @@ -19,17 +19,20 @@ namespace AutopilotMonitor.Functions.Functions private readonly TenantConfigurationService _configService; private readonly GatherRuleService _gatherRuleService; private readonly RateLimitService _rateLimitService; + private readonly SerialNumberValidator _serialNumberValidator; public GetAgentConfigFunction( ILogger logger, TenantConfigurationService configService, GatherRuleService gatherRuleService, - RateLimitService rateLimitService) + RateLimitService rateLimitService, + SerialNumberValidator serialNumberValidator) { _logger = logger; _configService = configService; _gatherRuleService = gatherRuleService; _rateLimitService = rateLimitService; + _serialNumberValidator = serialNumberValidator; } [Function("GetAgentConfig")] @@ -57,6 +60,7 @@ namespace AutopilotMonitor.Functions.Functions tenantId, _configService, _rateLimitService, + _serialNumberValidator, _logger ); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs index d8a670ce..1e3b10af 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/IngestEventsFunction.cs @@ -19,6 +19,7 @@ namespace AutopilotMonitor.Functions.Functions private readonly TableStorageService _storageService; private readonly TenantConfigurationService _configService; private readonly RateLimitService _rateLimitService; + private readonly SerialNumberValidator _serialNumberValidator; private readonly AnalyzeRuleService _analyzeRuleService; public IngestEventsFunction( @@ -26,12 +27,14 @@ namespace AutopilotMonitor.Functions.Functions TableStorageService storageService, TenantConfigurationService configService, RateLimitService rateLimitService, + SerialNumberValidator serialNumberValidator, AnalyzeRuleService analyzeRuleService) { _logger = logger; _storageService = storageService; _configService = configService; _rateLimitService = rateLimitService; + _serialNumberValidator = serialNumberValidator; _analyzeRuleService = analyzeRuleService; } @@ -96,7 +99,9 @@ namespace AutopilotMonitor.Functions.Functions request.TenantId, _configService, _rateLimitService, - _logger + _serialNumberValidator, + _logger, + request.SessionId ); if (errorResponse != null) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs index d2515748..469bbee3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/RegisterSessionFunction.cs @@ -16,17 +16,20 @@ namespace AutopilotMonitor.Functions.Functions private readonly TableStorageService _storageService; private readonly TenantConfigurationService _configService; private readonly RateLimitService _rateLimitService; + private readonly SerialNumberValidator _serialNumberValidator; public RegisterSessionFunction( ILogger logger, TableStorageService storageService, TenantConfigurationService configService, - RateLimitService rateLimitService) + RateLimitService rateLimitService, + SerialNumberValidator serialNumberValidator) { _logger = logger; _storageService = storageService; _configService = configService; _rateLimitService = rateLimitService; + _serialNumberValidator = serialNumberValidator; } [Function("RegisterSession")] @@ -61,7 +64,9 @@ namespace AutopilotMonitor.Functions.Functions registration.TenantId, _configService, _rateLimitService, - _logger + _serialNumberValidator, + _logger, + registration.SessionId ); if (errorResponse2 != null) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/SerialValidationConsentFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/SerialValidationConsentFunction.cs new file mode 100644 index 00000000..ffd78401 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/SerialValidationConsentFunction.cs @@ -0,0 +1,137 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions; + +public class SerialValidationConsentFunction +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly TenantAdminsService _tenantAdminsService; + private readonly GalacticAdminService _galacticAdminService; + private readonly SerialNumberValidator _serialNumberValidator; + + public SerialValidationConsentFunction( + ILogger logger, + IConfiguration configuration, + TenantAdminsService tenantAdminsService, + GalacticAdminService galacticAdminService, + SerialNumberValidator serialNumberValidator) + { + _logger = logger; + _configuration = configuration; + _tenantAdminsService = tenantAdminsService; + _galacticAdminService = galacticAdminService; + _serialNumberValidator = serialNumberValidator; + } + + [Function("GetSerialValidationConsentUrl")] + public async Task GetConsentUrl( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "config/{tenantId}/serial-validation/consent-url")] HttpRequestData req, + string tenantId) + { + var authError = await EnsureAuthorizedTenantAdminAsync(req, tenantId); + if (authError != null) + { + return authError; + } + + var validatorClientId = _configuration["Graph:ClientId"] ?? _configuration["AzureAd:ClientId"]; + if (string.IsNullOrWhiteSpace(validatorClientId)) + { + var badConfig = req.CreateResponse(HttpStatusCode.InternalServerError); + await badConfig.WriteAsJsonAsync(new + { + error = "Validator app client ID is not configured on the backend." + }); + return badConfig; + } + + var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); + var redirectUri = query["redirectUri"]; + if (string.IsNullOrWhiteSpace(redirectUri)) + { + redirectUri = $"{req.Url.Scheme}://{req.Url.Authority}/settings"; + } + + var consentUrl = + $"https://login.microsoftonline.com/{Uri.EscapeDataString(tenantId)}/v2.0/adminconsent" + + $"?client_id={Uri.EscapeDataString(validatorClientId)}" + + $"&redirect_uri={Uri.EscapeDataString(redirectUri)}" + + $"&state={Uri.EscapeDataString("serial-validation-enable")}"; + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new + { + consentUrl + }); + return response; + } + + [Function("GetSerialValidationConsentStatus")] + public async Task GetConsentStatus( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "config/{tenantId}/serial-validation/consent-status")] HttpRequestData req, + string tenantId) + { + var authError = await EnsureAuthorizedTenantAdminAsync(req, tenantId); + if (authError != null) + { + return authError; + } + + var result = await _serialNumberValidator.GetConsentStatusAsync(tenantId); + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new + { + isConsented = result.IsConsented, + message = result.Message + }); + return response; + } + + private async Task EnsureAuthorizedTenantAdminAsync(HttpRequestData req, string tenantId) + { + if (!TenantHelper.IsAuthenticated(req)) + { + var unauthorized = req.CreateResponse(HttpStatusCode.Unauthorized); + await unauthorized.WriteAsJsonAsync(new { error = "Authentication required." }); + return unauthorized; + } + + var authenticatedTenantId = TenantHelper.GetTenantId(req); + var userIdentifier = TenantHelper.GetUserIdentifier(req); + + if (!string.Equals(authenticatedTenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + { + var forbidden = req.CreateResponse(HttpStatusCode.Forbidden); + await forbidden.WriteAsJsonAsync(new { error = "Access denied. You can only manage your own tenant." }); + return forbidden; + } + + var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userIdentifier); + var isTenantAdmin = await _tenantAdminsService.IsTenantAdminAsync(tenantId, userIdentifier); + + if (!isGalacticAdmin && !isTenantAdmin) + { + _logger.LogWarning( + "User {User} attempted serial-validation consent operation without admin rights for tenant {TenantId}", + userIdentifier, + tenantId); + + var forbidden = req.CreateResponse(HttpStatusCode.Forbidden); + await forbidden.WriteAsJsonAsync(new + { + error = "Access denied. Tenant Admin or Galactic Admin required." + }); + return forbidden; + } + + return null; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/UpdateTenantSecurityBypassFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/UpdateTenantSecurityBypassFunction.cs new file mode 100644 index 00000000..340064f7 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/UpdateTenantSecurityBypassFunction.cs @@ -0,0 +1,94 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Services; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions +{ + public class UpdateTenantSecurityBypassFunction + { + private readonly ILogger _logger; + private readonly TenantConfigurationService _configService; + private readonly GalacticAdminService _galacticAdminService; + + public UpdateTenantSecurityBypassFunction( + ILogger logger, + TenantConfigurationService configService, + GalacticAdminService galacticAdminService) + { + _logger = logger; + _configService = configService; + _galacticAdminService = galacticAdminService; + } + + [Function("UpdateTenantSecurityBypass")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "config/{tenantId}/security-bypass")] HttpRequestData req, + string tenantId) + { + try + { + if (!TenantHelper.IsAuthenticated(req)) + { + var unauthorized = req.CreateResponse(HttpStatusCode.Unauthorized); + await unauthorized.WriteAsJsonAsync(new { error = "Authentication required." }); + return unauthorized; + } + + var userIdentifier = TenantHelper.GetUserIdentifier(req); + var isGalacticAdmin = await _galacticAdminService.IsGalacticAdminAsync(userIdentifier); + if (!isGalacticAdmin) + { + var forbidden = req.CreateResponse(HttpStatusCode.Forbidden); + await forbidden.WriteAsJsonAsync(new { error = "Access denied. Galactic Admin required." }); + return forbidden; + } + + var body = await req.ReadFromJsonAsync(); + if (body == null) + { + var bad = req.CreateResponse(HttpStatusCode.BadRequest); + await bad.WriteAsJsonAsync(new { error = "Invalid request body." }); + return bad; + } + + var config = await _configService.GetConfigurationAsync(tenantId); + config.AllowInsecureAgentRequests = body.AllowInsecureAgentRequests; + config.UpdatedBy = userIdentifier; + await _configService.SaveConfigurationAsync(config); + + _logger.LogWarning( + "Galactic Admin {User} set AllowInsecureAgentRequests={Enabled} for tenant {TenantId}", + userIdentifier, + body.AllowInsecureAgentRequests, + tenantId); + + var response = req.CreateResponse(HttpStatusCode.OK); + await response.WriteAsJsonAsync(new + { + success = true, + tenantId, + allowInsecureAgentRequests = config.AllowInsecureAgentRequests, + message = config.AllowInsecureAgentRequests + ? "Security bypass enabled for test purposes." + : "Security bypass disabled." + }); + return response; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error updating security bypass for tenant {TenantId}", tenantId); + var response = req.CreateResponse(HttpStatusCode.InternalServerError); + await response.WriteAsJsonAsync(new { error = "Internal server error" }); + return response; + } + } + + public class UpdateBypassRequest + { + public bool AllowInsecureAgentRequests { get; set; } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index a0c6d63b..91ecda24 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -91,6 +91,7 @@ builder.Services.AddHttpContextAccessor(); // Register our services builder.Services.AddMemoryCache(); +builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); // Initialize all tables at startup builder.Services.AddSingleton(); @@ -103,5 +104,6 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Build().Run(); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs index fcbf1760..1abce73a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs @@ -27,10 +27,12 @@ namespace AutopilotMonitor.Functions.Security string tenantId, TenantConfigurationService configService, RateLimitService rateLimitService, - ILogger logger) + SerialNumberValidator serialNumberValidator, + ILogger logger, + string? sessionId = null) { - var validator = new SecurityValidator(configService, rateLimitService, logger); - var validation = await validator.ValidateRequestAsync(req, tenantId); + var validator = new SecurityValidator(configService, rateLimitService, serialNumberValidator, 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 f5d91ed3..87abfb5b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs @@ -43,15 +43,18 @@ namespace AutopilotMonitor.Functions.Security private readonly TenantConfigurationService _configService; private readonly RateLimitService _rateLimitService; + private readonly SerialNumberValidator _serialNumberValidator; private readonly ILogger _logger; public SecurityValidator( TenantConfigurationService configService, RateLimitService rateLimitService, + SerialNumberValidator serialNumberValidator, ILogger logger) { _configService = configService; _rateLimitService = rateLimitService; + _serialNumberValidator = serialNumberValidator; _logger = logger; } @@ -61,12 +64,24 @@ namespace AutopilotMonitor.Functions.Security /// HTTP request /// Tenant ID for configuration lookup /// Security validation result - public async Task ValidateRequestAsync(HttpRequestData req, string tenantId) + public async Task ValidateRequestAsync(HttpRequestData req, string tenantId, string? sessionId = null) { // Load tenant configuration var config = await _configService.GetConfigurationAsync(tenantId); // Security validation is always enforced (no longer configurable per tenant) + // Hard gate: tenant must enable serial validation before agent traffic is accepted. + // Galactic Admins can set AllowInsecureAgentRequests=true in the config row for test tenants. + if (!config.ValidateSerialNumber && !config.AllowInsecureAgentRequests) + { + return new SecurityValidationResult + { + IsValid = false, + StatusCode = HttpStatusCode.Forbidden, + ErrorMessage = "Serial number validation is required", + Details = "Enable 'Validate Serial Number' in Configuration before using the agent ingestion endpoints." + }; + } // 1. Validate client certificate var certHeader = req.Headers.Contains("X-Client-Certificate") @@ -133,25 +148,25 @@ namespace AutopilotMonitor.Functions.Security }; } - // TODO: Serial number validation when Graph API integration is ready - // if (config.ValidateSerialNumber) - // { - // var serialNumber = req.Headers.Contains("X-Device-SerialNumber") - // ? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault() - // : null; - // - // var serialValidation = await _serialNumberValidator.ValidateSerialNumberAsync(tenantId, serialNumber); - // if (!serialValidation.IsValid) - // { - // return new SecurityValidationResult - // { - // IsValid = false, - // StatusCode = HttpStatusCode.Forbidden, - // ErrorMessage = "Device not registered in Autopilot", - // Details = serialValidation.ErrorMessage - // }; - // } - // } + // 4. Validate serial number against Intune Autopilot (optional, tenant setting) + if (config.ValidateSerialNumber) + { + var serialNumber = req.Headers.Contains("X-Device-SerialNumber") + ? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault() + : null; + + var serialValidation = await _serialNumberValidator.ValidateSerialNumberAsync(tenantId, serialNumber, sessionId); + if (!serialValidation.IsValid) + { + return new SecurityValidationResult + { + IsValid = false, + StatusCode = HttpStatusCode.Forbidden, + ErrorMessage = "Device not registered in Autopilot", + Details = serialValidation.ErrorMessage + }; + } + } // All checks passed return new SecurityValidationResult diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SerialNumberValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/SerialNumberValidator.cs index 5cd8298d..80b41368 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SerialNumberValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SerialNumberValidator.cs @@ -1,33 +1,7 @@ -/* - * SERIAL NUMBER VALIDATION AGAINST INTUNE AUTOPILOT - * - * This validator is prepared but commented out because it requires: - * 1. Multi-tenant Graph API integration - * 2. App registration with proper permissions (DeviceManagementServiceConfig.Read.All) - * 3. Tenant-specific Graph API credentials - * - * SECURITY BENEFITS: - * When enabled, this provides the strongest authentication by validating that: - * - The serial number exists in the tenant's Autopilot registration - * - Combined with cert thumbprint, manufacturer, and model, this makes attacks nearly impossible - * - * An attacker would need to: - * 1. Obtain a valid MDM client certificate from an enrolled device - * 2. Know the exact manufacturer and model (whitelisted) - * 3. Know a valid serial number registered in Autopilot for that tenant - * - * TO ENABLE: - * 1. Uncomment this code - * 2. Set up multi-tenant Graph API authentication - * 3. Uncomment the serial number header in BackendApiClient.cs - * 4. Uncomment the validation call in IngestEventsFunction.cs - */ - -/* -using System; -using System.Net.Http; using System.Net.Http.Headers; -using System.Threading.Tasks; +using System.Text; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -35,134 +9,239 @@ using Newtonsoft.Json.Linq; namespace AutopilotMonitor.Functions.Security { /// - /// Validates device serial numbers against Intune Autopilot registration - /// Requires Graph API integration with DeviceManagementServiceConfig.Read.All permission + /// Validates serial numbers against Intune Autopilot device registration via Microsoft Graph. + /// Caches positive/negative lookups to reduce Graph traffic. /// public class SerialNumberValidator { - private readonly ILogger _logger; - private readonly HttpClient _httpClient; + private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30); + private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5); + private static readonly TimeSpan ConsentStatusTtl = TimeSpan.FromMinutes(2); - // TODO: Replace with proper multi-tenant token provider - // private readonly IGraphTokenProvider _tokenProvider; + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IMemoryCache _cache; + private readonly IConfiguration _configuration; - public SerialNumberValidator(ILogger logger) + public SerialNumberValidator( + ILogger logger, + IHttpClientFactory httpClientFactory, + IMemoryCache cache, + IConfiguration configuration) { _logger = logger; - _httpClient = new HttpClient(); + _httpClientFactory = httpClientFactory; + _cache = cache; + _configuration = configuration; } - /// - /// Validates that a serial number is registered in Autopilot for the given tenant - /// - /// Tenant ID - /// Device serial number - /// Validation result - public async Task ValidateSerialNumberAsync(string tenantId, string serialNumber) + public async Task ValidateSerialNumberAsync( + string tenantId, + string? serialNumber, + string? sessionId = null) { - if (string.IsNullOrEmpty(serialNumber)) + if (string.IsNullOrWhiteSpace(serialNumber)) { return new SerialNumberValidationResult { IsValid = false, - ErrorMessage = "Serial number not provided" + ErrorMessage = "Serial number header not provided" }; } + var normalizedSerial = serialNumber.Trim(); + var cacheKey = BuildCacheKey(tenantId, normalizedSerial, sessionId); + if (_cache.TryGetValue(cacheKey, out SerialNumberValidationResult? cached) && cached != null) + { + return cached; + } + try { - // TODO: Get Graph API access token for the tenant - // var accessToken = await _tokenProvider.GetTokenForTenantAsync(tenantId); - - // Graph API endpoint to query Autopilot devices - var graphUrl = $"https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities" + - $"?$filter=serialNumber eq '{serialNumber}'"; - - // TODO: Add authentication header - // _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - - var response = await _httpClient.GetAsync(graphUrl); - response.EnsureSuccessStatusCode(); - - var responseJson = await response.Content.ReadAsStringAsync(); - var data = JsonConvert.DeserializeObject(responseJson); - - var devices = data["value"] as JArray; - if (devices == null || devices.Count == 0) + var accessToken = await GetGraphAccessTokenAsync(tenantId); + if (string.IsNullOrEmpty(accessToken)) { - _logger.LogWarning($"Serial number {serialNumber} not found in Autopilot for tenant {tenantId}"); - return new SerialNumberValidationResult + return CacheAndReturn(cacheKey, new SerialNumberValidationResult { IsValid = false, - ErrorMessage = $"Serial number '{serialNumber}' is not registered in Autopilot", - SerialNumber = serialNumber - }; + SerialNumber = normalizedSerial, + ErrorMessage = "Graph access token could not be acquired" + }, isPositive: false); } - // Serial number found in Autopilot - device is authorized - _logger.LogInformation($"Serial number {serialNumber} validated successfully in Autopilot"); - return new SerialNumberValidationResult + var graphClient = _httpClientFactory.CreateClient(); + graphClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + // Escape single quotes to prevent OData injection and malformed filters. + var escapedSerial = normalizedSerial.Replace("'", "''"); + var filter = Uri.EscapeDataString($"serialNumber eq '{escapedSerial}'"); + var graphUrl = $"https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities?$top=1&$filter={filter}"; + + var response = await graphClient.GetAsync(graphUrl); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "Graph serial validation failed for tenant {TenantId}. Status: {StatusCode}. Body: {ResponseBody}", + tenantId, + (int)response.StatusCode, + responseBody); + + return CacheAndReturn(cacheKey, new SerialNumberValidationResult + { + IsValid = false, + SerialNumber = normalizedSerial, + ErrorMessage = $"Graph query failed with status {(int)response.StatusCode}" + }, isPositive: false); + } + + var data = JsonConvert.DeserializeObject(responseBody); + var devices = data?["value"] as JArray; + if (devices == null || devices.Count == 0) + { + return CacheAndReturn(cacheKey, new SerialNumberValidationResult + { + IsValid = false, + SerialNumber = normalizedSerial, + ErrorMessage = $"Serial number '{normalizedSerial}' is not registered in Autopilot" + }, isPositive: false); + } + + var result = new SerialNumberValidationResult { IsValid = true, - SerialNumber = serialNumber, - AutopilotDeviceId = devices[0]["id"]?.ToString() + SerialNumber = normalizedSerial, + AutopilotDeviceId = devices[0]?["id"]?.ToString() }; + + _logger.LogInformation( + "Serial number validation succeeded for tenant {TenantId}, session {SessionId}, serial {SerialNumber}, autopilotId {AutopilotDeviceId}", + tenantId, + sessionId ?? "", + normalizedSerial, + result.AutopilotDeviceId ?? ""); + + return CacheAndReturn(cacheKey, result, isPositive: true); } catch (Exception ex) { - _logger.LogError(ex, $"Error validating serial number {serialNumber}"); - return new SerialNumberValidationResult + _logger.LogError( + ex, + "Error validating serial number for tenant {TenantId}, session {SessionId}, serial {SerialNumber}", + tenantId, + sessionId ?? "", + normalizedSerial); + + return CacheAndReturn(cacheKey, new SerialNumberValidationResult { IsValid = false, - ErrorMessage = $"Error validating serial number: {ex.Message}", - SerialNumber = serialNumber - }; + SerialNumber = normalizedSerial, + ErrorMessage = $"Error validating serial number: {ex.Message}" + }, isPositive: false); } } + + public async Task GetConsentStatusAsync(string tenantId) + { + var cacheKey = $"serial-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["Graph:ClientId"] ?? _configuration["AzureAd:ClientId"]; + var clientSecret = _configuration["Graph:ClientSecret"] ?? _configuration["AzureAd:ClientSecret"]; + + if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret)) + { + _logger.LogError( + "Serial validation is enabled but Graph app credentials are not configured. Set Graph:ClientId and Graph:ClientSecret."); + return null; + } + + var tokenClient = _httpClientFactory.CreateClient(); + var tokenUrl = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"; + 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); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "Failed to acquire Graph token for tenant {TenantId}. Status: {StatusCode}. Body: {ResponseBody}", + tenantId, + (int)response.StatusCode, + responseBody); + return null; + } + + var tokenJson = JsonConvert.DeserializeObject(responseBody); + return tokenJson?["access_token"]?.ToString(); + } + + private static string BuildCacheKey(string tenantId, string serialNumber, string? sessionId) + { + if (!string.IsNullOrWhiteSpace(sessionId)) + { + return $"serial-validation:{tenantId}:{sessionId}:{serialNumber}"; + } + + return $"serial-validation:{tenantId}:{serialNumber}"; + } + + private SerialNumberValidationResult CacheAndReturn( + string cacheKey, + SerialNumberValidationResult result, + bool isPositive) + { + var ttl = isPositive ? PositiveCacheTtl : NegativeCacheTtl; + _cache.Set(cacheKey, result, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ttl + }); + + return result; + } } - /// - /// Result of serial number validation - /// public class SerialNumberValidationResult { - /// - /// Whether the serial number is registered in Autopilot - /// public bool IsValid { get; set; } + public string? SerialNumber { get; set; } + public string? AutopilotDeviceId { get; set; } + public string? ErrorMessage { get; set; } + } - /// - /// Device serial number - /// - public string SerialNumber { get; set; } - - /// - /// Autopilot device ID (if found) - /// - public string AutopilotDeviceId { get; set; } - - /// - /// Error message if validation failed - /// - public string ErrorMessage { get; set; } + public class GraphConsentStatusResult + { + public bool IsConsented { get; set; } + public string? Message { get; set; } } } -*/ - -// USAGE EXAMPLE (to be added to IngestEventsFunction.cs when ready): -/* -// TODO: Uncomment when multi-tenant Graph API integration is ready -// Validate serial number against Intune Autopilot (strongest security check) -// var serialNumber = req.Headers.Contains("X-Device-SerialNumber") -// ? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault() -// : null; -// -// var serialNumberValidation = await _serialNumberValidator.ValidateSerialNumberAsync(request.TenantId, serialNumber); -// if (!serialNumberValidation.IsValid) -// { -// _logger.LogWarning($"Serial number validation failed: {serialNumberValidation.ErrorMessage}"); -// return await CreateErrorResponse(req, HttpStatusCode.Forbidden, "Device not registered in Autopilot"); -// } -// -// _logger.LogInformation($"Serial number validated: {serialNumber} (Autopilot ID: {serialNumberValidation.AutopilotDeviceId})"); -*/ diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs index b34dfbc1..baad4d47 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs @@ -184,6 +184,7 @@ namespace AutopilotMonitor.Functions.Services { "ManufacturerWhitelist", config.ManufacturerWhitelist }, { "ModelWhitelist", config.ModelWhitelist }, { "ValidateSerialNumber", config.ValidateSerialNumber }, + { "AllowInsecureAgentRequests", config.AllowInsecureAgentRequests }, { "DataRetentionDays", config.DataRetentionDays }, { "SessionTimeoutHours", config.SessionTimeoutHours }, { "MaxNdjsonPayloadSizeMB", config.MaxNdjsonPayloadSizeMB }, @@ -211,6 +212,7 @@ namespace AutopilotMonitor.Functions.Services ManufacturerWhitelist = entity.GetString("ManufacturerWhitelist") ?? "Dell*,HP*,Lenovo*,Microsoft Corporation", ModelWhitelist = entity.GetString("ModelWhitelist") ?? "*", ValidateSerialNumber = entity.GetBoolean("ValidateSerialNumber") ?? false, + AllowInsecureAgentRequests = entity.GetBoolean("AllowInsecureAgentRequests") ?? false, DataRetentionDays = entity.GetInt32("DataRetentionDays") ?? 90, SessionTimeoutHours = entity.GetInt32("SessionTimeoutHours") ?? 5, MaxNdjsonPayloadSizeMB = entity.GetInt32("MaxNdjsonPayloadSizeMB") ?? 5, diff --git a/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs index 88b02f86..d958366f 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/TenantConfiguration.cs @@ -89,6 +89,13 @@ namespace AutopilotMonitor.Shared.Models /// public bool ValidateSerialNumber { get; set; } = false; + /// + /// Emergency bypass for agent security gate (Galactic Admin use only). + /// If true, agent requests are accepted even when ValidateSerialNumber is false. + /// Default: false + /// + public bool AllowInsecureAgentRequests { get; set; } = false; + // ===== DATA MANAGEMENT SETTINGS ===== /// @@ -198,6 +205,7 @@ namespace AutopilotMonitor.Shared.Models ManufacturerWhitelist = "Dell*,HP*,Lenovo*,Microsoft Corporation", ModelWhitelist = "*", ValidateSerialNumber = false, + AllowInsecureAgentRequests = false, DataRetentionDays = 90, SessionTimeoutHours = 5, MaxNdjsonPayloadSizeMB = 5,