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