diff --git a/docs/backend/cloudpc-device-validation.md b/docs/backend/cloudpc-device-validation.md new file mode 100644 index 00000000..7cca122e --- /dev/null +++ b/docs/backend/cloudpc-device-validation.md @@ -0,0 +1,133 @@ +--- +type: Concept +title: W365 Cloud PC Device Validation — Cert-CN-Bound Fallback Validator +description: Why Windows 365 Cloud PCs can never pass the Autopilot serial lookup, and how the CloudPc validator admits exactly the tenant's service-provisioned Cloud PCs by binding the MDM certificate's Subject CN (= Intune device id) to the Graph virtualEndpoint/cloudPCs inventory. +resource: src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs +tags: + - backend + - security + - device-validation + - windows365 + - cloudpc + - graph +timestamp: 2026-08-06T00:00:00+02:00 +--- + +# W365 Cloud PC Device Validation + +Windows 365 Cloud PCs are provisioned headless by the Windows 365 service and are +**structurally never Autopilot-registered** — the serial lookup against +`windowsAutopilotDeviceIdentities` (and the Corporate Identifier fallback, whose +serials nobody can pre-import for VMs minted at provisioning time) always misses. +Before this validator, a Cloud PC agent died in the 403 "Device not registered" +even though its mTLS cert was perfectly valid, so Cloud PC first-connect +enrollment (Account Setup, RealmJoin, Hello) could not be monitored. + +# Schema — the validator chain + +`SecurityValidator.ValidateRequestAsync` stage 4 runs the same chain for every +device; there is **no device-type routing** and the local W365 markers +(Windows365 registry key, CloudManagedDesktopExtension service) are never +consulted for auth — they gain an attacker nothing: + +1. **Autopilot serial lookup** (`ValidateAutopilotDevice`) — unchanged primary. +2. **Corporate Identifier** (`ValidateCorporateIdentifier`) — unchanged fallback. +3. **CloudPc** (`ValidateCloudPcDevice`, default off) — only reached when 1+2 + missed. Extracts the Intune device id from the **chain-validated** client + certificate's Subject CN (`TryGetIntuneDeviceIdFromCertSubject`: CN present + AND canonical GUID, else definitive reject) and requires a `cloudPC` object + with `managedDeviceId eq ` in the claimed tenant + (`GET /v1.0/deviceManagement/virtualEndpoint/cloudPCs?$filter=…`). + `ValidatedBy = ValidatorType.CloudPc (5)`. + +Only machines actually provisioned by the Windows 365 service have a `cloudPC` +object — a regular Intune-enrolled non-Autopilot device still ends in the 403, +so the "Autopilot devices only" contract of stages 1–2 is not widened. The +tenant hard gate accepts `ValidateCloudPcDevice` as a third satisfying flag. + +## Why the cert CN, not headers or local markers + +Field-verified 2026-08-06 on a real W365 Enterprise Cloud PC (gktatooine.net): + +- MDM client cert Subject `CN=07623d56-…` == Intune `managedDevice.id` == + `cloudPC.managedDeviceId`. The CN cannot be forged without the Intune MDM + Device CA (chain pinned to embedded roots, `CustomRootTrust`). +- `$filter=managedDeviceId eq '…'` is supported server-side on **v1.0** — no + paging fallback needed. +- `managedDevice.serialNumber` matches local `Win32_BIOS.SerialNumber` exactly; + the "Cloud PC Enterprise …" model string exists **only** in Graph, never in + local WMI (local WMI reports Manufacturer='Microsoft Corporation', + Model='Virtual Machine'). + +Threat model: a valid Intune cert from a *different* tenant fails (its CN does +not exist in the claimed tenant's Cloud PC inventory — this is a *tenant +binding* the serial-based validators do not have); spoofed serial headers and +faked local W365 markers are irrelevant (never consulted); a rogue enrolled VM +with doctored SMBIOS cannot mint a `cloudPC` object (service-side state, not +device-reported inventory — the reason the model-string gate was rejected). +Retired/deprovisioned Cloud PCs disappear from the inventory and their +validation dies with them (compensates the absent cert revocation check). +Residual: a compromised *legitimate* Cloud PC of the tenant — same trust level +as any tenant device today, rate-limited by cert thumbprint. + +## Graph permission plumbing + +- Optional add-on permission `CloudPC.Read.All` — NOT part of the default + consent set. Feature `W365CloudPcValidation` in `GraphFeatureCatalog` → + surfaces automatically in the "Optional Graph capabilities" UI (the feature + table and grant command are backend-driven) and in + `Grant-AutopilotMonitorAddOn.ps1` (`-Features W365CloudPcValidation`). + `GraphFeatureCatalogSyncTests` pins C# catalog ↔ grant script (previously + comment-only lock-step). +- A Graph **403** (permission not granted) is a definitive negative (cached + 5 min), NOT transient — a 503 Retry-After loop cannot fix a missing grant. + The error message names the add-on feature. +- Same resilience contract as `AutopilotDeviceValidator`: 30/5 min + positive/negative cache, 2 attempts, transient → agent-facing 503. + +## Bootstrap script interplay (v2.3-dev.3) + +The Cloud-PC relax in `Install-AutopilotMonitor-dev.ps1` additionally exempts +**guard 4** (12 h uptime window): a Cloud PC runs headless for days before the +user's first connect, and the monitorable phase (Account Setup) only starts at +that first connect — the single-fresh-profile window (< 15 min) is the time +anchor there, not boot uptime. The OOBE-restore trigger keeps the uptime guard. + +## Agent-side IsCloudPc flag + decision-engine expectation + +Same change set, agent side. `CloudPcDetector` (V2.Core) re-uses the bootstrap's +field-verified marker AND — `HKLM\SOFTWARE\Microsoft\Windows365` key **plus** +installed `CloudManagedDesktopExtension` service (probed via its +`HKLM\SYSTEM\CurrentControlSet\Services` key; the agent deliberately has no +`System.ServiceProcess` dependency). SKIP-safe: any error resolves `false`. + +Two independent transport rails, never an auth input: + +- **Session metadata** — `SessionRegistration.IsCloudPc` → + Sessions table (sticky-true OR across re-registrations, like + `IsSelfDeployingProfile`) → SessionsIndex full mirror → `SessionSummary` → + search filter `isCloudPc` (portal, `/api/search/sessions`, `/api/raw/sessions`, + MCP `search_sessions`/`query_raw_sessions`). Agent-reported and unverified — + can legitimately disagree with the server-derived `ValidatedBy = CloudPc` + (e.g. Cloud PC admitted via a stage that ran earlier). +- **Decision engine** — the marker rides the `EnrollmentFactsObserved` payload + (`isCloudPc`), is recorded set-once for BOTH values in + `EnrollmentScenarioObservations.CloudPc`, and annotates the scenario profile + reason `cloud_pc_first_connect:no_device_esp_expected`. That is the + engine-recorded expectation "no Device ESP phase will appear": Device-ESP ran + headless at provisioning, the session starts at Account Setup. Deliberately + reason+observation only — no completion arm depends on DeviceSetup, `Mode` + is still classified by AccountSetup/IME signals (Classic), and `EspConfig` + keeps the *configured* FirstSync semantics. The positive marker surfaces as + `cloud_pc_marker` in the terminal audit-trail signal census. + +# Citations + +- `src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs` +- `src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs` — stage 4 chain, `TryGetIntuneDeviceIdFromCertSubject` +- `src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs` — `W365CloudPcValidation` +- `scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1` — customer-side grant +- `scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1` — Cloud-PC relax + guard-4 exemption +- `src/Backend/AutopilotMonitor.Functions.Tests/CloudPcDeviceValidatorTests.cs`, `SecurityValidatorTests.cs`, `GraphFeatureCatalogSyncTests.cs` +- `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CloudPcDetector.cs` — agent-side marker AND +- `src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioObservations.cs` — `CloudPc` fact; reason annotation in `EnrollmentScenarioProfileUpdater` diff --git a/docs/index.md b/docs/index.md index c920fe0a..a5ca2bda 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,6 +39,7 @@ https://docs.autopilotmonitor.com (separate repository). * [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains. * [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract. * [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other. +* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else. * [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade. # MCP Server diff --git a/docs/log.md b/docs/log.md index bc8abb51..a7cdbc0d 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,13 @@ # Log +## 2026-08-07 + +* **Update**: `backend/cloudpc-device-validation.md` — the "deliberately out of scope" follow-up landed in the same PR: agent-side `IsCloudPc` flag (`CloudPcDetector`, bootstrap-verified marker AND: Windows365 key + CloudManagedDesktopExtension service key, SKIP-safe) rides two rails — `SessionRegistration` → Sessions/Index (sticky-true) → `SessionSummary` + `isCloudPc` search filter (portal/raw/MCP), and the `EnrollmentFactsObserved` payload → `EnrollmentScenarioObservations.CloudPc` (set-once, both values) with profile reason `cloud_pc_first_connect:no_device_esp_expected` + `cloud_pc_marker` in the audit-trail census. Reason+observation only: no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-classified, EspConfig keeps configured-FirstSync semantics. Never an auth input. + +## 2026-08-06 + +* **Creation**: Added `backend/cloudpc-device-validation.md` — the CloudPc fallback validator (`ValidatorType.CloudPc = 5`, tenant flag `ValidateCloudPcDevice`): W365 Cloud PCs are structurally never Autopilot-registered, so stage 4 gains a third stage that binds the chain-validated MDM cert's Subject CN (= Intune device id, field-verified on a real Cloud PC) to the tenant's `virtualEndpoint/cloudPCs` inventory — admits exactly the service-provisioned Cloud PCs, no device-type routing, local W365 markers never consulted for auth. New optional Graph add-on `W365CloudPcValidation` → `CloudPC.Read.All` (catalog + grant script now pinned by `GraphFeatureCatalogSyncTests`; Graph 403 = definitive "grant missing", not transient). Bootstrap dev script v2.3-dev.3 exempts guard 4 (uptime) while the Cloud-PC relax is active — the fresh-profile window is the time anchor at first connect, not boot uptime. Follow-up deliberately out of scope: agent-side `IsCloudPc` classification + decision-engine "no Device ESP" expectation. + ## 2026-08-05 (later) * **Update**: `rules/rule-authoring-surface.md` — new section `POST /api/rules/gather/test-pattern`: the logparser pattern tester (agent-exact .NET matching: no IgnoreCase, first-match-per-line, cmtrace vs text mode, verbatim format=text hint), motivated by a field incident where a PHP-verified regex behaved differently under .NET. `CmTraceLogParser` moved Agent → `AutopilotMonitor.Shared.Logging` so agent and backend share the ONE parsing implementation (timestamp caveat: AssumeLocal = server TZ on backend, endpoint reports no timestamps). MCP: new `test_log_pattern` tool, logparser lint in `validate_rule`, guide gains the case-sensitivity/engine-mismatch pitfalls. diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-dev.Tests.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-dev.Tests.ps1 index 3f851824..2f53b765 100644 --- a/scripts/Bootstrap/Install-AutopilotMonitor-dev.Tests.ps1 +++ b/scripts/Bootstrap/Install-AutopilotMonitor-dev.Tests.ps1 @@ -248,6 +248,36 @@ Describe 'Get-BootstrapDecision' { $d.ReasonCode | Should -Be 'UptimeExceeded' } + It 'installs on a Cloud PC despite uptime over the window when the relax is active (guard 4 exemption, v2.3-dev.3)' { + # W365 reality: provisioned days ago, running headless ever since; the user's + # first connect just created the profile. Uptime is meaningless here. + $script:fakeProfile = New-FakeProfile 'FirstConnectUser' 0.6 + Mock Get-RealUserProfilePaths { @($script:fakeProfile) } + Mock Test-IsCloudPc { $true } + Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + $d.Install | Should -BeTrue + $d.RelaxActive | Should -BeTrue + } + + It 'still skips an over-uptime Cloud PC when the relax is NOT active (no fresh profile)' { + Mock Test-IsCloudPc { $true } + Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + $d.Install | Should -BeFalse + $d.ReasonCode | Should -Be 'UptimeExceeded' + } + + It 'keeps guard 4 for the OOBE-restore relax (physical device, uptime is meaningful)' { + $script:fakeProfile = New-FakeProfile 'RestoreUser' 3 + Mock Get-RealUserProfilePaths { @($script:fakeProfile) } + Mock Get-OobeState { 'InProgress' } + Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' } + $d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin + $d.Install | Should -BeFalse + $d.ReasonCode | Should -Be 'UptimeExceeded' + } + It 'skips when the agent binary is already present (guard 5)' { $binPath = Join-Path $TestDrive 'AgentBinExisting' New-Item -ItemType Directory -Path $binPath -Force | Out-Null diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1 index 46e2811c..c584cd00 100644 --- a/scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1 +++ b/scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1 @@ -45,6 +45,8 @@ Either trigger relaxes guards 2+3 ONLY when additionally: (a) there is exactly ONE real user profile, AND (b) that profile was created within $OobeProfileMaxAgeMinutes minutes. + The Cloud PC trigger additionally exempts guard 4 (uptime window) -- see + .PARAMETER MaxBootstrapWindowHours for why boot uptime is meaningless there. Any miss keeps the original skip (SKIP-safe: we never install on uncertainty). On productive devices the profile is old, so (b) keeps skipping correctly. Known accepted edge: a W365 Frontline shared-mode device can legitimately show a @@ -63,9 +65,11 @@ Maximum device uptime (hours) within which the bootstrap is still considered valid. Devices booted more than this many hours ago are skipped because we no longer trust their OOBE state. Default: 12. - Cloud PC note: a Cloud PC may run for days between provisioning and the user's - first connect; this guard then skips INTENTIONALLY -- the enrollment finished - long ago and there is nothing left to monitor. + Cloud PC note: NOT applied when the Cloud-PC relax is active. A Cloud PC runs + headless for days between provisioning and the user's first connect, and the + phase worth monitoring (Account Setup) only starts at that first connect -- + there the fresh-profile window (OobeProfileMaxAgeMinutes) is the time anchor, + not boot uptime. .PARAMETER OobeProfileMaxAgeMinutes Maximum age (minutes) of the single profile that may appear for the relax @@ -86,6 +90,11 @@ PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars. .CHANGELOG + 2026-08-06 v2.3-dev.3 Guard 4 (uptime window) exempted while the Cloud-PC relax is + active: Cloud PCs run headless for days before the user's first + connect, so boot uptime says nothing about enrollment freshness -- + the single-fresh-profile window (< OobeProfileMaxAgeMinutes) already + anchors the relax in time. OOBE-restore keeps the uptime guard. 2026-08-06 v2.3-dev.2 Cloud PC detection rebuilt on captured evidence: first field test proved local WMI reports only Manufacturer='Microsoft Corporation' + Model='Virtual Machine' (the 'Cloud PC ...' model @@ -135,7 +144,7 @@ param( ) # Script version (bump on meaningful changes; see .CHANGELOG above) -$ScriptVersion = "2.3-dev.2" +$ScriptVersion = "2.3-dev.3" # Configuration - Everything in ProgramData for easy cleanup $AgentBasePath = "$env:ProgramData\AutopilotMonitor" @@ -317,14 +326,22 @@ function Get-BootstrapDecision { # Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window. # Sleep/standby does not reset uptime, only real boot/restart does. - # Cloud PC note: uptime may exceed the window when the user first connects days after - # provisioning -> skip is intentional (enrollment finished long ago, nothing to monitor). + # Cloud PC exemption (v2.3-dev.3): a Cloud PC runs headless from provisioning until the + # user's first connect -- often days -- and the phase worth monitoring (Account Setup) + # only STARTS at that first connect. Boot uptime is therefore meaningless there; the + # single-fresh-profile window of the relax (< $OobeProfileMaxAgeMinutes min) is the time + # anchor instead. The OOBE-restore trigger keeps the original uptime guard (physical + # device, uptime is meaningful). $lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime $uptimeHours = ((Get-Date) - $lastBoot).TotalHours Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)" if ($uptimeHours -gt $MaxBootstrapWindowHours) { - Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h." - return [pscustomobject]@{ Install = $false; ReasonCode = 'UptimeExceeded'; RelaxActive = $relax.Active } + if ($relax.Active -and $relax.IsCloudPc) { + Write-Log "Uptime $([int]$uptimeHours)h exceeds the ${MaxBootstrapWindowHours}h window, but the Cloud PC relax is active (fresh first-connect profile is the time anchor). Continuing." + } else { + Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h." + return [pscustomobject]@{ Install = $false; ReasonCode = 'UptimeExceeded'; RelaxActive = $relax.Active } + } } # Guard 5: Is the agent already installed? (leftover from previous run) diff --git a/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 b/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 index dd0146ab..483a305b 100644 --- a/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 +++ b/scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1 @@ -23,7 +23,11 @@ One or more high-level feature identifiers. The script translates each to the underlying Graph permissions. Use this when you don't want to know permission strings. Available features: - - ScriptDisplayNames -> DeviceManagementScripts.Read.All + - ScriptDisplayNames -> DeviceManagementScripts.Read.All + - W365CloudPcValidation -> CloudPC.Read.All + (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) .PARAMETER Permissions Microsoft Graph application permission names to grant directly. Use when the admin UI @@ -76,7 +80,7 @@ param( [string]$ClientId, [Parameter(Mandatory = $true, ParameterSetName = 'ByFeatures')] - [ValidateSet('ScriptDisplayNames')] + [ValidateSet('ScriptDisplayNames', 'W365CloudPcValidation')] [string[]]$Features, [Parameter(Mandatory = $true, ParameterSetName = 'ByPermissions')] @@ -97,7 +101,8 @@ $ErrorActionPreference = 'Stop' # ---- Feature -> Graph permission catalog ----------------------------------- # Keep in lock-step with src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs $FeatureCatalog = @{ - 'ScriptDisplayNames' = @('DeviceManagementScripts.Read.All') + 'ScriptDisplayNames' = @('DeviceManagementScripts.Read.All') + 'W365CloudPcValidation' = @('CloudPC.Read.All') } # Translate Features -> Permissions when caller used the high-level form. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostEnrollmentFactsObservedSignalTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostEnrollmentFactsObservedSignalTests.cs index 1cc2e56f..81e23d38 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostEnrollmentFactsObservedSignalTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Program/PostEnrollmentFactsObservedSignalTests.cs @@ -66,6 +66,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program // same facts signal (safe default "false" off-device). var selfDeploying = payload["isSelfDeployingProfile"]; Assert.True(selfDeploying == "true" || selfDeploying == "false"); + // W365 — the Cloud PC marker fact rides the same signal (safe default "false" + // off-device; a build host is not a Cloud PC). + Assert.Contains("isCloudPc", (IDictionary)payload); + var cloudPc = payload["isCloudPc"]; + Assert.True(cloudPc == "true" || cloudPc == "false"); } [Fact] diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/CloudPcDetectorTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/CloudPcDetectorTests.cs new file mode 100644 index 00000000..ccc8a3fa --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/CloudPcDetectorTests.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.Agent.V2.Core.Security; +using Xunit; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Security +{ + /// + /// Pins the marker-AND contract of : BOTH + /// the Windows365 registry key and the CloudManagedDesktopExtension service key must be + /// present — each alone has plausible look-alikes (W365-Boot physical clients carry + /// Windows365 policy state; the MCMD agent family served other managed-desktop + /// offerings). Mirrors the bootstrap script's field-verified Test-IsCloudPc. + /// + public sealed class CloudPcDetectorTests + { + private static Func Probe(params string[] existingKeys) + { + var set = new HashSet(existingKeys, StringComparer.OrdinalIgnoreCase); + return path => set.Contains(path); + } + + [Fact] + public void Both_markers_present_resolves_true() + { + Assert.True(CloudPcDetector.ResolveIsCloudPc(Probe( + CloudPcDetector.Windows365MarkerKey, + CloudPcDetector.CmdeServiceKey))); + } + + [Fact] + public void Windows365_key_alone_resolves_false() + { + // W365-Boot physical client shape: policy state without the on-device MCMD agent. + Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe(CloudPcDetector.Windows365MarkerKey))); + } + + [Fact] + public void Cmde_service_alone_resolves_false() + { + // MCMD agent family also served other managed-desktop offerings. + Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe(CloudPcDetector.CmdeServiceKey))); + } + + [Fact] + public void No_markers_resolves_false() + { + Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe())); + } + + [Fact] + public void Null_probe_resolves_false() + { + Assert.False(CloudPcDetector.ResolveIsCloudPc(null)); + } + + [Fact] + public void Throwing_probe_degrades_to_false() + { + // SKIP-safe contract: a broken registry read must never classify, let alone throw. + Assert.False(CloudPcDetector.ResolveIsCloudPc(_ => throw new UnauthorizedAccessException("registry denied"))); + } + + [Fact] + public void DetectIsCloudPc_never_throws_on_build_host() + { + // Real-registry shell: exercises the exception-swallowing wrapper end to end. + // A build host is not a Cloud PC, but the contract under test is "no throw", + // not the detected value. + var ex = Record.Exception(() => CloudPcDetector.DetectIsCloudPc()); + Assert.Null(ex); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs index 17bb459a..406f48a5 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Security/SessionRegistrationHelperTests.cs @@ -137,6 +137,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security Assert.Equal(Environment.MachineName, captured.DeviceName); Assert.False(string.IsNullOrEmpty(captured.EnrollmentType)); Assert.True(captured.IsUserDriven); + // W365 marker rides the registration body. A build host is not a Cloud PC + // (marker-AND: Windows365 key + CloudManagedDesktopExtension service), so the + // detector's SKIP-safe default is what must land here. + Assert.False(captured.IsCloudPc); } [Fact] diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CloudPcDetector.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CloudPcDetector.cs new file mode 100644 index 00000000..affc45f0 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CloudPcDetector.cs @@ -0,0 +1,77 @@ +using System; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Security +{ + /// + /// Detects whether the agent runs on a Windows 365 Cloud PC, using the same marker + /// evidence the bootstrap script's Test-IsCloudPc settled on after the first + /// W365 field test (2026-08-06) disproved WMI-model matching (a Cloud PC reports only + /// Manufacturer='Microsoft Corporation' + Model='Virtual Machine' locally — identical + /// to any Hyper-V VM): + /// + /// HKLM\SOFTWARE\Microsoft\Windows365 exists, AND + /// the CloudManagedDesktopExtension service (Microsoft Cloud Managed + /// Desktop Extension, the W365 management agent that runs ON Cloud PCs) is + /// installed — probed via its HKLM\SYSTEM\CurrentControlSet\Services key, + /// so the agent needs no System.ServiceProcess dependency. + /// + /// BOTH markers are required (AND): each alone has plausible look-alikes (W365-Boot + /// physical clients may carry Windows365 policy state; the MCMD agent family also + /// served other managed-desktop offerings). + /// + /// Display/context metadata only — the flag rides on SessionRegistration and the + /// EnrollmentFactsObserved signal. It is NEVER an auth input: backend device + /// validation trusts exclusively the cert-CN-bound Graph lookup + /// (CloudPcDeviceValidator), not this locally-derived value. + /// Exception-swallowing, false on any error — mirrors the SKIP-safe bootstrap contract + /// and the degradation rules. + /// + /// + public static class CloudPcDetector + { + internal const string Windows365MarkerKey = @"SOFTWARE\Microsoft\Windows365"; + internal const string CmdeServiceKey = @"SYSTEM\CurrentControlSet\Services\CloudManagedDesktopExtension"; + + public static bool DetectIsCloudPc() + { + try + { + return ResolveIsCloudPc(SubKeyExists); + } + catch + { + return false; + } + } + + /// + /// Pure decision logic for . Exposed internally so the + /// marker-AND semantics can be exercised without a real registry (same seam contract + /// as ). + /// receives an HKLM-relative subkey path and reports + /// whether it exists; a throwing probe degrades to false. + /// + internal static bool ResolveIsCloudPc(Func subKeyExists) + { + if (subKeyExists == null) return false; + + try + { + return subKeyExists(Windows365MarkerKey) && subKeyExists(CmdeServiceKey); + } + catch + { + return false; + } + } + + private static bool SubKeyExists(string subKeyPath) + { + using (var key = Registry.LocalMachine.OpenSubKey(subKeyPath)) + { + return key != null; + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs index 12dac2bf..578a5763 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionRegistrationHelper.cs @@ -176,6 +176,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security EnrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType(), IsHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin(), IsSelfDeployingProfile = EnrollmentRegistryDetector.DetectSelfDeployingProfile(), + IsCloudPc = CloudPcDetector.DetectIsCloudPc(), // Deliberately stays true even for self-deploying profiles: the WhiteGlove // Part1/Part2 merge paths and existing dashboards key on IsUserDriven; // IsSelfDeployingProfile is the additive classification signal. diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs index ae5857c9..15ac75cf 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/LifecycleEmitters.cs @@ -309,12 +309,14 @@ namespace AutopilotMonitor.Agent.V2.Runtime var enrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType(); var isHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin(); var isSelfDeploying = EnrollmentRegistryDetector.DetectSelfDeployingProfile(); + var isCloudPc = CloudPcDetector.DetectIsCloudPc(); var payload = new Dictionary(StringComparer.Ordinal) { ["enrollmentType"] = enrollmentType, ["isHybridJoin"] = isHybridJoin ? "true" : "false", ["isSelfDeployingProfile"] = isSelfDeploying ? "true" : "false", + ["isCloudPc"] = isCloudPc ? "true" : "false", }; var evidence = new Evidence( @@ -329,7 +331,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime evidence: evidence, payload: payload); - logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying})."); + logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc})."); } catch (Exception ex) { diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/CloudPcObservationTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/CloudPcObservationTests.cs new file mode 100644 index 00000000..e1cac307 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/CloudPcObservationTests.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// W365 — reducer-level coverage for the Cloud PC marker fact riding + /// : the observation records BOTH + /// values set-once (mirror of the registry self-deploying fact), the profile reason + /// carries the "no Device-ESP phase expected" expectation, and the signal census folds the + /// positive marker into terminal audit trails. The engine deliberately has no DeviceSetup + /// arrival requirement, so no behavioural gate is added — the observation is the + /// engine-side context that a session starting at Account Setup is the expected shape. + /// + public sealed class CloudPcObservationTests + { + private static readonly DateTime T0 = new DateTime(2026, 8, 7, 10, 0, 0, DateTimeKind.Utc); + + private static DecisionSignal MakeSignal( + long ordinal, + DecisionSignalKind kind, + DateTime occurredAtUtc, + IReadOnlyDictionary? payload = null) => + new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: kind, + kindSchemaVersion: 1, + occurredAtUtc: occurredAtUtc, + sourceOrigin: "test", + evidence: new Evidence(EvidenceKind.Synthetic, $"t-{kind}-{ordinal}", $"synthetic {kind}"), + payload: payload); + + private static DecisionState ReduceFacts(DecisionEngine engine, DecisionState state, long ordinal, string isCloudPc) + => engine.Reduce(state, MakeSignal(ordinal, DecisionSignalKind.EnrollmentFactsObserved, T0.AddSeconds(ordinal), + new Dictionary + { + [SignalPayloadKeys.EnrollmentType] = "v1", + [SignalPayloadKeys.IsCloudPc] = isCloudPc, + })).NewState; + + [Fact] + public void EnrollmentFactsObserved_true_recordsObservation_andExpectationReason() + { + var engine = new DecisionEngine(); + var state = engine.Reduce( + DecisionState.CreateInitial("cpc-1", "t", T0), + MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + + state = ReduceFacts(engine, state, 1, "true"); + + Assert.NotNull(state.ScenarioObservations.CloudPc); + Assert.True(state.ScenarioObservations.CloudPc!.Value); + Assert.Equal(1, state.ScenarioObservations.CloudPc!.SourceSignalOrdinal); + Assert.Equal( + EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, + state.ScenarioProfile.Reason); + // Stage-agnostic fact signal — the reducer must not move the session anywhere. + Assert.Equal(SessionStage.SessionStarted, state.Stage); + } + + [Fact] + public void EnrollmentFactsObserved_false_recordsObservation_setOnce() + { + var engine = new DecisionEngine(); + var state = engine.Reduce( + DecisionState.CreateInitial("cpc-2", "t", T0), + MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + + state = ReduceFacts(engine, state, 1, "false"); + Assert.NotNull(state.ScenarioObservations.CloudPc); + Assert.False(state.ScenarioObservations.CloudPc!.Value); + + // Set-once: a later contradicting re-read must not overwrite the first sighting + // (same contract as RegistrySelfDeployingProfile — replay-deterministic evidence). + state = ReduceFacts(engine, state, 2, "true"); + Assert.False(state.ScenarioObservations.CloudPc!.Value); + Assert.Equal(1, state.ScenarioObservations.CloudPc!.SourceSignalOrdinal); + } + + [Fact] + public void Census_foldsPositiveMarker_only() + { + var engine = new DecisionEngine(); + var initial = engine.Reduce( + DecisionState.CreateInitial("cpc-3", "t", T0), + MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + + var cloudPcState = ReduceFacts(engine, initial, 1, "true"); + var census = DecisionStateSignalCensus.Build(cloudPcState); + Assert.Contains("cloud_pc_marker", census.SignalsSeen); + Assert.True(census.SignalEvidence.ContainsKey("cloudPcMarker")); + + var physicalState = ReduceFacts(engine, initial, 1, "false"); + var physicalCensus = DecisionStateSignalCensus.Build(physicalState); + Assert.DoesNotContain("cloud_pc_marker", physicalCensus.SignalsSeen); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs index 6fd601eb..cb464725 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/SerializationTests.cs @@ -490,7 +490,8 @@ namespace AutopilotMonitor.DecisionCore.Tests .WithShellCoreWhiteGloveSuccessSeen(sourceSignalOrdinal: 3) .WithSkipUserEsp(value: true, sourceSignalOrdinal: 4) .WithEspSyncFailureTimeoutMinutes(value: 90, sourceSignalOrdinal: 5) - .WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6)) + .WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6) + .WithCloudPc(value: true, sourceSignalOrdinal: 7)) .Build(); var json = StateSerializer.Serialize(state); @@ -504,6 +505,8 @@ namespace AutopilotMonitor.DecisionCore.Tests Assert.Equal(5, o.EspSyncFailureTimeoutMinutes!.SourceSignalOrdinal); Assert.False(o.RegistrySelfDeployingProfile!.Value); Assert.Equal(6, o.RegistrySelfDeployingProfile!.SourceSignalOrdinal); + Assert.True(o.CloudPc!.Value); + Assert.Equal(7, o.CloudPc!.SourceSignalOrdinal); // Never-observed facts must rehydrate as null (null ≠ veto — session 62e603c9). Assert.Null(o.WhiteGloveSealingPatternSeen); diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs index ede25da0..b17d9290 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/State/EnrollmentScenarioProfileUpdaterTests.cs @@ -515,6 +515,99 @@ namespace AutopilotMonitor.DecisionCore.Tests.State // ================================================================== helpers + // ------------------------------------------------ isCloudPc annotation (W365) + // Cloud PC first connect: Device-ESP already ran headless at provisioning, so the + // profile records the "no Device-ESP phase expected" expectation as the reason. + // Reason-only by design — Mode is classified by AccountSetup/IME signals (Classic), + // and EspConfig keeps the CONFIGURED FirstSync semantics. + + [Fact] + public void EnrollmentFactsObserved_cloudPc_annotatesReason_keepsModeUnknown() + { + var signal = MakeSignal( + DecisionSignalKind.EnrollmentFactsObserved, + ordinal: 0, + payload: new Dictionary + { + [SignalPayloadKeys.EnrollmentType] = "v1", + [SignalPayloadKeys.IsHybridJoin] = "false", + [SignalPayloadKeys.IsCloudPc] = "true", + }); + + var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved( + EnrollmentScenarioProfile.Empty, signal); + + Assert.Equal(EnrollmentMode.Unknown, profile.Mode); + Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason); + Assert.Equal(EnrollmentJoinMode.AzureAdJoin, profile.JoinMode); + Assert.Equal(EspConfig.Unknown, profile.EspConfig); + } + + [Fact] + public void EnrollmentFactsObserved_cloudPc_repeatPost_isIdempotent() + { + Dictionary Facts() => new Dictionary + { + [SignalPayloadKeys.EnrollmentType] = "v1", + [SignalPayloadKeys.IsHybridJoin] = "false", + [SignalPayloadKeys.IsCloudPc] = "true", + }; + var first = MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 0, payload: Facts()); + var second = MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 9, payload: Facts()); + + var p1 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved( + EnrollmentScenarioProfile.Empty, first); + var p2 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(p1, second); + + // The repeat must be a full no-op: the Cloud PC annotation already owns the + // reason slot, and the cloudPcAnnotates gate keeps the generic v1 annotation + // from re-claiming it (which would bump EvidenceOrdinal every post). + Assert.Same(p1, p2); + Assert.Equal(0, p2.EvidenceOrdinal); + } + + [Fact] + public void EnrollmentFactsObserved_cloudPcFalse_keepsGenericV1Reason() + { + var signal = MakeSignal( + DecisionSignalKind.EnrollmentFactsObserved, + ordinal: 0, + payload: new Dictionary + { + [SignalPayloadKeys.EnrollmentType] = "v1", + [SignalPayloadKeys.IsCloudPc] = "false", + }); + + var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved( + EnrollmentScenarioProfile.Empty, signal); + + Assert.Equal("enrollment_facts_observed:v1", profile.Reason); + } + + [Fact] + public void EnrollmentFactsObserved_cloudPc_doesNotDisturbSelfDeployingSeed() + { + // Defensive combination (not expected in the field — Cloud PCs are never + // Autopilot-registered): the deterministic self-deploying Mode seed must + // survive; only the reason slot goes to the more specific annotation. + var signal = MakeSignal( + DecisionSignalKind.EnrollmentFactsObserved, + ordinal: 0, + payload: new Dictionary + { + [SignalPayloadKeys.EnrollmentType] = "v1", + [SignalPayloadKeys.IsSelfDeployingProfile] = "true", + [SignalPayloadKeys.IsCloudPc] = "true", + }); + + var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved( + EnrollmentScenarioProfile.Empty, signal); + + Assert.Equal(EnrollmentMode.SelfDeploying, profile.Mode); + Assert.Equal(ProfileConfidence.High, profile.Confidence); + Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason); + } + private static DecisionSignal MakeSignal( DecisionSignalKind kind, long ordinal, diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/CloudPcDeviceValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/CloudPcDeviceValidatorTests.cs new file mode 100644 index 00000000..e580fa2d --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/CloudPcDeviceValidatorTests.cs @@ -0,0 +1,117 @@ +using AutopilotMonitor.Functions.Security; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for — focused on pure-function pieces: +/// JSON-to-DTO mapping (incl. exact-match guard on managedDeviceId) and cache key shape. +/// The HTTP/cache/retry resilience is mirrored 1:1 from AutopilotDeviceValidator; +/// behavioural drift would surface here. Response shapes match a real +/// virtualEndpoint/cloudPCs payload (field-captured 2026-08-06 on a W365 tenant). +/// +public class CloudPcDeviceValidatorTests +{ + private const string TenantId = "11111111-1111-1111-1111-111111111111"; + private const string DeviceId = "07623d56-1e77-4948-bff5-5bdac8167560"; + + // -- BuildCacheKey -- + + [Fact] + public void BuildCacheKey_StableShape() + { + var key = CloudPcDeviceValidator.BuildCacheKey(TenantId, DeviceId); + Assert.Equal($"cloudpc-device-validation:{TenantId}:{DeviceId}", key); + } + + [Fact] + public void BuildCacheKey_DistinctFromOtherValidatorKeys() + { + // The validators must NOT share cache keys; otherwise a positive hit in one + // would incorrectly satisfy a lookup in another. + var key = CloudPcDeviceValidator.BuildCacheKey(TenantId, DeviceId); + Assert.StartsWith("cloudpc-device-validation:", key); + Assert.DoesNotContain("autopilot", key); + Assert.DoesNotContain("device-association", key); + } + + // -- ParseCloudPcResponse: empty / missing / malformed -- + + [Fact] + public void Parse_EmptyValueArray_NotFound() + { + var result = CloudPcDeviceValidator.ParseCloudPcResponse("{\"value\":[]}", DeviceId); + Assert.False(result.IsValid); + Assert.False(result.IsTransient); + Assert.Equal(DeviceId, result.IntuneDeviceId); + Assert.Contains("not a Windows 365 Cloud PC", result.ErrorMessage); + } + + [Fact] + public void Parse_MissingValueProperty_NotFound() + { + var result = CloudPcDeviceValidator.ParseCloudPcResponse("{}", DeviceId); + Assert.False(result.IsValid); + } + + [Fact] + public void Parse_MalformedJson_NotFound_NotTransient() + { + // Defensive: bad payload is treated as "not a Cloud PC", not as a transient + // — transient handling is only triggered by HTTP-level failures. + var result = CloudPcDeviceValidator.ParseCloudPcResponse("this is not json", DeviceId); + Assert.False(result.IsValid); + Assert.False(result.IsTransient); + } + + // -- ParseCloudPcResponse: exact-match guard -- + + [Fact] + public void Parse_DifferentManagedDeviceId_NotFound() + { + // Guards against widened filter semantics: even if Graph returns Cloud PCs whose + // managedDeviceId does not exactly match the queried id, we must reject. + var body = "{\"value\":[{\"id\":\"423a1fb4-6033-4b69-a31f-e7f3f54194dc\",\"managedDeviceId\":\"99999999-9999-9999-9999-999999999999\"}]}"; + var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId); + Assert.False(result.IsValid); + } + + [Fact] + public void Parse_ExactMatch_PopulatesAllFields() + { + var body = $@"{{ + ""value"":[{{ + ""id"":""423a1fb4-6033-4b69-a31f-e7f3f54194dc"", + ""displayName"":""GKT-CloudPC - Obi-Wan Kenobi"", + ""managedDeviceId"":""{DeviceId}"", + ""managedDeviceName"":""CPC-cloud-AY5HT"", + ""userPrincipalName"":""cloudadmin@gktatooine.net"", + ""servicePlanName"":""Cloud PC Enterprise 2vCPU/4GB/128GB"" + }}] + }}"; + + var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId); + + Assert.True(result.IsValid); + Assert.False(result.IsTransient); + Assert.Equal(DeviceId, result.IntuneDeviceId); + Assert.Equal("423a1fb4-6033-4b69-a31f-e7f3f54194dc", result.CloudPcId); + Assert.Equal("CPC-cloud-AY5HT", result.ManagedDeviceName); + Assert.Equal("Cloud PC Enterprise 2vCPU/4GB/128GB", result.ServicePlanName); + } + + [Fact] + public void Parse_CaseInsensitiveIdMatch() + { + var body = $@"{{""value"":[{{""id"":""423a1fb4-6033-4b69-a31f-e7f3f54194dc"",""managedDeviceId"":""{DeviceId.ToUpperInvariant()}""}}]}}"; + var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId); + Assert.True(result.IsValid); + } + + [Fact] + public void Parse_TrimsWhitespace_OnGraphIdBeforeMatching() + { + var body = $@"{{""value"":[{{""id"":""423a1fb4-6033-4b69-a31f-e7f3f54194dc"",""managedDeviceId"":"" {DeviceId} ""}}]}}"; + var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId); + Assert.True(result.IsValid); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GraphFeatureCatalogSyncTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GraphFeatureCatalogSyncTests.cs new file mode 100644 index 00000000..ddc338aa --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GraphFeatureCatalogSyncTests.cs @@ -0,0 +1,79 @@ +using System.Text.RegularExpressions; +using AutopilotMonitor.Shared.Models.Graph; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Guard: the customer-side grant script (scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1) +/// duplicates the feature→permission catalog from — in its +/// $FeatureCatalog hashtable AND in the [ValidateSet(...)] of -Features. +/// The sync was previously comment-only ("Keep in lock-step with …") and had no enforcement; +/// this test fails the build when either side drifts. +/// +public class GraphFeatureCatalogSyncTests +{ + [Fact] + public void GrantScript_FeatureCatalog_MatchesSharedCatalog() + { + var script = ReadGrantScript(); + + // Parse: $FeatureCatalog = @{ 'Name' = @('Perm', ...) ... } + var blockMatch = Regex.Match(script, @"\$FeatureCatalog\s*=\s*@\{(?[^}]*)\}", RegexOptions.Singleline); + Assert.True(blockMatch.Success, "Could not locate the $FeatureCatalog hashtable in the grant script."); + + var scriptCatalog = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (Match entry in Regex.Matches(blockMatch.Groups["body"].Value, @"'(?[^']+)'\s*=\s*@\((?[^)]*)\)")) + { + var perms = Regex.Matches(entry.Groups["perms"].Value, @"'(?

