Compare commits

...

15 Commits

Author SHA1 Message Date
Oliver Kieselbach d95640c597 Agent: protected DACL on the agent data directory (D-355)
%ProgramData%\AutopilotMonitor inherited ProgramData's "Users may create files and
folders" entry, so a standard user could add files to the folder the SYSTEM agent and its
boot task start from. The agent outlives the device phase of OOBE (user phase, desktop,
reboots until the enrollment ends), and the payload catalog is checked at install time
only.

- AgentDirectoryAcl.EnsureProtected: protected DACL, SYSTEM and Administrators full
  control, Users read and execute, inherited by every subfolder; idempotent, never blocks
  the agent.
- Install mode applies it after the agent directories exist; every runtime start
  re-checks it, which also reaches agents that arrived by self-update.
- Tests: protected shape, apply once then leave alone, existing subfolders take it over,
  a widened or unprotected DACL is not taken as protected, a missing folder is not created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:26:44 +02:00
Oliver Kieselbach b5e9f45a46 bump pointer: internal (payload catalog lab VM result)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:57:58 +02:00
Oliver Kieselbach b4dabcce94 bump pointer: internal (payload catalog dev rehearsal)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:32:25 +02:00
Oliver Kieselbach 6bbdcc0313 bump pointer: internal (agent payload catalog plan status)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:09:51 +02:00
Oliver Kieselbach 283c094986 Agent payload: signed catalog, vendor check, dev channel in CI (D-354, phase A)
- build-agent.yml: input channel (release | dev; dev writes the lab blobs only and
  refuses stable/release). Every third-party PE needs its vendor's embedded Authenticode
  signature (signtool verify /pa, read-only). AutopilotMonitor.Agent.cat, a signed
  SHA-256 catalog over every payload file, is created after Authenticode and before the
  ZIP; the device check runs under Windows PowerShell 5.1 on the final ZIP.
- Agent: SelfUpdater step 5b verifies the staged payload against the catalog before any
  file is swapped (PayloadCatalogVerifier, Test-FileCatalog in-process; a rejection means
  no update, skip reason catalog_rejected). agent_started reports which bootstrap
  installed the agent, read from the bootstrap logs (BootstrapProvenanceReader).
- Payload: System.Management.Automation.dll (never loaded, the GAC copy binds first) and
  AutopilotMonitor.Shared.xml no longer ship; PDBs stay.
- Shared: CodeSigningPublisherPattern, AgentPayloadCatalogFileName;
  CodeSigningParityTests pins them against installer, loader and workflow.
- Submodules: infra (build.ps1 local only), internal (D-354, plan, docs), commands.

Installer v2.6 and the OOBE script follow once a catalog ZIP is on stable: a merged
installer change is live on the next loader run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:48:29 +02:00
Oliver Kieselbach 47728fbcd4 bump pointer: internal (external identities released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:44:18 +02:00
Oliver Kieselbach d5f885c4b4 bump pointer: internal (external identities never claim a tenant, D-353)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:21:38 +02:00
Oliver Kieselbach dd4975b73e Auth: an external identity never claims a tenant (D-353)
A caller whose token shows it is not a member of the token's tenant (acct = 1, a #EXT# user name,
or an idp naming another tenant, a personal Microsoft account or an external sign-in provider) no
longer seeds DomainName/OnboardedBy or queues the activation, is never the first-user Tenant Admin,
and cannot set the activation-page address in the roleless signup window. Positive evidence only:
a token without these signals stays a member's, and tid/idp are read under their inbound-mapped
names as well. Activation promotes OnboardedBy only; the UpdatedBy fallback is gone and a stored
#EXT# UPN is refused. Explicit grants (TenantAdmins row, app role) still apply.

