From 6702e1d43b3b2e345fa68e5bf78e09fc83894f4d Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 14 Apr 2026 16:09:32 +0200 Subject: [PATCH] feat: added DevPrepValidator (device association) and restructured agent to distinguish Classic vs v2 autopilot flow, in addition option for dev build added --- .../Install-AutopilotMonitor-Dev.ps1 | 273 ++++++++++++++++++ ...build_and_upload_debug_agent_build_dev.ps1 | 186 ++++++++++++ .../Flows/EnrollmentFlowTests.cs | 119 ++++++++ .../Monitoring/Core/CollectorCoordinator.cs | 28 ++ .../Monitoring/Core/MonitoringService.cs | 12 + .../Monitoring/Flows/ClassicAutopilotFlow.cs | 11 + .../Monitoring/Flows/DevicePreparationFlow.cs | 11 + .../Monitoring/Flows/EnrollmentFlowFactory.cs | 21 ++ .../Flows/IEnrollmentFlowHandler.cs | 30 ++ .../EnrollmentTracker.CompletionLogic.cs | 3 +- .../EnrollmentTracker.EventHandlers.cs | 11 +- .../Monitoring/Tracking/EnrollmentTracker.cs | 63 ++++ .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../DeviceAssociationValidatorTests.cs | 181 ++++++++++++ .../TableStorage/TableConfigRepository.cs | 2 + .../UpdateTenantConfigurationFunction.cs | 9 +- .../Sessions/RegisterSessionFunction.cs | 3 +- .../AutopilotMonitor.Functions/Program.cs | 1 + .../Security/DeviceAssociationResult.cs | 59 ++++ .../Security/DeviceAssociationValidator.cs | 249 ++++++++++++++++ .../Security/SecurityValidator.cs | 65 ++++- .../Models/Config/TenantConfiguration.cs | 10 + .../Models/Enrollment/EnrollmentType.cs | 51 ++++ .../Models/SessionApiModels.cs | 22 ++ .../app/settings/TenantConfigContext.tsx | 25 +- .../components/AutopilotValidationSection.tsx | 37 +++ .../sections/SectionAutopilotValidation.tsx | 15 + .../app/settings/types.ts | 6 + 28 files changed, 1491 insertions(+), 14 deletions(-) create mode 100644 scripts/Bootstrap/Install-AutopilotMonitor-Dev.ps1 create mode 100644 scripts/Deployment/build_and_upload_debug_agent_build_dev.ps1 create mode 100644 src/Agent/AutopilotMonitor.Agent.Core.Tests/Flows/EnrollmentFlowTests.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Flows/ClassicAutopilotFlow.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Flows/DevicePreparationFlow.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Flows/EnrollmentFlowFactory.cs create mode 100644 src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Flows/IEnrollmentFlowHandler.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationResult.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationValidator.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/Enrollment/EnrollmentType.cs diff --git a/scripts/Bootstrap/Install-AutopilotMonitor-Dev.ps1 b/scripts/Bootstrap/Install-AutopilotMonitor-Dev.ps1 new file mode 100644 index 00000000..a64a90b4 --- /dev/null +++ b/scripts/Bootstrap/Install-AutopilotMonitor-Dev.ps1 @@ -0,0 +1,273 @@ +<# +.SYNOPSIS + DEV variant of the bootstrap script to deploy and start the Autopilot Monitor agent. + +.DESCRIPTION + Funktionally identical to Install-AutopilotMonitor.ps1 with two differences: + 1. Default $AgentDownloadUrl points to AutopilotMonitor-Agent-dev.zip + 2. Integrity check is read from version-dev.json (NOT version.json) + This enables parallel lab testing of unreleased agent builds via a dedicated + Intune Platform Script assignment, without touching any production artefacts. + + See Install-AutopilotMonitor.ps1 for the full behaviour (guards, SHA-256 + integrity verification, scheduled task registration via --install, etc.). + +.PARAMETER AgentDownloadUrl + URL to download the DEV agent binaries from (ZIP file). Defaults to + AutopilotMonitor-Agent-dev.zip in the same blob container as production. + +.NOTES + - ASCII-only. PowerShell 5.1 / IME AgentExecutor reads scripts without BOM + as ANSI and corrupts multi-byte chars. + - Guards are intentionally identical to Prod bootstrapper so lab behaviour + matches production behaviour on fresh images. + +.CHANGELOG + 2026-04-14 v1.1-dev Initial DEV variant forked from Install-AutopilotMonitor.ps1 v1.1. + Default URL -> AutopilotMonitor-Agent-dev.zip, integrity file -> version-dev.json. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent-dev.zip", + + [Parameter(Mandatory = $false)] + [int]$MaxBootstrapWindowHours = 12 +) + +# Script version (bump on meaningful changes; see .CHANGELOG above) +$ScriptVersion = "1.1-dev" + +# Configuration - Everything in ProgramData for easy cleanup +$AgentBasePath = "$env:ProgramData\AutopilotMonitor" +$AgentBinPath = "$AgentBasePath\Agent" +$AgentLogPath = "$AgentBasePath\Logs" +$LogFile = "$AgentLogPath\bootstrap_agent.log" + +# Ensure directories exist +New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null +New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null +New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null + +function Write-Log { + param([string]$Message) + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $logMessage = "[$timestamp] $Message" + Write-Output $logMessage + Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue +} + +function Get-FileMd5Base64 { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $md5 = [System.Security.Cryptography.MD5]::Create() + try { + $stream = [System.IO.File]::OpenRead($Path) + try { + $hashBytes = $md5.ComputeHash($stream) + } + finally { + $stream.Dispose() + } + + return [Convert]::ToBase64String($hashBytes) + } + finally { + $md5.Dispose() + } +} + +try { + Write-Log "===== Autopilot Monitor Bootstrap Started (DEV) =====" + Write-Log "Bootstrap script version: v$ScriptVersion" + Write-Log "Agent download URL: $AgentDownloadUrl" + + # -- Pre-flight: Multi-signal guard (identical to production bootstrapper) -- + # Guard 1: Ghost re-installs (registry marker from previous deployment) + # Guard 2: Productive devices (real user profile exists -- WMI + filesystem) + # Guard 3: Productive devices (a real user has logged on before) + # Guard 4: Bootstrap window expired (device uptime > 12h without agent) + # Guard 5: Agent binary already present from a previous run + + # Guard 1 + $deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed + if ($deployed) { + Write-Log "SKIP: Agent was previously deployed at $deployed." + exit 0 + } + + # Guard 2 + $excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users)$' + + $profileNames = @( + try { + Get-CimInstance Win32_UserProfile -ErrorAction Stop | + Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } | + ForEach-Object { Split-Path $_.LocalPath -Leaf } + } catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." } + + (Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).Name + ) | Where-Object { $_ -and $_ -notmatch $excludePattern } | + Select-Object -Unique + + if ($profileNames) { + $names = ($profileNames | Select-Object -First 3) -join ', ' + Write-Log "SKIP: Real user profile(s) found ($names). Device appears productive." + exit 0 + } + + # Guard 3 + $lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser + if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*') { + Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive." + exit 0 + } + + # Guard 4 + $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." + exit 0 + } + + # Guard 5 + if (Test-Path $AgentBinPath) { + $existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue + if ($existingAgent) { + Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)." + exit 0 + } + } + + Write-Log "Pre-flight checks passed -- no prior deployment, no real user profiles, no logged-on user, within bootstrap window" + + # Download and extract agent binaries + $agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe" + + if (Test-Path $agentExePath) { + Write-Log "Agent already installed at $agentExePath" + } + else { + Write-Log "Downloading DEV agent from $AgentDownloadUrl..." + + try { + # DEV: Integrity-File ist version-dev.json (im selben Container wie das -dev ZIP) + $versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', 'version-dev.json' + $expectedSha256 = $null + + try { + Write-Log "Fetching version-dev.json from $versionJsonUrl for integrity verification..." + $versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop + if ($versionJsonResponse.sha256) { + $expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant() + Write-Log "SHA-256 hash from version-dev.json: $expectedSha256 (version: $($versionJsonResponse.version))" + } else { + Write-Log "version-dev.json has no sha256 field - falling back to legacy MD5 check" + } + } + catch { + Write-Log "WARNING: Could not fetch version-dev.json - falling back to legacy MD5 check: $($_.Exception.Message)" + } + + $zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent-dev.zip" + $maxDownloadAttempts = 3 + $downloadAttempt = 0 + $downloadResponse = $null + + do { + $downloadAttempt++ + try { + Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}" + $downloadResponse = Invoke-WebRequest ` + -Uri $AgentDownloadUrl ` + -OutFile $zipPath ` + -UseBasicParsing ` + -TimeoutSec 30 ` + -ErrorAction Stop ` + -PassThru + Write-Log "Downloaded agent to $zipPath" + break + } + catch { + if ($downloadAttempt -ge $maxDownloadAttempts) { + throw + } + + $retryDelaysInSeconds = @(2, 4, 8) + $retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1] + Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..." + Start-Sleep -Seconds $retryDelaySeconds + } + } while ($downloadAttempt -lt $maxDownloadAttempts) + + if ($expectedSha256) { + $actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() + Write-Log "Validating SHA-256 hash against version-dev.json" + if ($actualSha256 -ne $expectedSha256) { + throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted." + } + Write-Log "SHA-256 integrity check passed" + } + else { + $expectedMd5Header = $downloadResponse.Headers["Content-MD5"] + $expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() } + if ($expectedMd5 -notmatch '\S') { + Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation" + } + else { + $actualMd5 = Get-FileMd5Base64 -Path $zipPath + Write-Log "Validating Content-MD5 header against downloaded ZIP" + if ($actualMd5 -ne $expectedMd5) { + throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'" + } + Write-Log "MD5 integrity check passed" + } + } + + Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force + Write-Log "Extracted agent to $AgentBinPath" + + Remove-Item $zipPath -Force -ErrorAction SilentlyContinue + Write-Log "Cleaned up temporary files" + + if (-not (Test-Path $agentExePath)) { + throw "Agent executable not found after extraction at $agentExePath" + } + + Write-Log "DEV agent installation completed successfully" + } + catch { + Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)" + throw + } + } + + Write-Log "Calling agent install mode (--install)..." + & $agentExePath --install + $installExitCode = $LASTEXITCODE + if ($installExitCode -ne 0) { + throw "Agent install failed with exit code $installExitCode" + } + Write-Log "Agent install mode completed successfully" + Write-Log "===== Bootstrap Completed Successfully (DEV) =====" + + exit 0 +} +catch { + Write-Log "===== Bootstrap FAILED (DEV) =====" + Write-Log "ERROR: $($_.Exception.Message)" + Write-Log "Stack trace: $($_.ScriptStackTrace)" + Write-Log "Please check log file: $LogFile" + + $errMsg = "AutopilotMonitor bootstrap (DEV) failed: $($_.Exception.Message)" + if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' } + [Console]::Error.WriteLine($errMsg) + + exit 1 +} diff --git a/scripts/Deployment/build_and_upload_debug_agent_build_dev.ps1 b/scripts/Deployment/build_and_upload_debug_agent_build_dev.ps1 new file mode 100644 index 00000000..039abe7b --- /dev/null +++ b/scripts/Deployment/build_and_upload_debug_agent_build_dev.ps1 @@ -0,0 +1,186 @@ +# Upload DEV Debug Agent Build Script +# Builds the Debug output, creates a separate -dev ZIP archive, and uploads it +# (plus version-dev.json and the DEV bootstrapper) to Azure Blob Storage. +# +# Unterschiede zum produktiven Script (build_and_upload_debug_agent_build.ps1): +# - ZIP-Name: AutopilotMonitor-Agent-dev.zip (nicht AutopilotMonitor-Agent.zip) +# - Integrity-File: version-dev.json (nicht version.json) +# - Bootstrap-Upload: Install-AutopilotMonitor-Dev.ps1 (nicht Install-AutopilotMonitor.ps1) +# - KEIN Upload von: version.json, Install-AutopilotMonitor.ps1, Test-ShouldBootstrapAgent.ps1 +# - KEIN Update von: AdminConfiguration Table (Hash-Oracle bleibt produktiv unangetastet) +# +# => Produktion wird bit-identisch NICHT beeinflusst. Lab-Geraete ziehen via separater +# Intune-Assignment das Dev-ZIP ueber den Dev-Bootstrapper. + +$ErrorActionPreference = "Stop" + +# Configuration +$projectPath = "C:\Code\GitHubRepos\Autopilot-Monitor\src\Agent\AutopilotMonitor.Agent\AutopilotMonitor.Agent.csproj" +$summaryDialogProjectPath = "C:\Code\GitHubRepos\Autopilot-Monitor\src\Agent\AutopilotMonitor.SummaryDialog\AutopilotMonitor.SummaryDialog.csproj" +$buildConfiguration = "Debug" +$sourcePath = "C:\Code\GitHubRepos\Autopilot-Monitor\src\Agent\AutopilotMonitor.Agent\bin\Debug\net48" +$summaryDialogOutputPath = "C:\Code\GitHubRepos\Autopilot-Monitor\src\Agent\AutopilotMonitor.SummaryDialog\bin\Debug\net48" +$bootstrapScriptPath = "C:\Code\GitHubRepos\Autopilot-Monitor\scripts\Bootstrap\Install-AutopilotMonitor-Dev.ps1" +$zipFileName = "AutopilotMonitor-Agent-dev.zip" +$versionJsonDevName = "version-dev.json" +$tempZipPath = [System.IO.Path]::Combine($env:TEMP, $zipFileName) +$tempVersionDevPath = [System.IO.Path]::Combine($env:TEMP, $versionJsonDevName) +$bootstrapScriptName = [System.IO.Path]::GetFileName($bootstrapScriptPath) +$sasUrl = "https://autopilotmonitor.blob.core.windows.net/agent?sp=cw&st=2026-02-17T16:10:55Z&se=2028-02-18T00:25:55Z&spr=https&sv=2024-11-04&sr=c&sig=kA%2BeZlpqf4fYUvmO5YgFv%2Fk7yr6oQLPA51%2FDSQEl7hs%3D" +$sasQuery = $sasUrl.Substring($sasUrl.IndexOf('?')) +$blobUrl = "https://autopilotmonitor.blob.core.windows.net/agent/$zipFileName$sasQuery" +$bootstrapBlobUrl = "https://autopilotmonitor.blob.core.windows.net/agent/$bootstrapScriptName$sasQuery" +$versionDevBlobUrl = "https://autopilotmonitor.blob.core.windows.net/agent/$versionJsonDevName$sasQuery" + +Write-Host "=== DEV Agent Build and Upload ===" -ForegroundColor Magenta +Write-Host "Project: $projectPath" -ForegroundColor Gray +Write-Host "Build output folder: $sourcePath" -ForegroundColor Gray +Write-Host "Dev bootstrap script: $bootstrapScriptPath" -ForegroundColor Gray +Write-Host "Dev ZIP name: $zipFileName" -ForegroundColor Gray +Write-Host "Dev integrity file: $versionJsonDevName" -ForegroundColor Gray +Write-Host "Started at (UTC): $([DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss'))" -ForegroundColor Gray + +try { + # [1] Build Agent + Write-Host "`n[1/7] Building Agent project ($buildConfiguration)..." -ForegroundColor Yellow + dotnet build $projectPath -c $buildConfiguration + if ($LASTEXITCODE -ne 0) { + throw "Agent build failed with exit code $LASTEXITCODE" + } + Write-Host " Agent build completed successfully" -ForegroundColor Green + + # Verify EXE version matches version.json (prevents silent MSBuild version timing bugs) + $versionJsonCheck = Get-Content (Join-Path $sourcePath "version.json") -Raw | ConvertFrom-Json + $exeVersionCheck = [System.Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $sourcePath "AutopilotMonitor.Agent.exe")).ProductVersion + $exeVersionClean = $exeVersionCheck -replace '\+.*$', '' + if ($exeVersionClean -ne $versionJsonCheck.version) { + throw "VERSION MISMATCH: EXE reports '$exeVersionClean' but version.json says '$($versionJsonCheck.version)'. MSBuild property evaluation timing issue detected." + } + Write-Host " Version verified: EXE=$exeVersionClean matches version.json=$($versionJsonCheck.version)" -ForegroundColor Green + + # [2] Build SummaryDialog + Write-Host "`n[2/7] Building SummaryDialog project ($buildConfiguration)..." -ForegroundColor Yellow + dotnet build $summaryDialogProjectPath -c $buildConfiguration + if ($LASTEXITCODE -ne 0) { + throw "SummaryDialog build failed with exit code $LASTEXITCODE" + } + Write-Host " SummaryDialog build completed successfully" -ForegroundColor Green + + # [3] Run Agent unit tests + Write-Host "`n[3/7] Running Agent unit tests..." -ForegroundColor Yellow + $testProjectPath = "C:\Code\GitHubRepos\Autopilot-Monitor\src\Agent\AutopilotMonitor.Agent.Core.Tests\AutopilotMonitor.Agent.Core.Tests.csproj" + dotnet test $testProjectPath -c $buildConfiguration --nologo -v quiet + if ($LASTEXITCODE -ne 0) { + throw "Agent unit tests failed with exit code $LASTEXITCODE" + } + Write-Host " All agent tests passed" -ForegroundColor Green + + # Copy SummaryDialog EXE + config into Agent output folder + $summaryDialogExe = Join-Path $summaryDialogOutputPath "AutopilotMonitor.SummaryDialog.exe" + $summaryDialogConfig = Join-Path $summaryDialogOutputPath "AutopilotMonitor.SummaryDialog.exe.config" + if (Test-Path $summaryDialogExe) { + Copy-Item $summaryDialogExe -Destination $sourcePath -Force + if (Test-Path $summaryDialogConfig) { + Copy-Item $summaryDialogConfig -Destination $sourcePath -Force + } + Write-Host " Copied SummaryDialog.exe + .config into Agent output" -ForegroundColor Green + } else { + Write-Host " WARNING: SummaryDialog.exe not found at $summaryDialogExe" -ForegroundColor Yellow + } + + if (-not (Test-Path $sourcePath)) { + throw "Build output folder not found: $sourcePath" + } + if (-not (Test-Path $bootstrapScriptPath)) { + throw "Dev bootstrap script not found: $bootstrapScriptPath" + } + + # [4] Create ZIP archive (dev name) + Write-Host "`n[4/7] Creating DEV ZIP archive..." -ForegroundColor Yellow + if (Test-Path $tempZipPath) { + Remove-Item $tempZipPath -Force + Write-Host " Existing temporary ZIP removed" -ForegroundColor Gray + } + + Compress-Archive -Path "$sourcePath\*" -DestinationPath $tempZipPath -CompressionLevel Optimal + $zipSize = (Get-Item $tempZipPath).Length / 1MB + Write-Host " ZIP created: $([math]::Round($zipSize, 2)) MB" -ForegroundColor Green + + $sha256Hash = (Get-FileHash $tempZipPath -Algorithm SHA256).Hash.ToLowerInvariant() + Write-Host " ZIP SHA-256: $sha256Hash" -ForegroundColor Gray + + $exeSha256Hash = (Get-FileHash (Join-Path $sourcePath "AutopilotMonitor.Agent.exe") -Algorithm SHA256).Hash.ToLowerInvariant() + Write-Host " EXE SHA-256: $exeSha256Hash" -ForegroundColor Gray + + # Build version-dev.json IN TEMP. Prod version.json im bin-Ordner bleibt unberuehrt, + # damit ein versehentlicher Prod-Script-Lauf spaeter nicht auf einem verseuchten + # Zustand aufsetzt. + $versionDevObj = [PSCustomObject]@{ + version = $versionJsonCheck.version + sha256 = $sha256Hash + } + + # Parse bootstrap script version from DEV bootstrapper + $bootstrapVersion = $null + $bootstrapContent = Get-Content $bootstrapScriptPath -Raw + if ($bootstrapContent -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"') { + $bootstrapVersion = $Matches[1] + $versionDevObj | Add-Member -NotePropertyName "bootstrapVersion" -NotePropertyValue $bootstrapVersion -Force + Write-Host " Dev bootstrap script version: $bootstrapVersion" -ForegroundColor Gray + } else { + Write-Host " WARNING: Could not parse bootstrap `$ScriptVersion from $bootstrapScriptPath" -ForegroundColor Yellow + } + + $versionDevObj | ConvertTo-Json -Compress | Set-Content $tempVersionDevPath -Encoding UTF8 + Write-Host " version-dev.json created: $(Get-Content $tempVersionDevPath -Raw)" -ForegroundColor Gray + + $headers = @{ "x-ms-blob-type" = "BlockBlob" } + + # [5] Upload DEV ZIP + Write-Host "`n[5/7] Uploading DEV agent ZIP to Azure Blob Storage..." -ForegroundColor Yellow + $fileBytes = [System.IO.File]::ReadAllBytes($tempZipPath) + Invoke-RestMethod -Uri $blobUrl ` + -Method Put ` + -Headers $headers ` + -Body $fileBytes ` + -ContentType "application/zip" + Write-Host " DEV agent ZIP upload completed successfully" -ForegroundColor Green + + # [6] Upload DEV bootstrap script + Write-Host "`n[6/7] Uploading DEV bootstrap script to Azure Blob Storage..." -ForegroundColor Yellow + $scriptBytes = [System.IO.File]::ReadAllBytes($bootstrapScriptPath) + Invoke-RestMethod -Uri $bootstrapBlobUrl ` + -Method Put ` + -Headers $headers ` + -Body $scriptBytes ` + -ContentType "text/plain; charset=utf-8" + Write-Host " DEV bootstrap script upload completed successfully" -ForegroundColor Green + + # [7] Upload version-dev.json + Write-Host "`n[7/7] Uploading version-dev.json to Azure Blob Storage..." -ForegroundColor Yellow + $versionDevBytes = [System.IO.File]::ReadAllBytes($tempVersionDevPath) + Invoke-RestMethod -Uri $versionDevBlobUrl ` + -Method Put ` + -Headers $headers ` + -Body $versionDevBytes ` + -ContentType "application/json" + Write-Host " version-dev.json upload completed successfully" -ForegroundColor Green + + # NOTE: Kein Upload von version.json / Install-AutopilotMonitor.ps1 / Test-ShouldBootstrapAgent.ps1 + # NOTE: Kein AdminConfiguration Table-Update (Prod-Hash-Oracle bleibt unangetastet) + + # Cleanup + Remove-Item $tempZipPath -Force -ErrorAction SilentlyContinue + Remove-Item $tempVersionDevPath -Force -ErrorAction SilentlyContinue + + Write-Host "`n=== DEV build and upload completed successfully ===" -ForegroundColor Green + Write-Host "Files uploaded: $zipFileName, $bootstrapScriptName, $versionJsonDevName" -ForegroundColor Gray + Write-Host "Container: autopilotmonitor/agent" -ForegroundColor Gray + Write-Host "Agent version: $($versionJsonCheck.version) | Bootstrap version: $bootstrapVersion" -ForegroundColor Gray + Write-Host "Production artifacts (AutopilotMonitor-Agent.zip / version.json / AdminConfiguration) were NOT touched." -ForegroundColor Gray + +} catch { + Write-Host "`nERROR: $($_.Exception.Message)" -ForegroundColor Red + Write-Host "Details: $($_.Exception.ToString())" -ForegroundColor Red + exit 1 +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Flows/EnrollmentFlowTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Flows/EnrollmentFlowTests.cs new file mode 100644 index 00000000..5c937e42 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Flows/EnrollmentFlowTests.cs @@ -0,0 +1,119 @@ +using AutopilotMonitor.Agent.Core.Monitoring.Flows; +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Agent.Core.Tests.Flows +{ + /// + /// Guards the v1/v2 enrollment-flow "Weiche": ensures flow handlers expose the correct + /// policy bits and the factory picks the right handler for each EnrollmentType. + /// Prevents regressions such as DevPrep flows accidentally applying the ESP gate, + /// or Classic flows skipping ESP-phase tracking. + /// + public class EnrollmentFlowTests + { + // -- Enum <-> wire format roundtrip -- + + [Theory] + [InlineData("v1", EnrollmentType.Classic)] + [InlineData("V1", EnrollmentType.Classic)] + [InlineData("v2", EnrollmentType.DevicePreparation)] + [InlineData("V2", EnrollmentType.DevicePreparation)] + public void FromWireFormat_KnownValues_ReturnsMatchingEnum(string wire, EnrollmentType expected) + { + Assert.Equal(expected, EnrollmentTypeExtensions.FromWireFormat(wire)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("v3")] + [InlineData("classic")] + public void FromWireFormat_UnknownOrEmpty_ReturnsUnknown(string wire) + { + Assert.Equal(EnrollmentType.Unknown, EnrollmentTypeExtensions.FromWireFormat(wire)); + } + + [Theory] + [InlineData(EnrollmentType.Classic, "v1")] + [InlineData(EnrollmentType.DevicePreparation, "v2")] + [InlineData(EnrollmentType.Unknown, "v1")] // legacy-safe default + public void ToWireFormat_ProducesExpectedString(EnrollmentType type, string expected) + { + Assert.Equal(expected, type.ToWireFormat()); + } + + // -- Flow handler policy bits -- + + [Fact] + public void ClassicAutopilotFlow_TracksEspPhases_True() + { + var flow = new ClassicAutopilotFlow(); + Assert.Equal(EnrollmentType.Classic, flow.SupportedType); + Assert.True(flow.TracksEspPhases); + Assert.True(flow.AppliesEspGateOnDesktopArrival); + } + + [Fact] + public void DevicePreparationFlow_DoesNotTrackEspPhases() + { + var flow = new DevicePreparationFlow(); + Assert.Equal(EnrollmentType.DevicePreparation, flow.SupportedType); + Assert.False(flow.TracksEspPhases); + Assert.False(flow.AppliesEspGateOnDesktopArrival); + } + + // -- Factory selection -- + + [Fact] + public void Factory_Create_Classic_ReturnsClassicFlow() + { + Assert.IsType(EnrollmentFlowFactory.Create(EnrollmentType.Classic)); + } + + [Fact] + public void Factory_Create_DevicePreparation_ReturnsDevPrepFlow() + { + Assert.IsType(EnrollmentFlowFactory.Create(EnrollmentType.DevicePreparation)); + } + + [Fact] + public void Factory_Create_Unknown_FallsBackToClassic() + { + // Unknown must not produce null — Classic is the safe legacy default. + Assert.IsType(EnrollmentFlowFactory.Create(EnrollmentType.Unknown)); + } + + [Theory] + [InlineData("v1", typeof(ClassicAutopilotFlow))] + [InlineData("v2", typeof(DevicePreparationFlow))] + [InlineData("", typeof(ClassicAutopilotFlow))] + [InlineData("bogus", typeof(ClassicAutopilotFlow))] + public void Factory_FromWireFormat_PicksCorrectFlow(string wire, System.Type expectedType) + { + var flow = EnrollmentFlowFactory.FromWireFormat(wire); + Assert.IsType(expectedType, flow); + } + + // -- RegisterSessionResponse back-compat guard -- + + [Fact] + public void RegisterSessionResponse_DefaultValidatedBy_IsUnknown() + { + // A response from an older backend that does not set ValidatedBy must + // deserialize with ValidatedBy = Unknown so the agent falls back to + // its own registry detection. + var response = new RegisterSessionResponse(); + Assert.Equal(ValidatorType.Unknown, response.ValidatedBy); + } + + [Fact] + public void ValidatorType_Unknown_IsDefaultValue() + { + // Newtonsoft.Json / System.Text.Json default for a missing enum field is 0. + // Unknown must be 0 so missing-field deserialization stays backward compatible. + Assert.Equal(0, (int)ValidatorType.Unknown); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/CollectorCoordinator.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/CollectorCoordinator.cs index 5127d232..e2215b64 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/CollectorCoordinator.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/CollectorCoordinator.cs @@ -144,6 +144,28 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core _enrollmentTracker?.NotifyDesktopArrived(); } + /// Backend validator verdict cached when RegisterSession completes before the + /// EnrollmentTracker is instantiated. Applied to the tracker in StartEventCollectors(). + private ValidatorType _pendingBackendValidator = ValidatorType.Unknown; + + /// + /// Forwards the backend's authoritative validator verdict to the enrollment tracker + /// so it can reconcile with its registry-based enrollment-type detection. + /// If the tracker does not yet exist (RegisterSession runs before tracker start), + /// the verdict is cached and applied when the tracker is created. + /// + public void ReconcileWithBackendValidator(ValidatorType validatedBy) + { + if (_enrollmentTracker != null) + { + _enrollmentTracker.ReconcileWithBackendValidator(validatedBy); + } + else + { + _pendingBackendValidator = validatedBy; + } + } + /// /// Returns true for events generated by periodic timers (not real enrollment activity). /// @@ -266,6 +288,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core _enrollmentTracker.Start(); _logger.Info("EnrollmentTracker started — listening for IME patterns"); + if (_pendingBackendValidator != ValidatorType.Unknown) + { + _enrollmentTracker.ReconcileWithBackendValidator(_pendingBackendValidator); + _pendingBackendValidator = ValidatorType.Unknown; + } + if (_previousExitType == "reboot_kill" && _lastBootTimeUtc.HasValue) { _enrollmentTracker.NotifySystemRebootDetected(); diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs index afbf14ed..995cca7e 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs @@ -958,6 +958,18 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core { _logger.Info($"Session registered successfully: {response.SessionId}"); + // Reconcile registry-detected enrollment type with the backend's authoritative + // validator verdict. Mismatches emit an enrollment_type_mismatch warning event + // and live-switch the flow handler on the EnrollmentTracker. + try + { + _collectorCoordinator?.ReconcileWithBackendValidator(response.ValidatedBy); + } + catch (Exception reconcileEx) + { + _logger.Warning($"ReconcileWithBackendValidator failed (non-fatal): {reconcileEx.Message}"); + } + if (!string.IsNullOrEmpty(response.AdminAction)) { _logger.Warning($"Session already marked as {response.AdminAction} by administrator — will run cleanup after startup"); diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Flows/ClassicAutopilotFlow.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Flows/ClassicAutopilotFlow.cs new file mode 100644 index 00000000..c0a4579a --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Flows/ClassicAutopilotFlow.cs @@ -0,0 +1,11 @@ +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Flows +{ + public sealed class ClassicAutopilotFlow : IEnrollmentFlowHandler + { + public EnrollmentType SupportedType => EnrollmentType.Classic; + public bool TracksEspPhases => true; + public bool AppliesEspGateOnDesktopArrival => true; + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Flows/DevicePreparationFlow.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Flows/DevicePreparationFlow.cs new file mode 100644 index 00000000..48f0d124 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Flows/DevicePreparationFlow.cs @@ -0,0 +1,11 @@ +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Flows +{ + public sealed class DevicePreparationFlow : IEnrollmentFlowHandler + { + public EnrollmentType SupportedType => EnrollmentType.DevicePreparation; + public bool TracksEspPhases => false; + public bool AppliesEspGateOnDesktopArrival => false; + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Flows/EnrollmentFlowFactory.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Flows/EnrollmentFlowFactory.cs new file mode 100644 index 00000000..34adc425 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Flows/EnrollmentFlowFactory.cs @@ -0,0 +1,21 @@ +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Flows +{ + public static class EnrollmentFlowFactory + { + /// + /// Returns the flow handler for the given enrollment type. + /// falls back to the Classic flow + /// (safe default — Classic is the long-standing behavior). + /// + public static IEnrollmentFlowHandler Create(EnrollmentType type) => type switch + { + EnrollmentType.DevicePreparation => new DevicePreparationFlow(), + _ => new ClassicAutopilotFlow() + }; + + public static IEnrollmentFlowHandler FromWireFormat(string wireFormat) + => Create(EnrollmentTypeExtensions.FromWireFormat(wireFormat)); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Flows/IEnrollmentFlowHandler.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Flows/IEnrollmentFlowHandler.cs new file mode 100644 index 00000000..b6f3e512 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Flows/IEnrollmentFlowHandler.cs @@ -0,0 +1,30 @@ +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Flows +{ + /// + /// Encapsulates the policy decisions that differ between the Classic Autopilot (v1) + /// flow and the Windows Autopilot Device Preparation (v2) flow. + /// + /// Today the flow handler only owns policy bits; tracker ownership remains in + /// CollectorCoordinator. When DevPrep-specific trackers land, they will + /// move into the corresponding flow handler. + /// + public interface IEnrollmentFlowHandler + { + EnrollmentType SupportedType { get; } + + /// + /// True when the flow relies on ESP-phase signals for progress and completion. + /// Classic: true. DevPrep (WDP): false (no ESP exists). + /// + bool TracksEspPhases { get; } + + /// + /// True when completion triggered by desktop-arrival / desktop-hello must be + /// blocked while ESP has been observed but has not reached its final exit. + /// Classic: true (AccountSetup may still be running). DevPrep: false. + /// + bool AppliesEspGateOnDesktopArrival { get; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.CompletionLogic.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.CompletionLogic.cs index e5ec9f22..06140cae 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.CompletionLogic.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.CompletionLogic.cs @@ -741,6 +741,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking _isWaitingForEspSettle = loaded.IsWaitingForEspSettle; _enrollmentCompleteEmitted = loaded.EnrollmentCompleteEmitted; _enrollmentType = loaded.EnrollmentType ?? _enrollmentType; + _flowHandler = Flows.EnrollmentFlowFactory.FromWireFormat(_enrollmentType); _skipUserStatusPage = loaded.SkipUserStatusPage; _skipDeviceStatusPage = loaded.SkipDeviceStatusPage; _autopilotMode = loaded.AutopilotMode; @@ -955,7 +956,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking || isDeviceOnly; espGateBlocked = (source == "desktop_arrival" || source == "desktop_hello") - && enrollmentType != "v2" && espEverSeen && !espFinalExitSeen; + && _flowHandler.AppliesEspGateOnDesktopArrival && espEverSeen && !espFinalExitSeen; // GUARD 3: Hybrid Join reboot gate // In hybrid join, ESP exit may be for a mid-enrollment reboot (domain user diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.EventHandlers.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.EventHandlers.cs index 00cb0e99..1b4477db 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.EventHandlers.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.EventHandlers.cs @@ -37,17 +37,18 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking private void HandleEspPhaseChanged(string phase) { - string enrollType, lastPhase; + string lastPhase; + bool tracksEspPhases; lock (_stateLock) { - enrollType = _enrollmentType; + tracksEspPhases = _flowHandler.TracksEspPhases; lastPhase = _lastEspPhase; } - // WDP (v2) has no ESP - skip ESP phase handling entirely - if (enrollType == "v2") + // Flows that do not track ESP phases (DevPrep/WDP) ignore these events entirely. + if (!tracksEspPhases) { - _logger.Debug($"EnrollmentTracker: skipping ESP phase event in WDP enrollment (phase: {phase})"); + _logger.Debug($"EnrollmentTracker: skipping ESP phase event — flow does not track ESP (phase: {phase})"); return; } diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs index b2d550b4..7b13611c 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Tracking/EnrollmentTracker.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using AutopilotMonitor.Agent.Core.Logging; +using AutopilotMonitor.Agent.Core.Monitoring.Flows; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.Models; using Microsoft.Win32; @@ -50,6 +51,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking private string _lastEspPhase; // Track last ESP phase to prevent duplicate events private bool _hasAutoSwitchedToAppsPhase; // Track if we've already auto-switched to apps phase for current ESP phase private string _enrollmentType = "v1"; // "v1" = Autopilot Classic/ESP, "v2" = Windows Device Preparation + private IEnrollmentFlowHandler _flowHandler = EnrollmentFlowFactory.FromWireFormat("v1"); // Kept in sync with _enrollmentType via SetEnrollmentType() private bool _isWaitingForHello = false; // Track if we're waiting for Hello to complete before sending enrollment_complete private readonly bool _isBootstrapMode; // Agent started via bootstrap token (pre-MDM) private bool _sendTraceEvents = true; // Send Trace-severity events to backend for decision auditing @@ -122,6 +124,66 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking } } + /// + /// Called after a successful RegisterSession. Compares the backend's authoritative + /// validator verdict against the agent's registry-based enrollment-type detection + /// and, on mismatch, emits an enrollment_type_mismatch warning event and + /// switches the active flow handler to the backend's verdict. + /// + /// and + /// are treated as type-neutral (no claim about Classic vs DevPrep) and do not trigger a switch. + /// + public void ReconcileWithBackendValidator(ValidatorType validatedBy) + { + EnrollmentType backendType = validatedBy switch + { + ValidatorType.AutopilotV1 => EnrollmentType.Classic, + ValidatorType.DeviceAssociation => EnrollmentType.DevicePreparation, + _ => EnrollmentType.Unknown + }; + + if (backendType == EnrollmentType.Unknown) + return; + + string currentWire; + lock (_stateLock) { currentWire = _enrollmentType; } + var currentType = EnrollmentTypeExtensions.FromWireFormat(currentWire); + + if (currentType == backendType) + { + _logger.Verbose($"EnrollmentTracker: backend validator '{validatedBy}' matches registry-detected enrollment type '{currentWire}'."); + return; + } + + var newWire = backendType.ToWireFormat(); + _logger.Warning($"EnrollmentTracker: enrollment type mismatch — registry='{currentWire}', backend='{newWire}' (validator={validatedBy}). Switching to backend verdict."); + + lock (_stateLock) + { + _enrollmentType = newWire; + _flowHandler = EnrollmentFlowFactory.Create(backendType); + _stateData.EnrollmentType = newWire; + _stateDirty = true; + } + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "enrollment_type_mismatch", + Severity = EventSeverity.Warning, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"Registry-detected enrollment type '{currentWire}' differs from backend validator verdict '{newWire}' ({validatedBy}). Using backend verdict.", + Data = new Dictionary + { + { "registryEnrollmentType", currentWire }, + { "backendEnrollmentType", newWire }, + { "backendValidator", validatedBy.ToString() } + } + }); + } + // True when no interactive user session is expected. // Self-Deploying (AutopilotMode=1) is always device-only. // SkipUserStatusPage=true alone does NOT mean device-only — admins commonly skip @@ -538,6 +600,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Tracking { wasDeviceOnly = IsDeviceOnlyDeployment; _enrollmentType = result.enrollmentType; + _flowHandler = EnrollmentFlowFactory.FromWireFormat(result.enrollmentType); _skipUserStatusPage = result.skipUserStatusPage; _skipDeviceStatusPage = result.skipDeviceStatusPage; _autopilotMode = result.autopilotMode; diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 07eafa38..6edead7c 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -956 +960 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs new file mode 100644 index 00000000..34263a27 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceAssociationValidatorTests.cs @@ -0,0 +1,181 @@ +using System.Diagnostics; +using AutopilotMonitor.Functions.Security; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Tests for — focused on pure-function pieces: +/// JSON-to-DTO mapping (incl. exact-match guard), cache key shape, and Activity-tag +/// enrichment used by SecurityValidator. The HTTP/cache/retry resilience is mirrored +/// 1:1 from AutopilotDeviceValidator; behavioural drift would surface here. +/// +public class DeviceAssociationValidatorTests +{ + private const string TenantId = "11111111-1111-1111-1111-111111111111"; + private const string Serial = "1730-2406-2605-6305-0260-8436-93"; + + // -- BuildCacheKey -- + + [Fact] + public void BuildCacheKey_StableShape() + { + var key = DeviceAssociationValidator.BuildCacheKey(TenantId, Serial); + Assert.Equal($"device-association:{TenantId}:{Serial}", key); + } + + [Fact] + public void BuildCacheKey_DistinctFromAutopilotValidatorKey() + { + // The two validators must NOT share cache keys; otherwise a positive Autopilot + // hit would incorrectly satisfy a DevPrep lookup. + var devPrepKey = DeviceAssociationValidator.BuildCacheKey(TenantId, Serial); + Assert.StartsWith("device-association:", devPrepKey); + Assert.DoesNotContain("autopilot", devPrepKey); + } + + // -- ParseTenantAssociatedDevicesResponse: empty / missing / malformed -- + + [Fact] + public void Parse_EmptyValueArray_NotFound() + { + var body = "{\"value\":[]}"; + var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial); + Assert.False(result.IsValid); + Assert.False(result.IsTransient); + Assert.Equal(Serial, result.SerialNumber); + Assert.Contains("not associated", result.ErrorMessage); + } + + [Fact] + public void Parse_MissingValueProperty_NotFound() + { + var body = "{}"; + var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial); + Assert.False(result.IsValid); + } + + [Fact] + public void Parse_MalformedJson_NotFound_NotTransient() + { + // Defensive: bad payload is treated as "not associated", not as a transient + // — transient handling is only triggered by HTTP-level failures. + var body = "this is not json"; + var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial); + Assert.False(result.IsValid); + Assert.False(result.IsTransient); + } + + // -- ParseTenantAssociatedDevicesResponse: exact-match guard -- + + [Fact] + public void Parse_OnlySimilarSerial_NotFound() + { + // Guards against widened filter semantics: even if Graph returns devices whose serial + // merely contains the query, we must require an exact match before declaring success. + var body = $@"{{""value"":[{{""serialNumber"":""{Serial}-OTHER"",""associationState"":""preassociated""}}]}}"; + var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial); + Assert.False(result.IsValid); + } + + [Fact] + public void Parse_ExactSerialMatch_PopulatesAllFields() + { + var body = $@"{{ + ""value"":[{{ + ""id"":""f8226ccb-e464-1842-a341-325b2a4fd906"", + ""serialNumber"":""{Serial}"", + ""associationState"":""preassociated"", + ""devicePreparationPolicyId"":""00000000-0000-0000-0000-000000000000"", + ""preassociationDateTime"":""2026-04-14T12:00:21.5029229Z"", + ""associationDateTime"":""0001-01-01T00:00:00Z"", + ""preassociatedByUserPrincipalName"":""admin@contoso.com"", + ""assignedToUserPrincipalName"":null, + ""managedDeviceId"":""00000000-0000-0000-0000-000000000000"" + }}] + }}"; + + var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial); + + Assert.True(result.IsValid); + Assert.False(result.IsTransient); + Assert.Equal(Serial, result.SerialNumber); + Assert.Equal("preassociated", result.AssociationState); + Assert.Equal("00000000-0000-0000-0000-000000000000", result.DevicePreparationPolicyId); + Assert.Equal("admin@contoso.com", result.PreAssociatedByUserPrincipalName); + // Newtonsoft.Json yields "" for JSON null when calling .ToString() on the JToken; + // downstream telemetry uses IsNullOrEmpty so both null and "" surface as "absent". + Assert.True(string.IsNullOrEmpty(result.AssignedToUserPrincipalName)); + Assert.NotNull(result.PreAssociationDateTime); + Assert.Null(result.AssociationDateTime); // Graph "0001-01-01" → null (unset DateTimeOffset sentinel) + } + + [Fact] + public void Parse_TrimsWhitespace_OnGraphSerialBeforeMatching() + { + // Defensive: protect against Graph stragglers with stray whitespace. + var body = $@"{{""value"":[{{""serialNumber"":"" {Serial} "",""associationState"":""preassociated""}}]}}"; + var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial); + Assert.True(result.IsValid); + } + + [Fact] + public void Parse_CaseInsensitiveSerialMatch() + { + var lower = Serial.ToLowerInvariant(); + var upper = Serial.ToUpperInvariant(); + var body = $@"{{""value"":[{{""serialNumber"":""{upper}"",""associationState"":""preassociated""}}]}}"; + var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, lower); + Assert.True(result.IsValid); + } + + // -- EnrichRequestTelemetryWithDeviceAssociation -- + + [Fact] + public void Enrich_PopulatesActivityTags_WhenMatched() + { + using var activity = new Activity("test").Start(); + var result = new DeviceAssociationResult + { + IsValid = true, + IsTransient = false, + AssociationState = "preassociated", + DevicePreparationPolicyId = "policy-1", + PreAssociationDateTime = new DateTime(2026, 4, 14, 12, 0, 0, DateTimeKind.Utc) + }; + + SecurityValidator.EnrichRequestTelemetryWithDeviceAssociation(result); + + var tags = activity.Tags.ToDictionary(t => t.Key, t => t.Value); + Assert.Equal("true", tags["devprep.association.matched"]); + Assert.Equal("false", tags["devprep.association.transient"]); + Assert.Equal("preassociated", tags["devprep.association.state"]); + Assert.Equal("policy-1", tags["devprep.association.policyId"]); + Assert.Contains("devprep.association.preAssociationUtc", tags.Keys); + } + + [Fact] + public void Enrich_OnlyMatchedAndTransient_WhenNoOtherFields() + { + using var activity = new Activity("test").Start(); + var result = new DeviceAssociationResult { IsValid = false, IsTransient = true }; + + SecurityValidator.EnrichRequestTelemetryWithDeviceAssociation(result); + + var tags = activity.Tags.ToDictionary(t => t.Key, t => t.Value); + Assert.Equal("false", tags["devprep.association.matched"]); + Assert.Equal("true", tags["devprep.association.transient"]); + Assert.DoesNotContain("devprep.association.state", tags.Keys); + Assert.DoesNotContain("devprep.association.policyId", tags.Keys); + } + + [Fact] + public void Enrich_NoActivity_DoesNotThrow() + { + // Activity.Current is null in environments where no listener registered the activity source. + // Must be a safe no-op. + Activity.Current = null; + var result = new DeviceAssociationResult { IsValid = true }; + var ex = Record.Exception(() => SecurityValidator.EnrichRequestTelemetryWithDeviceAssociation(result)); + Assert.Null(ex); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index 161e9b6a..e8046f54 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -384,6 +384,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "ModelWhitelist", config.ModelWhitelist }, { "ValidateAutopilotDevice", config.ValidateAutopilotDevice }, { "ValidateCorporateIdentifier", config.ValidateCorporateIdentifier }, + { "ValidateDeviceAssociation", config.ValidateDeviceAssociation }, { "AllowInsecureAgentRequests", config.AllowInsecureAgentRequests }, { "DataRetentionDays", config.DataRetentionDays }, { "SessionTimeoutHours", config.SessionTimeoutHours }, @@ -462,6 +463,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage ModelWhitelist = entity.GetString("ModelWhitelist") ?? "*", ValidateAutopilotDevice = entity.GetBoolean("ValidateAutopilotDevice") ?? entity.GetBoolean("ValidateSerialNumber") ?? false, ValidateCorporateIdentifier = entity.GetBoolean("ValidateCorporateIdentifier") ?? false, + ValidateDeviceAssociation = entity.GetBoolean("ValidateDeviceAssociation") ?? false, AllowInsecureAgentRequests = entity.GetBoolean("AllowInsecureAgentRequests") ?? false, DataRetentionDays = entity.GetInt32("DataRetentionDays") ?? 90, SessionTimeoutHours = entity.GetInt32("SessionTimeoutHours") ?? 5, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs index 43423deb..695ed665 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs @@ -94,7 +94,8 @@ namespace AutopilotMonitor.Functions.Functions.Config config.UnrestrictedModeEnabled != existingConfig.UnrestrictedModeEnabled || config.CustomRateLimitRequestsPerMinute != existingConfig.CustomRateLimitRequestsPerMinute || config.RateLimitRequestsPerMinute != existingConfig.RateLimitRequestsPerMinute || - config.Disabled != existingConfig.Disabled) + config.Disabled != existingConfig.Disabled || + config.ValidateDeviceAssociation != existingConfig.ValidateDeviceAssociation) { _logger.LogWarning( "Tenant Admin {User} attempted to modify GA-only fields for tenant {TenantId}", @@ -109,6 +110,12 @@ namespace AutopilotMonitor.Functions.Functions.Config config.Disabled = existingConfig.Disabled; config.DisabledReason = existingConfig.DisabledReason; config.DisabledUntil = existingConfig.DisabledUntil; + // DevPrep "Device association" toggle is GA-only during Private Preview. + // TODO(devprep-followup): missing xUnit test for this GA-gate — needs + // UpdateTenantConfigurationFunction test harness (mock HttpRequestData + + // RequestContext + TenantConfigurationService). Tracked in + // memory/project_devprep_followups.md. + config.ValidateDeviceAssociation = existingConfig.ValidateDeviceAssociation; } // Safety: if GA gate is off, force UnrestrictedMode to false diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs index 5d62893d..36334631 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs @@ -136,7 +136,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions Success = true, Message = "Session registered successfully", RegisteredAt = DateTime.UtcNow, - AdminAction = adminAction + AdminAction = adminAction, + ValidatedBy = validation.ValidatedBy }; await response.WriteAsJsonAsync(responseData); diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index fc04cc6d..787a5bf9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -161,6 +161,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddHttpClient() .ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.SocketsHttpHandler { diff --git a/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationResult.cs b/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationResult.cs new file mode 100644 index 00000000..379e53fa --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationResult.cs @@ -0,0 +1,59 @@ +using System; + +namespace AutopilotMonitor.Functions.Security +{ + /// + /// Result of looking up a device in the DevPrep "Device association" Graph API + /// (/beta/deviceManagement/tenantAssociatedDevices). + /// + /// Mirrors the resilience contract of : + /// transient failures (Graph 5xx, token issues, network) set =true + /// and are NOT cached; definitive results (found / not-found) ARE cached. + /// + public class DeviceAssociationResult + { + public bool IsValid { get; set; } + + /// + /// True when the failure is transient and should be retried (Graph API error, token issue, network). + /// Transient failures are NOT cached. The caller is expected to surface 503 Retry-After + /// for the agent path and to log+swallow for shadow-mode paths. + /// + public bool IsTransient { get; set; } + + public string? SerialNumber { get; set; } + + // ---- Fields populated when a matching tenantAssociatedDevices entry is found ---- + + /// + /// Association state from the Graph response. + /// Observed values: "preassociated"; expected to also include "associated", "enrolled". + /// + public string? AssociationState { get; set; } + + /// + /// GUID of the DevPrep policy that the device is associated to (or all-zero GUID if not yet assigned). + /// + public string? DevicePreparationPolicyId { get; set; } + + /// + /// UPN of the user who pre-associated the device (or null when none). + /// + public string? PreAssociatedByUserPrincipalName { get; set; } + + /// + /// UPN the device is assigned to (set later in the association lifecycle, may be null). + /// + public string? AssignedToUserPrincipalName { get; set; } + + public DateTime? PreAssociationDateTime { get; set; } + public DateTime? AssociationDateTime { get; set; } + + /// + /// Intune managed device ID once enrollment has occurred (all-zero GUID otherwise). + /// + public string? ManagedDeviceId { get; set; } + + public string? ErrorMessage { get; set; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationValidator.cs new file mode 100644 index 00000000..2975b2a6 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Security/DeviceAssociationValidator.cs @@ -0,0 +1,249 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace AutopilotMonitor.Functions.Security +{ + /// + /// Validates devices against the Windows Autopilot Device Preparation (WDP) + /// "Device association" Graph API (tenantAssociatedDevices). + /// + /// Built as a near-sibling of — same DI dependencies, + /// same retry/cache/transient contract — so the resilience guarantees are identical: + /// - 30 min positive cache, 5 min negative cache (transient failures NOT cached) + /// - 2 attempts with 2s back-off between attempts + /// - on Graph 5xx, token failures, exceptions + /// - Serial-trim normalisation, single-quote escape for OData filter, exact-match guard + /// + public class DeviceAssociationValidator + { + private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30); + private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5); + + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IMemoryCache _cache; + private readonly GraphTokenService _graphTokenService; + + public DeviceAssociationValidator( + ILogger logger, + IHttpClientFactory httpClientFactory, + IMemoryCache cache, + GraphTokenService graphTokenService) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _cache = cache; + _graphTokenService = graphTokenService; + } + + public async Task LookupAsync( + string tenantId, + string? serialNumber, + string? sessionId = null) + { + if (string.IsNullOrWhiteSpace(serialNumber)) + { + return new DeviceAssociationResult + { + IsValid = false, + ErrorMessage = "Serial number header not provided" + }; + } + + var normalizedSerial = serialNumber.Trim(); + var cacheKey = BuildCacheKey(tenantId, normalizedSerial); + + if (_cache.TryGetValue(cacheKey, out DeviceAssociationResult? cached) && cached != null) + { + return cached; + } + + const int maxAttempts = 2; + DeviceAssociationResult? lastTransient = null; + + for (int attempt = 1; attempt <= maxAttempts; attempt++) + { + var result = await TryLookupViaGraphAsync(tenantId, normalizedSerial, sessionId, cacheKey, attempt); + + if (result.IsValid || !result.IsTransient) + return result; + + lastTransient = result; + if (attempt < maxAttempts) + { + _logger.LogWarning( + "Device association lookup transient failure for tenant {TenantId}, serial {SerialNumber} (attempt {Attempt}/{MaxAttempts}). Retrying...", + tenantId, normalizedSerial, attempt, maxAttempts); + await Task.Delay(TimeSpan.FromSeconds(2)); + } + } + + _logger.LogWarning( + "Device association lookup failed after {MaxAttempts} attempts for tenant {TenantId}, serial {SerialNumber}", + maxAttempts, tenantId, normalizedSerial); + + return lastTransient!; + } + + private async Task TryLookupViaGraphAsync( + string tenantId, string normalizedSerial, string? sessionId, string cacheKey, int attempt) + { + try + { + var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId); + if (string.IsNullOrEmpty(tokenResult.AccessToken)) + { + return new DeviceAssociationResult + { + IsValid = false, + IsTransient = true, + SerialNumber = normalizedSerial, + ErrorMessage = "Graph access token could not be acquired" + }; + } + + var graphClient = _httpClientFactory.CreateClient(); + graphClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult.AccessToken); + + // TODO(devprep-followup): OData $filter support on tenantAssociatedDevices is not yet + // documented for the Private Preview surface — fall back to a server-side $top page + // sorted by recency and exact-match client-side. When MS adds eq-filter support, swap + // to `$filter=serialNumber eq '...'` (mirror AutopilotDeviceValidator's escape pattern). + // Tracked in memory/project_devprep_followups.md. + var graphUrl = "https://graph.microsoft.com/beta/deviceManagement/tenantAssociatedDevices" + + "?$top=100&$orderby=preAssociationDateTime desc"; + + var response = await graphClient.GetAsync(graphUrl); + var responseBody = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "Device association Graph query failed for tenant {TenantId} (attempt {Attempt}). Status: {StatusCode}. Body: {ResponseBody}", + tenantId, attempt, (int)response.StatusCode, responseBody); + + return new DeviceAssociationResult + { + IsValid = false, + IsTransient = true, + SerialNumber = normalizedSerial, + ErrorMessage = $"Graph query failed with status {(int)response.StatusCode}" + }; + } + + var result = ParseTenantAssociatedDevicesResponse(responseBody, normalizedSerial); + if (result.IsValid) + { + _logger.LogInformation( + "Device association lookup succeeded for tenant {TenantId}, session {SessionId}, serial {SerialNumber}, state {State}, policy {PolicyId}", + tenantId, sessionId ?? "", normalizedSerial, + result.AssociationState ?? "", result.DevicePreparationPolicyId ?? ""); + } + return CacheAndReturn(cacheKey, result, isPositive: result.IsValid); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Error during device association lookup for tenant {TenantId}, session {SessionId}, serial {SerialNumber} (attempt {Attempt})", + tenantId, sessionId ?? "", normalizedSerial, attempt); + + return new DeviceAssociationResult + { + IsValid = false, + IsTransient = true, + SerialNumber = normalizedSerial, + ErrorMessage = $"Error during device association lookup: {ex.Message}" + }; + } + } + + /// + /// Pure-function: maps a tenantAssociatedDevices Graph response body to a + /// . Performs an exact-match guard on + /// serialNumber to avoid false positives if the Graph endpoint widens + /// its filter semantics later. + /// + internal static DeviceAssociationResult ParseTenantAssociatedDevicesResponse(string responseBody, string normalizedSerial) + { + var notFound = new DeviceAssociationResult + { + IsValid = false, + SerialNumber = normalizedSerial, + ErrorMessage = $"Device with serial '{normalizedSerial}' is not associated for DevPrep" + }; + + JObject? data; + try + { + data = JsonConvert.DeserializeObject(responseBody); + } + catch (JsonException) + { + return notFound; + } + + var devices = data?["value"] as JArray; + if (devices == null || devices.Count == 0) + return notFound; + + var match = devices.FirstOrDefault(d => string.Equals( + d?["serialNumber"]?.ToString()?.Trim(), + normalizedSerial, + StringComparison.OrdinalIgnoreCase)); + + if (match == null) + return notFound; + + return new DeviceAssociationResult + { + IsValid = true, + SerialNumber = normalizedSerial, + AssociationState = match["associationState"]?.ToString(), + DevicePreparationPolicyId = match["devicePreparationPolicyId"]?.ToString(), + PreAssociatedByUserPrincipalName = match["preassociatedByUserPrincipalName"]?.ToString(), + AssignedToUserPrincipalName = match["assignedToUserPrincipalName"]?.ToString(), + PreAssociationDateTime = ParseDateTime(match["preassociationDateTime"]), + AssociationDateTime = ParseDateTime(match["associationDateTime"]), + ManagedDeviceId = match["managedDeviceId"]?.ToString() + }; + } + + private static DateTime? ParseDateTime(JToken? token) + { + if (token == null || token.Type == JTokenType.Null) return null; + var s = token.ToString(); + if (string.IsNullOrEmpty(s)) return null; + // Graph returns "0001-01-01T00:00:00Z" for unset DateTimeOffset fields — treat as null. + if (DateTime.TryParse(s, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var dt)) + { + if (dt.Year <= 1) return null; + return dt; + } + return null; + } + + internal static string BuildCacheKey(string tenantId, string serialNumber) + => $"device-association:{tenantId}:{serialNumber}"; + + private DeviceAssociationResult CacheAndReturn( + string cacheKey, + DeviceAssociationResult result, + bool isPositive) + { + var ttl = isPositive ? PositiveCacheTtl : NegativeCacheTtl; + _cache.Set(cacheKey, result, new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = ttl + }); + return result; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs index 9d72a868..32fd507c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Linq; using System.Net; using System.Text.RegularExpressions; @@ -45,6 +46,7 @@ namespace AutopilotMonitor.Functions.Security private readonly RateLimitService _rateLimitService; private readonly AutopilotDeviceValidator _autopilotDeviceValidator; private readonly CorporateIdentifierValidator _corporateIdentifierValidator; + private readonly DeviceAssociationValidator? _deviceAssociationValidator; private readonly BootstrapSessionService? _bootstrapSessionService; private readonly ILogger _logger; @@ -54,12 +56,14 @@ namespace AutopilotMonitor.Functions.Security AutopilotDeviceValidator autopilotDeviceValidator, CorporateIdentifierValidator corporateIdentifierValidator, ILogger logger, - BootstrapSessionService? bootstrapSessionService = null) + BootstrapSessionService? bootstrapSessionService = null, + DeviceAssociationValidator? deviceAssociationValidator = null) { _configService = configService; _rateLimitService = rateLimitService; _autopilotDeviceValidator = autopilotDeviceValidator; _corporateIdentifierValidator = corporateIdentifierValidator; + _deviceAssociationValidator = deviceAssociationValidator; _bootstrapSessionService = bootstrapSessionService; _logger = logger; } @@ -199,7 +203,8 @@ namespace AutopilotMonitor.Functions.Security Manufacturer = bsManufacturer, Model = bsModel, SerialNumber = bsSerial, - RateLimitResult = bsRateLimitResult + RateLimitResult = bsRateLimitResult, + ValidatedBy = ValidatorType.Bootstrap }; } @@ -296,6 +301,7 @@ namespace AutopilotMonitor.Functions.Security bool deviceValidated = false; bool deviceValidationTransient = false; string? deviceValidationError = null; + ValidatorType validatedBy = ValidatorType.Unknown; if (config.ValidateAutopilotDevice) { @@ -304,6 +310,7 @@ namespace AutopilotMonitor.Functions.Security { deviceValidated = true; autopilotDeviceId = autopilotResult.AutopilotDeviceId; + validatedBy = ValidatorType.AutopilotV1; } else { @@ -318,6 +325,7 @@ namespace AutopilotMonitor.Functions.Security if (corpResult.IsValid) { deviceValidated = true; + validatedBy = ValidatorType.CorporateIdentifier; } else { @@ -355,6 +363,28 @@ namespace AutopilotMonitor.Functions.Security }; } + // 5. DevPrep "Device association" lookup (shadow mode — does NOT gate enrollment). + // Runs only when the tenant has opted in and the existing validators have already + // authorized the device. Result is attached to the request telemetry (App Insights) + // via Activity tags so we can observe DevPrep readiness without breaking enrollment. + if (config.ValidateDeviceAssociation && _deviceAssociationValidator != null && !string.IsNullOrEmpty(serialNumber)) + { + try + { + var devPrepResult = await _deviceAssociationValidator.LookupAsync(tenantId, serialNumber, sessionId); + EnrichRequestTelemetryWithDeviceAssociation(devPrepResult); + _logger.LogInformation( + "DevPrep association lookup (shadow) for tenant {TenantId}, serial {SerialNumber}: matched={Matched}, transient={Transient}, state={State}, policy={PolicyId}", + tenantId, serialNumber, devPrepResult.IsValid, devPrepResult.IsTransient, + devPrepResult.AssociationState ?? "", devPrepResult.DevicePreparationPolicyId ?? ""); + } + catch (Exception ex) + { + // Shadow mode must never break the enrollment flow — log and move on. + _logger.LogWarning(ex, "DevPrep association lookup (shadow) failed for tenant {TenantId}, serial {SerialNumber} — ignored.", tenantId, serialNumber); + } + } + // All checks passed return new SecurityValidationResult { @@ -364,9 +394,31 @@ namespace AutopilotMonitor.Functions.Security Model = model, SerialNumber = serialNumber, AutopilotDeviceId = autopilotDeviceId, - RateLimitResult = rateLimitResult + RateLimitResult = rateLimitResult, + ValidatedBy = validatedBy }; } + + /// + /// Attaches DevPrep association lookup outcome to the current Activity so it surfaces + /// in Application Insights request telemetry as customDimensions. + /// Centralised here so the SecurityValidator's call-site stays small. + /// + internal static void EnrichRequestTelemetryWithDeviceAssociation(DeviceAssociationResult result) + { + var activity = Activity.Current; + if (activity == null) return; + + activity.AddTag("devprep.association.matched", result.IsValid ? "true" : "false"); + activity.AddTag("devprep.association.transient", result.IsTransient ? "true" : "false"); + + if (!string.IsNullOrEmpty(result.AssociationState)) + activity.AddTag("devprep.association.state", result.AssociationState); + if (!string.IsNullOrEmpty(result.DevicePreparationPolicyId)) + activity.AddTag("devprep.association.policyId", result.DevicePreparationPolicyId); + if (result.PreAssociationDateTime.HasValue) + activity.AddTag("devprep.association.preAssociationUtc", result.PreAssociationDateTime.Value.ToString("O")); + } } /// @@ -438,5 +490,12 @@ namespace AutopilotMonitor.Functions.Security /// Bootstrap session short code (only set when IsBootstrapAuth is true) /// public string? BootstrapShortCode { get; set; } + + /// + /// Which validator ultimately authorized this device. Copied into the + /// RegisterSession response so the agent can reconcile the enrollment-type + /// verdict with its registry-based detection. + /// + public ValidatorType ValidatedBy { get; set; } = ValidatorType.Unknown; } } diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index 13daa87f..61bd72fa 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -111,6 +111,16 @@ namespace AutopilotMonitor.Shared.Models /// public bool ValidateCorporateIdentifier { get; set; } = false; + /// + /// Whether to look up devices in the Windows Autopilot Device Preparation (WDP) "Device association" catalog + /// via Graph (tenantAssociatedDevices). Currently runs in shadow mode only — the lookup + /// result is recorded as request-telemetry tags but does NOT block enrollment, even when the device is not + /// associated. Intended to be promoted to a hard gate (analogous to ) + /// once DevPrep leaves Private Preview. Visible in the Web settings page only to Global Admins during preview. + /// Requires the same Graph permission as the other validators (DeviceManagementServiceConfig.Read.All). + /// + public bool ValidateDeviceAssociation { get; set; } = false; + /// /// Emergency bypass for agent security gate (Global Admin use only). /// If true, agent requests are accepted even when ValidateAutopilotDevice is false. diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Enrollment/EnrollmentType.cs b/src/Shared/AutopilotMonitor.Shared/Models/Enrollment/EnrollmentType.cs new file mode 100644 index 00000000..8888b562 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Enrollment/EnrollmentType.cs @@ -0,0 +1,51 @@ +using System; + +namespace AutopilotMonitor.Shared.Models +{ + /// + /// The enrollment flow a device is going through. + /// Wire format (HTTP, Table Storage) stays "v1"/"v2" for backward compatibility — + /// use to convert. + /// + public enum EnrollmentType + { + Unknown = 0, + Classic = 1, // Windows Autopilot (v1): ESP-driven OOBE + DevicePreparation = 2 // Windows Autopilot Device Preparation (v2, WDP): no ESP + } + + public static class EnrollmentTypeExtensions + { + public const string WireClassic = "v1"; + public const string WireDevicePreparation = "v2"; + + /// + /// Returns the wire format string ("v1"/"v2") used in persisted data and HTTP payloads. + /// Unknown returns "v1" to preserve legacy default behavior for sessions that predate typing. + /// + public static string ToWireFormat(this EnrollmentType value) => value switch + { + EnrollmentType.DevicePreparation => WireDevicePreparation, + EnrollmentType.Classic => WireClassic, + _ => WireClassic + }; + + /// + /// Parses the wire format string into an . + /// Null/empty/unrecognized values return . + /// + public static EnrollmentType FromWireFormat(string? wireValue) + { + if (string.IsNullOrWhiteSpace(wireValue)) + return EnrollmentType.Unknown; + + if (string.Equals(wireValue, WireClassic, StringComparison.OrdinalIgnoreCase)) + return EnrollmentType.Classic; + + if (string.Equals(wireValue, WireDevicePreparation, StringComparison.OrdinalIgnoreCase)) + return EnrollmentType.DevicePreparation; + + return EnrollmentType.Unknown; + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs index 0e4282db..f6a637dc 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs @@ -11,6 +11,20 @@ namespace AutopilotMonitor.Shared.Models public SessionRegistration Registration { get; set; } = default!; } + /// + /// Identifies which validator authorized the device during session registration. + /// Surfaced in the RegisterSession response so the agent can reconcile against its + /// own registry-based detection and, when needed, switch its enrollment flow. + /// + public enum ValidatorType + { + Unknown = 0, + AutopilotV1 = 1, // AutopilotDeviceValidator (windowsAutopilotDeviceIdentities) + CorporateIdentifier = 2, // CorporateIdentifierValidator (importedDeviceIdentities) + DeviceAssociation = 3, // DevPrep DeviceAssociationValidator (tenantAssociatedDevices) — future + Bootstrap = 4 // Bootstrap token auth (pre-MDM OOBE) + } + /// /// Response from session registration /// @@ -26,6 +40,14 @@ namespace AutopilotMonitor.Shared.Models /// Values: "Succeeded", "Failed". Agent should run cleanup instead of starting monitoring. /// public string? AdminAction { get; set; } + + /// + /// Authoritative signal: which validator accepted this device. + /// Lets the agent reconcile its registry-based enrollment-type detection + /// against the backend's verdict (e.g. AutopilotV1 → Classic flow, DeviceAssociation → DevPrep flow). + /// Older backends that do not set this return Unknown — agent falls back to its own detection. + /// + public ValidatorType ValidatedBy { get; set; } = ValidatorType.Unknown; } /// diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx index f4ded0c7..30f49053 100644 --- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx @@ -30,6 +30,10 @@ interface TenantConfigContextValue { setValidateAutopilotDevice: (v: boolean) => void; validateCorporateIdentifier: boolean; setValidateCorporateIdentifier: (v: boolean) => void; + validateDeviceAssociation: boolean; + setValidateDeviceAssociation: (v: boolean) => void; + /** Toggle + persist DevPrep Device Association validation in one shot (no consent flow needed). */ + handleToggleDeviceAssociationValidation: (newValue: boolean) => Promise; autopilotConsentInProgress: boolean; beginDeviceValidationConsentFlow: (trigger: "autopilot" | "corporate") => Promise; @@ -246,6 +250,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } const [webhookNotifyOnHardwareRejection, setWebhookNotifyOnHardwareRejection] = useState(false); const [validateAutopilotDevice, setValidateAutopilotDevice] = useState(false); const [validateCorporateIdentifier, setValidateCorporateIdentifier] = useState(false); + const [validateDeviceAssociation, setValidateDeviceAssociation] = useState(false); const [dataRetentionDays, setDataRetentionDays] = useState(90); const [sessionTimeoutHours, setSessionTimeoutHours] = useState(5); @@ -347,6 +352,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } setWebhookNotifyOnHardwareRejection(data.webhookNotifyOnHardwareRejection ?? false); setValidateAutopilotDevice(data.validateAutopilotDevice); setValidateCorporateIdentifier(data.validateCorporateIdentifier ?? false); + setValidateDeviceAssociation(data.validateDeviceAssociation ?? false); setDataRetentionDays(data.dataRetentionDays ?? 90); setSessionTimeoutHours(data.sessionTimeoutHours ?? 5); setEnablePerformanceCollector(data.enablePerformanceCollector ?? true); @@ -530,7 +536,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 }) => { + const saveConfiguration = useCallback(async (sectionName: string, overrides?: { validateAutopilotDevice?: boolean; validateCorporateIdentifier?: boolean; validateDeviceAssociation?: boolean }) => { if (!tenantId || !config) return; try { @@ -540,6 +546,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } const autopilotDeviceValidationValue = overrides?.validateAutopilotDevice ?? validateAutopilotDevice; const corporateIdentifierValidationValue = overrides?.validateCorporateIdentifier ?? validateCorporateIdentifier; + const deviceAssociationValidationValue = overrides?.validateDeviceAssociation ?? validateDeviceAssociation; const updatedConfig: TenantConfiguration = { ...config, @@ -548,6 +555,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } webhookNotifyOnHardwareRejection, validateAutopilotDevice: autopilotDeviceValidationValue, validateCorporateIdentifier: corporateIdentifierValidationValue, + validateDeviceAssociation: deviceAssociationValidationValue, dataRetentionDays, sessionTimeoutHours, enablePerformanceCollector, @@ -610,6 +618,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } // Sync form state variables with the server response setValidateAutopilotDevice(result.config.validateAutopilotDevice); setValidateCorporateIdentifier(result.config.validateCorporateIdentifier ?? false); + setValidateDeviceAssociation(result.config.validateDeviceAssociation ?? false); setUnrestrictedMode(result.config.unrestrictedMode ?? false); trackEvent("settings_saved", { section: sectionName }); setSuccessMessage("Configuration saved successfully!"); @@ -627,7 +636,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } } }, [ tenantId, config, getAccessToken, addNotification, - manufacturerWhitelist, modelWhitelist, validateAutopilotDevice, validateCorporateIdentifier, + manufacturerWhitelist, modelWhitelist, validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation, dataRetentionDays, sessionTimeoutHours, enablePerformanceCollector, performanceCollectorInterval, helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds, enableGeoLocation, enableTimezoneAutoSet, enableImeMatchLog, logLevel, showScriptOutput, showEnrollmentSummary, @@ -884,6 +893,16 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } const handleSaveUnrestrictedMode = useCallback(() => saveConfiguration("unrestrictedMode"), [saveConfiguration]); + /** + * Toggle the DevPrep "Device association" shadow validation. No consent flow needed — + * the Graph permission is already covered by the existing Autopilot/Corporate validators + * and the result is observational (does not gate enrollment in Phase A). + */ + const handleToggleDeviceAssociationValidation = useCallback(async (newValue: boolean) => { + setValidateDeviceAssociation(newValue); + await saveConfiguration("autopilotValidation", { validateDeviceAssociation: newValue }); + }, [saveConfiguration]); + // ----------------------------------------------------------------------- // Admin management handlers // ----------------------------------------------------------------------- @@ -1138,6 +1157,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode } // Validation validateAutopilotDevice, setValidateAutopilotDevice, validateCorporateIdentifier, setValidateCorporateIdentifier, + validateDeviceAssociation, setValidateDeviceAssociation, + handleToggleDeviceAssociationValidation, autopilotConsentInProgress, beginDeviceValidationConsentFlow, // Hardware whitelist diff --git a/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx index ff442270..8c04bd36 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/AutopilotValidationSection.tsx @@ -7,6 +7,12 @@ interface AutopilotValidationSectionProps { setValidateAutopilotDevice: (value: boolean) => void; validateCorporateIdentifier: boolean; setValidateCorporateIdentifier: (value: boolean) => void; + /** DevPrep "Device association" — shadow mode during Private Preview. Provided when `showDeviceAssociationToggle=true`. */ + validateDeviceAssociation?: boolean; + /** Toggle + persist DevPrep validation in one shot (no consent dialog). */ + onToggleDeviceAssociation?: (newValue: boolean) => void | Promise; + /** Render the DevPrep Device Association toggle. Gated to Global Admins by the parent. */ + showDeviceAssociationToggle?: boolean; autopilotConsentInProgress: boolean; saving: boolean; onBeginConsent: (trigger: 'autopilot' | 'corporate') => void; @@ -17,6 +23,9 @@ export default function AutopilotValidationSection({ setValidateAutopilotDevice, validateCorporateIdentifier, setValidateCorporateIdentifier, + validateDeviceAssociation = false, + onToggleDeviceAssociation, + showDeviceAssociationToggle = false, autopilotConsentInProgress, saving, onBeginConsent, @@ -126,6 +135,34 @@ export default function AutopilotValidationSection({ + {/* DevPrep Device Association — Private Preview, GA-gated, shadow-mode (no enrollment block) */} + {showDeviceAssociationToggle && onToggleDeviceAssociation && ( +
+
+

