Compare commits

...

13 Commits

Author SHA1 Message Date
Oliver Kieselbach b89008e745 ci(agent): customer release notes come in through the release_notes dispatch input, never from CI (D-187)
build-agent.yml gains an optional release_notes input; the release body opens with
"What's new" from that input, or a generic fallback linking to the docs changelog
plus a marker the operator tooling uses to anchor the next notes, so releases
dispatched by hand leave no gap. --generate-notes dropped (pull requests only);
a compare link to the previous tag replaces it. Plan-first rule: plans live in
internal/work/plans from the first draft. Gitlinks: internal, .claude/commands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 15:47:10 +02:00
Oliver Kieselbach cfa1cbbed2 security: cap the privileged-denial alarm so a valid token cannot flood the operator
Any signed-in user holds a token the GA-only routes refuse with 403, so the assume-breach
alarm was a push-flood vector: one event per caller AND route per hour, multiplied by
Flex instances. PrivilegedDenialReporter now keys the throttle on the caller alone (a sweep
over the whole GA surface is one event; every attempt still lands in the Warning trace) and
carries a fixed per-instance window budget: 5 events per hour, then ONE Critical storm
marker (same event type, so the same Telegram rule pushes it) and trace-only silence until
the window resets. Worst case per instance and hour: 6 pushes. Global Reader slips count
against the budget too. Tests pin the per-caller throttle, the storm marker and the Reader
accounting; D-185 amended.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 14:54:50 +02:00
Oliver Kieselbach aa1881ec44 chore: bump internal gitlink (backlog ids B-xxx)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 14:48:25 +02:00
Oliver Kieselbach 744e35ee9b mcp: get_deployment_state — live vs verified-deployed for backend, web, mcp and agent (x29 phase 3)
One call replaces the six fetches of the backend-version skill: backend /api/health vs
versions/backend.json, portal /version.json vs versions/web.json, this server's own build
(build-info.ts, now shared with /health and the boot line) vs versions/mcp.json, and the
agent release manifest. Per component: live, manifest, drift (in-sync | drifted | unknown |
not-applicable) with a note that names the missing half — a manifest 404 is "never deployed
since manifests exist", never a silent in-sync; failed fetches land in partialErrors
(allSettled). The well-known hosts join config.ts as the URL guard requires. Platform scope
(GA + Global Reader); catalog snapshot updated. The local git comparison stays in the skill.

Closes the build of backlog x29 (all four phases); deploy + live verification tracked as
backlog `sch`, the user-side RBAC / app settings / alert rule as `ket`.

Tests: deployment-state (drift arithmetic, fan-out, 404, unreachable), MCP 691 green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 14:07:03 +02:00
Oliver Kieselbach 884b74cb8c reports: ticket-gated download of session-report ZIPs for the MCP (x29 phase 2)
The report ZIP a tenant admin submitted and the preserved diagnostics copy could only be
fetched with a storage key through an az script. They now follow the diagnostics ticket
model: mint under the caller's JWT, download with a short-lived HMAC ticket, bytes proxied
through the backend — a storage SAS never leaves it.

Backend
- DiagnosticsDownloadTicket gains a purpose parameter (default diag-dl-v1, new
  report-dl-v1): a ticket for one download surface is rejected on the other (wrong_purpose).
- POST global/session-reports/download-ticket (GlobalReadOrAdmin, same tier as the portal's
  download-url): flat-name guard, existence check, best-effort size, ticket bound to the
  caller's home tenant; reuses DiagnosticsDownloadTicketResponse (destination SessionReports).
- GET global/session-reports/download (PublicAnonymous, ticket is the sole authority):
  per-IP limit + decode via the new TicketDownloadPrelude (shared with the diagnostics route),
  streamed from the session-reports container with BlobStorageService.OpenReadAsync.
- Constants.BlobContainers.SessionReports replaces three duplicated literals.
- Pins: catalog completeness/JWT-exempt theories, auth-skip, no-store, purpose tests.

MCP
- get_session_report_download (platform scope) mirrors get_session_diagnostics: kind
  (session-report / preserved-diagnostics / diag-files-report), size, download URL, expiry,
  instructions; catalog snapshot updated.

Submodules: session-report-debug skill on the tool, fetch-report-zip.sh deleted; plan/todo.
Tests: backend 5839, MCP 683, solution build green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 14:01:52 +02:00
Oliver Kieselbach 67316a0fbd chore: bump internal gitlink (q8n lessons)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:50:51 +02:00
Oliver Kieselbach 501f994c7a logs proxy: three telemetry stores, a wall-clock budget and typed partial results (x29 phase 1)
POST /api/global/raw/logs (MCP query_backend_logs) now takes `source` = backend | web | mcp
and `budgetSeconds` 5..180 (default 30). Parity with the az CLI was already by construction
(same Kusto REST endpoint, verbatim query); the four remaining deltas were properties of the
proxy and are gone (D-186):

Backend
- LogQuerySources (Shared vocabulary, exported as manifest section `logSources`) and
  LogQuerySourceCatalog: APPINSIGHTS_APP_ID → backend, APPINSIGHTS_WEB_APP_ID → web (both
  api.applicationinsights.io), MCP_LOG_ANALYTICS_WORKSPACE_ID → mcp (api.loganalytics.io).
  A missing setting makes one source answer 503 naming it, never the others.
- AppInsightsQueryFunction: injectable IHttpClientFactory (named client without timeout) and
  TokenCredential; one CancellationTokenSource per request plus `Prefer: wait=<budget>`
  upstream, 504 names the budget; typed QueryBackendLogsResponse keeps the Kusto
  tables[].columns/rows verbatim (JsonElement cells) and adds source, budgetSeconds,
  elapsedMs and `partial`/`partialReason` when the store cut the result (previously
  swallowed); on failure the full upstream error JSON rides along as `upstream`.
- TypedResponseGuard: the last SerializeBaseline entry is gone (typed request record).

MCP
- query_backend_logs: source (z.enum from the generated LOG_SOURCES), budgetSeconds, a client
  signal of budget + 15 s, rawTable result cap, description with per-source table hints.

Submodules
- .claude/commands: backend-logs / ux-insights / mcp-telemetry on `source`; query-appinsights.sh
  deleted (35 call sites rewritten); az only for the live container tail.
- infra: migration scripts carry the two new app settings and role assignments
  (Monitoring Reader on the portal component, Log Analytics Reader on the MCP workspace).
- internal: concept doc backend/log-query-proxy.md, D-186, docs and plan updated.

Tests: catalog resolution, budget clamp, Kusto parsing incl. PartialError, error extraction,
wire parity of the new response; backend 5832, MCP 683, web tsc, solution build green.
Before live use: grant the two roles and set the two app settings (see the plan file).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:50:19 +02:00
Oliver Kieselbach c38f0cf2b0 fix(agent): keep-awake releases on the user-ESP page exit, not the synthesized completion; guard change reverted (q8n)
Reverts the HasAccountSetupActivity tightening from f0a3f043: measured on 485 Classic
(SkipUser=false) sessions, 96 see a single Shell-Core 62407 before any AccountSetup
progress and 75 of them complete AccountSetup only through the user-apps-settled synthesis,
which needs that exit remembered as the edge. Windows confirmed categorySucceeded in 0/485,
so the synthesis is the load-bearing completion path; presence semantics stay and are
documented as such.

UserEspKeepAwakeHost instead gets an additive progress probe
(ProvisioningStatusTracker.HasAccountSetupProgress via EspAndHelloHost) and releases on an
EspExiting that arrives after the AccountSetup registry left notStarted — the real user-ESP
page exit (progress precedes it by >= 1 min fleet-wide; the Device->Account handoff exit
always precedes progress). AccountSetupProvisioningComplete no longer releases; desktop
arrival is deliberately not a trigger (explorer.exe ran before the final exit in all 385
two-exit sessions). Sessions with only the handoff exit hold until the safety cap. Release
reason user_esp_exited replaces account_setup_complete. D-183 rewritten.

Tests: host (release on exit+progress, handoff exit holds, completion no longer releases,
exit before engage ignored), tracker (presence stays true / progress false on the pre-written
all-notStarted JSON). Solution build 0 warnings; agent suite 2823 green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:48:40 +02:00
Oliver Kieselbach 729ee56bde security: assume-breach alarm for the Global-Admin-only surface (x29 phase 0)
The 2026-09-04 audit found no bypass of the GA boundary, but a failed attempt left
only a Warning trace without identity, and a non-GA tools/call for a GA-only MCP tool
never reached the backend at all. This makes the boundary observable without adding
a second alarm writer (D-185):

Backend
- PrivilegedRouteDenied (Security): raised from the policy middleware's deny path for
  every 403 by an authenticated caller on a GlobalAdminOnly route. Critical for a
  caller without a platform role, Warning for a Global Reader. Details carry upn, oid,
  tid, client source, MCP tool name, correlation id, reason and policy. Throttled one
  hour per caller+path per instance (PrivilegedDenialReporter, IPrivilegedDenialReporter
  seam). PolicyResult now surfaces the denied tier; the DENIED log line gains oid, tid,
  clientSource and mcpTool.
- GET global/raw/access-probe: no-op GlobalAdminOnly route whose denial IS the signal
  (AccessProbeResponse DTO, catalog entry, parity test).
- Defense in depth: AppInsightsQueryFunction and both TableQueryFunction triggers re-check
  IsGlobalAdmin in the body (RawGlobalAdminGate); an empty RequestContext fails closed.
- Dual-register: OpsEventTypes.All, OpsAlertRulesSection.tsx, regenerated manifests.

MCP
- ga-tool-probe.ts: before dispatch, a caller without the GlobalAdmin role naming a
  strictGa tool leaves an unconditional [mcp-security] line (tool, upn, scope) and fires
  the access probe with X-MCP-Tool-Name; the SDK answer stays "Tool not found".
- GA_STRICT_TOOL_NAMES exported and pinned equal to the registered GA-minus-Reader set.
- debug-session prompt names query_backend_logs/query_table only for strictGa (Readers
  were told to call tools they cannot see); tool_call_rejected lines carry scope + upn;
  config-tool comment corrected against the actual catalog tiers.

Tests: reporter (severity, details, throttle, fail-soft), middleware policy surfacing and
IsPrivilegedDenial/BuildPrivilegedDenial, Program.cs middleware order source scan, raw
gate 403, probe catalog + JWT pins, wire parity; MCP probe unit tests, Global Reader over
HTTP on both eras, runtime refusal fires the probe, prompt gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:29:06 +02:00
Oliver Kieselbach f0a3f04398 fix(agent,rules): AccountSetup activity means observed progress; same-event filter for event_data conditions (q8n)
Agent: ProvisioningStatusTracker.HasAccountSetupActivity now requires at least one
AccountSetup subcategory past notStarted. Windows writes AccountSetupCategory.Status with
every subcategory notStarted BEFORE the Device-ESP page exits, so the presence check
confirmed the Device->Account handoff (Shell-Core 62407) as the final ESP exit; the
user-apps-settled synthesis then completed AccountSetup and released the User-ESP
keep-awake minutes before the real exit (fleet: 77 % of keep-awake sessions, median 3 min;
on battery that is enough for Modern Standby). D-183.

Rule engine: filterField/filterOperator/filterValue now apply to event_data and
event_data_array as a same-event filter (shared ApplyValueFilter with event_count).
ANALYZE-DEV-012 v1.1.0 excludes backfilled pre-session episodes (backfilled equals false);
a second independent condition could not, since each condition scans all events on its
own. D-184. Schema, Shared model, MCP authoring guide and generated types follow.

