mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs (#127)
* w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs Windows 365 Cloud PCs are never Autopilot-registered, so the serial lookup (and the Corporate Identifier fallback) structurally reject them and Cloud PC first-connect enrollment could not be monitored. New stage-3 fallback in the device-validation chain: extract the Intune device id from the chain-validated MDM client certificate's Subject CN (field-verified: CN == managedDevice id == cloudPC.managedDeviceId) and require a matching cloudPC object in the claimed tenant (Graph v1.0 virtualEndpoint/cloudPCs, $filter=managedDeviceId eq). Only service-provisioned Cloud PCs have such an object — a regular enrolled non-Autopilot device still ends in the 403, and local W365 markers are never consulted for auth. - Shared: ValidatorType.CloudPc = 5, TenantConfiguration.ValidateCloudPcDevice (default false, tenant-admin writable), GraphAppPermissions.CloudPCReadAll, GraphFeatureCatalog feature W365CloudPcValidation -> CloudPC.Read.All - Backend: CloudPcDeviceValidator (AutopilotDeviceValidator sibling contract: 30/5 min cache, 2 attempts, transient->503; GUID gate before the OData filter; exact-match guard; Graph 403 = definitive "grant missing", not transient), SecurityValidator CN extraction + fallback stage + hard gate, wiring through the 5 agent endpoints, TableConfigRepository persistence - Consent script: W365CloudPcValidation in catalog/ValidateSet/help; NEW GraphFeatureCatalogSyncTests pins C# catalog <-> grant script (previously comment-only lock-step) - Bootstrap dev script v2.3-dev.3: guard 4 (uptime window) exempted while the Cloud-PC relax is active — Cloud PCs run headless for days before first connect; the fresh-profile window is the time anchor there (+ Pester tests) - Web: validation toggle + context plumbing, ValidatedBy "CloudPc" label, privacy-page validation claim generalized (Last updated bumped) - Docs: OKF concept doc backend/cloudpc-device-validation.md + index + log Verified: web tsc clean, ESLint 0 errors (99 pre-existing warnings = ratchet cap, none added). .NET build/tests and Pester run in CI — SDK download and pwsh are unavailable in this sandbox's network policy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv * w365: agent-side IsCloudPc flag + engine expectation "no Device-ESP phase" Companion to the CloudPc validator: the agent now classifies Windows 365 Cloud PCs locally with the bootstrap's field-verified marker AND (HKLM\SOFTWARE\Microsoft\Windows365 key + installed CloudManagedDesktopExtension service, probed via its Services registry key so net48 needs no System.ServiceProcess). SKIP-safe: any error resolves false. Never an auth input - backend admission stays exclusively the cert-CN-bound Graph lookup (ValidatedBy=CloudPc); the two signals are independent and may disagree. Two transport rails: - Session metadata: SessionRegistration.IsCloudPc -> Sessions table (sticky-true OR across re-registrations, IsSelfDeployingProfile pattern) -> SessionsIndex full mirror -> SessionSummary -> new isCloudPc search filter end to end (SearchSessions + QueryRawSessions + pagination fingerprint + all three in-memory predicates; MCP search_sessions / query_raw_sessions params + summary projection; web type, Cloud PC pill, SessionInfoCard enrollment label, CSV export appended column). - Decision engine: isCloudPc rides the EnrollmentFactsObserved payload, recorded set-once for BOTH values in EnrollmentScenarioObservations.CloudPc and annotates the scenario profile reason cloud_pc_first_connect:no_device_esp_expected - the engine-recorded expectation that Device-ESP already ran headless at provisioning and the session starts at Account Setup. Deliberately reason+observation only: no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME- classified (Classic), EspConfig keeps configured-FirstSync semantics. Positive marker surfaces as cloud_pc_marker in the audit-trail census. Updater idempotency: the cloudPcAnnotates gate keeps the generic v1 reason branch from re-claiming the slot on repeat posts (would bump EvidenceOrdinal every post). Additive-nullable observation - no state schema bump (v4 convention), serializer roundtrip pinned. Tests: CloudPcDetector resolver (marker AND, throwing probe), registration body, facts-signal payload shape, updater annotation/idempotency/ self-deploying interplay, reducer observation both values + census fold, serializer roundtrip, index projection superset, sticky-true both directions. Suites: DecisionCore 500, Functions 3941, Agent V2 2154, vitest 632 - all green; tsc web+MCP clean; ESLint 0 errors (cap 99). Docs: cloudpc-device-validation.md follow-up section replaced with the shipped mechanism + log entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
17affb6dfd
commit
1c5f69a9fe
@@ -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 <CN>` 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`
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
+5
@@ -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<string, string>)payload);
|
||||
var cloudPc = payload["isCloudPc"];
|
||||
Assert.True(cloudPc == "true" || cloudPc == "false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the marker-AND contract of <see cref="CloudPcDetector.ResolveIsCloudPc"/>: 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 <c>Test-IsCloudPc</c>.
|
||||
/// </summary>
|
||||
public sealed class CloudPcDetectorTests
|
||||
{
|
||||
private static Func<string, bool> Probe(params string[] existingKeys)
|
||||
{
|
||||
var set = new HashSet<string>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -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]
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects whether the agent runs on a Windows 365 Cloud PC, using the same marker
|
||||
/// evidence the bootstrap script's <c>Test-IsCloudPc</c> 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):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>HKLM\SOFTWARE\Microsoft\Windows365</c> exists, AND</item>
|
||||
/// <item>the <c>CloudManagedDesktopExtension</c> service (Microsoft Cloud Managed
|
||||
/// Desktop Extension, the W365 management agent that runs ON Cloud PCs) is
|
||||
/// installed — probed via its <c>HKLM\SYSTEM\CurrentControlSet\Services</c> key,
|
||||
/// so the agent needs no System.ServiceProcess dependency.</item>
|
||||
/// </list>
|
||||
/// 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).
|
||||
/// <para>
|
||||
/// Display/context metadata only — the flag rides on <c>SessionRegistration</c> and the
|
||||
/// <c>EnrollmentFactsObserved</c> signal. It is NEVER an auth input: backend device
|
||||
/// validation trusts exclusively the cert-CN-bound Graph lookup
|
||||
/// (<c>CloudPcDeviceValidator</c>), not this locally-derived value.
|
||||
/// Exception-swallowing, false on any error — mirrors the SKIP-safe bootstrap contract
|
||||
/// and the <see cref="EnrollmentRegistryDetector"/> degradation rules.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure decision logic for <see cref="DetectIsCloudPc"/>. Exposed internally so the
|
||||
/// marker-AND semantics can be exercised without a real registry (same seam contract
|
||||
/// as <see cref="EnrollmentRegistryDetector.ResolveHybridJoinFromValues"/>).
|
||||
/// <paramref name="subKeyExists"/> receives an HKLM-relative subkey path and reports
|
||||
/// whether it exists; a throwing probe degrades to <c>false</c>.
|
||||
/// </summary>
|
||||
internal static bool ResolveIsCloudPc(Func<string, bool> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, string>(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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// W365 — reducer-level coverage for the Cloud PC marker fact riding
|
||||
/// <see cref="DecisionSignalKind.EnrollmentFactsObserved"/>: 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.
|
||||
/// </summary>
|
||||
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<string, string>? 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<string, string>
|
||||
{
|
||||
[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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
+93
@@ -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<string, string>
|
||||
{
|
||||
[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<string, string> Facts() => new Dictionary<string, string>
|
||||
{
|
||||
[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<string, string>
|
||||
{
|
||||
[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<string, string>
|
||||
{
|
||||
[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,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="CloudPcDeviceValidator"/> — 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 <c>AutopilotDeviceValidator</c>;
|
||||
/// behavioural drift would surface here. Response shapes match a real
|
||||
/// virtualEndpoint/cloudPCs payload (field-captured 2026-08-06 on a W365 tenant).
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guard: the customer-side grant script (scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1)
|
||||
/// duplicates the feature→permission catalog from <see cref="GraphFeatureCatalog"/> — in its
|
||||
/// <c>$FeatureCatalog</c> hashtable AND in the <c>[ValidateSet(...)]</c> of <c>-Features</c>.
|
||||
/// The sync was previously comment-only ("Keep in lock-step with …") and had no enforcement;
|
||||
/// this test fails the build when either side drifts.
|
||||
/// </summary>
|
||||
public class GraphFeatureCatalogSyncTests
|
||||
{
|
||||
[Fact]
|
||||
public void GrantScript_FeatureCatalog_MatchesSharedCatalog()
|
||||
{
|
||||
var script = ReadGrantScript();
|
||||
|
||||
// Parse: $FeatureCatalog = @{ 'Name' = @('Perm', ...) ... }
|
||||
var blockMatch = Regex.Match(script, @"\$FeatureCatalog\s*=\s*@\{(?<body>[^}]*)\}", RegexOptions.Singleline);
|
||||
Assert.True(blockMatch.Success, "Could not locate the $FeatureCatalog hashtable in the grant script.");
|
||||
|
||||
var scriptCatalog = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match entry in Regex.Matches(blockMatch.Groups["body"].Value, @"'(?<feature>[^']+)'\s*=\s*@\((?<perms>[^)]*)\)"))
|
||||
{
|
||||
var perms = Regex.Matches(entry.Groups["perms"].Value, @"'(?<p>[^']+)'")
|
||||
.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\((?<body>[^\)]*)\)\]");
|
||||
Assert.True(vsMatch.Success, "Could not locate the [ValidateSet(...)] of -Features in the grant script.");
|
||||
|
||||
var scriptFeatures = Regex.Matches(vsMatch.Groups["body"].Value, @"'(?<f>[^']+)'")
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -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 _));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+63
@@ -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 ====
|
||||
|
||||
/// <summary>
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"];
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -48,6 +48,7 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
new KeyValuePair<string, string?>("isPreProvisioned", filter.IsPreProvisioned?.ToString()),
|
||||
new KeyValuePair<string, string?>("isHybridJoin", filter.IsHybridJoin?.ToString()),
|
||||
new KeyValuePair<string, string?>("isSelfDeployingProfile", filter.IsSelfDeployingProfile?.ToString()),
|
||||
new KeyValuePair<string, string?>("isCloudPc", filter.IsCloudPc?.ToString()),
|
||||
new KeyValuePair<string, string?>("geoCountry", filter.GeoCountry),
|
||||
new KeyValuePair<string, string?>("startedAfter", filter.StartedAfter?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)),
|
||||
new KeyValuePair<string, string?>("startedBefore", filter.StartedBefore?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)),
|
||||
|
||||
@@ -394,6 +394,7 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.GraphTokenServ
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.AutopilotDeviceValidator>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CorporateIdentifierValidator>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.DeviceAssociationValidator>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CloudPcDeviceValidator>();
|
||||
|
||||
// Graph add-on permission detection + script display-name resolution.
|
||||
// Detector parses the `roles` claim of the SP access token; Resolver fetches
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates Windows 365 Cloud PCs via the service-side Cloud PC inventory
|
||||
/// (<c>virtualEndpoint/cloudPCs</c>). 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 <c>cloudPC</c> object whose
|
||||
/// <c>managedDeviceId</c> 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 <see cref="AutopilotDeviceValidator"/> — 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
|
||||
/// - <see cref="CloudPcValidationResult.IsTransient"/> 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).
|
||||
/// </summary>
|
||||
public class CloudPcDeviceValidator
|
||||
{
|
||||
private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30);
|
||||
private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly ILogger<CloudPcDeviceValidator> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly GraphTokenService _graphTokenService;
|
||||
|
||||
public CloudPcDeviceValidator(
|
||||
ILogger<CloudPcDeviceValidator> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache cache,
|
||||
GraphTokenService graphTokenService)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_cache = cache;
|
||||
_graphTokenService = graphTokenService;
|
||||
}
|
||||
|
||||
public async Task<CloudPcValidationResult> 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<CloudPcValidationResult> 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 ?? "<none>", normalizedId,
|
||||
result.CloudPcId ?? "<none>", result.ManagedDeviceName ?? "<none>");
|
||||
}
|
||||
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 ?? "<none>", normalizedId, attempt);
|
||||
|
||||
return new CloudPcValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
IsTransient = true,
|
||||
IntuneDeviceId = normalizedId,
|
||||
ErrorMessage = $"Error during Cloud PC validation: {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure-function: maps a <c>virtualEndpoint/cloudPCs</c> Graph response body to a
|
||||
/// <see cref="CloudPcValidationResult"/>. Performs an exact-match guard on
|
||||
/// <c>managedDeviceId</c> so a widened server-side filter can never produce a
|
||||
/// false positive.
|
||||
/// </summary>
|
||||
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<JObject>(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; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool IsTransient { get; set; }
|
||||
|
||||
/// <summary>Intune device id extracted from the MDM client certificate's Subject CN.</summary>
|
||||
public string? IntuneDeviceId { get; set; }
|
||||
|
||||
/// <summary>Cloud PC object id (virtualEndpoint/cloudPCs) when validation succeeded.</summary>
|
||||
public string? CloudPcId { get; set; }
|
||||
|
||||
/// <summary>Intune device name of the Cloud PC (CPC-*) — diagnostic only, never a gate.</summary>
|
||||
public string? ManagedDeviceName { get; set; }
|
||||
|
||||
/// <summary>Windows 365 service plan (e.g. "Cloud PC Enterprise 2vCPU/4GB/128GB") — diagnostic only.</summary>
|
||||
public string? ServicePlanName { get; set; }
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -131,6 +131,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// </summary>
|
||||
public const string IsSelfDeployingProfile = "isSelfDeployingProfile";
|
||||
|
||||
/// <summary>
|
||||
/// On <c>EnrollmentFactsObserved</c>: "true" / "false" from
|
||||
/// <c>CloudPcDetector.DetectIsCloudPc()</c> — 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.
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,17 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
/// </summary>
|
||||
public SignalFact<bool>? RegistrySelfDeployingProfile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw marker fact from <see cref="Signals.DecisionSignalKind.EnrollmentFactsObserved"/>
|
||||
/// (payload <c>isCloudPc</c>, from the Windows365 registry key + installed
|
||||
/// CloudManagedDesktopExtension service — the marker-AND field-verified by bootstrap
|
||||
/// v2.3-dev.2). <c>true</c> 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; <c>null</c> means the fact was never observed.
|
||||
/// </summary>
|
||||
public SignalFact<bool>? 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<bool>(value, sourceSignalOrdinal) };
|
||||
|
||||
public EnrollmentScenarioObservations WithCloudPc(bool value, long sourceSignalOrdinal) =>
|
||||
CloudPc != null
|
||||
? this
|
||||
: this with { CloudPc = new SignalFact<bool>(value, sourceSignalOrdinal) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
/// </summary>
|
||||
public static class EnrollmentScenarioProfileUpdater
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public const string CloudPcNoDeviceEspExpectedReason = "cloud_pc_first_connect:no_device_esp_expected";
|
||||
|
||||
// ================================================================== EnrollmentFactsObserved
|
||||
|
||||
/// <summary>
|
||||
@@ -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;
|
||||
|
||||
@@ -202,6 +202,19 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool ValidateDeviceAssociation { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>cloudPC</c> object
|
||||
/// (<c>virtualEndpoint/cloudPCs</c>, <c>managedDeviceId eq CN</c>) 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).
|
||||
/// </summary>
|
||||
public bool ValidateCloudPcDevice { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Emergency bypass for agent security gate (Global Admin use only).
|
||||
/// If true, agent requests are accepted even when ValidateAutopilotDevice is false.
|
||||
|
||||
@@ -102,6 +102,16 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool IsSelfDeployingProfile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool IsCloudPc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Which backend device-validation path accepted this registration (Autopilot S/N,
|
||||
/// Corporate Identifier, Device Association, or Bootstrap token). SERVER-POPULATED:
|
||||
|
||||
@@ -25,5 +25,13 @@ namespace AutopilotMonitor.Shared.Models.Graph
|
||||
|
||||
/// <summary>Read Intune managed apps (future use — Win32 app display names not yet covered).</summary>
|
||||
public const string DeviceManagementAppsReadAll = "DeviceManagementApps.Read.All";
|
||||
|
||||
/// <summary>
|
||||
/// Read Windows 365 Cloud PC objects (<c>virtualEndpoint/cloudPCs</c>). 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.
|
||||
/// </summary>
|
||||
public const string CloudPCReadAll = "CloudPC.Read.All";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,15 @@ namespace AutopilotMonitor.Shared.Models.Graph
|
||||
/// <summary>Resolves Intune Platform Script + Remediation Script display names in session timelines.</summary>
|
||||
public const string FeatureScriptDisplayNames = "ScriptDisplayNames";
|
||||
|
||||
/// <summary>Validates Windows 365 Cloud PCs via the service-side Cloud PC inventory (cert-CN bound).</summary>
|
||||
public const string FeatureW365CloudPcValidation = "W365CloudPcValidation";
|
||||
|
||||
// Backed by a plain dictionary (netstandard2.0 + C# 9, no Immutable types).
|
||||
private static readonly Dictionary<string, string[]> _byFeature
|
||||
= new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[FeatureScriptDisplayNames] = new[] { GraphAppPermissions.DeviceManagementScriptsReadAll },
|
||||
[FeatureW365CloudPcValidation] = new[] { GraphAppPermissions.CloudPCReadAll },
|
||||
};
|
||||
|
||||
/// <summary>All registered feature identifiers.</summary>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -362,6 +363,15 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool IsSelfDeployingProfile { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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!;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -879,6 +879,14 @@ function SessionCell({
|
||||
Self-Deploying
|
||||
</span>
|
||||
)}
|
||||
{session.isCloudPc && (
|
||||
<span
|
||||
className="px-2 inline-flex items-center gap-1 text-xs leading-5 font-semibold rounded-full bg-sky-100 text-sky-800"
|
||||
title="Windows 365 Cloud PC (agent-detected W365 markers)"
|
||||
>
|
||||
Cloud PC
|
||||
</span>
|
||||
)}
|
||||
{blockedDevicesSet.has(`${session.tenantId}:${session.serialNumber}`) && (
|
||||
<span
|
||||
className="px-2 inline-flex items-center gap-1 text-xs leading-5 font-semibold rounded-full bg-orange-100 text-orange-800"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
import { DOCS_URL } from "@/utils/config";
|
||||
|
||||
const LAST_UPDATED = "31 July 2026";
|
||||
const LAST_UPDATED = "6 August 2026";
|
||||
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
|
||||
const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`;
|
||||
|
||||
@@ -197,7 +197,7 @@ export default function PrivacyPage() {
|
||||
<h2 className="text-lg font-semibold text-gray-900 mt-6">How Your Data Is Protected</h2>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Device authentication by mutual TLS</strong> 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</li>
|
||||
<li><strong>Autopilot device validation via Microsoft Graph</strong> — only devices registered in your tenant are accepted, with an optional hardware allow-list on top</li>
|
||||
<li><strong>Device registration validation via Microsoft Graph</strong> — 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</li>
|
||||
<li><strong>Entra ID authentication</strong> for portal users with a restricted signing-algorithm allow-list and identity PII logging disabled</li>
|
||||
<li><strong>Fail-closed authorization</strong> — every API route must be registered with an access policy; an unregistered route is unreachable</li>
|
||||
<li><strong>Structural tenant isolation</strong> — storage partitioning by tenant, with the tenant identity taken from the validated token and never from a client-supplied header</li>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
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<void>;
|
||||
/**
|
||||
* Persist a validation-gate change immediately. The validation section has NO save bar —
|
||||
* every gate change that doesn't run the consent flow (disable, and enabling the second
|
||||
@@ -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<boolean> => {
|
||||
const saveConfiguration = useCallback(async (sectionName: string, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean; validateCloudPcDevice?: boolean; unrestrictedMode?: boolean }): Promise<boolean> => {
|
||||
// Read-only viewers (Operators) have no save affordances; this guard covers any path
|
||||
// that still reaches a save (the backend would 403 the 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,
|
||||
|
||||
|
||||
+59
-6
@@ -13,6 +13,10 @@ interface AutopilotValidationSectionProps {
|
||||
onToggleDeviceAssociation?: (newValue: boolean) => void | Promise<void>;
|
||||
/** Render the DevPrep Device Association toggle. Gated to Global Admins by the parent. */
|
||||
showDeviceAssociationToggle?: boolean;
|
||||
/** 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<void>;
|
||||
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')}
|
||||
</div>
|
||||
|
||||
{/* Windows 365 Cloud PC — fallback gate for Cloud PCs; permission via the Optional Graph capabilities add-on */}
|
||||
{onToggleCloudPc && (
|
||||
<div className="border-t border-gray-100 pt-5 space-y-3" data-testid="cloudpc-validation-toggle">
|
||||
<p className="text-sm font-semibold text-gray-700 tracking-wide">Windows 365</p>
|
||||
<label className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">Enable Windows 365 Cloud PC Validation</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Validates Windows 365 Cloud PCs against the tenant's Cloud PC inventory (Graph{" "}
|
||||
<code className="text-xs">virtualEndpoint/cloudPCs</code>), matched by the Intune device id
|
||||
from the agent's MDM certificate. Cloud PCs are never Autopilot-registered — enable this
|
||||
as a fallback so first-connect enrollment (Account Setup) of Cloud PCs can be monitored.
|
||||
Requires the optional <strong>CloudPC.Read.All</strong> permission — grant the{" "}
|
||||
<strong>W365CloudPcValidation</strong> add-on under <em>Optional Graph capabilities</em>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggleCloudPc}
|
||||
disabled={saving}
|
||||
aria-label="Toggle Windows 365 Cloud PC validation"
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${validateCloudPcDevice ? 'bg-emerald-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${validateCloudPcDevice ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DevPrep Device Association — Private Preview, GA-gated, shadow-mode (no enrollment block) */}
|
||||
{showDeviceAssociationToggle && onToggleDeviceAssociation && (
|
||||
<div className="border-t border-gray-100 pt-5 space-y-3" data-testid="devprep-association-toggle">
|
||||
@@ -194,7 +240,7 @@ export default function AutopilotValidationSection({
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<p className="text-sm text-amber-900">
|
||||
<strong>Important:</strong>{" "}
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -218,7 +264,9 @@ export default function AutopilotValidationSection({
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">Disable Validation</h3>
|
||||
<p className="text-sm text-amber-600 font-medium">
|
||||
{disableConfirm === 'autopilot' ? 'Autopilot Device Validation' : 'Corporate Identifier Validation'}
|
||||
{disableConfirm === 'autopilot' ? 'Autopilot Device Validation'
|
||||
: disableConfirm === 'corporate' ? 'Corporate Identifier Validation'
|
||||
: 'Windows 365 Cloud PC Validation'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,12 +274,17 @@ export default function AutopilotValidationSection({
|
||||
<p className="text-sm text-gray-700 mb-2">
|
||||
Are you sure you want to disable this validation?
|
||||
</p>
|
||||
{disableConfirm === 'autopilot' && !validateCorporateIdentifier && (
|
||||
{disableConfirm === 'autopilot' && !validateCorporateIdentifier && !validateCloudPcDevice && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4 text-sm text-red-800">
|
||||
This is the last active validation. Disabling it will cause the backend to <strong>reject all agent requests</strong> for this tenant.
|
||||
</div>
|
||||
)}
|
||||
{disableConfirm === 'corporate' && !validateAutopilotDevice && (
|
||||
{disableConfirm === 'corporate' && !validateAutopilotDevice && !validateCloudPcDevice && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4 text-sm text-red-800">
|
||||
This is the last active validation. Disabling it will cause the backend to <strong>reject all agent requests</strong> for this tenant.
|
||||
</div>
|
||||
)}
|
||||
{disableConfirm === 'cloudpc' && !validateAutopilotDevice && !validateCorporateIdentifier && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4 text-sm text-red-800">
|
||||
This is the last active validation. Disabling it will cause the backend to <strong>reject all agent requests</strong> for this tenant.
|
||||
</div>
|
||||
|
||||
+4
@@ -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}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user