Windows Autopilot Device Preparation

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

Important:{" "} diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx index 531a4747..719d8607 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionAutopilotValidation.tsx @@ -1,5 +1,6 @@ "use client"; +import { useAuth } from "@/contexts/AuthContext"; import { useTenantConfig } from "../../TenantConfigContext"; import { TenantNotifications } from "../../TenantNotifications"; import AutopilotValidationSection from "../../components/AutopilotValidationSection"; @@ -8,10 +9,21 @@ export function SectionAutopilotValidation() { const { validateAutopilotDevice, setValidateAutopilotDevice, validateCorporateIdentifier, setValidateCorporateIdentifier, + validateDeviceAssociation, + handleToggleDeviceAssociationValidation, autopilotConsentInProgress, savingSection, beginDeviceValidationConsentFlow, } = useTenantConfig(); + const { user } = useAuth(); + // DevPrep "Device association" is in Microsoft Private Preview — surface only to Global Admins + // until it ships GA. Backend always rejects writes from non-GA callers regardless of UI. + // TODO(devprep-followup): add vitest DOM-render test asserting toggle is hidden for + // non-GA users and visible for GA users. Requires adding jsdom + @testing-library/react + // to the web test setup (vitest.config.ts currently only matches *.test.ts, no JSX). + // Tracked in memory/project_devprep_followups.md. + const showDeviceAssociationToggle = user?.isGlobalAdmin === true; + return ( <> @@ -20,6 +32,9 @@ export function SectionAutopilotValidation() { setValidateAutopilotDevice={setValidateAutopilotDevice} validateCorporateIdentifier={validateCorporateIdentifier} setValidateCorporateIdentifier={setValidateCorporateIdentifier} + validateDeviceAssociation={validateDeviceAssociation} + onToggleDeviceAssociation={handleToggleDeviceAssociationValidation} + showDeviceAssociationToggle={showDeviceAssociationToggle} autopilotConsentInProgress={autopilotConsentInProgress} saving={savingSection === "autopilotValidation"} onBeginConsent={beginDeviceValidationConsentFlow} diff --git a/src/Web/autopilot-monitor-web/app/settings/types.ts b/src/Web/autopilot-monitor-web/app/settings/types.ts index 2e16e1b4..fd28ddef 100644 --- a/src/Web/autopilot-monitor-web/app/settings/types.ts +++ b/src/Web/autopilot-monitor-web/app/settings/types.ts @@ -8,6 +8,12 @@ export interface TenantConfiguration { modelWhitelist: string; validateAutopilotDevice: boolean; validateCorporateIdentifier?: boolean; + /** + * DevPrep Device Association validation (shadow mode during Private Preview). + * Looks devices up via Graph `tenantAssociatedDevices` but does NOT block enrollment. + * UI surface is gated to Global Admins until DevPrep ships GA. + */ + validateDeviceAssociation?: boolean; allowInsecureAgentRequests?: boolean; dataRetentionDays: number; sessionTimeoutHours: number;