mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Corp identifier validation: probe serialNumber type + unmask validator errors
First live WDP enrollment 403d at device validation. Root causes: - CorporateIdentifierValidator only searched the manufacturerModelSerial identifier type, but the Device Preparation guidance has admins upload SERIAL-type corporate identifiers - those never matched. The Graph searchExistingIdentities call now probes both types in one round-trip. - SecurityValidator overwrote deviceValidationError per validator, so the 403 Details only showed the LAST failure (CloudPc) and hid the corporate identifier miss. Errors now aggregate in chain order and the transient flag is sticky-OR (any transient failure yields 503 Retry-After instead of a definitive 403). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Graph contract tests for <see cref="CorporateIdentifierValidator"/>. WDP regression
|
||||
/// (2026-08-17, first live Device Preparation enrollment): admins following the Device
|
||||
/// Preparation guidance upload SERIAL-type corporate identifiers, but the validator only
|
||||
/// searched the manufacturerModelSerial type — every WDP device 403'd as "not registered".
|
||||
/// The validator must probe both identifier types in a single searchExistingIdentities call.
|
||||
/// </summary>
|
||||
public class CorporateIdentifierValidatorTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string Serial = "7801-5131-4473-3387-5637-4002-18";
|
||||
|
||||
private sealed class CapturingHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpResponseMessage _response;
|
||||
public string? LastRequestBody { get; private set; }
|
||||
|
||||
public CapturingHandler(HttpResponseMessage response) => _response = response;
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
LastRequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(ct);
|
||||
return _response;
|
||||
}
|
||||
}
|
||||
|
||||
private static (CorporateIdentifierValidator Sut, CapturingHandler Handler) BuildSut(
|
||||
string responseJson, HttpStatusCode status = HttpStatusCode.OK)
|
||||
{
|
||||
var handler = new CapturingHandler(new HttpResponseMessage(status)
|
||||
{
|
||||
Content = new StringContent(responseJson),
|
||||
});
|
||||
var factory = new Mock<IHttpClientFactory>();
|
||||
factory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(new HttpClient(handler));
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["EntraId:ClientId"] = "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
["EntraId:ClientSecret"] = "secret",
|
||||
}).Build();
|
||||
var registry = new EntraAppRegistry(configuration, NullLogger<EntraAppRegistry>.Instance);
|
||||
var tenantConfig = new Mock<TenantConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
var tokenService = new Mock<GraphTokenService>(
|
||||
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache,
|
||||
configuration, registry, tenantConfig.Object)
|
||||
{ CallBase = false };
|
||||
tokenService.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(GraphTokenResult.Success("token"));
|
||||
|
||||
var sut = new CorporateIdentifierValidator(
|
||||
NullLogger<CorporateIdentifierValidator>.Instance, factory.Object, cache, tokenService.Object);
|
||||
return (sut, handler);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_sendsBothIdentifierTypes_inOneSearchCall()
|
||||
{
|
||||
var (sut, handler) = BuildSut("""{"value":[]}""");
|
||||
|
||||
await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.NotNull(handler.LastRequestBody);
|
||||
var candidates = (JArray)JObject.Parse(handler.LastRequestBody!)["importedDeviceIdentities"]!;
|
||||
Assert.Collection(candidates,
|
||||
mms =>
|
||||
{
|
||||
Assert.Equal("manufacturerModelSerial", mms["importedDeviceIdentityType"]?.ToString());
|
||||
Assert.Equal($"Microsoft Corporation,Virtual Machine,{Serial}", mms["importedDeviceIdentifier"]?.ToString());
|
||||
},
|
||||
serialOnly =>
|
||||
{
|
||||
Assert.Equal("serialNumber", serialOnly["importedDeviceIdentityType"]?.ToString());
|
||||
Assert.Equal(Serial, serialOnly["importedDeviceIdentifier"]?.ToString());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_serialNumberTypeMatch_isValid()
|
||||
{
|
||||
// The WDP case: only a serial-type corporate identifier exists in Intune.
|
||||
var (sut, _) = BuildSut($$"""
|
||||
{"value":[{"importedDeviceIdentityType":"serialNumber","importedDeviceIdentifier":"{{Serial}}"}]}
|
||||
""");
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
Assert.Equal(Serial, result.Identifier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_manufacturerModelSerialMatch_staysValid()
|
||||
{
|
||||
var identifier = $"Microsoft Corporation,Virtual Machine,{Serial}";
|
||||
var (sut, _) = BuildSut($$"""
|
||||
{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"{{identifier}}"}]}
|
||||
""");
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.Equal(identifier, result.Identifier);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_noMatch_isDefinitive_andNamesBothSearchedForms()
|
||||
{
|
||||
var (sut, _) = BuildSut("""{"value":[]}""");
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
// The message must expose both searched identifier forms so a 403 is diagnosable
|
||||
// from the rejection log alone (the 2026-08-17 WDP session was not).
|
||||
Assert.Contains($"Microsoft Corporation,Virtual Machine,{Serial}", result.ErrorMessage);
|
||||
Assert.Contains($"serialNumber '{Serial}'", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateAsync_graphError_isTransient()
|
||||
{
|
||||
var (sut, _) = BuildSut("""{"error":{"code":"InternalServerError"}}""", HttpStatusCode.InternalServerError);
|
||||
|
||||
var result = await sut.ValidateAsync(TenantId, "Microsoft Corporation", "Virtual Machine", Serial);
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.True(result.IsTransient);
|
||||
}
|
||||
}
|
||||
@@ -141,4 +141,28 @@ public class SecurityValidatorTests
|
||||
Assert.False(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
|
||||
"CN=hostname, CN=07623d56-1e77-4948-bff5-5bdac8167560", out _));
|
||||
}
|
||||
|
||||
// --- Device-validation error aggregation ---
|
||||
// With several validators enabled, the 403 Details used to carry only the LAST failure
|
||||
// (CloudPc), hiding why the Autopilot / CorporateIdentifier lookups missed — the exact
|
||||
// trap during the first live WDP enrollment (2026-08-17).
|
||||
|
||||
[Fact]
|
||||
public void CombineValidationErrors_joinsInChainOrder()
|
||||
{
|
||||
var combined = SecurityValidator.CombineValidationErrors("autopilot miss", "corp miss");
|
||||
combined = SecurityValidator.CombineValidationErrors(combined, "cloudpc miss");
|
||||
|
||||
Assert.Equal("autopilot miss | corp miss | cloudpc miss", combined);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "only", "only")]
|
||||
[InlineData("only", null, "only")]
|
||||
[InlineData(null, null, null)]
|
||||
[InlineData("", "only", "only")]
|
||||
public void CombineValidationErrors_handlesMissingHalves(string? a, string? b, string? expected)
|
||||
{
|
||||
Assert.Equal(expected, SecurityValidator.CombineValidationErrors(a, b));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@ namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates devices against Intune Corporate Device Identifiers via Microsoft Graph beta API.
|
||||
/// Uses the importedDeviceIdentities/searchExistingIdentities endpoint with manufacturerModelSerial type.
|
||||
/// Uses the importedDeviceIdentities/searchExistingIdentities endpoint and probes BOTH
|
||||
/// identifier types an admin can upload: manufacturerModelSerial and serialNumber. The
|
||||
/// serial-only type is the one the Autopilot Device Preparation (WDP) guidance uses, so
|
||||
/// without it every WDP device whose admin followed the Microsoft docs fails validation.
|
||||
/// Caches positive/negative lookups to reduce Graph traffic.
|
||||
/// </summary>
|
||||
public class CorporateIdentifierValidator
|
||||
@@ -115,6 +118,8 @@ namespace AutopilotMonitor.Functions.Security
|
||||
graphClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult.AccessToken);
|
||||
|
||||
// POST https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities/searchExistingIdentities
|
||||
// Two candidates in one round-trip: admins upload corporate identifiers either as
|
||||
// "manufacturer,model,serial" or (per the WDP guidance) as bare serial number.
|
||||
var identifier = $"{normalizedManufacturer},{normalizedModel},{normalizedSerial}";
|
||||
var requestBody = new
|
||||
{
|
||||
@@ -124,6 +129,11 @@ namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
importedDeviceIdentityType = "manufacturerModelSerial",
|
||||
importedDeviceIdentifier = identifier
|
||||
},
|
||||
new
|
||||
{
|
||||
importedDeviceIdentityType = "serialNumber",
|
||||
importedDeviceIdentifier = normalizedSerial
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -161,21 +171,24 @@ namespace AutopilotMonitor.Functions.Security
|
||||
return CacheAndReturn(cacheKey, new CorporateIdentifierValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = $"Device '{identifier}' is not registered as a Corporate Identifier"
|
||||
ErrorMessage = $"Device is not registered as a Corporate Identifier (searched manufacturerModelSerial '{identifier}' and serialNumber '{normalizedSerial}')"
|
||||
}, isPositive: false);
|
||||
}
|
||||
|
||||
var matchedIdentifier = identities[0]?["importedDeviceIdentifier"]?.ToString() ?? identifier;
|
||||
var matchedType = identities[0]?["importedDeviceIdentityType"]?.ToString() ?? "manufacturerModelSerial";
|
||||
var result = new CorporateIdentifierValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
Identifier = identifier
|
||||
Identifier = matchedIdentifier
|
||||
};
|
||||
|
||||
_logger.LogInformation(
|
||||
"Corporate identifier validation succeeded for tenant {TenantId}, session {SessionId}, identifier {Identifier}",
|
||||
"Corporate identifier validation succeeded for tenant {TenantId}, session {SessionId}, identifier {Identifier} (type {IdentifierType})",
|
||||
tenantId,
|
||||
sessionId ?? "<none>",
|
||||
identifier);
|
||||
matchedIdentifier,
|
||||
matchedType);
|
||||
|
||||
return CacheAndReturn(cacheKey, result, isPositive: true);
|
||||
}
|
||||
|
||||
@@ -362,8 +362,13 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
else
|
||||
{
|
||||
deviceValidationError = corpResult.ErrorMessage;
|
||||
deviceValidationTransient = corpResult.IsTransient;
|
||||
// Accumulate instead of overwrite: with several validators enabled, only the
|
||||
// LAST failure used to surface in the 403 Details / rejection log, hiding why
|
||||
// the earlier (usually more relevant) lookups missed. Transient is sticky-OR:
|
||||
// if ANY validator failed transiently the device might actually be authorized,
|
||||
// so the caller must return 503 Retry-After rather than a definitive 403.
|
||||
deviceValidationError = CombineValidationErrors(deviceValidationError, corpResult.ErrorMessage);
|
||||
deviceValidationTransient |= corpResult.IsTransient;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,8 +389,8 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
else
|
||||
{
|
||||
deviceValidationError = cloudPcResult.ErrorMessage;
|
||||
deviceValidationTransient = cloudPcResult.IsTransient;
|
||||
deviceValidationError = CombineValidationErrors(deviceValidationError, cloudPcResult.ErrorMessage);
|
||||
deviceValidationTransient |= cloudPcResult.IsTransient;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,6 +471,18 @@ namespace AutopilotMonitor.Functions.Security
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Joins the per-validator failure messages so the 403 Details / rejection log carries
|
||||
/// every miss in chain order (Autopilot | CorporateIdentifier | CloudPc), not just the
|
||||
/// last validator's.
|
||||
/// </summary>
|
||||
internal static string? CombineValidationErrors(string? accumulated, string? next)
|
||||
{
|
||||
if (string.IsNullOrEmpty(next)) return accumulated;
|
||||
if (string.IsNullOrEmpty(accumulated)) return next;
|
||||
return accumulated + " | " + next;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the Intune device id from an MDM client certificate subject. Certs issued by
|
||||
/// the Microsoft Intune MDM Device CA carry the Intune managedDevice id as the CN
|
||||
|
||||
Reference in New Issue
Block a user