Compare commits

...

37 Commits

Author SHA1 Message Date
Oliver Kieselbach b8312ee0c6 bump pointer: internal (D-384 released: installer 2.9, bootstrap MSI 1.4.0, docs, web, MCP)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 02:49:58 +02:00
Oliver Kieselbach d639d9dda9 Bootstrap MSI 1.4: the action runs only the exact loader the package embeds (SHA-256 baked in at build time)
The automated security review of the previous commit found that a publisher pin alone
would run any validly signed script of ours placed at the copy's path, such as an older
loader without the held handle. The build now bakes the SHA-256 of the signed loader into
the action (wix build -d LoaderSha256=...; the package refuses to build without it), and
the action compares the bytes it read against it before the signature check.

- Package.wxs: hash check, preprocessor guard, comment
- build-msi.yml: the build step hashes the signed loader and passes it to wix
- Bootstrap.Integration.Tests.ps1: the harness substitutes the hash of the loader it serves;
  a copy that is not the embedded loader, however validly signed, runs nothing
- BootstrapContractTests: the hash condition, the guard and the build step are pinned
- internal: D-384, footprint doc and plan updated

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 02:09:56 +02:00
Oliver Kieselbach f44e8aadb0 Bootstrap MSI 1.4 keeps its loader copy under ProgramData and runs only what it checked; installer 2.9 removes the copy (D-384)
MSI 1.3 (D-377) installed the loader under Program Files, where nothing removed it: a
customer found the file on every device. Everything of ours lives under ProgramData.

- Package.wxs 1.4.0: the loader copy goes back to <base>\Msi (new component GUID); the
  deferred SYSTEM action, started in System32, reads the copy once, checks our signature
  and the publisher pin on those bytes and runs those bytes from memory, so a file a user
  planted at that path leads to nothing and no handle keeps the installer from removing
  the copy. Every bracket and brace is escaped for the Windows Installer formatter.
- Install-AutopilotMonitor.ps1 2.9: Remove-MsiTransportFolder removes the copy in the
  agent-present branch (install and skip clear the base already) and an agent folder that
  is then empty; Remove-LegacyMsiLoaderFolder removes MSI 1.3's folder under Program Files
  in every outcome while devices still enroll with 1.3.
- Tests: installer unit tests for both functions; the loader's MSI contract; the harness
  asserts after every run that nothing exists outside ProgramData, runs the MSI 1.4 action
  read from Package.wxs (valid, foreign, unsigned, changed; install, skip, marker only,
  agent present) and expects MSI 1.3's folder gone; BootstrapContractTests pin the
  directory chain, the publisher pin, the escapes and the working directory.
- build-msi.yml: smoke job installs the built MSI on windows-2022/2025 with the deployment
  marker set and expects nothing under ProgramData or Program Files, then uninstalls.
- internal: D-384, footprint doc, plan, lessons, backlog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 02:01:15 +02:00
Oliver Kieselbach e5b7003799 bump pointer: internal (ConfigMgr Watcher opt-in, D-382 updated)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:45:59 +02:00
Oliver Kieselbach 6db3cb9741 ConfigMgr Watcher: the ConfigMgr client check is opt-in per tenant (D-382)
The check from d4e67cdb now runs only for tenants that enable it, like the
RealmJoin Watcher: TenantConfiguration.EnableConfigMgrWatcher feeds
AnalyzerConfiguration.EnableConfigMgrWatcher (default off, ConfigVersion
42). With the setting off, DefaultComponentFactory does not create the
ConfigMgrClientHost, so other tenants' devices run no check at all.

- Portal: toggle "ConfigMgr Watcher" in Agent Settings, section field map,
  tenant config report.
- Privacy page: the check is named as optional.
- Shared manifest and the web and MCP wire types regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:45:22 +02:00
Oliver Kieselbach a213290eb6 bump pointer: internal (D-382 V1-style ConfigMgr detection, D-384 dropped, B-b2u, lesson)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:45:12 +02:00
Oliver Kieselbach d4e67cdbb5 ConfigMgr client detection is back in the V2 agent (D-382)
The V1 agent reported whether the Configuration Manager client is
installed; the check was lost when the V2 agent replaced the V1 enrollment
tracker. ConfigMgrClientProbe reads what V1 read (the CCM key, CCMSetup
LastValidVersion, the assigned site code, the %windir%\CCM folder, the
CcmExec service state) and keeps the V1 confidence score.
ConfigMgrClientDetector, polled by ConfigMgrClientHost every minute, emits
configmgr_client_detected with the V1 payload when the client is first seen
and again when its reported state changes, at most 10 times per run; a
restart into the same state stays silent.

