SerialNumber Validaion implementation against Intune Auopilot

This commit is contained in:
Oliver Kieselbach
2026-02-13 22:14:02 +01:00
parent 13e3f35aa0
commit 8858b588a2
14 changed files with 508 additions and 152 deletions
@@ -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)
@@ -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()
@@ -1 +1 @@
13
17
@@ -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<GetAgentConfigFunction> 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
);
@@ -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)
@@ -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<RegisterSessionFunction> 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)
@@ -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<SerialValidationConsentFunction> _logger;
private readonly IConfiguration _configuration;
private readonly TenantAdminsService _tenantAdminsService;
private readonly GalacticAdminService _galacticAdminService;
private readonly SerialNumberValidator _serialNumberValidator;
public SerialValidationConsentFunction(
ILogger<SerialValidationConsentFunction> logger,
IConfiguration configuration,
TenantAdminsService tenantAdminsService,
GalacticAdminService galacticAdminService,
SerialNumberValidator serialNumberValidator)
{
_logger = logger;
_configuration = configuration;
_tenantAdminsService = tenantAdminsService;
_galacticAdminService = galacticAdminService;
_serialNumberValidator = serialNumberValidator;
}
[Function("GetSerialValidationConsentUrl")]
public async Task<HttpResponseData> 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<HttpResponseData> 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<HttpResponseData?> 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;
}
}
@@ -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<UpdateTenantSecurityBypassFunction> _logger;
private readonly TenantConfigurationService _configService;
private readonly GalacticAdminService _galacticAdminService;
public UpdateTenantSecurityBypassFunction(
ILogger<UpdateTenantSecurityBypassFunction> logger,
TenantConfigurationService configService,
GalacticAdminService galacticAdminService)
{
_logger = logger;
_configService = configService;
_galacticAdminService = galacticAdminService;
}
[Function("UpdateTenantSecurityBypass")]
public async Task<HttpResponseData> 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<UpdateBypassRequest>();
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; }
}
}
}
@@ -91,6 +91,7 @@ builder.Services.AddHttpContextAccessor();
// Register our services
builder.Services.AddMemoryCache();
builder.Services.AddHttpClient();
builder.Services.AddSingleton<TableStorageService>();
builder.Services.AddHostedService<TableInitializerService>(); // Initialize all tables at startup
builder.Services.AddSingleton<TenantConfigurationService>();
@@ -103,5 +104,6 @@ builder.Services.AddSingleton<HealthCheckService>();
builder.Services.AddSingleton<GatherRuleService>();
builder.Services.AddSingleton<AnalyzeRuleService>();
builder.Services.AddSingleton<MaintenanceService>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.SerialNumberValidator>();
builder.Build().Run();
@@ -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)
{
@@ -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
/// <param name="req">HTTP request</param>
/// <param name="tenantId">Tenant ID for configuration lookup</param>
/// <returns>Security validation result</returns>
public async Task<SecurityValidationResult> ValidateRequestAsync(HttpRequestData req, string tenantId)
public async Task<SecurityValidationResult> 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
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<SerialNumberValidator> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly IConfiguration _configuration;
public SerialNumberValidator(ILogger logger)
public SerialNumberValidator(
ILogger<SerialNumberValidator> logger,
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
IConfiguration configuration)
{
_logger = logger;
_httpClient = new HttpClient();
_httpClientFactory = httpClientFactory;
_cache = cache;
_configuration = configuration;
}
/// <summary>
/// Validates that a serial number is registered in Autopilot for the given tenant
/// </summary>
/// <param name="tenantId">Tenant ID</param>
/// <param name="serialNumber">Device serial number</param>
/// <returns>Validation result</returns>
public async Task<SerialNumberValidationResult> ValidateSerialNumberAsync(string tenantId, string serialNumber)
public async Task<SerialNumberValidationResult> 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<JObject>(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<JObject>(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 ?? "<none>",
normalizedSerial,
result.AutopilotDeviceId ?? "<none>");
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 ?? "<none>",
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<GraphConsentStatusResult> 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<string?> 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<string, string>
{
["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<JObject>(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;
}
}
/// <summary>
/// Result of serial number validation
/// </summary>
public class SerialNumberValidationResult
{
/// <summary>
/// Whether the serial number is registered in Autopilot
/// </summary>
public bool IsValid { get; set; }
public string? SerialNumber { get; set; }
public string? AutopilotDeviceId { get; set; }
public string? ErrorMessage { get; set; }
}
/// <summary>
/// Device serial number
/// </summary>
public string SerialNumber { get; set; }
/// <summary>
/// Autopilot device ID (if found)
/// </summary>
public string AutopilotDeviceId { get; set; }
/// <summary>
/// Error message if validation failed
/// </summary>
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})");
*/
@@ -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,
@@ -89,6 +89,13 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool ValidateSerialNumber { get; set; } = false;
/// <summary>
/// Emergency bypass for agent security gate (Galactic Admin use only).
/// If true, agent requests are accepted even when ValidateSerialNumber is false.
/// Default: false
/// </summary>
public bool AllowInsecureAgentRequests { get; set; } = false;
// ===== DATA MANAGEMENT SETTINGS =====
/// <summary>
@@ -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,