diff --git a/docs/backend/client-cert-tenant-binding.md b/docs/backend/client-cert-tenant-binding.md index 580a61a0..59378c66 100644 --- a/docs/backend/client-cert-tenant-binding.md +++ b/docs/backend/client-cert-tenant-binding.md @@ -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 "" -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 diff --git a/docs/log.md b/docs/log.md index b91d46f8..ecd2fe02 100644 --- a/docs/log.md +++ b/docs/log.md @@ -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`. diff --git a/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 b/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 index fe8b5ab0..7809d9a8 100644 --- a/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 +++ b/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 @@ -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. diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GraphFeatureCatalogSyncTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GraphFeatureCatalogSyncTests.cs index b1bb1015..8b2c532a 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/GraphFeatureCatalogSyncTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GraphFeatureCatalogSyncTests.cs @@ -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"); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/IntuneDeviceBindingValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/IntuneDeviceBindingValidatorTests.cs new file mode 100644 index 00000000..86f9a3b5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/IntuneDeviceBindingValidatorTests.cs @@ -0,0 +1,183 @@ +using AutopilotMonitor.Functions.Security; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for — 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 1:1. +/// +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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index 1cb145fa..e768d4f5 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -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, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index 62498dac..7570a5b9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -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) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs index 51a04126..bdcb5966 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs @@ -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) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs index f660e953..9a552aa1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs index dfae713a..6b13fd74 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs @@ -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) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs index abd1f624..af398263 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs @@ -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) diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 8085516e..1dcf488a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Graph add-on permission detection + script display-name resolution. // Detector parses the `roles` claim of the SP access token; Resolver fetches diff --git a/src/Backend/AutopilotMonitor.Functions/Security/IntuneDeviceBindingValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/IntuneDeviceBindingValidator.cs new file mode 100644 index 00000000..72da2791 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Security/IntuneDeviceBindingValidator.cs @@ -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 +{ + /// + /// 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 + /// deviceManagement/managedDevices inventory. + /// + /// 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. + /// + /// + /// + /// Built as a near-sibling of — 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: + /// + /// A point-GET on managedDevices/{id} rather than an OData + /// $filter. There is no filter literal to inject into, and a 404 is an unambiguous + /// "not this tenant's device". + /// 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. + /// + /// SHADOW ONLY (stage 3): nothing consumes the result for an authorization decision. Grep + /// marker for the enforcement change: CERT-DEVICE-BINDING-SHADOW. + /// + public class IntuneDeviceBindingValidator + { + private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30); + private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5); + + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IMemoryCache _cache; + private readonly GraphTokenService _graphTokenService; + + public IntuneDeviceBindingValidator( + ILogger logger, + IHttpClientFactory httpClientFactory, + IMemoryCache cache, + GraphTokenService graphTokenService) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _cache = cache; + _graphTokenService = graphTokenService; + } + + /// + /// Resolves the Intune device id (from the client certificate's Subject CN) against the + /// tenant's managedDevices inventory. Never throws. + /// + public async Task 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 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 ?? "", normalizedId, attempt); + + return new IntuneDeviceBindingResult + { + Outcome = IntuneDeviceBindingOutcome.Transient, + IntuneDeviceId = normalizedId, + ErrorMessage = $"Error during Intune device binding: {ex.Message}" + }; + } + } + + /// + /// Pure-function: maps a managedDevices/{id} Graph response body to a result. + /// Re-checks the returned id so a redirected or widened lookup can never produce a + /// false positive. + /// + 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(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()) + }; + } + + /// + /// Parses Graph's enrolledDateTime. Intune returns 0001-01-01T00:00:00Z for + /// devices it has no enrollment timestamp for; that is absence, not a date, so it maps to + /// null rather than a bogus age of two thousand years. + /// + 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; + } + } + + /// + /// Stable outcome codes for the device-binding check. Emitted verbatim in the + /// AgentCertDeviceBinding log and as the CertDeviceBinding request dimension — + /// operators match on them in KQL, so keep the names stable. + /// + public enum IntuneDeviceBindingOutcome + { + /// Device id from the certificate resolves to a managedDevice in this tenant. + Match = 0, + + /// No managedDevice with that id in this tenant (foreign device, or not yet created). + NotFound = 1, + + /// Certificate subject carried no usable Intune device id. + NoDeviceIdInCert = 2, + + /// DeviceManagementManagedDevices.Read.All not granted in this tenant. + PermissionMissing = 3, + + /// Graph outage, token failure or exception — says nothing about the device. + Transient = 4 + } + + public class IntuneDeviceBindingResult + { + public IntuneDeviceBindingOutcome Outcome { get; set; } + + /// True only for . + public bool IsValid => Outcome == IntuneDeviceBindingOutcome.Match; + + /// + /// True when the failure says nothing about the device (Graph error, token issue). + /// Transient results are never cached. + /// + public bool IsTransient => Outcome == IntuneDeviceBindingOutcome.Transient; + + /// Intune device id taken from the client certificate's Subject CN. + public string? IntuneDeviceId { get; set; } + + /// Intune device name — diagnostic only, never a gate. + public string? DeviceName { get; set; } + + /// Entra device id of the same device, if Intune knows one — diagnostic only. + public string? AzureAdDeviceId { get; set; } + + /// Intune management state — diagnostic only. + public string? ManagementState { get; set; } + + /// + /// 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. + /// + public DateTimeOffset? EnrolledDateTime { get; set; } + + public string? ErrorMessage { get; set; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs index 3691ad8a..5ffe251c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs @@ -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) diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs index 0be0ed4e..36395894 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs @@ -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 { diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index f9fdb6e3..ae823405 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -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 /// public bool ValidateCloudPcDevice { get; set; } = false; + /// + /// 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). + /// + public bool ValidateIntuneDeviceBinding { get; set; } = false; + /// /// Emergency bypass for agent security gate (Global Admin use only). /// If true, agent requests are accepted even when ValidateAutopilotDevice is false. diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphAppPermissions.cs b/src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphAppPermissions.cs index 45216fd2..3e3a0d80 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphAppPermissions.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphAppPermissions.cs @@ -1,4 +1,4 @@ -namespace AutopilotMonitor.Shared.Models.Graph +namespace AutopilotMonitor.Shared.Models.Graph { /// /// Constant catalog of Microsoft Graph application permission names referenced by the @@ -26,6 +26,14 @@ namespace AutopilotMonitor.Shared.Models.Graph /// Read Intune managed apps (future use — Win32 app display names not yet covered). public const string DeviceManagementAppsReadAll = "DeviceManagementApps.Read.All"; + /// + /// Read Intune managed device objects (deviceManagement/managedDevices). 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. + /// + public const string DeviceManagementManagedDevicesReadAll = "DeviceManagementManagedDevices.Read.All"; + /// /// Read Windows 365 Cloud PC objects (virtualEndpoint/cloudPCs). Optional add-on /// permission backing the W365 Cloud PC device validator — Cloud PCs are never diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs b/src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs index 39cf2967..278ac9be 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs @@ -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 /// Validates Windows 365 Cloud PCs via the service-side Cloud PC inventory (cert-CN bound). public const string FeatureW365CloudPcValidation = "W365CloudPcValidation"; + /// + /// 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). + /// + public const string FeatureIntuneDeviceBinding = "IntuneDeviceBinding"; + // Backed by a plain dictionary (netstandard2.0 + C# 9, no Immutable types). private static readonly Dictionary _byFeature = new Dictionary(StringComparer.OrdinalIgnoreCase) { [FeatureScriptDisplayNames] = new[] { GraphAppPermissions.DeviceManagementScriptsReadAll }, [FeatureW365CloudPcValidation] = new[] { GraphAppPermissions.CloudPCReadAll }, + [FeatureIntuneDeviceBinding] = new[] { GraphAppPermissions.DeviceManagementManagedDevicesReadAll }, }; /// All registered feature identifiers. diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx index 28c05067..ecf280f2 100644 --- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx @@ -133,11 +133,14 @@ interface TenantConfigContextValue { handleToggleDeviceAssociationValidation: (newValue: boolean) => Promise; 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; + handleToggleIntuneDeviceBinding: (newValue: boolean) => Promise; /** * 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 => { + const saveConfiguration = useCallback(async (sectionName: SettingsSectionName, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: boolean; validateIntuneDeviceBinding?: boolean; unrestrictedMode?: boolean }): Promise => { // 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, diff --git a/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts b/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts index 89ef2468..7c092eba 100644 --- a/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts +++ b/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts @@ -23,6 +23,7 @@ const EXPECTED_OWNED_FIELDS = [ "manufacturerWhitelist", "modelWhitelist", "webhookNotifyOnHardwareRejection", // autopilotValidation "validateAutopilotDevice", "validateCorporateIdentifier", "validateDeviceAssociation", "validateCloudPcDevice", + "validateIntuneDeviceBinding", // notifications "notificationChannelsJson", // slaTargets diff --git a/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx index 27b62acb..e20ae715 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx @@ -13,6 +13,10 @@ interface AutopilotValidationSectionProps { onToggleDeviceAssociation?: (newValue: boolean) => void | Promise; /** 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; + 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({ )} + {/* Cert-to-device binding - GA-gated preview, shadow-mode (no enrollment block) */} + {showIntuneDeviceBindingToggle && onToggleIntuneDeviceBinding && ( +
+
+

Certificate Device Binding

+ + Preview · GA only + +
+ +
+ )} +

Important:{" "} diff --git a/src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts b/src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts index a9aba88f..30103343 100644 --- a/src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts +++ b/src/Web/autopilot-monitor-web/app/settings/sectionFieldMap.ts @@ -40,6 +40,7 @@ export const SECTION_FIELD_MAP = { "validateCorporateIdentifier", "validateDeviceAssociation", "validateCloudPcDevice", + "validateIntuneDeviceBinding", ], }, notifications: { diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx index b3cfb62b..f58356f3 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx @@ -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"} diff --git a/src/Web/autopilot-monitor-web/app/settings/types.ts b/src/Web/autopilot-monitor-web/app/settings/types.ts index 33066c43..db74fde4 100644 --- a/src/Web/autopilot-monitor-web/app/settings/types.ts +++ b/src/Web/autopilot-monitor-web/app/settings/types.ts @@ -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. diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index 49b5bc55..815c577c 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -87,6 +87,7 @@ export const SHARED_MANIFEST = { "validateCorporateIdentifier", "validateDeviceAssociation", "validateCloudPcDevice", + "validateIntuneDeviceBinding", "allowInsecureAgentRequests", "dataRetentionDays", "sessionTimeoutHours", diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 0d21bf45..bb13f3e2 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -83,6 +83,7 @@ "validateCorporateIdentifier", "validateDeviceAssociation", "validateCloudPcDevice", + "validateIntuneDeviceBinding", "allowInsecureAgentRequests", "dataRetentionDays", "sessionTimeoutHours",