Tests: ProvisioningStatusTrackerAccountSetupActivityTests (the verbatim all-notStarted
registry JSON), SleepEpisodeRuleTests (+3 backfill cases), RuleEngineEventDataFilterTests.
Solution build 0 warnings; agent suite 2820 and backend suite 5791 green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:11:28 +02:00
Oliver Kieselbach 49013c3cc4 chore: bump internal gitlink (cockpit path links)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 12:59:27 +02:00
Oliver Kieselbach 32cbc3620e chore: bump internal gitlink (cockpit reader view)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 12:53:30 +02:00
Oliver Kieselbach ae9c1b0744 chore: bump internal gitlink (cockpit edit)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 12:42:17 +02:00
83 changed files with 3670 additions and 296 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Agent Directives
- **Plan first.** Enter plan mode for any non-trivial task (3+ steps or an architectural decision) and write the spec before building. If something goes sideways, stop and re-plan instead of pushing on.
- **Plan first.** Enter plan mode for any non-trivial task (3+ steps or an architectural decision) and write the spec before building. The plan file is `internal/work/plans/<slug>.md` from the first draft; the harness plan folder outside the repo is never the storage place, at most a copy for the approval dialog. If something goes sideways, stop and re-plan instead of pushing on.
- **Use subagents liberally** for research, exploration, and parallel analysis — one focus each — to keep the main context clean.
- **Verify before claiming done.** Run the tests, check the logs, show the evidence. Diff against `main` where behaviour could regress.
- **Fix bugs autonomously.** Given a report, a failing test, or an error, resolve it — no hand-holding.
+36 -7
View File
@@ -27,6 +27,10 @@ on:
required: false
type: boolean
default: false
release_notes:
description: 'Customer-facing notes for the GitHub Release (Markdown bullets). Empty = generic fallback linking to the docs changelog'
required: false
type: string
permissions:
contents: write
@@ -326,7 +330,13 @@ jobs:
# First-party `gh` instead of a third-party action: this job holds contents:write,
# id-token:write and attestations:write, so no code we do not control runs here.
# --generate-notes appends the auto-generated notes below the body.
# The "What's new" section comes ONLY from the release_notes input -- CI never
# composes customer-facing text from commits. An empty input writes a generic
# fallback that links to the docs changelog and carries the HTML-comment marker
# below; the operator tooling scans release bodies for that marker to find the
# last release with real notes, so the next noted release covers the gap.
# Never rename the marker. --generate-notes is not used: it lists pull requests
# only (Dependabot noise); the compare link to the previous tag replaces it.
- name: Create GitHub Release
if: inputs.create_release
shell: pwsh
@@ -336,9 +346,8 @@ jobs:
RELEASE_NAME: Autopilot Monitor Agent ${{ steps.version.outputs.version }}
RELEASE_PRERELEASE: ${{ inputs.prerelease }}
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
RELEASE_BODY: |
## Autopilot Monitor Agent ${{ steps.version.outputs.version }}
RELEASE_NOTES: ${{ inputs.release_notes }}
RELEASE_BODY_TAIL: |
### Installation
1. Download `AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip`
2. Extract to `C:\ProgramData\AutopilotMonitor\Agent\`
@@ -363,8 +372,29 @@ jobs:
```
run: |
$ErrorActionPreference = 'Stop'
$notes = "$env:RELEASE_NOTES".Trim()
if ($notes -eq '') {
$notes = "Minor enhancements and bug fixes. See the [Agent Changelog](https://docs.autopilotmonitor.com/changelog/agent-changelog) for details.`n<!-- release-notes: none -->"
}
# Previous release for the compare link. Fail-soft: the first release has none,
# and a listing hiccup must never fail a release.
$prevTag = ''
$eap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
$prevTag = "$(gh release list --repo $env:GITHUB_REPOSITORY --limit 1 --exclude-drafts --json tagName -q '.[0].tagName' 2>$null)".Trim()
} catch { $prevTag = '' }
$ErrorActionPreference = $eap
$compare = ''
if ($prevTag -ne '' -and $prevTag -ne $env:RELEASE_TAG) {
$compare = "`n`n**Full changelog:** https://github.com/$env:GITHUB_REPOSITORY/compare/$prevTag...$env:RELEASE_TAG`n"
}
$body = "## $env:RELEASE_NAME`n`n### What's new`n$notes`n`n" + "$env:RELEASE_BODY_TAIL".TrimEnd() + $compare
$notesFile = Join-Path $env:RUNNER_TEMP 'release-notes.md'
Set-Content -Path $notesFile -Value $env:RELEASE_BODY -Encoding utf8 -NoNewline
Set-Content -Path $notesFile -Value $body -Encoding utf8 -NoNewline
$ghArgs = @(
'release', 'create', $env:RELEASE_TAG,
@@ -372,8 +402,7 @@ jobs:
'--repo', $env:GITHUB_REPOSITORY,
'--target', $env:GITHUB_SHA,
'--title', $env:RELEASE_NAME,
'--notes-file', $notesFile,
'--generate-notes'
'--notes-file', $notesFile
)
if ($env:RELEASE_PRERELEASE -eq 'true') { $ghArgs += '--prerelease' }
+1 -1
Submodule infra updated: 3bab9bbf83...2c2687b6ab
+5 -2
View File
@@ -2,10 +2,10 @@
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-DEV-012",
"title": "Device Entered Standby During Enrollment",
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log — pre-agent episodes are backfilled). Uses a single required condition on the episode's durationSeconds so the match is pinned to one event instance and the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log). The watcher also backfills pre-agent episodes from the last 24 h for timeline context (backfilled=true); those ended before the enrollment started and are excluded via the same-event filter backfilled equals false — a separate condition could not do that, because each condition scans all events on its own and a pre-session hibernate would still satisfy the duration test. A single required condition keeps the match pinned to one event instance so the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
"severity": "info",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -23,6 +23,9 @@
"dataField": "durationSeconds",
"operator": "gte",
"value": "300",
"filterField": "backfilled",
"filterOperator": "equals",
"filterValue": "false",
"required": true
}
],
+5 -2
View File
@@ -1772,10 +1772,10 @@
{
"ruleId": "ANALYZE-DEV-012",
"title": "Device Entered Standby During Enrollment",
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log — pre-agent episodes are backfilled). Uses a single required condition on the episode's durationSeconds so the match is pinned to one event instance and the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log). The watcher also backfills pre-agent episodes from the last 24 h for timeline context (backfilled=true); those ended before the enrollment started and are excluded via the same-event filter backfilled equals false — a separate condition could not do that, because each condition scans all events on its own and a pre-session hibernate would still satisfy the duration test. A single required condition keeps the match pinned to one event instance so the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
"severity": "info",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -1793,6 +1793,9 @@
"dataField": "durationSeconds",
"operator": "gte",
"value": "300",
"filterField": "backfilled",
"filterOperator": "equals",
"filterValue": "false",
"required": true
}
],
+3 -3
View File
@@ -204,16 +204,16 @@
},
"filterField": {
"type": "string",
"description": "For event_count only: optional value filter — only events whose filterField satisfies filterOperator/filterValue are counted (e.g. count only performance_snapshot events with memory_used_percent > 90). Applies before counting for both count_gte and count_per_group_gte."
"description": "Optional same-event value filter for event_count, event_data and event_data_array: only events whose filterField satisfies filterOperator/filterValue are considered. For event_count it applies before counting (both count_gte and count_per_group_gte, e.g. count only performance_snapshot events with memory_used_percent > 90). For event_data/event_data_array the filter and the main dataField/operator test are evaluated on the SAME event instance — the only way to pin two fields of one event together, since separate conditions each scan all events independently (e.g. system_sleep_episode with durationSeconds gte 300 AND backfilled equals false)."
},
"filterOperator": {
"type": "string",
"description": "Operator for the event_count value filter.",
"description": "Operator for the same-event value filter. Same vocabulary as operator.",
"enum": ["equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", "in", "not_in"]
},
"filterValue": {
"type": "string",
"description": "Value for the event_count value filter."
"description": "Value for the same-event value filter. Boolean values are stringified (\"true\"/\"false\")."
},
"correlateEventType": {
"type": "string",
@@ -0,0 +1,142 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>
/// Backlog q8n — pins the two AccountSetup probes side by side.
/// <para>
/// <see cref="ProvisioningStatusTracker.HasAccountSetupActivity"/> keeps PRESENCE semantics:
/// Windows writes <c>AccountSetupCategory.Status</c> with every subcategory <c>notStarted</c>
/// before the Device-ESP page exits, and about one in five Classic sessions sees only that
/// single Shell-Core 62407 for the whole enrollment — those resolve AccountSetup solely via
/// the user-apps-settled synthesis, which needs the exit remembered as the edge. Tightening
/// this property strands them (verified on 485 SkipUser=false sessions, 2026-09-01..04).
/// </para>
/// <para>
/// <see cref="ProvisioningStatusTracker.HasAccountSetupProgress"/> is the additive PROGRESS
/// probe for the User-ESP keep-awake host: true only once a subcategory left
/// <c>notStarted</c>, so the host can tell the real user-ESP page exit (session a2256107:
/// 09:47:12) from the Device→Account handoff exit (09:37:53) that released the hold early.
/// </para>
/// </summary>
public sealed class ProvisioningStatusTrackerAccountSetupActivityTests
{
private static readonly DateTime Fixed = new DateTime(2026, 9, 4, 9, 37, 42, DateTimeKind.Utc);
// Verbatim shape of the pre-written registry JSON seen at first_seen on 25H2/26200.
private const string AllNotStartedJson = @"{
""categoryState"":""notStarted"",
""AccountSetup.WaitingForAadRegistrationSubcategory"":{""subcategoryState"":""notStarted""},
""AccountSetup.PrepareMultifactorAuth"":{""subcategoryState"":""notStarted""},
""AccountSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""notStarted""},
""AccountSetup.CertificatesSubcategory"":{""subcategoryState"":""notStarted""},
""AccountSetup.NetworkConnectionsSubcategory"":{""subcategoryState"":""notStarted""},
""AccountSetup.AppsSubcategory"":{""subcategoryState"":""notStarted""},
""AccountSetup.SendResultsToMdmServer"":{""subcategoryState"":""notStarted""}
}";
private const string FirstProgressJson = @"{
""categoryState"":""inProgress"",
""AccountSetup.WaitingForAadRegistrationSubcategory"":{""subcategoryState"":""succeeded"",""subcategoryStatusText"":""succeeded""},
""AccountSetup.PrepareMultifactorAuth"":{""subcategoryState"":""notStarted""},
""AccountSetup.SecurityPoliciesSubcategory"":{""subcategoryState"":""notStarted""},
""AccountSetup.CertificatesSubcategory"":{""subcategoryState"":""notStarted""},
""AccountSetup.NetworkConnectionsSubcategory"":{""subcategoryState"":""notStarted""},
""AccountSetup.AppsSubcategory"":{""subcategoryState"":""notStarted""},
""AccountSetup.SendResultsToMdmServer"":{""subcategoryState"":""notStarted""}
}";
private sealed class Fixture : IDisposable
{
public TempDirectory Tmp { get; } = new TempDirectory();
public FakeSignalIngressSink Sink { get; } = new FakeSignalIngressSink();
public ProvisioningStatusTracker Tracker { get; }
public Fixture()
{
var clock = new VirtualClock(Fixed);
var post = new InformationalEventPost(Sink, clock);
var logger = new AgentLogger(Tmp.Path, AgentLogLevel.Info);
Tracker = new ProvisioningStatusTracker(
sessionId: "S1",
tenantId: "T1",
post: post,
logger: logger);
}
public void Dispose()
{
Tracker.Dispose();
Tmp.Dispose();
}
}
[Fact]
public void NothingTracked_NeitherActivityNorProgress()
{
using var f = new Fixture();
Assert.False(f.Tracker.HasAccountSetupActivity);
Assert.False(f.Tracker.HasAccountSetupProgress);
}
[Fact]
public void PreWrittenAllNotStartedJson_IsActivity_ButNotProgress()
{
// The registry JSON exists (7 subcategories) but nothing has started — the state at
// the intermediate Device-ESP exit. Activity (presence) must stay true so the
// completion guards keep remembering single-exit sessions; progress must be false so
// the keep-awake host does not treat this exit as the user-ESP page exit.
using var f = new Fixture();
f.Tracker.ProcessCategoryStatusForTest("AccountSetupCategory.Status", AllNotStartedJson);
Assert.True(f.Tracker.HasAccountSetupActivity);
Assert.False(f.Tracker.HasAccountSetupProgress);
}
[Fact]
public void FirstSubcategoryLeavingNotStarted_IsProgress()
{
using var f = new Fixture();
f.Tracker.ProcessCategoryStatusForTest("AccountSetupCategory.Status", AllNotStartedJson);
f.Tracker.ProcessCategoryStatusForTest("AccountSetupCategory.Status", FirstProgressJson);
Assert.True(f.Tracker.HasAccountSetupActivity);
Assert.True(f.Tracker.HasAccountSetupProgress);
}
[Theory]
[InlineData("inProgress")]
[InlineData("in_progress")]
[InlineData("failed")]
[InlineData("notRequired")]
public void AnyNonNotStartedState_IsProgress(string state)
{
using var f = new Fixture();
f.Tracker.ProcessCategoryStatusForTest("AccountSetupCategory.Status",
"{\"categoryState\":\"inProgress\"," +
"\"AccountSetup.AppsSubcategory\":{\"subcategoryState\":\"" + state + "\"}," +
"\"AccountSetup.CertificatesSubcategory\":{\"subcategoryState\":\"notStarted\"}}");
Assert.True(f.Tracker.HasAccountSetupProgress);
}
[Fact]
public void DeviceSetupProgress_DoesNotCountAsAccountSetupProgress()
{
// Adversarial: a fully progressed DeviceSetup category must not leak into the
// AccountSetup answer.
using var f = new Fixture();
f.Tracker.ProcessCategoryStatusForTest("DeviceSetupCategory.Status",
"{\"categoryState\":\"succeeded\",\"DeviceSetup.AppsSubcategory\":{\"subcategoryState\":\"succeeded\"}}");
f.Tracker.ProcessCategoryStatusForTest("AccountSetupCategory.Status", AllNotStartedJson);
Assert.False(f.Tracker.HasAccountSetupProgress);
}
}
}
@@ -114,14 +114,80 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
ing.Stop();
}
private static void PostEspExiting(SignalIngress ing) =>
ing.Post(DecisionSignalKind.EspExiting, At, "EspAndHelloTracker", Raw("exit"));
[Fact]
public void Releases_on_AccountSetupProvisioningComplete()
public void Releases_on_EspExiting_once_AccountSetup_shows_progress()
{
// q8n: the real user-ESP page exit is a 62407 that arrives after the AccountSetup
// registry left notStarted (session a2256107: 09:47:12, progress since 09:38:25).
var clock = new VirtualClock(At);
using var ing = BuildIngress(clock);
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
var progress = false;
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller,
espTimeoutProvider: () => null, accountSetupProgressProbe: () => Volatile.Read(ref progress));
ing.Start();
host.Start();
PostAccountSetupPhase(ing);
Assert.True(WaitFor(() => api.PreventCount == 1));
Volatile.Write(ref progress, true);
PostEspExiting(ing);
Assert.True(WaitFor(() => api.AllowCount == 1), "AllowSleep should be called once on the user-ESP page exit");
Assert.False(controller.IsEngaged);
host.Dispose();
ing.Stop();
}
[Fact]
public void Handoff_exit_before_AccountSetup_progress_keeps_the_hold()
{
// The Device→Account handoff 62407 (a2256107: 09:37:53) precedes any AccountSetup
// progress — Windows pre-writes the AccountSetup JSON all-notStarted — and must not
// release. The later exit with progress does.
var clock = new VirtualClock(At);
using var ing = BuildIngress(clock);
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var progress = false;
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller,
espTimeoutProvider: () => null, accountSetupProgressProbe: () => Volatile.Read(ref progress));
ing.Start();
host.Start();
PostAccountSetupPhase(ing);
Assert.True(WaitFor(() => api.PreventCount == 1));
PostEspExiting(ing); // handoff exit, no progress yet
Assert.False(WaitFor(() => api.AllowCount > 0, 500), "the handoff exit must not release the hold");
Assert.True(controller.IsEngaged);
Volatile.Write(ref progress, true);
PostEspExiting(ing); // the real user-ESP page exit
Assert.True(WaitFor(() => api.AllowCount == 1));
Assert.False(controller.IsEngaged);
host.Dispose();
ing.Stop();
}
[Fact]
public void AccountSetupProvisioningComplete_no_longer_releases()
{
// The synthesized completion fired a median 3 min before the page closed in 77 % of
// keep-awake sessions (q8n) — it is no longer a release trigger; the cap/stop/exit are.
var clock = new VirtualClock(At);
using var ing = BuildIngress(clock);
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller,
espTimeoutProvider: () => null, accountSetupProgressProbe: () => true);
ing.Start();
host.Start();
@@ -130,8 +196,34 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
ing.Post(DecisionSignalKind.AccountSetupProvisioningComplete, At, "ProvisioningStatusTracker", Raw("done"));
Assert.True(WaitFor(() => api.AllowCount == 1), "AllowSleep should be called once on AccountSetup completion");
Assert.False(controller.IsEngaged);
Assert.False(WaitFor(() => api.AllowCount > 0, 500), "completion must not release the hold any more");
Assert.True(controller.IsEngaged);
host.Dispose();
ing.Stop();
}
[Fact]
public void EspExiting_before_engage_is_ignored()
{
// Exit signals while idle (e.g. a Device-ESP exit before AccountSetup) neither release
// nor poison the later engage.
var clock = new VirtualClock(At);
using var ing = BuildIngress(clock);
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller,
espTimeoutProvider: () => null, accountSetupProgressProbe: () => true);
ing.Start();
host.Start();
PostEspExiting(ing);
Assert.False(WaitFor(() => api.AllowCount > 0, 300));
PostAccountSetupPhase(ing);
Assert.True(WaitFor(() => api.PreventCount == 1));
Assert.True(controller.IsEngaged);
Assert.Equal(0, api.AllowCount);
host.Dispose();
ing.Stop();
@@ -202,9 +202,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// <summary>
/// True when any AccountSetup subcategory has been tracked (resolved or in progress).
/// Presence semantics — see <see cref="ProvisioningStatusTracker.HasAccountSetupActivity"/>
/// for why this must stay that way.
/// </summary>
public bool HasAccountSetupActivity => _provisioningTracker?.HasAccountSetupActivity ?? false;
/// <summary>
/// True once an AccountSetup subcategory has left <c>notStarted</c> — see
/// <see cref="ProvisioningStatusTracker.HasAccountSetupProgress"/>. Keep-awake input only.
/// </summary>
public bool HasAccountSetupProgress => _provisioningTracker?.HasAccountSetupProgress ?? false;
/// <summary>
/// True once WhiteGlove start has been detected (EventID 509 or persisted from prior run).
/// Forwarded from ModernDeploymentTracker.
@@ -209,6 +209,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
/// <summary>
/// True when any AccountSetup subcategory has been tracked (resolved or in progress).
/// <para>
/// PRESENCE semantics, deliberately (backlog q8n, 2026-09-04): Windows writes
/// <c>AccountSetupCategory.Status</c> with every subcategory <c>notStarted</c> before the
/// Device-ESP page exits, so this is true at the Device→Account handoff 62407 already.
/// That is load-bearing for completion: about one in five Classic sessions sees only ONE
/// Shell-Core 62407 for the whole enrollment (no separate user-ESP page exit), and those
/// sessions resolve AccountSetup solely through the user-apps-settled synthesis, which
/// needs that single exit to be remembered as the edge. Tightening this to "progress"
/// would strand them. Callers that need "the user ESP page has demonstrably started"
/// use <see cref="HasAccountSetupProgress"/> instead.
/// </para>
/// </summary>
public bool HasAccountSetupActivity
{
@@ -225,6 +236,36 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
}
}
/// <summary>
/// True once at least one AccountSetup subcategory has left <c>notStarted</c> — the user
/// ESP page (or its background tracking) has demonstrably started working. Unlike
/// <see cref="HasAccountSetupActivity"/> this is NOT satisfied by the pre-written
/// all-<c>notStarted</c> JSON that precedes the Device-ESP exit (session a2256107: JSON at
/// 09:37:42, intermediate 62407 at 09:37:53, first progress at 09:38:25; fleet: progress
/// precedes the real user-ESP exit by at least ~1 min). Used by the User-ESP keep-awake
/// host to tell the real user-ESP page exit from the Device→Account handoff exit; not a
/// completion input.
/// </summary>
public bool HasAccountSetupProgress
{
get
{
lock (_stateLock)
{
if (_lastSubcategoryStates == null)
return false;
if (!_lastSubcategoryStates.TryGetValue("AccountSetupCategory.Status", out var subs) || subs == null)
return false;
foreach (var state in subs.Values)
{
if (!string.Equals(state, "notStarted", StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
}
}
/// <summary>
/// True when DeviceSetup registry JSON contains a SaveWhiteGloveSuccessResult property
/// with subcategoryState=succeeded. NOT a WhiteGlove indicator: on Win11 25H2 (build
@@ -372,7 +372,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
tenantId: tenantId,
ingress: ingress,
clock: clock,
logger: logger));
logger: logger,
// q8n: release on the user-ESP page exit, i.e. a Shell-Core 62407 that arrives
// after the AccountSetup registry left notStarted — never on the handoff exit.
accountSetupProgressProbe: () => espAndHelloHost.HasAccountSetupProgress));
}
// OOBE-console / Shift+F10 detection — opt-OUT per tenant (portal toggle →
@@ -55,6 +55,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// </summary>
public System.Collections.Generic.IReadOnlyCollection<string> StarvedAppsReported => _tracker.StarvedAppsReported;
/// <summary>
/// True once an AccountSetup subcategory has left <c>notStarted</c> — the probe the
/// User-ESP keep-awake host uses to tell the real user-ESP page exit from the
/// Device→Account handoff exit (see <see cref="EspAndHelloTracker.HasAccountSetupProgress"/>).
/// </summary>
public bool HasAccountSetupProgress => _tracker.HasAccountSetupProgress;
/// <summary>L6 — atomic claim for the termination sweep (see EspAndHelloTracker.TryClaimStarvedAppReport).</summary>
public bool TryClaimStarvedAppReport(string appId) => _tracker.TryClaimStarvedAppReport(appId);
@@ -22,15 +22,31 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// hook <c>GatherRuleExecutorHost</c> uses for phase_change triggers):
/// <list type="bullet">
/// <item><b>Engage</b> when a signal reports the ESP phase as <c>AccountSetup</c>.</item>
/// <item><b>Release</b> on <see cref="DecisionSignalKind.AccountSetupProvisioningComplete"/>
/// (User-ESP done), on <see cref="Stop"/> (session teardown), or on the safety-cap timer
/// (a backstop in case the completion signal is missed, so a device is never held awake
/// for the agent's full max-lifetime).</item>
/// <item><b>Release</b> on <see cref="DecisionSignalKind.EspExiting"/> once the AccountSetup
/// registry shows progress (the real user-ESP page exit — see below), on
/// <see cref="Stop"/> (session teardown), or on the safety-cap timer (a backstop so a
/// device is never held awake for the agent's full max-lifetime).</item>
/// </list>
/// The hold itself is owned by <see cref="KeepAwakeController"/>; the OS also auto-clears it
/// on process exit / reboot, so it can never leak.
/// </para>
/// <para>
/// <b>Why the ESP exit and not <see cref="DecisionSignalKind.AccountSetupProvisioningComplete"/></b>
/// (backlog q8n, 2026-09-04): that completion signal is, fleet-wide, almost always the
/// user-apps-settled synthesis (Windows no longer writes <c>categorySucceeded</c>), and the
/// synthesis builds on the Device→Account handoff 62407 — so it fired a median 3 min before
/// the user-ESP page actually closed in 77 % of keep-awake sessions, and a device on battery
/// dropped into Modern Standby with the page still up (session a2256107). The page itself is
/// the truth: a Shell-Core 62407 that arrives after the AccountSetup registry left
/// <c>notStarted</c> is the user-ESP page exit (progress precedes it by ≥ 1 min across the
/// fleet), whereas the handoff exit always precedes any progress. Desktop arrival is NOT a
/// substitute — explorer.exe already runs under the real user while the page is still up.
/// Sessions that only ever see the handoff exit (about one in five) keep the hold until the
/// safety cap; that is the advertised behaviour ("awake during User-ESP") erring long, never
/// early. The completion guards in <c>EspAndHelloTracker</c> deliberately stay on presence
/// semantics — see <c>ProvisioningStatusTracker.HasAccountSetupActivity</c>.
/// </para>
/// <para>
/// <b>Safety-cap sizing</b>: rather than a fixed product assumption, the cap is derived at
/// engage time from the tenant's own ESP policy — the Intune
/// <c>SyncFailureTimeout</c> ("show error when installation takes longer than X minutes",
@@ -62,8 +78,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
/// </summary>
internal const int EspCapMarginMinutes = 30;
// Release reasons (carried on the keep_awake_released event payload).
internal const string ReasonAccountSetupComplete = "account_setup_complete";
// Release reasons (carried on the keep_awake_released event payload). "account_setup_complete"
// (release on the synthesized completion signal) was retired with q8n — see the class doc.
internal const string ReasonUserEspExited = "user_esp_exited";
internal const string ReasonHostStop = "host_stop";
internal const string ReasonSafetyCap = "safety_cap";
@@ -83,6 +100,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
private readonly int _fallbackCapMinutes;
private readonly TimeSpan? _safetyCapOverride;
private readonly Func<int?> _espTimeoutProvider;
private readonly Func<bool> _accountSetupProgressProbe;
private enum HoldState { Idle, Engaging, Engaged, Released }
@@ -102,7 +120,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
int fallbackCapMinutes = DefaultFallbackCapMinutes,
KeepAwakeController? controller = null,
TimeSpan? safetyCapOverride = null,
Func<int?>? espTimeoutProvider = null)
Func<int?>? espTimeoutProvider = null,
Func<bool>? accountSetupProgressProbe = null)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
@@ -118,6 +137,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// The ESP SyncFailureTimeout is read at engage time (the ESP policy is present by then).
_espTimeoutProvider = espTimeoutProvider
?? (() => EspSkipConfigurationProbe.ReadFull(_logger).SyncFailureTimeoutMinutes);
// Without a probe no exit can be told apart from the handoff exit — the hold then
// lasts until the safety cap (erring long, never early). Production always wires the
// EspAndHelloHost probe (DefaultComponentFactory).
_accountSetupProgressProbe = accountSetupProgressProbe ?? (() => false);
}
/// <summary>
@@ -163,9 +186,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// (which briefly waits on the keep-awake thread) is dispatched to the ThreadPool.
try
{
if (kind == DecisionSignalKind.AccountSetupProvisioningComplete)
if (kind == DecisionSignalKind.EspExiting)
{
ThreadPool.QueueUserWorkItem(_ => SafeRelease(ReasonAccountSetupComplete, EventSeverity.Info));
// Only the user-ESP page exit releases: the Device→Account handoff 62407 arrives
// before any AccountSetup subcategory has left notStarted, the real exit after.
// Cheap to skip while idle; the probe is a locked dictionary scan.
if (IsEngagedOrEngaging() && ProbeAccountSetupProgress())
ThreadPool.QueueUserWorkItem(_ => SafeRelease(ReasonUserEspExited, EventSeverity.Info));
return;
}
@@ -188,6 +215,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
catch (Exception ex) { _logger.Error("UserEspKeepAwakeHost: engage failed.", ex); }
}
private bool IsEngagedOrEngaging()
{
lock (_gate) { return _state == HoldState.Engaging || _state == HoldState.Engaged; }
}
private bool ProbeAccountSetupProgress()
{
try { return _accountSetupProgressProbe(); }
catch (Exception ex)
{
_logger.Debug($"UserEspKeepAwakeHost: AccountSetup progress probe threw: {ex.Message}");
return false;
}
}
private void SafeRelease(string reason, EventSeverity severity)
{
try { Release(reason, severity, emit: true); }
@@ -80,7 +80,8 @@ public class AdminIdentityBindingAuthorizationTests
{
Middleware = new PolicyEnforcementMiddleware(
NullLogger<PolicyEnforcementMiddleware>.Instance, globalAdmin, delegatedAdmin,
new TenantMemberRoleResolver(tenantAdmins, tenantConfig), tenantConfig),
new TenantMemberRoleResolver(tenantAdmins, tenantConfig), tenantConfig,
new RecordingDenialReporter()),
Repo = repo,
};
}
@@ -1,3 +1,5 @@
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Raw;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using Xunit;
@@ -6,26 +8,13 @@ namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for the <c>POST /api/global/raw/logs</c> KQL-proxy endpoint served by
/// <see cref="AutopilotMonitor.Functions.Functions.Raw.AppInsightsQueryFunction"/> — an operator tool
/// that forwards an operator-supplied KQL query VERBATIM to the Application Insights REST API. Because
/// the query is passed through un-sandboxed and the App Insights workspace can surface secret-bearing
/// backend traces, the TRUST BOUNDARY for this endpoint is its authorization gate: only a platform
/// Global Admin may call it. These tests pin that gate.
///
/// SCOPE / WHAT IS AND IS NOT COVERED HERE (deliberate):
/// The function's own in-request behavior — appId-not-configured → 503, empty/whitespace query →
/// 400 BadRequest, App-Insights-error → 502 BadGateway structured error (innererror message + code +
/// SyntaxError/SemanticError hint), happy path → 200 with the raw AI tables JSON — is NOT exercised
/// here. The entrypoint <c>Run</c> sits directly on the abstract <c>HttpRequestData</c> /
/// <c>HttpResponseData</c> pair (whose <c>HttpResponseData.Cookies</c> returns the abstract
/// <c>HttpCookies</c>), reads the body via <c>ReadFromJsonAsync</c> and writes via
/// <c>CreateResponse</c>/<c>WriteAsJsonAsync</c> (all of which need a live worker serializer resolved
/// from <c>FunctionContext.InstanceServices</c>), and calls Application Insights through a PRIVATE
/// STATIC <c>HttpClient</c> + <c>DefaultAzureCredential</c> with no injection seam. Faking that whole
/// pipeline + a live Managed Identity is "more setup than the test is worth" — the exact rationale the
/// sibling function tests cite (see DeviceBlockFunctionTests / GetAllBlockedDevicesFunctionTests,
/// which likewise test the reachable seam instead of the HTTP entrypoint). The authorization gate
/// below IS the security-relevant, compilable seam for this endpoint.
/// <see cref="AppInsightsQueryFunction"/> — an operator tool that forwards an operator-supplied KQL
/// query VERBATIM to one of three telemetry stores (backend / web App Insights, MCP Log Analytics).
/// Because the query is passed through un-sandboxed and the stores surface secret-bearing traces and
/// portal user telemetry, the TRUST BOUNDARY for this endpoint is its authorization gate: only a
/// platform Global Admin may call it. These tests pin that gate, plus the pure pieces of the proxy
/// (budget clamp, Kusto body parsing incl. partial results, error extraction). The HTTP entrypoint
/// itself (worker serializer, managed identity) is not exercised — the seams are.
///
/// The gate is asserted through <see cref="EndpointAccessPolicyCatalog.FindPolicy"/> (the single source
/// of truth the running middleware consults) and <see cref="AuthenticationMiddleware.SkipsJwtValidation"/>.
@@ -34,6 +23,88 @@ public class AppInsightsQueryFunctionTests
{
private const string LogsRoute = "/api/global/raw/logs";
// ── Budget: null/0 → default, otherwise clamped into [5, 180] ──
[Theory]
[InlineData(null, 30)]
[InlineData(0, 30)]
[InlineData(-7, 30)]
[InlineData(1, 5)]
[InlineData(30, 30)]
[InlineData(120, 120)]
[InlineData(180, 180)]
[InlineData(600, 180)]
public void Budget_is_defaulted_and_clamped(int? requested, int expected)
{
Assert.Equal(expected, AppInsightsQueryFunction.ClampBudget(requested));
}
// ── Kusto body → typed tables; cells forwarded as the JSON the store produced ──
[Fact]
public void Kusto_body_is_parsed_cell_for_cell_and_complete_results_carry_no_partial()
{
const string body = """
{"tables":[{"name":"PrimaryResult","columns":[{"name":"timestamp","type":"datetime"},{"name":"count_","type":"long"},{"name":"customDimensions","type":"dynamic"},{"name":"note","type":"string"}],
"rows":[["2026-09-04T10:00:00Z",42,"{\"TenantId\":\"t1\"}",null]]}]}
""";
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody(body);
Assert.Null(partial);
var table = Assert.Single(tables);
Assert.Equal("PrimaryResult", table.Name);
Assert.Equal(new[] { "timestamp", "count_", "customDimensions", "note" }, table.Columns.Select(c => c.Name));
Assert.Equal(new[] { "datetime", "long", "dynamic", "string" }, table.Columns.Select(c => c.Type));
var row = Assert.Single(table.Rows);
Assert.Equal(JsonValueKind.String, row[0].ValueKind);
Assert.Equal(42, row[1].GetInt64());
// customDimensions stays the JSON STRING App Insights emits — not re-parsed, not re-encoded.
Assert.Equal(JsonValueKind.String, row[2].ValueKind);
Assert.Equal("{\"TenantId\":\"t1\"}", row[2].GetString());
Assert.Equal(JsonValueKind.Null, row[3].ValueKind);
// Cloned cells outlive the parsed document.
Assert.Equal("42", row[1].GetRawText());
}
[Fact]
public void A_200_with_a_PartialError_next_to_tables_is_surfaced_not_swallowed()
{
const string body = """
{"tables":[{"name":"PrimaryResult","columns":[{"name":"n","type":"long"}],"rows":[[1]]}],
"error":{"code":"PartialError","message":"There were some errors when processing your query.","innererror":{"code":"E_QUERY_RESULT_SET_TOO_LARGE","message":"Query result set has exceeded the internal record count limit 500000."}}}
""";
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody(body);
Assert.Single(tables);
Assert.Equal("E_QUERY_RESULT_SET_TOO_LARGE: Query result set has exceeded the internal record count limit 500000.", partial);
}
[Fact]
public void Empty_or_shapeless_body_yields_no_tables_and_no_partial()
{
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody("{}");
Assert.Empty(tables);
Assert.Null(partial);
}
// ── Error extraction: innererror first, then outer; tolerant of non-JSON ──
[Fact]
public void Error_extraction_prefers_innererror_and_tolerates_non_json()
{
var (msg, code) = AppInsightsQueryFunction.ExtractError(
"""{"error":{"message":"The request had some invalid properties","code":"BadArgumentError","innererror":{"code":"SyntaxError","message":"Query could not be parsed at 'foo'"}}}""");
Assert.Equal("SyntaxError", code);
Assert.Equal("Query could not be parsed at 'foo'", msg);
var (outerMsg, outerCode) = AppInsightsQueryFunction.ExtractError("""{"error":{"message":"Forbidden","code":"InsufficientAccessError"}}""");
Assert.Equal("InsufficientAccessError", outerCode);
Assert.Equal("Forbidden", outerMsg);
var (plainMsg, plainCode) = AppInsightsQueryFunction.ExtractError("<html>502 Bad Gateway</html>");
Assert.Null(plainCode);
Assert.Equal("Telemetry store query failed", plainMsg);
}
// ── Authorization gate: the KQL proxy is Global-Admin-only ──
[Fact]
public void RawLogs_post_is_registered_as_GlobalAdminOnly()
@@ -51,6 +122,8 @@ public class AppInsightsQueryFunctionTests
[InlineData("POST", "/api/global/raw/logs")]
[InlineData("GET", "/api/global/raw/tables")]
[InlineData("GET", "/api/global/raw/tables/TenantConfiguration")]
// The MCP's assume-breach probe: its whole value is that a non-GA caller is DENIED here.
[InlineData("GET", "/api/global/raw/access-probe")]
public void Raw_family_endpoints_are_GlobalAdminOnly_not_global_reader(string method, string path)
{
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
@@ -70,6 +143,14 @@ public class AppInsightsQueryFunctionTests
Assert.False(AuthenticationMiddleware.SkipsJwtValidation("POST", LogsRoute));
}
// ── The access probe must sit behind the SAME JWT gate: an anonymous hit is a 401 (no probe
// event), only an authenticated non-GA caller reaches the 403 that raises PrivilegedRouteDenied. ──
[Fact]
public void AccessProbe_requires_jwt_and_is_not_exempt()
{
Assert.False(AuthenticationMiddleware.SkipsJwtValidation("GET", "/api/global/raw/access-probe"));
}
// ── Verb binding: the KQL proxy is POST-only. A GET to the same path is unregistered and therefore
// fail-closed (FindPolicy == null → the middleware denies). Guards against a future GET alias that
// would carry the KQL in the query string (and into logs/URLs). ──
@@ -40,6 +40,7 @@ public class AuthenticationMiddlewareTests
[InlineData("GET", "/api/bootstrap/validate/ABC123")]
[InlineData("POST", "/api/agent/distress")]
[InlineData("GET", "/api/diagnostics/download")]
[InlineData("GET", "/api/global/session-reports/download")]
// DeviceOrBootstrapAuth
[InlineData("POST", "/api/agent/register-session")]
[InlineData("POST", "/api/agent/telemetry")]
@@ -31,6 +31,37 @@ public class DiagnosticsDownloadTicketTests
Assert.Equal("CustomerSas", dst);
}
// ── Purpose separation: a ticket for one download surface never opens another ──
[Fact]
public void Diagnostics_ticket_is_rejected_on_the_report_surface_and_vice_versa()
{
var diag = DiagnosticsDownloadTicket.Encode(TenantA, BlobA, "Hosted");
var report = DiagnosticsDownloadTicket.Encode(TenantA, "t_s_diag_request_20260904_120000.zip", "SessionReports",
purpose: DiagnosticsDownloadTicket.SessionReportPurpose);
// Each decodes on its own surface …
Assert.True(DiagnosticsDownloadTicket.TryDecode(diag, out _, out _, out _, out _));
Assert.True(DiagnosticsDownloadTicket.TryDecode(report, out _, out var reportBlob, out var reportDst, out _,
purpose: DiagnosticsDownloadTicket.SessionReportPurpose));
Assert.Equal("t_s_diag_request_20260904_120000.zip", reportBlob);
Assert.Equal("SessionReports", reportDst);
// … and is refused on the other, with the signature intact (purpose is signed, not filtered).
Assert.False(DiagnosticsDownloadTicket.TryDecode(diag, out _, out _, out _, out var r1,
purpose: DiagnosticsDownloadTicket.SessionReportPurpose));
Assert.Equal("wrong_purpose", r1);
Assert.False(DiagnosticsDownloadTicket.TryDecode(report, out _, out _, out _, out var r2));
Assert.Equal("wrong_purpose", r2);
}
[Fact]
public void Default_purpose_is_diagnostics()
{
var token = DiagnosticsDownloadTicket.Encode(TenantA, BlobA, "Hosted");
Assert.True(DiagnosticsDownloadTicket.TryDecode(token, out _, out _, out _, out _,
purpose: DiagnosticsDownloadTicket.DiagnosticsPurpose));
}
[Fact]
public void RoundTrip_HostedBlobName_Preserved()
{
@@ -366,6 +366,8 @@ public class EndpointPolicyCatalogCompletenessTests
[InlineData("GET", "/api/diagnostics/download", true)] // PublicAnonymous (ticket-gated)
[InlineData("GET", "/api/diagnostics/download-url", false)] // MemberRead (JWT)
[InlineData("POST", "/api/diagnostics/download-ticket", false)]// MemberRead (JWT) — mints the ticket
[InlineData("GET", "/api/global/session-reports/download", true)] // PublicAnonymous (ticket-gated report stream)
[InlineData("POST", "/api/global/session-reports/download-ticket", false)] // GlobalReadOrAdmin (JWT) — mints the ticket
[InlineData("GET", "/api/health", true)] // PublicAnonymous
[InlineData("POST", "/api/agent/telemetry", true)] // DeviceOrBootstrapAuth
[InlineData("GET", "/api/bootstrap/validate/ABC123", true)] // PublicAnonymous (param route)
@@ -0,0 +1,91 @@
using System.Collections.Generic;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The operator KQL proxy resolves its <c>source</c> against app settings once: every configured
/// store maps to its own Kusto endpoint and token scope, a known-but-unconfigured store names the
/// missing setting (503 material), an unknown name lists the vocabulary (400 material), and the
/// default is the backend store.
/// </summary>
public class LogQuerySourceCatalogTests
{
private static LogQuerySourceCatalog Build(params (string Key, string? Value)[] settings)
{
var dict = new Dictionary<string, string?>();
foreach (var (k, v) in settings) dict[k] = v;
var config = new ConfigurationBuilder().AddInMemoryCollection(dict).Build();
return new LogQuerySourceCatalog(config);
}
[Fact]
public void Every_configured_source_resolves_to_its_own_endpoint_and_scope()
{
var catalog = Build(
(LogQuerySourceCatalog.BackendAppIdSetting, "aaaa-backend"),
(LogQuerySourceCatalog.WebAppIdSetting, "bbbb-web"),
(LogQuerySourceCatalog.McpWorkspaceIdSetting, "cccc-workspace"));
Assert.True(catalog.TryResolve("backend", out var backend, out _));
Assert.Equal("https://api.applicationinsights.io/v1/apps/aaaa-backend/query", backend!.QueryUri.ToString());
Assert.Equal("https://api.applicationinsights.io/.default", backend.TokenScope);
Assert.True(catalog.TryResolve("web", out var web, out _));
Assert.Equal("https://api.applicationinsights.io/v1/apps/bbbb-web/query", web!.QueryUri.ToString());
Assert.True(catalog.TryResolve("mcp", out var mcp, out _));
Assert.Equal("https://api.loganalytics.io/v1/workspaces/cccc-workspace/query", mcp!.QueryUri.ToString());
Assert.Equal("https://api.loganalytics.io/.default", mcp.TokenScope);
Assert.Equal(3, catalog.Available.Count);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Missing_source_defaults_to_backend(string? name)
{
var catalog = Build((LogQuerySourceCatalog.BackendAppIdSetting, "aaaa-backend"));
Assert.True(catalog.TryResolve(name, out var source, out _));
Assert.Equal(LogQuerySources.Backend, source!.Name);
}
[Fact]
public void Known_but_unconfigured_source_names_the_missing_setting_and_leaves_others_intact()
{
var catalog = Build((LogQuerySourceCatalog.BackendAppIdSetting, "aaaa-backend"));
Assert.False(catalog.TryResolve("web", out var web, out var error));
Assert.Null(web);
Assert.Contains(LogQuerySourceCatalog.WebAppIdSetting, error);
Assert.Contains("not configured", error);
Assert.True(catalog.TryResolve("backend", out _, out _));
}
[Fact]
public void Unknown_source_lists_the_vocabulary()
{
var catalog = Build((LogQuerySourceCatalog.BackendAppIdSetting, "aaaa-backend"));
Assert.False(catalog.TryResolve("portal", out _, out var error));
Assert.Contains("Unknown log source 'portal'", error);
foreach (var s in LogQuerySources.All)
Assert.Contains(s, error);
}
[Fact]
public void Vocabulary_is_the_three_stores_in_declaration_order()
{
Assert.Equal(new[] { "backend", "web", "mcp" }, LogQuerySources.All);
Assert.True(LogQuerySources.IsKnown("mcp"));
Assert.False(LogQuerySources.IsKnown("MCP"));
Assert.False(LogQuerySources.IsKnown(null));
}
}
@@ -0,0 +1,60 @@
using System.IO;
using AutopilotMonitor.Functions.Middleware;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The catalog and <see cref="PolicyEnforcementMiddleware"/> are the authorization gate for every
/// HTTP route, and the raw table/log proxies rely on the <c>RequestContext</c> it resolves. Nothing
/// at runtime would notice if the registration in <c>Program.cs</c> were dropped or moved in front of
/// <see cref="AuthenticationMiddleware"/> (the policy step reads the principal the auth step stores) —
/// the routes would simply open up. This source scan pins the registration and its order.
/// </summary>
public class MiddlewarePipelineOrderTests
{
private static string RepoRoot()
{
var dir = new DirectoryInfo(System.AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
dir = dir.Parent;
Assert.NotNull(dir);
return dir!.FullName;
}
private static string ProgramSource() => File.ReadAllText(Path.Combine(
RepoRoot(), "src", "Backend", "AutopilotMonitor.Functions", "Program.cs"));
[Fact]
public void Authentication_then_policy_enforcement_are_registered_in_that_order()
{
var src = ProgramSource();
var auth = src.IndexOf($"UseMiddleware<{nameof(AuthenticationMiddleware)}>()", System.StringComparison.Ordinal);
var policy = src.IndexOf($"UseMiddleware<{nameof(PolicyEnforcementMiddleware)}>()", System.StringComparison.Ordinal);
var rateLimit = src.IndexOf($"UseMiddleware<{nameof(UserRateLimitMiddleware)}>()", System.StringComparison.Ordinal);
Assert.True(auth >= 0, "AuthenticationMiddleware is not registered in Program.cs");
Assert.True(policy >= 0, "PolicyEnforcementMiddleware is not registered in Program.cs");
Assert.True(auth < policy, "PolicyEnforcementMiddleware must run AFTER AuthenticationMiddleware (it reads the stored principal)");
Assert.True(rateLimit < 0 || policy < rateLimit, "UserRateLimitMiddleware keys off the RequestContext the policy step resolves");
}
[Fact]
public void Each_gate_middleware_is_registered_exactly_once()
{
var src = ProgramSource();
Assert.Equal(1, Count(src, $"UseMiddleware<{nameof(AuthenticationMiddleware)}>()"));
Assert.Equal(1, Count(src, $"UseMiddleware<{nameof(PolicyEnforcementMiddleware)}>()"));
}
private static int Count(string haystack, string needle)
{
var count = 0;
for (var i = haystack.IndexOf(needle, System.StringComparison.Ordinal); i >= 0;
i = haystack.IndexOf(needle, i + needle.Length, System.StringComparison.Ordinal))
count++;
return count;
}
}
@@ -21,6 +21,8 @@ public class NoStoreCacheMiddlewareTests
[InlineData("/api/diagnostics/download-url")]
[InlineData("/api/diagnostics/download-ticket")] // short-lived signed download credential
[InlineData("/api/diagnostics/download")] // ticket-gated anonymous ZIP stream
[InlineData("/api/global/session-reports/download-ticket")] // short-lived signed report download credential
[InlineData("/api/global/session-reports/download")] // ticket-gated anonymous report ZIP stream
// /api/search/* — quick, sessions, sessions-by-event, sessions-by-cve (all return session PII)
[InlineData("/api/search/quick")]
[InlineData("/api/search/sessions")]
@@ -119,11 +119,129 @@ public class PolicyEnforcementMiddlewareTests
var mw = new PolicyEnforcementMiddleware(
NullLogger<PolicyEnforcementMiddleware>.Instance, globalAdmin, delegatedAdmin,
new TenantMemberRoleResolver(tenantAdmins, tenantConfig), tenantConfig);
new TenantMemberRoleResolver(tenantAdmins, tenantConfig), tenantConfig,
new RecordingDenialReporter());
return new Harness { Middleware = mw, Repo = repo, ConfigRepo = configRepo };
}
// ── ASSUME-BREACH: which denials are reported as PrivilegedRouteDenied ──────────────────
// The deny path in Invoke reports iff the decision denied a GlobalAdminOnly route with 403 by an
// authenticated caller. DecideAsync is the seam: it must surface the denied tier on the result.
[Fact]
public async Task GlobalAdminOnly_denial_of_tenant_user_carries_the_policy_and_is_privileged()
{
var h = BuildHarness();
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var r = await h.Middleware.DecideAsync("POST", "/api/global/raw/logs", null, AuthedPrincipal(TenantA, "admin@contoso.com"));
Assert.False(r.Allowed);
Assert.Equal(403, r.StatusCode);
Assert.Equal(EndpointPolicy.GlobalAdminOnly, r.Policy);
Assert.Equal("NotGlobalAdmin", r.LogReason);
Assert.True(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
}
[Fact]
public async Task GlobalReader_on_raw_table_is_denied_and_privileged()
{
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalReader);
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/tables/TenantConfiguration", null, AuthedPrincipal(TenantA, "reader@contoso.com"));
Assert.False(r.Allowed);
Assert.Equal(403, r.StatusCode);
Assert.Equal(EndpointPolicy.GlobalAdminOnly, r.Policy);
Assert.True(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
}
[Fact]
public async Task GlobalAdmin_on_access_probe_is_allowed_and_not_privileged()
{
var h = BuildHarness();
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/access-probe", null, AuthedPrincipal(TenantA, "ga@contoso.com"));
Assert.True(r.Allowed);
Assert.True(r.Context!.IsGlobalAdmin);
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
}
[Fact]
public async Task Anonymous_on_GlobalAdminOnly_route_is_401_not_a_privileged_denial()
{
var h = BuildHarness();
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/access-probe", null, principal: null);
Assert.False(r.Allowed);
Assert.Equal(401, r.StatusCode);
Assert.Equal(EndpointPolicy.GlobalAdminOnly, r.Policy);
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
}
[Fact]
public async Task Unregistered_route_has_no_policy_and_is_not_privileged()
{
var h = BuildHarness();
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/does-not-exist", null, AuthedPrincipal(TenantA, "x@contoso.com"));
Assert.False(r.Allowed);
Assert.Null(r.Policy);
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
}
[Fact]
public async Task Member_route_denial_is_not_privileged()
{
var h = BuildHarness();
// No membership anywhere → MemberRead route denies with 403, but it is not the GA-only surface.
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, "nobody@contoso.com"));
Assert.False(r.Allowed);
Assert.NotEqual(EndpointPolicy.GlobalAdminOnly, r.Policy);
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
}
[Fact]
public async Task BuildPrivilegedDenial_projects_claims_headers_role_and_correlation()
{
var h = BuildHarness();
h.AsTenantAdmin(TenantA, "admin@contoso.com");
var principal = AuthedPrincipal(TenantA, "admin@contoso.com");
var r = await h.Middleware.DecideAsync("POST", "/api/global/raw/logs", null, principal);
var d = PolicyEnforcementMiddleware.BuildPrivilegedDenial(
r, "POST", "/api/global/raw/logs", principal, globalRole: null,
clientSource: "mcp", mcpToolName: "query_backend_logs", correlationId: "corr-42");
Assert.Equal("POST", d.Method);
Assert.Equal("/api/global/raw/logs", d.Path);
Assert.Equal(403, d.StatusCode);
Assert.Equal("NotGlobalAdmin", d.Reason);
Assert.Equal("GlobalAdminOnly", d.Policy);
Assert.Equal("admin@contoso.com", d.Upn);
Assert.Equal(TestOid, d.ObjectId);
Assert.Equal(TenantA, d.TenantId);
Assert.Equal("None", d.CallerRole);
Assert.Equal("mcp", d.ClientSource);
Assert.Equal("query_backend_logs", d.McpToolName);
Assert.Equal("corr-42", d.CorrelationId);
Assert.False(string.IsNullOrEmpty(d.CallerId));
var reader = PolicyEnforcementMiddleware.BuildPrivilegedDenial(
r, "POST", "/api/global/raw/logs", principal, globalRole: Constants.GlobalRoles.GlobalReader,
clientSource: null, mcpToolName: null, correlationId: "");
Assert.Equal(Constants.GlobalRoles.GlobalReader, reader.CallerRole);
Assert.Null(reader.ClientSource);
Assert.Null(reader.CorrelationId);
}
private static ClaimsPrincipal AuthedPrincipal(string tenantId, string upn, string objectId = TestOid)
=> new(new ClaimsIdentity(new[] { new Claim("tid", tenantId), new Claim("upn", upn), new Claim("oid", objectId) }, "TestAuth"));
@@ -0,0 +1,190 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The assume-breach alarm for the Global-Admin-only surface: a refused GlobalAdminOnly request must
/// land as a <c>PrivilegedRouteDenied</c> Security ops event that carries the caller's identity and
/// the MCP tool name, Critical for an outsider and Warning for a known Global Reader, throttled per
/// caller+path so a scripted probe cannot flood the push channel.
/// </summary>
public class PrivilegedDenialReporterTests
{
private static (PrivilegedDenialReporter Reporter, List<OpsEventEntry> Saved) Rig(bool repoThrows = false)
{
var saved = new List<OpsEventEntry>();
var opsRepo = new Mock<IOpsEventRepository>();
var setup = opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()));
if (repoThrows)
setup.ThrowsAsync(new System.InvalidOperationException("table down"));
else
setup.Callback<OpsEventEntry>(e => { lock (saved) saved.Add(e); }).Returns(Task.CompletedTask);
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var opsEvents = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance,
TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
var reporter = new PrivilegedDenialReporter(opsEvents, new MemoryCache(new MemoryCacheOptions()),
NullLogger<PrivilegedDenialReporter>.Instance);
return (reporter, saved);
}
private static PrivilegedDenial Denial(
string path = "/api/global/raw/logs", string callerRole = "None", string callerId = "oid-1",
string? clientSource = "mcp", string? mcpTool = "query_backend_logs")
=> new(
Method: "POST", Path: path, StatusCode: 403, Reason: "NotGlobalAdmin", Policy: "GlobalAdminOnly",
CallerId: callerId, Upn: "user@contoso.com", ObjectId: "oid-1",
TenantId: "11111111-1111-1111-1111-111111111111", CallerRole: callerRole,
ClientSource: clientSource, McpToolName: mcpTool, CorrelationId: "corr-1");
/// <summary>The write is fire-and-forget; give the completed-task continuation a moment.</summary>
private static List<OpsEventEntry> Settle(List<OpsEventEntry> saved, int expected)
{
var spin = new SpinWait();
var deadline = System.DateTime.UtcNow.AddSeconds(2);
while (System.DateTime.UtcNow < deadline)
{
lock (saved) if (saved.Count >= expected) break;
spin.SpinOnce();
}
lock (saved) return saved.ToList();
}
[Fact]
public void Outsider_lands_as_Critical_security_event_with_identity_and_tool()
{
var (reporter, saved) = Rig();
reporter.Report(Denial());
var evt = Assert.Single(Settle(saved, 1));
Assert.Equal(OpsEventCategory.Security, evt.Category);
Assert.Equal(OpsEventTypes.PrivilegedRouteDenied, evt.EventType);
Assert.Equal(OpsEventSeverity.Critical, evt.Severity);
Assert.Equal("11111111-1111-1111-1111-111111111111", evt.TenantId);
Assert.Equal("user@contoso.com", evt.UserId);
Assert.Contains("/api/global/raw/logs", evt.Message);
Assert.Contains("query_backend_logs", evt.Message);
using var details = JsonDocument.Parse(evt.Details!);
var d = details.RootElement;
Assert.Equal("oid-1", d.GetProperty("oid").GetString());
Assert.Equal("11111111-1111-1111-1111-111111111111", d.GetProperty("tid").GetString());
Assert.Equal("mcp", d.GetProperty("clientSource").GetString());
Assert.Equal("query_backend_logs", d.GetProperty("mcpToolName").GetString());
Assert.Equal("corr-1", d.GetProperty("correlationId").GetString());
Assert.Equal("GlobalAdminOnly", d.GetProperty("policy").GetString());
Assert.Equal("NotGlobalAdmin", d.GetProperty("reason").GetString());
}
[Fact]
public void Global_reader_lands_as_Warning_not_Critical()
{
var (reporter, saved) = Rig();
reporter.Report(Denial(callerRole: Constants.GlobalRoles.GlobalReader));
var evt = Assert.Single(Settle(saved, 1));
Assert.Equal(OpsEventSeverity.Warning, evt.Severity);
Assert.Equal(OpsEventTypes.PrivilegedRouteDenied, evt.EventType);
}
[Fact]
public void Direct_http_probe_without_mcp_headers_still_lands()
{
var (reporter, saved) = Rig();
reporter.Report(Denial(clientSource: null, mcpTool: null));
var evt = Assert.Single(Settle(saved, 1));
Assert.Contains("via direct", evt.Message);
using var details = JsonDocument.Parse(evt.Details!);
// Absent-when-null is the wire policy for API responses, but ops-event details use the default
// serializer: the keys are present with null so a reader can tell "no header" from "not recorded".
Assert.Equal(JsonValueKind.Null, details.RootElement.GetProperty("mcpToolName").ValueKind);
}
[Fact]
public void One_event_per_caller_per_window_whatever_path_another_caller_is_reported()
{
var (reporter, saved) = Rig();
reporter.Report(Denial());
reporter.Report(Denial(mcpTool: "query_table")); // same caller → throttled
reporter.Report(Denial(path: "/api/global/raw/tables")); // same caller, other route → throttled (a sweep is ONE event)
reporter.Report(Denial(callerId: "oid-2")); // new caller → reported
var events = Settle(saved, 2);
Assert.Equal(2, events.Count);
Assert.DoesNotContain(events, e => e.Message.Contains("/api/global/raw/tables"));
Assert.DoesNotContain(events, e => e.Message.Contains("query_table"));
Assert.All(events, e => Assert.Contains("/api/global/raw/logs", e.Message));
}
[Fact]
public void Instance_budget_ends_in_one_storm_marker_then_silence()
{
var (reporter, saved) = Rig();
// Six distinct callers: five normal events, the sixth trips the storm marker.
for (var i = 1; i <= 6; i++)
reporter.Report(Denial(callerId: $"oid-{i}", path: $"/api/global/raw/route-{i}"));
// Everything after that stays trace-only for the window — new callers included.
for (var i = 7; i <= 40; i++)
reporter.Report(Denial(callerId: $"oid-{i}"));
var events = Settle(saved, PrivilegedDenialReporter.MaxEventsPerWindow + 1);
Assert.Equal(PrivilegedDenialReporter.MaxEventsPerWindow + 1, events.Count);
Assert.All(events, e => Assert.Equal(OpsEventTypes.PrivilegedRouteDenied, e.EventType));
var normal = events.Take(PrivilegedDenialReporter.MaxEventsPerWindow).ToList();
Assert.All(normal, e => Assert.DoesNotContain("storm", e.Message));
var storm = events.Last();
Assert.Equal(OpsEventSeverity.Critical, storm.Severity);
Assert.Contains("denial storm", storm.Message);
Assert.Contains("/api/global/raw/route-6", storm.Message);
using var details = JsonDocument.Parse(storm.Details!);
Assert.True(details.RootElement.GetProperty("storm").GetBoolean());
Assert.Equal(PrivilegedDenialReporter.MaxEventsPerWindow, details.RootElement.GetProperty("cap").GetInt32());
Assert.Equal("oid-1", details.RootElement.GetProperty("lastOid").GetString());
}
[Fact]
public void Reader_denials_count_against_the_budget_too()
{
var (reporter, saved) = Rig();
for (var i = 1; i <= PrivilegedDenialReporter.MaxEventsPerWindow + 3; i++)
reporter.Report(Denial(callerId: $"reader-{i}", callerRole: Constants.GlobalRoles.GlobalReader));
var events = Settle(saved, PrivilegedDenialReporter.MaxEventsPerWindow + 1);
Assert.Equal(PrivilegedDenialReporter.MaxEventsPerWindow + 1, events.Count);
// The five Reader slips are Warning (no push under a Critical rule); the storm marker is Critical.
Assert.Equal(PrivilegedDenialReporter.MaxEventsPerWindow, events.Count(e => e.Severity == OpsEventSeverity.Warning));
Assert.Equal(OpsEventSeverity.Critical, events.Last().Severity);
}
[Fact]
public void Repository_failure_never_escapes_the_deny_path()
{
var (reporter, _) = Rig(repoThrows: true);
var ex = Record.Exception(() => reporter.Report(Denial()));
Assert.Null(ex);
}
}
@@ -0,0 +1,83 @@
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Raw;
using AutopilotMonitor.Functions.Helpers;
using Azure.Core.Serialization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Defense-in-depth re-check inside the raw table/log proxies and the access probe. The catalog +
/// policy middleware are the gate; this pins that the in-body check fails CLOSED when the middleware
/// did not run (empty <see cref="RequestContext"/>) or resolved a non-GA caller, and stays silent for
/// a resolved Global Admin.
/// </summary>
public class RawGlobalAdminGateTests
{
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(RequestContext? requestContext)
{
var services = new ServiceCollection();
services.AddOptions();
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer(ApiJsonOptions.Create()));
var provider = services.BuildServiceProvider();
var items = new Dictionary<object, object>();
if (requestContext != null)
items[RequestContext.ItemsKey] = requestContext;
var context = new Mock<FunctionContext>();
context.SetupGet(c => c.Items).Returns(items);
context.SetupGet(c => c.InstanceServices).Returns(provider);
var req = new Mock<HttpRequestData>(context.Object);
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context.Object));
return (req.Object, context.Object);
}
[Fact]
public async Task Middleware_never_ran_empty_context_is_403()
{
var (req, ctx) = BuildRequest(requestContext: null);
var denied = await RawGlobalAdminGate.DenyUnlessGlobalAdminAsync(req, ctx);
Assert.NotNull(denied);
Assert.Equal(HttpStatusCode.Forbidden, denied!.StatusCode);
}
[Fact]
public async Task Resolved_non_global_admin_is_403()
{
var (req, ctx) = BuildRequest(new RequestContext { IsGlobalAdmin = false, IsGlobalReader = true });
var denied = await RawGlobalAdminGate.DenyUnlessGlobalAdminAsync(req, ctx);
Assert.NotNull(denied);
Assert.Equal(HttpStatusCode.Forbidden, denied!.StatusCode);
}
[Fact]
public async Task Resolved_global_admin_passes()
{
var (req, ctx) = BuildRequest(new RequestContext { IsGlobalAdmin = true });
Assert.Null(await RawGlobalAdminGate.DenyUnlessGlobalAdminAsync(req, ctx));
}
private sealed class FakeHttpResponseData : HttpResponseData
{
public FakeHttpResponseData(FunctionContext context) : base(context) { }
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
public override HttpHeadersCollection Headers { get; set; } = new();
public override Stream Body { get; set; } = new MemoryStream();
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
}
}
@@ -140,6 +140,77 @@ public class RawWireParityTests
new ListRawTablesResponse { Count = tables.Count, Tables = tables });
}
// ── AppInsightsQueryFunction — POST /api/global/raw/logs ─────────────────────────────
// The proxy used to forward the App Insights body byte for byte (no DTO). The typed wrapper keeps
// the Kusto `tables[].columns/rows` shape verbatim inside and adds source/budget/partial around it.
[Fact]
public void QueryBackendLogs_wire_shape_keeps_kusto_tables_verbatim_and_drops_absent_partial()
{
using var cells = JsonDocument.Parse("[\"2026-09-04T10:00:00Z\",42,\"{\\\"TenantId\\\":\\\"t1\\\"}\",null]");
var row = cells.RootElement.EnumerateArray().Select(c => c.Clone()).ToList();
var response = new QueryBackendLogsResponse
{
Success = true,
Source = "backend",
Timespan = "PT1H",
BudgetSeconds = 30,
ElapsedMs = 812,
Partial = null,
PartialReason = null,
Tables = new[]
{
new KqlTable
{
Name = "PrimaryResult",
Columns = new[] { new KqlColumn { Name = "timestamp", Type = "datetime" }, new KqlColumn { Name = "count_", Type = "long" }, new KqlColumn { Name = "customDimensions", Type = "dynamic" }, new KqlColumn { Name = "note", Type = "string" } },
Rows = new[] { row },
},
},
};
var json = JsonSerializer.Serialize(response, WireOptions);
// The dynamic cell is the JSON STRING App Insights emits; the wire serializer escapes its inner
// quotes as " (default encoder) — the same string after decoding, so consumers that
// parse(customDimensions) are unaffected.
Assert.Equal(
"{\"success\":true,\"source\":\"backend\",\"timespan\":\"PT1H\",\"budgetSeconds\":30,\"elapsedMs\":812," +
"\"tables\":[{\"name\":\"PrimaryResult\",\"columns\":[{\"name\":\"timestamp\",\"type\":\"datetime\"},{\"name\":\"count_\",\"type\":\"long\"},{\"name\":\"customDimensions\",\"type\":\"dynamic\"},{\"name\":\"note\",\"type\":\"string\"}]," +
"\"rows\":[[\"2026-09-04T10:00:00Z\",42,\"{\\u0022TenantId\\u0022:\\u0022t1\\u0022}\",null]]}]}",
json);
using var roundTrip = JsonDocument.Parse(json);
var cell = roundTrip.RootElement.GetProperty("tables")[0].GetProperty("rows")[0][2].GetString();
Assert.Equal("{\"TenantId\":\"t1\"}", cell);
}
[Fact]
public void QueryBackendLogs_partial_result_carries_flag_and_reason()
{
var response = new QueryBackendLogsResponse
{
Success = true, Source = "mcp", Timespan = "P7D", BudgetSeconds = 120, ElapsedMs = 95000,
Partial = true, PartialReason = "E_QUERY_RESULT_SET_TOO_LARGE: too many rows",
Tables = new List<KqlTable>(),
};
var json = JsonSerializer.Serialize(response, WireOptions);
Assert.Contains("\"partial\":true,\"partialReason\":\"E_QUERY_RESULT_SET_TOO_LARGE: too many rows\",\"tables\":[]", json);
}
// ── AccessProbeFunction — GET /api/global/raw/access-probe ───────────────────────────
// New route (no anonymous predecessor): the wire shape is pinned as fixed JSON.
[Fact]
public void AccessProbe_wire_shape()
{
var json = JsonSerializer.Serialize(
new AccessProbeResponse { Success = true, Role = "GlobalAdmin" }, WireOptions);
Assert.Equal("{\"success\":true,\"role\":\"GlobalAdmin\"}", json);
}
// ── TableQueryFunction.QueryTable — GET /api/global/raw/tables/{tableName} ───────────
// Old literal: new { table = actualTableName, count = entities.Count, entities, nextLink }
// (entities is List<Dictionary<string, object?>> at the call site.)
@@ -0,0 +1,215 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the same-event value filter (FilterField/FilterOperator/FilterValue) on the
/// <c>event_data</c> and <c>event_data_array</c> condition sources (backlog q8n). Every
/// condition scans all events of its type independently, so two conditions cannot express
/// "field A AND field B on the SAME event" — the filter can. Mirrors the event_count filter
/// semantics (<see cref="RuleEngineEventCountFilterTests"/>): no filter = unchanged behaviour,
/// a missing filter field stringifies to empty.
/// </summary>
public class RuleEngineEventDataFilterTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "c3d4e5f6-a7b8-9012-cdef-123456789012";
[Fact]
public async Task EventData_FilterAndMainTest_MustHoldOnTheSameEvent()
{
// Event 1 passes the main test but fails the filter; event 2 passes the filter but
// fails the main test. Independent evaluation would fire — the same-event filter must not.
var events = new List<EnrollmentEvent>
{
Episode(sequence: 1, durationSeconds: 5000, backfilled: true),
Episode(sequence: 2, durationSeconds: 90, backfilled: false),
};
var outcome = await RunAsync(MakeEventDataRule(), events);
Assert.Empty(outcome.Results);
}
[Fact]
public async Task EventData_MatchesTheEventThatSatisfiesBoth_AndReportsItAsEvidence()
{
var events = new List<EnrollmentEvent>
{
Episode(sequence: 1, durationSeconds: 5000, backfilled: true),
Episode(sequence: 2, durationSeconds: 900, backfilled: false),
};
var outcome = await RunAsync(MakeEventDataRule(), events);
var result = Assert.Single(outcome.Results);
var evidence = Assert.IsAssignableFrom<IDictionary<string, object>>(result.MatchedConditions["long_live_episode"]);
Assert.Equal(2, Convert.ToInt32(evidence["sequence"]));
Assert.Equal("900", evidence["value"]?.ToString());
}
[Fact]
public async Task EventData_NoFilterConfigured_KeepsIndependentBehaviour()
{
var rule = MakeEventDataRule();
rule.Conditions[0].FilterField = null!;
rule.Conditions[0].FilterOperator = null!;
rule.Conditions[0].FilterValue = null!;
var events = new List<EnrollmentEvent>
{
Episode(sequence: 1, durationSeconds: 5000, backfilled: true),
};
var outcome = await RunAsync(rule, events);
Assert.Single(outcome.Results);
}
[Fact]
public async Task EventData_MissingFilterField_IsExcludedByEquals_AndKeptByNotEquals()
{
var evt = Episode(sequence: 1, durationSeconds: 5000, backfilled: false);
evt.Data!.Remove("backfilled");
var events = new List<EnrollmentEvent> { evt };
// equals "false" on a missing field → empty string ≠ "false" → excluded.
Assert.Empty((await RunAsync(MakeEventDataRule(), events)).Results);
// not_equals "true" on a missing field → kept.
var lenient = MakeEventDataRule();
lenient.Conditions[0].FilterOperator = "not_equals";
lenient.Conditions[0].FilterValue = "true";
Assert.Single((await RunAsync(lenient, events)).Results);
}
[Fact]
public async Task EventDataArray_FilterSelectsWhichEventsArraysAreIterated()
{
// The array element would match in both events; only the event passing the filter counts.
var rule = new AnalyzeRule
{
RuleId = "ANALYZE-TST-011",
Title = "Array with same-event filter",
Severity = "warning",
Category = "security",
Enabled = true,
IsBuiltIn = false,
BaseConfidence = 80,
ConfidenceThreshold = 40,
Conditions = new List<RuleCondition>
{
new()
{
Signal = "unexpected_artifact",
Source = "event_data_array",
EventType = "provisioning_package_scan",
DataField = "artifacts",
ItemField = "identity",
Operator = "regex",
Value = "^Evil",
FilterField = "source",
FilterOperator = "equals",
FilterValue = "registry",
Required = true
}
},
Explanation = "test"
};
var fileScan = ScanEvent(sequence: 1, source: "file", "Evil.ppkg");
Assert.Empty((await RunAsync(rule, new List<EnrollmentEvent> { fileScan })).Results);
var registryScan = ScanEvent(sequence: 2, source: "registry", "Evil.ppkg");
var outcome = await RunAsync(rule, new List<EnrollmentEvent> { fileScan, registryScan });
var result = Assert.Single(outcome.Results);
var evidence = Assert.IsAssignableFrom<IDictionary<string, object>>(result.MatchedConditions["unexpected_artifact"]);
Assert.Equal(2, Convert.ToInt32(evidence["sequence"]));
}
// ===== Builders =====
private static AnalyzeRule MakeEventDataRule() => new()
{
RuleId = "ANALYZE-TST-010",
Title = "Long live sleep episode",
Severity = "info",
Category = "device",
Enabled = true,
// Synthetic rule ID outside the embedded catalog — see RuleEngineEventCountFilterTests.
IsBuiltIn = false,
BaseConfidence = 90,
ConfidenceThreshold = 40,
Conditions = new List<RuleCondition>
{
new()
{
Signal = "long_live_episode",
Source = "event_data",
EventType = "system_sleep_episode",
DataField = "durationSeconds",
Operator = "gte",
Value = "300",
FilterField = "backfilled",
FilterOperator = "equals",
FilterValue = "false",
Required = true
}
},
Explanation = "test"
};
private static EnrollmentEvent Episode(int sequence, long durationSeconds, bool backfilled) => new()
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = "system_sleep_episode",
Timestamp = DateTime.UtcNow.AddMinutes(sequence),
Sequence = sequence,
Data = new Dictionary<string, object>
{
["kind"] = "modern_standby",
["durationSeconds"] = durationSeconds,
["backfilled"] = backfilled,
}
};
private static EnrollmentEvent ScanEvent(int sequence, string source, params string[] identities) => new()
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = "provisioning_package_scan",
Timestamp = DateTime.UtcNow.AddMinutes(sequence),
Sequence = sequence,
Data = new Dictionary<string, object>
{
["source"] = source,
["artifacts"] = identities
.Select(id => (object)new Dictionary<string, object> { ["identity"] = id })
.ToList(),
}
};
private static async Task<AnalysisOutcome> RunAsync(AnalyzeRule rule, List<EnrollmentEvent> events)
{
var ruleRepo = new Mock<IRuleRepository>();
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule> { rule });
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule>());
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<RuleEngineEventDataFilterTests>.Instance);
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
}
}
@@ -81,6 +81,8 @@ public sealed class SharedManifestParityTests
["opsEventSeverities"] = ConstStrings(typeof(OpsEventSeverity)),
// Declaration order (grouped by category) — the MCP renders this catalog verbatim.
["opsEventTypes"] = OpsEventTypes.All,
// Telemetry stores behind the operator KQL proxy (query_backend_logs `source`).
["logSources"] = LogQuerySources.All,
["tenantRoles"] = ConstStrings(typeof(Constants.TenantRoles)),
["globalRoles"] = ConstStrings(typeof(Constants.GlobalRoles)),
["delegatedRoles"] = ConstStrings(typeof(Constants.DelegatedRoles)),
@@ -90,9 +90,62 @@ public class SleepEpisodeRuleTests
Assert.Empty(outcome.Results);
}
[Fact]
public async Task ANALYZE_DEV_012_does_not_fire_on_a_backfilled_pre_session_episode()
{
// Backlog q8n: the watcher backfills pre-agent episodes for timeline context. A 91 h
// hibernate that ended before the enrollment started (session a2256107) satisfied the
// duration test and fired the rule although the device never slept while enrolling.
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-012");
var events = new List<EnrollmentEvent>
{
SleepEpisode(durationSeconds: 329_140, kind: "hibernate", backfilled: true),
};
var outcome = await RunAsync(rule, events);
Assert.Empty(outcome.Results);
}
[Fact]
public async Task ANALYZE_DEV_012_pins_duration_and_backfilled_to_the_same_episode()
{
// The adversarial mix from a2256107: a long backfilled episode plus a short live one.
// Two independent conditions (duration >= 300, backfilled = false) would each find a
// match on a different event and fire; the same-event filter must not.
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-012");
var events = new List<EnrollmentEvent>
{
SleepEpisode(durationSeconds: 329_140, kind: "hibernate", backfilled: true),
SleepEpisode(durationSeconds: 89, kind: "modern_standby", backfilled: false),
};
var outcome = await RunAsync(rule, events);
Assert.Empty(outcome.Results);
}
[Fact]
public async Task ANALYZE_DEV_012_fires_on_a_live_episode_next_to_a_backfilled_one()
{
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-DEV-012");
var events = new List<EnrollmentEvent>
{
SleepEpisode(durationSeconds: 329_140, kind: "hibernate", backfilled: true),
SleepEpisode(durationSeconds: 600, kind: "modern_standby", backfilled: false),
};
var outcome = await RunAsync(rule, events);
var result = Assert.Single(outcome.Results);
var matched = AsDict(result.MatchedConditions["slept_during_enrollment"]);
Assert.Equal("600", AsString(matched["value"])); // the live episode, not the backfilled one
}
// ===== Event builder — mirrors the SystemTimelineTracker emit shape =====
private static EnrollmentEvent SleepEpisode(long durationSeconds, string kind) => new()
private static EnrollmentEvent SleepEpisode(long durationSeconds, string kind, bool backfilled = false) => new()
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
@@ -106,7 +159,7 @@ public class SleepEpisodeRuleTests
["enteredAt"] = DateTime.UtcNow.AddSeconds(-durationSeconds).ToString("o"),
["exitedAt"] = DateTime.UtcNow.ToString("o"),
["durationSeconds"] = durationSeconds,
["backfilled"] = false,
["backfilled"] = backfilled,
}
};
@@ -0,0 +1,19 @@
using System.Collections.Generic;
using AutopilotMonitor.Functions.Services;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Recording stand-in for <see cref="IPrivilegedDenialReporter"/> so middleware tests can build a
/// <c>PolicyEnforcementMiddleware</c> without the ops-event/notification stack and still assert
/// what the deny path would have reported.
/// </summary>
internal sealed class RecordingDenialReporter : IPrivilegedDenialReporter
{
public List<PrivilegedDenial> Reported { get; } = new();
public void Report(PrivilegedDenial denial)
{
lock (Reported) Reported.Add(denial);
}
}
@@ -71,14 +71,11 @@ public class TypedResponseGuardTests
private static readonly Dictionary<string, int> IdentifierBaseline = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// JsonSerializer.Serialize(new { ... }) sites that are NOT responses. The single entry is
/// an outgoing HTTP CLIENT request body (App Insights query POST) — not response debt.
/// Everything else must be a typed DTO (error shapes are tolerated by the shape check).
/// JsonSerializer.Serialize(new { ... }) sites that are NOT responses. EMPTY: the last entry (the
/// App Insights query POST body) became a typed request record when the proxy learned sources.
/// Everything must be a typed DTO (error shapes are tolerated by the shape check).
/// </summary>
private static readonly Dictionary<string, int> SerializeBaseline = new(StringComparer.OrdinalIgnoreCase)
{
["Functions/Raw/AppInsightsQueryFunction.cs"] = 1,
};
private static readonly Dictionary<string, int> SerializeBaseline = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// A Build*/Compute*/*Payload/*Response method declared object hides its wire shape from
@@ -1,6 +1,5 @@
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Diagnostics;
using AutopilotMonitor.Shared.Diagnostics;
@@ -20,16 +19,11 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
/// <para>
/// Replay is bounded by the 10-min ticket TTL, the admin size cap, and the fact that the ticket
/// only ever points at the minting caller's own authorized blob; HMAC forgery is infeasible. On top
/// of that the route carries a per-client-IP limit as defense in depth — it is unauthenticated and
/// streams whole blobs, so a valid ticket replayed in a loop would otherwise be unbounded egress.
/// of that the route carries a per-client-IP limit as defense in depth (<see cref="TicketDownloadPrelude"/>).
/// </para>
/// </summary>
public class DiagnosticsTicketDownloadFunction
{
// Bounds replay of a still-valid ticket. A real client downloads a package once; the ceiling
// only has to stay clear of a legitimate retry-after-timeout.
private const int MaxRequestsPerMinutePerIp = 30;
private readonly ILogger<DiagnosticsTicketDownloadFunction> _logger;
private readonly DiagnosticsBlobStreamer _streamer;
private readonly RateLimitService _rateLimitService;
@@ -50,41 +44,13 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
{
try
{
// Rightmost X-Forwarded-For hop only — leftmost entries are caller-controlled.
var clientIp = ClientIpExtractor.GetTrustedClientIp(req);
var rateLimitResult = _rateLimitService.CheckRateLimit(
$"diag_ticket_download_{clientIp}", MaxRequestsPerMinutePerIp);
if (!rateLimitResult.IsAllowed)
{
_logger.LogWarning("DiagnosticsTicketDownload rate limit exceeded for IP {ClientIp} ({Count} requests)",
clientIp, rateLimitResult.RequestsInWindow);
var tooMany = req.CreateResponse(HttpStatusCode.TooManyRequests);
if (rateLimitResult.RetryAfter.HasValue)
tooMany.Headers.Add("Retry-After", ((int)rateLimitResult.RetryAfter.Value.TotalSeconds).ToString());
await tooMany.WriteAsJsonAsync(new { success = false, message = "Rate limit exceeded." });
return tooMany;
}
var ticket = HttpUtility.ParseQueryString(req.Url.Query)["t"];
if (string.IsNullOrEmpty(ticket))
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { success = false, message = "Missing download ticket." });
return bad;
}
if (!DiagnosticsDownloadTicket.TryDecode(ticket, out var tenantId, out var blobName, out _, out var reason))
{
_logger.LogWarning("DiagnosticsTicketDownload: rejecting ticket ({Reason})", reason);
var unauth = req.CreateResponse(HttpStatusCode.Unauthorized);
await unauth.WriteAsJsonAsync(new { success = false, message = "Invalid or expired download ticket." });
return unauth;
}
var (reject, ticket) = await TicketDownloadPrelude.RunAsync(
req, _rateLimitService, _logger, "diag_ticket_download", DiagnosticsDownloadTicket.DiagnosticsPurpose);
if (reject != null)
return reject;
return await _streamer.ProxyDownloadAsync(
req, tenantId, blobName,
req, ticket.TenantId, ticket.BlobName,
new Dictionary<string, string> { ["Source"] = "mcp-ticket" });
}
catch (ArgumentException)
@@ -0,0 +1,37 @@
using System.Net;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
namespace AutopilotMonitor.Functions.Functions.Raw
{
/// <summary>
/// GET /api/global/raw/access-probe — a GlobalAdminOnly route that does nothing.
/// <para>
/// It exists for the deny path, not the allow path. When a caller without the GlobalAdmin role
/// asks the MCP for a GA-only tool, the tool is not registered for them and the SDK answers
/// "not found" before any backend call — so the backend, which owns the identity binding and the
/// <c>PrivilegedRouteDenied</c> ops event, would never learn about the probe. The MCP therefore
/// fires this route (fire-and-forget, <c>X-MCP-Tool-Name</c> = the attempted tool) and the policy
/// middleware refuses it exactly like any other GlobalAdminOnly route: same catalog, same identity
/// check, same throttled Critical event, no second writer and no caller-supplied event payload.
/// A genuine Global Admin reaching it simply gets a typed OK.
/// </para>
/// </summary>
public class AccessProbeFunction
{
[Function("GlobalAccessProbe")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/access-probe")] HttpRequestData req,
FunctionContext context)
{
if (await RawGlobalAdminGate.DenyUnlessGlobalAdminAsync(req, context) is { } denied)
return denied;
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new AccessProbeResponse { Success = true, Role = Constants.GlobalRoles.GlobalAdmin });
return response;
}
}
}
@@ -1,162 +1,308 @@
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using AutopilotMonitor.Functions.Helpers;
using Azure.Identity;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure.Core;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Raw
{
/// <summary>
/// POST /api/global/raw/logs — the operator KQL proxy. Forwards an operator-supplied query VERBATIM
/// to one of the telemetry stores in <see cref="LogQuerySourceCatalog"/> (backend App Insights,
/// web App Insights, the MCP Container App's Log Analytics workspace) under the Function App's
/// managed identity and returns the Kusto result tables typed (<see cref="QueryBackendLogsResponse"/>).
/// <para>
/// Parity with <c>az monitor app-insights query</c> / <c>az monitor log-analytics query</c> is by
/// construction: same REST endpoint, same <c>{query, timespan}</c> body, tables forwarded cell for
/// cell. What the proxy adds is what the CLI leaves implicit — the source, the wall-clock budget the
/// call ran under (<c>Prefer: wait</c> upstream, a cancellation token here, no static client
/// timeout), an explicit <c>partial</c> flag when the store truncated the result, and the full
/// upstream error JSON on failure. There is no continuation: Kusto has none; a query that does not
/// fit the budget is narrowed (smaller timespan, <c>summarize</c>), not resumed.
/// </para>
/// Authorization: catalog <c>GlobalAdminOnly</c> + policy middleware, re-checked in the body.
/// </summary>
public class AppInsightsQueryFunction
{
internal const int DefaultBudgetSeconds = 30;
internal const int MinBudgetSeconds = 5;
/// <summary>Stays well under the ~230 s Azure HTTP front-end limit plus the MCP's own margin.</summary>
internal const int MaxBudgetSeconds = 180;
/// <summary>Upstream error bodies are forwarded whole up to this size so nothing az would show is lost.</summary>
private const int UpstreamErrorCap = 8192;
private readonly ILogger<AppInsightsQueryFunction> _logger;
private static readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) };
private static readonly DefaultAzureCredential _credential = new();
private readonly LogQuerySourceCatalog _sources;
private readonly IHttpClientFactory _httpClientFactory;
private readonly TokenCredential _credential;
// Application Insights REST API scope for Azure AD auth
private const string AppInsightsScope = "https://api.applicationinsights.io/.default";
public AppInsightsQueryFunction(ILogger<AppInsightsQueryFunction> logger)
public AppInsightsQueryFunction(
ILogger<AppInsightsQueryFunction> logger,
LogQuerySourceCatalog sources,
IHttpClientFactory httpClientFactory,
TokenCredential credential)
{
_logger = logger;
_sources = sources;
_httpClientFactory = httpClientFactory;
_credential = credential;
}
/// <summary>
/// POST /api/global/raw/logs — Proxy KQL queries to Application Insights
/// Body: { "query": "traces | where ...", "timespan": "PT1H" }
/// Uses Managed Identity (DefaultAzureCredential) for authentication.
/// Requires APPINSIGHTS_APP_ID app setting and "Monitoring Reader" RBAC role.
/// Body: <c>{ "query": "traces | ...", "timespan": "PT1H", "source": "backend", "budgetSeconds": 30 }</c>.
/// </summary>
[Function("QueryBackendLogs")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/raw/logs")] HttpRequestData req)
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/raw/logs")] HttpRequestData req,
FunctionContext context)
{
// Authorization is the catalog (GlobalAdminOnly) + PolicyEnforcementMiddleware; this is the
// in-body re-check so a middleware regression can never expose the KQL proxy.
if (await RawGlobalAdminGate.DenyUnlessGlobalAdminAsync(req, context) is { } denied)
return denied;
LogQueryRequest? body;
try
{
var appId = Environment.GetEnvironmentVariable("APPINSIGHTS_APP_ID");
body = await req.ReadFromJsonAsync<LogQueryRequest>();
}
catch (JsonException)
{
body = null;
}
if (body == null || string.IsNullOrWhiteSpace(body.Query))
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { error = "query is required" });
return bad;
}
if (string.IsNullOrEmpty(appId))
if (!_sources.TryResolve(body.Source, out var source, out var sourceError))
{
var known = LogQuerySources.IsKnown(string.IsNullOrWhiteSpace(body.Source) ? LogQuerySources.Backend : body.Source.Trim());
var status = known ? HttpStatusCode.ServiceUnavailable : HttpStatusCode.BadRequest;
var resp = req.CreateResponse(status);
await resp.WriteAsJsonAsync(new { error = sourceError });
return resp;
}
var timespan = string.IsNullOrWhiteSpace(body.Timespan) ? "PT1H" : body.Timespan.Trim();
var budget = ClampBudget(body.BudgetSeconds);
var stopwatch = Stopwatch.StartNew();
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(budget));
var token = await _credential.GetTokenAsync(new TokenRequestContext(new[] { source!.TokenScope }), cts.Token);
using var upstreamRequest = new HttpRequestMessage(HttpMethod.Post, source.QueryUri);
upstreamRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
// Ask the store to keep working as long as we are willing to wait (default server wait is shorter).
upstreamRequest.Headers.TryAddWithoutValidation("Prefer", $"wait={budget}");
upstreamRequest.Content = new StringContent(
JsonSerializer.Serialize(new KustoQuery(body.Query, timespan)), Encoding.UTF8, "application/json");
var client = _httpClientFactory.CreateClient(LogQuerySourceCatalog.HttpClientName);
using var upstreamResponse = await client.SendAsync(upstreamRequest, cts.Token);
var responseText = await upstreamResponse.Content.ReadAsStringAsync(cts.Token);
stopwatch.Stop();
if (!upstreamResponse.IsSuccessStatusCode)
{
var unavailable = req.CreateResponse(HttpStatusCode.ServiceUnavailable);
await unavailable.WriteAsJsonAsync(new { error = "Application Insights diagnostics is not configured. Set the APPINSIGHTS_APP_ID app setting and assign 'Monitoring Reader' role to the Function App's Managed Identity." });
return unavailable;
}
var body = await req.ReadFromJsonAsync<LogQueryRequest>();
if (body == null || string.IsNullOrWhiteSpace(body.Query))
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { error = "query is required" });
return bad;
}
var timespan = body.Timespan ?? "PT1H";
// Acquire Bearer token via Managed Identity / DefaultAzureCredential
var tokenResult = await _credential.GetTokenAsync(
new Azure.Core.TokenRequestContext(new[] { AppInsightsScope }));
// Call Application Insights REST API
var aiUrl = $"https://api.applicationinsights.io/v1/apps/{appId}/query";
var requestBody = JsonSerializer.Serialize(new
{
query = body.Query,
timespan
});
using var aiRequest = new HttpRequestMessage(HttpMethod.Post, aiUrl);
aiRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult.Token);
aiRequest.Content = new StringContent(requestBody, Encoding.UTF8, "application/json");
using var aiResponse = await _httpClient.SendAsync(aiRequest);
var responseText = await aiResponse.Content.ReadAsStringAsync();
if (!aiResponse.IsSuccessStatusCode)
{
_logger.LogWarning("App Insights query failed: {StatusCode} {Response}",
aiResponse.StatusCode, responseText);
// Extract the meaningful error message from AppInsights error JSON
// Format: { "error": { "message": "...", "code": "...", "innererror": { "code": "SyntaxError", "message": "..." } } }
var errorMessage = "Application Insights query failed";
var errorCode = (string?)null;
try
{
using var doc = JsonDocument.Parse(responseText);
if (doc.RootElement.TryGetProperty("error", out var errorObj))
{
if (errorObj.TryGetProperty("innererror", out var inner) &&
inner.TryGetProperty("message", out var innerMsg))
{
errorMessage = innerMsg.GetString() ?? errorMessage;
}
else if (errorObj.TryGetProperty("message", out var msg))
{
errorMessage = msg.GetString() ?? errorMessage;
}
if (errorObj.TryGetProperty("innererror", out var innerErr) &&
innerErr.TryGetProperty("code", out var innerCode))
{
errorCode = innerCode.GetString();
}
else if (errorObj.TryGetProperty("code", out var code))
{
errorCode = code.GetString();
}
}
}
catch
{
// responseText is not JSON — keep generic message
}
_logger.LogWarning("Log query ({Source}) failed: {StatusCode} {Response}",
source.Name, upstreamResponse.StatusCode, LogSanitizer.Clean(Cap(responseText, 2048)));
var (errorMessage, errorCode) = ExtractError(responseText);
var errorResp = req.CreateResponse(HttpStatusCode.BadGateway);
await errorResp.WriteAsJsonAsync(new
{
error = errorMessage,
errorCode,
statusCode = (int)aiResponse.StatusCode,
statusCode = (int)upstreamResponse.StatusCode,
source = source.Name,
hint = errorCode == "SyntaxError"
? "The KQL query has a syntax error. Check operators, pipe stages, and string quoting. Example: traces | where message contains 'error' | take 50"
: errorCode == "SemanticError"
? "The KQL query references an unknown table or column. Valid tables: traces, requests, exceptions, customEvents, dependencies."
? $"The KQL query references an unknown table or column for source '{source.Name}'. Run '<table> | getschema' to list columns."
: (string?)null,
// Everything the store said, so nothing the CLI would print is lost.
upstream = Cap(responseText, UpstreamErrorCap),
});
return errorResp;
}
var (tables, partialReason) = ParseKustoBody(responseText);
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("Content-Type", "application/json");
await response.WriteStringAsync(responseText);
await response.WriteAsJsonAsync(new QueryBackendLogsResponse
{
Success = true,
Source = source.Name,
Timespan = timespan,
BudgetSeconds = budget,
ElapsedMs = stopwatch.ElapsedMilliseconds,
Partial = partialReason != null ? true : null,
PartialReason = partialReason,
Tables = tables,
});
return response;
}
catch (TaskCanceledException)
catch (OperationCanceledException)
{
var timeout = req.CreateResponse(HttpStatusCode.GatewayTimeout);
await timeout.WriteAsJsonAsync(new { error = "Application Insights query timed out (30s limit)" });
await timeout.WriteAsJsonAsync(new
{
error = $"Log query ({source!.Name}) exceeded its budget of {budget}s",
budgetSeconds = budget,
hint = $"Narrow the query (smaller timespan, `summarize` instead of raw rows, `take N`) or raise budgetSeconds (max {MaxBudgetSeconds}).",
});
return timeout;
}
catch (Azure.Identity.CredentialUnavailableException ex)
{
_logger.LogError(ex, "Managed Identity not available for App Insights query");
_logger.LogError(ex, "Managed Identity not available for log query ({Source})", source!.Name);
var err = req.CreateResponse(HttpStatusCode.ServiceUnavailable);
await err.WriteAsJsonAsync(new { error = "Managed Identity is not configured. Enable System-assigned Managed Identity on the Function App and assign 'Monitoring Reader' role on the Application Insights resource." });
await err.WriteAsJsonAsync(new { error = "Managed Identity is not configured. Enable the system-assigned Managed Identity on the Function App and grant it read access to the telemetry store." });
return err;
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "Query Application Insights");
return await req.InternalServerErrorAsync(_logger, ex, "Query telemetry store");
}
}
private class LogQueryRequest
/// <summary>Null or out-of-range budgets collapse to the default / the nearest bound.</summary>
internal static int ClampBudget(int? requested)
{
if (requested is null || requested <= 0)
return DefaultBudgetSeconds;
return Math.Clamp(requested.Value, MinBudgetSeconds, MaxBudgetSeconds);
}
/// <summary>
/// Kusto REST success body → typed tables plus the store's partial-result note. A 200 can carry
/// <c>"error": { "code": "PartialError", ... }</c> next to <c>tables</c> when the result was cut
/// (size cap, shard timeout); that used to pass through unread. Cells are cloned out of the
/// document so they outlive it.
/// </summary>
internal static (IReadOnlyList<KqlTable> Tables, string? PartialReason) ParseKustoBody(string json)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var tables = new List<KqlTable>();
if (root.TryGetProperty("tables", out var tablesEl) && tablesEl.ValueKind == JsonValueKind.Array)
{
foreach (var t in tablesEl.EnumerateArray())
{
var columns = new List<KqlColumn>();
if (t.TryGetProperty("columns", out var colsEl) && colsEl.ValueKind == JsonValueKind.Array)
{
foreach (var c in colsEl.EnumerateArray())
{
columns.Add(new KqlColumn
{
Name = c.TryGetProperty("name", out var n) ? n.GetString() ?? string.Empty : string.Empty,
Type = c.TryGetProperty("type", out var ty) ? ty.GetString() ?? string.Empty : string.Empty,
});
}
}
var rows = new List<IReadOnlyList<JsonElement>>();
if (t.TryGetProperty("rows", out var rowsEl) && rowsEl.ValueKind == JsonValueKind.Array)
{
foreach (var r in rowsEl.EnumerateArray())
{
var cells = new List<JsonElement>(columns.Count);
if (r.ValueKind == JsonValueKind.Array)
foreach (var cell in r.EnumerateArray())
cells.Add(cell.Clone());
rows.Add(cells);
}
}
tables.Add(new KqlTable
{
Name = t.TryGetProperty("name", out var name) ? name.GetString() ?? string.Empty : string.Empty,
Columns = columns,
Rows = rows,
});
}
}
string? partialReason = null;
if (root.TryGetProperty("error", out var errorEl) && errorEl.ValueKind == JsonValueKind.Object)
{
var (message, code) = ExtractError(errorEl);
partialReason = string.IsNullOrEmpty(code) ? message : $"{code}: {message}";
}
return (tables, partialReason);
}
/// <summary>
/// Message + code from a Kusto error body — <c>innererror</c> first (the specific reason), then the
/// outer <c>error</c>. Tolerates non-JSON.
/// </summary>
internal static (string Message, string? Code) ExtractError(string body)
{
try
{
using var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("error", out var errorObj) && errorObj.ValueKind == JsonValueKind.Object)
return ExtractError(errorObj);
}
catch (JsonException)
{
// not JSON — keep the generic message
}
return ("Telemetry store query failed", null);
}
private static (string Message, string? Code) ExtractError(JsonElement errorObj)
{
var message = "Telemetry store query failed";
string? code = null;
if (errorObj.TryGetProperty("innererror", out var inner) && inner.ValueKind == JsonValueKind.Object)
{
if (inner.TryGetProperty("message", out var innerMsg) && innerMsg.ValueKind == JsonValueKind.String)
message = innerMsg.GetString() ?? message;
if (inner.TryGetProperty("code", out var innerCode) && innerCode.ValueKind == JsonValueKind.String)
code = innerCode.GetString();
}
if (message == "Telemetry store query failed" && errorObj.TryGetProperty("message", out var msg) && msg.ValueKind == JsonValueKind.String)
message = msg.GetString() ?? message;
if (code == null && errorObj.TryGetProperty("code", out var outerCode) && outerCode.ValueKind == JsonValueKind.String)
code = outerCode.GetString();
return (message, code);
}
private static string Cap(string s, int max) => s.Length <= max ? s : s[..max] + "…";
private sealed class LogQueryRequest
{
public string Query { get; set; } = string.Empty;
public string? Timespan { get; set; }
/// <summary>One of <see cref="LogQuerySources"/>; defaults to backend.</summary>
public string? Source { get; set; }
/// <summary>Wall-clock budget for the upstream call; clamped to 5..180, default 30.</summary>
public int? BudgetSeconds { get; set; }
}
/// <summary>Kusto REST request body — both App Insights and Log Analytics accept exactly this.</summary>
private sealed record KustoQuery(
[property: JsonPropertyName("query")] string Query,
[property: JsonPropertyName("timespan")] string Timespan);
}
}
@@ -0,0 +1,29 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
namespace AutopilotMonitor.Functions.Functions.Raw
{
/// <summary>
/// DEFENSE IN DEPTH for the raw table/log proxies. The catalog (<c>GlobalAdminOnly</c>) and the
/// policy middleware are the authorization gate; this re-check inside the function body only
/// guarantees that a regression there (middleware unregistered or reordered, a catalog edit to a
/// weaker tier) turns into a 403 instead of a secret-bearing dump. It reads the resolved
/// <see cref="RequestContext"/>: an empty context (middleware never ran) has
/// <c>IsGlobalAdmin == false</c>, so the gate fails closed by construction.
/// </summary>
internal static class RawGlobalAdminGate
{
/// <summary>Returns a 403 response to hand back, or null when the caller is a resolved Global Admin.</summary>
public static async Task<HttpResponseData?> DenyUnlessGlobalAdminAsync(HttpRequestData req, FunctionContext context)
{
if (context.GetRequestContext().IsGlobalAdmin)
return null;
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new { error = "Forbidden", message = "Global Admin role required." });
return forbidden;
}
}
}
@@ -39,8 +39,14 @@ namespace AutopilotMonitor.Functions.Functions.Raw
/// </summary>
[Function("ListRawTables")]
public async Task<HttpResponseData> ListTables(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/tables")] HttpRequestData req)
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/tables")] HttpRequestData req,
FunctionContext context)
{
// Authorization is the catalog (GlobalAdminOnly) + PolicyEnforcementMiddleware; in-body re-check
// so a middleware regression can never expose the raw table surface.
if (await RawGlobalAdminGate.DenyUnlessGlobalAdminAsync(req, context) is { } denied)
return denied;
try
{
var tables = Constants.TableNames.All
@@ -65,8 +71,14 @@ namespace AutopilotMonitor.Functions.Functions.Raw
[Function("QueryRawTable")]
public async Task<HttpResponseData> QueryTable(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/tables/{tableName}")] HttpRequestData req,
string tableName)
string tableName,
FunctionContext context)
{
// Authorization is the catalog (GlobalAdminOnly) + PolicyEnforcementMiddleware; in-body re-check
// so a middleware regression can never turn this into a secret-bearing table dump.
if (await RawGlobalAdminGate.DenyUnlessGlobalAdminAsync(req, context) is { } denied)
return denied;
try
{
// Validate table name
@@ -11,13 +11,14 @@ namespace AutopilotMonitor.Functions.Functions.Reports
{
/// <summary>
/// Returns a short-lived SAS download URL for a session report blob stored in central blob storage.
/// Global Admin only.
/// Portal path (GlobalReadOrAdmin); the MCP uses the ticket-gated proxy instead
/// (<see cref="SessionReportDownloadTicketFunction"/>) so a raw SAS never leaves the backend there.
/// </summary>
public class GetSessionReportDownloadUrlFunction
{
private readonly ILogger<GetSessionReportDownloadUrlFunction> _logger;
private readonly BlobStorageService _blobStorage;
private const string ContainerName = "session-reports";
private const string ContainerName = AutopilotMonitor.Shared.Constants.BlobContainers.SessionReports;
public GetSessionReportDownloadUrlFunction(
ILogger<GetSessionReportDownloadUrlFunction> logger,
@@ -0,0 +1,126 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Diagnostics;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Reports
{
/// <summary>
/// POST /api/global/session-reports/download-ticket — mints a short-lived, self-authenticating
/// download ticket for one blob of the operator <c>session-reports</c> container (a submitted
/// report ZIP, a preserved diagnostics copy, or a diag-files report). Same tier as the portal's
/// download-url route (GlobalReadOrAdmin, enforced by the policy middleware), but the client
/// gets a ticket for the proxied stream (<see cref="SessionReportTicketDownloadFunction"/>)
/// instead of a storage SAS — the MCP contract never hands out a SAS.
/// <para>
/// The ticket carries the caller's home tenant (JWT tid) as the audience it was issued to and
/// <see cref="DiagnosticsDownloadTicket.SessionReportPurpose"/> as its purpose, so a diagnostics
/// ticket can never open a report and vice versa.
/// </para>
/// </summary>
public class SessionReportDownloadTicketFunction
{
private const string ContainerName = AutopilotMonitor.Shared.Constants.BlobContainers.SessionReports;
public const string DestinationLabel = "SessionReports";
private readonly ILogger<SessionReportDownloadTicketFunction> _logger;
private readonly BlobStorageService _blobStorage;
public SessionReportDownloadTicketFunction(
ILogger<SessionReportDownloadTicketFunction> logger,
BlobStorageService blobStorage)
{
_logger = logger;
_blobStorage = blobStorage;
}
[Function("SessionReportDownloadTicket")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/session-reports/download-ticket")] HttpRequestData req)
{
try
{
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware.
var requestCtx = req.GetRequestContext();
string? blobName = null;
try
{
var body = await JsonSerializer.DeserializeAsync<TicketRequest>(
req.Body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
blobName = body?.BlobName;
}
catch (JsonException) { /* fall through to the 400 below */ }
if (string.IsNullOrEmpty(blobName))
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { success = false, message = "blobName is required." });
return bad;
}
// Report blobs are flat names; anything else is a traversal attempt.
if (!BlobNameGuard.IsFlat(blobName))
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { success = false, message = "Invalid blob name." });
return bad;
}
var blobClient = _blobStorage.GetContainerClient(ContainerName).GetBlobClient(blobName);
if (!await blobClient.ExistsAsync())
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { success = false, message = "Blob not found." });
return notFound;
}
var audience = string.IsNullOrEmpty(requestCtx.TenantId) ? "operator" : requestCtx.TenantId;
var issuedAt = DateTimeOffset.UtcNow;
var ticket = DiagnosticsDownloadTicket.Encode(
audience, blobName, DestinationLabel, issuedAt, DiagnosticsDownloadTicket.SessionReportPurpose);
var expiresAt = issuedAt.Add(DiagnosticsDownloadTicket.DefaultTtl).UtcDateTime;
long? sizeBytes;
using (var sizeCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)))
{
sizeBytes = await _blobStorage.TryGetSizeAsync(ContainerName, blobName, sizeCts.Token);
}
_logger.LogInformation(
"SessionReportDownloadTicket: issued for blob {Blob} to {User}, expires {ExpiresAt}",
blobName, requestCtx.UserPrincipalName, expiresAt.ToString("O"));
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new DiagnosticsDownloadTicketResponse
{
Success = true,
Url = $"/api/global/session-reports/download?t={Uri.EscapeDataString(ticket)}",
ExpiresAt = expiresAt,
BlobName = blobName,
Destination = DestinationLabel,
SizeBytes = sizeBytes,
});
return response;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error minting session-report download ticket");
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
await err.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
return err;
}
}
private sealed class TicketRequest
{
public string? BlobName { get; set; }
}
}
}
@@ -0,0 +1,104 @@
using System.Diagnostics;
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Diagnostics;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Reports
{
/// <summary>
/// GET /api/global/session-reports/download?t=… — ticket-gated stream of one blob from the
/// operator <c>session-reports</c> container for callers that hold no JWT (MCP/AI clients).
/// <c>PublicAnonymous</c> in the catalog: authorization happened at mint time
/// (<see cref="SessionReportDownloadTicketFunction"/>, GlobalReadOrAdmin); the HMAC ticket with
/// <see cref="DiagnosticsDownloadTicket.SessionReportPurpose"/> is the sole authority here, and
/// the blob name comes from the ticket alone. Bytes are proxied as-is (never unzipped); the
/// storage SAS never leaves the backend. Per-IP limit + decode via <see cref="TicketDownloadPrelude"/>.
/// </summary>
public class SessionReportTicketDownloadFunction
{
private const string ContainerName = AutopilotMonitor.Shared.Constants.BlobContainers.SessionReports;
/// <summary>Whole-transfer ceiling; report ZIPs are tens of MB at most.</summary>
private static readonly TimeSpan StreamTimeout = TimeSpan.FromSeconds(120);
private readonly ILogger<SessionReportTicketDownloadFunction> _logger;
private readonly BlobStorageService _blobStorage;
private readonly RateLimitService _rateLimitService;
public SessionReportTicketDownloadFunction(
ILogger<SessionReportTicketDownloadFunction> logger,
BlobStorageService blobStorage,
RateLimitService rateLimitService)
{
_logger = logger;
_blobStorage = blobStorage;
_rateLimitService = rateLimitService;
}
[Function("SessionReportTicketDownload")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/session-reports/download")] HttpRequestData req)
{
try
{
var (reject, ticket) = await TicketDownloadPrelude.RunAsync(
req, _rateLimitService, _logger, "report_ticket_download", DiagnosticsDownloadTicket.SessionReportPurpose);
if (reject != null)
return reject;
// Signed, but still: the container is flat and a nested name has no business here.
Security.BlobNameGuard.EnsureFlat(ticket.BlobName, nameof(ticket.BlobName));
using var cts = new CancellationTokenSource(StreamTimeout);
var sw = Stopwatch.StartNew();
var download = await _blobStorage.OpenReadAsync(ContainerName, ticket.BlobName, cts.Token);
// The result owns the network stream; dispose it, not the Response wrapper.
using var body = download.Value;
var contentLength = body.Details.ContentLength;
_logger.LogInformation(
"SessionReportTicketDownload: proxying {Blob}, {SizeBytes} bytes, fetch took {DurationMs}ms",
ticket.BlobName, contentLength, sw.ElapsedMilliseconds);
var response = req.CreateResponse(HttpStatusCode.OK);
response.Headers.Add("Content-Type", "application/octet-stream");
response.Headers.Add("Content-Disposition", $"attachment; filename=\"{ticket.BlobName}\"");
if (contentLength > 0)
response.Headers.Add("Content-Length", contentLength.ToString());
await body.Content.CopyToAsync(response.Body, cts.Token);
return response;
}
catch (ArgumentException)
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { success = false, message = "Invalid blob name." });
return bad;
}
catch (Azure.RequestFailedException ex) when (ex.Status == 404)
{
_logger.LogWarning("SessionReportTicketDownload: blob not found");
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { success = false, message = "Report blob not found." });
return notFound;
}
catch (OperationCanceledException)
{
_logger.LogWarning("SessionReportTicketDownload: timed out streaming blob");
var timeout = req.CreateResponse(HttpStatusCode.GatewayTimeout);
await timeout.WriteAsJsonAsync(new { success = false, message = "Report download timed out." });
return timeout;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in ticket-gated session-report download");
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
await err.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
return err;
}
}
}
}
@@ -0,0 +1,69 @@
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Diagnostics;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Helpers
{
/// <summary>
/// The shared front half of every ticket-gated anonymous download route (diagnostics packages,
/// session-report ZIPs): a per-client-IP limit as defense in depth — the route is unauthenticated
/// and streams whole blobs, so a valid ticket replayed in a loop would otherwise be unbounded
/// egress — followed by the HMAC ticket decode that is the route's SOLE authority. tenantId and
/// blobName come FROM the ticket, never from the query string, so a ticket cannot be retargeted;
/// the <paramref name="purpose"/> keeps a ticket minted for one surface from opening another.
/// </summary>
internal static class TicketDownloadPrelude
{
/// <summary>
/// Bounds replay of a still-valid ticket. A real client downloads a package once; the ceiling
/// only has to stay clear of a legitimate retry-after-timeout.
/// </summary>
public const int MaxRequestsPerMinutePerIp = 30;
public readonly record struct Admitted(string TenantId, string BlobName, string Destination);
/// <summary>
/// Returns the response to hand back (429 / 400 / 401) or null with the ticket's bound values.
/// </summary>
public static async Task<(HttpResponseData? Reject, Admitted Ticket)> RunAsync(
HttpRequestData req, RateLimitService rateLimitService, ILogger logger, string routeLabel, string purpose)
{
// Rightmost X-Forwarded-For hop only — leftmost entries are caller-controlled.
var clientIp = ClientIpExtractor.GetTrustedClientIp(req);
var rateLimitResult = rateLimitService.CheckRateLimit($"{routeLabel}_{clientIp}", MaxRequestsPerMinutePerIp);
if (!rateLimitResult.IsAllowed)
{
logger.LogWarning("{Route} rate limit exceeded for IP {ClientIp} ({Count} requests)",
routeLabel, clientIp, rateLimitResult.RequestsInWindow);
var tooMany = req.CreateResponse(HttpStatusCode.TooManyRequests);
if (rateLimitResult.RetryAfter.HasValue)
tooMany.Headers.Add("Retry-After", ((int)rateLimitResult.RetryAfter.Value.TotalSeconds).ToString());
await tooMany.WriteAsJsonAsync(new { success = false, message = "Rate limit exceeded." });
return (tooMany, default);
}
var ticket = HttpUtility.ParseQueryString(req.Url.Query)["t"];
if (string.IsNullOrEmpty(ticket))
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { success = false, message = "Missing download ticket." });
return (bad, default);
}
if (!DiagnosticsDownloadTicket.TryDecode(ticket, out var tenantId, out var blobName, out var destination, out var reason, purpose: purpose))
{
logger.LogWarning("{Route}: rejecting ticket ({Reason})", routeLabel, reason);
var unauth = req.CreateResponse(HttpStatusCode.Unauthorized);
await unauth.WriteAsJsonAsync(new { success = false, message = "Invalid or expired download ticket." });
return (unauth, default);
}
return (null, new Admitted(tenantId, blobName, destination));
}
}
}
@@ -38,19 +38,25 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
private readonly DelegatedAdminService _delegatedAdminService;
private readonly TenantMemberRoleResolver _memberRoleResolver;
private readonly TenantConfigurationService _tenantConfigService;
private readonly IPrivilegedDenialReporter _denialReporter;
/// <summary>Longest header value that lands in a denial record; anything beyond is caller noise.</summary>
private const int MaxDenialHeaderLength = 64;
public PolicyEnforcementMiddleware(
ILogger<PolicyEnforcementMiddleware> logger,
GlobalAdminService globalAdminService,
DelegatedAdminService delegatedAdminService,
TenantMemberRoleResolver memberRoleResolver,
TenantConfigurationService tenantConfigService)
TenantConfigurationService tenantConfigService,
IPrivilegedDenialReporter denialReporter)
{
_logger = logger;
_globalAdminService = globalAdminService;
_delegatedAdminService = delegatedAdminService;
_memberRoleResolver = memberRoleResolver;
_tenantConfigService = tenantConfigService;
_denialReporter = denialReporter;
}
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
@@ -111,13 +117,79 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return;
}
_logger.LogWarning("[PolicyEnforcement] DENIED {Method} {Path} status={Status} user={User} role={Role} reason={Reason}",
logMethod, logPath, result.StatusCode, LogSanitizer.Clean(result.UserIdentifier), result.UserRole, result.LogReason);
// Caller-supplied headers: sanitized + truncated before they reach a log line or a table row.
var clientSource = DenialHeader(httpContext, "X-Client-Source");
var mcpToolName = DenialHeader(httpContext, "X-MCP-Tool-Name");
_logger.LogWarning("[PolicyEnforcement] DENIED {Method} {Path} status={Status} user={User} role={Role} reason={Reason} oid={Oid} tid={Tid} clientSource={ClientSource} mcpTool={McpTool}",
logMethod, logPath, result.StatusCode, LogSanitizer.Clean(result.UserIdentifier), result.UserRole, result.LogReason,
LogSanitizer.Clean(principal?.GetObjectId()), LogSanitizer.Clean(principal?.GetTenantId()), clientSource, mcpToolName);
await ReportPrivilegedDenialAsync(context, result, httpMethod, requestPath, principal, clientSource, mcpToolName);
httpContext.Response.StatusCode = result.StatusCode;
httpContext.Response.ContentType = "application/json";
await httpContext.Response.WriteAsJsonAsync(new { error = result.ErrorCode, message = result.ErrorMessage });
}
private static string? DenialHeader(HttpContext httpContext, string name)
{
var raw = LogSanitizer.Clean(httpContext.Request.Headers[name].FirstOrDefault());
if (string.IsNullOrEmpty(raw))
return null;
return raw.Length <= MaxDenialHeaderLength ? raw : raw[..MaxDenialHeaderLength];
}
/// <summary>
/// ASSUME-BREACH layer for the Global-Admin-only surface. A 403 on a <c>GlobalAdminOnly</c> route by
/// an authenticated caller is the one denial that must never pass silently: it is either an operator
/// on the wrong account, a Global Reader overreaching, or someone probing the raw table/log proxies.
/// The event carries the backend's own view of the JWT (upn/oid/tid) plus the MCP tool name when the
/// call came through the MCP's access probe. 401s (no JWT / no upn) are not probes and stay log-only,
/// as do unregistered routes (anonymous scanner noise). The role lookup here is cached and only runs
/// on this rare path; it distinguishes a known Global Reader (Warning) from an outsider (Critical).
/// </summary>
private async Task ReportPrivilegedDenialAsync(
FunctionContext context, PolicyResult result, string httpMethod, string requestPath,
ClaimsPrincipal? principal, string? clientSource, string? mcpToolName)
{
if (!IsPrivilegedDenial(result) || principal == null)
return;
try
{
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
var denial = BuildPrivilegedDenial(result, httpMethod, requestPath, principal, globalRole,
clientSource, mcpToolName, context.GetCorrelationId());
_denialReporter.Report(denial);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[PolicyEnforcement] privileged-denial reporting failed");
}
}
/// <summary>A 403 by an authenticated caller on a GlobalAdminOnly route — the only denial that is reported.</summary>
internal static bool IsPrivilegedDenial(PolicyResult result)
=> !result.Allowed
&& result.Policy == EndpointPolicy.GlobalAdminOnly
&& result.StatusCode == (int)HttpStatusCode.Forbidden;
/// <summary>Pure projection of a denial into the record the reporter writes; separated so tests can pin it without an HttpContext.</summary>
internal static PrivilegedDenial BuildPrivilegedDenial(
PolicyResult result, string httpMethod, string requestPath, ClaimsPrincipal principal, string? globalRole,
string? clientSource, string? mcpToolName, string? correlationId)
=> new(
Method: httpMethod,
Path: requestPath,
StatusCode: result.StatusCode,
Reason: result.LogReason,
Policy: result.Policy?.ToString() ?? string.Empty,
CallerId: principal.GetCallerId() ?? UnidentifiedCallerBucket,
Upn: principal.GetUserPrincipalName(),
ObjectId: principal.GetObjectId(),
TenantId: principal.GetTenantId(),
CallerRole: globalRole ?? "None",
ClientSource: clientSource,
McpToolName: mcpToolName,
CorrelationId: string.IsNullOrEmpty(correlationId) ? null : correlationId);
/// <summary>
/// Transport-agnostic authorization decision: catalog lookup → policy evaluation → cross-tenant
/// enforcement → RequestContext construction. Returns an allow (carrying the resolved RequestContext)
@@ -204,7 +276,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
var (code, message) = status == HttpStatusCode.Unauthorized
? ("AuthenticationRequired", "Authentication required. Please provide a valid JWT token.")
: ("InsufficientPermissions", "Access denied. You do not have permission to access this resource.");
return PolicyResult.Deny((int)status, code, message, decision.UserIdentifier, decision.UserRole, decision.Reason);
return PolicyResult.Deny((int)status, code, message, decision.UserIdentifier, decision.UserRole, decision.Reason, catalogEntry.Policy);
}
}
@@ -216,7 +288,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(jwtTenantId), LogSanitizer.Clean(namedTarget), catalogEntry.TenantScoping, logPath);
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "CrossTenantAccessDenied",
"Access denied. You can only access your own tenant's resources.",
decision.UserIdentifier, decision.UserRole, "CrossTenant");
decision.UserIdentifier, decision.UserRole, "CrossTenant", catalogEntry.Policy);
}
// Tenant suspension gate — the operator kill-switch (TenantConfiguration.Disabled/DisabledUntil) and
@@ -238,7 +310,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
!string.IsNullOrEmpty(suspended.DisabledReason)
? suspended.DisabledReason
: "Your tenant has been suspended. Please contact support for more information.",
decision.UserIdentifier, decision.UserRole, "TenantSuspended");
decision.UserIdentifier, decision.UserRole, "TenantSuspended", catalogEntry.Policy);
}
}
@@ -656,12 +728,17 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
public string UserIdentifier { get; private init; } = "anonymous";
public string UserRole { get; private init; } = "N/A";
public string LogReason { get; private init; } = "";
/// <summary>
/// Policy tier of the denied catalog entry; null when the route is unregistered (fail-closed deny).
/// Lets <see cref="Invoke"/> single out GlobalAdminOnly denials for the assume-breach report.
/// </summary>
public EndpointPolicy? Policy { get; private init; }
public static PolicyResult Allow(RequestContext context, string user, string role)
=> new() { Allowed = true, Context = context, UserIdentifier = user, UserRole = role, LogReason = "Allow" };
public static PolicyResult Deny(int statusCode, string errorCode, string errorMessage,
string user, string role, string logReason)
string user, string role, string logReason, EndpointPolicy? policy = null)
=> new()
{
Allowed = false,
@@ -671,6 +748,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
UserIdentifier = user,
UserRole = role,
LogReason = logReason,
Policy = policy,
};
}
@@ -283,6 +283,14 @@ builder.Services.AddSingleton<
AutopilotMonitor.Functions.Services.Monitoring.AzurePoisonQueueProbe>();
builder.Services.AddSingleton<OpsAlertDispatchService>();
builder.Services.AddSingleton<OpsEventService>();
// Assume-breach observer on the policy middleware's deny path (GlobalAdminOnly 403 → Critical ops event).
builder.Services.AddSingleton<IPrivilegedDenialReporter, PrivilegedDenialReporter>();
// Operator KQL proxy (global/raw/logs): the three telemetry stores it can reach, the managed-identity
// credential as an injectable seam, and a client WITHOUT its own timeout — the per-request budget
// (CancellationTokenSource + Prefer: wait) is the only clock.
builder.Services.AddSingleton<LogQuerySourceCatalog>();
builder.Services.AddSingleton<Azure.Core.TokenCredential>(_ => new Azure.Identity.DefaultAzureCredential());
builder.Services.AddHttpClient(LogQuerySourceCatalog.HttpClientName, c => c.Timeout = Timeout.InfiniteTimeSpan);
// IME pattern-drift loop: folds ime_pattern_hits into ImePatternStats and raises
// ImePatternDriftSuspected; singleton because it caches the stats snapshot for evaluation.
builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Ime.ImePatternHealthService>();
@@ -173,6 +173,9 @@ public static class EndpointAccessPolicyCatalog
// ticket is the sole authority — authz happened at mint time (diagnostics/download-ticket,
// MemberRead). See DiagnosticsTicketDownloadFunction.
new("GET", "diagnostics/download", EndpointPolicy.PublicAnonymous),
// Ticket-gated session-report download (MCP/AI clients with no JWT) — same model, its
// own ticket purpose; minted at global/session-reports/download-ticket (GlobalReadOrAdmin).
new("GET", "global/session-reports/download", EndpointPolicy.PublicAnonymous),
// Azure Monitor action-group webhook (Common Alert Schema → ops event). Action groups
// cannot send custom headers, so the shared secret travels as ?secret= and is compared
// in constant time against the OpsAlertWebhookSecret app setting (fail-closed while
@@ -418,10 +421,14 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/raw/events", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
// Raw table/log access stays GlobalAdminOnly: query_table can dump arbitrary tables
// (incl. TenantConfiguration secrets), which would bypass the GlobalReader config-secret
// redaction. A read-only GlobalReader uses the curated read endpoints instead.
// redaction, and the KQL proxy reaches three telemetry stores (backend traces, portal user
// telemetry, MCP container logs). A read-only GlobalReader uses the curated read endpoints.
new("GET", "global/raw/tables", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/raw/tables/{tableName}", EndpointPolicy.GlobalAdminOnly),
new("POST", "global/raw/logs", EndpointPolicy.GlobalAdminOnly),
// No-op GA-only route the MCP fires when a NON-GA caller attempts a GA-only tool: the deny
// path here (PolicyEnforcementMiddleware → PrivilegedRouteDenied) is the assume-breach alarm.
new("GET", "global/raw/access-probe", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/search/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/search/sessions-by-event", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/search/sessions-by-cve", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
@@ -491,6 +498,8 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/metrics/mcp-usage/daily", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/distress-reports", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/session-reports", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
// Mints the HMAC ticket for the anonymous proxied stream above (the MCP's report download).
new("POST", "global/session-reports/download-ticket", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/session-reports/download-url", EndpointPolicy.GlobalReadOrAdmin),
new("PATCH", "global/session-reports/{reportId}/note", EndpointPolicy.GlobalAdminOnly),
// Flywheel evaluation stream: all annotation lanes across tenants (verdict/rule filters).
@@ -77,6 +77,37 @@ namespace AutopilotMonitor.Functions.Services
return _blobServiceClient.GetBlobContainerClient(containerName);
}
/// <summary>
/// Opens a read stream over a blob in an operator container. The caller owns the returned
/// response (it holds the network stream). 404 propagates as <see cref="Azure.RequestFailedException"/>.
/// Proxy alternative to <see cref="GetDownloadUrlAsync"/> for callers that must never receive a SAS.
/// </summary>
public virtual async Task<Azure.Response<Azure.Storage.Blobs.Models.BlobDownloadStreamingResult>> OpenReadAsync(
string containerName, string blobName, CancellationToken cancellationToken = default)
{
var blobClient = _blobServiceClient.GetBlobContainerClient(containerName).GetBlobClient(blobName);
return await blobClient.DownloadStreamingAsync(cancellationToken: cancellationToken);
}
/// <summary>
/// Best-effort blob size via a HEAD request; null on 404 or any failure. Informational only —
/// a ticket mint must never fail because the probe did.
/// </summary>
public virtual async Task<long?> TryGetSizeAsync(string containerName, string blobName, CancellationToken cancellationToken = default)
{
try
{
var blobClient = _blobServiceClient.GetBlobContainerClient(containerName).GetBlobClient(blobName);
var props = await blobClient.GetPropertiesAsync(cancellationToken: cancellationToken);
return props.Value.ContentLength;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Size probe failed for {Container}/{Blob}", containerName, blobName);
return null;
}
}
/// <summary>
/// Generates a time-limited download URL for a blob.
/// Uses User Delegation SAS for Managed Identity, or the connection string SAS for legacy.
@@ -48,7 +48,7 @@ namespace AutopilotMonitor.Functions.Services.Diagnostics
}
// Same container as the report ZIPs themselves (flat namespace, never pruned).
private const string ReportsContainerName = "session-reports";
private const string ReportsContainerName = AutopilotMonitor.Shared.Constants.BlobContainers.SessionReports;
private readonly TenantConfigurationService _configService;
private readonly AdminConfigurationService _adminConfigService;
@@ -0,0 +1,85 @@
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Configuration;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>One resolvable telemetry store behind the operator KQL proxy.</summary>
/// <param name="Name">Wire name (<see cref="LogQuerySources"/>).</param>
/// <param name="QueryUri">The store's Kusto query endpoint.</param>
/// <param name="TokenScope">Entra scope the managed identity requests a token for.</param>
/// <param name="SettingName">App setting that carries the store's id — named in the 503 when absent.</param>
public sealed record LogQuerySource(string Name, Uri QueryUri, string TokenScope, string SettingName);
/// <summary>
/// Resolves a <c>source</c> name to the telemetry store the proxy queries. All three stores speak
/// the same Kusto REST dialect (<c>{query, timespan}</c> in, <c>tables[].columns/rows</c> out) and
/// differ only in host, resource id and token scope:
/// <list type="bullet">
/// <item><c>backend</c>, <c>web</c> — Application Insights components (App-ID from app settings), scope <c>api.applicationinsights.io</c>.</item>
/// <item><c>mcp</c> — the Container App's Log Analytics workspace (customer id), scope <c>api.loganalytics.io</c>.</item>
/// </list>
/// The Function App's managed identity needs <c>Monitoring Reader</c> on each App Insights component
/// and <c>Log Analytics Reader</c> on the workspace (infra: New-TargetInfrastructure.ps1). Settings are
/// read once at construction; a missing one makes that source unavailable (503), never the others.
/// </summary>
public sealed class LogQuerySourceCatalog
{
public const string BackendAppIdSetting = "APPINSIGHTS_APP_ID";
public const string WebAppIdSetting = "APPINSIGHTS_WEB_APP_ID";
public const string McpWorkspaceIdSetting = "MCP_LOG_ANALYTICS_WORKSPACE_ID";
/// <summary>Named HttpClient without a client-side timeout: the per-request budget governs.</summary>
public const string HttpClientName = "log-query";
private const string AppInsightsScope = "https://api.applicationinsights.io/.default";
private const string LogAnalyticsScope = "https://api.loganalytics.io/.default";
private readonly Dictionary<string, LogQuerySource> _sources = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _missing = new(StringComparer.Ordinal);
public LogQuerySourceCatalog(IConfiguration configuration)
{
Register(LogQuerySources.Backend, configuration[BackendAppIdSetting], BackendAppIdSetting,
id => new Uri($"https://api.applicationinsights.io/v1/apps/{id}/query"), AppInsightsScope);
Register(LogQuerySources.Web, configuration[WebAppIdSetting], WebAppIdSetting,
id => new Uri($"https://api.applicationinsights.io/v1/apps/{id}/query"), AppInsightsScope);
Register(LogQuerySources.Mcp, configuration[McpWorkspaceIdSetting], McpWorkspaceIdSetting,
id => new Uri($"https://api.loganalytics.io/v1/workspaces/{id}/query"), LogAnalyticsScope);
}
private void Register(string name, string? id, string settingName, Func<string, Uri> uri, string scope)
{
if (string.IsNullOrWhiteSpace(id))
_missing[name] = settingName;
else
_sources[name] = new LogQuerySource(name, uri(id.Trim()), scope, settingName);
}
/// <summary>Sources that are configured on this instance.</summary>
public IReadOnlyCollection<string> Available => _sources.Keys;
/// <summary>
/// Resolves <paramref name="name"/>. Returns false with <paramref name="error"/> naming the
/// missing app setting (known but unconfigured) or the allowed vocabulary (unknown name).
/// </summary>
public bool TryResolve(string? name, out LogQuerySource? source, out string error)
{
source = null;
error = string.Empty;
var key = string.IsNullOrWhiteSpace(name) ? LogQuerySources.Backend : name.Trim();
if (_sources.TryGetValue(key, out var found))
{
source = found;
return true;
}
if (_missing.TryGetValue(key, out var setting))
{
error = $"Log source '{key}' is not configured on this backend. Set the {setting} app setting and grant the Function App's managed identity read access to that resource.";
return false;
}
error = $"Unknown log source '{key}'. Valid sources: {string.Join(", ", LogQuerySources.All)}.";
return false;
}
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
@@ -321,6 +322,51 @@ namespace AutopilotMonitor.Functions.Services
tenantId, "System.SessionOwnerBinding",
new { sessionId, outcome, callerKind, ownerKind, serialMatch, agentVersion, endpoint, enforced = false });
/// <summary>
/// Assume-breach signal for the Global-Admin-only surface: an authenticated caller was refused
/// (403) on a <c>GlobalAdminOnly</c> route. Fired from the policy middleware's deny path via
/// <see cref="PrivilegedDenialReporter"/> (throttled 1h per caller+path, per instance). The MCP
/// routes a non-GA attempt at a GA-only tool through the same path (access-probe route), so
/// one event type covers both the direct-HTTP probe and the tool-catalog probe.
/// Critical unless the caller is a Global Reader (a known operator, not an intruder) — the
/// severity split lets one rule push Critical to Telegram while Reader slips stay on the page.
/// Recorded against the caller's HOME tenant (JWT tid); the identity is in the details.
/// </summary>
public Task RecordPrivilegedRouteDeniedAsync(PrivilegedDenial d)
{
var isReader = d.CallerRole == Constants.GlobalRoles.GlobalReader;
var severity = isReader ? OpsEventSeverity.Warning : OpsEventSeverity.Critical;
var via = d.ClientSource ?? "direct";
var tool = string.IsNullOrEmpty(d.McpToolName) ? string.Empty : $", tool {d.McpToolName}";
return WriteAsync(OpsEventCategory.Security, OpsEventTypes.PrivilegedRouteDenied, severity,
$"Privileged route denied: {d.Method} {d.Path} for {d.Upn ?? "(no upn)"} (role={d.CallerRole}, via {via}{tool})",
d.TenantId, d.Upn ?? "System.PolicyEnforcement",
new
{
method = d.Method, path = d.Path, statusCode = d.StatusCode, reason = d.Reason, policy = d.Policy,
upn = d.Upn, oid = d.ObjectId, tid = d.TenantId, callerRole = d.CallerRole,
clientSource = d.ClientSource, mcpToolName = d.McpToolName, correlationId = d.CorrelationId,
});
}
/// <summary>
/// The reporter's per-instance budget is spent: one Critical marker (same event type, so the same
/// alert rule pushes it) that says further denials this window are trace-only. A storm is a
/// stronger signal than any single denial — either someone is sweeping the GA surface with a
/// valid token, or an operator script is misconfigured; both deserve the one push, neither
/// deserves fifty.
/// </summary>
public Task RecordPrivilegedRouteDenialStormAsync(PrivilegedDenial last, int cap, TimeSpan window)
=> WriteAsync(OpsEventCategory.Security, OpsEventTypes.PrivilegedRouteDenied, OpsEventSeverity.Critical,
$"Privileged-route denial storm: {cap} events in {window.TotalMinutes:0} min on this instance; further denials stay in the Warning trace until the window resets (last: {last.Method} {last.Path} by {last.Upn ?? "(no upn)"})",
last.TenantId, last.Upn ?? "System.PolicyEnforcement",
new
{
storm = true, cap, windowMinutes = (int)window.TotalMinutes,
lastMethod = last.Method, lastPath = last.Path, lastUpn = last.Upn, lastOid = last.ObjectId, lastTid = last.TenantId,
lastClientSource = last.ClientSource, lastMcpToolName = last.McpToolName,
});
/// <summary>
/// Fired by <see cref="KillSwitchEvaluator"/> when a Kill signal was actually SERVED to
/// an agent (as opposed to DeviceBlocked/VersionBlocked, which fire when the admin
@@ -0,0 +1,130 @@
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// One refused request on a <c>GlobalAdminOnly</c> route, as seen by the policy middleware.
/// Header-derived values (<see cref="ClientSource"/>, <see cref="McpToolName"/>) are caller-supplied
/// and already sanitized/truncated by the middleware before they land here.
/// </summary>
public sealed record PrivilegedDenial(
string Method,
string Path,
int StatusCode,
string Reason,
string Policy,
string CallerId,
string? Upn,
string? ObjectId,
string? TenantId,
string CallerRole,
string? ClientSource,
string? McpToolName,
string? CorrelationId);
/// <summary>
/// Seam between the policy middleware and the ops-event writer so the middleware stays unit-testable
/// without the notification stack (the test harness passes a recording fake).
/// </summary>
public interface IPrivilegedDenialReporter
{
/// <summary>Fire-and-forget; never throws, never blocks the deny response.</summary>
void Report(PrivilegedDenial denial);
}
/// <summary>
/// Assume-breach layer for the Global-Admin-only surface: turns a 403 on a <c>GlobalAdminOnly</c>
/// route into a <c>PrivilegedRouteDenied</c> ops event (Critical, or Warning for a Global Reader)
/// that operators route to a push channel. The authorization decision itself is not touched — this
/// only makes a refused probe visible with the backend's own view of the caller's identity.
/// <para>
/// FLOOD CONTROL. Any holder of a valid token for the API (every signed-in tenant user) can produce
/// these denials at will, so an unbounded alarm would hand an attacker the operator's attention as a
/// denial-of-service target. Two caps, both per instance:
/// <list type="bullet">
/// <item>one event per CALLER per hour, whatever route or tool they try — a sweep over every GA route
/// is one event, not one per route (every attempt still lands in the Warning trace);</item>
/// <item>at most <see cref="MaxEventsPerWindow"/> events per hour in total; the next denial produces ONE
/// Critical "storm" marker and everything after it stays trace-only until the window resets.</item>
/// </list>
/// Worst case per instance and hour: <see cref="MaxEventsPerWindow"/> + 1 pushes. Flex Consumption
/// fans a burst over several instances, so the fleet-wide figure is that times the instances that saw
/// traffic — bounded, and the storm marker is itself the stronger signal.
/// </para>
/// </summary>
public sealed class PrivilegedDenialReporter : IPrivilegedDenialReporter
{
/// <summary>Per-caller and per-instance budget window.</summary>
internal static readonly TimeSpan Window = TimeSpan.FromHours(1);
/// <summary>Normal events an instance emits per window before it switches to one storm marker.</summary>
internal const int MaxEventsPerWindow = 5;
private const string BudgetKey = "prd:budget";
private readonly OpsEventService _opsEvents;
private readonly IMemoryCache _cache;
private readonly ILogger<PrivilegedDenialReporter> _logger;
private readonly object _gate = new();
public PrivilegedDenialReporter(OpsEventService opsEvents, IMemoryCache cache, ILogger<PrivilegedDenialReporter> logger)
{
_opsEvents = opsEvents;
_cache = cache;
_logger = logger;
}
public void Report(PrivilegedDenial denial)
{
try
{
Task? write;
lock (_gate)
{
// Per caller, not per path: the key is the identity, so a scripted sweep over the
// whole GA surface yields one event carrying the first path it touched.
var callerKey = $"prd:{denial.CallerId}";
if (_cache.TryGetValue(callerKey, out _))
return;
// Per-instance budget, FIXED window from the first event (IMemoryCache is per Function
// instance — deliberately no storage round-trip on a deny path; see the class comment
// for the fleet arithmetic).
var now = DateTimeOffset.UtcNow;
var budget = _cache.TryGetValue<BudgetWindow>(BudgetKey, out var open) && open != null && open.EndsAt > now
? open
: new BudgetWindow(now.Add(Window));
budget.Count++;
_cache.Set(BudgetKey, budget, budget.EndsAt - now);
var count = budget.Count;
if (count > MaxEventsPerWindow + 1)
return; // storm already marked this window: trace-only from here
_cache.Set(callerKey, true, Window);
write = count <= MaxEventsPerWindow
? _opsEvents.RecordPrivilegedRouteDeniedAsync(denial)
: _opsEvents.RecordPrivilegedRouteDenialStormAsync(denial, MaxEventsPerWindow, Window);
}
_ = write.ContinueWith(
t => _logger.LogWarning(t.Exception?.InnerException, "PrivilegedRouteDenied ops event failed"),
TaskContinuationOptions.OnlyOnFaulted);
}
catch (Exception ex)
{
// Never let the observer break the deny response.
_logger.LogWarning(ex, "PrivilegedRouteDenied reporting failed");
}
}
private sealed class BudgetWindow
{
public BudgetWindow(DateTimeOffset endsAt) => EndsAt = endsAt;
public DateTimeOffset EndsAt { get; }
public int Count { get; set; }
}
}
}
@@ -163,9 +163,31 @@ namespace AutopilotMonitor.Functions.Services
return (true, existsEvidence);
}
/// <summary>
/// Optional same-event value filter (FilterField/FilterOperator/FilterValue): keeps only
/// events whose filter field satisfies the operator. Shared by <c>event_count</c> (which
/// events are counted) and <c>event_data</c>/<c>event_data_array</c> (which event
/// instances the main dataField/operator may match). The filter and the main test are
/// evaluated on the SAME event, which independent conditions cannot express — each
/// condition scans all events on its own, so "durationSeconds ≥ 300" and
/// "backfilled = false" as two conditions would match two different episodes
/// (ANALYZE-DEV-012 firing on a pre-session backfilled hibernate, backlog q8n).
/// A missing filter field stringifies to empty, so <c>equals</c> excludes the event
/// and <c>not_equals</c> keeps it. No filter configured = unchanged behaviour.
/// </summary>
private List<EnrollmentEvent> ApplyValueFilter(RuleCondition condition, List<EnrollmentEvent> matchingEvents)
{
if (string.IsNullOrEmpty(condition.FilterField) || string.IsNullOrEmpty(condition.FilterOperator))
return matchingEvents;
return matchingEvents
.Where(e => MatchesOperator(GetDataFieldValue(e, condition.FilterField) ?? string.Empty, condition.FilterOperator, condition.FilterValue))
.ToList();
}
private (bool matched, object evidence) EvaluateEventDataCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList();
var matchingEvents = ApplyValueFilter(condition, events.Where(e => MatchesEventType(e, condition.EventType)).ToList());
foreach (var evt in matchingEvents)
{
@@ -214,7 +236,7 @@ namespace AutopilotMonitor.Functions.Services
/// </summary>
private (bool matched, object evidence) EvaluateEventDataArrayCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList();
var matchingEvents = ApplyValueFilter(condition, events.Where(e => MatchesEventType(e, condition.EventType)).ToList());
foreach (var evt in matchingEvents)
{
@@ -362,16 +384,9 @@ namespace AutopilotMonitor.Functions.Services
private (bool matched, object evidence) EvaluateEventCountCondition(RuleCondition condition, List<EnrollmentEvent> events)
{
var matchingEvents = events.Where(e => MatchesEventType(e, condition.EventType)).ToList();
// Optional value filter: count only events whose FilterField satisfies FilterOperator/FilterValue
// (e.g. only performance_snapshot events with memory_used_percent > 90).
if (!string.IsNullOrEmpty(condition.FilterField) && !string.IsNullOrEmpty(condition.FilterOperator))
{
matchingEvents = matchingEvents
.Where(e => MatchesOperator(GetDataFieldValue(e, condition.FilterField) ?? string.Empty, condition.FilterOperator, condition.FilterValue))
.ToList();
}
var matchingEvents = ApplyValueFilter(condition, events.Where(e => MatchesEventType(e, condition.EventType)).ToList());
// count_per_group_gte: group by DataField value (e.g. appId), fire if any group >= threshold
if (condition.Operator == "count_per_group_gte" && !string.IsNullOrEmpty(condition.DataField) && int.TryParse(condition.Value, out var groupThreshold))
@@ -31,7 +31,7 @@ namespace AutopilotMonitor.Functions.Services
private readonly TenantConfigurationService _configService;
private readonly IRuleRepository _ruleRepo;
private readonly ISessionAnnotationRepository _annotationRepo;
private const string ContainerName = "session-reports";
private const string ContainerName = AutopilotMonitor.Shared.Constants.BlobContainers.SessionReports;
public SessionReportService(
INotificationRepository notificationRepo,
@@ -0,0 +1,108 @@
/**
* get_deployment_state: the drift arithmetic and the fetch fan-out. fetch is stubbed per URL so
* every branch is reachable without the network: in-sync, drifted (live ≠ manifest), a manifest
* 404 (unknown + partialErrors, never a silent in-sync), the backend's `commitHash` spelling,
* the agent's not-applicable verdict and the MCP's in-process live stamp.
*/
import { describe, it, expect, vi } from 'vitest';
import { assessDrift, collectDeploymentState, toStamp, DEPLOYMENT_SOURCES, mcpLiveStamp } from '../deployment-state.js';
import { SERVER_VERSION } from '../build-info.js';
type Body = Record<string, unknown> | { status: number };
function stubFetch(bodies: Partial<Record<keyof typeof DEPLOYMENT_SOURCES, Body>>): ReturnType<typeof vi.fn> {
const byUrl = new Map<string, Body>();
for (const [key, body] of Object.entries(bodies)) byUrl.set(DEPLOYMENT_SOURCES[key as keyof typeof DEPLOYMENT_SOURCES], body!);
return vi.fn(async (url: string) => {
const body = byUrl.get(String(url));
if (!body) return new Response('missing stub', { status: 599 });
if ('status' in body && Object.keys(body).length === 1) return new Response('', { status: body.status as number });
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
});
}
const ALL_IN_SYNC = {
backendLive: { status: 'healthy', version: '1.5.1200', commitHash: 'abc1234', buildUtc: '2026-09-04T10:00:00Z' },
backendManifest: { component: 'backend', version: '1.5.1200', commit: 'abc1234', buildUtc: '2026-09-04T10:00:00Z', deployedUtc: '2026-09-04T10:05:00Z', runId: '1' },
webLive: { component: 'web', commit: 'def5678', buildUtc: '2026-09-04T09:00:00Z' },
webManifest: { component: 'web', commit: 'def5678', deployedUtc: '2026-09-04T09:05:00Z', runId: '2' },
mcpManifest: { component: 'mcp', version: SERVER_VERSION, commit: 'unknown', buildUtc: 'unknown', docsCommit: 'ddd1111', deployedUtc: '2026-09-04T11:00:00Z', runId: '3' },
agentManifest: { version: '2.4.900', commit: '9999999', buildUtc: '2026-09-01T00:00:00Z', sha256: 'ff', bootstrapVersion: '2.1' },
};
describe('toStamp', () => {
it('normalizes the backend commitHash spelling and drops absent keys', () => {
expect(toStamp({ version: '1.5.1', commitHash: 'abc1234', buildUtc: 'x' })).toEqual({ version: '1.5.1', commit: 'abc1234', buildUtc: 'x' });
expect(toStamp({ commit: 'def5678', runId: 42 })).toEqual({ commit: 'def5678' });
expect(toStamp(null)).toBeNull();
});
});
describe('assessDrift', () => {
it('compares commits when both halves are known', () => {
expect(assessDrift({ commit: 'a' }, { commit: 'a' }, { liveApplicable: true })).toEqual({ drift: 'in-sync' });
const d = assessDrift({ commit: 'a' }, { commit: 'b' }, { liveApplicable: true });
expect(d.drift).toBe('drifted');
expect(d.note).toContain('a ≠ manifest commit b');
});
it('is never silently in-sync when a half is missing', () => {
expect(assessDrift(null, { commit: 'a' }, { liveApplicable: true }).drift).toBe('unknown');
expect(assessDrift({ commit: 'a' }, null, { liveApplicable: true })).toMatchObject({ drift: 'unknown', note: expect.stringContaining('404') });
expect(assessDrift({ commit: 'unknown' }, { commit: 'a' }, { liveApplicable: true })).toMatchObject({ drift: 'unknown', note: expect.stringContaining('local build') });
expect(assessDrift({ commit: 'a' }, { version: '1' }, { liveApplicable: true }).drift).toBe('unknown');
});
it('marks the agent not-applicable', () => {
expect(assessDrift(null, { commit: 'a' }, { liveApplicable: false }).drift).toBe('not-applicable');
});
});
describe('collectDeploymentState', () => {
it('fetches all six sources in parallel and reports every component', async () => {
const fetchMock = stubFetch(ALL_IN_SYNC);
const state = await collectDeploymentState(fetchMock as never);
expect(fetchMock).toHaveBeenCalledTimes(6);
expect(state.backend).toMatchObject({ drift: 'in-sync', live: { version: '1.5.1200', commit: 'abc1234' }, manifest: { commit: 'abc1234', runId: '1' } });
expect(state.web).toMatchObject({ drift: 'in-sync', live: { commit: 'def5678' }, manifest: { commit: 'def5678' } });
expect(state.agent).toMatchObject({ drift: 'not-applicable', live: null, manifest: { version: '2.4.900', sha256: 'ff', bootstrapVersion: '2.1' } });
// The MCP's live half is this process (a test run has no BUILD_COMMIT → unknown → never in-sync).
expect(state.mcp.live).toEqual(mcpLiveStamp());
expect(state.mcp.manifest?.docsCommit).toBe('ddd1111');
expect(['unknown', 'in-sync']).toContain(state.mcp.drift);
expect(state.partialErrors).toBeUndefined();
expect(state.checkedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it('reports drift when the live commit differs from the verified manifest', async () => {
const state = await collectDeploymentState(stubFetch({
...ALL_IN_SYNC,
backendLive: { version: '1.5.1199', commitHash: 'old0000', buildUtc: 'x' },
}) as never);
expect(state.backend.drift).toBe('drifted');
expect(state.backend.note).toContain('old0000 ≠ manifest commit abc1234');
});
it('a manifest 404 lands in partialErrors and as unknown, and never hides the other components', async () => {
const state = await collectDeploymentState(stubFetch({ ...ALL_IN_SYNC, webManifest: { status: 404 } }) as never);
expect(state.web).toMatchObject({ drift: 'unknown', manifest: null, live: { commit: 'def5678' } });
expect(state.web.note).toContain('404');
expect(state.partialErrors).toEqual({ webManifest: 'HTTP 404' });
expect(state.backend.drift).toBe('in-sync');
});
it('an unreachable live endpoint is unknown with the failure listed', async () => {
const fetchMock = stubFetch(ALL_IN_SYNC);
fetchMock.mockImplementationOnce(async () => { throw new Error('fetch failed'); });
const state = await collectDeploymentState(fetchMock as never);
expect(state.backend.drift).toBe('unknown');
expect(state.backend.live).toBeNull();
expect(state.partialErrors?.backendLive).toBe('fetch failed');
});
});
@@ -33,7 +33,7 @@ function captureToolHandlers(ga: boolean): Record<string, Handler> {
function capturePromptNames(ga: boolean): string[] {
const names: string[] = [];
const fake = { registerPrompt: (name: string) => { names.push(name); } };
registerPrompts(fake as never, ga);
registerPrompts(fake as never, ga, ga);
return names;
}
@@ -0,0 +1,102 @@
/**
* The assume-breach probe: a caller WITHOUT the GlobalAdmin role naming a GA-only tool
* must (a) leave an unconditional security log line with identity and scope and (b) fire
* the backend access probe with the attempted tool name — while a Global Admin, a call to
* a non-GA tool, or a non-tool request must do neither. Pure unit level: the caller
* context is set via runWithCaller, fetch is stubbed.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { runWithCaller } from '../client.js';
import { attemptedToolName, isGaToolProbe, observeGaToolProbe, ACCESS_PROBE_PATH } from '../ga-tool-probe.js';
import { GA_STRICT_TOOL_NAMES } from '../tools/admin.js';
const call = (name: string) => ({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: {} } });
let fetchMock: ReturnType<typeof vi.fn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchMock = vi.fn(async () => new Response('{"error":"Forbidden"}', { status: 403 }));
vi.stubGlobal('fetch', fetchMock);
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.unstubAllGlobals();
errorSpy.mockRestore();
});
async function flush(): Promise<void> {
await new Promise((r) => setTimeout(r, 0));
}
function securityLine(): string | undefined {
return errorSpy.mock.calls.map((c) => String(c[0])).find((l) => l.startsWith('[mcp-security] ga-tool-denied'));
}
describe('classification', () => {
it('extracts the tool name only from a tools/call body', () => {
expect(attemptedToolName(call('query_backend_logs'))).toBe('query_backend_logs');
expect(attemptedToolName({ method: 'tools/list' })).toBeUndefined();
expect(attemptedToolName({ method: 'tools/call', params: { name: 42 } })).toBeUndefined();
expect(attemptedToolName(undefined)).toBeUndefined();
});
it('flags every strictGa tool for a tenant caller and none for a Global Admin', () => {
for (const name of GA_STRICT_TOOL_NAMES) {
expect(runWithCaller({ token: 't', isGlobalAdmin: false }, () => isGaToolProbe(call(name)))).toBe(name);
expect(runWithCaller({ token: 't', isGlobalAdmin: true }, () => isGaToolProbe(call(name)))).toBeUndefined();
}
});
it('flags a Global Reader too (ga scope, but not strictGa)', () => {
expect(runWithCaller({ token: 't', isGlobalAdmin: false, isGlobalReader: true }, () => isGaToolProbe(call('query_table'))))
.toBe('query_table');
});
it('ignores non-GA tools and unknown names', () => {
expect(runWithCaller({ token: 't', isGlobalAdmin: false }, () => isGaToolProbe(call('get_session_summary')))).toBeUndefined();
expect(runWithCaller({ token: 't', isGlobalAdmin: false }, () => isGaToolProbe(call('no_such_tool')))).toBeUndefined();
});
});
describe('observeGaToolProbe', () => {
it('logs an unconditional security line and fires the access probe with the tool name', async () => {
runWithCaller({ token: 'tok', isGlobalAdmin: false, upn: 'user@contoso.com' }, () =>
observeGaToolProbe(call('query_backend_logs')));
await flush();
expect(securityLine()).toBe('[mcp-security] ga-tool-denied tool=query_backend_logs upn=user@contoso.com scope=tenant');
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url.endsWith(ACCESS_PROBE_PATH)).toBe(true);
const headers = init.headers as Record<string, string>;
expect(headers['X-MCP-Tool-Name']).toBe('query_backend_logs');
expect(headers['Authorization']).toBe('Bearer tok');
expect(headers['X-Client-Source']).toBe('mcp');
});
it('reports the Reader scope so the log distinguishes an operator from an outsider', async () => {
runWithCaller({ token: 'tok', isGlobalAdmin: false, isGlobalReader: true, upn: 'reader@contoso.com' }, () =>
observeGaToolProbe(call('query_table')));
await flush();
expect(securityLine()).toContain('scope=ga');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('is silent for a Global Admin and for non-GA tools', async () => {
runWithCaller({ token: 'tok', isGlobalAdmin: true }, () => observeGaToolProbe(call('query_backend_logs')));
runWithCaller({ token: 'tok', isGlobalAdmin: false }, () => observeGaToolProbe(call('get_session_summary')));
runWithCaller({ token: 'tok', isGlobalAdmin: false }, () => observeGaToolProbe({ method: 'tools/list' }));
await flush();
expect(fetchMock).not.toHaveBeenCalled();
expect(securityLine()).toBeUndefined();
});
it('never throws when the probe request fails', async () => {
fetchMock.mockImplementation(async () => { throw new Error('network down'); });
expect(() => runWithCaller({ token: 'tok', isGlobalAdmin: false }, () => observeGaToolProbe(call('list_tables')))).not.toThrow();
await flush();
});
});
@@ -16,7 +16,7 @@
* No backend token needed: validate_rule is MCP-local, and catalogs are built
* without search providers.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import express from 'express';
import type { Server } from 'node:http';
import { Client } from '@modelcontextprotocol/client';
@@ -29,6 +29,8 @@ const DEPS: ServerDeps = { serverVersion: '0.0.0-test', knowledgeBase: undefined
/** Flipped per test: what accessGuard would have resolved for the caller. */
let callerIsGlobalAdmin = true;
/** Read-only Global Reader: platform scope without the strictGa surface. */
let callerIsGlobalReader = false;
let httpServer: Server;
let url: URL;
@@ -43,7 +45,7 @@ beforeAll(async () => {
// Mirrors index.ts: the guard scopes the caller via AsyncLocalStorage around
// the dispatch; the factory must observe it through the SDK entry.
app.post('/mcp', (req, res) =>
runWithCaller({ token: 'test-token', isGlobalAdmin: callerIsGlobalAdmin }, () => handler(req, res)),
runWithCaller({ token: 'test-token', isGlobalAdmin: callerIsGlobalAdmin, isGlobalReader: callerIsGlobalReader, upn: 'caller@contoso.com' }, () => handler(req, res)),
);
await new Promise<void>((resolve) => {
httpServer = app.listen(0, '127.0.0.1', () => resolve());
@@ -205,6 +207,85 @@ describe('role tailoring is identical across eras (privilege-leak guard)', () =>
expect(modern).toContain('list_tenants');
expect(modern).toContain('update_tenant_config');
});
it('a Global Reader sees the platform reads but none of the strictGa tools on either path', async () => {
callerIsGlobalAdmin = false;
callerIsGlobalReader = true;
try {
const modern = await toolNames('auto');
const legacy = await toolNames('legacy');
expect(modern).toEqual(legacy);
expect(modern).toContain('list_tenants');
expect(modern).toContain('get_ops_events');
for (const name of ['query_backend_logs', 'query_table', 'list_tables', 'update_tenant_config', 'get_tenant_config', 'annotate_session']) {
expect(modern, `${name} leaked to a Global Reader`).not.toContain(name);
}
const { client } = await connect('auto');
try {
// The debug prompt must not point a Reader at tools it cannot see.
const prompt = await client.getPrompt({ name: 'debug-session', arguments: { sessionId: 'e259c121-1234-4abc-9def-0123456789ab' } });
const text = prompt.messages.map((m) => (m.content as { text?: string }).text ?? '').join(' ');
expect(text).not.toContain('query_backend_logs');
} finally {
await client.close();
}
} finally {
callerIsGlobalAdmin = true;
callerIsGlobalReader = false;
}
});
it('a Global Admin is pointed at the raw tools by the debug prompt', async () => {
callerIsGlobalAdmin = true;
const { client } = await connect('auto');
try {
const prompt = await client.getPrompt({ name: 'debug-session', arguments: { sessionId: 'e259c121-1234-4abc-9def-0123456789ab' } });
const text = prompt.messages.map((m) => (m.content as { text?: string }).text ?? '').join(' ');
expect(text).toContain('query_backend_logs');
} finally {
await client.close();
}
});
});
describe('assume-breach: a non-GA call to a GA-only tool', () => {
it('is refused as not-found AND fires the backend access probe with the tool name', async () => {
callerIsGlobalAdmin = false;
const realFetch = globalThis.fetch;
// The MCP client transport and the server-side probe share globalThis.fetch: pass the client's
// own POSTs to the loopback server through untouched and answer only the backend probe.
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/api/global/raw/access-probe')) {
return new Response('{"error":"Forbidden"}', { status: 403 });
}
return realFetch(input, init);
});
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const { client } = await connect('auto');
globalThis.fetch = fetchMock as unknown as typeof fetch;
try {
// The SDK refuses before any handler: the 2026 client surfaces that as a thrown protocol error.
await expect(client.callTool({ name: 'query_backend_logs', arguments: { query: 'traces | take 1' } }))
.rejects.toThrow(/query_backend_logs not found/);
} finally {
globalThis.fetch = realFetch;
await client.close();
}
await new Promise((r) => setTimeout(r, 20));
const probe = fetchMock.mock.calls.find(([u]) => String(u).endsWith('/api/global/raw/access-probe'));
expect(probe, 'access probe was not fired').toBeDefined();
const headers = (probe![1] as RequestInit).headers as Record<string, string>;
expect(headers['X-MCP-Tool-Name']).toBe('query_backend_logs');
expect(headers['Authorization']).toBe('Bearer test-token');
const line = errorSpy.mock.calls.map((c) => String(c[0])).find((l) => l.startsWith('[mcp-security] ga-tool-denied'));
expect(line).toContain('tool=query_backend_logs');
expect(line).toContain('upn=caller@contoso.com');
} finally {
errorSpy.mockRestore();
callerIsGlobalAdmin = true;
}
});
});
describe('transport hygiene', () => {
@@ -20,6 +20,7 @@
import { describe, it, expect } from 'vitest';
import { McpServer } from '@modelcontextprotocol/server';
import { registerTools } from '../tools.js';
import { GA_STRICT_TOOL_NAMES } from '../tools/admin.js';
import type { SearchProvider } from '../search-provider.js';
function registeredToolNames(ga: boolean, strictGa: boolean = ga, delegated: boolean = false): string[] {
@@ -113,6 +114,8 @@ describe('role catalog snapshot — privilege-leak guard', () => {
'get_api_usage',
'get_app_install_metrics',
'get_audit_logs',
// Platform scope (GA + Global Reader): public endpoints only (health, manifests), operator view.
'get_deployment_state',
// Available to every role: backed by MemberRead endpoints with TenantScoping.QueryParam /
// role-aware routing to the GlobalReadOrAdmin fleet variant — same placement as
// get_time_attribution (F2 counterpart).
@@ -132,6 +135,9 @@ describe('role catalog snapshot — privilege-leak guard', () => {
'get_session',
'get_session_diagnostics',
'get_session_events',
// Platform scope (GA + Global Reader): ticket-gated download of a submitted report ZIP /
// preserved diagnostics copy — the report counterpart of get_session_diagnostics.
'get_session_report_download',
'get_session_summary',
'get_software_inventory',
'get_tenant_config',
@@ -200,10 +206,12 @@ describe('role catalog snapshot — privilege-leak guard', () => {
'annotate_session',
'get_agent_efficiency_metrics',
'get_api_usage',
'get_deployment_state',
'get_fleet_overview',
'get_ime_pattern_health',
'get_ops_events',
'get_platform_metrics',
'get_session_report_download',
'get_tenant_config',
'get_tenant_config_schema',
'get_verdict_calibration',
@@ -264,6 +272,17 @@ describe('role catalog snapshot — privilege-leak guard', () => {
}
});
// The static set the assume-breach probe (ga-tool-probe.ts) classifies against must equal the
// REGISTERED strictGa difference — a new `if (strictGa)` site that forgets the set would leave
// that tool's probes unreported; a stale entry would report a tool Readers legitimately see.
it('GA_STRICT_TOOL_NAMES equals the registered strictGa difference (GA minus Global Reader)', () => {
const readerNames = registeredToolNames(true, false);
const registeredDiff = registeredToolNames(true, true).filter((n) => !readerNames.includes(n)).sort();
expect([...GA_STRICT_TOOL_NAMES].sort()).toEqual(registeredDiff);
expect([...GA_STRICT_TOOL_NAMES].sort()).toEqual(
[...RAW_GA_STRICT, ...CONFIG_WRITE_GA_STRICT, ...ANNOTATION_WRITE_GA_STRICT].sort());
});
it('the secret-bearing raw tools are a subset of the platform-only tools', () => {
// A raw GA-strict tool is by definition also platform-only; this documents
// (and enforces) that the Reader split never re-exposes a tenant-hidden tool.
@@ -0,0 +1,33 @@
/**
* Build identity of THIS server process — one place for /health, the boot line, the MCP
* handshake and the get_deployment_state tool (which reports the MCP's live state from
* in-process values: the process answering the call IS the live deployment).
*/
import { resolve, dirname } from 'node:path';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8')) as { version: string };
// Version contract shared with the agent and the backend: <major>.<minor>.<build>,
// where major.minor is curated in package.json and <build> is reserved per build
// from the counter blob versions/mcp.txt and baked in by deploy-mcp.yml. Without
// the env var (any local run) the package.json version stands as-is, which is how
// /health and the MCP handshake tell a workstation from a deployed image.
const BUILD_NUMBER = process.env.BUILD_NUMBER ?? '';
export const SERVER_VERSION: string = BUILD_NUMBER
? `${pkg.version.split('.').slice(0, 2).join('.')}.${BUILD_NUMBER}`
: pkg.version;
// Commit of THIS repo the image was built from. Counterpart to DOCS_COMMIT below:
// that one tracks the documentation bundle, this one the server itself.
export const BUILD_COMMIT = process.env.BUILD_COMMIT ?? 'unknown';
export const BUILD_UTC = process.env.BUILD_UTC ?? 'unknown';
// Commit of the docs bundle this image was built from. The docs repo changes
// independently of this one, so a deployed image can silently serve stale
// documentation; surfacing the SHA on /health makes that checkable instead of a
// thing to remember.
export const DOCS_COMMIT = process.env.DOCS_COMMIT ?? 'unknown';
@@ -167,6 +167,11 @@ export function getHomeTenantId(): string | undefined {
* when no context is active / no UPN was captured. Display label for the synthesized home-tenant entry
* in list_tenants — see CallerContext.upn.
*/
/** The current caller's UPN (lowercase) as the access check recorded it, or undefined without a context. */
export function getCallerUpn(): string | undefined {
return callerStore.getStore()?.upn;
}
export function getCallerUpnDomain(): string | undefined {
const upn = callerStore.getStore()?.upn ?? '';
const at = upn.indexOf('@');
@@ -34,6 +34,19 @@ export const ENTRA_LOGIN_BASE_URL = 'https://login.microsoftonline.com';
/** Published customer documentation — base for citable search_docs links. */
export const DOCS_BASE_URL = 'https://docs.autopilotmonitor.com';
/** The customer portal (SWA); serves /version.json with the deployed commit. */
export const PORTAL_BASE_URL = 'https://portal.autopilotmonitor.com';
/**
* Cross-component deploy records (versions/{backend,mcp,web}.json), written by each deploy
* workflow only after it verified the live endpoint. Blob origin on purpose: the pipelines and
* the operator skill read the same URL, and the download. alias is documented for /agent only.
*/
export const VERSIONS_BASE_URL = 'https://autopilotmonitoreu.blob.core.windows.net/versions';
/** Agent release line (Front Door alias, route caching disabled): version.json is the current manifest. */
export const AGENT_DOWNLOAD_BASE_URL = 'https://download.autopilotmonitor.com/agent';
/**
* Pretty-print EVERY tool result (2-space indentation) instead of compact JSON. Off by default:
* the consumer of a tool result is a language model whose context window is not gzipped —
@@ -0,0 +1,158 @@
/**
* Deployment state of the four components in one call — the data half of the operator's
* "is what I pushed actually live?" question. Every source is public/anonymous:
*
* backend live GET {API}/api/health → { version, commitHash, buildUtc }
* backend manifest versions/backend.json → { component, version, commit, buildUtc, deployedUtc, runId }
* mcp live in-process build info (this process IS the live deployment)
* mcp manifest versions/mcp.json → … + docsCommit
* web live GET {PORTAL}/version.json → { component, commit, buildUtc }
* web manifest versions/web.json → { component, commit, deployedUtc, runId }
* agent manifest {AGENT}/version.json → { version, commit, buildUtc, sha256, bootstrapVersion }
* (no live endpoint — the agent runs on devices)
*
* A manifest is written by the deploy workflow ONLY after it polled the live endpoint until
* that exact version + commit answered, so "manifest ≠ live" means the running instance drifted
* AFTER a verified deploy (rollback, stale revision, failed restart) — the case a live-vs-git
* comparison cannot see. The local-git half (HEAD vs live) stays in the backend-version skill:
* this server has no checkout.
*/
import { API_BASE_URL, PORTAL_BASE_URL, VERSIONS_BASE_URL, AGENT_DOWNLOAD_BASE_URL } from './config.js';
import { SERVER_VERSION, BUILD_COMMIT, BUILD_UTC, DOCS_COMMIT } from './build-info.js';
export type Drift = 'in-sync' | 'drifted' | 'unknown' | 'not-applicable';
export interface VersionStamp {
version?: string;
commit?: string;
buildUtc?: string;
deployedUtc?: string;
runId?: string;
docsCommit?: string;
sha256?: string;
bootstrapVersion?: string;
}
export interface ComponentState {
live: VersionStamp | null;
manifest: VersionStamp | null;
drift: Drift;
note?: string;
}
export interface DeploymentState {
checkedAt: string;
backend: ComponentState;
mcp: ComponentState;
web: ComponentState;
agent: ComponentState;
/** Source label → error text for every fetch that failed; absent when all succeeded. */
partialErrors?: Record<string, string>;
}
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
/** Fetch timeout per source; the MCP is not a fetch source here (in-process), so no cold start to wait for. */
const FETCH_TIMEOUT_MS = 10_000;
/** Ordered like the deploy chain: backend → web → mcp, agent last. */
export const DEPLOYMENT_SOURCES = {
backendLive: `${API_BASE_URL}/api/health`,
backendManifest: `${VERSIONS_BASE_URL}/backend.json`,
webLive: `${PORTAL_BASE_URL}/version.json`,
webManifest: `${VERSIONS_BASE_URL}/web.json`,
mcpManifest: `${VERSIONS_BASE_URL}/mcp.json`,
agentManifest: `${AGENT_DOWNLOAD_BASE_URL}/version.json`,
} as const;
type SourceKey = keyof typeof DEPLOYMENT_SOURCES;
async function fetchJson(fetchImpl: FetchLike, url: string): Promise<Record<string, unknown>> {
const res = await fetchImpl(url, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return (await res.json()) as Record<string, unknown>;
}
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.length > 0 ? v : undefined);
/** Backend /api/health spells the commit `commitHash`; every manifest spells it `commit`. */
export function toStamp(raw: Record<string, unknown> | null): VersionStamp | null {
if (!raw) return null;
const stamp: VersionStamp = {
version: str(raw.version),
commit: str(raw.commit) ?? str(raw.commitHash),
buildUtc: str(raw.buildUtc),
deployedUtc: str(raw.deployedUtc),
runId: str(raw.runId),
docsCommit: str(raw.docsCommit),
sha256: str(raw.sha256),
bootstrapVersion: str(raw.bootstrapVersion),
};
// Drop absent keys so the result stays lean.
for (const k of Object.keys(stamp) as Array<keyof VersionStamp>) if (stamp[k] === undefined) delete stamp[k];
return stamp;
}
/**
* Pure drift verdict for one component. Both commits known → compared; the agent (no live
* endpoint) is not-applicable; anything else is unknown with a note that says which half is
* missing — never a silent in-sync.
*/
export function assessDrift(live: VersionStamp | null, manifest: VersionStamp | null, opts: { liveApplicable: boolean }): { drift: Drift; note?: string } {
if (!opts.liveApplicable) return { drift: 'not-applicable', note: 'no live endpoint (runs on devices); the manifest is the release pointer' };
if (!manifest) return { drift: 'unknown', note: 'manifest unavailable (404 = never deployed since manifests exist)' };
if (!live) return { drift: 'unknown', note: 'live endpoint unavailable' };
if (!live.commit || live.commit === 'unknown') return { drift: 'unknown', note: 'live instance reports no commit (local build?)' };
if (!manifest.commit) return { drift: 'unknown', note: 'manifest carries no commit' };
if (live.commit === manifest.commit) return { drift: 'in-sync' };
return {
drift: 'drifted',
note: `live commit ${live.commit} ≠ manifest commit ${manifest.commit}: the running instance changed after the verified deploy (rollback, stale revision, failed restart)`,
};
}
/** The MCP's own live stamp — the process answering this call. */
export function mcpLiveStamp(): VersionStamp {
return { version: SERVER_VERSION, commit: BUILD_COMMIT, buildUtc: BUILD_UTC, docsCommit: DOCS_COMMIT };
}
/**
* Collects all sources in parallel (allSettled: one unreachable store must not hide the others),
* assesses drift per component and lists every failed fetch under partialErrors.
*/
export async function collectDeploymentState(fetchImpl: FetchLike = fetch): Promise<DeploymentState> {
const keys = Object.keys(DEPLOYMENT_SOURCES) as SourceKey[];
const settled = await Promise.allSettled(keys.map((k) => fetchJson(fetchImpl, DEPLOYMENT_SOURCES[k])));
const got: Partial<Record<SourceKey, Record<string, unknown>>> = {};
const partialErrors: Record<string, string> = {};
settled.forEach((r, i) => {
const key = keys[i];
if (r.status === 'fulfilled') got[key] = r.value;
else partialErrors[key] = r.reason instanceof Error ? r.reason.message : String(r.reason);
});
const backendLive = toStamp(got.backendLive ?? null);
const backendManifest = toStamp(got.backendManifest ?? null);
const webLive = toStamp(got.webLive ?? null);
const webManifest = toStamp(got.webManifest ?? null);
const mcpManifest = toStamp(got.mcpManifest ?? null);
const agentManifest = toStamp(got.agentManifest ?? null);
const mcpLive = mcpLiveStamp();
const component = (live: VersionStamp | null, manifest: VersionStamp | null, liveApplicable = true): ComponentState =>
({ live, manifest, ...assessDrift(live, manifest, { liveApplicable }) });
const state: DeploymentState = {
checkedAt: new Date().toISOString(),
backend: component(backendLive, backendManifest),
web: component(webLive, webManifest),
mcp: component(mcpLive, mcpManifest),
agent: component(null, agentManifest, false),
};
if (Object.keys(partialErrors).length) state.partialErrors = partialErrors;
return state;
}
@@ -0,0 +1,59 @@
/**
* ASSUME-BREACH observer for the Global-Admin-only tool surface.
*
* The GA-only tools are simply not registered for a caller without the GlobalAdmin
* role, so a `tools/call` naming one of them is refused by the SDK before any handler
* runs ("Tool X not found") and no backend request is ever made. That is correct as a
* boundary — and blind as a signal: the backend, which owns the identity binding and
* the `PrivilegedRouteDenied` ops event (Critical → push), never learns that someone
* without the role asked for `query_backend_logs`.
*
* This module closes that gap without adding a second alarm writer:
* 1. an UNCONDITIONAL security log line (not gated on MCP_TOOL_LOGGING, same style as
* the `[mcp-auth]` 401/403 lines in access-guard.ts) naming tool, upn and scope;
* 2. a fire-and-forget GET on the backend's GlobalAdminOnly no-op route
* `/api/global/raw/access-probe`, with `X-MCP-Tool-Name` = the attempted tool. The
* backend's policy middleware refuses it exactly like any other GA route and its
* deny path raises the throttled ops event with the backend's own view of the JWT.
*
* The SDK's answer to the client is unchanged (-32602 Tool not found). A caller who IS
* a Global Admin never reaches this: the tool is registered and dispatches normally.
*/
import { apiFetch, isGlobalAdmin, getCallerUpn } from './client.js';
import { callerScope } from './telemetry.js';
import { GA_STRICT_TOOL_NAMES } from './tools/admin.js';
/** Backend route whose DENIAL is the alarm. Registered GlobalAdminOnly; a GA gets a typed OK. */
export const ACCESS_PROBE_PATH = '/api/global/raw/access-probe';
/** The tool name of a `tools/call` body, or undefined for any other request shape. */
export function attemptedToolName(body: unknown): string | undefined {
const b = body as { method?: unknown; params?: { name?: unknown } } | undefined;
if (b?.method !== 'tools/call') return undefined;
return typeof b.params?.name === 'string' ? b.params.name : undefined;
}
/** Pure classification: is this request a non-GA caller asking for a GA-only tool? */
export function isGaToolProbe(body: unknown): string | undefined {
const name = attemptedToolName(body);
if (!name || !GA_STRICT_TOOL_NAMES.has(name) || isGlobalAdmin()) return undefined;
return name;
}
/**
* Observe one POST /mcp body. Must run inside the caller's async context (runWithCaller)
* and BEFORE dispatch. Never throws and never blocks the response: the probe request is
* detached, its outcome irrelevant here (the backend records it, or it fails silently).
*/
export function observeGaToolProbe(body: unknown): void {
try {
const tool = isGaToolProbe(body);
if (!tool) return;
console.error(`[mcp-security] ga-tool-denied tool=${tool} upn=${getCallerUpn() ?? '?'} scope=${callerScope()}`);
void apiFetch(ACCESS_PROBE_PATH, { headers: { 'X-MCP-Tool-Name': tool } }).catch(() => {
// Expected: the backend answers 403 (that IS the signal). Nothing to do here.
});
} catch {
// Observing must never break the request path.
}
}
@@ -14,6 +14,13 @@ export interface AcceptDelegationInvitationResponse {
managedTenantId: string;
}
/** Success body of GET /api/global/raw/access-probe (GlobalAccessProbe): the no-op GlobalAdminOnly route the MCP fires when a non-GA caller attempts a GA-only tool, so the backend's deny path records the probe. A Global Admin gets this body; everyone else gets the middleware's 403. */
export interface AccessProbeResponse {
success: boolean;
/** Always "GlobalAdmin" — the only role that reaches the body. */
role: string;
}
/** One currently active web user as listed by GET global/presence. */
export interface ActiveUserItem {
tenantId: string;
@@ -2122,6 +2129,20 @@ export interface IsGlobalAdminResponse {
upn?: string;
}
/** A Kusto column header: name plus the store's scalar type name (string, long, real, datetime, dynamic, bool). */
export interface KqlColumn {
name: string;
type: string;
}
/** One Kusto result table: column headers once, then rows as positional cell arrays. */
export interface KqlTable {
name: string;
columns: KqlColumn[];
/** Positional cells, one list per row, aligned with Columns. Cells are forwarded as the JSON the store produced (numbers stay numbers, customDimensions stays the JSON string App Insights emits, nulls stay null) — JsonElement is the honest "unknown" in the wire manifest. */
rows: unknown[][];
}
/** Response of GET global/backups: every backupId in the critical-table-backups container, newest first. Serialized with SerializerOptions (the backup surface's own options), not the ApiJsonOptions pipeline. */
export interface ListBackupsResponse {
backupIds: string[];
@@ -2631,6 +2652,24 @@ export interface ProgressLookupSessionResponse {
session?: SessionSummary;
}
/** Success body of POST /api/global/raw/logs (QueryBackendLogs): the KQL result of one telemetry store in the Kusto REST shape (tables[].columns/rows), wrapped with the source it came from and the proxy's own observations. The table shape is kept verbatim so every consumer that parsed the raw App Insights body keeps working. */
export interface QueryBackendLogsResponse {
success: boolean;
/** The store the query ran against — one of LogQuerySources.All. */
source: string;
/** ISO 8601 duration the query was bounded to (echo of the request, defaulted). */
timespan: string;
/** Server-side budget the upstream call ran under (clamped request value). */
budgetSeconds: number;
/** Wall-clock time of the upstream call. */
elapsedMs: number;
/** True when the store answered 200 but flagged the result as incomplete (Kusto PartialError: result-size cap, shard timeout). Absent on a complete result — never silent, because a truncated aggregate reads like a smaller number. */
partial?: boolean;
/** The store's own explanation when Partial is true. */
partialReason?: string;
tables: KqlTable[];
}
/** Success body of GET /api/raw/events and /api/global/raw/events (QueryRawEvents / QueryRawEventsGlobal): raw event rows, PascalCase-verbatim stored columns. */
export interface QueryRawEventsResponse {
/** Null on the global scope when no tenantId filter was given (cross-tenant query). */
@@ -2842,11 +2881,11 @@ export interface RuleCondition {
value: string;
/** Whether this condition must match for the rule to fire If false, it only contributes to confidence scoring */
required: boolean;
/** Optional value filter for "event_count": only events whose FilterField satisfies FilterOperator/FilterValue are counted (e.g. count only performance_snapshot events with memory_used_percent > 90). Applies before counting for both count_gte and count_per_group_gte. */
/** Optional same-event value filter: only events whose FilterField satisfies FilterOperator/FilterValue are considered by the condition. For "event_count": applies before counting (both count_gte and count_per_group_gte), e.g. count only performance_snapshot events with memory_used_percent > 90. For "event_data"/"event_data_array": the filter and the main DataField/Operator test are evaluated on the SAME event instance — the only way to pin two fields of one event together, since separate conditions each scan all events independently (e.g. system_sleep_episode with durationSeconds >= 300 AND backfilled = false). */
filterField: string;
/** Operator for the count filter. Uses same operators as the main Operator field. */
/** Operator for the same-event filter. Uses the same operators as the main Operator field. */
filterOperator: string;
/** Value for the count filter. */
/** Value for the same-event filter. */
filterValue: string;
/** The second event type to correlate with (Event B). Example: "app_install_failed" */
correlateEventType: string;
@@ -100,6 +100,7 @@ export const OPS_EVENT_TYPES = [
"PoisonQueueBacklogHigh",
"PoisonQueueBacklogCritical",
"ExcessiveSessionEventsAutoActioned",
"PrivilegedRouteDenied",
"OffboardingFeedbackReceived",
"TenantOffboarded",
"TenantOffboardingFailed",
@@ -151,3 +152,11 @@ export const ANNOTATION_VERDICTS = [
"inconclusive",
] as const;
export type AnnotationVerdict = (typeof ANNOTATION_VERDICTS)[number];
/** Telemetry stores behind the operator KQL proxy — query_backend_logs `source` (C# LogQuerySources). */
export const LOG_SOURCES = [
"backend",
"web",
"mcp",
] as const;
export type LogSourceName = (typeof LOG_SOURCES)[number];
@@ -16,25 +16,12 @@ import { createOAuthRouter } from './oauth.js';
import { accessGuard } from './access-guard.js';
import { toolLoggingEnabled, attachToolCallRejectionSniffer } from './telemetry.js';
import { API_BASE_URL } from './config.js';
import { SERVER_VERSION, BUILD_COMMIT, BUILD_UTC, DOCS_COMMIT } from './build-info.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8')) as { version: string };
// Version contract shared with the agent and the backend: <major>.<minor>.<build>,
// where major.minor is curated in package.json and <build> is reserved per build
// from the counter blob versions/mcp.txt and baked in by deploy-mcp.yml. Without
// the env var (any local run) the package.json version stands as-is, which is how
// /health and the MCP handshake tell a workstation from a deployed image.
const BUILD_NUMBER = process.env.BUILD_NUMBER ?? '';
const SERVER_VERSION: string = BUILD_NUMBER
? `${pkg.version.split('.').slice(0, 2).join('.')}.${BUILD_NUMBER}`
: pkg.version;
// Commit of THIS repo the image was built from. Counterpart to DOCS_COMMIT below:
// that one tracks the documentation bundle, this one the server itself.
const BUILD_COMMIT = process.env.BUILD_COMMIT ?? 'unknown';
const BUILD_UTC = process.env.BUILD_UTC ?? 'unknown';
// Build identity (version contract, commits) lives in build-info.ts so the deployment-state
// tool reports the same values /health does.
/**
* Boot stopwatch. minReplicas=0 means every idle period ends in a cold start a user
@@ -58,11 +45,6 @@ const RULES_DIR = process.env.RULES_DIR ?? resolve(__dirname, '..', '..', '..',
// registered. Point DOCS_DIR at a local clone to develop against it.
const DOCS_DIR = process.env.DOCS_DIR ?? '';
// Commit of the docs bundle this image was built from. The docs repo changes
// independently of this one, so a deployed image can silently serve stale
// documentation; surfacing the SHA on /health makes that checkable instead of a
// thing to remember.
const DOCS_COMMIT = process.env.DOCS_COMMIT ?? 'unknown';
// Surface the MCP_PUBLIC_URL state at boot. In production a missing pin is a
// hard boot failure (config.ts throws — host-spoofing defense); this dev-only
@@ -29,6 +29,7 @@ import type { Request, Response } from 'express';
import { createMcpHandler, isLegacyRequest } from '@modelcontextprotocol/server';
import type { McpServer, McpRequestContext } from '@modelcontextprotocol/server';
import { NodeStreamableHTTPServerTransport, toNodeHandler, toWebRequest } from '@modelcontextprotocol/node';
import { observeGaToolProbe } from './ga-tool-probe.js';
/** Builds the per-request server. Runs inside the caller's async context (see access-guard runWithCaller). */
export type PerRequestServerFactory = (ctx: McpRequestContext) => McpServer;
@@ -67,6 +68,11 @@ export function createMcpRequestHandler(
const modernNode = toNodeHandler(modern, { onerror });
return async (req, res) => {
// Assume-breach signal BEFORE dispatch: a non-GA caller naming a GA-only tool is refused by
// the SDK as "not found" and would otherwise leave no trace with an identity. Runs inside the
// caller's async context (both eras share this entry). Never throws, never alters the response.
observeGaToolProbe(req.body);
// Classification needs the method + headers of the request and the
// (already parsed) body. Passing `parsedBody` means nothing is read from
// the Node stream — express.json() already drained it.
@@ -120,8 +120,10 @@ export function createMcpServer(deps: ServerDeps, ga: boolean, strictGa: boolean
registerTools(s, deps.knowledgeBase, deps.eventTypeIndex, deps.docs, ga, strictGa, delegated);
registerResources(s);
// A delegated caller has no platform scope, so prompts get the tenant-user surface (ga=false) —
// the cross-tenant prompt wording would be misleading for a tenant-bounded MSP user.
registerPrompts(s, ga);
// the cross-tenant prompt wording would be misleading for a tenant-bounded MSP user. strictGa
// gates the prompt steps that name the strictGa-only raw tools (a Reader must not be told to
// call tools it cannot see).
registerPrompts(s, ga, strictGa);
return s;
}
@@ -14,7 +14,7 @@ import { z } from 'zod';
* with Zod but keep types as strings; numeric/optional semantics are described
* in the argument text for the model.
*/
export function registerPrompts(server: McpServer, ga: boolean): void {
export function registerPrompts(server: McpServer, ga: boolean, strictGa: boolean): void {
server.registerPrompt(
'investigate-failed-session',
{
@@ -89,7 +89,9 @@ export function registerPrompts(server: McpServer, ga: boolean): void {
'Use get_session_events / query_raw_events for the raw stream around the first error; gaps ' +
'between agent log and Events reveal upload/network issues. Use search_knowledge to look up ' +
'rules / IME patterns / error codes you encounter.\n' +
(ga
// strictGa, not ga: query_backend_logs / query_table are registered for a real Global
// Admin only — a Global Reader must not be pointed at tools it cannot see.
(strictGa
? '4. If the problem looks like a backend ingest/upload issue (events the agent logged as sent ' +
'are missing, or status disagrees with events), use query_backend_logs (App Insights KQL) and ' +
'query_table (e.g. RuleResults, AppInstallSummaries) to trace it.\n'
@@ -145,7 +145,10 @@ export const RULE_AUTHORING_GUIDE = {
'on a field inside those events. Most common source.',
event_data:
'Compare a data field inside events of eventType (dataField + operator + value). Supports ' +
'dot-paths into nested objects; "message" resolves to the event message.',
'dot-paths into nested objects; "message" resolves to the event message. Optional ' +
'filterField/filterOperator/filterValue pin a second field of the SAME event (e.g. ' +
'durationSeconds gte 300 with filter backfilled equals false) — two separate conditions ' +
'would match two different events.',
event_data_array:
'Iterate an ARRAY field (dataField) inside ONE event and test a sub-field of each element ' +
'(itemField; empty = element itself). Matches if ANY element passes. Allow-list pattern: ' +
@@ -496,11 +496,11 @@ export const ANALYZE_RULE_SCHEMA: Record<string, unknown> = {
},
"filterField": {
"type": "string",
"description": "For event_count only: optional value filter — only events whose filterField satisfies filterOperator/filterValue are counted (e.g. count only performance_snapshot events with memory_used_percent > 90). Applies before counting for both count_gte and count_per_group_gte."
"description": "Optional same-event value filter for event_count, event_data and event_data_array: only events whose filterField satisfies filterOperator/filterValue are considered. For event_count it applies before counting (both count_gte and count_per_group_gte, e.g. count only performance_snapshot events with memory_used_percent > 90). For event_data/event_data_array the filter and the main dataField/operator test are evaluated on the SAME event instance — the only way to pin two fields of one event together, since separate conditions each scan all events independently (e.g. system_sleep_episode with durationSeconds gte 300 AND backfilled equals false)."
},
"filterOperator": {
"type": "string",
"description": "Operator for the event_count value filter.",
"description": "Operator for the same-event value filter. Same vocabulary as operator.",
"enum": [
"equals",
"not_equals",
@@ -520,7 +520,7 @@ export const ANALYZE_RULE_SCHEMA: Record<string, unknown> = {
},
"filterValue": {
"type": "string",
"description": "Value for the event_count value filter."
"description": "Value for the same-event value filter. Boolean values are stringified (\"true\"/\"false\")."
},
"correlateEventType": {
"type": "string",
@@ -1,4 +1,4 @@
import { runWithToolName, hasGlobalScope, isDelegated } from './client.js';
import { runWithToolName, hasGlobalScope, isDelegated, getCallerUpn } from './client.js';
export const toolLoggingEnabled = process.env.MCP_TOOL_LOGGING === 'true';
@@ -63,7 +63,7 @@ export function summarizeArgs(
}
/** Caller scope for usage analysis — which audience a tool struggle belongs to. */
function callerScope(): 'ga' | 'delegated' | 'tenant' {
export function callerScope(): 'ga' | 'delegated' | 'tenant' {
if (hasGlobalScope()) return 'ga';
if (isDelegated()) return 'delegated';
return 'tenant';
@@ -175,6 +175,11 @@ export function logToolCallRejection(toolName: string, errorCode: number, messag
tool: toolName,
errorCode,
message: cap(message, 500),
// Who was refused: without scope + upn a "tool not found" from a caller probing a
// GA-only tool is indistinguishable from a typo (the security line in ga-tool-probe.ts
// is the unconditional counterpart; this one is the usage-analysis view).
scope: callerScope(),
upn: getCallerUpn(),
timestamp: new Date().toISOString(),
}));
} catch {
@@ -13,7 +13,8 @@ import type { DocsSearchBundle } from './search-provider.js';
* cross-tenant read tools. `strictGa` is true ONLY for a real Global Admin and gates two groups whose
* backend endpoints stay GlobalAdminOnly: the raw tools (list_tables / query_table /
* query_backend_logs — they can dump secret-bearing tables that the GlobalReader config redaction
* would otherwise hide) and the tenant-config write surface (get_tenant_config /
* would otherwise hide, and the KQL proxy reaches three telemetry stores incl. portal user
* telemetry and the MCP container logs) and the tenant-config write surface (get_tenant_config /
* get_tenant_config_schema / update_tenant_config / list_tenant_config_backups /
* revert_tenant_config — the server's only mutating tools; every write is snapshot-backed
* and verified server-side). When `ga` is false
@@ -5,6 +5,27 @@ import { withToolTelemetry } from '../telemetry.js';
import { getResourceContent, assertKnownEventType, 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, toolResultText, SessionIdSchema, TenantGuidSchema, tenantIdDescription } from './shared.js';
import { toolError } from './error-handler.js';
import { API_BASE_URL } from '../config.js';
import { collectDeploymentState } from '../deployment-state.js';
/**
* The tools registered ONLY for a real Global Admin (`strictGa`), by name. Static on purpose: the
* per-request registration below only runs for callers who pass the gate, so a non-GA request could
* never discover the set at runtime — yet that request is exactly the one the assume-breach probe
* (ga-tool-probe.ts) must classify. tools-catalog-order.test.ts pins this set against the registered
* catalog difference (GA minus Global Reader), so a new `if (strictGa)` site cannot drift from it.
*/
export const GA_STRICT_TOOL_NAMES: ReadonlySet<string> = new Set([
'annotate_session',
'list_tables',
'query_table',
'query_backend_logs',
'get_tenant_config',
'get_tenant_config_schema',
'update_tenant_config',
'list_tenant_config_backups',
'revert_tenant_config',
]);
/** Default first-page size of get_fleet_overview's session list (a fleet snapshot, not a sweep). */
const FLEET_OVERVIEW_PAGE_SIZE = 25;
@@ -45,6 +66,7 @@ export function buildFleetOverview(
}
import { shapeVerdictCalibration } from '../verdict-calibration-shape.js';
import type {
DiagnosticsDownloadTicketResponse,
GeographicLocationSessionsLeanResponse,
GetAllTenantConfigurationsResponse,
GetGlobalMcpUsageDailyResponse,
@@ -69,6 +91,7 @@ import {
ANNOTATION_LANES,
ANNOTATION_VERDICTS,
EVENT_SEVERITIES,
LOG_SOURCES,
OPS_EVENT_CATEGORIES,
OPS_EVENT_SEVERITIES,
SESSION_STATUSES,
@@ -1066,6 +1089,99 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
})
);
// Tool 18a: get_deployment_state — platform scope; every source is a public endpoint, the
// tool only saves the operator six fetches and the drift arithmetic.
if (ga) server.registerTool(
'get_deployment_state',
{
title: 'Get Deployment State',
description:
'What is live vs. what CI verified as deployed, for all four components in one call: backend ' +
'(/api/health vs versions/backend.json), web (portal /version.json vs versions/web.json), mcp (this ' +
'server\'s own build vs versions/mcp.json) and agent (release manifest only — it runs on devices). ' +
'A manifest is written by the deploy workflow only AFTER the live endpoint answered with exactly that ' +
'version + commit, so drift="drifted" means the running instance changed after a verified deploy ' +
'(rollback, stale revision, failed restart) — the case a live-vs-git comparison cannot see. ' +
'drift="unknown" carries a note saying which half is missing; a manifest 404 means "never deployed since ' +
'manifests exist", not an outage. Failed fetches are listed under partialErrors. Commits are 7-char short ' +
'SHAs; compare them against your local git HEAD yourself (git merge-base --is-ancestor <commit> HEAD).',
inputSchema: {},
annotations: READ_ONLY,
},
async (args) => withToolTelemetry('get_deployment_state', args, async () => {
try {
const state = await collectDeploymentState();
return toolResultText(state, MAX_RESULT_SIZE_CHARS.small);
} catch (error: unknown) {
return toolError('get_deployment_state', args, error);
}
})
);
// Tool 18b: get_session_report_download — platform scope; the report-ZIP counterpart of
// get_session_diagnostics (same ticket model, its own ticket purpose and container).
if (ga) server.registerTool(
'get_session_report_download',
{
title: 'Get Session Report Download (Report ZIP)',
description:
'Returns a short-lived, ready-to-use download URL for one blob of the operator session-reports store: ' +
'the report ZIP a tenant admin submitted (blobName from list_session_reports), the preserved copy of the ' +
'session\'s diagnostics archive (diagnosticsBlobName, when the reporter attached it), or a diag-files report. ' +
'Global Admin / Global Reader only.\n\n' +
'CLIENT REQUIREMENT: needs a client that can download files and run local file/shell tools (e.g. Claude ' +
'Code); a pure chat client only gets a link a human can open.\n\n' +
'HOW TO USE: download the ZIP from "downloadUrl" — NO auth header (short-lived signed ticket, ~10 min) — ' +
'unzip it locally and read report-metadata.json first (reporter, comment, what was attached, export ' +
'completeness), then session.csv / events.csv / ruleresults.csv / timeline.txt (what the reporter SAW), ' +
'screenshots (view them as images) and any attached logs; nested agent-logs.zip / screenshots.zip inside ' +
'must be unzipped too. A preserved diagnostics copy has the diag_zip_layout resource layout ' +
'(get_resource(name="diag_zip_layout")). The backend never unzips or parses the archive.',
inputSchema: {
blobName: z.string().min(1).describe(
'Flat blob name from list_session_reports: `blobName` (report ZIP, …_diag_request_….zip), ' +
'`diagnosticsBlobName` (preserved diagnostics copy, …_diag_archive_….zip) or a diag-files report (…_diag_files_….zip).'),
},
annotations: READ_ONLY,
},
async (args) => withToolTelemetry('get_session_report_download', args, async () => {
try {
const { blobName } = args;
const ticket = await apiFetch('/api/global/session-reports/download-ticket', {
method: 'POST',
body: JSON.stringify({ blobName }),
}) as DiagnosticsDownloadTicketResponse;
if (!ticket?.url) {
return toolError('get_session_report_download', args,
new Error('Backend did not return a download URL for the report ticket.'));
}
const downloadUrl = ticket.url.startsWith('http') ? ticket.url : `${API_BASE_URL}${ticket.url}`;
const kind = /_diag_archive_/.test(blobName) ? 'preserved-diagnostics'
: /_diag_files_/.test(blobName) ? 'diag-files-report' : 'session-report';
return toolResultText({
available: true,
blobName: ticket.blobName ?? blobName,
kind,
destination: ticket.destination,
sizeBytes: ticket.sizeBytes ?? null,
downloadUrl,
expiresAt: ticket.expiresAt,
instructions:
'Download the ZIP from downloadUrl with NO auth header (short-lived signed ticket), unzip it locally ' +
'(including nested agent-logs.zip / screenshots.zip), read report-metadata.json first and view screenshots ' +
'as images. The reporter\'s comment there is the primary question; diff what they saw (session.csv, ' +
'timeline.txt) against the live session (get_session_summary / get_session_events). The URL expires at ' +
'expiresAt — re-call this tool for a fresh one.',
...(kind === 'preserved-diagnostics' ? { zipLayoutResource: 'get_resource(name="diag_zip_layout")' } : {}),
}, MAX_RESULT_SIZE_CHARS.small);
} catch (error: unknown) {
return toolError('get_session_report_download', args, error);
}
})
);
// Tool 18b: list_session_annotations — platform scope (GA + Global Reader).
if (ga) server.registerTool(
'list_session_annotations',
@@ -1414,23 +1530,41 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
if (strictGa) server.registerTool(
'query_backend_logs',
{
title: 'Query Backend Logs',
title: 'Query Platform Logs (KQL)',
description:
'Query backend Application Insights logs using KQL. Global Admin only. ' +
'Use for debugging backend issues, tracing requests by correlation ID, and platform diagnostics.',
'Run a KQL query against one of the platform telemetry stores. Global Admin only. ' +
'The query is forwarded VERBATIM (same REST API as az monitor app-insights/log-analytics query); ' +
'the result is the Kusto shape `tables[].columns/rows` plus `source`, `budgetSeconds`, `elapsedMs` and — ' +
'when the store cut the result (size cap, shard timeout) — `partial: true` with `partialReason`. ' +
'Sources: "backend" (default) = the API\'s Application Insights: requests (one canonical row per HTTP call, ' +
'customDimensions.Source == \'WorkerMiddleware\'), traces, exceptions, customEvents, dependencies; ' +
'"web" = the portal\'s Application Insights: pageViews, browser customEvents, browserTimings, client dependencies; ' +
'"mcp" = the MCP Container App\'s Log Analytics workspace: ContainerAppConsoleLogs_CL (column Log_s is the ' +
'raw JSON line — parse_json(Log_s)) and ContainerAppSystemLogs_CL. ' +
'Budget: the backend runs the query for at most budgetSeconds (default 30, max 180) and answers 504 with a ' +
'hint when exceeded — there is no continuation (Kusto has none): narrow the timespan, aggregate with ' +
'summarize, cap rows with take. Both `timespan` and any ago() in the query bound the window; keep them ' +
'consistent. Write string literals in single quotes (the call is JSON) and project only the columns you need.',
inputSchema: {
query: z.string().describe('KQL query (e.g. "traces | where message contains \'error\' | take 50")'),
timespan: z.string().optional().default('PT1H').describe('ISO 8601 duration (default: PT1H = last 1 hour). Examples: PT30M, PT6H, P1D'),
timespan: z.string().optional().default('PT1H').describe('ISO 8601 duration (default: PT1H = last 1 hour). Examples: PT30M, PT6H, P1D, P7D'),
source: z.enum(LOG_SOURCES).optional().default('backend')
.describe('Telemetry store: backend (API App Insights, default), web (portal App Insights), mcp (MCP Container App Log Analytics).'),
budgetSeconds: z.coerce.number().int().min(5).max(180).optional().default(30)
.describe('Server-side wall-clock budget for the query (5-180, default 30). Raise it for multi-day summarize scans instead of retrying a timeout.'),
},
annotations: READ_ONLY_OPEN,
},
async (args) => withToolTelemetry('query_backend_logs', args, async () => {
try {
const budget = args.budgetSeconds ?? 30;
const data = await apiFetch('/api/global/raw/logs', {
method: 'POST',
body: JSON.stringify({ query: args.query, timespan: args.timespan }),
body: JSON.stringify({ query: args.query, timespan: args.timespan, source: args.source, budgetSeconds: budget }),
// The backend enforces the budget; the client only needs to outlast it (token mint + transfer margin).
signal: AbortSignal.timeout((budget + 15) * 1000),
});
return toolResultText(data, MAX_RESULT_SIZE_CHARS.events);
return toolResultText(data, MAX_RESULT_SIZE_CHARS.rawTable);
} catch (error: unknown) {
return toolError('query_backend_logs', args, error);
}
@@ -1438,10 +1572,14 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
);
// ── Tenant-config write surface (transactional) ───────────────────────
// Security note: all four tools are strictGa (real Global Admin only) — the backend
// routes (PATCH config/{tenantId}/fields, GET .../backups, POST .../revert) are
// GlobalAdminOnly to match. get_tenant_config is read-only but ALSO strictGa: its
// full-config view (even redacted) exposes operational settings no Reader needs.
// Security note: all five tools are strictGa (real Global Admin only). Backend backing per
// route (EndpointAccessPolicyCatalog): GET .../backups and POST .../revert are GlobalAdminOnly;
// PATCH config/{tenantId}/fields is TenantAdminOrGA and GET config/{tenantId} is MemberRead
// (secrets redacted for non-admins) — for those two the strictGa registration is the MCP-side
// decision to keep the operator config surface off the Reader/tenant catalog, NOT a claim that
// the backend would refuse a tenant admin on the same URL with their own token.
// get_tenant_config is read-only but ALSO strictGa: its full-config view (even redacted)
// exposes operational settings no Reader needs.
// The write flow is transactional server-side: fail-closed pre-write snapshot →
// ETag-conditional write → re-read → verify that EXACTLY the intended fields
// changed → automatic rollback on drift. Every change is revertible.
@@ -428,7 +428,7 @@ namespace AutopilotMonitor.Shared
public const string DiskSpaceLow = "disk_space_low"; // One-shot Warning (state-change-only, 1 GB hysteresis — NOT periodic) when the system drive drops below 2 GB free. Review MON-B10: disk-full was previously only visible inside Debug performance_snapshot payloads (filtered out of timelines), so a disk-starved enrollment carried no actionable threshold signal.
public const string EspPolicyProviderStalled = "esp_policy_provider_stalled"; // One-shot-per-provider Warning: a registered EnrollmentStatusTracking CSP policy provider stayed incomplete >= 15 min (Setup/Apps TrackingPoliciesCreated != 1, or DevicePreparation InstallationState not in {2,3}). The ESP waits on Setup/Apps providers WITHOUT any OS timeout — e.g. pre-provisioning + co-management leaves only "ConfigMgr" registered (no "Sidecar" = IME) and the user ESP sits at "Apps (Identifying)" for days. Observational only; payload carries the full provider list. See EspPolicyProviderProbe.
public const string KeepAwakeEngaged = "keep_awake_engaged"; // Info (state-change, NOT periodic) emitted by the opt-in UserEspKeepAwake host when it holds the device awake (system + display) on entering the User-ESP/AccountSetup phase, so idle standby cannot stall app installs / account provisioning. Reboots are unaffected. Per-tenant toggle (AnalyzerConfiguration.KeepAwakeDuringUserEsp, default off).
public const string KeepAwakeReleased = "keep_awake_released"; // Info (Warning when reason=safety_cap) emitted when the keep-awake hold is released — reason = account_setup_complete | host_stop | safety_cap. Brackets KeepAwakeEngaged; the OS also auto-clears the hold on process exit/reboot.
public const string KeepAwakeReleased = "keep_awake_released"; // Info (Warning when reason=safety_cap) emitted when the keep-awake hold is released — reason = user_esp_exited (Shell-Core 62407 after AccountSetup progress, i.e. the user-ESP page closed; agents before 2026-09-04 wrote account_setup_complete for the synthesized completion, which fired minutes early) | host_stop | safety_cap. Brackets KeepAwakeEngaged; the OS also auto-clears the hold on process exit/reboot.
// IME log tracker — app / device / script telemetry (V2 single-rail plan §5.9)
public const string EspPhaseChanged = "esp_phase_changed";
@@ -1407,6 +1407,15 @@ namespace AutopilotMonitor.Shared
/// </summary>
public const string HostedDiagnostics = "diagnostics";
/// <summary>
/// Operator-side session reports: the report ZIP a tenant admin submitted
/// (<c>{tenantId}_{sessionId}_diag_request_{ts}.zip</c>), the preserved copy of the
/// session's diagnostics archive (<c>…_diag_archive_{ts}.zip</c>) and diag-files
/// reports (<c>{tenantId}_diag_files_{ts}.zip</c>). Flat namespace, never pruned.
/// Read by the portal (SAS URL) and the MCP (HMAC ticket, proxied stream).
/// </summary>
public const string SessionReports = "session-reports";
/// <summary>
/// Critical-table backup destination. Daily timer + manual GA trigger write
/// per-table NDJSON dumps under <c>{backupId}/{tableName}.ndjson</c> plus a
@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
namespace AutopilotMonitor.Shared.DataAccess
{
/// <summary>
/// The telemetry stores the operator KQL proxy (<c>POST /api/global/raw/logs</c>, MCP
/// <c>query_backend_logs</c>) can be pointed at. Wire values are lowercase and travel as the
/// request's <c>source</c> field; the MCP derives its <c>z.enum</c> from this list through the
/// shared manifest (section <c>logSources</c>), so the vocabulary is never retyped.
/// </summary>
public static class LogQuerySources
{
/// <summary>The Functions backend's Application Insights component (requests, traces, exceptions, customEvents, dependencies).</summary>
public const string Backend = "backend";
/// <summary>The portal's Application Insights component (pageViews, browser customEvents, browserTimings, client dependencies).</summary>
public const string Web = "web";
/// <summary>The MCP Container App's Log Analytics workspace (ContainerAppConsoleLogs_CL / ContainerAppSystemLogs_CL).</summary>
public const string Mcp = "mcp";
/// <summary>Declaration order; the MCP renders the enum in this order.</summary>
public static readonly IReadOnlyList<string> All = new[] { Backend, Web, Mcp };
public static bool IsKnown(string? source)
=> source != null && Array.IndexOf(new[] { Backend, Web, Mcp }, source) >= 0;
}
}
@@ -70,6 +70,12 @@ namespace AutopilotMonitor.Shared.DataAccess
public const string PoisonQueueBacklogHigh = "PoisonQueueBacklogHigh";
public const string PoisonQueueBacklogCritical = "PoisonQueueBacklogCritical";
public const string ExcessiveSessionEventsAutoActioned = "ExcessiveSessionEventsAutoActioned";
/// <summary>
/// An authenticated caller without the GlobalAdmin role was refused (403) on a
/// <c>GlobalAdminOnly</c> route — including the MCP probe a non-GA tool call triggers.
/// Critical for callers without any platform role, Warning for a Global Reader.
/// </summary>
public const string PrivilegedRouteDenied = "PrivilegedRouteDenied";
// ── Tenant ── Tenant lifecycle: offboarding, trials, plan changes, regression radars, config toggles.
public const string OffboardingFeedbackReceived = "OffboardingFeedbackReceived";
@@ -116,7 +122,7 @@ namespace AutopilotMonitor.Shared.DataAccess
{
ConsentFlowStarted, ConsentFlowSuccess, ConsentFlowFailed, ConsentRedirectUriMismatch, AppHomingFlipped, AppHomingFlippedWithEntraRoles,
MaintenanceCompleted, MaintenanceFailed, MaintenanceLongRunning, SessionSweepCompleted, SessionSweepFailed, OpsEventCleanup, OrphanEventsCleaned, SessionDeletionMaintenanceStarted, SessionDeletionMaintenanceBudgetExceeded, SessionDeletionMaintenanceSkippedLocked, SessionDeletionMaintenanceLongRunning, SessionDeletionMaintenanceLongRunningSevere, SessionDeletionMaintenanceFailed, SessionDeletionStrandedQueued, SessionDeletionPoisoned, SessionDeletionMaintenanceCompleted, SessionDeletionMaintenanceFanoutSkipped, CriticalTableBackupCompleted, CriticalTableBackupPartial, CriticalTableBackupFailed, CriticalTableBackupSkippedLocked, BackupRowRestored, VerdictCalibrationDrift,
DeviceBlocked, VersionBlocked, SessionTenantConflict, SessionOwnerMismatch, KillSignalDelivered, EmbeddedCertExpiringSoon, EmbeddedCertExpiringUrgent, EmbeddedCertExpired, EmbeddedCertBundleEmpty, SignalRConnectionsHigh, SignalRConnectionsCritical, SignalRMessagesHigh, SignalRMessagesCritical, PoisonQueueBacklogHigh, PoisonQueueBacklogCritical, ExcessiveSessionEventsAutoActioned,
DeviceBlocked, VersionBlocked, SessionTenantConflict, SessionOwnerMismatch, KillSignalDelivered, EmbeddedCertExpiringSoon, EmbeddedCertExpiringUrgent, EmbeddedCertExpired, EmbeddedCertBundleEmpty, SignalRConnectionsHigh, SignalRConnectionsCritical, SignalRMessagesHigh, SignalRMessagesCritical, PoisonQueueBacklogHigh, PoisonQueueBacklogCritical, ExcessiveSessionEventsAutoActioned, PrivilegedRouteDenied,
OffboardingFeedbackReceived, TenantOffboarded, TenantOffboardingFailed, TenantAutoApproved, WelcomeEmailSent, WelcomeEmailSkipped, WelcomeEmailFailed, TenantTrialStarted, TenantTrialExpiring, TenantTrialExpired, TenantPlanDowngraded, TenantRetentionGraceExpiring, TenantRetentionGraceEnded, RuleFrequencyRegression, AppVersionDurationRegression, CollectLogsQuickConfigEnabled, DiagnosticsUploadEnabled, DiagnosticsUploadDisabled,
SessionActionQueued, SessionTimeouts, AgentEmergencyBreak, AgentBinaryIntegrityMismatch, CmTraceTimeSkewRegression, ExcessiveSessionEvents, NewImeVersionDetected, ImePatternDriftSuspected, BlobStorageMissing, BlobStorageUnreachable,
SlaBreachNotification, SlaConsecutiveFailures, SlaEvaluationCompleted,
@@ -40,8 +40,14 @@ namespace AutopilotMonitor.Shared.Diagnostics
/// <summary>Default 10-minute ticket expiry — long enough to download, short enough to bound replay.</summary>
public static readonly TimeSpan DefaultTtl = TimeSpan.FromMinutes(10);
/// <summary>Domain-separation tag baked into the signed payload.</summary>
private const string Purpose = "diag-dl-v1";
/// <summary>
/// Domain-separation tag baked into the signed payload — a ticket minted for one download
/// surface never opens another. Diagnostics packages (default) and session-report ZIPs
/// share the codec, the signing key and the TTL, but not the purpose.
/// </summary>
public const string DiagnosticsPurpose = "diag-dl-v1";
/// <summary>Purpose of a ticket for the operator <c>session-reports</c> container (report ZIPs, preserved diag copies).</summary>
public const string SessionReportPurpose = "report-dl-v1";
private const string SigningKeyEnvVar = "PaginationTokenSigningKey";
@@ -86,14 +92,15 @@ namespace AutopilotMonitor.Shared.Diagnostics
string tenantId,
string blobName,
string destination,
DateTimeOffset? issuedAt = null)
DateTimeOffset? issuedAt = null,
string? purpose = null)
{
if (string.IsNullOrWhiteSpace(tenantId)) throw new ArgumentException("tenantId required", nameof(tenantId));
if (string.IsNullOrWhiteSpace(blobName)) throw new ArgumentException("blobName required", nameof(blobName));
var payload = new TicketPayload
{
P = Purpose,
P = purpose ?? DiagnosticsPurpose,
Tid = tenantId,
Blob = blobName,
Dst = destination ?? string.Empty,
@@ -120,7 +127,8 @@ namespace AutopilotMonitor.Shared.Diagnostics
out string destination,
out string? rejectReason,
DateTimeOffset? now = null,
TimeSpan? ttl = null)
TimeSpan? ttl = null,
string? purpose = null)
{
tenantId = string.Empty;
blobName = string.Empty;
@@ -175,8 +183,8 @@ namespace AutopilotMonitor.Shared.Diagnostics
return false;
}
// Domain separation: reject anything not minted as a download ticket.
if (!FixedTimeEquals(payload.P ?? string.Empty, Purpose))
// Domain separation: reject anything not minted for THIS download surface.
if (!FixedTimeEquals(payload.P ?? string.Empty, purpose ?? DiagnosticsPurpose))
{
rejectReason = "wrong_purpose";
return false;
@@ -1,7 +1,60 @@
using System.Collections.Generic;
using System.Text.Json;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order.
/// <summary>
/// Success body of POST /api/global/raw/logs (QueryBackendLogs): the KQL result of one
/// telemetry store in the Kusto REST shape (<c>tables[].columns/rows</c>), wrapped with the
/// source it came from and the proxy's own observations. The table shape is kept verbatim so
/// every consumer that parsed the raw App Insights body keeps working.
/// </summary>
public class QueryBackendLogsResponse : IApiResponse
{
public bool Success { get; set; }
/// <summary>The store the query ran against — one of <c>LogQuerySources.All</c>.</summary>
public string Source { get; set; } = default!;
/// <summary>ISO 8601 duration the query was bounded to (echo of the request, defaulted).</summary>
public string Timespan { get; set; } = default!;
/// <summary>Server-side budget the upstream call ran under (clamped request value).</summary>
public int BudgetSeconds { get; set; }
/// <summary>Wall-clock time of the upstream call.</summary>
public long ElapsedMs { get; set; }
/// <summary>
/// True when the store answered 200 but flagged the result as incomplete (Kusto
/// <c>PartialError</c>: result-size cap, shard timeout). Absent on a complete result —
/// never silent, because a truncated aggregate reads like a smaller number.
/// </summary>
public bool? Partial { get; set; }
/// <summary>The store's own explanation when <see cref="Partial"/> is true.</summary>
public string? PartialReason { get; set; }
public IReadOnlyList<KqlTable> Tables { get; set; } = default!;
}
// Declaration order == wire order.
/// <summary>One Kusto result table: column headers once, then rows as positional cell arrays.</summary>
public class KqlTable
{
public string Name { get; set; } = default!;
public IReadOnlyList<KqlColumn> Columns { get; set; } = default!;
/// <summary>
/// Positional cells, one list per row, aligned with <see cref="Columns"/>. Cells are
/// forwarded as the JSON the store produced (numbers stay numbers, <c>customDimensions</c>
/// stays the JSON string App Insights emits, nulls stay null) — <see cref="JsonElement"/>
/// is the honest "unknown" in the wire manifest.
/// </summary>
public IReadOnlyList<IReadOnlyList<JsonElement>> Rows { get; set; } = default!;
}
// Declaration order == wire order.
/// <summary>A Kusto column header: name plus the store's scalar type name (string, long, real, datetime, dynamic, bool).</summary>
public class KqlColumn
{
public string Name { get; set; } = default!;
public string Type { get; set; } = default!;
}
// Raw endpoints return the literal stored table columns as dictionary rows
// (PascalCase-verbatim, e.g. "PartitionKey", "EventType"). This is wire-safe because
// System.Text.Json only renames PROPERTY names via PropertyNamingPolicy — dictionary
@@ -56,6 +109,19 @@ namespace AutopilotMonitor.Shared.Models
public IReadOnlyList<string> Tables { get; set; } = default!;
}
// Declaration order == wire order.
/// <summary>
/// Success body of GET /api/global/raw/access-probe (GlobalAccessProbe): the no-op GlobalAdminOnly
/// route the MCP fires when a non-GA caller attempts a GA-only tool, so the backend's deny path
/// records the probe. A Global Admin gets this body; everyone else gets the middleware's 403.
/// </summary>
public class AccessProbeResponse : IApiResponse
{
public bool Success { get; set; }
/// <summary>Always "GlobalAdmin" — the only role that reaches the body.</summary>
public string Role { get; set; } = default!;
}
// Declaration order == wire order.
/// <summary>
/// Success body of GET /api/global/raw/tables/{tableName} (QueryRawTable): raw table rows,
@@ -279,24 +279,28 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool Required { get; set; } = false;
// ===== Event Count Filter Properties =====
// Used only when Source = "event_count"
// ===== Same-Event Value Filter Properties =====
// Used when Source = "event_count", "event_data" or "event_data_array"
/// <summary>
/// Optional value filter for "event_count": only events whose FilterField
/// satisfies FilterOperator/FilterValue are counted (e.g. count only
/// performance_snapshot events with memory_used_percent > 90).
/// Applies before counting for both count_gte and count_per_group_gte.
/// Optional same-event value filter: only events whose FilterField satisfies
/// FilterOperator/FilterValue are considered by the condition.
/// For "event_count": applies before counting (both count_gte and count_per_group_gte),
/// e.g. count only performance_snapshot events with memory_used_percent > 90.
/// For "event_data"/"event_data_array": the filter and the main DataField/Operator test
/// are evaluated on the SAME event instance — the only way to pin two fields of one
/// event together, since separate conditions each scan all events independently
/// (e.g. system_sleep_episode with durationSeconds >= 300 AND backfilled = false).
/// </summary>
public string FilterField { get; set; } = default!;
/// <summary>
/// Operator for the count filter. Uses same operators as the main Operator field.
/// Operator for the same-event filter. Uses the same operators as the main Operator field.
/// </summary>
public string FilterOperator { get; set; } = default!;
/// <summary>
/// Value for the count filter.
/// Value for the same-event filter.
/// </summary>
public string FilterValue { get; set; } = default!;
@@ -97,6 +97,12 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
// per memory feedback_ops_event_types_dual_register so operators can wire a
// dedicated Telegram rule independent of the warn-tier ExcessiveSessionEvents.
"ExcessiveSessionEventsAutoActioned",
// Assume-breach layer for the Global-Admin-only surface: an authenticated caller without the
// GlobalAdmin role hit a GlobalAdminOnly route (or, via the MCP probe, a GA-only tool).
// Critical for callers without any platform role, Warning for a Global Reader. Flood-capped
// per instance: one event per caller per hour and at most 5 per hour in total, then ONE
// Critical "storm" marker (PrivilegedDenialReporter). Route it to Telegram at MinSeverity Critical.
"PrivilegedRouteDenied",
"VersionBlocked",
// Delivery confirmation: a Kill signal was actually SERVED to an agent (config or
// telemetry channel) — as opposed to DeviceBlocked/VersionBlocked which fire on rule
@@ -93,6 +93,8 @@ const MCP_VOCABULARIES = [
doc: "Session-annotation lanes (C# AnnotationLanes)." },
{ section: "annotationVerdicts", constName: "ANNOTATION_VERDICTS", typeName: "AnnotationVerdict",
doc: "Session-annotation verdicts (C# AnnotationVerdicts)." },
{ section: "logSources", constName: "LOG_SOURCES", typeName: "LogSourceName",
doc: "Telemetry stores behind the operator KQL proxy — query_backend_logs `source` (C# LogQuerySources)." },
];
/**
@@ -275,6 +275,7 @@ export const SHARED_MANIFEST = {
"PoisonQueueBacklogHigh",
"PoisonQueueBacklogCritical",
"ExcessiveSessionEventsAutoActioned",
"PrivilegedRouteDenied",
"OffboardingFeedbackReceived",
"TenantOffboarded",
"TenantOffboardingFailed",
@@ -308,6 +309,11 @@ export const SHARED_MANIFEST = {
"SlaEvaluationCompleted",
"AzureMonitorAlert"
],
"logSources": [
"backend",
"web",
"mcp"
],
"tenantRoles": [
"Admin",
"Operator",
@@ -271,6 +271,7 @@
"PoisonQueueBacklogHigh",
"PoisonQueueBacklogCritical",
"ExcessiveSessionEventsAutoActioned",
"PrivilegedRouteDenied",
"OffboardingFeedbackReceived",
"TenantOffboarded",
"TenantOffboardingFailed",
@@ -304,6 +305,11 @@
"SlaEvaluationCompleted",
"AzureMonitorAlert"
],
"logSources": [
"backend",
"web",
"mcp"
],
"tenantRoles": [
"Admin",
"Operator",
@@ -577,6 +583,27 @@
}
]
},
"AccessProbeResponse": {
"kind": "object",
"doc": "Success body of GET /api/global/raw/access-probe (GlobalAccessProbe): the no-op GlobalAdminOnly route the MCP fires when a non-GA caller attempts a GA-only tool, so the backend's deny path records the probe. A Global Admin gets this body; everyone else gets the middleware's 403.",
"fields": [
{
"name": "success",
"type": {
"kind": "primitive",
"name": "boolean"
}
},
{
"name": "role",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "Always \"GlobalAdmin\" — the only role that reaches the body."
}
]
},
"ActiveUserItem": {
"kind": "object",
"doc": "One currently active web user as listed by GET global/presence.",
@@ -9883,6 +9910,63 @@
}
]
},
"KqlColumn": {
"kind": "object",
"doc": "A Kusto column header: name plus the store's scalar type name (string, long, real, datetime, dynamic, bool).",
"fields": [
{
"name": "name",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "type",
"type": {
"kind": "primitive",
"name": "string"
}
}
]
},
"KqlTable": {
"kind": "object",
"doc": "One Kusto result table: column headers once, then rows as positional cell arrays.",
"fields": [
{
"name": "name",
"type": {
"kind": "primitive",
"name": "string"
}
},
{
"name": "columns",
"type": {
"kind": "array",
"element": {
"kind": "ref",
"name": "KqlColumn"
}
}
},
{
"name": "rows",
"type": {
"kind": "array",
"element": {
"kind": "array",
"element": {
"kind": "primitive",
"name": "unknown"
}
}
},
"doc": "Positional cells, one list per row, aligned with Columns. Cells are forwarded as the JSON the store produced (numbers stay numbers, customDimensions stays the JSON string App Insights emits, nulls stay null) — JsonElement is the honest \"unknown\" in the wire manifest."
}
]
},
"ListBackupsResponse": {
"kind": "object",
"doc": "Response of GET global/backups: every backupId in the critical-table-backups container, newest first. Serialized with SerializerOptions (the backup surface's own options), not the ApiJsonOptions pipeline.",
@@ -12343,6 +12427,79 @@
}
]
},
"QueryBackendLogsResponse": {
"kind": "object",
"doc": "Success body of POST /api/global/raw/logs (QueryBackendLogs): the KQL result of one telemetry store in the Kusto REST shape (tables[].columns/rows), wrapped with the source it came from and the proxy's own observations. The table shape is kept verbatim so every consumer that parsed the raw App Insights body keeps working.",
"fields": [
{
"name": "success",
"type": {
"kind": "primitive",
"name": "boolean"
}
},
{
"name": "source",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "The store the query ran against — one of LogQuerySources.All."
},
{
"name": "timespan",
"type": {
"kind": "primitive",
"name": "string"
},
"doc": "ISO 8601 duration the query was bounded to (echo of the request, defaulted)."
},
{
"name": "budgetSeconds",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Server-side budget the upstream call ran under (clamped request value)."
},
{
"name": "elapsedMs",
"type": {
"kind": "primitive",
"name": "number"
},
"doc": "Wall-clock time of the upstream call."
},
{
"name": "partial",
"type": {
"kind": "primitive",
"name": "boolean"
},
"optional": true,
"doc": "True when the store answered 200 but flagged the result as incomplete (Kusto PartialError: result-size cap, shard timeout). Absent on a complete result — never silent, because a truncated aggregate reads like a smaller number."
},
{
"name": "partialReason",
"type": {
"kind": "primitive",
"name": "string"
},
"optional": true,
"doc": "The store's own explanation when Partial is true."
},
{
"name": "tables",
"type": {
"kind": "array",
"element": {
"kind": "ref",
"name": "KqlTable"
}
}
}
]
},
"QueryRawEventsResponse": {
"kind": "object",
"doc": "Success body of GET /api/raw/events and /api/global/raw/events (QueryRawEvents / QueryRawEventsGlobal): raw event rows, PascalCase-verbatim stored columns.",
@@ -13257,7 +13414,7 @@
"kind": "primitive",
"name": "string"
},
"doc": "Optional value filter for \"event_count\": only events whose FilterField satisfies FilterOperator/FilterValue are counted (e.g. count only performance_snapshot events with memory_used_percent > 90). Applies before counting for both count_gte and count_per_group_gte."
"doc": "Optional same-event value filter: only events whose FilterField satisfies FilterOperator/FilterValue are considered by the condition. For \"event_count\": applies before counting (both count_gte and count_per_group_gte), e.g. count only performance_snapshot events with memory_used_percent > 90. For \"event_data\"/\"event_data_array\": the filter and the main DataField/Operator test are evaluated on the SAME event instance — the only way to pin two fields of one event together, since separate conditions each scan all events independently (e.g. system_sleep_episode with durationSeconds >= 300 AND backfilled = false)."
},
{
"name": "filterOperator",
@@ -13265,7 +13422,7 @@
"kind": "primitive",
"name": "string"
},
"doc": "Operator for the count filter. Uses same operators as the main Operator field."
"doc": "Operator for the same-event filter. Uses the same operators as the main Operator field."
},
{
"name": "filterValue",
@@ -13273,7 +13430,7 @@
"kind": "primitive",
"name": "string"
},
"doc": "Value for the count filter."
"doc": "Value for the same-event filter."
},
{
"name": "correlateEventType",
@@ -14,6 +14,13 @@ export interface AcceptDelegationInvitationResponse {
managedTenantId: string;
}
/** Success body of GET /api/global/raw/access-probe (GlobalAccessProbe): the no-op GlobalAdminOnly route the MCP fires when a non-GA caller attempts a GA-only tool, so the backend's deny path records the probe. A Global Admin gets this body; everyone else gets the middleware's 403. */
export interface AccessProbeResponse {
success: boolean;
/** Always "GlobalAdmin" — the only role that reaches the body. */
role: string;
}
/** One currently active web user as listed by GET global/presence. */
export interface ActiveUserItem {
tenantId: string;
@@ -2122,6 +2129,20 @@ export interface IsGlobalAdminResponse {
upn?: string;
}
/** A Kusto column header: name plus the store's scalar type name (string, long, real, datetime, dynamic, bool). */
export interface KqlColumn {
name: string;
type: string;
}
/** One Kusto result table: column headers once, then rows as positional cell arrays. */
export interface KqlTable {
name: string;
columns: KqlColumn[];
/** Positional cells, one list per row, aligned with Columns. Cells are forwarded as the JSON the store produced (numbers stay numbers, customDimensions stays the JSON string App Insights emits, nulls stay null) — JsonElement is the honest "unknown" in the wire manifest. */
rows: unknown[][];
}
/** Response of GET global/backups: every backupId in the critical-table-backups container, newest first. Serialized with SerializerOptions (the backup surface's own options), not the ApiJsonOptions pipeline. */
export interface ListBackupsResponse {
backupIds: string[];
@@ -2631,6 +2652,24 @@ export interface ProgressLookupSessionResponse {
session?: SessionSummary;
}
/** Success body of POST /api/global/raw/logs (QueryBackendLogs): the KQL result of one telemetry store in the Kusto REST shape (tables[].columns/rows), wrapped with the source it came from and the proxy's own observations. The table shape is kept verbatim so every consumer that parsed the raw App Insights body keeps working. */
export interface QueryBackendLogsResponse {
success: boolean;
/** The store the query ran against — one of LogQuerySources.All. */
source: string;
/** ISO 8601 duration the query was bounded to (echo of the request, defaulted). */
timespan: string;
/** Server-side budget the upstream call ran under (clamped request value). */
budgetSeconds: number;
/** Wall-clock time of the upstream call. */
elapsedMs: number;
/** True when the store answered 200 but flagged the result as incomplete (Kusto PartialError: result-size cap, shard timeout). Absent on a complete result — never silent, because a truncated aggregate reads like a smaller number. */
partial?: boolean;
/** The store's own explanation when Partial is true. */
partialReason?: string;
tables: KqlTable[];
}
/** Success body of GET /api/raw/events and /api/global/raw/events (QueryRawEvents / QueryRawEventsGlobal): raw event rows, PascalCase-verbatim stored columns. */
export interface QueryRawEventsResponse {
/** Null on the global scope when no tenantId filter was given (cross-tenant query). */
@@ -2842,11 +2881,11 @@ export interface RuleCondition {
value: string;
/** Whether this condition must match for the rule to fire If false, it only contributes to confidence scoring */
required: boolean;
/** Optional value filter for "event_count": only events whose FilterField satisfies FilterOperator/FilterValue are counted (e.g. count only performance_snapshot events with memory_used_percent > 90). Applies before counting for both count_gte and count_per_group_gte. */
/** Optional same-event value filter: only events whose FilterField satisfies FilterOperator/FilterValue are considered by the condition. For "event_count": applies before counting (both count_gte and count_per_group_gte), e.g. count only performance_snapshot events with memory_used_percent > 90. For "event_data"/"event_data_array": the filter and the main DataField/Operator test are evaluated on the SAME event instance — the only way to pin two fields of one event together, since separate conditions each scan all events independently (e.g. system_sleep_episode with durationSeconds >= 300 AND backfilled = false). */
filterField: string;
/** Operator for the count filter. Uses same operators as the main Operator field. */
/** Operator for the same-event filter. Uses the same operators as the main Operator field. */
filterOperator: string;
/** Value for the count filter. */
/** Value for the same-event filter. */
filterValue: string;
/** The second event type to correlate with (Event B). Example: "app_install_failed" */
correlateEventType: string;