[^']+)'") + .Select(m => m.Groups["p"].Value) + .ToArray(); + scriptCatalog[entry.Groups["feature"].Value] = perms; + } + + Assert.Equal( + GraphFeatureCatalog.Features.OrderBy(f => f, StringComparer.OrdinalIgnoreCase), + scriptCatalog.Keys.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)); + + foreach (var feature in GraphFeatureCatalog.Features) + { + Assert.Equal( + GraphFeatureCatalog.RequiredPermissions(feature).OrderBy(p => p, StringComparer.OrdinalIgnoreCase), + scriptCatalog[feature].OrderBy(p => p, StringComparer.OrdinalIgnoreCase)); + } + } + + [Fact] + public void GrantScript_ValidateSet_MatchesSharedCatalog() + { + var script = ReadGrantScript(); + + var vsMatch = Regex.Match(script, @"\[ValidateSet\((?[^\)]*)\)\]"); + Assert.True(vsMatch.Success, "Could not locate the [ValidateSet(...)] of -Features in the grant script."); + + var scriptFeatures = Regex.Matches(vsMatch.Groups["body"].Value, @"'(?[^']+)'") + .Select(m => m.Groups["f"].Value) + .ToArray(); + + Assert.Equal( + GraphFeatureCatalog.Features.OrderBy(f => f, StringComparer.OrdinalIgnoreCase), + scriptFeatures.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)); + } + + private static string ReadGrantScript() + { + var path = Path.Combine(FindRepoRoot(), "scripts", "CustomerSetup", "Grant-AutopilotMonitorAddOn.ps1"); + Assert.True(File.Exists(path), $"Grant script not found at {path}"); + return File.ReadAllText(path); + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln"))) + { + dir = dir.Parent; + } + Assert.NotNull(dir); + return dir!.FullName; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SecurityValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SecurityValidatorTests.cs index b992f089..aafa4d9b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SecurityValidatorTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SecurityValidatorTests.cs @@ -90,4 +90,55 @@ public class SecurityValidatorTests { Assert.False(SecurityValidator.IsValidGuid(payload)); } + + // --- Intune device id extraction from the MDM client certificate subject --- + // Intune MDM Device CA certs carry the Intune managedDevice id as the Subject CN + // (field-verified 2026-08-06 on a W365 Cloud PC). This is the identity anchor for the + // CloudPc validator, so the parse must be strict: CN present AND a canonical GUID. + + [Fact] + public void TryGetIntuneDeviceId_PlainCnGuid_Extracts() + { + Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject( + "CN=07623d56-1e77-4948-bff5-5bdac8167560", out var id)); + Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id); + } + + [Fact] + public void TryGetIntuneDeviceId_UppercaseGuid_NormalizedToLower() + { + Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject( + "CN=07623D56-1E77-4948-BFF5-5BDAC8167560", out var id)); + Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id); + } + + [Fact] + public void TryGetIntuneDeviceId_MultiRdnSubject_FindsCn() + { + Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject( + "O=Contoso, CN=07623d56-1e77-4948-bff5-5bdac8167560, C=DE", out var id)); + Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("O=Contoso, C=DE")] // no CN at all + [InlineData("CN=not-a-guid")] // CN present but not a GUID + [InlineData("CN=hostname.contoso.com")] // typical non-Intune device cert + [InlineData("CN=' or '1'='1")] // injection payload never survives + public void TryGetIntuneDeviceId_InvalidSubjects_ReturnFalse(string? subject) + { + Assert.False(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(subject, out var id)); + Assert.Null(id); + } + + [Fact] + public void TryGetIntuneDeviceId_NonGuidFirstCn_IsDefinitive() + { + // A first CN that is not a GUID means "not an Intune MDM device cert shape" — a + // later GUID-valued CN must NOT resurrect the parse (no CN-shopping). + Assert.False(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject( + "CN=hostname, CN=07623d56-1e77-4948-bff5-5bdac8167560", out _)); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionIndexProjectionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionIndexProjectionTests.cs index 1aaaf854..8bab9cd7 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SessionIndexProjectionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionIndexProjectionTests.cs @@ -43,6 +43,8 @@ public class SessionIndexProjectionTests ["ExcessiveEventsAutoActioned"] = true, // Self-deploying/kiosk profile marker (session 320b3bf7) — search-filterable. ["IsSelfDeployingProfile"] = true, + // W365 Cloud PC marker (agent-detected, sticky-true) — search-filterable. + ["IsCloudPc"] = true, // Device-validation path recorded at registration — shown on the session detail page. ["ValidatedBy"] = "AutopilotV1", // Agent→backend HTTP latency projection (UpdateSessionNetworkLatencyAsync merges these). @@ -72,6 +74,7 @@ public class SessionIndexProjectionTests Assert.True(idx.GetBoolean("ExcessiveEventsAlerted")); Assert.True(idx.GetBoolean("ExcessiveEventsAutoActioned")); Assert.True(idx.GetBoolean("IsSelfDeployingProfile")); + Assert.True(idx.GetBoolean("IsCloudPc")); Assert.Equal("AutopilotV1", idx.GetString("ValidatedBy")); Assert.Equal(513.5, idx.GetDouble("AvgApiLatencyMs")); Assert.Equal(100, idx.GetInt32("ApiRequestCount")); @@ -103,6 +106,7 @@ public class SessionIndexProjectionTests Assert.Equal(0, idx.GetInt32("RebootCount")); Assert.False(idx.GetBoolean("ExcessiveEventsAlerted")); Assert.False(idx.GetBoolean("IsSelfDeployingProfile")); + Assert.False(idx.GetBoolean("IsCloudPc")); Assert.Equal(string.Empty, idx.GetString("ImeAgentVersion")); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/StoreSessionReregistrationPreserveTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/StoreSessionReregistrationPreserveTests.cs index a38b0864..9d885033 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/StoreSessionReregistrationPreserveTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/StoreSessionReregistrationPreserveTests.cs @@ -168,6 +168,69 @@ public class StoreSessionReregistrationPreserveTests Assert.True(harness.Written!.GetBoolean("IsSelfDeployingProfile")); } + [Fact] + public async Task StoreSessionAsync_cloudPc_isStickyTrue_acrossReregistration() + { + // W365 marker mirrors the self-deploying sticky-true contract: once a boot observed + // the Cloud PC markers, a later re-registration whose registry/service probe degraded + // (registration.IsCloudPc=false) must NOT clear the fact. + var existing = new TableEntity(TenantId, SessionId) + { + ["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)), + ["Status"] = "InProgress", + ["IsCloudPc"] = true, + }; + existing.ETag = new ETag("0xEXISTING"); + var harness = new Harness(existing); + + var registration = new SessionRegistration + { + TenantId = TenantId, + SessionId = SessionId, + StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc), + SerialNumber = "SN-1", + Manufacturer = "Contoso", + Model = "Model-X", + DeviceName = "PC-1", + IsCloudPc = false, // fresh marker read degraded + }; + + var ok = await harness.Sut.StoreSessionAsync(registration); + + Assert.True(ok); + Assert.True(harness.Written!.GetBoolean("IsCloudPc")); + } + + [Fact] + public async Task StoreSessionAsync_cloudPc_upgradesFalseToTrue_onReregistration() + { + var existing = new TableEntity(TenantId, SessionId) + { + ["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)), + ["Status"] = "InProgress", + ["IsCloudPc"] = false, + }; + existing.ETag = new ETag("0xEXISTING"); + var harness = new Harness(existing); + + var registration = new SessionRegistration + { + TenantId = TenantId, + SessionId = SessionId, + StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc), + SerialNumber = "SN-1", + Manufacturer = "Contoso", + Model = "Model-X", + DeviceName = "PC-1", + IsCloudPc = true, + }; + + var ok = await harness.Sut.StoreSessionAsync(registration); + + Assert.True(ok); + Assert.True(harness.Written!.GetBoolean("IsCloudPc")); + } + // ============================================================ Harness ==== ///

diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index 0862a117..7180f442 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -603,6 +603,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "ValidateAutopilotDevice", config.ValidateAutopilotDevice }, { "ValidateCorporateIdentifier", config.ValidateCorporateIdentifier }, { "ValidateDeviceAssociation", config.ValidateDeviceAssociation }, + { "ValidateCloudPcDevice", config.ValidateCloudPcDevice }, { "AllowInsecureAgentRequests", config.AllowInsecureAgentRequests }, { "DataRetentionDays", config.DataRetentionDays }, { "SessionTimeoutHours", config.SessionTimeoutHours }, @@ -704,6 +705,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage ValidateAutopilotDevice = entity.GetBoolean("ValidateAutopilotDevice") ?? entity.GetBoolean("ValidateSerialNumber") ?? false, ValidateCorporateIdentifier = entity.GetBoolean("ValidateCorporateIdentifier") ?? false, ValidateDeviceAssociation = entity.GetBoolean("ValidateDeviceAssociation") ?? false, + ValidateCloudPcDevice = entity.GetBoolean("ValidateCloudPcDevice") ?? 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 2a26c5a6..a136a79b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -25,6 +25,7 @@ namespace AutopilotMonitor.Functions.Functions.Config private readonly AutopilotDeviceValidator _autopilotDeviceValidator; private readonly CorporateIdentifierValidator _corporateIdentifierValidator; private readonly DeviceAssociationValidator _deviceAssociationValidator; + private readonly CloudPcDeviceValidator _cloudPcDeviceValidator; private readonly BootstrapSessionService _bootstrapSessionService; private readonly KillSwitchEvaluator _killSwitchEvaluator; @@ -38,6 +39,7 @@ namespace AutopilotMonitor.Functions.Functions.Config AutopilotDeviceValidator autopilotDeviceValidator, CorporateIdentifierValidator corporateIdentifierValidator, DeviceAssociationValidator deviceAssociationValidator, + CloudPcDeviceValidator cloudPcDeviceValidator, BootstrapSessionService bootstrapSessionService, KillSwitchEvaluator killSwitchEvaluator) { @@ -50,6 +52,7 @@ namespace AutopilotMonitor.Functions.Functions.Config _autopilotDeviceValidator = autopilotDeviceValidator; _corporateIdentifierValidator = corporateIdentifierValidator; _deviceAssociationValidator = deviceAssociationValidator; + _cloudPcDeviceValidator = cloudPcDeviceValidator; _bootstrapSessionService = bootstrapSessionService; _killSwitchEvaluator = killSwitchEvaluator; } @@ -84,7 +87,8 @@ namespace AutopilotMonitor.Functions.Functions.Config _corporateIdentifierValidator, _logger, bootstrapSessionService: _bootstrapSessionService, - deviceAssociationValidator: _deviceAssociationValidator + deviceAssociationValidator: _deviceAssociationValidator, + cloudPcDeviceValidator: _cloudPcDeviceValidator ); if (errorResponse != null) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs index 6a921faa..51a04126 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs @@ -52,6 +52,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics private readonly AutopilotDeviceValidator _autopilotDeviceValidator; private readonly CorporateIdentifierValidator _corporateIdentifierValidator; private readonly DeviceAssociationValidator _deviceAssociationValidator; + private readonly CloudPcDeviceValidator _cloudPcDeviceValidator; private readonly BootstrapSessionService _bootstrapSessionService; private readonly HostedDiagnosticsBlobService _hostedDiagnostics; @@ -63,6 +64,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics AutopilotDeviceValidator autopilotDeviceValidator, CorporateIdentifierValidator corporateIdentifierValidator, DeviceAssociationValidator deviceAssociationValidator, + CloudPcDeviceValidator cloudPcDeviceValidator, BootstrapSessionService bootstrapSessionService, HostedDiagnosticsBlobService hostedDiagnostics) { @@ -73,6 +75,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics _autopilotDeviceValidator = autopilotDeviceValidator; _corporateIdentifierValidator = corporateIdentifierValidator; _deviceAssociationValidator = deviceAssociationValidator; + _cloudPcDeviceValidator = cloudPcDeviceValidator; _bootstrapSessionService = bootstrapSessionService; _hostedDiagnostics = hostedDiagnostics; } @@ -123,7 +126,8 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics _corporateIdentifierValidator, _logger, bootstrapSessionService: _bootstrapSessionService, - deviceAssociationValidator: _deviceAssociationValidator + deviceAssociationValidator: _deviceAssociationValidator, + cloudPcDeviceValidator: _cloudPcDeviceValidator ); if (errorResponse != null) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Graph/GetGraphPermissionsStatusFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Graph/GetGraphPermissionsStatusFunction.cs index 18929db4..133afe39 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Graph/GetGraphPermissionsStatusFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Graph/GetGraphPermissionsStatusFunction.cs @@ -125,6 +125,9 @@ public class GetGraphPermissionsStatusFunction ["ScriptDisplayNamesGranted"] = GraphFeatureCatalog .IsFeatureGranted(GraphFeatureCatalog.FeatureScriptDisplayNames, snapshot.GrantedRoles) .ToString(CultureInfo.InvariantCulture), + ["W365CloudPcValidationGranted"] = GraphFeatureCatalog + .IsFeatureGranted(GraphFeatureCatalog.FeatureW365CloudPcValidation, snapshot.GrantedRoles) + .ToString(CultureInfo.InvariantCulture), }); } catch (Exception ex) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs index 69ff3a0f..4b65373c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs @@ -49,6 +49,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest private readonly AutopilotDeviceValidator _autopilotDeviceValidator; private readonly CorporateIdentifierValidator _corporateIdentifierValidator; private readonly DeviceAssociationValidator _deviceAssociationValidator; + private readonly CloudPcDeviceValidator _cloudPcDeviceValidator; private readonly BootstrapSessionService _bootstrapSessionService; private readonly KillSwitchEvaluator _killSwitchEvaluator; private readonly SessionDeletionGuard _deletionGuard; @@ -66,6 +67,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest AutopilotDeviceValidator autopilotDeviceValidator, CorporateIdentifierValidator corporateIdentifierValidator, DeviceAssociationValidator deviceAssociationValidator, + CloudPcDeviceValidator cloudPcDeviceValidator, BootstrapSessionService bootstrapSessionService, KillSwitchEvaluator killSwitchEvaluator, SessionDeletionGuard deletionGuard) @@ -82,6 +84,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest _autopilotDeviceValidator = autopilotDeviceValidator; _corporateIdentifierValidator = corporateIdentifierValidator; _deviceAssociationValidator = deviceAssociationValidator; + _cloudPcDeviceValidator = cloudPcDeviceValidator; _bootstrapSessionService = bootstrapSessionService; _killSwitchEvaluator = killSwitchEvaluator; _deletionGuard = deletionGuard; @@ -111,7 +114,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest _corporateIdentifierValidator, _logger, bootstrapSessionService: _bootstrapSessionService, - deviceAssociationValidator: _deviceAssociationValidator); + deviceAssociationValidator: _deviceAssociationValidator, + cloudPcDeviceValidator: _cloudPcDeviceValidator); 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 220245a8..c27a4e0c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/ReportAgentErrorFunction.cs @@ -34,6 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest private readonly AutopilotDeviceValidator _autopilotDeviceValidator; private readonly CorporateIdentifierValidator _corporateIdentifierValidator; private readonly DeviceAssociationValidator _deviceAssociationValidator; + private readonly CloudPcDeviceValidator _cloudPcDeviceValidator; private readonly TelemetryClient _telemetryClient; private readonly BootstrapSessionService _bootstrapSessionService; private readonly ISessionRepository _sessionRepo; @@ -47,6 +48,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest AutopilotDeviceValidator autopilotDeviceValidator, CorporateIdentifierValidator corporateIdentifierValidator, DeviceAssociationValidator deviceAssociationValidator, + CloudPcDeviceValidator cloudPcDeviceValidator, TelemetryClient telemetryClient, BootstrapSessionService bootstrapSessionService, ISessionRepository sessionRepo, @@ -59,6 +61,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest _autopilotDeviceValidator = autopilotDeviceValidator; _corporateIdentifierValidator = corporateIdentifierValidator; _deviceAssociationValidator = deviceAssociationValidator; + _cloudPcDeviceValidator = cloudPcDeviceValidator; _telemetryClient = telemetryClient; _bootstrapSessionService = bootstrapSessionService; _sessionRepo = sessionRepo; @@ -94,7 +97,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest _corporateIdentifierValidator, _logger, bootstrapSessionService: _bootstrapSessionService, - deviceAssociationValidator: _deviceAssociationValidator + deviceAssociationValidator: _deviceAssociationValidator, + cloudPcDeviceValidator: _cloudPcDeviceValidator ); if (errorResponse != null) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs index 23e62d85..3610e330 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs @@ -92,6 +92,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw var isPreProvisioned = query["isPreProvisioned"]; var isHybridJoin = query["isHybridJoin"]; var isSelfDeployingProfile = query["isSelfDeployingProfile"]; + var isCloudPc = query["isCloudPc"]; var fieldsParam = query["fields"]; var pagination = SearchSessionsPagination.ParsePagination(query); @@ -128,6 +129,8 @@ namespace AutopilotMonitor.Functions.Functions.Raw filter.IsHybridJoin = hj; if (!string.IsNullOrEmpty(isSelfDeployingProfile) && bool.TryParse(isSelfDeployingProfile, out var sdp)) filter.IsSelfDeployingProfile = sdp; + if (!string.IsNullOrEmpty(isCloudPc) && bool.TryParse(isCloudPc, out var icp)) + filter.IsCloudPc = icp; if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin; if (int.TryParse(query["rebootCountMax"], out var rcMax)) filter.RebootCountMax = rcMax; var connectionType = query["connectionType"]; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs index 89fd4580..7caaa35c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs @@ -30,6 +30,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions private readonly AutopilotDeviceValidator _autopilotDeviceValidator; private readonly CorporateIdentifierValidator _corporateIdentifierValidator; private readonly DeviceAssociationValidator _deviceAssociationValidator; + private readonly CloudPcDeviceValidator _cloudPcDeviceValidator; private readonly BootstrapSessionService _bootstrapSessionService; private readonly WebhookNotificationService _webhookNotificationService; private readonly SessionDeletionGuard _deletionGuard; @@ -45,6 +46,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions AutopilotDeviceValidator autopilotDeviceValidator, CorporateIdentifierValidator corporateIdentifierValidator, DeviceAssociationValidator deviceAssociationValidator, + CloudPcDeviceValidator cloudPcDeviceValidator, BootstrapSessionService bootstrapSessionService, WebhookNotificationService webhookNotificationService, SessionDeletionGuard deletionGuard) @@ -58,6 +60,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions _autopilotDeviceValidator = autopilotDeviceValidator; _corporateIdentifierValidator = corporateIdentifierValidator; _deviceAssociationValidator = deviceAssociationValidator; + _cloudPcDeviceValidator = cloudPcDeviceValidator; _bootstrapSessionService = bootstrapSessionService; _webhookNotificationService = webhookNotificationService; _deletionGuard = deletionGuard; @@ -101,7 +104,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions _logger, registration.SessionId, bootstrapSessionService: _bootstrapSessionService, - deviceAssociationValidator: _deviceAssociationValidator + deviceAssociationValidator: _deviceAssociationValidator, + cloudPcDeviceValidator: _cloudPcDeviceValidator ); if (errorResponse2 != null) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs index 686509af..440de775 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs @@ -147,6 +147,7 @@ public class SearchSessionsFunction if (bool.TryParse(query["isPreProvisioned"], out var ipp)) filter.IsPreProvisioned = ipp; if (bool.TryParse(query["isHybridJoin"], out var ihj)) filter.IsHybridJoin = ihj; if (bool.TryParse(query["isSelfDeployingProfile"], out var isdp)) filter.IsSelfDeployingProfile = isdp; + if (bool.TryParse(query["isCloudPc"], out var icp)) filter.IsCloudPc = icp; if (DateTime.TryParse(query["startedAfter"], out var sa)) filter.StartedAfter = sa; if (DateTime.TryParse(query["startedBefore"], out var sb)) filter.StartedBefore = sb; if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin; @@ -207,6 +208,7 @@ public class SearchSessionsFunction if (fields.Contains("isUserDriven")) dict["isUserDriven"] = s.IsUserDriven; if (fields.Contains("isHybridJoin")) dict["isHybridJoin"] = s.IsHybridJoin; if (fields.Contains("isSelfDeployingProfile")) dict["isSelfDeployingProfile"] = s.IsSelfDeployingProfile; + if (fields.Contains("isCloudPc")) dict["isCloudPc"] = s.IsCloudPc; if (fields.Contains("agentVersion")) dict["agentVersion"] = s.AgentVersion; if (fields.Contains("imeAgentVersion")) dict["imeAgentVersion"] = s.ImeAgentVersion; if (fields.Contains("geoCountry")) dict["geoCountry"] = s.GeoCountry; diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs index 38426202..71296960 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs @@ -48,6 +48,7 @@ namespace AutopilotMonitor.Functions.Pagination new KeyValuePair("isPreProvisioned", filter.IsPreProvisioned?.ToString()), new KeyValuePair("isHybridJoin", filter.IsHybridJoin?.ToString()), new KeyValuePair("isSelfDeployingProfile", filter.IsSelfDeployingProfile?.ToString()), + new KeyValuePair("isCloudPc", filter.IsCloudPc?.ToString()), new KeyValuePair("geoCountry", filter.GeoCountry), new KeyValuePair("startedAfter", filter.StartedAfter?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)), new KeyValuePair("startedBefore", filter.StartedBefore?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)), diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 779b1ebf..9d8d1aee 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -394,6 +394,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/CloudPcDeviceValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs new file mode 100644 index 00000000..903d657a --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs @@ -0,0 +1,278 @@ +using AutopilotMonitor.Shared; +using System; +using System.Linq; +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 +{ + /// + /// Validates Windows 365 Cloud PCs via the service-side Cloud PC inventory + /// (virtualEndpoint/cloudPCs). Cloud PCs are structurally never + /// Autopilot-registered, so instead of a serial lookup this validator binds the + /// device identity to the MDM client certificate: the caller passes the Intune + /// device id extracted from the chain-validated cert's Subject CN, and the device + /// is valid only when the tenant has a cloudPC object whose + /// managedDeviceId equals that id. Only machines provisioned by the + /// Windows 365 service have such an object — no other enrolled device can pass. + /// + /// Built as a near-sibling of — same DI + /// dependencies, same retry/cache/transient contract: + /// - 30 min positive cache, 5 min negative cache (transient failures NOT cached) + /// - 2 attempts with 2s back-off between attempts + /// - on Graph 5xx, token failures, exceptions + /// Requires the optional Graph permission CloudPC.Read.All (a 403 from Graph is + /// treated as definitive "not granted", not transient — see TryValidateViaGraphAsync). + /// + public class CloudPcDeviceValidator + { + 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 CloudPcDeviceValidator( + ILogger logger, + IHttpClientFactory httpClientFactory, + IMemoryCache cache, + GraphTokenService graphTokenService) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _cache = cache; + _graphTokenService = graphTokenService; + } + + public async Task ValidateCloudPcAsync( + string tenantId, + string? intuneDeviceId, + string? sessionId = null) + { + // The id is interpolated into an OData filter — a strict GUID gate is the + // injection defense (same rule as SecurityValidator.IsValidGuid everywhere else). + if (!SecurityValidator.IsValidGuid(intuneDeviceId)) + { + return new CloudPcValidationResult + { + IsValid = false, + 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 CloudPcValidationResult? cached) && cached != null) + { + return cached; + } + + const int maxAttempts = 2; + CloudPcValidationResult? lastTransient = null; + + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + var result = await TryValidateViaGraphAsync(tenantId, normalizedId, sessionId, cacheKey, attempt); + + if (result.IsValid || !result.IsTransient) + return result; + + lastTransient = result; + if (attempt < maxAttempts) + { + _logger.LogWarning( + "Cloud PC validation transient failure for tenant {TenantId}, deviceId {IntuneDeviceId} (attempt {Attempt}/{MaxAttempts}). Retrying...", + tenantId, normalizedId, attempt, maxAttempts); + await Task.Delay(TimeSpan.FromSeconds(2)); + } + } + + _logger.LogWarning( + "Cloud PC validation failed after {MaxAttempts} attempts for tenant {TenantId}, deviceId {IntuneDeviceId}", + maxAttempts, tenantId, normalizedId); + + 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 CloudPcValidationResult + { + IsValid = false, + IsTransient = true, + IntuneDeviceId = normalizedId, + ErrorMessage = "Graph access token could not be acquired" + }; + } + + var graphClient = _httpClientFactory.CreateClient(); + graphClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult.AccessToken); + + // eq on managedDeviceId is supported server-side (field-verified 2026-08-06 on a + // real W365 tenant). normalizedId passed the strict GUID gate above, so the + // filter literal cannot carry injection payloads. + var filter = Uri.EscapeDataString($"managedDeviceId eq '{normalizedId}'"); + var graphUrl = $"{Constants.GraphBaseUrl}/v1.0/deviceManagement/virtualEndpoint/cloudPCs" + + $"?$select=id,displayName,managedDeviceId,managedDeviceName,servicePlanName&$filter={filter}"; + + var response = await graphClient.GetAsync(graphUrl); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "Cloud PC validation Graph query failed for tenant {TenantId} (attempt {Attempt}). Status: {StatusCode}. Body: {ResponseBody}", + tenantId, attempt, (int)response.StatusCode, responseBody); + + // 403 = CloudPC.Read.All not granted in this tenant. That is a configuration + // state, not an outage: retrying cannot fix it, and a 503 Retry-After would + // keep every W365 agent in a futile retry loop. Cache it as a definitive + // negative (5 min) so a fresh grant is picked up quickly. + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + return CacheAndReturn(cacheKey, new CloudPcValidationResult + { + IsValid = false, + IntuneDeviceId = normalizedId, + ErrorMessage = "Cloud PC lookup not permitted — grant the optional 'W365CloudPcValidation' Graph feature (CloudPC.Read.All)" + }, isPositive: false); + } + + return new CloudPcValidationResult + { + IsValid = false, + IsTransient = true, + IntuneDeviceId = normalizedId, + ErrorMessage = $"Graph query failed with status {(int)response.StatusCode}" + }; + } + + var result = ParseCloudPcResponse(responseBody, normalizedId); + if (result.IsValid) + { + _logger.LogInformation( + "Cloud PC validation succeeded for tenant {TenantId}, session {SessionId}, deviceId {IntuneDeviceId}, cloudPcId {CloudPcId}, name {ManagedDeviceName}", + tenantId, sessionId ?? "", normalizedId, + result.CloudPcId ?? "", result.ManagedDeviceName ?? ""); + } + return CacheAndReturn(cacheKey, result, isPositive: result.IsValid); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error during Cloud PC validation for tenant {TenantId}, session {SessionId}, deviceId {IntuneDeviceId} (attempt {Attempt})", + tenantId, sessionId ?? "", normalizedId, attempt); + + return new CloudPcValidationResult + { + IsValid = false, + IsTransient = true, + IntuneDeviceId = normalizedId, + ErrorMessage = $"Error during Cloud PC validation: {ex.Message}" + }; + } + } + + /// + /// Pure-function: maps a virtualEndpoint/cloudPCs Graph response body to a + /// . Performs an exact-match guard on + /// managedDeviceId so a widened server-side filter can never produce a + /// false positive. + /// + internal static CloudPcValidationResult ParseCloudPcResponse(string responseBody, string normalizedId) + { + var notFound = new CloudPcValidationResult + { + IsValid = false, + IntuneDeviceId = normalizedId, + ErrorMessage = $"Device '{normalizedId}' is not a Windows 365 Cloud PC in this tenant" + }; + + JObject? data; + try + { + data = JsonConvert.DeserializeObject(responseBody); + } + catch (JsonException) + { + return notFound; + } + + var cloudPcs = data?["value"] as JArray; + if (cloudPcs == null || cloudPcs.Count == 0) + return notFound; + + var match = cloudPcs.FirstOrDefault(c => string.Equals( + c?["managedDeviceId"]?.ToString()?.Trim(), + normalizedId, + StringComparison.OrdinalIgnoreCase)); + + if (match == null) + return notFound; + + return new CloudPcValidationResult + { + IsValid = true, + IntuneDeviceId = normalizedId, + CloudPcId = match["id"]?.ToString(), + ManagedDeviceName = match["managedDeviceName"]?.ToString(), + ServicePlanName = match["servicePlanName"]?.ToString() + }; + } + + internal static string BuildCacheKey(string tenantId, string intuneDeviceId) + => $"cloudpc-device-validation:{tenantId}:{intuneDeviceId}"; + + private CloudPcValidationResult CacheAndReturn( + string cacheKey, + CloudPcValidationResult result, + bool isPositive) + { + var ttl = isPositive ? PositiveCacheTtl : NegativeCacheTtl; + _cache.Set(cacheKey, result, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ttl + }); + return result; + } + } + + public class CloudPcValidationResult + { + public bool IsValid { get; set; } + + /// + /// True when the failure is transient (Graph API error, token issue, network timeout). + /// Transient failures are NOT cached and should trigger a 503 Retry-After to the agent. + /// + public bool IsTransient { get; set; } + + /// Intune device id extracted from the MDM client certificate's Subject CN. + public string? IntuneDeviceId { get; set; } + + /// Cloud PC object id (virtualEndpoint/cloudPCs) when validation succeeded. + public string? CloudPcId { get; set; } + + /// Intune device name of the Cloud PC (CPC-*) — diagnostic only, never a gate. + public string? ManagedDeviceName { get; set; } + + /// Windows 365 service plan (e.g. "Cloud PC Enterprise 2vCPU/4GB/128GB") — diagnostic only. + public string? ServicePlanName { 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 32215573..3691ad8a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidationExtensions.cs @@ -33,9 +33,10 @@ namespace AutopilotMonitor.Functions.Security ILogger logger, string? sessionId = null, BootstrapSessionService? bootstrapSessionService = null, - DeviceAssociationValidator? deviceAssociationValidator = null) + DeviceAssociationValidator? deviceAssociationValidator = null, + CloudPcDeviceValidator? cloudPcDeviceValidator = null) { - var validator = new SecurityValidator(configService, adminConfigService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger, bootstrapSessionService, deviceAssociationValidator); + var validator = new SecurityValidator(configService, adminConfigService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger, bootstrapSessionService, deviceAssociationValidator, cloudPcDeviceValidator); 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 c942c15a..16c7c4d6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs @@ -47,6 +47,7 @@ namespace AutopilotMonitor.Functions.Security private readonly AutopilotDeviceValidator _autopilotDeviceValidator; private readonly CorporateIdentifierValidator _corporateIdentifierValidator; private readonly DeviceAssociationValidator? _deviceAssociationValidator; + private readonly CloudPcDeviceValidator? _cloudPcDeviceValidator; private readonly BootstrapSessionService? _bootstrapSessionService; private readonly ILogger _logger; @@ -58,7 +59,8 @@ namespace AutopilotMonitor.Functions.Security CorporateIdentifierValidator corporateIdentifierValidator, ILogger logger, BootstrapSessionService? bootstrapSessionService = null, - DeviceAssociationValidator? deviceAssociationValidator = null) + DeviceAssociationValidator? deviceAssociationValidator = null, + CloudPcDeviceValidator? cloudPcDeviceValidator = null) { _configService = configService; _adminConfigService = adminConfigService; @@ -66,6 +68,7 @@ namespace AutopilotMonitor.Functions.Security _autopilotDeviceValidator = autopilotDeviceValidator; _corporateIdentifierValidator = corporateIdentifierValidator; _deviceAssociationValidator = deviceAssociationValidator; + _cloudPcDeviceValidator = cloudPcDeviceValidator; _bootstrapSessionService = bootstrapSessionService; _logger = logger; } @@ -235,14 +238,14 @@ namespace AutopilotMonitor.Functions.Security // Security validation is always enforced (no longer configurable per tenant) // Hard gate: tenant must enable at least one device validation method before agent traffic is accepted. // Global Admins can set AllowInsecureAgentRequests=true in the config row for test tenants. - if (!config.ValidateAutopilotDevice && !config.ValidateCorporateIdentifier && !config.AllowInsecureAgentRequests) + if (!config.ValidateAutopilotDevice && !config.ValidateCorporateIdentifier && !config.ValidateCloudPcDevice && !config.AllowInsecureAgentRequests) { return new SecurityValidationResult { IsValid = false, StatusCode = HttpStatusCode.Forbidden, ErrorMessage = "Device validation is required", - Details = "Enable 'Autopilot Device Validation' or 'Corporate Identifier Validation' in Configuration before using the agent ingestion endpoints." + Details = "Enable 'Autopilot Device Validation', 'Corporate Identifier Validation' or 'Windows 365 Cloud PC Validation' in Configuration before using the agent ingestion endpoints." }; } @@ -364,7 +367,29 @@ namespace AutopilotMonitor.Functions.Security } } - if ((config.ValidateAutopilotDevice || config.ValidateCorporateIdentifier) && !deviceValidated) + // W365 fallback: Cloud PCs are structurally never Autopilot-registered, so the serial + // lookups above always miss for them. Identity here comes from the chain-validated + // client certificate, NOT from spoofable headers: the Subject CN carries the Intune + // device id, and only a Windows-365-service-provisioned machine has a cloudPC object + // with that managedDeviceId — a regular Intune-enrolled (non-Autopilot) device still + // ends in the 403 below. + if (!deviceValidated && config.ValidateCloudPcDevice && _cloudPcDeviceValidator != null) + { + TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var intuneDeviceId); + var cloudPcResult = await _cloudPcDeviceValidator.ValidateCloudPcAsync(tenantId, intuneDeviceId, sessionId); + if (cloudPcResult.IsValid) + { + deviceValidated = true; + validatedBy = ValidatorType.CloudPc; + } + else + { + deviceValidationError = cloudPcResult.ErrorMessage; + deviceValidationTransient = cloudPcResult.IsTransient; + } + } + + if ((config.ValidateAutopilotDevice || config.ValidateCorporateIdentifier || config.ValidateCloudPcDevice) && !deviceValidated) { // Transient failures (Graph API down, token issues) → 503 Retry-After so agent retries // Definitive failures (device not registered) → 403 Forbidden @@ -441,6 +466,37 @@ namespace AutopilotMonitor.Functions.Security }; } + /// + /// Extracts the Intune device id from an MDM client certificate subject. Certs issued by + /// the Microsoft Intune MDM Device CA carry the Intune managedDevice id as the CN + /// (field-verified 2026-08-06 on a W365 Cloud PC). Returns false when the subject has no + /// CN or the CN is not a canonical GUID — the CloudPc validator then rejects it as + /// "not a valid Intune device id" without ever touching Graph. + /// + internal static bool TryGetIntuneDeviceIdFromCertSubject(string? subject, out string? intuneDeviceId) + { + intuneDeviceId = null; + if (string.IsNullOrWhiteSpace(subject)) + return false; + + foreach (var part in subject!.Split(',')) + { + var trimmed = part.Trim(); + if (!trimmed.StartsWith("CN=", StringComparison.OrdinalIgnoreCase)) + continue; + + var value = trimmed.Substring(3).Trim(); + if (IsValidGuid(value)) + { + intuneDeviceId = value.ToLowerInvariant(); + return true; + } + return false; // first CN wins; a non-GUID CN is definitive (not an Intune MDM device cert shape) + } + + return false; + } + /// /// Emits a structured warning with request-side context (path, hardware headers, agent /// version) at every fail-fast rejection point. Pairs with the cert-side warnings emitted diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs index 436ce2d5..16990ac2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs @@ -696,6 +696,7 @@ namespace AutopilotMonitor.Functions.Services if (filter.IsPreProvisioned.HasValue && session.IsPreProvisioned != filter.IsPreProvisioned.Value) return false; if (filter.IsHybridJoin.HasValue && session.IsHybridJoin != filter.IsHybridJoin.Value) return false; if (filter.IsSelfDeployingProfile.HasValue && session.IsSelfDeployingProfile != filter.IsSelfDeployingProfile.Value) return false; + if (filter.IsCloudPc.HasValue && session.IsCloudPc != filter.IsCloudPc.Value) return false; if (!string.IsNullOrEmpty(filter.GeoCountry) && !string.Equals(session.GeoCountry, filter.GeoCountry, StringComparison.OrdinalIgnoreCase)) return false; @@ -911,6 +912,7 @@ namespace AutopilotMonitor.Functions.Services if (filter.IsPreProvisioned.HasValue && session.IsPreProvisioned != filter.IsPreProvisioned.Value) continue; if (filter.IsHybridJoin.HasValue && session.IsHybridJoin != filter.IsHybridJoin.Value) continue; if (filter.IsSelfDeployingProfile.HasValue && session.IsSelfDeployingProfile != filter.IsSelfDeployingProfile.Value) continue; + if (filter.IsCloudPc.HasValue && session.IsCloudPc != filter.IsCloudPc.Value) continue; if (!string.IsNullOrEmpty(filter.GeoCountry) && !string.Equals(session.GeoCountry, filter.GeoCountry, StringComparison.OrdinalIgnoreCase)) continue; @@ -952,6 +954,7 @@ namespace AutopilotMonitor.Functions.Services if (filter.IsPreProvisioned.HasValue && s.IsPreProvisioned != filter.IsPreProvisioned.Value) return false; if (filter.IsHybridJoin.HasValue && s.IsHybridJoin != filter.IsHybridJoin.Value) return false; if (filter.IsSelfDeployingProfile.HasValue && s.IsSelfDeployingProfile != filter.IsSelfDeployingProfile.Value) return false; + if (filter.IsCloudPc.HasValue && s.IsCloudPc != filter.IsCloudPc.Value) return false; if (!string.IsNullOrEmpty(filter.GeoCountry) && !string.Equals(s.GeoCountry, filter.GeoCountry, StringComparison.OrdinalIgnoreCase)) return false; if (filter.StartedAfter.HasValue && s.StartedAt < filter.StartedAfter.Value) return false; diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs index 793a62f2..ee7c8eac 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs @@ -132,6 +132,7 @@ namespace AutopilotMonitor.Functions.Services ["IsHybridJoin"] = sessionEntity.GetBoolean("IsHybridJoin") ?? false, ["IsUserDriven"] = sessionEntity.GetBoolean("IsUserDriven") ?? false, ["IsSelfDeployingProfile"] = sessionEntity.GetBoolean("IsSelfDeployingProfile") ?? false, + ["IsCloudPc"] = sessionEntity.GetBoolean("IsCloudPc") ?? false, ["AgentVersion"] = sessionEntity.GetString("AgentVersion") ?? string.Empty, // Search-filterable column: search/MCP push an OData filter on ImeAgentVersion against // the index, so it MUST be projected (was previously absent → filter matched nothing). @@ -433,6 +434,7 @@ namespace AutopilotMonitor.Functions.Services bool isPreProvisioned = registration.IsPreProvisioned; bool isHybridJoin = registration.IsHybridJoin; bool isSelfDeployingProfile = registration.IsSelfDeployingProfile; + bool isCloudPc = registration.IsCloudPc; DateTime? lastEventAt = null; int? durationSeconds = null; string? diagnosticsBlobName = null; @@ -480,6 +482,10 @@ namespace AutopilotMonitor.Functions.Services // the Autopilot policy cache was momentarily unreadable must not pin the // self-deploying marker to false forever — once observed true, it stays true. isSelfDeployingProfile = (existingEntity.GetBoolean("IsSelfDeployingProfile") ?? false) || isSelfDeployingProfile; + // Same sticky-true OR: the W365 markers are read at boot; a transient + // registry/service hiccup on a later re-registration must not clear an + // already-observed Cloud PC fact. + isCloudPc = (existingEntity.GetBoolean("IsCloudPc") ?? false) || isCloudPc; lastEventAt = existingEntity.GetDateTimeOffset("LastEventAt")?.UtcDateTime; durationSeconds = existingEntity.GetInt32("DurationSeconds"); diagnosticsBlobName = existingEntity.GetString("DiagnosticsBlobName"); @@ -547,6 +553,7 @@ namespace AutopilotMonitor.Functions.Services ["IsPreProvisioned"] = isPreProvisioned, ["IsHybridJoin"] = isHybridJoin, ["IsSelfDeployingProfile"] = isSelfDeployingProfile, + ["IsCloudPc"] = isCloudPc, ["StartedAt"] = EnsureUtc(startedAt), ["AgentVersion"] = registration.AgentVersion ?? string.Empty, ["EnrollmentType"] = registration.EnrollmentType ?? "v1", @@ -2939,6 +2946,7 @@ namespace AutopilotMonitor.Functions.Services IsPreProvisioned = entity.GetBoolean("IsPreProvisioned") ?? false, IsHybridJoin = entity.GetBoolean("IsHybridJoin") ?? false, IsSelfDeployingProfile = entity.GetBoolean("IsSelfDeployingProfile") ?? false, + IsCloudPc = entity.GetBoolean("IsCloudPc") ?? false, ResumedAt = SafeGetDateTime(entity, "ResumedAt"), StalledAt = SafeGetDateTime(entity, "StalledAt"), OsName = entity.GetString("OsName") ?? string.Empty, diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index 1fd6e7f6..f09c4d8d 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -894,6 +894,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo isPreProvisioned: z.boolean().optional().describe('Filter pre-provisioned (white-glove) sessions'), isHybridJoin: z.boolean().optional().describe('Filter hybrid AAD-join sessions'), isSelfDeployingProfile: z.boolean().optional().describe('Filter self-deploying/kiosk profile sessions (CloudAssignedOobeConfig 0x20|0x40)'), + isCloudPc: z.boolean().optional().describe('Filter Windows 365 Cloud PC sessions (agent-detected W365 markers, sticky-true; independent of ValidatedBy="CloudPc")'), connectionType: z.enum(['WiFi', 'Ethernet']).optional() .describe('Active network connection type ("WiFi" or "Ethernet", exact match on the indexed column; last emission wins). Sessions predating the projection lack the column and are excluded.'), fields: z.string().optional().describe('Comma-separated pass-through projection over the literal stored column names (case-insensitive, PascalCase, e.g. "Status,OsEdition,ImeAgentVersion,GeoCity"); narrows the row but never drops a real column. PartitionKey + RowKey are always kept. Omit for the full raw row.'), @@ -908,14 +909,14 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo try { const { tenantId: rawTenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix, imeAgentVersion, imeAgentVersionPrefix, manufacturer, model, enrollmentType, deviceName, osBuild, - geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, connectionType, fields, pageSize, continuation } = args; + geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, isCloudPc, connectionType, fields, pageSize, continuation } = args; const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation); const basePath = pickGlobalOrTenantPath('/api/global/raw/sessions', '/api/raw/sessions', tenantId); const path = followNextLink( basePath, { tenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix, imeAgentVersion, imeAgentVersionPrefix, manufacturer, model, enrollmentType, deviceName, osBuild, - geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, connectionType, fields, pageSize }, + geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, isCloudPc, connectionType, fields, pageSize }, continuation, ); const data = await apiFetch(path); diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts index 8e943790..88047ecd 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts @@ -125,6 +125,9 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated: isHybridJoin: z.boolean().optional().describe('Filter by Hybrid Azure AD Join'), isSelfDeployingProfile: z.boolean().optional().describe( 'Filter by self-deploying/kiosk Autopilot profile (CloudAssignedOobeConfig bits 0x20|0x40, agent-detected at registration)'), + isCloudPc: z.boolean().optional().describe( + 'Filter by Windows 365 Cloud PC (agent-detected marker AND: Windows365 registry key + CloudManagedDesktopExtension service; sticky-true). ' + + 'Independent of validatedBy="CloudPc" (server-side Graph verification). Sessions from agents predating the field read as false.'), geoCountry: z.string().optional().describe('Country of enrollment (2-letter ISO code, e.g. "DE", "US")'), startedAfter: IsoDateString.optional().describe('ISO 8601 datetime — only sessions started after this'), startedBefore: IsoDateString.optional().describe('ISO 8601 datetime — only sessions started before this'), @@ -148,7 +151,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated: 'Use for counting / aggregation to avoid the response cap. Available: sessionId, tenantId, status, ' + 'serialNumber, manufacturer, model, deviceName, osBuild, osName, startedAt, completedAt, ' + 'durationSeconds, currentPhase, failureReason, eventCount, enrollmentType, isPreProvisioned, ' + - 'isUserDriven, isHybridJoin, isSelfDeployingProfile, agentVersion, imeAgentVersion, geoCountry, rebootCount, ' + + 'isUserDriven, isHybridJoin, isSelfDeployingProfile, isCloudPc, agentVersion, imeAgentVersion, geoCountry, rebootCount, ' + 'avgApiLatencyMs, apiRequestCount (agent→backend HTTP round-trip; weight latency by apiRequestCount when aggregating, ' + 'e.g. fields=geoCountry,avgApiLatencyMs,apiRequestCount for a per-country latency sweep; null on sessions from agents predating the field), ' + 'connectionType ("WiFi"/"Ethernet"; null on sessions predating the projection).'), @@ -490,8 +493,11 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated: isHybridJoin: s.isHybridJoin ?? false, isUserDriven: s.isUserDriven ?? false, isSelfDeployingProfile: s.isSelfDeployingProfile ?? false, + // Agent-detected Windows 365 Cloud PC marker (Windows365 registry key + + // CloudManagedDesktopExtension service). Independent of validatedBy="CloudPc". + isCloudPc: s.isCloudPc ?? false, // Backend device-validation path that admitted the device at registration: - // "AutopilotV1" | "CorporateIdentifier" | "DeviceAssociation" | "Bootstrap". + // "AutopilotV1" | "CorporateIdentifier" | "DeviceAssociation" | "Bootstrap" | "CloudPc". // Null for sessions predating the field or tenants with device validation off. validatedBy: s.validatedBy || null, device: { diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs index e35e413a..e42b8f09 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DeadlineNames.cs @@ -131,6 +131,16 @@ namespace AutopilotMonitor.DecisionCore.Engine /// public const string IsSelfDeployingProfile = "isSelfDeployingProfile"; + /// + /// On EnrollmentFactsObserved: "true" / "false" from + /// CloudPcDetector.DetectIsCloudPc() — the Windows 365 Cloud PC marker AND + /// (Windows365 registry key + CloudManagedDesktopExtension service). On a Cloud PC + /// first connect the Device-ESP phase already ran headless at provisioning time, so + /// the engine must expect the session to start at Account Setup with no DeviceSetup + /// signals ever arriving. + /// + public const string IsCloudPc = "isCloudPc"; + // --- InformationalEvent payload (plan §1.3, single-rail refactor) ------------ // Mirrors the EnrollmentEvent fields the reducer must reconstruct for the // EmitEventTimelineEntry effect. EventType / Source are mandatory; the rest are diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs index 9036b8e4..982bd72b 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs @@ -545,6 +545,18 @@ namespace AutopilotMonitor.DecisionCore.Engine .WithRegistrySelfDeployingProfile(isSelfDeploying, signal.SessionSignalOrdinal); } + // W365 — record the Cloud PC marker fact for BOTH values (same rationale as the + // self-deploying fact above). `true` is the engine's "no Device-ESP phase + // expected" context: Device-ESP already ran headless at provisioning, the session + // starts at Account Setup and DeviceSetup signals will never arrive. Set-once. + if (signal.Payload != null + && signal.Payload.TryGetValue(SignalPayloadKeys.IsCloudPc, out var rawCloudPc) + && bool.TryParse(rawCloudPc, out var isCloudPc)) + { + builder.ScenarioObservations = builder.ScenarioObservations + .WithCloudPc(isCloudPc, signal.SessionSignalOrdinal); + } + var newState = builder.Build(); var transition = BuildTakenTransition( before: state, diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSignalCensus.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSignalCensus.cs index b367ff90..5cea0c14 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSignalCensus.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSignalCensus.cs @@ -155,6 +155,14 @@ namespace AutopilotMonitor.DecisionCore.Engine ["withUser"] = obs.AadUserJoinWithUserObserved.Value, }; } + // W365 — surfaced so terminal audit trails show the session ran on a Cloud PC, + // where a missing DeviceSetup phase is expected (Device-ESP completed headless at + // provisioning), not an anomaly. Positive marker only; false adds no signal. + if (obs.CloudPc?.Value == true) + { + seen.Add("cloud_pc_marker"); + evidence["cloudPcMarker"] = OrdinalEvidence(obs.CloudPc.SourceSignalOrdinal); + } return new SignalCensusResult(seen, timestamps, evidence); } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioObservations.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioObservations.cs index c9703004..f5b14da3 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioObservations.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioObservations.cs @@ -80,6 +80,17 @@ namespace AutopilotMonitor.DecisionCore.State /// public SignalFact? RegistrySelfDeployingProfile { get; init; } + /// + /// Raw marker fact from + /// (payload isCloudPc, from the Windows365 registry key + installed + /// CloudManagedDesktopExtension service — the marker-AND field-verified by bootstrap + /// v2.3-dev.2). true means the session runs on a Windows 365 Cloud PC whose + /// Device-ESP already completed headless at provisioning time: no DeviceSetup phase + /// signals are expected, monitoring effectively starts at Account Setup. Records BOTH + /// values; null means the fact was never observed. + /// + public SignalFact? CloudPc { get; init; } + public EnrollmentScenarioObservations WithShellCoreWhiteGloveSuccessSeen(long sourceSignalOrdinal) => ShellCoreWhiteGloveSuccessSeen != null ? this @@ -119,5 +130,10 @@ namespace AutopilotMonitor.DecisionCore.State RegistrySelfDeployingProfile != null ? this : this with { RegistrySelfDeployingProfile = new SignalFact(value, sourceSignalOrdinal) }; + + public EnrollmentScenarioObservations WithCloudPc(bool value, long sourceSignalOrdinal) => + CloudPc != null + ? this + : this with { CloudPc = new SignalFact(value, sourceSignalOrdinal) }; } } diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs index 55373888..96efcbba 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioProfileUpdater.cs @@ -19,6 +19,14 @@ namespace AutopilotMonitor.DecisionCore.State /// public static class EnrollmentScenarioProfileUpdater { + /// + /// Profile reason recorded when the agent's Cloud PC marker detection reports a + /// Windows 365 Cloud PC: the Device-ESP phase already ran headless at provisioning + /// time, so the engine expects the session to start at Account Setup and DeviceSetup + /// phase signals to never arrive. + /// + public const string CloudPcNoDeviceEspExpectedReason = "cloud_pc_first_connect:no_device_esp_expected"; + // ================================================================== EnrollmentFactsObserved /// @@ -62,6 +70,15 @@ namespace AutopilotMonitor.DecisionCore.State if (signal.Payload != null) { + // W365 Cloud PC marker, parsed up front because it steers the reason slot of + // the enrollmentType branch below: when the payload carries isCloudPc=true the + // final reason is always the Cloud PC annotation, so the generic v1 annotation + // must not claim `changed` first (a repeat post would otherwise flip the reason + // v1→annotation every time and break idempotency through the ordinal bump). + var cloudPcAnnotates = signal.Payload.TryGetValue(SignalPayloadKeys.IsCloudPc, out var rawCloudPc) + && bool.TryParse(rawCloudPc, out var isCloudPc) + && isCloudPc; + if (signal.Payload.TryGetValue(SignalPayloadKeys.EnrollmentType, out var rawType) && !string.IsNullOrEmpty(rawType) && current.Mode == EnrollmentMode.Unknown) @@ -87,9 +104,11 @@ namespace AutopilotMonitor.DecisionCore.State reason = newReason; changed = true; } - else if (!string.Equals(current.Reason, newReason, StringComparison.Ordinal)) + else if (!cloudPcAnnotates + && !string.Equals(current.Reason, newReason, StringComparison.Ordinal)) { - // v1 (or any other): keep Mode=Unknown but record the reason once. + // v1 (or any other): keep Mode=Unknown but record the reason once — + // unless the Cloud PC annotation below owns the slot for this payload. reason = newReason; changed = true; } @@ -126,6 +145,26 @@ namespace AutopilotMonitor.DecisionCore.State joinMode = isHybrid ? EnrollmentJoinMode.HybridAzureAdJoin : EnrollmentJoinMode.AzureAdJoin; changed = true; } + + // W365 Cloud PC — record the "no Device-ESP phase expected" expectation as the + // profile reason: Device-ESP already ran headless at provisioning, so the + // session starts at Account Setup and a missing DeviceSetup phase is normal, + // not an anomaly. Deliberately reason-only: + // - Mode stays untouched — a Cloud PC first connect IS a classic user-driven + // flow; ApplyAccountSetupObserved/ApplyImeUserSessionCompleted classify it. + // - EspConfig stays untouched — it carries the CONFIGURED FirstSync semantics + // (skip flags), and on a Cloud PC the tenant config typically still says + // FullEsp even though the device half already happened pre-agent. + // Runs last so it wins the reason slot over any annotation set earlier in the + // same payload; the comparison against the LOCAL reason keeps repeat posts + // idempotent (paired with the cloudPcAnnotates gate on the v1 branch above). + // The raw fact (both values) lives in EnrollmentScenarioObservations.CloudPc. + if (cloudPcAnnotates + && !string.Equals(reason, CloudPcNoDeviceEspExpectedReason, StringComparison.Ordinal)) + { + reason = CloudPcNoDeviceEspExpectedReason; + changed = true; + } } if (!changed) return current; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index 8573e0e1..179a2491 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -202,6 +202,19 @@ namespace AutopilotMonitor.Shared.Models /// public bool ValidateDeviceAssociation { get; set; } = false; + /// + /// Whether to validate Windows 365 Cloud PCs as a fallback when the Autopilot / + /// Corporate Identifier lookups miss. Cloud PCs are provisioned by the Windows 365 + /// service and are structurally never Autopilot-registered; this validator instead + /// resolves the Intune device id from the (chain-validated) MDM client certificate's + /// Subject CN and requires a matching cloudPC object + /// (virtualEndpoint/cloudPCs, managedDeviceId eq CN) in the tenant. + /// Only service-provisioned Cloud PCs have such an object, so no other enrolled + /// device can pass this stage. Requires the optional Graph permission + /// CloudPC.Read.All (feature "W365CloudPcValidation" in the grant script). + /// + public bool ValidateCloudPcDevice { 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/Enrollment/SessionRegistration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Enrollment/SessionRegistration.cs index 6c93b425..d1faa7d8 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Enrollment/SessionRegistration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Enrollment/SessionRegistration.cs @@ -102,6 +102,16 @@ namespace AutopilotMonitor.Shared.Models /// public bool IsSelfDeployingProfile { get; set; } + /// + /// Whether the agent identified the device as a Windows 365 Cloud PC (marker AND: + /// HKLM\SOFTWARE\Microsoft\Windows365 key + installed CloudManagedDesktopExtension + /// service — the evidence set field-verified by bootstrap v2.3-dev.2). Display/filter + /// metadata only, agent-reported and NOT server-verified; the authoritative Cloud PC + /// auth signal is ValidatedBy == CloudPc (cert-CN-bound Graph lookup). The two can + /// legitimately disagree, e.g. when a Cloud PC is admitted via Autopilot validation. + /// + public bool IsCloudPc { get; set; } + /// /// Which backend device-validation path accepted this registration (Autopilot S/N, /// Corporate Identifier, Device Association, or Bootstrap token). SERVER-POPULATED: diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphAppPermissions.cs b/src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphAppPermissions.cs index 7b867f82..45216fd2 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphAppPermissions.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphAppPermissions.cs @@ -25,5 +25,13 @@ 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 Windows 365 Cloud PC objects (virtualEndpoint/cloudPCs). Optional add-on + /// permission backing the W365 Cloud PC device validator — Cloud PCs are never + /// Autopilot-registered, so validation resolves them through the service-side Cloud PC + /// inventory instead. + /// + public const string CloudPCReadAll = "CloudPC.Read.All"; } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs b/src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs index d065ebce..39cf2967 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs @@ -14,11 +14,15 @@ namespace AutopilotMonitor.Shared.Models.Graph /// Resolves Intune Platform Script + Remediation Script display names in session timelines. public const string FeatureScriptDisplayNames = "ScriptDisplayNames"; + /// Validates Windows 365 Cloud PCs via the service-side Cloud PC inventory (cert-CN bound). + public const string FeatureW365CloudPcValidation = "W365CloudPcValidation"; + // 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 }, }; /// All registered feature identifiers. diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs index 871f568b..6fdce1e2 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs @@ -22,7 +22,8 @@ namespace AutopilotMonitor.Shared.Models AutopilotV1 = 1, // AutopilotDeviceValidator (windowsAutopilotDeviceIdentities) CorporateIdentifier = 2, // CorporateIdentifierValidator (importedDeviceIdentities) DeviceAssociation = 3, // DevPrep DeviceAssociationValidator (tenantAssociatedDevices) — future - Bootstrap = 4 // Bootstrap token auth (pre-MDM OOBE) + Bootstrap = 4, // Bootstrap token auth (pre-MDM OOBE) + CloudPc = 5 // CloudPcDeviceValidator (virtualEndpoint/cloudPCs, cert-CN bound) — W365 } /// @@ -362,6 +363,15 @@ namespace AutopilotMonitor.Shared.Models /// public bool IsSelfDeployingProfile { get; set; } + /// + /// Whether the agent identified the device as a Windows 365 Cloud PC (Windows365 + /// registry key + CloudManagedDesktopExtension service, marker AND). Sent by the + /// agent at registration; sticky-true across re-registrations. Agent-reported + /// context — independent of ValidatedBy == CloudPc, which is the server-derived + /// cert-CN-bound Graph verification. + /// + public bool IsCloudPc { get; set; } + // Device detail fields — stored in the Sessions table but omitted from earlier versions public string OsName { get; set; } = default!; public string OsBuild { get; set; } = default!; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionSearchFilter.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionSearchFilter.cs index ad2ef528..91ba8873 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/SessionSearchFilter.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionSearchFilter.cs @@ -26,6 +26,7 @@ namespace AutopilotMonitor.Shared.Models public bool? IsPreProvisioned { get; set; } public bool? IsHybridJoin { get; set; } public bool? IsSelfDeployingProfile { get; set; } + public bool? IsCloudPc { get; set; } public string? GeoCountry { get; set; } public DateTime? StartedAfter { get; set; } public DateTime? StartedBefore { get; set; } diff --git a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx index c74c65cb..2ed5fe26 100644 --- a/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx +++ b/src/Web/autopilot-monitor-web/app/dashboard/components/SessionTable.tsx @@ -879,6 +879,14 @@ function SessionCell({ Self-Deploying )} + {session.isCloudPc && ( + + Cloud PC + + )} {blockedDevicesSet.has(`${session.tenantId}:${session.serialNumber}`) && ( How Your Data Is Protected
  • Device authentication by mutual TLS using the Intune MDM client certificate, validated against pinned Intune root CAs rather than the operating system trust store — and rejected if no trust anchor loads
  • -
  • Autopilot device validation via Microsoft Graph — only devices registered in your tenant are accepted, with an optional hardware allow-list on top
  • +
  • Device registration validation via Microsoft Graph — only devices your tenant actually knows are accepted (Windows Autopilot registration, corporate device identifiers, or the Windows 365 Cloud PC inventory), with an optional hardware allow-list on top
  • Entra ID authentication for portal users with a restricted signing-algorithm allow-list and identity PII logging disabled
  • Fail-closed authorization — every API route must be registered with an access policy; an unregistered route is unreachable
  • Structural tenant isolation — storage partitioning by tenant, with the tenant identity taken from the validated token and never from a client-supplied header
  • diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx index 6b00bb1f..b27bf438 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx @@ -128,7 +128,9 @@ function enrollmentTypeLabel(session: Session, isGatherRulesSession: boolean): s ? "Device Preparation" : session.isSelfDeployingProfile ? "Autopilot (Self-Deploying)" - : "Autopilot"; + : session.isCloudPc + ? "Windows 365 Cloud PC" + : "Autopilot"; return session.isPreProvisioned ? `${base} (PreProvisioned)` : base; } @@ -149,6 +151,7 @@ function validatedByLabel(session: Session): string | null { case "CorporateIdentifier": return "Corporate Identifier"; case "DeviceAssociation": return "Device Association"; case "Bootstrap": return "Bootstrap Token (pre-MDM)"; + case "CloudPc": return "Windows 365 Cloud PC"; default: return null; } } diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx index 31378424..2b678a85 100644 --- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx @@ -130,6 +130,13 @@ interface TenantConfigContextValue { setValidateDeviceAssociation: (v: boolean) => void; /** Toggle + persist DevPrep Device Association validation in one shot (no consent flow needed). */ handleToggleDeviceAssociationValidation: (newValue: boolean) => Promise; + validateCloudPcDevice: boolean; + setValidateCloudPcDevice: (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; /** * 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 @@ -384,6 +391,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } const [validateAutopilotDevice, setValidateAutopilotDevice] = useState(false); const [validateCorporateIdentifier, setValidateCorporateIdentifier] = useState(false); const [validateDeviceAssociation, setValidateDeviceAssociation] = useState(false); + const [validateCloudPcDevice, setValidateCloudPcDevice] = useState(false); const [dataRetentionDays, setDataRetentionDays] = useState(90); const [sessionTimeoutHours, setSessionTimeoutHours] = useState(5); @@ -524,6 +532,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } setValidateAutopilotDevice(data.validateAutopilotDevice); setValidateCorporateIdentifier(data.validateCorporateIdentifier ?? false); setValidateDeviceAssociation(data.validateDeviceAssociation ?? false); + setValidateCloudPcDevice(data.validateCloudPcDevice ?? false); setDataRetentionDays(data.dataRetentionDays ?? 90); setSessionTimeoutHours(data.sessionTimeoutHours ?? 5); setEnablePerformanceCollector(data.enablePerformanceCollector ?? true); @@ -702,7 +711,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } // ----------------------------------------------------------------------- // Save configuration (shared by all sections) // ----------------------------------------------------------------------- - const saveConfiguration = useCallback(async (sectionName: string, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; unrestrictedMode?: boolean }): Promise => { + const saveConfiguration = useCallback(async (sectionName: string, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: 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 PUT regardless). if (!tenantId || !config || !canEditConfig) return false; @@ -715,6 +724,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } const autopilotDeviceValidationValue = overrides?.validateAutopilotDevice ?? validateAutopilotDevice; const corporateIdentifierValidationValue = overrides?.validateCorporateIdentifier ?? validateCorporateIdentifier; const deviceAssociationValidationValue = overrides?.validateDeviceAssociation ?? validateDeviceAssociation; + const cloudPcValidationValue = overrides?.validateCloudPcDevice ?? validateCloudPcDevice; const unrestrictedModeValue = overrides?.unrestrictedMode ?? unrestrictedMode; const updatedConfig: TenantConfiguration = { @@ -725,6 +735,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } validateAutopilotDevice: autopilotDeviceValidationValue, validateCorporateIdentifier: corporateIdentifierValidationValue, validateDeviceAssociation: deviceAssociationValidationValue, + validateCloudPcDevice: cloudPcValidationValue, dataRetentionDays, sessionTimeoutHours, enablePerformanceCollector, @@ -794,6 +805,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } setValidateAutopilotDevice(result.config.validateAutopilotDevice); setValidateCorporateIdentifier(result.config.validateCorporateIdentifier ?? false); setValidateDeviceAssociation(result.config.validateDeviceAssociation ?? false); + setValidateCloudPcDevice(result.config.validateCloudPcDevice ?? false); setUnrestrictedMode(result.config.unrestrictedMode ?? false); trackEvent("settings_saved", { section: sectionName }); setSuccessMessage("Configuration saved successfully!"); @@ -813,7 +825,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } } }, [ tenantId, config, canEditConfig, getAccessToken, addNotification, - manufacturerWhitelist, modelWhitelist, webhookNotifyOnHardwareRejection, validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, + manufacturerWhitelist, modelWhitelist, webhookNotifyOnHardwareRejection, validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, validateCloudPcDevice, dataRetentionDays, sessionTimeoutHours, enablePerformanceCollector, performanceCollectorInterval, helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds, contactEmail, enableGeoLocation, enableTimezoneAutoSet, enableImeMatchLog, enableGatherRuleDebugLog, logLevel, showScriptOutput, showEnrollmentSummary, @@ -1292,6 +1304,17 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } await saveConfiguration("autopilotValidation", { validateDeviceAssociation: newValue }); }, [saveConfiguration]); + /** + * Toggle the Windows 365 Cloud PC validation fallback. No consent flow — the backing + * CloudPC.Read.All permission is an Optional Graph capabilities add-on (grant script); + * without the grant the backend simply keeps rejecting Cloud PCs with a pointer to the + * add-on, so enabling early is safe. + */ + const handleToggleCloudPcValidation = useCallback(async (newValue: boolean) => { + setValidateCloudPcDevice(newValue); + await saveConfiguration("autopilotValidation", { validateCloudPcDevice: newValue }); + }, [saveConfiguration]); + // ----------------------------------------------------------------------- // Admin management handlers // ----------------------------------------------------------------------- @@ -1613,6 +1636,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } validateCorporateIdentifier, setValidateCorporateIdentifier, validateDeviceAssociation, setValidateDeviceAssociation, handleToggleDeviceAssociationValidation, + validateCloudPcDevice, setValidateCloudPcDevice, + handleToggleCloudPcValidation, saveValidationGate, autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess, 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 720def54..27b62acb 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; + /** 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). */ + onToggleCloudPc?: (newValue: boolean) => void | Promise; autopilotConsentInProgress: boolean; saving: boolean; onBeginConsent: (trigger: 'autopilot' | 'corporate' | 'device-preparation') => void; @@ -31,13 +35,15 @@ export default function AutopilotValidationSection({ validateDeviceAssociation = false, onToggleDeviceAssociation, showDeviceAssociationToggle = false, + validateCloudPcDevice = false, + onToggleCloudPc, autopilotConsentInProgress, saving, onBeginConsent, onDetectExistingAccess, }: AutopilotValidationSectionProps) { - const anyValidationEnabled = validateAutopilotDevice || validateCorporateIdentifier; - const [disableConfirm, setDisableConfirm] = useState<'autopilot' | 'corporate' | null>(null); + const anyValidationEnabled = validateAutopilotDevice || validateCorporateIdentifier || validateCloudPcDevice; + const [disableConfirm, setDisableConfirm] = useState<'autopilot' | 'corporate' | 'cloudpc' | null>(null); const handleToggleAutopilot = () => { if (validateAutopilotDevice) { @@ -63,11 +69,23 @@ export default function AutopilotValidationSection({ } }; + // Cloud PC validation is a hard gate like the two above, but its permission is an add-on + // grant (CloudPC.Read.All via the grant script) — no consent dialog, direct toggle+persist. + const handleToggleCloudPc = () => { + if (validateCloudPcDevice) { + setDisableConfirm('cloudpc'); + } else if (onToggleCloudPc) { + void onToggleCloudPc(true); + } + }; + const confirmDisable = () => { if (disableConfirm === 'autopilot') { setValidateAutopilotDevice(false); } else if (disableConfirm === 'corporate') { setValidateCorporateIdentifier(false); + } else if (disableConfirm === 'cloudpc' && onToggleCloudPc) { + void onToggleCloudPc(false); } setDisableConfirm(null); }; @@ -162,6 +180,34 @@ export default function AutopilotValidationSection({ {!validateCorporateIdentifier && renderDetectButton('corporate')} + {/* Windows 365 Cloud PC — fallback gate for Cloud PCs; permission via the Optional Graph capabilities add-on */} + {onToggleCloudPc && ( +
    +

    Windows 365

    + +
    + )} + {/* DevPrep Device Association — Private Preview, GA-gated, shadow-mode (no enrollment block) */} {showDeviceAssociationToggle && onToggleDeviceAssociation && (
    @@ -194,7 +240,7 @@ export default function AutopilotValidationSection({

    Important:{" "} - If both validations are disabled, backend agent endpoints reject requests for this tenant. Enable at least one and complete admin consent first. + If all validations are disabled, backend agent endpoints reject requests for this tenant. Enable at least one and complete admin consent first.

    @@ -218,7 +264,9 @@ export default function AutopilotValidationSection({

    Disable Validation

    - {disableConfirm === 'autopilot' ? 'Autopilot Device Validation' : 'Corporate Identifier Validation'} + {disableConfirm === 'autopilot' ? 'Autopilot Device Validation' + : disableConfirm === 'corporate' ? 'Corporate Identifier Validation' + : 'Windows 365 Cloud PC Validation'}

    @@ -226,12 +274,17 @@ export default function AutopilotValidationSection({

    Are you sure you want to disable this validation?

    - {disableConfirm === 'autopilot' && !validateCorporateIdentifier && ( + {disableConfirm === 'autopilot' && !validateCorporateIdentifier && !validateCloudPcDevice && (
    This is the last active validation. Disabling it will cause the backend to reject all agent requests for this tenant.
    )} - {disableConfirm === 'corporate' && !validateAutopilotDevice && ( + {disableConfirm === 'corporate' && !validateAutopilotDevice && !validateCloudPcDevice && ( +
    + This is the last active validation. Disabling it will cause the backend to reject all agent requests for this tenant. +
    + )} + {disableConfirm === 'cloudpc' && !validateAutopilotDevice && !validateCorporateIdentifier && (
    This is the last active validation. Disabling it will cause the backend to reject all agent requests for this tenant.
    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 4405cd6c..b3cfb62b 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 @@ -16,6 +16,8 @@ export function SectionAutopilotValidation() { validateCorporateIdentifier, validateDeviceAssociation, handleToggleDeviceAssociationValidation, + validateCloudPcDevice, + handleToggleCloudPcValidation, saveValidationGate, autopilotConsentInProgress, savingSection, beginDeviceValidationConsentFlow, detectExistingAccess, @@ -142,6 +144,8 @@ export function SectionAutopilotValidation() { validateDeviceAssociation={validateDeviceAssociation} onToggleDeviceAssociation={handleToggleDeviceAssociationValidation} showDeviceAssociationToggle={showDeviceAssociationToggle} + validateCloudPcDevice={validateCloudPcDevice} + onToggleCloudPc={handleToggleCloudPcValidation} autopilotConsentInProgress={autopilotConsentInProgress} saving={savingSection === "autopilotValidation"} onBeginConsent={beginDeviceValidationConsentFlow} diff --git a/src/Web/autopilot-monitor-web/app/settings/types.ts b/src/Web/autopilot-monitor-web/app/settings/types.ts index 9ffad5de..dc93b041 100644 --- a/src/Web/autopilot-monitor-web/app/settings/types.ts +++ b/src/Web/autopilot-monitor-web/app/settings/types.ts @@ -43,6 +43,14 @@ export interface TenantConfiguration { * UI surface is gated to Global Admins until DevPrep ships GA. */ validateDeviceAssociation?: boolean; + /** + * Windows 365 Cloud PC validation (fallback after Autopilot/Corporate Identifier). + * Cloud PCs are never Autopilot-registered; the backend instead matches the Intune device + * id from the agent's MDM client certificate against the tenant's Cloud PC inventory + * (Graph virtualEndpoint/cloudPCs). Requires the optional CloudPC.Read.All add-on + * permission ("W365CloudPcValidation" in Optional Graph capabilities). + */ + validateCloudPcDevice?: 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/types/session.ts b/src/Web/autopilot-monitor-web/types/session.ts index 6805b305..f258aef8 100644 --- a/src/Web/autopilot-monitor-web/types/session.ts +++ b/src/Web/autopilot-monitor-web/types/session.ts @@ -32,6 +32,8 @@ export interface Session { isUserDriven?: boolean; /** Self-deploying/kiosk Autopilot profile (CloudAssignedOobeConfig 0x20|0x40, agent-detected at registration). */ isSelfDeployingProfile?: boolean; + /** Windows 365 Cloud PC (agent-detected marker AND: Windows365 registry key + CloudManagedDesktopExtension service). Independent of validatedBy === "CloudPc" (server-side Graph verification). */ + isCloudPc?: boolean; agentVersion?: string; // OS details osName?: string; diff --git a/src/Web/autopilot-monitor-web/utils/sessionExportUtils.ts b/src/Web/autopilot-monitor-web/utils/sessionExportUtils.ts index e9cdd557..9510c393 100644 --- a/src/Web/autopilot-monitor-web/utils/sessionExportUtils.ts +++ b/src/Web/autopilot-monitor-web/utils/sessionExportUtils.ts @@ -118,6 +118,7 @@ export interface SessionCsvData { apiRequestCount?: number; stalledAt?: string; failureSnapshotJson?: string; + isCloudPc?: boolean; } export function generateSessionCsvExport(session: SessionCsvData): string { @@ -126,7 +127,7 @@ export function generateSessionCsvExport(session: SessionCsvData): string { // PartitionKey = TenantId, RowKey = SessionId // Newer fields are APPENDED (never inserted) so the column prefix stays stable // for anything parsing older exports. - const header = "PartitionKey,RowKey,SerialNumber,DeviceName,Manufacturer,Model,OsName,OsBuild,OsDisplayVersion,OsEdition,OsLanguage,IsUserDriven,IsPreProvisioned,StartedAt,CompletedAt,AgentVersion,EnrollmentType,CurrentPhase,Status,EventCount,FailureReason,LastEventAt,DurationSeconds,DiagnosticsBlobName,RebootCount,FailureSource,ReconcileReason,AdminMarkedAction,ValidatedBy,IsHybridJoin,IsSelfDeployingProfile,ConnectionType,GeoCountry,GeoRegion,GeoCity,AvgApiLatencyMs,ApiRequestCount,StalledAt,FailureSnapshotJson"; + const header = "PartitionKey,RowKey,SerialNumber,DeviceName,Manufacturer,Model,OsName,OsBuild,OsDisplayVersion,OsEdition,OsLanguage,IsUserDriven,IsPreProvisioned,StartedAt,CompletedAt,AgentVersion,EnrollmentType,CurrentPhase,Status,EventCount,FailureReason,LastEventAt,DurationSeconds,DiagnosticsBlobName,RebootCount,FailureSource,ReconcileReason,AdminMarkedAction,ValidatedBy,IsHybridJoin,IsSelfDeployingProfile,ConnectionType,GeoCountry,GeoRegion,GeoCity,AvgApiLatencyMs,ApiRequestCount,StalledAt,FailureSnapshotJson,IsCloudPc"; const row = [ esc(session.tenantId), esc(session.sessionId), @@ -167,6 +168,7 @@ export function generateSessionCsvExport(session: SessionCsvData): string { String(session.apiRequestCount ?? ""), esc(session.stalledAt), esc(session.failureSnapshotJson), + String(session.isCloudPc ?? ""), ].join(","); return "\uFEFF" + header + "\n" + row; }