feat(security): cert-to-device binding, stage 3 GA-gated shadow

Tenant binding (stage 1) proves the client certificate was issued to this
tenant. It does not prove the certificate belongs to a device the tenant
still has: a cert lifted from a decommissioned machine keeps working.

IntuneDeviceBindingValidator resolves the Intune managedDevice id from the
certificate's Subject CN against the tenant's own managedDevices inventory.
Point-GET on managedDevices/{id} rather than an OData filter, so there is no
filter literal to inject into and a 404 is unambiguous. Cache/retry/transient
contract mirrors CloudPcDeviceValidator.

Shadow only, and visible only to Global Admins (same gate as the DevPrep
association preview) because the open question is a race, not an attack: a
device object can appear after the agent's first call. Every result carries
enrolledDateTime and the log line records enrolledAgeSeconds, so a device
created seconds before the request is distinguishable from a foreign cert.
Grep marker: CERT-DEVICE-BINDING-SHADOW.

The lookup is fire-and-forget like the DevPrep shadow so a cold Graph
round-trip never sits in the agent request path. That rules out stage 1's
request-row dimension (the row is written before the task finishes), hence
the Warning log; widening beyond preview should move to inline + dimension.

Needs DeviceManagementManagedDevices.Read.All, granted per tenant via the
customer script as feature IntuneDeviceBinding. The script's catalog,
ValidateSet and help block stay pinned to GraphFeatureCatalog by
GraphFeatureCatalogSyncTests (extended with the help-block case).

