feat: added DevPrepValidator (device association) and restructured agent to distinguish Classic vs v2 autopilot flow, in addition option for dev build added

This commit is contained in:
Oliver Kieselbach
2026-04-14 16:09:32 +02:00
parent d391900d6d
commit 6702e1d43b
28 changed files with 1491 additions and 14 deletions
@@ -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
}
@@ -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
}
@@ -0,0 +1,119 @@
using AutopilotMonitor.Agent.Core.Monitoring.Flows;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.Core.Tests.Flows
{
/// <summary>
/// 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.
/// </summary>
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<ClassicAutopilotFlow>(EnrollmentFlowFactory.Create(EnrollmentType.Classic));
}
[Fact]
public void Factory_Create_DevicePreparation_ReturnsDevPrepFlow()
{
Assert.IsType<DevicePreparationFlow>(EnrollmentFlowFactory.Create(EnrollmentType.DevicePreparation));
}
[Fact]
public void Factory_Create_Unknown_FallsBackToClassic()
{
// Unknown must not produce null — Classic is the safe legacy default.
Assert.IsType<ClassicAutopilotFlow>(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);
}
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
public void ReconcileWithBackendValidator(ValidatorType validatedBy)
{
if (_enrollmentTracker != null)
{
_enrollmentTracker.ReconcileWithBackendValidator(validatedBy);
}
else
{
_pendingBackendValidator = validatedBy;
}
}
/// <summary>
/// Returns true for events generated by periodic timers (not real enrollment activity).
/// </summary>
@@ -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();
@@ -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");
@@ -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;
}
}
@@ -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;
}
}
@@ -0,0 +1,21 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Flows
{
public static class EnrollmentFlowFactory
{
/// <summary>
/// Returns the flow handler for the given enrollment type.
/// <see cref="EnrollmentType.Unknown"/> falls back to the Classic flow
/// (safe default — Classic is the long-standing behavior).
/// </summary>
public static IEnrollmentFlowHandler Create(EnrollmentType type) => type switch
{
EnrollmentType.DevicePreparation => new DevicePreparationFlow(),
_ => new ClassicAutopilotFlow()
};
public static IEnrollmentFlowHandler FromWireFormat(string wireFormat)
=> Create(EnrollmentTypeExtensions.FromWireFormat(wireFormat));
}
}
@@ -0,0 +1,30 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.Core.Monitoring.Flows
{
/// <summary>
/// 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
/// <c>CollectorCoordinator</c>. When DevPrep-specific trackers land, they will
/// move into the corresponding flow handler.
/// </summary>
public interface IEnrollmentFlowHandler
{
EnrollmentType SupportedType { get; }
/// <summary>
/// True when the flow relies on ESP-phase signals for progress and completion.
/// Classic: true. DevPrep (WDP): false (no ESP exists).
/// </summary>
bool TracksEspPhases { get; }
/// <summary>
/// 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.
/// </summary>
bool AppliesEspGateOnDesktopArrival { get; }
}
}
@@ -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
@@ -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;
}
@@ -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
}
}
/// <summary>
/// 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 <c>enrollment_type_mismatch</c> warning event and
/// switches the active flow handler to the backend's verdict.
///
/// <see cref="ValidatorType.CorporateIdentifier"/> and <see cref="ValidatorType.Bootstrap"/>
/// are treated as type-neutral (no claim about Classic vs DevPrep) and do not trigger a switch.
/// </summary>
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<string, object>
{
{ "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;
@@ -1 +1 @@
956
960
@@ -0,0 +1,181 @@
using System.Diagnostics;
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="DeviceAssociationValidator"/> — 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 <c>AutopilotDeviceValidator</c>; behavioural drift would surface here.
/// </summary>
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);
}
}
@@ -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,
@@ -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
@@ -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);
@@ -161,6 +161,7 @@ builder.Services.AddSingleton<ReportAgentErrorFunction>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.GraphTokenService>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.AutopilotDeviceValidator>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CorporateIdentifierValidator>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.DeviceAssociationValidator>();
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Notifications.WebhookNotificationService>()
.ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.SocketsHttpHandler
{
@@ -0,0 +1,59 @@
using System;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Result of looking up a device in the DevPrep "Device association" Graph API
/// (<c>/beta/deviceManagement/tenantAssociatedDevices</c>).
///
/// Mirrors the resilience contract of <see cref="AutopilotDeviceValidationResult"/>:
/// transient failures (Graph 5xx, token issues, network) set <see cref="IsTransient"/>=true
/// and are NOT cached; definitive results (found / not-found) ARE cached.
/// </summary>
public class DeviceAssociationResult
{
public bool IsValid { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool IsTransient { get; set; }
public string? SerialNumber { get; set; }
// ---- Fields populated when a matching tenantAssociatedDevices entry is found ----
/// <summary>
/// Association state from the Graph response.
/// Observed values: "preassociated"; expected to also include "associated", "enrolled".
/// </summary>
public string? AssociationState { get; set; }
/// <summary>
/// GUID of the DevPrep policy that the device is associated to (or all-zero GUID if not yet assigned).
/// </summary>
public string? DevicePreparationPolicyId { get; set; }
/// <summary>
/// UPN of the user who pre-associated the device (or null when none).
/// </summary>
public string? PreAssociatedByUserPrincipalName { get; set; }
/// <summary>
/// UPN the device is assigned to (set later in the association lifecycle, may be null).
/// </summary>
public string? AssignedToUserPrincipalName { get; set; }
public DateTime? PreAssociationDateTime { get; set; }
public DateTime? AssociationDateTime { get; set; }
/// <summary>
/// Intune managed device ID once enrollment has occurred (all-zero GUID otherwise).
/// </summary>
public string? ManagedDeviceId { get; set; }
public string? ErrorMessage { get; set; }
}
}
@@ -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
{
/// <summary>
/// Validates devices against the Windows Autopilot Device Preparation (WDP)
/// "Device association" Graph API (<c>tenantAssociatedDevices</c>).
///
/// Built as a near-sibling of <see cref="AutopilotDeviceValidator"/> — 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
/// - <see cref="DeviceAssociationResult.IsTransient"/> on Graph 5xx, token failures, exceptions
/// - Serial-trim normalisation, single-quote escape for OData filter, exact-match guard
/// </summary>
public class DeviceAssociationValidator
{
private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30);
private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5);
private readonly ILogger<DeviceAssociationValidator> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly GraphTokenService _graphTokenService;
public DeviceAssociationValidator(
ILogger<DeviceAssociationValidator> logger,
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
GraphTokenService graphTokenService)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_cache = cache;
_graphTokenService = graphTokenService;
}
public async Task<DeviceAssociationResult> 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<DeviceAssociationResult> 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 ?? "<none>", normalizedSerial,
result.AssociationState ?? "<none>", result.DevicePreparationPolicyId ?? "<none>");
}
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 ?? "<none>", normalizedSerial, attempt);
return new DeviceAssociationResult
{
IsValid = false,
IsTransient = true,
SerialNumber = normalizedSerial,
ErrorMessage = $"Error during device association lookup: {ex.Message}"
};
}
}
/// <summary>
/// Pure-function: maps a tenantAssociatedDevices Graph response body to a
/// <see cref="DeviceAssociationResult"/>. Performs an exact-match guard on
/// <c>serialNumber</c> to avoid false positives if the Graph endpoint widens
/// its filter semantics later.
/// </summary>
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<JObject>(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;
}
}
}
@@ -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 ?? "<none>", devPrepResult.DevicePreparationPolicyId ?? "<none>");
}
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
};
}
/// <summary>
/// Attaches DevPrep association lookup outcome to the current Activity so it surfaces
/// in Application Insights request telemetry as <c>customDimensions</c>.
/// Centralised here so the SecurityValidator's call-site stays small.
/// </summary>
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"));
}
}
/// <summary>
@@ -438,5 +490,12 @@ namespace AutopilotMonitor.Functions.Security
/// Bootstrap session short code (only set when IsBootstrapAuth is true)
/// </summary>
public string? BootstrapShortCode { get; set; }
/// <summary>
/// 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.
/// </summary>
public ValidatorType ValidatedBy { get; set; } = ValidatorType.Unknown;
}
}
@@ -111,6 +111,16 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool ValidateCorporateIdentifier { get; set; } = false;
/// <summary>
/// Whether to look up devices in the Windows Autopilot Device Preparation (WDP) "Device association" catalog
/// via Graph (<c>tenantAssociatedDevices</c>). Currently runs in <strong>shadow mode only</strong> — 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 <see cref="ValidateAutopilotDevice"/>)
/// 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).
/// </summary>
public bool ValidateDeviceAssociation { 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.
@@ -0,0 +1,51 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// The enrollment flow a device is going through.
/// Wire format (HTTP, Table Storage) stays "v1"/"v2" for backward compatibility —
/// use <see cref="EnrollmentTypeExtensions"/> to convert.
/// </summary>
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";
/// <summary>
/// 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.
/// </summary>
public static string ToWireFormat(this EnrollmentType value) => value switch
{
EnrollmentType.DevicePreparation => WireDevicePreparation,
EnrollmentType.Classic => WireClassic,
_ => WireClassic
};
/// <summary>
/// Parses the wire format string into an <see cref="EnrollmentType"/>.
/// Null/empty/unrecognized values return <see cref="EnrollmentType.Unknown"/>.
/// </summary>
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;
}
}
}
@@ -11,6 +11,20 @@ namespace AutopilotMonitor.Shared.Models
public SessionRegistration Registration { get; set; } = default!;
}
/// <summary>
/// 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.
/// </summary>
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)
}
/// <summary>
/// Response from session registration
/// </summary>
@@ -26,6 +40,14 @@ namespace AutopilotMonitor.Shared.Models
/// Values: "Succeeded", "Failed". Agent should run cleanup instead of starting monitoring.
/// </summary>
public string? AdminAction { get; set; }
/// <summary>
/// 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.
/// </summary>
public ValidatorType ValidatedBy { get; set; } = ValidatorType.Unknown;
}
/// <summary>
@@ -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<void>;
autopilotConsentInProgress: boolean;
beginDeviceValidationConsentFlow: (trigger: "autopilot" | "corporate") => Promise<void>;
@@ -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
@@ -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<void>;
/** 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({
</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">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold text-gray-700 tracking-wide">Windows Autopilot Device Preparation</p>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wider bg-purple-100 text-purple-800">
Preview · GA only
</span>
</div>
<label className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-900">Enable DevPrep Device Association Validation</p>
<p className="text-sm text-gray-500">
Looks devices up in the WDP <code className="text-xs">tenantAssociatedDevices</code> catalog. Currently runs in <strong>shadow mode</strong> — the result is recorded as request telemetry only and does NOT block enrollment. Will become a hard gate once DevPrep ships GA.
</p>
</div>
<button
onClick={() => { void onToggleDeviceAssociation(!validateDeviceAssociation); }}
disabled={saving}
aria-label="Toggle DevPrep Device Association validation"
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors disabled:opacity-60 disabled:cursor-not-allowed ${validateDeviceAssociation ? 'bg-emerald-500' : 'bg-gray-300'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${validateDeviceAssociation ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</label>
</div>
)}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-900">
<strong>Important:</strong>{" "}
@@ -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 (
<>
<TenantNotifications />
@@ -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}
@@ -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;