mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff8bc7927f | |||
| 8a53c6233f | |||
| f3a4ce6ed8 | |||
| 42dae1b38c | |||
| b58830ddcb | |||
| caac165172 | |||
| d25f2286b4 | |||
| 1dd4a477c5 | |||
| b8312ee0c6 | |||
| d639d9dda9 | |||
| f44e8aadb0 | |||
| e5b7003799 | |||
| 6db3cb9741 | |||
| a213290eb6 | |||
| d4e67cdbb5 | |||
| e4d3e8ed30 | |||
| d4774ed043 | |||
| b9cc515258 | |||
| fef1f54c09 | |||
| 60dc850317 | |||
| da58ac0a27 | |||
| d1d15db4fe | |||
| a3810b74a7 | |||
| 80d50a6d92 | |||
| 5371de3856 | |||
| 483dc3de94 | |||
| 06b69aa0d8 | |||
| 351c8d4b3a | |||
| e704aa7359 | |||
| 622a2437a0 | |||
| 5ceab7e611 | |||
| 8a7cd5ed98 | |||
| 15804e2817 |
+1
-1
Submodule .claude/commands updated: b4fc66f0df...c67c475a62
@@ -1,13 +1,50 @@
|
||||
name: Bootstrap script gates
|
||||
|
||||
# Quality gate for the customer-facing PowerShell scripts, factored out so it exists
|
||||
# once and both callers run the identical checks:
|
||||
# * pester.yml -- pull requests (reports, does not block the merge button)
|
||||
# * publish-scripts.yml -- pushes to main, where it BLOCKS the upload to the
|
||||
# download alias. Devices execute these scripts as SYSTEM
|
||||
# during enrollment, so a red suite must never ship.
|
||||
# Quality gate for the customer-facing PowerShell scripts. Devices execute them as SYSTEM
|
||||
# during enrollment, so a red suite must never ship. It runs in two roles:
|
||||
# * on its own, on every pull request and push to main that changes an input of these
|
||||
# checks (reports, does not block the merge button);
|
||||
# * called as the gates job that BLOCKS every signing or upload of bootstrap code:
|
||||
# publish-scripts.yml, build-agent.yml (stable releases), build-msi.yml. Those runs
|
||||
# show under the caller.
|
||||
#
|
||||
# Both path lists are the checks' complete inputs and stay identical. They cover every
|
||||
# push path of publish-scripts.yml, which leaves the check of a [skip publish] push to
|
||||
# this workflow.
|
||||
#
|
||||
# No concurrency group: it would also bind the runs called by the publishers, and a
|
||||
# cancel there would block a publish.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
- '.github/scripts/Get-Endpoints.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 custom action; the
|
||||
# contract tests also read the MSI build.
|
||||
- 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1'
|
||||
- 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
- '.github/scripts/Get-Endpoints.ps1'
|
||||
- '.github/scripts/Invoke-BootstrapSystemPass.ps1'
|
||||
- '.github/scripts/Test-BootstrapAttacker.ps1'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
- 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1'
|
||||
- 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -254,6 +254,12 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# A stable release publishes the customer scripts as well, so it waits for the gates their
|
||||
# own publish waits for. Next to the build; skipped when nothing goes out as stable.
|
||||
gates:
|
||||
if: inputs.publish_as_stable
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
# Trust zone: only this repository's scripts, pinned actions and the pinned signing tool
|
||||
# run here -- never a restore or build. The build output arrives as data. The runner
|
||||
# image's Windows SDK signtool is used read-only, to verify third-party signatures.
|
||||
@@ -263,7 +269,9 @@ jobs:
|
||||
# sign nor write storage, whatever its permissions say.
|
||||
release:
|
||||
name: Sign and publish
|
||||
needs: [reserve, build]
|
||||
needs: [reserve, build, gates]
|
||||
# A skipped gates job (no stable publish) lets the release run; a failed or cancelled one stops it.
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' && (needs.gates.result == 'success' || needs.gates.result == 'skipped') }}
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
permissions:
|
||||
@@ -726,9 +734,9 @@ jobs:
|
||||
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:STABLE_ZIP -ContentType "application/zip"
|
||||
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:STABLE_MANIFEST -ContentType "application/json"
|
||||
|
||||
# Customer-facing scripts: same publisher publish-scripts.yml runs on every push
|
||||
# to main, so a release lands a set that is consistent with what is already live
|
||||
# (dev render, version-bump guard, bootstrapVersion oracles, alias verification).
|
||||
# Customer-facing scripts: same publisher and same gates (the gates job) as
|
||||
# publish-scripts.yml, so a release lands a set that is consistent with what is
|
||||
# already live (dev render, version-bump guard, bootstrapVersion oracles, alias verification).
|
||||
# Runs AFTER version.json so its bootstrapVersion read sees this build's manifest.
|
||||
# Signed with the same identity: the device-side loader verifies the publisher
|
||||
# of what it downloads, and the publisher refuses to upload unsigned scripts.
|
||||
|
||||
@@ -34,6 +34,12 @@ env:
|
||||
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
|
||||
|
||||
jobs:
|
||||
# The loader executes as SYSTEM on every enrolling device, and the MSI's custom action runs
|
||||
# it: nothing is signed before the bootstrap gates pass on this commit, since a signed copy
|
||||
# of ours is trusted wherever it lands.
|
||||
gates:
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
# Signs the loader BEFORE the build: wix embeds the file as it finds it, so a loader signed
|
||||
# afterwards would leave the copy inside the MSI unsigned. Devices under an AllSigned policy
|
||||
# need the embedded loader to carry a signature of its own.
|
||||
@@ -42,6 +48,7 @@ jobs:
|
||||
# here. The `release` environment is the only subject the code-signing identity trusts.
|
||||
sign-loader:
|
||||
name: Sign bootstrap loader
|
||||
needs: gates
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
permissions:
|
||||
@@ -59,22 +66,6 @@ jobs:
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
# The loader executes as SYSTEM on every enrolling device — a parse error or
|
||||
# non-ASCII mojibake (PS 5.1 codepage roulette) must fail the build, not the fleet.
|
||||
# bootstrap-script-gates.yml runs the same checks plus Pester on pushes; this job
|
||||
# stands on its own because it can be dispatched at any time.
|
||||
- name: Validate runner script (parse + ASCII-only)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$errs = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $env:RUNNER_SCRIPT), [ref]$null, [ref]$errs) | Out-Null
|
||||
if ($errs) { $errs | ForEach-Object { Write-Error $_.ToString() }; exit 1 }
|
||||
if ((Get-Content $env:RUNNER_SCRIPT -Raw) -match '[^\x00-\x7F]') {
|
||||
Write-Error "Runner script contains non-ASCII characters"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Runner script parse + ASCII check passed"
|
||||
|
||||
# Every published MSI is signed; a missing variable stops the run before the build.
|
||||
- name: Check code-signing configuration
|
||||
shell: pwsh
|
||||
@@ -181,11 +172,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 +194,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:
|
||||
|
||||
@@ -46,7 +46,6 @@ on:
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -79,7 +78,6 @@ on:
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ on:
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -70,7 +69,6 @@ on:
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
schedule:
|
||||
# Weekly, so advisories published after a merge still surface. Deliberately
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Pester (Bootstrap scripts)
|
||||
|
||||
# Pull-request view of the bootstrap script gates. Like ci.yml it reports rather than
|
||||
# blocks the merge button.
|
||||
#
|
||||
# There is deliberately no push trigger: publish-scripts.yml runs the very same
|
||||
# reusable gate workflow on every push to main and blocks the upload to the download
|
||||
# alias on it. A push trigger here would only duplicate that run, with no consequence.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '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:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pester-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# $/ is the self-repository form: this repository at the running commit.
|
||||
gates:
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
@@ -55,7 +55,12 @@ env:
|
||||
jobs:
|
||||
# $/ is the self-repository form: this repository at the running commit, never a
|
||||
# path some earlier step could have written to the workspace.
|
||||
#
|
||||
# A push with [skip publish] in one of its commit messages runs neither job and uploads
|
||||
# nothing; the gates' own push trigger, whose paths cover these, still checks it. Never
|
||||
# [skip actions] for that: it drops every check of the push, CI and these gates included.
|
||||
gates:
|
||||
if: github.event_name != 'push' || !contains(join(github.event.commits.*.message, ' '), '[skip publish]')
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
publish:
|
||||
|
||||
+1
-1
Submodule internal updated: f12178c74c...5ce7bb7b50
@@ -70,8 +70,8 @@ function global:HarnessInvokeRestMethod {
|
||||
|
||||
function global:HarnessGetAuthenticodeSignature {
|
||||
[CmdletBinding()]
|
||||
param([string]$FilePath)
|
||||
Write-HarnessTrace 'Get-AuthenticodeSignature' $FilePath
|
||||
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' }
|
||||
@@ -188,8 +188,11 @@ if ($global:HarnessOptions.SwapElevation) { $swaps['Test-IsElevated'] = 'Harness
|
||||
if ($global:HarnessOptions.SwapDacl) { $swaps['New-ProtectedDirectorySecurity'] = 'HarnessNewProtectedDirectorySecurity' }
|
||||
foreach ($name in $swaps.Keys) { Set-Alias -Name $name -Value $swaps[$name] -Scope Global -Force }
|
||||
|
||||
# The OOBE script arrives as irm | iex, which ignores #Requires.
|
||||
if ($global:HarnessOptions.Iex) {
|
||||
# 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
|
||||
@@ -228,6 +231,7 @@ exit $LASTEXITCODE
|
||||
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'
|
||||
@@ -262,9 +266,11 @@ exit $LASTEXITCODE
|
||||
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
|
||||
|
||||
@@ -287,6 +293,7 @@ exit $LASTEXITCODE
|
||||
$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()
|
||||
@@ -328,6 +335,25 @@ exit $LASTEXITCODE
|
||||
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 { $_ })
|
||||
@@ -369,6 +395,7 @@ exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
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')
|
||||
@@ -471,7 +498,7 @@ Describe 'loader 1.4' {
|
||||
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.8"', '$ScriptVersion = "2.7"') }
|
||||
@{ 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
|
||||
@@ -502,6 +529,7 @@ Describe 'loader 1.4' {
|
||||
}
|
||||
|
||||
Describe 'installer assigned directly' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
BeforeEach {
|
||||
$s = New-Scenario
|
||||
$imeCopy = Join-Path $s.Ime '0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.ps1'
|
||||
@@ -605,6 +633,7 @@ Describe 'installer assigned directly' {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -644,6 +673,7 @@ Describe 'loader 1.2, frozen' {
|
||||
}
|
||||
|
||||
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 = @() }
|
||||
@@ -660,6 +690,26 @@ Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
|
||||
$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 @(
|
||||
@@ -678,10 +728,127 @@ Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
|
||||
$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
|
||||
@@ -733,6 +900,7 @@ Describe 'OOBE script, as the backend generates it' {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -768,9 +936,9 @@ Describe 'publish pipeline and dev channel' {
|
||||
}
|
||||
|
||||
It 'refuses to publish an installer the loader would refuse: <Case>' -TestCases @(
|
||||
@{ Case = 'below the minimum version'; Old = '$ScriptVersion = "2.8"'; New = '$ScriptVersion = "2.7"' }
|
||||
@{ 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.8"'; New = "`$ScriptVersion = `"2.8`"`n`$ScriptVersion = `"2.9`"" }
|
||||
@{ 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
|
||||
@@ -817,7 +985,7 @@ Describe 'publish pipeline and dev channel' {
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
$run.StdOut | Should -Match 'Installer accepted: AutopilotMonitor-Installer v2\.8'
|
||||
$run.StdOut | Should -Match 'Installer accepted: AutopilotMonitor-Installer v2\.9'
|
||||
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -800,6 +800,153 @@ Describe 'Remove-AgentBaseOnSkip' {
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Remove-MsiTransportFolder' {
|
||||
BeforeEach {
|
||||
Mock Start-Sleep { }
|
||||
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
$AgentBasePath = Join-Path $TestDrive "AutopilotMonitor-$([guid]::NewGuid().ToString('N').Substring(0, 8))"
|
||||
$MsiTransportPath = Join-Path $AgentBasePath 'Msi'
|
||||
}
|
||||
|
||||
It 'has nothing to do without an agent folder or without the copy' {
|
||||
Remove-MsiTransportFolder | Should -BeNullOrEmpty
|
||||
New-Item -ItemType Directory -Path (Join-Path $AgentBasePath 'Logs') -Force | Out-Null
|
||||
Remove-MsiTransportFolder | Should -BeNullOrEmpty
|
||||
Test-Path (Join-Path $AgentBasePath 'Logs') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'removes the copy and keeps an agent folder that holds anything else' {
|
||||
New-Item -ItemType File -Path (Join-Path $AgentBasePath 'Logs\agent.log') -Value 'kept' -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
|
||||
|
||||
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath removed."
|
||||
|
||||
Test-Path $MsiTransportPath | Should -BeFalse
|
||||
Get-Content (Join-Path $AgentBasePath 'Logs\agent.log') | Should -Be 'kept'
|
||||
}
|
||||
|
||||
It 'removes the copy and with it an agent folder that is then empty' {
|
||||
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
|
||||
|
||||
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath removed, and with it the empty $AgentBasePath."
|
||||
|
||||
Test-Path $AgentBasePath | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'leaves a copy that is not ours, and says so' {
|
||||
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
|
||||
Mock Test-IsOwnedByUs { $Path -notlike '*\Msi' }
|
||||
|
||||
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath left as it is (Foreign)."
|
||||
|
||||
Test-Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'touches nothing below an agent folder that is not ours' {
|
||||
New-Item -ItemType Directory -Path $MsiTransportPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') -Value 'x'
|
||||
Mock Test-IsOwnedByUs { $false }
|
||||
|
||||
Remove-MsiTransportFolder | Should -BeNullOrEmpty
|
||||
|
||||
Test-Path (Join-Path $MsiTransportPath 'Start-AutopilotMonitor.ps1') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'never follows a link planted as the copy' {
|
||||
New-Item -ItemType Directory -Path $AgentBasePath -Force | Out-Null
|
||||
$target = New-SentinelJunction -Link $MsiTransportPath
|
||||
|
||||
Remove-MsiTransportFolder | Should -Be "$MsiTransportPath left as it is (Link)."
|
||||
|
||||
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'never throws' {
|
||||
Mock Get-FolderState { throw 'unexpected' }
|
||||
{ Remove-MsiTransportFolder } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Remove-LegacyMsiLoaderFolder' {
|
||||
BeforeEach {
|
||||
Mock Start-Sleep { }
|
||||
Mock New-ProtectedDirectorySecurity { New-TestDirectorySecurity }
|
||||
Mock Test-IsOwnedByUs { $true }
|
||||
$script:realProgramW6432 = $env:ProgramW6432
|
||||
$script:realProgramFiles = $env:ProgramFiles
|
||||
$env:ProgramW6432 = New-TestFolder 'ProgramFiles64'
|
||||
$env:ProgramFiles = New-TestFolder 'ProgramFiles32'
|
||||
$script:company = Join-Path $env:ProgramW6432 'AutopilotMonitor'
|
||||
$script:loader = Join-Path $script:company 'Bootstrap\Start-AutopilotMonitor.ps1'
|
||||
}
|
||||
AfterEach {
|
||||
$env:ProgramW6432 = $script:realProgramW6432
|
||||
$env:ProgramFiles = $script:realProgramFiles
|
||||
}
|
||||
|
||||
It 'has nothing to do where MSI 1.3 never ran' {
|
||||
Remove-LegacyMsiLoaderFolder | Should -BeNullOrEmpty
|
||||
New-Item -ItemType Directory -Path $script:company -Force | Out-Null
|
||||
Remove-LegacyMsiLoaderFolder | Should -BeNullOrEmpty
|
||||
Test-Path $script:company | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'removes what MSI 1.3 installed, and the company folder with it' {
|
||||
New-Item -ItemType File -Path $script:loader -Force | Out-Null
|
||||
|
||||
Remove-LegacyMsiLoaderFolder | Should -Be "$(Split-Path $script:loader) removed, and with it the empty $script:company."
|
||||
|
||||
Test-Path $script:company | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'keeps the company folder when anything else is in it' {
|
||||
New-Item -ItemType File -Path $script:loader -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $script:company 'other.txt') -Value 'x'
|
||||
|
||||
Remove-LegacyMsiLoaderFolder | Should -Be "$(Split-Path $script:loader) removed."
|
||||
|
||||
Test-Path (Split-Path $script:loader) | Should -BeFalse
|
||||
Test-Path (Join-Path $script:company 'other.txt') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'reads the 64-bit Program Files, also from the 32-bit host' {
|
||||
$other = Join-Path $env:ProgramFiles 'AutopilotMonitor\Bootstrap\Start-AutopilotMonitor.ps1'
|
||||
New-Item -ItemType File -Path $script:loader -Force | Out-Null
|
||||
New-Item -ItemType File -Path $other -Force | Out-Null
|
||||
|
||||
Remove-LegacyMsiLoaderFolder | Should -BeLike '*removed*'
|
||||
|
||||
Test-Path $script:company | Should -BeFalse
|
||||
Test-Path $other | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'leaves a folder that is not ours' {
|
||||
New-Item -ItemType File -Path $script:loader -Force | Out-Null
|
||||
Mock Test-IsOwnedByUs { $false }
|
||||
|
||||
Remove-LegacyMsiLoaderFolder | Should -Be "$script:company left as it is (Foreign)."
|
||||
|
||||
Test-Path $script:loader | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'never follows a link' {
|
||||
$target = New-SentinelJunction -Link $script:company
|
||||
|
||||
Remove-LegacyMsiLoaderFolder | Should -Be "$script:company left as it is (Link)."
|
||||
|
||||
Test-Path (Join-Path $target 'sentinel.txt') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'never throws' {
|
||||
Mock Get-FolderState { throw 'unexpected' }
|
||||
{ Remove-LegacyMsiLoaderFolder } | Should -Not -Throw
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-AgentPayloadRejections' {
|
||||
# The payload catalog check, shared verbatim with the OOBE go-script. What matters is the
|
||||
# DECISION: which payload is accepted and which is refused. Test-FileCatalog is mocked for
|
||||
@@ -1122,7 +1269,7 @@ Describe 'script text' {
|
||||
|
||||
It 'declares the role and version the loader checks, one line each' {
|
||||
([regex]::Matches($script:text, '(?m)^\$ScriptRole = "AutopilotMonitor-Installer"\s*$')).Count | Should -Be 1
|
||||
([regex]::Matches($script:text, '(?m)^\$ScriptVersion = "2\.8"\s*$')).Count | Should -Be 1
|
||||
([regex]::Matches($script:text, '(?m)^\$ScriptVersion = "2\.9"\s*$')).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'never stages anything in the shared temp folder' {
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -53,7 +54,7 @@ param(
|
||||
)
|
||||
|
||||
# The loader checks these two lines before it runs this script.
|
||||
$ScriptVersion = "2.8"
|
||||
$ScriptVersion = "2.9"
|
||||
$ScriptRole = "AutopilotMonitor-Installer"
|
||||
|
||||
# Signer of the agent's file catalog; a subject pattern, so certificate renewals keep working.
|
||||
@@ -69,6 +70,9 @@ $LogFile = "$AgentLogPath\bootstrap-script.log"
|
||||
$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"
|
||||
@@ -608,6 +612,57 @@ function Remove-AgentBaseOnSkip {
|
||||
}
|
||||
}
|
||||
|
||||
# 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 }
|
||||
@@ -711,12 +766,17 @@ function Invoke-Bootstrap {
|
||||
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
|
||||
$agent = Get-AgentPresence
|
||||
if ($agent) {
|
||||
Write-Log "Agent present ($agent): $AgentBasePath is left as it is."
|
||||
$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
|
||||
}
|
||||
@@ -729,6 +789,8 @@ function Invoke-Bootstrap {
|
||||
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
|
||||
|
||||
@@ -467,11 +467,15 @@ Describe 'publish contract' {
|
||||
$wxs.Contains('-LogFileName bootstrap-msi.log') | Should -BeTrue
|
||||
}
|
||||
|
||||
# The file a SYSTEM custom action runs must not sit where a standard user could have
|
||||
# created the folder first: Program Files, never ProgramData.
|
||||
It 'is installed by the bootstrap MSI under Program Files' {
|
||||
# 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="ProgramFiles64Folder">'
|
||||
$wxs | Should -Not -Match 'CommonAppDataFolder'
|
||||
$wxs | Should -Match '<StandardDirectory Id="CommonAppDataFolder">'
|
||||
$wxs | Should -Not -Match 'ProgramFiles'
|
||||
$wxs.Contains('Get-AuthenticodeSignature -Content $b -SourcePathOrExtension ''.ps1''') | Should -BeTrue
|
||||
$wxs.Contains('Directory="System64Folder"') | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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;
|
||||
@@ -26,6 +28,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Bootstrap
|
||||
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"));
|
||||
|
||||
@@ -80,8 +84,54 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Bootstrap
|
||||
Assert.Matches(@"\[string\]\$LogFileName = """ + Regex.Escape(BootstrapProvenanceReader.LoaderLogName) + @"""", Loader);
|
||||
Assert.Contains("$LogFile = \"$AgentLogPath\\" + BootstrapProvenanceReader.OobeLogName + "\"", OobeScript);
|
||||
|
||||
var wxs = File.ReadAllText(Path.Combine(RepoPaths.Root(), "src", "Agent", "AutopilotMonitor.BootstrapMsi", "Package.wxs"));
|
||||
Assert.Contains("-LogFileName " + BootstrapProvenanceReader.MsiLoaderLogName, wxs);
|
||||
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]
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>The V1 confidence score of <see cref="ConfigMgrClientSnapshot"/>.</summary>
|
||||
public sealed class ConfigMgrClientScoreTests
|
||||
{
|
||||
[Fact]
|
||||
public void Nothing_found_scores_zero_and_is_not_detected()
|
||||
{
|
||||
Assert.Equal(0, ConfigMgrClientSnapshot.Empty.ConfidenceScore);
|
||||
Assert.False(ConfigMgrClientSnapshot.Empty.Detected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_registered_running_client_scores_100()
|
||||
{
|
||||
var snapshot = new ConfigMgrClientSnapshot(true, true, "Running", "5.00.9999.1000", "ABC");
|
||||
Assert.Equal(100, snapshot.ConfidenceScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_folder_alone_is_a_weak_remnant_below_the_portal_threshold()
|
||||
{
|
||||
var snapshot = new ConfigMgrClientSnapshot(false, true, ConfigMgrClientProbe.ServiceNotFound, null, null);
|
||||
Assert.True(snapshot.Detected);
|
||||
Assert.Equal(10, snapshot.ConfidenceScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_stopped_service_counts_without_the_running_bonus()
|
||||
{
|
||||
var snapshot = new ConfigMgrClientSnapshot(true, true, "Stopped", "5.00.9999.1000", "ABC");
|
||||
Assert.Equal(80, snapshot.ConfidenceScore);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emission surface of <see cref="ConfigMgrClientDetector"/> via the real
|
||||
/// <see cref="InformationalEventPost"/>. Static probe override → serial collection.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")]
|
||||
public sealed class ConfigMgrClientDetectorEmissionTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 10, 23, 8, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private (ConfigMgrClientDetector sut, FakeSignalIngressSink sink) BuildDetector(StartupEventGate? gate = null)
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
var sink = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(At);
|
||||
var sut = new ConfigMgrClientDetector("S1", "T1", new InformationalEventPost(sink, clock), logger, clock, gate);
|
||||
return (sut, sink);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> Detected(FakeSignalIngressSink sink) =>
|
||||
sink.Posted.Where(p =>
|
||||
p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == "configmgr_client_detected").ToList();
|
||||
|
||||
private static ConfigMgrClientSnapshot Client(string serviceState, string? siteCode = "ABC") =>
|
||||
new ConfigMgrClientSnapshot(true, true, serviceState, "5.00.9999.1000", siteCode);
|
||||
|
||||
[Fact]
|
||||
public void No_client_never_emits()
|
||||
{
|
||||
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => ConfigMgrClientSnapshot.Empty);
|
||||
var (sut, sink) = BuildDetector();
|
||||
|
||||
sut.Check();
|
||||
sut.Check();
|
||||
|
||||
Assert.Empty(Detected(sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void First_detection_emits_the_v1_payload()
|
||||
{
|
||||
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => Client("Running"));
|
||||
var (sut, sink) = BuildDetector();
|
||||
|
||||
sut.Check();
|
||||
|
||||
var evt = Assert.Single(Detected(sink));
|
||||
Assert.Equal(DecisionSignalKind.InformationalEvent, evt.Kind);
|
||||
Assert.Equal("Info", evt.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal(ConfigMgrClientDetector.SourceLabel, evt.Payload[SignalPayloadKeys.Source]);
|
||||
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
|
||||
Assert.Equal(true, data["ccmRegistryFound"]);
|
||||
Assert.Equal(true, data["ccmDirectoryFound"]);
|
||||
Assert.Equal("Running", data["ccmServiceState"]);
|
||||
Assert.Equal("5.00.9999.1000", data["ccmVersion"]);
|
||||
Assert.Equal("ABC", data["siteCode"]);
|
||||
Assert.Equal(100, data["confidenceScore"]);
|
||||
Assert.Equal("startup", data["detectedAt"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unchanged_state_emits_once_a_changed_state_again()
|
||||
{
|
||||
var current = Client("Stopped", siteCode: null);
|
||||
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => current);
|
||||
var (sut, sink) = BuildDetector();
|
||||
|
||||
sut.Check();
|
||||
sut.Check();
|
||||
Assert.Single(Detected(sink));
|
||||
|
||||
current = Client("Running");
|
||||
sut.Check();
|
||||
|
||||
var events = Detected(sink);
|
||||
Assert.Equal(2, events.Count);
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(events[0].TypedPayload);
|
||||
Assert.Equal("unknown", data["siteCode"]); // missing values keep the V1 placeholder
|
||||
var latest = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(events[1].TypedPayload);
|
||||
Assert.Equal("Running", latest["ccmServiceState"]);
|
||||
Assert.Equal("poll", latest["detectedAt"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_restart_into_the_same_state_stays_silent()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
var current = Client("Running");
|
||||
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => current);
|
||||
|
||||
var (run1, sink1) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
|
||||
run1.Check();
|
||||
Assert.Single(Detected(sink1));
|
||||
|
||||
var (run2, sink2) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
|
||||
run2.Check();
|
||||
Assert.Empty(Detected(sink2));
|
||||
|
||||
current = Client("Stopped");
|
||||
run2.Check();
|
||||
Assert.Single(Detected(sink2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Emissions_are_capped_per_run()
|
||||
{
|
||||
var running = true;
|
||||
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => Client(running ? "Running" : "Stopped"));
|
||||
var (sut, sink) = BuildDetector();
|
||||
|
||||
for (var i = 0; i < ConfigMgrClientDetector.MaxEmissionsPerRun * 2; i++)
|
||||
{
|
||||
sut.Check();
|
||||
running = !running;
|
||||
}
|
||||
|
||||
Assert.Equal(ConfigMgrClientDetector.MaxEmissionsPerRun, Detected(sink).Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
-2
@@ -185,6 +185,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
Assert.False(EspPolicyProviderSnapshot.Empty.SidecarRegistered);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- phase gate for DevicePreparation providers
|
||||
|
||||
[Theory]
|
||||
[InlineData("device_preparation", true)]
|
||||
[InlineData("device_preparation_failed", true)] // a failed device preparation keeps its culprit
|
||||
[InlineData("none", true)] // unknown phase → keep observing
|
||||
[InlineData("unavailable", true)]
|
||||
[InlineData("", true)]
|
||||
[InlineData("device_preparation_done", false)]
|
||||
[InlineData("device_setup", false)]
|
||||
[InlineData("device_setup_failed", false)]
|
||||
[InlineData("account_setup", false)]
|
||||
[InlineData("esp_done", false)]
|
||||
public void Device_preparation_counts_until_it_is_done(string phase, bool expected)
|
||||
=> Assert.Equal(expected, EspPhaseSnapshot.DevicePreparationOpen(phase));
|
||||
|
||||
[Fact]
|
||||
public void DevicePreparation_provider_past_its_phase_drops_out_while_setupApps_still_counts()
|
||||
{
|
||||
// Field shape (client installed as a Win32 app): the client registers its
|
||||
// DevicePreparation provider after its own install with InstallationState=1, long
|
||||
// after device preparation ended. Nothing ever completes it.
|
||||
var snapshot = Snapshot(
|
||||
DevicePrep("ConfigMgr", installState: 1),
|
||||
SetupApps("Contoso", tracking: 0));
|
||||
var seen = new Dictionary<string, DateTime>
|
||||
{
|
||||
["devicePreparation|device|ConfigMgr"] = T0,
|
||||
["setupApps|device|Contoso"] = T0,
|
||||
};
|
||||
|
||||
var eval = EspPolicyProviderStallDetector.EvaluateStallTransition(
|
||||
snapshot, seen, new HashSet<string>(), null, false, T0.AddMinutes(30), Dwell,
|
||||
devicePreparationOpen: false);
|
||||
|
||||
Assert.False(eval.UpdatedFirstSeenUtc.ContainsKey("devicePreparation|device|ConfigMgr")); // reset
|
||||
var stalled = Assert.Single(eval.NewlyStalled);
|
||||
Assert.Equal("Contoso", stalled.Provider.Name);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- arm 2: sidecar-missing transition
|
||||
|
||||
[Fact]
|
||||
@@ -265,17 +305,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
// The phase defaults to "none" so the tests never read the test machine's own ESP state.
|
||||
private (EspPolicyProviderStallDetector sut, FakeSignalIngressSink sink, VirtualClock clock)
|
||||
BuildDetector(StartupEventGate? gate = null)
|
||||
BuildDetector(StartupEventGate? gate = null, Func<string>? phase = null)
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
var sink = new FakeSignalIngressSink();
|
||||
var clock = new VirtualClock(At);
|
||||
var post = new InformationalEventPost(sink, clock);
|
||||
var sut = new EspPolicyProviderStallDetector("S1", "T1", post, logger, clock, gate);
|
||||
var sut = new EspPolicyProviderStallDetector("S1", "T1", post, logger, clock, gate,
|
||||
espPhaseReader: phase ?? (() => EspPhaseSnapshot.None));
|
||||
return (sut, sink, clock);
|
||||
}
|
||||
|
||||
private static readonly TimeSpan DwellSpan = TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes);
|
||||
|
||||
private static PolicyProviderState SidecarDone() =>
|
||||
new PolicyProviderState("Sidecar", "device", EspPolicyProviderProbe.KindSetupApps, 1, null);
|
||||
|
||||
private static PolicyProviderState ConfigMgrDevicePrep(int? installationState) =>
|
||||
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindDevicePreparation, null, installationState);
|
||||
|
||||
private static EspPolicyProviderSnapshot ConfigMgrOnly() => new EspPolicyProviderSnapshot(new[]
|
||||
{
|
||||
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps,
|
||||
@@ -435,6 +485,104 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Provider_registered_after_device_preparation_never_emits()
|
||||
{
|
||||
// The ConfigMgr client as a Win32 app: its DevicePreparation provider shows up during
|
||||
// device setup with InstallationState=1 and stays there.
|
||||
var snapshot = new EspPolicyProviderSnapshot(new[] { SidecarDone(), ConfigMgrDevicePrep(1) });
|
||||
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => snapshot);
|
||||
var (sut, sink, clock) = BuildDetector(phase: () => "device_setup");
|
||||
|
||||
sut.Tick();
|
||||
clock.Advance(TimeSpan.FromHours(2));
|
||||
sut.Tick();
|
||||
|
||||
Assert.Empty(StalledEvents(sink));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_real_device_preparation_hang_still_emits()
|
||||
{
|
||||
// Field shape: only ConfigMgr providers registered, the ESP never left device
|
||||
// preparation and failed with 0x800705b4.
|
||||
var snapshot = new EspPolicyProviderSnapshot(new[]
|
||||
{
|
||||
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps, null, null),
|
||||
ConfigMgrDevicePrep(1),
|
||||
});
|
||||
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => snapshot);
|
||||
var (sut, sink, clock) = BuildDetector(phase: () => EspPhaseSnapshot.DevicePreparation);
|
||||
|
||||
sut.Tick();
|
||||
clock.Advance(DwellSpan);
|
||||
sut.Tick();
|
||||
|
||||
var evt = Assert.Single(StalledEvents(sink));
|
||||
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
|
||||
var stalled = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object>>>(data["stalledProviders"]);
|
||||
Assert.Contains(stalled, s => (string)s["kind"] == EspPolicyProviderProbe.KindDevicePreparation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Device_preparation_stall_message_names_the_provider_timeout()
|
||||
{
|
||||
var snapshot = new EspPolicyProviderSnapshot(new[] { SidecarDone(), ConfigMgrDevicePrep(1) });
|
||||
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => snapshot);
|
||||
var (sut, sink, clock) = BuildDetector(phase: () => EspPhaseSnapshot.DevicePreparation);
|
||||
|
||||
sut.Tick();
|
||||
clock.Advance(DwellSpan);
|
||||
sut.Tick();
|
||||
|
||||
var message = Assert.Single(StalledEvents(sink)).Payload![SignalPayloadKeys.Message];
|
||||
Assert.Contains("until their timeout", message);
|
||||
Assert.DoesNotContain("without any timeout", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_skips_device_preparation_providers_past_their_phase()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
var phase = EspPhaseSnapshot.DevicePreparation;
|
||||
var devicePrepOnly = new EspPolicyProviderSnapshot(new[] { SidecarDone(), ConfigMgrDevicePrep(1) });
|
||||
var withContoso = new EspPolicyProviderSnapshot(new[]
|
||||
{
|
||||
SidecarDone(),
|
||||
ConfigMgrDevicePrep(1),
|
||||
new PolicyProviderState("Contoso", "device", EspPolicyProviderProbe.KindSetupApps, 0, null),
|
||||
});
|
||||
var current = devicePrepOnly;
|
||||
|
||||
// Run 1: the DevicePreparation provider stalls during device preparation; later, in
|
||||
// device setup, a Setup/Apps provider stalls as well.
|
||||
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => current))
|
||||
{
|
||||
var (sut, sink, clock) = BuildDetector(new StartupEventGate(_tmp.Path, logger), () => phase);
|
||||
sut.Tick();
|
||||
clock.Advance(DwellSpan);
|
||||
sut.Tick();
|
||||
|
||||
phase = "device_setup";
|
||||
current = withContoso;
|
||||
sut.Tick();
|
||||
clock.Advance(DwellSpan);
|
||||
sut.Tick();
|
||||
Assert.Equal(2, StalledEvents(sink).Count);
|
||||
}
|
||||
|
||||
// Run 2 (restart in device setup): the same Contoso stall lands on the same
|
||||
// fingerprint only if the stale DevicePreparation provider stayed out of it.
|
||||
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => withContoso))
|
||||
{
|
||||
var (sut, sink, clock) = BuildDetector(new StartupEventGate(_tmp.Path, logger), () => "device_setup");
|
||||
sut.Tick();
|
||||
clock.Advance(DwellSpan);
|
||||
sut.Tick();
|
||||
Assert.Empty(StalledEvents(sink));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Healthy_or_absent_tracking_never_emits()
|
||||
{
|
||||
|
||||
+22
-1
@@ -40,7 +40,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
/// enabled by default) and system-boundary fakes only: temp state dir, fake ingress,
|
||||
/// virtual clock, no network metrics, no telemetry spool.
|
||||
/// </summary>
|
||||
private CollectorSurfaces CreateSurfaces()
|
||||
private CollectorSurfaces CreateSurfaces(AnalyzerConfiguration? analyzers = null)
|
||||
{
|
||||
var factory = new DefaultComponentFactory(
|
||||
agentConfig: new AgentConfiguration
|
||||
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
remoteConfig: new AgentConfigResponse
|
||||
{
|
||||
Collectors = CollectorConfiguration.CreateDefault(),
|
||||
Analyzers = analyzers ?? new AnalyzerConfiguration(),
|
||||
},
|
||||
networkMetrics: null,
|
||||
agentVersion: "test",
|
||||
@@ -142,6 +143,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, 0)]
|
||||
[InlineData(true, 1)]
|
||||
public void ConfigMgr_client_host_exists_only_when_the_tenant_enables_the_watcher(bool enabled, int expected)
|
||||
{
|
||||
// Opt-in (D-382): with the watcher off the check must not run at all, not merely stay quiet.
|
||||
var hosts = CreateSurfaces(new AnalyzerConfiguration { EnableConfigMgrWatcher = enabled }).Hosts;
|
||||
try
|
||||
{
|
||||
Assert.Equal(expected, hosts.Count(h => h is ConfigMgrClientHost));
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var host in hosts)
|
||||
{
|
||||
try { host.Dispose(); } catch { /* construction-only test — best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ime_app_state_callback_is_chained_into_the_user_apps_settled_recheck()
|
||||
{
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// The V1 ConfigMgr client check, run on every poll: emits <c>configmgr_client_detected</c>
|
||||
/// with the V1 payload when the client is first seen, and again when what it reports changes
|
||||
/// (the service starts, a site code is assigned) — the client often installs after the agent
|
||||
/// started. A <see cref="StartupEventGate"/> fingerprint keeps a restart into the same state
|
||||
/// silent. Observational only: emission goes through <see cref="InformationalEventPost"/>.
|
||||
/// </summary>
|
||||
internal sealed class ConfigMgrClientDetector
|
||||
{
|
||||
internal const string SourceLabel = "ConfigMgrClientDetector";
|
||||
|
||||
/// <summary>Emissions per agent run; a flapping service must not flood the timeline.</summary>
|
||||
internal const int MaxEmissionsPerRun = 10;
|
||||
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly IClock _clock;
|
||||
private readonly StartupEventGate? _startupGate;
|
||||
|
||||
private string? _lastFingerprint;
|
||||
private int _emissions;
|
||||
private bool _checkedOnce;
|
||||
|
||||
public ConfigMgrClientDetector(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
IClock clock,
|
||||
StartupEventGate? startupGate = null)
|
||||
{
|
||||
_sessionId = sessionId;
|
||||
_tenantId = tenantId;
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger;
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_startupGate = startupGate;
|
||||
}
|
||||
|
||||
/// <summary>Host timer callback and test seam — one probe round.</summary>
|
||||
internal void Check()
|
||||
{
|
||||
var trigger = _checkedOnce ? "poll" : "startup";
|
||||
_checkedOnce = true;
|
||||
|
||||
var snapshot = ConfigMgrClientProbe.Read(_logger);
|
||||
if (!snapshot.Detected || snapshot.Fingerprint == _lastFingerprint || _emissions >= MaxEmissionsPerRun)
|
||||
return;
|
||||
|
||||
_lastFingerprint = snapshot.Fingerprint;
|
||||
if (_startupGate != null && !_startupGate.ShouldEmit(
|
||||
Constants.EventTypes.ConfigMgrClientDetected, StartupEventGate.HashString(snapshot.Fingerprint)))
|
||||
{
|
||||
_logger?.Debug("ConfigMgrClientDetector: state already reported by a previous agent run — suppressed.");
|
||||
return;
|
||||
}
|
||||
|
||||
_emissions++;
|
||||
var score = snapshot.ConfidenceScore;
|
||||
var versionSuffix = snapshot.Version != null ? $", v{snapshot.Version}" : "";
|
||||
var siteSuffix = snapshot.SiteCode != null ? $", site {snapshot.SiteCode}" : "";
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = _clock.UtcNow,
|
||||
EventType = Constants.EventTypes.ConfigMgrClientDetected,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = SourceLabel,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"ConfigMgr client detected (confidence {score}/100): CcmExec {snapshot.ServiceState}{versionSuffix}{siteSuffix}",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
["ccmRegistryFound"] = snapshot.RegistryFound,
|
||||
["ccmDirectoryFound"] = snapshot.DirectoryFound,
|
||||
["ccmServiceState"] = snapshot.ServiceState,
|
||||
["ccmVersion"] = snapshot.Version ?? "unknown",
|
||||
["siteCode"] = snapshot.SiteCode ?? "unknown",
|
||||
["confidenceScore"] = score,
|
||||
["detectedAt"] = trigger,
|
||||
}
|
||||
});
|
||||
_startupGate?.MarkEmitted(Constants.EventTypes.ConfigMgrClientDetected);
|
||||
|
||||
_logger?.Info(
|
||||
$"ConfigMgrClientDetector: client detected (trigger={trigger}, confidence={score}, " +
|
||||
$"service={snapshot.ServiceState}, version={snapshot.Version ?? "?"}, site={snapshot.SiteCode ?? "?"}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.ServiceProcess;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the Configuration Manager client's footprint the way the V1 agent did: the
|
||||
/// <c>CCM</c> key, the version from <c>CCMSetup</c>, the assigned site code, the
|
||||
/// <c>%windir%\CCM</c> folder and the <c>CcmExec</c> service. Fail-soft: any failure yields
|
||||
/// <see cref="ConfigMgrClientSnapshot.Empty"/>. The service is queried only when its key
|
||||
/// exists, so devices without the client never throw on every poll.
|
||||
/// </summary>
|
||||
internal static class ConfigMgrClientProbe
|
||||
{
|
||||
internal const string CcmKeyPath = @"SOFTWARE\Microsoft\CCM";
|
||||
internal const string CcmSetupKeyPath = @"SOFTWARE\Microsoft\CCMSetup";
|
||||
internal const string MobileClientKeyPath = @"SOFTWARE\Microsoft\SMS\Mobile Client";
|
||||
internal const string CcmExecServiceKeyPath = @"SYSTEM\CurrentControlSet\Services\CcmExec";
|
||||
internal const string CcmExecServiceName = "CcmExec";
|
||||
|
||||
/// <summary>Service state when <c>CcmExec</c> is not installed (the V1 payload value).</summary>
|
||||
internal const string ServiceNotFound = "not_found";
|
||||
|
||||
/// <summary>Test seam; use <see cref="ScopedOverride"/> to guarantee cleanup.</summary>
|
||||
internal static Func<ConfigMgrClientSnapshot>? TestOverride;
|
||||
|
||||
public static ConfigMgrClientSnapshot Read(AgentLogger? logger = null)
|
||||
{
|
||||
var probe = TestOverride;
|
||||
if (probe != null) return probe();
|
||||
|
||||
try
|
||||
{
|
||||
// The agent is AnyCPU; the client writes the 64-bit view.
|
||||
using var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
|
||||
|
||||
bool registryFound;
|
||||
using (var ccm = hklm.OpenSubKey(CcmKeyPath))
|
||||
registryFound = ccm != null;
|
||||
|
||||
string? version;
|
||||
using (var setup = hklm.OpenSubKey(CcmSetupKeyPath))
|
||||
version = NonEmpty(setup?.GetValue("LastValidVersion")?.ToString());
|
||||
|
||||
string? siteCode;
|
||||
using (var mobileClient = hklm.OpenSubKey(MobileClientKeyPath))
|
||||
siteCode = NonEmpty(mobileClient?.GetValue("AssignedSiteCode")?.ToString());
|
||||
|
||||
bool serviceInstalled;
|
||||
using (var service = hklm.OpenSubKey(CcmExecServiceKeyPath))
|
||||
serviceInstalled = service != null;
|
||||
|
||||
var directoryFound = Directory.Exists(
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "CCM"));
|
||||
|
||||
return new ConfigMgrClientSnapshot(
|
||||
registryFound, directoryFound, serviceInstalled ? ReadServiceState() : ServiceNotFound, version, siteCode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger?.Debug($"ConfigMgrClientProbe: read failed: {ex.Message}");
|
||||
return ConfigMgrClientSnapshot.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadServiceState()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var controller = new ServiceController(CcmExecServiceName))
|
||||
return controller.Status.ToString();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return ServiceNotFound; // removed between the key read and the query
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value!.Trim();
|
||||
|
||||
/// <summary>Sets <see cref="TestOverride"/> for the scope and restores the previous value on Dispose.</summary>
|
||||
internal sealed class ScopedOverride : IDisposable
|
||||
{
|
||||
private readonly Func<ConfigMgrClientSnapshot>? _previous;
|
||||
private int _disposed;
|
||||
|
||||
public ScopedOverride(Func<ConfigMgrClientSnapshot> probe)
|
||||
{
|
||||
_previous = TestOverride;
|
||||
TestOverride = probe;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
TestOverride = _previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One read of the client's footprint, with the V1 confidence score.</summary>
|
||||
internal sealed class ConfigMgrClientSnapshot
|
||||
{
|
||||
public static readonly ConfigMgrClientSnapshot Empty =
|
||||
new ConfigMgrClientSnapshot(false, false, ConfigMgrClientProbe.ServiceNotFound, null, null);
|
||||
|
||||
public ConfigMgrClientSnapshot(bool registryFound, bool directoryFound, string serviceState, string? version, string? siteCode)
|
||||
{
|
||||
RegistryFound = registryFound;
|
||||
DirectoryFound = directoryFound;
|
||||
ServiceState = serviceState;
|
||||
Version = version;
|
||||
SiteCode = siteCode;
|
||||
}
|
||||
|
||||
public bool RegistryFound { get; }
|
||||
public bool DirectoryFound { get; }
|
||||
|
||||
/// <summary><see cref="System.ServiceProcess.ServiceControllerStatus"/> name, or <see cref="ConfigMgrClientProbe.ServiceNotFound"/>.</summary>
|
||||
public string ServiceState { get; }
|
||||
|
||||
public string? Version { get; }
|
||||
public string? SiteCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The V1 weights: the folder alone is too weak (it can be a remnant); the portal shows the
|
||||
/// detection from 50 on.
|
||||
/// </summary>
|
||||
public int ConfidenceScore =>
|
||||
(DirectoryFound ? 10 : 0)
|
||||
+ (RegistryFound ? 20 : 0)
|
||||
+ (Version != null ? 15 : 0)
|
||||
+ (SiteCode != null ? 15 : 0)
|
||||
+ (ServiceState != ConfigMgrClientProbe.ServiceNotFound ? 20 : 0)
|
||||
+ (ServiceState == nameof(ServiceControllerStatus.Running) ? 20 : 0);
|
||||
|
||||
public bool Detected => ConfidenceScore > 0;
|
||||
|
||||
/// <summary>Everything the event reports; a change re-emits.</summary>
|
||||
public string Fingerprint => $"{RegistryFound}|{DirectoryFound}|{ServiceState}|{Version}|{SiteCode}";
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -20,13 +20,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
internal const string None = "none";
|
||||
internal const string Unavailable = "unavailable";
|
||||
internal const string DevicePreparation = "device_preparation";
|
||||
private const string FailedSuffix = "_failed";
|
||||
|
||||
internal enum CategoryState { Absent, Running, Done, Failed }
|
||||
|
||||
// ESP order: device preparation, device setup, account setup.
|
||||
private static readonly (string ValueName, string Phase)[] Categories =
|
||||
{
|
||||
("DevicePreparationCategory.Status", "device_preparation"),
|
||||
("DevicePreparationCategory.Status", DevicePreparation),
|
||||
("DeviceSetupCategory.Status", "device_setup"),
|
||||
("AccountSetupCategory.Status", "account_setup"),
|
||||
};
|
||||
@@ -60,7 +62,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var states = Categories.Select((_, i) => Classify(i < statusJson.Count ? statusJson[i] : null)).ToList();
|
||||
|
||||
for (var i = 0; i < Categories.Length; i++)
|
||||
if (states[i] == CategoryState.Failed) return Categories[i].Phase + "_failed";
|
||||
if (states[i] == CategoryState.Failed) return Categories[i].Phase + FailedSuffix;
|
||||
for (var i = 0; i < Categories.Length; i++)
|
||||
if (states[i] == CategoryState.Running) return Categories[i].Phase;
|
||||
|
||||
@@ -69,6 +71,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
return lastDone == Categories.Length - 1 ? "esp_done" : Categories[lastDone].Phase + "_done";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether device preparation can still hold the ESP in a <see cref="Read"/> result: it
|
||||
/// runs or failed, or the phase is unknown (<see cref="None"/>, <see cref="Unavailable"/>).
|
||||
/// False once device preparation is done, whichever later phase the ESP is in.
|
||||
/// </summary>
|
||||
internal static bool DevicePreparationOpen(string phase) =>
|
||||
string.IsNullOrEmpty(phase)
|
||||
|| phase == None
|
||||
|| phase == Unavailable
|
||||
|| phase == DevicePreparation
|
||||
|| phase == DevicePreparation + FailedSuffix;
|
||||
|
||||
/// <summary>
|
||||
/// One category value: done (<c>categorySucceeded=true</c>, or every subcategory succeeded
|
||||
/// or notRequired), failed (<c>false</c>, or a failed subcategory), running (anything
|
||||
|
||||
+34
-9
@@ -39,6 +39,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// dispatch-guard exempt), never into decision state.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Phase gate: <c>DevicePreparation</c> providers count only while
|
||||
/// <see cref="EspPhaseSnapshot.DevicePreparationOpen"/> holds. Once device preparation is done
|
||||
/// their values hold nothing: a ConfigMgr client installed as a Win32 app registers its
|
||||
/// provider after its own install with <c>InstallationState=1</c>, and nothing completes it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Duplicate suppression: in-process one-shot per provider key, plus a
|
||||
/// <see cref="StartupEventGate"/> fingerprint over the sorted set of currently-stalled keys so
|
||||
/// an agent restart into the same stall does not re-report it — while a NEW provider joining
|
||||
@@ -75,6 +81,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly IClock _clock;
|
||||
private readonly StartupEventGate? _startupGate;
|
||||
private readonly TimeSpan _dwell;
|
||||
private readonly Func<string> _espPhaseReader;
|
||||
|
||||
private readonly Dictionary<string, DateTime> _firstSeenIncompleteUtc =
|
||||
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -94,7 +101,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
AgentLogger logger,
|
||||
IClock clock,
|
||||
StartupEventGate? startupGate = null,
|
||||
TimeSpan? dwell = null)
|
||||
TimeSpan? dwell = null,
|
||||
Func<string>? espPhaseReader = null)
|
||||
{
|
||||
_sessionId = sessionId;
|
||||
_tenantId = tenantId;
|
||||
@@ -103,6 +111,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_startupGate = startupGate;
|
||||
_dwell = dwell ?? TimeSpan.FromMinutes(DwellMinutes);
|
||||
_espPhaseReader = espPhaseReader ?? EspPhaseSnapshot.Read;
|
||||
}
|
||||
|
||||
/// <summary>Host timer callback and test seam — one probe + evaluation round.</summary>
|
||||
@@ -111,7 +120,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var snapshot = EspPolicyProviderProbe.Read(_logger);
|
||||
var eval = EvaluateStallTransition(
|
||||
snapshot, _firstSeenIncompleteUtc, _firedKeys,
|
||||
_sidecarMissingSinceUtc, _sidecarMissingFired, _clock.UtcNow, _dwell);
|
||||
_sidecarMissingSinceUtc, _sidecarMissingFired, _clock.UtcNow, _dwell,
|
||||
EspPhaseSnapshot.DevicePreparationOpen(_espPhaseReader()));
|
||||
|
||||
_firstSeenIncompleteUtc.Clear();
|
||||
foreach (var kv in eval.UpdatedFirstSeenUtc)
|
||||
@@ -133,6 +143,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// <param name="sidecarMissingAlreadyFired">Arm-2 one-shot latch (never re-report).</param>
|
||||
/// <param name="nowUtc">Evaluation timestamp.</param>
|
||||
/// <param name="dwell">Continuous-condition threshold (shared by both arms).</param>
|
||||
/// <param name="devicePreparationOpen">Whether device preparation can still hold the ESP (<see cref="EspPhaseSnapshot.DevicePreparationOpen"/>); false drops <c>DevicePreparation</c> providers from arm 1.</param>
|
||||
internal static StallEvaluation EvaluateStallTransition(
|
||||
EspPolicyProviderSnapshot snapshot,
|
||||
IReadOnlyDictionary<string, DateTime> firstSeenIncompleteUtc,
|
||||
@@ -140,9 +151,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
DateTime? sidecarMissingSinceUtc,
|
||||
bool sidecarMissingAlreadyFired,
|
||||
DateTime nowUtc,
|
||||
TimeSpan dwell)
|
||||
TimeSpan dwell,
|
||||
bool devicePreparationOpen = true)
|
||||
{
|
||||
var result = new StallEvaluation();
|
||||
var result = new StallEvaluation { DevicePreparationOpen = devicePreparationOpen };
|
||||
|
||||
// Missing root key or no registered providers = normal early enrollment: clear all
|
||||
// dwell state (a condition must be continuously OBSERVED to accrue dwell).
|
||||
@@ -152,8 +164,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// Arm 1 — a registered provider continuously incomplete.
|
||||
foreach (var provider in snapshot.Providers)
|
||||
{
|
||||
if (provider.IsComplete)
|
||||
continue; // completed → falls out of the dwell map (reset)
|
||||
if (provider.IsComplete || !result.Counts(provider))
|
||||
continue; // completed or past its phase → falls out of the dwell map (reset)
|
||||
|
||||
var firstSeen = firstSeenIncompleteUtc.TryGetValue(provider.Key, out var seen)
|
||||
? seen
|
||||
@@ -197,7 +209,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
// the same stall re-accrues dwell and lands on the same fingerprint → suppressed; a
|
||||
// new condition joining the stall changes it → re-emitted.
|
||||
var currentlyStalledKeys = snapshot.Providers
|
||||
.Where(p => !p.IsComplete && _firedKeys.Contains(p.Key))
|
||||
.Where(p => !p.IsComplete && eval.Counts(p) && _firedKeys.Contains(p.Key))
|
||||
.Select(p => p.Key)
|
||||
.ToList();
|
||||
if (_sidecarMissingFired && HasSetupAppsProvider(snapshot) && !snapshot.SidecarRegistered)
|
||||
@@ -259,9 +271,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
if (eval.SidecarMissingStalledForMinutes != null)
|
||||
data["sidecarMissingForMinutes"] = Math.Round(eval.SidecarMissingStalledForMinutes.Value, 1);
|
||||
|
||||
// The Setup/Apps wait has no timeout; device preparation gives up after the provider's
|
||||
// Timeout node (CSP default 15 min).
|
||||
var setupAppsWait = eval.SidecarMissingStalledForMinutes != null
|
||||
|| eval.NewlyStalled.Any(s => s.Provider.Kind == EspPolicyProviderProbe.KindSetupApps);
|
||||
var message =
|
||||
"ESP Setup/Apps tracking stalled: " + string.Join("; ", messageParts) +
|
||||
" — the ESP waits on its app-tracking providers without any timeout";
|
||||
"ESP policy provider stalled: " + string.Join("; ", messageParts) +
|
||||
(setupAppsWait
|
||||
? " — the ESP waits on its Setup/Apps providers without any timeout"
|
||||
: " — device preparation waits on its providers until their timeout");
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
@@ -298,6 +316,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
/// <summary>Non-null when the sidecar-missing condition crossed the dwell threshold this round and was not yet reported (arm 2).</summary>
|
||||
public double? SidecarMissingStalledForMinutes { get; set; }
|
||||
|
||||
/// <summary>The phase gate of this round; the cross-restart fingerprint applies the same filter.</summary>
|
||||
public bool DevicePreparationOpen { get; set; }
|
||||
|
||||
/// <summary>Whether arm 1 considers <paramref name="provider"/> in this round.</summary>
|
||||
public bool Counts(PolicyProviderState provider)
|
||||
=> DevicePreparationOpen || provider.Kind != EspPolicyProviderProbe.KindDevicePreparation;
|
||||
}
|
||||
|
||||
internal readonly struct StalledProvider
|
||||
|
||||
@@ -517,6 +517,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
startupGate: _startupEventGate));
|
||||
}
|
||||
|
||||
// ConfigMgr client presence (the V1 check) — opt-in per tenant (portal toggle →
|
||||
// AnalyzerConfiguration.EnableConfigMgrWatcher, default off). When off, the host is
|
||||
// never created, so devices of other tenants run no check at all.
|
||||
if (analyzers.EnableConfigMgrWatcher)
|
||||
{
|
||||
hosts.Add(new ConfigMgrClientHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
logger: logger,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
startupGate: _startupEventGate));
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.Info("DefaultComponentFactory: ConfigMgrClientHost not created (EnableConfigMgrWatcher=false)");
|
||||
}
|
||||
|
||||
// Registry second pillar (audit 2026-08-17): IME's authoritative per-app state from
|
||||
// HKLM\...\IntuneManagementExtension (Win32Apps EnforcementStateMessage,
|
||||
// EspTrackingWin32Apps, StatusServiceReports) via snapshot-and-diff. Emits
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Persistence;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Drives the <see cref="ConfigMgrClientDetector"/>: a first check shortly after start (the
|
||||
/// client may already be installed) and one per minute after that (it may arrive later, e.g.
|
||||
/// as a Win32 app). Created only when the tenant enables the ConfigMgr watcher
|
||||
/// (<c>AnalyzerConfiguration.EnableConfigMgrWatcher</c>).
|
||||
/// </summary>
|
||||
internal sealed class ConfigMgrClientHost : ICollectorHost
|
||||
{
|
||||
public string Name => "ConfigMgrClientDetector";
|
||||
|
||||
private static readonly TimeSpan FirstCheckDelay = TimeSpan.FromSeconds(10);
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly ConfigMgrClientDetector _detector;
|
||||
private readonly AgentLogger _logger;
|
||||
private Timer? _tickTimer;
|
||||
private int _checking;
|
||||
private int _disposed;
|
||||
|
||||
public ConfigMgrClientHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger logger,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
StartupEventGate? startupGate)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger;
|
||||
_detector = new ConfigMgrClientDetector(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: new InformationalEventPost(ingress, clock),
|
||||
logger: logger,
|
||||
clock: clock,
|
||||
startupGate: startupGate);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_tickTimer = new Timer(_ => SafeCheck(), state: null, dueTime: FirstCheckDelay, period: PollInterval);
|
||||
_logger.Info($"ConfigMgrClientHost: started (check every {PollInterval.TotalSeconds}s).");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
try
|
||||
{
|
||||
_tickTimer?.Dispose();
|
||||
_tickTimer = null;
|
||||
}
|
||||
catch (Exception ex) { _logger.Warning($"ConfigMgrClientHost: timer dispose failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
Stop();
|
||||
}
|
||||
|
||||
private void SafeCheck()
|
||||
{
|
||||
// A slow service query must not stack a second check on top of the first.
|
||||
if (Interlocked.Exchange(ref _checking, 1) == 1) return;
|
||||
try
|
||||
{
|
||||
_detector.Check();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("ConfigMgrClientHost: check failed.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _checking, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,27 @@
|
||||
logic stays updatable without re-shipping the MSI, and both delivery channels share one
|
||||
implementation.
|
||||
|
||||
The loader is installed under Program Files, where only administrators and SYSTEM can
|
||||
create or change anything: the file that a SYSTEM custom action runs must not live in a
|
||||
folder a standard user could create first (ProgramData lets every user create folders).
|
||||
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 -o bin\AutopilotMonitor-Bootstrap.msi
|
||||
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.3.0"
|
||||
Version="1.4.0"
|
||||
UpgradeCode="1fbe5515-d346-445c-b8b8-14ee79f97522"
|
||||
Scope="perMachine"
|
||||
Compressed="yes">
|
||||
@@ -28,16 +39,16 @@
|
||||
<MajorUpgrade DowngradeErrorMessage="A newer version of Autopilot Monitor Bootstrap is already installed." />
|
||||
<MediaTemplate EmbedCab="yes" />
|
||||
|
||||
<StandardDirectory Id="ProgramFiles64Folder">
|
||||
<StandardDirectory Id="CommonAppDataFolder">
|
||||
<Directory Id="CompanyFolder" Name="AutopilotMonitor">
|
||||
<Directory Id="INSTALLFOLDER" Name="Bootstrap" />
|
||||
<Directory Id="INSTALLFOLDER" Name="Msi" />
|
||||
</Directory>
|
||||
</StandardDirectory>
|
||||
|
||||
<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 (1.3.0: Program Files). -->
|
||||
<Component Id="RunnerScript" Guid="3C6F0E2D-8B41-4A77-9D5E-2F1B6C8A4D90">
|
||||
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"
|
||||
@@ -49,13 +60,14 @@
|
||||
<ComponentGroupRef Id="MainComponents" />
|
||||
</Feature>
|
||||
|
||||
<!-- Deferred + no-impersonate: runs as SYSTEM after files land. Return=ignore keeps
|
||||
the LOB install green even if the loader hits a transient error (the loader is
|
||||
additionally fail-soft itself). -LogFileName keeps this channel distinguishable in
|
||||
a diagnostics package; the diag tooling keys on that name. -->
|
||||
<!-- Deferred + no-impersonate: runs as SYSTEM after the file lands. Return=ignore keeps
|
||||
the LOB install green whatever happens (the loader is fail-soft itself). Runs on
|
||||
every install of the product, upgrades included, so the installer gets to remove the
|
||||
copy. -LogFileName keeps this channel distinguishable in a diagnostics package; the
|
||||
diag tooling keys on that name. -->
|
||||
<CustomAction Id="RunBootstrap"
|
||||
Directory="INSTALLFOLDER"
|
||||
ExeCommand=""[System64Folder]WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "[INSTALLFOLDER]Start-AutopilotMonitor.ps1" -LogFileName bootstrap-msi.log"
|
||||
Directory="System64Folder"
|
||||
ExeCommand=""[System64Folder]WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$b = [\[]System.IO.File[\]]::ReadAllBytes('[INSTALLFOLDER]Start-AutopilotMonitor.ps1'); $h = [\[]System.BitConverter[\]]::ToString([\[]System.Security.Cryptography.SHA256[\]]::Create().ComputeHash($b)).Replace('-', '').ToLowerInvariant(); $s = Get-AuthenticodeSignature -Content $b -SourcePathOrExtension '.ps1'; if ($h -eq '$(var.LoaderSha256)' -and $s.Status -eq 'Valid' -and $s.SignerCertificate.Subject -like '*O=glueckkanja AG*') [\{] & ([\[]scriptblock[\]]::Create([\[]System.Text.Encoding[\]]::UTF8.GetString($b))) -LogFileName bootstrap-msi.log [\}]""
|
||||
Execute="deferred"
|
||||
Impersonate="no"
|
||||
Return="ignore" />
|
||||
|
||||
@@ -72,10 +72,19 @@ public class AgentConfigResolverTests
|
||||
Assert.False(r.Analyzers.EnableSoftwareInventoryAnalyzer);
|
||||
Assert.True(r.Analyzers.EnableIntegrityBypassAnalyzer);
|
||||
Assert.False(r.Analyzers.EnableRealmJoinWatcher);
|
||||
Assert.False(r.Analyzers.EnableConfigMgrWatcher);
|
||||
Assert.False(r.Analyzers.KeepAwakeDuringUserEsp);
|
||||
Assert.True(r.Analyzers.EnableConsoleBypassDetection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tenant_switches_the_ConfigMgr_watcher_on()
|
||||
{
|
||||
var r = Build(Tenant(t => t.EnableConfigMgrWatcher = true));
|
||||
|
||||
Assert.True(r.Analyzers.EnableConfigMgrWatcher);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "CustomerSas", "Off", false)]
|
||||
[InlineData(null, "Hosted", "Off", true)] // hosted destination needs no SAS
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Functions.Infrastructure;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Tests.Offboarding;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -60,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 };
|
||||
@@ -124,7 +126,7 @@ 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,
|
||||
@@ -155,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()
|
||||
{
|
||||
@@ -163,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]
|
||||
@@ -780,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]
|
||||
@@ -798,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);
|
||||
}
|
||||
|
||||
@@ -818,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);
|
||||
@@ -849,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);
|
||||
@@ -859,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
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -929,19 +950,46 @@ 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
|
||||
@@ -987,6 +1035,8 @@ public class AuthFunctionSideEffectTests
|
||||
// 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);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -42,4 +42,29 @@ public class PreviewWhitelistApprovalCacheTests
|
||||
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,
|
||||
};
|
||||
|
||||
+65
-11
@@ -71,17 +71,6 @@ public class TenantAdminsServiceMembershipTests
|
||||
_repo.Verify(r => r.GetTenantMemberAsync(TenantId, Upn), Times.Once);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Constants.TenantRoles.Admin, true, true)]
|
||||
[InlineData(Constants.TenantRoles.Operator, true, false)]
|
||||
[InlineData(Constants.TenantRoles.Admin, false, false)]
|
||||
public async Task IsTenantAdmin_MeansAnEnabledAdminRow(string role, bool enabled, bool expected)
|
||||
{
|
||||
PointReadsReturn(Row(Upn, role, enabled));
|
||||
|
||||
Assert.Equal(expected, await _sut.IsTenantAdminAsync(TenantId, Upn));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Snapshot_TheCallersOwnRowDecides_WhateverTheTokenCasing()
|
||||
{
|
||||
@@ -148,4 +137,69 @@ public class TenantAdminsServiceMembershipTests
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,8 @@ public class TenantApprovalServiceTests
|
||||
configRepo, NullLogger<AdminConfigurationService>.Instance, cache);
|
||||
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
|
||||
|
||||
// IsTenantAdminAsync runs for real and reads through the virtual GetTableMembershipAsync,
|
||||
// which ArrangeNewlyApproved sets up per test.
|
||||
// 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);
|
||||
|
||||
@@ -84,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);
|
||||
}
|
||||
|
||||
@@ -123,14 +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);
|
||||
_tenantAdminsMock.Setup(x => x.GetTableMembershipAsync(TenantId, It.IsAny<string?>())).ReturnsAsync(alreadyAdmin
|
||||
? (TableMemberState.Enabled, new MemberRoleInfo { Role = Constants.TenantRoles.Admin })
|
||||
: (TableMemberState.NotPresent, null));
|
||||
_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()
|
||||
@@ -141,17 +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_OnboardingMemberAlreadyAdmin_IsNotWrittenAgain()
|
||||
public async Task NewlyApproved_OnboardingMemberAlreadySeated_IsLeftUntouchedAndActivationProceeds()
|
||||
{
|
||||
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example", alreadyAdmin: true);
|
||||
|
||||
await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
|
||||
Assert.True(await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy));
|
||||
|
||||
VerifyNoPromotion();
|
||||
// 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]
|
||||
@@ -175,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
|
||||
|
||||
@@ -11,12 +11,17 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// matching (the field incident that motivated the endpoint: a pattern verified with a PHP
|
||||
/// regex tester behaved differently under .NET), first-match-per-line, group extraction
|
||||
/// identical to what LogParserCollector puts into the emitted event data, CMTrace parsing
|
||||
/// via the SHARED CmTraceLogParser, and the format=text hint.
|
||||
/// via the SHARED CmTraceLogParser, and the format=text hint — plus the bounds that keep the
|
||||
/// caller's regex from occupying the shared Function App (D-379): first timeout ends the
|
||||
/// test, the time budget, cancellation, the pattern-length cap and the per-instance gate.
|
||||
/// </summary>
|
||||
public class TestLogPatternFunctionTests
|
||||
{
|
||||
private static Regex AgentRegex(string pattern)
|
||||
=> new(pattern, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
private static Regex AgentRegex(string pattern) => TestLogPatternFunction.BuildAgentRegex(pattern);
|
||||
|
||||
/// <summary>The production call minus the bounds: full budget, no cancellation.</summary>
|
||||
private static LogPatternTestResult Evaluate(Regex pattern, bool isTextMode, IReadOnlyList<string> lines)
|
||||
=> TestLogPatternFunction.EvaluatePattern(pattern, isTextMode, lines, TestLogPatternFunction.EvaluationBudget, CancellationToken.None);
|
||||
|
||||
private const string CmTraceLine =
|
||||
"<![LOG[Installation failed for app Ivanti Secure Access Client with error 0x87D1041C]LOG]!>" +
|
||||
@@ -27,7 +32,7 @@ public class TestLogPatternFunctionTests
|
||||
[Fact]
|
||||
public void TextMode_NamedGroups_LandInGroups()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
var result = Evaluate(
|
||||
AgentRegex(@"(?<level>ERROR|FATAL)\s+(?<component>\w+):\s+(?<message>.+)"),
|
||||
isTextMode: true,
|
||||
new[] { "ERROR Installer: disk full", "INFO Installer: all good" });
|
||||
@@ -38,6 +43,7 @@ public class TestLogPatternFunctionTests
|
||||
Assert.Equal("Installer", result.Lines[0].Groups!["component"]);
|
||||
Assert.Equal("disk full", result.Lines[0].Groups!["message"]);
|
||||
Assert.Equal("no_match", result.Lines[1].Outcome);
|
||||
Assert.Null(result.StopReason);
|
||||
}
|
||||
|
||||
/// <summary>The motivating field case: logparser matching has NO IgnoreCase — a pattern
|
||||
@@ -45,8 +51,7 @@ public class TestLogPatternFunctionTests
|
||||
[Fact]
|
||||
public void TextMode_IsCaseSensitive_AndSaysSoInTheNote()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("ERROR"), isTextMode: true, new[] { "error: something broke" });
|
||||
var result = Evaluate(AgentRegex("ERROR"), isTextMode: true, new[] { "error: something broke" });
|
||||
|
||||
Assert.Equal(0, result.MatchCount);
|
||||
Assert.Contains(result.Notes, n => n.Contains("case-SENSITIVE"));
|
||||
@@ -55,8 +60,7 @@ public class TestLogPatternFunctionTests
|
||||
[Fact]
|
||||
public void TextMode_UnsuccessfulOptionalGroup_IsOmitted()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex(@"ERROR(?<code>\s0x[0-9A-F]+)?"), isTextMode: true, new[] { "ERROR without code" });
|
||||
var result = Evaluate(AgentRegex(@"ERROR(?<code>\s0x[0-9A-F]+)?"), isTextMode: true, new[] { "ERROR without code" });
|
||||
|
||||
Assert.Equal(1, result.MatchCount);
|
||||
Assert.False(result.Lines[0].Groups!.ContainsKey("code"));
|
||||
@@ -67,8 +71,7 @@ public class TestLogPatternFunctionTests
|
||||
[Fact]
|
||||
public void CmTraceMode_MatchesAgainstParsedMessage_AndReportsComponentAndType()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex(@"error (?<errorCode>0x[0-9A-Fa-f]+)"), isTextMode: false, new[] { CmTraceLine });
|
||||
var result = Evaluate(AgentRegex(@"error (?<errorCode>0x[0-9A-Fa-f]+)"), isTextMode: false, new[] { CmTraceLine });
|
||||
|
||||
Assert.Equal(1, result.MatchCount);
|
||||
var row = result.Lines[0];
|
||||
@@ -84,8 +87,7 @@ public class TestLogPatternFunctionTests
|
||||
{
|
||||
// "component=" only exists in the CMTrace envelope, never in the message the
|
||||
// agent matches against — a pattern targeting it must NOT match.
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("component="), isTextMode: false, new[] { CmTraceLine });
|
||||
var result = Evaluate(AgentRegex("component="), isTextMode: false, new[] { CmTraceLine });
|
||||
|
||||
Assert.Equal(0, result.MatchCount);
|
||||
Assert.Equal("no_match", result.Lines[0].Outcome);
|
||||
@@ -94,8 +96,7 @@ public class TestLogPatternFunctionTests
|
||||
[Fact]
|
||||
public void CmTraceMode_PlainTextLines_ParseFail_WithFormatHint()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("ERROR"), isTextMode: false, new[] { "ERROR plain line", "another plain line" });
|
||||
var result = Evaluate(AgentRegex("ERROR"), isTextMode: false, new[] { "ERROR plain line", "another plain line" });
|
||||
|
||||
Assert.Equal(2, result.ParseFailureCount);
|
||||
Assert.All(result.Lines, l => Assert.Equal("parse_failed", l.Outcome));
|
||||
@@ -146,6 +147,45 @@ public class TestLogPatternFunctionTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The cap is the MCP schema's value; it is checked in validation, i.e. before
|
||||
/// the handler ever constructs a Regex from the caller's text.</summary>
|
||||
[Fact]
|
||||
public void Validate_RejectsOversizedPattern_AndAcceptsTheCapExactly()
|
||||
{
|
||||
var oversized = new TestLogPatternRequest
|
||||
{
|
||||
Pattern = new string('a', TestLogPatternFunction.MaxPatternLength + 1),
|
||||
SampleLines = new List<string> { "x" },
|
||||
};
|
||||
var error = TestLogPatternFunction.ValidateRequest(oversized);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("pattern", error);
|
||||
Assert.Contains(TestLogPatternFunction.MaxPatternLength.ToString(), error);
|
||||
|
||||
Assert.Null(TestLogPatternFunction.ValidateRequest(new TestLogPatternRequest
|
||||
{
|
||||
Pattern = new string('a', TestLogPatternFunction.MaxPatternLength),
|
||||
SampleLines = new List<string> { "x" },
|
||||
}));
|
||||
}
|
||||
|
||||
// ===== the regex construction =====
|
||||
|
||||
/// <summary>Agent parity minus Compiled: the semantic options and the 1 s match timeout
|
||||
/// are the agent's; Compiled (speed only) is deliberately absent for the one-shot pattern.</summary>
|
||||
[Fact]
|
||||
public void BuildAgentRegex_IsCaseSensitive_Interpreted_WithTheAgentsMatchTimeout()
|
||||
{
|
||||
var regex = TestLogPatternFunction.BuildAgentRegex("ERROR");
|
||||
|
||||
Assert.Equal(RegexOptions.None, regex.Options);
|
||||
Assert.Equal(TimeSpan.FromSeconds(1), regex.MatchTimeout);
|
||||
Assert.Equal(TestLogPatternFunction.MatchTimeout, regex.MatchTimeout);
|
||||
Assert.DoesNotMatch(regex, "error");
|
||||
// RegexParseException derives from ArgumentException — the handler's catch clause.
|
||||
Assert.ThrowsAny<ArgumentException>(() => TestLogPatternFunction.BuildAgentRegex("(unclosed"));
|
||||
}
|
||||
|
||||
// ===== hostile sample lines: the built-in CMTrace parser must stay bounded =====
|
||||
|
||||
/// <summary>A maximal request (200 x 8192-char lines) built from the reported worst case:
|
||||
@@ -160,11 +200,117 @@ public class TestLogPatternFunctionTests
|
||||
var lines = Enumerable.Repeat(line, TestLogPatternFunction.MaxSampleLines).ToArray();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = TestLogPatternFunction.EvaluatePattern(AgentRegex("error"), isTextMode: false, lines);
|
||||
var result = Evaluate(AgentRegex("error"), isTextMode: false, lines);
|
||||
sw.Stop();
|
||||
|
||||
Assert.Equal(TestLogPatternFunction.MaxSampleLines, result.ParseFailureCount);
|
||||
Assert.All(result.Lines, l => Assert.Equal("parse_failed", l.Outcome));
|
||||
Assert.Null(result.StopReason);
|
||||
Assert.True(sw.ElapsedMilliseconds < 1000, $"maximal worst-case request took {sw.ElapsedMilliseconds} ms");
|
||||
}
|
||||
|
||||
// ===== hostile pattern: the caller's regex must stay bounded (D-379) =====
|
||||
|
||||
/// <summary>The reported attack: a catastrophic pattern over 200 non-matching lines used to
|
||||
/// cost one 1 s timeout per line (~200 s of a worker thread). The first timeout now ends the
|
||||
/// test — one row, the reason, and the unevaluated range named in a note.</summary>
|
||||
[Fact]
|
||||
public void TextMode_CatastrophicPattern_StopsAtTheFirstTimeout()
|
||||
{
|
||||
var hostile = new string('a', 60) + "!";
|
||||
var lines = Enumerable.Repeat(hostile, TestLogPatternFunction.MaxSampleLines).ToArray();
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var result = Evaluate(AgentRegex(@"^(\w+\s?)+$"), isTextMode: true, lines);
|
||||
sw.Stop();
|
||||
|
||||
Assert.Equal(1, result.TimeoutCount);
|
||||
Assert.Single(result.Lines);
|
||||
Assert.Equal("regex_timeout", result.Lines[0].Outcome);
|
||||
Assert.Equal(TestLogPatternFunction.StopReasonRegexTimeout, result.StopReason);
|
||||
Assert.Contains(result.Notes, n => n.Contains("line 1 hit") && n.Contains("lines 2-200 were not evaluated"));
|
||||
Assert.DoesNotContain(result.Notes, n => n.Contains("case-SENSITIVE"));
|
||||
Assert.True(sw.ElapsedMilliseconds < 4000, $"catastrophic request took {sw.ElapsedMilliseconds} ms");
|
||||
}
|
||||
|
||||
/// <summary>A timeout on the LAST line is a complete test: its row and note stay, but
|
||||
/// nothing was skipped, so there is no stop reason.</summary>
|
||||
[Fact]
|
||||
public void TextMode_TimeoutOnTheLastLine_IsNotAStop()
|
||||
{
|
||||
var result = Evaluate(AgentRegex(@"^(\w+\s?)+$"), isTextMode: true, new[] { "a b", new string('a', 60) + "!" });
|
||||
|
||||
Assert.Equal(1, result.MatchCount);
|
||||
Assert.Equal(1, result.TimeoutCount);
|
||||
Assert.Equal(2, result.Lines.Count);
|
||||
Assert.Equal("regex_timeout", result.Lines[1].Outcome);
|
||||
Assert.Null(result.StopReason);
|
||||
Assert.Contains(result.Notes, n => n.Contains("line 2 hit") && !n.Contains("not evaluated"));
|
||||
}
|
||||
|
||||
/// <summary>The budget is checked before every line but the first, so a spent budget
|
||||
/// (here: zero) still yields exactly one evaluated line and names the rest.</summary>
|
||||
[Fact]
|
||||
public void BudgetSpent_StopsBeforeTheNextLine_AndNamesTheRest()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("ERROR"), isTextMode: true, new[] { "ERROR one", "ERROR two", "ERROR three" },
|
||||
TimeSpan.Zero, CancellationToken.None);
|
||||
|
||||
Assert.Single(result.Lines);
|
||||
Assert.Equal("matched", result.Lines[0].Outcome);
|
||||
Assert.Equal(TestLogPatternFunction.StopReasonTimeBudget, result.StopReason);
|
||||
Assert.Contains(result.Notes, n => n.Contains("after line 1") && n.Contains("lines 2-3 were not evaluated"));
|
||||
|
||||
var twoLines = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("ERROR"), isTextMode: true, new[] { "ERROR one", "ERROR two" },
|
||||
TimeSpan.Zero, CancellationToken.None);
|
||||
Assert.Contains(twoLines.Notes, n => n.Contains("line 2 was not evaluated"));
|
||||
}
|
||||
|
||||
/// <summary>A cancelled invocation (client gone, host stopping) ends the loop before the
|
||||
/// next line without throwing — a thrown OperationCanceledException would surface as a
|
||||
/// failed invocation in App Insights.</summary>
|
||||
[Fact]
|
||||
public void CancelledToken_StopsBeforeTheFirstLine_WithoutThrowing()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("ERROR"), isTextMode: true, new[] { "ERROR one" },
|
||||
TestLogPatternFunction.EvaluationBudget, cts.Token);
|
||||
|
||||
Assert.Empty(result.Lines);
|
||||
Assert.Equal(TestLogPatternFunction.StopReasonCancelled, result.StopReason);
|
||||
Assert.Equal(0, result.MatchCount);
|
||||
}
|
||||
|
||||
// ===== the per-instance gate =====
|
||||
|
||||
[Fact]
|
||||
public void Gate_HandsOutLimitSlots_AndTakesThemBackOnDispose()
|
||||
{
|
||||
var gate = new TestLogPatternGate(1);
|
||||
Assert.Equal(1, gate.Limit);
|
||||
|
||||
var first = gate.TryEnter();
|
||||
Assert.NotNull(first);
|
||||
Assert.Null(gate.TryEnter());
|
||||
|
||||
first.Dispose();
|
||||
first.Dispose(); // idempotent: a double release must not widen the gate
|
||||
var second = gate.TryEnter();
|
||||
Assert.NotNull(second);
|
||||
Assert.Null(gate.TryEnter());
|
||||
second.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_DefaultLimit_IsTwoPerInstance()
|
||||
{
|
||||
var gate = new TestLogPatternGate();
|
||||
Assert.Equal(2, gate.Limit);
|
||||
Assert.Equal(TestLogPatternGate.DefaultLimit, gate.Limit);
|
||||
}
|
||||
}
|
||||
|
||||
+28
-38
@@ -128,28 +128,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DisableGlobalAdminAsync(string upn)
|
||||
{
|
||||
upn = upn.ToLowerInvariant();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _globalAdminsTableClient.GetEntityAsync<GlobalAdminEntity>(
|
||||
"GlobalAdmins", upn);
|
||||
var entity = result.Value;
|
||||
if (entity != null)
|
||||
{
|
||||
entity.IsEnabled = false;
|
||||
await _globalAdminsTableClient.UpdateEntityAsync(entity, ETag.All);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- MCP Users ---
|
||||
|
||||
public async Task<McpUserEntry?> GetMcpUserAsync(string upn)
|
||||
@@ -825,12 +803,33 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
|
||||
public async Task<bool> AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false)
|
||||
{
|
||||
await _tenantAdminsTableClient.UpsertEntityAsync(NewMemberEntity(tenantId, upn, addedBy, role, canManageBootstrapTokens));
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> TryAddTenantMemberAsync(string tenantId, string upn, string addedBy, string role)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Conditional INSERT, never upsert: a row the tenant already holds for this UPN
|
||||
// (disabled, demoted) stays as the tenant set it, and two first-admin writers for
|
||||
// the same UPN cannot both land.
|
||||
await _tenantAdminsTableClient.AddEntityAsync(NewMemberEntity(tenantId, upn, addedBy, role, canManageBootstrapTokens: false));
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 409)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static TenantAdminEntity NewMemberEntity(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens)
|
||||
{
|
||||
tenantId = tenantId.ToLowerInvariant();
|
||||
upn = upn.ToLowerInvariant();
|
||||
addedBy = addedBy.ToLowerInvariant();
|
||||
|
||||
var entity = new TenantAdminEntity
|
||||
return new TenantAdminEntity
|
||||
{
|
||||
PartitionKey = tenantId,
|
||||
RowKey = upn,
|
||||
@@ -838,21 +837,20 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
Upn = upn,
|
||||
IsEnabled = true,
|
||||
AddedDate = DateTime.UtcNow,
|
||||
AddedBy = addedBy,
|
||||
AddedBy = addedBy.ToLowerInvariant(),
|
||||
Role = role,
|
||||
CanManageBootstrapTokens = canManageBootstrapTokens
|
||||
};
|
||||
|
||||
await _tenantAdminsTableClient.UpsertEntityAsync(entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveTenantMemberAsync(string tenantId, string upn)
|
||||
{
|
||||
tenantId = tenantId.ToLowerInvariant();
|
||||
upn = upn.ToLowerInvariant();
|
||||
await _tenantAdminsTableClient.DeleteEntityAsync(tenantId, upn);
|
||||
return true;
|
||||
// A missing row does NOT throw, DeleteEntityAsync returns the 404 response (see
|
||||
// RemoveDelegatedAdminAsync): report it, so removing a non-member 404s instead of auditing.
|
||||
var response = await _tenantAdminsTableClient.DeleteEntityAsync(tenantId, upn);
|
||||
return response.Status != 404;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateMemberPermissionsAsync(string tenantId, string upn, string role, bool canManageBootstrapTokens)
|
||||
@@ -921,14 +919,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> IsTenantAdminAsync(string tenantId, string upn)
|
||||
{
|
||||
var member = await GetTenantMemberAsync(tenantId, upn);
|
||||
if (member == null || !member.IsEnabled) return false;
|
||||
// Only true for Admin role (null Role = Admin for backward compat)
|
||||
return member.Role == null || member.Role == Constants.TenantRoles.Admin;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
private static DelegatedAdminEntry MapToDelegatedEntry(DelegatedAdminEntity entity)
|
||||
|
||||
+28
@@ -751,6 +751,32 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MarkRevokedAsync(string tenantId, string revokedBy)
|
||||
{
|
||||
var entity = new TableEntity(tenantId, "revoked")
|
||||
{
|
||||
{ "RevokedAt", DateTime.UtcNow },
|
||||
{ "RevokedBy", revokedBy }
|
||||
};
|
||||
|
||||
// Upsert (a second revoke refreshes the record); errors throw, because the revoke must
|
||||
// not proceed without its record — the seat guard of a later re-activation reads it.
|
||||
await _previewWhitelistTableClient.UpsertEntityAsync(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> WasRevokedAsync(string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await _previewWhitelistTableClient.GetEntityAsync<TableEntity>(tenantId, "revoked");
|
||||
return entity?.Value != null;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tenant Configuration Entity Mapping ---
|
||||
|
||||
// Internal static (not private): the Store↔Map pair is a serialization CONTRACT — every
|
||||
@@ -833,6 +859,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "EnableSoftwareInventoryAnalyzer", config.EnableSoftwareInventoryAnalyzer },
|
||||
{ "EnableIntegrityBypassAnalyzer", config.EnableIntegrityBypassAnalyzer },
|
||||
{ "EnableRealmJoinWatcher", config.EnableRealmJoinWatcher },
|
||||
{ "EnableConfigMgrWatcher", config.EnableConfigMgrWatcher },
|
||||
{ "KeepAwakeDuringUserEsp", config.KeepAwakeDuringUserEsp },
|
||||
{ "EnableConsoleBypassDetection", config.EnableConsoleBypassDetection },
|
||||
{ "LocalAdminAllowedAccountsJson", config.LocalAdminAllowedAccountsJson },
|
||||
@@ -954,6 +981,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
EnableSoftwareInventoryAnalyzer = entity.GetBoolean("EnableSoftwareInventoryAnalyzer"),
|
||||
EnableIntegrityBypassAnalyzer = entity.GetBoolean("EnableIntegrityBypassAnalyzer"),
|
||||
EnableRealmJoinWatcher = entity.GetBoolean("EnableRealmJoinWatcher"),
|
||||
EnableConfigMgrWatcher = entity.GetBoolean("EnableConfigMgrWatcher"),
|
||||
KeepAwakeDuringUserEsp = entity.GetBoolean("KeepAwakeDuringUserEsp"),
|
||||
EnableConsoleBypassDetection = entity.GetBoolean("EnableConsoleBypassDetection"),
|
||||
LocalAdminAllowedAccountsJson = entity.GetString("LocalAdminAllowedAccountsJson"),
|
||||
|
||||
+5
-5
@@ -27,7 +27,7 @@ public class DelegatedAdminManagementFunction
|
||||
private readonly DelegatedAdminService _delegatedAdminService;
|
||||
private readonly AdminIdentityResolver _identityResolver;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly ISignalRNotificationService _signalRService;
|
||||
private readonly LiveStreamRevoker _streams;
|
||||
private readonly DelegatedSlotService _slots;
|
||||
|
||||
public DelegatedAdminManagementFunction(
|
||||
@@ -35,14 +35,14 @@ public class DelegatedAdminManagementFunction
|
||||
DelegatedAdminService delegatedAdminService,
|
||||
AdminIdentityResolver identityResolver,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
ISignalRNotificationService signalRService,
|
||||
LiveStreamRevoker streams,
|
||||
DelegatedSlotService slots)
|
||||
{
|
||||
_logger = logger;
|
||||
_delegatedAdminService = delegatedAdminService;
|
||||
_identityResolver = identityResolver;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_signalRService = signalRService;
|
||||
_streams = streams;
|
||||
_slots = slots;
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ public class DelegatedAdminManagementFunction
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
tenantId.ToLowerInvariant(), "DELETE", "DelegatedAdmin", upn.ToLowerInvariant(), currentUpn ?? "");
|
||||
|
||||
await _signalRService.DisconnectUserAsync(upn.ToLowerInvariant());
|
||||
await _streams.CutBoundIdentityAsync(upn);
|
||||
|
||||
_logger.LogInformation("Delegated revoke: {Upn} -> {TenantId} by {By}", upn, tenantId, currentUpn);
|
||||
return true;
|
||||
@@ -210,7 +210,7 @@ public class DelegatedAdminManagementFunction
|
||||
new Dictionary<string, string> { { "IsEnabled", isEnabled.ToString() } });
|
||||
|
||||
if (!isEnabled)
|
||||
await _signalRService.DisconnectUserAsync(upn.ToLowerInvariant());
|
||||
await _streams.CutBoundIdentityAsync(upn);
|
||||
|
||||
_logger.LogInformation("Delegated {Action}: {Upn} -> {TenantId} by {By}",
|
||||
isEnabled ? "enable" : "disable", upn, tenantId, currentUpn);
|
||||
|
||||
+12
-2
@@ -17,7 +17,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin;
|
||||
/// Grants create a binding implicitly and REFUSE to overwrite one (409); this surface is the explicit, audited
|
||||
/// path for the two legitimate changes: re-homing a UPN in another tenant, and re-pinning the object id after
|
||||
/// the UPN was re-assigned to a new account (PUT without objectId clears the pin so the next sign-in from the
|
||||
/// bound tenant re-pins). DELETE makes every role row of the UPN inert without touching the rows.
|
||||
/// bound tenant re-pins). DELETE makes every role row of the UPN inert without touching the rows. Replacing or
|
||||
/// deleting a binding cuts the live streams signed in under the previous one.
|
||||
///
|
||||
/// Reads are GlobalReadOrAdmin; mutations are GlobalAdminOnly (route catalog). Audited under the binding's
|
||||
/// HOME tenant with entity <c>AdminIdentityBinding</c>.
|
||||
@@ -27,17 +28,20 @@ public class IdentityBindingManagementFunction
|
||||
private readonly ILogger<IdentityBindingManagementFunction> _logger;
|
||||
private readonly AdminIdentityBindingService _bindings;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly LiveStreamRevoker _streams;
|
||||
|
||||
private const string AuditEntity = "AdminIdentityBinding";
|
||||
|
||||
public IdentityBindingManagementFunction(
|
||||
ILogger<IdentityBindingManagementFunction> logger,
|
||||
AdminIdentityBindingService bindings,
|
||||
IMaintenanceRepository maintenanceRepo)
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
LiveStreamRevoker streams)
|
||||
{
|
||||
_logger = logger;
|
||||
_bindings = bindings;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_streams = streams;
|
||||
}
|
||||
|
||||
/// <summary>GET /api/global/identity-bindings — every binding. GlobalReadOrAdmin.</summary>
|
||||
@@ -93,6 +97,11 @@ public class IdentityBindingManagementFunction
|
||||
previous == null ? "Created" : "Replaced", upn, binding.TenantId,
|
||||
binding.IsObjectIdPinned ? binding.ObjectId : "(unpinned)", currentUpn);
|
||||
|
||||
// Every platform and delegated grant of the UPN resolved under the previous binding: cut those
|
||||
// connections, so their re-join meets the new one.
|
||||
if (previous != null)
|
||||
await _streams.CutAsync(previous.TenantId, upn);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new IdentityBindingResponse { Binding = binding });
|
||||
return response;
|
||||
@@ -128,6 +137,7 @@ public class IdentityBindingManagementFunction
|
||||
});
|
||||
_logger.LogWarning("[IdentityBinding] Removed binding for {Upn} (was tenant {TenantId}) by {By}",
|
||||
upn, previous.TenantId, currentUpn);
|
||||
await _streams.CutAsync(previous.TenantId, upn);
|
||||
|
||||
return await req.OkAsync(new MessageResponse { Message = "Binding removed" });
|
||||
}
|
||||
|
||||
+27
-17
@@ -19,20 +19,20 @@ public class TenantAdminManagementFunction
|
||||
private readonly ILogger<TenantAdminManagementFunction> _logger;
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly ISignalRNotificationService _signalRService;
|
||||
private readonly LiveStreamRevoker _streams;
|
||||
private readonly ISelfServiceAssignees _selfServiceAssignees;
|
||||
|
||||
public TenantAdminManagementFunction(
|
||||
ILogger<TenantAdminManagementFunction> logger,
|
||||
TenantAdminsService tenantAdminsService,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
ISignalRNotificationService signalRService,
|
||||
LiveStreamRevoker streams,
|
||||
ISelfServiceAssignees selfServiceAssignees)
|
||||
{
|
||||
_logger = logger;
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_signalRService = signalRService;
|
||||
_streams = streams;
|
||||
_selfServiceAssignees = selfServiceAssignees;
|
||||
}
|
||||
|
||||
@@ -59,12 +59,13 @@ public class TenantAdminManagementFunction
|
||||
/// SignalR group authorization is evaluated only at join time (SignalRAddToGroupFunction), so a
|
||||
/// member whose role was removed, disabled or lowered would keep their already-joined
|
||||
/// 'tenant-{tid}' broadcast, notify-group and session-group streams until the socket drops.
|
||||
/// Cutting the UPN's connections (negotiate binds userId = lowercased UPN) forces a reconnect that
|
||||
/// re-runs the join gates against the new role. Coarse by design — a still-authorized client
|
||||
/// simply reconnects — and mirrors the delegated-admin / tenant-group revoke paths.
|
||||
/// Cutting the member's connections forces a reconnect that re-runs the join gates against the new
|
||||
/// role. Coarse by design — a still-authorized client simply reconnects. The cut names this tenant
|
||||
/// only: the membership resolves under it, and so does the member's self-service delegation, so
|
||||
/// nothing here reaches the same UPN signed in to another tenant (see <see cref="LiveStreamRevoker"/>).
|
||||
/// </summary>
|
||||
private Task CutLiveStreamsAsync(string adminUpn) =>
|
||||
_signalRService.DisconnectUserAsync(adminUpn.ToLowerInvariant());
|
||||
private Task CutLiveStreamsAsync(string tenantId, string adminUpn) =>
|
||||
_streams.CutAsync(tenantId, adminUpn);
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/tenants/{tenantId}/admins
|
||||
@@ -198,8 +199,11 @@ public class TenantAdminManagementFunction
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the admin
|
||||
await _tenantAdminsService.RemoveTenantAdminAsync(requestCtx.TargetTenantId, adminUpn);
|
||||
// Remove the admin. A UPN without a row in this tenant is not this tenant's to act on: no audit, no cut.
|
||||
if (!await _tenantAdminsService.RemoveTenantAdminAsync(requestCtx.TargetTenantId, adminUpn))
|
||||
{
|
||||
return await req.NotFoundAsync("Member not found");
|
||||
}
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
@@ -210,7 +214,7 @@ public class TenantAdminManagementFunction
|
||||
);
|
||||
|
||||
await EndSelfServiceAssignmentAsync(requestCtx.TargetTenantId, adminUpn, upn!, DelegationSelfService.AssigneeReasons.MemberRemoved);
|
||||
await CutLiveStreamsAsync(adminUpn);
|
||||
await CutLiveStreamsAsync(requestCtx.TargetTenantId, adminUpn);
|
||||
|
||||
_logger.LogInformation($"Tenant Admin removed: {adminUpn} from tenant {requestCtx.TargetTenantId} by {upn}");
|
||||
|
||||
@@ -234,8 +238,11 @@ public class TenantAdminManagementFunction
|
||||
var requestCtx = context.GetRequestContext();
|
||||
var upn = requestCtx.UserPrincipalName;
|
||||
|
||||
// Disable the admin
|
||||
await _tenantAdminsService.DisableTenantAdminAsync(requestCtx.TargetTenantId, adminUpn);
|
||||
// Disable the admin (404 for a UPN without a row in this tenant, as on remove)
|
||||
if (!await _tenantAdminsService.DisableTenantAdminAsync(requestCtx.TargetTenantId, adminUpn))
|
||||
{
|
||||
return await req.NotFoundAsync("Member not found");
|
||||
}
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
@@ -247,7 +254,7 @@ public class TenantAdminManagementFunction
|
||||
);
|
||||
|
||||
await EndSelfServiceAssignmentAsync(requestCtx.TargetTenantId, adminUpn, upn!, DelegationSelfService.AssigneeReasons.MemberDisabled);
|
||||
await CutLiveStreamsAsync(adminUpn);
|
||||
await CutLiveStreamsAsync(requestCtx.TargetTenantId, adminUpn);
|
||||
|
||||
_logger.LogInformation($"Tenant Admin disabled: {adminUpn} for tenant {requestCtx.TargetTenantId} by {upn}");
|
||||
|
||||
@@ -271,8 +278,11 @@ public class TenantAdminManagementFunction
|
||||
var requestCtx = context.GetRequestContext();
|
||||
var upn = requestCtx.UserPrincipalName;
|
||||
|
||||
// Enable the admin
|
||||
await _tenantAdminsService.EnableTenantAdminAsync(requestCtx.TargetTenantId, adminUpn);
|
||||
// Enable the admin (404 for a UPN without a row in this tenant, as on remove)
|
||||
if (!await _tenantAdminsService.EnableTenantAdminAsync(requestCtx.TargetTenantId, adminUpn))
|
||||
{
|
||||
return await req.NotFoundAsync("Member not found");
|
||||
}
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
@@ -359,7 +369,7 @@ public class TenantAdminManagementFunction
|
||||
|
||||
// Only Admin holds the admin-tier notify group; any other target role may have lost it.
|
||||
if (role != AutopilotMonitor.Shared.Constants.TenantRoles.Admin)
|
||||
await CutLiveStreamsAsync(adminUpn);
|
||||
await CutLiveStreamsAsync(requestCtx.TargetTenantId, adminUpn);
|
||||
|
||||
_logger.LogInformation("Member permissions updated: {AdminUpn} -> role={Role} in tenant {TenantId} by {Upn}", adminUpn, role, requestCtx.TargetTenantId, upn);
|
||||
|
||||
|
||||
+7
-7
@@ -36,7 +36,7 @@ public class TenantGroupManagementFunction
|
||||
private readonly DelegatedAdminService _delegatedAdminService;
|
||||
private readonly AdminIdentityResolver _identityResolver;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly ISignalRNotificationService _signalRService;
|
||||
private readonly LiveStreamRevoker _streams;
|
||||
private readonly DelegatedSlotService _slots;
|
||||
private readonly ProConferralService _proConferral;
|
||||
private readonly ISelfServiceAssignees _selfServiceAssignees;
|
||||
@@ -48,7 +48,7 @@ public class TenantGroupManagementFunction
|
||||
DelegatedAdminService delegatedAdminService,
|
||||
AdminIdentityResolver identityResolver,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
ISignalRNotificationService signalRService,
|
||||
LiveStreamRevoker streams,
|
||||
DelegatedSlotService slots,
|
||||
ProConferralService proConferral,
|
||||
ISelfServiceAssignees selfServiceAssignees)
|
||||
@@ -57,7 +57,7 @@ public class TenantGroupManagementFunction
|
||||
_delegatedAdminService = delegatedAdminService;
|
||||
_identityResolver = identityResolver;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_signalRService = signalRService;
|
||||
_streams = streams;
|
||||
_slots = slots;
|
||||
_proConferral = proConferral;
|
||||
_selfServiceAssignees = selfServiceAssignees;
|
||||
@@ -204,7 +204,7 @@ public class TenantGroupManagementFunction
|
||||
await _delegatedAdminService.DeleteGroupAsync(groupId);
|
||||
|
||||
foreach (var assignee in assignees)
|
||||
await _signalRService.DisconnectUserAsync(assignee.Upn);
|
||||
await _streams.CutBoundIdentityAsync(assignee.Upn);
|
||||
|
||||
// The portal hides Delete for owned groups, but the route accepts any group id — without the
|
||||
// anchor a member that raised its retention under Pro (MSP) would be clamped to the Community
|
||||
@@ -309,9 +309,9 @@ public class TenantGroupManagementFunction
|
||||
normalizedTenantId, "DELETE", AuditEntity, assignee.Upn, currentUpn ?? "",
|
||||
new Dictionary<string, string> { { "GroupId", groupId }, { "Reason", "tenant-removed-from-group" } });
|
||||
// Enforcement: cut already-joined live streams for the tenant that just left the group.
|
||||
// Coarse by design (connection close, see DisconnectUserAsync) — still-authorized
|
||||
// Coarse by design (connection close, see LiveStreamRevoker) — still-authorized
|
||||
// streams recover automatically via the client's reconnect + re-join.
|
||||
await _signalRService.DisconnectUserAsync(assignee.Upn);
|
||||
await _streams.CutBoundIdentityAsync(assignee.Upn);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Tenant {TenantId} removed from group {GroupId} by {By}", normalizedTenantId, groupId, currentUpn);
|
||||
@@ -463,7 +463,7 @@ public class TenantGroupManagementFunction
|
||||
if (!unassigned)
|
||||
return false;
|
||||
|
||||
await _signalRService.DisconnectUserAsync(normalizedUpn);
|
||||
await _streams.CutBoundIdentityAsync(normalizedUpn);
|
||||
|
||||
if (group != null)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -433,9 +433,9 @@ public class AuthFunction
|
||||
|
||||
// SignalR group authorization is join-time only: a removed Global Admin/Reader with a live
|
||||
// connection in 'global-admins' would keep receiving cross-tenant pushes until the socket
|
||||
// drops. Cut the UPN's connections (negotiate binds userId = lowercased UPN); a reconnect
|
||||
// re-runs the join gates against the now-missing platform role.
|
||||
await _signalRService.DisconnectUserAsync(upn.ToLowerInvariant());
|
||||
// drops. Cut the connections signed in to the UPN's bound tenant, the only one a platform role
|
||||
// resolves under; a reconnect re-runs the join gates against the now-missing platform role.
|
||||
await _streams.CutBoundIdentityAsync(upn);
|
||||
|
||||
_logger.LogInformation($"Global Admin removed: {upn} by {currentUpn}");
|
||||
|
||||
@@ -701,8 +701,15 @@ public class AuthFunction
|
||||
{
|
||||
if (decision.NeedsAutoAdmin)
|
||||
{
|
||||
_logger.LogInformation("First user login for tenant {TenantId}: {Upn} - Auto-assigning as admin", tenantId, upn);
|
||||
await _tenantAdminsService.AddTenantAdminAsync(tenantId, upn, "System");
|
||||
// Warning on purpose: activation seats the onboarding member before it activates the tenant, so
|
||||
// this path is the exception (a tenant from before OnboardedBy existed, or a seat that went
|
||||
// missing), and Information never reaches Application Insights. A lost conditional insert means
|
||||
// a row for this caller appeared meanwhile (activation seating the same person, or a Global
|
||||
// Admin); it is left as written and the next request reads it.
|
||||
if (await _tenantAdminsService.TryAddTenantAdminAsync(tenantId, upn, "System"))
|
||||
_logger.LogWarning("First-user auto-admin: {Upn} seated as Admin of tenant {TenantId}", upn, tenantId);
|
||||
else
|
||||
_logger.LogInformation("First-user auto-admin for {Upn} in tenant {TenantId}: a member row appeared meanwhile — left untouched", upn, tenantId);
|
||||
}
|
||||
|
||||
_ = _metricsRepo.RecordUserLoginAsync(tenantId, upn, displayName, objectId)
|
||||
@@ -788,8 +795,18 @@ public class AuthFunction
|
||||
// it. A delegated user who legitimately administers their own tenant has a TenantAdmins row (memberRole
|
||||
// != null), so this only suppresses the silent grant, never an existing right. The same holds for an
|
||||
// external identity (a guest), which is not a member of this tenant at all.
|
||||
// Once the tenant has a recorded onboarding member (OnboardedBy), the first-user seat is theirs
|
||||
// alone: activation seats them before it activates the tenant, and should that seat ever be
|
||||
// missing (removed, never written, or the write failed) only they may take it on sign-in. Any
|
||||
// other roleless caller is answered roleless, never the first caller to see an activated tenant
|
||||
// with no rows. The record counts whatever its shape: a value activation cannot seat (an
|
||||
// external account's UPN) still blocks everyone else, and its own holder is excluded as an
|
||||
// external identity. Without a recorded member (rows from before OnboardedBy existed) the
|
||||
// first sign-in still becomes the admin.
|
||||
var onboardingMember = string.IsNullOrWhiteSpace(tenantConfig.OnboardedBy) ? null : tenantConfig.OnboardedBy;
|
||||
bool isTenantAdmin = memberRole?.Role == Constants.TenantRoles.Admin;
|
||||
bool needsAutoAdmin = memberRole == null && !hasTenantAdmins && !isGlobalReader && !isDelegated && !isExternalIdentity;
|
||||
bool needsAutoAdmin = memberRole == null && !hasTenantAdmins && !isGlobalReader && !isDelegated && !isExternalIdentity
|
||||
&& (onboardingMember == null || string.Equals(onboardingMember, upn, StringComparison.OrdinalIgnoreCase));
|
||||
if (needsAutoAdmin)
|
||||
{
|
||||
isTenantAdmin = true;
|
||||
|
||||
+10
-8
@@ -51,19 +51,21 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
|
||||
}
|
||||
|
||||
// Negotiate via the Management SDK (not the SignalRConnectionInfoInput binding) so the
|
||||
// client access token is bound to a SignalR USER ID = the lowercased UPN. That binding is
|
||||
// what makes revocation enforceable: when a delegated grant / tenant-group assignment is
|
||||
// revoked, the revoke path calls DisconnectUserAsync(upn) to cut already-joined
|
||||
// live streams — anonymous connections (no user id) cannot be targeted. The UPN comes from
|
||||
// the middleware-validated JWT, never from client-controlled binding data.
|
||||
var userEmail = TenantHelper.GetUserIdentifier(req);
|
||||
var negotiation = await _signalRService.NegotiateClientAsync(userEmail.ToLowerInvariant());
|
||||
// client access token is bound to a SignalR USER ID = sign-in tenant + principal key, the
|
||||
// pair every role resolves under (SignalRGroupHelper.UserId). That binding is what makes
|
||||
// revocation enforceable, and only inside the tenant whose grant was revoked
|
||||
// (LiveStreamRevoker). Both values come from the middleware-validated JWT, never from
|
||||
// client-controlled data. Without a principal key the connection has no user id: every
|
||||
// revocable join gate needs one.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var principalKey = string.IsNullOrEmpty(requestCtx.UserPrincipalName) ? null : requestCtx.UserPrincipalName;
|
||||
var negotiation = await _signalRService.NegotiateClientAsync(requestCtx.TenantId, principalKey);
|
||||
if (negotiation == null)
|
||||
{
|
||||
return await req.ErrorAsync(HttpStatusCode.ServiceUnavailable, Constants.ApiErrorCodes.ServiceUnavailable, "SignalR negotiation unavailable");
|
||||
}
|
||||
|
||||
_logger.LogInformation($"SignalR connection negotiated for user: {userEmail}");
|
||||
_logger.LogInformation($"SignalR connection negotiated for user: {principalKey ?? "(no principal key)"}");
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
// Exactly the shape the @microsoft/signalr client's negotiate protocol expects.
|
||||
|
||||
@@ -108,7 +108,7 @@ public class PreviewWhitelistFunction
|
||||
var principal = context.GetUser();
|
||||
var upn = principal?.GetUserPrincipalName();
|
||||
|
||||
await _previewWhitelistService.RevokeAsync(tenantId);
|
||||
await _previewWhitelistService.RevokeAsync(tenantId, upn!);
|
||||
|
||||
_logger.LogInformation("Preview tenant revoked: {TenantId} by {Upn}", tenantId, upn);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
@@ -18,30 +20,61 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
/// has (JS, PHP, Python) and .NET behaves subtly differently (this endpoint exists
|
||||
/// because exactly that happened in the field). Mirrored semantics, keep in lock-step
|
||||
/// with LogParserCollector (agent):
|
||||
/// - Regex options: Compiled, 1s timeout, NO IgnoreCase — logparser matching is
|
||||
/// case-SENSITIVE (unlike analyze-rule regex conditions).
|
||||
/// - Regex options: 1s match timeout, NO IgnoreCase — logparser matching is
|
||||
/// case-SENSITIVE (unlike analyze-rule regex conditions). The agent adds
|
||||
/// RegexOptions.Compiled; that flag changes speed only, never a match result, so the
|
||||
/// one-shot pattern here runs interpreted (see <see cref="BuildAgentRegex"/>).
|
||||
/// - First match per line (Regex.Match, not Matches).
|
||||
/// - format "cmtrace" (default): CmTraceLogParser.TryParseLine first (shared code, the
|
||||
/// same implementation the agent runs), regex against the parsed MESSAGE only.
|
||||
/// - format "text": regex against the raw line.
|
||||
/// - Captured data: every named/numbered group except "0", unsuccessful groups omitted.
|
||||
///
|
||||
/// No tenant data is touched; the endpoint is pure compute on the request body.
|
||||
/// No tenant data is touched; the endpoint is pure compute on the request body — and that
|
||||
/// compute is caller-controlled regex work on the shared Function App (ingest and every
|
||||
/// tenant's portal), so it is bounded three ways (D-379): the first match timeout or the
|
||||
/// <see cref="EvaluationBudget"/> ends the test, the invocation token (client gone, host
|
||||
/// stopping) ends it between two lines, and <see cref="TestLogPatternGate"/> caps how many
|
||||
/// tests run per instance. Each bound closes one carrier of the reported abuse; none of
|
||||
/// them changes a match result.
|
||||
/// </summary>
|
||||
public class TestLogPatternFunction
|
||||
{
|
||||
private readonly ILogger<TestLogPatternFunction> _logger;
|
||||
private readonly TestLogPatternGate _gate;
|
||||
|
||||
public TestLogPatternFunction(ILogger<TestLogPatternFunction> logger)
|
||||
public TestLogPatternFunction(ILogger<TestLogPatternFunction> logger, TestLogPatternGate gate)
|
||||
{
|
||||
_logger = logger;
|
||||
_gate = gate;
|
||||
}
|
||||
|
||||
internal const int MaxSampleLines = 200;
|
||||
internal const int MaxLineLength = 8192;
|
||||
|
||||
/// <summary>Same cap the MCP schema enforces (rules.ts); checked before any Regex is built.</summary>
|
||||
internal const int MaxPatternLength = 2000;
|
||||
|
||||
/// <summary>The agent's per-match timeout (LogParserCollector) — parity, not a tuning knob.</summary>
|
||||
internal static readonly TimeSpan MatchTimeout = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Wall-clock budget for one whole test. A legitimate pattern finishes 200 lines in
|
||||
/// milliseconds; the worst case per request is this budget plus one running match
|
||||
/// (≤ <see cref="MatchTimeout"/>), which is what <see cref="RetryAfterSeconds"/> promises.
|
||||
/// </summary>
|
||||
internal static readonly TimeSpan EvaluationBudget = TimeSpan.FromSeconds(2);
|
||||
|
||||
private const int RetryAfterSeconds = 3;
|
||||
|
||||
internal const string StopReasonRegexTimeout = "regex_timeout";
|
||||
internal const string StopReasonTimeBudget = "time_budget";
|
||||
internal const string StopReasonCancelled = "cancelled";
|
||||
|
||||
[Function("TestLogPattern")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "rules/gather/test-pattern")] HttpRequestData req)
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "rules/gather/test-pattern")] HttpRequestData req,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Authentication + TenantAdminOrGlobalReader authorization enforced by PolicyEnforcementMiddleware.
|
||||
|
||||
@@ -57,33 +90,62 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
|
||||
var isTextMode = string.Equals(request!.Format, "text", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Regex pattern;
|
||||
try
|
||||
// The slot is taken BEFORE the regex is built: parsing a hostile pattern is work too.
|
||||
var slot = _gate.TryEnter();
|
||||
if (slot == null)
|
||||
{
|
||||
// EXACT agent construction (LogParserCollector): Compiled + 1s timeout,
|
||||
// no IgnoreCase — logparser matching is case-sensitive.
|
||||
pattern = new Regex(request.Pattern!, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return await req.BadRequestAsync($"Invalid regex pattern (this is the agent's .NET engine speaking): {ex.Message}");
|
||||
_logger.LogWarning(
|
||||
"Log-pattern test refused: {Limit} evaluations already running on this instance",
|
||||
_gate.Limit);
|
||||
return await req.ErrorAsync(HttpStatusCode.TooManyRequests, Constants.ApiErrorCodes.RateLimited,
|
||||
"too many pattern tests are running right now — retry in a few seconds",
|
||||
retryAfterSeconds: RetryAfterSeconds);
|
||||
}
|
||||
|
||||
var result = EvaluatePattern(pattern, isTextMode, request.SampleLines!);
|
||||
using (slot)
|
||||
{
|
||||
Regex pattern;
|
||||
try
|
||||
{
|
||||
pattern = BuildAgentRegex(request.Pattern!);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return await req.BadRequestAsync($"Invalid regex pattern (this is the agent's .NET engine speaking): {ex.Message}");
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Log-pattern test: {LineCount} lines, {MatchCount} matched, {ParseFailures} parse failures, mode={Mode}",
|
||||
request.SampleLines!.Count, result.MatchCount, result.ParseFailureCount, isTextMode ? "text" : "cmtrace");
|
||||
var clock = Stopwatch.StartNew();
|
||||
var result = EvaluatePattern(pattern, isTextMode, request.SampleLines!, EvaluationBudget, cancellationToken);
|
||||
clock.Stop();
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new TestLogPatternResponse { Success = true, Format = isTextMode ? "text" : "cmtrace", Result = result });
|
||||
return response;
|
||||
if (result.StopReason != null)
|
||||
{
|
||||
// Warning so it reaches App Insights: one hit is an author with a broken
|
||||
// pattern, a cluster from one caller is load aimed at this endpoint.
|
||||
_logger.LogWarning(
|
||||
"Log-pattern test stopped: {Reason} after {Evaluated}/{Total} lines in {ElapsedMs} ms (pattern length {PatternLength}, mode={Mode})",
|
||||
result.StopReason, result.Lines.Count, request.SampleLines!.Count, clock.ElapsedMilliseconds,
|
||||
request.Pattern!.Length, isTextMode ? "text" : "cmtrace");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Log-pattern test: {LineCount} lines, {MatchCount} matched, {ParseFailures} parse failures, mode={Mode}",
|
||||
request.SampleLines!.Count, result.MatchCount, result.ParseFailureCount, isTextMode ? "text" : "cmtrace");
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new TestLogPatternResponse { Success = true, Format = isTextMode ? "text" : "cmtrace", Result = result });
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string? ValidateRequest(TestLogPatternRequest? request)
|
||||
{
|
||||
if (request == null || string.IsNullOrEmpty(request.Pattern))
|
||||
return "pattern is required";
|
||||
if (request.Pattern.Length > MaxPatternLength)
|
||||
return $"pattern exceeds {MaxPatternLength} characters";
|
||||
if (request.SampleLines == null || request.SampleLines.Count == 0)
|
||||
return "sampleLines is required — paste raw lines from the log file";
|
||||
if (request.SampleLines.Count > MaxSampleLines)
|
||||
@@ -98,16 +160,50 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-line evaluation mirroring LogParserCollector's inner loop. Returns one row per
|
||||
/// The agent's construction (LogParserCollector) minus RegexOptions.Compiled: the options
|
||||
/// that carry semantics are identical (no IgnoreCase, no Multiline/Singleline) and so is
|
||||
/// the match timeout. Compiled emits IL and JITs it for a pattern that runs once over at
|
||||
/// most 200 lines — on caller-controlled input that costs more than it saves. Throws
|
||||
/// <see cref="ArgumentException"/> for an invalid pattern, exactly like the agent.
|
||||
/// </summary>
|
||||
internal static Regex BuildAgentRegex(string pattern) => new(pattern, RegexOptions.None, MatchTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Per-line evaluation mirroring LogParserCollector's inner loop: one row per evaluated
|
||||
/// sample line so the author sees exactly which lines matched, which failed CMTrace
|
||||
/// parsing, and which data fields the emitted event would carry.
|
||||
///
|
||||
/// The pattern is untrusted work: the FIRST match timeout ends the test (one timed-out
|
||||
/// line already proves catastrophic backtracking — the agent would emit nothing for such
|
||||
/// lines, and 199 more seconds of it would add no information), the budget ends it when
|
||||
/// every line is merely slow, and the token ends it between two lines. Lines after the
|
||||
/// stop get no row (the MCP result is size-capped); <see cref="LogPatternTestResult.StopReason"/>
|
||||
/// and a note say where and why.
|
||||
/// </summary>
|
||||
internal static LogPatternTestResult EvaluatePattern(Regex pattern, bool isTextMode, IReadOnlyList<string> lines)
|
||||
internal static LogPatternTestResult EvaluatePattern(
|
||||
Regex pattern, bool isTextMode, IReadOnlyList<string> lines, TimeSpan budget, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new LogPatternTestResult();
|
||||
var clock = Stopwatch.StartNew();
|
||||
|
||||
for (var i = 0; i < lines.Count; i++)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Stop(result, StopReasonCancelled,
|
||||
i == 0 ? "evaluation cancelled before the first line" : $"evaluation cancelled after line {i}",
|
||||
i, lines.Count);
|
||||
break;
|
||||
}
|
||||
// Checked before every line but the first: the previous line spent the budget.
|
||||
if (i > 0 && clock.Elapsed >= budget)
|
||||
{
|
||||
Stop(result, StopReasonTimeBudget,
|
||||
$"evaluation stopped after line {i}: the test exceeded its {budget.TotalSeconds:0.#} s budget — a pattern this slow would strain the device too; anchor it (^) and avoid unbounded .* scans",
|
||||
i, lines.Count);
|
||||
break;
|
||||
}
|
||||
|
||||
var line = lines[i] ?? string.Empty;
|
||||
var row = new LogPatternLineResult { LineNumber = i + 1 };
|
||||
result.Lines.Add(row);
|
||||
@@ -140,7 +236,10 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
{
|
||||
row.Outcome = "regex_timeout";
|
||||
result.TimeoutCount++;
|
||||
continue;
|
||||
Stop(result, StopReasonRegexTimeout,
|
||||
$"line {i + 1} hit the agent's {MatchTimeout.TotalSeconds:0.#} s match timeout (catastrophic backtracking) — on the device such lines are skipped and only counted as regex timeouts; remove nested or overlapping quantifiers before deploying",
|
||||
i + 1, lines.Count);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!match.Success)
|
||||
@@ -169,7 +268,8 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
{
|
||||
result.Notes.Add("every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text");
|
||||
}
|
||||
if (result.MatchCount == 0 && result.ParseFailureCount < result.Lines.Count)
|
||||
// The case-sensitivity hint is noise once a timeout is the story.
|
||||
if (result.MatchCount == 0 && result.TimeoutCount == 0 && result.ParseFailureCount < result.Lines.Count)
|
||||
{
|
||||
result.Notes.Add("no line matched — remember logparser patterns are case-SENSITIVE (unlike analyze-rule regex conditions)");
|
||||
}
|
||||
@@ -177,6 +277,23 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records why the test ended at <paramref name="lastLine"/> (1-based; 0 = before the first
|
||||
/// line). The reason is a stop only while lines remain unevaluated — a timeout on the
|
||||
/// last line keeps its row and note but is a complete test.
|
||||
/// </summary>
|
||||
private static void Stop(LogPatternTestResult result, string reason, string note, int lastLine, int totalLines)
|
||||
{
|
||||
if (lastLine < totalLines)
|
||||
{
|
||||
result.StopReason = reason;
|
||||
note += lastLine + 1 == totalLines
|
||||
? $"; line {totalLines} was not evaluated"
|
||||
: $"; lines {lastLine + 1}-{totalLines} were not evaluated";
|
||||
}
|
||||
result.Notes.Add(note);
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
@@ -186,6 +303,48 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process-wide cap on concurrent pattern tests (singleton). A slot is held from the regex
|
||||
/// construction to the end of the evaluation; a caller finding no free slot is answered 429
|
||||
/// at once — no queueing — so this endpoint can never occupy more than <see cref="Limit"/>
|
||||
/// worker threads of an instance, whatever the request rate. Deliberately local to this one
|
||||
/// endpoint: D-203 refuses a process-wide semaphore in front of storage I/O because it would
|
||||
/// queue the ingest behind portal scans; nothing but this test waits on this gate.
|
||||
/// </summary>
|
||||
public sealed class TestLogPatternGate
|
||||
{
|
||||
internal const int DefaultLimit = 2;
|
||||
|
||||
private readonly SemaphoreSlim _slots;
|
||||
|
||||
public TestLogPatternGate() : this(DefaultLimit)
|
||||
{
|
||||
}
|
||||
|
||||
internal TestLogPatternGate(int limit)
|
||||
{
|
||||
Limit = limit;
|
||||
_slots = new SemaphoreSlim(limit, limit);
|
||||
}
|
||||
|
||||
public int Limit { get; }
|
||||
|
||||
/// <summary>A slot to dispose when the test ends, or null when every slot is taken.</summary>
|
||||
public IDisposable? TryEnter() => _slots.Wait(0) ? new Slot(_slots) : null;
|
||||
|
||||
private sealed class Slot : IDisposable
|
||||
{
|
||||
private SemaphoreSlim? _slots;
|
||||
|
||||
public Slot(SemaphoreSlim slots)
|
||||
{
|
||||
_slots = slots;
|
||||
}
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _slots, null)?.Release();
|
||||
}
|
||||
}
|
||||
|
||||
// LogPatternTestResult / LogPatternLineResult moved to
|
||||
// AutopilotMonitor.Shared.Models.RulesApiModels (wire contract, exported to TypeScript).
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ using System;
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for parsing SignalR group names.
|
||||
/// Helper class for SignalR addressing: the user id a client connection is negotiated under
|
||||
/// (<see cref="UserId"/>) and the group names.
|
||||
/// Group formats:
|
||||
/// - "tenant-{tenantId}" — tenant-wide live updates (sessions, etc.)
|
||||
/// - "tenant-{tenantId}-notify-member" — tenant notification bell (Member-tier visibility)
|
||||
@@ -19,6 +20,14 @@ public static class SignalRGroupHelper
|
||||
/// </summary>
|
||||
public const string HubName = "autopilotmonitor";
|
||||
|
||||
/// <summary>
|
||||
/// The SignalR user id of a client connection: the caller's sign-in tenant (JWT tid) plus principal key, the
|
||||
/// pair every role resolves under. A revoke addresses exactly this pair, so an action in one tenant never
|
||||
/// reaches the same UPN signed in to another. The tenant id is a GUID (no ':'), so the id is unambiguous.
|
||||
/// </summary>
|
||||
public static string UserId(string tenantId, string principalKey)
|
||||
=> $"{tenantId.ToLowerInvariant()}:{principalKey.ToLowerInvariant()}";
|
||||
|
||||
public const string TenantNotifyMemberSuffix = "-notify-member";
|
||||
public const string TenantNotifyAdminSuffix = "-notify-admin";
|
||||
|
||||
|
||||
@@ -173,6 +173,8 @@ builder.Services.AddMemoryCache();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddSingleton<ResiliencePolicies>();
|
||||
builder.Services.AddSingleton<TableStorageService>();
|
||||
// Per-instance cap on concurrent logparser pattern tests (caller-controlled regex work, D-379).
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Functions.Rules.TestLogPatternGate>();
|
||||
// Shared QueueClient bootstrap (code-quality audit D2): single Managed-Identity/connection-string
|
||||
// resolution consumed by every queue producer / worker / probe.
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Queueing.QueueClientFactory>();
|
||||
@@ -267,6 +269,8 @@ builder.Services.AddSingleton<SlaBreachEvaluationService>();
|
||||
// Identity binding (tid + oid behind every cross-tenant-role UPN) — consulted by both role services.
|
||||
builder.Services.AddSingleton<AdminIdentityBindingService>();
|
||||
builder.Services.AddSingleton<AdminIdentityResolver>();
|
||||
// Every revoke's live-stream cut, addressed to the tenant the revoked grant resolved under.
|
||||
builder.Services.AddSingleton<LiveStreamRevoker>();
|
||||
builder.Services.AddSingleton<GlobalAdminService>();
|
||||
builder.Services.AddSingleton<DelegatedAdminService>();
|
||||
builder.Services.AddSingleton<DelegatedSlotService>();
|
||||
|
||||
@@ -147,7 +147,7 @@ public class AdminIdentityBindingService
|
||||
_cache.Remove(CacheKey(upn));
|
||||
}
|
||||
|
||||
public Task<AdminIdentityBinding?> GetAsync(string upn) => _adminRepo.GetIdentityBindingAsync(upn.ToLowerInvariant());
|
||||
public virtual Task<AdminIdentityBinding?> GetAsync(string upn) => _adminRepo.GetIdentityBindingAsync(upn.ToLowerInvariant());
|
||||
|
||||
public Task<List<AdminIdentityBinding>> GetAllAsync() => _adminRepo.GetAllIdentityBindingsAsync();
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
public class AgentConfigResolver
|
||||
{
|
||||
/// <summary>Bumped when the wire shape gains a field the agent must understand.</summary>
|
||||
public const int ConfigVersion = 41; // InterceptedRegistrationRetryBoots (boots after an intercepted registration)
|
||||
public const int ConfigVersion = 42; // Analyzers.EnableConfigMgrWatcher (ConfigMgr client check, opt-in)
|
||||
|
||||
/// <summary>
|
||||
/// Served as <see cref="AgentConfigResponse.InterceptedRegistrationRetryBoots"/>. A code
|
||||
@@ -154,6 +154,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
EnableSoftwareInventoryAnalyzer = tenantConfig.EnableSoftwareInventoryAnalyzer ?? false,
|
||||
EnableIntegrityBypassAnalyzer = tenantConfig.EnableIntegrityBypassAnalyzer ?? true,
|
||||
EnableRealmJoinWatcher = tenantConfig.EnableRealmJoinWatcher ?? false,
|
||||
EnableConfigMgrWatcher = tenantConfig.EnableConfigMgrWatcher ?? false,
|
||||
KeepAwakeDuringUserEsp = tenantConfig.KeepAwakeDuringUserEsp ?? false,
|
||||
EnableConsoleBypassDetection = tenantConfig.EnableConsoleBypassDetection ?? true
|
||||
},
|
||||
|
||||
@@ -22,8 +22,10 @@ namespace AutopilotMonitor.Functions.Services;
|
||||
/// (UPN-keyed; binding + gate applied per call); every mutation invalidates the cache.
|
||||
///
|
||||
/// An assignment to a tenant's self-service group (<see cref="Constants.TenantGroupIds"/>) confers scope only
|
||||
/// while its assignee is an ENABLED member of the owning tenant — re-checked at every row resolution, so a
|
||||
/// person removed or disabled under Access Management stops reading the managed customers on every path.
|
||||
/// while its assignee is an ENABLED member of the owning tenant and bound to it as home — re-checked at every row
|
||||
/// resolution, so a person removed or disabled under Access Management stops reading the managed customers on
|
||||
/// every path, and a self-service grant resolves only under the owner's sign-in (which is why the owner's member
|
||||
/// actions cut its live streams under the owner alone, see <see cref="LiveStreamRevoker"/>).
|
||||
/// </summary>
|
||||
public class DelegatedAdminService
|
||||
{
|
||||
@@ -157,6 +159,16 @@ public class DelegatedAdminService
|
||||
continue;
|
||||
}
|
||||
|
||||
// The assignment bound the person to the owner as home; only a Global Admin's rebind moves them. Without
|
||||
// this check a rebound assignee would read the owner's customers under another tenant's sign-in.
|
||||
if (owner != null && !string.Equals((await _bindings.GetAsync(upn))?.TenantId, owner, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[DelegatedAdmin] Ignoring the assignment of {Upn} to self-service group {GroupId}: not bound to the owning tenant {OwnerTenantId}",
|
||||
upn, assignment.GroupId, owner);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Members from ONE partition read (meta-backed: a missing meta row ⇒ no group, no scope).
|
||||
var membership = await _adminRepo.GetGroupMembershipAsync(assignment.GroupId);
|
||||
if (membership == null)
|
||||
|
||||
@@ -58,7 +58,8 @@ public interface ISelfServiceAssignees
|
||||
/// <item>Removing a customer (by either side) frees the slot only after <see cref="DelegatedSlotService.ReleaseHold"/>
|
||||
/// (24 h) — no rotating customers through a small allowance. A Global Admin can release a hold early.</item>
|
||||
/// <item>Every access change is audited under BOTH tenants: the managed customer's trail records who can read
|
||||
/// it, the managing tenant's trail records its own actions. Live streams of affected users are cut.</item>
|
||||
/// it, the managing tenant's trail records its own actions. Live streams of affected users are cut under the
|
||||
/// managing tenant, the only tenant a self-service grant resolves under.</item>
|
||||
/// </list>
|
||||
/// Self-service grants are always <see cref="Constants.DelegatedRoles.DelegatedReader"/> (read-only).
|
||||
/// </summary>
|
||||
@@ -97,7 +98,7 @@ public class DelegationSelfService : ISelfServiceAssignees
|
||||
private readonly TenantEntitlementService _entitlements;
|
||||
private readonly IConfigRepository _configRepo;
|
||||
private readonly IMaintenanceRepository _audit;
|
||||
private readonly ISignalRNotificationService _signalR;
|
||||
private readonly LiveStreamRevoker _streams;
|
||||
private readonly ProConferralService _proConferral;
|
||||
private readonly ILogger<DelegationSelfService> _logger;
|
||||
private readonly TimeProvider _time;
|
||||
@@ -110,10 +111,10 @@ public class DelegationSelfService : ISelfServiceAssignees
|
||||
TenantEntitlementService entitlements,
|
||||
IConfigRepository configRepo,
|
||||
IMaintenanceRepository audit,
|
||||
ISignalRNotificationService signalR,
|
||||
LiveStreamRevoker streams,
|
||||
ProConferralService proConferral,
|
||||
ILogger<DelegationSelfService> logger)
|
||||
: this(adminRepo, invitations, delegatedAdmins, slots, entitlements, configRepo, audit, signalR, proConferral, logger, TimeProvider.System)
|
||||
: this(adminRepo, invitations, delegatedAdmins, slots, entitlements, configRepo, audit, streams, proConferral, logger, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -126,7 +127,7 @@ public class DelegationSelfService : ISelfServiceAssignees
|
||||
TenantEntitlementService entitlements,
|
||||
IConfigRepository configRepo,
|
||||
IMaintenanceRepository audit,
|
||||
ISignalRNotificationService signalR,
|
||||
LiveStreamRevoker streams,
|
||||
ProConferralService proConferral,
|
||||
ILogger<DelegationSelfService> logger,
|
||||
TimeProvider time)
|
||||
@@ -138,7 +139,7 @@ public class DelegationSelfService : ISelfServiceAssignees
|
||||
_entitlements = entitlements;
|
||||
_configRepo = configRepo;
|
||||
_audit = audit;
|
||||
_signalR = signalR;
|
||||
_streams = streams;
|
||||
_proConferral = proConferral;
|
||||
_logger = logger;
|
||||
_time = time;
|
||||
@@ -333,7 +334,7 @@ public class DelegationSelfService : ISelfServiceAssignees
|
||||
{
|
||||
await _audit.LogAuditEntryAsync(target, "DELETE", AuditGroupAccess, assignee.Upn, by,
|
||||
new Dictionary<string, string> { { "GroupId", groupId }, { "Reason", reason }, { "HomeTenantId", home } });
|
||||
await _signalR.DisconnectUserAsync(assignee.Upn);
|
||||
await _streams.CutAsync(home, assignee.Upn);
|
||||
}
|
||||
await _audit.LogAuditEntryAsync(home, "DELETE", AuditManagedTenant, target, by,
|
||||
new Dictionary<string, string> { { "Reason", reason }, { "SlotHeldUntilUtc", hold.HoldUntilUtc?.ToString("O") ?? string.Empty } });
|
||||
@@ -395,7 +396,7 @@ public class DelegationSelfService : ISelfServiceAssignees
|
||||
if (!await EndAssignmentAsync(homeTenantId, upn, actor, AssigneeReasons.SelfServiceUnassigned))
|
||||
return DelegationResult<bool>.Fail(404, Constants.DelegationCodes.AssigneeNotFound, "That person is not assigned.");
|
||||
|
||||
await _signalR.DisconnectUserAsync(upn.ToLowerInvariant());
|
||||
await _streams.CutAsync(homeTenantId, upn);
|
||||
return DelegationResult<bool>.Success(true);
|
||||
}
|
||||
|
||||
|
||||
+23
-3
@@ -18,6 +18,10 @@ internal static class DiagnosisCulpritFinder
|
||||
|
||||
private static readonly IReadOnlyList<string> EvidenceRank = DiagnosisCulpritEvidence.All;
|
||||
|
||||
// The agent's provider kinds (EspPolicyProviderProbe.KindDevicePreparation / KindSetupApps).
|
||||
private const string DevicePreparationProviderKind = "devicePreparation";
|
||||
private const string SetupAppsProviderKind = "setupApps";
|
||||
|
||||
internal static List<DiagnosisCulprit> Find(
|
||||
IReadOnlyList<EnrollmentEvent> events, FailureEvidence failure, EspBlockingSets? blocking)
|
||||
{
|
||||
@@ -89,7 +93,7 @@ internal static class DiagnosisCulpritFinder
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var provider in StalledProviders(events))
|
||||
foreach (var provider in StalledProviders(events, failure.Step.Category))
|
||||
Add(provider);
|
||||
|
||||
return culprits
|
||||
@@ -125,14 +129,21 @@ internal static class DiagnosisCulpritFinder
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stuck ESP policy providers from the latest <c>esp_policy_provider_stalled</c>.</summary>
|
||||
internal static IEnumerable<DiagnosisCulprit> StalledProviders(IReadOnlyList<EnrollmentEvent> events)
|
||||
/// <summary>
|
||||
/// Stuck ESP policy providers from the latest <c>esp_policy_provider_stalled</c>. Given the
|
||||
/// failed ESP category, only providers that can hold it count: device-preparation providers
|
||||
/// for a device-preparation failure, Setup/Apps providers for device or account setup. A
|
||||
/// ConfigMgr client installed as a Win32 app registers its device-preparation provider after
|
||||
/// device preparation ended, so that provider explains no later failure.
|
||||
/// </summary>
|
||||
internal static IEnumerable<DiagnosisCulprit> StalledProviders(IReadOnlyList<EnrollmentEvent> events, string? failedCategory = null)
|
||||
{
|
||||
var stalled = DiagnosisEspEvidence.LastOf(events, e => e.EventType == Constants.EventTypes.EspPolicyProviderStalled);
|
||||
foreach (var item in EventDataReader.Maps(stalled?.Data, "stalledProviders"))
|
||||
{
|
||||
var name = DiagnosisEspEvidence.NonEmpty(EventDataReader.String(item, "name"));
|
||||
if (name == null) continue;
|
||||
if (!CanHoldCategory(EventDataReader.String(item, "kind"), failedCategory)) continue;
|
||||
yield return new DiagnosisCulprit
|
||||
{
|
||||
Kind = DiagnosisCulpritKinds.PolicyProvider,
|
||||
@@ -146,6 +157,15 @@ internal static class DiagnosisCulpritFinder
|
||||
}
|
||||
}
|
||||
|
||||
private static bool CanHoldCategory(string? kind, string? failedCategory)
|
||||
{
|
||||
if (string.IsNullOrEmpty(failedCategory) || string.IsNullOrEmpty(kind)) return true;
|
||||
var devicePreparationFailed = string.Equals(failedCategory, "DevicePreparation", StringComparison.OrdinalIgnoreCase);
|
||||
if (string.Equals(kind, DevicePreparationProviderKind, StringComparison.OrdinalIgnoreCase)) return devicePreparationFailed;
|
||||
if (string.Equals(kind, SetupAppsProviderKind, StringComparison.OrdinalIgnoreCase)) return !devicePreparationFailed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static DiagnosisCulprit AppCulprit(AppTrack app, string evidence, EspBlockingSets? blocking, string? eventId) => new()
|
||||
{
|
||||
Kind = DiagnosisCulpritKinds.App,
|
||||
|
||||
@@ -66,7 +66,7 @@ public class GlobalAdminService
|
||||
|
||||
private readonly Push.IPushDeviceRevoker? _pushRevoker;
|
||||
|
||||
/// <summary>Role-loss hook (K6): a removed or disabled Global Admin loses the platform-scope push devices. Fail-soft.</summary>
|
||||
/// <summary>Role-loss hook (K6): a removed Global Admin loses the platform-scope push devices. Fail-soft.</summary>
|
||||
private Task RevokePushDevicesAsync(string upn, string reason)
|
||||
=> _pushRevoker?.RevokeOwnerAsync(Notifications.NotificationScope.Platform, upn, reason) ?? Task.CompletedTask;
|
||||
|
||||
@@ -173,20 +173,6 @@ public class GlobalAdminService
|
||||
await RevokePushDevicesAsync(upn, "global_admin_removed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disables (but does not delete) a Global Admin
|
||||
/// </summary>
|
||||
public async Task DisableGlobalAdminAsync(string upn)
|
||||
{
|
||||
upn = upn.ToLowerInvariant();
|
||||
|
||||
await _adminRepo.DisableGlobalAdminAsync(upn);
|
||||
|
||||
// Invalidate cache
|
||||
_cache.Remove($"global-role:{upn}");
|
||||
await RevokePushDevicesAsync(upn, "global_admin_disabled");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all Global Admins
|
||||
/// </summary>
|
||||
|
||||
@@ -31,25 +31,25 @@ namespace AutopilotMonitor.Functions.Services
|
||||
Task SendGlobalNotificationDismissedAllAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Negotiates a client connection whose access token is bound to a SignalR USER ID (the
|
||||
/// lowercased UPN). Binding the user id is what makes a later revoke enforceable server-side:
|
||||
/// without it, connections are anonymous to the service and
|
||||
/// <see cref="RemoveUserFromAllGroupsAsync"/> has nothing to target. Returns null when
|
||||
/// negotiation fails (caller should respond 503 — the client cannot connect anyway).
|
||||
/// Negotiates a client connection whose access token is bound to the SignalR user id of the
|
||||
/// caller's sign-in tenant plus principal key (<see cref="Helpers.SignalRGroupHelper.UserId"/>).
|
||||
/// Binding the user id is what makes a later revoke enforceable server-side, and only within
|
||||
/// that tenant. A caller without a principal key negotiates without a user id: every revocable
|
||||
/// join gate needs the key, so such a connection holds nothing a revoke would have to reach.
|
||||
/// Returns null when negotiation fails (caller should respond 503 — the client cannot connect anyway).
|
||||
/// </summary>
|
||||
Task<(string Url, string AccessToken)?> NegotiateClientAsync(string userId);
|
||||
Task<(string Url, string AccessToken)?> NegotiateClientAsync(string tenantId, string? principalKey);
|
||||
|
||||
/// <summary>
|
||||
/// Cuts every live SignalR stream of the given user (lowercased UPN) — the enforcement half
|
||||
/// of a delegated-grant / tenant-group revoke. Group authorization is join-time only, so an
|
||||
/// already-joined, revoked caller would otherwise keep receiving live tenant telemetry until
|
||||
/// the connection drops. Strips the user from all groups server-side AND pushes
|
||||
/// "accessRevoked" to the user's connections; the web client restarts its connection on
|
||||
/// receipt, and every re-join re-runs authorization — revoked groups 403, still-authorized
|
||||
/// streams recover automatically. Only reaches connections negotiated WITH a user id (see
|
||||
/// <see cref="NegotiateClientAsync"/>); best-effort — failures are logged at Warning, the
|
||||
/// authoritative revoke (table row) has already happened.
|
||||
/// Cuts every live SignalR stream of one principal — the sign-in tenant plus principal key a
|
||||
/// revoked grant was exercised under; <see cref="LiveStreamRevoker"/> decides which. Group
|
||||
/// authorization is join-time only, so an already-joined, revoked caller would otherwise keep
|
||||
/// receiving live tenant telemetry until the connection drops. Strips the user from all groups
|
||||
/// server-side AND pushes "accessRevoked" to the user's connections; the web client restarts its
|
||||
/// connection on receipt, and every re-join re-runs authorization — revoked groups 403,
|
||||
/// still-authorized streams recover automatically. Best-effort — failures are logged at Warning,
|
||||
/// the authoritative revoke (table row) has already happened.
|
||||
/// </summary>
|
||||
Task DisconnectUserAsync(string userId);
|
||||
Task DisconnectUserAsync(string tenantId, string principalKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Cuts the live SignalR streams of a revoked grant. A connection is negotiated under its caller's sign-in tenant
|
||||
/// plus principal key (<see cref="Helpers.SignalRGroupHelper.UserId"/>), and every grant resolves under exactly one
|
||||
/// such tenant, so each cut names it: a tenant membership (and a self-service delegation, which resolves only under
|
||||
/// its owner — see <see cref="DelegatedAdminService"/>) that tenant; a platform role or an operator-granted
|
||||
/// delegation the tenant of the UPN's identity binding. An action in one tenant therefore never reaches the same UPN
|
||||
/// signed in to another. The one caller of <see cref="ISignalRNotificationService.DisconnectUserAsync"/>.
|
||||
/// </summary>
|
||||
public class LiveStreamRevoker
|
||||
{
|
||||
private readonly ISignalRNotificationService _signalR;
|
||||
private readonly AdminIdentityBindingService _bindings;
|
||||
private readonly ILogger<LiveStreamRevoker> _logger;
|
||||
|
||||
public LiveStreamRevoker(ISignalRNotificationService signalR, AdminIdentityBindingService bindings, ILogger<LiveStreamRevoker> logger)
|
||||
{
|
||||
_signalR = signalR;
|
||||
_bindings = bindings;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Cuts the connections of <paramref name="principalKey"/> signed in to <paramref name="tenantId"/>.</summary>
|
||||
public Task CutAsync(string tenantId, string principalKey) => _signalR.DisconnectUserAsync(tenantId, principalKey);
|
||||
|
||||
/// <summary>
|
||||
/// Cuts the connections a platform role or operator-granted delegation of <paramref name="principalKey"/> was
|
||||
/// exercised under: the tenant of its identity binding, the only tenant such a grant resolves under. No binding
|
||||
/// ⇒ the grant never resolved ⇒ nothing to cut. Fail-soft like the cut itself — the revoke has already happened.
|
||||
/// </summary>
|
||||
public async Task CutBoundIdentityAsync(string principalKey)
|
||||
{
|
||||
AdminIdentityBinding? binding;
|
||||
try
|
||||
{
|
||||
binding = await _bindings.GetAsync(principalKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Reading the identity binding of {Upn} failed — its live streams were not cut", principalKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (binding != null)
|
||||
await _signalR.DisconnectUserAsync(binding.TenantId, principalKey);
|
||||
}
|
||||
}
|
||||
@@ -64,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
|
||||
@@ -83,10 +91,14 @@ public class PreviewWhitelistService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revokes a tenant's activation.
|
||||
/// 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 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
|
||||
@@ -94,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);
|
||||
|
||||
@@ -41,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,
|
||||
@@ -55,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
|
||||
@@ -65,88 +77,105 @@ public class TenantAdminsService
|
||||
=> _pushRevoker?.RevokeOwnerAsync(Notifications.NotificationScope.Tenant(tenantId), upn, reason) ?? Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// True if the UPN holds an enabled Admin row in the tenant (a row without a role is an Admin).
|
||||
/// Reads through <see cref="GetTableMembershipAsync"/>, so it shares that cache and its rules.
|
||||
/// 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>
|
||||
public async Task<bool> IsTenantAdminAsync(string tenantId, string? upn)
|
||||
{
|
||||
var (state, role) = await GetTableMembershipAsync(tenantId, upn);
|
||||
return state == TableMemberState.Enabled && role?.Role == Constants.TenantRoles.Admin;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
|
||||
@@ -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('@');
|
||||
}
|
||||
|
||||
+54
-54
@@ -34,7 +34,7 @@
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.71.2",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^4.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
@@ -1283,9 +1283,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2758,16 +2758,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
|
||||
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -2776,13 +2776,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
|
||||
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -2803,9 +2803,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
|
||||
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2816,13 +2816,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
|
||||
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -2830,14 +2830,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
|
||||
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -2846,9 +2846,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
|
||||
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -2856,13 +2856,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
|
||||
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -3063,9 +3063,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
||||
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5757,9 +5757,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
|
||||
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
|
||||
"integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -6027,19 +6027,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
|
||||
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/runner": "4.1.11",
|
||||
"@vitest/snapshot": "4.1.11",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/expect": "4.1.12",
|
||||
"@vitest/mocker": "4.1.12",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/runner": "4.1.12",
|
||||
"@vitest/snapshot": "4.1.12",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -6067,12 +6067,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/browser-preview": "4.1.11",
|
||||
"@vitest/browser-webdriverio": "4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"@vitest/browser-playwright": "4.1.12",
|
||||
"@vitest/browser-preview": "4.1.12",
|
||||
"@vitest/browser-webdriverio": "^4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.12",
|
||||
"@vitest/coverage-v8": "4.1.12",
|
||||
"@vitest/ui": "4.1.12",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.71.2",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^4.1.12"
|
||||
},
|
||||
"overrides": {
|
||||
"sharp": "^0.35.5",
|
||||
|
||||
@@ -421,6 +421,8 @@ export interface AnalyzerConfiguration {
|
||||
enableIntegrityBypassAnalyzer: boolean;
|
||||
/** Whether to enable the RealmJoin watcher, which tracks RealmJoin deployment state and enrollment packages during provisioning. This is not an IAgentAnalyzer; the flag gates creation of the RealmJoin collector host in the agent runtime. Default: false (opt-in; most tenants do not deploy via RealmJoin). */
|
||||
enableRealmJoinWatcher: boolean;
|
||||
/** Whether to enable the ConfigMgr watcher, which reports whether the Configuration Manager client is installed (service state, version, site code). This is not an IAgentAnalyzer; the flag gates creation of the ConfigMgr client host in the agent runtime. Default: false (opt-in; most tenants do not co-manage with ConfigMgr). */
|
||||
enableConfigMgrWatcher: boolean;
|
||||
/** Whether to keep the device awake (system + display) during the User-ESP (AccountSetup) phase so idle standby/sleep cannot stall app installs or account provisioning. This is not an IAgentAnalyzer; the flag gates creation of the UserEspKeepAwake host in the agent runtime. Reboots are unaffected (the hold only resets idle timers). Default: false (opt-in per tenant). */
|
||||
keepAwakeDuringUserEsp: boolean;
|
||||
/** Whether to detect an interactive console opened during enrollment — the classic Shift+F10 OOBE bypass. Gates BOTH the live ConsoleBypass host (an interactive-session cmd.exe with a bare, non-scripted command line; stopped on real-user desktop arrival) and the startup ConsolePrefetchScanner (CMD.EXE-*.pf ran after boot). Detection is best-effort, not gapless — a press before the agent installs is only coarsely covered by the prefetch artifact. Security-sensitive; not an IAgentAnalyzer toggle in the usual sense (it also gates a collector host). Default: true (opt-out per tenant — a Shift+F10 console during enrollment is a finding worth surfacing by default; tenants that knowingly use Shift+F10 for support can disable it). */
|
||||
@@ -3079,6 +3081,8 @@ export interface LogPatternTestResult {
|
||||
matchCount: number;
|
||||
parseFailureCount: number;
|
||||
timeoutCount: number;
|
||||
/** Set when the test ended before the last sample line was evaluated: regex_timeout (the first line that hit the agent's 1 s match timeout ends the test) | time_budget (the whole test exceeded its time budget) | cancelled. Lines after the stop have no row; a note names them. Null when every line was evaluated. */
|
||||
stopReason?: string;
|
||||
readonly lines: LogPatternLineResult[];
|
||||
readonly notes: string[];
|
||||
}
|
||||
@@ -5357,6 +5361,8 @@ export interface TenantConfiguration {
|
||||
enableIntegrityBypassAnalyzer?: boolean | null;
|
||||
/** Whether the RealmJoin watcher is enabled for this tenant's devices. RealmJoin enrollment-package tracking is off by default; enable only for tenants that deploy via RealmJoin. null = use agent default (false). */
|
||||
enableRealmJoinWatcher?: boolean | null;
|
||||
/** Whether the ConfigMgr watcher is enabled for this tenant's devices: the agent reports whether the Configuration Manager client is installed (service state, version, site code). Off by default; enable for tenants that co-manage with ConfigMgr. null = use agent default (false). */
|
||||
enableConfigMgrWatcher?: boolean | null;
|
||||
/** Whether to keep the device awake during the User-ESP (AccountSetup) phase for this tenant's devices. Prevents idle standby/sleep from stalling app installs / account provisioning; reboots are unaffected. Off by default. null = use agent default (false). */
|
||||
keepAwakeDuringUserEsp?: boolean | null;
|
||||
/** Whether to detect a SYSTEM console opened during enrollment (Shift+F10 OOBE bypass) for this tenant's devices. Gates the live ConsoleBypass watcher + the startup prefetch scanner. On by default (opt-out); tenants that knowingly use Shift+F10 for support can disable it. null = use agent default (true). */
|
||||
|
||||
@@ -84,7 +84,8 @@ export function registerRuleTools(server: McpServer, ga: boolean, ruleDryRun: bo
|
||||
'customer\'s log file, including lines that must match AND lines that must not. format="cmtrace" (default) ' +
|
||||
'parses each line as CMTrace/IME format first and runs the regex against the parsed message; format="text" ' +
|
||||
'matches the raw line. Returns per-line outcomes with the exact capture groups that would land in the emitted ' +
|
||||
'timeline event\'s data. Nothing is stored.',
|
||||
'timeline event\'s data. The first line hitting the agent\'s 1 s match timeout ends the test ' +
|
||||
'(stopReason="regex_timeout"); fix the backtracking first. Nothing is stored.',
|
||||
inputSchema: {
|
||||
pattern: z.string().min(1).max(2000).describe('The regex (named groups become event data fields), .NET syntax'),
|
||||
sampleLines: z.array(z.string().max(8192)).min(1).max(200)
|
||||
|
||||
@@ -741,7 +741,8 @@ namespace AutopilotMonitor.Shared
|
||||
public const string TelemetryUploadBlocked = "telemetry_upload_blocked"; // Warning (one-shot each) when the drain is blocked by a retained unknown permanent 4xx (400 contract bug / 404 route mismatch / tenant-validation), or by an interception on the network path (data.reason client_certificate_required | foreign_response, plus operation, statusCode, pageTitle, serverCertIssuer, firstBlockedAtUtc). Data is NOT discarded. LOCAL diagnostic (agent log + diag ZIP): this marker is queued behind the blocking batch and only reaches the backend after the block clears — no out-of-band bypass by design.
|
||||
public const string SessionParkedWithoutDeadline = "session_parked_without_deadline"; // One-shot Warning tripwire (liveness plan PR1): a non-terminal session entered the post-AccountSetup dead-end zone (ESP final exit or advisory failure) with NO resolution-capable deadline armed. After the 2026-06-12 dead-end fixes this must never fire in production — every occurrence is a bug report for an unknown parking variant.
|
||||
public const string DiskSpaceLow = "disk_space_low"; // One-shot Warning (state-change-only, 1 GB hysteresis — NOT periodic) when the system drive drops below 2 GB free. Review MON-B10: disk-full was previously only visible inside Debug performance_snapshot payloads (filtered out of timelines), so a disk-starved enrollment carried no actionable threshold signal.
|
||||
public const string EspPolicyProviderStalled = "esp_policy_provider_stalled"; // One-shot-per-provider Warning: a registered EnrollmentStatusTracking CSP policy provider stayed incomplete >= 15 min (Setup/Apps TrackingPoliciesCreated != 1, or DevicePreparation InstallationState not in {2,3}). The ESP waits on Setup/Apps providers WITHOUT any OS timeout — e.g. pre-provisioning + co-management leaves only "ConfigMgr" registered (no "Sidecar" = IME) and the user ESP sits at "Apps (Identifying)" for days. Observational only; payload carries the full provider list. See EspPolicyProviderProbe.
|
||||
public const string EspPolicyProviderStalled = "esp_policy_provider_stalled"; // One-shot-per-provider Warning: a registered EnrollmentStatusTracking CSP policy provider stayed incomplete >= 15 min (Setup/Apps TrackingPoliciesCreated != 1, or DevicePreparation InstallationState not in {2,3} while device preparation is still open). The ESP waits on Setup/Apps providers WITHOUT any OS timeout — e.g. pre-provisioning + co-management leaves only "ConfigMgr" registered (no "Sidecar" = IME) and the user ESP sits at "Apps (Identifying)" for days. Observational only; payload carries the full provider list. See EspPolicyProviderProbe.
|
||||
public const string ConfigMgrClientDetected = "configmgr_client_detected"; // Info when the ConfigMgr client is first seen and again when its reported state changes (CCM key, CCMSetup version, assigned site code, %windir%\CCM, CcmExec state; V1 confidence score), capped per run. The V1 check, restored in V2 and opt-in per tenant (AnalyzerConfiguration.EnableConfigMgrWatcher, D-382).
|
||||
public const string KeepAwakeEngaged = "keep_awake_engaged"; // Info (state-change, NOT periodic) emitted by the opt-in UserEspKeepAwake host when it holds the device awake (system + display) on entering the User-ESP/AccountSetup phase, so idle standby cannot stall app installs / account provisioning. Reboots are unaffected. Per-tenant toggle (AnalyzerConfiguration.KeepAwakeDuringUserEsp, default off).
|
||||
public const string KeepAwakeReleased = "keep_awake_released"; // Info (Warning when reason=safety_cap) emitted when the keep-awake hold is released — reason = user_esp_exited (Shell-Core 62407 after AccountSetup progress, i.e. the user-ESP page closed; agents before 2026-09-04 wrote account_setup_complete for the synthesized completion, which fired minutes early) | host_stop | safety_cap. Brackets KeepAwakeEngaged; the OS also auto-clears the hold on process exit/reboot.
|
||||
|
||||
@@ -1046,7 +1047,6 @@ namespace AutopilotMonitor.Shared
|
||||
// search/catalog stay complete for historical V1 session analysis. V1 emit
|
||||
// sites are intentionally NOT refactored to use these (V1 is going away).
|
||||
// ---------------------------------------------------------------------
|
||||
public const string ConfigMgrClientDetected = "configmgr_client_detected";
|
||||
public const string EnrollmentTypeMismatch = "enrollment_type_mismatch";
|
||||
public const string DecisionProcessCompletion = "decision_process_completion";
|
||||
public const string WhiteGloveClassification = "whiteglove_classification";
|
||||
|
||||
@@ -20,7 +20,6 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
Task<List<GlobalAdminEntry>> GetAllGlobalAdminsAsync();
|
||||
Task<bool> AddGlobalAdminAsync(string upn, string addedBy);
|
||||
Task<bool> RemoveGlobalAdminAsync(string upn);
|
||||
Task<bool> DisableGlobalAdminAsync(string upn);
|
||||
|
||||
// --- MCP Users ---
|
||||
Task<McpUserEntry?> GetMcpUserAsync(string upn);
|
||||
@@ -98,11 +97,19 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
// --- Tenant Members ---
|
||||
Task<List<TenantMember>> GetTenantMembersAsync(string tenantId);
|
||||
Task<bool> AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false);
|
||||
/// <summary>
|
||||
/// Conditional insert of a member row: true when this call created it, false when a row for the
|
||||
/// UPN already exists (left untouched, whatever it says). The first-admin writers (activation
|
||||
/// seating the onboarding member, the first-user auto-admin) use this so a row the tenant already
|
||||
/// holds is never overwritten. Storage errors throw.
|
||||
/// </summary>
|
||||
Task<bool> TryAddTenantMemberAsync(string tenantId, string upn, string addedBy, string role);
|
||||
/// <summary>Deletes the member row: true when one was deleted, false when the tenant had none for the UPN.</summary>
|
||||
Task<bool> RemoveTenantMemberAsync(string tenantId, string upn);
|
||||
Task<bool> UpdateMemberPermissionsAsync(string tenantId, string upn, string role, bool canManageBootstrapTokens);
|
||||
/// <summary>Sets the member row's enabled flag: false when the tenant has no row for the UPN.</summary>
|
||||
Task<bool> SetTenantMemberEnabledAsync(string tenantId, string upn, bool isEnabled);
|
||||
Task<TenantMember?> GetTenantMemberAsync(string tenantId, string upn);
|
||||
Task<bool> IsTenantAdminAsync(string tenantId, string upn);
|
||||
}
|
||||
|
||||
public class GlobalAdminEntry
|
||||
|
||||
@@ -137,5 +137,12 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
Task<bool> TryMarkWelcomeEmailSentAsync(string tenantId);
|
||||
/// <summary>Removes the welcome-email marker (no-op when absent, fail-soft).</summary>
|
||||
Task ClearWelcomeEmailSentMarkerAsync(string tenantId);
|
||||
/// <summary>
|
||||
/// Records that the tenant's activation was revoked (RowKey "revoked", upsert; errors throw).
|
||||
/// Never cleared by a re-approve: after a revoke a re-activation seats no onboarding member.
|
||||
/// </summary>
|
||||
Task MarkRevokedAsync(string tenantId, string revokedBy);
|
||||
/// <summary>True when the tenant's activation was revoked at least once; errors throw.</summary>
|
||||
Task<bool> WasRevokedAsync(string tenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,6 +659,14 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool EnableRealmJoinWatcher { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to enable the ConfigMgr watcher, which reports whether the Configuration
|
||||
/// Manager client is installed (service state, version, site code). This is not an
|
||||
/// IAgentAnalyzer; the flag gates creation of the ConfigMgr client host in the agent runtime.
|
||||
/// Default: false (opt-in; most tenants do not co-manage with ConfigMgr).
|
||||
/// </summary>
|
||||
public bool EnableConfigMgrWatcher { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to keep the device awake (system + display) during the User-ESP (AccountSetup)
|
||||
/// phase so idle standby/sleep cannot stall app installs or account provisioning. This is
|
||||
|
||||
@@ -603,6 +603,14 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool? EnableRealmJoinWatcher { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the ConfigMgr watcher is enabled for this tenant's devices: the agent reports
|
||||
/// whether the Configuration Manager client is installed (service state, version, site
|
||||
/// code). Off by default; enable for tenants that co-manage with ConfigMgr.
|
||||
/// null = use agent default (false).
|
||||
/// </summary>
|
||||
public bool? EnableConfigMgrWatcher { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to keep the device awake during the User-ESP (AccountSetup) phase for this
|
||||
/// tenant's devices. Prevents idle standby/sleep from stalling app installs / account
|
||||
|
||||
@@ -203,6 +203,13 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int MatchCount { get; set; }
|
||||
public int ParseFailureCount { get; set; }
|
||||
public int TimeoutCount { get; set; }
|
||||
|
||||
/// <summary>Set when the test ended before the last sample line was evaluated:
|
||||
/// regex_timeout (the first line that hit the agent's 1 s match timeout ends the test) |
|
||||
/// time_budget (the whole test exceeded its time budget) | cancelled. Lines after the stop
|
||||
/// have no row; a note names them. Null when every line was evaluated.</summary>
|
||||
public string? StopReason { get; set; }
|
||||
|
||||
public List<LogPatternLineResult> Lines { get; } = new();
|
||||
public List<string> Notes { get; } = new();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# This is NOT the Next.js you know
|
||||
## This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
||||
|
||||
|
||||
+1
@@ -145,6 +145,7 @@ describe("runtime catalogs parity with the agent-config defaults manifest", () =
|
||||
expect(agentDefaults.collectorDefaults.collectorIdleTimeoutMinutes).toBe(15);
|
||||
expect(agentDefaults.analyzerDefaults.enableIntegrityBypassAnalyzer).toBe(true);
|
||||
expect(agentDefaults.analyzerDefaults.enableRealmJoinWatcher).toBe(false);
|
||||
expect(agentDefaults.analyzerDefaults.enableConfigMgrWatcher).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@ export const TENANT_FIELDS: Record<keyof TenantConfiguration, TenantRow | Exclud
|
||||
enableIntegrityBypassAnalyzer: { section: "Analyzers", label: "Integrity Bypass Analyzer" },
|
||||
enableConsoleBypassDetection: { section: "Analyzers", label: "Console Bypass Detection" },
|
||||
enableRealmJoinWatcher: { section: "Analyzers", label: "Realm Join Watcher" },
|
||||
enableConfigMgrWatcher: { section: "Analyzers", label: "ConfigMgr Watcher" },
|
||||
|
||||
// Webhooks — channel list plus the legacy single-webhook fields it falls back to
|
||||
notificationChannelsJson: CHANNEL_BLOCK,
|
||||
@@ -341,6 +342,7 @@ export const ANALYZER_FIELDS: Record<keyof AnalyzerConfiguration, RuntimeRow | E
|
||||
enableSoftwareInventoryAnalyzer: { section: "Analyzers", label: "Software Inventory Analyzer", source: "tenant" },
|
||||
enableIntegrityBypassAnalyzer: { section: "Analyzers", label: "Integrity Bypass Analyzer", source: "tenant" },
|
||||
enableRealmJoinWatcher: { section: "Analyzers", label: "Realm Join Watcher", source: "tenant" },
|
||||
enableConfigMgrWatcher: { section: "Analyzers", label: "ConfigMgr Watcher", source: "tenant" },
|
||||
keepAwakeDuringUserEsp: { section: "Analyzers", label: "Keep Awake During User ESP", source: "tenant" },
|
||||
enableConsoleBypassDetection: { section: "Analyzers", label: "Console Bypass Detection", source: "tenant" },
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { LandingNavbar } from "../../components/landing/LandingNavbar";
|
||||
import { SiteFooter } from "../../components/SiteFooter";
|
||||
import { DOCS_URL } from "@/lib/config";
|
||||
|
||||
const LAST_UPDATED = "20 October 2026";
|
||||
const LAST_UPDATED = "23 October 2026";
|
||||
const DOCS_SECURITY_FAQ = `${DOCS_URL}/trust/security-faq`;
|
||||
const DOCS_DATA_FLOWS = `${DOCS_URL}/trust/data-flows`;
|
||||
const DOCS_DPA = `${DOCS_URL}/legal/data-privacy-agreement-dpa`;
|
||||
@@ -71,7 +71,7 @@ export default function PrivacyPage() {
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Device identity</strong> — serial number, device name, manufacturer, model, and the Entra ID tenant the device enrolls into</li>
|
||||
<li><strong>Enrollment progress</strong> — phases, ESP stages, application and script results, policy activity, Windows updates installed during the enrollment, reboots, timings, and failure codes</li>
|
||||
<li><strong>Device context</strong> — OS build, hardware characteristics, disk and network state, plus whatever your own gather rules request</li>
|
||||
<li><strong>Device context</strong> — OS build, hardware characteristics, disk and network state, plus whatever your own gather rules request; with the ConfigMgr Watcher enabled, also whether the Configuration Manager client is installed (service state, version, site code)</li>
|
||||
<li><strong>Approximate location</strong> — country, region, city, and approximate coordinates, if geolocation is enabled for your tenant</li>
|
||||
</ul>
|
||||
|
||||
|
||||
@@ -146,7 +146,6 @@ export default function SessionInfoCard({ session, enrollmentDuration, displaySt
|
||||
{configMgrDetected.ccmServiceState && configMgrDetected.ccmServiceState !== "not_found" && <> — {configMgrDetected.ccmServiceState}</>}
|
||||
{configMgrDetected.ccmVersion && configMgrDetected.ccmVersion !== "unknown" && <>, v{configMgrDetected.ccmVersion}</>}
|
||||
{configMgrDetected.siteCode && configMgrDetected.siteCode !== "unknown" && <>, site {configMgrDetected.siteCode}</>}.
|
||||
{" "}The SCCM agent may trigger reboots during ESP that disrupt the enrollment flow and cause unexpected event sequences (e.g., device events appearing in the account phase, AccountSetup never starting).
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -109,11 +109,12 @@ export function useSessionDerivedData(
|
||||
};
|
||||
}, [events]);
|
||||
|
||||
// Extract ConfigMgr co-management detection (if present)
|
||||
// Latest ConfigMgr client detection — the agent reports again when the client's state changes.
|
||||
// Only show badge when confidence >= 50 (directory-only is too weak).
|
||||
// Default to 100 for backward compat with old agent events that lack confidenceScore.
|
||||
const configMgrDetected = useMemo(() => {
|
||||
const evt = events.find(e => e.eventType === "configmgr_client_detected");
|
||||
const detections = events.filter(e => e.eventType === "configmgr_client_detected");
|
||||
const evt = detections[detections.length - 1];
|
||||
if (!evt?.data) return null;
|
||||
const confidence = (evt.data.confidenceScore as number) ?? 100;
|
||||
if (confidence < 50) return null;
|
||||
|
||||
@@ -222,6 +222,8 @@ interface TenantConfigContextValue {
|
||||
setEnrollmentSummaryLaunchRetrySeconds: (v: number) => void;
|
||||
enableRealmJoinWatcher: boolean;
|
||||
setEnableRealmJoinWatcher: (v: boolean) => void;
|
||||
enableConfigMgrWatcher: boolean;
|
||||
setEnableConfigMgrWatcher: (v: boolean) => void;
|
||||
handleSaveAgentSettings: () => void;
|
||||
handleResetAgentSettings: () => void;
|
||||
|
||||
@@ -432,6 +434,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
const [enrollmentSummaryBrandingImageUrl, setEnrollmentSummaryBrandingImageUrl] = useState("");
|
||||
const [enrollmentSummaryLaunchRetrySeconds, setEnrollmentSummaryLaunchRetrySeconds] = useState(120);
|
||||
const [enableRealmJoinWatcher, setEnableRealmJoinWatcher] = useState(false);
|
||||
const [enableConfigMgrWatcher, setEnableConfigMgrWatcher] = useState(false);
|
||||
const [keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp] = useState(false);
|
||||
|
||||
// Notification channels
|
||||
@@ -592,6 +595,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
setEnrollmentSummaryBrandingImageUrl(data.enrollmentSummaryBrandingImageUrl ?? "");
|
||||
setEnrollmentSummaryLaunchRetrySeconds(data.enrollmentSummaryLaunchRetrySeconds ?? 120);
|
||||
setEnableRealmJoinWatcher(data.enableRealmJoinWatcher ?? false);
|
||||
setEnableConfigMgrWatcher(data.enableConfigMgrWatcher ?? false);
|
||||
setKeepAwakeDuringUserEsp(data.keepAwakeDuringUserEsp ?? false);
|
||||
// Notification channels (auto-migrates from the legacy single-webhook fields)
|
||||
setNotificationChannels(channelsFromConfig(data));
|
||||
@@ -785,6 +789,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
enrollmentSummaryBrandingImageUrl: enrollmentSummaryBrandingImageUrl || undefined,
|
||||
enrollmentSummaryLaunchRetrySeconds,
|
||||
enableRealmJoinWatcher,
|
||||
enableConfigMgrWatcher,
|
||||
keepAwakeDuringUserEsp,
|
||||
// Notification channels are the authoritative config; the legacy single-webhook
|
||||
// fields are cleared on save so deleting the last channel can't resurrect a zombie
|
||||
@@ -853,7 +858,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds,
|
||||
contactEmail, companyName, enableGeoLocation, enableTimezoneAutoSet, enableDoGroupIdAutoSet, enableImeMatchLog, enableGatherRuleDebugLog, logLevel, showScriptOutput, showEnrollmentSummary,
|
||||
enrollmentSummaryTimeoutSeconds, enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
|
||||
enableRealmJoinWatcher, keepAwakeDuringUserEsp,
|
||||
enableRealmJoinWatcher, enableConfigMgrWatcher, keepAwakeDuringUserEsp,
|
||||
notificationChannels,
|
||||
slaTargetSuccessRate, slaTargetMaxDurationMinutes, slaTargetAppInstallSuccessRate,
|
||||
slaNotifyOnSuccessRateBreach, slaSuccessRateNotifyThreshold, slaNotifyOnDurationBreach,
|
||||
@@ -1226,6 +1231,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
setEnrollmentSummaryBrandingImageUrl(config.enrollmentSummaryBrandingImageUrl ?? "");
|
||||
setEnrollmentSummaryLaunchRetrySeconds(config.enrollmentSummaryLaunchRetrySeconds ?? 120);
|
||||
setEnableRealmJoinWatcher(config.enableRealmJoinWatcher ?? false);
|
||||
setEnableConfigMgrWatcher(config.enableConfigMgrWatcher ?? false);
|
||||
setKeepAwakeDuringUserEsp(config.keepAwakeDuringUserEsp ?? false);
|
||||
}, [config]);
|
||||
|
||||
@@ -1607,6 +1613,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
enrollmentSummaryBrandingImageUrl, setEnrollmentSummaryBrandingImageUrl,
|
||||
enrollmentSummaryLaunchRetrySeconds, setEnrollmentSummaryLaunchRetrySeconds,
|
||||
enableRealmJoinWatcher, setEnableRealmJoinWatcher,
|
||||
enableConfigMgrWatcher, setEnableConfigMgrWatcher,
|
||||
keepAwakeDuringUserEsp, setKeepAwakeDuringUserEsp,
|
||||
handleSaveAgentSettings, handleResetAgentSettings,
|
||||
|
||||
@@ -1690,7 +1697,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
enableGeoLocation, enableTimezoneAutoSet, enableDoGroupIdAutoSet, enableImeMatchLog, enableGatherRuleDebugLog,
|
||||
logLevel, showScriptOutput, showEnrollmentSummary, enrollmentSummaryTimeoutSeconds,
|
||||
enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
|
||||
enableRealmJoinWatcher, keepAwakeDuringUserEsp,
|
||||
enableRealmJoinWatcher, enableConfigMgrWatcher, keepAwakeDuringUserEsp,
|
||||
handleSaveAgentSettings, handleResetAgentSettings,
|
||||
enableLocalAdminAnalyzer, localAdminAllowedAccounts, newAllowedAccount,
|
||||
enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer, enableConsoleBypassDetection,
|
||||
|
||||
@@ -34,7 +34,7 @@ const EXPECTED_OWNED_FIELDS = [
|
||||
"contactEmail", "companyName",
|
||||
// agentSettings
|
||||
"enablePerformanceCollector", "performanceCollectorIntervalSeconds", "helloWaitTimeoutSeconds",
|
||||
"enableRealmJoinWatcher",
|
||||
"enableRealmJoinWatcher", "enableConfigMgrWatcher",
|
||||
"selfDestructOnComplete", "keepLogFile", "rebootOnComplete", "rebootDelaySeconds",
|
||||
"enableGeoLocation", "enableTimezoneAutoSet", "enableDoGroupIdAutoSet", "keepAwakeDuringUserEsp",
|
||||
"enableImeMatchLog", "enableGatherRuleDebugLog",
|
||||
|
||||
@@ -11,6 +11,7 @@ export function SectionAgentSettings() {
|
||||
performanceCollectorInterval, setPerformanceCollectorInterval,
|
||||
helloWaitTimeoutSeconds, setHelloWaitTimeoutSeconds,
|
||||
enableRealmJoinWatcher, setEnableRealmJoinWatcher,
|
||||
enableConfigMgrWatcher, setEnableConfigMgrWatcher,
|
||||
selfDestructOnComplete, setSelfDestructOnComplete,
|
||||
keepLogFile, setKeepLogFile,
|
||||
rebootOnComplete, setRebootOnComplete,
|
||||
@@ -43,6 +44,8 @@ export function SectionAgentSettings() {
|
||||
setHelloWaitTimeoutSeconds={setHelloWaitTimeoutSeconds}
|
||||
enableRealmJoinWatcher={enableRealmJoinWatcher}
|
||||
setEnableRealmJoinWatcher={setEnableRealmJoinWatcher}
|
||||
enableConfigMgrWatcher={enableConfigMgrWatcher}
|
||||
setEnableConfigMgrWatcher={setEnableConfigMgrWatcher}
|
||||
selfDestructOnComplete={selfDestructOnComplete}
|
||||
setSelfDestructOnComplete={setSelfDestructOnComplete}
|
||||
keepLogFile={keepLogFile}
|
||||
|
||||
@@ -20,6 +20,8 @@ interface AgentSettingsSectionProps {
|
||||
setHelloWaitTimeoutSeconds: (value: number) => void;
|
||||
enableRealmJoinWatcher: boolean;
|
||||
setEnableRealmJoinWatcher: (value: boolean) => void;
|
||||
enableConfigMgrWatcher: boolean;
|
||||
setEnableConfigMgrWatcher: (value: boolean) => void;
|
||||
selfDestructOnComplete: boolean;
|
||||
setSelfDestructOnComplete: (value: boolean) => void;
|
||||
keepLogFile: boolean;
|
||||
@@ -68,6 +70,8 @@ export default function AgentSettingsSection({
|
||||
setHelloWaitTimeoutSeconds,
|
||||
enableRealmJoinWatcher,
|
||||
setEnableRealmJoinWatcher,
|
||||
enableConfigMgrWatcher,
|
||||
setEnableConfigMgrWatcher,
|
||||
selfDestructOnComplete,
|
||||
setSelfDestructOnComplete,
|
||||
keepLogFile,
|
||||
@@ -423,6 +427,20 @@ export default function AgentSettingsSection({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ConfigMgr Watcher */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">ConfigMgr Watcher</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Shows in Session Info whether the Configuration Manager client is installed, with its service state, version and site code. Off by default; enable for tenants that co-manage devices with Configuration Manager.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEnableConfigMgrWatcher(!enableConfigMgrWatcher)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors ${enableConfigMgrWatcher ? 'bg-emerald-500' : 'bg-gray-300'}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enableConfigMgrWatcher ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hello Wait Timeout */}
|
||||
<div className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:border-emerald-200 transition-colors">
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -67,6 +67,7 @@ export const SECTION_FIELD_MAP = {
|
||||
"performanceCollectorIntervalSeconds",
|
||||
"helloWaitTimeoutSeconds",
|
||||
"enableRealmJoinWatcher",
|
||||
"enableConfigMgrWatcher",
|
||||
"selfDestructOnComplete",
|
||||
"keepLogFile",
|
||||
"rebootOnComplete",
|
||||
|
||||
@@ -130,6 +130,7 @@ export interface TenantConfiguration {
|
||||
enableSoftwareInventoryAnalyzer?: boolean;
|
||||
enableIntegrityBypassAnalyzer?: boolean;
|
||||
enableRealmJoinWatcher?: boolean;
|
||||
enableConfigMgrWatcher?: boolean;
|
||||
keepAwakeDuringUserEsp?: boolean;
|
||||
enableConsoleBypassDetection?: boolean;
|
||||
// Bootstrap token
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
*/
|
||||
export const PAGE_LASTMOD: Record<string, string> = {
|
||||
"/": "2026-10-12T00:18:39+02:00",
|
||||
"/about": "2026-10-12T00:18:39+02:00",
|
||||
"/about": "2026-10-12T00:52:03+02:00",
|
||||
"/ai": "2026-10-12T00:18:39+02:00",
|
||||
"/get-started": "2026-10-10T16:29:57+02:00",
|
||||
"/plans": "2026-10-10T16:29:57+02:00",
|
||||
"/buy": "2026-10-10T16:29:57+02:00",
|
||||
"/help": "2026-10-10T16:29:57+02:00",
|
||||
"/privacy": "2026-10-10T16:29:57+02:00",
|
||||
"/privacy": "2026-10-14T16:15:16+02:00",
|
||||
"/terms": "2026-10-10T16:29:57+02:00",
|
||||
};
|
||||
|
||||
@@ -199,6 +199,7 @@ export const SHARED_MANIFEST = {
|
||||
"enableSoftwareInventoryAnalyzer",
|
||||
"enableIntegrityBypassAnalyzer",
|
||||
"enableRealmJoinWatcher",
|
||||
"enableConfigMgrWatcher",
|
||||
"keepAwakeDuringUserEsp",
|
||||
"enableConsoleBypassDetection",
|
||||
"localAdminAllowedAccountsJson",
|
||||
@@ -304,6 +305,7 @@ export const SHARED_MANIFEST = {
|
||||
"enableSoftwareInventoryAnalyzer": null,
|
||||
"enableIntegrityBypassAnalyzer": null,
|
||||
"enableRealmJoinWatcher": null,
|
||||
"enableConfigMgrWatcher": null,
|
||||
"keepAwakeDuringUserEsp": null,
|
||||
"enableConsoleBypassDetection": null,
|
||||
"localAdminAllowedAccountsJson": null,
|
||||
@@ -446,6 +448,7 @@ export const SHARED_MANIFEST = {
|
||||
"enableSoftwareInventoryAnalyzer": false,
|
||||
"enableIntegrityBypassAnalyzer": true,
|
||||
"enableRealmJoinWatcher": false,
|
||||
"enableConfigMgrWatcher": false,
|
||||
"keepAwakeDuringUserEsp": false,
|
||||
"enableConsoleBypassDetection": true
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@
|
||||
"enableSoftwareInventoryAnalyzer",
|
||||
"enableIntegrityBypassAnalyzer",
|
||||
"enableRealmJoinWatcher",
|
||||
"enableConfigMgrWatcher",
|
||||
"keepAwakeDuringUserEsp",
|
||||
"enableConsoleBypassDetection",
|
||||
"localAdminAllowedAccountsJson",
|
||||
@@ -300,6 +301,7 @@
|
||||
"enableSoftwareInventoryAnalyzer": null,
|
||||
"enableIntegrityBypassAnalyzer": null,
|
||||
"enableRealmJoinWatcher": null,
|
||||
"enableConfigMgrWatcher": null,
|
||||
"keepAwakeDuringUserEsp": null,
|
||||
"enableConsoleBypassDetection": null,
|
||||
"localAdminAllowedAccountsJson": null,
|
||||
@@ -442,6 +444,7 @@
|
||||
"enableSoftwareInventoryAnalyzer": false,
|
||||
"enableIntegrityBypassAnalyzer": true,
|
||||
"enableRealmJoinWatcher": false,
|
||||
"enableConfigMgrWatcher": false,
|
||||
"keepAwakeDuringUserEsp": false,
|
||||
"enableConsoleBypassDetection": true
|
||||
}
|
||||
@@ -2945,6 +2948,14 @@
|
||||
},
|
||||
"doc": "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)."
|
||||
},
|
||||
{
|
||||
"name": "enableConfigMgrWatcher",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "boolean"
|
||||
},
|
||||
"doc": "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)."
|
||||
},
|
||||
{
|
||||
"name": "keepAwakeDuringUserEsp",
|
||||
"type": {
|
||||
@@ -14655,6 +14666,15 @@
|
||||
"name": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stopReason",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "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."
|
||||
},
|
||||
{
|
||||
"name": "lines",
|
||||
"type": {
|
||||
@@ -24639,6 +24659,16 @@
|
||||
"optional": true,
|
||||
"doc": "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)."
|
||||
},
|
||||
{
|
||||
"name": "enableConfigMgrWatcher",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "boolean",
|
||||
"nullable": true
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "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)."
|
||||
},
|
||||
{
|
||||
"name": "keepAwakeDuringUserEsp",
|
||||
"type": {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
+107
-95
@@ -16,7 +16,7 @@
|
||||
"fflate": "^0.8.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^16.4.0",
|
||||
"next": "^16.4.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
@@ -37,7 +37,7 @@
|
||||
"postcss": "^8.5.12",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^4.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": "22.x"
|
||||
@@ -1373,9 +1373,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.0.tgz",
|
||||
"integrity": "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==",
|
||||
"version": "16.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.1.tgz",
|
||||
"integrity": "sha512-5sUUrrSvs/2/2LnLoesybMGx12EtPB46fdZ6RhAw0XDGSPxU2LFyQBB7yuEiOFFJPKmBY6+q2jTr1/AMbnp0/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -1390,9 +1390,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.0.tgz",
|
||||
"integrity": "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==",
|
||||
"version": "16.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.1.tgz",
|
||||
"integrity": "sha512-U+hQ5Gj3fpzf4fveTIF3dpUFYhaSW8vHhdAnbiIomzXzg7boreYFgBqprepXFtW4sw+aTttiNmg0gew/iFNhbg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1406,9 +1406,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.0.tgz",
|
||||
"integrity": "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==",
|
||||
"version": "16.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.1.tgz",
|
||||
"integrity": "sha512-/hqC3V1XLUjhMASf/WridKdO9FXoEgN3HBWmtKbj00MPgnltutfraWJ3bYTpCRIgEIpbjJyOVLpYgAhh/BkdcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1422,12 +1422,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.0.tgz",
|
||||
"integrity": "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==",
|
||||
"version": "16.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.1.tgz",
|
||||
"integrity": "sha512-GHzncHbcaS9g95kcvTNtKQW3aCLoR+xc3Jm7qrkpk1R+D2BjMHIPyvQGRPKCdMX95BsH/421jBUK06q9tEwzjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1438,12 +1441,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.0.tgz",
|
||||
"integrity": "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==",
|
||||
"version": "16.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.1.tgz",
|
||||
"integrity": "sha512-1SuJyDxW/PR9dHJat/ZRgcYLhuSZ+1psP4DlKb2tKIuXbjygqpLrJWRSq+onZQBv8LcmU67EIrmb6c3XTaJLjw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1454,12 +1460,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.0.tgz",
|
||||
"integrity": "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==",
|
||||
"version": "16.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.1.tgz",
|
||||
"integrity": "sha512-Istng1hMGp9TGO4lUqeKLd70MPN/OOkucVTP2Nmj+gLkwBgmPqUw8CJYBCC7nbl/B/23bBCcjCI5Hsh6hazC1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1470,12 +1479,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.0.tgz",
|
||||
"integrity": "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==",
|
||||
"version": "16.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.1.tgz",
|
||||
"integrity": "sha512-aSC9wMNiZRRFVpPkPWBzRwLQxIGm/CiQcXyFF1XNcgodb+uMxdk7aSSGGpDXCbnvZdf4itjHxpOd5h7i7zaiYw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1486,9 +1498,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.0.tgz",
|
||||
"integrity": "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==",
|
||||
"version": "16.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.1.tgz",
|
||||
"integrity": "sha512-t/myvwFDlypMUjRvbBf8qLPyuaB0iXYYdBtwMt6DM6f+60wtGr4bSYholdPqNeV5VUXZhrpMwfza829kmhS3iA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3237,16 +3249,16 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
|
||||
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
|
||||
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -3255,13 +3267,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
|
||||
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
|
||||
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -3282,9 +3294,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
|
||||
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
|
||||
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3295,13 +3307,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
|
||||
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
|
||||
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -3309,14 +3321,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
|
||||
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
|
||||
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -3325,9 +3337,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
|
||||
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
|
||||
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -3335,13 +3347,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
|
||||
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
|
||||
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -3963,9 +3975,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
||||
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4473,9 +4485,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.16",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
|
||||
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
|
||||
"version": "3.4.17",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz",
|
||||
"integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
@@ -7036,12 +7048,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.4.0.tgz",
|
||||
"integrity": "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==",
|
||||
"version": "16.4.1",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.4.1.tgz",
|
||||
"integrity": "sha512-uc657V62E34fOI6W5NtG9IzwsPcms0Z60grz2MJGtglgD+pTFnNt4Yw2PzaU622tfX4vhrDgLZzicE2YlhI20Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.4.0",
|
||||
"@next/env": "16.4.1",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
@@ -7055,15 +7067,15 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.4.0",
|
||||
"@next/swc-darwin-x64": "16.4.0",
|
||||
"@next/swc-linux-arm64-gnu": "16.4.0",
|
||||
"@next/swc-linux-arm64-musl": "16.4.0",
|
||||
"@next/swc-linux-x64-gnu": "16.4.0",
|
||||
"@next/swc-linux-x64-musl": "16.4.0",
|
||||
"@next/swc-win32-arm64-msvc": "16.4.0",
|
||||
"@next/swc-win32-x64-msvc": "16.4.0",
|
||||
"sharp": "^0.35.4"
|
||||
"@next/swc-darwin-arm64": "16.4.1",
|
||||
"@next/swc-darwin-x64": "16.4.1",
|
||||
"@next/swc-linux-arm64-gnu": "16.4.1",
|
||||
"@next/swc-linux-arm64-musl": "16.4.1",
|
||||
"@next/swc-linux-x64-gnu": "16.4.1",
|
||||
"@next/swc-linux-x64-musl": "16.4.1",
|
||||
"@next/swc-win32-arm64-msvc": "16.4.1",
|
||||
"@next/swc-win32-x64-msvc": "16.4.1",
|
||||
"sharp": "^0.35.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
@@ -7089,9 +7101,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next/node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.0.tgz",
|
||||
"integrity": "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==",
|
||||
"version": "16.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.1.tgz",
|
||||
"integrity": "sha512-6ICqPo9awCkrtYNPhwkx1uU7rlFWrZRrIboDHS2LU+jv088UBYQtmJ25h6pCp27/+FZjF6BQ4Te4ErugyMeuBg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -8965,9 +8977,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
|
||||
"integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
|
||||
"integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -9448,19 +9460,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.11",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
|
||||
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
|
||||
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.11",
|
||||
"@vitest/mocker": "4.1.11",
|
||||
"@vitest/pretty-format": "4.1.11",
|
||||
"@vitest/runner": "4.1.11",
|
||||
"@vitest/snapshot": "4.1.11",
|
||||
"@vitest/spy": "4.1.11",
|
||||
"@vitest/utils": "4.1.11",
|
||||
"@vitest/expect": "4.1.12",
|
||||
"@vitest/mocker": "4.1.12",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/runner": "4.1.12",
|
||||
"@vitest/snapshot": "4.1.12",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -9488,12 +9500,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.11",
|
||||
"@vitest/browser-preview": "4.1.11",
|
||||
"@vitest/browser-webdriverio": "4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.11",
|
||||
"@vitest/coverage-v8": "4.1.11",
|
||||
"@vitest/ui": "4.1.11",
|
||||
"@vitest/browser-playwright": "4.1.12",
|
||||
"@vitest/browser-preview": "4.1.12",
|
||||
"@vitest/browser-webdriverio": "^4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.12",
|
||||
"@vitest/coverage-v8": "4.1.12",
|
||||
"@vitest/ui": "4.1.12",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"fflate": "^0.8.3",
|
||||
"jspdf": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^16.4.0",
|
||||
"next": "^16.4.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
@@ -48,7 +48,7 @@
|
||||
"postcss": "^8.5.12",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^4.1.12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-win32-x64-msvc": "npm:null@*"
|
||||
|
||||
Reference in New Issue
Block a user