Build 0 warnings, backend 4629/4629, web 874/874, tsc clean, eslint 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-24 18:26:33 +02:00
parent fb79c7711c
commit ea6a375c4b
26 changed files with 787 additions and 25 deletions
@@ -110,6 +110,42 @@ the extension: skip with a warning, or reject. Grep marker for every stage-2 sit
The bootstrap-token path (`X-Bootstrap-Token`) bypasses certificate validation by design and is
unaffected by either stage.
**Stage 3 — device binding (Global-Admin-only preview).** Tenant binding proves the certificate was
issued to this tenant; it does not prove the certificate belongs to a device the tenant still has.
`IntuneDeviceBindingValidator` closes that by resolving the Intune managedDevice id from the
certificate's Subject CN against the tenant's own `deviceManagement/managedDevices` inventory
(point-GET on `managedDevices/{id}` — no OData filter to inject into, and a 404 is unambiguous).
It is modelled on `CloudPcDeviceValidator` and shares its cache/retry/transient contract, with one
important difference in how it is wired: the lookup is fire-and-forget, like the DevPrep
association shadow, because a cold Graph round-trip can take seconds and 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 the detached task finishes — so the outcome is logged at
Warning instead. Volume stays bounded because only Global Admins can see the toggle
(`user?.isGlobalAdmin === true`, the same gate as the DevPrep preview). Widening it beyond preview
should move to an inline call plus a request dimension.
Outcomes: `Match`, `NotFound`, `NoDeviceIdInCert`, `PermissionMissing`, `Transient`. Only
`Transient` is retried and left uncached — a missing grant or a foreign device is a state, not an
outage.
The open question this preview exists to answer is a race, not an attack: a device object can in
principle appear in Intune *after* the agent's first call, and enforcing before that is understood
would reject legitimate enrollments. Every result therefore carries `enrolledDateTime`, and the log
line records `enrolledAgeSeconds` — a device whose object was created seconds before the request
looks completely different from a certificate that never belonged to the tenant. Grep marker:
`CERT-DEVICE-BINDING-SHADOW`.
Enabling it needs the optional Graph permission `DeviceManagementManagedDevices.Read.All`, granted
per tenant with the customer script — it is not in the default-consent set:
```powershell
.\Grant-AutopilotMonitorAddOn.ps1 -ClientId "<client-id>" -Features IntuneDeviceBinding
```
`GraphFeatureCatalog` (feature `IntuneDeviceBinding`) and the script's catalog, `ValidateSet` and
help block are pinned to each other by `GraphFeatureCatalogSyncTests`.
# Examples
Coverage — the question stage 2 depends on ("would enforcing lock anyone out?"):
@@ -138,6 +174,23 @@ that stage-2 enforcement would have to make a policy decision about. Note that t
alone can never show `Match`, so it cannot tell "everything matched" apart from "nothing ran" —
use the request-row query for that.
Stage 3, including the enrollment-race measurement:
```kusto
traces
| where message has "AgentCertDeviceBinding"
| extend Outcome = tostring(customDimensions.Outcome),
AgeSecs = toint(customDimensions.EnrolledAgeSeconds)
| summarize Requests = count(),
MinAgeSecs = min(AgeSecs),
P05AgeSecs = percentile(AgeSecs, 5)
by Outcome
```
`NotFound` is the row that decides whether this can ever become a gate. A small `MinAgeSecs` on
`Match` means device objects are appearing only just in time — the race is real and enforcement
would need a grace path.
# Citations
* `src/Shared/AutopilotMonitor.Shared/Security/MsDeviceCertificateOids.cs` — OID catalog and the
+2
View File
@@ -2,6 +2,8 @@
## 2026-08-24
* **Update**: `backend/client-cert-tenant-binding.md` — stage 3, the cert-to-device binding: new `IntuneDeviceBindingValidator` resolves the Subject-CN managedDevice id against the tenant's own `deviceManagement/managedDevices` (point-GET, no filter literal), modelled on `CloudPcDeviceValidator` and wired fire-and-forget like the DevPrep shadow so a cold Graph round-trip never enters the agent request path. New GA-only tenant toggle `ValidateIntuneDeviceBinding` (shadow — never touches `deviceValidated`), new optional Graph feature `IntuneDeviceBinding` → `DeviceManagementManagedDevices.Read.All` in `GraphFeatureCatalog` + the customer grant script, and `enrolledAgeSeconds` on every log line so the enrollment race can be measured before this could ever become a gate. Grep marker: `CERT-DEVICE-BINDING-SHADOW`.
* **Update**: `backend/client-cert-tenant-binding.md` — shadow telemetry moved off the dead `LogInformation` path: the `Match` outcome now rides on the request row as the `CertTenantBinding` dimension (`SecurityValidator` → `FunctionContext.Items` → `RequestTelemetryMiddleware`), which is unsampled and already exists per request, so the denominator costs nothing. Non-Match outcomes keep their Warning trace. Post-deploy verification of 249e39bc found zero rows across 32k requests — worker `LogInformation` never reaches App Insights (provider default rule Warning+), making "everything matched" indistinguishable from "never ran". `CertificateValidationResult.FromCache` removed with the sampling path it existed for.
* **New**: `backend/client-cert-tenant-binding.md` — the agent's mTLS certificate chains to Microsoft's shared Intune roots, so certificate validation alone never proved tenant membership; tenant scoping rested entirely on the (non-secret) device serial in the downstream validators. The Intune MDM leaf carries the Entra tenant id in OID `1.2.840.113556.5.14` (nested OCTET STRING), which is NOT `…5.6` (Intune Account id, a different GUID) and NOT `…5.4` (device id, stored as 16 bare bytes) — all decoded by one shared `MsDeviceCertificateOids.TryParseGuid` now used by agent and backend. Stage 1 ships as shadow only: `CertTenantBinding.Evaluate` (pure) plus `SecurityValidator.ObserveCertTenantBinding` (returns void, swallows its own exceptions, cannot change an authorization outcome), with `Match` sampled via `CertificateValidationResult.FromCache` so the denominator survives without per-request volume. Grep marker for the stage-2 enforcement change: `CERT-TENANT-BINDING-SHADOW`.
@@ -1,4 +1,4 @@
#Requires -Version 5.1
#Requires -Version 5.1
<#
.SYNOPSIS
Grants additional Microsoft Graph application permissions to the Autopilot Monitor
@@ -28,6 +28,11 @@
(validates Windows 365 Cloud PCs against the tenant's Cloud PC inventory --
Cloud PCs are never Autopilot-registered, so this enables agent monitoring
of Cloud PC first-connect enrollment)
- IntuneDeviceBinding -> DeviceManagementManagedDevices.Read.All
(cert-to-device binding: verifies that the Intune device id carried in the
agent's client certificate is a device THIS tenant actually enrolled --
currently a Global-Admin-only preview that records telemetry and does not
block enrollment)
- All -> every optional permission above, in one run
(also works with -Revoke to remove all optional grants at once)
@@ -93,7 +98,7 @@ param(
[string]$ClientId,
[Parameter(Mandatory = $true, ParameterSetName = 'ByFeatures')]
[ValidateSet('All', 'ScriptDisplayNames', 'W365CloudPcValidation')]
[ValidateSet('All', 'ScriptDisplayNames', 'W365CloudPcValidation', 'IntuneDeviceBinding')]
[string[]]$Features,
[Parameter(Mandatory = $true, ParameterSetName = 'ByPermissions')]
@@ -116,6 +121,7 @@ $ErrorActionPreference = 'Stop'
$FeatureCatalog = @{
'ScriptDisplayNames' = @('DeviceManagementScripts.Read.All')
'W365CloudPcValidation' = @('CloudPC.Read.All')
'IntuneDeviceBinding' = @('DeviceManagementManagedDevices.Read.All')
}
# Translate Features -> Permissions when caller used the high-level form.
@@ -1,4 +1,4 @@
using System.Text.RegularExpressions;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared.Models.Graph;
namespace AutopilotMonitor.Functions.Tests;
@@ -63,6 +63,18 @@ public class GraphFeatureCatalogSyncTests
scriptFeatures.Where(f => f != "All").OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
}
[Fact]
public void GrantScript_Help_DocumentsEveryFeature()
{
// The -Features help block is how an admin discovers what they can grant. A feature that
// is grantable but undocumented effectively does not exist for them.
var script = ReadGrantScript();
var help = script.Substring(0, script.IndexOf("$FeatureCatalog", StringComparison.Ordinal));
foreach (var feature in GraphFeatureCatalog.Features)
Assert.Contains(feature, help, StringComparison.Ordinal);
}
private static string ReadGrantScript()
{
var path = Path.Combine(FindRepoRoot(), "scripts", "CustomerSetup", "Grant-AutopilotMonitorAddOn.ps1");
@@ -0,0 +1,183 @@
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="IntuneDeviceBindingValidator"/> — the pure pieces: the managedDevice
/// JSON-to-DTO mapping (incl. the exact-match guard on the returned id), the enrolledDateTime
/// parsing that the enrollment-race measurement depends on, and the cache key shape.
/// The HTTP/cache/retry resilience mirrors <see cref="CloudPcDeviceValidator"/> 1:1.
/// </summary>
public class IntuneDeviceBindingValidatorTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string DeviceId = "07bc0167-9061-43b7-b243-0bb6aeda736e";
// -- BuildCacheKey --
[Fact]
public void BuildCacheKey_StableShape()
{
var key = IntuneDeviceBindingValidator.BuildCacheKey(TenantId, DeviceId);
Assert.Equal($"intune-device-binding:{TenantId}:{DeviceId}", key);
}
[Fact]
public void BuildCacheKey_DistinctFromOtherValidatorKeys()
{
// A shared prefix would let a positive hit in one validator satisfy a lookup in another.
var key = IntuneDeviceBindingValidator.BuildCacheKey(TenantId, DeviceId);
Assert.StartsWith("intune-device-binding:", key);
Assert.DoesNotContain("cloudpc", key);
Assert.DoesNotContain("autopilot", key);
}
[Fact]
public void BuildCacheKey_IsTenantScoped()
{
// The whole point of the check is that the same device id must not resolve across
// tenants — a tenant-blind cache key would defeat it.
var a = IntuneDeviceBindingValidator.BuildCacheKey(TenantId, DeviceId);
var b = IntuneDeviceBindingValidator.BuildCacheKey("22222222-2222-2222-2222-222222222222", DeviceId);
Assert.NotEqual(a, b);
}
// -- ParseManagedDeviceResponse --
[Fact]
public void Parse_MatchingDevice_ReturnsMatchWithDiagnostics()
{
var body = $$"""
{
"id": "{{DeviceId}}",
"deviceName": "TEST-DEVICE-01",
"enrolledDateTime": "2026-08-24T15:20:00Z",
"azureADDeviceId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"managementState": "managed"
}
""";
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, DeviceId);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
Assert.True(result.IsValid);
Assert.False(result.IsTransient);
Assert.Equal("TEST-DEVICE-01", result.DeviceName);
Assert.Equal("managed", result.ManagementState);
Assert.Equal(
new DateTimeOffset(2026, 8, 24, 15, 20, 0, TimeSpan.Zero),
result.EnrolledDateTime);
}
[Fact]
public void Parse_DifferentIdInBody_IsNotAMatch()
{
// Guards against a redirected or widened lookup being taken as confirmation of the id
// we actually asked about.
var body = """
{ "id": "99999999-9999-9999-9999-999999999999", "deviceName": "OTHER" }
""";
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, DeviceId);
Assert.Equal(IntuneDeviceBindingOutcome.NotFound, result.Outcome);
Assert.False(result.IsValid);
}
[Fact]
public void Parse_IdCasingDiffers_StillMatches()
{
// Graph echoes GUIDs lowercase, but casing must never decide a security outcome.
var body = $$"""
{ "id": "{{DeviceId.ToUpperInvariant()}}", "deviceName": "TEST-DEVICE-01" }
""";
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, DeviceId);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
}
[Theory]
[InlineData("")]
[InlineData("not json at all")]
[InlineData("{}")]
[InlineData("""{ "id": "" }""")]
[InlineData("""{ "deviceName": "no id field" }""")]
public void Parse_MalformedOrEmpty_IsNotFoundNotAnException(string body)
{
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, DeviceId);
Assert.Equal(IntuneDeviceBindingOutcome.NotFound, result.Outcome);
Assert.Equal(DeviceId, result.IntuneDeviceId);
}
[Fact]
public void Parse_MissingEnrolledDateTime_LeavesItNull()
{
var body = $$"""
{ "id": "{{DeviceId}}", "deviceName": "TEST-DEVICE-01" }
""";
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, DeviceId);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
Assert.Null(result.EnrolledDateTime);
}
// -- ParseEnrolledDateTime --
[Fact]
public void ParseEnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
{
// Intune returns 0001-01-01T00:00:00Z when it has no enrollment timestamp. Treating that
// as a real date would report an object age of ~2000 years and poison the race analysis.
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime("0001-01-01T00:00:00Z"));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not-a-date")]
public void ParseEnrolledDateTime_UnusableInput_IsNull(string? raw)
{
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime(raw));
}
[Fact]
public void ParseEnrolledDateTime_NormalizesToUtc()
{
// A device object created minutes ago is the signature of an enrollment race, so the
// instant has to be comparable regardless of the offset Graph reports it in.
var parsed = IntuneDeviceBindingValidator.ParseEnrolledDateTime("2026-08-24T17:20:00+02:00");
Assert.NotNull(parsed);
Assert.Equal(TimeSpan.Zero, parsed!.Value.Offset);
Assert.Equal(new DateTimeOffset(2026, 8, 24, 15, 20, 0, TimeSpan.Zero), parsed.Value);
}
// -- result semantics --
[Theory]
[InlineData(IntuneDeviceBindingOutcome.NotFound)]
[InlineData(IntuneDeviceBindingOutcome.NoDeviceIdInCert)]
[InlineData(IntuneDeviceBindingOutcome.PermissionMissing)]
public void DefinitiveNegatives_AreNotTransient(IntuneDeviceBindingOutcome outcome)
{
// Only Transient may be retried / left uncached. A missing grant or a foreign device is a
// state, not an outage — retrying cannot change either.
var result = new IntuneDeviceBindingResult { Outcome = outcome };
Assert.False(result.IsTransient);
Assert.False(result.IsValid);
}
[Fact]
public void TransientOutcome_IsNeitherValidNorDefinitive()
{
var result = new IntuneDeviceBindingResult { Outcome = IntuneDeviceBindingOutcome.Transient };
Assert.True(result.IsTransient);
Assert.False(result.IsValid);
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
@@ -652,6 +652,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "ValidateCorporateIdentifier", config.ValidateCorporateIdentifier },
{ "ValidateDeviceAssociation", config.ValidateDeviceAssociation },
{ "ValidateCloudPcDevice", config.ValidateCloudPcDevice },
{ "ValidateIntuneDeviceBinding", config.ValidateIntuneDeviceBinding },
{ "AllowInsecureAgentRequests", config.AllowInsecureAgentRequests },
{ "DataRetentionDays", config.DataRetentionDays },
{ "SessionTimeoutHours", config.SessionTimeoutHours },
@@ -757,6 +758,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
ValidateCorporateIdentifier = entity.GetBoolean("ValidateCorporateIdentifier") ?? false,
ValidateDeviceAssociation = entity.GetBoolean("ValidateDeviceAssociation") ?? false,
ValidateCloudPcDevice = entity.GetBoolean("ValidateCloudPcDevice") ?? false,
ValidateIntuneDeviceBinding = entity.GetBoolean("ValidateIntuneDeviceBinding") ?? false,
AllowInsecureAgentRequests = entity.GetBoolean("AllowInsecureAgentRequests") ?? false,
DataRetentionDays = entity.GetInt32("DataRetentionDays") ?? 90,
SessionTimeoutHours = entity.GetInt32("SessionTimeoutHours") ?? 5,
@@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using System.Net;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
@@ -26,6 +26,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly KillSwitchEvaluator _killSwitchEvaluator;
@@ -40,6 +41,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
BootstrapSessionService bootstrapSessionService,
KillSwitchEvaluator killSwitchEvaluator)
{
@@ -53,6 +55,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_bootstrapSessionService = bootstrapSessionService;
_killSwitchEvaluator = killSwitchEvaluator;
}
@@ -88,7 +91,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
_logger,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator
cloudPcDeviceValidator: _cloudPcDeviceValidator,
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
);
if (errorResponse != null)
@@ -1,4 +1,4 @@
using System.Net;
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
@@ -53,6 +53,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly HostedDiagnosticsBlobService _hostedDiagnostics;
@@ -65,6 +66,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
BootstrapSessionService bootstrapSessionService,
HostedDiagnosticsBlobService hostedDiagnostics)
{
@@ -76,6 +78,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_bootstrapSessionService = bootstrapSessionService;
_hostedDiagnostics = hostedDiagnostics;
}
@@ -127,7 +130,8 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
_logger,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator
cloudPcDeviceValidator: _cloudPcDeviceValidator,
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
);
if (errorResponse != null)
@@ -1,4 +1,4 @@
using System.IO;
using System.IO;
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
@@ -51,6 +51,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly KillSwitchEvaluator _killSwitchEvaluator;
private readonly SessionDeletionGuard _deletionGuard;
@@ -69,6 +70,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
BootstrapSessionService bootstrapSessionService,
KillSwitchEvaluator killSwitchEvaluator,
SessionDeletionGuard deletionGuard)
@@ -86,6 +88,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_bootstrapSessionService = bootstrapSessionService;
_killSwitchEvaluator = killSwitchEvaluator;
_deletionGuard = deletionGuard;
@@ -116,7 +119,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_logger,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator);
cloudPcDeviceValidator: _cloudPcDeviceValidator,
intuneDeviceBindingValidator: _intuneDeviceBindingValidator);
if (errorResponse != null) return AsOutput(errorResponse);
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
@@ -35,6 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly TelemetryClient _telemetryClient;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly ISessionRepository _sessionRepo;
@@ -49,6 +50,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
TelemetryClient telemetryClient,
BootstrapSessionService bootstrapSessionService,
ISessionRepository sessionRepo,
@@ -62,6 +64,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_telemetryClient = telemetryClient;
_bootstrapSessionService = bootstrapSessionService;
_sessionRepo = sessionRepo;
@@ -98,7 +101,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_logger,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator
cloudPcDeviceValidator: _cloudPcDeviceValidator,
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
);
if (errorResponse != null)
@@ -1,4 +1,4 @@
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared;
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
@@ -32,6 +32,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator _intuneDeviceBindingValidator;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly WebhookNotificationService _webhookNotificationService;
private readonly SessionDeletionGuard _deletionGuard;
@@ -48,6 +49,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
IntuneDeviceBindingValidator intuneDeviceBindingValidator,
BootstrapSessionService bootstrapSessionService,
WebhookNotificationService webhookNotificationService,
SessionDeletionGuard deletionGuard)
@@ -62,6 +64,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_bootstrapSessionService = bootstrapSessionService;
_webhookNotificationService = webhookNotificationService;
_deletionGuard = deletionGuard;
@@ -106,7 +109,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
registration.SessionId,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator
cloudPcDeviceValidator: _cloudPcDeviceValidator,
intuneDeviceBindingValidator: _intuneDeviceBindingValidator
);
if (errorResponse2 != null)
@@ -1,4 +1,4 @@
using System.IO.Compression;
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
using AutopilotMonitor.Functions.DataAccess;
@@ -424,6 +424,7 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.AutopilotDevic
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CorporateIdentifierValidator>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.DeviceAssociationValidator>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CloudPcDeviceValidator>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.IntuneDeviceBindingValidator>();
// Graph add-on permission detection + script display-name resolution.
// Detector parses the `roles` claim of the SP access token; Resolver fetches
@@ -0,0 +1,328 @@
using AutopilotMonitor.Shared;
using System;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Binds the agent's client certificate to a device object that actually exists in the tenant
/// the request claims: the certificate's Subject CN carries the Intune managedDevice id, and
/// this validator looks exactly that id up in the tenant's own
/// <c>deviceManagement/managedDevices</c> inventory.
/// <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.
/// </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:
/// <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
/// "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>
/// </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);
private readonly ILogger<IntuneDeviceBindingValidator> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly GraphTokenService _graphTokenService;
public IntuneDeviceBindingValidator(
ILogger<IntuneDeviceBindingValidator> logger,
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
GraphTokenService graphTokenService)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_cache = cache;
_graphTokenService = graphTokenService;
}
/// <summary>
/// Resolves the Intune device id (from the client certificate's Subject CN) against the
/// tenant's managedDevices inventory. Never throws.
/// </summary>
public async Task<IntuneDeviceBindingResult> ValidateAsync(
string tenantId,
string? intuneDeviceId,
string? sessionId = null)
{
// 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.
if (!SecurityValidator.IsValidGuid(intuneDeviceId))
{
return new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.NoDeviceIdInCert,
ErrorMessage = "Certificate subject does not carry a valid Intune device id"
};
}
var normalizedId = intuneDeviceId!.Trim().ToLowerInvariant();
var cacheKey = BuildCacheKey(tenantId, normalizedId);
if (_cache.TryGetValue(cacheKey, out IntuneDeviceBindingResult? cached) && cached != null)
return cached;
const int maxAttempts = 2;
IntuneDeviceBindingResult? lastTransient = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
var result = await TryValidateViaGraphAsync(tenantId, normalizedId, sessionId, cacheKey, attempt);
if (!result.IsTransient)
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));
}
}
return lastTransient!;
}
private async Task<IntuneDeviceBindingResult> TryValidateViaGraphAsync(
string tenantId, string normalizedId, string? sessionId, string cacheKey, int attempt)
{
try
{
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId);
if (string.IsNullOrEmpty(tokenResult.AccessToken))
{
return new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.Transient,
IntuneDeviceId = normalizedId,
ErrorMessage = "Graph access token could not be acquired"
};
}
var graphClient = _httpClientFactory.CreateClient();
graphClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult.AccessToken);
// 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";
var response = await graphClient.GetAsync(graphUrl);
var responseBody = await response.Content.ReadAsStringAsync();
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.
return CacheAndReturn(cacheKey, new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.NotFound,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Device '{normalizedId}' is not an enrolled Intune device in this tenant"
}, isPositive: false);
}
if (response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
// Configuration state, not an outage: retrying cannot fix a missing grant.
// Cached briefly so a fresh grant is picked up quickly.
return CacheAndReturn(cacheKey, new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.PermissionMissing,
IntuneDeviceId = normalizedId,
ErrorMessage = "Managed device lookup not permitted - grant the optional 'IntuneDeviceBinding' Graph feature (DeviceManagementManagedDevices.Read.All)"
}, isPositive: false);
}
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Intune device binding Graph query failed for tenant {TenantId} (attempt {Attempt}). Status: {StatusCode}. Body: {ResponseBody}",
tenantId, attempt, (int)response.StatusCode, responseBody);
return new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.Transient,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Graph query failed with status {(int)response.StatusCode}"
};
}
var result = ParseManagedDeviceResponse(responseBody, normalizedId);
return CacheAndReturn(cacheKey, result, isPositive: result.IsValid);
}
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);
return new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.Transient,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Error during Intune device binding: {ex.Message}"
};
}
}
/// <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.
/// </summary>
internal static IntuneDeviceBindingResult ParseManagedDeviceResponse(string responseBody, string normalizedId)
{
var notFound = new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.NotFound,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Device '{normalizedId}' is not an enrolled Intune device in this tenant"
};
JObject? device;
try
{
device = JsonConvert.DeserializeObject<JObject>(responseBody);
}
catch (JsonException)
{
return notFound;
}
var returnedId = device?["id"]?.ToString()?.Trim();
if (string.IsNullOrEmpty(returnedId) ||
!string.Equals(returnedId, normalizedId, StringComparison.OrdinalIgnoreCase))
{
return notFound;
}
return new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.Match,
IntuneDeviceId = normalizedId,
DeviceName = device?["deviceName"]?.ToString(),
AzureAdDeviceId = device?["azureADDeviceId"]?.ToString(),
ManagementState = device?["managementState"]?.ToString(),
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.ToString())
};
}
/// <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
/// <c>null</c> rather than a bogus age of two thousand years.
/// </summary>
internal static DateTimeOffset? ParseEnrolledDateTime(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
if (!DateTimeOffset.TryParse(
raw,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal
| System.Globalization.DateTimeStyles.AssumeUniversal,
out var parsed))
{
return null;
}
return parsed.Year <= 1 ? null : parsed;
}
internal static string BuildCacheKey(string tenantId, string intuneDeviceId)
=> $"intune-device-binding:{tenantId}:{intuneDeviceId}";
private IntuneDeviceBindingResult CacheAndReturn(
string cacheKey, IntuneDeviceBindingResult result, bool isPositive)
{
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = isPositive ? PositiveCacheTtl : NegativeCacheTtl
});
return result;
}
}
/// <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 —
/// operators match on them in KQL, so keep the names stable.
/// </summary>
public enum IntuneDeviceBindingOutcome
{
/// <summary>Device id from the certificate resolves to a managedDevice in this tenant.</summary>
Match = 0,
/// <summary>No managedDevice with that id in this tenant (foreign device, or not yet created).</summary>
NotFound = 1,
/// <summary>Certificate subject carried no usable Intune device id.</summary>
NoDeviceIdInCert = 2,
/// <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
}
public class IntuneDeviceBindingResult
{
public IntuneDeviceBindingOutcome Outcome { get; set; }
/// <summary>True only for <see cref="IntuneDeviceBindingOutcome.Match"/>.</summary>
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.
/// </summary>
public bool IsTransient => Outcome == IntuneDeviceBindingOutcome.Transient;
/// <summary>Intune device id taken from the client certificate's Subject CN.</summary>
public string? IntuneDeviceId { get; set; }
/// <summary>Intune device name — diagnostic only, never a gate.</summary>
public string? DeviceName { get; set; }
/// <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>
public string? ManagementState { 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.
/// </summary>
public DateTimeOffset? EnrolledDateTime { get; set; }
public string? ErrorMessage { get; set; }
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
@@ -34,9 +34,10 @@ namespace AutopilotMonitor.Functions.Security
string? sessionId = null,
BootstrapSessionService? bootstrapSessionService = null,
DeviceAssociationValidator? deviceAssociationValidator = null,
CloudPcDeviceValidator? cloudPcDeviceValidator = null)
CloudPcDeviceValidator? cloudPcDeviceValidator = null,
IntuneDeviceBindingValidator? intuneDeviceBindingValidator = null)
{
var validator = new SecurityValidator(configService, adminConfigService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger, bootstrapSessionService, deviceAssociationValidator, cloudPcDeviceValidator);
var validator = new SecurityValidator(configService, adminConfigService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger, bootstrapSessionService, deviceAssociationValidator, cloudPcDeviceValidator, intuneDeviceBindingValidator);
var validation = await validator.ValidateRequestAsync(req, tenantId, sessionId);
if (!validation.IsValid)
@@ -1,4 +1,4 @@
using System;
using System;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
@@ -48,6 +48,7 @@ namespace AutopilotMonitor.Functions.Security
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator? _deviceAssociationValidator;
private readonly CloudPcDeviceValidator? _cloudPcDeviceValidator;
private readonly IntuneDeviceBindingValidator? _intuneDeviceBindingValidator;
private readonly BootstrapSessionService? _bootstrapSessionService;
private readonly ILogger _logger;
@@ -60,7 +61,8 @@ namespace AutopilotMonitor.Functions.Security
ILogger logger,
BootstrapSessionService? bootstrapSessionService = null,
DeviceAssociationValidator? deviceAssociationValidator = null,
CloudPcDeviceValidator? cloudPcDeviceValidator = null)
CloudPcDeviceValidator? cloudPcDeviceValidator = null,
IntuneDeviceBindingValidator? intuneDeviceBindingValidator = null)
{
_configService = configService;
_adminConfigService = adminConfigService;
@@ -69,6 +71,7 @@ namespace AutopilotMonitor.Functions.Security
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_intuneDeviceBindingValidator = intuneDeviceBindingValidator;
_bootstrapSessionService = bootstrapSessionService;
_logger = logger;
}
@@ -463,6 +466,59 @@ namespace AutopilotMonitor.Functions.Security
});
}
// 6. 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 for the same reason as the DevPrep lookup above - a cold Graph
// round-trip can take seconds and 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
// like the DevPrep shadow. 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)
{
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);
// 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);
}
});
}
// All checks passed
return new SecurityValidationResult
{
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
@@ -226,6 +226,18 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool ValidateCloudPcDevice { get; set; } = false;
/// <summary>
/// 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).
/// </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.
@@ -1,4 +1,4 @@
namespace AutopilotMonitor.Shared.Models.Graph
namespace AutopilotMonitor.Shared.Models.Graph
{
/// <summary>
/// Constant catalog of Microsoft Graph application permission names referenced by the
@@ -26,6 +26,14 @@ namespace AutopilotMonitor.Shared.Models.Graph
/// <summary>Read Intune managed apps (future use — Win32 app display names not yet covered).</summary>
public const string DeviceManagementAppsReadAll = "DeviceManagementApps.Read.All";
/// <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.
/// </summary>
public const string DeviceManagementManagedDevicesReadAll = "DeviceManagementManagedDevices.Read.All";
/// <summary>
/// Read Windows 365 Cloud PC objects (<c>virtualEndpoint/cloudPCs</c>). Optional add-on
/// permission backing the W365 Cloud PC device validator — Cloud PCs are never
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models.Graph
@@ -17,12 +17,19 @@ namespace AutopilotMonitor.Shared.Models.Graph
/// <summary>Validates Windows 365 Cloud PCs via the service-side Cloud PC inventory (cert-CN bound).</summary>
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).
/// </summary>
public const string FeatureIntuneDeviceBinding = "IntuneDeviceBinding";
// Backed by a plain dictionary (netstandard2.0 + C# 9, no Immutable types).
private static readonly Dictionary<string, string[]> _byFeature
= new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
[FeatureScriptDisplayNames] = new[] { GraphAppPermissions.DeviceManagementScriptsReadAll },
[FeatureW365CloudPcValidation] = new[] { GraphAppPermissions.CloudPCReadAll },
[FeatureIntuneDeviceBinding] = new[] { GraphAppPermissions.DeviceManagementManagedDevicesReadAll },
};
/// <summary>All registered feature identifiers.</summary>
@@ -133,11 +133,14 @@ interface TenantConfigContextValue {
handleToggleDeviceAssociationValidation: (newValue: boolean) => Promise<void>;
validateCloudPcDevice: boolean;
setValidateCloudPcDevice: (v: boolean) => void;
validateIntuneDeviceBinding: boolean;
setValidateIntuneDeviceBinding: (v: boolean) => void;
/**
* Toggle + persist W365 Cloud PC validation in one shot. No consent flow — the backing
* CloudPC.Read.All permission is granted via the Optional Graph capabilities add-on script.
*/
handleToggleCloudPcValidation: (newValue: boolean) => Promise<void>;
handleToggleIntuneDeviceBinding: (newValue: boolean) => Promise<void>;
/**
* Persist a validation-gate change immediately. The validation section has NO save bar —
* every gate change that doesn't run the consent flow (disable, and enabling the second
@@ -394,6 +397,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const [validateCorporateIdentifier, setValidateCorporateIdentifier] = useState(false);
const [validateDeviceAssociation, setValidateDeviceAssociation] = useState(false);
const [validateCloudPcDevice, setValidateCloudPcDevice] = useState(false);
const [validateIntuneDeviceBinding, setValidateIntuneDeviceBinding] = useState(false);
const [dataRetentionDays, setDataRetentionDays] = useState(90);
const [sessionTimeoutHours, setSessionTimeoutHours] = useState(5);
@@ -535,6 +539,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setValidateCorporateIdentifier(data.validateCorporateIdentifier ?? false);
setValidateDeviceAssociation(data.validateDeviceAssociation ?? false);
setValidateCloudPcDevice(data.validateCloudPcDevice ?? false);
setValidateIntuneDeviceBinding(data.validateIntuneDeviceBinding ?? false);
setDataRetentionDays(data.dataRetentionDays ?? 90);
setSessionTimeoutHours(data.sessionTimeoutHours ?? 5);
setEnablePerformanceCollector(data.enablePerformanceCollector ?? true);
@@ -703,7 +708,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
// -----------------------------------------------------------------------
// Save configuration (shared by all sections)
// -----------------------------------------------------------------------
const saveConfiguration = useCallback(async (sectionName: SettingsSectionName, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: boolean; unrestrictedMode?: boolean }): Promise<boolean> => {
const saveConfiguration = useCallback(async (sectionName: SettingsSectionName, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: boolean; validateIntuneDeviceBinding?: boolean; unrestrictedMode?: boolean }): Promise<boolean> => {
// Read-only viewers (Operators) have no save affordances; this guard covers any path
// that still reaches a save (the backend would 403 the PATCH regardless).
if (!tenantId || !config || !canEditConfig) return false;
@@ -717,6 +722,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
const corporateIdentifierValidationValue = overrides?.validateCorporateIdentifier ?? validateCorporateIdentifier;
const deviceAssociationValidationValue = overrides?.validateDeviceAssociation ?? validateDeviceAssociation;
const cloudPcValidationValue = overrides?.validateCloudPcDevice ?? validateCloudPcDevice;
const intuneDeviceBindingValue = overrides?.validateIntuneDeviceBinding ?? validateIntuneDeviceBinding;
const unrestrictedModeValue = overrides?.unrestrictedMode ?? unrestrictedMode;
const updatedConfig: TenantConfiguration = {
@@ -728,6 +734,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
validateCorporateIdentifier: corporateIdentifierValidationValue,
validateDeviceAssociation: deviceAssociationValidationValue,
validateCloudPcDevice: cloudPcValidationValue,
validateIntuneDeviceBinding: intuneDeviceBindingValue,
dataRetentionDays,
sessionTimeoutHours,
enablePerformanceCollector,
@@ -822,6 +829,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
setValidateCorporateIdentifier(persisted.validateCorporateIdentifier ?? false);
setValidateDeviceAssociation(persisted.validateDeviceAssociation ?? false);
setValidateCloudPcDevice(persisted.validateCloudPcDevice ?? false);
setValidateIntuneDeviceBinding(persisted.validateIntuneDeviceBinding ?? false);
setUnrestrictedMode(persisted.unrestrictedMode ?? false);
trackEvent("settings_saved", { section: sectionName, fieldCount: Object.keys(patchFields).length });
setSuccessMessage("Configuration saved successfully!");
@@ -841,7 +849,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
}
}, [
tenantId, config, canEditConfig, getAccessToken, addNotification,
manufacturerWhitelist, modelWhitelist, webhookNotifyOnHardwareRejection, validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, validateCloudPcDevice,
manufacturerWhitelist, modelWhitelist, webhookNotifyOnHardwareRejection, validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, validateCloudPcDevice, validateIntuneDeviceBinding,
dataRetentionDays, sessionTimeoutHours, enablePerformanceCollector, performanceCollectorInterval,
helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds,
contactEmail, enableGeoLocation, enableTimezoneAutoSet, enableDoGroupIdAutoSet, enableImeMatchLog, enableGatherRuleDebugLog, logLevel, showScriptOutput, showEnrollmentSummary,
@@ -1333,6 +1341,17 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
await saveConfiguration("autopilotValidation", { validateCloudPcDevice: newValue });
}, [saveConfiguration]);
/**
* Toggle the cert-to-device binding check (Global-Admin-only preview). No consent flow -
* DeviceManagementManagedDevices.Read.All is an Optional Graph capabilities add-on granted
* with the script; without it the backend records "PermissionMissing" and nothing else, and
* because the check runs in shadow mode it never affects an enrollment either way.
*/
const handleToggleIntuneDeviceBinding = useCallback(async (newValue: boolean) => {
setValidateIntuneDeviceBinding(newValue);
await saveConfiguration("autopilotValidation", { validateIntuneDeviceBinding: newValue });
}, [saveConfiguration]);
// -----------------------------------------------------------------------
// Admin management handlers
// -----------------------------------------------------------------------
@@ -1660,7 +1679,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
validateDeviceAssociation, setValidateDeviceAssociation,
handleToggleDeviceAssociationValidation,
validateCloudPcDevice, setValidateCloudPcDevice,
validateIntuneDeviceBinding, setValidateIntuneDeviceBinding,
handleToggleCloudPcValidation,
handleToggleIntuneDeviceBinding,
saveValidationGate,
autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess,
@@ -1767,6 +1788,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
editionInfo, startingTrial, startTrial, appHomingFunnelActive, homingFlipped,
validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation,
handleToggleDeviceAssociationValidation, validateCloudPcDevice, handleToggleCloudPcValidation,
validateIntuneDeviceBinding, handleToggleIntuneDeviceBinding,
saveValidationGate, autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess,
manufacturerWhitelist, modelWhitelist, effectiveHwRejectionNotify, setHwRejectionNotifyWriteThrough,
handleSaveHardwareWhitelist, handleResetHardwareWhitelist,
@@ -23,6 +23,7 @@ const EXPECTED_OWNED_FIELDS = [
"manufacturerWhitelist", "modelWhitelist", "webhookNotifyOnHardwareRejection",
// autopilotValidation
"validateAutopilotDevice", "validateCorporateIdentifier", "validateDeviceAssociation", "validateCloudPcDevice",
"validateIntuneDeviceBinding",
// notifications
"notificationChannelsJson",
// slaTargets
@@ -13,6 +13,10 @@ interface AutopilotValidationSectionProps {
onToggleDeviceAssociation?: (newValue: boolean) => void | Promise<void>;
/** Render the DevPrep Device Association toggle. Gated to Global Admins by the parent. */
showDeviceAssociationToggle?: boolean;
/** Cert-to-device binding - Global-Admin-only preview. Provided when `showIntuneDeviceBindingToggle=true`. */
validateIntuneDeviceBinding?: boolean;
onToggleIntuneDeviceBinding?: (v: boolean) => void | Promise<void>;
showIntuneDeviceBindingToggle?: boolean;
/** Windows 365 Cloud PC validation — fallback gate for Cloud PCs (never Autopilot-registered). */
validateCloudPcDevice?: boolean;
/** Toggle + persist Cloud PC validation in one shot (permission comes via the Optional Graph capabilities add-on, no consent dialog). */
@@ -35,6 +39,9 @@ export default function AutopilotValidationSection({
validateDeviceAssociation = false,
onToggleDeviceAssociation,
showDeviceAssociationToggle = false,
validateIntuneDeviceBinding = false,
onToggleIntuneDeviceBinding,
showIntuneDeviceBindingToggle = false,
validateCloudPcDevice = false,
onToggleCloudPc,
autopilotConsentInProgress,
@@ -237,6 +244,35 @@ export default function AutopilotValidationSection({
</div>
)}
{/* Cert-to-device binding - GA-gated preview, shadow-mode (no enrollment block) */}
{showIntuneDeviceBindingToggle && onToggleIntuneDeviceBinding && (
<div className="border-t border-gray-100 pt-5 space-y-3" data-testid="intune-device-binding-toggle">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-gray-700 tracking-wide">Certificate Device Binding</p>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider bg-purple-100 text-purple-800">
Preview · GA only
</span>
</div>
<label className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-900">Enable Intune Device Binding Validation</p>
<p className="text-sm text-gray-500">
Checks that the Intune device id in the agent{"'"}s client certificate belongs to a device this tenant actually enrolled. Currently runs in <strong>shadow mode</strong> - the result is recorded as telemetry only and does NOT block enrollment. Requires the optional <strong>DeviceManagementManagedDevices.Read.All</strong> permission - grant the{" "}
<strong>IntuneDeviceBinding</strong> add-on under <em>Optional Graph capabilities</em>.
</p>
</div>
<button
onClick={() => { void onToggleIntuneDeviceBinding(!validateIntuneDeviceBinding); }}
disabled={saving}
aria-label="Toggle Intune device binding validation"
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${validateIntuneDeviceBinding ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${validateIntuneDeviceBinding ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</label>
</div>
)}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-900">
<strong>Important:</strong>{" "}
@@ -40,6 +40,7 @@ export const SECTION_FIELD_MAP = {
"validateCorporateIdentifier",
"validateDeviceAssociation",
"validateCloudPcDevice",
"validateIntuneDeviceBinding",
],
},
notifications: {
@@ -17,6 +17,8 @@ export function SectionAutopilotValidation() {
validateDeviceAssociation,
handleToggleDeviceAssociationValidation,
validateCloudPcDevice,
validateIntuneDeviceBinding,
handleToggleIntuneDeviceBinding,
handleToggleCloudPcValidation,
saveValidationGate,
autopilotConsentInProgress, savingSection,
@@ -42,6 +44,9 @@ export function SectionAutopilotValidation() {
// to the web test setup (vitest.config.ts currently only matches *.test.ts, no JSX).
// Tracked in memory/project_devprep_followups.md.
const showDeviceAssociationToggle = user?.isGlobalAdmin === true;
// Same GA gate as the DevPrep preview: only the operator can turn the binding check on
// while its enrollment-race behaviour is still being measured.
const showIntuneDeviceBindingToggle = user?.isGlobalAdmin === true;
// Dual app-reg window: tenants homed on the previous app registration (homedAppClientId
// null/absent) keep working unchanged — this is a purely informational nudge, part of the
@@ -145,6 +150,9 @@ export function SectionAutopilotValidation() {
onToggleDeviceAssociation={handleToggleDeviceAssociationValidation}
showDeviceAssociationToggle={showDeviceAssociationToggle}
validateCloudPcDevice={validateCloudPcDevice}
validateIntuneDeviceBinding={validateIntuneDeviceBinding}
onToggleIntuneDeviceBinding={handleToggleIntuneDeviceBinding}
showIntuneDeviceBindingToggle={showIntuneDeviceBindingToggle}
onToggleCloudPc={handleToggleCloudPcValidation}
autopilotConsentInProgress={autopilotConsentInProgress}
saving={savingSection === "autopilotValidation"}
@@ -53,6 +53,7 @@ export interface TenantConfiguration {
* permission ("W365CloudPcValidation" in Optional Graph capabilities).
*/
validateCloudPcDevice?: boolean;
validateIntuneDeviceBinding?: boolean;
allowInsecureAgentRequests?: boolean;
/**
* Dual app-reg window: client id of the Entra app registration this tenant is homed on.
@@ -87,6 +87,7 @@ export const SHARED_MANIFEST = {
"validateCorporateIdentifier",
"validateDeviceAssociation",
"validateCloudPcDevice",
"validateIntuneDeviceBinding",
"allowInsecureAgentRequests",
"dataRetentionDays",
"sessionTimeoutHours",
@@ -83,6 +83,7 @@
"validateCorporateIdentifier",
"validateDeviceAssociation",
"validateCloudPcDevice",
"validateIntuneDeviceBinding",
"allowInsecureAgentRequests",
"dataRetentionDays",
"sessionTimeoutHours",