mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8312ee0c6 | |||
| d639d9dda9 | |||
| f44e8aadb0 | |||
| e5b7003799 | |||
| 6db3cb9741 | |||
| a213290eb6 | |||
| d4e67cdbb5 | |||
| e4d3e8ed30 | |||
| d4774ed043 | |||
| b9cc515258 | |||
| fef1f54c09 | |||
| 60dc850317 | |||
| da58ac0a27 | |||
| d1d15db4fe | |||
| a3810b74a7 | |||
| 80d50a6d92 | |||
| 5371de3856 | |||
| 483dc3de94 | |||
| 06b69aa0d8 | |||
| 351c8d4b3a | |||
| e704aa7359 | |||
| 622a2437a0 | |||
| 5ceab7e611 | |||
| 8a7cd5ed98 | |||
| 15804e2817 | |||
| 194958d3cd | |||
| 40323ca57e | |||
| 1b4a87d467 | |||
| a43a4e474a | |||
| 82dafc5e8f | |||
| b4af8c77be | |||
| 0bfab326e5 | |||
| 3a898b5071 | |||
| 9377671349 | |||
| 902560692b | |||
| 3946eddcea | |||
| 48e27fb2d6 |
+1
-1
Submodule .claude/commands updated: c142d824e3...c67c475a62
@@ -0,0 +1,72 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs the bootstrap integration tests once as SYSTEM, the account the bootstrap runs as on a device.
|
||||
|
||||
.DESCRIPTION
|
||||
Registers a one-time scheduled task as SYSTEM that runs
|
||||
scripts/Bootstrap/Bootstrap.Integration.Tests.ps1 under Windows PowerShell 5.1, waits for it,
|
||||
prints its output and fails when the run did not happen as SYSTEM or a test failed. Run as
|
||||
SYSTEM, the tests use the real folder permissions, including Administrators as owner.
|
||||
|
||||
CI only (bootstrap-script-gates.yml): it needs an elevated runner and Pester installed for all
|
||||
users.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path,
|
||||
[int]$TimeoutMinutes = 20
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$work = Join-Path ([System.IO.Path]::GetTempPath()) "apm-system-pass-$PID"
|
||||
New-Item -ItemType Directory -Force -Path $work | Out-Null
|
||||
$runner = Join-Path $work 'run.ps1'
|
||||
$output = Join-Path $work 'output.txt'
|
||||
$result = Join-Path $work 'result.txt'
|
||||
$tests = Join-Path $RepoRoot 'scripts\Bootstrap\Bootstrap.Integration.Tests.ps1'
|
||||
|
||||
$runnerText = @'
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||
try {
|
||||
Import-Module Pester -MinimumVersion 5.5.0
|
||||
$configuration = New-PesterConfiguration
|
||||
$configuration.Run.Path = '__TESTS__'
|
||||
$configuration.Run.PassThru = $true
|
||||
$configuration.Output.Verbosity = 'Detailed'
|
||||
$run = Invoke-Pester -Configuration $configuration
|
||||
Set-Content -LiteralPath '__RESULT__' -Value ('{0}|{1}|{2}' -f $identity, $run.PassedCount, $run.FailedCount)
|
||||
} catch {
|
||||
Set-Content -LiteralPath '__RESULT__' -Value ('{0}|error|{1}' -f $identity, $_.Exception.Message)
|
||||
}
|
||||
'@
|
||||
Set-Content -LiteralPath $runner -Encoding ASCII -Value ($runnerText.Replace('__TESTS__', $tests).Replace('__RESULT__', $result))
|
||||
|
||||
$taskName = "apm-bootstrap-system-pass-$PID"
|
||||
$arguments = "-NoProfile -ExecutionPolicy Bypass -Command `"& '$runner' *> '$output'`""
|
||||
$action = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -Argument $arguments
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest
|
||||
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null
|
||||
try {
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
$deadline = (Get-Date).AddMinutes($TimeoutMinutes)
|
||||
do {
|
||||
Start-Sleep -Seconds 5
|
||||
$state = (Get-ScheduledTask -TaskName $taskName).State
|
||||
} while ($state -eq 'Running' -and (Get-Date) -lt $deadline)
|
||||
if ($state -eq 'Running') {
|
||||
Stop-ScheduledTask -TaskName $taskName
|
||||
throw "The SYSTEM pass did not finish within $TimeoutMinutes minutes."
|
||||
}
|
||||
} finally {
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $output) { Get-Content -LiteralPath $output }
|
||||
if (-not (Test-Path -LiteralPath $result)) { throw 'The SYSTEM pass wrote no result.' }
|
||||
$identity, $passed, $failed = (Get-Content -LiteralPath $result -Raw).Trim().Split('|', 3)
|
||||
if ($identity -ne 'S-1-5-18') { throw "The SYSTEM pass ran as $identity, not as SYSTEM." }
|
||||
if ($passed -eq 'error') { throw "The SYSTEM pass could not run the tests: $failed" }
|
||||
if ([int]$failed -gt 0 -or [int]$passed -eq 0) { throw "SYSTEM pass: $passed passed, $failed failed." }
|
||||
Write-Host "SYSTEM pass: $passed passed, 0 failed."
|
||||
@@ -17,7 +17,7 @@
|
||||
cosmetics.
|
||||
|
||||
Steps, in order:
|
||||
1. Parse $ScriptVersion from the loader and the bootstrap source.
|
||||
1. Parse the $ScriptVersion line from the loader and the bootstrap source.
|
||||
2. Render the -Dev variants by literal substitution of the URL/manifest
|
||||
defaults. A missing anchor is a hard failure -- a silently un-substituted
|
||||
dev script would point the dev fleet at the stable agent.
|
||||
@@ -25,17 +25,21 @@
|
||||
carries the same $ScriptVersion, abort. Otherwise the docs badge,
|
||||
version.json.bootstrapVersion and the portal's "your script is outdated"
|
||||
hint would all keep asserting a version that is no longer what ships.
|
||||
4. Build the publish set (loader, bootstrap, both dev renders, the extras).
|
||||
4. Build the publish set: both installers, then both loaders, then the extras.
|
||||
A loader that went live before the installer it requires would refuse the
|
||||
installer still being served. -Channel dev keeps only the two -Dev renders.
|
||||
5. Authenticode-sign every script (see -SigningToken). Signing is the LAST
|
||||
transformation: it covers the exact CRLF/UTF-8 bytes that get uploaded,
|
||||
and nothing may touch them afterwards or the signature breaks.
|
||||
6. Upload every script blob with Cache-Control: no-cache (they rotate in
|
||||
6. Run the loader's own identity check on the exact bytes of both installers:
|
||||
what fails here, every device would refuse.
|
||||
7. Upload every script blob with Cache-Control: no-cache (they rotate in
|
||||
place), mirrored fail-soft to the legacy account.
|
||||
7. Reconcile the version oracles: version.json.bootstrapVersion and
|
||||
.loaderVersion (read-modify-write under If-Match, so a concurrent agent
|
||||
8. Reconcile the version oracles (release channel only): version.json.bootstrapVersion
|
||||
and .loaderVersion (read-modify-write under If-Match, so a concurrent agent
|
||||
release cannot lose its agent fields) and
|
||||
AdminConfiguration.LatestBootstrapV2ScriptVersion.
|
||||
8. Verify through the alias -- re-download each blob and compare SHA-256.
|
||||
9. Verify through the alias -- re-download each blob and compare SHA-256.
|
||||
|
||||
Comparisons against a published script always strip its signature block first
|
||||
(Get-ComparableText). A signature carries a timestamp, so signed bytes differ on
|
||||
@@ -67,6 +71,11 @@
|
||||
Optional directory that receives a copy of every published file exactly as uploaded.
|
||||
Used by CI to offer the signed scripts as a build artifact for local inspection.
|
||||
|
||||
.PARAMETER Channel
|
||||
release (default): every script, plus version.json and the portal's script version.
|
||||
dev: only Start-AutopilotMonitor-Dev.ps1 and Install-AutopilotMonitor-Dev.ps1, so a
|
||||
script change can run on dev devices, signed, before it goes to customers.
|
||||
|
||||
.EXAMPLE
|
||||
./Publish-BootstrapScripts.ps1 -DryRun
|
||||
|
||||
@@ -83,6 +92,8 @@ param(
|
||||
[string]$CertificateName,
|
||||
[string]$ExpectedPublisher,
|
||||
[string]$SignedOutputDir,
|
||||
[ValidateSet('release', 'dev')]
|
||||
[string]$Channel = 'release',
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
@@ -293,7 +304,9 @@ function Assert-VersionBump {
|
||||
|
||||
function Get-BootstrapScriptVersion {
|
||||
param([string]$Content, [string]$Origin, [switch]$Optional)
|
||||
if ($Content -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"') { return $Matches[1] }
|
||||
# The assignment line itself, as the loader reads it: a comment or help text that mentions
|
||||
# $ScriptVersion must not count.
|
||||
if ($Content -match '(?m)^\$ScriptVersion[ \t]*=[ \t]*"([\d\.\-a-zA-Z]+)"[ \t]*\r?$') { return $Matches[1] }
|
||||
# Unreadable SOURCE is fatal. An unreadable PUBLISHED copy must not be: that is exactly
|
||||
# the state a republish is meant to repair, and a guard that blocks the repair is worse
|
||||
# than no guard.
|
||||
@@ -301,6 +314,19 @@ function Get-BootstrapScriptVersion {
|
||||
throw "Could not parse ScriptVersion from $Origin"
|
||||
}
|
||||
|
||||
# The loader's own check, run on the exact bytes that will be served: dot-sourced in this
|
||||
# function's scope, so none of the loader's variables reach the publisher's.
|
||||
function Assert-LoaderAcceptsInstaller {
|
||||
param([object]$Item, [string]$WorkDir)
|
||||
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
|
||||
$path = Join-Path $WorkDir $Item.BlobName
|
||||
[System.IO.File]::WriteAllBytes($path, [byte[]]$Item.Bytes)
|
||||
. $LoaderSource
|
||||
if (-not (Test-InstallerIdentity -Path $path)) {
|
||||
throw "The loader would refuse $($Item.BlobName) (role or minimum version). Nothing was uploaded."
|
||||
}
|
||||
}
|
||||
|
||||
# What the script actually DOES, with comments and layout removed: the PowerShell token
|
||||
# stream minus comments, newlines and line continuations. Lets the version-bump guard tell
|
||||
# a behaviour change (must be versioned -- consumers act on the version) apart from a
|
||||
@@ -353,8 +379,11 @@ $devLoaderContent = $loaderContent.Replace($stableLoaderLiteral, $devLoaderLiter
|
||||
Write-Host "Rendered $LoaderDevBlob (points at $DevBlob)"
|
||||
|
||||
# ------------------------------------------------------------------ 3. bump guards
|
||||
Assert-VersionBump -BlobName $BootstrapBlob -SourceContent $sourceContent -SourceOrigin $BootstrapSource -SourceVersion $scriptVersion
|
||||
Assert-VersionBump -BlobName $LoaderBlob -SourceContent $loaderContent -SourceOrigin $LoaderSource -SourceVersion $loaderVersion
|
||||
# The versions are what customers act on, so the guard watches the release blobs only.
|
||||
if ($Channel -eq 'release') {
|
||||
Assert-VersionBump -BlobName $BootstrapBlob -SourceContent $sourceContent -SourceOrigin $BootstrapSource -SourceVersion $scriptVersion
|
||||
Assert-VersionBump -BlobName $LoaderBlob -SourceContent $loaderContent -SourceOrigin $LoaderSource -SourceVersion $loaderVersion
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 4. publish set
|
||||
# Sources beyond the bootstrap pair, read straight from the repo.
|
||||
@@ -369,10 +398,15 @@ $extraSources = @(
|
||||
# Text is the unsigned publish form and stays the reference for every comparison against a
|
||||
# published copy; Bytes is what goes on the wire and carries the signature after step 4.
|
||||
$publishSet = [System.Collections.Generic.List[object]]::new()
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderBlob; Text = $loaderContent; Bytes = (Get-PublishBytes $loaderContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderDevBlob; Text = $devLoaderContent; Bytes = (Get-PublishBytes $devLoaderContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $BootstrapBlob; Text = $sourceContent; Bytes = $sourceBytes })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $DevBlob; Text = $devContent; Bytes = (Get-PublishBytes $devContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderBlob; Text = $loaderContent; Bytes = (Get-PublishBytes $loaderContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderDevBlob; Text = $devLoaderContent; Bytes = (Get-PublishBytes $devLoaderContent) })
|
||||
if ($Channel -eq 'dev') {
|
||||
$publishSet = [System.Collections.Generic.List[object]]@($publishSet | Where-Object { $_.BlobName -in @($DevBlob, $LoaderDevBlob) })
|
||||
$extraSources = @()
|
||||
Write-Host "Channel dev: publishing $DevBlob and $LoaderDevBlob only"
|
||||
}
|
||||
foreach ($extra in $extraSources) {
|
||||
$extraPath = Join-Path $RepoRoot $extra.Path
|
||||
if (-not (Test-Path $extraPath)) {
|
||||
@@ -391,13 +425,18 @@ if ($SigningEnabled) {
|
||||
Write-Host '::warning::No signing token supplied -- dry run continues with unsigned scripts.'
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 6. loader check
|
||||
foreach ($item in @($publishSet | Where-Object { $_.BlobName -in @($BootstrapBlob, $DevBlob) })) {
|
||||
Assert-LoaderAcceptsInstaller -Item $item -WorkDir (Join-Path ([System.IO.Path]::GetTempPath()) "apm-publish-check-$PID")
|
||||
}
|
||||
|
||||
$legacySas = if ($LegacySasToken) { $LegacySasToken.TrimStart('?') } else { '' }
|
||||
|
||||
# Acquired lazily: a dry run must stay runnable without any Azure login -- the PR gate
|
||||
# (bootstrap-script-gates.yml) executes this script with -DryRun and never logs in.
|
||||
$authHeaders = if ($DryRun) { $null } else { & (Join-Path $PSScriptRoot 'Get-StorageAuthHeaders.ps1') }
|
||||
|
||||
# ------------------------------------------------------------------ 6. upload
|
||||
# ------------------------------------------------------------------ 7. upload
|
||||
foreach ($item in $publishSet) {
|
||||
# Explicit type, not a convenience: an Object[] body is uploaded as space-separated
|
||||
# decimals rather than raw bytes, and every hash check still passes because [byte[]]
|
||||
@@ -464,11 +503,12 @@ foreach ($item in $publishSet) {
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 7. version oracles
|
||||
# ------------------------------------------------------------------ 8. version oracles
|
||||
# version.json is the agent manifest; only bootstrapVersion belongs to us. Read from the
|
||||
# blob (not the alias) for an authoritative ETag, write back under If-Match so a concurrent
|
||||
# agent release cannot lose its version/sha256 fields.
|
||||
if (-not $DryRun) {
|
||||
# agent release cannot lose its version/sha256 fields. They describe the release scripts, so
|
||||
# a dev publish leaves them alone.
|
||||
if (-not $DryRun -and $Channel -eq 'release') {
|
||||
try {
|
||||
$manifestResp = Invoke-WebRequest -Uri "$ContainerUrl/version.json" -Method Get -UseBasicParsing
|
||||
$manifestRaw = if ($manifestResp.Content -is [byte[]]) {
|
||||
@@ -512,7 +552,9 @@ if (-not $DryRun) {
|
||||
}
|
||||
}
|
||||
|
||||
if ($DryRun) {
|
||||
if ($Channel -ne 'release') {
|
||||
Write-Host "Channel dev: version.json and AdminConfiguration.LatestBootstrapV2ScriptVersion stay as they are"
|
||||
} elseif ($DryRun) {
|
||||
Write-Host " [dry-run] would set AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
|
||||
} else {
|
||||
$tableHeaders = @{
|
||||
@@ -525,7 +567,7 @@ if ($DryRun) {
|
||||
Write-Host "AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 8. verify via alias
|
||||
# ------------------------------------------------------------------ 9. verify via alias
|
||||
if (-not $DryRun) {
|
||||
$pending = [System.Collections.ArrayList]::new()
|
||||
$publishSet | ForEach-Object { [void]$pending.Add($_) }
|
||||
@@ -555,7 +597,7 @@ if (-not $DryRun) {
|
||||
if ($env:GITHUB_STEP_SUMMARY) {
|
||||
$mode = if ($DryRun) { 'DRY RUN -- nothing uploaded' } else { 'published + verified via the download alias' }
|
||||
$summary = @(
|
||||
"### Bootstrap scripts: $mode",
|
||||
"### Bootstrap scripts: $mode (channel $Channel)",
|
||||
'',
|
||||
"Loader script version: **$loaderVersion** | Bootstrap script version: **$scriptVersion**",
|
||||
"Signed: **$(if ($SigningEnabled) { "yes, certificate '$CertificateName'" } else { 'NO (unsigned dry run)' })**",
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Checks the bootstrap's folders against a real standard user.
|
||||
|
||||
.DESCRIPTION
|
||||
Creates a temporary local standard user and, with that user's token, tries what any local user
|
||||
on an enrolling device could try against the folders the loader and the installer create:
|
||||
write, change, rename and delete files, add entries, move or delete the folder. Every attempt
|
||||
must be denied. Folders the user creates first, or entries the user plants in an unprotected
|
||||
agent folder of an older installer, must make the installer stop instead of installing into
|
||||
them. A control attempt proves the user can write where users may.
|
||||
|
||||
The folders come from the installer's own functions, in a scratch copy of ProgramData that
|
||||
carries the real ProgramData permissions.
|
||||
|
||||
CI only (bootstrap-script-gates.yml): it needs an elevated runner, and it creates a local
|
||||
account and removes it again.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# The user's token, for impersonation in this process (a CI helper; customer scripts never
|
||||
# compile code).
|
||||
Add-Type -Namespace ApmCi -Name Native -MemberDefinition @'
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
public static extern bool LogonUser(string user, string domain, string password, int logonType, int logonProvider, out Microsoft.Win32.SafeHandles.SafeAccessTokenHandle token);
|
||||
'@
|
||||
|
||||
. (Join-Path $RepoRoot 'scripts\Bootstrap\Install-AutopilotMonitor.ps1')
|
||||
|
||||
$results = New-Object System.Collections.Generic.List[object]
|
||||
|
||||
function Invoke-AsUser {
|
||||
param([scriptblock]$Action)
|
||||
[System.Security.Principal.WindowsIdentity]::RunImpersonated($script:token, [Action]$Action)
|
||||
}
|
||||
|
||||
# An attempt the user must not get through: it has to fail with "access denied", not with
|
||||
# any other error that would only hide a broken test.
|
||||
function Assert-Denied {
|
||||
param([string]$Name, [scriptblock]$Attempt)
|
||||
$outcome = 'allowed'
|
||||
try {
|
||||
Invoke-AsUser $Attempt
|
||||
} catch {
|
||||
$base = $_.Exception.GetBaseException()
|
||||
$outcome = if ($base -is [System.UnauthorizedAccessException] -or $base.HResult -eq -2147024891) { 'denied' } else { "error: $($base.Message)" }
|
||||
}
|
||||
$results.Add([pscustomobject]@{ Check = $Name; Expected = 'denied'; Actual = $outcome })
|
||||
}
|
||||
|
||||
function Assert-That {
|
||||
param([string]$Name, [bool]$Condition, [string]$Detail = '')
|
||||
$results.Add([pscustomobject]@{ Check = $Name; Expected = 'true'; Actual = $(if ($Condition) { 'true' } else { "false $Detail".Trim() }) })
|
||||
}
|
||||
|
||||
# What a bootstrap running as SYSTEM leaves as owner; the default for other admin-created
|
||||
# entries differs between Windows editions.
|
||||
function Set-AdministratorsOwner {
|
||||
param([string]$Path)
|
||||
$security = (Get-Item -LiteralPath $Path -Force).GetAccessControl('Owner')
|
||||
$security.SetOwner((New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')))
|
||||
(Get-Item -LiteralPath $Path -Force).SetAccessControl($security)
|
||||
}
|
||||
|
||||
$name = 'apm-ci-' + [guid]::NewGuid().ToString('N').Substring(0, 8)
|
||||
$password = [guid]::NewGuid().ToString('N') + 'Aa1!'
|
||||
$root = Join-Path $env:SystemDrive ('apm-ci-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
$pd = Join-Path $root 'ProgramData'
|
||||
|
||||
New-LocalUser -Name $name -Password (ConvertTo-SecureString $password -AsPlainText -Force) -AccountNeverExpires -PasswordNeverExpires | Out-Null
|
||||
try {
|
||||
try { Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $name } catch { if ($_.FullyQualifiedErrorId -notlike 'MemberExists*') { throw } }
|
||||
$token = $null
|
||||
# Interactive logon first (what a local user has); network logon where a server allows no
|
||||
# local logon for Users.
|
||||
if (-not [ApmCi.Native]::LogonUser($name, '.', $password, 2, 0, [ref]$token) -and
|
||||
-not [ApmCi.Native]::LogonUser($name, '.', $password, 3, 0, [ref]$token)) {
|
||||
throw "LogonUser failed with Win32 error $([System.Runtime.InteropServices.Marshal]::GetLastWin32Error())."
|
||||
}
|
||||
$script:token = $token
|
||||
$userSid = (New-Object System.Security.Principal.NTAccount($name)).Translate([System.Security.Principal.SecurityIdentifier])
|
||||
$script:probe = $null
|
||||
Invoke-AsUser { $script:probe = [System.Security.Principal.WindowsIdentity]::GetCurrent() }
|
||||
Assert-That 'impersonation runs as the test user' ($script:probe.User -eq $userSid) "$($script:probe.User)"
|
||||
Assert-That 'the test user is no administrator' (-not (New-Object System.Security.Principal.WindowsPrincipal($script:probe)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator))
|
||||
|
||||
# A scratch ProgramData with the real one's permissions.
|
||||
New-Item -ItemType Directory -Path $pd -Force | Out-Null
|
||||
$security = New-Object System.Security.AccessControl.DirectorySecurity
|
||||
$security.SetSecurityDescriptorSddlForm((Get-Acl -LiteralPath $env:ProgramData).GetSecurityDescriptorSddlForm('Access'), 'Access')
|
||||
# Nothing from the drive root, whose default lets authenticated users change subfolders.
|
||||
$security.SetAccessRuleProtection($true, $false)
|
||||
(Get-Item -LiteralPath $pd).SetAccessControl($security)
|
||||
|
||||
# Control: the user may create in ProgramData, so every denial below is the folders' doing.
|
||||
$control = Join-Path $pd 'user-may-create-here'
|
||||
Invoke-AsUser { [System.IO.Directory]::CreateDirectory($control) | Out-Null; [System.IO.File]::WriteAllText((Join-Path $control 'x.txt'), 'x') }
|
||||
Assert-That 'control: the user can create in ProgramData' (Test-Path -LiteralPath (Join-Path $control 'x.txt'))
|
||||
|
||||
# The loader's run folder with a downloaded installer in it.
|
||||
$run = New-RunFolder -Parent $pd -Prefix 'AutopilotMonitor-Loader-'
|
||||
$download = Join-Path $run 'Install-AutopilotMonitor.ps1'
|
||||
[System.IO.File]::WriteAllText($download, 'verified installer')
|
||||
Assert-Denied 'run folder: add a file' { [System.IO.File]::WriteAllText((Join-Path $run 'planted.ps1'), 'x') }
|
||||
Assert-Denied 'run folder: add a folder or link' { [System.IO.Directory]::CreateDirectory((Join-Path $run 'planted')) | Out-Null }
|
||||
Assert-Denied 'run folder: change the download' { [System.IO.File]::AppendAllText($download, 'tampered') }
|
||||
Assert-Denied 'run folder: replace the download' { [System.IO.File]::WriteAllText($download, 'replaced') }
|
||||
Assert-Denied 'run folder: rename the download' { [System.IO.File]::Move($download, $download + '.old') }
|
||||
Assert-Denied 'run folder: delete the download' { [System.IO.File]::Delete($download) }
|
||||
Assert-Denied 'run folder: rename the folder' { [System.IO.Directory]::Move($run, $run + '-moved') }
|
||||
Assert-Denied 'run folder: delete the folder' { [System.IO.Directory]::Delete($run, $true) }
|
||||
Assert-That 'run folder: the download is unchanged' ([System.IO.File]::ReadAllText($download) -eq 'verified installer')
|
||||
|
||||
# The agent folder an install prepares.
|
||||
$base = Join-Path $pd 'AutopilotMonitor'
|
||||
Initialize-AgentBase -Path $base
|
||||
$logs = [System.IO.Directory]::CreateDirectory((Join-Path $base 'Logs')).FullName
|
||||
[System.IO.File]::WriteAllText((Join-Path $logs 'bootstrap-script.log'), 'log')
|
||||
Assert-Denied 'agent folder: add a file' { [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') }
|
||||
Assert-Denied 'agent folder: add a folder or link' { [System.IO.Directory]::CreateDirectory((Join-Path $base 'Agent')) | Out-Null }
|
||||
Assert-Denied 'agent folder: change a log' { [System.IO.File]::AppendAllText((Join-Path $logs 'bootstrap-script.log'), 'x') }
|
||||
Assert-Denied 'agent folder: delete a log' { [System.IO.File]::Delete((Join-Path $logs 'bootstrap-script.log')) }
|
||||
Assert-Denied 'agent folder: rename the folder' { [System.IO.Directory]::Move($base, $base + '-moved') }
|
||||
$owner = (Get-Item -LiteralPath $base).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
||||
Assert-That 'agent folder: owned by Administrators' ($owner -eq 'S-1-5-32-544') $owner
|
||||
[System.IO.Directory]::Delete($base, $true)
|
||||
|
||||
# The user creates the agent folder first: the installer must neither install into it nor
|
||||
# remove it.
|
||||
Invoke-AsUser { [System.IO.Directory]::CreateDirectory($base) | Out-Null; [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') }
|
||||
Assert-That 'squatted agent folder: seen as another account''s' ((Get-FolderState $base) -eq 'Foreign')
|
||||
$refused = $false
|
||||
try { Initialize-AgentBase -Path $base } catch { $refused = $_.Exception.Message -like '*(Foreign); not installing into it.' }
|
||||
Assert-That 'squatted agent folder: the install refuses it' $refused
|
||||
Assert-That 'squatted agent folder: a skip leaves it' ((Remove-AgentBaseOnSkip -Path $base) -like '*left as it is (Foreign).')
|
||||
Assert-That 'squatted agent folder: the planted file stays' (Test-Path -LiteralPath (Join-Path $base 'planted.dll'))
|
||||
[System.IO.Directory]::Delete($base, $true)
|
||||
|
||||
# An agent folder an older installer created without protection: the user plants a file,
|
||||
# the install protects the folder and stops at the planted file.
|
||||
[System.IO.Directory]::CreateDirectory($base) | Out-Null
|
||||
[System.IO.File]::WriteAllText((Join-Path $base 'old-installer.log'), 'old')
|
||||
Set-AdministratorsOwner $base
|
||||
Set-AdministratorsOwner (Join-Path $base 'old-installer.log')
|
||||
Invoke-AsUser { [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') }
|
||||
$refused = $false
|
||||
try { Initialize-AgentBase -Path $base } catch { $refused = $_.Exception.Message -like '*holds entries another account created*' }
|
||||
Assert-That 'planted entry: the install stops' $refused
|
||||
Assert-That 'planted entry: own leftovers are gone, the planted file stays' ((-not (Test-Path -LiteralPath (Join-Path $base 'old-installer.log'))) -and (Test-Path -LiteralPath (Join-Path $base 'planted.dll')))
|
||||
Assert-Denied 'planted entry: the protected folder takes no more' { [System.IO.File]::WriteAllText((Join-Path $base 'second.dll'), 'x') }
|
||||
[System.IO.Directory]::Delete($base, $true)
|
||||
|
||||
# The user creates a run folder name first: it is never used.
|
||||
$squatted = Join-Path $pd 'AutopilotMonitor-Loader-00000000'
|
||||
Invoke-AsUser { [System.IO.Directory]::CreateDirectory($squatted) | Out-Null }
|
||||
$script:names = [System.Collections.Generic.Queue[string]]::new([string[]]@('00000000', '11111111'))
|
||||
function New-RunFolderName { return $script:names.Dequeue() }
|
||||
$fresh = New-RunFolder -Parent $pd -Prefix 'AutopilotMonitor-Loader-'
|
||||
Assert-That 'squatted run folder: a new name is drawn' ($fresh -eq (Join-Path $pd 'AutopilotMonitor-Loader-11111111')) $fresh
|
||||
$squattedOwner = (Get-Item -LiteralPath $squatted).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier])
|
||||
Assert-That 'squatted run folder: still the user''s, untouched' ($squattedOwner -eq $userSid)
|
||||
} finally {
|
||||
if ($script:token) { $script:token.Dispose() }
|
||||
Remove-LocalUser -Name $name -ErrorAction SilentlyContinue
|
||||
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
$results | Format-Table -AutoSize | Out-String -Width 200 | Write-Host
|
||||
$failed = @($results | Where-Object { $_.Actual -ne $_.Expected })
|
||||
if ($failed.Count -gt 0) { throw "Attacker test: $($failed.Count) of $($results.Count) checks failed." }
|
||||
Write-Host "Attacker test: all $($results.Count) checks passed."
|
||||
@@ -15,21 +15,28 @@ permissions:
|
||||
|
||||
jobs:
|
||||
gates:
|
||||
name: Bootstrap guard tests (PS 5.1)
|
||||
runs-on: windows-latest
|
||||
name: Bootstrap guard tests (PS 5.1, ${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Folder permissions and Windows PowerShell 5.1 come with the OS, so both server images.
|
||||
os: [windows-2022, windows-2025]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# For all users: the SYSTEM pass below runs Pester as SYSTEM.
|
||||
- name: Install Pester 5+
|
||||
shell: powershell
|
||||
run: |
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
|
||||
Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck -MinimumVersion 5.5.0
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers | Out-Null
|
||||
Install-Module Pester -Scope AllUsers -Force -SkipPublisherCheck -MinimumVersion 5.5.0
|
||||
|
||||
# Windows PowerShell 5.1 on purpose: that is the runtime IME uses to execute
|
||||
# platform scripts, so guard/relax logic is verified without resetting a device.
|
||||
# The runner is elevated, so the integration tests use the real folder permissions.
|
||||
- name: Run Pester
|
||||
shell: powershell
|
||||
run: |
|
||||
@@ -40,11 +47,20 @@ jobs:
|
||||
$c.Output.Verbosity = 'Detailed'
|
||||
Invoke-Pester -Configuration $c
|
||||
|
||||
# On a device the bootstrap runs as SYSTEM: the integration tests once more as SYSTEM.
|
||||
- name: Integration tests as SYSTEM
|
||||
shell: powershell
|
||||
run: ./.github/scripts/Invoke-BootstrapSystemPass.ps1
|
||||
|
||||
- name: Folder permissions against a real standard user
|
||||
shell: powershell
|
||||
run: ./.github/scripts/Test-BootstrapAttacker.ps1
|
||||
|
||||
- name: Guard scripts stay pure ASCII
|
||||
shell: powershell
|
||||
run: |
|
||||
$failed = $false
|
||||
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' | ForEach-Object {
|
||||
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' -Recurse | ForEach-Object {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
|
||||
for ($i = 0; $i -lt $bytes.Length; $i++) {
|
||||
if ($bytes[$i] -gt 127) {
|
||||
@@ -57,6 +73,15 @@ jobs:
|
||||
if ($failed) { exit 1 }
|
||||
Write-Host 'All bootstrap scripts are pure ASCII.'
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
# The names and log lines the scripts share with the agent, checked against the agent's
|
||||
# own code.
|
||||
- name: Script contracts with the agent
|
||||
run: dotnet test src/Agent/AutopilotMonitor.Agent.V2.Core.Tests --configuration Release --filter "Category=BootstrapContract" --logger "console;verbosity=normal"
|
||||
|
||||
# The publish chain renders the -Dev variant by literal substitution and refuses to
|
||||
# ship when an anchor moved. Running it dry here surfaces that (and any drift against
|
||||
# the alias) on the PR instead of on the push that would have published.
|
||||
|
||||
@@ -181,11 +181,16 @@ jobs:
|
||||
- name: Build MSI
|
||||
shell: pwsh
|
||||
run: |
|
||||
# The MSI's action runs only the exact bytes the package embeds: the hash of the
|
||||
# signed loader goes into the command here, so no other signed script of ours runs
|
||||
# in its place on a device.
|
||||
$loaderSha256 = (Get-FileHash -LiteralPath $env:RUNNER_SCRIPT -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Host "Embedded loader SHA-256: $loaderSha256"
|
||||
# wix resolves File/@Source relative to the working directory, not the wxs —
|
||||
# build from inside the project dir (same as the local build documented in the wxs).
|
||||
$out = Join-Path (Get-Location) $env:STABLE_MSI
|
||||
Push-Location $env:MSI_PROJECT_DIR
|
||||
wix build Package.wxs -arch x64 -o $out
|
||||
wix build Package.wxs -arch x64 -d LoaderSha256=$loaderSha256 -o $out
|
||||
Pop-Location
|
||||
if (-not (Test-Path $env:STABLE_MSI)) { Write-Error "wix build produced no MSI"; exit 1 }
|
||||
Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)"
|
||||
@@ -198,11 +203,90 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# The real chain on a runner, before anything is signed or published: msiexec installs the
|
||||
# MSI from the build, its action checks the loader copy and runs it, the loader fetches the
|
||||
# published installer, which finds the deployment marker set here and removes the copy. The
|
||||
# marker is the installer's first guard, so no agent is ever installed on the runner. Needs
|
||||
# the published installer to be 2.9 or later, which removes the copy in every outcome.
|
||||
smoke:
|
||||
name: Install on a runner (${{ matrix.os }})
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-2022, windows-2025]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download unsigned MSI
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ runner.temp }}/msi
|
||||
|
||||
- name: Install with the deployment marker set, check the footprint, uninstall
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = Join-Path $env:RUNNER_TEMP "msi/$env:STABLE_MSI"
|
||||
$wxs = Get-Content "$env:MSI_PROJECT_DIR/Package.wxs" -Raw
|
||||
if ($wxs -notmatch 'Version="(\d+\.\d+\.\d+)"') { throw 'Could not parse Version from Package.wxs' }
|
||||
$version = $Matches[1]
|
||||
|
||||
New-Item -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Force | Out-Null
|
||||
Set-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -Value ((Get-Date).ToUniversalTime().ToString('o'))
|
||||
|
||||
function Invoke-Msiexec {
|
||||
param([string]$Verb, [string]$Log)
|
||||
$p = Start-Process -FilePath msiexec.exe -ArgumentList "/$Verb `"$msi`" /qn /l*v `"$Log`"" -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content $Log | Select-Object -Last 60; throw "msiexec /$Verb exited with $($p.ExitCode)" }
|
||||
}
|
||||
function Get-Registration {
|
||||
Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' | Get-ItemProperty | Where-Object { $_.DisplayName -eq 'Autopilot Monitor Bootstrap' }
|
||||
}
|
||||
function Show-Tree {
|
||||
param([string]$Path)
|
||||
Get-ChildItem -LiteralPath $Path -Recurse -Force | ForEach-Object { Write-Host " $($_.FullName)" }
|
||||
}
|
||||
|
||||
$installLog = Join-Path $env:RUNNER_TEMP 'msi-install.log'
|
||||
Invoke-Msiexec -Verb i -Log $installLog
|
||||
$registered = Get-Registration
|
||||
if (-not $registered) { throw 'The product is not registered after the install.' }
|
||||
if ($registered.DisplayVersion -ne $version) { throw "Registered version $($registered.DisplayVersion), Package.wxs says $version." }
|
||||
if ((Get-Content $installLog -Raw) -notmatch 'RunBootstrap') { throw 'The install log does not show the action.' }
|
||||
Write-Host "Registered: $($registered.DisplayName) $($registered.DisplayVersion)"
|
||||
|
||||
# Only the installer removes the copy the MSI put under ProgramData: its absence
|
||||
# proves the action ran the loader and the loader ran the installer.
|
||||
foreach ($path in @("$env:ProgramW6432\AutopilotMonitor", "$env:ProgramData\AutopilotMonitor")) {
|
||||
if (Test-Path -LiteralPath $path) { Show-Tree $path; throw "$path exists after the run." }
|
||||
}
|
||||
$runFolders = @(Get-ChildItem -LiteralPath $env:ProgramData -Directory -Filter 'AutopilotMonitor-Loader-*' -Force)
|
||||
if ($runFolders.Count -ne 0) {
|
||||
foreach ($folder in $runFolders) {
|
||||
Show-Tree $folder.FullName
|
||||
Get-ChildItem -LiteralPath $folder.FullName -Filter '*.log' | ForEach-Object { Get-Content -LiteralPath $_.FullName }
|
||||
}
|
||||
throw 'A loader run folder stays: the loader did not end with exit 0.'
|
||||
}
|
||||
Write-Host 'After the install: nothing under ProgramData or Program Files.'
|
||||
|
||||
Invoke-Msiexec -Verb x -Log (Join-Path $env:RUNNER_TEMP 'msi-uninstall.log')
|
||||
if (Get-Registration) { throw 'The product is still registered after the uninstall.' }
|
||||
Remove-Item -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Recurse -Force
|
||||
Write-Host 'Uninstalled; the registration is gone.'
|
||||
|
||||
# Trust zone: signs, verifies and publishes the MSI as data; never builds. Environment
|
||||
# `release`, like sign-loader.
|
||||
release:
|
||||
name: Sign and publish MSI
|
||||
needs: build
|
||||
needs: smoke
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
permissions:
|
||||
|
||||
@@ -14,7 +14,12 @@ on:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
- '.github/scripts/Invoke-BootstrapSystemPass.ps1'
|
||||
- '.github/scripts/Test-BootstrapAttacker.ps1'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
# The integration tests run the generated OOBE script and the MSI's command line.
|
||||
- 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1'
|
||||
- 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -23,6 +23,14 @@ on:
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
channel:
|
||||
description: 'release: every script. dev: only the two -Dev scripts, to try a change on dev devices first'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- release
|
||||
- dev
|
||||
default: release
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -113,13 +121,16 @@ jobs:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
|
||||
|
||||
# A push publishes the release channel; only a dispatch can choose dev.
|
||||
- name: Publish bootstrap + customer scripts
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
PUBLISH_CHANNEL: ${{ inputs.channel || 'release' }}
|
||||
run: |
|
||||
$dryRun = '${{ inputs.dry_run }}' -eq 'true'
|
||||
./.github/scripts/Publish-BootstrapScripts.ps1 `
|
||||
-Channel $env:PUBLISH_CHANNEL `
|
||||
-LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' `
|
||||
-SigningToken $env:CODE_SIGN_AKV_ACCESS_TOKEN `
|
||||
-KeyVaultUrl $env:CODE_SIGN_KEY_VAULT_URL `
|
||||
|
||||
+1
-1
Submodule internal updated: deed41e4f6...ffb7595c02
@@ -0,0 +1,991 @@
|
||||
# Integration tests (Pester 5+) for the bootstrap chain: the real loader and installer, each
|
||||
# started the way Intune and the bootstrap MSI start them -- powershell.exe -File, in a process
|
||||
# of its own -- against a scratch ProgramData and TEMP. Loader 1.2 and the MSI 1.0.x runner run
|
||||
# from TestData, frozen from git: devices keep running them until customers replace them.
|
||||
#
|
||||
# What these tests do not cover is swapped out by global aliases, which win over cmdlets and
|
||||
# over functions a script defines: downloads (served from a scratch folder), signatures, the
|
||||
# catalog check, the guard decision, the deployment marker, the agent task, the agent's
|
||||
# --install (a real program start from PowerShell's location, with the agent's work left out)
|
||||
# and sleeps. Every swap writes a trace line, and the tests check the lines they rely on, so a
|
||||
# swap that stops working turns a test red instead of reaching the real machine.
|
||||
#
|
||||
# Run elevated (CI also runs it as SYSTEM), the folder permissions are the real ones. Without
|
||||
# elevation the test account may not make Administrators the owner, so the protected DACL is
|
||||
# swapped for one without that owner change, and folders the account owns count as ours.
|
||||
# Entries whose name starts with "foreign" count as another account's.
|
||||
#
|
||||
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
BeforeAll {
|
||||
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$script:loaderPath = Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1'
|
||||
$script:installerPath = Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1'
|
||||
$script:loader12Path = Join-Path $PSScriptRoot 'TestData\Start-AutopilotMonitor-1.2.ps1'
|
||||
$script:msiRunnerPath = Join-Path $PSScriptRoot 'TestData\Invoke-BootstrapDownload-1.0.ps1'
|
||||
# The backend's pin of the generated OOBE script, test values filled in.
|
||||
$script:oobeScriptPath = Join-Path $script:repoRoot 'src\Backend\AutopilotMonitor.Functions.Tests\TestData\oobe-bootstrap-golden.ps1'
|
||||
$script:powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
$script:isElevated = (New-Object System.Security.Principal.WindowsPrincipal([System.Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
$script:payloadFiles = @('AutopilotMonitor.Agent.Core.dll', 'AutopilotMonitor.Agent.cat', 'AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.exe.config')
|
||||
|
||||
# The agent package: text files under the agent's names. --install is swapped out, so
|
||||
# nothing in it ever runs.
|
||||
$payload = Join-Path $TestDrive 'payload'
|
||||
New-Item -ItemType Directory -Path $payload | Out-Null
|
||||
foreach ($name in $script:payloadFiles) { Set-Content -LiteralPath (Join-Path $payload $name) -Value "stub $name" }
|
||||
$script:agentZip = Join-Path $TestDrive 'AutopilotMonitor-Agent.zip'
|
||||
Compress-Archive -Path (Join-Path $payload '*') -DestinationPath $script:agentZip
|
||||
$script:agentZipSha256 = (Get-FileHash -LiteralPath $script:agentZip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
|
||||
$script:wrapperText = @'
|
||||
$global:HarnessDir = $PSScriptRoot
|
||||
$global:HarnessOptions = [System.IO.File]::ReadAllText((Join-Path $PSScriptRoot 'options.json')) | ConvertFrom-Json
|
||||
|
||||
function global:Write-HarnessTrace {
|
||||
param([string]$Name, [string]$Detail)
|
||||
[System.IO.File]::AppendAllText((Join-Path $global:HarnessDir 'trace.txt'), "$Name|$Detail`r`n")
|
||||
}
|
||||
|
||||
function global:Get-HarnessServerFile {
|
||||
param([string]$Uri)
|
||||
$source = Join-Path $global:HarnessOptions.Server ($Uri.Substring($Uri.LastIndexOf('/') + 1))
|
||||
if (-not [System.IO.File]::Exists($source)) { throw "The remote server returned an error: (404) Not Found. ($Uri)" }
|
||||
return $source
|
||||
}
|
||||
|
||||
function global:HarnessInvokeWebRequest {
|
||||
[CmdletBinding()]
|
||||
param([string]$Uri, [string]$OutFile, [switch]$UseBasicParsing, [int]$TimeoutSec, [string]$Method)
|
||||
Write-HarnessTrace 'Invoke-WebRequest' $Uri
|
||||
[System.IO.File]::Copy((Get-HarnessServerFile $Uri), $OutFile, $true)
|
||||
}
|
||||
|
||||
function global:HarnessInvokeRestMethod {
|
||||
[CmdletBinding()]
|
||||
param([string]$Uri, [switch]$UseBasicParsing, [int]$TimeoutSec)
|
||||
Write-HarnessTrace 'Invoke-RestMethod' $Uri
|
||||
return ([System.IO.File]::ReadAllText((Get-HarnessServerFile $Uri)) | ConvertFrom-Json)
|
||||
}
|
||||
|
||||
function global:HarnessGetAuthenticodeSignature {
|
||||
[CmdletBinding()]
|
||||
param([string]$FilePath, [byte[]]$Content, [string[]]$SourcePathOrExtension)
|
||||
Write-HarnessTrace 'Get-AuthenticodeSignature' $(if ($Content) { "content:$($Content.Length)" } else { $FilePath })
|
||||
return [pscustomobject]@{
|
||||
Status = $global:HarnessOptions.SignatureStatus
|
||||
SignerCertificate = [pscustomobject]@{ Subject = $global:HarnessOptions.SignerSubject; Thumbprint = 'HARNESS' }
|
||||
}
|
||||
}
|
||||
|
||||
function global:HarnessTestFileCatalog {
|
||||
[CmdletBinding()]
|
||||
param([string]$Path, [string]$CatalogFilePath, [string[]]$FilesToSkip, [switch]$Detailed)
|
||||
Write-HarnessTrace 'Test-FileCatalog' $Path
|
||||
return [pscustomobject]@{
|
||||
Status = $(if ($global:HarnessOptions.CatalogValid) { 'Valid' } else { 'ValidationFailed' })
|
||||
Signature = [pscustomobject]@{ Status = 'Valid'; SignerCertificate = [pscustomobject]@{ Subject = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' } }
|
||||
CatalogItems = @{}
|
||||
PathItems = @{}
|
||||
}
|
||||
}
|
||||
|
||||
function global:HarnessGetBootstrapDecision {
|
||||
param([int]$MaxBootstrapWindowHours, [int]$OobeProfileMaxAgeMinutes)
|
||||
Write-HarnessTrace 'Get-BootstrapDecision' "MaxBootstrapWindowHours=$MaxBootstrapWindowHours"
|
||||
$install = [bool]$global:HarnessOptions.Install
|
||||
return [pscustomobject]@{ Install = $install; ReasonCode = $(if ($install) { $null } else { 'ProfilesFound' }); RelaxActive = $false }
|
||||
}
|
||||
|
||||
function global:HarnessGetRegistryValue64 {
|
||||
param([string]$SubKey, [string]$Name)
|
||||
Write-HarnessTrace 'Get-RegistryValue64' "$SubKey\$Name"
|
||||
if ($SubKey -eq 'SOFTWARE\AutopilotMonitor' -and $Name -eq 'Deployed') { return $global:HarnessOptions.Marker }
|
||||
return $null
|
||||
}
|
||||
|
||||
function global:HarnessGetScheduledTask {
|
||||
[CmdletBinding()]
|
||||
param([string]$TaskName)
|
||||
Write-HarnessTrace 'Get-ScheduledTask' $TaskName
|
||||
if ($global:HarnessOptions.TaskPresent) { return [pscustomobject]@{ TaskName = $TaskName } }
|
||||
throw (New-Object System.Management.Automation.ErrorRecord((New-Object System.Exception "No MSFT_ScheduledTask objects found with property 'TaskName' equal to '$TaskName'."), 'CmdletizationQuery_NotFound_TaskName,Get-ScheduledTask', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $TaskName))
|
||||
}
|
||||
|
||||
function global:HarnessInvokeAgentInstallMode {
|
||||
param([string]$ExePath, [string[]]$Arguments = @('--install'))
|
||||
Write-HarnessTrace 'Invoke-AgentInstallMode' ('{0} {1} exists={2}' -f $ExePath, ($Arguments -join ' '), [System.IO.File]::Exists($ExePath))
|
||||
# A real program start from PowerShell's own location, as the agent start is: a location
|
||||
# that no longer exists fails here with error 267, as it did on the MSI channel. With
|
||||
# AgentStartFails the stub payload itself is started, a text file that never runs.
|
||||
if ($global:HarnessOptions.AgentStartFails) {
|
||||
& $ExePath @Arguments | Out-Host
|
||||
return $LASTEXITCODE
|
||||
}
|
||||
& "$env:SystemRoot\System32\cmd.exe" /c "exit $([int]$global:HarnessOptions.InstallExitCode)" | Out-Host
|
||||
return $LASTEXITCODE
|
||||
}
|
||||
|
||||
function global:HarnessGetProcess {
|
||||
[CmdletBinding()]
|
||||
param([string[]]$Name)
|
||||
if ($Name -contains 'AutopilotMonitor.Agent') {
|
||||
Write-HarnessTrace 'Get-Process' 'AutopilotMonitor.Agent'
|
||||
return [pscustomobject]@{ Id = 4242; StartTime = Get-Date }
|
||||
}
|
||||
return Microsoft.PowerShell.Management\Get-Process @PSBoundParameters
|
||||
}
|
||||
|
||||
function global:HarnessStartSleep {
|
||||
[CmdletBinding()]
|
||||
param([int]$Seconds, [int]$Milliseconds)
|
||||
Write-HarnessTrace 'Start-Sleep' "$Seconds s $Milliseconds ms"
|
||||
}
|
||||
|
||||
function global:HarnessTestIsElevated {
|
||||
Write-HarnessTrace 'Test-IsElevated' "$($global:HarnessOptions.Elevated)"
|
||||
return [bool]$global:HarnessOptions.Elevated
|
||||
}
|
||||
|
||||
# The protected DACL without the owner change a standard account may not make.
|
||||
function global:HarnessNewProtectedDirectorySecurity {
|
||||
$security = New-Object System.Security.AccessControl.DirectorySecurity
|
||||
$security.SetAccessRuleProtection($true, $false)
|
||||
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
||||
$propagation = [System.Security.AccessControl.PropagationFlags]::None
|
||||
foreach ($sid in @([System.Security.Principal.WindowsIdentity]::GetCurrent().User, (New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')))) {
|
||||
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', $inherit, $propagation, 'Allow')))
|
||||
}
|
||||
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule((New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-545')), 'ReadAndExecute', $inherit, $propagation, 'Allow')))
|
||||
return $security
|
||||
}
|
||||
|
||||
function global:HarnessTestIsOwnedByUs {
|
||||
param([string]$Path)
|
||||
if ([System.IO.Path]::GetFileName($Path) -like 'foreign*') { return $false }
|
||||
try {
|
||||
$owner = (Get-Item -LiteralPath $Path -Force -ErrorAction Stop).GetAccessControl([System.Security.AccessControl.AccessControlSections]::Owner).GetOwner([System.Security.Principal.SecurityIdentifier])
|
||||
return ($owner.Value -eq 'S-1-5-18' -or $owner.Value -eq 'S-1-5-32-544' -or $owner -eq [System.Security.Principal.WindowsIdentity]::GetCurrent().User)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$swaps = @{
|
||||
'Invoke-WebRequest' = 'HarnessInvokeWebRequest'
|
||||
'Invoke-RestMethod' = 'HarnessInvokeRestMethod'
|
||||
'Get-AuthenticodeSignature' = 'HarnessGetAuthenticodeSignature'
|
||||
'Test-FileCatalog' = 'HarnessTestFileCatalog'
|
||||
'Get-BootstrapDecision' = 'HarnessGetBootstrapDecision'
|
||||
'Get-RegistryValue64' = 'HarnessGetRegistryValue64'
|
||||
'Get-ScheduledTask' = 'HarnessGetScheduledTask'
|
||||
'Invoke-AgentInstallMode' = 'HarnessInvokeAgentInstallMode'
|
||||
'Get-Process' = 'HarnessGetProcess'
|
||||
'Start-Sleep' = 'HarnessStartSleep'
|
||||
'Test-IsOwnedByUs' = 'HarnessTestIsOwnedByUs'
|
||||
}
|
||||
if ($global:HarnessOptions.SwapElevation) { $swaps['Test-IsElevated'] = 'HarnessTestIsElevated' }
|
||||
if ($global:HarnessOptions.SwapDacl) { $swaps['New-ProtectedDirectorySecurity'] = 'HarnessNewProtectedDirectorySecurity' }
|
||||
foreach ($name in $swaps.Keys) { Set-Alias -Name $name -Value $swaps[$name] -Scope Global -Force }
|
||||
|
||||
# The bootstrap MSI's action is a -Command line; the OOBE script arrives as irm | iex, which
|
||||
# ignores #Requires.
|
||||
if ($global:HarnessOptions.CommandText) {
|
||||
Invoke-Expression $global:HarnessOptions.CommandText
|
||||
} elseif ($global:HarnessOptions.Iex) {
|
||||
Invoke-Expression ([System.IO.File]::ReadAllText($global:HarnessOptions.Script))
|
||||
} else {
|
||||
& $global:HarnessOptions.Script @args
|
||||
}
|
||||
exit $LASTEXITCODE
|
||||
'@
|
||||
|
||||
# A ZIP with exactly these entry names.
|
||||
function New-ZipWithEntries {
|
||||
param([string]$Path, [string[]]$Names)
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$archive = [System.IO.Compression.ZipFile]::Open($Path, [System.IO.Compression.ZipArchiveMode]::Create)
|
||||
try {
|
||||
foreach ($name in $Names) {
|
||||
$writer = New-Object System.IO.StreamWriter($archive.CreateEntry($name).Open())
|
||||
try { $writer.Write("content of $name") } finally { $writer.Dispose() }
|
||||
}
|
||||
} finally { $archive.Dispose() }
|
||||
}
|
||||
|
||||
# Serves a package with one entry that points outside the payload folder, with the
|
||||
# matching manifest hash, under the stable names.
|
||||
function Set-EscapingPackage {
|
||||
param($Scenario)
|
||||
$zip = Join-Path $Scenario.Server 'AutopilotMonitor-Agent.zip'
|
||||
Remove-Item -LiteralPath $zip
|
||||
New-ZipWithEntries -Path $zip -Names @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat', '../../../outside.txt')
|
||||
$sha = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Set-Content -LiteralPath (Join-Path $Scenario.Server 'version.json') -Value ('{"version":"2.0.9999","sha256":"' + $sha + '"}')
|
||||
}
|
||||
|
||||
function New-Scenario {
|
||||
$root = Join-Path $TestDrive ('s' + [guid]::NewGuid().ToString('N').Substring(0, 7))
|
||||
$scenario = [pscustomobject]@{
|
||||
Root = $root
|
||||
ProgramData = Join-Path $root 'ProgramData'
|
||||
Base = Join-Path $root 'ProgramData\AutopilotMonitor'
|
||||
ProgramFiles = Join-Path $root 'ProgramFiles'
|
||||
Temp = Join-Path $root 'Temp'
|
||||
Server = Join-Path $root 'Server'
|
||||
Outside = Join-Path $root 'Outside'
|
||||
Ime = Join-Path $root 'Ime'
|
||||
}
|
||||
foreach ($dir in @($scenario.ProgramData, $scenario.Temp, $scenario.Server, $scenario.Outside, $scenario.Ime)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
Copy-Item -LiteralPath $script:installerPath -Destination (Join-Path $scenario.Server 'Install-AutopilotMonitor.ps1')
|
||||
Copy-Item -LiteralPath $script:agentZip -Destination (Join-Path $scenario.Server 'AutopilotMonitor-Agent.zip')
|
||||
Set-Content -LiteralPath (Join-Path $scenario.Server 'version.json') -Value ('{"version":"2.0.9999","sha256":"' + $script:agentZipSha256 + '"}')
|
||||
return $scenario
|
||||
}
|
||||
|
||||
function Invoke-Scenario {
|
||||
param(
|
||||
$Scenario,
|
||||
[string]$Script,
|
||||
[string[]]$Arguments = @(),
|
||||
[string]$WorkingDirectory,
|
||||
[hashtable]$Options = @{}
|
||||
)
|
||||
$settings = @{
|
||||
Script = $Script
|
||||
Server = $Scenario.Server
|
||||
Install = $true
|
||||
Marker = $null
|
||||
TaskPresent = $false
|
||||
InstallExitCode = 0
|
||||
SignatureStatus = 'Valid'
|
||||
SignerSubject = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE'
|
||||
CatalogValid = $true
|
||||
Elevated = $true
|
||||
Iex = $false
|
||||
CommandText = $null
|
||||
AgentStartFails = $false
|
||||
}
|
||||
foreach ($key in $Options.Keys) { $settings[$key] = $Options[$key] }
|
||||
$script:promiseScenario = $Scenario
|
||||
$settings.SwapElevation = ([bool]$settings.Elevated -ne $script:isElevated)
|
||||
$settings.SwapDacl = -not $script:isElevated
|
||||
|
||||
$harness = Join-Path $Scenario.Root 'Harness'
|
||||
New-Item -ItemType Directory -Path $harness -Force | Out-Null
|
||||
$tracePath = Join-Path $harness 'trace.txt'
|
||||
if (Test-Path -LiteralPath $tracePath) { Remove-Item -LiteralPath $tracePath }
|
||||
[System.IO.File]::WriteAllText((Join-Path $harness 'options.json'), ($settings | ConvertTo-Json))
|
||||
[System.IO.File]::WriteAllText((Join-Path $harness 'run.ps1'), $script:wrapperText)
|
||||
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $script:powershell
|
||||
$quoted = @($Arguments | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } })
|
||||
$psi.Arguments = ('-NoProfile -ExecutionPolicy Bypass -File "{0}" {1}' -f (Join-Path $harness 'run.ps1'), ($quoted -join ' ')).Trim()
|
||||
$psi.WorkingDirectory = $(if ($WorkingDirectory) { $WorkingDirectory } else { $Scenario.Root })
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$psi.EnvironmentVariables['ProgramData'] = $Scenario.ProgramData
|
||||
$psi.EnvironmentVariables['ALLUSERSPROFILE'] = $Scenario.ProgramData
|
||||
$psi.EnvironmentVariables['TEMP'] = $Scenario.Temp
|
||||
$psi.EnvironmentVariables['TMP'] = $Scenario.Temp
|
||||
$psi.EnvironmentVariables['ProgramW6432'] = $Scenario.ProgramFiles
|
||||
$process = [System.Diagnostics.Process]::Start($psi)
|
||||
$stdout = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderr = $process.StandardError.ReadToEndAsync()
|
||||
if (-not $process.WaitForExit(300000)) { $process.Kill(); throw 'The harness run did not end within five minutes.' }
|
||||
$process.WaitForExit()
|
||||
|
||||
return [pscustomobject]@{
|
||||
ExitCode = $process.ExitCode
|
||||
StdOut = $stdout.Result
|
||||
StdErr = $stderr.Result
|
||||
Trace = @(if (Test-Path -LiteralPath $tracePath) { Get-Content -LiteralPath $tracePath })
|
||||
}
|
||||
}
|
||||
|
||||
# Every entry below $Root, links as links and never followed; run-folder ids as <id>.
|
||||
# -Content adds a hash per file, for "exactly as it was".
|
||||
function Get-Tree {
|
||||
param([string]$Root, [switch]$Content)
|
||||
$entries = New-Object System.Collections.Generic.List[string]
|
||||
$walk = {
|
||||
param([string]$Folder)
|
||||
foreach ($entry in [System.IO.Directory]::GetFileSystemEntries($Folder)) {
|
||||
$relative = $entry.Substring($Root.Length + 1) -replace '-[0-9a-f]{8}(?=\\|$)', '-<id>'
|
||||
$attributes = [System.IO.File]::GetAttributes($entry)
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { $entries.Add("$relative -> link"); continue }
|
||||
if (($attributes -band [System.IO.FileAttributes]::Directory) -ne 0) { $entries.Add("$relative\"); & $walk $entry; continue }
|
||||
if ($Content) { $relative += ' ' + (Get-FileHash -LiteralPath $entry -Algorithm SHA256).Hash.Substring(0, 16) }
|
||||
$entries.Add($relative)
|
||||
}
|
||||
}
|
||||
if ([System.IO.Directory]::Exists($Root)) { & $walk $Root }
|
||||
return (Join-Tree $entries)
|
||||
}
|
||||
|
||||
function Join-Tree {
|
||||
param([string[]]$Entries)
|
||||
$sorted = [string[]]@($Entries | Where-Object { $_ })
|
||||
[System.Array]::Sort($sorted, [System.StringComparer]::Ordinal)
|
||||
return ($sorted -join "`n")
|
||||
}
|
||||
|
||||
# The promise every run keeps, whatever the scenario asserts about ProgramData: nothing of
|
||||
# ours anywhere else. The scenario's own fixture folders are the only other entries.
|
||||
function Assert-NothingOutsideProgramData {
|
||||
param($Scenario)
|
||||
$fixtures = @('ProgramData', 'ProgramFiles', 'Temp', 'Server', 'Outside', 'Ime', 'Harness')
|
||||
$others = @([System.IO.Directory]::GetFileSystemEntries($Scenario.Root) | ForEach-Object { [System.IO.Path]::GetFileName($_) } | Where-Object { $_ -notin $fixtures })
|
||||
$others | Should -BeNullOrEmpty -Because 'the scripts write nowhere but ProgramData'
|
||||
Get-Tree $Scenario.ProgramFiles | Should -BeNullOrEmpty -Because 'nothing of ours stays under Program Files'
|
||||
Get-Tree $Scenario.Temp | Should -BeNullOrEmpty -Because 'the scripts never stage in the shared temp folder'
|
||||
}
|
||||
|
||||
# Every Describe runs this after each test: the last run of the test kept the promise.
|
||||
function Assert-PromiseOfLastRun {
|
||||
if (-not $script:promiseScenario) { return }
|
||||
$scenario = $script:promiseScenario
|
||||
$script:promiseScenario = $null
|
||||
Assert-NothingOutsideProgramData $scenario
|
||||
}
|
||||
|
||||
function Get-StdErrLines {
|
||||
param($Run)
|
||||
return @($Run.StdErr -split "`r?`n" | Where-Object { $_ })
|
||||
}
|
||||
|
||||
function New-Junction {
|
||||
param([string]$Link, [string]$Target)
|
||||
cmd.exe /c mklink /J "$Link" "$Target" | Out-Null
|
||||
}
|
||||
|
||||
function Add-File {
|
||||
param([string]$Path, [string]$Value = 'x')
|
||||
New-Item -ItemType Directory -Path (Split-Path $Path) -Force | Out-Null
|
||||
Set-Content -LiteralPath $Path -Value $Value
|
||||
}
|
||||
|
||||
# A base with what earlier bootstrap versions and agents could leave behind, plus a file
|
||||
# no version knows yet and a junction out of it.
|
||||
function Add-Leftovers {
|
||||
param($Scenario)
|
||||
Add-File (Join-Path $Scenario.Base 'Install-AutopilotMonitor.ps1')
|
||||
Add-File (Join-Path $Scenario.Base 'Logs\bootstrap-loader.log')
|
||||
Add-File (Join-Path $Scenario.Base 'Logs\bootstrap-script.log')
|
||||
Add-File (Join-Path $Scenario.Base 'Agent-Install\Agent\AutopilotMonitor.Agent.Core.dll')
|
||||
Add-File (Join-Path $Scenario.Base 'future-version-file.bin')
|
||||
Add-File (Join-Path $Scenario.Outside 'target\sentinel.txt') 'must survive'
|
||||
New-Junction -Link (Join-Path $Scenario.Base 'linked') -Target (Join-Path $Scenario.Outside 'target')
|
||||
}
|
||||
|
||||
$script:installedTree = @(
|
||||
'AutopilotMonitor\'
|
||||
'AutopilotMonitor\Agent\'
|
||||
'AutopilotMonitor\Agent\AutopilotMonitor.Agent.Core.dll'
|
||||
'AutopilotMonitor\Agent\AutopilotMonitor.Agent.cat'
|
||||
'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe'
|
||||
'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe.config'
|
||||
'AutopilotMonitor\Logs\'
|
||||
)
|
||||
}
|
||||
|
||||
Describe 'loader 1.4' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
It 'installs and leaves only the agent folder, with its log carried over' {
|
||||
$s = New-Scenario
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath -Arguments @('-MaxBootstrapWindowHours', '24')
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
Get-Tree $s.Temp | Should -BeNullOrEmpty
|
||||
$run.Trace | Should -Contain 'Get-BootstrapDecision|MaxBootstrapWindowHours=24'
|
||||
$run.Trace | Should -Contain 'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1'
|
||||
$run.Trace | Should -Contain 'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip'
|
||||
$run.Trace | Should -Contain 'Invoke-RestMethod|https://download.autopilotmonitor.com/agent/version.json'
|
||||
@($run.Trace | Where-Object { $_ -like 'Test-FileCatalog|*' }).Count | Should -Be 1
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*\AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe --install exists=True' }).Count | Should -Be 1
|
||||
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
|
||||
Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-loader.log') -Raw | Should -Match '=== Autopilot Monitor bootstrap loader v1\.4 started'
|
||||
if ($script:isElevated) {
|
||||
(Get-Item -LiteralPath $s.Base).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier]).Value | Should -Be 'S-1-5-32-544'
|
||||
}
|
||||
}
|
||||
|
||||
It 'skips and leaves nothing behind, also what older versions left and files no version knows' {
|
||||
$s = New-Scenario
|
||||
Add-Leftovers $s
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive'
|
||||
$run.StdOut | Should -Match 'No agent on this device: .* removed\.'
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Agent.zip' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'leaves the folder of an agent exactly as it is, self-update leftovers included' {
|
||||
$s = New-Scenario
|
||||
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
|
||||
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.Core.dll.old') 'previous version'
|
||||
Add-File (Join-Path $s.Base 'Updates\AutopilotMonitor-Agent-Update.zip') 'update'
|
||||
Add-File (Join-Path $s.Base 'Agent-Update\AutopilotMonitor.Agent.Core.dll') 'staged'
|
||||
Add-File (Join-Path $s.Base 'Logs\agent.log') 'agent log'
|
||||
Add-File (Join-Path $s.Base 'State\session.id') 'session'
|
||||
$before = Get-Tree $s.ProgramData -Content
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData -Content | Should -Be $before
|
||||
$run.StdOut | Should -Match 'Agent present \(agent executable\)'
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'leaves the folder alone when only the deployment marker <Case>' -TestCases @(
|
||||
@{ Case = 'is there (logs kept after enrollment)'; Options = @{ Marker = '2026-10-01T08:00:00Z' } }
|
||||
@{ Case = 'is missing but the agent task exists'; Options = @{ TaskPresent = $true } }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
Add-File (Join-Path $s.Base 'Logs\agent.log') 'kept log'
|
||||
$before = Get-Tree $s.ProgramData -Content
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath -Options $Options
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData -Content | Should -Be $before
|
||||
}
|
||||
|
||||
It 'keeps its log when the installer fails; a later run removes it, a run within the hour leaves it' {
|
||||
$s = New-Scenario
|
||||
Remove-Item -LiteralPath (Join-Path $s.Server 'version.json')
|
||||
$failed = Invoke-Scenario $s -Script $script:loaderPath
|
||||
|
||||
$failed.ExitCode | Should -Be 0
|
||||
@(Get-StdErrLines $failed).Count | Should -Be 1
|
||||
$failed.StdErr | Should -Match '^AutopilotMonitor bootstrap failed: No SHA-256 hash available'
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @(
|
||||
'AutopilotMonitor-Loader-<id>\'
|
||||
'AutopilotMonitor-Loader-<id>\bootstrap-loader.log'
|
||||
'AutopilotMonitor\'
|
||||
'AutopilotMonitor\Logs\'
|
||||
'AutopilotMonitor\Logs\bootstrap-loader.log'
|
||||
'AutopilotMonitor\Logs\bootstrap-script.log'
|
||||
))
|
||||
|
||||
Set-Content -LiteralPath (Join-Path $s.Server 'version.json') -Value ('{"version":"2.0.9999","sha256":"' + $script:agentZipSha256 + '"}')
|
||||
$kept = (Get-ChildItem -LiteralPath $s.ProgramData -Directory -Filter 'AutopilotMonitor-Loader-*').FullName
|
||||
|
||||
# Minutes later the folder may still belong to a running loader: it stays.
|
||||
$soon = Invoke-Scenario $s -Script $script:loaderPath
|
||||
$soon.ExitCode | Should -Be 0
|
||||
$soon.StdErr | Should -BeNullOrEmpty
|
||||
Test-Path -LiteralPath $kept | Should -BeTrue
|
||||
Get-Tree $s.Base | Should -Be (Join-Tree ((@($script:installedTree) | ForEach-Object { $_ -replace '^AutopilotMonitor\\', '' } | Where-Object { $_ }) + 'Logs\bootstrap-loader.log' + 'Logs\bootstrap-script.log'))
|
||||
|
||||
[System.IO.Directory]::SetCreationTimeUtc($kept, [System.DateTime]::UtcNow.AddHours(-2))
|
||||
$later = Invoke-Scenario $s -Script $script:loaderPath -Options @{ Install = $false }
|
||||
$later.ExitCode | Should -Be 0
|
||||
Test-Path -LiteralPath $kept | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'runs nothing it cannot verify: <Case>' -TestCases @(
|
||||
@{ Case = 'unsigned'; Options = @{ SignatureStatus = 'NotSigned' }; Edit = $null }
|
||||
@{ Case = 'another publisher'; Options = @{ SignerSubject = 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }; Edit = $null }
|
||||
@{ Case = 'an installer below 2.8'; Options = @{}; Edit = @('$ScriptVersion = "2.9"', '$ScriptVersion = "2.7"') }
|
||||
@{ Case = 'not the installer'; Options = @{}; Edit = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptRole = "AutopilotMonitor-Other"') }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
if ($Edit) {
|
||||
$served = Join-Path $s.Server 'Install-AutopilotMonitor.ps1'
|
||||
$text = [System.IO.File]::ReadAllText($served)
|
||||
$text.Contains($Edit[0]) | Should -BeTrue
|
||||
[System.IO.File]::WriteAllText($served, $text.Replace($Edit[0], $Edit[1]))
|
||||
}
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath -Options $Options
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Install-AutopilotMonitor.ps1' }).Count | Should -Be 3
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 0
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor-Loader-<id>\', 'AutopilotMonitor-Loader-<id>\bootstrap-loader.log'))
|
||||
}
|
||||
|
||||
It 'refuses to run without SYSTEM or administrator rights and creates nothing' {
|
||||
$s = New-Scenario
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath -Options @{ Elevated = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
@(Get-StdErrLines $run) | Should -Be 'AutopilotMonitor loader: not running as SYSTEM or administrator; nothing was downloaded or changed.'
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'installer assigned directly' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
BeforeEach {
|
||||
$s = New-Scenario
|
||||
$imeCopy = Join-Path $s.Ime '0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.ps1'
|
||||
Copy-Item -LiteralPath $script:installerPath -Destination $imeCopy
|
||||
}
|
||||
|
||||
It 'installs, and the copy IME runs stays where IME put it' {
|
||||
$run = Invoke-Scenario $s -Script $imeCopy
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
Test-Path -LiteralPath $imeCopy | Should -BeTrue
|
||||
Get-Tree $s.Temp | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'skips and leaves nothing behind' {
|
||||
Add-Leftovers $s
|
||||
$run = Invoke-Scenario $s -Script $imeCopy -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive'
|
||||
}
|
||||
|
||||
# An agent that ran owns its folder, even after a failed --install; one that never started
|
||||
# leaves only payload, which a later run would take for an agent.
|
||||
It 'fails with exit code 1 and exactly one line on stderr: <Case>' -TestCases @(
|
||||
@{ Case = 'the download fails'; Missing = 'AutopilotMonitor-Agent.zip'; Options = @{}; Message = 'AutopilotMonitor bootstrap failed: The remote server returned an error: (404) Not Found.*'; AgentFolder = 'gone' }
|
||||
@{ Case = 'the payload fails its catalog'; Missing = $null; Options = @{ CatalogValid = $false }; Message = 'AutopilotMonitor bootstrap failed: Payload verification FAILED*'; AgentFolder = 'gone' }
|
||||
@{ Case = 'the agent install fails'; Missing = $null; Options = @{ InstallExitCode = 7 }; Message = 'AutopilotMonitor bootstrap failed: Agent install failed with exit code 7'; AgentFolder = 'stays' }
|
||||
@{ Case = 'the agent does not start'; Missing = $null; Options = @{ AgentStartFails = $true }; Message = "AutopilotMonitor bootstrap failed: Program 'AutopilotMonitor.Agent.exe' failed to run*"; AgentFolder = 'gone' }
|
||||
) {
|
||||
if ($Missing) { Remove-Item -LiteralPath (Join-Path $s.Server $Missing) }
|
||||
$run = Invoke-Scenario $s -Script $imeCopy -Options $Options
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
@(Get-StdErrLines $run).Count | Should -Be 1
|
||||
@(Get-StdErrLines $run)[0] | Should -BeLike $Message
|
||||
Get-Tree $s.ProgramData | Should -Not -Match 'Agent-Install'
|
||||
Test-Path -LiteralPath (Join-Path $s.Base 'Agent') | Should -Be ($AgentFolder -eq 'stays')
|
||||
Test-Path -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-script.log') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'refuses an agent package with an entry outside the payload folder and writes nothing outside' {
|
||||
Set-EscapingPackage $s
|
||||
$run = Invoke-Scenario $s -Script $imeCopy
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
@(Get-StdErrLines $run).Count | Should -Be 1
|
||||
@(Get-StdErrLines $run)[0] | Should -BeLike 'AutopilotMonitor bootstrap failed: The agent package has an entry that would be written outside the payload folder*'
|
||||
Get-Tree $s.Root | Should -Not -Match 'outside\.txt'
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
}
|
||||
|
||||
It 'refuses to run without SYSTEM or administrator rights and changes nothing' {
|
||||
Add-File (Join-Path $s.Base 'Logs\bootstrap-script.log') 'earlier run'
|
||||
$before = Get-Tree $s.ProgramData -Content
|
||||
$run = Invoke-Scenario $s -Script $imeCopy -Options @{ Elevated = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
@(Get-StdErrLines $run) | Should -Be 'AutopilotMonitor bootstrap: not running as SYSTEM or administrator; nothing was changed.'
|
||||
Get-Tree $s.ProgramData -Content | Should -Be $before
|
||||
}
|
||||
|
||||
It 'does not install into a folder that holds another account''s entries, and leaves them' {
|
||||
Add-File (Join-Path $s.Base 'foreign-planted.dll') 'planted'
|
||||
Add-File (Join-Path $s.Base 'Logs\bootstrap-script.log') 'earlier run'
|
||||
$run = Invoke-Scenario $s -Script $imeCopy
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
@(Get-StdErrLines $run).Count | Should -Be 1
|
||||
$run.StdErr | Should -Match 'holds entries another account created'
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\foreign-planted.dll'))
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'leaves another account''s entries and their folder when it skips' {
|
||||
Add-File (Join-Path $s.Base 'foreign-planted.dll') 'planted'
|
||||
Add-File (Join-Path $s.Base 'Logs\bootstrap-script.log') 'earlier run'
|
||||
$run = Invoke-Scenario $s -Script $imeCopy -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\foreign-planted.dll'))
|
||||
}
|
||||
|
||||
It 'never follows an agent folder that is a link: <Case>' -TestCases @(
|
||||
@{ Case = 'install'; Install = $true; ExitCode = 1 }
|
||||
@{ Case = 'skip'; Install = $false; ExitCode = 0 }
|
||||
) {
|
||||
Add-File (Join-Path $s.Outside 'target\sentinel.txt') 'must survive'
|
||||
New-Junction -Link $s.Base -Target (Join-Path $s.Outside 'target')
|
||||
$run = Invoke-Scenario $s -Script $imeCopy -Options @{ Install = $Install }
|
||||
|
||||
$run.ExitCode | Should -Be $ExitCode
|
||||
Get-Tree $s.ProgramData | Should -Be 'AutopilotMonitor -> link'
|
||||
Get-Tree (Join-Path $s.Outside 'target') | Should -Be 'sentinel.txt'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'loader 1.2, frozen' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
It 'installs with the current installer, which removes its copy and keeps the loader start line' {
|
||||
$s = New-Scenario
|
||||
$run = Invoke-Scenario $s -Script $script:loader12Path
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
$log = @(Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-loader.log'))
|
||||
$log[0] | Should -Match '^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] === Autopilot Monitor bootstrap loader v1\.2 started \(carried over by the bootstrapper\) ===$'
|
||||
}
|
||||
|
||||
It 'leaves the folder of an agent as it is, but not the copy the old loader saved there' {
|
||||
$s = New-Scenario
|
||||
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
|
||||
Add-File (Join-Path $s.Base 'Logs\agent.log') 'agent log'
|
||||
$run = Invoke-Scenario $s -Script $script:loader12Path
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @(
|
||||
'AutopilotMonitor\', 'AutopilotMonitor\Agent\', 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe'
|
||||
'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\agent.log', 'AutopilotMonitor\Logs\bootstrap-loader.log'
|
||||
))
|
||||
Get-Content -LiteralPath (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') | Should -Be 'running agent'
|
||||
}
|
||||
|
||||
It 'skips with the current installer and leaves nothing behind' {
|
||||
$s = New-Scenario
|
||||
Add-Leftovers $s
|
||||
$run = Invoke-Scenario $s -Script $script:loader12Path -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
It '<Name> skips and leaves <Left>' -TestCases @(
|
||||
@{ Name = 'MSI 1.3 (loader in Program Files)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Location = 'program-files'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
|
||||
@{ Name = 'MSI 1.2 (loader 1.3)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
|
||||
@{ Name = 'MSI 1.1 (loader 1.2)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader12'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
|
||||
@{ Name = 'MSI 1.0 (own runner)'; File = 'Invoke-BootstrapDownload.ps1'; Source = 'msiRunner'; Arguments = @(); Left = 'only the log the runner holds open'; Tree = @('AutopilotMonitor\', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\bootstrap-msi.log') }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
$msi = if ($Location -eq 'program-files') { Join-Path $s.Root 'ProgramFiles\AutopilotMonitor\Bootstrap' } else { Join-Path $s.Base 'Msi' }
|
||||
New-Item -ItemType Directory -Path $msi -Force | Out-Null
|
||||
$sourcePath = @{ loader = $script:loaderPath; loader12 = $script:loader12Path; msiRunner = $script:msiRunnerPath }[$Source]
|
||||
Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $msi $File)
|
||||
$run = Invoke-Scenario $s -Script (Join-Path $msi $File) -Arguments $Arguments -WorkingDirectory $msi -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree $Tree)
|
||||
if ($Location -eq 'program-files') { $run.StdOut | Should -Match 'Bootstrap removed, and with it the empty' }
|
||||
}
|
||||
|
||||
# MSI 1.3 installed its loader under Program Files, where nothing of ours belongs; the
|
||||
# installer takes it away in every outcome, also when an agent is present.
|
||||
It 'MSI 1.3 leaves nothing under Program Files when an agent is present' {
|
||||
$s = New-Scenario
|
||||
$msi = Join-Path $s.ProgramFiles 'AutopilotMonitor\Bootstrap'
|
||||
New-Item -ItemType Directory -Path $msi -Force | Out-Null
|
||||
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $msi 'Start-AutopilotMonitor.ps1')
|
||||
Add-File (Join-Path $s.Base 'Logs\agent.log') 'kept log'
|
||||
$before = Get-Tree $s.ProgramData -Content
|
||||
$run = Invoke-Scenario $s -Script (Join-Path $msi 'Start-AutopilotMonitor.ps1') -Arguments @('-LogFileName', 'bootstrap-msi.log') -WorkingDirectory $msi -Options @{ Marker = '2026-10-01T08:00:00Z' }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData -Content | Should -Be $before
|
||||
Test-Path -LiteralPath (Join-Path $s.ProgramFiles 'AutopilotMonitor') | Should -BeFalse
|
||||
$run.StdOut | Should -Match 'Agent present \(deployment marker\): .* is left as it is\.'
|
||||
$run.StdOut | Should -Match 'Bootstrap removed, and with it the empty'
|
||||
}
|
||||
|
||||
It '<Name> installs' -TestCases @(
|
||||
@{ Name = 'MSI 1.3 (loader in Program Files)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Location = 'program-files'; Arguments = @('-LogFileName', 'bootstrap-msi.log') }
|
||||
@{ Name = 'MSI 1.2 (loader 1.3)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Arguments = @('-LogFileName', 'bootstrap-msi.log') }
|
||||
@{ Name = 'MSI 1.1 (loader 1.2)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader12'; Arguments = @('-LogFileName', 'bootstrap-msi.log') }
|
||||
@{ Name = 'MSI 1.0 (own runner)'; File = 'Invoke-BootstrapDownload.ps1'; Source = 'msiRunner'; Arguments = @() }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
$msi = if ($Location -eq 'program-files') { Join-Path $s.Root 'ProgramFiles\AutopilotMonitor\Bootstrap' } else { Join-Path $s.Base 'Msi' }
|
||||
New-Item -ItemType Directory -Path $msi -Force | Out-Null
|
||||
$sourcePath = @{ loader = $script:loaderPath; loader12 = $script:loader12Path; msiRunner = $script:msiRunnerPath }[$Source]
|
||||
Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $msi $File)
|
||||
$run = Invoke-Scenario $s -Script (Join-Path $msi $File) -Arguments $Arguments -WorkingDirectory $msi
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-msi.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
if ($Location -eq 'program-files') { $run.StdOut | Should -Match 'Bootstrap removed, and with it the empty' }
|
||||
}
|
||||
}
|
||||
|
||||
# MSI 1.4 installs the loader copy under <base>\Msi and runs this command as SYSTEM from
|
||||
# System32: read the copy once, check our signature on those bytes, run those bytes. The
|
||||
# command is taken from Package.wxs and formatted as Windows Installer formats it (measured
|
||||
# on the built MSI, 2026-10-23); the wrapper runs it where the stubs apply.
|
||||
Describe 'bootstrap MSI 1.4: the action checks the loader copy and runs what it read' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
BeforeAll {
|
||||
$wxs = [xml][System.IO.File]::ReadAllText((Join-Path $script:repoRoot 'src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs'))
|
||||
$action = @($wxs.Wix.Package.CustomAction) | Where-Object { $_.Id -eq 'RunBootstrap' }
|
||||
if ($action.ExeCommand -notmatch ' -Command "(.*)"$') { throw 'Package.wxs: the action has no -Command argument.' }
|
||||
$script:actionCommand = $Matches[1]
|
||||
# What the build bakes in: the hash of the loader the package embeds, here the one served.
|
||||
$script:loaderSha256 = (Get-FileHash -LiteralPath $script:loaderPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
|
||||
function Format-MsiAction {
|
||||
param([string]$Text, [string]$InstallFolder)
|
||||
return $Text.Replace('$(var.LoaderSha256)', $script:loaderSha256).Replace('[\[]', '[').Replace('[\]]', ']').Replace('[\{]', '{').Replace('[\}]', '}').Replace('[INSTALLFOLDER]', $InstallFolder)
|
||||
}
|
||||
|
||||
function Add-MsiCopy {
|
||||
param($Scenario)
|
||||
$msi = Join-Path $Scenario.Base 'Msi'
|
||||
New-Item -ItemType Directory -Path $msi -Force | Out-Null
|
||||
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $msi 'Start-AutopilotMonitor.ps1')
|
||||
}
|
||||
|
||||
function Invoke-MsiAction {
|
||||
param($Scenario, [hashtable]$Options = @{})
|
||||
$command = Format-MsiAction -Text $script:actionCommand -InstallFolder ((Join-Path $Scenario.Base 'Msi') + '\')
|
||||
return Invoke-Scenario $Scenario -WorkingDirectory "$env:SystemRoot\System32" -Options ($Options + @{ CommandText = $command })
|
||||
}
|
||||
|
||||
$script:copyOnlyTree = @('AutopilotMonitor\', 'AutopilotMonitor\Msi\', 'AutopilotMonitor\Msi\Start-AutopilotMonitor.ps1')
|
||||
}
|
||||
|
||||
It 'is PowerShell that parses, with every bracket and brace escaped for Windows Installer' {
|
||||
# Without the [\x] escapes, the only bracket left is the one property reference.
|
||||
($script:actionCommand -replace '\[\\.\]', '').Replace('[INSTALLFOLDER]', '') | Should -Not -Match '[{}\[\]]'
|
||||
@([regex]::Matches($script:actionCommand, '\[[A-Za-z0-9_.]+\]') | ForEach-Object { $_.Value } | Sort-Object -Unique) | Should -Be @('[INSTALLFOLDER]')
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseInput((Format-MsiAction -Text $script:actionCommand -InstallFolder 'C:\ProgramData\AutopilotMonitor\Msi\'), [ref]$null, [ref]$errors) | Out-Null
|
||||
@($errors) | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'installs, and the copy goes with the emptied agent folder' {
|
||||
$s = New-Scenario
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-msi.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-AuthenticodeSignature|content:*' }).Count | Should -Be 1
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 1
|
||||
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
|
||||
Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-msi.log') -Raw | Should -Match '=== Autopilot Monitor bootstrap loader v1\.4 started'
|
||||
}
|
||||
|
||||
It 'skips and leaves nothing behind' {
|
||||
$s = New-Scenario
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
$run.StdOut | Should -Match 'No agent on this device: .* removed\.'
|
||||
}
|
||||
|
||||
It 'leaves nothing behind when only the deployment marker is there' {
|
||||
$s = New-Scenario
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s -Options @{ Marker = '2026-10-01T08:00:00Z' }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
$run.StdOut | Should -Match 'Agent present \(deployment marker\): .*\\Msi removed, and with it the empty'
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Agent.zip' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'removes the copy and leaves the folder of an agent as it is' {
|
||||
$s = New-Scenario
|
||||
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
|
||||
Add-File (Join-Path $s.Base 'Logs\agent.log') 'agent log'
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\Agent\', 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\agent.log'))
|
||||
$run.StdOut | Should -Match 'Agent present \(agent executable\): .*\\Msi removed\.'
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'runs nothing it cannot verify: <Case>' -TestCases @(
|
||||
@{ Case = 'unsigned'; Options = @{ SignatureStatus = 'NotSigned' }; Append = $null }
|
||||
@{ Case = 'changed after signing'; Options = @{ SignatureStatus = 'HashMismatch' }; Append = $null }
|
||||
@{ Case = 'another publisher'; Options = @{ SignerSubject = 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }; Append = $null }
|
||||
@{ Case = 'not the loader the package embeds, however validly signed'; Options = @{}; Append = "`r`n# one more line" }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
Add-MsiCopy $s
|
||||
if ($Append) { [System.IO.File]::AppendAllText((Join-Path $s.Base 'Msi\Start-AutopilotMonitor.ps1'), $Append) }
|
||||
$run = Invoke-MsiAction $s -Options $Options
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree $script:copyOnlyTree)
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-AuthenticodeSignature|content:*' }).Count | Should -Be 1
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 0
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'OOBE script, as the backend generates it' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
It 'installs the agent with the bootstrap token and leaves only the agent folder' {
|
||||
$s = New-Scenario
|
||||
Add-Leftovers $s
|
||||
$run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\oobe_bootstrap.log'))
|
||||
Get-Tree $s.Temp | Should -BeNullOrEmpty
|
||||
Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive'
|
||||
$run.Trace | Should -Contain ('Invoke-AgentInstallMode|' + $s.Base + '\Agent\AutopilotMonitor.Agent.exe --install --no-auth --bootstrap-token 22222222-2222-2222-2222-222222222222 --tenant-id 11111111-1111-1111-1111-111111111111 exists=True')
|
||||
@($run.Trace | Where-Object { $_ -like 'Test-FileCatalog|*' }).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'only runs --install again when the agent is there' {
|
||||
$s = New-Scenario
|
||||
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
|
||||
Add-File (Join-Path $s.Base 'State\session.id') 'session'
|
||||
$run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @(
|
||||
'AutopilotMonitor\', 'AutopilotMonitor\Agent\', 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe'
|
||||
'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\oobe_bootstrap.log', 'AutopilotMonitor\State\', 'AutopilotMonitor\State\session.id'
|
||||
))
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'refuses an agent package with an entry outside the payload folder and writes nothing outside' {
|
||||
$s = New-Scenario
|
||||
Set-EscapingPackage $s
|
||||
$run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true }
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
$run.StdOut | Should -Match 'The agent package has an entry that would be written outside the payload folder'
|
||||
Get-Tree $s.Root | Should -Not -Match 'outside\.txt'
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\oobe_bootstrap.log'))
|
||||
}
|
||||
|
||||
It 'refuses to run without SYSTEM or administrator rights and creates nothing' {
|
||||
$s = New-Scenario
|
||||
$run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true; Elevated = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
$run.StdOut | Should -Match 'Run this script from PowerShell as SYSTEM or administrator\. Nothing was changed\.'
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'publish pipeline and dev channel' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
BeforeAll {
|
||||
$script:rendered = Join-Path $TestDrive 'rendered'
|
||||
$script:pwsh = (Get-Command pwsh -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
|
||||
$script:publish = Join-Path $script:repoRoot '.github\scripts\Publish-BootstrapScripts.ps1'
|
||||
|
||||
# Output read as text from a process of its own: a stderr line must not become an error
|
||||
# record, which ends the test under the 'Stop' preference CI runs with.
|
||||
function Invoke-Publish {
|
||||
param([string[]]$Arguments)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $script:pwsh
|
||||
$psi.Arguments = ('-NoProfile -File "{0}" {1}' -f $script:publish, ($Arguments -join ' ')).Trim()
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$process = [System.Diagnostics.Process]::Start($psi)
|
||||
$stdout = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderr = $process.StandardError.ReadToEndAsync()
|
||||
$process.WaitForExit()
|
||||
return [pscustomobject]@{ ExitCode = $process.ExitCode; Output = @(($stdout.Result + "`n" + $stderr.Result) -split "`r?`n") }
|
||||
}
|
||||
|
||||
$dryRun = Invoke-Publish @('-DryRun', '-SignedOutputDir', ('"' + $script:rendered + '"'))
|
||||
if ($dryRun.ExitCode -ne 0) { throw "Publish dry run failed: $($dryRun.Output -join [Environment]::NewLine)" }
|
||||
$script:publishOutput = $dryRun.Output
|
||||
}
|
||||
|
||||
# A loader that went live first would refuse the installer still being served.
|
||||
It 'puts both installers before both loaders' {
|
||||
$order = @($script:publishOutput | Where-Object { $_ -match '\[dry-run\] (\S+\.ps1) ' } | ForEach-Object { $Matches[1] })
|
||||
$order[0..3] | Should -Be @('Install-AutopilotMonitor.ps1', 'Install-AutopilotMonitor-Dev.ps1', 'Start-AutopilotMonitor.ps1', 'Start-AutopilotMonitor-Dev.ps1')
|
||||
@($script:publishOutput | Where-Object { $_ -like '*Installer accepted: AutopilotMonitor-Installer v*' }).Count | Should -Be 2
|
||||
}
|
||||
|
||||
It 'refuses to publish an installer the loader would refuse: <Case>' -TestCases @(
|
||||
@{ Case = 'below the minimum version'; Old = '$ScriptVersion = "2.9"'; New = '$ScriptVersion = "2.7"' }
|
||||
@{ Case = 'without its role line'; Old = '$ScriptRole = "AutopilotMonitor-Installer"'; New = '# no role' }
|
||||
@{ Case = 'with a second version line'; Old = '$ScriptVersion = "2.9"'; New = "`$ScriptVersion = `"2.9`"`n`$ScriptVersion = `"3.0`"" }
|
||||
) {
|
||||
$root = Join-Path $TestDrive ('repo' + [guid]::NewGuid().ToString('N').Substring(0, 6))
|
||||
New-Item -ItemType Directory -Path (Join-Path $root 'scripts\Bootstrap') -Force | Out-Null
|
||||
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $root 'scripts\Bootstrap')
|
||||
$text = [System.IO.File]::ReadAllText($script:installerPath)
|
||||
$text.Contains($Old) | Should -BeTrue
|
||||
[System.IO.File]::WriteAllText((Join-Path $root 'scripts\Bootstrap\Install-AutopilotMonitor.ps1'), $text.Replace($Old, $New))
|
||||
|
||||
$refused = Invoke-Publish @('-DryRun', '-Channel', 'dev', '-RepoRoot', ('"' + $root + '"'))
|
||||
|
||||
$refused.ExitCode | Should -Not -Be 0
|
||||
($refused.Output -join "`n") | Should -Match 'The loader would refuse Install-AutopilotMonitor-Dev\.ps1'
|
||||
($refused.Output -join "`n") | Should -Not -Match '\[dry-run\]'
|
||||
}
|
||||
|
||||
It 'the dev loader fetches the dev installer, which installs the dev agent' {
|
||||
$s = New-Scenario
|
||||
Copy-Item -LiteralPath (Join-Path $script:rendered 'Install-AutopilotMonitor-Dev.ps1') -Destination $s.Server
|
||||
Copy-Item -LiteralPath $script:agentZip -Destination (Join-Path $s.Server 'AutopilotMonitor-Agent-dev.zip')
|
||||
Set-Content -LiteralPath (Join-Path $s.Server 'version-dev.json') -Value ('{"version":"2.0.9999","sha256":"' + $script:agentZipSha256 + '"}')
|
||||
Remove-Item -LiteralPath (Join-Path $s.Server 'Install-AutopilotMonitor.ps1'), (Join-Path $s.Server 'AutopilotMonitor-Agent.zip'), (Join-Path $s.Server 'version.json')
|
||||
$devLoader = Join-Path $s.Ime 'Start-AutopilotMonitor-Dev.ps1'
|
||||
Copy-Item -LiteralPath (Join-Path $script:rendered 'Start-AutopilotMonitor-Dev.ps1') -Destination $devLoader
|
||||
$run = Invoke-Scenario $s -Script $devLoader
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-*' }) | Should -Be @(
|
||||
'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor-Dev.ps1'
|
||||
'Invoke-RestMethod|https://download.autopilotmonitor.com/agent/version-dev.json'
|
||||
'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip'
|
||||
('Invoke-AgentInstallMode|' + $s.Base + '\Agent\AutopilotMonitor.Agent.exe --install exists=True')
|
||||
)
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
}
|
||||
|
||||
It 'the stable loader in its published form runs the stable installer in its published form' {
|
||||
$s = New-Scenario
|
||||
Copy-Item -LiteralPath (Join-Path $script:rendered 'Install-AutopilotMonitor.ps1') -Destination $s.Server -Force
|
||||
$loader = Join-Path $s.Ime 'Start-AutopilotMonitor.ps1'
|
||||
Copy-Item -LiteralPath (Join-Path $script:rendered 'Start-AutopilotMonitor.ps1') -Destination $loader
|
||||
[System.IO.File]::ReadAllText($loader).Contains("`r`n") | Should -BeTrue
|
||||
$run = Invoke-Scenario $s -Script $loader
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
$run.StdOut | Should -Match 'Installer accepted: AutopilotMonitor-Installer v2\.9'
|
||||
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,131 +1,41 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Bootstrap script to deploy and start the Autopilot Monitor agent.
|
||||
Installs the Autopilot Monitor agent during Windows Autopilot enrollment.
|
||||
|
||||
.DESCRIPTION
|
||||
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
|
||||
Runs very early in the enrollment process (first Intune action) and:
|
||||
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
|
||||
A relax exemption allows install when a single freshly-created profile
|
||||
appears DURING OOBE (Windows Backup for Organizations restore) or on a
|
||||
freshly provisioned Windows 365 Cloud PC.
|
||||
2. Downloads the monitoring agent ZIP from Azure Blob Storage
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
|
||||
4. Extracts the agent into a fresh staging folder
|
||||
5. Verifies the payload against its signed catalog (AutopilotMonitor.Agent.cat):
|
||||
signed by the expected publisher, every file present and unchanged, nothing extra
|
||||
6. Moves the verified payload into %ProgramData%\AutopilotMonitor\Agent
|
||||
7. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
8. Verifies the runtime process actually launched
|
||||
Agent self-destructs when enrollment completes.
|
||||
Runs as SYSTEM, assigned as an Intune platform script or started by
|
||||
Start-AutopilotMonitor.ps1. It installs the agent only while the device is in enrollment
|
||||
and skips productive devices: earlier deployment, real user profile, last logged-on user,
|
||||
or uptime beyond the bootstrap window. One profile created minutes ago is accepted during
|
||||
OOBE (Windows Backup for Organizations restore) and on a Windows 365 Cloud PC at first
|
||||
connect.
|
||||
|
||||
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
normally means "productive -> skip". Two legitimate flows break that assumption:
|
||||
The agent is installed only when its ZIP matches the SHA-256 in its manifest and every
|
||||
file matches the catalog signed by glueckkanja AG. The agent removes itself when
|
||||
enrollment completes; the registry value HKLM\SOFTWARE\AutopilotMonitor\Deployed stays so
|
||||
that the device is not installed again. A skipped device without an agent keeps nothing,
|
||||
and the loader copy the bootstrap MSI installs is removed in every outcome.
|
||||
|
||||
(1) Windows Backup for Organizations: one user signs in + restores DURING OOBE.
|
||||
Trigger: the OS positively reports OOBE InProgress.
|
||||
|
||||
(2) Windows 365 Cloud PC: W365 provisions headless -- OOBE and Intune enrollment
|
||||
complete before any user ever connects, so OOBE is never InProgress when
|
||||
this script runs. The assigned user's profile is created at first connect,
|
||||
typically seconds before Intune pulls this script. Trigger: the device
|
||||
positively identifies as a Cloud PC via TWO local markers (both required):
|
||||
the HKLM\SOFTWARE\Microsoft\Windows365 registry key AND the
|
||||
CloudManagedDesktopExtension service.
|
||||
|
||||
Either trigger relaxes guards 2+3 ONLY when additionally:
|
||||
(a) there is exactly ONE real user profile, AND
|
||||
(b) that profile was created within $OobeProfileMaxAgeMinutes minutes.
|
||||
The Cloud PC trigger additionally exempts guard 4 (uptime window) -- see
|
||||
.PARAMETER MaxBootstrapWindowHours. Any miss keeps the original skip
|
||||
(SKIP-safe: we never install on uncertainty). On productive devices the
|
||||
profile is old, so (b) keeps skipping correctly.
|
||||
Log: %ProgramData%\AutopilotMonitor\Logs\bootstrap-script.log when the agent is installed
|
||||
or the install fails; a skip is logged to the script output only. Exit code 0: installed
|
||||
or skipped. Exit code 1 with one error line: failed, or not run as SYSTEM or administrator.
|
||||
|
||||
.PARAMETER AgentDownloadUrl
|
||||
URL to download the agent ZIP from. Defaults to the production download endpoint;
|
||||
override only for parallel lab/dev assignments that point at a separate pre-release ZIP.
|
||||
Agent ZIP to install. Default: the published agent.
|
||||
|
||||
.PARAMETER VersionJsonName
|
||||
Filename of the integrity manifest in the same blob container as the agent ZIP.
|
||||
Defaults to "version.json"; override only for parallel lab/dev manifests.
|
||||
Manifest next to the agent ZIP that holds its SHA-256. Default: version.json.
|
||||
|
||||
.PARAMETER MaxBootstrapWindowHours
|
||||
Maximum device uptime (hours) within which the bootstrap is still considered
|
||||
valid. Devices booted more than this many hours ago are skipped because we no
|
||||
longer trust their OOBE state. Default: 12.
|
||||
Not applied while the Cloud PC relax is active: a Cloud PC runs headless for
|
||||
days between provisioning and the user's first connect, so the fresh-profile
|
||||
window (OobeProfileMaxAgeMinutes) is the time anchor there, not boot uptime.
|
||||
Devices up longer than this since their last boot are skipped; not applied to a Cloud PC
|
||||
at first connect. Default: 12.
|
||||
|
||||
.PARAMETER OobeProfileMaxAgeMinutes
|
||||
Maximum age (minutes) of the single profile that may appear for the relax
|
||||
exemption to apply. The restore/first-connect -> bootstrap gap is always
|
||||
minutes; an older profile is treated as a productive device. Default: 15.
|
||||
Maximum age of the one new profile accepted during OOBE or on a Cloud PC. Default: 15.
|
||||
|
||||
.NOTES
|
||||
- Agent is temporary and auto-removes after enrollment.
|
||||
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
|
||||
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
|
||||
(prevents ghost re-installs on re-Autopilot of the same device).
|
||||
- Scheduled Task survives reboots during enrollment.
|
||||
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
|
||||
the class -> state reads as 'Unavailable' -> original (skip) behaviour. Docs:
|
||||
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
- Guard/relax logic lives in functions; the script only executes when run
|
||||
directly. Dot-sourcing it (as the Pester suite in
|
||||
Install-AutopilotMonitor.Tests.ps1 does) loads the functions without side effects.
|
||||
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-10-13 v2.6 Payload catalog: the agent ZIP carries AutopilotMonitor.Agent.cat,
|
||||
a catalog of every payload file signed by the expected publisher.
|
||||
The ZIP is extracted into a staging folder and moved into the
|
||||
agent folder only when Test-FileCatalog finds the catalog signed
|
||||
by the expected publisher and every file present, unchanged and
|
||||
nothing extra. A rejected payload is deleted, so the next run
|
||||
starts clean. Replaces the v2.5 gate, which covered only the
|
||||
AutopilotMonitor binaries.
|
||||
2026-09-08 v2.5 Publisher gate: every AutopilotMonitor binary in the extracted
|
||||
payload must carry a valid Authenticode signature from the
|
||||
expected publisher before --install runs. The SHA-256 manifest
|
||||
check stays and is unchanged; it proves the bytes, the signature
|
||||
proves the author.
|
||||
2026-08-08 v2.3 Windows 365 Cloud PC support: a positively identified Cloud PC
|
||||
(Windows365 registry key AND CloudManagedDesktopExtension
|
||||
service) now triggers the guard 2+3 relax alongside OOBE
|
||||
InProgress; the single-fresh-profile conditions are unchanged
|
||||
and guard 4 (uptime window) is exempted for Cloud PCs.
|
||||
Raw OOBE state and Cloud PC marker states are now logged.
|
||||
Guard/relax logic refactored into functions with a dot-source
|
||||
entry guard so the Pester suite can test it without executing
|
||||
the bootstrap; behaviour when run directly is unchanged.
|
||||
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
|
||||
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
|
||||
https://download.autopilotmonitor.com/agent/ (route /agent/*).
|
||||
version.json is still derived from the same container.
|
||||
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
|
||||
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
|
||||
single freshly-created profile appears in OOBE (Windows Backup for
|
||||
Organizations). SKIP-safe when the API is absent (< 1809).
|
||||
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
|
||||
now mandatory (no hash -> bootstrap aborts, no unverified install).
|
||||
2026-08-18 v2.4 Renamed log file bootstrap_agent.log -> bootstrap-script.log
|
||||
(unified kebab-case log naming across the agent log directory).
|
||||
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
|
||||
TenantId-wait), so the script only calls `--install` plain.
|
||||
Hardened post-install with a 10 s runtime-process verify.
|
||||
2026-04-09 v1.1 Introduced explicit script version, logged on startup.
|
||||
2026-03-31 Replaced OS age + MDM pre-flight checks with multi-signal guard
|
||||
(registry deployment marker, WMI/filesystem user profile, last
|
||||
logged-on user, 12 h bootstrap window).
|
||||
2026-03-30 Fixed non-ASCII characters that broke parsing under PowerShell 5.1.
|
||||
2026-03-29 Hardened integrity check: SHA-256 verification via version.json.
|
||||
2026-02-13 Simplified bootstrapper, introduced --install parameter for agent.
|
||||
2026-02-12 Robust download with integrity check (Content-MD5), boot time support,
|
||||
pre-flight check to skip if agent already installed.
|
||||
2026-02-05 Initial version.
|
||||
Requires Windows PowerShell 5.1.
|
||||
Changes: https://docs.autopilotmonitor.com/changelog/platform-changelog
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
@@ -143,39 +53,84 @@ param(
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.6"
|
||||
# The loader checks these two lines before it runs this script.
|
||||
$ScriptVersion = "2.9"
|
||||
$ScriptRole = "AutopilotMonitor-Installer"
|
||||
|
||||
# The publisher the payload catalog must carry. A -like pattern on the subject, NOT a
|
||||
# thumbprint: certificates are renewed, deployed copies of this script are not. Same value
|
||||
# the loader pins for this script.
|
||||
# Signer of the agent's file catalog; a subject pattern, so certificate renewals keep working.
|
||||
$ExpectedPublisher = "*O=glueckkanja AG*"
|
||||
|
||||
# Signed catalog at the root of every agent ZIP: the SHA-256 of every other payload file.
|
||||
$AgentCatalogName = "AutopilotMonitor.Agent.cat"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentStagingPath = "$AgentBasePath\Agent-Install"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\bootstrap-script.log"
|
||||
|
||||
# Write-Host (NOT Write-Output): log calls happen inside functions that return
|
||||
# decision objects; Write-Output would leak every log line into those return values.
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
# Where loaders before v1.3 saved this script, and the name prefix of loader v1.3 run folders.
|
||||
$LegacyInstallerPath = "$AgentBasePath\Install-AutopilotMonitor.ps1"
|
||||
$LoaderRunFolderPrefix = "AutopilotMonitor-Loader-"
|
||||
|
||||
# Where the bootstrap MSI installs its loader copy; this script removes it in every outcome.
|
||||
$MsiTransportPath = "$AgentBasePath\Msi"
|
||||
|
||||
$AgentExeName = "AutopilotMonitor.Agent.exe"
|
||||
$AgentTaskName = "AutopilotMonitor-Agent"
|
||||
$DeploymentMarkerKey = "SOFTWARE\AutopilotMonitor"
|
||||
$DeploymentMarkerValue = "Deployed"
|
||||
|
||||
# Lines wait in memory until an install has prepared the Logs folder.
|
||||
$LogState = [pscustomobject]@{
|
||||
Ready = $false
|
||||
Pending = (New-Object System.Collections.Generic.List[string])
|
||||
}
|
||||
|
||||
# Raw OOBE state via the documented WinRT API
|
||||
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Returns the state string (InProgress/Completed/NotStarted). An older OS without the
|
||||
# class (< Win10 1809 / 10.0.17763.0) or any error returns 'Unavailable'
|
||||
# -> SKIP-safe (we never relax a guard on uncertainty).
|
||||
# Write-Host, so log lines never end up in a function's return value.
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$line = "[{0}] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Message
|
||||
Write-Host $line
|
||||
if ($LogState.Ready) {
|
||||
Add-Content -Path $LogFile -Value $line -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$LogState.Pending.Add($line)
|
||||
}
|
||||
}
|
||||
|
||||
function Open-LogFile {
|
||||
if ($LogState.Pending.Count -gt 0) {
|
||||
[System.IO.File]::AppendAllText($LogFile, (($LogState.Pending -join "`r`n") + "`r`n"))
|
||||
$LogState.Pending.Clear()
|
||||
}
|
||||
$LogState.Ready = $true
|
||||
}
|
||||
|
||||
# 64-bit registry view: a 32-bit host, the Intune default, would read WOW6432Node.
|
||||
function Get-RegistryValue64 {
|
||||
param([string]$SubKey, [string]$Name)
|
||||
$hive = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
|
||||
try {
|
||||
$key = $hive.OpenSubKey($SubKey)
|
||||
if ($null -eq $key) { return $null }
|
||||
try { return $key.GetValue($Name) } finally { $key.Close() }
|
||||
} finally {
|
||||
$hive.Close()
|
||||
}
|
||||
}
|
||||
|
||||
function Test-RegistryKey64 {
|
||||
param([string]$SubKey)
|
||||
$hive = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
|
||||
try {
|
||||
$key = $hive.OpenSubKey($SubKey)
|
||||
if ($null -eq $key) { return $false }
|
||||
$key.Close()
|
||||
return $true
|
||||
} finally {
|
||||
$hive.Close()
|
||||
}
|
||||
}
|
||||
|
||||
# OOBE state from Windows 10 1809 or later; 'Unavailable' otherwise, which relaxes no guard.
|
||||
function Get-OobeState {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
@@ -186,12 +141,7 @@ function Get-OobeState {
|
||||
}
|
||||
}
|
||||
|
||||
# Positive Windows 365 Cloud PC detection. Local WMI/SMBIOS on a Cloud PC only
|
||||
# reports the generic Manufacturer='Microsoft Corporation' / Model='Virtual Machine',
|
||||
# so identification uses two W365-specific markers instead. BOTH are required (AND):
|
||||
# each alone has look-alikes (e.g. W365 Boot physical clients, other managed-desktop
|
||||
# agents). Identity and marker states are logged for diagnosis; any error returns
|
||||
# $false (SKIP-safe).
|
||||
# Windows 365 Cloud PC: both markers are required, since each alone has look-alikes.
|
||||
function Test-IsCloudPc {
|
||||
try {
|
||||
$cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
|
||||
@@ -200,7 +150,7 @@ function Test-IsCloudPc {
|
||||
catch { Write-Log "INFO: computer identity query failed: $($_.Exception.Message)" }
|
||||
|
||||
try {
|
||||
$regMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365'
|
||||
$regMarker = Test-RegistryKey64 -SubKey 'SOFTWARE\Microsoft\Windows365'
|
||||
$svcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
|
||||
Write-Log "Cloud PC markers: Windows365 registry key=$regMarker; CloudManagedDesktopExtension service=$svcMarker."
|
||||
return ($regMarker -and $svcMarker)
|
||||
@@ -211,10 +161,8 @@ function Test-IsCloudPc {
|
||||
}
|
||||
}
|
||||
|
||||
# Collects real (non-special) user profiles as full paths (WMI Special flag + filesystem).
|
||||
# NOTE: callers MUST wrap the result in @(...). PowerShell unrolls function output, so an
|
||||
# empty result returns $null and a single path returns a scalar string, whose [0] indexer
|
||||
# would yield its first char ('C') instead of the full path.
|
||||
# Real user profiles from WMI and C:\Users. Callers wrap the result in @(): a single path
|
||||
# would otherwise come back as a string.
|
||||
function Get-RealUserProfilePaths {
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
@(
|
||||
@@ -229,12 +177,8 @@ function Get-RealUserProfilePaths {
|
||||
Select-Object -Unique
|
||||
}
|
||||
|
||||
# Relax: the ONLY exemption to guards 2+3. Two triggers (see .DESCRIPTION):
|
||||
# (1) OOBE InProgress -> Windows Backup for Organizations restore
|
||||
# (2) Cloud PC identity -> Windows 365 (headless OOBE, profile at first connect)
|
||||
# Either trigger additionally requires: exactly one real profile AND that profile
|
||||
# created < $OobeProfileMaxAgeMinutes minutes ago. Any miss keeps the original skip.
|
||||
# Returns an object with: Active (bool), OobeState (string), IsCloudPc (bool).
|
||||
# The only exemption from guards 2 and 3: OOBE in progress or a Cloud PC, plus exactly one
|
||||
# profile created less than OobeProfileMaxAgeMinutes ago.
|
||||
function Get-RelaxDecision {
|
||||
param(
|
||||
[string[]]$ProfilePaths,
|
||||
@@ -281,30 +225,25 @@ function Get-RelaxDecision {
|
||||
}
|
||||
}
|
||||
|
||||
# Runs pre-flight guards 1-5 and returns the install decision WITHOUT side effects
|
||||
# beyond logging. Returns an object with:
|
||||
# Install (bool), ReasonCode (null | AlreadyDeployed | ProfilesFound |
|
||||
# LastLoggedOnUser | UptimeExceeded | AgentPresent), RelaxActive (bool).
|
||||
# Guards 1-4; reads only. Returns Install, ReasonCode and RelaxActive.
|
||||
function Get-BootstrapDecision {
|
||||
param(
|
||||
[int]$MaxBootstrapWindowHours,
|
||||
[int]$OobeProfileMaxAgeMinutes,
|
||||
[string]$AgentBinPath
|
||||
[int]$OobeProfileMaxAgeMinutes
|
||||
)
|
||||
|
||||
# Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
|
||||
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
|
||||
# Guard 1: deployed before; the marker outlives the agent.
|
||||
$deployed = $null
|
||||
try { $deployed = Get-RegistryValue64 -SubKey $DeploymentMarkerKey -Name $DeploymentMarkerValue } catch { }
|
||||
if ($deployed) {
|
||||
Write-Log "SKIP: Agent was previously deployed at $deployed."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'AlreadyDeployed'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# The @() wrap is required, see NOTE on Get-RealUserProfilePaths.
|
||||
$profilePaths = @(Get-RealUserProfilePaths)
|
||||
$relax = Get-RelaxDecision -ProfilePaths $profilePaths -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard),
|
||||
# unless the relax signature holds.
|
||||
# Guard 2: real user profiles.
|
||||
if ($profilePaths.Count -gt 0 -and -not $relax.Active) {
|
||||
$details = (($profilePaths | ForEach-Object {
|
||||
$leaf = Split-Path $_ -Leaf
|
||||
@@ -317,19 +256,16 @@ function Get-BootstrapDecision {
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'ProfilesFound'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
|
||||
# (same relax exemption: the restoring/first-connect user signs in legitimately).
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
# Guard 3: a real user has logged on.
|
||||
$lastLoggedOnUser = $null
|
||||
try { $lastLoggedOnUser = Get-RegistryValue64 -SubKey 'SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' } catch { }
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $relax.Active) {
|
||||
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'LastLoggedOnUser'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window.
|
||||
# Sleep/standby does not reset uptime, only real boot/restart does.
|
||||
# Exempted while the Cloud PC relax is active: a Cloud PC runs headless from
|
||||
# provisioning until the user's first connect (often days), so boot uptime says
|
||||
# nothing there -- the fresh-profile window of the relax is the time anchor.
|
||||
# Guard 4: uptime since the last boot. A Cloud PC can wait days for its first connect, so
|
||||
# there the fresh profile is the time anchor.
|
||||
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
|
||||
@@ -342,27 +278,197 @@ function Get-BootstrapDecision {
|
||||
}
|
||||
}
|
||||
|
||||
# Guard 5: Is the agent already installed? (leftover from previous run)
|
||||
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)."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'AgentPresent'; RelaxActive = $relax.Active }
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
|
||||
}
|
||||
|
||||
### BEGIN shared payload verification -- embedded verbatim into the OOBE go-script by
|
||||
### OobeBootstrapScriptGenerator (D-354). Keep it self-contained: parameters only, no
|
||||
### script-scope variables, no Write-Log.
|
||||
# Returns the reasons why the extracted payload in $Path must not run; an empty result means
|
||||
# it matches its signed catalog exactly. Test-FileCatalog's Status covers the hashes only (an
|
||||
# unsigned catalog over matching files reports Valid), so the signature and its publisher are
|
||||
# judged on their own. Any error is a rejection: a payload that cannot be verified is an
|
||||
# unverified payload. The comma keeps a single rejection an ARRAY: PowerShell unrolls a
|
||||
# one-element result to a bare string, and the caller reads .Count.
|
||||
# Why the agent folder belongs to an agent (marker, executable or task), or $null. What
|
||||
# cannot be read counts as an agent, whose folder is never changed.
|
||||
function Get-AgentPresence {
|
||||
try {
|
||||
if ($null -ne (Get-RegistryValue64 -SubKey $DeploymentMarkerKey -Name $DeploymentMarkerValue)) { return 'deployment marker' }
|
||||
} catch { return "deployment marker unreadable ($($_.Exception.Message))" }
|
||||
|
||||
try {
|
||||
$null = Get-Item -LiteralPath (Join-Path $AgentBinPath $AgentExeName) -Force -ErrorAction Stop
|
||||
return 'agent executable'
|
||||
} catch {
|
||||
if ($_.CategoryInfo.Category -ne [System.Management.Automation.ErrorCategory]::ObjectNotFound) {
|
||||
return "agent executable unreadable ($($_.Exception.Message))"
|
||||
}
|
||||
}
|
||||
|
||||
# The category, not the localized message, tells a missing task from a failed query.
|
||||
for ($attempt = 1; $attempt -le 2; $attempt++) {
|
||||
try {
|
||||
$null = Get-ScheduledTask -TaskName $AgentTaskName -ErrorAction Stop
|
||||
return 'agent task'
|
||||
} catch {
|
||||
if ($_.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::ObjectNotFound) { return $null }
|
||||
if ($attempt -eq 2) { return "agent task unreadable ($($_.Exception.Message))" }
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# The loader repeats the first six functions of this block word for word.
|
||||
### BEGIN shared install helpers (the OOBE script embeds this block; keep it self-contained)
|
||||
|
||||
function Test-IsElevated {
|
||||
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
return (New-Object System.Security.Principal.WindowsPrincipal($identity)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
# SYSTEM and Administrators full control, Users read, nothing inherited. Owner Administrators,
|
||||
# so a folder that any elevated account created counts as ours.
|
||||
function New-ProtectedDirectorySecurity {
|
||||
$security = New-Object System.Security.AccessControl.DirectorySecurity
|
||||
$security.SetAccessRuleProtection($true, $false)
|
||||
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
||||
$propagation = [System.Security.AccessControl.PropagationFlags]::None
|
||||
$system = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::LocalSystemSid, $null)
|
||||
$admins = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid, $null)
|
||||
$users = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::BuiltinUsersSid, $null)
|
||||
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($system, 'FullControl', $inherit, $propagation, 'Allow')))
|
||||
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($admins, 'FullControl', $inherit, $propagation, 'Allow')))
|
||||
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($users, 'ReadAndExecute', $inherit, $propagation, 'Allow')))
|
||||
$security.SetOwner($admins)
|
||||
return $security
|
||||
}
|
||||
|
||||
function Test-IsReparsePoint {
|
||||
param([string]$Path)
|
||||
return (([System.IO.File]::GetAttributes($Path) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)
|
||||
}
|
||||
|
||||
# Owner SYSTEM or Administrators. Any other owner, or one that cannot be read, is another
|
||||
# account's.
|
||||
function Test-IsOwnedByUs {
|
||||
param([string]$Path)
|
||||
try {
|
||||
$owner = (Get-Item -LiteralPath $Path -Force -ErrorAction Stop).GetAccessControl([System.Security.AccessControl.AccessControlSections]::Owner).GetOwner([System.Security.Principal.SecurityIdentifier])
|
||||
return ($owner.IsWellKnown([System.Security.Principal.WellKnownSidType]::LocalSystemSid) -or
|
||||
$owner.IsWellKnown([System.Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid))
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Random, so nobody can create a run folder's name ahead of the run.
|
||||
function New-RunFolderName {
|
||||
$bytes = New-Object byte[] 4
|
||||
$generator = [System.Security.Cryptography.RandomNumberGenerator]::Create()
|
||||
try { $generator.GetBytes($bytes) } finally { $generator.Dispose() }
|
||||
return -join ($bytes | ForEach-Object { $_.ToString('x2') })
|
||||
}
|
||||
|
||||
# CreateDirectory leaves an existing folder as it is, so only a new name is used and the new
|
||||
# folder is checked.
|
||||
function New-RunFolder {
|
||||
param([string]$Parent, [string]$Prefix)
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
$path = Join-Path $Parent ($Prefix + (New-RunFolderName))
|
||||
if (Test-Path -LiteralPath $path) { continue }
|
||||
[System.IO.Directory]::CreateDirectory($path, (New-ProtectedDirectorySecurity)) | Out-Null
|
||||
if (-not (Test-IsReparsePoint $path) -and (Test-IsOwnedByUs $path)) { return $path }
|
||||
}
|
||||
throw "Could not create a run folder under $Parent."
|
||||
}
|
||||
|
||||
# 'Missing', 'Ours', or why the folder is not ours: 'Link', 'File' or 'Foreign'.
|
||||
function Get-FolderState {
|
||||
param([string]$Path)
|
||||
try { $attributes = [System.IO.File]::GetAttributes($Path) }
|
||||
catch [System.IO.FileNotFoundException] { return 'Missing' }
|
||||
catch [System.IO.DirectoryNotFoundException] { return 'Missing' }
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { return 'Link' }
|
||||
if (($attributes -band [System.IO.FileAttributes]::Directory) -eq 0) { return 'File' }
|
||||
if (-not (Test-IsOwnedByUs $Path)) { return 'Foreign' }
|
||||
return 'Ours'
|
||||
}
|
||||
|
||||
function Remove-OwnFile {
|
||||
param([string]$Path)
|
||||
# A virus scanner may hold the file for a moment.
|
||||
for ($attempt = 1; ; $attempt++) {
|
||||
try {
|
||||
[System.IO.File]::Delete($Path)
|
||||
return
|
||||
} catch {
|
||||
if ($attempt -ge 3) { throw }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-OwnTreeEntries {
|
||||
param([string]$Path, $Left)
|
||||
try { $entries = [System.IO.Directory]::GetFileSystemEntries($Path) } catch { $Left.Locked.Add($Path); return }
|
||||
foreach ($entry in $entries) {
|
||||
try {
|
||||
$attributes = [System.IO.File]::GetAttributes($entry)
|
||||
$isDirectory = ($attributes -band [System.IO.FileAttributes]::Directory) -ne 0
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
if ($isDirectory) { [System.IO.Directory]::Delete($entry, $false) } else { [System.IO.File]::Delete($entry) }
|
||||
continue
|
||||
}
|
||||
if (-not (Test-IsOwnedByUs $entry)) {
|
||||
$Left.Foreign.Add($entry)
|
||||
continue
|
||||
}
|
||||
if ($isDirectory) {
|
||||
Remove-OwnTreeEntries -Path $entry -Left $Left
|
||||
[System.IO.Directory]::Delete($entry, $false)
|
||||
} else {
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReadOnly) -ne 0) {
|
||||
[System.IO.File]::SetAttributes($entry, ($attributes -band (-bnot [System.IO.FileAttributes]::ReadOnly)))
|
||||
}
|
||||
Remove-OwnFile -Path $entry
|
||||
}
|
||||
} catch {
|
||||
$Left.Locked.Add($entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Deletes our own entries in a folder we own. Links are removed, never followed; other
|
||||
# accounts' entries stay. Returns what stayed; never throws.
|
||||
function Remove-OwnTree {
|
||||
param([string]$Path, [switch]$KeepRoot)
|
||||
$left = [pscustomobject]@{
|
||||
Foreign = New-Object System.Collections.Generic.List[string]
|
||||
Locked = New-Object System.Collections.Generic.List[string]
|
||||
}
|
||||
Remove-OwnTreeEntries -Path $Path -Left $left
|
||||
if (-not $KeepRoot) {
|
||||
try { [System.IO.Directory]::Delete($Path, $false) } catch { $left.Locked.Add($Path) }
|
||||
}
|
||||
return $left
|
||||
}
|
||||
|
||||
# Creates the agent folder protected, or protects and empties one of ours. Throws for a link,
|
||||
# a file, or a folder that is, or holds entries that are, another account's.
|
||||
function Initialize-AgentBase {
|
||||
param([string]$Path)
|
||||
$state = Get-FolderState $Path
|
||||
if ($state -eq 'Missing') {
|
||||
[System.IO.Directory]::CreateDirectory($Path, (New-ProtectedDirectorySecurity)) | Out-Null
|
||||
$state = Get-FolderState $Path
|
||||
if ($state -ne 'Ours') { throw "$Path could not be created as a protected folder ($state)." }
|
||||
return
|
||||
}
|
||||
if ($state -ne 'Ours') {
|
||||
throw "$Path is not a folder that SYSTEM or administrators created ($state); not installing into it."
|
||||
}
|
||||
(Get-Item -LiteralPath $Path -Force -ErrorAction Stop).SetAccessControl((New-ProtectedDirectorySecurity))
|
||||
$left = Remove-OwnTree -Path $Path -KeepRoot
|
||||
if ($left.Foreign.Count -gt 0) {
|
||||
throw ("$Path holds entries another account created ({0}); not installing into it." -f (($left.Foreign | Select-Object -First 3) -join ', '))
|
||||
}
|
||||
}
|
||||
|
||||
# Why the extracted payload must not run; empty when it matches its signed catalog. The
|
||||
# catalog Status covers the hashes only, so the signer is checked on its own. The comma keeps
|
||||
# a single rejection an array.
|
||||
function Get-AgentPayloadRejections {
|
||||
param(
|
||||
[string]$Path,
|
||||
@@ -415,11 +521,44 @@ function Get-AgentPayloadRejections {
|
||||
return , $rejected
|
||||
}
|
||||
|
||||
# Extracts the agent ZIP into a fresh staging folder, verifies it there and moves it into the
|
||||
# agent folder only when it matches its signed catalog. A rejected payload is deleted with the
|
||||
# staging folder: nothing unverified lands where SYSTEM runs the agent, and the next run starts
|
||||
# clean instead of finding a half-installed agent. The caller has made sure no agent is
|
||||
# installed, so anything already in the agent folder is a leftover and goes first.
|
||||
# Extracts the agent ZIP into the payload folder. Every entry is checked before the first one
|
||||
# is written: a rooted name, or one whose resolved path lies outside the folder, refuses the
|
||||
# whole package. The guarantee is this script's own, not the device's Expand-Archive's.
|
||||
function Expand-AgentPayload {
|
||||
param([string]$ZipPath, [string]$Destination)
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$root = [System.IO.Path]::GetFullPath($Destination).TrimEnd('\') + '\'
|
||||
$archive = [System.IO.Compression.ZipFile]::OpenRead($ZipPath)
|
||||
try {
|
||||
$planned = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($entry in $archive.Entries) {
|
||||
$name = $entry.FullName
|
||||
$target = $null
|
||||
if (-not [System.IO.Path]::IsPathRooted($name) -and -not $name.Contains(':')) {
|
||||
$target = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($root, $name))
|
||||
}
|
||||
if ($null -eq $target -or -not $target.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "The agent package has an entry that would be written outside the payload folder ($name); refusing the package."
|
||||
}
|
||||
$planned.Add([pscustomobject]@{ Entry = $entry; Target = $target })
|
||||
}
|
||||
[System.IO.Directory]::CreateDirectory($root) | Out-Null
|
||||
foreach ($item in $planned) {
|
||||
if ($item.Entry.FullName.EndsWith('/') -or $item.Entry.FullName.EndsWith('\')) {
|
||||
[System.IO.Directory]::CreateDirectory($item.Target) | Out-Null
|
||||
continue
|
||||
}
|
||||
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($item.Target)) | Out-Null
|
||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($item.Entry, $item.Target, $false)
|
||||
}
|
||||
} finally {
|
||||
$archive.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
# Verifies the payload in the run folder and moves it in as the agent folder. The move fails
|
||||
# when that folder exists, so nothing is ever merged into it.
|
||||
function Install-VerifiedAgentPayload {
|
||||
param(
|
||||
[string]$ZipPath,
|
||||
@@ -429,57 +568,236 @@ function Install-VerifiedAgentPayload {
|
||||
[string]$CatalogName = 'AutopilotMonitor.Agent.cat'
|
||||
)
|
||||
|
||||
if (Test-Path -LiteralPath $StagingPath) {
|
||||
Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
New-Item -Path $StagingPath -ItemType Directory -Force | Out-Null
|
||||
try {
|
||||
Expand-Archive -Path $ZipPath -DestinationPath $StagingPath -Force -ErrorAction Stop
|
||||
$payload = Join-Path $StagingPath 'Agent'
|
||||
Expand-AgentPayload -ZipPath $ZipPath -Destination $payload
|
||||
|
||||
$rejections = Get-AgentPayloadRejections -Path $StagingPath -ExpectedPublisher $ExpectedPublisher -CatalogName $CatalogName
|
||||
if ($rejections.Count -gt 0) {
|
||||
throw "Payload verification FAILED ($($rejections -join '; ')). Refusing to install an agent that is not the published one."
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $TargetPath) {
|
||||
Get-ChildItem -LiteralPath $TargetPath -Force | Remove-Item -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
else {
|
||||
New-Item -Path $TargetPath -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
Get-ChildItem -LiteralPath $StagingPath -Force | Move-Item -Destination $TargetPath -Force -ErrorAction Stop
|
||||
$rejections = Get-AgentPayloadRejections -Path $payload -ExpectedPublisher $ExpectedPublisher -CatalogName $CatalogName
|
||||
if ($rejections.Count -gt 0) {
|
||||
throw "Payload verification FAILED ($($rejections -join '; ')). Refusing to install an agent that is not the published one."
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
for ($attempt = 1; ; $attempt++) {
|
||||
try {
|
||||
[System.IO.Directory]::Move($payload, $TargetPath)
|
||||
return
|
||||
} catch {
|
||||
if ($attempt -ge 3 -or [System.IO.Directory]::Exists($TargetPath)) { throw }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
}
|
||||
}
|
||||
### END shared payload verification
|
||||
|
||||
# Main bootstrap flow. Only invoked when the script is run directly (see entry guard
|
||||
# at the bottom); never on dot-source.
|
||||
function Invoke-Bootstrap {
|
||||
# The agent writes its output to the host; only the exit code comes back.
|
||||
function Invoke-AgentInstallMode {
|
||||
param([string]$ExePath, [string[]]$Arguments = @('--install'))
|
||||
& $ExePath @Arguments | Out-Host
|
||||
return $LASTEXITCODE
|
||||
}
|
||||
### END shared install helpers
|
||||
|
||||
# Skip without an agent: removes the agent folder. Other accounts' entries and files in use
|
||||
# stay, and the folder with them. Returns a line for the log; never throws.
|
||||
function Remove-AgentBaseOnSkip {
|
||||
param([string]$Path)
|
||||
try {
|
||||
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
|
||||
$state = Get-FolderState $Path
|
||||
if ($state -eq 'Missing') { return "$Path does not exist; nothing to remove." }
|
||||
if ($state -ne 'Ours') { return "$Path left as it is ($state)." }
|
||||
(Get-Item -LiteralPath $Path -Force -ErrorAction Stop).SetAccessControl((New-ProtectedDirectorySecurity))
|
||||
$left = Remove-OwnTree -Path $Path
|
||||
if ($left.Foreign.Count -eq 0 -and $left.Locked.Count -eq 0) { return "$Path removed." }
|
||||
return ("$Path kept: {0} entries of another account, {1} in use ({2})." -f $left.Foreign.Count, $left.Locked.Count, ((@($left.Foreign) + @($left.Locked) | Select-Object -First 3) -join ', '))
|
||||
} catch {
|
||||
return "$Path left as it is ($($_.Exception.Message))."
|
||||
}
|
||||
}
|
||||
|
||||
# The loader copy the bootstrap MSI installs. It goes whatever the outcome; an agent folder
|
||||
# that is then empty goes with it (a running agent, or logs kept after enrollment, keep it).
|
||||
# Returns what was done, or $null when there was nothing to do; never throws.
|
||||
function Remove-MsiTransportFolder {
|
||||
try {
|
||||
if ((Get-FolderState $AgentBasePath) -ne 'Ours') { return $null }
|
||||
$state = Get-FolderState $MsiTransportPath
|
||||
if ($state -eq 'Missing') { return $null }
|
||||
if ($state -ne 'Ours') { return "$MsiTransportPath left as it is ($state)." }
|
||||
$left = Remove-OwnTree -Path $MsiTransportPath
|
||||
if ($left.Foreign.Count -gt 0 -or $left.Locked.Count -gt 0) {
|
||||
return ("$MsiTransportPath kept: {0} entries of another account, {1} in use." -f $left.Foreign.Count, $left.Locked.Count)
|
||||
}
|
||||
if (@([System.IO.Directory]::EnumerateFileSystemEntries($AgentBasePath)).Count -eq 0) {
|
||||
[System.IO.Directory]::Delete($AgentBasePath, $false)
|
||||
return "$MsiTransportPath removed, and with it the empty $AgentBasePath."
|
||||
}
|
||||
return "$MsiTransportPath removed."
|
||||
} catch {
|
||||
return "$MsiTransportPath left as it is ($($_.Exception.Message))."
|
||||
}
|
||||
}
|
||||
|
||||
# What bootstrap MSI 1.3 installed under Program Files, on devices that still enroll with it.
|
||||
# Returns what was done, or $null when there was nothing to do; never throws.
|
||||
function Remove-LegacyMsiLoaderFolder {
|
||||
try {
|
||||
$programFiles = if ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles }
|
||||
if (-not $programFiles) { return $null }
|
||||
$company = Join-Path $programFiles 'AutopilotMonitor'
|
||||
$companyState = Get-FolderState $company
|
||||
if ($companyState -eq 'Missing') { return $null }
|
||||
if ($companyState -ne 'Ours') { return "$company left as it is ($companyState)." }
|
||||
$path = Join-Path $company 'Bootstrap'
|
||||
$state = Get-FolderState $path
|
||||
if ($state -eq 'Missing') { return $null }
|
||||
if ($state -ne 'Ours') { return "$path left as it is ($state)." }
|
||||
$left = Remove-OwnTree -Path $path
|
||||
if ($left.Foreign.Count -gt 0 -or $left.Locked.Count -gt 0) {
|
||||
return ("$path kept: {0} entries of another account, {1} in use." -f $left.Foreign.Count, $left.Locked.Count)
|
||||
}
|
||||
if (@([System.IO.Directory]::EnumerateFileSystemEntries($company)).Count -eq 0) {
|
||||
[System.IO.Directory]::Delete($company, $false)
|
||||
return "$path removed, and with it the empty $company."
|
||||
}
|
||||
return "$path removed."
|
||||
} catch {
|
||||
return "The loader folder of bootstrap MSI 1.3 is left as it is ($($_.Exception.Message))."
|
||||
}
|
||||
}
|
||||
|
||||
function Test-IsLoaderRunFolder {
|
||||
param([string]$Folder)
|
||||
if (-not $Folder) { return $false }
|
||||
$parent = [System.IO.Path]::GetDirectoryName([System.IO.Path]::GetFullPath($Folder))
|
||||
return ([string]::Equals($parent, [System.IO.Path]::GetFullPath($env:ProgramData).TrimEnd('\'), [System.StringComparison]::OrdinalIgnoreCase) -and
|
||||
[System.IO.Path]::GetFileName($Folder).StartsWith($LoaderRunFolderPrefix, [System.StringComparison]::OrdinalIgnoreCase))
|
||||
}
|
||||
|
||||
function Test-IsLegacyInstallerPath {
|
||||
param([string]$ScriptPath)
|
||||
if (-not $ScriptPath) { return $false }
|
||||
return [string]::Equals([System.IO.Path]::GetFullPath($ScriptPath), [System.IO.Path]::GetFullPath($LegacyInstallerPath), [System.StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
|
||||
# The loader's log, carried into Logs for the agent and the diagnostics package: whole from a
|
||||
# loader run folder that is ours; from older loaders, whose log folder users could write to,
|
||||
# only the start line. Callers wrap the result in @(); never throws.
|
||||
function Read-LoaderLogs {
|
||||
param([string]$ScriptPath, [string]$ScriptRoot)
|
||||
$logs = New-Object System.Collections.Generic.List[object]
|
||||
try {
|
||||
if ((Test-IsLoaderRunFolder $ScriptRoot) -and (Get-FolderState $ScriptRoot) -eq 'Ours') {
|
||||
foreach ($file in [System.IO.Directory]::GetFiles($ScriptRoot, '*.log')) {
|
||||
$logs.Add([pscustomobject]@{ Name = [System.IO.Path]::GetFileName($file); Text = [System.IO.File]::ReadAllText($file) })
|
||||
}
|
||||
} elseif (Test-IsLegacyInstallerPath $ScriptPath) {
|
||||
foreach ($name in @('bootstrap-loader.log', 'bootstrap-msi.log')) {
|
||||
$path = Join-Path $AgentLogPath $name
|
||||
if (-not [System.IO.File]::Exists($path)) { continue }
|
||||
$stream = New-Object System.IO.FileStream($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]'ReadWrite, Delete')
|
||||
try {
|
||||
$start = [Math]::Max([long]0, $stream.Length - 65536)
|
||||
$null = $stream.Seek($start, [System.IO.SeekOrigin]::Begin)
|
||||
$buffer = New-Object byte[] ($stream.Length - $start)
|
||||
$read = $stream.Read($buffer, 0, $buffer.Length)
|
||||
$tail = [System.Text.Encoding]::ASCII.GetString($buffer, 0, $read)
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
}
|
||||
$starts = [regex]::Matches($tail, '(?m)^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] === Autopilot Monitor bootstrap loader v\d+(\.\d+){0,3} started')
|
||||
if ($starts.Count -gt 0) {
|
||||
$logs.Add([pscustomobject]@{ Name = $name; Text = $starts[$starts.Count - 1].Value + " (carried over by the bootstrapper) ===`r`n" })
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
return $logs.ToArray()
|
||||
}
|
||||
|
||||
function Write-LoaderLogs {
|
||||
param($Logs)
|
||||
foreach ($log in $Logs) {
|
||||
try { [System.IO.File]::AppendAllText((Join-Path $AgentLogPath $log.Name), $log.Text) } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
# Run folders that failed loader runs kept for diagnosis. A folder younger than an hour may
|
||||
# belong to a loader that is still running and stays. Never throws.
|
||||
function Remove-StaleLoaderRunFolders {
|
||||
param([string]$ScriptRoot)
|
||||
try { $folders = [System.IO.Directory]::GetDirectories($env:ProgramData, $LoaderRunFolderPrefix + '*') } catch { return }
|
||||
$cutoff = [System.DateTime]::UtcNow.AddHours(-1)
|
||||
foreach ($folder in $folders) {
|
||||
if ($ScriptRoot -and [string]::Equals([System.IO.Path]::GetFullPath($folder), [System.IO.Path]::GetFullPath($ScriptRoot), [System.StringComparison]::OrdinalIgnoreCase)) { continue }
|
||||
try {
|
||||
if ((Get-FolderState $folder) -eq 'Ours' -and [System.IO.Directory]::GetCreationTimeUtc($folder) -lt $cutoff) { $null = Remove-OwnTree -Path $folder }
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
# The copy that loaders before v1.3 leave at a fixed path. Never throws: it runs in a finally.
|
||||
function Remove-LegacyInstallerCopy {
|
||||
param([string]$ScriptPath)
|
||||
try {
|
||||
if ((Test-IsLegacyInstallerPath $ScriptPath) -and [System.IO.File]::Exists($ScriptPath)) {
|
||||
[System.IO.File]::Delete($ScriptPath)
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
function Invoke-Bootstrap {
|
||||
# One error preference, whatever the caller set; anything unexpected ends in the catch.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
try {
|
||||
if (-not (Test-IsElevated)) {
|
||||
$message = "AutopilotMonitor bootstrap: not running as SYSTEM or administrator; nothing was changed."
|
||||
Write-Host $message
|
||||
[Console]::Error.WriteLine($message)
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The bootstrap MSI starts PowerShell inside the agent folder: a working directory cannot
|
||||
# be deleted, and PowerShell starts programs from its own location, which must not point
|
||||
# at a folder that is gone by then.
|
||||
[System.IO.Directory]::SetCurrentDirectory("$env:SystemRoot\System32")
|
||||
Set-Location -LiteralPath "$env:SystemRoot\System32"
|
||||
|
||||
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
|
||||
Write-Log "Bootstrap script version: v$ScriptVersion"
|
||||
|
||||
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes -AgentBinPath $AgentBinPath
|
||||
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
|
||||
$agent = Get-AgentPresence
|
||||
if ($agent) {
|
||||
$transport = Remove-MsiTransportFolder
|
||||
Write-Log "Agent present ($agent): $(if ($transport) { $transport } else { "$AgentBasePath is left as it is." })"
|
||||
$legacy = Remove-LegacyMsiLoaderFolder
|
||||
if ($legacy) { Write-Log $legacy }
|
||||
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
|
||||
exit 0
|
||||
}
|
||||
if (-not $decision.Install) {
|
||||
Write-Log "No agent on this device: $(Remove-AgentBaseOnSkip -Path $AgentBasePath)"
|
||||
$legacy = Remove-LegacyMsiLoaderFolder
|
||||
if ($legacy) { Write-Log $legacy }
|
||||
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (relaxActive=$($decision.RelaxActive))"
|
||||
|
||||
# Download, verify and install the agent binaries. Guard 5 has already returned when an
|
||||
# agent exe exists, so this always starts without an installed agent.
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
$loaderLogs = @(Read-LoaderLogs -ScriptPath $PSCommandPath -ScriptRoot $PSScriptRoot)
|
||||
Initialize-AgentBase -Path $AgentBasePath
|
||||
[System.IO.Directory]::CreateDirectory($AgentLogPath) | Out-Null
|
||||
Write-LoaderLogs $loaderLogs
|
||||
Open-LogFile
|
||||
Write-Log "Agent folder prepared: $AgentBasePath (protected, emptied)."
|
||||
$legacy = Remove-LegacyMsiLoaderFolder
|
||||
if ($legacy) { Write-Log $legacy }
|
||||
Remove-StaleLoaderRunFolders -ScriptRoot $PSScriptRoot
|
||||
|
||||
$agentExePath = Join-Path $AgentBinPath $AgentExeName
|
||||
$staging = New-RunFolder -Parent $AgentBasePath -Prefix 'Agent-Install-'
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl into $staging..."
|
||||
try {
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
# The manifest sits next to the agent ZIP.
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$manifestAttempts = 3
|
||||
@@ -505,7 +823,7 @@ function Invoke-Bootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$zipPath = Join-Path $staging "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
@@ -537,8 +855,7 @@ function Invoke-Bootstrap {
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# SHA-256 from the version manifest is the only accepted integrity proof.
|
||||
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
|
||||
# No hash, no install.
|
||||
if (-not $expectedSha256) {
|
||||
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
|
||||
}
|
||||
@@ -549,20 +866,12 @@ function Invoke-Bootstrap {
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
|
||||
# Payload catalog. The SHA-256 above proves the ZIP is the one the manifest names, but
|
||||
# that manifest lives in the same container as the ZIP and cannot outlive a compromise
|
||||
# of the download host. The catalog is signed by the expected publisher, whose pin
|
||||
# travels inside THIS script -- verified by the loader before it ran -- so the chain
|
||||
# from the file an administrator assigns to every file the agent runs from stays
|
||||
# publisher-anchored end to end. Fail-closed like the hash check: running unverified
|
||||
# code as SYSTEM is the risk being removed.
|
||||
# The manifest comes from the same host as the ZIP; the signed catalog proves that
|
||||
# every file comes from the publisher.
|
||||
Write-Log "Verifying the payload against its signed catalog ($AgentCatalogName, publisher pin: $ExpectedPublisher)..."
|
||||
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
|
||||
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $staging -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
|
||||
Write-Log "Payload verified against its signed catalog and installed 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"
|
||||
}
|
||||
@@ -573,10 +882,26 @@ function Invoke-Bootstrap {
|
||||
Write-Log "ERROR downloading/verifying agent: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
finally {
|
||||
$left = Remove-OwnTree -Path $staging
|
||||
if ($left.Foreign.Count -gt 0 -or $left.Locked.Count -gt 0) {
|
||||
Write-Log "Run folder $staging not fully removed ($($left.Foreign.Count + $left.Locked.Count) entries stay)."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
$installExitCode = $LASTEXITCODE
|
||||
try {
|
||||
$installExitCode = Invoke-AgentInstallMode -ExePath $agentExePath
|
||||
}
|
||||
catch [System.Management.Automation.ApplicationFailedException] {
|
||||
# The program never started (bad directory, not a program, blocked by policy). An
|
||||
# agent that ran owns its folder; one that never ran leaves only payload there, which
|
||||
# every later run would take for an agent.
|
||||
$left = Remove-OwnTree -Path $AgentBinPath
|
||||
if ($left.Foreign.Count -eq 0 -and $left.Locked.Count -eq 0) { Write-Log "The agent did not start; $AgentBinPath removed." }
|
||||
else { Write-Log "The agent did not start; $AgentBinPath not fully removed ($($left.Foreign.Count + $left.Locked.Count) entries stay)." }
|
||||
throw
|
||||
}
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
@@ -607,19 +932,23 @@ function Invoke-Bootstrap {
|
||||
Write-Log "===== Bootstrap FAILED ====="
|
||||
Write-Log "ERROR: $($_.Exception.Message)"
|
||||
Write-Log "Stack trace: $($_.ScriptStackTrace)"
|
||||
Write-Log "Please check log file: $LogFile"
|
||||
if ($LogState.Ready) { Write-Log "Please check log file: $LogFile" }
|
||||
|
||||
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
|
||||
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
|
||||
# One line: a failed program start carries PowerShell's position lines in its message.
|
||||
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)" -replace '\s*\r?\n\s*', ' '
|
||||
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 2045) + '...' }
|
||||
[Console]::Error.WriteLine($errMsg)
|
||||
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Entry guard: execute only when run as a script (IME, console). Dot-sourcing
|
||||
# (". .\Install-AutopilotMonitor.ps1", as the Pester suite does) only loads
|
||||
# the functions above and MUST stay side-effect free.
|
||||
# Dot-sourcing (tests, release pipeline) loads the functions without running the bootstrap.
|
||||
if ($MyInvocation.InvocationName -ne '.') {
|
||||
Invoke-Bootstrap
|
||||
try {
|
||||
Invoke-Bootstrap
|
||||
}
|
||||
finally {
|
||||
Remove-LegacyInstallerCopy -ScriptPath $PSCommandPath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# The script under test is dot-sourced; its entry guard prevents the loader from
|
||||
# executing, so loading it here is side-effect free. Network and signature checks
|
||||
# are mocked -- what is verified is the DECISION: what does the loader accept, what
|
||||
# does it refuse, and how often does it retry.
|
||||
# does it refuse, how often does it retry, and what does it leave behind.
|
||||
#
|
||||
# Run under Windows PowerShell 5.1 to match the IME runtime:
|
||||
# powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }"
|
||||
@@ -20,6 +20,20 @@ BeforeAll {
|
||||
} else { $null }
|
||||
return [pscustomobject]@{ Status = $Status; SignerCertificate = $cert }
|
||||
}
|
||||
|
||||
# Arguments as PowerShell hands them to a script, names marked as names; an array of
|
||||
# plain strings would bind by position instead.
|
||||
function ConvertTo-ScriptArguments { return , $args }
|
||||
|
||||
function Get-ScriptAst {
|
||||
param([string]$Path)
|
||||
$tokens = $null; $errors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors)
|
||||
return [pscustomobject]@{ Ast = $ast; Tokens = $tokens; Errors = $errors }
|
||||
}
|
||||
|
||||
$script:loaderPath = Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1'
|
||||
$script:installerPath = Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1'
|
||||
}
|
||||
|
||||
Describe 'Test-BootstrapSignature' {
|
||||
@@ -69,22 +83,110 @@ Describe 'Test-BootstrapSignature' {
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-InstallerIdentity' {
|
||||
BeforeAll {
|
||||
$script:roleLine = '$ScriptRole = "AutopilotMonitor-Installer"'
|
||||
}
|
||||
|
||||
It 'reads the role and the version of LF text' {
|
||||
$id = Get-InstallerIdentity -Text ("param()`n`$ScriptVersion = `"2.7`"`n" + $script:roleLine + "`n")
|
||||
$id.Role | Should -BeExactly 'AutopilotMonitor-Installer'
|
||||
$id.Version | Should -Be ([version]'2.7')
|
||||
}
|
||||
|
||||
It 'reads CRLF text that ends in an Authenticode signature block' {
|
||||
$text = (@('$ScriptVersion = "2.7"', $script:roleLine, '# SIG # Begin signature block', '# MIIFxwYJKoZIhvcNAQcCoIIFuDCCBbQCAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB', '# SIG # End signature block') -join "`r`n") + "`r`n"
|
||||
(Get-InstallerIdentity -Text $text).Version | Should -Be ([version]'2.7')
|
||||
}
|
||||
|
||||
It 'compares versions as numbers, so 2.10 is above 2.7' {
|
||||
$id = Get-InstallerIdentity -Text ("`$ScriptVersion = `"2.10`"`n" + $script:roleLine + "`n")
|
||||
$id.Version -gt [version]'2.7' | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'returns nothing for <Case>' -TestCases @(
|
||||
@{ Case = 'a missing role line'; Lines = @('$ScriptVersion = "2.7"') }
|
||||
@{ Case = 'a missing version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"') }
|
||||
@{ Case = 'a role line in a comment'; Lines = @('# $ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"') }
|
||||
@{ Case = 'an indented version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', ' $ScriptVersion = "2.7"') }
|
||||
@{ Case = 'a second version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"', '$ScriptVersion = "9.9"') }
|
||||
@{ Case = 'a second role line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptRole = "Other"', '$ScriptVersion = "2.7"') }
|
||||
@{ Case = 'a pre-release version'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7-beta"') }
|
||||
@{ Case = 'a version line with more code'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"; $x = 1') }
|
||||
) {
|
||||
Get-InstallerIdentity -Text (($Lines -join "`n") + "`n") | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
# The pair the loader and the installer in this repository must keep: the published
|
||||
# installer has to pass the loader that ships with it.
|
||||
It 'accepts the installer in this repository' {
|
||||
$id = Get-InstallerIdentity -Text ([System.IO.File]::ReadAllText($script:installerPath))
|
||||
$id.Role | Should -BeExactly $ExpectedInstallerRole
|
||||
$id.Version -ge $MinimumInstallerVersion | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-InstallerIdentity' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
$script:candidate = Join-Path $TestDrive ("installer-" + [guid]::NewGuid().ToString('N') + '.ps1')
|
||||
}
|
||||
|
||||
It 'accepts version <Version>' -TestCases @(@{ Version = '2.8' }, @{ Version = '2.10' }, @{ Version = '3.0.1' }) {
|
||||
[System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"$Version`"`n`$ScriptRole = `"AutopilotMonitor-Installer`"`n")
|
||||
Test-InstallerIdentity -Path $script:candidate | Should -BeTrue
|
||||
}
|
||||
|
||||
# Every installer the publisher ever signed keeps a valid signature; the minimum keeps the
|
||||
# older ones from running.
|
||||
It 'refuses an installer below the minimum version' {
|
||||
[System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"2.7`"`n`$ScriptRole = `"AutopilotMonitor-Installer`"`n")
|
||||
Test-InstallerIdentity -Path $script:candidate | Should -BeFalse
|
||||
Should -Invoke Write-Log -ParameterFilter { $Message -like '*below the minimum 2.8*' }
|
||||
}
|
||||
|
||||
# The publisher signs other scripts too.
|
||||
It 'refuses another role' {
|
||||
[System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"2.8`"`n`$ScriptRole = `"AutopilotMonitor-Uninstaller`"`n")
|
||||
Test-InstallerIdentity -Path $script:candidate | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'refuses a script without the identity lines' {
|
||||
[System.IO.File]::WriteAllText($script:candidate, "Write-Host 'signed, but not the installer'`n")
|
||||
Test-InstallerIdentity -Path $script:candidate | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'refuses a file it cannot read' {
|
||||
Test-InstallerIdentity -Path (Join-Path $TestDrive 'missing.ps1') | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-VerifiedBootstrapScript' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
Mock Start-Sleep { }
|
||||
Mock Invoke-WebRequest { }
|
||||
Mock Invoke-WebRequest { [System.IO.File]::WriteAllText($OutFile, 'downloaded') }
|
||||
Mock Test-InstallerIdentity { $true }
|
||||
# The test account owns the folder; the loader asks for SYSTEM or Administrators.
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
$script:run = Join-Path $TestDrive ('run-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
New-Item -ItemType Directory -Path $script:run | Out-Null
|
||||
$script:dest = Join-Path $script:run 'Install-AutopilotMonitor.ps1'
|
||||
$RunState = [pscustomobject]@{ LogFile = $null; Installer = $null }
|
||||
}
|
||||
AfterEach {
|
||||
if ($RunState.Installer) { $RunState.Installer.Dispose() }
|
||||
}
|
||||
|
||||
It 'returns after the first attempt when the download verifies' {
|
||||
Mock Test-BootstrapSignature { $true }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
|
||||
Should -Invoke Invoke-WebRequest -Times 1 -Exactly
|
||||
}
|
||||
|
||||
It 'gives up after three attempts when verification keeps failing' {
|
||||
Mock Test-BootstrapSignature { $false }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeFalse
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
||||
Should -Invoke Invoke-WebRequest -Times 3 -Exactly
|
||||
}
|
||||
|
||||
@@ -93,27 +195,247 @@ Describe 'Get-VerifiedBootstrapScript' {
|
||||
It 'retries a failed verification and accepts a later attempt' {
|
||||
$script:calls = 0
|
||||
Mock Test-BootstrapSignature { $script:calls++; return ($script:calls -ge 2) }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
|
||||
Should -Invoke Invoke-WebRequest -Times 2 -Exactly
|
||||
}
|
||||
|
||||
It 'retries a transport failure' {
|
||||
$script:downloads = 0
|
||||
Mock Invoke-WebRequest { $script:downloads++; if ($script:downloads -eq 1) { throw 'connection reset' } }
|
||||
Mock Invoke-WebRequest { $script:downloads++; if ($script:downloads -eq 1) { throw 'connection reset' }; [System.IO.File]::WriteAllText($OutFile, 'downloaded') }
|
||||
Mock Test-BootstrapSignature { $true }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
|
||||
Should -Invoke Invoke-WebRequest -Times 2 -Exactly
|
||||
}
|
||||
|
||||
It 'never executes anything when no attempt verifies' {
|
||||
It 'refuses a validly signed script that is not the current installer' {
|
||||
Mock Test-BootstrapSignature { $true }
|
||||
Mock Test-InstallerIdentity { $false }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
||||
Should -Invoke Invoke-WebRequest -Times 3 -Exactly
|
||||
}
|
||||
|
||||
# The identity lines count only once the signature proves they are the publisher's.
|
||||
It 'reads the identity only after the signature passed' {
|
||||
Mock Test-BootstrapSignature { $false }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeFalse
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
||||
Should -Invoke Test-InstallerIdentity -Times 0 -Exactly
|
||||
}
|
||||
|
||||
It 'stops without downloading when the run folder is no longer a folder of ours' {
|
||||
Mock Test-IsOwnedByUs { $false }
|
||||
Mock Test-BootstrapSignature { $true }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
||||
Should -Invoke Invoke-WebRequest -Times 0 -Exactly
|
||||
}
|
||||
|
||||
# The file that was checked is the file that runs: nobody can replace it in between.
|
||||
It 'keeps the verified file open, so it cannot be replaced or deleted' {
|
||||
Mock Test-BootstrapSignature { $true }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
|
||||
$RunState.Installer | Should -Not -BeNullOrEmpty
|
||||
{ [System.IO.File]::WriteAllText($script:dest, 'replaced') } | Should -Throw
|
||||
{ [System.IO.File]::Delete($script:dest) } | Should -Throw
|
||||
[System.IO.File]::ReadAllText($script:dest) | Should -Be 'downloaded'
|
||||
}
|
||||
|
||||
It 'holds nothing when no attempt verifies' {
|
||||
Mock Test-BootstrapSignature { $false }
|
||||
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
|
||||
$RunState.Installer | Should -BeNullOrEmpty
|
||||
{ [System.IO.File]::Delete($script:dest) } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-DownloadSecurityProtocol' {
|
||||
It 'keeps the system default, which includes TLS 1.3' {
|
||||
Get-DownloadSecurityProtocol -Current ([Net.SecurityProtocolType]::SystemDefault) | Should -Be ([Net.SecurityProtocolType]::SystemDefault)
|
||||
}
|
||||
|
||||
It 'adds TLS 1.2 to an explicit list' {
|
||||
Get-DownloadSecurityProtocol -Current ([Net.SecurityProtocolType]'Ssl3, Tls') | Should -Be ([Net.SecurityProtocolType]'Ssl3, Tls, Tls12')
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Remove-LoaderRunFiles' {
|
||||
BeforeEach {
|
||||
Mock Write-Host { }
|
||||
$run = Join-Path $TestDrive ("run-" + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $run | Out-Null
|
||||
$installer = Join-Path $run 'Install-AutopilotMonitor.ps1'
|
||||
$log = Join-Path $run 'bootstrap-loader.log'
|
||||
Set-Content -Path $installer -Value 'downloaded'
|
||||
Set-Content -Path $log -Value 'log'
|
||||
$RunState = [pscustomobject]@{ LogFile = $log; Installer = $null }
|
||||
}
|
||||
|
||||
It 'removes the download, the log and the folder' {
|
||||
Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer
|
||||
Test-Path -LiteralPath $run | Should -BeFalse
|
||||
$RunState.LogFile | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'releases the held download before deleting it' {
|
||||
$RunState.Installer = New-Object System.IO.FileStream($installer, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
|
||||
Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer
|
||||
Test-Path -LiteralPath $run | Should -BeFalse
|
||||
$RunState.Installer | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'keeps the log and the folder with -KeepLog, but never the download' {
|
||||
Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer -KeepLog
|
||||
@(Get-ChildItem -LiteralPath $run -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log')
|
||||
}
|
||||
|
||||
It 'leaves a folder that holds anything else, without throwing' {
|
||||
Set-Content -Path (Join-Path $run 'other.txt') -Value 'other'
|
||||
{ Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer } | Should -Not -Throw
|
||||
@(Get-ChildItem -LiteralPath $run -Force | ForEach-Object Name) | Should -Be @('other.txt')
|
||||
Should -Invoke Write-Host -ParameterFilter { "$Object" -like 'INFO: run folder*' }
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Start-BootstrapLoader' {
|
||||
BeforeEach {
|
||||
$script:savedProgramData = $env:ProgramData
|
||||
$env:ProgramData = Join-Path $TestDrive ("pd-" + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $env:ProgramData | Out-Null
|
||||
$script:runFolder = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-0a1b2c3d'
|
||||
$script:marker = Join-Path $TestDrive ("ran-" + [guid]::NewGuid().ToString('N') + '.txt')
|
||||
|
||||
$RunState = [pscustomobject]@{ LogFile = $null; Installer = $null }
|
||||
$InstallerArguments = @()
|
||||
|
||||
Mock Write-Host { }
|
||||
Mock Start-Sleep { }
|
||||
Mock Test-IsElevated { $true }
|
||||
# A standard user cannot make Administrators the owner, so the run folder is a plain
|
||||
# one here; New-RunFolder itself is tested with the installer, which shares it.
|
||||
Mock New-RunFolder { [System.IO.Directory]::CreateDirectory($script:runFolder) | Out-Null; return $script:runFolder }
|
||||
}
|
||||
|
||||
AfterEach {
|
||||
$env:ProgramData = $script:savedProgramData
|
||||
}
|
||||
|
||||
It 'refuses to run without SYSTEM or administrator rights and creates nothing' {
|
||||
Mock Test-IsElevated { $false }
|
||||
$stderr = New-Object System.IO.StringWriter
|
||||
$previous = [Console]::Error
|
||||
[Console]::SetError($stderr)
|
||||
try { Start-BootstrapLoader } finally { [Console]::SetError($previous) }
|
||||
$stderr.ToString() | Should -Match 'not running as SYSTEM or administrator'
|
||||
Should -Invoke New-RunFolder -Times 0 -Exactly
|
||||
@(Get-ChildItem -LiteralPath $env:ProgramData -Force).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'runs nothing and keeps only its log when no download verifies' {
|
||||
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "Set-Content -LiteralPath '$($script:marker)' -Value ran"); $false }
|
||||
Start-BootstrapLoader
|
||||
Test-Path -LiteralPath $script:marker | Should -BeFalse
|
||||
@(Get-ChildItem -LiteralPath $script:runFolder -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log')
|
||||
Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Nothing was executed'
|
||||
}
|
||||
|
||||
It 'runs the verified installer with the passed arguments by name and removes its folder after exit 0' {
|
||||
$agentLog = Join-Path $env:ProgramData 'AutopilotMonitor\Logs\agent.log'
|
||||
New-Item -ItemType Directory -Path (Split-Path $agentLog) -Force | Out-Null
|
||||
Set-Content -Path $agentLog -Value 'agent'
|
||||
Mock Get-VerifiedBootstrapScript {
|
||||
[System.IO.File]::WriteAllText($Destination, "param([string]`$AgentDownloadUrl = 'default', [int]`$MaxBootstrapWindowHours = 12)`nSet-Content -LiteralPath '$($script:marker)' -Value `"`$AgentDownloadUrl `$MaxBootstrapWindowHours`"`nexit 0`n")
|
||||
$true
|
||||
}
|
||||
$InstallerArguments = ConvertTo-ScriptArguments -MaxBootstrapWindowHours 24
|
||||
|
||||
Start-BootstrapLoader
|
||||
|
||||
Get-Content -LiteralPath $script:marker | Should -Be 'default 24'
|
||||
Test-Path -LiteralPath $script:runFolder | Should -BeFalse
|
||||
Get-Content -LiteralPath $agentLog | Should -Be 'agent'
|
||||
}
|
||||
|
||||
It 'keeps its log but not the installer when the installer fails' {
|
||||
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "exit 1`n"); $true }
|
||||
Start-BootstrapLoader
|
||||
@(Get-ChildItem -LiteralPath $script:runFolder -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log')
|
||||
Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Installer finished \(exit code 1\)'
|
||||
}
|
||||
|
||||
It 'keeps its log when the installer throws' {
|
||||
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "throw 'installer broke'`n"); $true }
|
||||
Start-BootstrapLoader
|
||||
Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Installer threw: installer broke'
|
||||
}
|
||||
|
||||
# An installer that ends without an exit code must not inherit a 0 from earlier work.
|
||||
It 'does not take an earlier exit code for the installer''s' {
|
||||
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "Write-Output 'no exit code'`n"); $true }
|
||||
$global:LASTEXITCODE = 0
|
||||
Start-BootstrapLoader
|
||||
Test-Path -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'names its version first in its log, in the form the installer carries over' {
|
||||
Mock Get-VerifiedBootstrapScript { $false }
|
||||
Start-BootstrapLoader
|
||||
$first = (Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log'))[0]
|
||||
$first | Should -Match '^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] === Autopilot Monitor bootstrap loader v\d+(\.\d+){0,3} started'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'loader contract' {
|
||||
BeforeAll {
|
||||
$script:loader = Get-ScriptAst $script:loaderPath
|
||||
}
|
||||
|
||||
It 'parses without errors' {
|
||||
$script:loader.Errors.Count | Should -Be 0
|
||||
}
|
||||
|
||||
# Unbound arguments reach the installer only while the script is not an advanced one.
|
||||
It 'stays a plain script, so unbound arguments are kept for the installer' {
|
||||
$script:loader.Ast.ParamBlock.Attributes.Count | Should -Be 0
|
||||
@($script:loader.Ast.ParamBlock.Parameters.Attributes | Where-Object { $_.TypeName.Name -eq 'Parameter' }) | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
# The six functions also run in the installer and, embedded, in the OOBE script.
|
||||
It 'carries the shared functions word for word as the installer has them' {
|
||||
$installer = Get-ScriptAst $script:installerPath
|
||||
foreach ($name in @('Test-IsElevated', 'New-ProtectedDirectorySecurity', 'Test-IsReparsePoint', 'Test-IsOwnedByUs', 'New-RunFolderName', 'New-RunFolder')) {
|
||||
$mine = $script:loader.Ast.Find({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $a.Name -eq $name }, $true)
|
||||
$theirs = $installer.Ast.Find({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $a.Name -eq $name }, $true)
|
||||
$mine | Should -Not -BeNullOrEmpty -Because $name
|
||||
$mine.Extent.Text | Should -BeExactly $theirs.Extent.Text -Because $name
|
||||
}
|
||||
}
|
||||
|
||||
It 'calls no command beyond the few it needs' {
|
||||
$own = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | ForEach-Object { $_.Name })
|
||||
$allowed = $own + @('Add-Content', 'ForEach-Object', 'Get-AuthenticodeSignature', 'Get-Date', 'Get-Item', 'Invoke-WebRequest', 'Join-Path', 'New-Object', 'Out-Null', 'Start-Sleep', 'Test-Path', 'Write-Host')
|
||||
$used = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.CommandAst] }, $true) | ForEach-Object { $_.GetCommandName() } | Where-Object { $_ } | Sort-Object -Unique)
|
||||
@($used | Where-Object { $allowed -notcontains $_ }) | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'deletes only the download, its log and its own empty folder' {
|
||||
$deletes = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and $a.Member.Extent.Text -eq 'Delete' }, $true))
|
||||
$deletes.Count | Should -Be 3
|
||||
foreach ($delete in $deletes) {
|
||||
if ($delete.Expression.Extent.Text -eq '[System.IO.Directory]') {
|
||||
$delete.Arguments[1].Extent.Text | Should -Be '$false'
|
||||
} else {
|
||||
$delete.Expression.Extent.Text | Should -Be '[System.IO.File]'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
It 'never names the agent folder' {
|
||||
$strings = @($script:loader.Tokens | Where-Object { $_ -is [System.Management.Automation.Language.StringToken] } | ForEach-Object { $_.Value })
|
||||
@($strings | Where-Object { $_ -match '(^|\\)AutopilotMonitor($|\\)' }) | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'publish contract' {
|
||||
BeforeAll {
|
||||
$script:loaderText = Get-Content (Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1') -Raw
|
||||
$script:loaderText = Get-Content $script:loaderPath -Raw
|
||||
}
|
||||
|
||||
# Publish-BootstrapScripts.ps1 renders the -Dev loader by substituting this exact
|
||||
@@ -127,7 +449,7 @@ Describe 'publish contract' {
|
||||
# The publisher parses this to write version.json.loaderVersion and to run the
|
||||
# version-bump guard.
|
||||
It 'declares a parsable ScriptVersion' {
|
||||
$script:loaderText -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"' | Should -BeTrue
|
||||
$script:loaderText -match '(?m)^\$ScriptVersion = "\d+(\.\d+){1,3}"\r?$' | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'pins the expected publisher by subject, not by thumbprint' {
|
||||
@@ -144,4 +466,16 @@ Describe 'publish contract' {
|
||||
$wxs.Contains('[INSTALLFOLDER]Start-AutopilotMonitor.ps1') | Should -BeTrue
|
||||
$wxs.Contains('-LogFileName bootstrap-msi.log') | Should -BeTrue
|
||||
}
|
||||
|
||||
# Everything of ours lives under ProgramData, so the copy the MSI installs does too, where
|
||||
# the installer removes it. A standard user could have created that folder first, so the
|
||||
# action never runs the file as such: it checks the signature on the bytes it read and runs
|
||||
# those bytes, from System32.
|
||||
It 'is installed by the bootstrap MSI under ProgramData and run only after the check' {
|
||||
$wxs = Get-Content (Join-Path $PSScriptRoot '..\..\src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs') -Raw
|
||||
$wxs | Should -Match '<StandardDirectory Id="CommonAppDataFolder">'
|
||||
$wxs | Should -Not -Match 'ProgramFiles'
|
||||
$wxs.Contains('Get-AuthenticodeSignature -Content $b -SourcePathOrExtension ''.ps1''') | Should -BeTrue
|
||||
$wxs.Contains('Directory="System64Folder"') | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,107 +1,174 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stage 1 of the Autopilot Monitor bootstrap: fetches the current installer and runs it.
|
||||
Downloads the current Autopilot Monitor installer and runs it.
|
||||
|
||||
.DESCRIPTION
|
||||
This is the file an administrator assigns in Intune, and the same file the bootstrap MSI
|
||||
installs and runs -- one implementation for both delivery channels; the MSI is only the
|
||||
packaging. It carries no enrollment logic at all: it downloads the published
|
||||
Install-AutopilotMonitor.ps1, proves that it was signed by Autopilot Monitor, and runs it
|
||||
in this process.
|
||||
Assign this script in Intune once; the bootstrap MSI runs the same script. It downloads
|
||||
Install-AutopilotMonitor.ps1 from download.autopilotmonitor.com and runs it only when the
|
||||
file carries a valid signature from glueckkanja AG and is installer version 2.7 or later.
|
||||
Because the installer is fetched on every run, this script does not need replacing when
|
||||
the installer changes.
|
||||
|
||||
Why two stages: the installer holds every guard, relax rule and download path, and those
|
||||
change. Whoever assigns the installer directly has to replace their Intune copy each
|
||||
time; whoever assigns this loader never does, because the part that changes lives on the
|
||||
server. Assigning the installer directly stays supported and unchanged.
|
||||
The download and the log go into %ProgramData%\AutopilotMonitor-Loader-<random>, which only
|
||||
SYSTEM and administrators can change. The loader keeps the downloaded file open from the
|
||||
check to the end of the run, so what it checked is what runs. After a successful run the
|
||||
folder is removed; after a failure it keeps the log for diagnosis, and a later installer
|
||||
run removes it.
|
||||
|
||||
This file is deliberately boring. It is the one piece that cannot be fixed remotely, so
|
||||
it does the least it can: download, verify, invoke, log.
|
||||
The exit code is always 0, so the loader never fails enrollment. Without SYSTEM or
|
||||
administrator rights it stops with an error and changes nothing.
|
||||
|
||||
Verification is by Authenticode publisher, not by hash. A hash oracle would have to be
|
||||
fetched from the same host as the script itself, so it could not survive a compromise of
|
||||
that host, while the expected publisher below travels with this file. It pins the
|
||||
certificate SUBJECT and not a thumbprint, so a certificate renewal does not brick a
|
||||
loader that is already deployed on devices.
|
||||
|
||||
Fail-soft: the loader always exits 0 and never throws into the caller. A device that
|
||||
cannot reach the download endpoint is a device without monitoring, never a failed
|
||||
enrollment step. Verification failures are the same: nothing runs, and the reason is in
|
||||
the log. Refusing to execute unverified code as SYSTEM is the point of this file.
|
||||
|
||||
This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
Any other arguments are passed to the installer, for example -MaxBootstrapWindowHours 24.
|
||||
|
||||
.PARAMETER BootstrapUrl
|
||||
Installer URL. Defaults to the published stable installer; the -Dev variant of this
|
||||
loader is rendered from that default at publish time.
|
||||
Installer to download. Default: the published installer.
|
||||
|
||||
.PARAMETER LogFileName
|
||||
Log file inside the agent log directory. The MSI channel passes bootstrap-msi.log so the
|
||||
delivery channel stays visible in a diagnostics package; the diag tooling keys on these
|
||||
names, so do not rename them.
|
||||
|
||||
.PARAMETER BootstrapArguments
|
||||
Everything else is passed through to the installer unchanged, so any parameter the
|
||||
installer accepts can be set here without this file knowing about it.
|
||||
|
||||
.EXAMPLE
|
||||
.\Start-AutopilotMonitor.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\Start-AutopilotMonitor.ps1 -MaxBootstrapWindowHours 24
|
||||
Name of the log file. Default: bootstrap-loader.log.
|
||||
|
||||
.NOTES
|
||||
Author : Oliver Kieselbach (autopilotmonitor.com)
|
||||
Runtime : Windows PowerShell 5.1, SYSTEM context, during Autopilot enrollment
|
||||
|
||||
.CHANGELOG
|
||||
2026-09-08 v1.2 Log the signer CN instead of the full EV subject; the full subject is
|
||||
logged only when the publisher is why a script was rejected.
|
||||
2026-09-08 v1.1 Also used by the bootstrap MSI, which replaced its own copy of this
|
||||
logic; -LogFileName keeps the channel visible in diagnostics.
|
||||
2026-09-08 v1.0 Initial version: two-stage bootstrap, publisher-pinned verification.
|
||||
Requires Windows PowerShell 5.1.
|
||||
Changes: https://docs.autopilotmonitor.com/changelog/platform-changelog
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[ValidatePattern('^[A-Za-z0-9._-]+$')]
|
||||
[string]$LogFileName = "bootstrap-loader.log",
|
||||
|
||||
[Parameter(Mandatory = $false, ValueFromRemainingArguments = $true)]
|
||||
[string[]]$BootstrapArguments
|
||||
[string]$LogFileName = "bootstrap-loader.log"
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "1.2"
|
||||
# Unbound arguments reach the installer by name. [CmdletBinding()] or a [Parameter()]
|
||||
# attribute would reject them instead.
|
||||
$InstallerArguments = $args
|
||||
|
||||
# The publisher every downloaded stage-2 script must carry. A -like pattern on the subject,
|
||||
# NOT a thumbprint: certificates are renewed, deployed loaders are not.
|
||||
$ScriptVersion = "1.4"
|
||||
|
||||
# A subject pattern, not a thumbprint, so a certificate renewal keeps deployed loaders working.
|
||||
$ExpectedPublisher = "*O=glueckkanja AG*"
|
||||
|
||||
$LoaderBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$LoaderLogPath = "$LoaderBasePath\Logs"
|
||||
$LogFile = "$LoaderLogPath\$LogFileName"
|
||||
$BootstrapPath = "$LoaderBasePath\Install-AutopilotMonitor.ps1"
|
||||
$ExpectedInstallerRole = "AutopilotMonitor-Installer"
|
||||
$MinimumInstallerVersion = [version]"2.8"
|
||||
|
||||
$RunFolderPrefix = "AutopilotMonitor-Loader-"
|
||||
|
||||
$DownloadAttempts = 3
|
||||
$RetryDelaySeconds = 10
|
||||
|
||||
# Write-Host as well as the file: the Intune script output is what an administrator sees in
|
||||
# the portal, the file is what a support case reads.
|
||||
# The log file, and the handle that keeps the verified installer unchanged until the run ends.
|
||||
$RunState = [pscustomobject]@{ LogFile = $null; Installer = $null }
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
if ($RunState.LogFile) {
|
||||
Add-Content -Path $RunState.LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# True only when the file is signed, the signature validates against the machine's trust
|
||||
# chain, and the signer is us. Any error is a false -- an unreadable signature is an
|
||||
# unverified script.
|
||||
### Shared with Install-AutopilotMonitor.ps1, word for word.
|
||||
|
||||
function Test-IsElevated {
|
||||
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
return (New-Object System.Security.Principal.WindowsPrincipal($identity)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function New-ProtectedDirectorySecurity {
|
||||
$security = New-Object System.Security.AccessControl.DirectorySecurity
|
||||
$security.SetAccessRuleProtection($true, $false)
|
||||
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
||||
$propagation = [System.Security.AccessControl.PropagationFlags]::None
|
||||
$system = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::LocalSystemSid, $null)
|
||||
$admins = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid, $null)
|
||||
$users = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::BuiltinUsersSid, $null)
|
||||
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($system, 'FullControl', $inherit, $propagation, 'Allow')))
|
||||
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($admins, 'FullControl', $inherit, $propagation, 'Allow')))
|
||||
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($users, 'ReadAndExecute', $inherit, $propagation, 'Allow')))
|
||||
$security.SetOwner($admins)
|
||||
return $security
|
||||
}
|
||||
|
||||
function Test-IsReparsePoint {
|
||||
param([string]$Path)
|
||||
return (([System.IO.File]::GetAttributes($Path) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)
|
||||
}
|
||||
|
||||
function Test-IsOwnedByUs {
|
||||
param([string]$Path)
|
||||
try {
|
||||
$owner = (Get-Item -LiteralPath $Path -Force -ErrorAction Stop).GetAccessControl([System.Security.AccessControl.AccessControlSections]::Owner).GetOwner([System.Security.Principal.SecurityIdentifier])
|
||||
return ($owner.IsWellKnown([System.Security.Principal.WellKnownSidType]::LocalSystemSid) -or
|
||||
$owner.IsWellKnown([System.Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid))
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function New-RunFolderName {
|
||||
$bytes = New-Object byte[] 4
|
||||
$generator = [System.Security.Cryptography.RandomNumberGenerator]::Create()
|
||||
try { $generator.GetBytes($bytes) } finally { $generator.Dispose() }
|
||||
return -join ($bytes | ForEach-Object { $_.ToString('x2') })
|
||||
}
|
||||
|
||||
function New-RunFolder {
|
||||
param([string]$Parent, [string]$Prefix)
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
$path = Join-Path $Parent ($Prefix + (New-RunFolderName))
|
||||
if (Test-Path -LiteralPath $path) { continue }
|
||||
[System.IO.Directory]::CreateDirectory($path, (New-ProtectedDirectorySecurity)) | Out-Null
|
||||
if (-not (Test-IsReparsePoint $path) -and (Test-IsOwnedByUs $path)) { return $path }
|
||||
}
|
||||
throw "Could not create a run folder under $Parent."
|
||||
}
|
||||
|
||||
### End of the shared functions.
|
||||
|
||||
# Keeps the system default (0, which includes TLS 1.3); otherwise adds TLS 1.2.
|
||||
function Get-DownloadSecurityProtocol {
|
||||
param([Net.SecurityProtocolType]$Current)
|
||||
if ([int]$Current -eq 0) { return $Current }
|
||||
return ($Current -bor [Net.SecurityProtocolType]::Tls12)
|
||||
}
|
||||
|
||||
# Exactly one role line and one version line, each at the start of a line; $null otherwise.
|
||||
function Get-InstallerIdentity {
|
||||
param([string]$Text)
|
||||
$roles = [regex]::Matches($Text, '(?m)^\$ScriptRole[ \t]*=[ \t]*"([A-Za-z0-9.-]+)"[ \t]*\r?$')
|
||||
$versions = [regex]::Matches($Text, '(?m)^\$ScriptVersion[ \t]*=[ \t]*"(\d+(?:\.\d+){1,3})"[ \t]*\r?$')
|
||||
if ($roles.Count -ne 1 -or $versions.Count -ne 1) { return $null }
|
||||
return [pscustomobject]@{ Role = $roles[0].Groups[1].Value; Version = [version]$versions[0].Groups[1].Value }
|
||||
}
|
||||
|
||||
# Runs after the signature check, so the lines it reads are signed.
|
||||
function Test-InstallerIdentity {
|
||||
param([string]$Path)
|
||||
try {
|
||||
$identity = Get-InstallerIdentity -Text ([System.IO.File]::ReadAllText($Path))
|
||||
}
|
||||
catch {
|
||||
Write-Log "Identity check failed to run: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
if ($null -eq $identity) {
|
||||
Write-Log "REJECTED: the script does not declare exactly one role line and one version line."
|
||||
return $false
|
||||
}
|
||||
if ($identity.Role -ne $ExpectedInstallerRole) {
|
||||
Write-Log "REJECTED: role '$($identity.Role)' is not '$ExpectedInstallerRole'."
|
||||
return $false
|
||||
}
|
||||
if ($identity.Version -lt $MinimumInstallerVersion) {
|
||||
Write-Log "REJECTED: installer version $($identity.Version) is below the minimum $MinimumInstallerVersion."
|
||||
return $false
|
||||
}
|
||||
Write-Log "Installer accepted: $($identity.Role) v$($identity.Version)."
|
||||
return $true
|
||||
}
|
||||
|
||||
# A valid signature from the expected publisher; any error counts as unverified.
|
||||
function Test-BootstrapSignature {
|
||||
param([string]$Path)
|
||||
|
||||
@@ -118,9 +185,7 @@ function Test-BootstrapSignature {
|
||||
return $false
|
||||
}
|
||||
|
||||
# The full subject of an EV certificate is a line of street address and registration
|
||||
# numbers. The CN answers the only question the log has to answer; the full subject is
|
||||
# logged when it is the reason for a rejection.
|
||||
# The CN is enough for the log; the full subject is logged when it causes a rejection.
|
||||
$subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { "<unsigned>" }
|
||||
$signer = if ($subject -match 'CN=([^,]+)') { $Matches[1] } else { $subject }
|
||||
Write-Log "Signature status: $($sig.Status). Signer: $signer"
|
||||
@@ -138,20 +203,28 @@ function Test-BootstrapSignature {
|
||||
return $true
|
||||
}
|
||||
|
||||
# Downloads the installer and returns $true once a downloaded copy passes verification.
|
||||
# Verification lives inside the retry loop on purpose: a truncated or proxy-mangled body
|
||||
# fails the signature check, and that is worth another attempt.
|
||||
# A download that fails verification, such as a truncated one, is retried too. The run folder
|
||||
# is checked before every attempt: one that is no longer a real folder of ours stops the run.
|
||||
# The downloaded file is opened for reading before it is checked and stays open until the run
|
||||
# ends, so no one can change or replace what was checked.
|
||||
function Get-VerifiedBootstrapScript {
|
||||
param([string]$Url, [string]$Destination)
|
||||
param([string]$Url, [string]$Destination, [string]$RunFolder)
|
||||
|
||||
for ($attempt = 1; $attempt -le $DownloadAttempts; $attempt++) {
|
||||
if ((Test-IsReparsePoint $RunFolder) -or -not (Test-IsOwnedByUs $RunFolder)) {
|
||||
Write-Log "REJECTED: the run folder $RunFolder is no longer a folder of ours. Nothing was downloaded."
|
||||
return $false
|
||||
}
|
||||
try {
|
||||
Write-Log "Download attempt $attempt/$DownloadAttempts from $Url"
|
||||
Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
|
||||
|
||||
if (Test-BootstrapSignature -Path $Destination) {
|
||||
$held = New-Object System.IO.FileStream($Destination, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
|
||||
if ((Test-BootstrapSignature -Path $Destination) -and (Test-InstallerIdentity -Path $Destination)) {
|
||||
$RunState.Installer = $held
|
||||
return $true
|
||||
}
|
||||
$held.Dispose()
|
||||
}
|
||||
catch {
|
||||
Write-Log "Download attempt $attempt failed: $($_.Exception.Message)"
|
||||
@@ -165,51 +238,87 @@ function Get-VerifiedBootstrapScript {
|
||||
return $false
|
||||
}
|
||||
|
||||
# Main flow. Only invoked when the script is run directly (see entry guard at the bottom),
|
||||
# so the Pester suite can dot-source this file without bootstrapping anything.
|
||||
function Start-BootstrapLoader {
|
||||
New-Item -ItemType Directory -Force -Path $LoaderLogPath -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
Write-Log "=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ==="
|
||||
|
||||
# PS 5.1 on an OOBE device still defaults to TLS 1.0 for this process.
|
||||
# The download always goes. After an installer exit 0 the log and the folder go too;
|
||||
# otherwise they stay for diagnosis.
|
||||
function Remove-LoaderRunFiles {
|
||||
param([string]$RunFolder, [string]$InstallerPath, [switch]$KeepLog)
|
||||
if ($RunState.Installer) {
|
||||
$RunState.Installer.Dispose()
|
||||
$RunState.Installer = $null
|
||||
}
|
||||
try {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
if ([System.IO.File]::Exists($InstallerPath)) { [System.IO.File]::Delete($InstallerPath) }
|
||||
if ($KeepLog) { return }
|
||||
$logFile = $RunState.LogFile
|
||||
$RunState.LogFile = $null
|
||||
if ($logFile -and [System.IO.File]::Exists($logFile)) { [System.IO.File]::Delete($logFile) }
|
||||
[System.IO.Directory]::Delete($RunFolder, $false)
|
||||
}
|
||||
catch {
|
||||
Write-Log "INFO: could not raise the TLS protocol: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if (-not (Get-VerifiedBootstrapScript -Url $BootstrapUrl -Destination $BootstrapPath)) {
|
||||
Write-Log "No verified installer available after $DownloadAttempts attempt(s). Nothing was executed."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Log "Executing $BootstrapPath"
|
||||
|
||||
# Same process, so the execution policy this loader runs under applies to stage 2 as
|
||||
# well and no second policy decision can strand the installer. Splatting an array of
|
||||
# remaining arguments keeps this file free of any knowledge about the installer's
|
||||
# parameters.
|
||||
$arguments = @()
|
||||
if ($BootstrapArguments) { $arguments = $BootstrapArguments }
|
||||
|
||||
try {
|
||||
& $BootstrapPath @arguments
|
||||
Write-Log "Installer finished (exit code $LASTEXITCODE)."
|
||||
}
|
||||
catch {
|
||||
Write-Log "Installer threw: $($_.Exception.Message)"
|
||||
Write-Host "INFO: run folder $RunFolder was not fully removed: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Entry guard: dot-sourcing (Pester) loads the functions without running the loader.
|
||||
function Start-BootstrapLoader {
|
||||
if (-not (Test-IsElevated)) {
|
||||
$message = "AutopilotMonitor loader: not running as SYSTEM or administrator; nothing was downloaded or changed."
|
||||
Write-Host $message
|
||||
[Console]::Error.WriteLine($message)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
[Net.ServicePointManager]::SecurityProtocol = Get-DownloadSecurityProtocol -Current ([Net.ServicePointManager]::SecurityProtocol)
|
||||
}
|
||||
catch {
|
||||
Write-Host "INFO: could not adjust the TLS protocols: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
try {
|
||||
$runFolder = New-RunFolder -Parent $env:ProgramData -Prefix $RunFolderPrefix
|
||||
}
|
||||
catch {
|
||||
Write-Host "AutopilotMonitor loader: no run folder ($($_.Exception.Message)). Nothing was downloaded or executed."
|
||||
return
|
||||
}
|
||||
|
||||
$RunState.LogFile = Join-Path $runFolder $LogFileName
|
||||
Write-Log "=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ==="
|
||||
Write-Log ("PowerShell {0}, {1}, language mode {2}, run folder {3}" -f $PSVersionTable.PSVersion, $(if ([Environment]::Is64BitProcess) { '64-bit' } else { '32-bit' }), $ExecutionContext.SessionState.LanguageMode, $runFolder)
|
||||
|
||||
$installerPath = Join-Path $runFolder 'Install-AutopilotMonitor.ps1'
|
||||
$succeeded = $false
|
||||
try {
|
||||
if (-not (Get-VerifiedBootstrapScript -Url $BootstrapUrl -Destination $installerPath -RunFolder $runFolder)) {
|
||||
Write-Log "No verified installer available after $DownloadAttempts attempt(s). Nothing was executed."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Log "Executing $installerPath"
|
||||
|
||||
# Same process, so the execution policy that admitted this script covers the installer.
|
||||
# The exit code is reset so that an installer that throws cannot report an older one.
|
||||
$global:LASTEXITCODE = $null
|
||||
try {
|
||||
& $installerPath @InstallerArguments
|
||||
Write-Log "Installer finished (exit code $LASTEXITCODE)."
|
||||
$succeeded = ($LASTEXITCODE -eq 0)
|
||||
}
|
||||
catch {
|
||||
Write-Log "Installer threw: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-LoaderRunFiles -RunFolder $runFolder -InstallerPath $installerPath -KeepLog:(-not $succeeded)
|
||||
}
|
||||
}
|
||||
|
||||
# Dot-sourcing (tests) loads the functions without running the loader.
|
||||
if ($MyInvocation.InvocationName -ne '.') {
|
||||
try {
|
||||
Start-BootstrapLoader
|
||||
}
|
||||
catch {
|
||||
# Nothing above should throw; if it does, it must not become a failed Intune script.
|
||||
Write-Log "Loader failed: $($_.Exception.Message)"
|
||||
}
|
||||
exit 0
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Autopilot Monitor Bootstrap Guard Test
|
||||
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
|
||||
# Mirrors Install-AutopilotMonitor.ps1 (v2.3) incl. the relax exemption for
|
||||
# Mirrors Install-AutopilotMonitor.ps1 (v2.7) incl. the relax exemption for
|
||||
# guards 2+3, triggered by "Windows Backup for Organizations" (OOBE InProgress)
|
||||
# or a positively identified Windows 365 Cloud PC, and the guard 4 (uptime)
|
||||
# exemption while the Cloud PC relax is active.
|
||||
# or a positively identified Windows 365 Cloud PC, the guard 4 (uptime)
|
||||
# exemption while the Cloud PC relax is active, and the agent check.
|
||||
|
||||
param(
|
||||
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
|
||||
@@ -29,20 +29,47 @@ function Write-Step {
|
||||
Write-Host "$prefix $Message"
|
||||
}
|
||||
|
||||
# HKLM through the 64-bit view, as the installer reads it: a 32-bit host would read WOW6432Node.
|
||||
function Get-RegistryValueSafe {
|
||||
param(
|
||||
[string]$Path,
|
||||
[string]$SubKey,
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
try {
|
||||
return (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop).$Name
|
||||
$hive = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
|
||||
try {
|
||||
$key = $hive.OpenSubKey($SubKey)
|
||||
if ($null -eq $key) { return $null }
|
||||
try { return $key.GetValue($Name) } finally { $key.Close() }
|
||||
} finally {
|
||||
$hive.Close()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Test-RegistryKeySafe {
|
||||
param([string]$SubKey)
|
||||
|
||||
try {
|
||||
$hive = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
|
||||
try {
|
||||
$key = $hive.OpenSubKey($SubKey)
|
||||
if ($null -eq $key) { return $false }
|
||||
$key.Close()
|
||||
return $true
|
||||
} finally {
|
||||
$hive.Close()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
@@ -58,7 +85,7 @@ function Test-OobeInProgress {
|
||||
|
||||
# Positive Windows 365 Cloud PC detection: two local markers, BOTH required (AND),
|
||||
# because each alone has look-alikes. Mirrors Test-IsCloudPc in the bootstrapper.
|
||||
$cloudPcRegMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365'
|
||||
$cloudPcRegMarker = Test-RegistryKeySafe -SubKey 'SOFTWARE\Microsoft\Windows365'
|
||||
$cloudPcSvcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
|
||||
$isCloudPc = ($cloudPcRegMarker -and $cloudPcSvcMarker)
|
||||
|
||||
@@ -73,7 +100,7 @@ Write-Host '=== Autopilot Monitor Bootstrap Guard Test (Dry Run) ==='
|
||||
Write-Host ''
|
||||
|
||||
# Guard 1: Agent was already deployed on this device (survives self-destruct)
|
||||
$deployed = Get-RegistryValueSafe -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed'
|
||||
$deployed = Get-RegistryValueSafe -SubKey 'SOFTWARE\AutopilotMonitor' -Name 'Deployed'
|
||||
if ($deployed) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 1: Agent was previously deployed at '$deployed'."
|
||||
$wouldInstall = $false
|
||||
@@ -146,7 +173,7 @@ if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
$lastLoggedOnUser = Get-RegistryValueSafe -SubKey 'SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser'
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
$wouldInstall = $false
|
||||
@@ -183,13 +210,19 @@ catch {
|
||||
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
|
||||
}
|
||||
|
||||
# Guard 5: Agent binary already present
|
||||
# Agent check: an installed agent (its executable or its task; the marker is guard 1) is
|
||||
# left as it is.
|
||||
$agentTask = Get-ScheduledTask -TaskName 'AutopilotMonitor-Agent' -ErrorAction SilentlyContinue
|
||||
if (Test-Path $AgentExePath) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
|
||||
Write-Step -Status 'SKIP' -Message "Agent check: Agent already installed at '$AgentExePath'."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Agent binary already present: $AgentExePath")
|
||||
} elseif ($agentTask) {
|
||||
Write-Step -Status 'SKIP' -Message "Agent check: Agent task 'AutopilotMonitor-Agent' exists."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Agent task present: AutopilotMonitor-Agent")
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 5: Agent binary not present at '$AgentExePath'."
|
||||
Write-Step -Status 'PASS' -Message "Agent check: No agent binary at '$AgentExePath' and no agent task."
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Invoke-BootstrapDownload.ps1 - thin runner embedded in the Bootstrap MSI.
|
||||
#
|
||||
# The MSI is only a delivery vehicle (MDM LOB channel installs earlier than the IME
|
||||
# script/app batches during Autopilot Device Preparation). This runner downloads the
|
||||
# CURRENT Install-AutopilotMonitor.ps1 from the download endpoint and executes it, so
|
||||
# every piece of real bootstrap logic (guards, relax rules, agent download) stays
|
||||
# server-updatable without re-shipping the MSI.
|
||||
#
|
||||
# Fail-soft by design: the runner always exits 0 so a transient network failure never
|
||||
# marks the LOB install as failed; diagnostics go to bootstrap-msi.log.
|
||||
# ASCII only - no special characters in this file.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$baseDir = Join-Path $env:ProgramData 'AutopilotMonitor'
|
||||
$logDir = Join-Path $baseDir 'Logs'
|
||||
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
|
||||
$log = Join-Path $logDir 'bootstrap-msi.log'
|
||||
|
||||
function Write-Log([string]$message) {
|
||||
Add-Content -Path $log -Value ("[{0}] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $message)
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Log "MSI bootstrap runner started (user=$env:USERNAME, pid=$PID)."
|
||||
|
||||
if (Test-Path 'HKLM:\SOFTWARE\AutopilotMonitor\Deployed') {
|
||||
Write-Log 'Deployed marker present - skipping, another channel already installed the agent.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
$url = 'https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1'
|
||||
$dest = Join-Path $baseDir 'Install-AutopilotMonitor.ps1'
|
||||
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $dest
|
||||
break
|
||||
} catch {
|
||||
Write-Log "Download attempt $attempt failed: $_"
|
||||
if ($attempt -eq 3) { throw }
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Bootstrap script downloaded to $dest - executing."
|
||||
# PS 5.1 file redirection (*>>) writes UTF-16LE, which garbles a log otherwise
|
||||
# written via Add-Content (ANSI). Route through Add-Content for one encoding.
|
||||
& $dest *>&1 | ForEach-Object { $_.ToString() } | Add-Content -Path $log
|
||||
Write-Log "Bootstrap script finished (exit code $LASTEXITCODE)."
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Log ("MSI bootstrap runner failed: " + ($_ | Out-String))
|
||||
exit 0
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Stage 1 of the Autopilot Monitor bootstrap: fetches the current installer and runs it.
|
||||
|
||||
.DESCRIPTION
|
||||
This is the file an administrator assigns in Intune, and the same file the bootstrap MSI
|
||||
installs and runs -- one implementation for both delivery channels; the MSI is only the
|
||||
packaging. It carries no enrollment logic at all: it downloads the published
|
||||
Install-AutopilotMonitor.ps1, proves that it was signed by Autopilot Monitor, and runs it
|
||||
in this process.
|
||||
|
||||
Why two stages: the installer holds every guard, relax rule and download path, and those
|
||||
change. Whoever assigns the installer directly has to replace their Intune copy each
|
||||
time; whoever assigns this loader never does, because the part that changes lives on the
|
||||
server. Assigning the installer directly stays supported and unchanged.
|
||||
|
||||
This file is deliberately boring. It is the one piece that cannot be fixed remotely, so
|
||||
it does the least it can: download, verify, invoke, log.
|
||||
|
||||
Verification is by Authenticode publisher, not by hash. A hash oracle would have to be
|
||||
fetched from the same host as the script itself, so it could not survive a compromise of
|
||||
that host, while the expected publisher below travels with this file. It pins the
|
||||
certificate SUBJECT and not a thumbprint, so a certificate renewal does not brick a
|
||||
loader that is already deployed on devices.
|
||||
|
||||
Fail-soft: the loader always exits 0 and never throws into the caller. A device that
|
||||
cannot reach the download endpoint is a device without monitoring, never a failed
|
||||
enrollment step. Verification failures are the same: nothing runs, and the reason is in
|
||||
the log. Refusing to execute unverified code as SYSTEM is the point of this file.
|
||||
|
||||
This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
.PARAMETER BootstrapUrl
|
||||
Installer URL. Defaults to the published stable installer; the -Dev variant of this
|
||||
loader is rendered from that default at publish time.
|
||||
|
||||
.PARAMETER LogFileName
|
||||
Log file inside the agent log directory. The MSI channel passes bootstrap-msi.log so the
|
||||
delivery channel stays visible in a diagnostics package; the diag tooling keys on these
|
||||
names, so do not rename them.
|
||||
|
||||
.PARAMETER BootstrapArguments
|
||||
Everything else is passed through to the installer unchanged, so any parameter the
|
||||
installer accepts can be set here without this file knowing about it.
|
||||
|
||||
.EXAMPLE
|
||||
.\Start-AutopilotMonitor.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\Start-AutopilotMonitor.ps1 -MaxBootstrapWindowHours 24
|
||||
|
||||
.NOTES
|
||||
Author : Oliver Kieselbach (autopilotmonitor.com)
|
||||
Runtime : Windows PowerShell 5.1, SYSTEM context, during Autopilot enrollment
|
||||
|
||||
.CHANGELOG
|
||||
2026-09-08 v1.2 Log the signer CN instead of the full EV subject; the full subject is
|
||||
logged only when the publisher is why a script was rejected.
|
||||
2026-09-08 v1.1 Also used by the bootstrap MSI, which replaced its own copy of this
|
||||
logic; -LogFileName keeps the channel visible in diagnostics.
|
||||
2026-09-08 v1.0 Initial version: two-stage bootstrap, publisher-pinned verification.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[ValidatePattern('^[A-Za-z0-9._-]+$')]
|
||||
[string]$LogFileName = "bootstrap-loader.log",
|
||||
|
||||
[Parameter(Mandatory = $false, ValueFromRemainingArguments = $true)]
|
||||
[string[]]$BootstrapArguments
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "1.2"
|
||||
|
||||
# The publisher every downloaded stage-2 script must carry. A -like pattern on the subject,
|
||||
# NOT a thumbprint: certificates are renewed, deployed loaders are not.
|
||||
$ExpectedPublisher = "*O=glueckkanja AG*"
|
||||
|
||||
$LoaderBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$LoaderLogPath = "$LoaderBasePath\Logs"
|
||||
$LogFile = "$LoaderLogPath\$LogFileName"
|
||||
$BootstrapPath = "$LoaderBasePath\Install-AutopilotMonitor.ps1"
|
||||
|
||||
$DownloadAttempts = 3
|
||||
$RetryDelaySeconds = 10
|
||||
|
||||
# Write-Host as well as the file: the Intune script output is what an administrator sees in
|
||||
# the portal, the file is what a support case reads.
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# True only when the file is signed, the signature validates against the machine's trust
|
||||
# chain, and the signer is us. Any error is a false -- an unreadable signature is an
|
||||
# unverified script.
|
||||
function Test-BootstrapSignature {
|
||||
param([string]$Path)
|
||||
|
||||
try {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $Path -ErrorAction Stop
|
||||
}
|
||||
catch {
|
||||
Write-Log "Signature check failed to run: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($null -eq $sig) {
|
||||
Write-Log "Signature check returned nothing."
|
||||
return $false
|
||||
}
|
||||
|
||||
# The full subject of an EV certificate is a line of street address and registration
|
||||
# numbers. The CN answers the only question the log has to answer; the full subject is
|
||||
# logged when it is the reason for a rejection.
|
||||
$subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { "<unsigned>" }
|
||||
$signer = if ($subject -match 'CN=([^,]+)') { $Matches[1] } else { $subject }
|
||||
Write-Log "Signature status: $($sig.Status). Signer: $signer"
|
||||
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
Write-Log "REJECTED: expected signature status 'Valid'."
|
||||
return $false
|
||||
}
|
||||
if ($subject -notlike $ExpectedPublisher) {
|
||||
Write-Log "REJECTED: signer does not match the expected publisher. Full subject: $subject"
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Log "Signature accepted (thumbprint $($sig.SignerCertificate.Thumbprint))."
|
||||
return $true
|
||||
}
|
||||
|
||||
# Downloads the installer and returns $true once a downloaded copy passes verification.
|
||||
# Verification lives inside the retry loop on purpose: a truncated or proxy-mangled body
|
||||
# fails the signature check, and that is worth another attempt.
|
||||
function Get-VerifiedBootstrapScript {
|
||||
param([string]$Url, [string]$Destination)
|
||||
|
||||
for ($attempt = 1; $attempt -le $DownloadAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Download attempt $attempt/$DownloadAttempts from $Url"
|
||||
Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
|
||||
|
||||
if (Test-BootstrapSignature -Path $Destination) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Log "Download attempt $attempt failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if ($attempt -lt $DownloadAttempts) {
|
||||
Start-Sleep -Seconds $RetryDelaySeconds
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
# Main flow. Only invoked when the script is run directly (see entry guard at the bottom),
|
||||
# so the Pester suite can dot-source this file without bootstrapping anything.
|
||||
function Start-BootstrapLoader {
|
||||
New-Item -ItemType Directory -Force -Path $LoaderLogPath -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
Write-Log "=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ==="
|
||||
|
||||
# PS 5.1 on an OOBE device still defaults to TLS 1.0 for this process.
|
||||
try {
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
}
|
||||
catch {
|
||||
Write-Log "INFO: could not raise the TLS protocol: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
if (-not (Get-VerifiedBootstrapScript -Url $BootstrapUrl -Destination $BootstrapPath)) {
|
||||
Write-Log "No verified installer available after $DownloadAttempts attempt(s). Nothing was executed."
|
||||
return
|
||||
}
|
||||
|
||||
Write-Log "Executing $BootstrapPath"
|
||||
|
||||
# Same process, so the execution policy this loader runs under applies to stage 2 as
|
||||
# well and no second policy decision can strand the installer. Splatting an array of
|
||||
# remaining arguments keeps this file free of any knowledge about the installer's
|
||||
# parameters.
|
||||
$arguments = @()
|
||||
if ($BootstrapArguments) { $arguments = $BootstrapArguments }
|
||||
|
||||
try {
|
||||
& $BootstrapPath @arguments
|
||||
Write-Log "Installer finished (exit code $LASTEXITCODE)."
|
||||
}
|
||||
catch {
|
||||
Write-Log "Installer threw: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# Entry guard: dot-sourcing (Pester) loads the functions without running the loader.
|
||||
if ($MyInvocation.InvocationName -ne '.') {
|
||||
try {
|
||||
Start-BootstrapLoader
|
||||
}
|
||||
catch {
|
||||
# Nothing above should throw; if it does, it must not become a failed Intune script.
|
||||
Write-Log "Loader failed: $($_.Exception.Message)"
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
This script removes all traces of the Autopilot Monitor agent:
|
||||
- Stops and removes the Scheduled Task
|
||||
- Deletes C:\ProgramData\AutopilotMonitor folder
|
||||
- Deletes C:\ProgramData\AutopilotMonitor-Loader-* folders that a failed loader run kept
|
||||
|
||||
Normally the agent self-destructs after enrollment completion.
|
||||
Use this script only for manual cleanup or troubleshooting.
|
||||
@@ -16,7 +17,8 @@
|
||||
.NOTES
|
||||
- Requires Administrator privileges
|
||||
- Safe to run multiple times
|
||||
- No Registry cleanup needed (agent doesn't use Registry)
|
||||
- Keeps HKLM\SOFTWARE\AutopilotMonitor\Deployed: the bootstrap reads it and never
|
||||
installs the agent on this device again
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
@@ -67,6 +69,17 @@ catch {
|
||||
Write-Host " [INFO] You may need to manually delete: $AgentBasePath" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Delete loader run folders kept by failed runs
|
||||
foreach ($runFolder in @(Get-ChildItem -Path $env:ProgramData -Directory -Filter 'AutopilotMonitor-Loader-*' -ErrorAction SilentlyContinue)) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $runFolder.FullName -Recurse -Force -ErrorAction Stop
|
||||
Write-Host " [OK] Loader run folder deleted: $($runFolder.FullName)" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Host " [FAIL] Error deleting $($runFolder.FullName): $($_.Exception.Message)" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "===== Cleanup Completed =====" -ForegroundColor Cyan
|
||||
Write-Host "Autopilot Monitor agent has been removed from this device." -ForegroundColor Green
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Bootstrap
|
||||
{
|
||||
/// <summary>
|
||||
/// What the bootstrap scripts and the agent must agree on: the names by which the installer
|
||||
/// recognizes an installed agent, the folders and log files, and the log lines the agent reads
|
||||
/// back as its provenance. Every script value is read from the script's own text, so a rename
|
||||
/// on either side fails here. The bootstrap script gates run this class on every change to
|
||||
/// the scripts.
|
||||
/// </summary>
|
||||
[Trait("Category", "BootstrapContract")]
|
||||
public sealed class BootstrapContractTests : IDisposable
|
||||
{
|
||||
private static readonly string BootstrapDir = Path.Combine(RepoPaths.Root(), "scripts", "Bootstrap");
|
||||
private static readonly string Installer = File.ReadAllText(Path.Combine(BootstrapDir, "Install-AutopilotMonitor.ps1"));
|
||||
private static readonly string Loader = File.ReadAllText(Path.Combine(BootstrapDir, "Start-AutopilotMonitor.ps1"));
|
||||
private static readonly string OobeScript = File.ReadAllText(Path.Combine(
|
||||
RepoPaths.Root(), "src", "Backend", "AutopilotMonitor.Functions.Tests", "TestData", "oobe-bootstrap-golden.ps1"));
|
||||
private static readonly string Wxs = File.ReadAllText(Path.Combine(
|
||||
RepoPaths.Root(), "src", "Agent", "AutopilotMonitor.BootstrapMsi", "Package.wxs"));
|
||||
|
||||
private readonly string _logs = Path.Combine(Path.GetTempPath(), "apm-bootstrap-contract-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public BootstrapContractTests() => Directory.CreateDirectory(_logs);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_logs, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
/// <summary>The value of the one top-level <c>$Name = "value"</c> line of a script.</summary>
|
||||
private static string Assignment(string script, string name)
|
||||
{
|
||||
var matches = Regex.Matches(script, @"(?m)^\$" + name + @"[ \t]*=[ \t]*""([^""]*)""[ \t]*\r?$");
|
||||
Assert.True(matches.Count == 1, $"expected exactly one top-level ${name} line, found {matches.Count}");
|
||||
return matches[0].Groups[1].Value;
|
||||
}
|
||||
|
||||
private static string ScriptPath(string agentPath) => agentPath.Replace("%ProgramData%", "$env:ProgramData");
|
||||
|
||||
private static string AgentSubdirectory() =>
|
||||
(string)typeof(AutopilotMonitor.Agent.V2.Program)
|
||||
.GetField("DefaultAgentSubdirectory", BindingFlags.NonPublic | BindingFlags.Static)
|
||||
.GetRawConstantValue();
|
||||
|
||||
private void WriteLog(string name, string line) =>
|
||||
File.WriteAllText(Path.Combine(_logs, name), line + "\r\n", Encoding.ASCII);
|
||||
|
||||
[Fact]
|
||||
public void The_installer_recognizes_an_agent_by_the_names_the_agent_uses()
|
||||
{
|
||||
Assert.Equal(DeploymentMarker.RegistryKeyPath, Assignment(Installer, "DeploymentMarkerKey"));
|
||||
Assert.Equal(DeploymentMarker.RegistryValueName, Assignment(Installer, "DeploymentMarkerValue"));
|
||||
Assert.Equal(Constants.ScheduledTaskName, Assignment(Installer, "AgentTaskName"));
|
||||
Assert.Equal(typeof(AutopilotMonitor.Agent.V2.Program).Assembly.GetName().Name + ".exe", Assignment(Installer, "AgentExeName"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_installer_works_in_the_folders_the_agent_uses()
|
||||
{
|
||||
var basePath = Assignment(Installer, "AgentBasePath");
|
||||
|
||||
Assert.Equal(ScriptPath(Constants.AgentDataDirectory), basePath);
|
||||
Assert.Equal(ScriptPath(Constants.LogDirectory), Assignment(Installer, "AgentLogPath").Replace("$AgentBasePath", basePath));
|
||||
Assert.Equal(basePath + "\\" + AgentSubdirectory(), Assignment(Installer, "AgentBinPath").Replace("$AgentBasePath", basePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_bootstrap_logs_under_the_name_the_agent_reads()
|
||||
{
|
||||
Assert.Equal("$AgentLogPath\\" + BootstrapProvenanceReader.InstallerLogName, Assignment(Installer, "LogFile"));
|
||||
Assert.Matches(@"\[string\]\$LogFileName = """ + Regex.Escape(BootstrapProvenanceReader.LoaderLogName) + @"""", Loader);
|
||||
Assert.Contains("$LogFile = \"$AgentLogPath\\" + BootstrapProvenanceReader.OobeLogName + "\"", OobeScript);
|
||||
|
||||
Assert.Contains("-LogFileName " + BootstrapProvenanceReader.MsiLoaderLogName, Wxs);
|
||||
}
|
||||
|
||||
// The bootstrap MSI installs its loader copy where the installer removes it, starts
|
||||
// PowerShell in System32, and runs only bytes it has checked against the publisher pin
|
||||
// the loader applies to the installer. Windows Installer formats the command, so every
|
||||
// bracket and brace is escaped and the only property references are the two paths.
|
||||
[Fact]
|
||||
public void The_msi_puts_the_loader_copy_where_the_installer_removes_it_and_runs_only_what_it_checked()
|
||||
{
|
||||
XNamespace ns = "http://wixtoolset.org/schemas/v4/wxs";
|
||||
var package = XDocument.Parse(Wxs).Root.Element(ns + "Package");
|
||||
Assert.Equal("1fbe5515-d346-445c-b8b8-14ee79f97522", (string)package.Attribute("UpgradeCode"));
|
||||
|
||||
var root = package.Elements(ns + "StandardDirectory").Single();
|
||||
Assert.Equal("CommonAppDataFolder", (string)root.Attribute("Id"));
|
||||
var chain = root.Descendants(ns + "Directory").Select(d => (string)d.Attribute("Name")).ToArray();
|
||||
Assert.Equal("INSTALLFOLDER", (string)root.Descendants(ns + "Directory").Last().Attribute("Id"));
|
||||
var transport = Assignment(Installer, "MsiTransportPath").Replace("$AgentBasePath", Assignment(Installer, "AgentBasePath"));
|
||||
Assert.Equal("$env:ProgramData\\" + string.Join("\\", chain), transport);
|
||||
|
||||
var action = package.Elements(ns + "CustomAction").Single(a => (string)a.Attribute("Id") == "RunBootstrap");
|
||||
Assert.Equal("System64Folder", (string)action.Attribute("Directory"));
|
||||
Assert.Equal("deferred", (string)action.Attribute("Execute"));
|
||||
Assert.Equal("no", (string)action.Attribute("Impersonate"));
|
||||
Assert.Equal("ignore", (string)action.Attribute("Return"));
|
||||
|
||||
var exeCommand = (string)action.Attribute("ExeCommand");
|
||||
var command = exeCommand.Substring(exeCommand.IndexOf(" -Command ", StringComparison.Ordinal));
|
||||
Assert.Contains("-NonInteractive", exeCommand);
|
||||
Assert.Contains("[\\[]System.IO.File[\\]]::ReadAllBytes('[INSTALLFOLDER]Start-AutopilotMonitor.ps1')", command);
|
||||
Assert.Contains("Get-AuthenticodeSignature -Content $b -SourcePathOrExtension '.ps1'", command);
|
||||
Assert.Contains("$h -eq '$(var.LoaderSha256)' -and $s.Status -eq 'Valid' -and $s.SignerCertificate.Subject -like '" + Assignment(Loader, "ExpectedPublisher") + "'", command);
|
||||
// The hash is baked in by the build from the loader it embeds; a package built without it must not exist.
|
||||
Assert.Contains("<?ifndef var.LoaderSha256 ?>", Wxs);
|
||||
var workflow = File.ReadAllText(Path.Combine(RepoPaths.Root(), ".github", "workflows", "build-msi.yml"));
|
||||
Assert.Contains("wix build Package.wxs -arch x64 -d LoaderSha256=$loaderSha256", workflow);
|
||||
Assert.Contains("Get-FileHash -LiteralPath $env:RUNNER_SCRIPT -Algorithm SHA256", workflow);
|
||||
Assert.Contains("[\\[]scriptblock[\\]]::Create([\\[]System.Text.Encoding[\\]]::UTF8.GetString($b))", command);
|
||||
Assert.DoesNotContain("-File", command);
|
||||
// Without the [\x] escapes, the only bracket left is the one property reference.
|
||||
var unescaped = Regex.Replace(command, @"\[\\.\]", "");
|
||||
Assert.DoesNotMatch(@"[{}\[\]]", unescaped.Replace("[INSTALLFOLDER]", ""));
|
||||
Assert.Equal(new[] { "[INSTALLFOLDER]" }, Regex.Matches(command, @"\[[A-Za-z0-9_.]+\]").Cast<Match>().Select(m => m.Value).Distinct().ToArray());
|
||||
|
||||
var custom = package.Element(ns + "InstallExecuteSequence").Elements(ns + "Custom").Single();
|
||||
Assert.Equal("RunBootstrap", (string)custom.Attribute("Action"));
|
||||
Assert.Equal("NOT Installed", (string)custom.Attribute("Condition"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_agent_reads_the_versions_the_current_scripts_write()
|
||||
{
|
||||
var installerVersion = Assignment(Installer, "ScriptVersion");
|
||||
var loaderVersion = Assignment(Loader, "ScriptVersion");
|
||||
Assert.Contains("Write-Log \"Bootstrap script version: v$ScriptVersion\"", Installer);
|
||||
Assert.Contains("Write-Log \"=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ===\"", Loader);
|
||||
|
||||
WriteLog(BootstrapProvenanceReader.InstallerLogName, $"[2026-10-22 10:00:01] Bootstrap script version: v{installerVersion}");
|
||||
WriteLog(BootstrapProvenanceReader.LoaderLogName, $"[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v{loaderVersion} started (user=SYSTEM, pid=4242) ===");
|
||||
|
||||
var provenance = BootstrapProvenanceReader.Read(_logs);
|
||||
|
||||
Assert.Equal(installerVersion, provenance.ScriptVersion);
|
||||
Assert.Equal("loader", provenance.LoaderLog);
|
||||
Assert.Equal(loaderVersion, provenance.LoaderVersion);
|
||||
}
|
||||
|
||||
// The installer carries only the start line over from loaders before 1.3, rebuilt from a
|
||||
// pattern; the agent has to read that line as the loader's version.
|
||||
[Fact]
|
||||
public void The_agent_reads_the_start_line_the_installer_carries_over_from_an_older_loader()
|
||||
{
|
||||
const string suffix = " (carried over by the bootstrapper) ===`r`n";
|
||||
Assert.Contains("$starts[$starts.Count - 1].Value + \"" + suffix + "\"", Installer);
|
||||
|
||||
WriteLog(BootstrapProvenanceReader.MsiLoaderLogName, "[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v1.2 started (carried over by the bootstrapper) ===");
|
||||
|
||||
var provenance = BootstrapProvenanceReader.Read(_logs);
|
||||
|
||||
Assert.Equal("msi", provenance.LoaderLog);
|
||||
Assert.Equal("1.2", provenance.LoaderVersion);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Security.Principal;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
{
|
||||
/// <summary>
|
||||
/// A fact that needs an elevated process, such as making the Administrators group the owner
|
||||
/// of a folder. Skipped in a non-elevated local run; the CI runners are elevated.
|
||||
/// </summary>
|
||||
public sealed class ElevatedFactAttribute : FactAttribute
|
||||
{
|
||||
public ElevatedFactAttribute()
|
||||
{
|
||||
using (var identity = WindowsIdentity.GetCurrent())
|
||||
{
|
||||
if (!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator))
|
||||
Skip = "needs an elevated process (assigning Administrators as the folder owner)";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>The V1 confidence score of <see cref="ConfigMgrClientSnapshot"/>.</summary>
|
||||
public sealed class ConfigMgrClientScoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void Nothing_found_scores_zero_and_is_not_detected()
|
||||
{
|
||||
Assert.Equal(0, ConfigMgrClientSnapshot.Empty.ConfidenceScore);
|
||||
Assert.False(ConfigMgrClientSnapshot.Empty.Detected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_registered_running_client_scores_100()
|
||||
{
|
||||
var snapshot = new ConfigMgrClientSnapshot(true, true, "Running", "5.00.9999.1000", "ABC");
|
||||
Assert.Equal(100, snapshot.ConfidenceScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_folder_alone_is_a_weak_remnant_below_the_portal_threshold()
|
||||
{
|
||||
var snapshot = new ConfigMgrClientSnapshot(false, true, ConfigMgrClientProbe.ServiceNotFound, null, null);
|
||||
Assert.True(snapshot.Detected);
|
||||
Assert.Equal(10, snapshot.ConfidenceScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stopped_service_counts_without_the_running_bonus()
|
||||
{
|
||||
var snapshot = new ConfigMgrClientSnapshot(true, true, "Stopped", "5.00.9999.1000", "ABC");
|
||||
Assert.Equal(80, snapshot.ConfidenceScore);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emission surface of <see cref="ConfigMgrClientDetector"/> via the real
|
||||
/// <see cref="InformationalEventPost"/>. Static probe override → serial collection.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")]
|
||||
public sealed class ConfigMgrClientDetectorEmissionTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 10, 23, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private (ConfigMgrClientDetector sut, FakeSignalIngressSink sink) BuildDetector(StartupEventGate? gate = null)
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
var sink = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(At);
|
||||
var sut = new ConfigMgrClientDetector("S1", "T1", new InformationalEventPost(sink, clock), logger, clock, gate);
|
||||
return (sut, sink);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> Detected(FakeSignalIngressSink sink) =>
|
||||
sink.Posted.Where(p =>
|
||||
p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == "configmgr_client_detected").ToList();
|
||||
|
||||
private static ConfigMgrClientSnapshot Client(string serviceState, string? siteCode = "ABC") =>
|
||||
new ConfigMgrClientSnapshot(true, true, serviceState, "5.00.9999.1000", siteCode);
|
||||
|
||||
[Fact]
|
||||
public void No_client_never_emits()
|
||||
{
|
||||
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => ConfigMgrClientSnapshot.Empty);
|
||||
var (sut, sink) = BuildDetector();
|
||||
|
||||
sut.Check();
|
||||
sut.Check();
|
||||
|
||||
Assert.Empty(Detected(sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First_detection_emits_the_v1_payload()
|
||||
{
|
||||
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => Client("Running"));
|
||||
var (sut, sink) = BuildDetector();
|
||||
|
||||
sut.Check();
|
||||
|
||||
var evt = Assert.Single(Detected(sink));
|
||||
Assert.Equal(DecisionSignalKind.InformationalEvent, evt.Kind);
|
||||
Assert.Equal("Info", evt.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal(ConfigMgrClientDetector.SourceLabel, evt.Payload[SignalPayloadKeys.Source]);
|
||||
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
|
||||
Assert.Equal(true, data["ccmRegistryFound"]);
|
||||
Assert.Equal(true, data["ccmDirectoryFound"]);
|
||||
Assert.Equal("Running", data["ccmServiceState"]);
|
||||
Assert.Equal("5.00.9999.1000", data["ccmVersion"]);
|
||||
Assert.Equal("ABC", data["siteCode"]);
|
||||
Assert.Equal(100, data["confidenceScore"]);
|
||||
Assert.Equal("startup", data["detectedAt"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unchanged_state_emits_once_a_changed_state_again()
|
||||
{
|
||||
var current = Client("Stopped", siteCode: null);
|
||||
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => current);
|
||||
var (sut, sink) = BuildDetector();
|
||||
|
||||
sut.Check();
|
||||
sut.Check();
|
||||
Assert.Single(Detected(sink));
|
||||
|
||||
current = Client("Running");
|
||||
sut.Check();
|
||||
|
||||
var events = Detected(sink);
|
||||
Assert.Equal(2, events.Count);
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(events[0].TypedPayload);
|
||||
Assert.Equal("unknown", data["siteCode"]); // missing values keep the V1 placeholder
|
||||
var latest = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(events[1].TypedPayload);
|
||||
Assert.Equal("Running", latest["ccmServiceState"]);
|
||||
Assert.Equal("poll", latest["detectedAt"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_restart_into_the_same_state_stays_silent()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
var current = Client("Running");
|
||||
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => current);
|
||||
|
||||
var (run1, sink1) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
|
||||
run1.Check();
|
||||
Assert.Single(Detected(sink1));
|
||||
|
||||
var (run2, sink2) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
|
||||
run2.Check();
|
||||
Assert.Empty(Detected(sink2));
|
||||
|
||||
current = Client("Stopped");
|
||||
run2.Check();
|
||||
Assert.Single(Detected(sink2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Emissions_are_capped_per_run()
|
||||
{
|
||||
var running = true;
|
||||
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => Client(running ? "Running" : "Stopped"));
|
||||
var (sut, sink) = BuildDetector();
|
||||
|
||||
for (var i = 0; i < ConfigMgrClientDetector.MaxEmissionsPerRun * 2; i++)
|
||||
{
|
||||
sut.Check();
|
||||
running = !running;
|
||||
}
|
||||
|
||||
Assert.Equal(ConfigMgrClientDetector.MaxEmissionsPerRun, Detected(sink).Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
-2
@@ -185,6 +185,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.False(EspPolicyProviderSnapshot.Empty.SidecarRegistered);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- phase gate for DevicePreparation providers
|
||||
|
||||
[Theory]
|
||||
[InlineData("device_preparation", true)]
|
||||
[InlineData("device_preparation_failed", true)] // a failed device preparation keeps its culprit
|
||||
[InlineData("none", true)] // unknown phase → keep observing
|
||||
[InlineData("unavailable", true)]
|
||||
[InlineData("", true)]
|
||||
[InlineData("device_preparation_done", false)]
|
||||
[InlineData("device_setup", false)]
|
||||
[InlineData("device_setup_failed", false)]
|
||||
[InlineData("account_setup", false)]
|
||||
[InlineData("esp_done", false)]
|
||||
public void Device_preparation_counts_until_it_is_done(string phase, bool expected)
|
||||
=> Assert.Equal(expected, EspPhaseSnapshot.DevicePreparationOpen(phase));
|
||||
|
||||
[Fact]
|
||||
public void DevicePreparation_provider_past_its_phase_drops_out_while_setupApps_still_counts()
|
||||
{
|
||||
// Field shape (client installed as a Win32 app): the client registers its
|
||||
// DevicePreparation provider after its own install with InstallationState=1, long
|
||||
// after device preparation ended. Nothing ever completes it.
|
||||
var snapshot = Snapshot(
|
||||
DevicePrep("ConfigMgr", installState: 1),
|
||||
SetupApps("Contoso", tracking: 0));
|
||||
var seen = new Dictionary<string, DateTime>
|
||||
{
|
||||
["devicePreparation|device|ConfigMgr"] = T0,
|
||||
["setupApps|device|Contoso"] = T0,
|
||||
};
|
||||
|
||||
var eval = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
snapshot, seen, new HashSet<string>(), null, false, T0.AddMinutes(30), Dwell,
|
||||
devicePreparationOpen: false);
|
||||
|
||||
Assert.False(eval.UpdatedFirstSeenUtc.ContainsKey("devicePreparation|device|ConfigMgr")); // reset
|
||||
var stalled = Assert.Single(eval.NewlyStalled);
|
||||
Assert.Equal("Contoso", stalled.Provider.Name);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- arm 2: sidecar-missing transition
|
||||
|
||||
[Fact]
|
||||
@@ -265,17 +305,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
// The phase defaults to "none" so the tests never read the test machine's own ESP state.
|
||||
private (EspPolicyProviderStallDetector sut, FakeSignalIngressSink sink, VirtualClock clock)
|
||||
BuildDetector(StartupEventGate? gate = null)
|
||||
BuildDetector(StartupEventGate? gate = null, Func<string>? phase = null)
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
var sink = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(At);
|
||||
var post = new InformationalEventPost(sink, clock);
|
||||
var sut = new EspPolicyProviderStallDetector("S1", "T1", post, logger, clock, gate);
|
||||
var sut = new EspPolicyProviderStallDetector("S1", "T1", post, logger, clock, gate,
|
||||
espPhaseReader: phase ?? (() => EspPhaseSnapshot.None));
|
||||
return (sut, sink, clock);
|
||||
}
|
||||
|
||||
private static readonly TimeSpan DwellSpan = TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes);
|
||||
|
||||
private static PolicyProviderState SidecarDone() =>
|
||||
new PolicyProviderState("Sidecar", "device", EspPolicyProviderProbe.KindSetupApps, 1, null);
|
||||
|
||||
private static PolicyProviderState ConfigMgrDevicePrep(int? installationState) =>
|
||||
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindDevicePreparation, null, installationState);
|
||||
|
||||
private static EspPolicyProviderSnapshot ConfigMgrOnly() => new EspPolicyProviderSnapshot(new[]
|
||||
{
|
||||
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps,
|
||||
@@ -435,6 +485,104 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provider_registered_after_device_preparation_never_emits()
|
||||
{
|
||||
// The ConfigMgr client as a Win32 app: its DevicePreparation provider shows up during
|
||||
// device setup with InstallationState=1 and stays there.
|
||||
var snapshot = new EspPolicyProviderSnapshot(new[] { SidecarDone(), ConfigMgrDevicePrep(1) });
|
||||
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => snapshot);
|
||||
var (sut, sink, clock) = BuildDetector(phase: () => "device_setup");
|
||||
|
||||
sut.Tick();
|
||||
clock.Advance(TimeSpan.FromHours(2));
|
||||
sut.Tick();
|
||||
|
||||
Assert.Empty(StalledEvents(sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_real_device_preparation_hang_still_emits()
|
||||
{
|
||||
// Field shape: only ConfigMgr providers registered, the ESP never left device
|
||||
// preparation and failed with 0x800705b4.
|
||||
var snapshot = new EspPolicyProviderSnapshot(new[]
|
||||
{
|
||||
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps, null, null),
|
||||
ConfigMgrDevicePrep(1),
|
||||
});
|
||||
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => snapshot);
|
||||
var (sut, sink, clock) = BuildDetector(phase: () => EspPhaseSnapshot.DevicePreparation);
|
||||
|
||||
sut.Tick();
|
||||
clock.Advance(DwellSpan);
|
||||
sut.Tick();
|
||||
|
||||
var evt = Assert.Single(StalledEvents(sink));
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
|
||||
var stalled = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object>>>(data["stalledProviders"]);
|
||||
Assert.Contains(stalled, s => (string)s["kind"] == EspPolicyProviderProbe.KindDevicePreparation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_preparation_stall_message_names_the_provider_timeout()
|
||||
{
|
||||
var snapshot = new EspPolicyProviderSnapshot(new[] { SidecarDone(), ConfigMgrDevicePrep(1) });
|
||||
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => snapshot);
|
||||
var (sut, sink, clock) = BuildDetector(phase: () => EspPhaseSnapshot.DevicePreparation);
|
||||
|
||||
sut.Tick();
|
||||
clock.Advance(DwellSpan);
|
||||
sut.Tick();
|
||||
|
||||
var message = Assert.Single(StalledEvents(sink)).Payload![SignalPayloadKeys.Message];
|
||||
Assert.Contains("until their timeout", message);
|
||||
Assert.DoesNotContain("without any timeout", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_skips_device_preparation_providers_past_their_phase()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
var phase = EspPhaseSnapshot.DevicePreparation;
|
||||
var devicePrepOnly = new EspPolicyProviderSnapshot(new[] { SidecarDone(), ConfigMgrDevicePrep(1) });
|
||||
var withContoso = new EspPolicyProviderSnapshot(new[]
|
||||
{
|
||||
SidecarDone(),
|
||||
ConfigMgrDevicePrep(1),
|
||||
new PolicyProviderState("Contoso", "device", EspPolicyProviderProbe.KindSetupApps, 0, null),
|
||||
});
|
||||
var current = devicePrepOnly;
|
||||
|
||||
// Run 1: the DevicePreparation provider stalls during device preparation; later, in
|
||||
// device setup, a Setup/Apps provider stalls as well.
|
||||
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => current))
|
||||
{
|
||||
var (sut, sink, clock) = BuildDetector(new StartupEventGate(_tmp.Path, logger), () => phase);
|
||||
sut.Tick();
|
||||
clock.Advance(DwellSpan);
|
||||
sut.Tick();
|
||||
|
||||
phase = "device_setup";
|
||||
current = withContoso;
|
||||
sut.Tick();
|
||||
clock.Advance(DwellSpan);
|
||||
sut.Tick();
|
||||
Assert.Equal(2, StalledEvents(sink).Count);
|
||||
}
|
||||
|
||||
// Run 2 (restart in device setup): the same Contoso stall lands on the same
|
||||
// fingerprint only if the stale DevicePreparation provider stayed out of it.
|
||||
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => withContoso))
|
||||
{
|
||||
var (sut, sink, clock) = BuildDetector(new StartupEventGate(_tmp.Path, logger), () => "device_setup");
|
||||
sut.Tick();
|
||||
clock.Advance(DwellSpan);
|
||||
sut.Tick();
|
||||
Assert.Empty(StalledEvents(sink));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Healthy_or_absent_tracking_never_emits()
|
||||
{
|
||||
|
||||
+22
-1
@@ -40,7 +40,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
/// enabled by default) and system-boundary fakes only: temp state dir, fake ingress,
|
||||
/// virtual clock, no network metrics, no telemetry spool.
|
||||
/// </summary>
|
||||
private CollectorSurfaces CreateSurfaces()
|
||||
private CollectorSurfaces CreateSurfaces(AnalyzerConfiguration? analyzers = null)
|
||||
{
|
||||
var factory = new DefaultComponentFactory(
|
||||
agentConfig: new AgentConfiguration
|
||||
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
remoteConfig: new AgentConfigResponse
|
||||
{
|
||||
Collectors = CollectorConfiguration.CreateDefault(),
|
||||
Analyzers = analyzers ?? new AnalyzerConfiguration(),
|
||||
},
|
||||
networkMetrics: null,
|
||||
agentVersion: "test",
|
||||
@@ -142,6 +143,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, 0)]
|
||||
[InlineData(true, 1)]
|
||||
public void ConfigMgr_client_host_exists_only_when_the_tenant_enables_the_watcher(bool enabled, int expected)
|
||||
{
|
||||
// Opt-in (D-382): with the watcher off the check must not run at all, not merely stay quiet.
|
||||
var hosts = CreateSurfaces(new AnalyzerConfiguration { EnableConfigMgrWatcher = enabled }).Hosts;
|
||||
try
|
||||
{
|
||||
Assert.Equal(expected, hosts.Count(h => h is ConfigMgrClientHost));
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
try { host.Dispose(); } catch { /* construction-only test — best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ime_app_state_callback_is_chained_into_the_user_apps_settled_recheck()
|
||||
{
|
||||
|
||||
@@ -4,14 +4,16 @@ using System.Linq;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// The agent data directory keeps standard users from adding files where the SYSTEM agent
|
||||
/// starts from. Works on a temp folder the test owns; as owner it can always hand itself the
|
||||
/// rights back to clean up.
|
||||
/// The agent folders keep standard users from adding files where the SYSTEM agent starts from.
|
||||
/// Works on a temp folder the test owns; as owner, or as an elevated administrator, it can
|
||||
/// always hand itself the rights back to clean up. Tests that assign the Administrators group
|
||||
/// as owner need an elevated process and skip without one.
|
||||
/// </summary>
|
||||
public sealed class AgentDirectoryAclTests : IDisposable
|
||||
{
|
||||
@@ -21,6 +23,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
| FileSystemRights.ChangePermissions | FileSystemRights.TakeOwnership;
|
||||
|
||||
private static readonly SecurityIdentifier Users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
|
||||
private static readonly SecurityIdentifier Administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
|
||||
|
||||
private readonly string _root = Path.Combine(Path.GetTempPath(), "apm-acl-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
@@ -51,8 +54,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
.Where(r => r.AccessControlType == AccessControlType.Allow && Users.Equals(r.IdentityReference))
|
||||
.Aggregate((FileSystemRights)0, (all, r) => all | r.FileSystemRights);
|
||||
|
||||
private static SecurityIdentifier OwnerOf(string path) =>
|
||||
(SecurityIdentifier)new DirectoryInfo(path).GetAccessControl(AccessControlSections.Owner).GetOwner(typeof(SecurityIdentifier));
|
||||
|
||||
[Fact]
|
||||
public void The_protected_security_inherits_nothing_and_gives_users_read_only()
|
||||
public void The_protected_security_inherits_nothing_gives_users_read_only_and_names_administrators_as_owner()
|
||||
{
|
||||
var security = AgentDirectoryAcl.BuildProtectedSecurity();
|
||||
|
||||
@@ -62,9 +68,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
// An allow rule always carries Synchronize; .NET adds it on construction.
|
||||
Assert.Equal(FileSystemRights.ReadAndExecute, users.FileSystemRights & ~FileSystemRights.Synchronize);
|
||||
Assert.Equal((FileSystemRights)0, users.FileSystemRights & WriteRights);
|
||||
Assert.Equal(Administrators, security.GetOwner(typeof(SecurityIdentifier)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[ElevatedFact]
|
||||
public void A_folder_is_protected_once_and_then_left_alone()
|
||||
{
|
||||
var first = AgentDirectoryAcl.EnsureProtected(_root);
|
||||
@@ -72,11 +79,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
|
||||
Assert.Equal(AgentDirectoryAclOutcome.Applied, first.Outcome);
|
||||
Assert.Equal(AgentDirectoryAclOutcome.AlreadyProtected, second.Outcome);
|
||||
Assert.True(AgentDirectoryAcl.IsProtectedAsExpected(new DirectoryInfo(_root).GetAccessControl(AccessControlSections.Access)));
|
||||
Assert.True(AgentDirectoryAcl.IsProtectedAsExpected(new DirectoryInfo(_root).GetAccessControl(AccessControlSections.Access | AccessControlSections.Owner)));
|
||||
Assert.Equal((FileSystemRights)0, UsersRights(_root) & WriteRights);
|
||||
Assert.Equal(Administrators, OwnerOf(_root));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
// The owner can always hand itself the rights back, so a folder another account created
|
||||
// first is theirs until the owner changes, whatever its DACL says.
|
||||
[ElevatedFact]
|
||||
public void A_folder_owned_by_another_account_gets_administrators_as_owner()
|
||||
{
|
||||
var directory = new DirectoryInfo(_root);
|
||||
var mine = directory.GetAccessControl(AccessControlSections.Owner);
|
||||
mine.SetOwner(WindowsIdentity.GetCurrent().User!);
|
||||
directory.SetAccessControl(mine);
|
||||
Assert.NotEqual(Administrators, OwnerOf(_root));
|
||||
|
||||
var result = AgentDirectoryAcl.EnsureProtected(_root);
|
||||
|
||||
Assert.Equal(AgentDirectoryAclOutcome.Applied, result.Outcome);
|
||||
Assert.Equal(Administrators, OwnerOf(_root));
|
||||
}
|
||||
|
||||
[ElevatedFact]
|
||||
public void Existing_subfolders_take_over_the_protection()
|
||||
{
|
||||
// Install mode protects the base after Agent, Spool and Logs already exist.
|
||||
@@ -101,6 +126,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.False(AgentDirectoryAcl.IsProtectedAsExpected(widened));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_dacl_under_another_owner_is_not_taken_as_protected()
|
||||
{
|
||||
var mine = AgentDirectoryAcl.BuildProtectedSecurity();
|
||||
mine.SetOwner(WindowsIdentity.GetCurrent().User!);
|
||||
|
||||
Assert.False(AgentDirectoryAcl.IsProtectedAsExpected(mine));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_junction_is_refused_and_its_target_left_alone()
|
||||
{
|
||||
var target = Directory.CreateDirectory(Path.Combine(_root, "target")).FullName;
|
||||
var link = Path.Combine(_root, "link");
|
||||
NtfsLinks.CreateJunction(link, target);
|
||||
try
|
||||
{
|
||||
var result = AgentDirectoryAcl.EnsureProtected(link);
|
||||
|
||||
Assert.Equal(AgentDirectoryAclOutcome.Failed, result.Outcome);
|
||||
Assert.Contains("junction", result.Error);
|
||||
Assert.False(new DirectoryInfo(target).GetAccessControl().AreAccessRulesProtected);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NtfsLinks.RemoveLink(link);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_missing_folder_is_reported_not_created()
|
||||
{
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// The V1 ConfigMgr client check, run on every poll: emits <c>configmgr_client_detected</c>
|
||||
/// with the V1 payload when the client is first seen, and again when what it reports changes
|
||||
/// (the service starts, a site code is assigned) — the client often installs after the agent
|
||||
/// started. A <see cref="StartupEventGate"/> fingerprint keeps a restart into the same state
|
||||
/// silent. Observational only: emission goes through <see cref="InformationalEventPost"/>.
|
||||
/// </summary>
|
||||
internal sealed class ConfigMgrClientDetector
|
||||
{
|
||||
internal const string SourceLabel = "ConfigMgrClientDetector";
|
||||
|
||||
/// <summary>Emissions per agent run; a flapping service must not flood the timeline.</summary>
|
||||
internal const int MaxEmissionsPerRun = 10;
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly IClock _clock;
|
||||
private readonly StartupEventGate? _startupGate;
|
||||
|
||||
private string? _lastFingerprint;
|
||||
private int _emissions;
|
||||
private bool _checkedOnce;
|
||||
|
||||
public ConfigMgrClientDetector(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
IClock clock,
|
||||
StartupEventGate? startupGate = null)
|
||||
{
|
||||
_sessionId = sessionId;
|
||||
_tenantId = tenantId;
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger;
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_startupGate = startupGate;
|
||||
}
|
||||
|
||||
/// <summary>Host timer callback and test seam — one probe round.</summary>
|
||||
internal void Check()
|
||||
{
|
||||
var trigger = _checkedOnce ? "poll" : "startup";
|
||||
_checkedOnce = true;
|
||||
|
||||
var snapshot = ConfigMgrClientProbe.Read(_logger);
|
||||
if (!snapshot.Detected || snapshot.Fingerprint == _lastFingerprint || _emissions >= MaxEmissionsPerRun)
|
||||
return;
|
||||
|
||||
_lastFingerprint = snapshot.Fingerprint;
|
||||
if (_startupGate != null && !_startupGate.ShouldEmit(
|
||||
Constants.EventTypes.ConfigMgrClientDetected, StartupEventGate.HashString(snapshot.Fingerprint)))
|
||||
{
|
||||
_logger?.Debug("ConfigMgrClientDetector: state already reported by a previous agent run — suppressed.");
|
||||
return;
|
||||
}
|
||||
|
||||
_emissions++;
|
||||
var score = snapshot.ConfidenceScore;
|
||||
var versionSuffix = snapshot.Version != null ? $", v{snapshot.Version}" : "";
|
||||
var siteSuffix = snapshot.SiteCode != null ? $", site {snapshot.SiteCode}" : "";
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = _clock.UtcNow,
|
||||
EventType = Constants.EventTypes.ConfigMgrClientDetected,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = SourceLabel,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"ConfigMgr client detected (confidence {score}/100): CcmExec {snapshot.ServiceState}{versionSuffix}{siteSuffix}",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["ccmRegistryFound"] = snapshot.RegistryFound,
|
||||
["ccmDirectoryFound"] = snapshot.DirectoryFound,
|
||||
["ccmServiceState"] = snapshot.ServiceState,
|
||||
["ccmVersion"] = snapshot.Version ?? "unknown",
|
||||
["siteCode"] = snapshot.SiteCode ?? "unknown",
|
||||
["confidenceScore"] = score,
|
||||
["detectedAt"] = trigger,
|
||||
}
|
||||
});
|
||||
_startupGate?.MarkEmitted(Constants.EventTypes.ConfigMgrClientDetected);
|
||||
|
||||
_logger?.Info(
|
||||
$"ConfigMgrClientDetector: client detected (trigger={trigger}, confidence={score}, " +
|
||||
$"service={snapshot.ServiceState}, version={snapshot.Version ?? "?"}, site={snapshot.SiteCode ?? "?"}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.ServiceProcess;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the Configuration Manager client's footprint the way the V1 agent did: the
|
||||
/// <c>CCM</c> key, the version from <c>CCMSetup</c>, the assigned site code, the
|
||||
/// <c>%windir%\CCM</c> folder and the <c>CcmExec</c> service. Fail-soft: any failure yields
|
||||
/// <see cref="ConfigMgrClientSnapshot.Empty"/>. The service is queried only when its key
|
||||
/// exists, so devices without the client never throw on every poll.
|
||||
/// </summary>
|
||||
internal static class ConfigMgrClientProbe
|
||||
{
|
||||
internal const string CcmKeyPath = @"SOFTWARE\Microsoft\CCM";
|
||||
internal const string CcmSetupKeyPath = @"SOFTWARE\Microsoft\CCMSetup";
|
||||
internal const string MobileClientKeyPath = @"SOFTWARE\Microsoft\SMS\Mobile Client";
|
||||
internal const string CcmExecServiceKeyPath = @"SYSTEM\CurrentControlSet\Services\CcmExec";
|
||||
internal const string CcmExecServiceName = "CcmExec";
|
||||
|
||||
/// <summary>Service state when <c>CcmExec</c> is not installed (the V1 payload value).</summary>
|
||||
internal const string ServiceNotFound = "not_found";
|
||||
|
||||
/// <summary>Test seam; use <see cref="ScopedOverride"/> to guarantee cleanup.</summary>
|
||||
internal static Func<ConfigMgrClientSnapshot>? TestOverride;
|
||||
|
||||
public static ConfigMgrClientSnapshot Read(AgentLogger? logger = null)
|
||||
{
|
||||
var probe = TestOverride;
|
||||
if (probe != null) return probe();
|
||||
|
||||
try
|
||||
{
|
||||
// The agent is AnyCPU; the client writes the 64-bit view.
|
||||
using var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||
|
||||
bool registryFound;
|
||||
using (var ccm = hklm.OpenSubKey(CcmKeyPath))
|
||||
registryFound = ccm != null;
|
||||
|
||||
string? version;
|
||||
using (var setup = hklm.OpenSubKey(CcmSetupKeyPath))
|
||||
version = NonEmpty(setup?.GetValue("LastValidVersion")?.ToString());
|
||||
|
||||
string? siteCode;
|
||||
using (var mobileClient = hklm.OpenSubKey(MobileClientKeyPath))
|
||||
siteCode = NonEmpty(mobileClient?.GetValue("AssignedSiteCode")?.ToString());
|
||||
|
||||
bool serviceInstalled;
|
||||
using (var service = hklm.OpenSubKey(CcmExecServiceKeyPath))
|
||||
serviceInstalled = service != null;
|
||||
|
||||
var directoryFound = Directory.Exists(
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "CCM"));
|
||||
|
||||
return new ConfigMgrClientSnapshot(
|
||||
registryFound, directoryFound, serviceInstalled ? ReadServiceState() : ServiceNotFound, version, siteCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Debug($"ConfigMgrClientProbe: read failed: {ex.Message}");
|
||||
return ConfigMgrClientSnapshot.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadServiceState()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var controller = new ServiceController(CcmExecServiceName))
|
||||
return controller.Status.ToString();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return ServiceNotFound; // removed between the key read and the query
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value!.Trim();
|
||||
|
||||
/// <summary>Sets <see cref="TestOverride"/> for the scope and restores the previous value on Dispose.</summary>
|
||||
internal sealed class ScopedOverride : IDisposable
|
||||
{
|
||||
private readonly Func<ConfigMgrClientSnapshot>? _previous;
|
||||
private int _disposed;
|
||||
|
||||
public ScopedOverride(Func<ConfigMgrClientSnapshot> probe)
|
||||
{
|
||||
_previous = TestOverride;
|
||||
TestOverride = probe;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
TestOverride = _previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One read of the client's footprint, with the V1 confidence score.</summary>
|
||||
internal sealed class ConfigMgrClientSnapshot
|
||||
{
|
||||
public static readonly ConfigMgrClientSnapshot Empty =
|
||||
new ConfigMgrClientSnapshot(false, false, ConfigMgrClientProbe.ServiceNotFound, null, null);
|
||||
|
||||
public ConfigMgrClientSnapshot(bool registryFound, bool directoryFound, string serviceState, string? version, string? siteCode)
|
||||
{
|
||||
RegistryFound = registryFound;
|
||||
DirectoryFound = directoryFound;
|
||||
ServiceState = serviceState;
|
||||
Version = version;
|
||||
SiteCode = siteCode;
|
||||
}
|
||||
|
||||
public bool RegistryFound { get; }
|
||||
public bool DirectoryFound { get; }
|
||||
|
||||
/// <summary><see cref="System.ServiceProcess.ServiceControllerStatus"/> name, or <see cref="ConfigMgrClientProbe.ServiceNotFound"/>.</summary>
|
||||
public string ServiceState { get; }
|
||||
|
||||
public string? Version { get; }
|
||||
public string? SiteCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The V1 weights: the folder alone is too weak (it can be a remnant); the portal shows the
|
||||
/// detection from 50 on.
|
||||
/// </summary>
|
||||
public int ConfidenceScore =>
|
||||
(DirectoryFound ? 10 : 0)
|
||||
+ (RegistryFound ? 20 : 0)
|
||||
+ (Version != null ? 15 : 0)
|
||||
+ (SiteCode != null ? 15 : 0)
|
||||
+ (ServiceState != ConfigMgrClientProbe.ServiceNotFound ? 20 : 0)
|
||||
+ (ServiceState == nameof(ServiceControllerStatus.Running) ? 20 : 0);
|
||||
|
||||
public bool Detected => ConfidenceScore > 0;
|
||||
|
||||
/// <summary>Everything the event reports; a change re-emits.</summary>
|
||||
public string Fingerprint => $"{RegistryFound}|{DirectoryFound}|{ServiceState}|{Version}|{SiteCode}";
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -20,13 +20,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
internal const string None = "none";
|
||||
internal const string Unavailable = "unavailable";
|
||||
internal const string DevicePreparation = "device_preparation";
|
||||
private const string FailedSuffix = "_failed";
|
||||
|
||||
internal enum CategoryState { Absent, Running, Done, Failed }
|
||||
|
||||
// ESP order: device preparation, device setup, account setup.
|
||||
private static readonly (string ValueName, string Phase)[] Categories =
|
||||
{
|
||||
("DevicePreparationCategory.Status", "device_preparation"),
|
||||
("DevicePreparationCategory.Status", DevicePreparation),
|
||||
("DeviceSetupCategory.Status", "device_setup"),
|
||||
("AccountSetupCategory.Status", "account_setup"),
|
||||
};
|
||||
@@ -60,7 +62,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var states = Categories.Select((_, i) => Classify(i < statusJson.Count ? statusJson[i] : null)).ToList();
|
||||
|
||||
for (var i = 0; i < Categories.Length; i++)
|
||||
if (states[i] == CategoryState.Failed) return Categories[i].Phase + "_failed";
|
||||
if (states[i] == CategoryState.Failed) return Categories[i].Phase + FailedSuffix;
|
||||
for (var i = 0; i < Categories.Length; i++)
|
||||
if (states[i] == CategoryState.Running) return Categories[i].Phase;
|
||||
|
||||
@@ -69,6 +71,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
return lastDone == Categories.Length - 1 ? "esp_done" : Categories[lastDone].Phase + "_done";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether device preparation can still hold the ESP in a <see cref="Read"/> result: it
|
||||
/// runs or failed, or the phase is unknown (<see cref="None"/>, <see cref="Unavailable"/>).
|
||||
/// False once device preparation is done, whichever later phase the ESP is in.
|
||||
/// </summary>
|
||||
internal static bool DevicePreparationOpen(string phase) =>
|
||||
string.IsNullOrEmpty(phase)
|
||||
|| phase == None
|
||||
|| phase == Unavailable
|
||||
|| phase == DevicePreparation
|
||||
|| phase == DevicePreparation + FailedSuffix;
|
||||
|
||||
/// <summary>
|
||||
/// One category value: done (<c>categorySucceeded=true</c>, or every subcategory succeeded
|
||||
/// or notRequired), failed (<c>false</c>, or a failed subcategory), running (anything
|
||||
|
||||
+34
-9
@@ -39,6 +39,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// dispatch-guard exempt), never into decision state.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Phase gate: <c>DevicePreparation</c> providers count only while
|
||||
/// <see cref="EspPhaseSnapshot.DevicePreparationOpen"/> holds. Once device preparation is done
|
||||
/// their values hold nothing: a ConfigMgr client installed as a Win32 app registers its
|
||||
/// provider after its own install with <c>InstallationState=1</c>, and nothing completes it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Duplicate suppression: in-process one-shot per provider key, plus a
|
||||
/// <see cref="StartupEventGate"/> fingerprint over the sorted set of currently-stalled keys so
|
||||
/// an agent restart into the same stall does not re-report it — while a NEW provider joining
|
||||
@@ -75,6 +81,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly IClock _clock;
|
||||
private readonly StartupEventGate? _startupGate;
|
||||
private readonly TimeSpan _dwell;
|
||||
private readonly Func<string> _espPhaseReader;
|
||||
|
||||
private readonly Dictionary<string, DateTime> _firstSeenIncompleteUtc =
|
||||
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -94,7 +101,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
AgentLogger logger,
|
||||
IClock clock,
|
||||
StartupEventGate? startupGate = null,
|
||||
TimeSpan? dwell = null)
|
||||
TimeSpan? dwell = null,
|
||||
Func<string>? espPhaseReader = null)
|
||||
{
|
||||
_sessionId = sessionId;
|
||||
_tenantId = tenantId;
|
||||
@@ -103,6 +111,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_startupGate = startupGate;
|
||||
_dwell = dwell ?? TimeSpan.FromMinutes(DwellMinutes);
|
||||
_espPhaseReader = espPhaseReader ?? EspPhaseSnapshot.Read;
|
||||
}
|
||||
|
||||
/// <summary>Host timer callback and test seam — one probe + evaluation round.</summary>
|
||||
@@ -111,7 +120,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var snapshot = EspPolicyProviderProbe.Read(_logger);
|
||||
var eval = EvaluateStallTransition(
|
||||
snapshot, _firstSeenIncompleteUtc, _firedKeys,
|
||||
_sidecarMissingSinceUtc, _sidecarMissingFired, _clock.UtcNow, _dwell);
|
||||
_sidecarMissingSinceUtc, _sidecarMissingFired, _clock.UtcNow, _dwell,
|
||||
EspPhaseSnapshot.DevicePreparationOpen(_espPhaseReader()));
|
||||
|
||||
_firstSeenIncompleteUtc.Clear();
|
||||
foreach (var kv in eval.UpdatedFirstSeenUtc)
|
||||
@@ -133,6 +143,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// <param name="sidecarMissingAlreadyFired">Arm-2 one-shot latch (never re-report).</param>
|
||||
/// <param name="nowUtc">Evaluation timestamp.</param>
|
||||
/// <param name="dwell">Continuous-condition threshold (shared by both arms).</param>
|
||||
/// <param name="devicePreparationOpen">Whether device preparation can still hold the ESP (<see cref="EspPhaseSnapshot.DevicePreparationOpen"/>); false drops <c>DevicePreparation</c> providers from arm 1.</param>
|
||||
internal static StallEvaluation EvaluateStallTransition(
|
||||
EspPolicyProviderSnapshot snapshot,
|
||||
IReadOnlyDictionary<string, DateTime> firstSeenIncompleteUtc,
|
||||
@@ -140,9 +151,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
DateTime? sidecarMissingSinceUtc,
|
||||
bool sidecarMissingAlreadyFired,
|
||||
DateTime nowUtc,
|
||||
TimeSpan dwell)
|
||||
TimeSpan dwell,
|
||||
bool devicePreparationOpen = true)
|
||||
{
|
||||
var result = new StallEvaluation();
|
||||
var result = new StallEvaluation { DevicePreparationOpen = devicePreparationOpen };
|
||||
|
||||
// Missing root key or no registered providers = normal early enrollment: clear all
|
||||
// dwell state (a condition must be continuously OBSERVED to accrue dwell).
|
||||
@@ -152,8 +164,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// Arm 1 — a registered provider continuously incomplete.
|
||||
foreach (var provider in snapshot.Providers)
|
||||
{
|
||||
if (provider.IsComplete)
|
||||
continue; // completed → falls out of the dwell map (reset)
|
||||
if (provider.IsComplete || !result.Counts(provider))
|
||||
continue; // completed or past its phase → falls out of the dwell map (reset)
|
||||
|
||||
var firstSeen = firstSeenIncompleteUtc.TryGetValue(provider.Key, out var seen)
|
||||
? seen
|
||||
@@ -197,7 +209,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// the same stall re-accrues dwell and lands on the same fingerprint → suppressed; a
|
||||
// new condition joining the stall changes it → re-emitted.
|
||||
var currentlyStalledKeys = snapshot.Providers
|
||||
.Where(p => !p.IsComplete && _firedKeys.Contains(p.Key))
|
||||
.Where(p => !p.IsComplete && eval.Counts(p) && _firedKeys.Contains(p.Key))
|
||||
.Select(p => p.Key)
|
||||
.ToList();
|
||||
if (_sidecarMissingFired && HasSetupAppsProvider(snapshot) && !snapshot.SidecarRegistered)
|
||||
@@ -259,9 +271,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (eval.SidecarMissingStalledForMinutes != null)
|
||||
data["sidecarMissingForMinutes"] = Math.Round(eval.SidecarMissingStalledForMinutes.Value, 1);
|
||||
|
||||
// The Setup/Apps wait has no timeout; device preparation gives up after the provider's
|
||||
// Timeout node (CSP default 15 min).
|
||||
var setupAppsWait = eval.SidecarMissingStalledForMinutes != null
|
||||
|| eval.NewlyStalled.Any(s => s.Provider.Kind == EspPolicyProviderProbe.KindSetupApps);
|
||||
var message =
|
||||
"ESP Setup/Apps tracking stalled: " + string.Join("; ", messageParts) +
|
||||
" — the ESP waits on its app-tracking providers without any timeout";
|
||||
"ESP policy provider stalled: " + string.Join("; ", messageParts) +
|
||||
(setupAppsWait
|
||||
? " — the ESP waits on its Setup/Apps providers without any timeout"
|
||||
: " — device preparation waits on its providers until their timeout");
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
@@ -298,6 +316,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
/// <summary>Non-null when the sidecar-missing condition crossed the dwell threshold this round and was not yet reported (arm 2).</summary>
|
||||
public double? SidecarMissingStalledForMinutes { get; set; }
|
||||
|
||||
/// <summary>The phase gate of this round; the cross-restart fingerprint applies the same filter.</summary>
|
||||
public bool DevicePreparationOpen { get; set; }
|
||||
|
||||
/// <summary>Whether arm 1 considers <paramref name="provider"/> in this round.</summary>
|
||||
public bool Counts(PolicyProviderState provider)
|
||||
=> DevicePreparationOpen || provider.Kind != EspPolicyProviderProbe.KindDevicePreparation;
|
||||
}
|
||||
|
||||
internal readonly struct StalledProvider
|
||||
|
||||
@@ -517,6 +517,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
startupGate: _startupEventGate));
|
||||
}
|
||||
|
||||
// ConfigMgr client presence (the V1 check) — opt-in per tenant (portal toggle →
|
||||
// AnalyzerConfiguration.EnableConfigMgrWatcher, default off). When off, the host is
|
||||
// never created, so devices of other tenants run no check at all.
|
||||
if (analyzers.EnableConfigMgrWatcher)
|
||||
{
|
||||
hosts.Add(new ConfigMgrClientHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
logger: logger,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
startupGate: _startupEventGate));
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Info("DefaultComponentFactory: ConfigMgrClientHost not created (EnableConfigMgrWatcher=false)");
|
||||
}
|
||||
|
||||
// Registry second pillar (audit 2026-08-17): IME's authoritative per-app state from
|
||||
// HKLM\...\IntuneManagementExtension (Win32Apps EnforcementStateMessage,
|
||||
// EspTrackingWin32Apps, StatusServiceReports) via snapshot-and-diff. Emits
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Drives the <see cref="ConfigMgrClientDetector"/>: a first check shortly after start (the
|
||||
/// client may already be installed) and one per minute after that (it may arrive later, e.g.
|
||||
/// as a Win32 app). Created only when the tenant enables the ConfigMgr watcher
|
||||
/// (<c>AnalyzerConfiguration.EnableConfigMgrWatcher</c>).
|
||||
/// </summary>
|
||||
internal sealed class ConfigMgrClientHost : ICollectorHost
|
||||
{
|
||||
public string Name => "ConfigMgrClientDetector";
|
||||
|
||||
private static readonly TimeSpan FirstCheckDelay = TimeSpan.FromSeconds(10);
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly ConfigMgrClientDetector _detector;
|
||||
private readonly AgentLogger _logger;
|
||||
private Timer? _tickTimer;
|
||||
private int _checking;
|
||||
private int _disposed;
|
||||
|
||||
public ConfigMgrClientHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger logger,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
StartupEventGate? startupGate)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger;
|
||||
_detector = new ConfigMgrClientDetector(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: new InformationalEventPost(ingress, clock),
|
||||
logger: logger,
|
||||
clock: clock,
|
||||
startupGate: startupGate);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_tickTimer = new Timer(_ => SafeCheck(), state: null, dueTime: FirstCheckDelay, period: PollInterval);
|
||||
_logger.Info($"ConfigMgrClientHost: started (check every {PollInterval.TotalSeconds}s).");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
try
|
||||
{
|
||||
_tickTimer?.Dispose();
|
||||
_tickTimer = null;
|
||||
}
|
||||
catch (Exception ex) { _logger.Warning($"ConfigMgrClientHost: timer dispose failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
Stop();
|
||||
}
|
||||
|
||||
private void SafeCheck()
|
||||
{
|
||||
// A slow service query must not stack a second check on top of the first.
|
||||
if (Interlocked.Exchange(ref _checking, 1) == 1) return;
|
||||
try
|
||||
{
|
||||
_detector.Check();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("ConfigMgrClientHost: check failed.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _checking, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,20 @@ using System.Security.Principal;
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Keeps <c>%ProgramData%\AutopilotMonitor</c> out of reach of standard users. Left alone the
|
||||
/// folder inherits ProgramData's "Users may create files and folders" entry, and the SYSTEM
|
||||
/// agent and its boot task start from the Agent subfolder: a user who can add a file there
|
||||
/// can plant code the next SYSTEM start loads. During the device phase of OOBE only SYSTEM is
|
||||
/// around, but the agent keeps running through the user phase and on the desktop until the
|
||||
/// enrollment ends, restarted by the boot task after every reboot; that is the window this
|
||||
/// closes. The payload catalog does not cover it: it is checked at install time, a file
|
||||
/// added later is never looked at.
|
||||
/// Keeps <c>%ProgramData%\AutopilotMonitor</c> and the Agent folder the SYSTEM task runs from
|
||||
/// out of reach of standard users. Left alone the folder inherits ProgramData's "Users may
|
||||
/// create files and folders" entry: a user who can add a file where the agent starts can plant
|
||||
/// code the next SYSTEM start loads. During the device phase of OOBE only SYSTEM is around, but
|
||||
/// the agent keeps running through the user phase and on the desktop until the enrollment
|
||||
/// ends, restarted by the boot task after every reboot; that is the window this closes. The
|
||||
/// payload catalog does not cover it: it is checked at install time, a file added later is
|
||||
/// never looked at.
|
||||
/// <para>
|
||||
/// The DACL is protected (nothing inherited from ProgramData): SYSTEM and Administrators full
|
||||
/// control, Users read and execute, all inherited by the subfolders. Explicit entries on a
|
||||
/// child stay. The summary dialog's temp folder lives outside this tree anyway.
|
||||
/// control, Users read and execute, all inherited by the subfolders. The owner is Administrators:
|
||||
/// the owner of a folder can always hand itself the rights back, so a folder a standard user
|
||||
/// created first stays theirs until the owner changes, whatever the DACL says. A junction or
|
||||
/// symbolic link is never protected or followed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class AgentDirectoryAcl
|
||||
@@ -31,8 +33,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
private static readonly SecurityIdentifier Users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
|
||||
|
||||
/// <summary>
|
||||
/// Applies the protected DACL to <paramref name="path"/> unless it is already in place.
|
||||
/// Never throws: a folder the agent cannot protect must not stop the agent.
|
||||
/// Applies the protected DACL and the owner to <paramref name="path"/> unless both are already
|
||||
/// in place. A reparse point is refused, not followed. Never throws: a folder the agent cannot
|
||||
/// protect must not crash the agent; the caller decides what the failure means.
|
||||
/// </summary>
|
||||
public static AgentDirectoryAclResult EnsureProtected(string path)
|
||||
{
|
||||
@@ -41,8 +44,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
var directory = new DirectoryInfo(path);
|
||||
if (!directory.Exists)
|
||||
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.Missing, null);
|
||||
if ((directory.Attributes & FileAttributes.ReparsePoint) != 0)
|
||||
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.Failed, $"{path} is a junction or symbolic link, not a folder");
|
||||
|
||||
if (IsProtectedAsExpected(directory.GetAccessControl(AccessControlSections.Access)))
|
||||
if (IsProtectedAsExpected(directory.GetAccessControl(AccessControlSections.Access | AccessControlSections.Owner)))
|
||||
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.AlreadyProtected, null);
|
||||
|
||||
directory.SetAccessControl(BuildProtectedSecurity());
|
||||
@@ -54,6 +59,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the folder with the protected DACL and owner in one step, so there is no moment
|
||||
/// in which it carries ProgramData's inherited entries.
|
||||
/// </summary>
|
||||
public static void CreateProtected(string path) => Directory.CreateDirectory(path, BuildProtectedSecurity());
|
||||
|
||||
internal static DirectorySecurity BuildProtectedSecurity()
|
||||
{
|
||||
var security = new DirectorySecurity();
|
||||
@@ -61,15 +72,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
security.AddAccessRule(new FileSystemAccessRule(LocalSystem, FileSystemRights.FullControl, Inherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(Administrators, FileSystemRights.FullControl, Inherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(Users, FileSystemRights.ReadAndExecute, Inherit, PropagationFlags.None, AccessControlType.Allow));
|
||||
security.SetOwner(Administrators);
|
||||
return security;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the DACL is protected and holds exactly the three entries above. Windows adds
|
||||
/// <see cref="FileSystemRights.Synchronize"/> when it stores an entry, so that bit is ignored.
|
||||
/// True when the owner is SYSTEM or Administrators and the DACL is protected and holds exactly
|
||||
/// the three entries above. Windows adds <see cref="FileSystemRights.Synchronize"/> when it
|
||||
/// stores an entry, so that bit is ignored.
|
||||
/// </summary>
|
||||
internal static bool IsProtectedAsExpected(DirectorySecurity security)
|
||||
{
|
||||
if (!(security.GetOwner(typeof(SecurityIdentifier)) is SecurityIdentifier owner)
|
||||
|| !(owner.IsWellKnown(WellKnownSidType.LocalSystemSid) || owner.IsWellKnown(WellKnownSidType.BuiltinAdministratorsSid)))
|
||||
return false;
|
||||
|
||||
if (!security.AreAccessRulesProtected)
|
||||
return false;
|
||||
|
||||
|
||||
@@ -57,8 +57,11 @@ namespace AutopilotMonitor.Agent.V2
|
||||
logger.Info("======================= Agent install mode (--install) =======================");
|
||||
|
||||
EnsureAgentDirectories(logger);
|
||||
LogAgentDirectoryAcl(AgentDirectoryAcl.EnsureProtected(
|
||||
Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory)), logger, alwaysLog: true);
|
||||
if (!ProtectAgentDirectories(logger, alwaysLog: true))
|
||||
{
|
||||
logger.Error("Install refused: the agent folder could not be protected; nothing was installed.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var sourceExePath = Assembly.GetExecutingAssembly().Location;
|
||||
var sourceDir = Path.GetDirectoryName(sourceExePath) ?? string.Empty;
|
||||
@@ -213,7 +216,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
// SKIP via pre-flight (agent already installed), and the device would
|
||||
// sit without a runtime until manual intervention. The bootstrap
|
||||
// script's Get-Process probe is the canonical "no runtime came up"
|
||||
// signal — see Install-AutopilotMonitor.ps1 ~line 280.
|
||||
// signal — see "Runtime process verified" in Install-AutopilotMonitor.ps1.
|
||||
RuntimeLaunchResult launch;
|
||||
if (!TokenPrivilegeProbe.IsPrivilegeHeld(TokenPrivilegeProbe.TimeZonePrivilege))
|
||||
{
|
||||
@@ -276,31 +279,48 @@ namespace AutopilotMonitor.Agent.V2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs the outcome of <see cref="AgentDirectoryAcl.EnsureProtected"/>. Install mode logs
|
||||
/// every outcome; the runtime start only a change or a failure, so a normal start stays quiet.
|
||||
/// Protects the base folder and the Agent folder the task runs from. False when one of them
|
||||
/// could not be protected: install mode then refuses to go on, a running agent logs it.
|
||||
/// Install mode logs every outcome; the runtime start only a change or a failure, so a
|
||||
/// normal start stays quiet.
|
||||
/// </summary>
|
||||
internal static void LogAgentDirectoryAcl(AgentDirectoryAclResult result, AgentLogger logger, bool alwaysLog)
|
||||
internal static bool ProtectAgentDirectories(AgentLogger logger, bool alwaysLog)
|
||||
{
|
||||
switch (result.Outcome)
|
||||
var basePath = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
|
||||
var ok = true;
|
||||
foreach (var path in new[] { basePath, Path.Combine(basePath, DefaultAgentSubdirectory) })
|
||||
{
|
||||
case AgentDirectoryAclOutcome.Applied:
|
||||
logger.Info("Agent data directory protected: SYSTEM and Administrators full control, Users read and execute, nothing inherited from ProgramData.");
|
||||
break;
|
||||
case AgentDirectoryAclOutcome.Failed:
|
||||
logger.Warning($"Could not protect the agent data directory: {result.Error}");
|
||||
break;
|
||||
default:
|
||||
if (alwaysLog) logger.Info($"Agent data directory ACL: {result.Outcome}.");
|
||||
break;
|
||||
var result = AgentDirectoryAcl.EnsureProtected(path);
|
||||
switch (result.Outcome)
|
||||
{
|
||||
case AgentDirectoryAclOutcome.Applied:
|
||||
logger.Info($"Agent folder {path} protected: owner Administrators; SYSTEM and Administrators full control, Users read and execute, nothing inherited.");
|
||||
break;
|
||||
case AgentDirectoryAclOutcome.Failed:
|
||||
logger.Warning($"Could not protect the agent folder {path}: {result.Error}");
|
||||
ok = false;
|
||||
break;
|
||||
default:
|
||||
if (alwaysLog) logger.Info($"Agent folder {path} ACL: {result.Outcome}.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
private static void EnsureAgentDirectories(AgentLogger logger)
|
||||
{
|
||||
var basePath = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
|
||||
if (!Directory.Exists(basePath))
|
||||
{
|
||||
// Protected from the first moment: created with ProgramData's default entries, the
|
||||
// folder would let any user add to it until the protection arrives.
|
||||
AgentDirectoryAcl.CreateProtected(basePath);
|
||||
logger.Info($"Created directory: {basePath}");
|
||||
}
|
||||
|
||||
var paths = new[]
|
||||
{
|
||||
basePath,
|
||||
Path.Combine(basePath, DefaultAgentSubdirectory),
|
||||
Environment.ExpandEnvironmentVariables(Constants.SpoolDirectory),
|
||||
Environment.ExpandEnvironmentVariables(Constants.LogDirectory),
|
||||
|
||||
@@ -170,8 +170,7 @@ namespace AutopilotMonitor.Agent.V2
|
||||
|
||||
// Same protection install mode applies, re-checked on every start: it also reaches
|
||||
// agents that arrived by self-update over an install made before the hardening.
|
||||
LogAgentDirectoryAcl(AgentDirectoryAcl.EnsureProtected(
|
||||
Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory)), logger, alwaysLog: false);
|
||||
ProtectAgentDirectories(logger, alwaysLog: false);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -11,12 +11,27 @@
|
||||
logic stays updatable without re-shipping the MSI, and both delivery channels share one
|
||||
implementation.
|
||||
|
||||
Build: wix build Package.wxs -arch x64 -o bin\AutopilotMonitor-Bootstrap.msi
|
||||
Everything of ours on a device lives under ProgramData. The loader copy goes to the agent
|
||||
folder, which the installer owns: it removes the copy in every outcome. Any standard user
|
||||
can create that folder first, and Windows Installer keeps a modified unversioned file, so
|
||||
the custom action never runs the file at that path as such: it reads the file once, checks
|
||||
that those bytes are the loader this package embeds (SHA-256 baked in at build time, so no
|
||||
other script of ours, however validly signed, runs here) and carry our Authenticode
|
||||
signature (the publisher pin the loader applies to the installer), and runs exactly those
|
||||
bytes. PowerShell starts in System32, never in a folder users can write to. Windows
|
||||
Installer formats the command: every square bracket and every brace of the PowerShell text
|
||||
is written as [\[], [\]], [\{] or [\}], because a brace block that holds a bracket is a
|
||||
conditional block to the formatter and loses its braces.
|
||||
|
||||
Build: wix build Package.wxs -arch x64 -d LoaderSha256=<lower-case SHA-256 of the loader being embedded> -o bin\AutopilotMonitor-Bootstrap.msi
|
||||
-->
|
||||
<?ifndef var.LoaderSha256 ?>
|
||||
<?error LoaderSha256 is required: the SHA-256 of the loader this package embeds, lower-case (wix build -d LoaderSha256=...). ?>
|
||||
<?endif ?>
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
<Package Name="Autopilot Monitor Bootstrap"
|
||||
Manufacturer="Autopilot Monitor"
|
||||
Version="1.1.0"
|
||||
Version="1.4.0"
|
||||
UpgradeCode="1fbe5515-d346-445c-b8b8-14ee79f97522"
|
||||
Scope="perMachine"
|
||||
Compressed="yes">
|
||||
@@ -32,8 +47,8 @@
|
||||
|
||||
<ComponentGroup Id="MainComponents" Directory="INSTALLFOLDER">
|
||||
<!-- Source is relative to the build working directory (the project dir), not to this
|
||||
file. New component GUID because the key path changed. -->
|
||||
<Component Id="RunnerScript" Guid="A972F561-01B5-400A-8C2A-47D12BA97DCA">
|
||||
file. New component GUID because the key path changed (1.4.0: back to ProgramData). -->
|
||||
<Component Id="RunnerScript" Guid="5E8D1B6A-2C47-4F93-A1D0-7B9E3C5F8A21">
|
||||
<File Id="RunnerPs1"
|
||||
Source="..\..\..\scripts\Bootstrap\Start-AutopilotMonitor.ps1"
|
||||
Name="Start-AutopilotMonitor.ps1"
|
||||
@@ -45,13 +60,14 @@
|
||||
<ComponentGroupRef Id="MainComponents" />
|
||||
</Feature>
|
||||
|
||||
<!-- Deferred + no-impersonate: runs as SYSTEM after files land. Return=ignore keeps
|
||||
the LOB install green even if the loader hits a transient error (the loader is
|
||||
additionally fail-soft itself). -LogFileName keeps this channel distinguishable in
|
||||
a diagnostics package; the diag tooling keys on that name. -->
|
||||
<!-- Deferred + no-impersonate: runs as SYSTEM after the file lands. Return=ignore keeps
|
||||
the LOB install green whatever happens (the loader is fail-soft itself). Runs on
|
||||
every install of the product, upgrades included, so the installer gets to remove the
|
||||
copy. -LogFileName keeps this channel distinguishable in a diagnostics package; the
|
||||
diag tooling keys on that name. -->
|
||||
<CustomAction Id="RunBootstrap"
|
||||
Directory="INSTALLFOLDER"
|
||||
ExeCommand=""[System64Folder]WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "[INSTALLFOLDER]Start-AutopilotMonitor.ps1" -LogFileName bootstrap-msi.log"
|
||||
Directory="System64Folder"
|
||||
ExeCommand=""[System64Folder]WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$b = [\[]System.IO.File[\]]::ReadAllBytes('[INSTALLFOLDER]Start-AutopilotMonitor.ps1'); $h = [\[]System.BitConverter[\]]::ToString([\[]System.Security.Cryptography.SHA256[\]]::Create().ComputeHash($b)).Replace('-', '').ToLowerInvariant(); $s = Get-AuthenticodeSignature -Content $b -SourcePathOrExtension '.ps1'; if ($h -eq '$(var.LoaderSha256)' -and $s.Status -eq 'Valid' -and $s.SignerCertificate.Subject -like '*O=glueckkanja AG*') [\{] & ([\[]scriptblock[\]]::Create([\[]System.Text.Encoding[\]]::UTF8.GetString($b))) -LogFileName bootstrap-msi.log [\}]""
|
||||
Execute="deferred"
|
||||
Impersonate="no"
|
||||
Return="ignore" />
|
||||
|
||||
@@ -72,10 +72,19 @@ public class AgentConfigResolverTests
|
||||
Assert.False(r.Analyzers.EnableSoftwareInventoryAnalyzer);
|
||||
Assert.True(r.Analyzers.EnableIntegrityBypassAnalyzer);
|
||||
Assert.False(r.Analyzers.EnableRealmJoinWatcher);
|
||||
Assert.False(r.Analyzers.EnableConfigMgrWatcher);
|
||||
Assert.False(r.Analyzers.KeepAwakeDuringUserEsp);
|
||||
Assert.True(r.Analyzers.EnableConsoleBypassDetection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tenant_switches_the_ConfigMgr_watcher_on()
|
||||
{
|
||||
var r = Build(Tenant(t => t.EnableConfigMgrWatcher = true));
|
||||
|
||||
Assert.True(r.Analyzers.EnableConfigMgrWatcher);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "CustomerSas", "Off", false)]
|
||||
[InlineData(null, "Hosted", "Off", true)] // hosted destination needs no SAS
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Functions.Infrastructure;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Tests.Offboarding;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -43,7 +44,8 @@ public class AuthFunctionSideEffectTests
|
||||
private readonly FakeOffboardingAuditRepository _offboardingRepo = new();
|
||||
private readonly StoredTenantConfig _store;
|
||||
private readonly Mock<GlobalAdminService> _globalAdminMock;
|
||||
private readonly Func<PushOwnerSignInService?, AuthFunction> _buildSut;
|
||||
/// <summary>Builds the function; null arguments fall back to the class mocks.</summary>
|
||||
private readonly Func<PushOwnerSignInService?, TenantAdminsService?, PreviewWhitelistService?, AuthFunction> _buildSut;
|
||||
private readonly AuthFunction _sut;
|
||||
|
||||
public AuthFunctionSideEffectTests()
|
||||
@@ -59,6 +61,7 @@ public class AuthFunctionSideEffectTests
|
||||
{ CallBase = false };
|
||||
|
||||
var bindings = new StubAdminIdentityBindingService(bound: true);
|
||||
bindings.BoundTenants[RemovedAdminUpn] = RemovedAdminHomeTenant;
|
||||
_globalAdminMock = new Mock<GlobalAdminService>(
|
||||
adminRepo, bindings, cache, Mock.Of<ILogger<GlobalAdminService>>())
|
||||
{ CallBase = false };
|
||||
@@ -106,14 +109,14 @@ public class AuthFunctionSideEffectTests
|
||||
_opsEventsMock = new Mock<OpsEventService>(null!, null!, null!) { CallBase = false };
|
||||
_opsEventsMock.Setup(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>())).Returns(Task.CompletedTask);
|
||||
|
||||
_buildSut = pushSignIn => new AuthFunction(
|
||||
_buildSut = (pushSignIn, tenantAdmins, preview) => new AuthFunction(
|
||||
Mock.Of<ILogger<AuthFunction>>(),
|
||||
globalAdminMock.Object,
|
||||
delegatedAdminMock.Object,
|
||||
_tenantConfigMock.Object,
|
||||
_tenantAdminsMock.Object,
|
||||
tenantAdmins ?? _tenantAdminsMock.Object,
|
||||
_metricsRepoMock.Object,
|
||||
_previewMock.Object,
|
||||
preview ?? _previewMock.Object,
|
||||
_telegramMock.Object,
|
||||
_globalNotificationMock.Object,
|
||||
_mcpUserMock.Object,
|
||||
@@ -123,12 +126,12 @@ public class AuthFunctionSideEffectTests
|
||||
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()),
|
||||
new Mock<AdminIdentityResolver>(
|
||||
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object,
|
||||
_signalR,
|
||||
new LiveStreamRevoker(_signalR, bindings, Mock.Of<ILogger<LiveStreamRevoker>>()),
|
||||
adminConfigService.Object,
|
||||
_offboardingRepo,
|
||||
pushSignIn: pushSignIn,
|
||||
opsEvents: _opsEventsMock.Object);
|
||||
_sut = _buildSut(null);
|
||||
_sut = _buildSut(null, null, null);
|
||||
|
||||
// Writes go through UpdateAsync / CreateOrUpdateAsync over one stored row (none by default:
|
||||
// a brand-new tenant). Default: all fire-and-forget calls succeed.
|
||||
@@ -154,6 +157,9 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
// ── RemoveGlobalAdmin cuts live SignalR streams (join-time-only group authz) ──
|
||||
|
||||
private const string RemovedAdminUpn = "removed@contoso.com";
|
||||
private const string RemovedAdminHomeTenant = "77777777-7777-7777-7777-777777777777";
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveGlobalAdmin_DisconnectsTheRemovedAdminsSignalRConnections()
|
||||
{
|
||||
@@ -162,7 +168,8 @@ public class AuthFunctionSideEffectTests
|
||||
var response = await _sut.RemoveGlobalAdmin(req, "Removed@Contoso.com", ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(new[] { "removed@contoso.com" }, _signalR.DisconnectedUsers);
|
||||
// A platform role resolves only under the UPN's bound tenant: the cut names that tenant.
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(RemovedAdminHomeTenant, RemovedAdminUpn) }, _signalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -779,14 +786,14 @@ public class AuthFunctionSideEffectTests
|
||||
public async Task PostDecision_NeedsAutoAdmin_CallsAddTenantAdmin()
|
||||
{
|
||||
_tenantAdminsMock
|
||||
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
|
||||
.ReturnsAsync(new TenantAdminRow());
|
||||
.Setup(x => x.TryAddTenantAdminAsync(TenantId, Upn, "System"))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
|
||||
|
||||
await _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
_tenantAdminsMock.Verify(x => x.AddTenantAdminAsync(TenantId, Upn, "System"), Times.Once);
|
||||
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(TenantId, Upn, "System"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -797,7 +804,7 @@ public class AuthFunctionSideEffectTests
|
||||
await _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
_tenantAdminsMock.Verify(
|
||||
x => x.AddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()),
|
||||
x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
@@ -817,14 +824,14 @@ public class AuthFunctionSideEffectTests
|
||||
public async Task PostDecision_AutoAdminAndMetrics_BothExecute()
|
||||
{
|
||||
_tenantAdminsMock
|
||||
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
|
||||
.ReturnsAsync(new TenantAdminRow());
|
||||
.Setup(x => x.TryAddTenantAdminAsync(TenantId, Upn, "System"))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
|
||||
|
||||
await _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
_tenantAdminsMock.Verify(x => x.AddTenantAdminAsync(TenantId, Upn, "System"), Times.Once);
|
||||
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(TenantId, Upn, "System"), Times.Once);
|
||||
_metricsRepoMock.Verify(
|
||||
x => x.RecordUserLoginAsync(TenantId, Upn, DisplayName, ObjectId),
|
||||
Times.Once);
|
||||
@@ -848,7 +855,7 @@ public class AuthFunctionSideEffectTests
|
||||
public async Task PostDecision_AutoAdminFailure_Throws()
|
||||
{
|
||||
_tenantAdminsMock
|
||||
.Setup(x => x.AddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Setup(x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Table Storage down"));
|
||||
|
||||
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
|
||||
@@ -858,6 +865,21 @@ public class AuthFunctionSideEffectTests
|
||||
() => _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostDecision_AutoAdminSeatAlreadyTaken_DoesNotThrow()
|
||||
{
|
||||
// The activation seated the same person a moment earlier (conditional insert lost): nothing to overwrite.
|
||||
_tenantAdminsMock
|
||||
.Setup(x => x.TryAddTenantAdminAsync(TenantId, Upn, "System"))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var decision = AuthDecisionResult.Success(new { }, needsAutoAdmin: true);
|
||||
|
||||
await _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(TenantId, Upn, "System"), Times.Once);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GetCurrentUser end to end: an external identity never claims the tenant
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -880,7 +902,8 @@ public class AuthFunctionSideEffectTests
|
||||
{
|
||||
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((config, exists));
|
||||
_previewMock.Setup(x => x.IsApprovedAsync(TenantId)).ReturnsAsync(approved);
|
||||
_tenantAdminsMock.Setup(x => x.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminRow>());
|
||||
_tenantAdminsMock.Setup(x => x.GetMembershipSnapshotAsync(TenantId, Upn))
|
||||
.ReturnsAsync((TableMemberState.NotPresent, null, false));
|
||||
_mcpUserMock
|
||||
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
|
||||
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
|
||||
@@ -927,19 +950,95 @@ public class AuthFunctionSideEffectTests
|
||||
config.DomainName = "contoso.com";
|
||||
ArrangeAuthMe(config, exists: true, approved: true);
|
||||
_tenantAdminsMock
|
||||
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
|
||||
.ReturnsAsync(new TenantAdminRow());
|
||||
.Setup(x => x.TryAddTenantAdminAsync(TenantId, Upn, "System"))
|
||||
.ReturnsAsync(true);
|
||||
var (req, ctx) = BuildRequest(SignedIn(external));
|
||||
|
||||
var response = await _sut.GetCurrentUser(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
_tenantAdminsMock.Verify(
|
||||
x => x.AddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()),
|
||||
x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()),
|
||||
external ? Times.Never() : Times.Once());
|
||||
Assert.Contains(external ? "\"isTenantAdmin\":false" : "\"isTenantAdmin\":true", await ReadBodyAsync(response));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The activation race: the tenant is activated and its partition is empty for a moment while an
|
||||
/// ordinary member polls auth/me. The first-user seat belongs to the designated onboarding member
|
||||
/// alone; everybody else is answered roleless and nothing is written.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("first.member@contoso.com", false)]
|
||||
[InlineData("USER@CONTOSO.COM", true)]
|
||||
public async Task GetCurrentUser_ActivatedTenantWithoutAdmins_OnlyTheDesignatedOnboardingMemberTakesTheSeat(
|
||||
string onboardedBy, bool callerIsTheOnboardingMember)
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = "contoso.com";
|
||||
config.OnboardedBy = onboardedBy;
|
||||
ArrangeAuthMe(config, exists: true, approved: true);
|
||||
_tenantAdminsMock.Setup(x => x.TryAddTenantAdminAsync(TenantId, Upn, "System")).ReturnsAsync(true);
|
||||
var (req, ctx) = BuildRequest(SignedIn(external: false));
|
||||
|
||||
var response = await _sut.GetCurrentUser(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Contains(callerIsTheOnboardingMember ? "\"isTenantAdmin\":true" : "\"isTenantAdmin\":false", await ReadBodyAsync(response));
|
||||
_tenantAdminsMock.Verify(
|
||||
x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()),
|
||||
callerIsTheOnboardingMember ? Times.Once() : Times.Never());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The sequence behind new tenants' first admins landing on the progress page, over the real role caches.
|
||||
/// While the tenant waits for activation, this instance resolves the member's role (the feedback-status
|
||||
/// read after every activation poll) and finds no row. The auto-approve worker then activates the tenant
|
||||
/// and promotes the member on another instance, whose cache invalidation never reaches this one. The next
|
||||
/// poll must answer the member as Admin: not as still pending (a cached "not activated") and not as
|
||||
/// roleless (a cached "no row" next to a fresh member list).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task GetCurrentUser_FirstPollAfterActivation_AnswersThePromotedMemberAsAdmin()
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.DomainName = "contoso.com";
|
||||
config.OnboardedBy = Upn;
|
||||
ArrangeAuthMe(config, exists: true, approved: false);
|
||||
|
||||
var activated = false;
|
||||
var rows = new List<TenantMember>();
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(r => r.IsInPreviewWhitelistAsync(TenantId)).ReturnsAsync(() => activated);
|
||||
var adminRepo = new Mock<IAdminRepository>();
|
||||
adminRepo.Setup(r => r.GetTenantMembersAsync(TenantId)).ReturnsAsync(() => rows.ToList());
|
||||
adminRepo.Setup(r => r.GetTenantMemberAsync(TenantId, Upn)).ReturnsAsync(() => rows.FirstOrDefault());
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tenantAdmins = new TenantAdminsService(adminRepo.Object, cache, Mock.Of<ILogger<TenantAdminsService>>());
|
||||
var sut = _buildSut(null, tenantAdmins, new PreviewWhitelistService(
|
||||
configRepo.Object, cache, Mock.Of<ILogger<PreviewWhitelistService>>(), _tenantConfigMock.Object));
|
||||
|
||||
var (pollReq, pollCtx) = BuildRequest(SignedIn(external: false));
|
||||
Assert.Equal(HttpStatusCode.Forbidden, (await sut.GetCurrentUser(pollReq, pollCtx)).StatusCode);
|
||||
Assert.Equal(TableMemberState.NotPresent, (await tenantAdmins.GetTableMembershipAsync(TenantId, Upn)).State);
|
||||
|
||||
activated = true;
|
||||
rows.Add(new TenantMember { TenantId = TenantId, Upn = Upn, Role = Constants.TenantRoles.Admin, IsEnabled = true });
|
||||
|
||||
var (req, ctx) = BuildRequest(SignedIn(external: false));
|
||||
var response = await sut.GetCurrentUser(req, ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await ReadBodyAsync(response);
|
||||
Assert.Contains("\"isTenantAdmin\":true", body);
|
||||
Assert.Contains("\"role\":\"Admin\"", body);
|
||||
// The promotion already wrote the row: no first-user auto-admin on top of it.
|
||||
adminRepo.Verify(r => r.AddTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
adminRepo.Verify(r => r.TryAddTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GetCurrentUser: the push sign-in (stamp with the app-role claim, resume)
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -951,10 +1050,9 @@ public class AuthFunctionSideEffectTests
|
||||
config.DomainName = "contoso.com";
|
||||
config.EntraAppRolesEnabled = true;
|
||||
ArrangeAuthMe(config, exists: true, approved: true);
|
||||
_tenantAdminsMock.Setup(x => x.GetTenantAdminsAsync(TenantId))
|
||||
.ReturnsAsync(new List<TenantAdminRow> { new() { TenantId = TenantId, Upn = "first@contoso.com" } });
|
||||
_tenantAdminsMock.Setup(x => x.GetTableMembershipAsync(TenantId, Upn)).ReturnsAsync((memberRow,
|
||||
memberRow == TableMemberState.Enabled ? new MemberRoleInfo { Role = Constants.TenantRoles.Operator } : null));
|
||||
_tenantAdminsMock.Setup(x => x.GetMembershipSnapshotAsync(TenantId, Upn)).ReturnsAsync((memberRow,
|
||||
memberRow == TableMemberState.Enabled ? new MemberRoleInfo { Role = Constants.TenantRoles.Operator } : null,
|
||||
true));
|
||||
}
|
||||
|
||||
private (AuthFunction Sut, Push.PushDeliveryServiceTests.Harness Push) BuildWithPushSignIn()
|
||||
@@ -962,7 +1060,7 @@ public class AuthFunctionSideEffectTests
|
||||
var push = new Push.PushDeliveryServiceTests.Harness();
|
||||
var signIn = new PushOwnerSignInService(push.Repo, new Mock<IAdminRepository>().Object, push.Settings, push.Sut,
|
||||
Mock.Of<ILogger<PushOwnerSignInService>>());
|
||||
return (_buildSut(signIn), push);
|
||||
return (_buildSut(signIn, null, null), push);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@@ -545,6 +545,83 @@ public class AuthFunctionTests
|
||||
Assert.True((bool)body.isTenantAdmin);
|
||||
}
|
||||
|
||||
private static TenantConfiguration ConfigOnboardedBy(string? onboardedBy)
|
||||
{
|
||||
var config = DefaultConfig();
|
||||
config.OnboardedBy = onboardedBy;
|
||||
return config;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoAdmin_DesignatedOnboardingMember_AnyOtherRolelessCallerStaysRoleless()
|
||||
{
|
||||
// The activation race: the tenant is activated, its partition is empty for a moment, and a colleague
|
||||
// polls auth/me. The first-user seat belongs to the onboarding member alone.
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
ConfigOnboardedBy("first.member@contoso.com"), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: null, mcpCheck: McpDenied(),
|
||||
hasTenantAdmins: false,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.NeedsAutoAdmin);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.False((bool)body.isTenantAdmin);
|
||||
Assert.Null((string?)body.role);
|
||||
Assert.False((bool)body.canManageBootstrapTokens);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AutoAdmin_DesignatedOnboardingMember_TakesTheSeatWhateverTheCasing()
|
||||
{
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
ConfigOnboardedBy(Upn.ToUpperInvariant()), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: null, mcpCheck: McpDenied(),
|
||||
hasTenantAdmins: false,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.NeedsAutoAdmin);
|
||||
var body = ToDynamic(result.Body);
|
||||
Assert.True((bool)body.isTenantAdmin);
|
||||
Assert.Equal(Constants.TenantRoles.Admin, (string)body.role);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("System (Global Rate Limit Sync)")]
|
||||
[InlineData("alice_fabrikam.example#EXT#@contoso.example")]
|
||||
public void AutoAdmin_RecordedOnboardingMemberActivationCannotSeat_StillBlocksEveryoneElse(string onboardedBy)
|
||||
{
|
||||
// A recorded member counts whatever its shape: nobody else takes the seat, and the holder of such a
|
||||
// value is an external identity (or no member at all) and excluded on its own.
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
ConfigOnboardedBy(onboardedBy), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: null, mcpCheck: McpDenied(),
|
||||
hasTenantAdmins: false,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.NeedsAutoAdmin);
|
||||
Assert.False((bool)ToDynamic(result.Body).isTenantAdmin);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void AutoAdmin_WithoutARecordedOnboardingMember_TheFirstSignInStillBecomesAdmin(string? onboardedBy)
|
||||
{
|
||||
// Rows from before OnboardedBy existed.
|
||||
var result = AuthFunction.BuildAuthResult(
|
||||
ConfigOnboardedBy(onboardedBy), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
|
||||
memberRole: null, mcpCheck: McpDenied(),
|
||||
hasTenantAdmins: false,
|
||||
TenantId, Upn, DisplayName, ObjectId);
|
||||
|
||||
Assert.True(result.NeedsAutoAdmin);
|
||||
Assert.True((bool)ToDynamic(result.Body).isTenantAdmin);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MCP Access
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -27,6 +27,12 @@ public class DelegatedAdminServiceTests
|
||||
private const string TenantC = "33333333-3333-3333-3333-333333333333";
|
||||
|
||||
private static (DelegatedAdminService Svc, Mock<IAdminRepository> Repo) Build()
|
||||
{
|
||||
var (svc, repo, _) = BuildWithBindings();
|
||||
return (svc, repo);
|
||||
}
|
||||
|
||||
private static (DelegatedAdminService Svc, Mock<IAdminRepository> Repo, StubAdminIdentityBindingService Bindings) BuildWithBindings()
|
||||
{
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()))
|
||||
@@ -38,14 +44,18 @@ public class DelegatedAdminServiceTests
|
||||
// Entitlements stubbed to Enterprise for every tenant — these tests pin ROLE resolution;
|
||||
// the edition filter has its own dedicated tests (DelegatedAdminEditionGateTests).
|
||||
// Binding stubbed to "bound" — these tests pin ROW resolution; the identity check has its own
|
||||
// tests (AdminIdentityBindingServiceTests / AdminIdentityBindingAuthorizationTests).
|
||||
// tests (AdminIdentityBindingServiceTests / AdminIdentityBindingAuthorizationTests). The binding
|
||||
// read homes the assignees of these tests in HomeTenant, which owns the self-service group below.
|
||||
var bindings = new StubAdminIdentityBindingService(bound: true);
|
||||
bindings.BoundTenants[Upn] = HomeTenant;
|
||||
bindings.BoundTenants[SelfServiceAppKey] = HomeTenant;
|
||||
var svc = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubAdminIdentityBindingService(bound: true),
|
||||
bindings,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Pro),
|
||||
cache,
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
return (svc, repo);
|
||||
return (svc, repo, bindings);
|
||||
}
|
||||
|
||||
/// <summary>The caller identity for a UPN: homed in <see cref="HomeTenant"/> with <see cref="Oid"/>.</summary>
|
||||
@@ -667,10 +677,12 @@ public class DelegatedAdminServiceTests
|
||||
repo.Verify(r => r.GetTenantMemberAsync(HomeTenant, Upn), Times.Once);
|
||||
}
|
||||
|
||||
private const string SelfServiceAppKey = "app:0a0a0a0a-0000-0000-0000-000000000001";
|
||||
|
||||
[Fact]
|
||||
public async Task GetScope_SelfServiceGroup_ApplicationPrincipal_IsGatedOnItsAppKeyRow()
|
||||
{
|
||||
const string appKey = "app:0a0a0a0a-0000-0000-0000-000000000001";
|
||||
const string appKey = SelfServiceAppKey;
|
||||
var (svc, repo) = Build();
|
||||
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(appKey)).ReturnsAsync(new List<TenantGroupAssignment>
|
||||
{
|
||||
@@ -686,10 +698,10 @@ public class DelegatedAdminServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScope_OperatorGroup_NeverReadsTenantMembership()
|
||||
public async Task GetScope_OperatorGroup_NeverReadsTenantMembershipOrTheBinding()
|
||||
{
|
||||
// Operator groups (GUID ids) are granted by a Global Admin to people who are not members of any owner.
|
||||
var (svc, repo) = Build();
|
||||
var (svc, repo, bindings) = BuildWithBindings();
|
||||
ReturnsGroups(repo, Assignment(GroupId1));
|
||||
GroupTenants(repo, GroupId1, TenantA);
|
||||
|
||||
@@ -697,6 +709,62 @@ public class DelegatedAdminServiceTests
|
||||
|
||||
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantA));
|
||||
repo.Verify(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
Assert.Equal(0, bindings.GetCalls);
|
||||
}
|
||||
|
||||
// ── ...and only while its assignee is bound to the owner: a self-service grant resolves under the owner alone ──
|
||||
|
||||
[Theory]
|
||||
[InlineData(TenantC)] // a Global Admin rebound the person to their real tenant (the squat chain)
|
||||
[InlineData(null)] // no binding at all
|
||||
public async Task GetScope_SelfServiceGroup_AssigneeNotBoundToTheOwner_ConfersNothing(string? boundTenant)
|
||||
{
|
||||
var (svc, repo, bindings) = BuildWithBindings();
|
||||
ReturnsGroups(repo, Assignment(SelfServiceGroup));
|
||||
GroupTenants(repo, SelfServiceGroup, TenantA);
|
||||
OwnerMember(repo, Upn, enabled: true);
|
||||
if (boundTenant == null)
|
||||
bindings.BoundTenants.Remove(Upn);
|
||||
else
|
||||
bindings.BoundTenants[Upn] = boundTenant;
|
||||
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.True(scope.IsEmpty);
|
||||
repo.Verify(r => r.GetGroupMembershipAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetScope_ReboundSelfServiceAssignee_KeepsOperatorGroupAndDirectGrant()
|
||||
{
|
||||
var (svc, repo, bindings) = BuildWithBindings();
|
||||
Returns(repo, Row(TenantC));
|
||||
ReturnsGroups(repo, Assignment(SelfServiceGroup), Assignment(GroupId1));
|
||||
GroupTenants(repo, SelfServiceGroup, TenantA);
|
||||
GroupTenants(repo, GroupId1, TenantB);
|
||||
OwnerMember(repo, Upn, enabled: true);
|
||||
bindings.BoundTenants[Upn] = TenantC;
|
||||
|
||||
var scope = await svc.GetScopeAsync(Id(Upn));
|
||||
|
||||
Assert.Null(scope.RoleFor(TenantA));
|
||||
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantB));
|
||||
Assert.Equal(Constants.DelegatedRoles.DelegatedReader, scope.RoleFor(TenantC));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetFreshScope_ReadsTheBindingFresh_AfterARebind()
|
||||
{
|
||||
// The SignalR re-join after a rebind's cut must not be admitted by a row scope cached before it.
|
||||
var (svc, repo, bindings) = BuildWithBindings();
|
||||
ReturnsGroups(repo, Assignment(SelfServiceGroup));
|
||||
GroupTenants(repo, SelfServiceGroup, TenantA);
|
||||
OwnerMember(repo, Upn, enabled: true);
|
||||
Assert.NotNull((await svc.GetScopeAsync(Id(Upn))).RoleFor(TenantA)); // warms the row cache
|
||||
|
||||
bindings.BoundTenants[Upn] = TenantC; // rebound elsewhere
|
||||
|
||||
Assert.True((await svc.GetFreshScopeAsync(Id(Upn))).IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -40,6 +41,7 @@ public class DelegationSelfServiceTests
|
||||
public required Mock<IDelegationInvitationRepository> Invitations { get; init; }
|
||||
public required Mock<IMaintenanceRepository> Audit { get; init; }
|
||||
public required FakeSignalRNotificationService SignalR { get; init; }
|
||||
public required StubAdminIdentityBindingService Bindings { get; init; }
|
||||
public required List<DelegationInvitation> Rows { get; init; }
|
||||
public required List<string> GroupTenants { get; init; }
|
||||
public required Mock<ProConferralService> ProConferral { get; init; }
|
||||
@@ -110,7 +112,10 @@ public class DelegationSelfServiceTests
|
||||
var entitlements = homeResolution is EditionResolution resolution
|
||||
? new StubTenantEntitlementService(t => t == Home ? resolution : new EditionResolution(TenantEdition.Community, EditionSource.Community, OwnPro: false), null)
|
||||
: new StubTenantEntitlementService(t => t == Home ? homeEdition : TenantEdition.Community);
|
||||
var delegatedAdmins = new DelegatedAdminService(repo.Object, new StubAdminIdentityBindingService(bound: true), entitlements, cache, NullLogger<DelegatedAdminService>.Instance);
|
||||
// A self-service assignee is homed in the managing tenant (the assignment binds it there).
|
||||
var bindings = new StubAdminIdentityBindingService(bound: true);
|
||||
bindings.BoundTenants[MspUser] = Home;
|
||||
var delegatedAdmins = new DelegatedAdminService(repo.Object, bindings, entitlements, cache, NullLogger<DelegatedAdminService>.Instance);
|
||||
var slots = new DelegatedSlotService(repo.Object, configRepo.Object, invitations.Object, cache, NullLogger<DelegatedSlotService>.Instance, time);
|
||||
var audit = new Mock<IMaintenanceRepository>();
|
||||
audit.Setup(a => a.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
|
||||
@@ -124,9 +129,10 @@ public class DelegationSelfServiceTests
|
||||
ManagedTenantProIndex.None,
|
||||
NullLogger<ProConferralService>.Instance) { CallBase = false };
|
||||
|
||||
var svc = new DelegationSelfService(repo.Object, invitations.Object, delegatedAdmins, slots, entitlements, configRepo.Object, audit.Object, signalR,
|
||||
var streams = new LiveStreamRevoker(signalR, bindings, NullLogger<LiveStreamRevoker>.Instance);
|
||||
var svc = new DelegationSelfService(repo.Object, invitations.Object, delegatedAdmins, slots, entitlements, configRepo.Object, audit.Object, streams,
|
||||
proConferral.Object, NullLogger<DelegationSelfService>.Instance, time);
|
||||
return new Harness { Svc = svc, Repo = repo, Invitations = invitations, Audit = audit, SignalR = signalR, Rows = rows, GroupTenants = groupTenants, ProConferral = proConferral };
|
||||
return new Harness { Svc = svc, Repo = repo, Invitations = invitations, Audit = audit, SignalR = signalR, Bindings = bindings, Rows = rows, GroupTenants = groupTenants, ProConferral = proConferral };
|
||||
}
|
||||
|
||||
// ── Conferred Pro hooks ───────────────────────────────────────────────────────
|
||||
@@ -444,7 +450,7 @@ public class DelegationSelfServiceTests
|
||||
h.Audit.Verify(a => a.LogAuditEntryAsync(Customer, "DELETE", "DelegatedGroupAccess", MspUser, MspAdmin,
|
||||
It.Is<Dictionary<string, string>?>(d => d != null && d["Reason"] == "customer-removed-by-manager")), Times.Once);
|
||||
VerifyAudit(h.Audit, Home, "DELETE", "DelegationManagedTenant", Times.Once());
|
||||
Assert.Contains(MspUser, h.SignalR.DisconnectedUsers);
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(Home, MspUser) }, h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -462,6 +468,8 @@ public class DelegationSelfServiceTests
|
||||
Assert.Equal(Customer, hold.TenantId);
|
||||
h.Audit.Verify(a => a.LogAuditEntryAsync(Customer, "DELETE", "DelegatedGroupAccess", MspUser, CustomerAdmin,
|
||||
It.Is<Dictionary<string, string>?>(d => d != null && d["Reason"] == "customer-revoked")), Times.Once);
|
||||
// The customer's revoke cuts the assignees' streams under the managing tenant, where the grant resolved.
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(Home, MspUser) }, h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
// ── Assignees ───────────────────────────────────────────────────────────────
|
||||
@@ -610,7 +618,24 @@ public class DelegationSelfServiceTests
|
||||
Assert.True(r.Ok);
|
||||
h.Audit.Verify(a => a.LogAuditEntryAsync(Customer, "DELETE", "DelegatedGroupAccess", MspUser, MspAdmin,
|
||||
It.Is<Dictionary<string, string>?>(d => d != null && d["Reason"] == "self-service-unassigned")), Times.Once);
|
||||
Assert.Equal(new[] { MspUser }, h.SignalR.DisconnectedUsers);
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(Home, MspUser) }, h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unassign_OfAnAssigneeRehomedElsewhere_CutsOnlyUnderTheManagingTenant()
|
||||
{
|
||||
// The squat chain of the security review: the assignment bound the UPN here, a Global Admin later rebound it
|
||||
// to its real tenant. The managing tenant's unassign must not reach the UPN's connections there.
|
||||
var h = Build();
|
||||
h.GroupTenants.Add(Customer);
|
||||
AssignedToTheGroup(h);
|
||||
h.Bindings.BoundTenants[MspUser] = Other;
|
||||
|
||||
var r = await h.Svc.UnassignAsync(Home, MspUser, MspAdmin);
|
||||
|
||||
Assert.True(r.Ok);
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(Home, MspUser) }, h.SignalR.DisconnectedUsers);
|
||||
Assert.DoesNotContain(SignalRGroupHelper.UserId(Other, MspUser), h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
// ── Managers (customer side) ────────────────────────────────────────────────
|
||||
|
||||
@@ -365,6 +365,51 @@ public class DiagnosisHeadlineBuilderTests
|
||||
Assert.Equal(DiagnosisCauses.EspStepFailure, h.Cause);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_device_preparation_provider_is_no_culprit_for_a_later_apps_failure()
|
||||
{
|
||||
// A ConfigMgr client installed as a Win32 app registers its device-preparation provider
|
||||
// after device preparation ended; its stall explains no device-setup failure.
|
||||
var events = new Timeline()
|
||||
.Add("esp_policy_provider_stalled", new()
|
||||
{
|
||||
["reason"] = "provider_incomplete",
|
||||
["stalledProviders"] = new List<object>
|
||||
{
|
||||
new Dictionary<string, object> { ["name"] = "ConfigMgr", ["scope"] = "device", ["kind"] = "devicePreparation", ["stalledForMinutes"] = 15.0 },
|
||||
},
|
||||
})
|
||||
.Add("enrollment_failed", Failed("DeviceSetup", "Apps"));
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed), events.Events);
|
||||
|
||||
Assert.Null(h.Culprits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_device_preparation_providers_explain_a_device_preparation_failure()
|
||||
{
|
||||
// Field shape: the ESP never left device preparation (0x800705b4).
|
||||
var events = new Timeline()
|
||||
.Add("esp_policy_provider_stalled", new()
|
||||
{
|
||||
["reason"] = "sidecar_provider_missing",
|
||||
["stalledProviders"] = new List<object>
|
||||
{
|
||||
new Dictionary<string, object> { ["name"] = "ConfigMgr", ["scope"] = "device", ["kind"] = "devicePreparation", ["stalledForMinutes"] = 40.0 },
|
||||
new Dictionary<string, object> { ["name"] = "Contoso", ["scope"] = "device", ["kind"] = "setupApps", ["stalledForMinutes"] = 40.0 },
|
||||
},
|
||||
})
|
||||
.Add("enrollment_failed", Failed("DevicePreparation", "EspProviderInstallation", "0x800705b4"));
|
||||
|
||||
var h = DiagnosisHeadlineBuilder.Build(Session(SessionStatus.Failed, VerdictPaths.AgentFailed), events.Events);
|
||||
|
||||
var provider = Assert.Single(h.Culprits!);
|
||||
Assert.Equal(DiagnosisCulpritKinds.PolicyProvider, provider.Kind);
|
||||
Assert.Equal("ConfigMgr", provider.Name);
|
||||
Assert.Equal(40.0, provider.StalledForMinutes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_appx_log_candidate_joins_the_culprits()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
@@ -76,18 +77,21 @@ public class FakeSignalRNotificationService : ISignalRNotificationService
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public List<string> NegotiatedUserIds { get; } = new();
|
||||
/// <summary>The SignalR user id each negotiation was bound to, composed like the real service (null: no user id).</summary>
|
||||
public List<string?> NegotiatedUserIds { get; } = new();
|
||||
|
||||
/// <summary>The SignalR user id each cut addressed, composed like the real service.</summary>
|
||||
public List<string> DisconnectedUsers { get; } = new();
|
||||
|
||||
public Task<(string Url, string AccessToken)?> NegotiateClientAsync(string userId)
|
||||
public Task<(string Url, string AccessToken)?> NegotiateClientAsync(string tenantId, string? principalKey)
|
||||
{
|
||||
NegotiatedUserIds.Add(userId);
|
||||
NegotiatedUserIds.Add(string.IsNullOrEmpty(principalKey) ? null : SignalRGroupHelper.UserId(tenantId, principalKey));
|
||||
return Task.FromResult<(string Url, string AccessToken)?>(("https://fake.signalr.test/client", "fake-token"));
|
||||
}
|
||||
|
||||
public Task DisconnectUserAsync(string userId)
|
||||
public Task DisconnectUserAsync(string tenantId, string principalKey)
|
||||
{
|
||||
DisconnectedUsers.Add(userId);
|
||||
DisconnectedUsers.Add(SignalRGroupHelper.UserId(tenantId, principalKey));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Functions.Infrastructure;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Azure.Core.Serialization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A live SignalR connection is addressed by its principal (sign-in tenant plus principal key), and a revoke names the
|
||||
/// tenant the revoked grant resolved under, so an action in one tenant never restarts the same UPN's connections in
|
||||
/// another. Pins the ends of that chain: negotiate binds the pair from the validated request context,
|
||||
/// <see cref="LiveStreamRevoker"/> finds the tenant a platform or delegated grant was exercised under, the
|
||||
/// identity-binding routes cut the previous principal, and the call-site inventory keeps these the only paths.
|
||||
/// </summary>
|
||||
public class LiveStreamPrincipalTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string OtherTenantId = "22222222-2222-2222-2222-222222222222";
|
||||
private const string Upn = "person@partner.example";
|
||||
private const string OperatorUpn = "ga@vendor.example";
|
||||
|
||||
// ── negotiate ────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Negotiate_binds_the_connection_to_the_sign_in_tenant_and_principal_key()
|
||||
{
|
||||
var signalR = new FakeSignalRNotificationService();
|
||||
var req = BuildRequest(new RequestContext { TenantId = TenantId, UserPrincipalName = "Person@Partner.Example" });
|
||||
|
||||
var response = await new SignalRNegotiateFunction(NullLogger<SignalRNegotiateFunction>.Instance, signalR).Negotiate(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(new string?[] { SignalRGroupHelper.UserId(TenantId, Upn) }, signalR.NegotiatedUserIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Negotiate_without_a_principal_key_binds_no_user_id()
|
||||
{
|
||||
// Every revocable join gate needs the key, so such a connection holds nothing a revoke would have to reach;
|
||||
// a login the portal keeps on token claims alone still gets its Progress Portal live updates.
|
||||
var signalR = new FakeSignalRNotificationService();
|
||||
var req = BuildRequest(new RequestContext { TenantId = TenantId, UserPrincipalName = string.Empty });
|
||||
|
||||
var response = await new SignalRNegotiateFunction(NullLogger<SignalRNegotiateFunction>.Instance, signalR).Negotiate(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(new string?[] { null }, signalR.NegotiatedUserIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Negotiate_unauthenticated_is_refused_without_negotiating()
|
||||
{
|
||||
var signalR = new FakeSignalRNotificationService();
|
||||
var req = BuildRequest(new RequestContext { TenantId = TenantId, UserPrincipalName = Upn }, authenticated: false);
|
||||
|
||||
var response = await new SignalRNegotiateFunction(NullLogger<SignalRNegotiateFunction>.Instance, signalR).Negotiate(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
Assert.Empty(signalR.NegotiatedUserIds);
|
||||
}
|
||||
|
||||
// ── LiveStreamRevoker ────────────────────────────────────────────────────
|
||||
|
||||
private static (LiveStreamRevoker Revoker, FakeSignalRNotificationService SignalR, StubAdminIdentityBindingService Bindings) BuildRevoker()
|
||||
{
|
||||
var signalR = new FakeSignalRNotificationService();
|
||||
var bindings = new StubAdminIdentityBindingService();
|
||||
return (new LiveStreamRevoker(signalR, bindings, NullLogger<LiveStreamRevoker>.Instance), signalR, bindings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cut_addresses_exactly_the_named_tenant()
|
||||
{
|
||||
var (revoker, signalR, bindings) = BuildRevoker();
|
||||
bindings.BoundTenants[Upn] = OtherTenantId;
|
||||
|
||||
await revoker.CutAsync(TenantId, Upn);
|
||||
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, Upn) }, signalR.DisconnectedUsers);
|
||||
Assert.Equal(0, bindings.GetCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CutBoundIdentity_addresses_the_tenant_of_the_binding()
|
||||
{
|
||||
var (revoker, signalR, bindings) = BuildRevoker();
|
||||
bindings.BoundTenants[Upn] = OtherTenantId;
|
||||
|
||||
await revoker.CutBoundIdentityAsync(Upn);
|
||||
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(OtherTenantId, Upn) }, signalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CutBoundIdentity_without_a_binding_cuts_nothing()
|
||||
{
|
||||
// No binding ⇒ no platform or delegated grant of the UPN ever resolved ⇒ no connection holds its groups.
|
||||
var (revoker, signalR, _) = BuildRevoker();
|
||||
|
||||
await revoker.CutBoundIdentityAsync(Upn);
|
||||
|
||||
Assert.Empty(signalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CutBoundIdentity_survives_a_failed_binding_read_without_cutting()
|
||||
{
|
||||
var (revoker, signalR, bindings) = BuildRevoker();
|
||||
bindings.GetThrows = new InvalidOperationException("storage unavailable");
|
||||
|
||||
await revoker.CutBoundIdentityAsync(Upn);
|
||||
|
||||
Assert.Empty(signalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
// ── identity-binding routes cut the previous principal ───────────────────
|
||||
|
||||
private static (IdentityBindingManagementFunction Fn, FakeSignalRNotificationService SignalR, StubAdminIdentityBindingService Bindings) BuildBindingFn()
|
||||
{
|
||||
var (revoker, signalR, bindings) = BuildRevoker();
|
||||
var fn = new IdentityBindingManagementFunction(
|
||||
NullLogger<IdentityBindingManagementFunction>.Instance, bindings, Mock.Of<IMaintenanceRepository>(), revoker);
|
||||
return (fn, signalR, bindings);
|
||||
}
|
||||
|
||||
private static RequestContext Operator() => new() { TenantId = OtherTenantId, UserPrincipalName = OperatorUpn, IsGlobalAdmin = true };
|
||||
|
||||
[Fact]
|
||||
public async Task Replacing_a_binding_cuts_the_connections_signed_in_under_the_previous_one()
|
||||
{
|
||||
var (fn, signalR, bindings) = BuildBindingFn();
|
||||
bindings.BoundTenants[Upn] = TenantId;
|
||||
var req = BuildRequest(Operator(), new { homeTenantId = OtherTenantId });
|
||||
|
||||
var response = await fn.PutIdentityBinding(req, Upn, req.FunctionContext);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, Upn) }, signalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Creating_a_binding_cuts_nothing()
|
||||
{
|
||||
var (fn, signalR, _) = BuildBindingFn();
|
||||
var req = BuildRequest(Operator(), new { homeTenantId = TenantId });
|
||||
|
||||
var response = await fn.PutIdentityBinding(req, Upn, req.FunctionContext);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Empty(signalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deleting_a_binding_cuts_the_connections_signed_in_under_it()
|
||||
{
|
||||
var (fn, signalR, bindings) = BuildBindingFn();
|
||||
bindings.BoundTenants[Upn] = TenantId;
|
||||
var req = BuildRequest(Operator());
|
||||
|
||||
var response = await fn.DeleteIdentityBinding(req, Upn, req.FunctionContext);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, Upn) }, signalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Deleting_a_missing_binding_is_404_and_cuts_nothing()
|
||||
{
|
||||
var (fn, signalR, _) = BuildBindingFn();
|
||||
var req = BuildRequest(Operator());
|
||||
|
||||
var response = await fn.DeleteIdentityBinding(req, Upn, req.FunctionContext);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Empty(signalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
// ── call-site inventory ──────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Only_the_revoker_cuts_streams_and_only_negotiate_binds_connections()
|
||||
{
|
||||
// A second cut site would have to decide the principal on its own — the decision this change put in one place.
|
||||
var functionsRoot = Path.Combine(FindRepoRoot(), "src", "Backend", "AutopilotMonitor.Functions");
|
||||
var cuts = new List<string>();
|
||||
var negotiations = new List<string>();
|
||||
|
||||
foreach (var file in Directory.EnumerateFiles(functionsRoot, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = Path.GetRelativePath(functionsRoot, file).Replace(Path.DirectorySeparatorChar, '/');
|
||||
if (relative.StartsWith("bin/", StringComparison.OrdinalIgnoreCase) ||
|
||||
relative.StartsWith("obj/", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var text = File.ReadAllText(file);
|
||||
if (text.Contains(".DisconnectUserAsync(", StringComparison.Ordinal))
|
||||
cuts.Add(relative);
|
||||
if (text.Contains(".NegotiateClientAsync(", StringComparison.Ordinal))
|
||||
negotiations.Add(relative);
|
||||
}
|
||||
|
||||
Assert.Equal(new[] { "Services/LiveStreamRevoker.cs" }, cuts);
|
||||
Assert.Equal(new[] { "Functions/Infrastructure/SignalRNegotiateFunction.cs" }, negotiations);
|
||||
}
|
||||
|
||||
// ── harness ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static HttpRequestData BuildRequest(RequestContext requestContext, object? body = null, bool authenticated = true)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
// Production wire settings, so camelCase test bodies bind to the request models like real requests do.
|
||||
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer(ApiJsonOptions.Create()));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var items = new Dictionary<object, object> { [RequestContext.ItemsKey] = requestContext };
|
||||
if (authenticated)
|
||||
items["ClaimsPrincipal"] = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("tid", requestContext.TenantId) }, "test"));
|
||||
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(items);
|
||||
context.SetupGet(c => c.InstanceServices).Returns(provider);
|
||||
|
||||
var req = new Mock<HttpRequestData>(context.Object);
|
||||
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
|
||||
req.SetupGet(r => r.Body).Returns(new MemoryStream(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(body ?? new { }))));
|
||||
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context.Object));
|
||||
return req.Object;
|
||||
}
|
||||
|
||||
private sealed class FakeHttpResponseData : HttpResponseData
|
||||
{
|
||||
public FakeHttpResponseData(FunctionContext context) : base(context) { }
|
||||
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||||
public override HttpHeadersCollection Headers { get; set; } = new();
|
||||
public override Stream Body { get; set; } = new MemoryStream();
|
||||
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,9 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// Pins the OOBE bootstrap script generator, ported from the portal's
|
||||
/// app/go/[code]/route.ts. The golden file TestData/oobe-bootstrap-golden.ps1 is the
|
||||
/// pin of the shipped template (modulo line endings): first captured from the TS
|
||||
/// template literal, since D-354 extended with the payload catalog check that the
|
||||
/// generator embeds verbatim from the installer. Ports the behavioral cases of the
|
||||
/// template literal, since D-354 extended with the installer's shared install helpers
|
||||
/// (payload catalog check, protected agent folder, run folder), which the generator
|
||||
/// embeds verbatim. Ports the behavioral cases of the
|
||||
/// vitest suite app/go/[code]/__tests__/route.test.ts that concern script generation.
|
||||
/// </summary>
|
||||
public class OobeBootstrapScriptGeneratorTests
|
||||
@@ -50,8 +51,8 @@ public class OobeBootstrapScriptGeneratorTests
|
||||
var script = Generate();
|
||||
|
||||
Assert.StartsWith("#Requires -RunAsAdministrator", script);
|
||||
Assert.Contains($"--tenant-id '{TenantId}'", script);
|
||||
Assert.Contains($"--bootstrap-token '{Token}'", script);
|
||||
Assert.Contains($"'--tenant-id', '{TenantId}'", script);
|
||||
Assert.Contains($"'--bootstrap-token', '{Token}'", script);
|
||||
Assert.Contains($"$AgentDownloadUrl = '{DownloadUrl}'", script);
|
||||
Assert.Contains("Generated: 2026-07-28T12:00:00.000Z", script);
|
||||
Assert.Contains("Expires: 2026-08-01T12:00:00.000Z", script);
|
||||
@@ -73,9 +74,10 @@ public class OobeBootstrapScriptGeneratorTests
|
||||
Assert.Contains("'Bootstrap token expires: 2026-08-01T12:00:00.000Z'", script);
|
||||
}
|
||||
|
||||
// ── D-354: the payload is checked against its signed catalog before --install ──
|
||||
// ── D-354: the payload is checked against its signed catalog before --install, with the
|
||||
// installer's own code for that and for the agent folder ──
|
||||
[Fact]
|
||||
public void SuccessScript_embeds_the_installers_payload_verification_verbatim()
|
||||
public void SuccessScript_embeds_the_installers_shared_install_helpers_verbatim()
|
||||
{
|
||||
var installer = File.ReadAllText(Path.Combine(FindRepoRoot(), "scripts", "Bootstrap", "Install-AutopilotMonitor.ps1"))
|
||||
.Replace("\r\n", "\n");
|
||||
@@ -85,22 +87,42 @@ public class OobeBootstrapScriptGeneratorTests
|
||||
|
||||
Assert.Contains("function Get-AgentPayloadRejections", block);
|
||||
Assert.Contains("function Install-VerifiedAgentPayload", block);
|
||||
Assert.Contains("function Initialize-AgentBase", block);
|
||||
Assert.Contains("function New-RunFolder", block);
|
||||
Assert.Contains("function Remove-OwnTree", block);
|
||||
Assert.Contains("function Invoke-AgentInstallMode", block);
|
||||
Assert.Contains(block, Generate().Replace("\r\n", "\n"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessScript_verifies_the_payload_before_installing_the_agent()
|
||||
public void SuccessScript_prepares_the_folder_and_verifies_the_payload_before_installing_the_agent()
|
||||
{
|
||||
var script = Generate();
|
||||
|
||||
var prepare = script.IndexOf("Initialize-AgentBase -Path $AgentBasePath", StringComparison.Ordinal);
|
||||
var verify = script.IndexOf(
|
||||
"Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName",
|
||||
"Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $staging -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName",
|
||||
StringComparison.Ordinal);
|
||||
var install = script.IndexOf("& $agentExePath --install", StringComparison.Ordinal);
|
||||
var install = script.IndexOf("Invoke-AgentInstallMode -ExePath $agentExePath -Arguments @('--install'", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(verify > 0, "the generated script never verifies the payload");
|
||||
Assert.True(prepare > 0, "the generated script never prepares the agent folder");
|
||||
Assert.True(verify > prepare, "the payload check must run in the prepared folder");
|
||||
Assert.True(install > verify, "--install must run after the payload check");
|
||||
Assert.DoesNotContain("Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath", script);
|
||||
Assert.DoesNotContain("$env:TEMP", script);
|
||||
}
|
||||
|
||||
// #Requires is ignored under irm | iex, so the script checks for itself before it changes anything.
|
||||
[Fact]
|
||||
public void SuccessScript_refuses_to_run_without_elevation_before_it_changes_anything()
|
||||
{
|
||||
var script = Generate();
|
||||
|
||||
var check = script.IndexOf("if (-not (Test-IsElevated)) {", StringComparison.Ordinal);
|
||||
var tryStart = script.IndexOf("\ntry {", StringComparison.Ordinal);
|
||||
|
||||
Assert.True(check > tryStart && tryStart > 0, "the elevation check must open the main block");
|
||||
Assert.True(check < script.IndexOf("Initialize-AgentBase -Path $AgentBasePath", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -269,6 +269,28 @@ public class PolicyEnforcementMiddlewareTests
|
||||
private static ClaimsPrincipal AuthedPrincipal(string tenantId, string upn, string objectId = TestOid)
|
||||
=> new(new ClaimsIdentity(new[] { new Claim("tid", tenantId), new Claim("upn", upn), new Claim("oid", objectId) }, "TestAuth"));
|
||||
|
||||
// ── A grant written on another instance counts on the next request ──────────────
|
||||
|
||||
/// <summary>
|
||||
/// A new tenant's first admin opens the dashboard on an instance that resolved "no row" for them seconds
|
||||
/// earlier, while the auto-approve worker wrote their promotion out of reach of this process's cache
|
||||
/// invalidation. The session list must already admit them.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MemberRead_RowWrittenAfterADenial_IsAdmittedOnTheNextRequest()
|
||||
{
|
||||
const string upn = "first.admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
|
||||
var before = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
var after = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(before.Allowed);
|
||||
Assert.True(after.Allowed);
|
||||
Assert.True(after.Context!.IsTenantAdmin);
|
||||
}
|
||||
|
||||
// ── ADDITIVE: GlobalReader + own-tenant Admin keeps IsTenantAdmin ──────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The activation check auth/me gates on. The auto-approve worker writes the activation on another instance
|
||||
/// while the activation page polls, so "not activated" is never cached; an activation is.
|
||||
/// </summary>
|
||||
public class PreviewWhitelistApprovalCacheTests
|
||||
{
|
||||
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
|
||||
private readonly Mock<IConfigRepository> _repo = new();
|
||||
private readonly PreviewWhitelistService _sut;
|
||||
|
||||
public PreviewWhitelistApprovalCacheTests()
|
||||
{
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_sut = new PreviewWhitelistService(_repo.Object, cache, NullLogger<PreviewWhitelistService>.Instance,
|
||||
new TenantConfigurationService(_repo.Object, NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NotActivated_IsNotCached_SoTheWorkersActivationCountsAtOnce()
|
||||
{
|
||||
_repo.SetupSequence(r => r.IsInPreviewWhitelistAsync(TenantId)).ReturnsAsync(false).ReturnsAsync(true);
|
||||
|
||||
Assert.False(await _sut.IsApprovedAsync(TenantId));
|
||||
Assert.True(await _sut.IsApprovedAsync(TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Activated_IsServedFromTheCache()
|
||||
{
|
||||
_repo.SetupSequence(r => r.IsInPreviewWhitelistAsync(TenantId)).ReturnsAsync(true).ReturnsAsync(false);
|
||||
|
||||
Assert.True(await _sut.IsApprovedAsync(TenantId));
|
||||
Assert.True(await _sut.IsApprovedAsync(TenantId));
|
||||
_repo.Verify(r => r.IsInPreviewWhitelistAsync(TenantId), Times.Once);
|
||||
}
|
||||
|
||||
// --- RevokeAsync: the revoke record comes first and is fail-loud; it is what stops a later re-activation
|
||||
// from re-seating an onboarding member the tenant removed.
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_RecordsTheRevokeBeforeItRemovesTheActivation()
|
||||
{
|
||||
var writes = new List<string>();
|
||||
_repo.Setup(r => r.MarkRevokedAsync(TenantId, "ga@contoso.com")).Callback(() => writes.Add("record")).Returns(Task.CompletedTask);
|
||||
_repo.Setup(r => r.RemoveFromPreviewWhitelistAsync(TenantId)).Callback(() => writes.Add("remove")).ReturnsAsync(true);
|
||||
|
||||
await _sut.RevokeAsync(TenantId, "ga@contoso.com");
|
||||
|
||||
Assert.Equal(new[] { "record", "remove" }, writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Revoke_WhoseRecordFails_ChangesNothing()
|
||||
{
|
||||
_repo.Setup(r => r.MarkRevokedAsync(TenantId, "ga@contoso.com")).ThrowsAsync(new InvalidOperationException("storage"));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.RevokeAsync(TenantId, "ga@contoso.com"));
|
||||
|
||||
_repo.Verify(r => r.RemoveFromPreviewWhitelistAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
[InlineData("first.last@example.de")]
|
||||
[InlineData("cadm_user@contoso.onmicrosoft.com")]
|
||||
[InlineData("m.muster_ext_@contoso.example")] // a member naming scheme, not the external marker
|
||||
[InlineData("systemadmin@contoso.com")] // a common admin-account prefix, not a sentinel
|
||||
[InlineData("system.ops@contoso.com")]
|
||||
public void IsPromotableUpn_AcceptsRealUpnShapes(string upn)
|
||||
{
|
||||
Assert.True(TenantApprovalService.IsPromotableUpn(upn));
|
||||
@@ -33,11 +35,13 @@ public class PreviewWhitelistRequesterUpnTests
|
||||
[InlineData("System (Global Rate Limit Sync)")]
|
||||
[InlineData("system (global rate limit sync)")]
|
||||
[InlineData("System (whatever-future-sentinel)")]
|
||||
public void IsPromotableUpn_RejectsAnySystemPrefixSentinel(string upn)
|
||||
[InlineData("System (auto-approve) admin@contoso.com")] // an '@' does not rescue a value with spaces
|
||||
public void IsPromotableUpn_RejectsAnySentinelShape(string upn)
|
||||
{
|
||||
// The shape-based prefix check is exactly the defense against the corruption
|
||||
// pattern: any new "System (...)" sentinel introduced anywhere in the codebase
|
||||
// is rejected without anyone needing to remember to update this method.
|
||||
// Shape, not enumeration: a sentinel has no '@' or carries spaces, so any new "System (...)"
|
||||
// sentinel introduced anywhere in the codebase is rejected without anyone needing to remember
|
||||
// to update this method — while a real member whose name starts with "system" is accepted
|
||||
// (see the shapes above); a prefix test once locked such members out of their own tenant.
|
||||
Assert.False(TenantApprovalService.IsPromotableUpn(upn));
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ namespace AutopilotMonitor.Functions.Tests.Push;
|
||||
|
||||
/// <summary>
|
||||
/// The role-loss hooks (K6): a tenant member who is removed, disabled or demoted below Operator
|
||||
/// loses the paired push devices of that tenant, a removed or disabled Global Admin loses the
|
||||
/// platform-scope devices, and no other mutation touches a device. The hook is optional — the
|
||||
/// original constructors without a revoker keep working — and it keys on the NEW role only.
|
||||
/// loses the paired push devices of that tenant, a removed Global Admin loses the platform-scope
|
||||
/// devices, and no other mutation touches a device. The hook is optional — the original
|
||||
/// constructors without a revoker keep working — and it keys on the NEW role only.
|
||||
/// </summary>
|
||||
public class PushRoleLossHookTests
|
||||
{
|
||||
@@ -130,6 +130,21 @@ public class PushRoleLossHookTests
|
||||
h.AdminRepo.Verify(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Remove_disable_and_enable_of_a_missing_row_return_false_and_revoke_nothing()
|
||||
{
|
||||
// A UPN without a row in the tenant is not the tenant's to act on: the routes answer 404 on false.
|
||||
var h = new TenantHarness();
|
||||
h.AdminRepo.Setup(r => r.RemoveTenantMemberAsync(TenantId, Upn)).ReturnsAsync(false);
|
||||
h.AdminRepo.Setup(r => r.SetTenantMemberEnabledAsync(TenantId, Upn, It.IsAny<bool>())).ReturnsAsync(false);
|
||||
|
||||
Assert.False(await h.Sut.RemoveTenantAdminAsync(TenantId, Upn));
|
||||
Assert.False(await h.Sut.DisableTenantAdminAsync(TenantId, Upn));
|
||||
Assert.False(await h.Sut.EnableTenantAdminAsync(TenantId, Upn));
|
||||
|
||||
AssertNeverRevoked(h.Revoker);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_that_changes_no_row_revokes_nothing()
|
||||
{
|
||||
@@ -168,16 +183,6 @@ public class PushRoleLossHookTests
|
||||
AssertRevokedOnce(h.Revoker, NotificationScope.Platform, Upn, "global_admin_removed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabling_a_global_admin_revokes_their_platform_devices_once_as_global_admin_disabled()
|
||||
{
|
||||
var h = new GlobalHarness();
|
||||
|
||||
await h.Sut.DisableGlobalAdminAsync(Upn);
|
||||
|
||||
AssertRevokedOnce(h.Revoker, NotificationScope.Platform, Upn, "global_admin_disabled");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Global_remove_hands_the_revoker_the_lower_cased_upn()
|
||||
{
|
||||
@@ -189,15 +194,13 @@ public class PushRoleLossHookTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Global_service_without_a_revoker_still_completes_remove_and_disable()
|
||||
public async Task Global_service_without_a_revoker_still_completes_remove()
|
||||
{
|
||||
var h = new GlobalHarness(withRevoker: false);
|
||||
|
||||
await h.Sut.RemoveGlobalAdminAsync(Upn);
|
||||
await h.Sut.DisableGlobalAdminAsync(Upn);
|
||||
|
||||
h.AdminRepo.Verify(r => r.RemoveGlobalAdminAsync(Upn), Times.Once);
|
||||
h.AdminRepo.Verify(r => r.DisableGlobalAdminAsync(Upn), Times.Once);
|
||||
}
|
||||
|
||||
// ── Assertions ───────────────────────────────────────────────────────────
|
||||
@@ -248,7 +251,6 @@ public class PushRoleLossHookTests
|
||||
public GlobalHarness(bool withRevoker = true)
|
||||
{
|
||||
AdminRepo.Setup(r => r.RemoveGlobalAdminAsync(It.IsAny<string>())).ReturnsAsync(true);
|
||||
AdminRepo.Setup(r => r.DisableGlobalAdminAsync(It.IsAny<string>())).ReturnsAsync(true);
|
||||
Revoker.Setup(r => r.RevokeOwnerAsync(It.IsAny<NotificationScope>(), It.IsAny<string>(), It.IsAny<string>())).Returns(Task.CompletedTask);
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
@@ -17,7 +18,8 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// security invariants: (1) a miss (no assignment row) neither audits nor disconnects — a typo must not
|
||||
/// write false customer-visible "access removed" rows while the real grant stays live; (2) a real
|
||||
/// revoke/disable/unassign/group-delete always cuts the affected UPN's live streams (group authorization
|
||||
/// is join-time only); (3) grant validation is fail-closed (GUID tenantId, recognized roles only).
|
||||
/// is join-time only) under its bound tenant, the only one the grant resolved under; (3) grant validation is
|
||||
/// fail-closed (GUID tenantId, recognized roles only).
|
||||
/// </summary>
|
||||
public class RevokeEnforcementTests
|
||||
{
|
||||
@@ -65,14 +67,29 @@ public class RevokeEnforcementTests
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()), Times.Never);
|
||||
|
||||
/// <summary>The tenant the revoked UPNs are bound to: a platform or delegated grant resolves only under it.</summary>
|
||||
private const string HomeTenant = "66666666-6666-6666-6666-666666666666";
|
||||
|
||||
/// <summary>The revoker the functions cut through, over bindings homing every UPN of these tests in <see cref="HomeTenant"/>.</summary>
|
||||
private static LiveStreamRevoker Streams(FakeSignalRNotificationService signalR, bool bound)
|
||||
{
|
||||
var bindings = new StubAdminIdentityBindingService();
|
||||
if (bound)
|
||||
{
|
||||
foreach (var upn in new[] { UpnLower, "one@partner.example", "two@partner.example" })
|
||||
bindings.BoundTenants[upn] = HomeTenant;
|
||||
}
|
||||
return new LiveStreamRevoker(signalR, bindings, NullLogger<LiveStreamRevoker>.Instance);
|
||||
}
|
||||
|
||||
private static (DelegatedAdminManagementFunction Fn, Mock<IAdminRepository> Repo,
|
||||
Mock<IMaintenanceRepository> Audit, FakeSignalRNotificationService SignalR) BuildAdminFn()
|
||||
Mock<IMaintenanceRepository> Audit, FakeSignalRNotificationService SignalR) BuildAdminFn(bool bound = true)
|
||||
{
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
var audit = BuildAuditRepo();
|
||||
var signalR = new FakeSignalRNotificationService();
|
||||
var fn = new DelegatedAdminManagementFunction(
|
||||
NullLogger<DelegatedAdminManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR, BuildSlots(repo));
|
||||
NullLogger<DelegatedAdminManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, Streams(signalR, bound), BuildSlots(repo));
|
||||
return (fn, repo, audit, signalR);
|
||||
}
|
||||
|
||||
@@ -90,7 +107,7 @@ public class RevokeEnforcementTests
|
||||
var audit = BuildAuditRepo();
|
||||
var signalR = new FakeSignalRNotificationService();
|
||||
var fn = new TenantGroupManagementFunction(
|
||||
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR, BuildSlots(repo),
|
||||
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, Streams(signalR, bound: true), BuildSlots(repo),
|
||||
proConferral ?? TestProConferral.Inert(), assignees ?? new FakeSelfServiceAssignees(signalR));
|
||||
return (fn, repo, audit, signalR);
|
||||
}
|
||||
@@ -202,7 +219,7 @@ public class RevokeEnforcementTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeCore_RemovedRow_AuditsAndDisconnectsLowercasedUpn()
|
||||
public async Task RevokeCore_RemovedRow_AuditsAndDisconnectsUnderTheBoundTenant()
|
||||
{
|
||||
var (fn, repo, audit, signalR) = BuildAdminFn();
|
||||
repo.Setup(r => r.RemoveDelegatedAdminAsync(UpnLower, TenantA)).ReturnsAsync(true);
|
||||
@@ -210,8 +227,23 @@ public class RevokeEnforcementTests
|
||||
var removed = await fn.RevokeCoreAsync(Upn, TenantA, "ga@vendor.example");
|
||||
|
||||
Assert.True(removed);
|
||||
// SignalR user ids are lowercased UPNs — a mixed-case route param must still hit the user's connections.
|
||||
Assert.Equal(new[] { UpnLower }, signalR.DisconnectedUsers);
|
||||
// The grant resolved only under the UPN's bound tenant — never the managed tenant it pointed at. User ids
|
||||
// are lowercase, so a mixed-case route param still hits the user's connections.
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(HomeTenant, UpnLower) }, signalR.DisconnectedUsers);
|
||||
audit.Verify(a => a.LogAuditEntryAsync(
|
||||
TenantA, "DELETE", "DelegatedAdmin", UpnLower, "ga@vendor.example", null), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RevokeCore_RemovedRow_WithoutBinding_AuditsButCutsNothing()
|
||||
{
|
||||
// No binding ⇒ the grant never resolved for anyone ⇒ no connection holds its groups.
|
||||
var (fn, repo, audit, signalR) = BuildAdminFn(bound: false);
|
||||
repo.Setup(r => r.RemoveDelegatedAdminAsync(UpnLower, TenantA)).ReturnsAsync(true);
|
||||
|
||||
Assert.True(await fn.RevokeCoreAsync(Upn, TenantA, "ga@vendor.example"));
|
||||
|
||||
Assert.Empty(signalR.DisconnectedUsers);
|
||||
audit.Verify(a => a.LogAuditEntryAsync(
|
||||
TenantA, "DELETE", "DelegatedAdmin", UpnLower, "ga@vendor.example", null), Times.Once);
|
||||
}
|
||||
@@ -240,7 +272,7 @@ public class RevokeEnforcementTests
|
||||
var ok = await fn.SetEnabledCoreAsync(Upn, TenantA, isEnabled: false, "ga@vendor.example");
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(new[] { UpnLower }, signalR.DisconnectedUsers);
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(HomeTenant, UpnLower) }, signalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -287,7 +319,7 @@ public class RevokeEnforcementTests
|
||||
var unassigned = await fn.UnassignCoreAsync(GroupId, Upn, "ga@vendor.example");
|
||||
|
||||
Assert.True(unassigned);
|
||||
Assert.Equal(new[] { UpnLower }, signalR.DisconnectedUsers);
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(HomeTenant, UpnLower) }, signalR.DisconnectedUsers);
|
||||
// Lost access is audited under EACH of the group's tenants (customer-visible trail).
|
||||
audit.Verify(a => a.LogAuditEntryAsync(
|
||||
TenantA, "DELETE", "DelegatedGroupAccess", UpnLower, "ga@vendor.example",
|
||||
@@ -320,7 +352,11 @@ public class RevokeEnforcementTests
|
||||
|
||||
await fn.DeleteGroupCoreAsync(GroupId, "ga@vendor.example");
|
||||
|
||||
Assert.Equal(new[] { "one@partner.example", "two@partner.example" }, signalR.DisconnectedUsers);
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
SignalRGroupHelper.UserId(HomeTenant, "one@partner.example"),
|
||||
SignalRGroupHelper.UserId(HomeTenant, "two@partner.example"),
|
||||
}, signalR.DisconnectedUsers);
|
||||
audit.Verify(a => a.LogAuditEntryAsync(
|
||||
TenantA, "DELETE", "DelegatedGroupAccess", "*", "ga@vendor.example",
|
||||
It.IsAny<Dictionary<string, string>?>()), Times.Once);
|
||||
|
||||
@@ -277,6 +277,8 @@ public class RulesWireParityTests
|
||||
MatchCount = 1,
|
||||
ParseFailureCount = 0,
|
||||
TimeoutCount = 0,
|
||||
// null on a complete test (key vanishes); a stopped test carries the reason string.
|
||||
StopReason = null,
|
||||
};
|
||||
result.Lines.Add(new LogPatternLineResult
|
||||
{
|
||||
|
||||
@@ -306,9 +306,9 @@ public class SessionDeletionHandlerOrderingTests
|
||||
public Task SendGlobalNotificationAsync(object dto) => Task.CompletedTask;
|
||||
public Task SendGlobalNotificationDismissedAsync(string notificationId) => Task.CompletedTask;
|
||||
public Task SendGlobalNotificationDismissedAllAsync() => Task.CompletedTask;
|
||||
public Task<(string Url, string AccessToken)?> NegotiateClientAsync(string userId)
|
||||
public Task<(string Url, string AccessToken)?> NegotiateClientAsync(string tenantId, string? principalKey)
|
||||
=> Task.FromResult<(string Url, string AccessToken)?>(null);
|
||||
public Task DisconnectUserAsync(string userId) => Task.CompletedTask;
|
||||
public Task DisconnectUserAsync(string tenantId, string principalKey) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,31 @@ public class SignalRGroupHelperTests
|
||||
Assert.Equal(expected, SignalRGroupHelper.ExtractTenantIdFromGroupName(group));
|
||||
}
|
||||
|
||||
// ── User ids: the principal a connection is negotiated under and a revoke addresses ──
|
||||
|
||||
[Fact]
|
||||
public void UserId_is_the_lowercased_sign_in_tenant_and_principal_key()
|
||||
{
|
||||
Assert.Equal($"{TenantId}:user@contoso.example",
|
||||
SignalRGroupHelper.UserId(TenantId.ToUpperInvariant(), "User@Contoso.Example"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserId_of_the_same_upn_differs_per_sign_in_tenant()
|
||||
{
|
||||
// The reason a revoke in one tenant never reaches the same UPN signed in to another.
|
||||
Assert.NotEqual(
|
||||
SignalRGroupHelper.UserId(TenantId, "user@contoso.example"),
|
||||
SignalRGroupHelper.UserId("99999999-9999-9999-9999-999999999999", "user@contoso.example"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UserId_keeps_an_application_principal_key_whole()
|
||||
{
|
||||
Assert.Equal($"{TenantId}:app:0a0a0a0a-0000-0000-0000-000000000001",
|
||||
SignalRGroupHelper.UserId(TenantId, "app:0A0A0A0A-0000-0000-0000-000000000001"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsTenantNotifyAdminGroup_OnlyTrueForAdminSuffix()
|
||||
{
|
||||
|
||||
@@ -22,6 +22,15 @@ internal sealed class StubAdminIdentityBindingService : AdminIdentityBindingServ
|
||||
/// <summary>Every (upn, tenantId, objectId) passed to EnsureBoundAsync / RebindAsync, in call order.</summary>
|
||||
public List<(string Upn, string TenantId, string? ObjectId)> Bindings { get; } = new();
|
||||
|
||||
/// <summary>The home tenant <see cref="GetAsync"/> answers per UPN: seeded by a test, or the latest bind / rebind.</summary>
|
||||
public Dictionary<string, string> BoundTenants { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>When set, <see cref="GetAsync"/> throws it (a storage failure on the binding read).</summary>
|
||||
public Exception? GetThrows { get; set; }
|
||||
|
||||
/// <summary>How often <see cref="GetAsync"/> was called.</summary>
|
||||
public int GetCalls { get; private set; }
|
||||
|
||||
public StubAdminIdentityBindingService(bool bound = true) : this(_ => bound)
|
||||
{
|
||||
}
|
||||
@@ -42,6 +51,7 @@ internal sealed class StubAdminIdentityBindingService : AdminIdentityBindingServ
|
||||
{
|
||||
OnEnsureBound?.Invoke();
|
||||
Bindings.Add((upn.ToLowerInvariant(), tenantId.ToLowerInvariant(), objectId?.ToLowerInvariant()));
|
||||
BoundTenants[upn] = tenantId.ToLowerInvariant();
|
||||
return Task.FromResult(new AdminIdentityBinding
|
||||
{
|
||||
Upn = upn.ToLowerInvariant(),
|
||||
@@ -54,5 +64,19 @@ internal sealed class StubAdminIdentityBindingService : AdminIdentityBindingServ
|
||||
public override Task<AdminIdentityBinding> RebindAsync(string upn, string tenantId, string? objectId, string boundBy)
|
||||
=> EnsureBoundAsync(upn, tenantId, objectId, boundBy);
|
||||
|
||||
public override Task RemoveAsync(string upn) => Task.CompletedTask;
|
||||
public override Task RemoveAsync(string upn)
|
||||
{
|
||||
BoundTenants.Remove(upn);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public override Task<AdminIdentityBinding?> GetAsync(string upn)
|
||||
{
|
||||
GetCalls++;
|
||||
if (GetThrows != null)
|
||||
throw GetThrows;
|
||||
return Task.FromResult(BoundTenants.TryGetValue(upn, out var tenantId)
|
||||
? new AdminIdentityBinding { Upn = upn.ToLowerInvariant(), TenantId = tenantId }
|
||||
: null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ public class TenantAdminManagementFunctionTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string CallerUpn = "admin@contoso.com";
|
||||
private const string ForeignTenantId = "f0e1d2c3-b4a5-4697-8899-aabbccddeeff";
|
||||
private const string ForeignUpn = "ga@fabrikam.example";
|
||||
|
||||
// ── TryCanonicalizeRole ─────────────────────────────────────────────────
|
||||
|
||||
@@ -144,7 +146,7 @@ public class TenantAdminManagementFunctionTests
|
||||
var response = await h.Function.RemoveTenantAdmin(req, TenantId, "Demoted@Contoso.com", ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(new[] { "demoted@contoso.com" }, h.SignalR.DisconnectedUsers);
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, "demoted@contoso.com") }, h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -156,7 +158,7 @@ public class TenantAdminManagementFunctionTests
|
||||
var response = await h.Function.DisableTenantAdmin(req, TenantId, "Demoted@Contoso.com", ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(new[] { "demoted@contoso.com" }, h.SignalR.DisconnectedUsers);
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, "demoted@contoso.com") }, h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -165,8 +167,9 @@ public class TenantAdminManagementFunctionTests
|
||||
var h = BuildHarness();
|
||||
var (req, ctx) = BuildRequest(new { });
|
||||
|
||||
await h.Function.EnableTenantAdmin(req, TenantId, "user@contoso.com", ctx);
|
||||
var response = await h.Function.EnableTenantAdmin(req, TenantId, "user@contoso.com", ctx);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Empty(h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
@@ -177,11 +180,60 @@ public class TenantAdminManagementFunctionTests
|
||||
|
||||
var (demote, ctx1) = BuildRequest(new { role = "viewer", canManageBootstrapTokens = false });
|
||||
await h.Function.UpdateMemberPermissions(demote, TenantId, "User@Contoso.com", ctx1);
|
||||
Assert.Equal(new[] { "user@contoso.com" }, h.SignalR.DisconnectedUsers);
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, "user@contoso.com") }, h.SignalR.DisconnectedUsers);
|
||||
|
||||
var (promote, ctx2) = BuildRequest(new { role = "admin", canManageBootstrapTokens = false });
|
||||
await h.Function.UpdateMemberPermissions(promote, TenantId, "other@contoso.com", ctx2);
|
||||
Assert.Equal(new[] { "user@contoso.com" }, h.SignalR.DisconnectedUsers);
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, "user@contoso.com") }, h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
// ── a member action never reaches the UPN signed in to another tenant (security finding) ──
|
||||
|
||||
[Fact]
|
||||
public async Task Adding_then_removing_disabling_and_demoting_a_foreign_upn_cuts_only_this_tenants_connections()
|
||||
{
|
||||
// The attack: a tenant admin adds the UPN of a Global Admin homed elsewhere, then removes, disables or
|
||||
// demotes it. Every cut must name this tenant; the victim's connections live under their own tenant.
|
||||
var h = BuildHarness();
|
||||
var (add, ctx0) = BuildRequest(new { upn = ForeignUpn, role = "admin" });
|
||||
await h.Function.AddTenantAdmin(add, TenantId, ctx0);
|
||||
|
||||
var (demote, ctx1) = BuildRequest(new { role = "viewer", canManageBootstrapTokens = false });
|
||||
await h.Function.UpdateMemberPermissions(demote, TenantId, ForeignUpn, ctx1);
|
||||
var (disable, ctx2) = BuildRequest(new { });
|
||||
await h.Function.DisableTenantAdmin(disable, TenantId, ForeignUpn, ctx2);
|
||||
var (remove, ctx3) = BuildRequest(new { });
|
||||
await h.Function.RemoveTenantAdmin(remove, TenantId, ForeignUpn, ctx3);
|
||||
|
||||
var thisTenant = SignalRGroupHelper.UserId(TenantId, ForeignUpn);
|
||||
Assert.Equal(new[] { thisTenant, thisTenant, thisTenant }, h.SignalR.DisconnectedUsers);
|
||||
Assert.DoesNotContain(SignalRGroupHelper.UserId(ForeignTenantId, ForeignUpn), h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Remove_disable_and_enable_of_a_non_member_return_404_without_any_side_effect()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
h.AdminRepo.Setup(r => r.RemoveTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
|
||||
h.AdminRepo.Setup(r => r.SetTenantMemberEnabledAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var (remove, ctx1) = BuildRequest(new { });
|
||||
var removed = await h.Function.RemoveTenantAdmin(remove, TenantId, ForeignUpn, ctx1);
|
||||
var (disable, ctx2) = BuildRequest(new { });
|
||||
var disabled = await h.Function.DisableTenantAdmin(disable, TenantId, ForeignUpn, ctx2);
|
||||
var (enable, ctx3) = BuildRequest(new { });
|
||||
var enabled = await h.Function.EnableTenantAdmin(enable, TenantId, ForeignUpn, ctx3);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, removed.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, disabled.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, enabled.StatusCode);
|
||||
Assert.Empty(h.SignalR.DisconnectedUsers);
|
||||
Assert.Empty(h.Assignees.Ended);
|
||||
h.MaintenanceRepo.Verify(r => r.LogAuditEntryAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<Dictionary<string, string>?>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
// ── removal and disable end the member's self-service delegation, before the stream cut ──
|
||||
@@ -201,7 +253,7 @@ public class TenantAdminManagementFunctionTests
|
||||
Assert.Equal(CallerUpn, ended.Actor);
|
||||
Assert.Equal(DelegationSelfService.AssigneeReasons.MemberRemoved, ended.Reason);
|
||||
Assert.Equal(0, ended.DisconnectsBefore);
|
||||
Assert.Equal(new[] { "leaver@contoso.com" }, h.SignalR.DisconnectedUsers);
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, "leaver@contoso.com") }, h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -218,7 +270,7 @@ public class TenantAdminManagementFunctionTests
|
||||
Assert.Equal("leaver@contoso.com", ended.Upn.ToLowerInvariant());
|
||||
Assert.Equal(DelegationSelfService.AssigneeReasons.MemberDisabled, ended.Reason);
|
||||
Assert.Equal(0, ended.DisconnectsBefore);
|
||||
Assert.Equal(new[] { "leaver@contoso.com" }, h.SignalR.DisconnectedUsers);
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, "leaver@contoso.com") }, h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -248,7 +300,7 @@ public class TenantAdminManagementFunctionTests
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Single(h.Assignees.Ended);
|
||||
Assert.Equal(new[] { "leaver@contoso.com" }, h.SignalR.DisconnectedUsers);
|
||||
Assert.Equal(new[] { SignalRGroupHelper.UserId(TenantId, "leaver@contoso.com") }, h.SignalR.DisconnectedUsers);
|
||||
}
|
||||
|
||||
// ── harness ─────────────────────────────────────────────────────────────
|
||||
@@ -257,12 +309,14 @@ public class TenantAdminManagementFunctionTests
|
||||
{
|
||||
public TenantAdminManagementFunction Function = default!;
|
||||
public Mock<IAdminRepository> AdminRepo = default!;
|
||||
public Mock<IMaintenanceRepository> MaintenanceRepo = default!;
|
||||
public FakeSignalRNotificationService SignalR = default!;
|
||||
public FakeSelfServiceAssignees Assignees = default!;
|
||||
}
|
||||
|
||||
private static Harness BuildHarness()
|
||||
{
|
||||
// Every member a test names exists unless the test says otherwise (the routes 404 a UPN without a row).
|
||||
var adminRepo = new Mock<IAdminRepository>(MockBehavior.Loose);
|
||||
adminRepo.Setup(r => r.AddTenantMemberAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()))
|
||||
@@ -270,6 +324,9 @@ public class TenantAdminManagementFunctionTests
|
||||
adminRepo.Setup(r => r.UpdateMemberPermissionsAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(true);
|
||||
adminRepo.Setup(r => r.RemoveTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(true);
|
||||
adminRepo.Setup(r => r.SetTenantMemberEnabledAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var service = new TenantAdminsService(
|
||||
adminRepo.Object,
|
||||
@@ -279,12 +336,17 @@ public class TenantAdminManagementFunctionTests
|
||||
var maintenanceRepo = new Mock<IMaintenanceRepository>(MockBehavior.Loose);
|
||||
var signalR = new FakeSignalRNotificationService();
|
||||
var assignees = new FakeSelfServiceAssignees(signalR);
|
||||
// The Global Admin of another tenant is bound there: no member action of this tenant may follow it.
|
||||
var bindings = new StubAdminIdentityBindingService();
|
||||
bindings.BoundTenants[ForeignUpn] = ForeignTenantId;
|
||||
var streams = new LiveStreamRevoker(signalR, bindings, NullLogger<LiveStreamRevoker>.Instance);
|
||||
|
||||
return new Harness
|
||||
{
|
||||
Function = new TenantAdminManagementFunction(
|
||||
NullLogger<TenantAdminManagementFunction>.Instance, service, maintenanceRepo.Object, signalR, assignees),
|
||||
NullLogger<TenantAdminManagementFunction>.Instance, service, maintenanceRepo.Object, streams, assignees),
|
||||
AdminRepo = adminRepo,
|
||||
MaintenanceRepo = maintenanceRepo,
|
||||
SignalR = signalR,
|
||||
Assignees = assignees,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The membership reads behind every role decision. A grant can be written where this process's cache
|
||||
/// invalidation never reaches (activation promotes the onboarding member on the auto-approve worker), so a
|
||||
/// missing or disabled row is never cached; members stay on the cached path. The snapshot auth/me decides
|
||||
/// the first-user auto-admin from reads the caller's row and "the tenant has members" in one go.
|
||||
/// </summary>
|
||||
public class TenantAdminsServiceMembershipTests
|
||||
{
|
||||
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
private const string Upn = "first.admin@contoso.com";
|
||||
|
||||
private readonly Mock<IAdminRepository> _repo = new();
|
||||
private readonly TenantAdminsService _sut;
|
||||
|
||||
public TenantAdminsServiceMembershipTests()
|
||||
{
|
||||
_sut = new TenantAdminsService(
|
||||
_repo.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<TenantAdminsService>.Instance);
|
||||
}
|
||||
|
||||
private static TenantMember Row(string upn, string role = Constants.TenantRoles.Admin, bool enabled = true) =>
|
||||
new() { TenantId = TenantId, Upn = upn, Role = role, IsEnabled = enabled };
|
||||
|
||||
private void PointReadsReturn(params TenantMember?[] rows)
|
||||
{
|
||||
var answers = new Queue<TenantMember?>(rows);
|
||||
_repo.Setup(r => r.GetTenantMemberAsync(TenantId, Upn)).ReturnsAsync(() => answers.Dequeue());
|
||||
}
|
||||
|
||||
private void MembersAre(params TenantMember[] rows) =>
|
||||
_repo.Setup(r => r.GetTenantMembersAsync(TenantId)).ReturnsAsync(rows.ToList());
|
||||
|
||||
[Fact]
|
||||
public async Task MissingRow_IsNotCached_SoAGrantWrittenElsewhereCountsAtOnce()
|
||||
{
|
||||
PointReadsReturn(null, Row(Upn));
|
||||
|
||||
Assert.Equal(TableMemberState.NotPresent, (await _sut.GetTableMembershipAsync(TenantId, Upn)).State);
|
||||
var (state, role) = await _sut.GetTableMembershipAsync(TenantId, Upn);
|
||||
|
||||
Assert.Equal(TableMemberState.Enabled, state);
|
||||
Assert.Equal(Constants.TenantRoles.Admin, role!.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledRow_IsNotCached_SoReEnablingCountsAtOnce()
|
||||
{
|
||||
PointReadsReturn(Row(Upn, enabled: false), Row(Upn));
|
||||
|
||||
Assert.Equal(TableMemberState.Disabled, (await _sut.GetTableMembershipAsync(TenantId, Upn)).State);
|
||||
Assert.Equal(TableMemberState.Enabled, (await _sut.GetTableMembershipAsync(TenantId, Upn)).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnabledRow_IsServedFromTheCache()
|
||||
{
|
||||
PointReadsReturn(Row(Upn), null);
|
||||
|
||||
Assert.Equal(TableMemberState.Enabled, (await _sut.GetTableMembershipAsync(TenantId, Upn)).State);
|
||||
Assert.Equal(TableMemberState.Enabled, (await _sut.GetTableMembershipAsync(TenantId, Upn)).State);
|
||||
_repo.Verify(r => r.GetTenantMemberAsync(TenantId, Upn), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_TheCallersOwnRowDecides_WhateverTheTokenCasing()
|
||||
{
|
||||
MembersAre(Row("someone.else@contoso.com", Constants.TenantRoles.Viewer), Row(Upn, Constants.TenantRoles.Operator));
|
||||
|
||||
var (state, role, hasMembers) = await _sut.GetMembershipSnapshotAsync(TenantId, "First.Admin@Contoso.com");
|
||||
|
||||
Assert.Equal(TableMemberState.Enabled, state);
|
||||
Assert.Equal(Constants.TenantRoles.Operator, role!.Role);
|
||||
Assert.True(hasMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_EmptyTenant_HasNoMembers()
|
||||
{
|
||||
MembersAre();
|
||||
|
||||
var (state, role, hasMembers) = await _sut.GetMembershipSnapshotAsync(TenantId, Upn);
|
||||
|
||||
Assert.Equal(TableMemberState.NotPresent, state);
|
||||
Assert.Null(role);
|
||||
Assert.False(hasMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_ADisabledRowOfSomeoneElse_StillCountsAsAMember()
|
||||
{
|
||||
// A tenant whose only admin is disabled must not make the next person who signs in its admin.
|
||||
MembersAre(Row("someone.else@contoso.com", enabled: false));
|
||||
|
||||
var (state, _, hasMembers) = await _sut.GetMembershipSnapshotAsync(TenantId, Upn);
|
||||
|
||||
Assert.Equal(TableMemberState.NotPresent, state);
|
||||
Assert.True(hasMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_TheCallersDisabledRow_IsAnExplicitDeny()
|
||||
{
|
||||
MembersAre(Row(Upn, enabled: false));
|
||||
|
||||
var (state, role, hasMembers) = await _sut.GetMembershipSnapshotAsync(TenantId, Upn);
|
||||
|
||||
Assert.Equal(TableMemberState.Disabled, state);
|
||||
Assert.Null(role);
|
||||
Assert.True(hasMembers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_IsNeverCached()
|
||||
{
|
||||
_repo.SetupSequence(r => r.GetTenantMembersAsync(TenantId))
|
||||
.ReturnsAsync(new List<TenantMember>())
|
||||
.ReturnsAsync(new List<TenantMember> { Row(Upn) });
|
||||
|
||||
Assert.False((await _sut.GetMembershipSnapshotAsync(TenantId, Upn)).TenantHasMembers);
|
||||
Assert.Equal(TableMemberState.Enabled, (await _sut.GetMembershipSnapshotAsync(TenantId, Upn)).State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_RefusesATenantIdThatIsNotAGuid()
|
||||
{
|
||||
// The repository builds its partition filter from the tenant id.
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => _sut.GetMembershipSnapshotAsync("x' or PartitionKey ne '", Upn));
|
||||
_repo.Verify(r => r.GetTenantMembersAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAddTenantAdmin_CreatesTheRowLowercased_AndDropsTheCachedMembership()
|
||||
{
|
||||
_repo.Setup(r => r.TryAddTenantMemberAsync(TenantId, Upn, "system (auto-approve)", Constants.TenantRoles.Admin)).ReturnsAsync(true);
|
||||
// A cached enabled row first, so the re-read after the seat is visible as a role change.
|
||||
PointReadsReturn(Row(Upn, role: Constants.TenantRoles.Viewer), Row(Upn));
|
||||
Assert.Equal(Constants.TenantRoles.Viewer, (await _sut.GetTableMembershipAsync(TenantId, Upn)).Role!.Role);
|
||||
|
||||
Assert.True(await _sut.TryAddTenantAdminAsync(TenantId, Upn.ToUpperInvariant(), "System (auto-approve)"));
|
||||
|
||||
Assert.Equal(Constants.TenantRoles.Admin, (await _sut.GetTableMembershipAsync(TenantId, Upn)).Role!.Role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAddTenantAdmin_RowExists_AnswersFalseAndNeverUpserts()
|
||||
{
|
||||
_repo.Setup(r => r.TryAddTenantMemberAsync(TenantId, Upn, "system", Constants.TenantRoles.Admin)).ReturnsAsync(false);
|
||||
|
||||
Assert.False(await _sut.TryAddTenantAdminAsync(TenantId, Upn, "System"));
|
||||
|
||||
_repo.Verify(r => r.AddTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
// --- The tenant's audit log shows every first-admin seat like a seat an admin wrote.
|
||||
|
||||
private TenantAdminsService WithAudit(Mock<IMaintenanceRepository> audit) =>
|
||||
new(_repo.Object, new MemoryCache(new MemoryCacheOptions()), NullLogger<TenantAdminsService>.Instance,
|
||||
Mock.Of<AutopilotMonitor.Functions.Services.Push.IPushDeviceRevoker>(), audit.Object);
|
||||
|
||||
[Fact]
|
||||
public async Task TryAddTenantAdmin_Seated_WritesTheTenantsAuditEntry()
|
||||
{
|
||||
var audit = new Mock<IMaintenanceRepository>();
|
||||
_repo.Setup(r => r.TryAddTenantMemberAsync(TenantId, Upn, "system (auto-approve)", Constants.TenantRoles.Admin)).ReturnsAsync(true);
|
||||
|
||||
Assert.True(await WithAudit(audit).TryAddTenantAdminAsync(TenantId, Upn, "System (auto-approve)"));
|
||||
|
||||
audit.Verify(a => a.LogAuditEntryAsync(TenantId, "CREATE", "TenantAdmin", Upn, "system (auto-approve)",
|
||||
It.Is<Dictionary<string, string>>(d => d["Role"] == Constants.TenantRoles.Admin)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAddTenantAdmin_RowExists_WritesNoAuditEntry()
|
||||
{
|
||||
var audit = new Mock<IMaintenanceRepository>();
|
||||
_repo.Setup(r => r.TryAddTenantMemberAsync(TenantId, Upn, "system", Constants.TenantRoles.Admin)).ReturnsAsync(false);
|
||||
|
||||
Assert.False(await WithAudit(audit).TryAddTenantAdminAsync(TenantId, Upn, "System"));
|
||||
|
||||
audit.Verify(a => a.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAddTenantAdmin_AuditWriteFails_TheSeatStillCounts()
|
||||
{
|
||||
var audit = new Mock<IMaintenanceRepository>();
|
||||
audit.Setup(a => a.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<Dictionary<string, string>>())).ThrowsAsync(new InvalidOperationException("storage"));
|
||||
_repo.Setup(r => r.TryAddTenantMemberAsync(TenantId, Upn, "system", Constants.TenantRoles.Admin)).ReturnsAsync(true);
|
||||
|
||||
Assert.True(await WithAudit(audit).TryAddTenantAdminAsync(TenantId, Upn, "System"));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
@@ -54,7 +56,8 @@ public class TenantApprovalServiceTests
|
||||
configRepo, NullLogger<AdminConfigurationService>.Instance, cache);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
|
||||
// IsTenantAdminAsync is not virtual: it runs for real against the empty repository (no member yet).
|
||||
// TryAddTenantAdminAsync is virtual: ArrangeNewlyApproved answers it per test (true = seated,
|
||||
// false = the row exists). The unset IsApprovedFreshAsync answers false: not activated yet.
|
||||
_tenantAdminsMock = new Mock<TenantAdminsService>(
|
||||
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance);
|
||||
|
||||
@@ -81,16 +84,18 @@ public class TenantApprovalServiceTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AlreadyApproved_ReturnsFalse_AndRunsNoSideEffects()
|
||||
public async Task AlreadyApproved_ReturnsFalse_AndWritesNothing()
|
||||
{
|
||||
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(false);
|
||||
// The fresh pre-check decides, not the 5-minute activation cache: a re-activation right after a
|
||||
// revoke on another instance must not be answered as "already activated" from that cache.
|
||||
_previewMock.Setup(x => x.IsApprovedFreshAsync(TenantId)).ReturnsAsync(true);
|
||||
|
||||
var newlyApproved = await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
||||
|
||||
Assert.False(newlyApproved);
|
||||
// The side-effect block (auto-promote + welcome mail) starts with the config read —
|
||||
// it must never run for a lost activation race.
|
||||
_tenantConfigMock.Verify(x => x.GetConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
_previewMock.Verify(x => x.ApproveAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_tenantConfigMock.Verify(x => x.GetConfigurationFreshAsync(It.IsAny<string>()), Times.Never);
|
||||
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_previewMock.Verify(x => x.GetNotificationEmailAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
@@ -112,7 +117,7 @@ public class TenantApprovalServiceTests
|
||||
|
||||
// --- Auto-promote on activation: only the onboarding member (OnboardedBy) becomes Tenant Admin.
|
||||
|
||||
private void ArrangeNewlyApproved(string? onboardedBy, string updatedBy = "System")
|
||||
private void ArrangeNewlyApproved(string? onboardedBy, string updatedBy = "System", bool alreadyAdmin = false)
|
||||
{
|
||||
var config = ConfigWith(contactEmail: null);
|
||||
config.OnboardedBy = onboardedBy;
|
||||
@@ -120,11 +125,13 @@ public class TenantApprovalServiceTests
|
||||
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(true);
|
||||
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId)).ReturnsAsync(config);
|
||||
_tenantConfigMock.Setup(x => x.GetConfigurationFreshAsync(TenantId)).ReturnsAsync(config);
|
||||
_tenantAdminsMock.Setup(x => x.TryAddTenantAdminAsync(TenantId, It.IsAny<string>(), ApprovedBy)).ReturnsAsync(!alreadyAdmin);
|
||||
}
|
||||
|
||||
private void VerifyNoPromotion() =>
|
||||
_tenantAdminsMock.Verify(
|
||||
x => x.AddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
|
||||
[Fact]
|
||||
public async Task NewlyApproved_PromotesTheOnboardingMember()
|
||||
@@ -135,7 +142,19 @@ public class TenantApprovalServiceTests
|
||||
await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
||||
|
||||
_tenantAdminsMock.Verify(
|
||||
x => x.AddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy), Times.Once);
|
||||
x => x.TryAddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewlyApproved_OnboardingMemberAlreadySeated_IsLeftUntouchedAndActivationProceeds()
|
||||
{
|
||||
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example", alreadyAdmin: true);
|
||||
|
||||
Assert.True(await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy));
|
||||
|
||||
// The conditional insert is the only write: a row the tenant holds (disabled, demoted) is never overwritten.
|
||||
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy), Times.Once);
|
||||
_previewMock.Verify(x => x.ApproveAsync(TenantId, ApprovedBy), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -159,6 +178,74 @@ public class TenantApprovalServiceTests
|
||||
VerifyNoPromotion();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewlyApproved_SeatsTheOnboardingMemberBeforeTheActivationRow()
|
||||
{
|
||||
// The invariant behind the activation race: no observable state has the tenant activated while the
|
||||
// designated admin's row is still missing. The seat is written first, the activation row second.
|
||||
var writes = new List<string>();
|
||||
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example");
|
||||
_tenantAdminsMock.Setup(x => x.TryAddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy))
|
||||
.Callback(() => writes.Add("seat")).ReturnsAsync(true);
|
||||
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy))
|
||||
.Callback(() => writes.Add("activate")).ReturnsAsync(true);
|
||||
|
||||
Assert.True(await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy));
|
||||
|
||||
Assert.Equal(new[] { "seat", "activate" }, writes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LostActivationRace_SeatIsTheWinnersRow_ButNoMailAndNoOpsEvent()
|
||||
{
|
||||
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example");
|
||||
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(false);
|
||||
|
||||
Assert.False(await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy));
|
||||
|
||||
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy), Times.Once);
|
||||
_previewMock.Verify(x => x.GetNotificationEmailAsync(It.IsAny<string>()), Times.Never);
|
||||
Assert.Empty(_opsEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewlyApproved_SeatWriteFails_ActivationStillProceeds()
|
||||
{
|
||||
// Best-effort on purpose: with the seat missing, auth/me seats the designated member on their next
|
||||
// sign-in and nobody else.
|
||||
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example");
|
||||
_tenantAdminsMock.Setup(x => x.TryAddTenantAdminAsync(TenantId, It.IsAny<string>(), ApprovedBy))
|
||||
.ThrowsAsync(new InvalidOperationException("Table Storage down"));
|
||||
|
||||
Assert.True(await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy));
|
||||
|
||||
_previewMock.Verify(x => x.ApproveAsync(TenantId, ApprovedBy), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NewlyApproved_SeatsAnOnboardingMemberWhoseNameStartsWithSystem()
|
||||
{
|
||||
// "system" is a common prefix of real admin accounts; only the sentinel SHAPE (no '@', spaces) is unpromotable.
|
||||
ArrangeNewlyApproved(onboardedBy: "systemadmin@contoso.example");
|
||||
|
||||
await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
||||
|
||||
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(TenantId, "systemadmin@contoso.example", ApprovedBy), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReactivationAfterARevoke_SeatsNobody_ButActivates()
|
||||
{
|
||||
// The seat belongs to the first activation: a member the tenant removed stays removed.
|
||||
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example");
|
||||
_previewMock.Setup(x => x.WasRevokedAsync(TenantId)).ReturnsAsync(true);
|
||||
|
||||
Assert.True(await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy));
|
||||
|
||||
VerifyNoPromotion();
|
||||
_previewMock.Verify(x => x.ApproveAsync(TenantId, ApprovedBy), Times.Once);
|
||||
}
|
||||
|
||||
// --- TrySendWelcomeEmailAsync: the once-only send shared by the approval path and the
|
||||
// notification-email save path. Ordering contract under test: the sent-marker must be
|
||||
// consumed strictly AFTER the address check (a marker consumed with no address would
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// The shared effective-role resolver (policy middleware + MCP AllMembers gate): TenantAdmins table first,
|
||||
/// the token's Entra app-role claim only when no row exists AND the tenant opted in, and no tenant-config
|
||||
/// read at all for a roleless token — so the common "no row, no claim" path costs one cached membership read.
|
||||
/// read at all for a roleless token — so the common "no row, no claim" path costs one membership read.
|
||||
/// </summary>
|
||||
public class TenantMemberRoleResolverTests
|
||||
{
|
||||
|
||||
@@ -17,16 +17,10 @@ $AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\oobe_bootstrap.log"
|
||||
$AgentStagingPath = "$AgentBasePath\Agent-Install"
|
||||
|
||||
$ExpectedPublisher = '*O=glueckkanja AG*'
|
||||
$AgentCatalogName = 'AutopilotMonitor.Agent.cat'
|
||||
|
||||
# Create directories
|
||||
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"
|
||||
@@ -35,15 +29,164 @@ function Write-Log {
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
### BEGIN shared payload verification -- embedded verbatim into the OOBE go-script by
|
||||
### OobeBootstrapScriptGenerator (D-354). Keep it self-contained: parameters only, no
|
||||
### script-scope variables, no Write-Log.
|
||||
# Returns the reasons why the extracted payload in $Path must not run; an empty result means
|
||||
# it matches its signed catalog exactly. Test-FileCatalog's Status covers the hashes only (an
|
||||
# unsigned catalog over matching files reports Valid), so the signature and its publisher are
|
||||
# judged on their own. Any error is a rejection: a payload that cannot be verified is an
|
||||
# unverified payload. The comma keeps a single rejection an ARRAY: PowerShell unrolls a
|
||||
# one-element result to a bare string, and the caller reads .Count.
|
||||
### BEGIN shared install helpers (the OOBE script embeds this block; keep it self-contained)
|
||||
|
||||
function Test-IsElevated {
|
||||
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
return (New-Object System.Security.Principal.WindowsPrincipal($identity)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
# SYSTEM and Administrators full control, Users read, nothing inherited. Owner Administrators,
|
||||
# so a folder that any elevated account created counts as ours.
|
||||
function New-ProtectedDirectorySecurity {
|
||||
$security = New-Object System.Security.AccessControl.DirectorySecurity
|
||||
$security.SetAccessRuleProtection($true, $false)
|
||||
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
||||
$propagation = [System.Security.AccessControl.PropagationFlags]::None
|
||||
$system = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::LocalSystemSid, $null)
|
||||
$admins = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid, $null)
|
||||
$users = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::BuiltinUsersSid, $null)
|
||||
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($system, 'FullControl', $inherit, $propagation, 'Allow')))
|
||||
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($admins, 'FullControl', $inherit, $propagation, 'Allow')))
|
||||
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($users, 'ReadAndExecute', $inherit, $propagation, 'Allow')))
|
||||
$security.SetOwner($admins)
|
||||
return $security
|
||||
}
|
||||
|
||||
function Test-IsReparsePoint {
|
||||
param([string]$Path)
|
||||
return (([System.IO.File]::GetAttributes($Path) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)
|
||||
}
|
||||
|
||||
# Owner SYSTEM or Administrators. Any other owner, or one that cannot be read, is another
|
||||
# account's.
|
||||
function Test-IsOwnedByUs {
|
||||
param([string]$Path)
|
||||
try {
|
||||
$owner = (Get-Item -LiteralPath $Path -Force -ErrorAction Stop).GetAccessControl([System.Security.AccessControl.AccessControlSections]::Owner).GetOwner([System.Security.Principal.SecurityIdentifier])
|
||||
return ($owner.IsWellKnown([System.Security.Principal.WellKnownSidType]::LocalSystemSid) -or
|
||||
$owner.IsWellKnown([System.Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid))
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Random, so nobody can create a run folder's name ahead of the run.
|
||||
function New-RunFolderName {
|
||||
$bytes = New-Object byte[] 4
|
||||
$generator = [System.Security.Cryptography.RandomNumberGenerator]::Create()
|
||||
try { $generator.GetBytes($bytes) } finally { $generator.Dispose() }
|
||||
return -join ($bytes | ForEach-Object { $_.ToString('x2') })
|
||||
}
|
||||
|
||||
# CreateDirectory leaves an existing folder as it is, so only a new name is used and the new
|
||||
# folder is checked.
|
||||
function New-RunFolder {
|
||||
param([string]$Parent, [string]$Prefix)
|
||||
for ($attempt = 1; $attempt -le 3; $attempt++) {
|
||||
$path = Join-Path $Parent ($Prefix + (New-RunFolderName))
|
||||
if (Test-Path -LiteralPath $path) { continue }
|
||||
[System.IO.Directory]::CreateDirectory($path, (New-ProtectedDirectorySecurity)) | Out-Null
|
||||
if (-not (Test-IsReparsePoint $path) -and (Test-IsOwnedByUs $path)) { return $path }
|
||||
}
|
||||
throw "Could not create a run folder under $Parent."
|
||||
}
|
||||
|
||||
# 'Missing', 'Ours', or why the folder is not ours: 'Link', 'File' or 'Foreign'.
|
||||
function Get-FolderState {
|
||||
param([string]$Path)
|
||||
try { $attributes = [System.IO.File]::GetAttributes($Path) }
|
||||
catch [System.IO.FileNotFoundException] { return 'Missing' }
|
||||
catch [System.IO.DirectoryNotFoundException] { return 'Missing' }
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { return 'Link' }
|
||||
if (($attributes -band [System.IO.FileAttributes]::Directory) -eq 0) { return 'File' }
|
||||
if (-not (Test-IsOwnedByUs $Path)) { return 'Foreign' }
|
||||
return 'Ours'
|
||||
}
|
||||
|
||||
function Remove-OwnFile {
|
||||
param([string]$Path)
|
||||
# A virus scanner may hold the file for a moment.
|
||||
for ($attempt = 1; ; $attempt++) {
|
||||
try {
|
||||
[System.IO.File]::Delete($Path)
|
||||
return
|
||||
} catch {
|
||||
if ($attempt -ge 3) { throw }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-OwnTreeEntries {
|
||||
param([string]$Path, $Left)
|
||||
try { $entries = [System.IO.Directory]::GetFileSystemEntries($Path) } catch { $Left.Locked.Add($Path); return }
|
||||
foreach ($entry in $entries) {
|
||||
try {
|
||||
$attributes = [System.IO.File]::GetAttributes($entry)
|
||||
$isDirectory = ($attributes -band [System.IO.FileAttributes]::Directory) -ne 0
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
if ($isDirectory) { [System.IO.Directory]::Delete($entry, $false) } else { [System.IO.File]::Delete($entry) }
|
||||
continue
|
||||
}
|
||||
if (-not (Test-IsOwnedByUs $entry)) {
|
||||
$Left.Foreign.Add($entry)
|
||||
continue
|
||||
}
|
||||
if ($isDirectory) {
|
||||
Remove-OwnTreeEntries -Path $entry -Left $Left
|
||||
[System.IO.Directory]::Delete($entry, $false)
|
||||
} else {
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReadOnly) -ne 0) {
|
||||
[System.IO.File]::SetAttributes($entry, ($attributes -band (-bnot [System.IO.FileAttributes]::ReadOnly)))
|
||||
}
|
||||
Remove-OwnFile -Path $entry
|
||||
}
|
||||
} catch {
|
||||
$Left.Locked.Add($entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Deletes our own entries in a folder we own. Links are removed, never followed; other
|
||||
# accounts' entries stay. Returns what stayed; never throws.
|
||||
function Remove-OwnTree {
|
||||
param([string]$Path, [switch]$KeepRoot)
|
||||
$left = [pscustomobject]@{
|
||||
Foreign = New-Object System.Collections.Generic.List[string]
|
||||
Locked = New-Object System.Collections.Generic.List[string]
|
||||
}
|
||||
Remove-OwnTreeEntries -Path $Path -Left $left
|
||||
if (-not $KeepRoot) {
|
||||
try { [System.IO.Directory]::Delete($Path, $false) } catch { $left.Locked.Add($Path) }
|
||||
}
|
||||
return $left
|
||||
}
|
||||
|
||||
# Creates the agent folder protected, or protects and empties one of ours. Throws for a link,
|
||||
# a file, or a folder that is, or holds entries that are, another account's.
|
||||
function Initialize-AgentBase {
|
||||
param([string]$Path)
|
||||
$state = Get-FolderState $Path
|
||||
if ($state -eq 'Missing') {
|
||||
[System.IO.Directory]::CreateDirectory($Path, (New-ProtectedDirectorySecurity)) | Out-Null
|
||||
$state = Get-FolderState $Path
|
||||
if ($state -ne 'Ours') { throw "$Path could not be created as a protected folder ($state)." }
|
||||
return
|
||||
}
|
||||
if ($state -ne 'Ours') {
|
||||
throw "$Path is not a folder that SYSTEM or administrators created ($state); not installing into it."
|
||||
}
|
||||
(Get-Item -LiteralPath $Path -Force -ErrorAction Stop).SetAccessControl((New-ProtectedDirectorySecurity))
|
||||
$left = Remove-OwnTree -Path $Path -KeepRoot
|
||||
if ($left.Foreign.Count -gt 0) {
|
||||
throw ("$Path holds entries another account created ({0}); not installing into it." -f (($left.Foreign | Select-Object -First 3) -join ', '))
|
||||
}
|
||||
}
|
||||
|
||||
# Why the extracted payload must not run; empty when it matches its signed catalog. The
|
||||
# catalog Status covers the hashes only, so the signer is checked on its own. The comma keeps
|
||||
# a single rejection an array.
|
||||
function Get-AgentPayloadRejections {
|
||||
param(
|
||||
[string]$Path,
|
||||
@@ -96,11 +239,44 @@ function Get-AgentPayloadRejections {
|
||||
return , $rejected
|
||||
}
|
||||
|
||||
# Extracts the agent ZIP into a fresh staging folder, verifies it there and moves it into the
|
||||
# agent folder only when it matches its signed catalog. A rejected payload is deleted with the
|
||||
# staging folder: nothing unverified lands where SYSTEM runs the agent, and the next run starts
|
||||
# clean instead of finding a half-installed agent. The caller has made sure no agent is
|
||||
# installed, so anything already in the agent folder is a leftover and goes first.
|
||||
# Extracts the agent ZIP into the payload folder. Every entry is checked before the first one
|
||||
# is written: a rooted name, or one whose resolved path lies outside the folder, refuses the
|
||||
# whole package. The guarantee is this script's own, not the device's Expand-Archive's.
|
||||
function Expand-AgentPayload {
|
||||
param([string]$ZipPath, [string]$Destination)
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$root = [System.IO.Path]::GetFullPath($Destination).TrimEnd('\') + '\'
|
||||
$archive = [System.IO.Compression.ZipFile]::OpenRead($ZipPath)
|
||||
try {
|
||||
$planned = New-Object System.Collections.Generic.List[object]
|
||||
foreach ($entry in $archive.Entries) {
|
||||
$name = $entry.FullName
|
||||
$target = $null
|
||||
if (-not [System.IO.Path]::IsPathRooted($name) -and -not $name.Contains(':')) {
|
||||
$target = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($root, $name))
|
||||
}
|
||||
if ($null -eq $target -or -not $target.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "The agent package has an entry that would be written outside the payload folder ($name); refusing the package."
|
||||
}
|
||||
$planned.Add([pscustomobject]@{ Entry = $entry; Target = $target })
|
||||
}
|
||||
[System.IO.Directory]::CreateDirectory($root) | Out-Null
|
||||
foreach ($item in $planned) {
|
||||
if ($item.Entry.FullName.EndsWith('/') -or $item.Entry.FullName.EndsWith('\')) {
|
||||
[System.IO.Directory]::CreateDirectory($item.Target) | Out-Null
|
||||
continue
|
||||
}
|
||||
[System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($item.Target)) | Out-Null
|
||||
[System.IO.Compression.ZipFileExtensions]::ExtractToFile($item.Entry, $item.Target, $false)
|
||||
}
|
||||
} finally {
|
||||
$archive.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
# Verifies the payload in the run folder and moves it in as the agent folder. The move fails
|
||||
# when that folder exists, so nothing is ever merged into it.
|
||||
function Install-VerifiedAgentPayload {
|
||||
param(
|
||||
[string]$ZipPath,
|
||||
@@ -110,71 +286,89 @@ function Install-VerifiedAgentPayload {
|
||||
[string]$CatalogName = 'AutopilotMonitor.Agent.cat'
|
||||
)
|
||||
|
||||
if (Test-Path -LiteralPath $StagingPath) {
|
||||
Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
New-Item -Path $StagingPath -ItemType Directory -Force | Out-Null
|
||||
try {
|
||||
Expand-Archive -Path $ZipPath -DestinationPath $StagingPath -Force -ErrorAction Stop
|
||||
$payload = Join-Path $StagingPath 'Agent'
|
||||
Expand-AgentPayload -ZipPath $ZipPath -Destination $payload
|
||||
|
||||
$rejections = Get-AgentPayloadRejections -Path $StagingPath -ExpectedPublisher $ExpectedPublisher -CatalogName $CatalogName
|
||||
if ($rejections.Count -gt 0) {
|
||||
throw "Payload verification FAILED ($($rejections -join '; ')). Refusing to install an agent that is not the published one."
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $TargetPath) {
|
||||
Get-ChildItem -LiteralPath $TargetPath -Force | Remove-Item -Recurse -Force -ErrorAction Stop
|
||||
}
|
||||
else {
|
||||
New-Item -Path $TargetPath -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
Get-ChildItem -LiteralPath $StagingPath -Force | Move-Item -Destination $TargetPath -Force -ErrorAction Stop
|
||||
$rejections = Get-AgentPayloadRejections -Path $payload -ExpectedPublisher $ExpectedPublisher -CatalogName $CatalogName
|
||||
if ($rejections.Count -gt 0) {
|
||||
throw "Payload verification FAILED ($($rejections -join '; ')). Refusing to install an agent that is not the published one."
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
for ($attempt = 1; ; $attempt++) {
|
||||
try {
|
||||
[System.IO.Directory]::Move($payload, $TargetPath)
|
||||
return
|
||||
} catch {
|
||||
if ($attempt -ge 3 -or [System.IO.Directory]::Exists($TargetPath)) { throw }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
}
|
||||
}
|
||||
### END shared payload verification
|
||||
|
||||
# The agent writes its output to the host; only the exit code comes back.
|
||||
function Invoke-AgentInstallMode {
|
||||
param([string]$ExePath, [string[]]$Arguments = @('--install'))
|
||||
& $ExePath @Arguments | Out-Host
|
||||
return $LASTEXITCODE
|
||||
}
|
||||
### END shared install helpers
|
||||
|
||||
try {
|
||||
# #Requires has no effect under irm | iex.
|
||||
if (-not (Test-IsElevated)) {
|
||||
throw "Run this script from PowerShell as SYSTEM or administrator. Nothing was changed."
|
||||
}
|
||||
|
||||
# An installed agent only gets --install again. Otherwise the agent folder is prepared as
|
||||
# the installer prepares it: protected and empty.
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
$agentInstalled = Test-Path $agentExePath
|
||||
if (-not $agentInstalled) {
|
||||
Initialize-AgentBase -Path $AgentBasePath
|
||||
}
|
||||
[System.IO.Directory]::CreateDirectory($AgentLogPath) | Out-Null
|
||||
|
||||
Write-Log "===== Autopilot Monitor OOBE Bootstrap ====="
|
||||
Write-Log 'Bootstrap token expires: 2026-08-01T12:00:00.000Z'
|
||||
|
||||
# Check if agent is already installed
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
if (Test-Path $agentExePath) {
|
||||
if ($agentInstalled) {
|
||||
Write-Log "Agent already installed at $agentExePath -- skipping download"
|
||||
}
|
||||
else {
|
||||
# Download agent ZIP
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
# Download, verification and extraction happen in a run folder of their own.
|
||||
$staging = New-RunFolder -Parent $AgentBasePath -Prefix 'Agent-Install-'
|
||||
try {
|
||||
$zipPath = Join-Path $staging "AutopilotMonitor-Agent.zip"
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts"
|
||||
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
|
||||
$retryDelays = @(2, 4, 8)
|
||||
$delay = $retryDelays[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..."
|
||||
Start-Sleep -Seconds $delay
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts"
|
||||
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
|
||||
$retryDelays = @(2, 4, 8)
|
||||
$delay = $retryDelays[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..."
|
||||
Start-Sleep -Seconds $delay
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# Verify against the signed payload catalog before anything lands in the agent folder
|
||||
Write-Log "Verifying the payload against its signed catalog..."
|
||||
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Payload verified and installed to $AgentBinPath"
|
||||
# Verify against the signed payload catalog before anything lands in the agent folder
|
||||
Write-Log "Verifying the payload against its signed catalog..."
|
||||
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $staging -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
|
||||
Write-Log "Payload verified and installed to $AgentBinPath"
|
||||
}
|
||||
finally {
|
||||
$null = Remove-OwnTree -Path $staging
|
||||
}
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
@@ -183,8 +377,7 @@ try {
|
||||
|
||||
# Install agent with bootstrap token
|
||||
Write-Log "Installing agent with bootstrap token..."
|
||||
& $agentExePath --install --no-auth --bootstrap-token '22222222-2222-2222-2222-222222222222' --tenant-id '11111111-1111-1111-1111-111111111111'
|
||||
$installExitCode = $LASTEXITCODE
|
||||
$installExitCode = Invoke-AgentInstallMode -ExePath $agentExePath -Arguments @('--install', '--no-auth', '--bootstrap-token', '22222222-2222-2222-2222-222222222222', '--tenant-id', '11111111-1111-1111-1111-111111111111')
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
|
||||
@@ -11,12 +11,17 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// matching (the field incident that motivated the endpoint: a pattern verified with a PHP
|
||||
/// regex tester behaved differently under .NET), first-match-per-line, group extraction
|
||||
/// identical to what LogParserCollector puts into the emitted event data, CMTrace parsing
|
||||
/// via the SHARED CmTraceLogParser, and the format=text hint.
|
||||
/// via the SHARED CmTraceLogParser, and the format=text hint — plus the bounds that keep the
|
||||
/// caller's regex from occupying the shared Function App (D-379): first timeout ends the
|
||||
/// test, the time budget, cancellation, the pattern-length cap and the per-instance gate.
|
||||
/// </summary>
|
||||
public class TestLogPatternFunctionTests
|
||||
{
|
||||
private static Regex AgentRegex(string pattern)
|
||||
=> new(pattern, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
private static Regex AgentRegex(string pattern) => TestLogPatternFunction.BuildAgentRegex(pattern);
|
||||
|
||||
/// <summary>The production call minus the bounds: full budget, no cancellation.</summary>
|
||||
private static LogPatternTestResult Evaluate(Regex pattern, bool isTextMode, IReadOnlyList<string> lines)
|
||||
=> TestLogPatternFunction.EvaluatePattern(pattern, isTextMode, lines, TestLogPatternFunction.EvaluationBudget, CancellationToken.None);
|
||||
|
||||
private const string CmTraceLine =
|
||||
"<![LOG[Installation failed for app Ivanti Secure Access Client with error 0x87D1041C]LOG]!>" +
|
||||
@@ -27,7 +32,7 @@ public class TestLogPatternFunctionTests
|
||||
[Fact]
|
||||
public void TextMode_NamedGroups_LandInGroups()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
var result = Evaluate(
|
||||
AgentRegex(@"(?<level>ERROR|FATAL)\s+(?<component>\w+):\s+(?<message>.+)"),
|
||||
isTextMode: true,
|
||||
new[] { "ERROR Installer: disk full", "INFO Installer: all good" });
|
||||
@@ -38,6 +43,7 @@ public class TestLogPatternFunctionTests
|
||||
Assert.Equal("Installer", result.Lines[0].Groups!["component"]);
|
||||
Assert.Equal("disk full", result.Lines[0].Groups!["message"]);
|
||||
Assert.Equal("no_match", result.Lines[1].Outcome);
|
||||
Assert.Null(result.StopReason);
|
||||
}
|
||||
|
||||
/// <summary>The motivating field case: logparser matching has NO IgnoreCase — a pattern
|
||||
@@ -45,8 +51,7 @@ public class TestLogPatternFunctionTests
|
||||
[Fact]
|
||||
public void TextMode_IsCaseSensitive_AndSaysSoInTheNote()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("ERROR"), isTextMode: true, new[] { "error: something broke" });
|
||||
var result = Evaluate(AgentRegex("ERROR"), isTextMode: true, new[] { "error: something broke" });
|
||||
|
||||
Assert.Equal(0, result.MatchCount);
|
||||
Assert.Contains(result.Notes, n => n.Contains("case-SENSITIVE"));
|
||||
@@ -55,8 +60,7 @@ public class TestLogPatternFunctionTests
|
||||
[Fact]
|
||||
public void TextMode_UnsuccessfulOptionalGroup_IsOmitted()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex(@"ERROR(?<code>\s0x[0-9A-F]+)?"), isTextMode: true, new[] { "ERROR without code" });
|
||||
var result = Evaluate(AgentRegex(@"ERROR(?<code>\s0x[0-9A-F]+)?"), isTextMode: true, new[] { "ERROR without code" });
|
||||
|
||||
Assert.Equal(1, result.MatchCount);
|
||||
Assert.False(result.Lines[0].Groups!.ContainsKey("code"));
|
||||
@@ -67,8 +71,7 @@ public class TestLogPatternFunctionTests
|
||||
[Fact]
|
||||
public void CmTraceMode_MatchesAgainstParsedMessage_AndReportsComponentAndType()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex(@"error (?<errorCode>0x[0-9A-Fa-f]+)"), isTextMode: false, new[] { CmTraceLine });
|
||||
var result = Evaluate(AgentRegex(@"error (?<errorCode>0x[0-9A-Fa-f]+)"), isTextMode: false, new[] { CmTraceLine });
|
||||
|
||||
Assert.Equal(1, result.MatchCount);
|
||||
var row = result.Lines[0];
|
||||
@@ -84,8 +87,7 @@ public class TestLogPatternFunctionTests
|
||||
{
|
||||
// "component=" only exists in the CMTrace envelope, never in the message the
|
||||
// agent matches against — a pattern targeting it must NOT match.
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("component="), isTextMode: false, new[] { CmTraceLine });
|
||||
var result = Evaluate(AgentRegex("component="), isTextMode: false, new[] { CmTraceLine });
|
||||
|
||||
Assert.Equal(0, result.MatchCount);
|
||||
Assert.Equal("no_match", result.Lines[0].Outcome);
|
||||
@@ -94,8 +96,7 @@ public class TestLogPatternFunctionTests
|
||||
[Fact]
|
||||
public void CmTraceMode_PlainTextLines_ParseFail_WithFormatHint()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("ERROR"), isTextMode: false, new[] { "ERROR plain line", "another plain line" });
|
||||
var result = Evaluate(AgentRegex("ERROR"), isTextMode: false, new[] { "ERROR plain line", "another plain line" });
|
||||
|
||||
Assert.Equal(2, result.ParseFailureCount);
|
||||
Assert.All(result.Lines, l => Assert.Equal("parse_failed", l.Outcome));
|
||||
@@ -146,6 +147,45 @@ public class TestLogPatternFunctionTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The cap is the MCP schema's value; it is checked in validation, i.e. before
|
||||
/// the handler ever constructs a Regex from the caller's text.</summary>
|
||||
[Fact]
|
||||
public void Validate_RejectsOversizedPattern_AndAcceptsTheCapExactly()
|
||||
{
|
||||
var oversized = new TestLogPatternRequest
|
||||
{
|
||||
Pattern = new string('a', TestLogPatternFunction.MaxPatternLength + 1),
|
||||
SampleLines = new List<string> { "x" },
|
||||
};
|
||||
var error = TestLogPatternFunction.ValidateRequest(oversized);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("pattern", error);
|
||||
Assert.Contains(TestLogPatternFunction.MaxPatternLength.ToString(), error);
|
||||
|
||||
Assert.Null(TestLogPatternFunction.ValidateRequest(new TestLogPatternRequest
|
||||
{
|
||||
Pattern = new string('a', TestLogPatternFunction.MaxPatternLength),
|
||||
SampleLines = new List<string> { "x" },
|
||||
}));
|
||||
}
|
||||
|
||||
// ===== the regex construction =====
|
||||
|
||||
/// <summary>Agent parity minus Compiled: the semantic options and the 1 s match timeout
|
||||
/// are the agent's; Compiled (speed only) is deliberately absent for the one-shot pattern.</summary>
|
||||
[Fact]
|
||||
public void BuildAgentRegex_IsCaseSensitive_Interpreted_WithTheAgentsMatchTimeout()
|
||||
{
|
||||
var regex = TestLogPatternFunction.BuildAgentRegex("ERROR");
|
||||
|
||||
Assert.Equal(RegexOptions.None, regex.Options);
|
||||
Assert.Equal(TimeSpan.FromSeconds(1), regex.MatchTimeout);
|
||||
Assert.Equal(TestLogPatternFunction.MatchTimeout, regex.MatchTimeout);
|
||||
Assert.DoesNotMatch(regex, "error");
|
||||
// RegexParseException derives from ArgumentException — the handler's catch clause.
|
||||
Assert.ThrowsAny<ArgumentException>(() => TestLogPatternFunction.BuildAgentRegex("(unclosed"));
|
||||
}
|
||||
|
||||
// ===== hostile sample lines: the built-in CMTrace parser must stay bounded =====
|
||||
|
||||
/// <summary>A maximal request (200 x 8192-char lines) built from the reported worst case:
|
||||
@@ -160,11 +200,117 @@ public class TestLogPatternFunctionTests
|
||||
var lines = Enumerable.Repeat(line, TestLogPatternFunction.MaxSampleLines).ToArray();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = TestLogPatternFunction.EvaluatePattern(AgentRegex("error"), isTextMode: false, lines);
|
||||
var result = Evaluate(AgentRegex("error"), isTextMode: false, lines);
|
||||
sw.Stop();
|
||||
|
||||
Assert.Equal(TestLogPatternFunction.MaxSampleLines, result.ParseFailureCount);
|
||||
Assert.All(result.Lines, l => Assert.Equal("parse_failed", l.Outcome));
|
||||
Assert.Null(result.StopReason);
|
||||
Assert.True(sw.ElapsedMilliseconds < 1000, $"maximal worst-case request took {sw.ElapsedMilliseconds} ms");
|
||||
}
|
||||
|
||||
// ===== hostile pattern: the caller's regex must stay bounded (D-379) =====
|
||||
|
||||
/// <summary>The reported attack: a catastrophic pattern over 200 non-matching lines used to
|
||||
/// cost one 1 s timeout per line (~200 s of a worker thread). The first timeout now ends the
|
||||
/// test — one row, the reason, and the unevaluated range named in a note.</summary>
|
||||
[Fact]
|
||||
public void TextMode_CatastrophicPattern_StopsAtTheFirstTimeout()
|
||||
{
|
||||
var hostile = new string('a', 60) + "!";
|
||||
var lines = Enumerable.Repeat(hostile, TestLogPatternFunction.MaxSampleLines).ToArray();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = Evaluate(AgentRegex(@"^(\w+\s?)+$"), isTextMode: true, lines);
|
||||
sw.Stop();
|
||||
|
||||
Assert.Equal(1, result.TimeoutCount);
|
||||
Assert.Single(result.Lines);
|
||||
Assert.Equal("regex_timeout", result.Lines[0].Outcome);
|
||||
Assert.Equal(TestLogPatternFunction.StopReasonRegexTimeout, result.StopReason);
|
||||
Assert.Contains(result.Notes, n => n.Contains("line 1 hit") && n.Contains("lines 2-200 were not evaluated"));
|
||||
Assert.DoesNotContain(result.Notes, n => n.Contains("case-SENSITIVE"));
|
||||
Assert.True(sw.ElapsedMilliseconds < 4000, $"catastrophic request took {sw.ElapsedMilliseconds} ms");
|
||||
}
|
||||
|
||||
/// <summary>A timeout on the LAST line is a complete test: its row and note stay, but
|
||||
/// nothing was skipped, so there is no stop reason.</summary>
|
||||
[Fact]
|
||||
public void TextMode_TimeoutOnTheLastLine_IsNotAStop()
|
||||
{
|
||||
var result = Evaluate(AgentRegex(@"^(\w+\s?)+$"), isTextMode: true, new[] { "a b", new string('a', 60) + "!" });
|
||||
|
||||
Assert.Equal(1, result.MatchCount);
|
||||
Assert.Equal(1, result.TimeoutCount);
|
||||
Assert.Equal(2, result.Lines.Count);
|
||||
Assert.Equal("regex_timeout", result.Lines[1].Outcome);
|
||||
Assert.Null(result.StopReason);
|
||||
Assert.Contains(result.Notes, n => n.Contains("line 2 hit") && !n.Contains("not evaluated"));
|
||||
}
|
||||
|
||||
/// <summary>The budget is checked before every line but the first, so a spent budget
|
||||
/// (here: zero) still yields exactly one evaluated line and names the rest.</summary>
|
||||
[Fact]
|
||||
public void BudgetSpent_StopsBeforeTheNextLine_AndNamesTheRest()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("ERROR"), isTextMode: true, new[] { "ERROR one", "ERROR two", "ERROR three" },
|
||||
TimeSpan.Zero, CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Lines);
|
||||
Assert.Equal("matched", result.Lines[0].Outcome);
|
||||
Assert.Equal(TestLogPatternFunction.StopReasonTimeBudget, result.StopReason);
|
||||
Assert.Contains(result.Notes, n => n.Contains("after line 1") && n.Contains("lines 2-3 were not evaluated"));
|
||||
|
||||
var twoLines = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("ERROR"), isTextMode: true, new[] { "ERROR one", "ERROR two" },
|
||||
TimeSpan.Zero, CancellationToken.None);
|
||||
Assert.Contains(twoLines.Notes, n => n.Contains("line 2 was not evaluated"));
|
||||
}
|
||||
|
||||
/// <summary>A cancelled invocation (client gone, host stopping) ends the loop before the
|
||||
/// next line without throwing — a thrown OperationCanceledException would surface as a
|
||||
/// failed invocation in App Insights.</summary>
|
||||
[Fact]
|
||||
public void CancelledToken_StopsBeforeTheFirstLine_WithoutThrowing()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("ERROR"), isTextMode: true, new[] { "ERROR one" },
|
||||
TestLogPatternFunction.EvaluationBudget, cts.Token);
|
||||
|
||||
Assert.Empty(result.Lines);
|
||||
Assert.Equal(TestLogPatternFunction.StopReasonCancelled, result.StopReason);
|
||||
Assert.Equal(0, result.MatchCount);
|
||||
}
|
||||
|
||||
// ===== the per-instance gate =====
|
||||
|
||||
[Fact]
|
||||
public void Gate_HandsOutLimitSlots_AndTakesThemBackOnDispose()
|
||||
{
|
||||
var gate = new TestLogPatternGate(1);
|
||||
Assert.Equal(1, gate.Limit);
|
||||
|
||||
var first = gate.TryEnter();
|
||||
Assert.NotNull(first);
|
||||
Assert.Null(gate.TryEnter());
|
||||
|
||||
first.Dispose();
|
||||
first.Dispose(); // idempotent: a double release must not widen the gate
|
||||
var second = gate.TryEnter();
|
||||
Assert.NotNull(second);
|
||||
Assert.Null(gate.TryEnter());
|
||||
second.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_DefaultLimit_IsTwoPerInstance()
|
||||
{
|
||||
var gate = new TestLogPatternGate();
|
||||
Assert.Equal(2, gate.Limit);
|
||||
Assert.Equal(TestLogPatternGate.DefaultLimit, gate.Limit);
|
||||
}
|
||||
}
|
||||
|
||||
+28
-38
@@ -128,28 +128,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DisableGlobalAdminAsync(string upn)
|
||||
{
|
||||
upn = upn.ToLowerInvariant();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _globalAdminsTableClient.GetEntityAsync<GlobalAdminEntity>(
|
||||
"GlobalAdmins", upn);
|
||||
var entity = result.Value;
|
||||
if (entity != null)
|
||||
{
|
||||
entity.IsEnabled = false;
|
||||
await _globalAdminsTableClient.UpdateEntityAsync(entity, ETag.All);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- MCP Users ---
|
||||
|
||||
public async Task<McpUserEntry?> GetMcpUserAsync(string upn)
|
||||
@@ -825,12 +803,33 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
|
||||
public async Task<bool> AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false)
|
||||
{
|
||||
await _tenantAdminsTableClient.UpsertEntityAsync(NewMemberEntity(tenantId, upn, addedBy, role, canManageBootstrapTokens));
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> TryAddTenantMemberAsync(string tenantId, string upn, string addedBy, string role)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Conditional INSERT, never upsert: a row the tenant already holds for this UPN
|
||||
// (disabled, demoted) stays as the tenant set it, and two first-admin writers for
|
||||
// the same UPN cannot both land.
|
||||
await _tenantAdminsTableClient.AddEntityAsync(NewMemberEntity(tenantId, upn, addedBy, role, canManageBootstrapTokens: false));
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 409)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static TenantAdminEntity NewMemberEntity(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens)
|
||||
{
|
||||
tenantId = tenantId.ToLowerInvariant();
|
||||
upn = upn.ToLowerInvariant();
|
||||
addedBy = addedBy.ToLowerInvariant();
|
||||
|
||||
var entity = new TenantAdminEntity
|
||||
return new TenantAdminEntity
|
||||
{
|
||||
PartitionKey = tenantId,
|
||||
RowKey = upn,
|
||||
@@ -838,21 +837,20 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
Upn = upn,
|
||||
IsEnabled = true,
|
||||
AddedDate = DateTime.UtcNow,
|
||||
AddedBy = addedBy,
|
||||
AddedBy = addedBy.ToLowerInvariant(),
|
||||
Role = role,
|
||||
CanManageBootstrapTokens = canManageBootstrapTokens
|
||||
};
|
||||
|
||||
await _tenantAdminsTableClient.UpsertEntityAsync(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveTenantMemberAsync(string tenantId, string upn)
|
||||
{
|
||||
tenantId = tenantId.ToLowerInvariant();
|
||||
upn = upn.ToLowerInvariant();
|
||||
await _tenantAdminsTableClient.DeleteEntityAsync(tenantId, upn);
|
||||
return true;
|
||||
// A missing row does NOT throw, DeleteEntityAsync returns the 404 response (see
|
||||
// RemoveDelegatedAdminAsync): report it, so removing a non-member 404s instead of auditing.
|
||||
var response = await _tenantAdminsTableClient.DeleteEntityAsync(tenantId, upn);
|
||||
return response.Status != 404;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateMemberPermissionsAsync(string tenantId, string upn, string role, bool canManageBootstrapTokens)
|
||||
@@ -921,14 +919,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> IsTenantAdminAsync(string tenantId, string upn)
|
||||
{
|
||||
var member = await GetTenantMemberAsync(tenantId, upn);
|
||||
if (member == null || !member.IsEnabled) return false;
|
||||
// Only true for Admin role (null Role = Admin for backward compat)
|
||||
return member.Role == null || member.Role == Constants.TenantRoles.Admin;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private static DelegatedAdminEntry MapToDelegatedEntry(DelegatedAdminEntity entity)
|
||||
|
||||
+28
@@ -751,6 +751,32 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MarkRevokedAsync(string tenantId, string revokedBy)
|
||||
{
|
||||
var entity = new TableEntity(tenantId, "revoked")
|
||||
{
|
||||
{ "RevokedAt", DateTime.UtcNow },
|
||||
{ "RevokedBy", revokedBy }
|
||||
};
|
||||
|
||||
// Upsert (a second revoke refreshes the record); errors throw, because the revoke must
|
||||
// not proceed without its record — the seat guard of a later re-activation reads it.
|
||||
await _previewWhitelistTableClient.UpsertEntityAsync(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> WasRevokedAsync(string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await _previewWhitelistTableClient.GetEntityAsync<TableEntity>(tenantId, "revoked");
|
||||
return entity?.Value != null;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tenant Configuration Entity Mapping ---
|
||||
|
||||
// Internal static (not private): the Store↔Map pair is a serialization CONTRACT — every
|
||||
@@ -833,6 +859,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "EnableSoftwareInventoryAnalyzer", config.EnableSoftwareInventoryAnalyzer },
|
||||
{ "EnableIntegrityBypassAnalyzer", config.EnableIntegrityBypassAnalyzer },
|
||||
{ "EnableRealmJoinWatcher", config.EnableRealmJoinWatcher },
|
||||
{ "EnableConfigMgrWatcher", config.EnableConfigMgrWatcher },
|
||||
{ "KeepAwakeDuringUserEsp", config.KeepAwakeDuringUserEsp },
|
||||
{ "EnableConsoleBypassDetection", config.EnableConsoleBypassDetection },
|
||||
{ "LocalAdminAllowedAccountsJson", config.LocalAdminAllowedAccountsJson },
|
||||
@@ -954,6 +981,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
EnableSoftwareInventoryAnalyzer = entity.GetBoolean("EnableSoftwareInventoryAnalyzer"),
|
||||
EnableIntegrityBypassAnalyzer = entity.GetBoolean("EnableIntegrityBypassAnalyzer"),
|
||||
EnableRealmJoinWatcher = entity.GetBoolean("EnableRealmJoinWatcher"),
|
||||
EnableConfigMgrWatcher = entity.GetBoolean("EnableConfigMgrWatcher"),
|
||||
KeepAwakeDuringUserEsp = entity.GetBoolean("KeepAwakeDuringUserEsp"),
|
||||
EnableConsoleBypassDetection = entity.GetBoolean("EnableConsoleBypassDetection"),
|
||||
LocalAdminAllowedAccountsJson = entity.GetString("LocalAdminAllowedAccountsJson"),
|
||||
|
||||
+5
-5
@@ -27,7 +27,7 @@ public class DelegatedAdminManagementFunction
|
||||
private readonly DelegatedAdminService _delegatedAdminService;
|
||||
private readonly AdminIdentityResolver _identityResolver;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly ISignalRNotificationService _signalRService;
|
||||
private readonly LiveStreamRevoker _streams;
|
||||
private readonly DelegatedSlotService _slots;
|
||||
|
||||
public DelegatedAdminManagementFunction(
|
||||
@@ -35,14 +35,14 @@ public class DelegatedAdminManagementFunction
|
||||
DelegatedAdminService delegatedAdminService,
|
||||
AdminIdentityResolver identityResolver,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
ISignalRNotificationService signalRService,
|
||||
LiveStreamRevoker streams,
|
||||
DelegatedSlotService slots)
|
||||
{
|
||||
_logger = logger;
|
||||
_delegatedAdminService = delegatedAdminService;
|
||||
_identityResolver = identityResolver;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_signalRService = signalRService;
|
||||
_streams = streams;
|
||||
_slots = slots;
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ public class DelegatedAdminManagementFunction
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
tenantId.ToLowerInvariant(), "DELETE", "DelegatedAdmin", upn.ToLowerInvariant(), currentUpn ?? "");
|
||||
|
||||
await _signalRService.DisconnectUserAsync(upn.ToLowerInvariant());
|
||||
await _streams.CutBoundIdentityAsync(upn);
|
||||
|
||||
_logger.LogInformation("Delegated revoke: {Upn} -> {TenantId} by {By}", upn, tenantId, currentUpn);
|
||||
return true;
|
||||
@@ -210,7 +210,7 @@ public class DelegatedAdminManagementFunction
|
||||
new Dictionary<string, string> { { "IsEnabled", isEnabled.ToString() } });
|
||||
|
||||
if (!isEnabled)
|
||||
await _signalRService.DisconnectUserAsync(upn.ToLowerInvariant());
|
||||
await _streams.CutBoundIdentityAsync(upn);
|
||||
|
||||
_logger.LogInformation("Delegated {Action}: {Upn} -> {TenantId} by {By}",
|
||||
isEnabled ? "enable" : "disable", upn, tenantId, currentUpn);
|
||||
|
||||
+12
-2
@@ -17,7 +17,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin;
|
||||
/// Grants create a binding implicitly and REFUSE to overwrite one (409); this surface is the explicit, audited
|
||||
/// path for the two legitimate changes: re-homing a UPN in another tenant, and re-pinning the object id after
|
||||
/// the UPN was re-assigned to a new account (PUT without objectId clears the pin so the next sign-in from the
|
||||
/// bound tenant re-pins). DELETE makes every role row of the UPN inert without touching the rows.
|
||||
/// bound tenant re-pins). DELETE makes every role row of the UPN inert without touching the rows. Replacing or
|
||||
/// deleting a binding cuts the live streams signed in under the previous one.
|
||||
///
|
||||
/// Reads are GlobalReadOrAdmin; mutations are GlobalAdminOnly (route catalog). Audited under the binding's
|
||||
/// HOME tenant with entity <c>AdminIdentityBinding</c>.
|
||||
@@ -27,17 +28,20 @@ public class IdentityBindingManagementFunction
|
||||
private readonly ILogger<IdentityBindingManagementFunction> _logger;
|
||||
private readonly AdminIdentityBindingService _bindings;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly LiveStreamRevoker _streams;
|
||||
|
||||
private const string AuditEntity = "AdminIdentityBinding";
|
||||
|
||||
public IdentityBindingManagementFunction(
|
||||
ILogger<IdentityBindingManagementFunction> logger,
|
||||
AdminIdentityBindingService bindings,
|
||||
IMaintenanceRepository maintenanceRepo)
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
LiveStreamRevoker streams)
|
||||
{
|
||||
_logger = logger;
|
||||
_bindings = bindings;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_streams = streams;
|
||||
}
|
||||
|
||||
/// <summary>GET /api/global/identity-bindings — every binding. GlobalReadOrAdmin.</summary>
|
||||
@@ -93,6 +97,11 @@ public class IdentityBindingManagementFunction
|
||||
previous == null ? "Created" : "Replaced", upn, binding.TenantId,
|
||||
binding.IsObjectIdPinned ? binding.ObjectId : "(unpinned)", currentUpn);
|
||||
|
||||
// Every platform and delegated grant of the UPN resolved under the previous binding: cut those
|
||||
// connections, so their re-join meets the new one.
|
||||
if (previous != null)
|
||||
await _streams.CutAsync(previous.TenantId, upn);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new IdentityBindingResponse { Binding = binding });
|
||||
return response;
|
||||
@@ -128,6 +137,7 @@ public class IdentityBindingManagementFunction
|
||||
});
|
||||
_logger.LogWarning("[IdentityBinding] Removed binding for {Upn} (was tenant {TenantId}) by {By}",
|
||||
upn, previous.TenantId, currentUpn);
|
||||
await _streams.CutAsync(previous.TenantId, upn);
|
||||
|
||||
return await req.OkAsync(new MessageResponse { Message = "Binding removed" });
|
||||
}
|
||||
|
||||
+27
-17
@@ -19,20 +19,20 @@ public class TenantAdminManagementFunction
|
||||
private readonly ILogger<TenantAdminManagementFunction> _logger;
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly ISignalRNotificationService _signalRService;
|
||||
private readonly LiveStreamRevoker _streams;
|
||||
private readonly ISelfServiceAssignees _selfServiceAssignees;
|
||||
|
||||
public TenantAdminManagementFunction(
|
||||
ILogger<TenantAdminManagementFunction> logger,
|
||||
TenantAdminsService tenantAdminsService,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
ISignalRNotificationService signalRService,
|
||||
LiveStreamRevoker streams,
|
||||
ISelfServiceAssignees selfServiceAssignees)
|
||||
{
|
||||
_logger = logger;
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_signalRService = signalRService;
|
||||
_streams = streams;
|
||||
_selfServiceAssignees = selfServiceAssignees;
|
||||
}
|
||||
|
||||
@@ -59,12 +59,13 @@ public class TenantAdminManagementFunction
|
||||
/// SignalR group authorization is evaluated only at join time (SignalRAddToGroupFunction), so a
|
||||
/// member whose role was removed, disabled or lowered would keep their already-joined
|
||||
/// 'tenant-{tid}' broadcast, notify-group and session-group streams until the socket drops.
|
||||
/// Cutting the UPN's connections (negotiate binds userId = lowercased UPN) forces a reconnect that
|
||||
/// re-runs the join gates against the new role. Coarse by design — a still-authorized client
|
||||
/// simply reconnects — and mirrors the delegated-admin / tenant-group revoke paths.
|
||||
/// Cutting the member's connections forces a reconnect that re-runs the join gates against the new
|
||||
/// role. Coarse by design — a still-authorized client simply reconnects. The cut names this tenant
|
||||
/// only: the membership resolves under it, and so does the member's self-service delegation, so
|
||||
/// nothing here reaches the same UPN signed in to another tenant (see <see cref="LiveStreamRevoker"/>).
|
||||
/// </summary>
|
||||
private Task CutLiveStreamsAsync(string adminUpn) =>
|
||||
_signalRService.DisconnectUserAsync(adminUpn.ToLowerInvariant());
|
||||
private Task CutLiveStreamsAsync(string tenantId, string adminUpn) =>
|
||||
_streams.CutAsync(tenantId, adminUpn);
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/tenants/{tenantId}/admins
|
||||
@@ -198,8 +199,11 @@ public class TenantAdminManagementFunction
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the admin
|
||||
await _tenantAdminsService.RemoveTenantAdminAsync(requestCtx.TargetTenantId, adminUpn);
|
||||
// Remove the admin. A UPN without a row in this tenant is not this tenant's to act on: no audit, no cut.
|
||||
if (!await _tenantAdminsService.RemoveTenantAdminAsync(requestCtx.TargetTenantId, adminUpn))
|
||||
{
|
||||
return await req.NotFoundAsync("Member not found");
|
||||
}
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
@@ -210,7 +214,7 @@ public class TenantAdminManagementFunction
|
||||
);
|
||||
|
||||
await EndSelfServiceAssignmentAsync(requestCtx.TargetTenantId, adminUpn, upn!, DelegationSelfService.AssigneeReasons.MemberRemoved);
|
||||
await CutLiveStreamsAsync(adminUpn);
|
||||
await CutLiveStreamsAsync(requestCtx.TargetTenantId, adminUpn);
|
||||
|
||||
_logger.LogInformation($"Tenant Admin removed: {adminUpn} from tenant {requestCtx.TargetTenantId} by {upn}");
|
||||
|
||||
@@ -234,8 +238,11 @@ public class TenantAdminManagementFunction
|
||||
var requestCtx = context.GetRequestContext();
|
||||
var upn = requestCtx.UserPrincipalName;
|
||||
|
||||
// Disable the admin
|
||||
await _tenantAdminsService.DisableTenantAdminAsync(requestCtx.TargetTenantId, adminUpn);
|
||||
// Disable the admin (404 for a UPN without a row in this tenant, as on remove)
|
||||
if (!await _tenantAdminsService.DisableTenantAdminAsync(requestCtx.TargetTenantId, adminUpn))
|
||||
{
|
||||
return await req.NotFoundAsync("Member not found");
|
||||
}
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
@@ -247,7 +254,7 @@ public class TenantAdminManagementFunction
|
||||
);
|
||||
|
||||
await EndSelfServiceAssignmentAsync(requestCtx.TargetTenantId, adminUpn, upn!, DelegationSelfService.AssigneeReasons.MemberDisabled);
|
||||
await CutLiveStreamsAsync(adminUpn);
|
||||
await CutLiveStreamsAsync(requestCtx.TargetTenantId, adminUpn);
|
||||
|
||||
_logger.LogInformation($"Tenant Admin disabled: {adminUpn} for tenant {requestCtx.TargetTenantId} by {upn}");
|
||||
|
||||
@@ -271,8 +278,11 @@ public class TenantAdminManagementFunction
|
||||
var requestCtx = context.GetRequestContext();
|
||||
var upn = requestCtx.UserPrincipalName;
|
||||
|
||||
// Enable the admin
|
||||
await _tenantAdminsService.EnableTenantAdminAsync(requestCtx.TargetTenantId, adminUpn);
|
||||
// Enable the admin (404 for a UPN without a row in this tenant, as on remove)
|
||||
if (!await _tenantAdminsService.EnableTenantAdminAsync(requestCtx.TargetTenantId, adminUpn))
|
||||
{
|
||||
return await req.NotFoundAsync("Member not found");
|
||||
}
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
@@ -359,7 +369,7 @@ public class TenantAdminManagementFunction
|
||||
|
||||
// Only Admin holds the admin-tier notify group; any other target role may have lost it.
|
||||
if (role != AutopilotMonitor.Shared.Constants.TenantRoles.Admin)
|
||||
await CutLiveStreamsAsync(adminUpn);
|
||||
await CutLiveStreamsAsync(requestCtx.TargetTenantId, adminUpn);
|
||||
|
||||
_logger.LogInformation("Member permissions updated: {AdminUpn} -> role={Role} in tenant {TenantId} by {Upn}", adminUpn, role, requestCtx.TargetTenantId, upn);
|
||||
|
||||
|
||||
+7
-7
@@ -36,7 +36,7 @@ public class TenantGroupManagementFunction
|
||||
private readonly DelegatedAdminService _delegatedAdminService;
|
||||
private readonly AdminIdentityResolver _identityResolver;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly ISignalRNotificationService _signalRService;
|
||||
private readonly LiveStreamRevoker _streams;
|
||||
private readonly DelegatedSlotService _slots;
|
||||
private readonly ProConferralService _proConferral;
|
||||
private readonly ISelfServiceAssignees _selfServiceAssignees;
|
||||
@@ -48,7 +48,7 @@ public class TenantGroupManagementFunction
|
||||
DelegatedAdminService delegatedAdminService,
|
||||
AdminIdentityResolver identityResolver,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
ISignalRNotificationService signalRService,
|
||||
LiveStreamRevoker streams,
|
||||
DelegatedSlotService slots,
|
||||
ProConferralService proConferral,
|
||||
ISelfServiceAssignees selfServiceAssignees)
|
||||
@@ -57,7 +57,7 @@ public class TenantGroupManagementFunction
|
||||
_delegatedAdminService = delegatedAdminService;
|
||||
_identityResolver = identityResolver;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_signalRService = signalRService;
|
||||
_streams = streams;
|
||||
_slots = slots;
|
||||
_proConferral = proConferral;
|
||||
_selfServiceAssignees = selfServiceAssignees;
|
||||
@@ -204,7 +204,7 @@ public class TenantGroupManagementFunction
|
||||
await _delegatedAdminService.DeleteGroupAsync(groupId);
|
||||
|
||||
foreach (var assignee in assignees)
|
||||
await _signalRService.DisconnectUserAsync(assignee.Upn);
|
||||
await _streams.CutBoundIdentityAsync(assignee.Upn);
|
||||
|
||||
// The portal hides Delete for owned groups, but the route accepts any group id — without the
|
||||
// anchor a member that raised its retention under Pro (MSP) would be clamped to the Community
|
||||
@@ -309,9 +309,9 @@ public class TenantGroupManagementFunction
|
||||
normalizedTenantId, "DELETE", AuditEntity, assignee.Upn, currentUpn ?? "",
|
||||
new Dictionary<string, string> { { "GroupId", groupId }, { "Reason", "tenant-removed-from-group" } });
|
||||
// Enforcement: cut already-joined live streams for the tenant that just left the group.
|
||||
// Coarse by design (connection close, see DisconnectUserAsync) — still-authorized
|
||||
// Coarse by design (connection close, see LiveStreamRevoker) — still-authorized
|
||||
// streams recover automatically via the client's reconnect + re-join.
|
||||
await _signalRService.DisconnectUserAsync(assignee.Upn);
|
||||
await _streams.CutBoundIdentityAsync(assignee.Upn);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Tenant {TenantId} removed from group {GroupId} by {By}", normalizedTenantId, groupId, currentUpn);
|
||||
@@ -463,7 +463,7 @@ public class TenantGroupManagementFunction
|
||||
if (!unassigned)
|
||||
return false;
|
||||
|
||||
await _signalRService.DisconnectUserAsync(normalizedUpn);
|
||||
await _streams.CutBoundIdentityAsync(normalizedUpn);
|
||||
|
||||
if (group != null)
|
||||
{
|
||||
|
||||
+59
-49
@@ -37,7 +37,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
// SECURITY INVARIANT: every template value comes either from
|
||||
// BootstrapScriptValueValidator.TryValidate (the request-derived ones) or from a
|
||||
// compile-time constant (portal URL, publisher pin, catalog name, and the installer's
|
||||
// payload-verification block embedded at build time). The single-quoted PowerShell
|
||||
// shared install helpers embedded at build time). The single-quoted PowerShell
|
||||
// string literals below are the second line of defense against injection
|
||||
// (single-quoted PS strings do NOT expand $(...), $var, or backticks).
|
||||
// Do NOT change to double-quoted without re-reviewing the validator's
|
||||
@@ -67,20 +67,20 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
.Replace("__PORTAL_URL__", Constants.PortalBaseUrl)
|
||||
.Replace("__EXPECTED_PUBLISHER__", Constants.CodeSigningPublisherPattern)
|
||||
.Replace("__PAYLOAD_CATALOG__", Constants.AgentPayloadCatalogFileName)
|
||||
.Replace("__PAYLOAD_VERIFICATION__", SharedPayloadVerification.Value);
|
||||
.Replace("__SHARED_INSTALL_HELPERS__", SharedInstallHelpers.Value);
|
||||
}
|
||||
|
||||
internal const string InstallerResourceName = "AutopilotMonitor.Functions.Bootstrap.Install-AutopilotMonitor.ps1";
|
||||
internal const string SharedBlockBegin = "### BEGIN shared payload verification";
|
||||
internal const string SharedBlockEnd = "### END shared payload verification";
|
||||
internal const string SharedBlockBegin = "### BEGIN shared install helpers";
|
||||
internal const string SharedBlockEnd = "### END shared install helpers";
|
||||
|
||||
// The payload check is the installer's own code, embedded at build time and cut out
|
||||
// between its markers: one text for both install paths, so they can never drift
|
||||
// (D-354). A missing marker fails the first request loudly instead of serving a
|
||||
// script that installs without the check.
|
||||
private static readonly Lazy<string> SharedPayloadVerification = new(LoadSharedPayloadVerification);
|
||||
// The payload check and the folder handling are the installer's own code, embedded at
|
||||
// build time and cut out between its markers: one text for both install paths, so they
|
||||
// can never drift (D-354). A missing marker fails the first request loudly instead of
|
||||
// serving a script that installs without them.
|
||||
private static readonly Lazy<string> SharedInstallHelpers = new(LoadSharedInstallHelpers);
|
||||
|
||||
internal static string LoadSharedPayloadVerification()
|
||||
internal static string LoadSharedInstallHelpers()
|
||||
{
|
||||
using var stream = typeof(OobeBootstrapScriptGenerator).Assembly.GetManifestResourceStream(InstallerResourceName)
|
||||
?? throw new InvalidOperationException($"Embedded resource {InstallerResourceName} is missing.");
|
||||
@@ -90,7 +90,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap
|
||||
var begin = installer.IndexOf(SharedBlockBegin, StringComparison.Ordinal);
|
||||
var end = installer.IndexOf(SharedBlockEnd, StringComparison.Ordinal);
|
||||
if (begin < 0 || end < begin || installer.IndexOf(SharedBlockBegin, begin + 1, StringComparison.Ordinal) >= 0)
|
||||
throw new InvalidOperationException("The installer's shared payload-verification markers are missing or ambiguous.");
|
||||
throw new InvalidOperationException("The installer's shared install-helper markers are missing or ambiguous.");
|
||||
|
||||
var lineEnd = installer.IndexOf('\n', end);
|
||||
return installer.Substring(begin, (lineEnd < 0 ? installer.Length : lineEnd) - begin);
|
||||
@@ -116,16 +116,10 @@ $AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\oobe_bootstrap.log"
|
||||
$AgentStagingPath = "$AgentBasePath\Agent-Install"
|
||||
|
||||
$ExpectedPublisher = '__EXPECTED_PUBLISHER__'
|
||||
$AgentCatalogName = '__PAYLOAD_CATALOG__'
|
||||
|
||||
# Create directories
|
||||
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"
|
||||
@@ -134,47 +128,64 @@ function Write-Log {
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
__PAYLOAD_VERIFICATION__
|
||||
__SHARED_INSTALL_HELPERS__
|
||||
|
||||
try {
|
||||
# #Requires has no effect under irm | iex.
|
||||
if (-not (Test-IsElevated)) {
|
||||
throw "Run this script from PowerShell as SYSTEM or administrator. Nothing was changed."
|
||||
}
|
||||
|
||||
# An installed agent only gets --install again. Otherwise the agent folder is prepared as
|
||||
# the installer prepares it: protected and empty.
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
$agentInstalled = Test-Path $agentExePath
|
||||
if (-not $agentInstalled) {
|
||||
Initialize-AgentBase -Path $AgentBasePath
|
||||
}
|
||||
[System.IO.Directory]::CreateDirectory($AgentLogPath) | Out-Null
|
||||
|
||||
Write-Log "===== Autopilot Monitor OOBE Bootstrap ====="
|
||||
Write-Log 'Bootstrap token expires: __EXPIRES_AT__'
|
||||
|
||||
# Check if agent is already installed
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
if (Test-Path $agentExePath) {
|
||||
if ($agentInstalled) {
|
||||
Write-Log "Agent already installed at $agentExePath -- skipping download"
|
||||
}
|
||||
else {
|
||||
# Download agent ZIP
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
# Download, verification and extraction happen in a run folder of their own.
|
||||
$staging = New-RunFolder -Parent $AgentBasePath -Prefix 'Agent-Install-'
|
||||
try {
|
||||
$zipPath = Join-Path $staging "AutopilotMonitor-Agent.zip"
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts"
|
||||
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
|
||||
$retryDelays = @(2, 4, 8)
|
||||
$delay = $retryDelays[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..."
|
||||
Start-Sleep -Seconds $delay
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt $downloadAttempt/$maxDownloadAttempts"
|
||||
Invoke-WebRequest -Uri $AgentDownloadUrl -OutFile $zipPath -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
|
||||
Write-Log "Downloaded agent to $zipPath"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) { throw }
|
||||
$retryDelays = @(2, 4, 8)
|
||||
$delay = $retryDelays[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${delay}s..."
|
||||
Start-Sleep -Seconds $delay
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# Verify against the signed payload catalog before anything lands in the agent folder
|
||||
Write-Log "Verifying the payload against its signed catalog..."
|
||||
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Payload verified and installed to $AgentBinPath"
|
||||
# Verify against the signed payload catalog before anything lands in the agent folder
|
||||
Write-Log "Verifying the payload against its signed catalog..."
|
||||
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $staging -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
|
||||
Write-Log "Payload verified and installed to $AgentBinPath"
|
||||
}
|
||||
finally {
|
||||
$null = Remove-OwnTree -Path $staging
|
||||
}
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
@@ -183,8 +194,7 @@ try {
|
||||
|
||||
# Install agent with bootstrap token
|
||||
Write-Log "Installing agent with bootstrap token..."
|
||||
& $agentExePath --install --no-auth --bootstrap-token '__BOOTSTRAP_TOKEN__' --tenant-id '__TENANT_ID__'
|
||||
$installExitCode = $LASTEXITCODE
|
||||
$installExitCode = Invoke-AgentInstallMode -ExePath $agentExePath -Arguments @('--install', '--no-auth', '--bootstrap-token', '__BOOTSTRAP_TOKEN__', '--tenant-id', '__TENANT_ID__')
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ public class AuthFunction
|
||||
private readonly Services.Activation.ITenantAutoApproveEnqueuer _tenantAutoApproveEnqueuer;
|
||||
private readonly EntraAppRegistry _appRegistry;
|
||||
private readonly AdminIdentityResolver _identityResolver;
|
||||
private readonly ISignalRNotificationService _signalRService;
|
||||
private readonly LiveStreamRevoker _streams;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly IOffboardingAuditRepository _offboardingRepo;
|
||||
private readonly Services.Push.PushOwnerSignInService? _pushSignIn;
|
||||
@@ -57,7 +57,7 @@ public class AuthFunction
|
||||
Services.Activation.ITenantAutoApproveEnqueuer tenantAutoApproveEnqueuer,
|
||||
EntraAppRegistry appRegistry,
|
||||
AdminIdentityResolver identityResolver,
|
||||
ISignalRNotificationService signalRService,
|
||||
LiveStreamRevoker streams,
|
||||
AdminConfigurationService adminConfigService,
|
||||
IOffboardingAuditRepository offboardingRepo,
|
||||
Services.Push.PushOwnerSignInService? pushSignIn = null,
|
||||
@@ -69,7 +69,7 @@ public class AuthFunction
|
||||
_offboardingRepo = offboardingRepo;
|
||||
_adminConfigService = adminConfigService;
|
||||
_identityResolver = identityResolver;
|
||||
_signalRService = signalRService;
|
||||
_streams = streams;
|
||||
_globalAdminService = globalAdminService;
|
||||
_delegatedAdminService = delegatedAdminService;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
@@ -127,15 +127,16 @@ public class AuthFunction
|
||||
var globalRoleTask = _globalAdminService.GetGlobalRoleAsync(identity);
|
||||
var delegatedScopeTask = _delegatedAdminService.GetScopeAsync(identity);
|
||||
var isApprovedTask = _previewWhitelistService.IsApprovedAsync(tenantId);
|
||||
var membershipTask = _tenantAdminsService.GetTableMembershipAsync(tenantId, upn);
|
||||
// The caller's row and whether the tenant has any member come from ONE read: the first-user
|
||||
// auto-admin decision must never combine two states of the TenantAdmins partition.
|
||||
var membershipTask = _tenantAdminsService.GetMembershipSnapshotAsync(tenantId, upn);
|
||||
// App roles ride along so the AllMembers path sees a claim-derived member the same way the policy
|
||||
// middleware does (the table-vs-claim reconciliation happens inside the shared role resolver).
|
||||
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, tenantId, objectId, principal.GetAppRoles());
|
||||
var existingAdminsTask = _tenantAdminsService.GetTenantAdminsAsync(tenantId);
|
||||
var whatsNewSeenTask = _metricsRepo.GetUserWhatsNewSeenAsync(tenantId, upn);
|
||||
|
||||
await Task.WhenAll(tenantConfigTask, globalRoleTask, delegatedScopeTask, isApprovedTask,
|
||||
membershipTask, mcpCheckTask, existingAdminsTask, whatsNewSeenTask);
|
||||
membershipTask, mcpCheckTask, whatsNewSeenTask);
|
||||
|
||||
var (tenantConfig, _) = tenantConfigTask.Result;
|
||||
var globalRole = globalRoleTask.Result;
|
||||
@@ -145,9 +146,8 @@ public class AuthFunction
|
||||
// Surfaced to the web app so it can show fleet/switcher UI and bound it to this set.
|
||||
var delegatedTenantIds = delegatedScopeTask.Result.TenantIds;
|
||||
var isApproved = isApprovedTask.Result;
|
||||
var (tableState, tableRole) = membershipTask.Result;
|
||||
var (tableState, tableRole, tenantHasMembers) = membershipTask.Result;
|
||||
var mcpCheck = mcpCheckTask.Result;
|
||||
var existingAdmins = existingAdminsTask.Result;
|
||||
var whatsNewSeen = whatsNewSeenTask.Result;
|
||||
|
||||
// Reconcile the TenantAdmins table state with any Entra app-role claim. An enabled row
|
||||
@@ -195,7 +195,7 @@ public class AuthFunction
|
||||
// --- Pure decision logic (tested by AuthFunctionTests) ---
|
||||
var decision = BuildAuthResult(
|
||||
tenantConfig, isGlobalAdmin, isGlobalReader, isApproved,
|
||||
memberRole, mcpCheck, existingAdmins.Count > 0,
|
||||
memberRole, mcpCheck, tenantHasMembers,
|
||||
tenantId, upn, displayName ?? string.Empty, objectId ?? string.Empty,
|
||||
delegatedTenantIds,
|
||||
homedApp: _appRegistry.ResolveForTenant(tenantConfig).IsLegacy ? "legacy" : "primary",
|
||||
@@ -433,9 +433,9 @@ public class AuthFunction
|
||||
|
||||
// SignalR group authorization is join-time only: a removed Global Admin/Reader with a live
|
||||
// connection in 'global-admins' would keep receiving cross-tenant pushes until the socket
|
||||
// drops. Cut the UPN's connections (negotiate binds userId = lowercased UPN); a reconnect
|
||||
// re-runs the join gates against the now-missing platform role.
|
||||
await _signalRService.DisconnectUserAsync(upn.ToLowerInvariant());
|
||||
// drops. Cut the connections signed in to the UPN's bound tenant, the only one a platform role
|
||||
// resolves under; a reconnect re-runs the join gates against the now-missing platform role.
|
||||
await _streams.CutBoundIdentityAsync(upn);
|
||||
|
||||
_logger.LogInformation($"Global Admin removed: {upn} by {currentUpn}");
|
||||
|
||||
@@ -701,8 +701,15 @@ public class AuthFunction
|
||||
{
|
||||
if (decision.NeedsAutoAdmin)
|
||||
{
|
||||
_logger.LogInformation("First user login for tenant {TenantId}: {Upn} - Auto-assigning as admin", tenantId, upn);
|
||||
await _tenantAdminsService.AddTenantAdminAsync(tenantId, upn, "System");
|
||||
// Warning on purpose: activation seats the onboarding member before it activates the tenant, so
|
||||
// this path is the exception (a tenant from before OnboardedBy existed, or a seat that went
|
||||
// missing), and Information never reaches Application Insights. A lost conditional insert means
|
||||
// a row for this caller appeared meanwhile (activation seating the same person, or a Global
|
||||
// Admin); it is left as written and the next request reads it.
|
||||
if (await _tenantAdminsService.TryAddTenantAdminAsync(tenantId, upn, "System"))
|
||||
_logger.LogWarning("First-user auto-admin: {Upn} seated as Admin of tenant {TenantId}", upn, tenantId);
|
||||
else
|
||||
_logger.LogInformation("First-user auto-admin for {Upn} in tenant {TenantId}: a member row appeared meanwhile — left untouched", upn, tenantId);
|
||||
}
|
||||
|
||||
_ = _metricsRepo.RecordUserLoginAsync(tenantId, upn, displayName, objectId)
|
||||
@@ -788,8 +795,18 @@ public class AuthFunction
|
||||
// it. A delegated user who legitimately administers their own tenant has a TenantAdmins row (memberRole
|
||||
// != null), so this only suppresses the silent grant, never an existing right. The same holds for an
|
||||
// external identity (a guest), which is not a member of this tenant at all.
|
||||
// Once the tenant has a recorded onboarding member (OnboardedBy), the first-user seat is theirs
|
||||
// alone: activation seats them before it activates the tenant, and should that seat ever be
|
||||
// missing (removed, never written, or the write failed) only they may take it on sign-in. Any
|
||||
// other roleless caller is answered roleless, never the first caller to see an activated tenant
|
||||
// with no rows. The record counts whatever its shape: a value activation cannot seat (an
|
||||
// external account's UPN) still blocks everyone else, and its own holder is excluded as an
|
||||
// external identity. Without a recorded member (rows from before OnboardedBy existed) the
|
||||
// first sign-in still becomes the admin.
|
||||
var onboardingMember = string.IsNullOrWhiteSpace(tenantConfig.OnboardedBy) ? null : tenantConfig.OnboardedBy;
|
||||
bool isTenantAdmin = memberRole?.Role == Constants.TenantRoles.Admin;
|
||||
bool needsAutoAdmin = memberRole == null && !hasTenantAdmins && !isGlobalReader && !isDelegated && !isExternalIdentity;
|
||||
bool needsAutoAdmin = memberRole == null && !hasTenantAdmins && !isGlobalReader && !isDelegated && !isExternalIdentity
|
||||
&& (onboardingMember == null || string.Equals(onboardingMember, upn, StringComparison.OrdinalIgnoreCase));
|
||||
if (needsAutoAdmin)
|
||||
{
|
||||
isTenantAdmin = true;
|
||||
|
||||
+10
-8
@@ -51,19 +51,21 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
|
||||
}
|
||||
|
||||
// Negotiate via the Management SDK (not the SignalRConnectionInfoInput binding) so the
|
||||
// client access token is bound to a SignalR USER ID = the lowercased UPN. That binding is
|
||||
// what makes revocation enforceable: when a delegated grant / tenant-group assignment is
|
||||
// revoked, the revoke path calls DisconnectUserAsync(upn) to cut already-joined
|
||||
// live streams — anonymous connections (no user id) cannot be targeted. The UPN comes from
|
||||
// the middleware-validated JWT, never from client-controlled binding data.
|
||||
var userEmail = TenantHelper.GetUserIdentifier(req);
|
||||
var negotiation = await _signalRService.NegotiateClientAsync(userEmail.ToLowerInvariant());
|
||||
// client access token is bound to a SignalR USER ID = sign-in tenant + principal key, the
|
||||
// pair every role resolves under (SignalRGroupHelper.UserId). That binding is what makes
|
||||
// revocation enforceable, and only inside the tenant whose grant was revoked
|
||||
// (LiveStreamRevoker). Both values come from the middleware-validated JWT, never from
|
||||
// client-controlled data. Without a principal key the connection has no user id: every
|
||||
// revocable join gate needs one.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var principalKey = string.IsNullOrEmpty(requestCtx.UserPrincipalName) ? null : requestCtx.UserPrincipalName;
|
||||
var negotiation = await _signalRService.NegotiateClientAsync(requestCtx.TenantId, principalKey);
|
||||
if (negotiation == null)
|
||||
{
|
||||
return await req.ErrorAsync(HttpStatusCode.ServiceUnavailable, Constants.ApiErrorCodes.ServiceUnavailable, "SignalR negotiation unavailable");
|
||||
}
|
||||
|
||||
_logger.LogInformation($"SignalR connection negotiated for user: {userEmail}");
|
||||
_logger.LogInformation($"SignalR connection negotiated for user: {principalKey ?? "(no principal key)"}");
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
// Exactly the shape the @microsoft/signalr client's negotiate protocol expects.
|
||||
|
||||
@@ -108,7 +108,7 @@ public class PreviewWhitelistFunction
|
||||
var principal = context.GetUser();
|
||||
var upn = principal?.GetUserPrincipalName();
|
||||
|
||||
await _previewWhitelistService.RevokeAsync(tenantId);
|
||||
await _previewWhitelistService.RevokeAsync(tenantId, upn!);
|
||||
|
||||
_logger.LogInformation("Preview tenant revoked: {TenantId} by {Upn}", tenantId, upn);
|
||||
|
||||
@@ -201,13 +201,13 @@ public class PreviewWhitelistFunction
|
||||
|
||||
// Send-on-save half of the welcome-mail race: with auto-approve, activation
|
||||
// typically finishes before the user has typed this address on the activation
|
||||
// page — the approval path then found no address and deferred to us. Fresh
|
||||
// approval read: a cached "not approved" can be stale for exactly this window.
|
||||
// page — the approval path then found no address and deferred to us. IsApprovedAsync
|
||||
// never caches "not approved", so an activation that landed before this write is seen.
|
||||
// The sent-marker inside TrySendWelcomeEmailAsync dedupes against the approval
|
||||
// path and against repeated saves.
|
||||
var welcomeEmailSent = false;
|
||||
if (!string.IsNullOrWhiteSpace(email) &&
|
||||
await _previewWhitelistService.IsApprovedFreshAsync(tenantId))
|
||||
await _previewWhitelistService.IsApprovedAsync(tenantId))
|
||||
{
|
||||
welcomeEmailSent = await _tenantApprovalService.TrySendWelcomeEmailAsync(tenantId);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
@@ -18,30 +20,61 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
/// has (JS, PHP, Python) and .NET behaves subtly differently (this endpoint exists
|
||||
/// because exactly that happened in the field). Mirrored semantics, keep in lock-step
|
||||
/// with LogParserCollector (agent):
|
||||
/// - Regex options: Compiled, 1s timeout, NO IgnoreCase — logparser matching is
|
||||
/// case-SENSITIVE (unlike analyze-rule regex conditions).
|
||||
/// - Regex options: 1s match timeout, NO IgnoreCase — logparser matching is
|
||||
/// case-SENSITIVE (unlike analyze-rule regex conditions). The agent adds
|
||||
/// RegexOptions.Compiled; that flag changes speed only, never a match result, so the
|
||||
/// one-shot pattern here runs interpreted (see <see cref="BuildAgentRegex"/>).
|
||||
/// - First match per line (Regex.Match, not Matches).
|
||||
/// - format "cmtrace" (default): CmTraceLogParser.TryParseLine first (shared code, the
|
||||
/// same implementation the agent runs), regex against the parsed MESSAGE only.
|
||||
/// - format "text": regex against the raw line.
|
||||
/// - Captured data: every named/numbered group except "0", unsuccessful groups omitted.
|
||||
///
|
||||
/// No tenant data is touched; the endpoint is pure compute on the request body.
|
||||
/// No tenant data is touched; the endpoint is pure compute on the request body — and that
|
||||
/// compute is caller-controlled regex work on the shared Function App (ingest and every
|
||||
/// tenant's portal), so it is bounded three ways (D-379): the first match timeout or the
|
||||
/// <see cref="EvaluationBudget"/> ends the test, the invocation token (client gone, host
|
||||
/// stopping) ends it between two lines, and <see cref="TestLogPatternGate"/> caps how many
|
||||
/// tests run per instance. Each bound closes one carrier of the reported abuse; none of
|
||||
/// them changes a match result.
|
||||
/// </summary>
|
||||
public class TestLogPatternFunction
|
||||
{
|
||||
private readonly ILogger<TestLogPatternFunction> _logger;
|
||||
private readonly TestLogPatternGate _gate;
|
||||
|
||||
public TestLogPatternFunction(ILogger<TestLogPatternFunction> logger)
|
||||
public TestLogPatternFunction(ILogger<TestLogPatternFunction> logger, TestLogPatternGate gate)
|
||||
{
|
||||
_logger = logger;
|
||||
_gate = gate;
|
||||
}
|
||||
|
||||
internal const int MaxSampleLines = 200;
|
||||
internal const int MaxLineLength = 8192;
|
||||
|
||||
/// <summary>Same cap the MCP schema enforces (rules.ts); checked before any Regex is built.</summary>
|
||||
internal const int MaxPatternLength = 2000;
|
||||
|
||||
/// <summary>The agent's per-match timeout (LogParserCollector) — parity, not a tuning knob.</summary>
|
||||
internal static readonly TimeSpan MatchTimeout = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Wall-clock budget for one whole test. A legitimate pattern finishes 200 lines in
|
||||
/// milliseconds; the worst case per request is this budget plus one running match
|
||||
/// (≤ <see cref="MatchTimeout"/>), which is what <see cref="RetryAfterSeconds"/> promises.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan EvaluationBudget = TimeSpan.FromSeconds(2);
|
||||
|
||||
private const int RetryAfterSeconds = 3;
|
||||
|
||||
internal const string StopReasonRegexTimeout = "regex_timeout";
|
||||
internal const string StopReasonTimeBudget = "time_budget";
|
||||
internal const string StopReasonCancelled = "cancelled";
|
||||
|
||||
[Function("TestLogPattern")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "rules/gather/test-pattern")] HttpRequestData req)
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "rules/gather/test-pattern")] HttpRequestData req,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Authentication + TenantAdminOrGlobalReader authorization enforced by PolicyEnforcementMiddleware.
|
||||
|
||||
@@ -57,33 +90,62 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
|
||||
var isTextMode = string.Equals(request!.Format, "text", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Regex pattern;
|
||||
try
|
||||
// The slot is taken BEFORE the regex is built: parsing a hostile pattern is work too.
|
||||
var slot = _gate.TryEnter();
|
||||
if (slot == null)
|
||||
{
|
||||
// EXACT agent construction (LogParserCollector): Compiled + 1s timeout,
|
||||
// no IgnoreCase — logparser matching is case-sensitive.
|
||||
pattern = new Regex(request.Pattern!, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return await req.BadRequestAsync($"Invalid regex pattern (this is the agent's .NET engine speaking): {ex.Message}");
|
||||
_logger.LogWarning(
|
||||
"Log-pattern test refused: {Limit} evaluations already running on this instance",
|
||||
_gate.Limit);
|
||||
return await req.ErrorAsync(HttpStatusCode.TooManyRequests, Constants.ApiErrorCodes.RateLimited,
|
||||
"too many pattern tests are running right now — retry in a few seconds",
|
||||
retryAfterSeconds: RetryAfterSeconds);
|
||||
}
|
||||
|
||||
var result = EvaluatePattern(pattern, isTextMode, request.SampleLines!);
|
||||
using (slot)
|
||||
{
|
||||
Regex pattern;
|
||||
try
|
||||
{
|
||||
pattern = BuildAgentRegex(request.Pattern!);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return await req.BadRequestAsync($"Invalid regex pattern (this is the agent's .NET engine speaking): {ex.Message}");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Log-pattern test: {LineCount} lines, {MatchCount} matched, {ParseFailures} parse failures, mode={Mode}",
|
||||
request.SampleLines!.Count, result.MatchCount, result.ParseFailureCount, isTextMode ? "text" : "cmtrace");
|
||||
var clock = Stopwatch.StartNew();
|
||||
var result = EvaluatePattern(pattern, isTextMode, request.SampleLines!, EvaluationBudget, cancellationToken);
|
||||
clock.Stop();
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new TestLogPatternResponse { Success = true, Format = isTextMode ? "text" : "cmtrace", Result = result });
|
||||
return response;
|
||||
if (result.StopReason != null)
|
||||
{
|
||||
// Warning so it reaches App Insights: one hit is an author with a broken
|
||||
// pattern, a cluster from one caller is load aimed at this endpoint.
|
||||
_logger.LogWarning(
|
||||
"Log-pattern test stopped: {Reason} after {Evaluated}/{Total} lines in {ElapsedMs} ms (pattern length {PatternLength}, mode={Mode})",
|
||||
result.StopReason, result.Lines.Count, request.SampleLines!.Count, clock.ElapsedMilliseconds,
|
||||
request.Pattern!.Length, isTextMode ? "text" : "cmtrace");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Log-pattern test: {LineCount} lines, {MatchCount} matched, {ParseFailures} parse failures, mode={Mode}",
|
||||
request.SampleLines!.Count, result.MatchCount, result.ParseFailureCount, isTextMode ? "text" : "cmtrace");
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new TestLogPatternResponse { Success = true, Format = isTextMode ? "text" : "cmtrace", Result = result });
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string? ValidateRequest(TestLogPatternRequest? request)
|
||||
{
|
||||
if (request == null || string.IsNullOrEmpty(request.Pattern))
|
||||
return "pattern is required";
|
||||
if (request.Pattern.Length > MaxPatternLength)
|
||||
return $"pattern exceeds {MaxPatternLength} characters";
|
||||
if (request.SampleLines == null || request.SampleLines.Count == 0)
|
||||
return "sampleLines is required — paste raw lines from the log file";
|
||||
if (request.SampleLines.Count > MaxSampleLines)
|
||||
@@ -98,16 +160,50 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-line evaluation mirroring LogParserCollector's inner loop. Returns one row per
|
||||
/// The agent's construction (LogParserCollector) minus RegexOptions.Compiled: the options
|
||||
/// that carry semantics are identical (no IgnoreCase, no Multiline/Singleline) and so is
|
||||
/// the match timeout. Compiled emits IL and JITs it for a pattern that runs once over at
|
||||
/// most 200 lines — on caller-controlled input that costs more than it saves. Throws
|
||||
/// <see cref="ArgumentException"/> for an invalid pattern, exactly like the agent.
|
||||
/// </summary>
|
||||
internal static Regex BuildAgentRegex(string pattern) => new(pattern, RegexOptions.None, MatchTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Per-line evaluation mirroring LogParserCollector's inner loop: one row per evaluated
|
||||
/// sample line so the author sees exactly which lines matched, which failed CMTrace
|
||||
/// parsing, and which data fields the emitted event would carry.
|
||||
///
|
||||
/// The pattern is untrusted work: the FIRST match timeout ends the test (one timed-out
|
||||
/// line already proves catastrophic backtracking — the agent would emit nothing for such
|
||||
/// lines, and 199 more seconds of it would add no information), the budget ends it when
|
||||
/// every line is merely slow, and the token ends it between two lines. Lines after the
|
||||
/// stop get no row (the MCP result is size-capped); <see cref="LogPatternTestResult.StopReason"/>
|
||||
/// and a note say where and why.
|
||||
/// </summary>
|
||||
internal static LogPatternTestResult EvaluatePattern(Regex pattern, bool isTextMode, IReadOnlyList<string> lines)
|
||||
internal static LogPatternTestResult EvaluatePattern(
|
||||
Regex pattern, bool isTextMode, IReadOnlyList<string> lines, TimeSpan budget, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new LogPatternTestResult();
|
||||
var clock = Stopwatch.StartNew();
|
||||
|
||||
for (var i = 0; i < lines.Count; i++)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Stop(result, StopReasonCancelled,
|
||||
i == 0 ? "evaluation cancelled before the first line" : $"evaluation cancelled after line {i}",
|
||||
i, lines.Count);
|
||||
break;
|
||||
}
|
||||
// Checked before every line but the first: the previous line spent the budget.
|
||||
if (i > 0 && clock.Elapsed >= budget)
|
||||
{
|
||||
Stop(result, StopReasonTimeBudget,
|
||||
$"evaluation stopped after line {i}: the test exceeded its {budget.TotalSeconds:0.#} s budget — a pattern this slow would strain the device too; anchor it (^) and avoid unbounded .* scans",
|
||||
i, lines.Count);
|
||||
break;
|
||||
}
|
||||
|
||||
var line = lines[i] ?? string.Empty;
|
||||
var row = new LogPatternLineResult { LineNumber = i + 1 };
|
||||
result.Lines.Add(row);
|
||||
@@ -140,7 +236,10 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
{
|
||||
row.Outcome = "regex_timeout";
|
||||
result.TimeoutCount++;
|
||||
continue;
|
||||
Stop(result, StopReasonRegexTimeout,
|
||||
$"line {i + 1} hit the agent's {MatchTimeout.TotalSeconds:0.#} s match timeout (catastrophic backtracking) — on the device such lines are skipped and only counted as regex timeouts; remove nested or overlapping quantifiers before deploying",
|
||||
i + 1, lines.Count);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!match.Success)
|
||||
@@ -169,7 +268,8 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
{
|
||||
result.Notes.Add("every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text");
|
||||
}
|
||||
if (result.MatchCount == 0 && result.ParseFailureCount < result.Lines.Count)
|
||||
// The case-sensitivity hint is noise once a timeout is the story.
|
||||
if (result.MatchCount == 0 && result.TimeoutCount == 0 && result.ParseFailureCount < result.Lines.Count)
|
||||
{
|
||||
result.Notes.Add("no line matched — remember logparser patterns are case-SENSITIVE (unlike analyze-rule regex conditions)");
|
||||
}
|
||||
@@ -177,6 +277,23 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records why the test ended at <paramref name="lastLine"/> (1-based; 0 = before the first
|
||||
/// line). The reason is a stop only while lines remain unevaluated — a timeout on the
|
||||
/// last line keeps its row and note but is a complete test.
|
||||
/// </summary>
|
||||
private static void Stop(LogPatternTestResult result, string reason, string note, int lastLine, int totalLines)
|
||||
{
|
||||
if (lastLine < totalLines)
|
||||
{
|
||||
result.StopReason = reason;
|
||||
note += lastLine + 1 == totalLines
|
||||
? $"; line {totalLines} was not evaluated"
|
||||
: $"; lines {lastLine + 1}-{totalLines} were not evaluated";
|
||||
}
|
||||
result.Notes.Add(note);
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
@@ -186,6 +303,48 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide cap on concurrent pattern tests (singleton). A slot is held from the regex
|
||||
/// construction to the end of the evaluation; a caller finding no free slot is answered 429
|
||||
/// at once — no queueing — so this endpoint can never occupy more than <see cref="Limit"/>
|
||||
/// worker threads of an instance, whatever the request rate. Deliberately local to this one
|
||||
/// endpoint: D-203 refuses a process-wide semaphore in front of storage I/O because it would
|
||||
/// queue the ingest behind portal scans; nothing but this test waits on this gate.
|
||||
/// </summary>
|
||||
public sealed class TestLogPatternGate
|
||||
{
|
||||
internal const int DefaultLimit = 2;
|
||||
|
||||
private readonly SemaphoreSlim _slots;
|
||||
|
||||
public TestLogPatternGate() : this(DefaultLimit)
|
||||
{
|
||||
}
|
||||
|
||||
internal TestLogPatternGate(int limit)
|
||||
{
|
||||
Limit = limit;
|
||||
_slots = new SemaphoreSlim(limit, limit);
|
||||
}
|
||||
|
||||
public int Limit { get; }
|
||||
|
||||
/// <summary>A slot to dispose when the test ends, or null when every slot is taken.</summary>
|
||||
public IDisposable? TryEnter() => _slots.Wait(0) ? new Slot(_slots) : null;
|
||||
|
||||
private sealed class Slot : IDisposable
|
||||
{
|
||||
private SemaphoreSlim? _slots;
|
||||
|
||||
public Slot(SemaphoreSlim slots)
|
||||
{
|
||||
_slots = slots;
|
||||
}
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _slots, null)?.Release();
|
||||
}
|
||||
}
|
||||
|
||||
// LogPatternTestResult / LogPatternLineResult moved to
|
||||
// AutopilotMonitor.Shared.Models.RulesApiModels (wire contract, exported to TypeScript).
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ using System;
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for parsing SignalR group names.
|
||||
/// Helper class for SignalR addressing: the user id a client connection is negotiated under
|
||||
/// (<see cref="UserId"/>) and the group names.
|
||||
/// Group formats:
|
||||
/// - "tenant-{tenantId}" — tenant-wide live updates (sessions, etc.)
|
||||
/// - "tenant-{tenantId}-notify-member" — tenant notification bell (Member-tier visibility)
|
||||
@@ -19,6 +20,14 @@ public static class SignalRGroupHelper
|
||||
/// </summary>
|
||||
public const string HubName = "autopilotmonitor";
|
||||
|
||||
/// <summary>
|
||||
/// The SignalR user id of a client connection: the caller's sign-in tenant (JWT tid) plus principal key, the
|
||||
/// pair every role resolves under. A revoke addresses exactly this pair, so an action in one tenant never
|
||||
/// reaches the same UPN signed in to another. The tenant id is a GUID (no ':'), so the id is unambiguous.
|
||||
/// </summary>
|
||||
public static string UserId(string tenantId, string principalKey)
|
||||
=> $"{tenantId.ToLowerInvariant()}:{principalKey.ToLowerInvariant()}";
|
||||
|
||||
public const string TenantNotifyMemberSuffix = "-notify-member";
|
||||
public const string TenantNotifyAdminSuffix = "-notify-admin";
|
||||
|
||||
|
||||
@@ -173,6 +173,8 @@ builder.Services.AddMemoryCache();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddSingleton<ResiliencePolicies>();
|
||||
builder.Services.AddSingleton<TableStorageService>();
|
||||
// Per-instance cap on concurrent logparser pattern tests (caller-controlled regex work, D-379).
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Functions.Rules.TestLogPatternGate>();
|
||||
// Shared QueueClient bootstrap (code-quality audit D2): single Managed-Identity/connection-string
|
||||
// resolution consumed by every queue producer / worker / probe.
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Queueing.QueueClientFactory>();
|
||||
@@ -267,6 +269,8 @@ builder.Services.AddSingleton<SlaBreachEvaluationService>();
|
||||
// Identity binding (tid + oid behind every cross-tenant-role UPN) — consulted by both role services.
|
||||
builder.Services.AddSingleton<AdminIdentityBindingService>();
|
||||
builder.Services.AddSingleton<AdminIdentityResolver>();
|
||||
// Every revoke's live-stream cut, addressed to the tenant the revoked grant resolved under.
|
||||
builder.Services.AddSingleton<LiveStreamRevoker>();
|
||||
builder.Services.AddSingleton<GlobalAdminService>();
|
||||
builder.Services.AddSingleton<DelegatedAdminService>();
|
||||
builder.Services.AddSingleton<DelegatedSlotService>();
|
||||
|
||||
@@ -147,7 +147,7 @@ public class AdminIdentityBindingService
|
||||
_cache.Remove(CacheKey(upn));
|
||||
}
|
||||
|
||||
public Task<AdminIdentityBinding?> GetAsync(string upn) => _adminRepo.GetIdentityBindingAsync(upn.ToLowerInvariant());
|
||||
public virtual Task<AdminIdentityBinding?> GetAsync(string upn) => _adminRepo.GetIdentityBindingAsync(upn.ToLowerInvariant());
|
||||
|
||||
public Task<List<AdminIdentityBinding>> GetAllAsync() => _adminRepo.GetAllIdentityBindingsAsync();
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
public class AgentConfigResolver
|
||||
{
|
||||
/// <summary>Bumped when the wire shape gains a field the agent must understand.</summary>
|
||||
public const int ConfigVersion = 41; // InterceptedRegistrationRetryBoots (boots after an intercepted registration)
|
||||
public const int ConfigVersion = 42; // Analyzers.EnableConfigMgrWatcher (ConfigMgr client check, opt-in)
|
||||
|
||||
/// <summary>
|
||||
/// Served as <see cref="AgentConfigResponse.InterceptedRegistrationRetryBoots"/>. A code
|
||||
@@ -154,6 +154,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
EnableSoftwareInventoryAnalyzer = tenantConfig.EnableSoftwareInventoryAnalyzer ?? false,
|
||||
EnableIntegrityBypassAnalyzer = tenantConfig.EnableIntegrityBypassAnalyzer ?? true,
|
||||
EnableRealmJoinWatcher = tenantConfig.EnableRealmJoinWatcher ?? false,
|
||||
EnableConfigMgrWatcher = tenantConfig.EnableConfigMgrWatcher ?? false,
|
||||
KeepAwakeDuringUserEsp = tenantConfig.KeepAwakeDuringUserEsp ?? false,
|
||||
EnableConsoleBypassDetection = tenantConfig.EnableConsoleBypassDetection ?? true
|
||||
},
|
||||
|
||||
@@ -22,8 +22,10 @@ namespace AutopilotMonitor.Functions.Services;
|
||||
/// (UPN-keyed; binding + gate applied per call); every mutation invalidates the cache.
|
||||
///
|
||||
/// An assignment to a tenant's self-service group (<see cref="Constants.TenantGroupIds"/>) confers scope only
|
||||
/// while its assignee is an ENABLED member of the owning tenant — re-checked at every row resolution, so a
|
||||
/// person removed or disabled under Access Management stops reading the managed customers on every path.
|
||||
/// while its assignee is an ENABLED member of the owning tenant and bound to it as home — re-checked at every row
|
||||
/// resolution, so a person removed or disabled under Access Management stops reading the managed customers on
|
||||
/// every path, and a self-service grant resolves only under the owner's sign-in (which is why the owner's member
|
||||
/// actions cut its live streams under the owner alone, see <see cref="LiveStreamRevoker"/>).
|
||||
/// </summary>
|
||||
public class DelegatedAdminService
|
||||
{
|
||||
@@ -157,6 +159,16 @@ public class DelegatedAdminService
|
||||
continue;
|
||||
}
|
||||
|
||||
// The assignment bound the person to the owner as home; only a Global Admin's rebind moves them. Without
|
||||
// this check a rebound assignee would read the owner's customers under another tenant's sign-in.
|
||||
if (owner != null && !string.Equals((await _bindings.GetAsync(upn))?.TenantId, owner, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[DelegatedAdmin] Ignoring the assignment of {Upn} to self-service group {GroupId}: not bound to the owning tenant {OwnerTenantId}",
|
||||
upn, assignment.GroupId, owner);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Members from ONE partition read (meta-backed: a missing meta row ⇒ no group, no scope).
|
||||
var membership = await _adminRepo.GetGroupMembershipAsync(assignment.GroupId);
|
||||
if (membership == null)
|
||||
|
||||
@@ -58,7 +58,8 @@ public interface ISelfServiceAssignees
|
||||
/// <item>Removing a customer (by either side) frees the slot only after <see cref="DelegatedSlotService.ReleaseHold"/>
|
||||
/// (24 h) — no rotating customers through a small allowance. A Global Admin can release a hold early.</item>
|
||||
/// <item>Every access change is audited under BOTH tenants: the managed customer's trail records who can read
|
||||
/// it, the managing tenant's trail records its own actions. Live streams of affected users are cut.</item>
|
||||
/// it, the managing tenant's trail records its own actions. Live streams of affected users are cut under the
|
||||
/// managing tenant, the only tenant a self-service grant resolves under.</item>
|
||||
/// </list>
|
||||
/// Self-service grants are always <see cref="Constants.DelegatedRoles.DelegatedReader"/> (read-only).
|
||||
/// </summary>
|
||||
@@ -97,7 +98,7 @@ public class DelegationSelfService : ISelfServiceAssignees
|
||||
private readonly TenantEntitlementService _entitlements;
|
||||
private readonly IConfigRepository _configRepo;
|
||||
private readonly IMaintenanceRepository _audit;
|
||||
private readonly ISignalRNotificationService _signalR;
|
||||
private readonly LiveStreamRevoker _streams;
|
||||
private readonly ProConferralService _proConferral;
|
||||
private readonly ILogger<DelegationSelfService> _logger;
|
||||
private readonly TimeProvider _time;
|
||||
@@ -110,10 +111,10 @@ public class DelegationSelfService : ISelfServiceAssignees
|
||||
TenantEntitlementService entitlements,
|
||||
IConfigRepository configRepo,
|
||||
IMaintenanceRepository audit,
|
||||
ISignalRNotificationService signalR,
|
||||
LiveStreamRevoker streams,
|
||||
ProConferralService proConferral,
|
||||
ILogger<DelegationSelfService> logger)
|
||||
: this(adminRepo, invitations, delegatedAdmins, slots, entitlements, configRepo, audit, signalR, proConferral, logger, TimeProvider.System)
|
||||
: this(adminRepo, invitations, delegatedAdmins, slots, entitlements, configRepo, audit, streams, proConferral, logger, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -126,7 +127,7 @@ public class DelegationSelfService : ISelfServiceAssignees
|
||||
TenantEntitlementService entitlements,
|
||||
IConfigRepository configRepo,
|
||||
IMaintenanceRepository audit,
|
||||
ISignalRNotificationService signalR,
|
||||
LiveStreamRevoker streams,
|
||||
ProConferralService proConferral,
|
||||
ILogger<DelegationSelfService> logger,
|
||||
TimeProvider time)
|
||||
@@ -138,7 +139,7 @@ public class DelegationSelfService : ISelfServiceAssignees
|
||||
_entitlements = entitlements;
|
||||
_configRepo = configRepo;
|
||||
_audit = audit;
|
||||
_signalR = signalR;
|
||||
_streams = streams;
|
||||
_proConferral = proConferral;
|
||||
_logger = logger;
|
||||
_time = time;
|
||||
@@ -333,7 +334,7 @@ public class DelegationSelfService : ISelfServiceAssignees
|
||||
{
|
||||
await _audit.LogAuditEntryAsync(target, "DELETE", AuditGroupAccess, assignee.Upn, by,
|
||||
new Dictionary<string, string> { { "GroupId", groupId }, { "Reason", reason }, { "HomeTenantId", home } });
|
||||
await _signalR.DisconnectUserAsync(assignee.Upn);
|
||||
await _streams.CutAsync(home, assignee.Upn);
|
||||
}
|
||||
await _audit.LogAuditEntryAsync(home, "DELETE", AuditManagedTenant, target, by,
|
||||
new Dictionary<string, string> { { "Reason", reason }, { "SlotHeldUntilUtc", hold.HoldUntilUtc?.ToString("O") ?? string.Empty } });
|
||||
@@ -395,7 +396,7 @@ public class DelegationSelfService : ISelfServiceAssignees
|
||||
if (!await EndAssignmentAsync(homeTenantId, upn, actor, AssigneeReasons.SelfServiceUnassigned))
|
||||
return DelegationResult<bool>.Fail(404, Constants.DelegationCodes.AssigneeNotFound, "That person is not assigned.");
|
||||
|
||||
await _signalR.DisconnectUserAsync(upn.ToLowerInvariant());
|
||||
await _streams.CutAsync(homeTenantId, upn);
|
||||
return DelegationResult<bool>.Success(true);
|
||||
}
|
||||
|
||||
|
||||
+23
-3
@@ -18,6 +18,10 @@ internal static class DiagnosisCulpritFinder
|
||||
|
||||
private static readonly IReadOnlyList<string> EvidenceRank = DiagnosisCulpritEvidence.All;
|
||||
|
||||
// The agent's provider kinds (EspPolicyProviderProbe.KindDevicePreparation / KindSetupApps).
|
||||
private const string DevicePreparationProviderKind = "devicePreparation";
|
||||
private const string SetupAppsProviderKind = "setupApps";
|
||||
|
||||
internal static List<DiagnosisCulprit> Find(
|
||||
IReadOnlyList<EnrollmentEvent> events, FailureEvidence failure, EspBlockingSets? blocking)
|
||||
{
|
||||
@@ -89,7 +93,7 @@ internal static class DiagnosisCulpritFinder
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var provider in StalledProviders(events))
|
||||
foreach (var provider in StalledProviders(events, failure.Step.Category))
|
||||
Add(provider);
|
||||
|
||||
return culprits
|
||||
@@ -125,14 +129,21 @@ internal static class DiagnosisCulpritFinder
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stuck ESP policy providers from the latest <c>esp_policy_provider_stalled</c>.</summary>
|
||||
internal static IEnumerable<DiagnosisCulprit> StalledProviders(IReadOnlyList<EnrollmentEvent> events)
|
||||
/// <summary>
|
||||
/// Stuck ESP policy providers from the latest <c>esp_policy_provider_stalled</c>. Given the
|
||||
/// failed ESP category, only providers that can hold it count: device-preparation providers
|
||||
/// for a device-preparation failure, Setup/Apps providers for device or account setup. A
|
||||
/// ConfigMgr client installed as a Win32 app registers its device-preparation provider after
|
||||
/// device preparation ended, so that provider explains no later failure.
|
||||
/// </summary>
|
||||
internal static IEnumerable<DiagnosisCulprit> StalledProviders(IReadOnlyList<EnrollmentEvent> events, string? failedCategory = null)
|
||||
{
|
||||
var stalled = DiagnosisEspEvidence.LastOf(events, e => e.EventType == Constants.EventTypes.EspPolicyProviderStalled);
|
||||
foreach (var item in EventDataReader.Maps(stalled?.Data, "stalledProviders"))
|
||||
{
|
||||
var name = DiagnosisEspEvidence.NonEmpty(EventDataReader.String(item, "name"));
|
||||
if (name == null) continue;
|
||||
if (!CanHoldCategory(EventDataReader.String(item, "kind"), failedCategory)) continue;
|
||||
yield return new DiagnosisCulprit
|
||||
{
|
||||
Kind = DiagnosisCulpritKinds.PolicyProvider,
|
||||
@@ -146,6 +157,15 @@ internal static class DiagnosisCulpritFinder
|
||||
}
|
||||
}
|
||||
|
||||
private static bool CanHoldCategory(string? kind, string? failedCategory)
|
||||
{
|
||||
if (string.IsNullOrEmpty(failedCategory) || string.IsNullOrEmpty(kind)) return true;
|
||||
var devicePreparationFailed = string.Equals(failedCategory, "DevicePreparation", StringComparison.OrdinalIgnoreCase);
|
||||
if (string.Equals(kind, DevicePreparationProviderKind, StringComparison.OrdinalIgnoreCase)) return devicePreparationFailed;
|
||||
if (string.Equals(kind, SetupAppsProviderKind, StringComparison.OrdinalIgnoreCase)) return !devicePreparationFailed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static DiagnosisCulprit AppCulprit(AppTrack app, string evidence, EspBlockingSets? blocking, string? eventId) => new()
|
||||
{
|
||||
Kind = DiagnosisCulpritKinds.App,
|
||||
|
||||
@@ -66,7 +66,7 @@ public class GlobalAdminService
|
||||
|
||||
private readonly Push.IPushDeviceRevoker? _pushRevoker;
|
||||
|
||||
/// <summary>Role-loss hook (K6): a removed or disabled Global Admin loses the platform-scope push devices. Fail-soft.</summary>
|
||||
/// <summary>Role-loss hook (K6): a removed Global Admin loses the platform-scope push devices. Fail-soft.</summary>
|
||||
private Task RevokePushDevicesAsync(string upn, string reason)
|
||||
=> _pushRevoker?.RevokeOwnerAsync(Notifications.NotificationScope.Platform, upn, reason) ?? Task.CompletedTask;
|
||||
|
||||
@@ -173,20 +173,6 @@ public class GlobalAdminService
|
||||
await RevokePushDevicesAsync(upn, "global_admin_removed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables (but does not delete) a Global Admin
|
||||
/// </summary>
|
||||
public async Task DisableGlobalAdminAsync(string upn)
|
||||
{
|
||||
upn = upn.ToLowerInvariant();
|
||||
|
||||
await _adminRepo.DisableGlobalAdminAsync(upn);
|
||||
|
||||
// Invalidate cache
|
||||
_cache.Remove($"global-role:{upn}");
|
||||
await RevokePushDevicesAsync(upn, "global_admin_disabled");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all Global Admins
|
||||
/// </summary>
|
||||
|
||||
@@ -31,25 +31,25 @@ namespace AutopilotMonitor.Functions.Services
|
||||
Task SendGlobalNotificationDismissedAllAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Negotiates a client connection whose access token is bound to a SignalR USER ID (the
|
||||
/// lowercased UPN). Binding the user id is what makes a later revoke enforceable server-side:
|
||||
/// without it, connections are anonymous to the service and
|
||||
/// <see cref="RemoveUserFromAllGroupsAsync"/> has nothing to target. Returns null when
|
||||
/// negotiation fails (caller should respond 503 — the client cannot connect anyway).
|
||||
/// Negotiates a client connection whose access token is bound to the SignalR user id of the
|
||||
/// caller's sign-in tenant plus principal key (<see cref="Helpers.SignalRGroupHelper.UserId"/>).
|
||||
/// Binding the user id is what makes a later revoke enforceable server-side, and only within
|
||||
/// that tenant. A caller without a principal key negotiates without a user id: every revocable
|
||||
/// join gate needs the key, so such a connection holds nothing a revoke would have to reach.
|
||||
/// Returns null when negotiation fails (caller should respond 503 — the client cannot connect anyway).
|
||||
/// </summary>
|
||||
Task<(string Url, string AccessToken)?> NegotiateClientAsync(string userId);
|
||||
Task<(string Url, string AccessToken)?> NegotiateClientAsync(string tenantId, string? principalKey);
|
||||
|
||||
/// <summary>
|
||||
/// Cuts every live SignalR stream of the given user (lowercased UPN) — the enforcement half
|
||||
/// of a delegated-grant / tenant-group revoke. Group authorization is join-time only, so an
|
||||
/// already-joined, revoked caller would otherwise keep receiving live tenant telemetry until
|
||||
/// the connection drops. Strips the user from all groups server-side AND pushes
|
||||
/// "accessRevoked" to the user's connections; the web client restarts its connection on
|
||||
/// receipt, and every re-join re-runs authorization — revoked groups 403, still-authorized
|
||||
/// streams recover automatically. Only reaches connections negotiated WITH a user id (see
|
||||
/// <see cref="NegotiateClientAsync"/>); best-effort — failures are logged at Warning, the
|
||||
/// authoritative revoke (table row) has already happened.
|
||||
/// Cuts every live SignalR stream of one principal — the sign-in tenant plus principal key a
|
||||
/// revoked grant was exercised under; <see cref="LiveStreamRevoker"/> decides which. Group
|
||||
/// authorization is join-time only, so an already-joined, revoked caller would otherwise keep
|
||||
/// receiving live tenant telemetry until the connection drops. Strips the user from all groups
|
||||
/// server-side AND pushes "accessRevoked" to the user's connections; the web client restarts its
|
||||
/// connection on receipt, and every re-join re-runs authorization — revoked groups 403,
|
||||
/// still-authorized streams recover automatically. Best-effort — failures are logged at Warning,
|
||||
/// the authoritative revoke (table row) has already happened.
|
||||
/// </summary>
|
||||
Task DisconnectUserAsync(string userId);
|
||||
Task DisconnectUserAsync(string tenantId, string principalKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Cuts the live SignalR streams of a revoked grant. A connection is negotiated under its caller's sign-in tenant
|
||||
/// plus principal key (<see cref="Helpers.SignalRGroupHelper.UserId"/>), and every grant resolves under exactly one
|
||||
/// such tenant, so each cut names it: a tenant membership (and a self-service delegation, which resolves only under
|
||||
/// its owner — see <see cref="DelegatedAdminService"/>) that tenant; a platform role or an operator-granted
|
||||
/// delegation the tenant of the UPN's identity binding. An action in one tenant therefore never reaches the same UPN
|
||||
/// signed in to another. The one caller of <see cref="ISignalRNotificationService.DisconnectUserAsync"/>.
|
||||
/// </summary>
|
||||
public class LiveStreamRevoker
|
||||
{
|
||||
private readonly ISignalRNotificationService _signalR;
|
||||
private readonly AdminIdentityBindingService _bindings;
|
||||
private readonly ILogger<LiveStreamRevoker> _logger;
|
||||
|
||||
public LiveStreamRevoker(ISignalRNotificationService signalR, AdminIdentityBindingService bindings, ILogger<LiveStreamRevoker> logger)
|
||||
{
|
||||
_signalR = signalR;
|
||||
_bindings = bindings;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Cuts the connections of <paramref name="principalKey"/> signed in to <paramref name="tenantId"/>.</summary>
|
||||
public Task CutAsync(string tenantId, string principalKey) => _signalR.DisconnectUserAsync(tenantId, principalKey);
|
||||
|
||||
/// <summary>
|
||||
/// Cuts the connections a platform role or operator-granted delegation of <paramref name="principalKey"/> was
|
||||
/// exercised under: the tenant of its identity binding, the only tenant such a grant resolves under. No binding
|
||||
/// ⇒ the grant never resolved ⇒ nothing to cut. Fail-soft like the cut itself — the revoke has already happened.
|
||||
/// </summary>
|
||||
public async Task CutBoundIdentityAsync(string principalKey)
|
||||
{
|
||||
AdminIdentityBinding? binding;
|
||||
try
|
||||
{
|
||||
binding = await _bindings.GetAsync(principalKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Reading the identity binding of {Upn} failed — its live streams were not cut", principalKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (binding != null)
|
||||
await _signalR.DisconnectUserAsync(binding.TenantId, principalKey);
|
||||
}
|
||||
}
|
||||
@@ -20,14 +20,6 @@ public class PreviewWhitelistService
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Short TTL for a negative ("not approved") result. The activation page polls auth/me
|
||||
/// while the auto-approve worker activates the tenant ~1 minute after signup on another
|
||||
/// instance — a 5-minute negative cache would hide that activation for up to 4 extra
|
||||
/// minutes. Positive results keep the long TTL (activation is rarely revoked).
|
||||
/// </summary>
|
||||
private static readonly TimeSpan NotApprovedCacheDuration = TimeSpan.FromSeconds(30);
|
||||
|
||||
public PreviewWhitelistService(
|
||||
IConfigRepository configRepo,
|
||||
IMemoryCache cache,
|
||||
@@ -41,7 +33,11 @@ public class PreviewWhitelistService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a tenant is activated (cached).
|
||||
/// Checks whether a tenant is activated. Only an activation is cached: "not activated" is read
|
||||
/// fresh on every call, because the activation is written on the auto-approve worker (another
|
||||
/// instance, out of reach of this cache) while the activation page polls auth/me. The
|
||||
/// notification-email save path relies on this, too: it must see an activation that landed
|
||||
/// just before its own write (welcome-mail arbitration).
|
||||
/// </summary>
|
||||
public virtual async Task<bool> IsApprovedAsync(string tenantId)
|
||||
{
|
||||
@@ -56,7 +52,8 @@ public class PreviewWhitelistService
|
||||
{
|
||||
var result = await _configRepo.IsInPreviewWhitelistAsync(tenantId);
|
||||
|
||||
_cache.Set(cacheKey, result, result ? CacheDuration : NotApprovedCacheDuration);
|
||||
if (result)
|
||||
_cache.Set(cacheKey, true, CacheDuration);
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -67,6 +64,14 @@ public class PreviewWhitelistService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Uncached read of the activation, for the activation path's own pre-check: a cached activation can
|
||||
/// outlive a revoke on another instance by up to 5 minutes, and a re-activation must not be answered
|
||||
/// as "already activated" from that cache. Storage errors throw.
|
||||
/// </summary>
|
||||
public virtual Task<bool> IsApprovedFreshAsync(string tenantId)
|
||||
=> _configRepo.IsInPreviewWhitelistAsync(tenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Activates a tenant (adds it to the whitelist). Returns true when this call created
|
||||
/// the entry, false when the tenant was already activated — the conditional insert at
|
||||
@@ -86,33 +91,14 @@ public class PreviewWhitelistService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fresh, uncached approval check. Used by the notification-email save path to decide
|
||||
/// send-on-save: the 30s negative cache of <see cref="IsApprovedAsync"/> would hide an
|
||||
/// activation for exactly the race window this check exists to close (auto-approve on
|
||||
/// another instance finishing seconds before the user saves their address).
|
||||
/// Fail-closed: false on storage errors — the GA resend button remains the fallback.
|
||||
/// Revokes a tenant's activation. The revoke is recorded FIRST and fail-loud, so a failed record
|
||||
/// changes nothing: from then on a re-activation seats no onboarding member — the membership is
|
||||
/// the tenant's own. A re-approve never clears the record; offboarding wipes the partition, so a
|
||||
/// re-onboarded tenant starts fresh (D-288).
|
||||
/// </summary>
|
||||
public virtual async Task<bool> IsApprovedFreshAsync(string tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
return await _configRepo.IsInPreviewWhitelistAsync(tenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error checking preview whitelist (fresh) for tenant {TenantId}", tenantId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revokes a tenant's activation.
|
||||
/// </summary>
|
||||
public async Task RevokeAsync(string tenantId)
|
||||
public async Task RevokeAsync(string tenantId, string revokedBy)
|
||||
{
|
||||
await _configRepo.MarkRevokedAsync(tenantId, revokedBy);
|
||||
await _configRepo.RemoveFromPreviewWhitelistAsync(tenantId);
|
||||
|
||||
// A re-approve after revoke is a fresh activation and should send a fresh
|
||||
@@ -120,9 +106,13 @@ public class PreviewWhitelistService
|
||||
await _configRepo.ClearWelcomeEmailSentMarkerAsync(tenantId);
|
||||
|
||||
_cache.Remove($"preview:{tenantId}");
|
||||
_logger.LogInformation("Tenant {TenantId} revoked from preview", tenantId);
|
||||
_logger.LogInformation("Tenant {TenantId} revoked from preview by {RevokedBy}", tenantId, revokedBy);
|
||||
}
|
||||
|
||||
/// <summary>True once the tenant's activation was revoked at least once (see <see cref="RevokeAsync"/>); errors throw.</summary>
|
||||
public virtual Task<bool> WasRevokedAsync(string tenantId)
|
||||
=> _configRepo.WasRevokedAsync(tenantId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all approved tenants (for Global Admin overview).
|
||||
/// </summary>
|
||||
|
||||
@@ -271,12 +271,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Negotiates a client connection with the access token bound to <paramref name="userId"/>
|
||||
/// (lowercased UPN) — see <see cref="ISignalRNotificationService.NegotiateClientAsync"/>.
|
||||
/// Negotiates a client connection with the access token bound to the principal's user id, or to
|
||||
/// none without a principal key — see <see cref="ISignalRNotificationService.NegotiateClientAsync"/>.
|
||||
/// Token generation is local (no service round-trip beyond hub-context init).
|
||||
/// </summary>
|
||||
public virtual async Task<(string Url, string AccessToken)?> NegotiateClientAsync(string userId)
|
||||
public virtual async Task<(string Url, string AccessToken)?> NegotiateClientAsync(string tenantId, string? principalKey)
|
||||
{
|
||||
var userId = string.IsNullOrEmpty(principalKey) ? null : SignalRGroupHelper.UserId(tenantId, principalKey);
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(NegotiateTimeout);
|
||||
@@ -297,7 +298,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cuts the user's live streams after a revoke (see
|
||||
/// Cuts one principal's live streams after a revoke (see
|
||||
/// <see cref="ISignalRNotificationService.DisconnectUserAsync"/>). Two prongs, because the
|
||||
/// Management SDK (1.33) exposes no user-level connection close: (1) strip the user from all
|
||||
/// groups server-side, and (2) push "accessRevoked" to the user's connections — the web
|
||||
@@ -306,8 +307,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// whose connection-scoped memberships survive (1) keeps its streams until the connection
|
||||
/// drops. Warning (not Debug) on failure so it reaches App Insights.
|
||||
/// </summary>
|
||||
public virtual async Task DisconnectUserAsync(string userId)
|
||||
public virtual async Task DisconnectUserAsync(string tenantId, string principalKey)
|
||||
{
|
||||
var userId = SignalRGroupHelper.UserId(tenantId, principalKey);
|
||||
try
|
||||
{
|
||||
using var cts = new CancellationTokenSource(SendTimeout);
|
||||
|
||||
@@ -20,11 +20,14 @@ public class TenantAdminsService
|
||||
// Authorization state is cached per-process (IMemoryCache). On a scaled-out Flex Consumption
|
||||
// plan InvalidateMemberCache only clears the instance that handled the grant/revoke mutation;
|
||||
// other warm instances keep serving the stale role until their entry expires. A short TTL caps
|
||||
// that cross-instance divergence window (newly-granted role appears, revoked role disappears)
|
||||
// so a role change self-heals in seconds instead of minutes. The underlying lookup is a single
|
||||
// Table Storage point-read (PK+RK), so the extra reads are negligible. Do NOT raise this back
|
||||
// to minutes "for performance" — it reintroduces the role flip-flop. Matches the 30s
|
||||
// cross-instance revalidation convention used by BlockedDeviceService.
|
||||
// that cross-instance divergence window so a revoked or changed role self-heals in seconds
|
||||
// instead of minutes. Only an ENABLED membership is cached: a missing or disabled row is re-read
|
||||
// on every call, because the grant that ends it may run where no invalidation reaches this
|
||||
// process (activation promotes the onboarding member on the queue worker) and a cached "no"
|
||||
// answered that new admin as a roleless user. The underlying lookup is a single Table Storage
|
||||
// point-read (PK+RK), so the extra reads are negligible. Do NOT raise this back to minutes
|
||||
// "for performance" — it reintroduces the role flip-flop. Matches the 30s cross-instance
|
||||
// revalidation convention used by BlockedDeviceService.
|
||||
private readonly TimeSpan _cacheDuration = TimeSpan.FromSeconds(30);
|
||||
|
||||
public TenantAdminsService(
|
||||
@@ -38,9 +41,8 @@ public class TenantAdminsService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The container picks this (longest satisfiable) constructor; the three-argument one stays
|
||||
/// for the tests that mock the service with positional arguments (Moq does not fill optional
|
||||
/// parameters).
|
||||
/// The shorter constructors stay for the tests that mock the service with positional arguments
|
||||
/// (Moq does not fill optional parameters); the container picks the longest satisfiable one.
|
||||
/// </summary>
|
||||
public TenantAdminsService(
|
||||
IAdminRepository adminRepo,
|
||||
@@ -52,7 +54,20 @@ public class TenantAdminsService
|
||||
_pushRevoker = pushRevoker;
|
||||
}
|
||||
|
||||
/// <summary>The container's pick: the audit repository records every first-admin seat in the tenant's audit log.</summary>
|
||||
public TenantAdminsService(
|
||||
IAdminRepository adminRepo,
|
||||
IMemoryCache cache,
|
||||
ILogger<TenantAdminsService> logger,
|
||||
Push.IPushDeviceRevoker pushRevoker,
|
||||
IMaintenanceRepository auditRepo)
|
||||
: this(adminRepo, cache, logger, pushRevoker)
|
||||
{
|
||||
_auditRepo = auditRepo;
|
||||
}
|
||||
|
||||
private readonly Push.IPushDeviceRevoker? _pushRevoker;
|
||||
private readonly IMaintenanceRepository? _auditRepo;
|
||||
|
||||
/// <summary>
|
||||
/// Role-loss hook (K6): a member who is removed, disabled or demoted below Operator loses the
|
||||
@@ -62,119 +77,105 @@ public class TenantAdminsService
|
||||
=> _pushRevoker?.RevokeOwnerAsync(Notifications.NotificationScope.Tenant(tenantId), upn, reason) ?? Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a UPN is a Tenant Admin for a specific tenant
|
||||
/// Uses caching for performance
|
||||
/// Seats a user as Tenant Admin unless the tenant already holds a row for them: true when the row was
|
||||
/// created, false when one exists (left untouched, whatever it says). The two first-admin writers,
|
||||
/// activation seating the onboarding member and auth/me's first-user auto-admin, go through here, so
|
||||
/// neither can overwrite a row the tenant set (disabled, demoted) nor race the other for the same UPN.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant ID</param>
|
||||
/// <param name="upn">User Principal Name (e.g., oliver@contoso.com)</param>
|
||||
/// <returns>True if the user is a Tenant Admin for this tenant</returns>
|
||||
public async Task<bool> IsTenantAdminAsync(string tenantId, string? upn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(upn))
|
||||
{
|
||||
_logger.LogDebug("IsTenantAdminAsync: TenantId or UPN is null or empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize for case-insensitive comparison
|
||||
tenantId = tenantId.ToLowerInvariant();
|
||||
upn = upn.ToLowerInvariant();
|
||||
|
||||
// Check cache first
|
||||
var cacheKey = $"tenant-admin:{tenantId}:{upn}";
|
||||
if (_cache.TryGetValue<bool>(cacheKey, out var isAdmin))
|
||||
{
|
||||
_logger.LogDebug("Tenant Admin check (from cache): {Upn} @ {TenantId} -> {IsAdmin}", upn, tenantId, isAdmin);
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
// Query via repository
|
||||
_logger.LogDebug("Querying repository for Tenant Admin: {Upn} @ {TenantId}", upn, tenantId);
|
||||
var member = await _adminRepo.GetTenantMemberAsync(tenantId, upn);
|
||||
// Only true for Admin role (null Role = Admin for backward compat)
|
||||
var result = member != null && member.IsEnabled
|
||||
&& (member.Role == null || member.Role == Constants.TenantRoles.Admin);
|
||||
|
||||
_logger.LogDebug("Tenant Admin check result: {Upn} @ {TenantId} -> {Result}", upn, tenantId, result);
|
||||
|
||||
_cache.Set(cacheKey, result, _cacheDuration);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a user as a Tenant Admin
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant ID</param>
|
||||
/// <param name="upn">User Principal Name</param>
|
||||
/// <param name="addedBy">UPN of the admin who is adding this user</param>
|
||||
public virtual async Task<TenantAdminRow> AddTenantAdminAsync(string tenantId, string upn, string addedBy)
|
||||
public virtual async Task<bool> TryAddTenantAdminAsync(string tenantId, string upn, string addedBy)
|
||||
{
|
||||
tenantId = tenantId.ToLowerInvariant();
|
||||
upn = upn.ToLowerInvariant();
|
||||
addedBy = addedBy.ToLowerInvariant();
|
||||
|
||||
await _adminRepo.AddTenantMemberAsync(tenantId, upn, addedBy, Constants.TenantRoles.Admin);
|
||||
if (!await _adminRepo.TryAddTenantMemberAsync(tenantId, upn, addedBy, Constants.TenantRoles.Admin))
|
||||
return false;
|
||||
|
||||
InvalidateMemberCache(tenantId, upn);
|
||||
|
||||
_logger.LogInformation($"Added Tenant Admin: {upn} to tenant {tenantId} by {addedBy}");
|
||||
|
||||
return new TenantAdminRow
|
||||
{
|
||||
TenantId = tenantId,
|
||||
Upn = upn,
|
||||
IsEnabled = true,
|
||||
AddedDate = DateTime.UtcNow,
|
||||
AddedBy = addedBy
|
||||
};
|
||||
_logger.LogInformation("Added Tenant Admin: {Upn} to tenant {TenantId} by {AddedBy}", upn, tenantId, addedBy);
|
||||
await RecordSeatAsync(tenantId, upn, addedBy);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a user from Tenant Admins
|
||||
/// The tenant's audit log is where its admins review membership changes, so a seat written by
|
||||
/// activation or by a first sign-in shows up there like one written by an admin. Fail-soft: the
|
||||
/// row is already written, and a lost audit line must not fail a sign-in.
|
||||
/// </summary>
|
||||
public async Task RemoveTenantAdminAsync(string tenantId, string upn)
|
||||
private async Task RecordSeatAsync(string tenantId, string upn, string addedBy)
|
||||
{
|
||||
if (_auditRepo == null)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await _auditRepo.LogAuditEntryAsync(tenantId, "CREATE", "TenantAdmin", upn, addedBy,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "Role", Constants.TenantRoles.Admin },
|
||||
{ "CanManageBootstrapTokens", "False" },
|
||||
{ "PrincipalType", "User" },
|
||||
{ "Source", "first-admin seat" }
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Audit entry for the first-admin seat of {Upn} in tenant {TenantId} was not written", upn, tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a user from Tenant Admins. False, with no side effect, when the tenant has no row for the UPN.
|
||||
/// </summary>
|
||||
public async Task<bool> RemoveTenantAdminAsync(string tenantId, string upn)
|
||||
{
|
||||
tenantId = tenantId.ToLowerInvariant();
|
||||
upn = upn.ToLowerInvariant();
|
||||
|
||||
await _adminRepo.RemoveTenantMemberAsync(tenantId, upn);
|
||||
if (!await _adminRepo.RemoveTenantMemberAsync(tenantId, upn))
|
||||
return false;
|
||||
|
||||
InvalidateMemberCache(tenantId, upn);
|
||||
await RevokePushDevicesAsync(tenantId, upn, "member_removed");
|
||||
|
||||
_logger.LogInformation($"Removed Tenant Admin: {upn} from tenant {tenantId}");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables (but does not delete) a Tenant Admin
|
||||
/// Disables (but does not delete) a Tenant Admin. False, with no side effect, when the tenant has no row for the UPN.
|
||||
/// </summary>
|
||||
public async Task DisableTenantAdminAsync(string tenantId, string upn)
|
||||
public async Task<bool> DisableTenantAdminAsync(string tenantId, string upn)
|
||||
{
|
||||
tenantId = tenantId.ToLowerInvariant();
|
||||
upn = upn.ToLowerInvariant();
|
||||
|
||||
await _adminRepo.SetTenantMemberEnabledAsync(tenantId, upn, false);
|
||||
if (!await _adminRepo.SetTenantMemberEnabledAsync(tenantId, upn, false))
|
||||
return false;
|
||||
|
||||
InvalidateMemberCache(tenantId, upn);
|
||||
await RevokePushDevicesAsync(tenantId, upn, "member_disabled");
|
||||
|
||||
_logger.LogInformation($"Disabled Tenant Admin: {upn} for tenant {tenantId}");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enables a Tenant Admin
|
||||
/// Enables a Tenant Admin. False when the tenant has no row for the UPN.
|
||||
/// </summary>
|
||||
public async Task EnableTenantAdminAsync(string tenantId, string upn)
|
||||
public async Task<bool> EnableTenantAdminAsync(string tenantId, string upn)
|
||||
{
|
||||
tenantId = tenantId.ToLowerInvariant();
|
||||
upn = upn.ToLowerInvariant();
|
||||
|
||||
await _adminRepo.SetTenantMemberEnabledAsync(tenantId, upn, true);
|
||||
if (!await _adminRepo.SetTenantMemberEnabledAsync(tenantId, upn, true))
|
||||
return false;
|
||||
|
||||
InvalidateMemberCache(tenantId, upn);
|
||||
|
||||
_logger.LogInformation($"Enabled Tenant Admin: {upn} for tenant {tenantId}");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -202,37 +203,15 @@ public class TenantAdminsService
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the cache for a specific tenant's admins
|
||||
/// Useful after bulk updates
|
||||
/// </summary>
|
||||
public void ClearCacheForTenant(string tenantId)
|
||||
{
|
||||
// Note: IMemoryCache doesn't have a clear all method by default
|
||||
// In production, consider using a distributed cache with better cache invalidation
|
||||
// For now, cache entries will expire after _cacheDuration
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Role-aware methods (Admin / Operator / Viewer)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Gets the role info for a tenant member. Returns null if the user has no enabled row
|
||||
/// (either no row at all or a disabled row). For Entra app-role reconciliation use
|
||||
/// <see cref="GetTableMembershipAsync"/>, which distinguishes "no row" from "disabled row".
|
||||
/// </summary>
|
||||
public virtual async Task<MemberRoleInfo?> GetMemberRoleAsync(string tenantId, string? upn)
|
||||
{
|
||||
var (state, role) = await GetTableMembershipAsync(tenantId, upn);
|
||||
return state == TableMemberState.Enabled ? role : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tri-state table membership for a user: whether a TenantAdmins row is absent,
|
||||
/// disabled, or enabled (with its role). The disabled/absent distinction is required by the
|
||||
/// Entra app-role resolver so a disabled row stays an explicit deny and is never silently
|
||||
/// re-authorized through an app-role claim.
|
||||
/// re-authorized through an app-role claim. Only the enabled state is cached (see _cacheDuration).
|
||||
/// </summary>
|
||||
public virtual async Task<(TableMemberState State, MemberRoleInfo? Role)> GetTableMembershipAsync(string tenantId, string? upn)
|
||||
{
|
||||
@@ -246,33 +225,41 @@ public class TenantAdminsService
|
||||
if (_cache.TryGetValue<(TableMemberState, MemberRoleInfo?)>(cacheKey, out var cached))
|
||||
return cached;
|
||||
|
||||
var member = await _adminRepo.GetTenantMemberAsync(tenantId, upn);
|
||||
|
||||
(TableMemberState State, MemberRoleInfo? Role) result;
|
||||
if (member == null)
|
||||
result = (TableMemberState.NotPresent, null);
|
||||
else if (!member.IsEnabled)
|
||||
result = (TableMemberState.Disabled, null);
|
||||
else
|
||||
result = (TableMemberState.Enabled, new MemberRoleInfo
|
||||
{
|
||||
Role = member.Role ?? Constants.TenantRoles.Admin,
|
||||
CanManageBootstrapTokens = member.CanManageBootstrapTokens
|
||||
});
|
||||
|
||||
_cache.Set(cacheKey, result, _cacheDuration);
|
||||
var result = ToMembership(await _adminRepo.GetTenantMemberAsync(tenantId, upn));
|
||||
if (result.State == TableMemberState.Enabled)
|
||||
_cache.Set(cacheKey, result, _cacheDuration);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a user can manage bootstrap tokens (Admin, or Operator with CanManageBootstrapTokens).
|
||||
/// The caller's table membership and whether the tenant has any member row at all (disabled and
|
||||
/// application rows included), both from ONE uncached partition read. auth/me decides the first-user
|
||||
/// auto-admin from these two facts; read apart they disagree right after activation promotes the
|
||||
/// onboarding member, and the new admin is answered as a roleless user. Never cache this: a cached
|
||||
/// "no members" would make the next person who signs in the tenant's admin.
|
||||
/// </summary>
|
||||
public async Task<bool> CanManageBootstrapAsync(string tenantId, string? upn)
|
||||
public virtual async Task<(TableMemberState State, MemberRoleInfo? Role, bool TenantHasMembers)> GetMembershipSnapshotAsync(
|
||||
string tenantId, string upn)
|
||||
{
|
||||
var role = await GetMemberRoleAsync(tenantId, upn);
|
||||
if (role == null) return false;
|
||||
if (role.Role == Constants.TenantRoles.Admin) return true;
|
||||
return role.Role == Constants.TenantRoles.Operator && role.CanManageBootstrapTokens;
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
|
||||
var members = await _adminRepo.GetTenantMembersAsync(tenantId);
|
||||
var (state, role) = ToMembership(
|
||||
members.FirstOrDefault(m => string.Equals(m.Upn, upn, StringComparison.OrdinalIgnoreCase)));
|
||||
return (state, role, members.Count > 0);
|
||||
}
|
||||
|
||||
private static (TableMemberState State, MemberRoleInfo? Role) ToMembership(TenantMember? member)
|
||||
{
|
||||
if (member == null)
|
||||
return (TableMemberState.NotPresent, null);
|
||||
if (!member.IsEnabled)
|
||||
return (TableMemberState.Disabled, null);
|
||||
return (TableMemberState.Enabled, new MemberRoleInfo
|
||||
{
|
||||
Role = member.Role ?? Constants.TenantRoles.Admin,
|
||||
CanManageBootstrapTokens = member.CanManageBootstrapTokens
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -324,12 +311,10 @@ public class TenantAdminsService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates both admin and member cache entries for a user.
|
||||
/// Drops this process's cached membership of a user (other instances expire on their own).
|
||||
/// </summary>
|
||||
private void InvalidateMemberCache(string tenantId, string upn)
|
||||
{
|
||||
_cache.Remove($"tenant-admin:{tenantId}:{upn}");
|
||||
_cache.Remove($"tenant-member:{tenantId}:{upn}");
|
||||
_cache.Remove($"tenant-member-state:{tenantId}:{upn}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,77 +36,100 @@ public class TenantApprovalService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates a tenant: adds it to the whitelist, then runs the best-effort side
|
||||
/// effects. The whitelist add is the activation itself and fails loud; everything
|
||||
/// after it is non-fatal. Returns false when the tenant was ALREADY activated
|
||||
/// (concurrent duplicate or repeat approve) — the conditional whitelist insert makes
|
||||
/// this idempotent, so a lost race never re-sends the welcome mail or re-promotes.
|
||||
/// Activates a tenant. The order of the writes is the invariant: the onboarding member's Admin row
|
||||
/// is seated BEFORE the activation row, so no observable state has the tenant activated while the
|
||||
/// designated admin's row is still missing (auth/me would otherwise seat the first roleless caller
|
||||
/// of that moment). The two rows live in different tables, which rules out one atomic batch, hence
|
||||
/// "before". The conditional activation insert stays the arbiter and fails loud; false means the
|
||||
/// tenant was activated concurrently (double signup), and the seat written just before is the same
|
||||
/// row the winner seats, so the loser only skips the mail. A tenant that is already activated
|
||||
/// returns false before anything is written. Everything after the activation row is best-effort.
|
||||
/// </summary>
|
||||
public virtual async Task<bool> ApproveWithSideEffectsAsync(string tenantId, string approvedBy)
|
||||
{
|
||||
if (await _previewWhitelistService.IsApprovedFreshAsync(tenantId))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Tenant {TenantId} already activated — nothing to do (approve by {ApprovedBy})",
|
||||
tenantId, approvedBy);
|
||||
return false;
|
||||
}
|
||||
|
||||
await TrySeatOnboardingMemberAsync(tenantId, approvedBy);
|
||||
|
||||
if (!await _previewWhitelistService.ApproveAsync(tenantId, approvedBy))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Tenant {TenantId} already activated — skipping activation side effects (approve by {ApprovedBy})",
|
||||
"Tenant {TenantId} was activated concurrently — skipping activation side effects (approve by {ApprovedBy})",
|
||||
tenantId, approvedBy);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Tenant activated: {TenantId} by {ApprovedBy}", tenantId, approvedBy);
|
||||
|
||||
// Auto-promote the onboarding member (OnboardedBy, written once by the first member login and
|
||||
// never by an external identity) as TenantAdmin if they are not already one.
|
||||
// This ensures whoever signed up doesn't need manual admin assignment after activation.
|
||||
// Never UpdatedBy: that is the last writer, not the requester, and any signed-in user sets it.
|
||||
await TrySendWelcomeEmailAsync(tenantId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seats the onboarding member (OnboardedBy, written once by the first member login and never by an
|
||||
/// external identity; never UpdatedBy, the last writer) as Tenant Admin, so whoever signed up needs
|
||||
/// no manual assignment after activation. A row the tenant already holds for them stays untouched,
|
||||
/// and after a revoke nobody is seated at all: from then on the membership is the tenant's own, and
|
||||
/// a member its admins removed stays removed whatever a re-activation does.
|
||||
/// Best-effort: a storage failure here must not block the activation. With the seat missing, auth/me
|
||||
/// seats the designated member on their next sign-in and nobody else.
|
||||
/// </summary>
|
||||
private async Task TrySeatOnboardingMemberAsync(string tenantId, string approvedBy)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
|
||||
var requesterUpn = tenantConfig.OnboardedBy;
|
||||
// Fresh read: a cached view can be the default row an earlier request on this instance
|
||||
// created before the first sign-in seeded OnboardedBy.
|
||||
var tenantConfig = await _tenantConfigurationService.GetConfigurationFreshAsync(tenantId);
|
||||
var requesterUpn = tenantConfig?.OnboardedBy;
|
||||
|
||||
// Positive UPN-shape validation: real Azure AD UPNs always contain '@', while
|
||||
// every system-written sentinel ("System", "System (auto-re-enable)",
|
||||
// "System (Global Rate Limit Sync)", …) does not. An equality list against
|
||||
// known sentinels previously missed a third sentinel and corrupted 10 tenants'
|
||||
// TenantAdmins rows — keep this check shape-based, not enumeration-based.
|
||||
if (IsPromotableUpn(requesterUpn))
|
||||
if (!IsPromotableUpn(requesterUpn))
|
||||
{
|
||||
// IsPromotableUpn returned true → requesterUpn is non-null, non-empty, contains '@'
|
||||
var validUpn = requesterUpn!;
|
||||
var isAlreadyAdmin = await _tenantAdminsService.IsTenantAdminAsync(tenantId, validUpn);
|
||||
if (!isAlreadyAdmin)
|
||||
{
|
||||
await _tenantAdminsService.AddTenantAdminAsync(tenantId, validUpn, approvedBy);
|
||||
_logger.LogInformation(
|
||||
"Auto-promoted tenant requester {RequesterUpn} as TenantAdmin for tenant {TenantId} on activation by {ApprovedBy}",
|
||||
validUpn, tenantId, approvedBy);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Tenant requester {RequesterUpn} is already a TenantAdmin for tenant {TenantId} — skipping auto-promote",
|
||||
validUpn, tenantId);
|
||||
}
|
||||
_logger.LogInformation(
|
||||
"No promotable onboarding UPN in TenantConfiguration for tenant {TenantId} (OnboardedBy: '{OnboardedBy}') — skipping auto-promote",
|
||||
tenantId, requesterUpn ?? "<null>");
|
||||
return;
|
||||
}
|
||||
|
||||
if (await _previewWhitelistService.WasRevokedAsync(tenantId))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Tenant {TenantId} was revoked before — a re-activation seats nobody; the onboarding member {RequesterUpn} is not re-seated",
|
||||
tenantId, requesterUpn);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await _tenantAdminsService.TryAddTenantAdminAsync(tenantId, requesterUpn!, approvedBy))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Auto-promoted tenant requester {RequesterUpn} as TenantAdmin for tenant {TenantId} on activation by {ApprovedBy}",
|
||||
requesterUpn, tenantId, approvedBy);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"No promotable onboarding UPN in TenantConfiguration for tenant {TenantId} (OnboardedBy: '{OnboardedBy}') — skipping auto-promote",
|
||||
tenantId, tenantConfig.OnboardedBy ?? "<null>");
|
||||
"Tenant requester {RequesterUpn} already has a member row in tenant {TenantId} — left untouched",
|
||||
requesterUpn, tenantId);
|
||||
}
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Non-fatal: activation already succeeded, admin promotion is best-effort
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to auto-promote tenant requester as TenantAdmin for tenant {TenantId} — activation still succeeded",
|
||||
"Failed to auto-promote tenant requester as TenantAdmin for tenant {TenantId} — activation continues",
|
||||
tenantId);
|
||||
}
|
||||
|
||||
// Outside the promote try/catch: a promote failure must not swallow the mail.
|
||||
await TrySendWelcomeEmailAsync(tenantId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -215,13 +238,16 @@ public class TenantApprovalService
|
||||
|
||||
/// <summary>
|
||||
/// True when a stored onboarding UPN may be promoted to TenantAdmin: shaped like a real user
|
||||
/// principal name (contains '@', no "System" sentinel prefix) and not an external account's
|
||||
/// resource-tenant UPN (<c>#EXT#</c>), which no member login writes.
|
||||
/// principal name (contains '@', no whitespace) and not an external account's resource-tenant
|
||||
/// UPN (<c>#EXT#</c>), which no member login writes. Shape, never an enumeration: every
|
||||
/// system-written sentinel ("System", "System (auto-approve)", …) has no '@' and carries
|
||||
/// spaces. A prefix test on "System" once rejected real members such as systemadmin@…, which
|
||||
/// left their tenant without a seat and the first-user rule open to anyone.
|
||||
/// </summary>
|
||||
internal static bool IsPromotableUpn(string? upn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(upn)) return false;
|
||||
if (upn.StartsWith("System", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (upn.Any(char.IsWhiteSpace)) return false;
|
||||
if (ClaimsPrincipalExtensions.IsExternalUpn(upn)) return false;
|
||||
return upn.Contains('@');
|
||||
}
|
||||
|
||||
@@ -421,6 +421,8 @@ export interface AnalyzerConfiguration {
|
||||
enableIntegrityBypassAnalyzer: boolean;
|
||||
/** Whether to enable the RealmJoin watcher, which tracks RealmJoin deployment state and enrollment packages during provisioning. This is not an IAgentAnalyzer; the flag gates creation of the RealmJoin collector host in the agent runtime. Default: false (opt-in; most tenants do not deploy via RealmJoin). */
|
||||
enableRealmJoinWatcher: boolean;
|
||||
/** Whether to enable the ConfigMgr watcher, which reports whether the Configuration Manager client is installed (service state, version, site code). This is not an IAgentAnalyzer; the flag gates creation of the ConfigMgr client host in the agent runtime. Default: false (opt-in; most tenants do not co-manage with ConfigMgr). */
|
||||
enableConfigMgrWatcher: boolean;
|
||||
/** Whether to keep the device awake (system + display) during the User-ESP (AccountSetup) phase so idle standby/sleep cannot stall app installs or account provisioning. This is not an IAgentAnalyzer; the flag gates creation of the UserEspKeepAwake host in the agent runtime. Reboots are unaffected (the hold only resets idle timers). Default: false (opt-in per tenant). */
|
||||
keepAwakeDuringUserEsp: boolean;
|
||||
/** Whether to detect an interactive console opened during enrollment — the classic Shift+F10 OOBE bypass. Gates BOTH the live ConsoleBypass host (an interactive-session cmd.exe with a bare, non-scripted command line; stopped on real-user desktop arrival) and the startup ConsolePrefetchScanner (CMD.EXE-*.pf ran after boot). Detection is best-effort, not gapless — a press before the agent installs is only coarsely covered by the prefetch artifact. Security-sensitive; not an IAgentAnalyzer toggle in the usual sense (it also gates a collector host). Default: true (opt-out per tenant — a Shift+F10 console during enrollment is a finding worth surfacing by default; tenants that knowingly use Shift+F10 for support can disable it). */
|
||||
@@ -3079,6 +3081,8 @@ export interface LogPatternTestResult {
|
||||
matchCount: number;
|
||||
parseFailureCount: number;
|
||||
timeoutCount: number;
|
||||
/** Set when the test ended before the last sample line was evaluated: regex_timeout (the first line that hit the agent's 1 s match timeout ends the test) | time_budget (the whole test exceeded its time budget) | cancelled. Lines after the stop have no row; a note names them. Null when every line was evaluated. */
|
||||
stopReason?: string;
|
||||
readonly lines: LogPatternLineResult[];
|
||||
readonly notes: string[];
|
||||
}
|
||||
@@ -5357,6 +5361,8 @@ export interface TenantConfiguration {
|
||||
enableIntegrityBypassAnalyzer?: boolean | null;
|
||||
/** Whether the RealmJoin watcher is enabled for this tenant's devices. RealmJoin enrollment-package tracking is off by default; enable only for tenants that deploy via RealmJoin. null = use agent default (false). */
|
||||
enableRealmJoinWatcher?: boolean | null;
|
||||
/** Whether the ConfigMgr watcher is enabled for this tenant's devices: the agent reports whether the Configuration Manager client is installed (service state, version, site code). Off by default; enable for tenants that co-manage with ConfigMgr. null = use agent default (false). */
|
||||
enableConfigMgrWatcher?: boolean | null;
|
||||
/** Whether to keep the device awake during the User-ESP (AccountSetup) phase for this tenant's devices. Prevents idle standby/sleep from stalling app installs / account provisioning; reboots are unaffected. Off by default. null = use agent default (false). */
|
||||
keepAwakeDuringUserEsp?: boolean | null;
|
||||
/** Whether to detect a SYSTEM console opened during enrollment (Shift+F10 OOBE bypass) for this tenant's devices. Gates the live ConsoleBypass watcher + the startup prefetch scanner. On by default (opt-out); tenants that knowingly use Shift+F10 for support can disable it. null = use agent default (true). */
|
||||
|
||||
@@ -84,7 +84,8 @@ export function registerRuleTools(server: McpServer, ga: boolean, ruleDryRun: bo
|
||||
'customer\'s log file, including lines that must match AND lines that must not. format="cmtrace" (default) ' +
|
||||
'parses each line as CMTrace/IME format first and runs the regex against the parsed message; format="text" ' +
|
||||
'matches the raw line. Returns per-line outcomes with the exact capture groups that would land in the emitted ' +
|
||||
'timeline event\'s data. Nothing is stored.',
|
||||
'timeline event\'s data. The first line hitting the agent\'s 1 s match timeout ends the test ' +
|
||||
'(stopReason="regex_timeout"); fix the backtracking first. Nothing is stored.',
|
||||
inputSchema: {
|
||||
pattern: z.string().min(1).max(2000).describe('The regex (named groups become event data fields), .NET syntax'),
|
||||
sampleLines: z.array(z.string().max(8192)).min(1).max(200)
|
||||
|
||||
@@ -741,7 +741,8 @@ namespace AutopilotMonitor.Shared
|
||||
public const string TelemetryUploadBlocked = "telemetry_upload_blocked"; // Warning (one-shot each) when the drain is blocked by a retained unknown permanent 4xx (400 contract bug / 404 route mismatch / tenant-validation), or by an interception on the network path (data.reason client_certificate_required | foreign_response, plus operation, statusCode, pageTitle, serverCertIssuer, firstBlockedAtUtc). Data is NOT discarded. LOCAL diagnostic (agent log + diag ZIP): this marker is queued behind the blocking batch and only reaches the backend after the block clears — no out-of-band bypass by design.
|
||||
public const string SessionParkedWithoutDeadline = "session_parked_without_deadline"; // One-shot Warning tripwire (liveness plan PR1): a non-terminal session entered the post-AccountSetup dead-end zone (ESP final exit or advisory failure) with NO resolution-capable deadline armed. After the 2026-06-12 dead-end fixes this must never fire in production — every occurrence is a bug report for an unknown parking variant.
|
||||
public const string DiskSpaceLow = "disk_space_low"; // One-shot Warning (state-change-only, 1 GB hysteresis — NOT periodic) when the system drive drops below 2 GB free. Review MON-B10: disk-full was previously only visible inside Debug performance_snapshot payloads (filtered out of timelines), so a disk-starved enrollment carried no actionable threshold signal.
|
||||
public const string EspPolicyProviderStalled = "esp_policy_provider_stalled"; // One-shot-per-provider Warning: a registered EnrollmentStatusTracking CSP policy provider stayed incomplete >= 15 min (Setup/Apps TrackingPoliciesCreated != 1, or DevicePreparation InstallationState not in {2,3}). The ESP waits on Setup/Apps providers WITHOUT any OS timeout — e.g. pre-provisioning + co-management leaves only "ConfigMgr" registered (no "Sidecar" = IME) and the user ESP sits at "Apps (Identifying)" for days. Observational only; payload carries the full provider list. See EspPolicyProviderProbe.
|
||||
public const string EspPolicyProviderStalled = "esp_policy_provider_stalled"; // One-shot-per-provider Warning: a registered EnrollmentStatusTracking CSP policy provider stayed incomplete >= 15 min (Setup/Apps TrackingPoliciesCreated != 1, or DevicePreparation InstallationState not in {2,3} while device preparation is still open). The ESP waits on Setup/Apps providers WITHOUT any OS timeout — e.g. pre-provisioning + co-management leaves only "ConfigMgr" registered (no "Sidecar" = IME) and the user ESP sits at "Apps (Identifying)" for days. Observational only; payload carries the full provider list. See EspPolicyProviderProbe.
|
||||
public const string ConfigMgrClientDetected = "configmgr_client_detected"; // Info when the ConfigMgr client is first seen and again when its reported state changes (CCM key, CCMSetup version, assigned site code, %windir%\CCM, CcmExec state; V1 confidence score), capped per run. The V1 check, restored in V2 and opt-in per tenant (AnalyzerConfiguration.EnableConfigMgrWatcher, D-382).
|
||||
public const string KeepAwakeEngaged = "keep_awake_engaged"; // Info (state-change, NOT periodic) emitted by the opt-in UserEspKeepAwake host when it holds the device awake (system + display) on entering the User-ESP/AccountSetup phase, so idle standby cannot stall app installs / account provisioning. Reboots are unaffected. Per-tenant toggle (AnalyzerConfiguration.KeepAwakeDuringUserEsp, default off).
|
||||
public const string KeepAwakeReleased = "keep_awake_released"; // Info (Warning when reason=safety_cap) emitted when the keep-awake hold is released — reason = user_esp_exited (Shell-Core 62407 after AccountSetup progress, i.e. the user-ESP page closed; agents before 2026-09-04 wrote account_setup_complete for the synthesized completion, which fired minutes early) | host_stop | safety_cap. Brackets KeepAwakeEngaged; the OS also auto-clears the hold on process exit/reboot.
|
||||
|
||||
@@ -1046,7 +1047,6 @@ namespace AutopilotMonitor.Shared
|
||||
// search/catalog stay complete for historical V1 session analysis. V1 emit
|
||||
// sites are intentionally NOT refactored to use these (V1 is going away).
|
||||
// ---------------------------------------------------------------------
|
||||
public const string ConfigMgrClientDetected = "configmgr_client_detected";
|
||||
public const string EnrollmentTypeMismatch = "enrollment_type_mismatch";
|
||||
public const string DecisionProcessCompletion = "decision_process_completion";
|
||||
public const string WhiteGloveClassification = "whiteglove_classification";
|
||||
|
||||
@@ -20,7 +20,6 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
Task<List<GlobalAdminEntry>> GetAllGlobalAdminsAsync();
|
||||
Task<bool> AddGlobalAdminAsync(string upn, string addedBy);
|
||||
Task<bool> RemoveGlobalAdminAsync(string upn);
|
||||
Task<bool> DisableGlobalAdminAsync(string upn);
|
||||
|
||||
// --- MCP Users ---
|
||||
Task<McpUserEntry?> GetMcpUserAsync(string upn);
|
||||
@@ -98,11 +97,19 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
// --- Tenant Members ---
|
||||
Task<List<TenantMember>> GetTenantMembersAsync(string tenantId);
|
||||
Task<bool> AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false);
|
||||
/// <summary>
|
||||
/// Conditional insert of a member row: true when this call created it, false when a row for the
|
||||
/// UPN already exists (left untouched, whatever it says). The first-admin writers (activation
|
||||
/// seating the onboarding member, the first-user auto-admin) use this so a row the tenant already
|
||||
/// holds is never overwritten. Storage errors throw.
|
||||
/// </summary>
|
||||
Task<bool> TryAddTenantMemberAsync(string tenantId, string upn, string addedBy, string role);
|
||||
/// <summary>Deletes the member row: true when one was deleted, false when the tenant had none for the UPN.</summary>
|
||||
Task<bool> RemoveTenantMemberAsync(string tenantId, string upn);
|
||||
Task<bool> UpdateMemberPermissionsAsync(string tenantId, string upn, string role, bool canManageBootstrapTokens);
|
||||
/// <summary>Sets the member row's enabled flag: false when the tenant has no row for the UPN.</summary>
|
||||
Task<bool> SetTenantMemberEnabledAsync(string tenantId, string upn, bool isEnabled);
|
||||
Task<TenantMember?> GetTenantMemberAsync(string tenantId, string upn);
|
||||
Task<bool> IsTenantAdminAsync(string tenantId, string upn);
|
||||
}
|
||||
|
||||
public class GlobalAdminEntry
|
||||
|
||||
@@ -137,5 +137,12 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
Task<bool> TryMarkWelcomeEmailSentAsync(string tenantId);
|
||||
/// <summary>Removes the welcome-email marker (no-op when absent, fail-soft).</summary>
|
||||
Task ClearWelcomeEmailSentMarkerAsync(string tenantId);
|
||||
/// <summary>
|
||||
/// Records that the tenant's activation was revoked (RowKey "revoked", upsert; errors throw).
|
||||
/// Never cleared by a re-approve: after a revoke a re-activation seats no onboarding member.
|
||||
/// </summary>
|
||||
Task MarkRevokedAsync(string tenantId, string revokedBy);
|
||||
/// <summary>True when the tenant's activation was revoked at least once; errors throw.</summary>
|
||||
Task<bool> WasRevokedAsync(string tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,6 +659,14 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool EnableRealmJoinWatcher { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable the ConfigMgr watcher, which reports whether the Configuration
|
||||
/// Manager client is installed (service state, version, site code). This is not an
|
||||
/// IAgentAnalyzer; the flag gates creation of the ConfigMgr client host in the agent runtime.
|
||||
/// Default: false (opt-in; most tenants do not co-manage with ConfigMgr).
|
||||
/// </summary>
|
||||
public bool EnableConfigMgrWatcher { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to keep the device awake (system + display) during the User-ESP (AccountSetup)
|
||||
/// phase so idle standby/sleep cannot stall app installs or account provisioning. This is
|
||||
|
||||
@@ -603,6 +603,14 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool? EnableRealmJoinWatcher { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the ConfigMgr watcher is enabled for this tenant's devices: the agent reports
|
||||
/// whether the Configuration Manager client is installed (service state, version, site
|
||||
/// code). Off by default; enable for tenants that co-manage with ConfigMgr.
|
||||
/// null = use agent default (false).
|
||||
/// </summary>
|
||||
public bool? EnableConfigMgrWatcher { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to keep the device awake during the User-ESP (AccountSetup) phase for this
|
||||
/// tenant's devices. Prevents idle standby/sleep from stalling app installs / account
|
||||
|
||||
@@ -203,6 +203,13 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int MatchCount { get; set; }
|
||||
public int ParseFailureCount { get; set; }
|
||||
public int TimeoutCount { get; set; }
|
||||
|
||||
/// <summary>Set when the test ended before the last sample line was evaluated:
|
||||
/// regex_timeout (the first line that hit the agent's 1 s match timeout ends the test) |
|
||||
/// time_budget (the whole test exceeded its time budget) | cancelled. Lines after the stop
|
||||
/// have no row; a note names them. Null when every line was evaluated.</summary>
|
||||
public string? StopReason { get; set; }
|
||||
|
||||
public List<LogPatternLineResult> Lines { get; } = new();
|
||||
public List<string> Notes { get; } = new();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
## This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useAuth } from "../../../../contexts/AuthContext";
|
||||
import { apiErrorText, fetchJson } from "@/lib/apiClient";
|
||||
import { api } from "@/lib/api";
|
||||
import { tenantManagementUrl } from "@/lib/routes";
|
||||
import { SegmentedControl } from "@/components/SegmentedControl";
|
||||
import { CardSkeleton } from "@/components/skeletons/PageSkeleton";
|
||||
import { chartColors } from "@/components/charts/chartTheme";
|
||||
@@ -259,7 +261,13 @@ function BucketSignups({
|
||||
return (
|
||||
<li key={row.tenantId} className="flex items-center gap-3 py-1.5 text-sm">
|
||||
<span className={`${weekly ? "w-32" : "w-20"} shrink-0 text-xs text-gray-500 tabular-nums`}>{signupTime(atMs, weekly)}</span>
|
||||
<span className="min-w-0 truncate text-gray-900">{row.domainName}</span>
|
||||
<Link
|
||||
href={tenantManagementUrl(row.tenantId)}
|
||||
title="Open in Tenant Management"
|
||||
className="min-w-0 truncate text-gray-900 hover:text-blue-700 hover:underline"
|
||||
>
|
||||
{row.domainName}
|
||||
</Link>
|
||||
{row.companyName && <span className="hidden min-w-0 truncate text-gray-500 sm:inline">{row.companyName}</span>}
|
||||
<span
|
||||
className={`ml-auto inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${facetOption("plan", edition)?.badgeClass ?? ""}`}
|
||||
|
||||
+1
@@ -145,6 +145,7 @@ describe("runtime catalogs parity with the agent-config defaults manifest", () =
|
||||
expect(agentDefaults.collectorDefaults.collectorIdleTimeoutMinutes).toBe(15);
|
||||
expect(agentDefaults.analyzerDefaults.enableIntegrityBypassAnalyzer).toBe(true);
|
||||
expect(agentDefaults.analyzerDefaults.enableRealmJoinWatcher).toBe(false);
|
||||
expect(agentDefaults.analyzerDefaults.enableConfigMgrWatcher).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@ export const TENANT_FIELDS: Record<keyof TenantConfiguration, TenantRow | Exclud
|
||||
enableIntegrityBypassAnalyzer: { section: "Analyzers", label: "Integrity Bypass Analyzer" },
|
||||
enableConsoleBypassDetection: { section: "Analyzers", label: "Console Bypass Detection" },
|
||||
enableRealmJoinWatcher: { section: "Analyzers", label: "Realm Join Watcher" },
|
||||
enableConfigMgrWatcher: { section: "Analyzers", label: "ConfigMgr Watcher" },
|
||||
|
||||
// Webhooks — channel list plus the legacy single-webhook fields it falls back to
|
||||
notificationChannelsJson: CHANNEL_BLOCK,
|
||||
@@ -341,6 +342,7 @@ export const ANALYZER_FIELDS: Record<keyof AnalyzerConfiguration, RuntimeRow | E
|
||||
enableSoftwareInventoryAnalyzer: { section: "Analyzers", label: "Software Inventory Analyzer", source: "tenant" },
|
||||
enableIntegrityBypassAnalyzer: { section: "Analyzers", label: "Integrity Bypass Analyzer", source: "tenant" },
|
||||
enableRealmJoinWatcher: { section: "Analyzers", label: "Realm Join Watcher", source: "tenant" },
|
||||
enableConfigMgrWatcher: { section: "Analyzers", label: "ConfigMgr Watcher", source: "tenant" },
|
||||
keepAwakeDuringUserEsp: { section: "Analyzers", label: "Keep Awake During User ESP", source: "tenant" },
|
||||
enableConsoleBypassDetection: { section: "Analyzers", label: "Console Bypass Detection", source: "tenant" },
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
import { DOCS_URL } from "@/lib/config";
|
||||
|
||||
const LAST_UPDATED = "20 October 2026";
|
||||
const LAST_UPDATED = "23 October 2026";
|
||||
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
|
||||
const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`;
|
||||
const DOCS_DPA = `${DOCS_URL}/legal/data-privacy-agreement-dpa`;
|
||||
@@ -71,7 +71,7 @@ export default function PrivacyPage() {
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Device identity</strong> — serial number, device name, manufacturer, model, and the Entra ID tenant the device enrolls into</li>
|
||||
<li><strong>Enrollment progress</strong> — phases, ESP stages, application and script results, policy activity, Windows updates installed during the enrollment, reboots, timings, and failure codes</li>
|
||||
<li><strong>Device context</strong> — OS build, hardware characteristics, disk and network state, plus whatever your own gather rules request</li>
|
||||
<li><strong>Device context</strong> — OS build, hardware characteristics, disk and network state, plus whatever your own gather rules request; with the ConfigMgr Watcher enabled, also whether the Configuration Manager client is installed (service state, version, site code)</li>
|
||||
<li><strong>Approximate location</strong> — country, region, city, and approximate coordinates, if geolocation is enabled for your tenant</li>
|
||||
</ul>
|
||||
|
||||
|
||||
@@ -146,7 +146,6 @@ export default function SessionInfoCard({ session, enrollmentDuration, displaySt
|
||||
{configMgrDetected.ccmServiceState && configMgrDetected.ccmServiceState !== "not_found" && <> — {configMgrDetected.ccmServiceState}</>}
|
||||
{configMgrDetected.ccmVersion && configMgrDetected.ccmVersion !== "unknown" && <>, v{configMgrDetected.ccmVersion}</>}
|
||||
{configMgrDetected.siteCode && configMgrDetected.siteCode !== "unknown" && <>, site {configMgrDetected.siteCode}</>}.
|
||||
{" "}The SCCM agent may trigger reboots during ESP that disrupt the enrollment flow and cause unexpected event sequences (e.g., device events appearing in the account phase, AccountSetup never starting).
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -109,11 +109,12 @@ export function useSessionDerivedData(
|
||||
};
|
||||
}, [events]);
|
||||
|
||||
// Extract ConfigMgr co-management detection (if present)
|
||||
// Latest ConfigMgr client detection — the agent reports again when the client's state changes.
|
||||
// Only show badge when confidence >= 50 (directory-only is too weak).
|
||||
// Default to 100 for backward compat with old agent events that lack confidenceScore.
|
||||
const configMgrDetected = useMemo(() => {
|
||||
const evt = events.find(e => e.eventType === "configmgr_client_detected");
|
||||
const detections = events.filter(e => e.eventType === "configmgr_client_detected");
|
||||
const evt = detections[detections.length - 1];
|
||||
if (!evt?.data) return null;
|
||||
const confidence = (evt.data.confidenceScore as number) ?? 100;
|
||||
if (confidence < 50) return null;
|
||||
|
||||
@@ -222,6 +222,8 @@ interface TenantConfigContextValue {
|
||||
setEnrollmentSummaryLaunchRetrySeconds: (v: number) => void;
|
||||
enableRealmJoinWatcher: boolean;
|
||||
setEnableRealmJoinWatcher: (v: boolean) => void;
|
||||
enableConfigMgrWatcher: boolean;
|
||||
setEnableConfigMgrWatcher: (v: boolean) => void;
|
||||
handleSaveAgentSettings: () => void;
|
||||
handleResetAgentSettings: () => void;
|
||||
|
||||
@@ -432,6 +434,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
const [enrollmentSummaryBrandingImageUrl, setEnrollmentSummaryBrandingImageUrl] = useState("");
|
||||
const [enrollmentSummaryLaunchRetrySeconds, setEnrollmentSummaryLaunchRetrySeconds] = useState(120);
|
||||
const [enableRealmJoinWatcher, setEnableRealmJoinWatcher] = useState(false);
|
||||
const [enableConfigMgrWatcher, setEnableConfigMgrWatcher] = useState(false);
|
||||
const [keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp] = useState(false);
|
||||
|
||||
// Notification channels
|
||||
@@ -592,6 +595,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
setEnrollmentSummaryBrandingImageUrl(data.enrollmentSummaryBrandingImageUrl ?? "");
|
||||
setEnrollmentSummaryLaunchRetrySeconds(data.enrollmentSummaryLaunchRetrySeconds ?? 120);
|
||||
setEnableRealmJoinWatcher(data.enableRealmJoinWatcher ?? false);
|
||||
setEnableConfigMgrWatcher(data.enableConfigMgrWatcher ?? false);
|
||||
setKeepAwakeDuringUserEsp(data.keepAwakeDuringUserEsp ?? false);
|
||||
// Notification channels (auto-migrates from the legacy single-webhook fields)
|
||||
setNotificationChannels(channelsFromConfig(data));
|
||||
@@ -785,6 +789,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
enrollmentSummaryBrandingImageUrl: enrollmentSummaryBrandingImageUrl || undefined,
|
||||
enrollmentSummaryLaunchRetrySeconds,
|
||||
enableRealmJoinWatcher,
|
||||
enableConfigMgrWatcher,
|
||||
keepAwakeDuringUserEsp,
|
||||
// Notification channels are the authoritative config; the legacy single-webhook
|
||||
// fields are cleared on save so deleting the last channel can't resurrect a zombie
|
||||
@@ -853,7 +858,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds,
|
||||
contactEmail, companyName, enableGeoLocation, enableTimezoneAutoSet, enableDoGroupIdAutoSet, enableImeMatchLog, enableGatherRuleDebugLog, logLevel, showScriptOutput, showEnrollmentSummary,
|
||||
enrollmentSummaryTimeoutSeconds, enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
|
||||
enableRealmJoinWatcher, keepAwakeDuringUserEsp,
|
||||
enableRealmJoinWatcher, enableConfigMgrWatcher, keepAwakeDuringUserEsp,
|
||||
notificationChannels,
|
||||
slaTargetSuccessRate, slaTargetMaxDurationMinutes, slaTargetAppInstallSuccessRate,
|
||||
slaNotifyOnSuccessRateBreach, slaSuccessRateNotifyThreshold, slaNotifyOnDurationBreach,
|
||||
@@ -1226,6 +1231,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
setEnrollmentSummaryBrandingImageUrl(config.enrollmentSummaryBrandingImageUrl ?? "");
|
||||
setEnrollmentSummaryLaunchRetrySeconds(config.enrollmentSummaryLaunchRetrySeconds ?? 120);
|
||||
setEnableRealmJoinWatcher(config.enableRealmJoinWatcher ?? false);
|
||||
setEnableConfigMgrWatcher(config.enableConfigMgrWatcher ?? false);
|
||||
setKeepAwakeDuringUserEsp(config.keepAwakeDuringUserEsp ?? false);
|
||||
}, [config]);
|
||||
|
||||
@@ -1607,6 +1613,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
enrollmentSummaryBrandingImageUrl, setEnrollmentSummaryBrandingImageUrl,
|
||||
enrollmentSummaryLaunchRetrySeconds, setEnrollmentSummaryLaunchRetrySeconds,
|
||||
enableRealmJoinWatcher, setEnableRealmJoinWatcher,
|
||||
enableConfigMgrWatcher, setEnableConfigMgrWatcher,
|
||||
keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp,
|
||||
handleSaveAgentSettings, handleResetAgentSettings,
|
||||
|
||||
@@ -1690,7 +1697,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
enableGeoLocation, enableTimezoneAutoSet, enableDoGroupIdAutoSet, enableImeMatchLog, enableGatherRuleDebugLog,
|
||||
logLevel, showScriptOutput, showEnrollmentSummary, enrollmentSummaryTimeoutSeconds,
|
||||
enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
|
||||
enableRealmJoinWatcher, keepAwakeDuringUserEsp,
|
||||
enableRealmJoinWatcher, enableConfigMgrWatcher, keepAwakeDuringUserEsp,
|
||||
handleSaveAgentSettings, handleResetAgentSettings,
|
||||
enableLocalAdminAnalyzer, localAdminAllowedAccounts, newAllowedAccount,
|
||||
enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer, enableConsoleBypassDetection,
|
||||
|
||||
@@ -34,7 +34,7 @@ const EXPECTED_OWNED_FIELDS = [
|
||||
"contactEmail", "companyName",
|
||||
// agentSettings
|
||||
"enablePerformanceCollector", "performanceCollectorIntervalSeconds", "helloWaitTimeoutSeconds",
|
||||
"enableRealmJoinWatcher",
|
||||
"enableRealmJoinWatcher", "enableConfigMgrWatcher",
|
||||
"selfDestructOnComplete", "keepLogFile", "rebootOnComplete", "rebootDelaySeconds",
|
||||
"enableGeoLocation", "enableTimezoneAutoSet", "enableDoGroupIdAutoSet", "keepAwakeDuringUserEsp",
|
||||
"enableImeMatchLog", "enableGatherRuleDebugLog",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user