diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CorporateIdentifierValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CorporateIdentifierValidatorTests.cs index 5f9a82b2..5a421d86 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/CorporateIdentifierValidatorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/CorporateIdentifierValidatorTests.cs @@ -24,6 +24,10 @@ public class CorporateIdentifierValidatorTests private const string TenantId = "11111111-1111-1111-1111-111111111111"; private const string Serial = "7801-5131-4473-3387-5637-4002-18"; private const string MmsIdentifier = $"Microsoft Corporation,Virtual Machine,{Serial}"; + // The form Intune actually stores (field-verified 2026-08-17 in the portal list): + // uppercased, spaces and dashes stripped per component. + private const string StoredIdentifier = "MICROSOFTCORPORATION,VIRTUALMACHINE,78015131447333875637400218"; + private const string StoredSerial = "78015131447333875637400218"; private sealed class SequenceHandler : HttpMessageHandler { @@ -73,7 +77,7 @@ public class CorporateIdentifierValidatorTests } [Fact] - public async Task ValidateAsync_usesGet_withContainsFilterOnSerial() + public async Task ValidateAsync_usesGet_withContainsFilterOnNormalizedSerial() { var (sut, handler) = BuildSut(Json("""{"value":[]}""")); @@ -84,14 +88,18 @@ public class CorporateIdentifierValidatorTests var url = request.RequestUri!.ToString(); Assert.Contains("/beta/deviceManagement/importedDeviceIdentities", url); Assert.DoesNotContain("searchExistingIdentities", url); - Assert.Contains("contains(importedDeviceIdentifier", Uri.UnescapeDataString(url)); + // The stored value is normalized (dashless), so filtering with the raw dashed serial + // could never hit — the filter must use the normalized serial. + Assert.Contains($"contains(importedDeviceIdentifier,'{StoredSerial}')", Uri.UnescapeDataString(url)); } [Fact] - public async Task ValidateAsync_mmsIdentityMatch_isValid() + public async Task ValidateAsync_storedNormalizedIdentity_matchesRawDeviceValues() { + // THE field case: device reports "Microsoft Corporation" / "Virtual Machine" / dashed + // serial; Intune stores "MICROSOFTCORPORATION,VIRTUALMACHINE,". Must match. var (sut, _) = BuildSut(Json($$""" - {"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"{{MmsIdentifier}}"}]} + {"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"{{StoredIdentifier}}"}]} """)); var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial); @@ -102,10 +110,12 @@ public class CorporateIdentifierValidatorTests } [Fact] - public async Task ValidateAsync_matchIsCaseInsensitive() + public async Task ValidateAsync_rawStoredIdentity_alsoMatches() { + // Entries created directly via Graph may be stored raw — both sides are normalized + // before comparison, so these match too. var (sut, _) = BuildSut(Json($$""" - {"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"{{MmsIdentifier.ToUpperInvariant()}}"}]} + {"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"{{MmsIdentifier}}"}]} """)); var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial); @@ -113,13 +123,26 @@ public class CorporateIdentifierValidatorTests Assert.True(result.IsValid); } + [Fact] + public async Task ValidateAsync_differentDevice_doesNotMatch() + { + var (sut, _) = BuildSut(Json(""" + {"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"MICROSOFTCORPORATION,VIRTUALMACHINE,39260126318839370830655125"}]} + """)); + + var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial); + + Assert.False(result.IsValid); + Assert.False(result.IsTransient); + } + [Fact] public async Task ValidateAsync_serialOnlyIdentity_doesNotAuthorize() { - // Serial-type corporate identifiers are unsupported for Windows — an Intune-side - // misconfiguration, deliberately not treated as authorization (decision 2026-08-17). + // Serial-type corporate identifiers are an Intune-side misconfiguration for Windows, + // deliberately not treated as authorization (decision 2026-08-17). var (sut, _) = BuildSut(Json($$""" - {"value":[{"importedDeviceIdentityType":"serialNumber","importedDeviceIdentifier":"{{Serial}}"}]} + {"value":[{"importedDeviceIdentityType":"serialNumber","importedDeviceIdentifier":"{{StoredSerial}}"}]} """)); var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial); @@ -132,8 +155,8 @@ public class CorporateIdentifierValidatorTests public async Task ValidateAsync_followsNextLink_untilMatch() { var (sut, handler) = BuildSut( - Json("""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Dell,XPS,OTHER1"}],"@odata.nextLink":"https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities?$skiptoken=abc"}"""), - Json($$"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"{{MmsIdentifier}}"}]}""")); + Json("""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"DELL,XPS,OTHER1"}],"@odata.nextLink":"https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities?$skiptoken=abc"}"""), + Json($$"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"{{StoredIdentifier}}"}]}""")); var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial); @@ -143,7 +166,7 @@ public class CorporateIdentifierValidatorTests } [Fact] - public async Task ValidateAsync_noMatch_isDefinitive() + public async Task ValidateAsync_noMatch_isDefinitive_andShowsBothForms() { var (sut, _) = BuildSut(Json("""{"value":[]}""")); @@ -151,7 +174,10 @@ public class CorporateIdentifierValidatorTests Assert.False(result.IsValid); Assert.False(result.IsTransient); + // Both the raw and the normalized form must appear so a rejection log is diagnosable + // against the portal list (which shows only the normalized form). Assert.Contains(MmsIdentifier, result.ErrorMessage); + Assert.Contains(StoredIdentifier, result.ErrorMessage); } [Fact] @@ -161,7 +187,7 @@ public class CorporateIdentifierValidatorTests // unfiltered list instead of failing. var (sut, handler) = BuildSut( Json("""{"error":{"code":"BadRequest","message":"filter not supported"}}""", HttpStatusCode.BadRequest), - Json($$"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"{{MmsIdentifier}}"}]}""")); + Json($$"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"{{StoredIdentifier}}"}]}""")); var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial); @@ -171,6 +197,17 @@ public class CorporateIdentifierValidatorTests Assert.DoesNotContain("$filter", handler.Requests[1].RequestUri!.ToString()); } + [Theory] + [InlineData("Microsoft Corporation", "MICROSOFTCORPORATION")] + [InlineData("Virtual Machine", "VIRTUALMACHINE")] + [InlineData("7801-5131-4473-3387-5637-4002-18", "78015131447333875637400218")] + [InlineData("ThinkPad T14 Gen 3", "THINKPADT14GEN3")] + [InlineData("Serial.With.Periods", "SERIALWITHPERIODS")] + public void NormalizeComponent_matchesIntunePortalNormalization(string raw, string expected) + { + Assert.Equal(expected, CorporateIdentifierValidator.NormalizeComponent(raw)); + } + [Theory] [InlineData(HttpStatusCode.Forbidden)] [InlineData(HttpStatusCode.Unauthorized)] diff --git a/src/Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs index 37204e4c..d4cbe970 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/CorporateIdentifierValidator.cs @@ -2,6 +2,7 @@ using AutopilotMonitor.Shared; 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; @@ -17,9 +18,11 @@ namespace AutopilotMonitor.Functions.Security /// ReadWrite.All scope the app deliberately does not hold (field case 2026-08-17: the action /// 403'd in every tenant). Only the manufacturerModelSerial type counts — the sole /// corporate-identifier type Windows supports; serial-only entries are an Intune-side admin - /// misconfiguration and must not authorize. A Graph 401/403 means missing admin consent and - /// is classified as a definitive failure, not transient. Caches positive/negative lookups to - /// reduce Graph traffic. + /// misconfiguration and must not authorize. Matching happens in Intune's NORMALIZED space + /// (uppercase, non-alphanumerics stripped per component — see ), + /// because that is the form the portal stores and matches at enrollment. A Graph 401/403 + /// means missing admin consent and is classified as a definitive failure, not transient. + /// Caches positive/negative lookups to reduce Graph traffic. /// public class CorporateIdentifierValidator { @@ -125,12 +128,21 @@ namespace AutopilotMonitor.Functions.Security // Read.All, which the app already holds for windowsAutopilotDeviceIdentities. // Mirrors AutopilotDeviceValidator: contains() for server-side narrowing (eq is // unreliable on Intune endpoints), exact match client-side. - var identifier = $"{normalizedManufacturer},{normalizedModel},{normalizedSerial}"; - var escapedSerial = normalizedSerial.Replace("'", "''"); - var filter = Uri.EscapeDataString($"contains(importedDeviceIdentifier,'{escapedSerial}')"); + // + // Intune NORMALIZES corporate identifiers on upload: uppercased, every + // non-alphanumeric character stripped per component — "Microsoft Corporation, + // Virtual Machine,7801-5131-…" is stored as "MICROSOFTCORPORATION, + // VIRTUALMACHINE,78015131…" (field-verified 2026-08-17: the portal list shows + // the normalized form and the device matched + enrolled against it). All + // matching therefore happens in normalized space — including the contains() + // narrowing, which with the raw dashed serial would never hit the stored value. + var rawIdentifier = $"{normalizedManufacturer},{normalizedModel},{normalizedSerial}"; + var normalizedIdentifier = BuildNormalizedIdentifier(normalizedManufacturer, normalizedModel, normalizedSerial); + // Normalized serial is alphanumeric-only, so no OData quote-escaping is needed. + var filter = Uri.EscapeDataString($"contains(importedDeviceIdentifier,'{NormalizeComponent(normalizedSerial)}')"); var filteredUrl = $"{Constants.GraphBaseUrl}/beta/deviceManagement/importedDeviceIdentities?$top=100&$filter={filter}"; - var scan = await ScanPagesForIdentifierAsync(graphClient, filteredUrl, identifier, tenantId, attempt, maxPages: 5); + var scan = await ScanPagesForIdentifierAsync(graphClient, filteredUrl, normalizedIdentifier, tenantId, attempt, maxPages: 5); if (scan.Outcome == ScanOutcome.FilterRejected) { @@ -141,19 +153,19 @@ namespace AutopilotMonitor.Functions.Security "Corporate identifier validation: contains() filter rejected by Graph for tenant {TenantId}; falling back to unfiltered scan.", tenantId); var unfilteredUrl = $"{Constants.GraphBaseUrl}/beta/deviceManagement/importedDeviceIdentities?$top=1000"; - scan = await ScanPagesForIdentifierAsync(graphClient, unfilteredUrl, identifier, tenantId, attempt, maxPages: 60); + scan = await ScanPagesForIdentifierAsync(graphClient, unfilteredUrl, normalizedIdentifier, tenantId, attempt, maxPages: 60); } switch (scan.Outcome) { case ScanOutcome.Found: _logger.LogInformation( - "Corporate identifier validation succeeded for tenant {TenantId}, session {SessionId}, identifier {Identifier}", - tenantId, sessionId ?? "", identifier); + "Corporate identifier validation succeeded for tenant {TenantId}, session {SessionId}, identifier {Identifier} (normalized {NormalizedIdentifier})", + tenantId, sessionId ?? "", rawIdentifier, normalizedIdentifier); return CacheAndReturn(cacheKey, new CorporateIdentifierValidationResult { IsValid = true, - Identifier = identifier + Identifier = rawIdentifier }, isPositive: true); case ScanOutcome.NotFound: @@ -161,7 +173,7 @@ namespace AutopilotMonitor.Functions.Security return CacheAndReturn(cacheKey, new CorporateIdentifierValidationResult { IsValid = false, - ErrorMessage = $"Device '{identifier}' is not registered as a Corporate Identifier (manufacturerModelSerial)" + ErrorMessage = $"Device '{rawIdentifier}' (normalized '{normalizedIdentifier}') is not registered as a Corporate Identifier (manufacturerModelSerial)" }, isPositive: false); case ScanOutcome.PermissionDenied: @@ -217,12 +229,13 @@ namespace AutopilotMonitor.Functions.Security /// /// Pages through an importedDeviceIdentities GET url (following @odata.nextLink) and /// looks for a manufacturerModelSerial identity whose importedDeviceIdentifier equals - /// (case-insensitive — Intune's own matching is not - /// case-sensitive). Serial-only identities never match: Intune ignores them for Windows, - /// so they are an Intune-side misconfiguration, not authorization. + /// after normalizing the stored value the same + /// way (both sides pass through , so raw legacy + /// entries match too). Serial-only identities never match: they are an Intune-side + /// misconfiguration for Windows, not authorization. /// private async Task ScanPagesForIdentifierAsync( - HttpClient graphClient, string url, string identifier, string tenantId, int attempt, int maxPages) + HttpClient graphClient, string url, string normalizedIdentifier, string tenantId, int attempt, int maxPages) { for (var page = 0; page < maxPages && !string.IsNullOrEmpty(url); page++) { @@ -256,7 +269,8 @@ namespace AutopilotMonitor.Functions.Security var type = identity?["importedDeviceIdentityType"]?.ToString(); var value = identity?["importedDeviceIdentifier"]?.ToString(); if (string.Equals(type, "manufacturerModelSerial", StringComparison.OrdinalIgnoreCase) - && string.Equals(value, identifier, StringComparison.OrdinalIgnoreCase)) + && value != null + && string.Equals(NormalizeStoredIdentifier(value), normalizedIdentifier, StringComparison.Ordinal)) { return new ScanResult(ScanOutcome.Found); } @@ -279,6 +293,45 @@ namespace AutopilotMonitor.Functions.Security return new ScanResult(ScanOutcome.NotFound); } + /// + /// Normalizes one identifier component the way the Intune portal does on upload: + /// uppercase, every non-alphanumeric character removed ("Microsoft Corporation" → + /// "MICROSOFTCORPORATION", "7801-5131-…-18" → "78015131…18"). Field-verified 2026-08-17 + /// against the portal's stored corporate identifier list. + /// + internal static string NormalizeComponent(string value) + { + var sb = new StringBuilder(value.Length); + foreach (var c in value) + { + if (char.IsLetterOrDigit(c)) + sb.Append(char.ToUpperInvariant(c)); + } + return sb.ToString(); + } + + internal static string BuildNormalizedIdentifier(string manufacturer, string model, string serial) => + $"{NormalizeComponent(manufacturer)},{NormalizeComponent(model)},{NormalizeComponent(serial)}"; + + /// + /// Normalizes a stored importedDeviceIdentifier for comparison: same per-character rule + /// as but keeps the comma separators. Stored values are + /// already normalized by the portal; running them through again is a no-op there and + /// makes raw-form entries (e.g. created via Graph directly) match as well. + /// + internal static string NormalizeStoredIdentifier(string value) + { + var sb = new StringBuilder(value.Length); + foreach (var c in value) + { + if (c == ',') + sb.Append(c); + else if (char.IsLetterOrDigit(c)) + sb.Append(char.ToUpperInvariant(c)); + } + return sb.ToString(); + } + private static string BuildCacheKey(string tenantId, string manufacturer, string model, string serialNumber) { return $"corporate-id-validation:{tenantId}:{manufacturer}:{model}:{serialNumber}";