feat(backend,shared): Intune Enrollment Validation admits enrolled devices without pre-registration (D-282)

The certificate's Intune managedDevice id is looked up in the tenant's Intune. With the tenant
switch ValidateIntuneDeviceBinding on, that lookup is the last accepting link of the validator
chain: an enrolled device that is not being retired, wiped or deleted is admitted as
ValidatorType.IntuneEnrollment. A 404 for a certificate younger than 60 minutes is
NotFoundRecentEnrollment (503, never cached); a missing grant is 503 with 120 s.

For devices another validator admitted, the lookup only observes: in the background, never
admitting or rejecting, and only when the app token carries
DeviceManagementManagedDevices.Read.All, so a tenant without the add-on costs no Graph call.
Outcomes ride on the request row (CertDeviceBinding, role, serial match, owner and enrollment
type).

TenantConfiguration.HasAnyDeviceValidation() is the single gate precondition; the feature flags
expose it as deviceValidationEnabled (replaces validateAutopilotDevice).

The agent parses ValidatorType strictly: release the agent before this backend.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-25 11:57:26 +02:00
parent 9c3e5ecb30
commit 2e736f40ad
22 changed files with 1055 additions and 185 deletions
@@ -44,6 +44,9 @@ public class GetTenantFeatureFlagsPayloadTests
// Non-sensitive by review: bool only ("is a contact address set"), never the
// address itself (admin-gated full config only).
"contactEmailSet",
// Non-sensitive by review: bool only ("is any device validation method enabled"),
// never which one or any Graph detail.
"deviceValidationEnabled",
"diagnosticsUploadConfigured",
"edition",
// Non-sensitive by review: one of "community" | "plan" | "trial" | "msp" — says WHY the
@@ -59,7 +62,6 @@ public class GetTenantFeatureFlagsPayloadTests
// wire options omit null keys (WhenWritingNull) — exactly what production always
// served. Payload_ActiveTrial_IsTrialWithExpiry pins the key on an active trial.
"unrestrictedMode",
"validateAutopilotDevice",
}, fieldNames);
}
@@ -81,13 +83,30 @@ public class GetTenantFeatureFlagsPayloadTests
var element = Serialize(config);
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
Assert.True(element.GetProperty("validateAutopilotDevice").GetBoolean());
Assert.True(element.GetProperty("deviceValidationEnabled").GetBoolean());
Assert.False(element.GetProperty("showScriptOutput").GetBoolean());
Assert.True(element.GetProperty("enableSoftwareInventoryAnalyzer").GetBoolean());
Assert.False(element.GetProperty("enableIntegrityBypassAnalyzer").GetBoolean());
Assert.True(element.GetProperty("unrestrictedMode").GetBoolean());
}
[Theory]
[InlineData(false, false, false)]
[InlineData(true, false, true)]
[InlineData(false, true, true)]
public void Payload_DeviceValidationEnabled_IsAnyMethod_NotJustAutopilot(bool autopilot, bool intuneEnrollment, bool expected)
{
// A device-preparation tenant that validates only via Intune enrollment (or device
// association) must not see the "agent ingestion is blocked" banner.
var element = Serialize(new TenantConfiguration
{
ValidateAutopilotDevice = autopilot,
ValidateIntuneDeviceBinding = intuneEnrollment,
});
Assert.Equal(expected, element.GetProperty("deviceValidationEnabled").GetBoolean());
}
// ── Effective feature gates (plan-derived) ──────────────────────────────
[Fact]
@@ -162,6 +162,7 @@ public class IntuneDeviceBindingValidatorTests
[InlineData(IntuneDeviceBindingOutcome.NotFound)]
[InlineData(IntuneDeviceBindingOutcome.NoDeviceIdInCert)]
[InlineData(IntuneDeviceBindingOutcome.PermissionMissing)]
[InlineData(IntuneDeviceBindingOutcome.NotManaged)]
public void DefinitiveNegatives_AreNotTransient(IntuneDeviceBindingOutcome outcome)
{
// Only Transient may be retried / left uncached. A missing grant or a foreign device is a
@@ -0,0 +1,535 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Intune Enrollment Validation: the certificate's Intune device id admits a device without any
/// pre-registration (<see cref="IntuneDeviceBindingValidator.ValidateAsync"/>), and observes
/// devices another validator admitted (<see cref="IntuneDeviceBindingValidator.ObserveInBackground"/>)
/// — the latter only when the tenant granted the Graph permission, so a tenant without the add-on
/// costs no Graph call at all.
/// </summary>
public class IntuneEnrollmentValidationTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string DeviceId = "07bc0167-9061-43b7-b243-0bb6aeda736e";
private static readonly DateTimeOffset OldCert = DateTimeOffset.UtcNow.AddDays(-30);
private static readonly DateTimeOffset RecentCert = DateTimeOffset.UtcNow.AddMinutes(-5);
private const string MatchBody =
"{\"id\":\"07bc0167-9061-43b7-b243-0bb6aeda736e\",\"deviceName\":\"TEST-DEVICE-01\",\"managementState\":\"managed\","
+ "\"serialNumber\":\"TESTSERIAL01\",\"managedDeviceOwnerType\":\"company\",\"deviceEnrollmentType\":\"windowsAzureADJoin\"}";
// ------------------------------------------------------------------ harness
private sealed class ScriptedHandler : HttpMessageHandler
{
private readonly Queue<Func<HttpResponseMessage>> _responses;
public List<HttpRequestMessage> Requests { get; } = new();
public ScriptedHandler(params Func<HttpResponseMessage>[] responses) => _responses = new(responses);
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
lock (Requests) Requests.Add(request);
var next = _responses.Count > 1 ? _responses.Dequeue() : _responses.Peek();
return Task.FromResult(next());
}
}
private static Func<HttpResponseMessage> Respond(HttpStatusCode status, string body = "{}")
=> () => new HttpResponseMessage(status) { Content = new StringContent(body) };
private sealed record Sut(
IntuneDeviceBindingValidator Validator,
ScriptedHandler Handler,
MemoryCache Cache,
Mock<GraphTokenService> Tokens,
Mock<IGraphFeatureDetector> Detector);
private static Sut Build(bool permissionGranted, params Func<HttpResponseMessage>[] responses)
{
var handler = new ScriptedHandler(responses.Length == 0 ? new[] { Respond(HttpStatusCode.OK, MatchBody) } : responses);
var factory = new Mock<IHttpClientFactory>();
factory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(() => new HttpClient(handler, disposeHandler: false));
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 tokens = new Mock<GraphTokenService>(
NullLogger<GraphTokenService>.Instance, Mock.Of<IHttpClientFactory>(), cache,
configuration, registry, tenantConfig.Object)
{ CallBase = false };
tokens.Setup(t => t.GetAccessTokenAsync(TenantId, It.IsAny<CancellationToken>()))
.ReturnsAsync(GraphTokenResult.Success("token"));
var detector = new Mock<IGraphFeatureDetector>();
detector.Setup(d => d.HasPermissionAsync(TenantId, GraphAppPermissions.DeviceManagementManagedDevicesReadAll, It.IsAny<CancellationToken>()))
.ReturnsAsync(permissionGranted);
var validator = new IntuneDeviceBindingValidator(
NullLogger<IntuneDeviceBindingValidator>.Instance, factory.Object, cache, tokens.Object, detector.Object);
return new Sut(validator, handler, cache, tokens, detector);
}
private static async Task WaitUntil(Func<bool> condition)
{
var deadline = DateTime.UtcNow.AddSeconds(10);
while (!condition())
{
if (DateTime.UtcNow > deadline)
throw new TimeoutException("Background observation did not settle in time.");
await Task.Delay(20);
}
}
private static bool InFlight(MemoryCache cache)
=> cache.TryGetValue(IntuneDeviceBindingValidator.BuildCacheKey(TenantId, DeviceId) + ":inflight", out _);
// ------------------------------------------------------------------ admitting lookups
[Fact]
public async Task Admitting_Match_IsCached_SoTheNextRequestCostsNoGraphCall()
{
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.OK, MatchBody));
var first = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
var second = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
Assert.Equal(IntuneDeviceBindingOutcome.Match, first.Outcome);
Assert.False(first.ServedFromCache);
Assert.True(second.ServedFromCache);
Assert.Single(sut.Handler.Requests);
Assert.Equal("TESTSERIAL01", second.SerialNumber);
}
[Fact]
public async Task Admitting_UsesAPointGetWithTheTelemetrySelect()
{
var sut = Build(permissionGranted: true);
await sut.Validator.ValidateAsync(TenantId, DeviceId.ToUpperInvariant(), OldCert, IntuneDeviceBindingRole.Admitting);
var uri = Uri.UnescapeDataString(Assert.Single(sut.Handler.Requests).RequestUri!.ToString());
Assert.Contains($"/v1.0/deviceManagement/managedDevices/{DeviceId}?", uri);
Assert.DoesNotContain("$filter", uri);
foreach (var field in new[] { "managementState", "serialNumber", "managedDeviceOwnerType", "deviceEnrollmentType", "azureADRegistered" })
Assert.Contains(field, uri);
}
[Fact]
public async Task Admitting_NotFound_WithAnOldCertificate_IsDefinitiveAndCached()
{
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.NotFound));
var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
Assert.Equal(IntuneDeviceBindingOutcome.NotFound, result.Outcome);
Assert.Single(sut.Handler.Requests);
Assert.Equal(IntuneDeviceBindingOutcome.NotFound, sut.Validator.TryGetCached(TenantId, DeviceId)?.Outcome);
}
[Fact]
public async Task Admitting_NotFound_WithARecentCertificate_IsTransientAndNeverCached()
{
// The enrollment race: the device may not be visible in Graph yet. Caching the 404 would
// keep a genuine device out for the whole negative TTL.
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.NotFound));
var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, RecentCert, IntuneDeviceBindingRole.Admitting);
Assert.Equal(IntuneDeviceBindingOutcome.NotFoundRecentEnrollment, result.Outcome);
Assert.True(result.IsTransient);
Assert.Equal(2, sut.Handler.Requests.Count);
Assert.Null(sut.Validator.TryGetCached(TenantId, DeviceId));
}
[Fact]
public async Task Admitting_RecentCertificate_ThenTheObjectAppears_IsAdmittedOnTheRetry()
{
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.NotFound), Respond(HttpStatusCode.OK, MatchBody));
var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, RecentCert, IntuneDeviceBindingRole.Admitting);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
Assert.Equal(2, sut.Handler.Requests.Count);
}
[Fact]
public async Task Admitting_ForbiddenWithAFreshTokenToo_IsPermissionMissing()
{
var sut = Build(permissionGranted: false, Respond(HttpStatusCode.Forbidden), Respond(HttpStatusCode.Forbidden));
var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
Assert.Equal(IntuneDeviceBindingOutcome.PermissionMissing, result.Outcome);
sut.Tokens.Verify(t => t.InvalidateTenant(TenantId), Times.Once);
}
[Fact]
public async Task Admitting_RunsEvenWhenThePermissionWasNotDetected()
{
// An admin who just ran the grant script must not wait for the detector's token cache:
// the admitting lookup goes to Graph and the 401/403 refresh picks up the new role.
var sut = Build(permissionGranted: false, Respond(HttpStatusCode.Forbidden), Respond(HttpStatusCode.OK, MatchBody));
var result = await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
sut.Detector.Verify(d => d.HasPermissionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Admitting_InvalidDeviceId_NeverReachesGraph()
{
var sut = Build(permissionGranted: true);
var result = await sut.Validator.ValidateAsync(TenantId, "not-a-guid", OldCert, IntuneDeviceBindingRole.Admitting);
Assert.Equal(IntuneDeviceBindingOutcome.NoDeviceIdInCert, result.Outcome);
Assert.Empty(sut.Handler.Requests);
}
// ------------------------------------------------------------------ observation
[Fact]
public async Task Observe_WithoutThePermission_MakesNoGraphCall_AndAsksTheDetectorOncePerWindow()
{
var sut = Build(permissionGranted: false);
sut.Validator.ObserveInBackground(TenantId, DeviceId, OldCert, null);
await WaitUntil(() => !InFlight(sut.Cache));
// Later requests of the same tenant stop at the cached "not granted" verdict.
sut.Validator.ObserveInBackground(TenantId, DeviceId, OldCert, null);
sut.Validator.ObserveInBackground(TenantId, "22222222-3333-4444-5555-666666666666", OldCert, null);
await WaitUntil(() => !InFlight(sut.Cache));
Assert.Empty(sut.Handler.Requests);
sut.Tokens.Verify(t => t.GetAccessTokenAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
sut.Detector.Verify(d => d.HasPermissionAsync(TenantId, GraphAppPermissions.DeviceManagementManagedDevicesReadAll, It.IsAny<CancellationToken>()), Times.Once);
Assert.Null(sut.Validator.TryGetCached(TenantId, DeviceId));
}
[Fact]
public async Task Observe_WithThePermission_FillsTheCacheInTheBackground()
{
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.OK, MatchBody));
sut.Validator.ObserveInBackground(TenantId, DeviceId, OldCert, "session-1");
await WaitUntil(() => sut.Validator.TryGetCached(TenantId, DeviceId) != null);
var observed = sut.Validator.TryGetCached(TenantId, DeviceId)!;
Assert.Equal(IntuneDeviceBindingOutcome.Match, observed.Outcome);
Assert.True(observed.ServedFromCache);
Assert.Single(sut.Handler.Requests);
}
[Fact]
public async Task Observe_WhenAlreadyCached_DoesNothing()
{
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.OK, MatchBody));
await sut.Validator.ValidateAsync(TenantId, DeviceId, OldCert, IntuneDeviceBindingRole.Admitting);
sut.Validator.ObserveInBackground(TenantId, DeviceId, OldCert, null);
Assert.False(InFlight(sut.Cache));
Assert.Single(sut.Handler.Requests);
sut.Detector.Verify(d => d.HasPermissionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Observe_TransientOutcome_KeepsACooldownInsteadOfALookupPerRequest()
{
// A recent certificate whose device is not visible yet caches nothing; without the cooldown
// every request of that device would start a new Graph lookup.
var sut = Build(permissionGranted: true, Respond(HttpStatusCode.NotFound));
sut.Validator.ObserveInBackground(TenantId, DeviceId, RecentCert, null);
await WaitUntil(() => sut.Handler.Requests.Count == 2);
await Task.Delay(100);
sut.Validator.ObserveInBackground(TenantId, DeviceId, RecentCert, null);
await Task.Delay(100);
Assert.True(InFlight(sut.Cache));
Assert.Equal(2, sut.Handler.Requests.Count);
Assert.Null(sut.Validator.TryGetCached(TenantId, DeviceId));
}
[Fact]
public void Observe_InvalidDeviceId_DoesNothing()
{
var sut = Build(permissionGranted: true);
sut.Validator.ObserveInBackground(TenantId, null, OldCert, null);
sut.Detector.Verify(d => d.HasPermissionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
// ------------------------------------------------------------------ SecurityValidator chain
private static (SecurityValidator Validator, Mock<IntuneDeviceBindingValidator> Binding) BuildChain(TenantConfiguration config)
{
var configRepo = Mock.Of<IConfigRepository>();
var cache = new MemoryCache(new MemoryCacheOptions());
config.ManufacturerWhitelist = "*";
config.ModelWhitelist = "*";
var configService = new Mock<TenantConfigurationService>(
configRepo, Mock.Of<ILogger<TenantConfigurationService>>(), cache)
{ CallBase = false };
configService.Setup(x => x.TryGetConfigurationAsync(It.IsAny<string>())).ReturnsAsync((config, true));
var adminConfigService = new Mock<AdminConfigurationService>(
configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
{ CallBase = false };
adminConfigService.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration());
var binding = new Mock<IntuneDeviceBindingValidator>(
NullLogger<IntuneDeviceBindingValidator>.Instance, Mock.Of<IHttpClientFactory>(), cache,
null!, Mock.Of<IGraphFeatureDetector>())
{ CallBase = false };
var validator = new SecurityValidator(
configService.Object, adminConfigService.Object,
new RateLimitService(cache, Mock.Of<ILogger<RateLimitService>>()),
autopilotDeviceValidator: null!, corporateIdentifierValidator: null!,
NullLogger.Instance,
intuneDeviceBindingValidator: binding.Object);
return (validator, binding);
}
private static string CertTenant => CertTenantBindingTests.SampleCertTenantId.ToString();
private static void SetupAdmitting(Mock<IntuneDeviceBindingValidator> binding, IntuneDeviceBindingResult result)
=> binding.Setup(b => b.ValidateAsync(CertTenant, DeviceId, It.IsAny<DateTimeOffset?>(), IntuneDeviceBindingRole.Admitting,
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(result);
[Fact]
public async Task Chain_OnlyIntuneEnrollmentEnabled_PassesTheGate_AndAdmitsAnEnrolledDevice()
{
var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
SetupAdmitting(binding, new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.Match, IntuneDeviceId = DeviceId,
SerialNumber = "TESTSERIAL01", OwnerType = "personal", EnrollmentType = "windowsAzureADJoin",
});
var items = new Dictionary<object, object>();
var result = await validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), items), CertTenant);
Assert.True(result.IsValid, result.ErrorMessage + " " + result.Details);
Assert.Equal(ValidatorType.IntuneEnrollment, result.ValidatedBy);
Assert.Equal("IntuneEnrollment", items[RequestRowMarkers.DeviceValidationKey]);
Assert.Equal("Match", items[RequestRowMarkers.CertDeviceBindingKey]);
Assert.Equal("Admitting", items[RequestRowMarkers.CertDeviceBindingRoleKey]);
Assert.Equal("true", items[RequestRowMarkers.CertDeviceSerialMatchKey]);
Assert.Equal("personal", items[RequestRowMarkers.CertDeviceOwnerTypeKey]);
Assert.Equal("windowsAzureADJoin", items[RequestRowMarkers.CertDeviceEnrollmentTypeKey]);
// The admitting lookup already stamped the row; no second, observing lookup.
binding.Verify(b => b.ObserveInBackground(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<DateTimeOffset?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task Chain_PassesTheCertificateNotBeforeToTheLookup()
{
var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
DateTimeOffset? seen = null;
binding.Setup(b => b.ValidateAsync(CertTenant, DeviceId, It.IsAny<DateTimeOffset?>(), IntuneDeviceBindingRole.Admitting,
It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.Callback<string, string?, DateTimeOffset?, IntuneDeviceBindingRole, string?, CancellationToken>((_, _, nb, _, _, _) => seen = nb)
.ReturnsAsync(new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.Match });
await validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64()), CertTenant);
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero), seen);
}
[Theory]
[InlineData(IntuneDeviceBindingOutcome.NotFound)]
[InlineData(IntuneDeviceBindingOutcome.NotManaged)]
[InlineData(IntuneDeviceBindingOutcome.NoDeviceIdInCert)]
public async Task Chain_DefinitiveMiss_IsA403(IntuneDeviceBindingOutcome outcome)
{
var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = outcome, ErrorMessage = "miss" });
var items = new Dictionary<object, object>();
var result = await validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), items), CertTenant);
Assert.False(result.IsValid);
Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
Assert.Equal(outcome.ToString(), items[RequestRowMarkers.CertDeviceBindingKey]);
Assert.Equal(RequestRowMarkers.DeviceValidation.Rejected, items[RequestRowMarkers.DeviceValidationKey]);
}
[Theory]
[InlineData(IntuneDeviceBindingOutcome.NotFoundRecentEnrollment, 30)]
[InlineData(IntuneDeviceBindingOutcome.Transient, 30)]
[InlineData(IntuneDeviceBindingOutcome.PermissionMissing, 120)]
public async Task Chain_NotYetDecidable_IsA503WithRetryAfter(IntuneDeviceBindingOutcome outcome, int retryAfter)
{
var (validator, binding) = BuildChain(new TenantConfiguration { ValidateIntuneDeviceBinding = true });
SetupAdmitting(binding, new IntuneDeviceBindingResult { Outcome = outcome, ErrorMessage = "later" });
var result = await validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64()), CertTenant);
Assert.False(result.IsValid);
Assert.Equal(HttpStatusCode.ServiceUnavailable, result.StatusCode);
Assert.Equal(retryAfter, result.RetryAfterSeconds);
}
[Fact]
public async Task Chain_ToggleOff_NoAdmittingLookup_OnlyABackgroundObservation()
{
var (validator, binding) = BuildChain(new TenantConfiguration { AllowInsecureAgentRequests = true });
var items = new Dictionary<object, object>();
var result = await validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), items), CertTenant);
Assert.True(result.IsValid);
binding.Verify(b => b.ValidateAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<DateTimeOffset?>(),
It.IsAny<IntuneDeviceBindingRole>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Never);
binding.Verify(b => b.ObserveInBackground(CertTenant, DeviceId, It.IsAny<DateTimeOffset?>(), It.IsAny<string?>()), Times.Once);
Assert.False(items.ContainsKey(RequestRowMarkers.CertDeviceBindingKey));
}
[Fact]
public async Task Chain_CachedObservation_IsStampedAsObserving_AndNeverRejects()
{
var (validator, binding) = BuildChain(new TenantConfiguration { AllowInsecureAgentRequests = true });
binding.Setup(b => b.TryGetCached(CertTenant, DeviceId)).Returns(new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.NotFound, SerialNumber = "OTHER-SERIAL",
});
var items = new Dictionary<object, object>();
var result = await validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64(), items), CertTenant);
Assert.True(result.IsValid);
Assert.Equal("NotFound", items[RequestRowMarkers.CertDeviceBindingKey]);
Assert.Equal("Observing", items[RequestRowMarkers.CertDeviceBindingRoleKey]);
Assert.Equal("false", items[RequestRowMarkers.CertDeviceSerialMatchKey]);
binding.Verify(b => b.ObserveInBackground(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<DateTimeOffset?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task Chain_NothingEnabled_IsStillRejectedAtTheGate()
{
var (validator, _) = BuildChain(new TenantConfiguration());
var result = await validator.ValidateRequestAsync(
CertTenantBindingTests.BuildRequestWithCert(CertTenantBindingTests.SampleCertBase64()), CertTenant);
Assert.False(result.IsValid);
Assert.Equal("Device validation is required", result.ErrorMessage);
}
// ------------------------------------------------------------------ gate + wire
[Theory]
[InlineData(nameof(TenantConfiguration.ValidateAutopilotDevice))]
[InlineData(nameof(TenantConfiguration.ValidateCorporateIdentifier))]
[InlineData(nameof(TenantConfiguration.ValidateDeviceAssociation))]
[InlineData(nameof(TenantConfiguration.ValidateCloudPcDevice))]
[InlineData(nameof(TenantConfiguration.ValidateIntuneDeviceBinding))]
public void HasAnyDeviceValidation_EachMethodCountsAlone(string property)
{
var config = new TenantConfiguration();
Assert.False(config.HasAnyDeviceValidation());
typeof(TenantConfiguration).GetProperty(property)!.SetValue(config, true);
Assert.True(config.HasAnyDeviceValidation());
}
[Fact]
public void HasAnyDeviceValidation_CoversEveryValidateFlag()
{
// A new Validate* flag that the gate does not know would leave its tenants locked out.
var flags = typeof(TenantConfiguration).GetProperties()
.Where(p => p.PropertyType == typeof(bool) && p.Name.StartsWith("Validate", StringComparison.Ordinal))
.Select(p => p.Name)
.ToList();
foreach (var flag in flags)
{
var config = new TenantConfiguration();
typeof(TenantConfiguration).GetProperty(flag)!.SetValue(config, true);
Assert.True(config.HasAnyDeviceValidation(), $"{flag} is not part of HasAnyDeviceValidation");
}
}
[Fact]
public void RegisterWire_EveryValidatorTheBackendSends_ParsesInTheAgent()
{
// The backend writes enum names (ApiJsonOptions); the agent reads the same Shared model
// with a plain JsonConvert.DeserializeObject. An agent built from this Shared therefore
// parses every member — which is why a new member ships with an agent release first.
foreach (var value in Enum.GetValues<ValidatorType>())
{
var json = JsonSerializer.Serialize(new RegisterSessionResponse { ValidatedBy = value }, ApiJsonOptions.Create());
Assert.Contains($"\"validatedBy\":\"{value}\"", json);
var parsed = Newtonsoft.Json.JsonConvert.DeserializeObject<RegisterSessionResponse>(json);
Assert.Equal(value, parsed!.ValidatedBy);
}
}
private enum ReleasedAgentValidatorType { Unknown = 0, AutopilotV1 = 1, CorporateIdentifier = 2, DeviceAssociation = 3, Bootstrap = 4, CloudPc = 5 }
private sealed class ReleasedAgentResponse
{
public ReleasedAgentValidatorType ValidatedBy { get; set; }
}
[Fact]
public void RegisterWire_AnAgentWithoutTheMember_FailsTheRegistration()
{
// Why the release order matters: an agent that predates IntuneEnrollment throws on it,
// and a failed parse counts as a failed registration. Agents self-update at every start,
// so releasing the agent before the backend closes the gap.
Assert.ThrowsAny<Newtonsoft.Json.JsonException>(() =>
Newtonsoft.Json.JsonConvert.DeserializeObject<ReleasedAgentResponse>("{\"validatedBy\":\"IntuneEnrollment\"}"));
}
[Theory]
[InlineData("TESTSERIAL01", "testserial01", true)]
[InlineData(" TESTSERIAL01 ", "TESTSERIAL01", true)]
[InlineData("TESTSERIAL01", "OTHER", false)]
[InlineData(null, "TESTSERIAL01", null)]
[InlineData("TESTSERIAL01", "", null)]
public void SerialMatch_ComparesTrimmedAndCaseInsensitive(string? header, string? intune, bool? expected)
=> Assert.Equal(expected, RequestRowMarkers.SerialMatch(header, intune));
}
@@ -373,7 +373,7 @@ public class TypedContractCompletionWireParityTests
{
bootstrapTokenEnabled = true,
diagnosticsUploadConfigured = false,
validateAutopilotDevice = true,
deviceValidationEnabled = true,
appHomingFunnelActive = false,
showScriptOutput = true,
enableSoftwareInventoryAnalyzer = false,
@@ -399,7 +399,7 @@ public class TypedContractCompletionWireParityTests
{
BootstrapTokenEnabled = true,
DiagnosticsUploadConfigured = false,
ValidateAutopilotDevice = true,
DeviceValidationEnabled = true,
AppHomingFunnelActive = false,
ShowScriptOutput = true,
EnableSoftwareInventoryAnalyzer = false,
@@ -303,9 +303,9 @@ public class AutopilotDeviceValidationConsentFunction
}
/// <summary>
/// Known consent triggers — frontend flows that initiate the AAD admin-consent dialog.
/// "device-preparation" is reserved for a future DevPrep-specific consent flow; it is
/// accepted by the success endpoint so the value can land in ops events when wired up.
/// Known consent triggers — frontend flows that initiate the AAD admin-consent dialog:
/// one per serial-based validation (Autopilot, corporate identifier, and
/// "device-preparation" for Device Association Validation).
/// </summary>
internal static readonly HashSet<string> KnownConsentTriggers = new(StringComparer.OrdinalIgnoreCase)
{
@@ -85,9 +85,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
// read the full config, so this boolean is their only signal. Deliberately exposes no
// destination detail — just "would an upload work".
DiagnosticsUploadConfigured = DiagnosticsUploadConfigChange.IsConfigured(config),
// Drives the "Autopilot Device Validation disabled" dashboard banner
// (useTenantSecurityConfig).
ValidateAutopilotDevice = config.ValidateAutopilotDevice,
// Drives the "no device validation enabled" dashboard banner (useTenantSecurityConfig).
DeviceValidationEnabled = config.HasAnyDeviceValidation(),
// Dual app-reg self-service migration: when true, running the consent flow (or
// "Detect existing access") targets the NEW app registration and auto-flips this
// tenant's homing after verification — drives the explanatory banner in the
@@ -129,6 +129,9 @@ public class GetGraphPermissionsStatusFunction
["W365CloudPcValidationGranted"] = GraphFeatureCatalog
.IsFeatureGranted(GraphFeatureCatalog.FeatureW365CloudPcValidation, snapshot.GrantedRoles)
.ToString(CultureInfo.InvariantCulture),
["IntuneDeviceBindingGranted"] = GraphFeatureCatalog
.IsFeatureGranted(GraphFeatureCatalog.FeatureIntuneDeviceBinding, snapshot.GrantedRoles)
.ToString(CultureInfo.InvariantCulture),
});
}
catch (Exception ex)
@@ -143,7 +143,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
_logger.LogInformation($"Registering session {registration.SessionId} for tenant {registration.TenantId} (Device: {validation.CertificateThumbprint})");
// Server-populated: record which device-validation path accepted this request
// (AutopilotV1 / CorporateIdentifier / DeviceAssociation / Bootstrap) so the
// (AutopilotV1 / CorporateIdentifier / DeviceAssociation / Bootstrap / CloudPc /
// IntuneEnrollment) so the
// session row shows HOW the backend admitted the device. Overwrites any
// agent-sent value unconditionally — this is the server's verdict, not input.
registration.ValidatedBy = validation.ValidatedBy;
@@ -198,6 +198,14 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
if (context.Items.TryGetValue(RequestRowMarkers.DeviceValidationKey, out var deviceValidation) && deviceValidation is string deviceValidationOutcome)
requestTelemetry.Properties[RequestRowMarkers.DeviceValidationKey] = deviceValidationOutcome;
// CERT-DEVICE-BINDING — the Intune device lookup behind the certificate (admitting or
// observing), set by SecurityValidator whenever a lookup result exists for the request.
foreach (var key in RequestRowMarkers.CertDeviceBindingKeys)
{
if (context.Items.TryGetValue(key, out var value) && value is string text)
requestTelemetry.Properties[key] = text;
}
var reqCtx = context.GetRequestContext();
var tenantId = ResolveTenantId(
reqCtx.TenantId,
@@ -268,6 +268,7 @@ namespace AutopilotMonitor.Functions.Security
Thumbprint = thumbprint,
Subject = certificate.Subject,
Issuer = certificate.Issuer,
NotBefore = new DateTimeOffset(notBeforeUtc, TimeSpan.Zero),
CertTenantId = certTenantId,
CertTenantIdStatus = certTenantIdStatus
};
@@ -412,6 +413,12 @@ namespace AutopilotMonitor.Functions.Security
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Start of the certificate's validity (UTC), set on success. The Intune MDM Device CA
/// issues the certificate at enrollment, so its age bounds how recently the device enrolled.
/// </summary>
public DateTimeOffset? NotBefore { get; set; }
/// <summary>
/// Entra TenantId stamped into the certificate by the Intune MDM Device CA
/// (OID <c>1.2.840.113556.5.14</c>), or <c>null</c> when
@@ -1,6 +1,10 @@
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models.Graph;
using System;
using System.Collections.Generic;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
@@ -17,55 +21,97 @@ namespace AutopilotMonitor.Functions.Security
/// <para>
/// <see cref="CertTenantBinding"/> already proves the certificate was issued to this tenant.
/// This adds the second half: that the specific device is still one of the tenant's enrolled
/// devices, so a certificate lifted from a decommissioned or wiped machine no longer resolves.
/// devices. That is proof enough to admit a device without any pre-registration, which is
/// what the Intune Enrollment Validation option does (<see cref="ValidateAsync"/>). For
/// devices admitted by another validator the same lookup only observes
/// (<see cref="ObserveInBackground"/>).
/// </para>
/// </summary>
/// <remarks>
/// Built as a near-sibling of <see cref="CloudPcDeviceValidator"/> — same DI dependencies and
/// the same cache/retry/transient contract (30 min positive, 5 min negative, 2 attempts with a
/// 2s back-off, transient on Graph 5xx / token failure / exception). Two deliberate differences:
/// Same cache/retry/budget contract as <see cref="DeviceAssociationValidator"/> (30 min
/// positive, 5 min negative, 2 attempts inside the chain budget, transient never cached), with
/// three deliberate differences:
/// <list type="bullet">
/// <item><description>A point-GET on <c>managedDevices/{id}</c> rather than an OData
/// <c>$filter</c>. There is no filter literal to inject into, and a 404 is an unambiguous
/// <c>$filter</c> (D-125): no filter literal to inject into, and a 404 is an unambiguous
/// "not this tenant's device".</description></item>
/// <item><description><see cref="IntuneDeviceBindingResult.EnrolledDateTime"/> is carried out
/// so callers can record how old the device object was at request time. That is the open
/// question this check must answer before it could ever gate enrollment: a device whose object
/// appears only after the agent's first call is a race, not an attack.</description></item>
/// <item><description>A 404 for a certificate younger than <see cref="RecentEnrollmentWindow"/>
/// is <see cref="IntuneDeviceBindingOutcome.NotFoundRecentEnrollment"/>: the device object may
/// simply not be visible in Graph yet, so the caller answers 503 Retry-After instead of 403 and
/// nothing is cached.</description></item>
/// <item><description>Observation never runs without the Graph permission: whether the tenant
/// granted it is read from the cached app token's roles claim, so a tenant without the add-on
/// costs no Graph call at all.</description></item>
/// </list>
/// SHADOW ONLY (stage 3): nothing consumes the result for an authorization decision. Grep
/// marker for the enforcement change: <c>CERT-DEVICE-BINDING-SHADOW</c>.
/// </remarks>
public class IntuneDeviceBindingValidator
{
private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30);
private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5);
/// <summary>
/// How long after the certificate's NotBefore a 404 counts as "not visible yet" rather than
/// "not this tenant's device". Generous on purpose: the window only turns a 403 into a 503
/// and never admits anything, and whether Intune backdates NotBefore is unmeasured
/// (rejected devices reported their first call at least ~12 min after NotBefore).
/// </summary>
internal static readonly TimeSpan RecentEnrollmentWindow = TimeSpan.FromMinutes(60);
/// <summary>How long a tenant's permission verdict is reused before asking the detector again.</summary>
private static readonly TimeSpan PermissionVerdictTtl = TimeSpan.FromMinutes(2);
/// <summary>Upper bound for one background observation, so an in-flight marker cannot outlive a stuck call.</summary>
private static readonly TimeSpan InFlightTtl = TimeSpan.FromMinutes(1);
/// <summary>
/// Pause after an observation that ended transient (nothing was cached), so a device whose
/// object is not visible yet costs one Graph call per interval, not one per request.
/// </summary>
private static readonly TimeSpan TransientObservationCooldown = TimeSpan.FromSeconds(30);
/// <summary>
/// managementState values of a device that is being removed from management. Such a device
/// still has an object, but it is on its way out, so it must not be admitted. Every other
/// state (managed, unhealthy, discovered, *Failed, *Canceled) is a device Intune still manages.
/// </summary>
private static readonly HashSet<string> LeavingManagementStates = new(StringComparer.OrdinalIgnoreCase)
{
"retirePending", "retireIssued", "wipePending", "wipeIssued", "deletePending",
};
private readonly ILogger<IntuneDeviceBindingValidator> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly GraphTokenService _graphTokenService;
private readonly IGraphFeatureDetector _graphFeatureDetector;
public IntuneDeviceBindingValidator(
ILogger<IntuneDeviceBindingValidator> logger,
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
GraphTokenService graphTokenService)
GraphTokenService graphTokenService,
IGraphFeatureDetector graphFeatureDetector)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_cache = cache;
_graphTokenService = graphTokenService;
_graphFeatureDetector = graphFeatureDetector;
}
/// <summary>
/// Resolves the Intune device id (from the client certificate's Subject CN) against the
/// tenant's managedDevices inventory. Never throws.
/// tenant's managedDevices inventory. Runs regardless of whether the permission was
/// detected: an admitting call must be able to pick up a freshly granted permission through
/// the 401/403 token refresh (<see cref="GraphAuthFailure"/>). Never throws.
/// </summary>
public async Task<IntuneDeviceBindingResult> ValidateAsync(
public virtual async Task<IntuneDeviceBindingResult> ValidateAsync(
string tenantId,
string? intuneDeviceId,
string? sessionId = null)
DateTimeOffset? certNotBefore,
IntuneDeviceBindingRole role,
string? sessionId = null,
CancellationToken ct = default)
{
// The id becomes a URL path segment. The strict GUID gate keeps it from being anything
// else - the same rule the rest of the security path applies.
@@ -89,30 +135,119 @@ namespace AutopilotMonitor.Functions.Security
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
var result = await TryValidateViaGraphAsync(tenantId, normalizedId, sessionId, cacheKey, attempt);
var result = await TryValidateViaGraphAsync(tenantId, normalizedId, certNotBefore, cacheKey, attempt, ct);
if (!result.IsTransient)
{
LogLookup(result, tenantId, certNotBefore, role, sessionId);
return result;
}
lastTransient = result;
if (attempt < maxAttempts)
{
_logger.LogWarning(
"Intune device binding transient failure for tenant {TenantId}, deviceId {IntuneDeviceId} (attempt {Attempt}/{MaxAttempts}). Retrying...",
tenantId, normalizedId, attempt, maxAttempts);
await Task.Delay(TimeSpan.FromSeconds(2));
}
// Budget spent (chain token cancelled): no second attempt on a request the agent
// has abandoned - the transient result becomes the 503 Retry-After.
if (attempt == maxAttempts || ct.IsCancellationRequested)
break;
await DelayBeforeRetryAsync(ct);
}
LogLookup(lastTransient!, tenantId, certNotBefore, role, sessionId);
return lastTransient!;
}
private async Task<IntuneDeviceBindingResult> TryValidateViaGraphAsync(
string tenantId, string normalizedId, string? sessionId, string cacheKey, int attempt)
/// <summary>
/// The cached result for this device, if a lookup already ran. Lets the caller stamp the
/// observation on the request row without waiting for Graph.
/// </summary>
public virtual IntuneDeviceBindingResult? TryGetCached(string tenantId, string? intuneDeviceId)
{
if (!SecurityValidator.IsValidGuid(intuneDeviceId))
return null;
return _cache.TryGetValue(BuildCacheKey(tenantId, intuneDeviceId!.Trim().ToLowerInvariant()), out IntuneDeviceBindingResult? cached)
? cached?.AsCacheHit()
: null;
}
/// <summary>
/// Observation for a device another validator admitted: fills the cache in the background so
/// later requests can stamp the outcome, without adding Graph latency to an admitted request.
/// Does nothing when the tenant has not granted DeviceManagementManagedDevices.Read.All
/// (read from the cached app token, no Graph call), when the result is already cached, or
/// when a lookup for the device is already running. Never throws.
/// </summary>
public virtual void ObserveInBackground(string tenantId, string? intuneDeviceId, DateTimeOffset? certNotBefore, string? sessionId)
{
if (!SecurityValidator.IsValidGuid(intuneDeviceId))
return;
var normalizedId = intuneDeviceId!.Trim().ToLowerInvariant();
var cacheKey = BuildCacheKey(tenantId, normalizedId);
var permissionKey = BuildPermissionKey(tenantId);
if (_cache.TryGetValue(cacheKey, out _))
return;
if (_cache.TryGetValue(permissionKey, out bool granted) && !granted)
return;
var inFlightKey = cacheKey + ":inflight";
if (_cache.TryGetValue(inFlightKey, out _))
return;
_cache.Set(inFlightKey, true, InFlightTtl);
_ = Task.Run(async () =>
{
var cooldown = false;
try
{
using var budget = DeviceValidationBudget.CreateChainCts();
if (!_cache.TryGetValue(permissionKey, out bool hasPermission))
{
hasPermission = await _graphFeatureDetector.HasPermissionAsync(
tenantId, GraphAppPermissions.DeviceManagementManagedDevicesReadAll, budget.Token);
_cache.Set(permissionKey, hasPermission, PermissionVerdictTtl);
}
if (hasPermission)
{
var result = await ValidateAsync(tenantId, normalizedId, certNotBefore, IntuneDeviceBindingRole.Observing, sessionId, budget.Token);
cooldown = result.IsTransient;
}
}
catch (Exception ex)
{
cooldown = true;
_logger.LogWarning(ex, "AgentCertDeviceBinding observation failed for tenant {TenantId} - ignored.", tenantId);
}
finally
{
// A transient outcome caches nothing, so the in-flight marker doubles as the
// retry cooldown; otherwise the cached result answers every later request.
if (cooldown)
_cache.Set(inFlightKey, true, TransientObservationCooldown);
else
_cache.Remove(inFlightKey);
}
});
}
/// <summary>Retry pause that yields early when the chain budget runs out (never throws).</summary>
private static async Task DelayBeforeRetryAsync(CancellationToken ct)
{
try { await Task.Delay(TimeSpan.FromSeconds(2), ct); }
catch (OperationCanceledException) { }
}
private async Task<IntuneDeviceBindingResult> TryValidateViaGraphAsync(
string tenantId, string normalizedId, DateTimeOffset? certNotBefore, string cacheKey, int attempt, CancellationToken chain)
{
using var attemptCts = DeviceValidationBudget.CreateAttemptCts(chain);
var ct = attemptCts.Token;
try
{
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId);
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId, ct);
if (string.IsNullOrEmpty(tokenResult.AccessToken))
{
return new IntuneDeviceBindingResult
@@ -128,16 +263,26 @@ namespace AutopilotMonitor.Functions.Security
// Point-GET: normalizedId passed the GUID gate, so the path segment is safe.
var graphUrl = $"{Constants.GraphBaseUrl}/v1.0/deviceManagement/managedDevices/{normalizedId}"
+ "?$select=id,deviceName,enrolledDateTime,azureADDeviceId,managementState";
+ "?$select=id,deviceName,enrolledDateTime,azureADDeviceId,managementState,"
+ "serialNumber,managedDeviceOwnerType,deviceEnrollmentType,azureADRegistered";
var response = await graphClient.GetAsync(graphUrl);
var responseBody = await response.Content.ReadAsStringAsync();
var response = await graphClient.GetAsync(graphUrl, ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Definitive: this tenant has no managedDevice with that id. Either the device
// belongs elsewhere, or its object has not appeared yet - the race this shadow
// pass exists to measure.
// A young certificate means the device may simply not be visible yet: say so and
// cache nothing, so the next attempt sees the object as soon as it appears.
if (IsRecentEnrollment(certNotBefore, DateTimeOffset.UtcNow))
{
return new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.NotFoundRecentEnrollment,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Device '{normalizedId}' is not visible in Intune yet (recent enrollment)"
};
}
return CacheAndReturn(cacheKey, new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.NotFound,
@@ -146,8 +291,8 @@ namespace AutopilotMonitor.Functions.Security
}, isPositive: false);
}
// 401/403 on attempt 1: the cached token may predate the tenant's consent (see
// GraphAuthFailure) — drop it and let the loop retry with a fresh one before the
// 401/403 on attempt 1: the cached token may predate the tenant's grant (see
// GraphAuthFailure) - drop it and let the loop retry with a fresh one before the
// definitive "not granted" verdict below is cached against a stale token.
if (GraphAuthFailure.TryRecoverStaleToken(_graphTokenService, _logger, nameof(IntuneDeviceBindingValidator), tenantId, response.StatusCode, attempt))
{
@@ -159,7 +304,7 @@ namespace AutopilotMonitor.Functions.Security
};
}
if (response.StatusCode == System.Net.HttpStatusCode.Forbidden)
if (GraphAuthFailure.IsAuthFailure(response.StatusCode))
{
GraphAuthFailure.LogPermissionMissing(_logger, nameof(IntuneDeviceBindingValidator), tenantId, response.StatusCode);
// Configuration state, not an outage: retrying cannot fix a missing grant.
@@ -189,12 +334,22 @@ namespace AutopilotMonitor.Functions.Security
var result = ParseManagedDeviceResponse(responseBody, normalizedId);
return CacheAndReturn(cacheKey, result, isPositive: result.IsValid);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Budget exhausted (per-attempt or chain) - transient, never cached.
return new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.Transient,
IntuneDeviceId = normalizedId,
ErrorMessage = "Managed device lookup timed out"
};
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error during Intune device binding for tenant {TenantId}, session {SessionId}, deviceId {IntuneDeviceId} (attempt {Attempt})",
tenantId, sessionId ?? "<none>", normalizedId, attempt);
"Error during Intune device binding for tenant {TenantId}, deviceId {IntuneDeviceId} (attempt {Attempt})",
tenantId, normalizedId, attempt);
return new IntuneDeviceBindingResult
{
@@ -205,10 +360,37 @@ namespace AutopilotMonitor.Functions.Security
}
}
/// <summary>
/// One Warning line per real Graph lookup (never for cache hits: one enrollment produced 136
/// requests but a single Graph call). Carries the two ages that answer the enrollment-race
/// question: how old the device object and the certificate were at lookup time.
/// </summary>
private void LogLookup(IntuneDeviceBindingResult result, string tenantId, DateTimeOffset? certNotBefore, IntuneDeviceBindingRole role, string? sessionId)
{
var now = DateTimeOffset.UtcNow;
var enrolledAgeSeconds = result.EnrolledDateTime.HasValue
? (long)Math.Round((now - result.EnrolledDateTime.Value).TotalSeconds)
: -1;
var certAgeSeconds = certNotBefore.HasValue
? (long)Math.Round((now - certNotBefore.Value).TotalSeconds)
: -1;
_logger.LogWarning(
"AgentCertDeviceBinding outcome={Outcome} role={Role} tenant={TenantId} "
+ "certDeviceId={CertDeviceId} device={DeviceName} enrolledAgeSeconds={EnrolledAgeSeconds} "
+ "certAgeSeconds={CertAgeSeconds} mgmtState={ManagementState} ownerType={OwnerType} "
+ "enrollmentType={EnrollmentType} session={SessionId} detail={Detail}",
result.Outcome, role, tenantId,
result.IntuneDeviceId ?? "n/a", result.DeviceName ?? "n/a", enrolledAgeSeconds,
certAgeSeconds, result.ManagementState ?? "n/a", result.OwnerType ?? "n/a",
result.EnrollmentType ?? "n/a", sessionId ?? "n/a", result.ErrorMessage ?? "n/a");
}
/// <summary>
/// Pure-function: maps a <c>managedDevices/{id}</c> Graph response body to a result.
/// Re-checks the returned <c>id</c> so a redirected or widened lookup can never produce a
/// false positive.
/// false positive, and turns a device on its way out of management into
/// <see cref="IntuneDeviceBindingOutcome.NotManaged"/>.
/// </summary>
internal static IntuneDeviceBindingResult ParseManagedDeviceResponse(string responseBody, string normalizedId)
{
@@ -236,17 +418,36 @@ namespace AutopilotMonitor.Functions.Security
return notFound;
}
var managementState = device?["managementState"]?.ToString();
var leaving = managementState != null && LeavingManagementStates.Contains(managementState);
return new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.Match,
Outcome = leaving ? IntuneDeviceBindingOutcome.NotManaged : IntuneDeviceBindingOutcome.Match,
IntuneDeviceId = normalizedId,
DeviceName = device?["deviceName"]?.ToString(),
AzureAdDeviceId = device?["azureADDeviceId"]?.ToString(),
ManagementState = device?["managementState"]?.ToString(),
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.ToString())
ManagementState = managementState,
SerialNumber = NullIfEmpty(device?["serialNumber"]?.ToString()),
OwnerType = NullIfEmpty(device?["managedDeviceOwnerType"]?.ToString()),
EnrollmentType = NullIfEmpty(device?["deviceEnrollmentType"]?.ToString()),
AzureAdRegistered = device?["azureADRegistered"]?.Type == JTokenType.Boolean
? device["azureADRegistered"]!.Value<bool>()
: null,
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.ToString()),
ErrorMessage = leaving
? $"Device '{normalizedId}' is being removed from Intune management (state {managementState})"
: null
};
}
/// <summary>
/// True when the certificate was issued less than <see cref="RecentEnrollmentWindow"/> ago.
/// An unknown NotBefore is never recent: without it there is no evidence of a race.
/// </summary>
internal static bool IsRecentEnrollment(DateTimeOffset? certNotBefore, DateTimeOffset now)
=> certNotBefore.HasValue && now - certNotBefore.Value < RecentEnrollmentWindow;
/// <summary>
/// Parses Graph's <c>enrolledDateTime</c>. Intune returns <c>0001-01-01T00:00:00Z</c> for
/// devices it has no enrollment timestamp for; that is absence, not a date, so it maps to
@@ -269,9 +470,14 @@ namespace AutopilotMonitor.Functions.Security
return parsed.Year <= 1 ? null : parsed;
}
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
internal static string BuildCacheKey(string tenantId, string intuneDeviceId)
=> $"intune-device-binding:{tenantId}:{intuneDeviceId}";
internal static string BuildPermissionKey(string tenantId)
=> $"intune-device-binding-permission:{tenantId}";
private IntuneDeviceBindingResult CacheAndReturn(
string cacheKey, IntuneDeviceBindingResult result, bool isPositive)
{
@@ -283,6 +489,13 @@ namespace AutopilotMonitor.Functions.Security
}
}
/// <summary>Why a lookup ran: to admit a device, or to observe one another validator admitted.</summary>
public enum IntuneDeviceBindingRole
{
Admitting = 0,
Observing = 1
}
/// <summary>
/// Stable outcome codes for the device-binding check. Emitted verbatim in the
/// <c>AgentCertDeviceBinding</c> log and as the <c>CertDeviceBinding</c> request dimension —
@@ -290,10 +503,10 @@ namespace AutopilotMonitor.Functions.Security
/// </summary>
public enum IntuneDeviceBindingOutcome
{
/// <summary>Device id from the certificate resolves to a managedDevice in this tenant.</summary>
/// <summary>Device id from the certificate resolves to a managed device in this tenant.</summary>
Match = 0,
/// <summary>No managedDevice with that id in this tenant (foreign device, or not yet created).</summary>
/// <summary>No managedDevice with that id in this tenant, and the certificate is not recent.</summary>
NotFound = 1,
/// <summary>Certificate subject carried no usable Intune device id.</summary>
@@ -302,8 +515,14 @@ namespace AutopilotMonitor.Functions.Security
/// <summary>DeviceManagementManagedDevices.Read.All not granted in this tenant.</summary>
PermissionMissing = 3,
/// <summary>Graph outage, token failure or exception — says nothing about the device.</summary>
Transient = 4
/// <summary>Graph outage, token failure, timeout or exception — says nothing about the device.</summary>
Transient = 4,
/// <summary>The device exists but is being retired, wiped or deleted.</summary>
NotManaged = 5,
/// <summary>No managedDevice yet, but the certificate is recent: the object may not be visible yet.</summary>
NotFoundRecentEnrollment = 6
}
public class IntuneDeviceBindingResult
@@ -314,10 +533,11 @@ namespace AutopilotMonitor.Functions.Security
public bool IsValid => Outcome == IntuneDeviceBindingOutcome.Match;
/// <summary>
/// True when the failure says nothing about the device (Graph error, token issue).
/// Transient results are never cached.
/// True when the failure says nothing definitive about the device (Graph error, token
/// issue, or a device object that may not be visible yet). Transient results are never cached.
/// </summary>
public bool IsTransient => Outcome == IntuneDeviceBindingOutcome.Transient;
public bool IsTransient => Outcome == IntuneDeviceBindingOutcome.Transient
|| Outcome == IntuneDeviceBindingOutcome.NotFoundRecentEnrollment;
/// <summary>Intune device id taken from the client certificate's Subject CN.</summary>
public string? IntuneDeviceId { get; set; }
@@ -328,9 +548,21 @@ namespace AutopilotMonitor.Functions.Security
/// <summary>Entra device id of the same device, if Intune knows one — diagnostic only.</summary>
public string? AzureAdDeviceId { get; set; }
/// <summary>Intune management state — diagnostic only.</summary>
/// <summary>Intune management state; a leaving state turns a match into <see cref="IntuneDeviceBindingOutcome.NotManaged"/>.</summary>
public string? ManagementState { get; set; }
/// <summary>Serial number Intune recorded for the device — compared against the agent's serial header.</summary>
public string? SerialNumber { get; set; }
/// <summary>Intune ownership (<c>company</c>, <c>personal</c>, <c>unknown</c>) — telemetry only.</summary>
public string? OwnerType { get; set; }
/// <summary>Intune deviceEnrollmentType (e.g. <c>windowsAzureADJoin</c>) — telemetry only.</summary>
public string? EnrollmentType { get; set; }
/// <summary>True for a workplace-joined (Entra registered) device — telemetry only.</summary>
public bool? AzureAdRegistered { get; set; }
/// <summary>
/// When Intune recorded the enrollment. The age of this value at request time is what
/// tells a genuine race (object created seconds ago) apart from a foreign certificate.
@@ -341,9 +573,6 @@ namespace AutopilotMonitor.Functions.Security
/// <summary>
/// True when this result came from the in-memory cache rather than a fresh Graph lookup.
/// Callers use it to log once per real lookup instead of once per request: a single
/// enrollment produced 136 requests but only one Graph call, so logging per request said
/// the same thing 136 times.
/// </summary>
public bool ServedFromCache { get; set; }
@@ -358,6 +587,10 @@ namespace AutopilotMonitor.Functions.Security
DeviceName = DeviceName,
AzureAdDeviceId = AzureAdDeviceId,
ManagementState = ManagementState,
SerialNumber = SerialNumber,
OwnerType = OwnerType,
EnrollmentType = EnrollmentType,
AzureAdRegistered = AzureAdRegistered,
EnrolledDateTime = EnrolledDateTime,
ErrorMessage = ErrorMessage,
ServedFromCache = true
@@ -41,6 +41,66 @@ namespace AutopilotMonitor.Functions.Security
public static string DeviceValidationValue(ValidatorType validatedBy)
=> validatedBy == ValidatorType.Unknown ? DeviceValidation.None : validatedBy.ToString();
/// <summary>
/// Outcome of the Intune device lookup behind the client certificate
/// (<see cref="IntuneDeviceBindingOutcome"/> name). Absent when no lookup result exists for
/// the request: the tenant granted no permission, or an observation is still running.
/// </summary>
public const string CertDeviceBindingKey = "CertDeviceBinding";
/// <summary>
/// <see cref="IntuneDeviceBindingRole"/> of that lookup: <c>Admitting</c> when it decided
/// admission, <c>Observing</c> when another validator had already admitted the device.
/// </summary>
public const string CertDeviceBindingRoleKey = "CertDeviceBindingRole";
/// <summary>
/// <c>true</c> / <c>false</c>: whether the agent's serial header equals the serial Intune
/// recorded for the certificate's device. Absent when either side is unknown. Observation
/// only: nothing rejects on it.
/// </summary>
public const string CertDeviceSerialMatchKey = "CertDeviceSerialMatch";
/// <summary>Intune managedDeviceOwnerType of the certificate's device (company / personal / unknown).</summary>
public const string CertDeviceOwnerTypeKey = "CertDeviceOwnerType";
/// <summary>Intune deviceEnrollmentType of the certificate's device.</summary>
public const string CertDeviceEnrollmentTypeKey = "CertDeviceEnrollmentType";
/// <summary>Every cert-device key; <c>RequestTelemetryMiddleware</c> copies them onto the request row.</summary>
public static readonly string[] CertDeviceBindingKeys =
{
CertDeviceBindingKey, CertDeviceBindingRoleKey, CertDeviceSerialMatchKey,
CertDeviceOwnerTypeKey, CertDeviceEnrollmentTypeKey,
};
/// <summary>
/// Stamps one Intune device lookup onto the request row. <paramref name="headerSerial"/> is
/// the agent-supplied serial, compared case-insensitively against the one Intune recorded.
/// </summary>
public static void StampCertDeviceBinding(
HttpRequestData req, IntuneDeviceBindingResult result, IntuneDeviceBindingRole role, string? headerSerial)
{
Stamp(req, CertDeviceBindingKey, result.Outcome.ToString());
Stamp(req, CertDeviceBindingRoleKey, role.ToString());
var serialMatch = SerialMatch(headerSerial, result.SerialNumber);
if (serialMatch.HasValue)
Stamp(req, CertDeviceSerialMatchKey, serialMatch.Value ? "true" : "false");
if (!string.IsNullOrEmpty(result.OwnerType))
Stamp(req, CertDeviceOwnerTypeKey, result.OwnerType!);
if (!string.IsNullOrEmpty(result.EnrollmentType))
Stamp(req, CertDeviceEnrollmentTypeKey, result.EnrollmentType!);
}
/// <summary>Null when either serial is unknown; otherwise a trimmed, case-insensitive comparison.</summary>
internal static bool? SerialMatch(string? headerSerial, string? intuneSerial)
{
if (string.IsNullOrWhiteSpace(headerSerial) || string.IsNullOrWhiteSpace(intuneSerial))
return null;
return string.Equals(headerSerial!.Trim(), intuneSerial!.Trim(), System.StringComparison.OrdinalIgnoreCase);
}
public static void Stamp(HttpRequestData req, string key, string value)
{
var items = req.FunctionContext?.Items;
@@ -291,14 +291,14 @@ namespace AutopilotMonitor.Functions.Security
// Security validation is always enforced (no longer configurable per tenant)
// Hard gate: tenant must enable at least one device validation method before agent traffic is accepted.
// Global Admins can set AllowInsecureAgentRequests=true in the config row for test tenants.
if (!config.ValidateAutopilotDevice && !config.ValidateCorporateIdentifier && !config.ValidateDeviceAssociation && !config.ValidateCloudPcDevice && !config.AllowInsecureAgentRequests)
if (!config.HasAnyDeviceValidation() && !config.AllowInsecureAgentRequests)
{
return new SecurityValidationResult
{
IsValid = false,
StatusCode = HttpStatusCode.Forbidden,
ErrorMessage = "Device validation is required",
Details = "Enable 'Autopilot Device Validation', 'Corporate Identifier Validation', 'Device Association Validation' or 'Windows 365 Cloud PC Validation' in Configuration before using the agent ingestion endpoints."
Details = "Enable at least one device validation method (Autopilot, Corporate Identifier, Device Association, Windows 365 Cloud PC or Intune Enrollment) in Configuration before using the agent ingestion endpoints."
};
}
@@ -413,6 +413,8 @@ namespace AutopilotMonitor.Functions.Security
string? serialNumber = req.Headers.Contains("X-Device-SerialNumber")
? req.Headers.GetValues("X-Device-SerialNumber").FirstOrDefault()
: null;
// TLS-proven device identity: the Intune managedDevice id in the certificate CN.
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var certIntuneDeviceId);
string? autopilotDeviceId = null;
bool deviceValidated = false;
bool deviceValidationTransient = false;
@@ -494,8 +496,7 @@ namespace AutopilotMonitor.Functions.Security
// ends in the 403 below.
if (!deviceValidated && config.ValidateCloudPcDevice && _cloudPcDeviceValidator != null)
{
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var intuneDeviceId);
var cloudPcResult = await _cloudPcDeviceValidator.ValidateCloudPcAsync(tenantId, intuneDeviceId, sessionId, validationCt);
var cloudPcResult = await _cloudPcDeviceValidator.ValidateCloudPcAsync(tenantId, certIntuneDeviceId, sessionId, validationCt);
if (cloudPcResult.IsValid)
{
deviceValidated = true;
@@ -508,7 +509,44 @@ namespace AutopilotMonitor.Functions.Security
}
}
if ((config.ValidateAutopilotDevice || config.ValidateCorporateIdentifier || config.ValidateDeviceAssociation || config.ValidateCloudPcDevice) && !deviceValidated)
// Intune Enrollment Validation — the last accepting method and the only one that needs no
// pre-registration: the certificate's Intune device id must resolve to a device this
// tenant still manages. Same TLS-proven identity as the Cloud PC stage, so nothing here
// depends on a header. Runs even when the permission was not detected, so a freshly
// granted one is picked up through the 401/403 token refresh instead of after the
// detector's token cache expires.
if (!deviceValidated && config.ValidateIntuneDeviceBinding && _intuneDeviceBindingValidator != null)
{
var binding = await _intuneDeviceBindingValidator.ValidateAsync(
tenantId, certIntuneDeviceId, certValidation.NotBefore, IntuneDeviceBindingRole.Admitting, sessionId, validationCt);
RequestRowMarkers.StampCertDeviceBinding(req, binding, IntuneDeviceBindingRole.Admitting, serialNumber);
if (binding.IsValid)
{
deviceValidated = true;
validatedBy = ValidatorType.IntuneEnrollment;
}
else
{
deviceValidationError = CombineValidationErrors(deviceValidationError, binding.ErrorMessage);
// Transient covers a Graph outage and a device object that is not visible yet
// (recent certificate). A missing grant is a configuration gap, not evidence
// about the device, so it is answered like the missing core consent of the
// Autopilot and Device Association lookups (503, 120 s) rather than as
// "device not registered" (GraphAuthFailure).
if (binding.IsTransient)
{
deviceValidationTransient = true;
}
else if (binding.Outcome == IntuneDeviceBindingOutcome.PermissionMissing)
{
deviceValidationTransient = true;
deviceValidationRetryAfter = MaxRetryAfter(deviceValidationRetryAfter, GraphAuthFailure.PermissionMissingRetryAfterSeconds);
}
}
}
if (config.HasAnyDeviceValidation() && !deviceValidated)
{
// Transient failures (Graph API down, token issues) → 503 Retry-After so agent retries
// Definitive failures (device not registered) → 403 Forbidden
@@ -542,74 +580,23 @@ namespace AutopilotMonitor.Functions.Security
};
}
// 5. CERT-DEVICE-BINDING-SHADOW - cert-to-device binding (Global-Admin-only preview).
// Stage 1 (CertTenantBinding) proves the certificate was issued to THIS tenant; this
// proves the specific device is one the tenant actually enrolled, so a certificate
// lifted from a decommissioned machine stops resolving. Observation only: it never
// touches deviceValidated or the returned result.
//
// Fire-and-forget: a cold Graph round-trip can take seconds and an observation-only
// check must never sit in the agent's request path. That rules out the request-row
// dimension stage 1 uses (the row is written the moment the function returns, before
// this task finishes), so the outcome is logged at Warning instead (one line per real
// Graph lookup, see ServedFromCache). Volume is bounded: only Global Admins can enable the
// toggle. Widening it beyond preview should move to an inline call plus the
// CertTenantBinding-style request dimension.
if (config.ValidateIntuneDeviceBinding && _intuneDeviceBindingValidator != null)
// 5. CERT-DEVICE-BINDING — observation for a device another validator admitted (or an
// AllowInsecureAgentRequests tenant). Never admits, never rejects, and never waits for
// Graph: the request row gets the cached outcome, and a cache miss starts a background
// lookup — only when the tenant granted DeviceManagementManagedDevices.Read.All.
if (validatedBy != ValidatorType.IntuneEnrollment && _intuneDeviceBindingValidator != null)
{
var bindingValidator = _intuneDeviceBindingValidator;
var bindingTenant = tenantId;
var bindingSession = sessionId;
var bindingSubject = certValidation.Subject;
var bindingThumbprint = certValidation.Thumbprint;
var bindingLogger = _logger;
_ = Task.Run(async () =>
{
try
{
TryGetIntuneDeviceIdFromCertSubject(bindingSubject, out var certDeviceId);
var binding = await bindingValidator.ValidateAsync(bindingTenant, certDeviceId, bindingSession);
// One line per real Graph lookup, not per request. Measured on the first
// live enrollment: 136 requests produced exactly ONE Graph call (the 30 min
// positive cache did its job), so per-request logging repeated the same
// finding 136 times. Cached repeats add nothing - the age just counts up
// mechanically from the same enrolledDateTime. Uncached outcomes
// (Transient, NoDeviceIdInCert) still log every time, which is what we want
// for anomalies.
if (binding.ServedFromCache)
return;
// Age of the device object at request time: the discriminator between a
// genuine enrollment race (object created moments ago, or not yet) and a
// certificate that never belonged to this tenant.
var enrolledAgeSeconds = binding.EnrolledDateTime.HasValue
? (long)Math.Round((DateTimeOffset.UtcNow - binding.EnrolledDateTime.Value).TotalSeconds)
: -1;
bindingLogger.LogWarning(
"AgentCertDeviceBinding outcome={Outcome} enforced={Enforced} tenant={TenantId} "
+ "certDeviceId={CertDeviceId} device={DeviceName} enrolledAgeSeconds={EnrolledAgeSeconds} "
+ "mgmtState={ManagementState} thumbprint={Thumbprint} session={SessionId} detail={Detail}",
binding.Outcome, false, bindingTenant,
certDeviceId ?? "n/a", binding.DeviceName ?? "n/a", enrolledAgeSeconds,
binding.ManagementState ?? "n/a", bindingThumbprint ?? "n/a",
bindingSession ?? "n/a", binding.ErrorMessage ?? "n/a");
}
catch (Exception ex)
{
bindingLogger.LogWarning(ex,
"AgentCertDeviceBinding (shadow) failed for tenant {TenantId} - ignored.", bindingTenant);
}
});
var observed = _intuneDeviceBindingValidator.TryGetCached(tenantId, certIntuneDeviceId);
if (observed != null)
RequestRowMarkers.StampCertDeviceBinding(req, observed, IntuneDeviceBindingRole.Observing, serialNumber);
else
_intuneDeviceBindingValidator.ObserveInBackground(tenantId, certIntuneDeviceId, certValidation.NotBefore, sessionId);
}
// All checks passed. The admitting validator rides on the request row: the Sessions
// row keeps ValidatedBy per session, this is the per-request counter and timestamp.
RequestRowMarkers.Stamp(req, RequestRowMarkers.DeviceValidationKey,
RequestRowMarkers.DeviceValidationValue(validatedBy));
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var certIntuneDeviceId);
return new SecurityValidationResult
{
IsValid = true,
@@ -626,7 +613,8 @@ 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
/// every miss in chain order (Autopilot | CorporateIdentifier | DeviceAssociation | CloudPc |
/// IntuneEnrollment), not just the
/// last validator's.
/// </summary>
internal static string? CombineValidationErrors(string? accumulated, string? next)
@@ -195,8 +195,8 @@ namespace AutopilotMonitor.Functions.Services
if (!string.IsNullOrEmpty(completionSource))
indexEntity["CompletionSource"] = completionSource;
// Device-validation path recorded at registration (AutopilotV1 / CorporateIdentifier /
// DeviceAssociation / Bootstrap) — index-served session reads must carry it too.
// Device-validation path recorded at registration (ValidatorType name) — index-served
// session reads must carry it too.
var validatedBy = sessionEntity.GetString("ValidatedBy");
if (!string.IsNullOrEmpty(validatedBy))
indexEntity["ValidatedBy"] = validatedBy;
@@ -3370,7 +3370,7 @@ export interface RegisterSessionResponse {
registeredAt: string;
/** Non-null when the session was already marked as terminal by an admin before agent restart. Values: "Succeeded", "Failed". Agent should run cleanup instead of starting monitoring. */
adminAction?: string;
/** Authoritative signal: which validator accepted this device. Lets the agent reconcile its registry-based enrollment-type detection against the backend's verdict (e.g. AutopilotV1 → Classic flow, DeviceAssociation → DevPrep flow). Older backends that do not set this return Unknown — agent falls back to its own detection. */
/** Which validator accepted this device. The agent only logs it and forwards it on the SessionStarted signal; its enrollment flow comes from its own registry detection. */
validatedBy: ValidatorType;
/** Machine-readable reason on a failure response (see AgentErrorCodes). Null on success and on failures that carry no agent-actionable code. */
errorCode?: string;
@@ -4220,7 +4220,7 @@ export interface SessionSummary {
completionSource: string;
/** Non-null only when an administrator explicitly flipped the session via the portal (MarkSessionSucceeded / MarkSessionFailed). Values: null (default, agent-driven), "Succeeded", "Failed". This is the authoritative source for the backend's AdminAction response field sent to agents. Previously the backend inferred admin-override from "status is terminal + current event is not a completion marker", which fired falsely on every post-completion event the agent sent (agent_shutting_down, diagnostics_uploaded, enrollment_summary_shown). The dedicated field eliminates that false-positive. */
adminMarkedAction?: string;
/** Which backend device-validation path accepted this device at session registration — ValidatorType name as string: "AutopilotV1" (Autopilot S/N lookup), "CorporateIdentifier", "DeviceAssociation" (device preparation), "CloudPc", or "Bootstrap" (pre-MDM token). Latest non-Unknown validation wins (a Bootstrap session re-registering under cert auth upgrades to the cert-path validator). Empty for sessions that predate this field or tenants with device validation off. */
/** Which backend device-validation path accepted this device at session registration — ValidatorType name as string: "AutopilotV1" (Autopilot S/N lookup), "CorporateIdentifier", "DeviceAssociation" (device preparation), "CloudPc", "IntuneEnrollment" (enrolled Intune device, no pre-registration) or "Bootstrap" (pre-MDM token). Latest non-Unknown validation wins (a Bootstrap session re-registering under cert auth upgrades to the cert-path validator). Empty for sessions that predate this field or tenants with device validation off. */
validatedBy: string;
eventCount: number;
durationSeconds?: number;
@@ -4775,15 +4775,15 @@ export interface TenantConfiguration {
modelWhitelist: string;
/** Whether to validate devices against Intune Autopilot device registration Requires Graph API integration (admin consent for DeviceManagementServiceConfig.Read.All) */
validateAutopilotDevice: boolean;
/** Whether to validate devices against Intune Corporate Device Identifiers (manufacturer + model + serial number via importedDeviceIdentities/searchExistingIdentities). Requires Graph API integration (admin consent for DeviceManagementServiceConfig.ReadWrite.All) */
/** Whether to validate devices against Intune Corporate Device Identifiers (manufacturer + model + serial number, read from importedDeviceIdentities). Requires Graph API integration (admin consent for DeviceManagementServiceConfig.Read.All) */
validateCorporateIdentifier: boolean;
/** Whether to validate devices against the Windows Autopilot device preparation "Device association" catalog via Graph (tenantAssociatedDevices, serial number match). One of the accepting methods of the device-validation gate, evaluated after the Autopilot and Corporate Identifier lookups (see SecurityValidator). Device association is GA since 2026-08-25; associated devices are marked corporate-owned by Intune itself, so no corporate identifier exists for them. Requires the same Graph permission as the other validators (DeviceManagementServiceConfig.Read.All). */
validateDeviceAssociation: boolean;
/** Whether to validate Windows 365 Cloud PCs as a fallback when the Autopilot / Corporate Identifier lookups miss. Cloud PCs are provisioned by the Windows 365 service and are structurally never Autopilot-registered; this validator instead resolves the Intune device id from the (chain-validated) MDM client certificate's Subject CN and requires a matching cloudPC object (virtualEndpoint/cloudPCs, managedDeviceId eq CN) in the tenant. Only service-provisioned Cloud PCs have such an object, so no other enrolled device can pass this stage. Requires the optional Graph permission CloudPC.Read.All (feature "W365CloudPcValidation" in the grant script). */
validateCloudPcDevice: boolean;
/** Cert-to-device binding check (Global-Admin-only preview, SHADOW mode). Resolves the Intune managedDevice id carried in the agent client certificate's Subject CN against this tenant's own managedDevices inventory, proving the certificate belongs to a device the tenant actually enrolled. The result is recorded as telemetry only and never blocks enrollment, because a device object can in principle appear later than the agent's first call - measuring that race is the point of the shadow pass. Requires the optional Graph permission DeviceManagementManagedDevices.Read.All (feature "IntuneDeviceBinding" in the grant script). */
/** Intune Enrollment Validation: the last accepting method of the device-validation gate. Resolves the Intune managedDevice id carried in the agent client certificate's Subject CN against this tenant's own managedDevices inventory and admits the device when it is an enrolled device of the tenant, without any pre-registration (Autopilot hash, corporate identifier, device association). Evaluated only when no earlier validator admitted the device. Requires the optional Graph permission DeviceManagementManagedDevices.Read.All (feature "IntuneDeviceBinding" in the grant script). */
validateIntuneDeviceBinding: boolean;
/** Emergency bypass for agent security gate (Global Admin use only). If true, agent requests are accepted even when ValidateAutopilotDevice is false. Default: false */
/** Emergency bypass for agent security gate (Global Admin use only). If true, agent requests are accepted even when no device validation is enabled (see HasAnyDeviceValidation). Default: false */
allowInsecureAgentRequests: boolean;
/** Data retention period in days Sessions and events older than this will be deleted by the daily maintenance job Default: 90 days */
dataRetentionDays: number;
@@ -4953,8 +4953,8 @@ export interface TenantFeatureFlagsResponse {
bootstrapTokenEnabled: boolean;
/** Whether an on-demand diagnostics upload can succeed right now (mode not Off + usable destination). Deliberately no destination detail. */
diagnosticsUploadConfigured: boolean;
/** Drives the "Autopilot Device Validation disabled" dashboard banner. */
validateAutopilotDevice: boolean;
/** At least one device-validation method is enabled (HasAnyDeviceValidation); false drives the "agent ingestion is blocked" dashboard banner. */
deviceValidationEnabled: boolean;
/** Dual app-reg self-service migration: consent flow targets the NEW app registration. Non-sensitive — exposes no client ids. */
appHomingFunnelActive: boolean;
showScriptOutput: boolean;
@@ -5404,8 +5404,8 @@ export interface UserUsageRecord {
lastRequestAt: string;
}
/** Identifies which validator authorized the device during session registration. Surfaced in the RegisterSession response so the agent can reconcile against its own registry-based detection and, when needed, switch its enrollment flow. */
export type ValidatorType = "Unknown" | "AutopilotV1" | "CorporateIdentifier" | "DeviceAssociation" | "Bootstrap" | "CloudPc";
/** Identifies which validator authorized the device during session registration. Stored on the session row and echoed in the RegisterSession response. The agent parses that response strictly (an unknown name fails the registration), so a new member ships with an agent release BEFORE the backend that can send it; agents self-update at every start, so a released agent is what every new registration runs. */
export type ValidatorType = "Unknown" | "AutopilotV1" | "CorporateIdentifier" | "DeviceAssociation" | "Bootstrap" | "CloudPc" | "IntuneEnrollment";
/** One ACTIVE verdict-calibration alert episode (operator-only). Persisted as the verdictcalibration|{kind}|{path}|{status} keyspace of the notification tracker — the row IS the dedup (one ops event per episode) and the alerts[] payload of the calibration endpoint. Deleted when the signal re-arms (share back under 1.5× baseline, path stops occurring, evidence-gap share back under 15 %) or by the tracker's 30-day retention sweep. Numbers refresh on every radar pass; FirstNotifiedAt never moves. Dimension concentration is CORRELATION only — every consumer says so. */
export interface VerdictCalibrationAlert {
@@ -285,8 +285,8 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>
/// Whether to validate devices against Intune Corporate Device Identifiers
/// (manufacturer + model + serial number via importedDeviceIdentities/searchExistingIdentities).
/// Requires Graph API integration (admin consent for DeviceManagementServiceConfig.ReadWrite.All)
/// (manufacturer + model + serial number, read from importedDeviceIdentities).
/// Requires Graph API integration (admin consent for DeviceManagementServiceConfig.Read.All)
/// </summary>
public bool ValidateCorporateIdentifier { get; set; } = false;
@@ -314,20 +314,20 @@ namespace AutopilotMonitor.Shared.Models
public bool ValidateCloudPcDevice { get; set; } = false;
/// <summary>
/// Cert-to-device binding check (Global-Admin-only preview, SHADOW mode).
/// Intune Enrollment Validation: the last accepting method of the device-validation gate.
/// Resolves the Intune managedDevice id carried in the agent client certificate's Subject
/// CN against this tenant's own managedDevices inventory, proving the certificate belongs
/// to a device the tenant actually enrolled. The result is recorded as telemetry only and
/// never blocks enrollment, because a device object can in principle appear later than the
/// agent's first call - measuring that race is the point of the shadow pass. Requires the
/// optional Graph permission DeviceManagementManagedDevices.Read.All (feature
/// "IntuneDeviceBinding" in the grant script).
/// CN against this tenant's own managedDevices inventory and admits the device when it is
/// an enrolled device of the tenant, without any pre-registration (Autopilot hash,
/// corporate identifier, device association). Evaluated only when no earlier validator
/// admitted the device. Requires the optional Graph permission
/// DeviceManagementManagedDevices.Read.All (feature "IntuneDeviceBinding" in the grant script).
/// </summary>
public bool ValidateIntuneDeviceBinding { get; set; } = false;
/// <summary>
/// Emergency bypass for agent security gate (Global Admin use only).
/// If true, agent requests are accepted even when ValidateAutopilotDevice is false.
/// If true, agent requests are accepted even when no device validation is enabled
/// (see <see cref="HasAnyDeviceValidation"/>).
/// Default: false
/// </summary>
public bool AllowInsecureAgentRequests { get; set; } = false;
@@ -1030,6 +1030,18 @@ namespace AutopilotMonitor.Shared.Models
return channels;
}
/// <summary>
/// True when at least one device-validation method is enabled. The single definition of
/// the agent gate's precondition: without one (and without AllowInsecureAgentRequests)
/// the agent ingestion endpoints reject every request.
/// </summary>
public bool HasAnyDeviceValidation()
=> ValidateAutopilotDevice
|| ValidateCorporateIdentifier
|| ValidateDeviceAssociation
|| ValidateCloudPcDevice
|| ValidateIntuneDeviceBinding;
/// <summary>
/// Checks if the tenant is currently disabled
/// Takes into account DisabledUntil if set
@@ -16,8 +16,8 @@ namespace AutopilotMonitor.Shared.Models
public bool BootstrapTokenEnabled { get; set; }
/// <summary>Whether an on-demand diagnostics upload can succeed right now (mode not Off + usable destination). Deliberately no destination detail.</summary>
public bool DiagnosticsUploadConfigured { get; set; }
/// <summary>Drives the "Autopilot Device Validation disabled" dashboard banner.</summary>
public bool ValidateAutopilotDevice { get; set; }
/// <summary>At least one device-validation method is enabled (<see cref="TenantConfiguration.HasAnyDeviceValidation"/>); false drives the "agent ingestion is blocked" dashboard banner.</summary>
public bool DeviceValidationEnabled { get; set; }
/// <summary>Dual app-reg self-service migration: consent flow targets the NEW app registration. Non-sensitive — exposes no client ids.</summary>
public bool AppHomingFunnelActive { get; set; }
public bool ShowScriptOutput { get; set; }
@@ -28,9 +28,9 @@
/// <summary>
/// Read Intune managed device objects (<c>deviceManagement/managedDevices</c>). Optional
/// add-on permission backing the Intune device-binding check, which verifies that the
/// managedDevice id carried in the agent's client certificate actually exists in the
/// tenant it claims. Not part of the default-consent set — must be granted per tenant.
/// add-on permission backing Intune Enrollment Validation, which resolves the managedDevice
/// id carried in the agent's client certificate in the tenant it claims. Not part of the
/// default-consent set — must be granted per tenant.
/// </summary>
public const string DeviceManagementManagedDevicesReadAll = "DeviceManagementManagedDevices.Read.All";
@@ -18,8 +18,9 @@ namespace AutopilotMonitor.Shared.Models.Graph
public const string FeatureW365CloudPcValidation = "W365CloudPcValidation";
/// <summary>
/// Verifies that the Intune managedDevice id carried in the agent's client certificate
/// actually exists in the tenant the request claims (cert-to-device binding).
/// Intune Enrollment Validation: resolves the Intune managedDevice id carried in the
/// agent's client certificate against the tenant's managed devices, which admits devices
/// without any pre-registration and observes the ones other validators admitted.
/// </summary>
public const string FeatureIntuneDeviceBinding = "IntuneDeviceBinding";
@@ -13,8 +13,10 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>
/// Identifies which validator authorized the device during session registration.
/// Surfaced in the RegisterSession response so the agent can reconcile against its
/// own registry-based detection and, when needed, switch its enrollment flow.
/// Stored on the session row and echoed in the RegisterSession response. The agent parses
/// that response strictly (an unknown name fails the registration), so a new member ships
/// with an agent release BEFORE the backend that can send it; agents self-update at every
/// start, so a released agent is what every new registration runs.
/// </summary>
public enum ValidatorType
{
@@ -23,7 +25,8 @@ namespace AutopilotMonitor.Shared.Models
CorporateIdentifier = 2, // CorporateIdentifierValidator (importedDeviceIdentities)
DeviceAssociation = 3, // DeviceAssociationValidator (device preparation tenantAssociatedDevices)
Bootstrap = 4, // Bootstrap token auth (pre-MDM OOBE)
CloudPc = 5 // CloudPcDeviceValidator (virtualEndpoint/cloudPCs, cert-CN bound) — W365
CloudPc = 5, // CloudPcDeviceValidator (virtualEndpoint/cloudPCs, cert-CN bound) — W365
IntuneEnrollment = 6 // IntuneDeviceBindingValidator (managedDevices, cert-CN bound) — no pre-registration
}
/// <summary>
@@ -44,10 +47,8 @@ namespace AutopilotMonitor.Shared.Models
public string? AdminAction { get; set; }
/// <summary>
/// Authoritative signal: which validator accepted this device.
/// Lets the agent reconcile its registry-based enrollment-type detection
/// against the backend's verdict (e.g. AutopilotV1 → Classic flow, DeviceAssociation → DevPrep flow).
/// Older backends that do not set this return Unknown — agent falls back to its own detection.
/// Which validator accepted this device. The agent only logs it and forwards it on the
/// SessionStarted signal; its enrollment flow comes from its own registry detection.
/// </summary>
public ValidatorType ValidatedBy { get; set; } = ValidatorType.Unknown;
@@ -351,8 +352,9 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>
/// Which backend device-validation path accepted this device at session registration —
/// <see cref="ValidatorType"/> name as string: "AutopilotV1" (Autopilot S/N lookup),
/// "CorporateIdentifier", "DeviceAssociation" (device preparation), "CloudPc", or
/// "Bootstrap" (pre-MDM token). Latest non-Unknown validation wins (a Bootstrap
/// "CorporateIdentifier", "DeviceAssociation" (device preparation), "CloudPc",
/// "IntuneEnrollment" (enrolled Intune device, no pre-registration) or "Bootstrap"
/// (pre-MDM token). Latest non-Unknown validation wins (a Bootstrap
/// session re-registering under cert auth upgrades to the cert-path validator).
/// Empty for sessions that predate this field or tenants with device validation off.
/// </summary>
@@ -15676,7 +15676,7 @@
"kind": "enum",
"name": "ValidatorType"
},
"doc": "Authoritative signal: which validator accepted this device. Lets the agent reconcile its registry-based enrollment-type detection against the backend's verdict (e.g. AutopilotV1 → Classic flow, DeviceAssociation → DevPrep flow). Older backends that do not set this return Unknown — agent falls back to its own detection."
"doc": "Which validator accepted this device. The agent only logs it and forwards it on the SessionStarted signal; its enrollment flow comes from its own registry detection."
},
{
"name": "errorCode",
@@ -19451,7 +19451,7 @@
"kind": "primitive",
"name": "string"
},
"doc": "Which backend device-validation path accepted this device at session registration — ValidatorType name as string: \"AutopilotV1\" (Autopilot S/N lookup), \"CorporateIdentifier\", \"DeviceAssociation\" (device preparation), \"CloudPc\", or \"Bootstrap\" (pre-MDM token). Latest non-Unknown validation wins (a Bootstrap session re-registering under cert auth upgrades to the cert-path validator). Empty for sessions that predate this field or tenants with device validation off."
"doc": "Which backend device-validation path accepted this device at session registration — ValidatorType name as string: \"AutopilotV1\" (Autopilot S/N lookup), \"CorporateIdentifier\", \"DeviceAssociation\" (device preparation), \"CloudPc\", \"IntuneEnrollment\" (enrolled Intune device, no pre-registration) or \"Bootstrap\" (pre-MDM token). Latest non-Unknown validation wins (a Bootstrap session re-registering under cert auth upgrades to the cert-path validator). Empty for sessions that predate this field or tenants with device validation off."
},
{
"name": "eventCount",
@@ -21881,7 +21881,7 @@
"kind": "primitive",
"name": "boolean"
},
"doc": "Whether to validate devices against Intune Corporate Device Identifiers (manufacturer + model + serial number via importedDeviceIdentities/searchExistingIdentities). Requires Graph API integration (admin consent for DeviceManagementServiceConfig.ReadWrite.All)"
"doc": "Whether to validate devices against Intune Corporate Device Identifiers (manufacturer + model + serial number, read from importedDeviceIdentities). Requires Graph API integration (admin consent for DeviceManagementServiceConfig.Read.All)"
},
{
"name": "validateDeviceAssociation",
@@ -21905,7 +21905,7 @@
"kind": "primitive",
"name": "boolean"
},
"doc": "Cert-to-device binding check (Global-Admin-only preview, SHADOW mode). Resolves the Intune managedDevice id carried in the agent client certificate's Subject CN against this tenant's own managedDevices inventory, proving the certificate belongs to a device the tenant actually enrolled. The result is recorded as telemetry only and never blocks enrollment, because a device object can in principle appear later than the agent's first call - measuring that race is the point of the shadow pass. Requires the optional Graph permission DeviceManagementManagedDevices.Read.All (feature \"IntuneDeviceBinding\" in the grant script)."
"doc": "Intune Enrollment Validation: the last accepting method of the device-validation gate. Resolves the Intune managedDevice id carried in the agent client certificate's Subject CN against this tenant's own managedDevices inventory and admits the device when it is an enrolled device of the tenant, without any pre-registration (Autopilot hash, corporate identifier, device association). Evaluated only when no earlier validator admitted the device. Requires the optional Graph permission DeviceManagementManagedDevices.Read.All (feature \"IntuneDeviceBinding\" in the grant script)."
},
{
"name": "allowInsecureAgentRequests",
@@ -21913,7 +21913,7 @@
"kind": "primitive",
"name": "boolean"
},
"doc": "Emergency bypass for agent security gate (Global Admin use only). If true, agent requests are accepted even when ValidateAutopilotDevice is false. Default: false"
"doc": "Emergency bypass for agent security gate (Global Admin use only). If true, agent requests are accepted even when no device validation is enabled (see HasAnyDeviceValidation). Default: false"
},
{
"name": "dataRetentionDays",
@@ -22634,12 +22634,12 @@
"doc": "Whether an on-demand diagnostics upload can succeed right now (mode not Off + usable destination). Deliberately no destination detail."
},
{
"name": "validateAutopilotDevice",
"name": "deviceValidationEnabled",
"type": {
"kind": "primitive",
"name": "boolean"
},
"doc": "Drives the \"Autopilot Device Validation disabled\" dashboard banner."
"doc": "At least one device-validation method is enabled (HasAnyDeviceValidation); false drives the \"agent ingestion is blocked\" dashboard banner."
},
{
"name": "appHomingFunnelActive",
@@ -24572,14 +24572,15 @@
},
"ValidatorType": {
"kind": "enum",
"doc": "Identifies which validator authorized the device during session registration. Surfaced in the RegisterSession response so the agent can reconcile against its own registry-based detection and, when needed, switch its enrollment flow.",
"doc": "Identifies which validator authorized the device during session registration. Stored on the session row and echoed in the RegisterSession response. The agent parses that response strictly (an unknown name fails the registration), so a new member ships with an agent release BEFORE the backend that can send it; agents self-update at every start, so a released agent is what every new registration runs.",
"members": [
"Unknown",
"AutopilotV1",
"CorporateIdentifier",
"DeviceAssociation",
"Bootstrap",
"CloudPc"
"CloudPc",
"IntuneEnrollment"
]
},
"VerdictCalibrationAlert": {
@@ -3370,7 +3370,7 @@ export interface RegisterSessionResponse {
registeredAt: string;
/** Non-null when the session was already marked as terminal by an admin before agent restart. Values: "Succeeded", "Failed". Agent should run cleanup instead of starting monitoring. */
adminAction?: string;
/** Authoritative signal: which validator accepted this device. Lets the agent reconcile its registry-based enrollment-type detection against the backend's verdict (e.g. AutopilotV1 → Classic flow, DeviceAssociation → DevPrep flow). Older backends that do not set this return Unknown — agent falls back to its own detection. */
/** Which validator accepted this device. The agent only logs it and forwards it on the SessionStarted signal; its enrollment flow comes from its own registry detection. */
validatedBy: ValidatorType;
/** Machine-readable reason on a failure response (see AgentErrorCodes). Null on success and on failures that carry no agent-actionable code. */
errorCode?: string;
@@ -4220,7 +4220,7 @@ export interface SessionSummary {
completionSource: string;
/** Non-null only when an administrator explicitly flipped the session via the portal (MarkSessionSucceeded / MarkSessionFailed). Values: null (default, agent-driven), "Succeeded", "Failed". This is the authoritative source for the backend's AdminAction response field sent to agents. Previously the backend inferred admin-override from "status is terminal + current event is not a completion marker", which fired falsely on every post-completion event the agent sent (agent_shutting_down, diagnostics_uploaded, enrollment_summary_shown). The dedicated field eliminates that false-positive. */
adminMarkedAction?: string;
/** Which backend device-validation path accepted this device at session registration — ValidatorType name as string: "AutopilotV1" (Autopilot S/N lookup), "CorporateIdentifier", "DeviceAssociation" (device preparation), "CloudPc", or "Bootstrap" (pre-MDM token). Latest non-Unknown validation wins (a Bootstrap session re-registering under cert auth upgrades to the cert-path validator). Empty for sessions that predate this field or tenants with device validation off. */
/** Which backend device-validation path accepted this device at session registration — ValidatorType name as string: "AutopilotV1" (Autopilot S/N lookup), "CorporateIdentifier", "DeviceAssociation" (device preparation), "CloudPc", "IntuneEnrollment" (enrolled Intune device, no pre-registration) or "Bootstrap" (pre-MDM token). Latest non-Unknown validation wins (a Bootstrap session re-registering under cert auth upgrades to the cert-path validator). Empty for sessions that predate this field or tenants with device validation off. */
validatedBy: string;
eventCount: number;
durationSeconds?: number;
@@ -4775,15 +4775,15 @@ export interface TenantConfiguration {
modelWhitelist: string;
/** Whether to validate devices against Intune Autopilot device registration Requires Graph API integration (admin consent for DeviceManagementServiceConfig.Read.All) */
validateAutopilotDevice: boolean;
/** Whether to validate devices against Intune Corporate Device Identifiers (manufacturer + model + serial number via importedDeviceIdentities/searchExistingIdentities). Requires Graph API integration (admin consent for DeviceManagementServiceConfig.ReadWrite.All) */
/** Whether to validate devices against Intune Corporate Device Identifiers (manufacturer + model + serial number, read from importedDeviceIdentities). Requires Graph API integration (admin consent for DeviceManagementServiceConfig.Read.All) */
validateCorporateIdentifier: boolean;
/** Whether to validate devices against the Windows Autopilot device preparation "Device association" catalog via Graph (tenantAssociatedDevices, serial number match). One of the accepting methods of the device-validation gate, evaluated after the Autopilot and Corporate Identifier lookups (see SecurityValidator). Device association is GA since 2026-08-25; associated devices are marked corporate-owned by Intune itself, so no corporate identifier exists for them. Requires the same Graph permission as the other validators (DeviceManagementServiceConfig.Read.All). */
validateDeviceAssociation: boolean;
/** Whether to validate Windows 365 Cloud PCs as a fallback when the Autopilot / Corporate Identifier lookups miss. Cloud PCs are provisioned by the Windows 365 service and are structurally never Autopilot-registered; this validator instead resolves the Intune device id from the (chain-validated) MDM client certificate's Subject CN and requires a matching cloudPC object (virtualEndpoint/cloudPCs, managedDeviceId eq CN) in the tenant. Only service-provisioned Cloud PCs have such an object, so no other enrolled device can pass this stage. Requires the optional Graph permission CloudPC.Read.All (feature "W365CloudPcValidation" in the grant script). */
validateCloudPcDevice: boolean;
/** Cert-to-device binding check (Global-Admin-only preview, SHADOW mode). Resolves the Intune managedDevice id carried in the agent client certificate's Subject CN against this tenant's own managedDevices inventory, proving the certificate belongs to a device the tenant actually enrolled. The result is recorded as telemetry only and never blocks enrollment, because a device object can in principle appear later than the agent's first call - measuring that race is the point of the shadow pass. Requires the optional Graph permission DeviceManagementManagedDevices.Read.All (feature "IntuneDeviceBinding" in the grant script). */
/** Intune Enrollment Validation: the last accepting method of the device-validation gate. Resolves the Intune managedDevice id carried in the agent client certificate's Subject CN against this tenant's own managedDevices inventory and admits the device when it is an enrolled device of the tenant, without any pre-registration (Autopilot hash, corporate identifier, device association). Evaluated only when no earlier validator admitted the device. Requires the optional Graph permission DeviceManagementManagedDevices.Read.All (feature "IntuneDeviceBinding" in the grant script). */
validateIntuneDeviceBinding: boolean;
/** Emergency bypass for agent security gate (Global Admin use only). If true, agent requests are accepted even when ValidateAutopilotDevice is false. Default: false */
/** Emergency bypass for agent security gate (Global Admin use only). If true, agent requests are accepted even when no device validation is enabled (see HasAnyDeviceValidation). Default: false */
allowInsecureAgentRequests: boolean;
/** Data retention period in days Sessions and events older than this will be deleted by the daily maintenance job Default: 90 days */
dataRetentionDays: number;
@@ -4953,8 +4953,8 @@ export interface TenantFeatureFlagsResponse {
bootstrapTokenEnabled: boolean;
/** Whether an on-demand diagnostics upload can succeed right now (mode not Off + usable destination). Deliberately no destination detail. */
diagnosticsUploadConfigured: boolean;
/** Drives the "Autopilot Device Validation disabled" dashboard banner. */
validateAutopilotDevice: boolean;
/** At least one device-validation method is enabled (HasAnyDeviceValidation); false drives the "agent ingestion is blocked" dashboard banner. */
deviceValidationEnabled: boolean;
/** Dual app-reg self-service migration: consent flow targets the NEW app registration. Non-sensitive — exposes no client ids. */
appHomingFunnelActive: boolean;
showScriptOutput: boolean;
@@ -5404,8 +5404,8 @@ export interface UserUsageRecord {
lastRequestAt: string;
}
/** Identifies which validator authorized the device during session registration. Surfaced in the RegisterSession response so the agent can reconcile against its own registry-based detection and, when needed, switch its enrollment flow. */
export type ValidatorType = "Unknown" | "AutopilotV1" | "CorporateIdentifier" | "DeviceAssociation" | "Bootstrap" | "CloudPc";
/** Identifies which validator authorized the device during session registration. Stored on the session row and echoed in the RegisterSession response. The agent parses that response strictly (an unknown name fails the registration), so a new member ships with an agent release BEFORE the backend that can send it; agents self-update at every start, so a released agent is what every new registration runs. */
export type ValidatorType = "Unknown" | "AutopilotV1" | "CorporateIdentifier" | "DeviceAssociation" | "Bootstrap" | "CloudPc" | "IntuneEnrollment";
/** One ACTIVE verdict-calibration alert episode (operator-only). Persisted as the verdictcalibration|{kind}|{path}|{status} keyspace of the notification tracker — the row IS the dedup (one ops event per episode) and the alerts[] payload of the calibration endpoint. Deleted when the signal re-arms (share back under 1.5× baseline, path stops occurring, evidence-gap share back under 15 %) or by the tracker's 30-day retention sweep. Numbers refresh on every radar pass; FirstNotifiedAt never moves. Dimension concentration is CORRELATION only — every consumer says so. */
export interface VerdictCalibrationAlert {