The once-per-interval log gate of the client-app binding becomes Helpers/LogThrottle and also
throttles the new warning, which the polling activation page would otherwise repeat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:19:56 +02:00
Oliver Kieselbach 18310b05aa bump pointer: internal (MCP input bounds released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:29:24 +02:00
Oliver Kieselbach 40894ed5e7 bump pointer: internal (MCP input bounds, D-352)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:10:02 +02:00
Oliver Kieselbach 6894e71668 MCP: bound caller-sized search input; scoring cost no longer grows with the keyword count
search_events, search_knowledge and search_docs take a query of at most 1,000
characters; search_events takes at most 20 keywords of 100 characters and
scores at most 32 distinct keywords, caller keywords first, reporting the rest
as keywordsDropped with a note. The error-code fallback takes at most 10 codes,
search_docs.section at most 100 characters.

The event scorer compiles the query once (compileConcepts): each event field is
split into words once and its words are looked up by the key of their first
three characters, so scoring an event costs the same whatever the number of
keywords. The matching rule is unchanged; a seeded differential test pins it
against the per-keyword rule. scoreEvents yields to the event loop every 256
events and stops once the caller has gone.

Every paginated tool's continuation goes through one ContinuationSchema
(at most 4,096 characters), and buildQuery appends instead of set: both
URLSearchParams.set paths were quadratic in caller-sized input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:07:28 +02:00
Oliver Kieselbach a28fdc874f bump pointer: internal (watch at the seal + app badge released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:45:24 +02:00
Oliver Kieselbach 7480a2e218 Push receiver: an app badge counts unread alerts; an open history reads them
Every stored history entry starts unread. After a new entry the worker sets the
app badge to the unread count through applyBadge, fire and forget: inside
Safari's declarative push event the setAppBadge promise never settles, so it is
never awaited and never handed to waitUntil (pushSw.test.ts pins it). The
history page marks everything read on every visible load, clears the badge,
closes the notifications whose entries the history holds and shows "New" on
what arrived since the last visit. Unpair clears the badge; the wipe push
leaves it to the next open. The payload repeats mutable inside notification,
where Safari 18.4-26.1 reads it, and carries no app_badge.

Bumps internal (plan, D-350, D-351, concept doc).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:22:56 +02:00
Oliver Kieselbach 96fc88462d Push: the session watch fires at the WhiteGlove seal
"Notify me when done" now also fires on the first genuine pre-provisioning
seal ("Pre-provisioning finished") on the gate of the Pre-Provisioning
Completed alert, and the watch is consumed there; watching the user phase takes
a new click. A watch placed on a sealed session lives 30 days instead of 48
hours. EventIngestProcessor.WatchOutcomeFor is the one gate, the watch text
comes from PushSessionWatchService.WatchMessage. The portal button re-reads the
watch on every status change and names what it waits for. The Success hint
says it covers a finished pre-provisioning; the receiver labels the
preprovisioning_* alerts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:22:44 +02:00
Oliver Kieselbach 6b86549fd0 bump pointers: internal, .claude/commands (client-cert interception released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:30:15 +02:00
66 changed files with 3022 additions and 375 deletions
+197 -4
View File
@@ -7,6 +7,14 @@ on:
description: 'Build number override (leave empty to auto-increment from buildcounter.txt)'
required: false
type: string
channel:
description: 'release = versioned line (+ stable on cutover). dev = lab channel only (Agent-v2-dev.zip, version-v2-dev.json, Agent-dev.zip, version-dev.json); needs publish_as_stable=false and create_release=false'
required: true
type: choice
options:
- release
- dev
default: release
upload_blob:
description: 'Upload to versioned namespace (Agent-v2.zip + version-v2.json)'
required: true
@@ -60,6 +68,15 @@ env:
VERSIONED_ZIP: AutopilotMonitor-Agent-v2.zip
STABLE_ZIP: AutopilotMonitor-Agent.zip
STABLE_MANIFEST: version.json
# Lab channel (channel=dev): the names the dev renders of the bootstrap scripts read.
CHANNEL: ${{ inputs.channel }}
DEV_VERSIONED_ZIP: AutopilotMonitor-Agent-v2-dev.zip
DEV_MANIFEST: version-v2-dev.json
DEV_STABLE_ZIP: AutopilotMonitor-Agent-dev.zip
DEV_STABLE_MANIFEST: version-dev.json
# Signed catalog over every other file of the payload. Must equal
# Constants.AgentPayloadCatalogFileName; CodeSigningParityTests keeps them in step.
PAYLOAD_CATALOG: AutopilotMonitor.Agent.cat
# Authenticode via Azure Key Vault; identity and vault come from repository variables, the
# timestamp server (CODE_SIGN_TIMESTAMP_URL) from the endpoint registry like every host here.
# CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify step.
@@ -106,6 +123,22 @@ jobs:
}
Write-Host "Code-signing configuration present (certificate '$env:CODE_SIGN_CERTIFICATE_NAME')"
# The dev channel is a lab channel: it never touches stable, AdminConfiguration, the
# bootstrap scripts or a GitHub Release. Refused here, before a build number is burnt.
- name: Check channel inputs
shell: pwsh
env:
PUBLISH_AS_STABLE: ${{ inputs.publish_as_stable }}
CREATE_RELEASE: ${{ inputs.create_release }}
run: |
if ($env:CHANNEL -notin @('release', 'dev')) {
throw "Unknown channel '$($env:CHANNEL)'."
}
if ($env:CHANNEL -eq 'dev' -and ($env:PUBLISH_AS_STABLE -eq 'true' -or $env:CREATE_RELEASE -eq 'true')) {
throw "channel=dev publishes the lab channel only: run it with publish_as_stable=false and create_release=false."
}
Write-Host "Channel: $env:CHANNEL"
- name: Login to Azure (OIDC)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
@@ -222,7 +255,8 @@ jobs:
retention-days: 1
# 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.
# 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.
#
# No `environment:` yet: the code-signing credential is still bound to the ref, and a job
# carries exactly one OIDC subject, so an environment here would break the signing login.
@@ -353,8 +387,7 @@ jobs:
throw "azuresigntool failed with exit code $LASTEXITCODE -- aborting before anything is published."
}
- name: Create release artifact
id: artifact
- name: Stage release payload
shell: pwsh
run: |
$version = $env:AGENT_VERSION
@@ -399,6 +432,98 @@ jobs:
}
Write-Host "Version check passed: EXE = manifest = reserved = $version"
# Third-party code enters the payload only with its vendor's own signature: every PE that
# is not ours needs a valid EMBEDDED Authenticode signature from an allowed vendor. Embedded
# only (signtool verify /pa, no /a): Get-AuthenticodeSignature prefers the runner's Windows
# catalogs and reports "Microsoft Windows" for files Windows happens to ship as well. An
# unsigned file or a new vendor stops the release until a person decides. This guards the
# bytes we did not write; a build that alters our own code is D-346's remaining risk.
- name: Check third-party signatures
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$allowedVendors = @('Microsoft Corporation', 'Json.NET (.NET Foundation)')
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" -ErrorAction SilentlyContinue |
Where-Object { $_.Directory.Parent.Name -match '^\d+(\.\d+){1,3}$' } |
Sort-Object { [version]$_.Directory.Parent.Name } -Descending |
Select-Object -First 1
if (-not $signtool) { throw "signtool.exe not found in the runner image's Windows SDK." }
Write-Host "Using $($signtool.FullName)"
$failed = $false
$checked = 0
foreach ($file in Get-ChildItem -Path 'staging/agent' -Recurse -File) {
# A PE by content (MZ header), not by extension.
$head = [byte[]]::new(2)
$stream = [System.IO.File]::OpenRead($file.FullName)
try { $read = $stream.Read($head, 0, 2) } finally { $stream.Dispose() }
if ($read -lt 2 -or $head[0] -ne 0x4D -or $head[1] -ne 0x5A) { continue }
if ($file.Name -like 'AutopilotMonitor.*') { continue } # ours: the verify step pins them
$checked++
$output = & $signtool.FullName verify /pa $file.FullName 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host "::error file=$($file.Name)::No valid embedded vendor signature. $(($output | Out-String).Trim())"
$failed = $true
continue
}
$subject = [System.Security.Cryptography.X509Certificates.X509Certificate]::CreateFromSignedFile($file.FullName).Subject
$vendor = if ($subject -match '(?:^|,\s*)O=("[^"]*"|[^,]*)') { $Matches[1].Trim('"') } else { '' }
if ($vendor -notin $allowedVendors) {
Write-Host "::error file=$($file.Name)::Signed by '$subject' -- vendor '$vendor' is not on the allowlist."
$failed = $true
continue
}
Write-Host "$($file.Name): embedded signature valid, vendor $vendor"
}
if ($failed) { throw "Third-party signature check failed. Nothing was published." }
Write-Host "All $checked third-party PE files carry a valid signature from an allowed vendor."
# The payload contract: one catalog, signed like our binaries, holding the SHA-256 of every
# other file in the ZIP. Installer, OOBE script and self-updater accept a payload only when
# this catalog is ours and matches the extracted files exactly. Created AFTER signing and
# BEFORE the ZIP -- the same load-bearing position as the signing step above.
- name: Create and sign payload catalog
shell: pwsh
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: |
$ErrorActionPreference = 'Stop'
$stagingDir = 'staging/agent'
$catalog = Join-Path $env:RUNNER_TEMP $env:PAYLOAD_CATALOG
if (Test-Path (Join-Path $stagingDir $env:PAYLOAD_CATALOG)) {
throw "$stagingDir already contains $env:PAYLOAD_CATALOG."
}
New-FileCatalog -Path $stagingDir -CatalogFilePath $catalog -CatalogVersion 2.0 | Out-Null
& azuresigntool.exe sign --verbose `
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
--file-digest sha256 `
--description "Autopilot Monitor Agent" `
--description-url "$env:WWW_BASE_URL" `
$catalog
if ($LASTEXITCODE -ne 0) {
throw "azuresigntool failed on the payload catalog with exit code $LASTEXITCODE -- aborting before anything is published."
}
Copy-Item -Path $catalog -Destination $stagingDir
$count = (Test-FileCatalog -Path $stagingDir -CatalogFilePath $catalog -FilesToSkip $env:PAYLOAD_CATALOG -Detailed).CatalogItems.Count
Write-Host "Payload catalog signed: $count files"
- name: Create release artifact
id: artifact
shell: pwsh
run: |
$version = $env:AGENT_VERSION
$stagingDir = "staging/agent"
$manifest = Get-Content "$env:AGENT_OUTPUT/$env:MANIFEST_NAME" -Raw | ConvertFrom-Json
$versionedZip = "AutopilotMonitor-Agent-v$version.zip"
Compress-Archive -Path "$stagingDir/*" -DestinationPath $versionedZip
Write-Host "Created GitHub-Release artifact: $versionedZip"
@@ -488,6 +613,62 @@ jobs:
}
Write-Host "All $($files.Count) binaries signed with certificate $($thumbprints.Keys -join ', ')"
# The devices' check, run on the FINAL ZIP rather than the staging folder: whatever the
# archiver did (Compress-Archive skips hidden files) fails here instead of on a device.
# Windows PowerShell 5.1, the edition installer and OOBE script verify with; the pin is
# the installer's own literal, the value devices compare against.
- name: Verify payload catalog (device check)
shell: powershell
env:
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
run: |
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.IO.Compression.FileSystem
$payload = Join-Path $env:RUNNER_TEMP 'verify-payload'
if (Test-Path $payload) { throw "$payload already exists." }
[System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $env:RELEASE_ZIP).Path, $payload)
$pinLine = Select-String -Path $env:BOOTSTRAP_SOURCE -Pattern '^\$ExpectedPublisher = "([^"]+)"' | Select-Object -First 1
if (-not $pinLine) { throw "Could not read the publisher pin from $env:BOOTSTRAP_SOURCE." }
$pin = $pinLine.Matches[0].Groups[1].Value
if (-not [string]::IsNullOrWhiteSpace($env:CODE_SIGN_EXPECTED_SUBJECT) -and $pin -ne $env:CODE_SIGN_EXPECTED_SUBJECT) {
throw "Installer pin '$pin' differs from CODE_SIGN_EXPECTED_SUBJECT '$env:CODE_SIGN_EXPECTED_SUBJECT'."
}
$catalog = Join-Path $payload $env:PAYLOAD_CATALOG
if (-not (Test-Path $catalog -PathType Leaf)) { throw "$env:PAYLOAD_CATALOG is missing from the ZIP." }
$result = Test-FileCatalog -Path $payload -CatalogFilePath $catalog -FilesToSkip $env:PAYLOAD_CATALOG -Detailed
$problems = @()
if ($result.Status -ne 'Valid') {
$expected = $result.CatalogItems
$actual = $result.PathItems
$missing = @($expected.Keys | Where-Object { -not $actual.ContainsKey($_) })
$extra = @($actual.Keys | Where-Object { -not $expected.ContainsKey($_) })
$changed = @($expected.Keys | Where-Object { $actual.ContainsKey($_) -and $actual[$_] -ne $expected[$_] })
$problems += "payload differs from the catalog (missing: $($missing -join ', '); extra: $($extra -join ', '); changed: $($changed -join ', '))"
}
$signature = $result.Signature
if ($signature.Status -ne 'Valid') {
$problems += "catalog signature status is '$($signature.Status)'"
} elseif ($signature.SignerCertificate.Subject -notlike $pin) {
$problems += "catalog signed by '$($signature.SignerCertificate.Subject)', expected '$pin'"
}
if (-not $signature.TimeStamperCertificate) {
$problems += 'catalog signature carries no RFC-3161 timestamp -- it would expire with the certificate'
}
$exeSigner = (Get-AuthenticodeSignature (Join-Path $payload 'AutopilotMonitor.Agent.exe')).SignerCertificate
if ($signature.SignerCertificate -and $exeSigner -and $signature.SignerCertificate.Thumbprint -ne $exeSigner.Thumbprint) {
$problems += 'catalog and agent exe carry different signing certificates'
}
if ($problems.Count -gt 0) {
$problems | ForEach-Object { Write-Host "::error::$_" }
throw "Payload catalog verification failed. Nothing was published."
}
Write-Host "Payload catalog verified on the final ZIP: $($result.CatalogItems.Count) files, signer $($signature.SignerCertificate.Subject)"
- name: Upload to Azure Blob Storage
if: inputs.upload_blob
shell: pwsh
@@ -531,6 +712,18 @@ jobs:
}
}
# Dev channel: lab blobs only, under the names the dev renders of the bootstrap scripts
# read. Nothing else is touched -- no versioned or stable line, no AdminConfiguration, no
# scripts; the channel check in `reserve` already refused publish_as_stable/create_release.
if ($env:CHANNEL -eq 'dev') {
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:DEV_VERSIONED_ZIP -ContentType "application/zip"
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:DEV_MANIFEST -ContentType "application/json"
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:DEV_STABLE_ZIP -ContentType "application/zip"
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:DEV_STABLE_MANIFEST -ContentType "application/json"
Write-Host "DEV CHANNEL: lab blobs written, nothing else touched."
exit 0
}
# Versioned namespace (always)
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:VERSIONED_ZIP -ContentType "application/zip"
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:MANIFEST_NAME -ContentType "application/json"
@@ -636,7 +829,7 @@ jobs:
- **Published as stable:** ${{ inputs.publish_as_stable }}
### Provenance
The executables are Authenticode-signed and timestamped. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
The executables are Authenticode-signed and timestamped. Every file in the ZIP is listed in `AutopilotMonitor.Agent.cat`, a catalog signed with the same certificate, and third-party libraries carry their vendor's signature. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
```
gh attestation verify AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
```
+1 -1
Submodule infra updated: 0af4689368...0085deab45
@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
{
/// <summary>
/// The bootstrap provenance in <c>agent_started</c>. Fixture lines are the scripts' real
/// formats: installer <c>[ts] Bootstrap script version: vX.Y</c>
/// (Install-AutopilotMonitor.ps1), loader <c>[ts] === Autopilot Monitor bootstrap loader vX.Y
/// started (user=..., pid=...) ===</c> (Start-AutopilotMonitor.ps1).
/// </summary>
public sealed class BootstrapProvenanceReaderTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "apm-provenance-" + Guid.NewGuid().ToString("N"));
public BootstrapProvenanceReaderTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ }
}
private void Write(string name, params string[] lines) =>
File.WriteAllText(Path.Combine(_dir, name), string.Join("\r\n", lines) + "\r\n", Encoding.ASCII);
private static string InstallerRun(string version) => $"[2026-10-13 09:00:00] Bootstrap script version: v{version}";
private static string LoaderRun(string version) =>
$"[2026-10-13 08:59:58] === Autopilot Monitor bootstrap loader v{version} started (user=SECRETUSER, pid=4242) ===";
[Fact]
public void Loader_path_reports_installer_and_loader_versions()
{
Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.2"), "[2026-10-13 08:59:59] Installer signature verified");
Write(BootstrapProvenanceReader.InstallerLogName, "[2026-10-13 09:00:00] ===== Autopilot Monitor Bootstrap Started =====", InstallerRun("2.5"));
var p = BootstrapProvenanceReader.Read(_dir);
Assert.True(p.ScriptLogPresent);
Assert.Equal("2.5", p.ScriptVersion);
Assert.Equal("loader", p.LoaderLog);
Assert.Equal("1.2", p.LoaderVersion);
Assert.False(p.OobeLogPresent);
}
[Fact]
public void The_last_run_wins_when_the_log_grew_over_several_runs()
{
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.3"), "x", InstallerRun("2.4"), "y", InstallerRun("2.5"), "z");
Assert.Equal("2.5", BootstrapProvenanceReader.Read(_dir).ScriptVersion);
}
[Fact]
public void A_directly_assigned_installer_has_no_loader_log()
{
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.4"));
var p = BootstrapProvenanceReader.Read(_dir);
Assert.Equal("2.4", p.ScriptVersion);
Assert.Null(p.LoaderLog);
Assert.Null(p.LoaderVersion);
}
[Fact]
public void An_installer_from_before_the_version_line_is_present_but_without_version()
{
Write(BootstrapProvenanceReader.InstallerLogName, "[2026-03-01 09:00:00] ===== Autopilot Monitor Bootstrap Started =====");
var p = BootstrapProvenanceReader.Read(_dir);
Assert.True(p.ScriptLogPresent);
Assert.Null(p.ScriptVersion);
}
[Fact]
public void The_msi_loader_log_is_told_apart_from_the_intune_loader()
{
Write(BootstrapProvenanceReader.MsiLoaderLogName, LoaderRun("1.1"));
var p = BootstrapProvenanceReader.Read(_dir);
Assert.Equal("msi", p.LoaderLog);
Assert.Equal("1.1", p.LoaderVersion);
}
[Fact]
public void With_both_loader_logs_the_most_recently_written_one_counts()
{
Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.0"));
Write(BootstrapProvenanceReader.MsiLoaderLogName, LoaderRun("1.2"));
File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.LoaderLogName), DateTime.UtcNow.AddDays(-3));
File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.MsiLoaderLogName), DateTime.UtcNow.AddMinutes(-1));
var p = BootstrapProvenanceReader.Read(_dir);
Assert.Equal("msi", p.LoaderLog);
Assert.Equal("1.2", p.LoaderVersion);
}
[Fact]
public void The_oobe_go_script_is_recognised_by_its_log()
{
Write(BootstrapProvenanceReader.OobeLogName, "[2026-10-13 09:00:00] ===== Autopilot Monitor OOBE Bootstrap =====");
Assert.True(BootstrapProvenanceReader.Read(_dir).OobeLogPresent);
}
[Fact]
public void Only_the_tail_of_a_large_log_is_read()
{
var filler = Enumerable.Repeat(new string('.', 200), 600).ToArray(); // ~120 KB
var lines = new List<string> { InstallerRun("2.1") };
lines.AddRange(filler);
lines.Add(InstallerRun("2.5"));
Write(BootstrapProvenanceReader.InstallerLogName, lines.ToArray());
Assert.Equal("2.5", BootstrapProvenanceReader.Read(_dir).ScriptVersion);
}
[Fact]
public void A_missing_directory_reads_as_unknown_and_never_throws()
{
var p = BootstrapProvenanceReader.Read(Path.Combine(_dir, "does-not-exist"));
Assert.False(p.ScriptLogPresent);
Assert.Null(p.ScriptVersion);
Assert.Null(p.LoaderLog);
Assert.False(p.OobeLogPresent);
}
[Fact]
public void The_event_keys_carry_versions_only_never_the_account_name()
{
Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.2"));
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.5"));
var data = new Dictionary<string, object>();
BootstrapProvenanceReader.Read(_dir).AddTo(data);
Assert.Equal(true, data["bootstrapScriptLogPresent"]);
Assert.Equal("2.5", data["bootstrapScriptVersion"]);
Assert.Equal("loader", data["bootstrapLoaderLog"]);
Assert.Equal("1.2", data["bootstrapLoaderVersion"]);
Assert.Equal(false, data["oobeBootstrapLogPresent"]);
Assert.DoesNotContain(data.Values, v => v is string s && s.Contains("SECRETUSER"));
}
[Fact]
public void Keys_that_were_not_found_are_left_out()
{
var data = new Dictionary<string, object>();
BootstrapProvenanceReader.Read(_dir).AddTo(data);
Assert.Equal(new[] { "bootstrapScriptLogPresent", "oobeBootstrapLogPresent" }, data.Keys.OrderBy(k => k).ToArray());
}
}
}
@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
{
/// <summary>
/// Step 5b of the self-update: the staged payload must match its signed catalog before a
/// single file is swapped in. The verifier is replaced through the static hook; each test
/// restores it, as in <see cref="SelfUpdaterGracefulShutdownTests"/>.
/// </summary>
public sealed class SelfUpdaterPayloadCatalogTests : IDisposable
{
private const string StagingDir = @"C:\ProgramData\AutopilotMonitor\Agent-Update";
private readonly Func<string, PayloadCatalogVerdict> _savedVerifier;
private readonly List<string> _log = new List<string>();
public SelfUpdaterPayloadCatalogTests()
{
_savedVerifier = SelfUpdater.VerifyPayloadCatalog;
}
public void Dispose() => SelfUpdater.VerifyPayloadCatalog = _savedVerifier;
[Fact]
public void Production_default_is_the_real_verifier()
{
Assert.Equal(typeof(PayloadCatalogVerifier), _savedVerifier.Method.DeclaringType);
Assert.Equal(nameof(PayloadCatalogVerifier.Verify), _savedVerifier.Method.Name);
}
[Fact]
public void Accepted_payload_lets_the_update_continue()
{
string? checkedDir = null;
SelfUpdater.VerifyPayloadCatalog = dir => { checkedDir = dir; return PayloadCatalogVerdict.Accept(); };
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
Assert.True(ok);
Assert.Null(reason);
Assert.Equal(StagingDir, checkedDir);
Assert.Contains(_log, line => line.Contains("payload catalog verified"));
}
[Fact]
public void Rejected_payload_stops_the_update_and_hands_over_the_reason()
{
SelfUpdater.VerifyPayloadCatalog = _ =>
PayloadCatalogVerdict.Reject("payload differs from catalog (missing: none; extra: evil.dll; changed: none)");
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
Assert.False(ok);
Assert.Contains("extra: evil.dll", reason);
Assert.Contains(_log, line => line.Contains("REJECTED"));
}
[Fact]
public void A_throwing_verifier_is_a_rejection_not_a_pass()
{
SelfUpdater.VerifyPayloadCatalog = _ => throw new InvalidOperationException("runspace broke");
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
Assert.False(ok);
Assert.Contains("runspace broke", reason);
}
[Fact]
public void The_catalog_check_runs_before_any_file_is_swapped()
{
// Source-level pin: CheckAndApplyUpdateAsync has no seam for the download, so the
// ordering that makes the check meaningful is pinned on the source itself.
var source = File.ReadAllText(Path.Combine(FindRepoRoot(),
"src", "Agent", "AutopilotMonitor.Agent.V2.Core", "Monitoring", "Runtime", "SelfUpdater.cs"));
var check = source.IndexOf("if (!VerifyStagedPayload(stagingDir, log, out var catalogRejection))", StringComparison.Ordinal);
var swap = source.IndexOf("var swapOk = SwapFiles(agentDir, stagingDir, log);", StringComparison.Ordinal);
Assert.True(check > 0, "step 5b not found in SelfUpdater.cs");
Assert.True(swap > check, "SwapFiles must come after the catalog check");
Assert.Contains("WriteSkipMarker(\"catalog_rejected\"", source);
}
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;
}
}
}
@@ -44,6 +44,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Equal("Succeeded", data["remoteConfigOutcome"]);
}
[Fact]
public void EmitAgentStarted_CarriesTheBootstrapProvenance()
{
// Values depend on the machine's %ProgramData%\AutopilotMonitor\Logs (see
// BootstrapProvenanceReaderTests); the two presence flags are always on the wire.
using var rig = new EmitterRig();
rig.RemoteConfig.SetFakeOutcome(RemoteConfigFetchOutcome.Succeeded, configVersion: 28);
LifecycleEmitters.EmitAgentStarted(
rig.Post, rig.AgentConfig, rig.PreviousExit, "2.0.806", rig.RemoteConfig, rig.Logger);
var data = AssertSingleEmit(rig.Sink, expectedEventType: "agent_started");
Assert.IsType<bool>(data["bootstrapScriptLogPresent"]);
Assert.IsType<bool>(data["oobeBootstrapLogPresent"]);
}
[Fact]
public void EmitAgentStarted_OnFallbackToDefaults_FlagsRemoteConfigFalse()
{
@@ -0,0 +1,113 @@
using System;
using System.IO;
using System.Linq;
using System.Security.AccessControl;
using System.Security.Principal;
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// The agent data directory keeps standard users from adding files where the SYSTEM agent
/// starts from. Works on a temp folder the test owns; as owner it can always hand itself the
/// rights back to clean up.
/// </summary>
public sealed class AgentDirectoryAclTests : IDisposable
{
private const FileSystemRights WriteRights =
FileSystemRights.WriteData | FileSystemRights.AppendData | FileSystemRights.WriteAttributes
| FileSystemRights.WriteExtendedAttributes | FileSystemRights.Delete | FileSystemRights.DeleteSubdirectoriesAndFiles
| FileSystemRights.ChangePermissions | FileSystemRights.TakeOwnership;
private static readonly SecurityIdentifier Users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
private readonly string _root = Path.Combine(Path.GetTempPath(), "apm-acl-" + Guid.NewGuid().ToString("N"));
public AgentDirectoryAclTests() => Directory.CreateDirectory(_root);
public void Dispose()
{
try
{
var me = WindowsIdentity.GetCurrent().User!;
var directory = new DirectoryInfo(_root);
var security = directory.GetAccessControl();
security.AddAccessRule(new FileSystemAccessRule(me, FileSystemRights.FullControl,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
directory.SetAccessControl(security);
Directory.Delete(_root, recursive: true);
}
catch
{
// best effort: a leftover temp folder must not fail the run
}
}
private static FileSystemRights UsersRights(string path) =>
new DirectoryInfo(path).GetAccessControl()
.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier))
.Cast<FileSystemAccessRule>()
.Where(r => r.AccessControlType == AccessControlType.Allow && Users.Equals(r.IdentityReference))
.Aggregate((FileSystemRights)0, (all, r) => all | r.FileSystemRights);
[Fact]
public void The_protected_security_inherits_nothing_and_gives_users_read_only()
{
var security = AgentDirectoryAcl.BuildProtectedSecurity();
Assert.True(security.AreAccessRulesProtected);
var users = security.GetAccessRules(true, true, typeof(SecurityIdentifier)).Cast<FileSystemAccessRule>()
.Single(r => Users.Equals(r.IdentityReference));
// An allow rule always carries Synchronize; .NET adds it on construction.
Assert.Equal(FileSystemRights.ReadAndExecute, users.FileSystemRights & ~FileSystemRights.Synchronize);
Assert.Equal((FileSystemRights)0, users.FileSystemRights & WriteRights);
}
[Fact]
public void A_folder_is_protected_once_and_then_left_alone()
{
var first = AgentDirectoryAcl.EnsureProtected(_root);
var second = AgentDirectoryAcl.EnsureProtected(_root);
Assert.Equal(AgentDirectoryAclOutcome.Applied, first.Outcome);
Assert.Equal(AgentDirectoryAclOutcome.AlreadyProtected, second.Outcome);
Assert.True(AgentDirectoryAcl.IsProtectedAsExpected(new DirectoryInfo(_root).GetAccessControl(AccessControlSections.Access)));
Assert.Equal((FileSystemRights)0, UsersRights(_root) & WriteRights);
}
[Fact]
public void Existing_subfolders_take_over_the_protection()
{
// Install mode protects the base after Agent, Spool and Logs already exist.
var agent = Directory.CreateDirectory(Path.Combine(_root, "Agent")).FullName;
AgentDirectoryAcl.EnsureProtected(_root);
Assert.Equal((FileSystemRights)0, UsersRights(agent) & WriteRights);
Assert.NotEqual((FileSystemRights)0, UsersRights(agent) & FileSystemRights.ReadAndExecute);
}
[Fact]
public void An_unprotected_or_widened_dacl_is_not_taken_as_protected()
{
var unprotected = AgentDirectoryAcl.BuildProtectedSecurity();
unprotected.SetAccessRuleProtection(isProtected: false, preserveInheritance: true);
Assert.False(AgentDirectoryAcl.IsProtectedAsExpected(unprotected));
var widened = AgentDirectoryAcl.BuildProtectedSecurity();
widened.AddAccessRule(new FileSystemAccessRule(Users, FileSystemRights.Modify,
InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow));
Assert.False(AgentDirectoryAcl.IsProtectedAsExpected(widened));
}
[Fact]
public void A_missing_folder_is_reported_not_created()
{
var missing = Path.Combine(_root, "does-not-exist");
Assert.Equal(AgentDirectoryAclOutcome.Missing, AgentDirectoryAcl.EnsureProtected(missing).Outcome);
Assert.False(Directory.Exists(missing));
}
}
}
@@ -0,0 +1,142 @@
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Shared;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// The decision over what <c>Test-FileCatalog -Detailed</c> reports. The runspace itself is
/// exercised on a lab device (plan agent-payload-catalog, V1/V3), never in the test host.
/// </summary>
public sealed class PayloadCatalogVerifierTests
{
private const string OurSubject = "CN=glueckkanja AG, O=glueckkanja AG, L=Offenbach, S=Hessen, C=DE";
private static Dictionary<string, string> Files(params string[] namesAndHashes)
{
var map = new Dictionary<string, string>();
for (var i = 0; i < namesAndHashes.Length; i += 2) map[namesAndHashes[i]] = namesAndHashes[i + 1];
return map;
}
private static PayloadCatalogFacts Facts(
string status = "Valid", string? signatureStatus = "Valid", string? subject = OurSubject,
Dictionary<string, string>? catalog = null, Dictionary<string, string>? path = null)
=> new PayloadCatalogFacts(status, signatureStatus, subject,
catalog ?? Files("a.dll", "AA"), path ?? Files("a.dll", "AA"));
private static PayloadCatalogVerdict Evaluate(PayloadCatalogFacts facts)
=> PayloadCatalogVerifier.Evaluate(facts, Constants.CodeSigningPublisherPattern);
[Fact]
public void Signed_by_us_and_matching_is_accepted()
{
var verdict = Evaluate(Facts());
Assert.True(verdict.Accepted);
Assert.Null(verdict.Reason);
}
[Fact]
public void Matching_hashes_under_an_unsigned_catalog_are_rejected()
{
// Status covers the hashes only: an unsigned catalog over matching files reports Valid.
var verdict = Evaluate(Facts(signatureStatus: "NotSigned", subject: null));
Assert.False(verdict.Accepted);
Assert.Contains("catalog signature status NotSigned", verdict.Reason);
}
[Fact]
public void A_validly_signed_catalog_from_another_publisher_is_rejected()
{
var verdict = Evaluate(Facts(subject: "CN=Contoso Ltd, O=Contoso Ltd, C=US"));
Assert.False(verdict.Accepted);
Assert.Contains("does not match", verdict.Reason);
}
[Fact]
public void Missing_signature_details_are_a_rejection_not_a_pass()
{
Assert.False(Evaluate(Facts(signatureStatus: null, subject: null)).Accepted);
Assert.False(Evaluate(Facts(subject: "")).Accepted);
}
[Fact]
public void A_differing_payload_names_missing_extra_and_changed_files()
{
var verdict = Evaluate(Facts(
status: "ValidationFailed",
catalog: Files("a.dll", "AA", "b.dll", "BB", "c.config", "CC"),
path: Files("a.dll", "AA", "b.dll", "XX", "evil.dll", "EE")));
Assert.False(verdict.Accepted);
Assert.Contains("missing: c.config", verdict.Reason);
Assert.Contains("extra: evil.dll", verdict.Reason);
Assert.Contains("changed: b.dll", verdict.Reason);
}
[Fact]
public void Long_difference_lists_are_capped()
{
var path = Files("a.dll", "AA");
for (var i = 0; i < 12; i++) path[$"extra{i:00}.txt"] = "00";
var verdict = Evaluate(Facts(status: "ValidationFailed", path: path));
Assert.Contains("(+7 more)", verdict.Reason);
}
[Fact]
public void Signature_and_payload_problems_are_both_reported()
{
var verdict = Evaluate(Facts(status: "ValidationFailed", signatureStatus: "HashMismatch", subject: null,
path: Files("a.dll", "AA", "x.dll", "11")));
Assert.Contains("catalog signature status HashMismatch", verdict.Reason);
Assert.Contains("extra: x.dll", verdict.Reason);
}
[Theory]
[InlineData(OurSubject, true)]
[InlineData("cn=glueckkanja ag, o=glueckkanja ag, c=de", true)] // -like is case-insensitive
[InlineData("CN=x, O=glueckkanja AG Holding, C=DE", true)] // the pattern is open-ended by design
[InlineData("CN=glueckkanja AG, O=Contoso, C=DE", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void Publisher_pattern_behaves_like_powershell_like(string? subject, bool expected)
{
Assert.Equal(expected, PayloadCatalogVerifier.MatchesPublisherPattern(subject, Constants.CodeSigningPublisherPattern));
}
[Fact]
public void Regex_metacharacters_in_the_pattern_are_literal()
{
Assert.True(PayloadCatalogVerifier.MatchesPublisherPattern("O=Json.NET (.NET Foundation)", "*O=Json.NET (.NET Foundation)*"));
Assert.False(PayloadCatalogVerifier.MatchesPublisherPattern("O=JsonXNET (.NET Foundation)", "*O=Json.NET (.NET Foundation)*"));
}
[Fact]
public void A_payload_without_catalog_is_rejected_before_any_runspace_starts()
{
var dir = Path.Combine(Path.GetTempPath(), "apm-catalog-test-" + System.Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
File.WriteAllText(Path.Combine(dir, "readme.txt"), "x");
var verdict = PayloadCatalogVerifier.Verify(dir);
Assert.False(verdict.Accepted);
Assert.Contains("missing", verdict.Reason);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
}
}
@@ -62,8 +62,15 @@
<ItemGroup>
<Reference Include="System.Net.Http" />
<Reference Include="System.Management" />
<!--
Compile against the GAC copy, never ship it (Private=false). The runtime binds this
strong-named assembly from the device's GAC before it probes the app folder, and every
Windows 10/11 has it (Windows PowerShell 5.1 runs on it), so a shipped copy was never
loaded. It was the build machine's file: unsigned on any other machine, and half the ZIP.
-->
<Reference Include="System.Management.Automation">
<HintPath>$(windir)\Microsoft.NET\assembly\GAC_MSIL\System.Management.Automation\v4.0_3.0.0.0__31bf3856ad364e35\System.Management.Automation.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="System.ServiceProcess" />
<Reference Include="System.IO.Compression" />
@@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
/// <summary>
/// Which bootstrap installed this agent, read from the bootstrap scripts' own logs next to the
/// agent log. The scripts cannot report it themselves (they run before any agent exists), and
/// passing a version to <c>--install</c> would only ever cover installers released after that
/// change -- the old installers still assigned in Intune are exactly the ones worth counting.
/// <list type="bullet">
/// <item><c>bootstrap-script.log</c>: <c>Bootstrap script version: vX.Y</c>, written by every
/// installer run since 2026-04-09. A log without that line means an older installer.</item>
/// <item><c>bootstrap-loader.log</c> (Intune) / <c>bootstrap-msi.log</c> (MSI):
/// <c>bootstrap loader vX.Y started</c>, written by every loader since v1.0.</item>
/// <item><c>oobe_bootstrap.log</c>: the OOBE go-script (generated by the backend, no version).</item>
/// </list>
/// Only the version number is extracted; the loader line also names the account, which is
/// never read out. Observational only, and it never throws: an unreadable log reads as absent.
/// </summary>
internal static class BootstrapProvenanceReader
{
internal const string InstallerLogName = "bootstrap-script.log";
internal const string LoaderLogName = "bootstrap-loader.log";
internal const string MsiLoaderLogName = "bootstrap-msi.log";
internal const string OobeLogName = "oobe_bootstrap.log";
// The logs grow across runs; the last run's lines sit at the end. One run writes a few KB.
private const int TailBytes = 64 * 1024;
private static readonly Regex InstallerVersionLine = new Regex(
@"Bootstrap script version: v(?<v>\d+(?:\.\d+){0,3})\b", RegexOptions.CultureInvariant);
private static readonly Regex LoaderStartLine = new Regex(
@"bootstrap loader v(?<v>\d+(?:\.\d+){0,3}) started\b", RegexOptions.CultureInvariant);
public static BootstrapProvenance Read(string logDirectory)
{
try
{
var installerLog = ReadTail(Path.Combine(logDirectory, InstallerLogName));
// Intune loader and MSI loader write separate files; when a device has both, the
// more recently written one belongs to the bootstrap that ran last.
var loader = new[] { (Name: LoaderLogName, Channel: "loader"), (Name: MsiLoaderLogName, Channel: "msi") }
.Select(l => (l.Channel, Path: Path.Combine(logDirectory, l.Name)))
.Where(l => File.Exists(l.Path))
.OrderByDescending(l => File.GetLastWriteTimeUtc(l.Path))
.Select(l => (l.Channel, Version: LastVersion(ReadTail(l.Path), LoaderStartLine)))
.FirstOrDefault();
return new BootstrapProvenance(
scriptLogPresent: installerLog != null,
scriptVersion: LastVersion(installerLog, InstallerVersionLine),
loaderLog: loader.Channel,
loaderVersion: loader.Version,
oobeLogPresent: File.Exists(Path.Combine(logDirectory, OobeLogName)));
}
catch
{
return BootstrapProvenance.Unknown;
}
}
internal static string LastVersion(string text, Regex line)
{
if (string.IsNullOrEmpty(text)) return null;
string version = null;
foreach (Match match in line.Matches(text))
version = match.Groups["v"].Value;
return version;
}
/// <summary>The last <see cref="TailBytes"/> of the file, or null when it is missing or unreadable.</summary>
private static string ReadTail(string path)
{
try
{
if (!File.Exists(path)) return null;
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
{
var start = Math.Max(0, stream.Length - TailBytes);
stream.Seek(start, SeekOrigin.Begin);
var buffer = new byte[stream.Length - start];
var read = 0;
while (read < buffer.Length)
{
var n = stream.Read(buffer, read, buffer.Length - read);
if (n == 0) break;
read += n;
}
// The scripts write ASCII (their own pure-ASCII rule); UTF-8 decodes it either way.
return Encoding.UTF8.GetString(buffer, 0, read);
}
}
catch
{
return null;
}
}
}
/// <summary>What <see cref="BootstrapProvenanceReader"/> found; null fields were not found.</summary>
internal sealed class BootstrapProvenance
{
public static readonly BootstrapProvenance Unknown = new BootstrapProvenance(false, null, null, null, false);
public BootstrapProvenance(bool scriptLogPresent, string scriptVersion, string loaderLog, string loaderVersion, bool oobeLogPresent)
{
ScriptLogPresent = scriptLogPresent;
ScriptVersion = scriptVersion;
LoaderLog = loaderLog;
LoaderVersion = loaderVersion;
OobeLogPresent = oobeLogPresent;
}
public bool ScriptLogPresent { get; }
public string ScriptVersion { get; }
public string LoaderLog { get; }
public string LoaderVersion { get; }
public bool OobeLogPresent { get; }
/// <summary>
/// Adds the <c>agent_started</c> keys. Version and loader keys only when found, so a
/// missing key always means "not found" and never an empty string.
/// </summary>
public void AddTo(IDictionary<string, object> data)
{
data["bootstrapScriptLogPresent"] = ScriptLogPresent;
if (ScriptVersion != null) data["bootstrapScriptVersion"] = ScriptVersion;
if (LoaderLog != null) data["bootstrapLoaderLog"] = LoaderLog;
if (LoaderVersion != null) data["bootstrapLoaderVersion"] = LoaderVersion;
data["oobeBootstrapLogPresent"] = OobeLogPresent;
}
}
}
@@ -139,6 +139,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// </summary>
internal const int GracefulExitFallbackMs = 15000;
/// <summary>
/// The payload catalog check of step 5b. Production keeps the default,
/// <see cref="PayloadCatalogVerifier.Verify"/>; tests swap it so the update path runs
/// without a PowerShell runspace. Same static-hook idiom as <see cref="BackendExpectedSha256"/>.
/// </summary>
internal static Func<string, PayloadCatalogVerdict> VerifyPayloadCatalog { get; set; } = PayloadCatalogVerifier.Verify;
/// <summary>
/// Checks for a newer agent version and applies the update if available.
/// On success, restarts the process and never returns.
@@ -381,6 +388,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
return;
}
// Step 5b: The payload must match its signed catalog -- the check the installer and
// the OOBE script run before --install. Covers what the ZIP hash cannot: a hash
// from the download host vouches for nothing if that host is compromised, and
// without any hash there was no check at all.
if (!VerifyStagedPayload(stagingDir, log, out var catalogRejection))
{
CleanupStaging(stagingDir, zipPath);
if (writeMarkers)
WriteSkipMarker("catalog_rejected", currentVersion, latestVersion, catalogRejection, log);
return;
}
// Step 6: Swap files (rename locked → .old, copy new)
var swapTimerSw = Stopwatch.StartNew();
var swapOk = SwapFiles(agentDir, stagingDir, log);
@@ -597,6 +616,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
}
}
/// <summary>
/// Step 5b. Fail-closed: a rejected payload is never swapped in, and the running agent --
/// verified when it was installed -- stays. A verifier that throws counts as a rejection.
/// </summary>
internal static bool VerifyStagedPayload(string stagingDir, Action<string> log, out string rejectionReason)
{
var sw = Stopwatch.StartNew();
PayloadCatalogVerdict verdict;
try
{
verdict = VerifyPayloadCatalog(stagingDir);
}
catch (Exception ex)
{
verdict = PayloadCatalogVerdict.Reject($"catalog check failed to run ({ex.Message})");
}
sw.Stop();
if (verdict.Accepted)
{
log($"Self-update: payload catalog verified ({sw.ElapsedMilliseconds}ms)");
rejectionReason = null;
return true;
}
log($"Self-update: payload catalog REJECTED ({sw.ElapsedMilliseconds}ms) — {verdict.Reason}. Aborting update.");
rejectionReason = verdict.Reason;
return false;
}
/// <summary>
/// Extracts the ZIP to the staging directory. Returns false on failure.
///
@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.AccessControl;
using System.Security.Principal;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Keeps <c>%ProgramData%\AutopilotMonitor</c> out of reach of standard users. Left alone the
/// folder inherits ProgramData's "Users may create files and folders" entry, and the SYSTEM
/// agent and its boot task start from the Agent subfolder: a user who can add a file there
/// can plant code the next SYSTEM start loads. During the device phase of OOBE only SYSTEM is
/// around, but the agent keeps running through the user phase and on the desktop until the
/// enrollment ends, restarted by the boot task after every reboot; that is the window this
/// closes. The payload catalog does not cover it: it is checked at install time, a file
/// added later is never looked at.
/// <para>
/// The DACL is protected (nothing inherited from ProgramData): SYSTEM and Administrators full
/// control, Users read and execute, all inherited by the subfolders. Explicit entries on a
/// child stay. The summary dialog's temp folder lives outside this tree anyway.
/// </para>
/// </summary>
public static class AgentDirectoryAcl
{
private const InheritanceFlags Inherit = InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit;
private static readonly SecurityIdentifier LocalSystem = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
private static readonly SecurityIdentifier Administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
private static readonly SecurityIdentifier Users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
/// <summary>
/// Applies the protected DACL to <paramref name="path"/> unless it is already in place.
/// Never throws: a folder the agent cannot protect must not stop the agent.
/// </summary>
public static AgentDirectoryAclResult EnsureProtected(string path)
{
try
{
var directory = new DirectoryInfo(path);
if (!directory.Exists)
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.Missing, null);
if (IsProtectedAsExpected(directory.GetAccessControl(AccessControlSections.Access)))
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.AlreadyProtected, null);
directory.SetAccessControl(BuildProtectedSecurity());
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.Applied, null);
}
catch (Exception ex)
{
return new AgentDirectoryAclResult(AgentDirectoryAclOutcome.Failed, ex.Message);
}
}
internal static DirectorySecurity BuildProtectedSecurity()
{
var security = new DirectorySecurity();
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
security.AddAccessRule(new FileSystemAccessRule(LocalSystem, FileSystemRights.FullControl, Inherit, PropagationFlags.None, AccessControlType.Allow));
security.AddAccessRule(new FileSystemAccessRule(Administrators, FileSystemRights.FullControl, Inherit, PropagationFlags.None, AccessControlType.Allow));
security.AddAccessRule(new FileSystemAccessRule(Users, FileSystemRights.ReadAndExecute, Inherit, PropagationFlags.None, AccessControlType.Allow));
return security;
}
/// <summary>
/// True when the DACL is protected and holds exactly the three entries above. Windows adds
/// <see cref="FileSystemRights.Synchronize"/> when it stores an entry, so that bit is ignored.
/// </summary>
internal static bool IsProtectedAsExpected(DirectorySecurity security)
{
if (!security.AreAccessRulesProtected)
return false;
var rules = security.GetAccessRules(includeExplicit: true, includeInherited: true, typeof(SecurityIdentifier))
.Cast<FileSystemAccessRule>()
.ToList();
return rules.Count == 3
&& Has(rules, LocalSystem, FileSystemRights.FullControl)
&& Has(rules, Administrators, FileSystemRights.FullControl)
&& Has(rules, Users, FileSystemRights.ReadAndExecute);
}
private static bool Has(IEnumerable<FileSystemAccessRule> rules, SecurityIdentifier sid, FileSystemRights rights) =>
rules.Any(r => r.AccessControlType == AccessControlType.Allow
&& !r.IsInherited
&& sid.Equals(r.IdentityReference)
&& (r.FileSystemRights & ~FileSystemRights.Synchronize) == (rights & ~FileSystemRights.Synchronize)
&& r.InheritanceFlags == Inherit
&& r.PropagationFlags == PropagationFlags.None);
}
public enum AgentDirectoryAclOutcome
{
Applied,
AlreadyProtected,
Missing,
Failed,
}
/// <summary>Outcome of <see cref="AgentDirectoryAcl.EnsureProtected"/>; <see cref="Error"/> only on failure.</summary>
public sealed class AgentDirectoryAclResult
{
public AgentDirectoryAclResult(AgentDirectoryAclOutcome outcome, string error)
{
Outcome = outcome;
Error = error;
}
public AgentDirectoryAclOutcome Outcome { get; }
public string Error { get; }
}
}
@@ -0,0 +1,203 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Verifies an extracted agent payload against its signed catalog
/// (<see cref="Constants.AgentPayloadCatalogFileName"/>) with Windows PowerShell's own
/// <c>Test-FileCatalog</c>, the cmdlet the bootstrap installer and the OOBE script run as well,
/// so all three verifiers share one semantics: the catalog must carry a valid signature from
/// <see cref="Constants.CodeSigningPublisherPattern"/>, and every file of the payload must match
/// it, with nothing missing, changed or extra.
/// <para>
/// A reimplementation of the catalog format (WinVerifyTrust plus the CryptCAT APIs) was the
/// alternative and was rejected: a second parser for the same contract would drift from the
/// cmdlet the other two verifiers use. The runspace costs about a second, paid only on the
/// rare update path.
/// </para>
/// </summary>
public static class PayloadCatalogVerifier
{
private const int InvokeTimeoutMs = 60000;
private const int MaxNamesPerList = 5;
/// <summary>
/// Checks <paramref name="payloadDir"/>. Never throws: any failure to run the check is a
/// rejection, because an unverifiable payload must not be installed.
/// </summary>
public static PayloadCatalogVerdict Verify(string payloadDir)
{
var catalogPath = Path.Combine(payloadDir, Constants.AgentPayloadCatalogFileName);
if (!File.Exists(catalogPath))
return PayloadCatalogVerdict.Reject($"catalog {Constants.AgentPayloadCatalogFileName} missing");
return RunCatalogCheck(payloadDir, catalogPath);
}
// Kept out of Verify (and never inlined) so the PowerShell engine is loaded only when a
// catalog is actually checked, not when the method that guards it is compiled.
[MethodImpl(MethodImplOptions.NoInlining)]
private static PayloadCatalogVerdict RunCatalogCheck(string payloadDir, string catalogPath)
{
try
{
using (var runspace = RunspaceFactory.CreateRunspace())
{
runspace.Open();
using (var ps = PowerShell.Create())
{
ps.Runspace = runspace;
ps.AddCommand(@"Microsoft.PowerShell.Security\Test-FileCatalog")
.AddParameter("Path", payloadDir)
.AddParameter("CatalogFilePath", catalogPath)
.AddParameter("FilesToSkip", new[] { Constants.AgentPayloadCatalogFileName })
.AddParameter("Detailed");
var asyncResult = ps.BeginInvoke();
if (!asyncResult.AsyncWaitHandle.WaitOne(InvokeTimeoutMs))
{
ps.Stop();
return PayloadCatalogVerdict.Reject($"catalog check timed out after {InvokeTimeoutMs / 1000}s");
}
var results = ps.EndInvoke(asyncResult);
if (ps.HadErrors && ps.Streams.Error.Count > 0)
return PayloadCatalogVerdict.Reject($"catalog check failed to run ({ps.Streams.Error[0]})");
var info = results.FirstOrDefault();
if (info == null)
return PayloadCatalogVerdict.Reject("catalog check returned nothing");
var signature = info.Properties["Signature"]?.Value as Signature;
return Evaluate(new PayloadCatalogFacts(
status: info.Properties["Status"]?.Value?.ToString(),
signatureStatus: signature?.Status.ToString(),
signerSubject: signature?.SignerCertificate?.Subject,
catalogItems: ToStringMap(info.Properties["CatalogItems"]?.Value),
pathItems: ToStringMap(info.Properties["PathItems"]?.Value)),
Constants.CodeSigningPublisherPattern);
}
}
}
catch (Exception ex)
{
return PayloadCatalogVerdict.Reject($"catalog check failed to run ({ex.Message})");
}
}
/// <summary>
/// The decision over the facts <c>Test-FileCatalog -Detailed</c> reports. Separate from the
/// runspace so it is testable without PowerShell. <c>Status</c> covers the hashes only: an
/// unsigned catalog over matching files reports <c>Valid</c>, which is why the signature is
/// judged on its own.
/// </summary>
internal static PayloadCatalogVerdict Evaluate(PayloadCatalogFacts facts, string publisherPattern)
{
var reasons = new List<string>();
if (!string.Equals(facts.SignatureStatus, "Valid", StringComparison.Ordinal))
{
reasons.Add($"catalog signature status {facts.SignatureStatus ?? "unknown"}");
}
else if (!MatchesPublisherPattern(facts.SignerSubject, publisherPattern))
{
reasons.Add($"catalog publisher '{facts.SignerSubject}' does not match {publisherPattern}");
}
if (!string.Equals(facts.Status, "Valid", StringComparison.Ordinal))
reasons.Add(DescribeDifference(facts.CatalogItems, facts.PathItems));
return reasons.Count == 0
? PayloadCatalogVerdict.Accept()
: PayloadCatalogVerdict.Reject(string.Join("; ", reasons));
}
/// <summary>
/// PowerShell's case-insensitive <c>-like</c> for the two wildcards the pin uses (<c>*</c>,
/// <c>?</c>), without loading the PowerShell engine. Bracket classes are not supported;
/// <c>CodeSigningParityTests</c> pins the pattern's shape.
/// </summary>
internal static bool MatchesPublisherPattern(string subject, string pattern)
{
if (string.IsNullOrEmpty(subject) || string.IsNullOrEmpty(pattern)) return false;
var regex = "^" + Regex.Escape(pattern).Replace(@"\*", ".*").Replace(@"\?", ".") + "$";
return Regex.IsMatch(subject, regex, RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.CultureInvariant);
}
private static string DescribeDifference(
IReadOnlyDictionary<string, string> expected, IReadOnlyDictionary<string, string> actual)
{
var missing = expected.Keys.Where(k => !actual.ContainsKey(k));
var extra = actual.Keys.Where(k => !expected.ContainsKey(k));
var changed = expected.Keys.Where(k => actual.TryGetValue(k, out var hash)
&& !string.Equals(hash, expected[k], StringComparison.OrdinalIgnoreCase));
return $"payload differs from catalog (missing: {Cap(missing)}; extra: {Cap(extra)}; changed: {Cap(changed)})";
}
private static string Cap(IEnumerable<string> names)
{
var list = names.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
if (list.Count == 0) return "none";
var shown = string.Join(", ", list.Take(MaxNamesPerList));
return list.Count > MaxNamesPerList ? $"{shown} (+{list.Count - MaxNamesPerList} more)" : shown;
}
private static IReadOnlyDictionary<string, string> ToStringMap(object value)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (value is IDictionary dictionary)
{
foreach (DictionaryEntry entry in dictionary)
map[Convert.ToString(entry.Key)] = Convert.ToString(entry.Value);
}
return map;
}
}
/// <summary>What <c>Test-FileCatalog -Detailed</c> reported, reduced to the fields the decision needs.</summary>
internal sealed class PayloadCatalogFacts
{
public PayloadCatalogFacts(
string status, string signatureStatus, string signerSubject,
IReadOnlyDictionary<string, string> catalogItems, IReadOnlyDictionary<string, string> pathItems)
{
Status = status;
SignatureStatus = signatureStatus;
SignerSubject = signerSubject;
CatalogItems = catalogItems ?? new Dictionary<string, string>();
PathItems = pathItems ?? new Dictionary<string, string>();
}
public string Status { get; }
public string SignatureStatus { get; }
public string SignerSubject { get; }
public IReadOnlyDictionary<string, string> CatalogItems { get; }
public IReadOnlyDictionary<string, string> PathItems { get; }
}
/// <summary>Outcome of a payload catalog check; <see cref="Reason"/> is null when accepted.</summary>
public sealed class PayloadCatalogVerdict
{
private PayloadCatalogVerdict(bool accepted, string reason)
{
Accepted = accepted;
Reason = reason;
}
public bool Accepted { get; }
public string Reason { get; }
public static PayloadCatalogVerdict Accept() => new PayloadCatalogVerdict(true, null);
public static PayloadCatalogVerdict Reject(string reason) => new PayloadCatalogVerdict(false, reason);
}
}
@@ -33,6 +33,13 @@
<InformationalVersion>$(Version)</InformationalVersion>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<!--
Of the files that travel next to referenced assemblies, only PDBs ship: they put file and
line into the stack traces the agent log records. XML doc files (AutopilotMonitor.Shared.xml)
are IntelliSense data no device reads. This output folder is the release payload.
-->
<AllowedReferenceRelatedFileExtensions>.pdb</AllowedReferenceRelatedFileExtensions>
</PropertyGroup>
<Target Name="PersistBuildCounter" BeforeTargets="BeforeBuild" Condition="'$(BUILD_NUMBER)' == ''">
@@ -57,6 +57,8 @@ namespace AutopilotMonitor.Agent.V2
logger.Info("======================= Agent install mode (--install) =======================");
EnsureAgentDirectories(logger);
LogAgentDirectoryAcl(AgentDirectoryAcl.EnsureProtected(
Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory)), logger, alwaysLog: true);
var sourceExePath = Assembly.GetExecutingAssembly().Location;
var sourceDir = Path.GetDirectoryName(sourceExePath) ?? string.Empty;
@@ -273,6 +275,26 @@ namespace AutopilotMonitor.Agent.V2
}
}
/// <summary>
/// Logs the outcome of <see cref="AgentDirectoryAcl.EnsureProtected"/>. Install mode logs
/// every outcome; the runtime start only a change or a failure, so a normal start stays quiet.
/// </summary>
internal static void LogAgentDirectoryAcl(AgentDirectoryAclResult result, AgentLogger logger, bool alwaysLog)
{
switch (result.Outcome)
{
case AgentDirectoryAclOutcome.Applied:
logger.Info("Agent data directory protected: SYSTEM and Administrators full control, Users read and execute, nothing inherited from ProgramData.");
break;
case AgentDirectoryAclOutcome.Failed:
logger.Warning($"Could not protect the agent data directory: {result.Error}");
break;
default:
if (alwaysLog) logger.Info($"Agent data directory ACL: {result.Outcome}.");
break;
}
}
private static void EnsureAgentDirectories(AgentLogger logger)
{
var basePath = Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory);
@@ -168,6 +168,11 @@ namespace AutopilotMonitor.Agent.V2
var formattedArgs = FormatArgsForLog(args);
logger.Info($"Command line: {(string.IsNullOrEmpty(formattedArgs) ? "(no args)" : formattedArgs)}");
// Same protection install mode applies, re-checked on every start: it also reaches
// agents that arrived by self-update over an install made before the hardening.
LogAgentDirectoryAcl(AgentDirectoryAcl.EnsureProtected(
Environment.ExpandEnvironmentVariables(Constants.AgentDataDirectory)), logger, alwaysLog: false);
try
{
return RunAgent(args, logger, dataDirectory, logDirectory, consoleMode);
@@ -92,6 +92,13 @@ namespace AutopilotMonitor.Agent.V2.Runtime
if (recovered)
data["remoteConfigRecovered"] = true;
// Which bootstrap installed this agent (installer and loader versions, MSI or
// Intune loader, OOBE go-script), read from the scripts' own logs: makes the
// fleet's share of old or directly assigned installers countable.
BootstrapProvenanceReader
.Read(Environment.ExpandEnvironmentVariables(SharedConstants.LogDirectory))
.AddTo(data);
if (!string.IsNullOrEmpty(previousExit?.CrashExceptionType))
data["previousCrashException"] = previousExit.CrashExceptionType;
@@ -31,6 +31,8 @@ public class AuthFunctionSideEffectTests
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
private readonly Mock<TenantAdminsService> _tenantAdminsMock;
private readonly Mock<PreviewWhitelistService> _previewMock;
private readonly Mock<McpUserService> _mcpUserMock;
private readonly Mock<TelegramNotificationService> _telegramMock;
private readonly Mock<OpsEventService> _opsEventsMock;
private readonly Mock<GlobalNotificationService> _globalNotificationMock;
@@ -71,7 +73,7 @@ public class AuthFunctionSideEffectTests
adminRepo, cache, Mock.Of<ILogger<TenantAdminsService>>())
{ CallBase = false };
var previewMock = new Mock<PreviewWhitelistService>(
_previewMock = new Mock<PreviewWhitelistService>(
configRepo, cache, Mock.Of<ILogger<PreviewWhitelistService>>(), _tenantConfigMock.Object)
{ CallBase = false };
@@ -87,7 +89,7 @@ public class AuthFunctionSideEffectTests
configRepo, Mock.Of<ILogger<AdminConfigurationService>>(), cache)
{ CallBase = false };
var mcpUserMock = new Mock<McpUserService>(
_mcpUserMock = new Mock<McpUserService>(
adminRepo, new StubAdminIdentityBindingService(bound: true), cache, Mock.Of<ILogger<McpUserService>>(),
globalAdminMock.Object, delegatedAdminMock.Object, adminConfigService.Object,
new TenantMemberRoleResolver(_tenantAdminsMock.Object, _tenantConfigMock.Object), _tenantConfigMock.Object)
@@ -107,10 +109,10 @@ public class AuthFunctionSideEffectTests
_tenantConfigMock.Object,
_tenantAdminsMock.Object,
_metricsRepoMock.Object,
previewMock.Object,
_previewMock.Object,
_telegramMock.Object,
_globalNotificationMock.Object,
mcpUserMock.Object,
_mcpUserMock.Object,
_autoApproveEnqueuerMock.Object,
new AutopilotMonitor.Functions.Security.EntraAppRegistry(
new Microsoft.Extensions.Configuration.ConfigurationBuilder().Build(),
@@ -169,6 +171,13 @@ public class AuthFunctionSideEffectTests
}
private static (HttpRequestData Req, FunctionContext Ctx) BuildAuthenticatedRequest(string callerUpn, string? tenantId = null)
{
var claims = new List<Claim> { new("preferred_username", callerUpn) };
if (tenantId != null) claims.Add(new Claim("tid", tenantId));
return BuildRequest(new ClaimsPrincipal(new ClaimsIdentity(claims, "test")));
}
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(ClaimsPrincipal principal)
{
var services = new ServiceCollection();
services.AddOptions();
@@ -177,10 +186,6 @@ public class AuthFunctionSideEffectTests
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)));
var provider = services.BuildServiceProvider();
var claims = new List<Claim> { new("preferred_username", callerUpn) };
if (tenantId != null) claims.Add(new Claim("tid", tenantId));
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "test"));
var context = new Mock<FunctionContext>();
context.SetupGet(c => c.Items).Returns(
new Dictionary<object, object> { ["ClaimsPrincipal"] = principal });
@@ -846,4 +851,86 @@ public class AuthFunctionSideEffectTests
await Assert.ThrowsAsync<InvalidOperationException>(
() => _sut.HandlePostDecisionSideEffectsAsync(decision, TenantId, Upn, DisplayName, ObjectId));
}
// -------------------------------------------------------------------------
// GetCurrentUser end to end: an external identity never claims the tenant
// -------------------------------------------------------------------------
/// <summary>
/// A validated token as the middleware hands it on: tid, oid and idp only under their mapped names. The
/// member's idp names its own tenant, the guest's another one — the same principal shape otherwise.
/// </summary>
private static ClaimsPrincipal SignedIn(bool external) => new(new ClaimsIdentity(new[]
{
new Claim("http://schemas.microsoft.com/identity/claims/tenantid", TenantId),
new Claim("http://schemas.microsoft.com/identity/claims/objectidentifier", ObjectId),
new Claim(ClaimTypes.Upn, Upn),
new Claim("http://schemas.microsoft.com/identity/claims/identityprovider", external
? "https://sts.windows.net/bbbbbbbb-1111-2222-3333-444444444444/"
: $"https://sts.windows.net/{TenantId}/"),
}, "test"));
private void ArrangeAuthMe(TenantConfiguration config, bool exists, bool approved)
{
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((config, exists));
_previewMock.Setup(x => x.IsApprovedAsync(TenantId)).ReturnsAsync(approved);
_tenantAdminsMock.Setup(x => x.GetTenantAdminsAsync(TenantId)).ReturnsAsync(new List<TenantAdminRow>());
_mcpUserMock
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
.ReturnsAsync(McpAccessCheckResult.Denied("not allowed"));
_metricsRepoMock.Setup(x => x.GetUserWhatsNewSeenAsync(TenantId, Upn)).ReturnsAsync(new UserWhatsNewSeen());
}
private static async Task<string> ReadBodyAsync(HttpResponseData response)
{
response.Body.Position = 0;
return await new StreamReader(response.Body).ReadToEndAsync();
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task GetCurrentUser_FirstSignInOfANewTenant_OnlyAMemberOnboardsIt(bool external)
{
// The pointer read stays healthy on purpose: a throwing read would skip the seed for the guest too, and
// the test would pass for the wrong reason.
ArrangeAuthMe(DefaultConfig(), exists: false, approved: false);
var (req, ctx) = BuildRequest(SignedIn(external));
var response = await _sut.GetCurrentUser(req, ctx);
// Both wait at the activation gate; only the member's sign-in seeded the tenant and queued its activation.
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Contains("PendingActivation", await ReadBodyAsync(response));
Assert.Equal(external ? 0 : 1, _store.Writes.Count);
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), external ? Times.Never() : Times.Once());
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(TenantId, Upn), external ? Times.Never() : Times.Once());
if (!external)
Assert.Equal(Upn, _store.Row!.OnboardedBy);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task GetCurrentUser_ActivatedTenantWithoutAdmins_OnlyAMemberBecomesItsFirstAdmin(bool external)
{
var config = DefaultConfig();
config.DomainName = "contoso.com";
ArrangeAuthMe(config, exists: true, approved: true);
_tenantAdminsMock
.Setup(x => x.AddTenantAdminAsync(TenantId, Upn, "System"))
.ReturnsAsync(new TenantAdminRow());
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>()),
external ? Times.Never() : Times.Once());
Assert.Contains(external ? "\"isTenantAdmin\":false" : "\"isTenantAdmin\":true", await ReadBodyAsync(response));
}
}
@@ -414,6 +414,43 @@ public class AuthFunctionTests
Assert.Null((string?)body.role);
}
[Fact]
public void AutoAdmin_ExternalIdentityFirstUser_IsNeverTheFirstAdmin()
{
// A guest signing in to an activated tenant that has no admins yet must not be made its Tenant Admin:
// it is not a member of the tenant at all.
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: null, mcpCheck: McpDenied(),
hasTenantAdmins: false,
TenantId, Upn, DisplayName, ObjectId,
isExternalIdentity: true);
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 ExternalIdentity_KeepsARoleTheTenantGrantedExplicitly()
{
var result = AuthFunction.BuildAuthResult(
DefaultConfig(), isGlobalAdmin: false, isGlobalReader: false, isPreviewApproved: true,
memberRole: AdminRole(), mcpCheck: McpDenied(),
hasTenantAdmins: true,
TenantId, Upn, DisplayName, ObjectId,
isExternalIdentity: true);
Assert.True(result.IsSuccess);
Assert.False(result.NeedsAutoAdmin);
var body = ToDynamic(result.Body);
Assert.True((bool)body.isTenantAdmin);
Assert.Equal(Constants.TenantRoles.Admin, (string)body.role);
}
// -------------------------------------------------------------------------
// Auto-Admin Logic
// -------------------------------------------------------------------------
@@ -1,6 +1,7 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Middleware;
using Microsoft.IdentityModel.Tokens;
using Xunit;
@@ -34,12 +35,13 @@ public class AuthenticationMiddlewareTokenValidationTests
string audience = ClientId,
string tid = Tid,
DateTime? notBefore = null,
DateTime? expires = null)
DateTime? expires = null,
IEnumerable<Claim>? extraClaims = null)
{
var token = new JwtSecurityToken(
issuer: issuer ?? V2Issuer,
audience: audience,
claims: new[] { new Claim("tid", tid) },
claims: new[] { new Claim("tid", tid) }.Concat(extraClaims ?? Array.Empty<Claim>()),
notBefore: notBefore ?? DateTime.UtcNow.AddMinutes(-5),
expires: expires ?? DateTime.UtcNow.AddMinutes(30),
signingCredentials: creds); // null creds → alg "none", unsigned
@@ -136,6 +138,39 @@ public class AuthenticationMiddlewareTokenValidationTests
Assert.ThrowsAny<SecurityTokenException>(() => Validate(forged));
}
// ── External identity: the guest signals survive inbound claim mapping ──
// A validated token carries tid and idp only under their mapped names, so a classifier reading the raw
// names would call every member with an idp external, or every guest a member.
[Theory]
[InlineData("https://sts.windows.net/bbbbbbbb-1111-2222-3333-444444444444/", true)] // a guest of another Entra tenant
[InlineData("live.com", true)] // a personal Microsoft account
[InlineData("https://sts.windows.net/" + Tid + "/", false)] // a member: own tenant, v1 form under the v2 issuer
public void IdpClaim_after_validation_decides_external_identity(string idp, bool external)
{
var principal = Validate(Mint(RsaCreds, extraClaims: new[] { new Claim("idp", idp) }));
Assert.Equal(external, principal.IsExternalIdentity());
}
[Theory]
[InlineData("1", true)]
[InlineData("0", false)]
public void AcctClaim_after_validation_is_read_as_the_number_Entra_sends(string acct, bool external)
{
var principal = Validate(Mint(RsaCreds, extraClaims: new[] { new Claim("acct", acct, ClaimValueTypes.Integer32) }));
Assert.Equal(external, principal.IsExternalIdentity());
}
[Fact]
public void MemberToken_without_guest_signals_is_not_external()
{
var principal = Validate(Mint(RsaCreds, extraClaims: new[] { new Claim("upn", "alice@contoso.example") }));
Assert.False(principal.IsExternalIdentity());
}
// ── IsValidTenantId: pre-signature tid GUID gate ─────────────────────
[Theory]
@@ -0,0 +1,62 @@
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The publisher pin and the payload catalog name live in places that cannot share code: the C#
/// constants (OOBE bootstrap template, agent self-updater), the bootstrap installer, the frozen
/// loader and the release workflow. A drift would make one verifier refuse what the others
/// accept, or the pipeline publish a catalog no device looks for, so each literal is pinned
/// against its constant here.
/// </summary>
public class CodeSigningParityTests
{
private static readonly Regex ExpectedPublisherLine = new(
"^\\$ExpectedPublisher = \"(?<pin>[^\"]+)\"\\r?$", RegexOptions.Multiline);
[Theory]
[InlineData("Install-AutopilotMonitor.ps1")]
[InlineData("Start-AutopilotMonitor.ps1")]
public void Bootstrap_script_pin_matches_the_constant(string scriptName)
{
var source = File.ReadAllText(Path.Combine(FindRepoRoot(), "scripts", "Bootstrap", scriptName));
var matches = ExpectedPublisherLine.Matches(source);
Assert.Single(matches);
Assert.Equal(Constants.CodeSigningPublisherPattern, matches[0].Groups["pin"].Value);
}
[Fact]
public void Pin_is_a_subject_pattern_not_a_thumbprint()
{
Assert.StartsWith("*O=", Constants.CodeSigningPublisherPattern);
Assert.EndsWith("*", Constants.CodeSigningPublisherPattern);
Assert.DoesNotMatch("^[0-9A-Fa-f]{40}$", Constants.CodeSigningPublisherPattern.Trim('*'));
// The agent's matcher implements -like for * and ? only (PayloadCatalogVerifier).
Assert.DoesNotContain("[", Constants.CodeSigningPublisherPattern);
}
[Fact]
public void Release_workflow_catalog_name_matches_the_constant()
{
var workflow = File.ReadAllText(Path.Combine(FindRepoRoot(), ".github", "workflows", "build-agent.yml"));
var match = Regex.Match(workflow, "^\\s+PAYLOAD_CATALOG: (?<name>\\S+)\\r?$", RegexOptions.Multiline);
Assert.True(match.Success, "build-agent.yml no longer declares PAYLOAD_CATALOG");
Assert.Equal(Constants.AgentPayloadCatalogFileName, match.Groups["name"].Value);
}
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;
}
}
@@ -0,0 +1,108 @@
using System.Security.Claims;
using AutopilotMonitor.Functions.Extensions;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// <see cref="ClaimsPrincipalExtensions.IsExternalIdentity"/>: a caller that is not a member of the token's tenant
/// never claims it (no first-login seed, no first-user Tenant Admin, no activation promotion). Positive evidence
/// only — every case expecting a member pins a false positive that would silently strand a genuine first sign-in.
/// A validated token carries tid and idp only under their inbound-mapped names, so both forms are covered.
/// </summary>
public class ExternalIdentityTests
{
private const string Tid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
private const string OtherTenant = "bbbbbbbb-1111-2222-3333-444444444444";
private const string MappedTenantId = "http://schemas.microsoft.com/identity/claims/tenantid";
private const string MappedIdp = "http://schemas.microsoft.com/identity/claims/identityprovider";
private static ClaimsPrincipal Principal(params Claim[] extra) => WithUpn("alice@contoso.example", extra);
private static ClaimsPrincipal WithUpn(string upn, params Claim[] extra)
{
var claims = new List<Claim> { new(MappedTenantId, Tid), new(ClaimTypes.Upn, upn) };
claims.AddRange(extra);
return new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
}
[Fact]
public void Member_WithoutAnySignal_IsNotExternal()
=> Assert.False(Principal().IsExternalIdentity());
// ── acct: Entra's guest flag ─────────────────────────────────────────────────
[Theory]
[InlineData("1", true)]
[InlineData("0", false)]
public void AcctClaim_GuestFlagDecides(string acct, bool external)
=> Assert.Equal(external, Principal(new Claim("acct", acct)).IsExternalIdentity());
// ── #EXT#: the resource-tenant UPN of an external account ───────────────────
[Theory]
[InlineData("upn")]
[InlineData(ClaimTypes.Upn)]
[InlineData("preferred_username")]
[InlineData("unique_name")]
[InlineData(ClaimTypes.Name)]
public void ExternalUpnMarker_InAnyUserNameClaim_IsExternal(string claimType)
{
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(MappedTenantId, Tid),
new Claim(claimType, "alice_contoso.example#ext#@fabrikam.example"),
}, "TestAuth"));
Assert.True(principal.IsExternalIdentity());
}
[Fact]
public void ExternalUpnMarker_InTheDisplayName_IsNoEvidence()
=> Assert.False(Principal(new Claim("name", "Alice #EXT# Example")).IsExternalIdentity());
[Fact]
public void UnderscoreExtSuffix_IsNoEvidence_MemberNamingSchemesUseIt()
=> Assert.False(WithUpn("m.muster_ext_@contoso.example").IsExternalIdentity());
// ── idp: where the account authenticated ────────────────────────────────────
[Theory]
[InlineData("https://sts.windows.net/" + OtherTenant + "/", true)] // a guest of another Entra tenant
[InlineData("https://login.microsoftonline.com/" + OtherTenant + "/v2.0", true)]
[InlineData("https://sts.windows.net/9188040d-6c67-4c5b-b112-36a304b66dad/", true)] // the personal-account tenant
[InlineData("live.com", true)] // a personal Microsoft account
[InlineData("LIVE.COM", true)]
[InlineData("mail", true)] // email one-time passcode
[InlineData("google.com", true)]
[InlineData("facebook.com", true)]
[InlineData("https://sts.windows.net/" + Tid + "/", false)] // a member: its own tenant (v1 form, also under a v2 issuer)
[InlineData("https://login.microsoftonline.com/" + Tid + "/v2.0", false)]
[InlineData("http://adfs.contoso.example/adfs/services/trust", false)] // an unknown host: no evidence either way
[InlineData("https://auth.idp.example/" + OtherTenant + "/as", false)] // a GUID segment on a foreign host: no evidence
[InlineData("", false)]
public void IdpClaim_NamesWhereTheAccountAuthenticated(string idp, bool external)
=> Assert.Equal(external, Principal(new Claim("idp", idp)).IsExternalIdentity());
[Theory]
[InlineData("idp")]
[InlineData(MappedIdp)]
public void IdpNamingAnotherTenant_IsExternal_InEitherClaimForm(string idpClaimType)
=> Assert.True(Principal(new Claim(idpClaimType, $"https://sts.windows.net/{OtherTenant}/")).IsExternalIdentity());
[Theory]
[InlineData("tid")]
[InlineData(MappedTenantId)]
public void IdpNamingTheOwnTenant_IsAMember_InEitherTenantClaimForm(string tenantClaimType)
{
// A validated token has no raw tid: comparing against it would call every member with an idp external.
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(tenantClaimType, Tid),
new Claim(ClaimTypes.Upn, "alice@contoso.example"),
new Claim(MappedIdp, $"https://sts.windows.net/{Tid}/"),
}, "TestAuth"));
Assert.False(principal.IsExternalIdentity());
}
}
@@ -0,0 +1,53 @@
using AutopilotMonitor.Functions.Helpers;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// <see cref="LogThrottle"/>: one line per key and interval, so a polled path (auth/me under the activation page,
/// a delegated token on every request) shows up in App Insights without flooding it; bounded memory.
/// </summary>
public class LogThrottleTests
{
private static readonly DateTime T0 = new(2026, 10, 13, 8, 0, 0, DateTimeKind.Utc);
[Fact]
public void FirstLine_Passes_ARepeatWithinTheInterval_DoesNot()
{
var throttle = new LogThrottle(TimeSpan.FromHours(1));
Assert.True(throttle.ShouldLog("k", T0));
Assert.False(throttle.ShouldLog("k", T0.AddMinutes(59)));
}
[Fact]
public void ARepeat_PassesAgain_OnceTheIntervalHasElapsed()
{
var throttle = new LogThrottle(TimeSpan.FromHours(1));
Assert.True(throttle.ShouldLog("k", T0));
Assert.True(throttle.ShouldLog("k", T0.AddHours(1)));
Assert.False(throttle.ShouldLog("k", T0.AddHours(1).AddMinutes(1)));
}
[Fact]
public void Keys_AreThrottledIndependently()
{
var throttle = new LogThrottle(TimeSpan.FromHours(1));
Assert.True(throttle.ShouldLog("a", T0));
Assert.True(throttle.ShouldLog("b", T0));
Assert.False(throttle.ShouldLog("a", T0));
}
[Fact]
public void AtTheKeyCap_TheMemoryIsDropped_SoALineRepeatsEarlyInsteadOfGrowingWithoutBound()
{
var throttle = new LogThrottle(TimeSpan.FromHours(1), maxKeys: 2);
Assert.True(throttle.ShouldLog("a", T0));
Assert.True(throttle.ShouldLog("b", T0));
Assert.True(throttle.ShouldLog("c", T0));
Assert.True(throttle.ShouldLog("a", T0));
}
}
@@ -19,6 +19,7 @@ namespace AutopilotMonitor.Functions.Tests;
/// farewell mails. The route must admit roleless callers (first-touch signup) but only while the
/// tenant has no member yet — afterwards a member role is required, so an ordinary employee with a
/// valid JWT and no product role can no longer redirect or suppress the tenant's correspondence.
/// The window is never an external identity's: it does not activate the tenant.
/// </summary>
public class PreviewNotificationEmailWriteGateTests
{
@@ -43,7 +44,7 @@ public class PreviewNotificationEmailWriteGateTests
var admins = AdminsWith(enabledMember: true);
var ctx = new RequestContext { UserRole = role };
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, externalIdentity: false, TenantId, admins.Object));
admins.Verify(a => a.GetTenantAdminsAsync(It.IsAny<string>()), Times.Never);
}
@@ -53,7 +54,7 @@ public class PreviewNotificationEmailWriteGateTests
var admins = AdminsWith(enabledMember: true);
var ctx = new RequestContext { IsGlobalAdmin = true };
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, externalIdentity: false, TenantId, admins.Object));
}
[Fact]
@@ -62,7 +63,7 @@ public class PreviewNotificationEmailWriteGateTests
var admins = AdminsWith(enabledMember: false);
var ctx = new RequestContext { UserRole = "Authenticated" };
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, externalIdentity: false, TenantId, admins.Object));
}
[Fact]
@@ -71,7 +72,7 @@ public class PreviewNotificationEmailWriteGateTests
var admins = AdminsWith(enabledMember: true);
var ctx = new RequestContext { UserRole = "Authenticated" };
Assert.False(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
Assert.False(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, externalIdentity: false, TenantId, admins.Object));
}
[Fact]
@@ -86,7 +87,26 @@ public class PreviewNotificationEmailWriteGateTests
});
var ctx = new RequestContext { UserRole = "Authenticated" };
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, TenantId, admins.Object));
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, externalIdentity: false, TenantId, admins.Object));
}
[Fact]
public async Task External_identity_never_writes_in_the_signup_window()
{
// The window exists for the activating user, and an external identity never activates a tenant.
var admins = AdminsWith(enabledMember: false);
var ctx = new RequestContext { UserRole = "Authenticated" };
Assert.False(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, externalIdentity: true, TenantId, admins.Object));
}
[Fact]
public async Task External_identity_with_a_role_the_tenant_granted_may_write()
{
var admins = AdminsWith(enabledMember: true);
var ctx = new RequestContext { UserRole = Constants.TenantRoles.Admin };
Assert.True(await PreviewWhitelistFunction.MayWriteNotificationEmailAsync(ctx, externalIdentity: true, TenantId, admins.Object));
}
private static Mock<TenantAdminsService> AdminsWith(bool enabledMember)
@@ -1,37 +1,29 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Unit tests for the static helpers <c>TenantApprovalService.PickRequesterUpn</c>
/// and <c>TenantApprovalService.IsRealUserUpn</c>.
/// Unit tests for <c>TenantApprovalService.IsPromotableUpn</c>, the shape guard on the stored onboarding UPN that
/// activation promotes to TenantAdmin.
/// <para>
/// These guard the auto-promote-on-preview-approval path, which previously corrupted
/// 10 tenants' TenantAdmins rows by writing the sentinel string
/// <c>"System (Global Rate Limit Sync)"</c> as a TenantAdmin Upn. The fixes:
/// <list type="number">
/// <item>shape-based UPN validation (no enumeration of sentinel strings),</item>
/// <item>prefer the immutable <see cref="TenantConfiguration.OnboardedBy"/> over the
/// mutable <see cref="TenantConfiguration.UpdatedBy"/>.</item>
/// </list>
/// The auto-promote-on-approval path once corrupted 10 tenants' TenantAdmins rows by writing the sentinel string
/// <c>"System (Global Rate Limit Sync)"</c> as a TenantAdmin Upn. The guard is therefore shape-based (no
/// enumeration of sentinel strings), and it also refuses an external account's resource-tenant UPN. Which value is
/// promoted at all (OnboardedBy only, never UpdatedBy) is pinned in <see cref="TenantApprovalServiceTests"/>.
/// </para>
/// </summary>
public class PreviewWhitelistRequesterUpnTests
{
// -------------------------------------------------------------------------
// IsRealUserUpn — positive shape check, immune to new sentinel strings
// -------------------------------------------------------------------------
[Theory]
[InlineData("user@contoso.com")]
[InlineData("admin@tenant.onmicrosoft.com")]
[InlineData("first.last@example.de")]
[InlineData("cadm_user@contoso.onmicrosoft.com")]
public void IsRealUserUpn_AcceptsRealUpnShapes(string upn)
[InlineData("m.muster_ext_@contoso.example")] // a member naming scheme, not the external marker
public void IsPromotableUpn_AcceptsRealUpnShapes(string upn)
{
Assert.True(TenantApprovalService.IsRealUserUpn(upn));
Assert.True(TenantApprovalService.IsPromotableUpn(upn));
}
[Theory]
@@ -41,12 +33,20 @@ public class PreviewWhitelistRequesterUpnTests
[InlineData("System (Global Rate Limit Sync)")]
[InlineData("system (global rate limit sync)")]
[InlineData("System (whatever-future-sentinel)")]
public void IsRealUserUpn_RejectsAnySystemPrefixSentinel(string upn)
public void IsPromotableUpn_RejectsAnySystemPrefixSentinel(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.
Assert.False(TenantApprovalService.IsRealUserUpn(upn));
Assert.False(TenantApprovalService.IsPromotableUpn(upn));
}
[Theory]
[InlineData("alice_fabrikam.example#EXT#@contoso.example")]
[InlineData("alice_fabrikam.example#ext#@contoso.example")]
public void IsPromotableUpn_RejectsAnExternalAccountsResourceTenantUpn(string upn)
{
Assert.False(TenantApprovalService.IsPromotableUpn(upn));
}
[Theory]
@@ -55,80 +55,8 @@ public class PreviewWhitelistRequesterUpnTests
[InlineData(" ")]
[InlineData("nodomain")]
[InlineData("just-a-string")]
public void IsRealUserUpn_RejectsNullOrNonEmailShapes(string? upn)
public void IsPromotableUpn_RejectsNullOrNonEmailShapes(string? upn)
{
Assert.False(TenantApprovalService.IsRealUserUpn(upn));
}
// -------------------------------------------------------------------------
// PickRequesterUpn — prefer OnboardedBy, fall back to UpdatedBy
// -------------------------------------------------------------------------
[Fact]
public void PickRequesterUpn_PrefersOnboardedByEvenWhenUpdatedByIsClean()
{
var cfg = new TenantConfiguration
{
OnboardedBy = "onboarder@contoso.com",
UpdatedBy = "editor@contoso.com",
};
Assert.Equal("onboarder@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
}
[Fact]
public void PickRequesterUpn_FallsBackToUpdatedByWhenOnboardedByNull()
{
var cfg = new TenantConfiguration
{
OnboardedBy = null,
UpdatedBy = "editor@contoso.com",
};
Assert.Equal("editor@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
}
[Fact]
public void PickRequesterUpn_FallsBackToUpdatedByWhenOnboardedByEmpty()
{
var cfg = new TenantConfiguration
{
OnboardedBy = "",
UpdatedBy = "editor@contoso.com",
};
Assert.Equal("editor@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
}
[Fact]
public void PickRequesterUpn_ShieldsAgainstUpdatedByClobberWhenOnboardedBySet()
{
// Regression: this is the exact scenario that corrupted 10 tenants.
// The global rate-limit sync clobbered UpdatedBy with the sentinel string;
// OnboardedBy preserves the real requester so auto-promote still picks them.
var cfg = new TenantConfiguration
{
OnboardedBy = "real.requester@contoso.com",
UpdatedBy = "System (Global Rate Limit Sync)",
};
Assert.Equal("real.requester@contoso.com", TenantApprovalService.PickRequesterUpn(cfg));
Assert.True(TenantApprovalService.IsRealUserUpn(TenantApprovalService.PickRequesterUpn(cfg)));
}
[Fact]
public void PickRequesterUpn_LegacyTenant_NoOnboardedBy_SentinelInUpdatedBy_IsRejectedByGuard()
{
// Defense-in-depth: even without OnboardedBy (pre-fix tenants), the IsRealUserUpn
// guard alone prevents the sentinel from leaking into TenantAdmins.
var cfg = new TenantConfiguration
{
OnboardedBy = null,
UpdatedBy = "System (Global Rate Limit Sync)",
};
var picked = TenantApprovalService.PickRequesterUpn(cfg);
Assert.Equal("System (Global Rate Limit Sync)", picked);
Assert.False(TenantApprovalService.IsRealUserUpn(picked));
Assert.False(TenantApprovalService.IsPromotableUpn(upn));
}
}
@@ -148,6 +148,10 @@ public class PushAlertProjectorTests
Assert.Equal(8030, root.GetProperty("web_push").GetInt32());
Assert.True(root.GetProperty("mutable").GetBoolean());
var notification = root.GetProperty("notification");
// Safari 18.4–26.1 reads "mutable" only inside "notification"; without it the worker never runs there.
Assert.True(notification.GetProperty("mutable").GetBoolean());
Assert.False(root.TryGetProperty("app_badge", out _));
Assert.False(notification.TryGetProperty("app_badge", out _));
Assert.Equal(Constants.PortalPushHistoryUrl("entry-9"), notification.GetProperty("navigate").GetString());
var data = notification.GetProperty("data");
Assert.Equal(1, data.GetProperty("v").GetInt32());
@@ -5,6 +5,7 @@ using AutopilotMonitor.Functions.Services.Push;
using AutopilotMonitor.Push;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -15,8 +16,8 @@ namespace AutopilotMonitor.Functions.Tests.Push;
/// Session watches ("notify my devices when this session ends"): the watch row lives in the
/// SESSION's tenant, the devices it fires on are the watcher's OWN scopes (home tenant, plus the
/// platform partition for a Global Admin), gated by an enabled Push channel and the role check at
/// watch time and again at notify time. 48-hour lifetime, one-hour push TTL, consumed at the
/// terminal status, fail-soft towards the ingest path.
/// watch time and again at notify time. 48-hour lifetime (30 days on a sealed session), one-hour
/// push TTL, consumed at the terminal status or the WhiteGlove seal, fail-soft towards the ingest path.
/// </summary>
public class PushSessionWatchServiceTests
{
@@ -40,7 +41,7 @@ public class PushSessionWatchServiceTests
h.AddDevice("d1", Admin, HomeTenantId);
h.Eligibility.Setup(e => e.HasPushChannelAsync(It.IsAny<NotificationScope>())).ReturnsAsync(false);
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin);
Assert.Equal(PushOpError.ChannelRequired, result.Error);
Assert.Empty(h.Repo.Watches);
@@ -53,7 +54,7 @@ public class PushSessionWatchServiceTests
h.AddDevice("d1", Admin, HomeTenantId);
h.Eligibility.Setup(e => e.IsEligibleAsync(Home, Admin.Upn, Admin.ObjectId, Admin.HomeTenantId)).ReturnsAsync(false);
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin);
Assert.Equal(PushOpError.NotEligible, result.Error);
Assert.Empty(h.Repo.Watches);
@@ -68,7 +69,7 @@ public class PushSessionWatchServiceTests
var h = new Harness();
h.AddDevice("d1", Admin, HomeTenantId, status);
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin);
Assert.Equal(PushOpError.Conflict, result.Error);
Assert.Empty(h.Repo.Watches);
@@ -80,7 +81,7 @@ public class PushSessionWatchServiceTests
var h = new Harness();
h.AddDevice("d1", Operator, HomeTenantId);
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin);
Assert.Equal(PushOpError.Conflict, result.Error);
Assert.Empty(h.Repo.Watches);
@@ -93,7 +94,7 @@ public class PushSessionWatchServiceTests
h.AddDevice("d1", Admin, HomeTenantId);
var before = DateTime.UtcNow;
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, Admin);
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.InProgress, Admin);
var after = DateTime.UtcNow;
Assert.True(result.Ok);
@@ -107,13 +108,29 @@ public class PushSessionWatchServiceTests
Assert.Equal(row.ExpiresUtc, result.Value.ExpiresUtc!.Value);
}
[Fact]
public async Task Watch_on_a_sealed_session_lives_thirty_days_because_the_user_phase_starts_whenever_the_device_is_handed_out()
{
var h = new Harness();
h.AddDevice("d1", Admin, HomeTenantId);
var before = DateTime.UtcNow;
var result = await h.Sut.WatchAsync(HomeTenantId, SessionId, SessionStatus.Pending, Admin);
var after = DateTime.UtcNow;
Assert.True(result.Ok);
var row = h.Repo.Watches[(HomeTenantId, SessionId, Admin.ObjectId)];
Assert.InRange(row.ExpiresUtc, before.AddDays(30), after.AddDays(30));
Assert.Equal(row.ExpiresUtc, result.Value!.ExpiresUtc!.Value);
}
[Fact]
public async Task Global_admin_with_only_a_platform_device_watches_a_customer_session_from_the_platform_scope()
{
var h = new Harness();
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, GlobalAdmin);
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, SessionStatus.InProgress, GlobalAdmin);
Assert.True(result.Ok);
// The row lives in the SESSION's tenant partition, not in the platform or the home partition.
@@ -130,7 +147,7 @@ public class PushSessionWatchServiceTests
h.AddDevice("h1", GlobalAdmin, HomeTenantId);
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, GlobalAdmin);
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, SessionStatus.InProgress, GlobalAdmin);
Assert.True(result.Ok);
Assert.Equal($"{Constants.Push.PlatformScope};{HomeTenantId}", h.Repo.Watches[(CustomerTenantId, SessionId, GlobalAdmin.ObjectId)].OwnerScopes);
@@ -144,7 +161,7 @@ public class PushSessionWatchServiceTests
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
h.Eligibility.Setup(e => e.HasPushChannelAsync(Home)).ReturnsAsync(false);
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, GlobalAdmin);
var result = await h.Sut.WatchAsync(CustomerTenantId, SessionId, SessionStatus.InProgress, GlobalAdmin);
Assert.True(result.Ok);
Assert.Equal(Constants.Push.PlatformScope, h.Repo.Watches[(CustomerTenantId, SessionId, GlobalAdmin.ObjectId)].OwnerScopes);
@@ -263,7 +280,7 @@ public class PushSessionWatchServiceTests
h.AddDevice("c2", CustomerAdmin, CustomerTenantId);
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope);
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890");
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", "1234567890");
Assert.Equal(Endpoints(p1, p2), SentTo(h));
Assert.Empty(h.Repo.Watches);
@@ -283,12 +300,40 @@ public class PushSessionWatchServiceTests
h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId);
h.AddWatch(HomeTenantId, SessionId, Operator, HomeTenantId);
await h.Sut.NotifyAsync(HomeTenantId, SessionId, success: false, "DESKTOP-4711", null);
await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Failed,"DESKTOP-4711", null);
Assert.Equal(Endpoints(d1, d2), SentTo(h));
Assert.Empty(h.Repo.Watches);
}
[Fact]
public async Task Notify_at_the_seal_pushes_and_consumes_the_watch_so_the_user_phase_needs_a_new_one()
{
var h = new Harness();
var device = h.AddDevice("d1", Admin, HomeTenantId);
h.AddWatch(HomeTenantId, SessionId, Admin, HomeTenantId);
await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Sealed, "DESKTOP-4711", "1234567890");
Assert.Equal(device.Endpoint, Assert.Single(h.Http.Requests).RequestUri!.ToString());
Assert.Empty(h.Repo.Watches);
}
[Theory]
[InlineData(SessionWatchOutcome.Succeeded, "Enrollment finished", "DESKTOP-4711 (…7890) finished enrollment.", "success")]
[InlineData(SessionWatchOutcome.Failed, "Enrollment failed", "DESKTOP-4711 (…7890) failed enrollment.", "error")]
[InlineData(SessionWatchOutcome.Sealed, "Pre-provisioning finished", "DESKTOP-4711 (…7890) finished pre-provisioning.", "success")]
public void Watch_message_names_the_outcome_with_the_device_and_the_masked_serial(SessionWatchOutcome outcome, string title, string body, string severity)
{
Assert.Equal((title, body, severity), PushSessionWatchService.WatchMessage(outcome, "DESKTOP-4711", "1234567890"));
}
[Fact]
public void Watch_message_without_a_device_name_or_serial_says_the_device()
{
Assert.Equal("The device finished pre-provisioning.", PushSessionWatchService.WatchMessage(SessionWatchOutcome.Sealed, null, null).Body);
}
[Fact]
public async Task Notify_with_empty_owner_scopes_falls_back_to_the_session_tenant()
{
@@ -296,7 +341,7 @@ public class PushSessionWatchServiceTests
var device = h.AddDevice("d1", Admin, HomeTenantId);
h.AddWatch(HomeTenantId, SessionId, Admin, ownerScopes: string.Empty); // legacy row
await h.Sut.NotifyAsync(HomeTenantId, SessionId, success: true, "DESKTOP-4711", null);
await h.Sut.NotifyAsync(HomeTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", null);
var request = Assert.Single(h.Http.Requests);
Assert.Equal(device.Endpoint, request.RequestUri!.ToString());
@@ -310,7 +355,7 @@ public class PushSessionWatchServiceTests
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope, expiresUtc: DateTime.UtcNow.AddMinutes(-1));
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: false, "DESKTOP-4711", "1234567890");
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Failed,"DESKTOP-4711", "1234567890");
Assert.Empty(h.Http.Requests);
Assert.Empty(h.Repo.Watches);
@@ -323,7 +368,7 @@ public class PushSessionWatchServiceTests
repo.Setup(r => r.GetWatchesForSessionAsync(CustomerTenantId, SessionId)).ReturnsAsync(new List<PushSessionWatch>());
var h = new Harness(repo.Object);
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", null);
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", null);
repo.Verify(r => r.GetWatchesForSessionAsync(CustomerTenantId, SessionId), Times.Once);
repo.VerifyNoOtherCalls();
@@ -331,15 +376,16 @@ public class PushSessionWatchServiceTests
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Notify_push_carries_the_one_hour_ttl_and_high_urgency(bool success)
[InlineData(SessionWatchOutcome.Succeeded)]
[InlineData(SessionWatchOutcome.Failed)]
[InlineData(SessionWatchOutcome.Sealed)]
public async Task Notify_push_carries_the_one_hour_ttl_and_high_urgency(SessionWatchOutcome outcome)
{
var h = new Harness();
h.AddDevice("p1", GlobalAdmin, Constants.Push.PlatformScope);
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope);
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success, "DESKTOP-4711", "1234567890");
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, outcome, "DESKTOP-4711", "1234567890");
var request = Assert.Single(h.Http.Requests);
Assert.Equal("3600", Header(request, "TTL")); // WatchPushTtl: "Watch bis Session-Ende + 1 h"
@@ -354,7 +400,7 @@ public class PushSessionWatchServiceTests
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope);
h.Eligibility.Setup(e => e.IsEligibleAsync(NotificationScope.Platform, GlobalAdmin.Upn, GlobalAdmin.ObjectId, GlobalAdmin.HomeTenantId)).ReturnsAsync(false);
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890");
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", "1234567890");
Assert.Empty(h.Http.Requests);
Assert.Equal(Constants.Push.DeviceStatus.Active, h.Repo.Devices[(Constants.Push.PlatformScope, "p1")].Status);
@@ -369,7 +415,7 @@ public class PushSessionWatchServiceTests
h.AddWatch(CustomerTenantId, SessionId, GlobalAdmin, Constants.Push.PlatformScope);
h.Eligibility.Setup(e => e.IsOwnerFreshAsync(GlobalAdmin.HomeTenantId, GlobalAdmin.ObjectId, It.IsAny<DateTime>())).ReturnsAsync(false);
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890");
await h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", "1234567890");
var row = h.Repo.Devices[(Constants.Push.PlatformScope, "p1")];
Assert.Equal(Constants.Push.DeviceStatus.Paused, row.Status);
@@ -387,7 +433,7 @@ public class PushSessionWatchServiceTests
repo.Setup(r => r.GetWatchesForSessionAsync(It.IsAny<string>(), It.IsAny<string>())).ThrowsAsync(new InvalidOperationException("storage unavailable"));
var h = new Harness(repo.Object);
var exception = await Record.ExceptionAsync(() => h.Sut.NotifyAsync(CustomerTenantId, SessionId, success: true, "DESKTOP-4711", "1234567890"));
var exception = await Record.ExceptionAsync(() => h.Sut.NotifyAsync(CustomerTenantId, SessionId, SessionWatchOutcome.Succeeded,"DESKTOP-4711", "1234567890"));
Assert.Null(exception);
Assert.Empty(h.Http.Requests);
@@ -0,0 +1,71 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Push;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests.Push;
/// <summary>
/// The ingest gate that decides whether a batch fires the session watches: the terminal status on
/// the enrollment alert's gate, else the first genuine WhiteGlove seal on the pre-provisioning
/// alert's gate. A duplicate seal or a status that did not change fires nothing.
/// </summary>
public class SessionWatchOutcomeGateTests
{
private static EnrollmentEvent Event(string type) => new() { EventType = type };
[Fact]
public void A_completion_that_changed_the_status_fires_succeeded()
{
var c = new EventClassification { CompletionEvent = Event("enrollment_complete") };
Assert.Equal(SessionWatchOutcome.Succeeded, EventIngestProcessor.WatchOutcomeFor(true, false, c, null));
}
[Fact]
public void A_failure_with_a_failure_verdict_fires_failed()
{
var c = new EventClassification { FailureEvent = Event("enrollment_failed") };
Assert.Equal(SessionWatchOutcome.Failed, EventIngestProcessor.WatchOutcomeFor(true, false, c, "ESP timeout (0x800705b4)"));
}
[Fact]
public void An_agent_timeout_that_classified_without_a_failure_reason_fires_nothing()
{
var c = new EventClassification { FailureEvent = Event("enrollment_failed") };
Assert.Null(EventIngestProcessor.WatchOutcomeFor(true, false, c, null));
}
[Fact]
public void A_terminal_event_without_a_status_change_fires_nothing()
{
var c = new EventClassification { CompletionEvent = Event("enrollment_complete") };
Assert.Null(EventIngestProcessor.WatchOutcomeFor(false, false, c, null));
}
[Fact]
public void The_first_genuine_seal_fires_sealed()
{
var c = new EventClassification { WhiteGloveEvent = Event("whiteglove_complete") };
Assert.Equal(SessionWatchOutcome.Sealed, EventIngestProcessor.WatchOutcomeFor(false, true, c, null));
}
[Fact]
public void The_duplicate_whiteglove_complete_fires_nothing()
{
// The agent emits whiteglove_complete twice; the second batch finds the session already
// sealed, so whiteGloveStatusTransitioned stays false and the watch is not consumed again.
var c = new EventClassification { WhiteGloveEvent = Event("whiteglove_complete") };
Assert.Null(EventIngestProcessor.WatchOutcomeFor(false, false, c, null));
}
[Fact]
public void A_batch_without_terminal_or_seal_events_fires_nothing()
{
Assert.Null(EventIngestProcessor.WatchOutcomeFor(true, true, new EventClassification(), null));
}
}
@@ -21,6 +21,7 @@ public class TenantApprovalServiceTests
private readonly Mock<PreviewWhitelistService> _previewMock;
private readonly Mock<TenantConfigurationService> _tenantConfigMock;
private readonly Mock<TenantAdminsService> _tenantAdminsMock;
private readonly Mock<IEmailService> _emailMock;
private readonly List<OpsEventEntry> _opsEvents = new();
private readonly TenantApprovalService _sut;
@@ -53,12 +54,15 @@ public class TenantApprovalServiceTests
configRepo, NullLogger<AdminConfigurationService>.Instance, cache);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
// IsTenantAdminAsync is not virtual: it runs for real against the empty repository (no member yet).
_tenantAdminsMock = new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance);
_sut = new TenantApprovalService(
NullLogger<TenantApprovalService>.Instance,
_previewMock.Object,
_tenantConfigMock.Object,
new Mock<TenantAdminsService>(
Mock.Of<IAdminRepository>(), cache, NullLogger<TenantAdminsService>.Instance).Object,
_tenantAdminsMock.Object,
_emailMock.Object,
new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch));
}
@@ -95,8 +99,8 @@ public class TenantApprovalServiceTests
{
_previewMock.Setup(x => x.ApproveAsync(TenantId, ApprovedBy)).ReturnsAsync(true);
_previewMock.Setup(x => x.GetNotificationEmailAsync(TenantId)).ReturnsAsync((string?)null);
// Default config has no OnboardedBy/UpdatedBy UPN → auto-promote is skipped by the
// shape guard; no notification email → no mail. Only the return value is under test.
// Default config has no OnboardedBy → nothing to promote; no notification email → no
// mail. Only the return value is under test.
_tenantConfigMock.Setup(x => x.GetConfigurationAsync(TenantId))
.ReturnsAsync(TenantConfiguration.CreateDefault(TenantId));
@@ -106,6 +110,55 @@ public class TenantApprovalServiceTests
_previewMock.Verify(x => x.GetNotificationEmailAsync(TenantId), Times.Once);
}
// --- Auto-promote on activation: only the onboarding member (OnboardedBy) becomes Tenant Admin.
private void ArrangeNewlyApproved(string? onboardedBy, string updatedBy = "System")
{
var config = ConfigWith(contactEmail: null);
config.OnboardedBy = onboardedBy;
config.UpdatedBy = updatedBy;
_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);
}
private void VerifyNoPromotion() =>
_tenantAdminsMock.Verify(
x => x.AddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
[Fact]
public async Task NewlyApproved_PromotesTheOnboardingMember()
{
// A sync job clobbering UpdatedBy with its sentinel once leaked into TenantAdmins; OnboardedBy decides alone.
ArrangeNewlyApproved(onboardedBy: "first.member@contoso.example", updatedBy: "System (Global Rate Limit Sync)");
await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
_tenantAdminsMock.Verify(
x => x.AddTenantAdminAsync(TenantId, "first.member@contoso.example", ApprovedBy), Times.Once);
}
[Fact]
public async Task NewlyApproved_NeverPromotesAnExternalAccountsUpn()
{
ArrangeNewlyApproved(onboardedBy: "alice_fabrikam.example#EXT#@contoso.example");
await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
VerifyNoPromotion();
}
[Fact]
public async Task NewlyApproved_WithoutOnboardedBy_NeverFallsBackToTheLastWriter()
{
// UpdatedBy is whoever wrote the row last (any signed-in user can, e.g. by accepting the DPA), not the requester.
ArrangeNewlyApproved(onboardedBy: null, updatedBy: "first.member@contoso.example");
await _sut.ApproveWithSideEffectsAsync(TenantId, ApprovedBy);
VerifyNoPromotion();
}
// --- 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
@@ -181,6 +181,6 @@ public class TenantAutoApproveHandlerTests
{
// The sentinel must never pass the promotable-user shape check — that is the
// defense against writing a system string into the TenantAdmins table.
Assert.False(TenantApprovalService.IsRealUserUpn(TenantAutoApproveHandler.AutoApprovedBy));
Assert.False(TenantApprovalService.IsPromotableUpn(TenantAutoApproveHandler.AutoApprovedBy));
}
}
@@ -86,6 +86,75 @@ public static class ClaimsPrincipalExtensions
public static bool IsCappedPrincipal(this ClaimsPrincipal principal)
=> principal.IsApplicationPrincipal() || principal.IsClientCapped();
private const string ExternalUpnMarker = "#EXT#";
private const string IdentityProviderClaimType = "http://schemas.microsoft.com/identity/claims/identityprovider";
// Raw and inbound-mapped names of the claims that carry a user name — never the display name ("name").
private static readonly string[] UserNameClaimTypes =
{ "upn", ClaimTypes.Upn, "preferred_username", "unique_name", ClaimTypes.Name };
// idp values that only an external account carries in a workforce tenant: a personal Microsoft account,
// an email one-time passcode, the social sign-in providers.
private static readonly HashSet<string> ExternalOnlyIdentityProviders =
new(StringComparer.OrdinalIgnoreCase) { "live.com", "mail", "google.com", "facebook.com" };
// The Entra token service: an idp URI on one of these hosts names the authenticating tenant first.
private static readonly HashSet<string> EntraSignInHosts = new(StringComparer.OrdinalIgnoreCase)
{
new Uri(AutopilotMonitor.Shared.Constants.EntraLoginBaseUrl).Host, "sts.windows.net", "login.windows.net", "login.microsoft.com",
};
private static readonly Guid PersonalMicrosoftAccountTenantId = new("9188040d-6c67-4c5b-b112-36a304b66dad");
/// <summary>
/// True when the token shows that the caller is not a member of the token's tenant (<c>tid</c>): a B2B guest
/// or another external identity. Such a caller keeps what the tenant grants it explicitly but never claims the
/// tenant — no first-login seed, no first-user Tenant Admin, no activation promotion, no write in the roleless
/// signup window. Positive evidence only; a token without any of these signals is a member's:
/// <list type="bullet">
/// <item><c>acct = 1</c>, Entra's guest flag (an optional claim of the API app registration);</item>
/// <item><c>#EXT#</c> in a user-name claim, the resource-tenant UPN Entra gives external accounts;</item>
/// <item>an <c>idp</c> naming a personal Microsoft account, an external sign-in provider, or another tenant on
/// an Entra sign-in host. A member's token carries no <c>idp</c> or one naming its own tenant. An <c>idp</c> on
/// any other host is no evidence: a false positive would silently strand a genuine first sign-in.</item>
/// </list>
/// Inbound claim mapping renames <c>tid</c> and <c>idp</c>, so both are read in either form.
/// </summary>
public static bool IsExternalIdentity(this ClaimsPrincipal principal)
{
if (string.Equals(principal.FindFirst("acct")?.Value?.Trim(), "1", StringComparison.Ordinal))
return true;
if (UserNameClaimTypes.Any(type => IsExternalUpn(principal.FindFirst(type)?.Value)))
return true;
var identityProvider = principal.FindFirst("idp")?.Value ?? principal.FindFirst(IdentityProviderClaimType)?.Value;
return IsForeignIdentityProvider(identityProvider, principal.GetTenantId());
}
/// <summary>True when a user name carries the <c>#EXT#</c> marker of an external account's resource-tenant UPN.</summary>
internal static bool IsExternalUpn(string? userName)
=> userName != null && userName.Contains(ExternalUpnMarker, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// True when an <c>idp</c> claim value shows that the account authenticated outside <paramref name="tenantId"/>.
/// </summary>
internal static bool IsForeignIdentityProvider(string? identityProvider, string? tenantId)
{
var idp = identityProvider?.Trim();
if (string.IsNullOrEmpty(idp))
return false;
if (ExternalOnlyIdentityProviders.Contains(idp))
return true;
if (!Uri.TryCreate(idp, UriKind.Absolute, out var uri) || !EntraSignInHosts.Contains(uri.Host))
return false;
if (!Guid.TryParse(uri.AbsolutePath.Trim('/').Split('/')[0], out var authenticatingTenant))
return false;
return authenticatingTenant == PersonalMicrosoftAccountTenantId
|| !Guid.TryParse(tenantId, out var tokenTenant)
|| authenticatingTenant != tokenTenant;
}
/// <summary>
/// The caller's rate-limit budget class (<see cref="Security.ThrottleSurface"/>), from signed claims
/// only: <c>appidacr</c> (v1.0) / <c>azpacr</c> (v2.0) say how the CLIENT authenticated — <c>0</c> is a
@@ -22,6 +22,8 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure;
/// </summary>
public class AuthFunction
{
private static readonly LogThrottle ExternalIdentityLog = new(TimeSpan.FromHours(1));
private readonly ILogger<AuthFunction> _logger;
private readonly GlobalAdminService _globalAdminService;
private readonly DelegatedAdminService _delegatedAdminService;
@@ -162,8 +164,13 @@ public class AuthFunction
// excluded, so a read-only MSP login does not phantom-onboard its home tenant (false signup, default
// config row). A delegated user who legitimately participates in their own tenant (member/global)
// still onboards normally; the handlers self-gate when the config already exists.
// An external identity (a guest: this tid is not its home) never participates, whatever it was
// granted: its home domain would become this tenant's DomainName, which resolves cross-tenant grants,
// and its UPN the OnboardedBy that activation promotes to Tenant Admin.
var isDelegated = delegatedTenantIds.Count > 0;
var isHomeTenantParticipant = !isDelegated || isGlobalAdmin || isGlobalReader || memberRole != null;
var isExternalIdentity = principal.IsExternalIdentity();
var isHomeTenantParticipant = !isExternalIdentity
&& (!isDelegated || isGlobalAdmin || isGlobalReader || memberRole != null);
// Which app registration minted this login's token (dual app-reg window) — drives the
// onboarding homing decision and the per-tenant last-seen provenance below.
var tokenAudience = principal.GetAudience();
@@ -176,6 +183,14 @@ public class AuthFunction
await HandleAutoReEnableAsync(tenantConfig, tenantId);
await HandleAuthClientIdTrackingAsync(tenantConfig, tenantId, tokenAudience);
}
else if (isExternalIdentity && string.IsNullOrEmpty(tenantConfig.DomainName)
&& ExternalIdentityLog.ShouldLog($"{tenantId}|{objectId}"))
{
// The activation page polls auth/me, so this is throttled per caller.
_logger.LogWarning(
"External identity {Upn} signed in to tenant {TenantId}, which no member has onboarded yet; it does not onboard it",
upn, tenantId);
}
// --- Pure decision logic (tested by AuthFunctionTests) ---
var decision = BuildAuthResult(
@@ -186,7 +201,8 @@ public class AuthFunction
homedApp: _appRegistry.ResolveForTenant(tenantConfig).IsLegacy ? "legacy" : "primary",
whatsNewSeenPlatformUtc: whatsNewSeen.PlatformUtc,
whatsNewSeenAgentUtc: whatsNewSeen.AgentUtc,
mcpClientRegistrationEnabled: (await _adminConfigService.GetConfigurationAsync())?.McpClientRegistrationEnabled ?? false);
mcpClientRegistrationEnabled: (await _adminConfigService.GetConfigurationAsync())?.McpClientRegistrationEnabled ?? false,
isExternalIdentity: isExternalIdentity);
if (!decision.IsSuccess)
{
@@ -723,7 +739,8 @@ public class AuthFunction
string homedApp = "primary",
DateTime? whatsNewSeenPlatformUtc = null,
DateTime? whatsNewSeenAgentUtc = null,
bool mcpClientRegistrationEnabled = false)
bool mcpClientRegistrationEnabled = false,
bool isExternalIdentity = false)
{
// A delegated ("MSP") admin manages a subset of OTHER tenants. They are explicitly authorized, so —
// like a Global Admin / Reader — they bypass the private-preview gate even when their own home tenant
@@ -773,9 +790,10 @@ public class AuthFunction
// to manage OTHER tenants and bypassed the preview gate; auto-admining their own (possibly
// non-customer) home tenant would convert a read-only delegated assignment into write authority over
// 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.
// != 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.
bool isTenantAdmin = memberRole?.Role == Constants.TenantRoles.Admin;
bool needsAutoAdmin = memberRole == null && !hasTenantAdmins && !isGlobalReader && !isDelegated;
bool needsAutoAdmin = memberRole == null && !hasTenantAdmins && !isGlobalReader && !isDelegated && !isExternalIdentity;
if (needsAutoAdmin)
{
isTenantAdmin = true;
@@ -51,10 +51,11 @@ public class SessionWatchFunctions
if (!PushFunctionSupport.IsPlausibleId(sessionId))
return await req.NotFoundAsync("Session not found.");
var (tenantId, caller) = await ScopeAsync(req, sessionId);
if (await _sessionRepo.GetSessionAsync(tenantId, sessionId) == null)
var session = await _sessionRepo.GetSessionAsync(tenantId, sessionId);
if (session == null)
return await req.NotFoundAsync("Session not found.");
var result = await _watches.WatchAsync(tenantId, sessionId, caller);
var result = await _watches.WatchAsync(tenantId, sessionId, session.Status, caller);
return result.Ok
? await req.OkAsync(result.Value!)
: await PushFunctionSupport.ErrorAsync(req, result.Error, result.Message);
@@ -169,16 +169,18 @@ public class PreviewWhitelistFunction
var principal = context.GetUser();
var tenantId = principal?.GetTenantId();
if (string.IsNullOrWhiteSpace(tenantId))
if (principal == null || string.IsNullOrWhiteSpace(tenantId))
{
return await req.BadRequestAsync("Could not determine tenant");
}
if (!await MayWriteNotificationEmailAsync(req.GetRequestContext(), tenantId, _tenantAdminsService))
var externalIdentity = principal.IsExternalIdentity();
if (!await MayWriteNotificationEmailAsync(req.GetRequestContext(), externalIdentity, tenantId, _tenantAdminsService))
{
_logger.LogWarning(
"Preview notification email write denied for roleless caller {Upn} — tenant {TenantId} already has members",
principal?.GetUserPrincipalName() ?? "unknown", tenantId);
"Preview notification email write denied for roleless caller {Upn} in tenant {TenantId}: {Reason}",
principal.GetUserPrincipalName() ?? "unknown", tenantId,
externalIdentity ? "external identity" : "the tenant already has members");
return await req.ForbiddenAsync("A tenant member role is required to change the notification email");
}
@@ -218,15 +220,18 @@ public class PreviewWhitelistFunction
/// offboarding farewell, operator contact). A member role (or platform scope) always may write
/// it. A roleless caller may write it ONLY while the tenant has no enabled member at all — the
/// first-touch signup window, where the activating user is still roleless because the requester
/// is auto-promoted to Admin only on approval. Once any member exists, that window is closed for
/// good: an ordinary employee with a valid JWT but no product role must not be able to redirect
/// or suppress the tenant's correspondence.
/// is auto-promoted to Admin only on approval. The window is never an external identity's: it
/// neither onboards a tenant nor is promoted on activation. Once any member exists, that window is
/// closed for good: an ordinary employee with a valid JWT but no product role must not be able to
/// redirect or suppress the tenant's correspondence.
/// </summary>
internal static async Task<bool> MayWriteNotificationEmailAsync(
RequestContext ctx, string tenantId, TenantAdminsService tenantAdminsService)
RequestContext ctx, bool externalIdentity, string tenantId, TenantAdminsService tenantAdminsService)
{
if (ctx.IsTenantMemberOrGlobalAdmin())
return true;
if (externalIdentity)
return false;
var members = await tenantAdminsService.GetTenantAdminsAsync(tenantId);
return !members.Any(m => m.IsEnabled);
@@ -0,0 +1,34 @@
using System.Collections.Concurrent;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// Lets one log line per key through per interval, for request paths that would otherwise repeat it on every
/// call (worker traces reach App Insights at Warning+ only, so a polled or hot path must not flood them).
/// Process-local and bounded: past <c>maxKeys</c> the memory is dropped, which at worst repeats a line early.
/// </summary>
public sealed class LogThrottle
{
private readonly ConcurrentDictionary<string, DateTime> _lastLogged = new();
private readonly TimeSpan _interval;
private readonly int _maxKeys;
public LogThrottle(TimeSpan interval, int maxKeys = 1000)
{
_interval = interval;
_maxKeys = maxKeys;
}
/// <summary>True when the line for <paramref name="key"/> may be written now; records that it was.</summary>
public bool ShouldLog(string key) => ShouldLog(key, DateTime.UtcNow);
internal bool ShouldLog(string key, DateTime nowUtc)
{
if (_lastLogged.TryGetValue(key, out var last) && nowUtc - last < _interval)
return false;
if (_lastLogged.Count >= _maxKeys)
_lastLogged.Clear();
_lastLogged[key] = nowUtc;
return true;
}
}
@@ -1,4 +1,3 @@
using System.Collections.Concurrent;
using System.Net;
using System.Security.Claims;
using AutopilotMonitor.Functions.Extensions;
@@ -21,12 +20,9 @@ namespace AutopilotMonitor.Functions.Middleware;
/// </summary>
public class ClientAppBindingMiddleware : IFunctionsWorkerMiddleware
{
private static readonly TimeSpan LogInterval = TimeSpan.FromHours(1);
private const int MaxLogKeys = 1000;
// Worker traces reach App Insights at Warning+ only; the request dimensions carry the per-request
// measurement, this line makes a new (tenant, client, outcome) visible without flooding.
private static readonly ConcurrentDictionary<string, DateTime> LastLogged = new();
private static readonly LogThrottle OutcomeLog = new(TimeSpan.FromHours(1));
private readonly ILogger<ClientAppBindingMiddleware> _logger;
private readonly IConfiguration _configuration;
@@ -118,13 +114,8 @@ public class ClientAppBindingMiddleware : IFunctionsWorkerMiddleware
private void LogOncePerInterval(string? tenantId, string? clientAppId, string outcome, bool enforced)
{
var key = $"{tenantId}|{clientAppId}|{outcome}|{enforced}";
var now = DateTime.UtcNow;
if (LastLogged.TryGetValue(key, out var last) && now - last < LogInterval)
if (!OutcomeLog.ShouldLog($"{tenantId}|{clientAppId}|{outcome}|{enforced}"))
return;
if (LastLogged.Count >= MaxLogKeys)
LastLogged.Clear();
LastLogged[key] = now;
_logger.LogWarning(
"[ClientAppBinding] Delegated token from client app {ClientAppId} (tid={TenantId}): {Outcome}, enforced={Enforced}",
@@ -274,7 +274,8 @@ public static class EndpointAccessPolicyCatalog
// AuthenticatedUserWithRole (not plain AuthenticatedUser): the activating user is still
// roleless during first-touch signup, so the tier must admit non-members — but the address is
// tenant-shared state feeding the welcome/farewell mails, so the function re-gates on the
// resolved role: roleless callers may only write while the tenant has no member yet.
// resolved role: roleless callers may only write while the tenant has no member yet, and never
// as an external identity, which does not activate a tenant.
new("PUT", "preview/notification-email", EndpointPolicy.AuthenticatedUserWithRole),
new("GET", "feedback/status", EndpointPolicy.AuthenticatedUser),
new("POST", "feedback", EndpointPolicy.AuthenticatedUser),
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Functions.Services.Activation
public class TenantAutoApproveHandler
{
/// <summary>ApprovedBy sentinel. Deliberately without '@' so the shape-based
/// <see cref="TenantApprovalService.IsRealUserUpn"/> guard can never treat it as a
/// <see cref="TenantApprovalService.IsPromotableUpn"/> guard can never treat it as a
/// promotable user.</summary>
public const string AutoApprovedBy = "System (auto-approve)";
@@ -2,6 +2,7 @@ using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using System.Linq;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Functions.Services.Push;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Notifications;
using Microsoft.Azure.Functions.Worker;
@@ -27,11 +28,13 @@ namespace AutopilotMonitor.Functions.Services
var failureVerdictApplies = c.FailureEvent != null && failureReason != null;
var reachedTerminalStatus = statusTransitioned && (c.CompletionEvent != null || failureVerdictApplies);
// Session watches ("notify me when this session ends") fire on the same terminal gate
// as the enrollment alert, independent of any channel configuration. Fail-soft inside.
if (reachedTerminalStatus && _pushWatchNotifier != null)
// Session watches ("notify me when this session ends") fire on the same gates as the
// enrollment and pre-provisioning alerts, independent of any channel configuration.
// Fail-soft inside.
var watchOutcome = WatchOutcomeFor(statusTransitioned, whiteGloveStatusTransitioned, c, failureReason);
if (watchOutcome != null && _pushWatchNotifier != null)
{
await _pushWatchNotifier.NotifyAsync(request.TenantId, request.SessionId, c.CompletionEvent != null,
await _pushWatchNotifier.NotifyAsync(request.TenantId, request.SessionId, watchOutcome.Value,
updatedSession?.DeviceName, updatedSession?.SerialNumber).ConfigureAwait(false);
}
@@ -126,6 +129,24 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// What a session watch fires on for this batch, or null: the terminal status on the
/// enrollment alert's gate, else the first genuine WhiteGlove seal on the pre-provisioning
/// alert's gate (never the duplicate <c>whiteglove_complete</c>, which leaves
/// <paramref name="whiteGloveStatusTransitioned"/> false). The seal consumes the watch.
/// </summary>
internal static SessionWatchOutcome? WatchOutcomeFor(
bool statusTransitioned, bool whiteGloveStatusTransitioned, EventClassification c, string? failureReason)
{
if (statusTransitioned && c.CompletionEvent != null)
return SessionWatchOutcome.Succeeded;
if (statusTransitioned && c.FailureEvent != null && failureReason != null)
return SessionWatchOutcome.Failed;
if (whiteGloveStatusTransitioned && c.WhiteGloveEvent != null)
return SessionWatchOutcome.Sealed;
return null;
}
/// <summary>
/// Hands an alert to the notification-dispatch queue. The send used to be fire-and-forget
/// after the HTTP response; the host does not count such work at shutdown, so a Flex
@@ -160,7 +160,10 @@ public static class PushAlertProjector
/// <summary>
/// Declarative Web Push JSON (<c>web_push: 8030</c>): Safari ≥ 18.4 shows it without the
/// service worker when the worker fails; <c>mutable</c> wakes the worker so it can store the
/// history entry; every other browser hands the same object to the worker's push event.
/// history entry and set the badge; every other browser hands the same object to the worker's
/// push event. <c>mutable</c> stands twice: Safari 18.4–26.1 reads it only inside
/// <c>notification</c>, 26.2+ at the top level. No <c>app_badge</c>: the count is the
/// device's own unread history, which only the worker knows.
/// Over budget, facts are dropped from the end, then the body is shortened.
/// </summary>
public static byte[] ToJsonBytes(PushPayload payload)
@@ -190,6 +193,7 @@ public static class PushAlertProjector
w.WriteNumber("web_push", 8030);
w.WriteBoolean("mutable", true);
w.WriteStartObject("notification");
w.WriteBoolean("mutable", true);
w.WriteString("title", p.Title);
w.WriteString("body", body);
w.WriteString("navigate", Constants.PortalPushHistoryUrl(p.Id));
@@ -6,24 +6,38 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services.Push;
/// <summary>Terminal-status hook for the ingest path: fires the pushes of everyone watching the session.</summary>
/// <summary>What a watch fires on: the session's terminal status, or the WhiteGlove seal as an intermediate goal.</summary>
public enum SessionWatchOutcome
{
Succeeded,
Failed,
/// <summary>Pre-provisioning (WhiteGlove part 1) sealed the device; the user phase follows later.</summary>
Sealed,
}
/// <summary>Ingest-path hook: fires the pushes of everyone watching the session.</summary>
public interface IPushSessionWatchNotifier
{
Task NotifyAsync(string tenantId, string sessionId, bool success, string? deviceName, string? serialNumber);
Task NotifyAsync(string tenantId, string sessionId, SessionWatchOutcome outcome, string? deviceName, string? serialNumber);
}
/// <summary>
/// "Notify my devices when this session ends" (plan use case 3): one watch per (session, person),
/// consumed at the terminal status. The watch row lives in the SESSION's tenant (tenant isolation
/// of the session), the devices it fires on are the watcher's OWN: their home-tenant scope and,
/// for a Global Admin, the platform scope — so an operator can watch a customer's session from
/// their own paired phone. Admin and Operator only (F14), only in a scope with an enabled Push
/// channel (K4), and the fan-out runs through the same role and freshness filter as any alert.
/// Watches expire after 48 hours (the agent's emergency break) and die with the session's tenant.
/// consumed at the terminal status — or at the WhiteGlove seal, the moment the technician at the
/// bench is waiting for; watching the user phase takes a second click. The watch row lives in the
/// SESSION's tenant (tenant isolation of the session), the devices it fires on are the watcher's
/// OWN: their home-tenant scope and, for a Global Admin, the platform scope — so an operator can
/// watch a customer's session from their own paired phone. Admin and Operator only (F14), only in
/// a scope with an enabled Push channel (K4), and the fan-out runs through the same role and
/// freshness filter as any alert. Watches expire after 48 hours (the agent's emergency break), a
/// watch on a sealed session after 30 days (the user phase starts whenever the device is handed
/// out), and die with the session's tenant.
/// </summary>
public class PushSessionWatchService : IPushSessionWatchNotifier
{
public static readonly TimeSpan WatchLifetime = TimeSpan.FromHours(48);
/// <summary>A sealed (Pending) session resumes only when the device reaches its user; matches the owner inactivity window.</summary>
public static readonly TimeSpan WatchLifetimeSealed = TimeSpan.FromDays(30);
/// <summary>The terminal push waits for an offline phone longer than a system message: plan "Watch bis Session-Ende + 1 h".</summary>
public static readonly TimeSpan WatchPushTtl = TimeSpan.FromHours(1);
private const char ScopeSeparator = ';';
@@ -56,7 +70,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
return scopes;
}
public async Task<PushOpResult<SessionWatchResponse>> WatchAsync(string tenantId, string sessionId, PushCaller caller)
public async Task<PushOpResult<SessionWatchResponse>> WatchAsync(string tenantId, string sessionId, SessionStatus sessionStatus, PushCaller caller)
{
var anyChannel = false;
var anyEligible = false;
@@ -95,7 +109,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
OwnerUpn = caller.Upn,
OwnerScopes = string.Join(ScopeSeparator, usable),
CreatedUtc = now,
ExpiresUtc = now + WatchLifetime,
ExpiresUtc = now + (sessionStatus == SessionStatus.Pending ? WatchLifetimeSealed : WatchLifetime),
};
await _repo.UpsertWatchAsync(watch).ConfigureAwait(false);
return PushOpResult<SessionWatchResponse>.Success(new SessionWatchResponse { Watching = true, ExpiresUtc = watch.ExpiresUtc, Available = true });
@@ -137,7 +151,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
}
/// <summary>Fail-soft: a watch push must never cost the ingest path anything.</summary>
public async Task NotifyAsync(string tenantId, string sessionId, bool success, string? deviceName, string? serialNumber)
public async Task NotifyAsync(string tenantId, string sessionId, SessionWatchOutcome outcome, string? deviceName, string? serialNumber)
{
try
{
@@ -146,10 +160,7 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
return;
var now = DateTime.UtcNow;
var device = PushAlertProjector.Clean(deviceName, 48);
var serial = PushAlertProjector.MaskSerial(serialNumber);
var body = (device.Length > 0 ? device : "The device") + (serial.Length > 0 ? $" ({serial})" : string.Empty)
+ (success ? " finished enrollment." : " failed enrollment.");
var (title, body, severity) = WatchMessage(outcome, deviceName, serialNumber);
foreach (var watch in watches)
{
@@ -161,9 +172,8 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
foreach (var scope in ScopesOf(watch, tenantId))
{
var payload = PushAlertProjector.SystemMessage("session_watch",
success ? "Enrollment finished" : "Enrollment failed",
body, success ? "success" : "error", scope, now, Constants.PortalSessionUrl(sessionId));
var payload = PushAlertProjector.SystemMessage("session_watch", title, body, severity, scope, now,
Constants.PortalSessionUrl(sessionId));
await _delivery.SendSystemToOwnerAsync(scope, watch.OwnerObjectId, payload, WatchPushTtl).ConfigureAwait(false);
}
@@ -176,6 +186,20 @@ public class PushSessionWatchService : IPushSessionWatchNotifier
}
}
/// <summary>The watch push's lock-screen text: device name and masked serial only (D-328).</summary>
internal static (string Title, string Body, string Severity) WatchMessage(SessionWatchOutcome outcome, string? deviceName, string? serialNumber)
{
var device = PushAlertProjector.Clean(deviceName, 48);
var serial = PushAlertProjector.MaskSerial(serialNumber);
var subject = (device.Length > 0 ? device : "The device") + (serial.Length > 0 ? $" ({serial})" : string.Empty);
return outcome switch
{
SessionWatchOutcome.Succeeded => ("Enrollment finished", subject + " finished enrollment.", "success"),
SessionWatchOutcome.Sealed => ("Pre-provisioning finished", subject + " finished pre-provisioning.", "success"),
_ => ("Enrollment failed", subject + " failed enrollment.", "error"),
};
}
private static IEnumerable<NotificationScope> ScopesOf(PushSessionWatch watch, string sessionTenantId)
{
var keys = (watch.OwnerScopes ?? string.Empty).Split(ScopeSeparator, StringSplitOptions.RemoveEmptyEntries);
@@ -1,4 +1,4 @@
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Functions.Extensions;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
@@ -54,22 +54,23 @@ public class TenantApprovalService
_logger.LogInformation("Tenant activated: {TenantId} by {ApprovedBy}", tenantId, approvedBy);
// Auto-promote the tenant requester (first user who triggered tenant config creation)
// as TenantAdmin if they are not already one.
// 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.
try
{
var tenantConfig = await _tenantConfigurationService.GetConfigurationAsync(tenantId);
var requesterUpn = PickRequesterUpn(tenantConfig);
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 (IsRealUserUpn(requesterUpn))
if (IsPromotableUpn(requesterUpn))
{
// IsRealUserUpn returned true → requesterUpn is non-null, non-empty, contains '@'
// IsPromotableUpn returned true → requesterUpn is non-null, non-empty, contains '@'
var validUpn = requesterUpn!;
var isAlreadyAdmin = await _tenantAdminsService.IsTenantAdminAsync(tenantId, validUpn);
if (!isAlreadyAdmin)
@@ -89,8 +90,8 @@ public class TenantApprovalService
else
{
_logger.LogInformation(
"No valid tenant requester UPN found in TenantConfiguration for tenant {TenantId} (OnboardedBy: '{OnboardedBy}', UpdatedBy: '{UpdatedBy}') — skipping auto-promote",
tenantId, tenantConfig.OnboardedBy ?? "<null>", tenantConfig.UpdatedBy ?? "<null>");
"No promotable onboarding UPN in TenantConfiguration for tenant {TenantId} (OnboardedBy: '{OnboardedBy}') — skipping auto-promote",
tenantId, tenantConfig.OnboardedBy ?? "<null>");
}
}
@@ -213,32 +214,15 @@ public class TenantApprovalService
}
/// <summary>
/// Picks the UPN of the user to auto-promote as TenantAdmin on activation.
/// Prefers <see cref="TenantConfiguration.OnboardedBy"/> (immutable, set once on first
/// user login) over <see cref="TenantConfiguration.UpdatedBy"/> (mutable, can be
/// clobbered by background jobs such as the global rate-limit sync). Fall back to
/// <see cref="TenantConfiguration.UpdatedBy"/> for tenants onboarded before
/// <see cref="TenantConfiguration.OnboardedBy"/> existed — callers still guard the
/// result with <see cref="IsRealUserUpn"/> so a sentinel value cannot leak into
/// the TenantAdmins table.
/// 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.
/// </summary>
internal static string? PickRequesterUpn(TenantConfiguration config)
{
if (config == null) return null;
return !string.IsNullOrWhiteSpace(config.OnboardedBy)
? config.OnboardedBy
: config.UpdatedBy;
}
/// <summary>
/// True when the value looks like a real Azure AD user principal name
/// (contains '@' and does not start with the "System" sentinel prefix
/// used by background jobs that touch <see cref="TenantConfiguration.UpdatedBy"/>).
/// </summary>
internal static bool IsRealUserUpn(string? upn)
internal static bool IsPromotableUpn(string? upn)
{
if (string.IsNullOrWhiteSpace(upn)) return false;
if (upn.StartsWith("System", StringComparison.OrdinalIgnoreCase)) return false;
if (ClaimsPrincipalExtensions.IsExternalUpn(upn)) return false;
return upn.Contains('@');
}
}
@@ -0,0 +1,341 @@
/**
* Caller-sized input must not decide what one call costs: one MCP process serves every tenant, so a
* single request may cost no more than a small multiple of its event volume. Pins the compiled concept
* matcher against the per-keyword rule it replaced (seeded differential test), its keyword-count-free
* per-event cost, the cooperative scoring loop, the keyword and error-code caps, and the schema bounds
* on query, keywords, section and continuation.
*
* Pure unit tests: fetch is stubbed and the registered tool handlers are invoked directly.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { McpServer } from '@modelcontextprotocol/server';
import { registerTools } from '../tools.js';
import { buildQuery, runWithCaller } from '../client.js';
import {
compileConcepts,
dedupeSynonymConcepts,
extractErrorCodeNeedles,
KEYWORD_SYNONYM_GROUPS,
matchConcepts,
MAX_ERROR_CODE_NEEDLES,
MAX_SCORED_KEYWORDS,
MAX_SEARCH_KEYWORD_CHARS,
MAX_SEARCH_KEYWORDS,
MAX_SEARCH_QUERY_CHARS,
SCORED_FIELDS,
scoreEvent,
scoreEvents,
} from '../tools/search.js';
import type { SearchProvider } from '../search-provider.js';
// ── Deterministic inputs ────────────────────────────────────────────────
/** mulberry32: seeded, so every run checks the same cases. */
function prng(seed: number): () => number {
let s = seed;
return () => {
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
type Ev = { eventType?: string; severity?: string; source?: string; message?: string; data?: Record<string, unknown> };
const VOCAB = ['install', 'app', 'download', 'policy', 'device', 'enrollment', 'phase', 'script', 'detection', 'exit',
'code', 'started', 'completed', 'certificate', 'network', 'agent', 'registry', 'value', 'result', 'status'];
/** Production-sized events: a ~60-character message and ~600 characters of data, every field filled. */
function makeEvents(n: number, seed = 1): Ev[] {
const rnd = prng(seed);
const words = (len: number): string => {
let s = '';
while (s.length < len) s += (s ? ' ' : '') + VOCAB[Math.floor(rnd() * VOCAB.length)];
return s;
};
return Array.from({ length: n }, (_, i) => ({
eventType: `${VOCAB[i % VOCAB.length]}_${VOCAB[(i * 7) % VOCAB.length]}`,
severity: ['Info', 'Warning', 'Error'][i % 3],
source: 'ImeLogTracker',
message: words(60),
data: Object.fromEntries(Array.from({ length: 8 }, (_, k) => [`${VOCAB[k]}Key${k}`, words(60)])),
}));
}
/** Distinct keywords that occur in no event. */
const absent = (n: number): string[] => Array.from({ length: n }, (_, i) => `zq${i.toString(36).padStart(5, '0')}`);
// ── The per-keyword rule the compiled matcher replaced, written out as the oracle ──
const GROUP_OF = new Map(KEYWORD_SYNONYM_GROUPS.flatMap((group) => group.map((term) => [term, group] as const)));
const WEIGHT = { eventType: 3, message: 2, source: 1.5, severity: 1, data: 0.5 } as const;
function oracleWordPrefix(text: string, term: string): boolean {
if (term.length < 4) return false;
return text.split(/[\s_\-.:,/]+/).some((word) => {
if (word.length < 4) return false;
const p = Math.min(word.length, term.length, 6);
return word.slice(0, p) === term.slice(0, p);
});
}
/** Keyword as substring or word prefix; its synonyms at word level only. */
function oracleFieldMatch(text: string, keyword: string): boolean {
if (text.includes(keyword) || oracleWordPrefix(text, keyword)) return true;
return (GROUP_OF.get(keyword) ?? []).some((term) => term !== keyword && oracleWordPrefix(text, term));
}
/** Per concept: the heaviest field it matches, or null. */
function oracleBestFields(e: Ev, concepts: readonly string[]): Array<string | null> {
const texts: Array<[keyof typeof WEIGHT, string]> = [
['eventType', (e.eventType ?? '').toLowerCase()],
['message', (e.message ?? '').toLowerCase()],
['source', (e.source ?? '').toLowerCase()],
['severity', (e.severity ?? '').toLowerCase()],
['data', e.data ? JSON.stringify(e.data).toLowerCase() : ''],
];
return concepts.map((keyword) => {
let best: keyof typeof WEIGHT | null = null;
for (const [name, text] of texts) {
if (text && oracleFieldMatch(text, keyword) && (best === null || WEIGHT[name] > WEIGHT[best])) best = name;
}
return best;
});
}
// Stems with prefix collisions at every length around the 4-6 character comparison window, synonyms
// and the words they hide in ("in[stall]", "c[hang]e"), short keywords below the prefix minimum.
const STEMS = ['abcd', 'abcde', 'abcdef', 'abcdefg', 'abcdx', 'abc', 'ab', 'abce', 'abcdeq', 'xyzw', 'xyz', 'install',
'installer', 'installation', 'setup', 'setups', 'reboot', 'rebooting', 'restarts', 'reset', 'resetting', 'stuck',
'stuckness', 'hang', 'hangs', 'change', 'hung', 'timeout', 'timedout', 'stall', 'stalled', 'download', 'downloads',
'transfer', 'hello', 'hel', 'whfb', 'failed', 'failure', 'error', 'esp', 'msi', 'auth', 'authentication', 'app', 'do', 'id'];
const ch = String.fromCharCode;
// The old split's delimiters, Unicode whitespace it also splits on, characters it does not split on,
// and nothing at all (two stems glued together).
const SEPARATORS = [' ', '_', '-', '.', ':', ',', '/', ch(9), ch(10), ' ', '__', ' - ', ch(0xa0), ch(0x2028), ch(0x3000),
ch(0xfeff), '(', ')', '"', '=', ''];
function randomText(rnd: () => number, maxWords: number): string {
const n = Math.floor(rnd() * maxWords);
let s = '';
for (let i = 0; i < n; i++) {
const stem = STEMS[Math.floor(rnd() * STEMS.length)];
s += (i ? SEPARATORS[Math.floor(rnd() * SEPARATORS.length)] : '') + (rnd() < 0.2 ? stem.toUpperCase() : stem);
}
return s;
}
function randomKeywords(rnd: () => number): string[] {
const pick = (): string => STEMS[Math.floor(rnd() * STEMS.length)];
const out = new Set<string>();
const n = 1 + Math.floor(rnd() * 8);
for (let i = 0; i < n; i++) {
const r = rnd();
const keyword = r < 0.6 ? pick()
: r < 0.75 ? pick().slice(0, 2 + Math.floor(rnd() * 6))
: r < 0.9 ? `${pick()} ${pick()}`
: pick() + ['ing', 's', 'ed', 'x'][Math.floor(rnd() * 4)];
if (keyword) out.add(keyword);
}
return [...out];
}
// ── Registered tools ────────────────────────────────────────────────────
type ToolHandler = (args: Record<string, unknown>, extra: unknown) => Promise<{ content: Array<{ text: string }> }>;
type ParseableSchema = { safeParse(value: unknown): { success: boolean } };
type RegisteredTool = { handler: ToolHandler; inputSchema: ParseableSchema & { shape: Record<string, ParseableSchema> } };
const stubDocs: SearchProvider = { name: 'stub', semanticCapable: true, size: 1, index: async () => {}, search: async () => [] };
function registeredTools(): Record<string, RegisteredTool> {
const server = new McpServer({ name: 'test', version: '0.0.0' });
registerTools(server, undefined, undefined, { vector: stubDocs, sections: ['trust'] }, true, true, false);
return (server as unknown as { _registeredTools: Record<string, RegisteredTool> })._registeredTools;
}
const SESSION = 'e259c121-1234-4abc-9def-0123456789ab';
const GA = { token: 'ga', isGlobalAdmin: true };
const extra = { signal: new AbortController().signal };
function stubSessionEvents(events: Ev[]): void {
vi.stubGlobal('fetch', vi.fn(async () => {
const body = { success: true, sessionId: SESSION, count: events.length, events };
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as unknown as Response;
}));
}
afterEach(() => vi.unstubAllGlobals());
// ── Tests ───────────────────────────────────────────────────────────────
describe('compiled concept matcher', () => {
it('lists the scored fields strongest first, so the first field a concept matches is its strongest', () => {
expect(SCORED_FIELDS.map((f) => f.name)).toEqual(['eventType', 'message', 'source', 'severity', 'data']);
const weights = SCORED_FIELDS.map((f) => f.weight);
expect(weights).toEqual([...weights].sort((a, b) => b - a));
});
it('matches every concept in the same strongest field as the per-keyword rule it replaced', () => {
const rnd = prng(7);
const names = SCORED_FIELDS.map((f) => f.name);
let compared = 0;
for (let i = 0; i < 10_000; i++) {
const keywords = randomKeywords(rnd);
const e: Ev = {
eventType: randomText(rnd, 4),
message: randomText(rnd, 12),
source: randomText(rnd, 3),
severity: ['Info', 'Error', 'Warning', '', 'Debug'][Math.floor(rnd() * 5)],
data: rnd() < 0.7 ? { a: randomText(rnd, 10), b: randomText(rnd, 6), n: Math.floor(rnd() * 100) } : undefined,
};
const matcher = compileConcepts(keywords);
expect(matcher.concepts).toEqual(dedupeSynonymConcepts(keywords));
const got = matchConcepts(e, matcher).map((f) => (f < 0 ? null : names[f]));
expect(got, JSON.stringify({ keywords, e })).toEqual(oracleBestFields(e, matcher.concepts));
compared += got.length;
}
expect(compared).toBeGreaterThan(20_000);
});
it('splits each field once per event, however many keywords the query carries', () => {
const events = makeEvents(200);
const splitsFor = (keywords: string[]): number => {
const matcher = compileConcepts(keywords);
const split = vi.spyOn(String.prototype, 'split');
try {
for (const e of events) scoreEvent(e, keywords, false, undefined, undefined, matcher);
return split.mock.calls.length;
} finally {
split.mockRestore();
}
};
const one = splitsFor(absent(1));
expect(one).toBe(events.length * SCORED_FIELDS.length);
expect(splitsFor(absent(MAX_SCORED_KEYWORDS))).toBe(one);
});
it('scores a maximal request in well under a second', async () => {
const events = makeEvents(3000);
const t0 = performance.now();
await scoreEvents(events, absent(MAX_SCORED_KEYWORDS), false, new Map(), new Map(), 0.05);
expect(performance.now() - t0).toBeLessThan(1000);
});
});
describe('scoreEvents', () => {
it('returns exactly what the per-event scorer returns', async () => {
const events = makeEvents(700, 3);
const keywords = ['install', 'certificate', 'stuck', 'agent'];
const scored = await scoreEvents(events, keywords, true, new Map(), new Map(), 0.05);
const expected = events.flatMap((e, index) => {
const r = scoreEvent(e, keywords, true, new Map(), new Map());
return r && r.score >= 0.05 ? [{ ...r, index }] : [];
});
expect(scored.length).toBeGreaterThan(0);
expect(scored.map(({ index, score, matchedKeywords, bestFields }) => ({ index, score, matchedKeywords, bestFields })))
.toEqual(expected);
});
it('yields to the event loop while it scores, so other requests keep running', async () => {
let otherRequestRan = false;
setImmediate(() => { otherRequestRan = true; });
await scoreEvents(makeEvents(600), absent(1), false, new Map(), new Map(), 0);
expect(otherRequestRan).toBe(true);
});
it('stops once the caller has gone', async () => {
const gone = new AbortController();
gone.abort();
await expect(runWithCaller({ token: 't', isGlobalAdmin: false, signal: gone.signal },
() => scoreEvents(makeEvents(600), absent(1), false, new Map(), new Map(), 0))).rejects.toThrow();
});
});
describe('search_events keyword cap', () => {
const tools = registeredTools();
const run = async (args: Record<string, unknown>): Promise<Record<string, unknown>> => {
stubSessionEvents(makeEvents(5));
const res = await runWithCaller(GA, () => tools.search_events.handler(
{ sessionId: SESSION, depth: 'fast', guaranteedTopRanked: 3, ...args }, extra));
return JSON.parse(res.content[0].text) as Record<string, unknown>;
};
it(`scores the first ${MAX_SCORED_KEYWORDS} keywords, caller keywords first, and reports the rest`, async () => {
const query = Array.from({ length: 40 }, (_, i) => `queryword${i}`).join(' ');
const body = await run({ query, keywords: ['callerword'] });
const used = body.keywordsUsed as string[];
expect(used).toHaveLength(MAX_SCORED_KEYWORDS);
expect(used[0]).toBe('callerword');
expect(body.keywordsDropped).toBe(41 - MAX_SCORED_KEYWORDS);
expect(body.keywordNote).toContain(String(MAX_SCORED_KEYWORDS));
});
it('reports nothing dropped for an ordinary query', async () => {
const body = await run({ query: 'app install failed with certificate error' });
expect(body.keywordsDropped).toBeUndefined();
expect(body.keywordNote).toBeUndefined();
});
});
describe('error-code needles', () => {
it(`takes at most ${MAX_ERROR_CODE_NEEDLES} codes, 0x-prefixed ones first`, () => {
const bare = Array.from({ length: 12 }, (_, i) => `abcd${(i + 16).toString(16)}`).join(' ');
const needles = extractErrorCodeNeedles(`${bare} 0x80070002 0x87D1041C`);
expect(needles).toHaveLength(MAX_ERROR_CODE_NEEDLES);
expect(needles.slice(0, 2)).toEqual(['80070002', '87d1041c']);
});
});
describe('schema bounds', () => {
const tools = registeredTools();
const accepts = (tool: string, args: Record<string, unknown>): boolean => tools[tool].inputSchema.safeParse(args).success;
it('bounds the query of all three search tools', () => {
for (const tool of ['search_events', 'search_knowledge', 'search_docs']) {
expect(accepts(tool, { query: 'x'.repeat(MAX_SEARCH_QUERY_CHARS) }), tool).toBe(true);
expect(accepts(tool, { query: 'x'.repeat(MAX_SEARCH_QUERY_CHARS + 1) }), tool).toBe(false);
}
});
it('bounds the caller keywords of search_events in number and length', () => {
const atLimit = Array.from({ length: MAX_SEARCH_KEYWORDS }, () => 'k'.repeat(MAX_SEARCH_KEYWORD_CHARS));
expect(accepts('search_events', { query: 'q', keywords: atLimit })).toBe(true);
expect(accepts('search_events', { query: 'q', keywords: [...atLimit, 'k'] })).toBe(false);
expect(accepts('search_events', { query: 'q', keywords: ['k'.repeat(MAX_SEARCH_KEYWORD_CHARS + 1)] })).toBe(false);
});
it('bounds the search_docs section', () => {
expect(accepts('search_docs', { query: 'q', section: 'trust' })).toBe(true);
expect(accepts('search_docs', { query: 'q', section: 's'.repeat(101) })).toBe(false);
});
it('accepts a real nextLink on every paginated tool and refuses a forged one', () => {
const nextLink = `/api/search/sessions?pageSize=50&tenantId=11111111-1111-4111-8111-111111111111&continuation=${'A'.repeat(800)}`;
const forged = `/api/search/sessions?${'fields=&'.repeat(36_000)}`;
const paginated = Object.entries(tools).filter(([, tool]) => 'continuation' in tool.inputSchema.shape);
expect(paginated.map(([name]) => name)).toEqual(expect.arrayContaining(['search_sessions', 'get_session_events', 'get_audit_logs']));
for (const [name, tool] of paginated) {
expect(tool.inputSchema.shape.continuation.safeParse(nextLink).success, name).toBe(true);
expect(tool.inputSchema.shape.continuation.safeParse(forged).success, name).toBe(false);
}
});
});
describe('buildQuery', () => {
it('builds the same query string as before, skipping empty values', () => {
expect(buildQuery({ a: '1', b: 'x y', c: undefined, d: '', e: 0, f: null })).toBe('?a=1&b=x+y&e=0');
expect(buildQuery({})).toBe('');
});
it('stays linear for a caller-sized key set', () => {
// Far more keys than a request body carries, so the complexity class is unmistakable:
// measured ~0.1 s linear against ~9 s for a per-key search of the whole list.
const many = Object.fromEntries(Array.from({ length: 64_000 }, (_, i) => [`prop.os_info.k${i}`, 'v']));
const t0 = performance.now();
const q = buildQuery(many);
expect(performance.now() - t0).toBeLessThan(2000);
expect(q.split('&')).toHaveLength(64_000);
});
});
@@ -523,8 +523,10 @@ async function apiFetch<T>(path: string, options: ApiFetchOptions = {}): Promise
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
const p = new URLSearchParams();
// append, not set: object keys are unique, and set() searches the whole list on every call —
// quadratic for a caller-sized key set such as search_sessions' deviceProperties.
for (const [k, v] of Object.entries(params)) {
if (v != null && v !== '') p.set(k, String(v));
if (v != null && v !== '') p.append(k, String(v));
}
const s = p.toString();
return s ? `?${s}` : '';
@@ -4961,13 +4961,13 @@ export interface TenantConfigPatchOutcomeResponse {
export interface TenantConfiguration {
/** Tenant ID (PartitionKey in Table Storage) */
tenantId: string;
/** Domain name extracted from the first user's UPN Used for display purposes (e.g., contoso.com) */
/** Domain of the onboarding member's UPN (e.g. contoso.com), seeded once by the first member login and never from an external identity. Tenant identity, not only display: transactional mails render it, and cross-tenant grants resolve a never-signed-in UPN's home tenant through it. */
domainName: string;
/** When the configuration was last updated */
lastUpdated: string;
/** Updated by (user email or system) */
updatedBy: string;
/** UPN of the user whose first login created this tenant configuration. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten. Used by the preview-approval auto-promote path so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel string into the TenantAdmins table. Null on rows that pre-date the OnboardedBy field — auto-promote falls back to UpdatedBy with a UPN-shape guard. */
/** UPN of the member whose first login onboarded this tenant. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten; an external identity (guest) never sets it. Activation promotes this UPN, and only this one, to Tenant Admin, so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel or a last writer into the TenantAdmins table. Null on rows that pre-date the field: such a tenant gets no promotion on activation. */
onboardedBy?: string | null;
/** Version of the data processing agreement this tenant accepted — CurrentDpaVersion at that moment. Written once: with the onboarding when the first sign-in came through the get-started tick, otherwise when the portal's acceptance dialog is confirmed (DpaAcceptancePending). Never client-writable. Null on tenants onboarded before the field existed. */
dpaVersion?: string | null;
@@ -3,7 +3,7 @@ import { z } from 'zod';
import { apiFetch, buildQuery, DEFAULT_FIRST_PAGE_SIZE, effectivePageSize, enforceDelegatedTenant, enforceDelegatedTenantForPage, followNextLink, getCallerUpnDomain, getDelegatedTenantIds, getHomeTenantId, pageSizeForCall, pickGlobalOrTenantPath, scanWithTimeoutFallback, jsonBody } from '../client.js';
import { withToolTelemetry } from '../telemetry.js';
import { getResourceContent, withEventTypeNote, RESOURCE_NAMES } from '../resource-catalog.js';
import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, leanFieldSelection, toolResultText, SessionIdSchema, TenantGuidSchema, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, daysDescription } from './shared.js';
import { READ_ONLY, READ_ONLY_OPEN, MUTATING, MAX_RESULT_SIZE_CHARS, LEAN_RAW_EVENT_FIELDS, LEAN_RAW_EVENT_OMISSION, leanFieldSelection, toolResultText, SessionIdSchema, TenantGuidSchema, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, ContinuationSchema, daysDescription } from './shared.js';
import { toolError } from './error-handler.js';
import { API_BASE_URL } from '../config.js';
import { collectDeploymentState } from '../deployment-state.js';
@@ -430,7 +430,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
daily: z.boolean().optional().default(false).describe('Return daily aggregated summary instead of per-endpoint breakdown'),
pageSize: z.coerce.number().int().min(1).max(2000).optional().default(50)
.describe(pageSizeDescription(50, 2000)),
continuation: z.string().optional()
continuation: ContinuationSchema.optional()
.describe(CONTINUATION_DESCRIPTION + ' Offset cursor: re-send the same filters with it.'),
},
annotations: READ_ONLY,
@@ -512,7 +512,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
.describe(daysDescription(30, 365)),
pageSize: z.coerce.number().int().min(1).max(1000).optional().default(50)
.describe(pageSizeDescription(50)),
continuation: z.string().optional()
continuation: ContinuationSchema.optional()
.describe(CONTINUATION_DESCRIPTION + ' Offset cursor: re-send the same filters with it.'),
},
annotations: READ_ONLY,
@@ -770,7 +770,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
.describe(daysDescription(7, 365, 'Applies to the stats and the session list.')),
pageSize: z.coerce.number().int().min(1).max(200).optional()
.describe(pageSizeDescription(FLEET_OVERVIEW_PAGE_SIZE, 200)),
continuation: z.string().optional()
continuation: ContinuationSchema.optional()
.describe(CONTINUATION_DESCRIPTION),
},
annotations: READ_ONLY,
@@ -835,7 +835,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
'included; default: all safe fields. Applied server-side; unknown/secret keys are ignored.'),
pageSize: z.coerce.number().int().min(1).max(1000).optional()
.describe(pageSizeDescription(100, 1000, 'Ignored in filter mode (query/tenantId).')),
continuation: z.string().optional()
continuation: ContinuationSchema.optional()
.describe(CONTINUATION_DESCRIPTION + ' Ignored in filter mode.'),
},
annotations: READ_ONLY,
@@ -939,7 +939,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
entityId: z.string().optional().describe('Exact-match filter on the affected entity id (e.g. a tenantId, deviceId, or report id).'),
pageSize: z.coerce.number().int().min(1).max(1000).optional()
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
continuation: z.string().optional()
continuation: ContinuationSchema.optional()
.describe(CONTINUATION_DESCRIPTION),
},
annotations: READ_ONLY,
@@ -995,7 +995,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive upper bound of the window.'),
pageSize: z.coerce.number().int().min(1).max(1000).optional()
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
continuation: z.string().optional()
continuation: ContinuationSchema.optional()
.describe(CONTINUATION_DESCRIPTION),
},
annotations: READ_ONLY,
@@ -1082,7 +1082,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
pageSize: z.coerce.number().int().min(1).max(1000).optional()
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
continuation: z.string().optional()
continuation: ContinuationSchema.optional()
.describe(CONTINUATION_DESCRIPTION),
},
annotations: READ_ONLY,
@@ -1125,7 +1125,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
.describe('One effective status. "published" lists the approved submissions whose rule is live in the global catalog.'),
pageSize: z.coerce.number().int().min(1).max(1000).optional()
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
continuation: z.string().optional()
continuation: ContinuationSchema.optional()
.describe(CONTINUATION_DESCRIPTION),
},
annotations: READ_ONLY,
@@ -1400,7 +1400,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — exclusive upper bound on the last-updated time.'),
pageSize: z.coerce.number().int().min(1).max(1000).optional()
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
continuation: z.string().optional()
continuation: ContinuationSchema.optional()
.describe(CONTINUATION_DESCRIPTION),
},
annotations: READ_ONLY,
@@ -1496,7 +1496,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
.describe('Keep-list of column names; PartitionKey + RowKey always kept. Columns: PartitionKey, RowKey, Timestamp (storage write time), OccurredUtc (event time), EventId, SessionId, TenantId, EventType, Severity (int), Source, Phase (int), Message, Sequence, DataJson (raw string), ReceivedAt, SentAt, OriginalTimestamp, TimestampClamped, CausedByTransitionStepIndex, CausedBySignalOrdinal. Default: all but DataJson on an unfiltered read; the full row on a filtered read (eventType/severity/source).'),
pageSize: z.coerce.number().int().min(1).max(1000).optional()
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE, 1000, 'Index rows walked per call. When the projection includes DataJson the ceiling is ' + RAW_EVENTS_PAYLOAD_PAGE_MAX + ' (default ' + RAW_EVENTS_PAYLOAD_PAGE_DEFAULT + '); a larger value is refused.')),
continuation: z.string().optional()
continuation: ContinuationSchema.optional()
.describe(CONTINUATION_DESCRIPTION),
},
annotations: READ_ONLY,
@@ -1579,7 +1579,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
fields: z.string().optional().describe('Keep-list of literal column names (case-insensitive, PascalCase, e.g. "Status,OsEdition,ImeAgentVersion,GeoCity"); PartitionKey + RowKey are always kept. Omit for the full raw row.'),
pageSize: z.coerce.number().int().min(1).max(1000).optional()
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
continuation: z.string().optional()
continuation: ContinuationSchema.optional()
.describe(CONTINUATION_DESCRIPTION),
},
annotations: READ_ONLY,
@@ -1654,7 +1654,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
'client-side. PartitionKey and RowKey are always kept.'),
pageSize: z.coerce.number().int().min(1).max(1000).optional()
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
continuation: z.string().optional()
continuation: ContinuationSchema.optional()
.describe(CONTINUATION_DESCRIPTION),
},
annotations: READ_ONLY,
@@ -2195,7 +2195,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
...inventoryScopeShape,
pageSize: z.coerce.number().int().min(1).max(500).optional().default(100)
.describe(pageSizeDescription(100, 500)),
continuation: z.string().optional()
continuation: ContinuationSchema.optional()
.describe(CONTINUATION_DESCRIPTION + ' Offset cursor: re-send the same filters with it.'),
},
annotations: READ_ONLY,
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/server';
import { z } from 'zod';
import { apiFetch, buildQuery, enforceDelegatedTenant, followNextLink, pickGlobalOrTenantPath } from '../client.js';
import { apiFetch, buildQuery, enforceDelegatedTenant, followNextLink, getCallerSignal, pickGlobalOrTenantPath } from '../client.js';
import { withToolTelemetry, logSearchZeroHit } from '../telemetry.js';
import type { SearchProvider, DocsSearchBundle } from '../search-provider.js';
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, tenantIdDescription } from './shared.js';
@@ -160,6 +160,26 @@ const DEPTH_PRESETS = {
deep: { budget: DEEP_BUDGET, defaultTopK: 20, defaultMinScore: 0.05 },
} as const;
// ── Input bounds ────────────────────────────────────────────────────────
//
// One process serves every tenant, and every fetched event is scored against every query keyword,
// so the keyword count multiplies the work of a call. These bounds keep one request's cost a small
// multiple of its event volume; real queries stay far below them.
/** Longest `query` the three search tools accept. */
export const MAX_SEARCH_QUERY_CHARS = 1000;
/** Most caller-supplied `keywords` per search_events call. */
export const MAX_SEARCH_KEYWORDS = 20;
/** Longest caller-supplied keyword. */
export const MAX_SEARCH_KEYWORD_CHARS = 100;
/** Distinct keywords one search scores (caller keywords first, then the query's words); the rest is reported as dropped. */
export const MAX_SCORED_KEYWORDS = 32;
const KEYWORDS_DROPPED_NOTE =
`Only the first ${MAX_SCORED_KEYWORDS} distinct keywords were scored (your keywords first, then words from the ` +
'query); shorten the query to choose which ones count.';
/** Longest search_docs `section` (a top-level folder of the docs bundle). */
const MAX_DOCS_SECTION_CHARS = 100;
/**
* EventSeverity enum (C# `EnrollmentEvent.EventSeverity`) → name. The raw endpoint stores
* Severity as the underlying Int32; the enriched endpoints emit the name string. We map back
@@ -561,6 +581,9 @@ export function resolveQueryKeywords(query: string, callerKeywords?: string[]):
// ── Error-code fallback ─────────────────────────────────────────────────
/** Error codes one query contributes to the literal fallback. */
export const MAX_ERROR_CODE_NEEDLES = 10;
/**
* Pull opaque error-code tokens (HRESULT / Win32 / NTSTATUS hex) out of a query. These embed
* poorly — to a sentence-transformer "0x87D1041C" is near-random noise — so a semantic search can
@@ -572,15 +595,22 @@ export function resolveQueryKeywords(query: string, callerKeywords?: string[]):
* Bare (un-prefixed) hex must contain at least one a-f letter and be 6-8 chars, so plain decimal
* numbers — dates, counts, build numbers — never trigger the fallback. `0x`-prefixed tokens are
* always codes, so those accept 4-8 digits (covers short Win32 HRESULTs like 0x801c03ed).
*
* At most MAX_ERROR_CODE_NEEDLES distinct codes, `0x`-prefixed ones first: every needle is scanned
* against every document of the corpus.
*/
export function extractErrorCodeNeedles(query: string): string[] {
const needles = new Set<string>();
// 0x-prefixed hex (0x80070002, 0x87D1041C). The `x` is not in [0-9a-f], so the bare-hex pass
// below can never re-match the same digits — no double counting.
for (const m of query.matchAll(/\b0x([0-9a-f]{4,8})\b/gi)) needles.add(m[1].toLowerCase());
for (const m of query.matchAll(/\b0x([0-9a-f]{4,8})\b/gi)) {
if (needles.size >= MAX_ERROR_CODE_NEEDLES) return [...needles];
needles.add(m[1].toLowerCase());
}
// Bare HRESULT-shaped hex: 6-8 chars with at least one a-f letter (87D1041C) — the letter
// requirement excludes pure decimals that aren't error codes.
for (const m of query.matchAll(/\b([0-9a-f]{6,8})\b/gi)) {
if (needles.size >= MAX_ERROR_CODE_NEEDLES) break;
if (/[a-f]/i.test(m[1])) needles.add(m[1].toLowerCase());
}
return [...needles];
@@ -613,6 +643,8 @@ export function queryHasProblemIntent(keywords: string[]): boolean {
}
const MIN_PREFIX_LEN = 4;
/** A prefix comparison looks at no more than this many leading characters. */
const MAX_PREFIX_LEN = MIN_PREFIX_LEN + 2;
/** Split text into word tokens for prefix matching (same delimiters everywhere). */
function splitWords(text: string): string[] {
@@ -624,7 +656,7 @@ function sharesWordPrefix(words: string[], keyword: string): boolean {
if (keyword.length < MIN_PREFIX_LEN) return false;
for (const word of words) {
if (word.length < MIN_PREFIX_LEN) continue;
const prefixLen = Math.min(word.length, keyword.length, MIN_PREFIX_LEN + 2);
const prefixLen = Math.min(word.length, keyword.length, MAX_PREFIX_LEN);
if (word.slice(0, prefixLen) === keyword.slice(0, prefixLen)) return true;
}
return false;
@@ -730,7 +762,7 @@ export function orderBySelectivity(types: string[], keywords: string[], specific
* non-canonical words (e.g. "restarted" for system_reboot_detected, "stuck" for a timeout).
* Groups are symmetric — every term maps to the whole group.
*/
const KEYWORD_SYNONYM_GROUPS: readonly string[][] = [
export const KEYWORD_SYNONYM_GROUPS: readonly string[][] = [
['reboot', 'rebooted', 'restart', 'restarted', 'reset'],
['timeout', 'timedout', 'stall', 'stalled', 'stuck', 'hang', 'hung'],
['hello', 'whfb', 'passwordless'],
@@ -750,7 +782,7 @@ const SYNONYM_LOOKUP: ReadonlyMap<string, readonly string[]> = (() => {
/**
* Collapse query keywords that fall in the SAME synonym group down to one representative concept
* (first occurrence kept), preserving order and keeping non-synonym keywords as-is. Because
* keywordMatchesField already expands a single keyword to its whole group, one representative per
* compileConcepts already expands a single keyword to its whole group, one representative per
* group is sufficient — and it stops a query that spells the same concept two ways (e.g. "stuck"
* AND "timeout") from counting as two matched keywords, which inflated coverage for off-topic types.
* A no-op for queries with no intra-group duplicates.
@@ -769,37 +801,6 @@ export function dedupeSynonymConcepts(keywords: string[]): string[] {
return out;
}
/**
* Match a SYNONYM at word granularity (whole word, or a shared word-prefix of >= MIN_PREFIX_LEN),
* never as a mid-word substring. Synonyms are short (stall/hang/reset/…) and the loose substring
* check used for literal keywords would mis-fire — e.g. "stall" sits inside "in[stall]", "hang"
* inside "c[hang]e" — making a "timeout" query spuriously match every app_install_* event. Word-
* level matching keeps the useful equivalences (reboot↔restart, stuck↔timeout) without those
* false positives.
*/
function synonymMatchesWord(text: string, term: string): boolean {
if (term.length < MIN_PREFIX_LEN) return false;
for (const word of text.split(/[\s_\-.:,/]+/)) {
if (word === term) return true;
if (word.length >= MIN_PREFIX_LEN) {
const prefixLen = Math.min(word.length, term.length, MIN_PREFIX_LEN + 2);
if (word.slice(0, prefixLen) === term.slice(0, prefixLen)) return true;
}
}
return false;
}
/** True if the keyword (loose, prefix-aware) or any of its synonyms (word-level) matches `text`. */
function keywordMatchesField(text: string, keyword: string): boolean {
if (prefixAwareMatch(text, keyword)) return true;
const group = SYNONYM_LOOKUP.get(keyword);
if (!group) return false;
for (const term of group) {
if (term !== keyword && synonymMatchesWord(text, term)) return true;
}
return false;
}
/** Field weights for scoring — eventType is the most discriminating field. */
const FIELD_WEIGHTS = {
eventType: 3.0,
@@ -809,6 +810,92 @@ const FIELD_WEIGHTS = {
data: 0.5,
} as const;
/**
* The scored fields in DESCENDING weight order: a concept counts once, at the first field it matches,
* which is only its strongest field while this order holds.
*/
export const SCORED_FIELDS: ReadonlyArray<{ name: string; weight: number; text: (e: EventEntry) => string }> = [
{ name: 'eventType', weight: FIELD_WEIGHTS.eventType, text: (e) => (e.eventType ?? '').toLowerCase() },
{ name: 'message', weight: FIELD_WEIGHTS.message, text: (e) => (e.message ?? '').toLowerCase() },
{ name: 'source', weight: FIELD_WEIGHTS.source, text: (e) => (e.source ?? '').toLowerCase() },
{ name: 'severity', weight: FIELD_WEIGHTS.severity, text: (e) => (e.severity ?? '').toLowerCase() },
{ name: 'data', weight: FIELD_WEIGHTS.data, text: (e) => (e.data ? JSON.stringify(e.data).toLowerCase() : '') },
];
/** One word-prefix term of a concept (the keyword or one of its synonyms), cut to MAX_PREFIX_LEN. */
type PrefixTerm = { concept: number; head: string };
/**
* A query's concepts, compiled once per search so that scoring an event splits each field into words
* once — the per-event cost does not grow with the number of keywords.
*
* A concept matches a field when its keyword is a substring of the field, or when a field word shares a
* prefix with one of the concept's terms: the shorter of the two MAX_PREFIX_LEN-character heads is a
* prefix of the longer, both at least MIN_PREFIX_LEN long (the sharesWordPrefix rule). The terms are
* the keyword and, for a synonym, its whole group. Synonyms match at word granularity only, never as a
* mid-word substring: they are short (stall/hang/reset/…), "stall" sits inside "in[stall]" and "hang"
* inside "c[hang]e", so a substring check would make a "timeout" query match every app_install_* event.
*/
export type ConceptMatcher = {
readonly concepts: readonly string[];
/** Terms by the headKey of their first three characters. */
readonly termsByKey: ReadonlyMap<number, readonly PrefixTerm[]>;
};
/** Map key of a string's first three UTF-16 code units, so a field word is looked up without cutting a substring. */
function headKey(s: string): number {
return s.charCodeAt(0) * 0x1_0000_0000 + s.charCodeAt(1) * 0x1_0000 + s.charCodeAt(2);
}
export function compileConcepts(queryKeywords: string[]): ConceptMatcher {
const concepts = dedupeSynonymConcepts(queryKeywords);
const termsByKey = new Map<number, PrefixTerm[]>();
concepts.forEach((keyword, concept) => {
for (const term of SYNONYM_LOOKUP.get(keyword) ?? [keyword]) {
if (term.length < MIN_PREFIX_LEN) continue;
const head = term.slice(0, MAX_PREFIX_LEN);
const key = headKey(head);
const terms = termsByKey.get(key);
if (!terms) termsByKey.set(key, [{ concept, head }]);
else if (!terms.some((t) => t.concept === concept && t.head === head)) terms.push({ concept, head });
}
});
return { concepts, termsByKey };
}
/**
* For each concept, the index into SCORED_FIELDS of the strongest field it matches, or -1. A concept is
* tested only until it matched, so `data` is serialized only while a concept is still open.
*/
export function matchConcepts(e: EventEntry, { concepts, termsByKey }: ConceptMatcher): number[] {
const fieldOf: number[] = new Array(concepts.length).fill(-1);
let open = concepts.length;
for (let f = 0; f < SCORED_FIELDS.length && open > 0; f++) {
const text = SCORED_FIELDS[f].text(e);
if (!text) continue;
for (let c = 0; c < concepts.length; c++) {
if (fieldOf[c] < 0 && text.includes(concepts[c])) {
fieldOf[c] = f;
open--;
}
}
if (open === 0 || termsByKey.size === 0) continue;
for (const word of splitWords(text)) {
if (word.length < MIN_PREFIX_LEN) continue;
const terms = termsByKey.get(headKey(word));
if (!terms) continue;
for (const { concept, head } of terms) {
if (fieldOf[concept] < 0 && (word.length >= head.length ? word.startsWith(head) : head.startsWith(word))) {
fieldOf[concept] = f;
open--;
}
}
if (open === 0) break;
}
}
return fieldOf;
}
type ScoredEvent = {
index: number;
score: number;
@@ -856,6 +943,9 @@ function severityIntentFactor(e: EventEntry, boostFailures: boolean, entityTypeM
* non-problem keyword matches the event TYPE, the event is the specifically-named entity and
* earns the ENTITY_BOOST (not the benign-Info damp) on a problem query — see severityIntentFactor.
* Omitted → no entity boost (old behavior), which is why the existing unit tests are unaffected.
*
* `matcher` is the query compiled by compileConcepts. It is query-invariant, so scoreEvents compiles it
* once for every fetched event; standalone callers (unit tests) omit it and get it compiled here.
*/
export function scoreEvent(
e: EventEntry,
@@ -863,49 +953,30 @@ export function scoreEvent(
boostFailures = false,
semanticTypeScores?: Map<string, number>,
keywordSpecificity?: Map<string, number>,
precomputedConcepts?: string[],
matcher: ConceptMatcher = compileConcepts(queryKeywords),
): ScoredEvent | null {
const fields: Array<{ name: string; text: string; weight: number }> = [
{ name: 'eventType', text: (e.eventType ?? '').toLowerCase(), weight: FIELD_WEIGHTS.eventType },
{ name: 'message', text: (e.message ?? '').toLowerCase(), weight: FIELD_WEIGHTS.message },
{ name: 'source', text: (e.source ?? '').toLowerCase(), weight: FIELD_WEIGHTS.source },
{ name: 'severity', text: (e.severity ?? '').toLowerCase(), weight: FIELD_WEIGHTS.severity },
{ name: 'data', text: e.data ? JSON.stringify(e.data).toLowerCase() : '', weight: FIELD_WEIGHTS.data },
];
// Collapse query keywords that share a synonym group to a single concept so two query words
// expressing ONE idea (e.g. "stuck" + "timeout") don't double-count coverage/score against an
// off-topic type (hello_wait_timeout) and outrank the actually-named entity (app_download_started).
// This is query-invariant, so the cross-session loop computes it once and passes it in to avoid
// recomputing the dedupe (and its Set/Map allocations) for every one of potentially thousands of
// fetched events; standalone callers (unit tests) omit it and get the same value computed here.
const concepts = precomputedConcepts ?? dedupeSynonymConcepts(queryKeywords);
// The concepts are the keywords collapsed to one per synonym group, so two query words expressing ONE
// idea (e.g. "stuck" + "timeout") don't double-count coverage/score against an off-topic type
// (hello_wait_timeout) and outrank the actually-named entity (app_download_started).
const { concepts } = matcher;
const fieldOf = matchConcepts(e, matcher);
let totalScore = 0;
const matched: string[] = [];
const bestFields = new Set<string>();
let entityTypeMatch = false;
for (const kw of concepts) {
let kwBestWeight = 0;
let kwBestField = '';
for (const field of fields) {
if (field.text && keywordMatchesField(field.text, kw)) {
if (field.weight > kwBestWeight) {
kwBestWeight = field.weight;
kwBestField = field.name;
}
}
}
if (kwBestWeight > 0) {
totalScore += kwBestWeight;
matched.push(kw);
bestFields.add(kwBestField);
// A rare, non-problem keyword matching the event TYPE marks this as the named entity.
if (kwBestField === 'eventType' && !isProblemIntentKeyword(kw)
&& (keywordSpecificity?.get(kw) ?? Infinity) <= RARE_THRESHOLD) {
entityTypeMatch = true;
}
for (let c = 0; c < concepts.length; c++) {
if (fieldOf[c] < 0) continue;
const kw = concepts[c];
const field = SCORED_FIELDS[fieldOf[c]];
totalScore += field.weight;
matched.push(kw);
bestFields.add(field.name);
// A rare, non-problem keyword matching the event TYPE marks this as the named entity.
if (field.name === 'eventType' && !isProblemIntentKeyword(kw)
&& (keywordSpecificity?.get(kw) ?? Infinity) <= RARE_THRESHOLD) {
entityTypeMatch = true;
}
}
@@ -940,6 +1011,36 @@ export function scoreEvent(
};
}
/** Events scored between two yields to the event loop. */
const SCORE_YIELD_INTERVAL = 256;
/**
* Score fetched events against the query, keeping those at or above `minScore`. Yields to the event
* loop every SCORE_YIELD_INTERVAL events: one process serves every tenant, and a cross-session search
* ranks tens of thousands of events, which in one synchronous pass would stall every other request
* (and /health) until it finished. Stops once the caller has gone, since nobody reads the result.
*/
export async function scoreEvents(
events: EventEntry[],
queryKeywords: string[],
boostFailures: boolean,
semanticTypeScores: Map<string, number>,
keywordSpecificity: Map<string, number>,
minScore: number,
): Promise<Array<ScoredEvent & { event: EventEntry }>> {
const matcher = compileConcepts(queryKeywords);
const scored: Array<ScoredEvent & { event: EventEntry }> = [];
for (let i = 0; i < events.length; i++) {
if (i > 0 && i % SCORE_YIELD_INTERVAL === 0) {
await new Promise<void>((resolve) => setImmediate(resolve));
getCallerSignal()?.throwIfAborted();
}
const result = scoreEvent(events[i], queryKeywords, boostFailures, semanticTypeScores, keywordSpecificity, matcher);
if (result && result.score >= minScore) scored.push({ ...result, index: i, event: events[i] });
}
return scored;
}
/**
* Spread the top-scored events across distinct sessions so one session's many
* same-type events don't monopolize the list — cross-session search otherwise keeps
@@ -1066,7 +1167,7 @@ export function registerSearchTools(
'even when it sits inside another event\'s data — pass a sessionId for a complete scan of every event field. ' +
'`truncated: true` means incomplete recall: narrow per `recallNote` or use depth="deep".',
inputSchema: {
query: z.string().describe('Natural-language description of what to find (e.g. "machine restarted unexpectedly", "app download stuck", "certificate error")'),
query: z.string().max(MAX_SEARCH_QUERY_CHARS).describe('Natural-language description of what to find (e.g. "machine restarted unexpectedly", "app download stuck", "certificate error")'),
sessionId: SessionIdSchema.optional().describe('Search within one session (complete scan). Omitted: cross-session search by event type.'),
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
depth: z.enum(['fast', 'deep']).optional().default('fast')
@@ -1075,7 +1176,7 @@ export function registerSearchTools(
.describe('Number of matching events to return (1-50). Defaults: 10 (fast) / 20 (deep).'),
minScore: z.coerce.number().min(0).max(1).optional()
.describe('Minimum relevance score (0-1). Defaults: 0.1 (fast) / 0.05 (deep). Lower surfaces weaker matches.'),
keywords: z.array(z.string()).optional()
keywords: z.array(z.string().max(MAX_SEARCH_KEYWORD_CHARS)).max(MAX_SEARCH_KEYWORDS).optional()
.describe('Additional exact keywords, merged (case-insensitively) with those auto-extracted from the query.'),
guaranteedTopRanked: z.coerce.number().min(0).max(50).optional().default(3)
.describe('How many top hits (by pure score) stay at the head in rank order, exempt from cross-session diversification (default 3). Set =topK to disable diversification, 0 for maximum session spread. Ignored with sessionId.'),
@@ -1091,8 +1192,11 @@ export function registerSearchTools(
const topK = args.topK ?? preset.defaultTopK;
const minScore = args.minScore ?? preset.defaultMinScore;
// Extract keywords FIRST so we can use them for index-based pre-filtering
const queryKeywords = resolveQueryKeywords(query, keywords);
// Extract keywords FIRST so we can use them for index-based pre-filtering. Everything below
// sees only the first MAX_SCORED_KEYWORDS; the response reports the rest as dropped.
const allKeywords = resolveQueryKeywords(query, keywords);
const queryKeywords = allKeywords.slice(0, MAX_SCORED_KEYWORDS);
const keywordsDropped = allKeywords.length - queryKeywords.length;
if (queryKeywords.length === 0) {
logSearchZeroHit('search_events', query, { reason: 'no_keywords' });
return toolResultText(
@@ -1108,16 +1212,7 @@ export function registerSearchTools(
await fetchSessionEvents(sessionId, tenantId, queryKeywords, preset.budget, query, eventTypeIndex, keywordSpecificity);
const boostFailures = queryHasProblemIntent(queryKeywords);
// Synonym-collapse the query keywords ONCE — scoreEvent runs per fetched event (potentially
// thousands), and the concept set is identical across all of them.
const concepts = dedupeSynonymConcepts(queryKeywords);
const scored: Array<ScoredEvent & { event: EventEntry }> = [];
for (let i = 0; i < events.length; i++) {
const result = scoreEvent(events[i], queryKeywords, boostFailures, semanticTypeScores, keywordSpecificity, concepts);
if (result && result.score >= minScore) {
scored.push({ ...result, index: i, event: events[i] });
}
}
const scored = await scoreEvents(events, queryKeywords, boostFailures, semanticTypeScores, keywordSpecificity, minScore);
scored.sort((a, b) => b.score - a.score);
const results = diversifyBySession(scored, topK, 2, guaranteedTopRanked).map((s) => ({
@@ -1153,6 +1248,7 @@ export function registerSearchTools(
depth,
searchMethod,
keywordsUsed: queryKeywords,
...(keywordsDropped > 0 ? { keywordsDropped, keywordNote: KEYWORDS_DROPPED_NOTE } : {}),
sessionsSearchedCount: sessionIds.length,
eventsFetched: events.length,
eventsMatched: scored.length,
@@ -1186,7 +1282,7 @@ export function registerSearchTools(
'because opaque codes embed poorly. For one known code, lookup_error_code is the direct answer (accepts ' +
'decimal, symbol and enforcement-state input too).',
inputSchema: {
query: z.string().describe('Natural language search query (e.g. "app download timeout", "TPM not ready", "ESP stuck")'),
query: z.string().max(MAX_SEARCH_QUERY_CHARS).describe('Natural language search query (e.g. "app download timeout", "TPM not ready", "ESP stuck")'),
topK: z.coerce.number().min(1).max(20).optional().default(5).describe('Number of results to return (1-20, default 5)'),
type: z.enum(['all', 'analyze-rule', 'gather-rule', 'ime-log-pattern', 'error-code']).optional().default('all')
.describe('Filter by document type. Default: search all types.'),
@@ -1333,9 +1429,9 @@ export function registerSearchTools(
'Rule of thumb: "how does X work?" → search_docs; "why did this session fail?" → search_knowledge or ' +
'get_session_summary.',
inputSchema: {
query: z.string().describe('Natural language question (e.g. "how do I deploy the agent with Intune", "where is my data stored", "which roles exist")'),
query: z.string().max(MAX_SEARCH_QUERY_CHARS).describe('Natural language question (e.g. "how do I deploy the agent with Intune", "where is my data stored", "which roles exist")'),
topK: z.coerce.number().min(1).max(20).optional().default(5).describe('Number of results to return (1-20, default 5)'),
section: z.string().optional()
section: z.string().max(MAX_DOCS_SECTION_CHARS).optional()
.describe('Restrict to one documentation area, matching the bundle\'s top-level folder ' +
`(one of: ${docs.sections.join(', ')}). Omit to search everything.`),
minScore: z.coerce.number().min(0).max(1).optional().default(0.25)
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/server';
import { z } from 'zod';
import { ApiError, apiFetch, buildQuery, jsonBody, DEFAULT_FIRST_PAGE_SIZE, effectivePageSize, enforceDelegatedTenant, enforceDelegatedTenantForPage, followNextLink, pageSizeForCall, pickGlobalOrTenantPath, scanWithTimeoutFallback } from '../client.js';
import { withToolTelemetry } from '../telemetry.js';
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, LEAN_EVENT_FIELDS, LEAN_EVENT_OMISSION, leanFieldSelection, SUMMARY_EVENT_FIELDS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, foreignSuccessRunIds, isCorrectedForeignFailure, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, daysDescription } from './shared.js';
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, LEAN_EVENT_FIELDS, LEAN_EVENT_OMISSION, leanFieldSelection, SUMMARY_EVENT_FIELDS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, foreignSuccessRunIds, isCorrectedForeignFailure, tenantIdDescription, pageSizeDescription, CONTINUATION_DESCRIPTION, ContinuationSchema, daysDescription } from './shared.js';
import { toolError } from './error-handler.js';
import { lookupErrorCode } from '../error-code-catalog.js';
import { withEventTypeNote, assertKnownDevicePropertyKeys } from '../resource-catalog.js';
@@ -174,7 +174,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
),
pageSize: z.coerce.number().int().min(1).max(1000).optional()
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE, 1000, 'Raise it for full sweeps.')),
continuation: z.string().optional().describe(CONTINUATION_DESCRIPTION),
continuation: ContinuationSchema.optional().describe(CONTINUATION_DESCRIPTION),
},
annotations: READ_ONLY,
},
@@ -227,7 +227,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
tenantId: z.string().optional().describe(tenantIdDescription(ga, delegated)),
pageSize: z.coerce.number().int().min(1).max(1000).optional()
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE, 1000, 'Raise it for full sweeps.')),
continuation: z.string().optional().describe(CONTINUATION_DESCRIPTION),
continuation: ContinuationSchema.optional().describe(CONTINUATION_DESCRIPTION),
},
annotations: READ_ONLY,
},
@@ -410,7 +410,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
.describe('Projection. Omitted on an UNFILTERED read = lean default "' + LEAN_EVENT_FIELDS + '" without the multi-KB "data" payload (omittedFields says so); omitted on a FILTERED read = full events incl. "data". List "data" for the whole payload or "data.<key>" (e.g. "data.errorCode") for single keys. Further keys: eventId, sessionId, tenantId, receivedAt, sentAt, rowKey, originalTimestamp, timestampClamped, causedByTransitionStepIndex, causedBySignalOrdinal.'),
pageSize: z.coerce.number().int().min(1).max(1000).optional()
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE)),
continuation: z.string().optional().describe(CONTINUATION_DESCRIPTION),
continuation: ContinuationSchema.optional().describe(CONTINUATION_DESCRIPTION),
},
annotations: READ_ONLY,
},
@@ -749,7 +749,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
overallRisk: z.enum(['low', 'medium', 'high', 'critical']).optional(),
pageSize: z.coerce.number().int().min(1).max(1000).optional()
.describe(pageSizeDescription(DEFAULT_FIRST_PAGE_SIZE, 1000, 'Raise it for a full exposure audit.')),
continuation: z.string().optional().describe(CONTINUATION_DESCRIPTION),
continuation: ContinuationSchema.optional().describe(CONTINUATION_DESCRIPTION),
},
annotations: READ_ONLY,
},
@@ -372,6 +372,13 @@ export function pageSizeDescription(defaultSize: number, max = 1000, note?: stri
export const CONTINUATION_DESCRIPTION =
'The whole nextLink string from the previous response (preferred — it carries the resolved filters) or its opaque continuation value.';
/**
* continuation: a server-emitted nextLink or its opaque token; real ones stay under 1 KB. The bound
* matters: a pageSize/fields override re-sets params on the caller's link (withQueryOverrides), and
* URLSearchParams.set drops duplicate keys one at a time — quadratic in a forged link's duplicates.
*/
export const ContinuationSchema = z.string().max(4096);
/** days: a trailing window ending now. */
export function daysDescription(defaultDays: number, max: number, note?: string): string {
return `Trailing window in days (1-${max}; default ${defaultDays}).${note ? ` ${note}` : ''}`;
@@ -309,6 +309,22 @@ namespace AutopilotMonitor.Shared
/// </summary>
public const string AgentDirectory = @"%ProgramData%\AutopilotMonitor\Agent";
/// <summary>
/// Publisher pin for everything the release pipeline signs: a <c>-like</c> pattern on the
/// Authenticode signer's subject, never a thumbprint (certificates are renewed, deployed
/// verifiers are not). The bootstrap loader and installer carry the same literal as
/// <c>$ExpectedPublisher</c>; a parity test keeps all three in step.
/// </summary>
public const string CodeSigningPublisherPattern = "*O=glueckkanja AG*";
/// <summary>
/// Signed catalog at the root of every agent ZIP, holding the SHA-256 of every other file
/// in the payload. Installer, OOBE bootstrap script and self-updater accept a payload only
/// when this catalog is signed by <see cref="CodeSigningPublisherPattern"/> and matches the
/// extracted files exactly: nothing missing, changed or extra.
/// </summary>
public const string AgentPayloadCatalogFileName = "AutopilotMonitor.Agent.cat";
// -----------------------------------------------------------------------
// Upload / batching defaults
// -----------------------------------------------------------------------
@@ -18,8 +18,9 @@ namespace AutopilotMonitor.Shared.Models
public string TenantId { get; set; } = default!;
/// <summary>
/// Domain name extracted from the first user's UPN
/// Used for display purposes (e.g., contoso.com)
/// Domain of the onboarding member's UPN (e.g. contoso.com), seeded once by the first member
/// login and never from an external identity. Tenant identity, not only display: transactional
/// mails render it, and cross-tenant grants resolve a never-signed-in UPN's home tenant through it.
/// </summary>
public string DomainName { get; set; } = default!;
@@ -34,13 +35,13 @@ namespace AutopilotMonitor.Shared.Models
public string UpdatedBy { get; set; } = default!;
/// <summary>
/// UPN of the user whose first login created this tenant configuration. Set once
/// in <c>HandleNewTenantDomainAsync</c> alongside <see cref="DomainName"/> and
/// never overwritten. Used by the preview-approval auto-promote path so background
/// jobs that mutate <see cref="UpdatedBy"/> (e.g. global rate-limit sync) cannot
/// leak a sentinel string into the TenantAdmins table.
/// Null on rows that pre-date the OnboardedBy field — auto-promote falls back to
/// <see cref="UpdatedBy"/> with a UPN-shape guard.
/// UPN of the member whose first login onboarded this tenant. Set once in
/// <c>HandleNewTenantDomainAsync</c> alongside <see cref="DomainName"/> and never
/// overwritten; an external identity (guest) never sets it. Activation promotes this UPN,
/// and only this one, to Tenant Admin, so background jobs that mutate
/// <see cref="UpdatedBy"/> (e.g. global rate-limit sync) cannot leak a sentinel or a last
/// writer into the TenantAdmins table. Null on rows that pre-date the field: such a tenant
/// gets no promotion on activation.
/// </summary>
public string? OnboardedBy { get; set; }
@@ -9,8 +9,10 @@ import { sameHistory } from "@/lib/push/historyList";
import { createLongPress } from "@/lib/push/longPress";
import { createPullToRefresh, type PullState } from "@/lib/push/pullToRefresh";
import {
applyBadge,
deleteHistoryEntry,
entryFragment,
markAllRead,
META_KEYS,
parseFragment,
pruneStoredHistory,
@@ -19,11 +21,11 @@ import {
writeMeta,
type HistoryEntry,
} from "@/lib/push/pushCore";
import { importDisplayedNotifications, readPairCookie, reconcileDevice, type ReconcileResult } from "@/lib/push/pushClient";
import { closeReadNotifications, importDisplayedNotifications, readPairCookie, reconcileDevice, type ReconcileResult } from "@/lib/push/pushClient";
import { formatDateTime, formatRelativeTime } from "@/lib/push/pushFormat";
import { EntryActionSheet } from "./EntryActionSheet";
import { useIsClient, useLocationHash } from "./pushEnvironment";
import { DANGER_CHIP, DEVICE_STATUS_CHIP, NEUTRAL_CHIP, SEVERITY_ACCENT } from "./pushStyles";
import { DANGER_CHIP, DEVICE_STATUS_CHIP, NEUTRAL_CHIP, NEW_CHIP, SEVERITY_ACCENT } from "./pushStyles";
interface WorkerMessage {
channel?: unknown;
@@ -43,13 +45,16 @@ const REFRESH_MIN_MS = 500;
* while visible, and the tab/app coming back all re-read the store; a pull from the top refreshes
* on demand (a home-screen app has no native gesture). Every open reconciles the device with the
* server (K14). A long press (or right-click) on an entry opens its actions; a delete stays on
* the device.
* the device. Like a messenger, a history on screen is read: the app badge goes to zero, the
* notification centre lets go of what the history holds, and what arrived since the last visit
* carries "New" until the app is left again.
*/
export default function PushHistoryPage() {
const router = useRouter();
const isClient = useIsClient();
const hash = useLocationHash();
const [entries, setEntries] = useState<HistoryEntry[] | null>(null);
const [fresh, setFresh] = useState<ReadonlySet<string>>(() => new Set());
const [device, setDevice] = useState<ReconcileResult | null>(null);
const [historyKey, setHistoryKey] = useState(0);
const [reloadKey, setReloadKey] = useState(0);
@@ -66,16 +71,26 @@ export default function PushHistoryPage() {
if (expanded.focusId !== focusId) setExpanded({ focusId, id: focusId });
// One read of the store (prune first). An unchanged list keeps its identity so a poll never re-renders for nothing.
// On screen, the read also marks everything read; the badge is cleared on every such read, since
// the worker may raise it between the read and the mark.
const loadHistory = useCallback(async () => {
const onScreen = document.visibilityState === "visible";
let list: HistoryEntry[] = [];
let seen: string[] = [];
try {
await importDisplayedNotifications();
await pruneStoredHistory();
list = await readHistory();
if (onScreen) seen = await markAllRead();
} catch {
// No IndexedDB (private mode): an empty history is the honest state.
}
if (onScreen) {
applyBadge(navigator, 0);
void closeReadNotifications(new Set(list.map((entry) => entry.id)));
}
setEntries((prev) => (sameHistory(prev, list) ? prev : list));
if (seen.length > 0) setFresh((prev) => new Set([...prev, ...seen]));
setNow(Date.now());
}, []);
@@ -98,7 +113,11 @@ export default function PushHistoryPage() {
if (event.data.type === "wiped") setReloadKey((k) => k + 1);
};
const onBack = () => {
if (document.visibilityState !== "visible") return;
if (document.visibilityState !== "visible") {
// Leaving the app ends the visit: what was new has been seen.
setFresh((prev) => (prev.size === 0 ? prev : new Set()));
return;
}
setHistoryKey((k) => k + 1);
setReloadKey((k) => k + 1);
};
@@ -337,6 +356,7 @@ export default function PushHistoryPage() {
entry={entry}
open={expanded.id === entry.id}
focused={focusId === entry.id}
fresh={fresh.has(entry.id)}
now={now}
onToggle={toggle}
onActions={setSheet}
@@ -355,6 +375,8 @@ interface EntryCardProps {
entry: HistoryEntry;
open: boolean;
focused: boolean;
/** Arrived since the history was last on screen. */
fresh: boolean;
now: number;
onToggle: (id: string) => void;
onActions: (entry: HistoryEntry) => void;
@@ -365,7 +387,7 @@ interface EntryCardProps {
* card suppresses the browser's own text selection and the iOS callout so the press reads as
* a press, and it ignores the click that the release after a long press may produce.
*/
function EntryCard({ entry, open, focused, now, onToggle, onActions }: EntryCardProps) {
function EntryCard({ entry, open, focused, fresh, now, onToggle, onActions }: EntryCardProps) {
const openActions = useLatest(() => onActions(entry));
const [press] = useState(() => createLongPress({ onLongPress: () => openActions.current() }));
useEffect(() => () => press.dispose(), [press]);
@@ -397,9 +419,12 @@ function EntryCard({ entry, open, focused, now, onToggle, onActions }: EntryCard
>
<div className="flex items-baseline justify-between gap-2">
<span className="text-sm font-medium text-gray-900 break-words min-w-0">{entry.title}</span>
<time dateTime={entry.ts} title={formatDateTime(entry.ts)} className="text-xs text-gray-500 shrink-0">
{now ? formatRelativeTime(entry.ts, now) : ""}
</time>
<span className="flex items-baseline gap-1.5 shrink-0">
{fresh && <span className={`px-1.5 py-px rounded-full text-[10px] font-semibold uppercase tracking-wide ${NEW_CHIP}`}>New</span>}
<time dateTime={entry.ts} title={formatDateTime(entry.ts)} className="text-xs text-gray-500">
{now ? formatRelativeTime(entry.ts, now) : ""}
</time>
</span>
</div>
{entry.body && (
<p className={`text-sm text-gray-600 mt-0.5 break-words ${open ? "" : "line-clamp-2"}`}>{entry.body}</p>
@@ -19,4 +19,6 @@ export const DEVICE_STATUS_CHIP: Record<string, string> = {
};
export const NEUTRAL_CHIP = "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300";
/** "New" on an entry that arrived since the history was last on screen. */
export const NEW_CHIP = "bg-sky-100 text-sky-800 dark:bg-sky-900/40 dark:text-sky-300";
export const DANGER_CHIP = "bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300";
@@ -21,10 +21,12 @@ interface SessionWatchButtonProps {
/**
* "Notify me when done" (plan push-relay): registers a watch that pushes to the caller's own
* paired devices when this session reaches a terminal status. Hidden once the session is
* terminal; the host gates it to Admins/Operators (and Global Admins) like Report Session.
* The GET says whether watching is possible for this person at all (a Push channel in one of
* their own scopes); without that the button stays hidden instead of letting a click fail.
* paired devices when this session reaches a terminal status — or when pre-provisioning seals
* the device, which consumes the watch; on the sealed (Pending) session a new click watches the
* user phase. Hidden once the session is terminal; the host gates it to Admins/Operators (and
* Global Admins) like Report Session. The GET says whether watching is possible for this person
* at all (a Push channel in one of their own scopes); without that the button stays hidden
* instead of letting a click fail. Every status change re-reads the watch, so the seal shows here.
*/
export default function SessionWatchButton({ sessionId, effectiveTenantId, sessionStatus, getAccessToken, addNotification }: SessionWatchButtonProps) {
const [watching, setWatching] = useState<boolean | null>(null);
@@ -32,6 +34,8 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi
const [busy, setBusy] = useState(false);
const active = sessionId.length > 0 && !!sessionStatus && !TERMINAL_STATUSES.has(sessionStatus);
const sealed = sessionStatus === "Pending";
const until = sealed ? "the user phase ends" : "this session ends or pre-provisioning finishes";
useEffect(() => {
if (!active) return;
@@ -51,7 +55,7 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi
return () => {
cancelled = true;
};
}, [active, sessionId, effectiveTenantId, getAccessToken]);
}, [active, sessionStatus, sessionId, effectiveTenantId, getAccessToken]);
if (!active || !available) return null;
@@ -63,7 +67,7 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi
const result = await setSessionWatch(sessionId, effectiveTenantId, next, getAccessToken);
setWatching(result.watching);
if (result.watching) {
addNotification("success", TOAST_TITLE, "Your paired devices will be notified when this session ends.", TOAST_KEY);
addNotification("success", TOAST_TITLE, `Your paired devices will be notified when ${until}.`, TOAST_KEY);
} else {
addNotification("info", TOAST_TITLE, "You will not be notified for this session.", TOAST_KEY);
}
@@ -84,7 +88,7 @@ export default function SessionWatchButton({ sessionId, effectiveTenantId, sessi
onClick={() => void toggle()}
disabled={busy || watching === null}
aria-pressed={watching === true}
title={watching ? "Stop notifying my devices when this session ends." : "Push to my paired devices when this session ends."}
title={watching ? `Stop notifying my devices when ${until}.` : `Push to my paired devices when ${until}.`}
className={`px-4 py-2 rounded-md transition-colors flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed ${
watching
? "bg-sky-100 border border-sky-300 text-sky-800 hover:bg-sky-200"
@@ -142,7 +142,7 @@ export default function AdminManagementSection({
</svg>
<p className="text-sm text-yellow-800">
<strong>Important:</strong> Make sure to keep at least one Admin in the list to maintain full access!
The first user to log in was automatically made an admin.
The first member of your organization to log in was automatically made an admin.
</p>
</div>
</div>
@@ -55,7 +55,7 @@ export const PROVIDERS: {
const EVENT_TOGGLES: { key: keyof NotificationChannel; label: string; hint: string }[] = [
{ key: "notifyOnStart", label: "Start", hint: "Enrollment started (one signal per fresh session and per Pre-Provisioning resume)" },
{ key: "notifyOnSuccess", label: "Success", hint: "Enrollment completed successfully" },
{ key: "notifyOnSuccess", label: "Success", hint: "Enrollment completed successfully, or pre-provisioning finished" },
{ key: "notifyOnFailure", label: "Failure", hint: "Enrollment failed" },
{ key: "notifyOnHardwareRejection", label: "Hardware rejection", hint: "Device rejected by the hardware whitelist" },
{ key: "notifyOnSlaEvents", label: "SLA", hint: "SLA breach / resolved / consecutive failures (which breaches are evaluated is configured under SLA Targets)" },
@@ -44,6 +44,8 @@ describe("classifyEntry", () => {
expect(classifyEntry(entry("b", "X", "platform", [{ name: "Category", value: "Consent" }]))).toEqual({ group: "platform", kind: "consent", label: "Consent" });
expect(classifyEntry(entry("c", "test", "platform"))).toEqual({ group: "platform", kind: "other", label: "Other" });
expect(classifyEntry(entry("d", "enrollment_succeeded"))).toEqual({ group: "enrollment", kind: "enrollment_succeeded", label: "Enrollment succeeded" });
// The WhiteGlove seal travels on the Success toggle as its own alert kind.
expect(classifyEntry(entry("f", "preprovisioning_completed"))).toEqual({ group: "enrollment", kind: "preprovisioning_completed", label: "Pre-provisioning completed" });
expect(classifyEntry(entry("e", "some_new_kind"))).toEqual({ group: "enrollment", kind: "some_new_kind", label: "Some new kind" });
});
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
APP_VERSION,
appendTraceRecord,
applyBadge,
base64UrlToUint8Array,
bufferToBase64Url,
cleanLabel,
@@ -36,6 +37,7 @@ import {
toNotificationOptions,
TRACE_MAX_RECORDS,
TRACE_VALUE_MAX_CHARS,
unreadIds,
type HistoryEntry,
} from "../push/pushCore";
@@ -246,6 +248,62 @@ describe("pruneHistory", () => {
});
});
describe("app badge (unreadIds / applyBadge)", () => {
const entry = (id: string, unread?: boolean): HistoryEntry => ({ ...normalizePayload(FULL_PAYLOAD, NOW), id, unread });
it("counts only entries stored unread; a fresh payload carries no flag at all", () => {
expect(normalizePayload(FULL_PAYLOAD, NOW).unread).toBeUndefined();
expect(unreadIds([entry("a", true), entry("b", false), entry("c"), entry("d", true)])).toEqual(["a", "d"]);
expect(unreadIds([])).toEqual([]);
});
it("sets a count above zero and clears at zero, always with a number", () => {
const calls: string[] = [];
const nav = {
setAppBadge: (count: number) => {
calls.push(`set:${count}`);
return Promise.resolve();
},
clearAppBadge: () => {
calls.push("clear");
return Promise.resolve();
},
};
applyBadge(nav, 3);
applyBadge(nav, 0);
expect(calls).toEqual(["set:3", "clear"]);
});
it("returns at once although Safari's setAppBadge promise never settles inside a declarative push event", () => {
let called = false;
const nav = {
setAppBadge: () => {
called = true;
return new Promise<void>(() => {});
},
};
expect(applyBadge(nav, 1)).toBeUndefined();
expect(called).toBe(true);
});
it("swallows a rejection, a throw and a missing Badging API", async () => {
applyBadge({ setAppBadge: () => Promise.reject(new Error("NotAllowedError")) }, 2);
applyBadge(
{
clearAppBadge: () => {
throw new Error("boom");
},
},
0,
);
applyBadge({}, 5);
applyBadge(undefined, 5);
applyBadge(null, 0);
// A rejection left unhandled would fail the run; give it a tick to surface.
await new Promise((resolve) => setTimeout(resolve, 0));
});
});
describe("planSubscriptionKeys (worker repair after a browser-side subscription change)", () => {
const stored = { [META_KEYS.kid]: "k1", [META_KEYS.vapidPublicKey]: "key-1" };
@@ -5,8 +5,9 @@ import { GENERIC_BODY, GENERIC_TITLE } from "../push/pushCore";
* Pins the K25 "always show something" contract of the service worker glue (public/push/sw.js):
* the push handler reaches showNotification before IndexedDB is even opened, a payload that
* cannot be read still shows the generic title, and the event's waitUntil promise settles
* although the history write hangs or fails. The worker is loaded for real with a stubbed
* worker global, so the test runs the shipped glue and not a copy of it.
* although the history write hangs or fails — or Safari's setAppBadge never settles. The worker
* is loaded for real with a stubbed worker global, so the test runs the shipped glue and not a
* copy of it.
*/
type Handler = (event: unknown) => void;
@@ -25,7 +26,8 @@ interface FakeSelf {
sequence: string[];
}
function fakeSelf(): FakeSelf {
/** @param navigator the worker's navigator (Badging API); absent like on a browser without it */
function fakeSelf(navigator?: unknown): FakeSelf {
const handlers = new Map<string, Handler>();
const sequence: string[] = [];
const showNotification = vi.fn(async () => {
@@ -47,6 +49,7 @@ function fakeSelf(): FakeSelf {
},
clients,
location: { origin: "https://portal.example.test" },
navigator,
};
vi.stubGlobal("self", self);
return { handlers, showNotification, clients, sequence };
@@ -274,6 +277,68 @@ describe("push service worker glue (K25)", () => {
});
});
describe("app badge after a history write", () => {
const CORE = "../../public/push/sw-core.js";
const readable = { notification: { title: "Enrollment failed", body: "DESKTOP-1234", data: { id: "badge-1", type: "enrollment_failed" } } };
afterEach(() => {
vi.doUnmock(CORE);
});
/** The real glue over a store stub: the write answers `added`, the store holds `unread` unread entries. */
async function loadWorkerWithStore(added: boolean, unread: number, navigator: unknown) {
vi.resetModules();
const writeTrace = vi.fn(async () => undefined);
vi.doMock(CORE, async (importOriginal) => ({
...(await importOriginal<typeof import("../../public/push/sw-core.js")>()),
writeHistoryEntry: vi.fn(async () => added),
countUnread: vi.fn(async () => unread),
writeTrace,
}));
const env = fakeSelf(navigator);
await import("../../public/push/sw.js");
return { ...env, writeTrace };
}
it("raises the badge to the unread count without waiting for setAppBadge, whose promise never settles in Safari's declarative push event", async () => {
const setAppBadge = vi.fn(() => new Promise<void>(() => {}));
const env = await loadWorkerWithStore(true, 3, { setAppBadge });
const event = fakeEvent({ data: { json: () => readable } });
env.handlers.get("push")!(event);
// Settles by itself — not through the 5-second persist bound, which the fake timers never reach.
await expect(event.waited).resolves.toBeDefined();
expect(setAppBadge).toHaveBeenCalledWith(3);
expect(env.writeTrace).toHaveBeenCalledWith(expect.objectContaining({ id: "badge-1", result: "added", badge: 3 }));
});
it("leaves the badge alone for a duplicate and for an empty count", async () => {
const setAppBadge = vi.fn(async () => undefined);
const duplicate = await loadWorkerWithStore(false, 3, { setAppBadge });
const first = fakeEvent({ data: { json: () => readable } });
duplicate.handlers.get("push")!(first);
await expect(first.waited).resolves.toBeDefined();
const empty = await loadWorkerWithStore(true, 0, { setAppBadge });
const second = fakeEvent({ data: { json: () => readable } });
empty.handlers.get("push")!(second);
await expect(second.waited).resolves.toBeDefined();
expect(setAppBadge).not.toHaveBeenCalled();
});
it("still settles on a browser without the Badging API", async () => {
const env = await loadWorkerWithStore(true, 2, undefined);
const event = fakeEvent({ data: { json: () => readable } });
env.handlers.get("push")!(event);
await expect(event.waited).resolves.toBeDefined();
expect(env.writeTrace).toHaveBeenCalledWith(expect.objectContaining({ result: "added", badge: 2 }));
});
});
describe("notificationclick on a notification the platform displayed itself", () => {
function clickEvent(notification: Record<string, unknown>) {
const event = fakeEvent({}) as FakeEvent & { notification: Record<string, unknown> };
@@ -22742,7 +22742,7 @@
"kind": "primitive",
"name": "string"
},
"doc": "Domain name extracted from the first user's UPN Used for display purposes (e.g., contoso.com)"
"doc": "Domain of the onboarding member's UPN (e.g. contoso.com), seeded once by the first member login and never from an external identity. Tenant identity, not only display: transactional mails render it, and cross-tenant grants resolve a never-signed-in UPN's home tenant through it."
},
{
"name": "lastUpdated",
@@ -22768,7 +22768,7 @@
"nullable": true
},
"optional": true,
"doc": "UPN of the user whose first login created this tenant configuration. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten. Used by the preview-approval auto-promote path so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel string into the TenantAdmins table. Null on rows that pre-date the OnboardedBy field — auto-promote falls back to UpdatedBy with a UPN-shape guard."
"doc": "UPN of the member whose first login onboarded this tenant. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten; an external identity (guest) never sets it. Activation promotes this UPN, and only this one, to Tenant Admin, so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel or a last writer into the TenantAdmins table. Null on rows that pre-date the field: such a tenant gets no promotion on activation."
},
{
"name": "dpaVersion",
@@ -4961,13 +4961,13 @@ export interface TenantConfigPatchOutcomeResponse {
export interface TenantConfiguration {
/** Tenant ID (PartitionKey in Table Storage) */
tenantId: string;
/** Domain name extracted from the first user's UPN Used for display purposes (e.g., contoso.com) */
/** Domain of the onboarding member's UPN (e.g. contoso.com), seeded once by the first member login and never from an external identity. Tenant identity, not only display: transactional mails render it, and cross-tenant grants resolve a never-signed-in UPN's home tenant through it. */
domainName: string;
/** When the configuration was last updated */
lastUpdated: string;
/** Updated by (user email or system) */
updatedBy: string;
/** UPN of the user whose first login created this tenant configuration. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten. Used by the preview-approval auto-promote path so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel string into the TenantAdmins table. Null on rows that pre-date the OnboardedBy field — auto-promote falls back to UpdatedBy with a UPN-shape guard. */
/** UPN of the member whose first login onboarded this tenant. Set once in HandleNewTenantDomainAsync alongside DomainName and never overwritten; an external identity (guest) never sets it. Activation promotes this UPN, and only this one, to Tenant Admin, so background jobs that mutate UpdatedBy (e.g. global rate-limit sync) cannot leak a sentinel or a last writer into the TenantAdmins table. Null on rows that pre-date the field: such a tenant gets no promotion on activation. */
onboardedBy?: string | null;
/** Version of the data processing agreement this tenant accepted — CurrentDpaVersion at that moment. Written once: with the onboarding when the first sign-in came through the get-started tick, otherwise when the portal's acceptance dialog is confirmed (DpaAcceptancePending). Never client-writable. Null on tenants onboarded before the field existed. */
dpaVersion?: string | null;
@@ -40,6 +40,9 @@ const ENROLLMENT_LABELS: Record<string, string> = {
enrollment_failed: "Enrollment failed",
enrollment_complete: "Enrollment complete",
enrollment_end: "Enrollment ended",
preprovisioning_completed: "Pre-provisioning completed",
preprovisioning_resumed: "Pre-provisioning resumed",
preprovisioning_failed: "Pre-provisioning failed",
session_stalled: "Session stalled",
session_watch: "Session watch",
consecutive_failures: "Consecutive failures",
@@ -5,6 +5,7 @@
*/
import { API_BASE_URL } from "@/lib/config";
import {
applyBadge,
base64UrlToUint8Array,
bufferToBase64Url,
clearPushDb,
@@ -172,6 +173,26 @@ export async function importDisplayedNotifications(): Promise<number> {
return added;
}
/**
* Clears the notifications whose entries the history holds from the notification centre, the way
* a native app does once its messages were read (on Android this also clears the launcher dot,
* which follows open notifications). Called after the import, so a notification the worker never
* persisted is in the history first. One whose entry cannot be identified — a declarative push
* the platform displayed itself carries no data — stays: the notification centre is its only record.
*/
export async function closeReadNotifications(historyIds: ReadonlySet<string>): Promise<void> {
const registration = await getPushRegistration().catch(() => null);
if (!registration || typeof registration.getNotifications !== "function") return;
try {
for (const notification of await registration.getNotifications()) {
const id = entryFromNotification(notification, Date.now())?.id;
if (id && historyIds.has(id)) notification.close();
}
} catch {
// The notifications stay; the next open tries again.
}
}
export interface DisplayedNotification {
/** The history id behind the notification, or null when the entry rule does not recognise it. */
id: string | null;
@@ -232,7 +253,7 @@ export async function collectPushDiagnostics(): Promise<PushDiagnostics> {
return { worker, controlled: navigator.serviceWorker.controller !== null, displayed, historyCount, trace };
}
/** Drops the subscription and every local trace; used by unpair, the wipe command and "device gone". */
/** Drops the subscription, the badge and every local trace; used by unpair, the wipe command and "device gone". */
export async function wipeLocalDevice(): Promise<void> {
try {
const registration = await getPushRegistration();
@@ -241,6 +262,7 @@ export async function wipeLocalDevice(): Promise<void> {
} catch {
// The browser may already have dropped it; the server copy is what matters.
}
applyBadge(navigator, 0);
await clearPushDb();
}
@@ -99,6 +99,7 @@ export const META_KEYS = /** @type {const} */ ({
* @property {string | null} tag
* @property {string} scope
* @property {boolean} generic true when the payload could not be read and the generic text was used
* @property {boolean} [unread] set when the entry is stored, cleared once the history page shows it; the app badge counts these
*/
/**
@@ -376,6 +377,32 @@ export function pruneHistory(entries, nowMs, retentionDays = HISTORY_RETENTION_D
return kept.map((item) => item.entry);
}
/**
* The entries the history page has not shown yet — what the app badge counts.
* @param {HistoryEntry[]} entries
* @returns {string[]}
*/
export function unreadIds(entries) {
return entries.filter((entry) => entry.unread === true).map((entry) => entry.id);
}
/**
* Sets the app icon badge to `count`, 0 clears it; fire and forget. Never awaited: inside a
* declarative push event Safari's setAppBadge promise never settles, so awaiting it — or
* handing it to waitUntil — would hold the event open. A call without a number clears the badge
* on iOS, so a count is always passed. Without the Badging API nothing happens.
* @param {{ setAppBadge?: (count: number) => Promise<void>, clearAppBadge?: () => Promise<void> } | null | undefined} nav the page's navigator or the worker's self.navigator
* @param {number} count
*/
export function applyBadge(nav, count) {
try {
const pending = count > 0 ? nav?.setAppBadge?.(count) : nav?.clearAppBadge?.();
pending?.catch(() => {});
} catch {
// A browser that throws instead of rejecting: no badge, nothing else changes.
}
}
/**
* The user's retention choice as the pages store it and the worker reads it: an integer 0–365
* (0 = keep until the entry cap). Anything else — absent, non-numeric, fractional, out of range —
@@ -726,7 +753,7 @@ export function readHistory() {
}
/**
* Upserts one entry (dedupe by id) and prunes what the retention rule no longer keeps.
* Upserts one entry (dedupe by id), unread, and prunes what the retention rule no longer keeps.
* @param {HistoryEntry} entry
* @returns {Promise<boolean>} false when an entry with this id already existed
*/
@@ -734,12 +761,33 @@ export function writeHistoryEntry(entry) {
return withStores("readwrite", async ({ history, meta }) => {
const existing = await requestToPromise(history.get(entry.id));
if (existing) return false;
await requestToPromise(history.put(entry));
await requestToPromise(history.put({ ...entry, unread: true }));
await pruneStore(history, meta);
return true;
});
}
/**
* @returns {Promise<number>} the stored entries the history page has not shown yet (the badge count)
*/
export function countUnread() {
return withStores("readonly", async ({ history }) => unreadIds(/** @type {HistoryEntry[]} */ (await requestToPromise(history.getAll()))).length);
}
/**
* Marks every unread entry read — the history page is showing them.
* @returns {Promise<string[]>} the ids that were unread
*/
export function markAllRead() {
return withStores("readwrite", async ({ history }) => {
const all = /** @type {HistoryEntry[]} */ (await requestToPromise(history.getAll()));
for (const entry of all) {
if (entry.unread === true) await requestToPromise(history.put({ ...entry, unread: false }));
}
return unreadIds(all);
});
}
/**
* Removes one entry from the local history (the user's delete; nothing leaves the device).
* @param {string} id
@@ -14,9 +14,11 @@
* lost. lib/__tests__/pushSw.test.ts pins the order and the settling.
*/
import {
applyBadge,
base64UrlToUint8Array,
bufferToBase64Url,
clearPushDb,
countUnread,
entryFragment,
entryFromNotification,
GENERIC_BODY,
@@ -129,8 +131,10 @@ function describeError(error) {
/**
* Best-effort history write plus its trace record, bounded in time as one; never rejects. The
* record says what became of the entry ("added", "duplicate", "error: …"); when the database
* never answers, the missing record is the evidence the status page shows.
* record says what became of the entry ("added", "duplicate", "error: …") and the badge it set;
* when the database never answers, the missing record is the evidence the status page shows.
* A new entry raises the app badge to the unread count — applyBadge is fire and forget, its
* promise never settles inside Safari's declarative push event.
* @param {import("./sw-core.js").HistoryEntry} entry
* @param {Record<string, unknown>} fields the record's fields besides id, type, result and shown
* @param {Promise<string>} [shown] the display outcome — already in flight and never rejecting, awaited only for the record
@@ -146,14 +150,24 @@ function persist(entry, fields, shown) {
} catch (error) {
result = "error: " + describeError(error);
}
if (result === "added") await notifyWindows("history").catch(() => {});
/** @type {number | undefined} */
let badge;
if (result === "added") {
await notifyWindows("history").catch(() => {});
badge = await countUnread().catch(() => undefined);
if (badge) applyBadge(self.navigator, badge);
}
const display = shown ? await shown.catch(() => "failed") : undefined;
await writeTrace({ ...fields, id: entry.id, type: entry.type, result, shown: display });
await writeTrace({ ...fields, id: entry.id, type: entry.type, result, shown: display, badge });
})(),
);
}
/** The wipe command: forget the device locally and drop the subscription. */
/**
* The wipe command: forget the device locally and drop the subscription. The badge is left to the
* page's next open: a badge of 0 set inside the push event can hide the "unpaired" notification
* on iOS 18.1–18.5 (FB16095572 on native pushes).
*/
async function wipe() {
try {
const subscription = await self.registration.pushManager.getSubscription();