- Session Info shows the latest detection; the V1 sentence about disturbed
  event sequences is gone.
- Privacy page: device context names the Configuration Manager client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:44:16 +02:00
Oliver Kieselbach e4d3e8ed30 bump pointer: internal (D-382 to D-384, provider-stall concept doc, configmgr-client-status plan phase 1a)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:55:23 +02:00
Oliver Kieselbach d4774ed043 Provider-stall tripwire counts device-preparation providers only while device preparation is open (D-383)
A ConfigMgr client installed as a Win32 app registers its device-preparation
policy provider after its own install, with InstallationState=1, and nothing
completes it. The tripwire then reported a stall long after device
preparation had ended.

- EspPhaseSnapshot.DevicePreparationOpen decides whether device preparation
  can still hold the ESP (it runs or failed, or the phase is unknown).
  EvaluateStallTransition takes it as a parameter; the detector reads the
  phase through an injectable reader.
- The cross-restart fingerprint applies the same filter, so a restart does
  not report the same stall again.
- The message names the wait: Setup/Apps providers hold the ESP without any
  timeout, device-preparation providers until their timeout.
- The diagnosis names a stalled provider only for a failure in its ESP
  category; the waiting list of an unfinished session stays unfiltered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:55:00 +02:00
Oliver Kieselbach b9cc515258 bump pointer: internal (deploy of D-379, D-380, D-381 recorded)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:14:11 +02:00
Oliver Kieselbach fef1f54c09 bump pointer: internal (configmgr-client-status plan draft)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:25 +02:00
Oliver Kieselbach 60dc850317 web: AGENTS.md heading level, page lastmod regenerated for /about and /privacy
Both were left uncommitted in the working tree; committed as they stand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:25 +02:00
Oliver Kieselbach da58ac0a27 bump pointer: internal (B-ne6 join volume)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:57 +02:00
Oliver Kieselbach d1d15db4fe bump pointer: internal (backlog B-ne6 from the security review of D-381)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:30 +02:00
Oliver Kieselbach a3810b74a7 bump pointer: internal (SignalR revoke bound to the sign-in tenant, D-381)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:40:47 +02:00
Oliver Kieselbach 80d50a6d92 A live SignalR connection is addressed by its sign-in tenant; a revoke reaches only the tenant whose grant it ends (D-381)
Security finding (CWE-863): the SignalR user id was the bare UPN and every revoke cut that UPN across the
hub. A tenant admin could add any UPN (a Global Admin, another customer's operator) and remove, disable or
demote it in a loop, restarting that person's live connections in every tenant.

- Negotiate binds the user id to the sign-in tenant plus principal key from the validated request context
  (SignalRGroupHelper.UserId); a caller without a principal key negotiates without a user id, since every
  revocable join gate needs the key.
- ISignalRNotificationService.DisconnectUserAsync takes the tenant and the key. LiveStreamRevoker is its
  only caller and names the tenant each grant resolved under: member routes the route's tenant,
  self-service routes the managing tenant, Global Admin routes the tenant of the UPN's identity binding.
  Replacing or deleting a binding now cuts the streams signed in under the previous one.
- A self-service assignment confers scope only while its assignee is bound to the owning tenant, so a
  self-service grant resolves under the owner alone and the owner's revokes never need another tenant.
- Remove, disable and enable of a UPN without a row in the tenant answer 404 and change nothing: no audit,
  no push revoke, no end of an assignment, no cut.
- GlobalAdminService.DisableGlobalAdminAsync and its repository method had no caller and are removed.
- Tests pin the finding's attack, the 404s, the self-service gate, the binding routes and the call sites;
  each part was mutated back to the old behaviour and failed its tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:39:26 +02:00
Oliver Kieselbach 5371de3856 bump pointer: internal (D-380 verify items in B-pxg)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 22:24:55 +02:00
Oliver Kieselbach 483dc3de94 The onboarding seat belongs to the first activation and to the recorded member alone (D-380)
Two follow-up findings of the security scan against D-378.

- IsPromotableUpn is a shape test (contains '@', no whitespace, no #EXT#); the "System" prefix
  test is gone. It rejected real members such as systemadmin@… although every sentinel already
  fails the '@' test, so such a tenant got no seat and fell back to first-caller-wins.
- BuildAuthResult keys the first-user guard off a recorded OnboardedBy, not off its promotability:
  a recorded value nobody can seat still blocks every other roleless caller.
- RevokeAsync records the revoke (PreviewWhitelist row "revoked") before it deletes the activation,
  fail-loud; a re-approve never clears it, and activation seats nobody while it exists. A member
  the tenant removed stays removed whatever a Global Admin's revoke + re-approve does.
- Every successful first-admin seat (activation and first sign-in) writes the tenant's audit entry
  CREATE TenantAdmin, fail-soft, through a new longest TenantAdminsService constructor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 22:22:57 +02:00
Oliver Kieselbach 06b69aa0d8 The logparser pattern test ends at the first timeout or a 2 s budget, honours cancellation, caps the pattern at 2,000 characters, runs interpreted and admits two tests per instance (D-379)
Security finding (ReDoS / CWE-1333): a Tenant Admin of any tenant or a Global Reader could send a catastrophic pattern with 200 non-matching lines to POST rules/gather/test-pattern and cost ~200 s of a worker thread per request (one swallowed 1 s timeout per line), repeat it up to the portal budget and disconnect at once — nothing cancelled the work, nothing limited concurrency, the pattern length was bounded only by the body cap, and RegexOptions.Compiled emitted IL for every caller pattern.

- TestLogPatternFunction: the first RegexMatchTimeoutException ends the test, a 2 s EvaluationBudget ends it when every line is merely slow, and the invocation CancellationToken (client disconnect, host shutdown) ends it between two lines — without throwing. Lines after the stop get no row; LogPatternTestResult.StopReason (regex_timeout | time_budget | cancelled) and a note name the stop and the unevaluated range. A timeout on the last line is a complete test.
- ValidateRequest rejects patterns over 2,000 characters (the MCP schema's cap) before any Regex is built.
- BuildAgentRegex keeps the agent's semantic options and its 1 s match timeout but drops Compiled: the flag changes speed only, never a match result.
- TestLogPatternGate (singleton, limit 2) is entered before the regex is built; without a free slot the function answers 429 RateLimited with Retry-After: 3 and never queues. Stops and refusals log at Warning.
- Shared: LogPatternTestResult.StopReason (additive); manifests and wire types regenerated. MCP: the tool description says that the first timeout ends the test.
- Tests: pattern cap, interpreted construction with the agent's timeout, catastrophic pattern stops after one timeout (was ~200 s), last-line timeout is no stop, budget and cancellation stops, gate slots.

Submodule pointers: internal (plan, D-379, backlog B-66p/B-j42), .claude/commands (platform-health marker).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 21:35:14 +02:00
Oliver Kieselbach 351c8d4b3a bump pointer: internal (activation race fix live, D-378)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 21:01:23 +02:00
Oliver Kieselbach e704aa7359 Activation seats the onboarding admin before it activates the tenant; the first-user seat belongs to that member alone (D-378)
Security finding (CWE-367): the activation wrote the approved row first and the OnboardedBy
admin row a few storage round-trips later. In between the tenant was activated with no
members, and auth/me seated any roleless caller of that moment as Admin; every member of the
customer's Entra tenant could poll auth/me across the auto-approve moment.

- TenantApprovalService: uncached pre-check, then the OnboardedBy Admin row (conditional
  insert, best-effort, fresh config read), then the activation row (still the arbiter), then
  the welcome mail. The two tables cannot share a batch, so "before" is the guarantee.
- AuthFunction.BuildAuthResult: with a promotable OnboardedBy the first-user auto-admin goes
  to that UPN only; other roleless callers stay roleless. Without one (rows from before the
  field existed) the first sign-in still becomes the admin. The grant logs at Warning.
- Both first-admin writers use IAdminRepository.TryAddTenantMemberAsync (AddEntityAsync,
  409 = left untouched) instead of an upsert; TenantAdminsService.TryAddTenantAdminAsync
  replaces AddTenantAdminAsync. PreviewWhitelistService.IsApprovedFreshAsync is the pre-check.
- Dead after this: IsTenantAdminAsync on the service, the repository interface and the table
  repository, plus its test.
- Tests pin the write order, the designated-member guard (unit and auth/me end to end) and the
  conditional write; each part was mutated back to the old code and failed its tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 20:47:53 +02:00
Oliver Kieselbach 622a2437a0 bump pointer: internal (plan note on the customer wording correction, D-377)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 19:01:17 +02:00
Oliver Kieselbach 5ceab7e611 bump pointer: internal (bootstrap security findings released, D-377)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 18:52:47 +02:00
Oliver Kieselbach 8a7cd5ed98 bump pointer: internal (MSI field check and lab name both MSI paths)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:51:18 +02:00
Oliver Kieselbach 15804e2817 bump pointer: internal (bootstrap MSI agent start report closed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:20:39 +02:00
Oliver Kieselbach 194958d3cd bump pointer: internal (bootstrap security findings built, D-377)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 18:07:54 +02:00
Oliver Kieselbach 40323ca57e Bootstrap hardening after a security review, and the MSI channel installs again (D-377)
Findings of a Claude security scan against b4af8c7, each measured first, plus
the MSI channel incident a parallel session found.

- Installer 2.8 extracts the agent ZIP with its own containment check
  (Expand-AgentPayload): every entry is resolved against the payload folder
  before the first write, a rooted or escaping name refuses the package. The
  inbox Expand-Archive of Windows PowerShell 5.1 already refuses such entries
  (measured), so the finding was not exploitable as reported; the guarantee is
  now the script's own, pinned by tests, and Expand-Archive is banned.
- Installer 2.8 also moves PowerShell's own location out of the agent folder
  (Set-Location): on the MSI channel the location pointed at the deleted Msi
  folder and the agent start failed with error 267 since 2.7 went live. When
  the agent program never started, the Agent folder is removed again so a later
  run can install; the stderr line is flattened to one line.
- Run folders younger than an hour stay (a loader may still run there); the
  loader's log is taken only from a run folder that is ours.
- Loader 1.4 (minimum installer 2.8) checks before every download attempt that
  its run folder is still a real folder of ours, opens the download for reading
  before the signature and identity checks and holds it until the run ends, so
  what it checked is what runs and nothing can replace or delete it meanwhile.
- Agent: the protected folder security names Administrators as owner; a folder
  with another owner or a reparse point is not taken as protected, reparse points
  are refused, the base is created protected, install mode protects base and
  Agent and refuses to install when it cannot; every start re-checks both.
- Bootstrap MSI 1.3.0 installs its loader under Program Files, where a standard
  user cannot create the folder first.
- Tests: containment table, hour rule, held handle, owner and junction tests
  (ElevatedFact for the owner assignment), MSI location contract; harness
  scenarios for an escaping package (installer and OOBE script), the hour rule,
  the Program Files layout and an agent that does not start, with the install
  stand-in starting a real program from PowerShell's location. OOBE golden
  regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 18:07:51 +02:00
Oliver Kieselbach 1b4a87d467 bump pointer: internal (onboarding role fix live, D-376)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:53:00 +02:00
Oliver Kieselbach a43a4e474a New tenants' first admin keeps the Admin role right after activation (D-376)
After auto-activation, a third of new tenants' first admins were answered by
auth/me without a role and sent from the activation page to /progress. The
auto-approve worker promotes the onboarding member on another instance, so the
portal's caches never hear of it: auth/me combined a cached "no row" (refreshed
1.5 s after every activation poll by feedback/status) with a fresh member list,
and neither the role nor the first-user auto-admin applied.

- TenantAdminsService caches only an enabled membership; a missing or disabled
  row is read on every call. IsTenantAdminAsync reads through the same cache.
- auth/me takes the caller's row and "the tenant has any row" from one uncached
  partition read (GetMembershipSnapshotAsync); disabled and application rows
  still count as members.
- PreviewWhitelistService caches only an activation; "not activated" is read on
  every call, so the activation page leaves at the first poll after approval.
  IsApprovedFreshAsync is folded into IsApprovedAsync.
- Removed the dead ClearCacheForTenant, GetMemberRoleAsync and
  CanManageBootstrapAsync.

Tests reproduce the sequence over the real caches (auth/me and the first
dashboard request); each part fails against the old caching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:11:10 +02:00
Oliver Kieselbach 82dafc5e8f bump pointer: internal (bootstrap loader 1.3 / installer 2.7 released, D-375)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:33:56 +02:00
Oliver Kieselbach b4af8c77be Bootstrap integration tests read the publish script's output as text
GitHub Actions runs powershell steps with $ErrorActionPreference = 'stop', and
under Windows PowerShell 5.1 a stderr line of a native process redirected with
2>&1 then ends the test: the three publish refusals failed in CI although the
publish refused as it should. The publish dry runs now start pwsh through
System.Diagnostics.Process like every other scenario. Local run under 'stop':
199 of 199.

[skip actions]: published by a dispatch of publish-scripts.yml, channel dev first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:59:45 +02:00
Oliver Kieselbach 0bfab326e5 bump pointer: internal (bootstrap loader 1.3 / installer 2.7 built, D-375)
[skip actions]: the scripts go out by a dispatch of publish-scripts.yml, channel dev first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:35:10 +02:00
Oliver Kieselbach 3a898b5071 Bootstrap leaves nothing behind: loader 1.3 and installer 2.7 (D-375)
- Loader 1.3 downloads into a protected run folder of its own
  (%ProgramData%\AutopilotMonitor-Loader-<8 hex>), runs the installer only when it
  is signed by the pinned publisher and declares the installer role and version
  2.7 or later, removes the folder after a successful run and never touches the
  agent folder. Unbound arguments now reach the installer by name; the array
  splat bound them by position.
- Installer 2.7 refuses without elevation and decides before it writes: guards
  read HKLM through the 64-bit view (a 32-bit host never saw the deployment
  marker), guard 5 is covered by the agent check. An agent's folder stays as it
  is, a skip without agent removes the folder, an install starts from an emptied,
  protected folder and stages the ZIP in a run folder. It moves the MSI's working
  directory out, writes stderr only on failure and removes the copy older loaders
  left at the fixed path.
- Help texts of both scripts state what customers need; .CHANGELOG, which made
  Get-Help ignore the help, gives way to the Platform Changelog.
- OOBE go-script: the installer's shared helpers, its own elevation check
  (#Requires is ignored under irm | iex), a run folder.
- Publish: version read line-anchored, the loader's own check on the exact
  publish bytes, installers before loaders, -Channel dev (dispatch input).
- Bootstrap MSI 1.2.0; Test-ShouldBootstrapAgent reads the 64-bit view;
  Uninstall removes loader run folders and keeps the Deployed marker.
- Tests: Pester unit suites, an integration harness running the real scripts
  (loader 1.2 and the MSI 1.0 runner frozen in TestData), BootstrapContractTests;
  the gate runs on windows-2022 and windows-2025, once more as SYSTEM, and against
  a real standard user.

[skip actions]: published by a dispatch of publish-scripts.yml, channel dev first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:34:54 +02:00
Oliver Kieselbach 9377671349 Customers metrics: each new customer links to its tenant in Tenant Management
The signup list under the chart now links every domain to
/admin/tenants/management?tenantId=…, the deep link the ops events
"View tenant" button already uses, so the list opens narrowed to that tenant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:31:27 +02:00
Oliver Kieselbach 902560692b bump pointer: internal (agent v2.0.1490 docs, web and MCP deployed, D-374)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:20:44 +02:00
Oliver Kieselbach 3946eddcea bump pointer: .claude/commands (ux-insights expected values)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:18:30 +02:00
Oliver Kieselbach 48e27fb2d6 bump pointer: internal (Mark as Succeeded released as agent v2.0.1490, D-374)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:29:17 +02:00
108 changed files with 7432 additions and 1172 deletions
@@ -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."
+60 -18
View File
@@ -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)' })**",
+176
View File
@@ -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."
+30 -5
View File
@@ -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.
+86 -2
View File
@@ -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:
+5
View File
@@ -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:
+11
View File
@@ -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 `
@@ -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
+578 -249
View File
@@ -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
}
}
+220 -111
View File
@@ -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
+44 -11
View File
@@ -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)";
}
}
}
}
@@ -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);
}
}
}
@@ -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()
{
@@ -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()
{
@@ -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 ?? "?"}).");
}
}
}
@@ -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}";
}
}
@@ -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
@@ -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="&quot;[System64Folder]WindowsPowerShell\v1.0\powershell.exe&quot; -NoProfile -ExecutionPolicy Bypass -File &quot;[INSTALLFOLDER]Start-AutopilotMonitor.ps1&quot; -LogFileName bootstrap-msi.log"
Directory="System64Folder"
ExeCommand="&quot;[System64Folder]WindowsPowerShell\v1.0\powershell.exe&quot; -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command &quot;$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*') [\{] &amp; ([\[]scriptblock[\]]::Create([\[]System.Text.Encoding[\]]::UTF8.GetString($b))) -LogFileName bootstrap-msi.log [\}]&quot;"
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);
}
}
@@ -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)
@@ -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"),
@@ -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);
@@ -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" });
}
@@ -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);
@@ -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)
{
@@ -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;
@@ -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);
}
@@ -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 -1
View File
@@ -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 ?? ""}`}
@@ -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