mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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,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);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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)
|
||||
|
||||
+6
-2
@@ -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)
|
||||
|
||||
+6
-2
@@ -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: {
|
||||
|
||||
+8
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user