Compare commits

...

35 Commits

Author SHA1 Message Date
Oliver Kieselbach 46880b58e7 Portal: view a tenant as its member (Global Admin, read-only)
?viewAs=<tenant id> (optional &viewAsRole=Operator|Viewer) arms a view-as
tab. The selection lives in sessionStorage, outlives the sign-in redirect
of a new tab, and every API call carries the view-as headers, the SignalR
negotiate included. A purple bar names the viewed tenant and leaves the
mode; Tenant Management gets a "View as" link per tenant.

The tab stays isolated from the caller's other tabs and from the viewed
tenant's numbers:
- the dashboard seed is skipped, because a seed is matched by URL alone
- API calls bypass the browser's shared HTTP cache
- the Global-Admin view is forced off without writing localStorage, so
  other tabs keep it
- web telemetry counts the tab at the Global Admin's home tenant and names
  the viewed one as viewAsTenantId
- a refused view-as reloads the tab as an ordinary one and says why once

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:39:34 +01:00
Oliver Kieselbach ee96ff2d23 Backend: read-only "view as tenant" for Global Admins
A Global Admin can send X-View-As-Tenant (optional X-View-As-Role) to read
one tenant's portal exactly as a member of that tenant sees it. The policy
middleware admits the headers only for a Global Admin in person (never an
MCP call or a foreign client app), refuses every write with ViewAsReadOnly,
and decides everything else with the same evaluators over an emulated
member, so the emulation cannot drift from the real rules: a parity test
compares every read route and tenant role against a real member.

- RequestContext.ViewAs: TenantId and TargetTenantId become the viewed
  tenant and TenantHelper.GetTenantId follows them; identity uses keep the
  caller's home tenant (IdentityTenantId)
- Route catalog: ViewAsAccess, GET reads and everything else writes, with
  each exception listed in a test
- auth/me answers as the emulated member without any sign-in side effect
- GET actions stay closed under view-as: re-analyze, re-scan, the consent
  flow and the homing auto-flip
- SignalR binding, presence and the request row stay with the real caller;
  every telemetry item of such a request carries ViewAsTenantId
- Ratchet: functions take the data tenant from the request context, never
  from the token

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:39:12 +01:00
Oliver Kieselbach 4275c13dfc bump pointer: internal (D-420 released: Newtonsoft guard, Graph/Entra parsers on System.Text.Json, follow-ups)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 13:04:18 +01:00
Oliver Kieselbach 1616e33c14 Merge pull request #211 from okieselbach/feat/newtonsoft-guard-upstream-stj
Newtonsoft.Json contained by a guard; Graph/Entra response parsers on System.Text.Json (D-420)
2026-11-06 12:18:10 +01:00
Oliver Kieselbach 0b39455d4e Graph/Entra response parsers: permanent invariant fuzz (D-420)
UpstreamJsonInvariantTests drive the nine production parsers with 500 bodies
each, generated with every value kind, the inputs of the deliberate
differences D1 to D7 included, and check without a reference:
- a parser throws only what its callers expect (JsonException for an
  unreadable body or link, InvalidOperationException for a non-object list
  entry, nothing for the Entra rejection and the managed device), and every
  listed kind still occurs;
- a body reads the same in another layout: compact, or spread out with
  comments and trailing commas, members in another order;
- every text in a result is text the body sent, trimmed or cut at most;
- ParseObjectOrNull yields an object, null or a JsonException for any text,
  edited bodies included.

The body generator and the result descriptions move out of the temporary
differential test into UpstreamResponseCases and UpstreamParserReadings, so
they stay when the Newtonsoft reference goes. The generator's default mode
draws the same random sequence as before, so the differential test still
compares the same 20,000 cases per parser. Value pools and serialized
literals are built once; layers 1, 3 and 4 together run in under a second.

Mutation-checked locally: reading non-text values as their raw JSON fails
18 of the 28 fuzz tests, dropping the element clone fails all of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:59:45 +01:00
Oliver Kieselbach 48baffe753 Graph/Entra response parsers on System.Text.Json (D-420)
The eight parsers for Microsoft Graph and Entra token-endpoint responses read
through a new helper, Helpers/UpstreamJson, instead of Newtonsoft.Json:
GraphTokenService, EntraTokenRejection, the Autopilot, corporate-identifier,
device-association, Cloud PC and Intune-binding validators, and the script
display-name resolver. Every call site keeps its flow; the existing
catch (JsonException) blocks now bind to System.Text.Json.

Unchanged and pinned by the characterization tests: empty and null bodies,
malformed bodies and their outcome per validator, non-object list entries
(transient), paging, duplicate keys (last wins), case-sensitive names,
comments and trailing commas, the token lifetime.

Deliberate differences, each pinned by a test named _D1 to _D7:
- D1 JSON null in a text field reads as null instead of "".
- D2 dates keep their fraction and are culture-independent UTC.
- D3 date-shaped text is kept as sent.
- D4 non-text values, and text whose escapes are no valid UTF-16, read as
  null (fail-closed).
- D5 an expires_in that is not a whole number falls back to the default
  token lifetime instead of throwing or being coerced.
- D6 an error code beyond Int64 is skipped instead of throwing.
- D7 a body cut off mid-object is unreadable; Newtonsoft's DeserializeObject
  read the part before the cut as if it were complete.

Also:
- The script resolver caches a per-ID 200 without a name, as the full pull
  already does, so a nameless script costs no Graph call on every view.
- The token cache caps expires_in before the TimeSpan conversion; a value
  near Int64.MaxValue overflowed instead of hitting the 55-minute cap.
- New UpstreamJsonTests cover every helper member and a culture matrix;
  a TTL test records the cache lifetime per expires_in variant.
- The differential test compares a cut-off body with the parser's answer to
  an unreadable body (D7) and stays green on 20,000 cases per parser.
- The Newtonsoft guard baseline loses the eight parser files and the dead
  using in MaintenanceService.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:40:25 +01:00
Oliver Kieselbach f6f88161f9 Graph/Entra response parsers: differential test against a Newtonsoft reference
LegacyUpstreamParsers keeps a verbatim copy of the eight Newtonsoft parsers in the test project.
UpstreamJsonDifferentialTests generates 20,000 bodies per parser from a fixed seed and requires the
production parser to yield exactly what the reference yields, or to throw where it throws.
The bodies vary:
- root kind, empty and broken bodies, trailing content
- comments, trailing commas, whitespace, property order, unknown and duplicate properties
- value lists of mixed entries, escaped and Unicode text
- paging links, lifetimes as number or numeric text, error-code variants

They leave out the inputs that the move to System.Text.Json changes on purpose; those are pinned in
GraphResponseParsingTests. Each parser must also reach a real result in at least 2 % of the cases,
so the comparison cannot pass on misses alone.

Today production and reference are identical, so the test is green. Altering two parsers in the
reference turned exactly those two tests red. The two files are temporary and go once the System.Text.Json
parsers have run cleanly in production (D-420).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:05:20 +01:00
Oliver Kieselbach d0a525ecbd Graph/Entra response parsers: characterization tests
GraphResponseParsingTests pins what the eight Graph and Entra token-endpoint parsers yield for
every realistic body and every malformed one: empty, null, array and broken bodies, missing or
non-array value lists, non-object entries before and after the match, paging links, lifetimes as
number or text, error-code variants, comments and trailing commas. Written against the current
Newtonsoft code and green on it. The tests ending in _D1 to _D6 pin the behaviour that the move
to System.Text.Json changes on purpose; the rest is the parsers' contract (D-420).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:51:07 +01:00
Oliver Kieselbach 257f403949 Graph/Entra responses: read through pure functions, behaviour unchanged
GraphTokenService, AutopilotDeviceValidator, CorporateIdentifierValidator and
ScriptDisplayNameResolver parsed their response bodies inline in the HTTP methods. The parsing
now sits in internal static functions (ReadTokenResponse, FindRegistration, ReadIdentityPage,
ReadScriptPage, ReadScriptDisplayName) with the same Newtonsoft calls, so every body yields the
same result and every exception still reaches the same catch block. This gives the response
parsers one testable shape ahead of their move to System.Text.Json (D-420).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:43:13 +01:00
Oliver Kieselbach 7cbcb6bc4c Guard: Newtonsoft.Json frozen per file and per project
NewtonsoftUsageGuardTests scans every shipped source file under src/; test projects are excluded, and
bin, obj, node_modules and .next are not entered. A code line that names the Newtonsoft.Json namespace
counts as a use, a comment line does not. The 104 files that use it today form a baseline with the
reason each group still needs it, and only the five projects that reference the package today may
keep the reference. A new use, a new package reference or a stale entry fails the test, so the
baseline can only shrink. New code reads and writes JSON with System.Text.Json (D-420).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 10:38:58 +01:00
Oliver Kieselbach f43462567e bump pointer: internal (D-419: #209 and the CI race fix)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:21:12 +01:00
Oliver Kieselbach b5ac4651a0 Session deletion maintenance test waits for both watchdog events
The severe-watchdog test blocked only until the severe OpsEvent and then asserted the warning as
well. The two delays are independent timers: under load the severe one can complete first, and the
cancel in the finally then drops a warning delay whose callback has not run yet (seen once in CI on
3bc8b87a). The fanout body now waits for both events.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:12:08 +01:00
Oliver Kieselbach 3bc8b87a19 Bump xunit.runner.visualstudio from 2.8.2 to 4.0.1
Applied on main for #209: after the dotnet group update changed the neighbouring xunit lines, the
PR conflicted and Dependabot did not act on the rebase request. The change is the PR's own five
lines. All five test suites keep their counts (8532 / 3891 / 648 / 44 / 125).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:01:16 +01:00
Oliver Kieselbach 950b3cc679 bump pointer: internal (D-419 Dependabot wave: decision, plan, backlog)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 23:40:25 +01:00
dependabot[bot] 0559919a9e Bump Azure.Data.Tables and 15 others (#210)
Bumps Azure.Data.Tables from 12.11.0 to 12.13.0
Bumps Azure.Monitor.Query from 1.5.0 to 1.7.1
Bumps Azure.Storage.Blobs from 12.27.0 to 12.30.1
Bumps Azure.Storage.Queues from 12.25.0 to 12.28.1
Bumps Microsoft.AspNetCore.Authentication.JwtBearer from 10.0.10 to 10.0.14
Bumps Microsoft.Azure.Functions.Worker from 2.51.0 to 2.52.0
Bumps Microsoft.Azure.Functions.Worker.ApplicationInsights from 2.50.0 to 2.51.0
Bumps Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore from 2.1.0 to 2.1.1
Bumps Microsoft.Azure.Functions.Worker.Extensions.SignalRService from 2.0.1 to 2.1.0
Bumps Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues from 5.5.4 to 5.5.5
Bumps Microsoft.Azure.Functions.Worker.Sdk from 2.0.7 to 2.1.0
Bumps Microsoft.Azure.SignalR.Management from 1.33.0 to 1.33.1
Bumps Microsoft.Identity.Web from 4.3.0 to 4.16.1
Bumps Moq from 4.20.72 to 4.21.0
Bumps Newtonsoft.Json from 13.0.3 to 13.0.4
Bumps xunit from 2.9.2 to 2.9.3

---
updated-dependencies:
- dependency-name: Newtonsoft.Json
  dependency-version: 13.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Newtonsoft.Json
  dependency-version: 13.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: xunit
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: xunit
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Newtonsoft.Json
  dependency-version: 13.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: xunit
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Azure.Data.Tables
  dependency-version: 12.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Azure.Monitor.Query
  dependency-version: 1.7.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Azure.Storage.Blobs
  dependency-version: 12.30.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Azure.Storage.Queues
  dependency-version: 12.28.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Microsoft.AspNetCore.Authentication.JwtBearer
  dependency-version: 10.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker
  dependency-version: 2.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker.ApplicationInsights
  dependency-version: 2.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore
  dependency-version: 2.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker.Extensions.SignalRService
  dependency-version: 2.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues
  dependency-version: 5.5.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.Functions.Worker.Sdk
  dependency-version: 2.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Microsoft.Azure.SignalR.Management
  dependency-version: 1.33.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: Microsoft.Identity.Web
  dependency-version: 4.16.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: Moq
  dependency-version: 4.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dotnet
- dependency-name: xunit
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
- dependency-name: xunit
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dotnet
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 23:26:42 +01:00
dependabot[bot] e8f8b876e3 chore(deps-dev): bump the web group across 1 directory with 2 updates (#202)
Bumps the web group with 2 updates in the /src/Web/autopilot-monitor-web directory: [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) and [postcss](https://github.com/postcss/postcss).


Updates `knip` from 6.43.0 to 6.44.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.44.0/packages/knip)

Updates `postcss` from 8.5.25 to 8.5.32
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.25...8.5.32)

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.44.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: postcss
  dependency-version: 8.5.32
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 23:15:54 +01:00
dependabot[bot] 83afd4f1c9 chore(deps-dev): bump @azure/static-web-apps-cli (#200)
Bumps the swa-cli group with 1 update in the /tools/swa-cli directory: [@azure/static-web-apps-cli](https://github.com/azure/static-web-apps-cli).


Updates `@azure/static-web-apps-cli` from 2.0.10 to 2.1.0
- [Release notes](https://github.com/azure/static-web-apps-cli/releases)
- [Commits](https://github.com/azure/static-web-apps-cli/compare/v2.0.10...v2.1.0)

---
updated-dependencies:
- dependency-name: "@azure/static-web-apps-cli"
  dependency-version: 2.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: swa-cli
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 23:11:36 +01:00
dependabot[bot] d695c9b8c4 chore(deps-dev): bump knip (#201)
Bumps the mcp group with 1 update in the /src/McpServer/autopilot-monitor-mcp directory: [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip).


Updates `knip` from 6.43.0 to 6.44.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.44.0/packages/knip)

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.44.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 22:58:41 +01:00
dependabot[bot] 1ee7c48c92 Bump System.Text.Json from 9.0.1 to 10.0.14 (#207)
---
updated-dependencies:
- dependency-name: System.Text.Json
  dependency-version: 10.0.14
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 22:47:41 +01:00
dependabot[bot] 98ac414208 Bump Microsoft.NET.Test.Sdk from 17.11.1 to 18.11.0 (#206)
---
updated-dependencies:
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.11.0
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.11.0
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.11.0
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.11.0
  dependency-type: direct:production
  update-type: version-update:semver-major
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.11.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 22:47:35 +01:00
dependabot[bot] 5153e90e7c Bump Microsoft.Extensions.Http.Polly from 8.0.25 to 10.0.14 (#205)
---
updated-dependencies:
- dependency-name: Microsoft.Extensions.Http.Polly
  dependency-version: 10.0.14
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 22:47:29 +01:00
Oliver Kieselbach 5b139f90e7 Backend: Pkcs past two .NET DoS advisories, Moq 4.21-safe log assertion, no Dependabot major for Application Insights 3
- Microsoft.Identity.Web 4.3.0 brings System.Security.Cryptography.Pkcs 10.0.0 through its token
  cache, affected by GHSA-846f-f764-76mx and GHSA-2fmv-rv2c-59x7 (fixed in 10.0.13). A direct
  reference lifts it to 10.0.14 until Microsoft.Identity.Web 4.17.0 or later is in.
- Moq 4.21 annotates the It.IsAnyType match parameter as nullable. The GraphTokenService log
  assertion checks for null first, so the pending dotnet group update compiles.
- Dependabot no longer proposes Microsoft.ApplicationInsights.WorkerService 3: the OpenTelemetry
  rewrite drops telemetry processors and the Functions worker integration still builds on 2.x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:45:55 +01:00
Oliver Kieselbach 05d21cb240 bump pointer: internal (D-418 released: plan review)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:26:12 +01:00
Oliver Kieselbach 5cc69a696a bump pointer: internal, .claude/commands (D-418 get_api_usage)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:10:24 +01:00
Oliver Kieselbach fd3ce44c0c get_api_usage reads the MCP usage overview; totals, minute shares and tenant names come from the backend
The MCP tool get_api_usage (Global Admin and Global Reader) shows what the
admin MCP Usage page shows: view=summary (totals, top tools and tenants,
newest quota refusals, accounts closest to a per-minute limit, each list
cut with its full length stated), view=tools|tenants|quotaHits|
minuteLimits|daily (one list in full), view=organization (one tenant's
budget by account and day) and view=account (one account's requests by
tool, path and day). tenantId and userId must be GUIDs. The overview call
outlasts the route's telemetry budget.

The overview response gains totals, a sharePercent per minute row and the
tenant's domainName, computed once in McpUsageOverviewService; the page
reads them instead of computing its own. The tenant configuration is read
once per tenant and a failed read only drops the name and the per-minute
override.

Removed: GET global/metrics/mcp-usage and /daily with their DTOs and the
repository reads behind them; nothing reads the per-user usage log as a
whole any more. Also removed: the unused web URL helper for the per-user
route and the MCP usage pager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 22:10:23 +01:00
Oliver Kieselbach 0b48b1ce63 bump pointer: internal (work files)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:54:47 +01:00
Oliver Kieselbach 5cb536c46e bump pointer: internal (D-416 MCP usage overview: plan, review, backlog)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:11:32 +01:00
Oliver Kieselbach 3f97a418d4 MCP Usage page (GA): tenants, tools and limits at a glance
The Global Admin MCP Usage page reads the overview route. The header has
the window, a switch for platform admins and Refresh; tiles count tool
calls, charged requests, tenants, accounts and the accounts at a limit;
a chart stacks the calls per day by usage plan. Three tabs in ?view=:
- Tenants & accounts: grouped by tenant with domain, edition and the
  quota bars for today and the month, expandable to accounts and service
  principals, with search and a near-a-limit filter
- Tools: ranked by calls with reach, requests per call, error rate, p95,
  answer size and over-cap calls; the backend routes on expand
- Limits: the plan legend, the quota refusals, and each account's
  busiest 60 seconds against the MCP server's and the backend's
  per-minute limits

QuotaBar moves to components (the reporting page uses it unchanged), the
stacked bar chart takes a custom tooltip like the bar chart, and the
global and daily usage URLs leave the web API client (the routes stay
for the MCP tool).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:00:49 +01:00
Oliver Kieselbach cdd44d152a bump pointer: internal (D-417 Dependabot after D-415)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:57:45 +01:00
Oliver Kieselbach d26d877989 Dependabot: SWA CLI lockfile in tools/, overrides for adm-zip and tmp, private submodules skipped, postcss override follows its devDependency
The SWA CLI lockfile moves from .github/tools/swa-cli to tools/swa-cli:
Dependabot's update PR for the old folder was refused. deploy-web and the
Dependabot entry follow. Its package.json overrides adm-zip (^0.6.1) and tmp
(^0.2.7), which the CLI still pins below the versions that fix their
advisories. The CLI's own extract call works on adm-zip 0.6.1, and
swa --version and swa deploy --help load.

.gitmodules sets update = none for the three private submodules, so
recursive clones skip them. Public clones no longer fail on them, and
Dependabot's NuGet updater, which clones with --recurse-submodules and aborts
on any failure, can run again. A clone with access sets
submodule.<name>.update = checkout once.

The web postcss override becomes "$postcss": it follows the devDependency
that Dependabot bumps instead of conflicting with it. The lockfile is
unchanged.

zizmor.yml moves to .github/zizmor.yml, where zizmor finds it on its own;
CI, CodeQL and the workflow gates watch the new path.

Decision D-417.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:55:55 +01:00
Oliver Kieselbach 59959c663e MCP usage overview route: tenants, accounts, tools, refusals, peaks
GET global/metrics/mcp-usage/overview (GlobalReadOrAdmin) builds the
all-tenant MCP usage view the Global Admin metrics page will read. Calls
are counted from the request telemetry as one correlation id per tool
call; requests and quota windows come from the quota counters (one RowKey
range over McpTenantUsage, each account's state through ReadStateAsync).
Seven fixed queries cover calls, routes, quota and per-minute refusals,
peaks over sliding 60-second windows, tool quality and the MCP server's
own refusals and limit. A store that does not answer empties only its
parts and is named in the response.

The query call moves from the log proxy into LogQueryExecutor, shared by
both; the proxy answers as before. The request-row dimension names become
constants used by writer and queries, the access probe names its function,
and the organization quota node moves into McpQuotaNodes. A fixture under
tests/fixtures/mcp-log-lines pins the MCP log fields the queries read, on
both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:44:50 +01:00
Oliver Kieselbach ed2e3fd96c MCP quota: a quota view reads without counting
Since the previous commit an allowed quota check counts the request on the
instance's counters. The usage page (GET metrics/mcp-usage/me) also ran
that check to show the quota, so every page view counted as an MCP request
until the next storage read. The service now has two entries: AdmitAsync
decides and counts and is called only by the enforcement middleware;
ReadStateAsync returns the same decision without counting, for views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:48:46 +01:00
Oliver Kieselbach d35edeaf9f MCP quota: count admitted requests at once, McpQuotaExceeded ops event
The daily and monthly MCP quota now decides and counts in one step on the
instance's cached counters, so one instance blocks at the limit, also for
the parallel requests of one tool call. Concurrent first checks share one
counter (single-flight), and the UTC day is part of the counter key, so a
reading from before midnight never answers a check after it. Only what
other instances admitted inside their 60-second reading stays soft.

The first refusal per instance, account or organization, window and period
raises the new ops event McpQuotaExceeded (Tenant, Info), selectable in the
alert rules. The Warning lines of quota and per-minute refusals start with
shared prefix constants.

The MCP server writes its per-account message budget into the boot line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:42:27 +01:00
Oliver Kieselbach 21b802a028 bump pointer: internal (keep-awake D-413/D-414 released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:39:58 +01:00
dependabot[bot] ae258cd9a7 chore(deps): bump jose (#199)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 1 update: [jose](https://github.com/panva/jose).


Updates `jose` from 6.2.13 to 6.2.14
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.13...v6.2.14)

---
updated-dependencies:
- dependency-name: jose
  dependency-version: 6.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-05 17:22:50 +01:00
158 changed files with 10709 additions and 1565 deletions
+1 -1
View File
@@ -30,7 +30,7 @@
- Stage explicit file lists; never `git add -A` or a directory. Pushing is the user's call.
- New API responses need a typed DTO plus the parity fact. MCP vocabularies are generated from the wire types, never retyped.
- Every route belongs in the route policy catalog; every new event type goes into the MCP resource catalog.
- Workflow files under `.github/` go through zizmor before they are committed (`zizmor .` from the repository root, findings from severity low on): fix every finding or ignore it in `zizmor.yml` with a reason. The `zizmor` job in `action-pins.yml` runs the same check on every push.
- Workflow files under `.github/` go through zizmor before they are committed (`zizmor .` from the repository root, findings from severity low on): fix every finding or ignore it in `.github/zizmor.yml` with a reason. The `zizmor` job in `action-pins.yml` runs the same check on every push.
- The about, terms and privacy pages under `src/Web/autopilot-monitor-web/app/` state facts customers rely on: verify every claim against the code before writing it and update them in the same change as the fact they describe.
## Code Quality
+7 -1
View File
@@ -24,6 +24,11 @@ updates:
dotnet-security:
applies-to: security-updates
patterns: ['*']
# Application Insights 3 is the OpenTelemetry rewrite (no telemetry processors, another sampling
# model) and the Functions worker integration still builds on 2.x: a planned migration, not a bump.
ignore:
- dependency-name: 'Microsoft.ApplicationInsights.WorkerService'
update-types: ['version-update:semver-major']
- package-ecosystem: npm
directory: /src/Web/autopilot-monitor-web
@@ -101,8 +106,9 @@ updates:
# SWA CLI for the deploy job of deploy-web.yml, which installs it from this lockfile next to
# the production deploy token: a version reaches that job only through a reviewed bump here.
# deploy-web watches this folder, so a merged bump redeploys and proves the new CLI at once.
# Not under .github/: there Dependabot's update PR was refused as an unauthorized change.
- package-ecosystem: npm
directory: /.github/tools/swa-cli
directory: /tools/swa-cli
schedule:
interval: weekly
cooldown:
-8
View File
@@ -1,8 +0,0 @@
{
"name": "autopilot-monitor-swa-cli",
"private": true,
"description": "Azure Static Web Apps CLI for the deploy job of deploy-web.yml - installed from this lockfile with --ignore-scripts, because that job holds the production deploy token.",
"devDependencies": {
"@azure/static-web-apps-cli": "2.0.10"
}
}
+5 -4
View File
@@ -5,8 +5,9 @@ name: Workflow gates
# version comment (stricter than zizmor's unpinned-uses audit).
# * zizmor -- static analysis of every workflow (permissions, credential
# persistence, template injection, ...). Configuration and the
# reasoned ignores live in zizmor.yml at the repository root; the
# same run is expected locally before a workflow change is committed.
# reasoned ignores live in .github/zizmor.yml, which zizmor finds on
# its own; the same run is expected locally before a workflow change
# is committed.
# * hosts -- no workflow or .github/scripts helper spells out a host; every one
# comes from .github/scripts/Get-Endpoints.ps1, CI's member of the URL
# registry (the C#, web and MCP registries have guard tests of their own).
@@ -39,10 +40,10 @@ name: Workflow gates
on:
push:
branches: [main]
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', '.github/zizmor.yml']
pull_request:
branches: [main]
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', '.github/zizmor.yml']
workflow_dispatch:
permissions:
+2 -2
View File
@@ -24,7 +24,7 @@ on:
- 'infra/**'
- '.claude/**'
- '.gitmodules'
- 'zizmor.yml'
- '.github/zizmor.yml'
- '**/*.md'
- 'LICENSE'
- '.github/ISSUE_TEMPLATE/**'
@@ -56,7 +56,7 @@ on:
- 'infra/**'
- '.claude/**'
- '.gitmodules'
- 'zizmor.yml'
- '.github/zizmor.yml'
- '**/*.md'
- 'LICENSE'
- '.github/ISSUE_TEMPLATE/**'
+2 -2
View File
@@ -17,7 +17,7 @@ on:
- 'infra/**'
- '.claude/**'
- '.gitmodules'
- 'zizmor.yml'
- '.github/zizmor.yml'
- '**/*.md'
- 'LICENSE'
- '.github/ISSUE_TEMPLATE/**'
@@ -48,7 +48,7 @@ on:
- 'infra/**'
- '.claude/**'
- '.gitmodules'
- 'zizmor.yml'
- '.github/zizmor.yml'
- '**/*.md'
- 'LICENSE'
- '.github/ISSUE_TEMPLATE/**'
+4 -4
View File
@@ -1,6 +1,6 @@
name: Deploy Web (Static Web App)
# Build here, upload with the SWA CLI from its own lockfile (.github/tools/swa-cli). No
# Build here, upload with the SWA CLI from its own lockfile (tools/swa-cli). No
# third-party action sees the deploy token, the build runs on the same path ci.yml tests
# (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the action's container,
# and the exported site is attested like the agent ZIP and the MSI.
@@ -19,7 +19,7 @@ on:
- main
paths:
- 'src/Web/autopilot-monitor-web/**'
- '.github/tools/swa-cli/**'
- 'tools/swa-cli/**'
- '.github/workflows/deploy-web.yml'
workflow_dispatch:
@@ -173,7 +173,7 @@ jobs:
# --no-use-keychain keeps unloaded. Before the artifact arrives, so nothing from the
# build zone can touch the install.
- name: Install the SWA CLI from its lockfile
working-directory: .github/tools/swa-cli
working-directory: tools/swa-cli
run: npm ci --ignore-scripts
- name: Download build artifact
@@ -193,7 +193,7 @@ jobs:
env:
SWA_CLI_DEPLOYMENT_TOKEN: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
run: |
.github/tools/swa-cli/node_modules/.bin/swa deploy \
tools/swa-cli/node_modules/.bin/swa deploy \
--app-location "$WEB_DIR" \
--output-location out \
--swa-config-location "$WEB_DIR" \
View File
+6
View File
@@ -1,9 +1,15 @@
# The three submodules are private. "update = none" makes recursive clones skip them (the
# public, Dependabot's NuGet updater); a clone with access sets
# submodule.<name>.update = checkout once, or runs git submodule update --init --checkout.
[submodule ".claude/commands"]
path = .claude/commands
url = https://github.com/okieselbach/Autopilot-Monitor-dotclaude.git
update = none
[submodule "docs"]
path = internal
url = https://github.com/okieselbach/Autopilot-Monitor-internal.git
update = none
[submodule "infra"]
path = infra
url = https://github.com/okieselbach/Autopilot-Monitor-infra.git
update = none
@@ -18,9 +18,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -44,7 +44,7 @@
<Import Project="V2Isolation.targets" />
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
<ItemGroup>
@@ -78,7 +78,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
<!-- Plan §2.16 strict separation rule — build-time enforcement. -->
@@ -13,9 +13,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -15,9 +15,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -15,7 +15,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
</ItemGroup>
<ItemGroup>
@@ -2,6 +2,7 @@ using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Raw;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
@@ -71,7 +72,7 @@ public class AppInsightsQueryFunctionTests
"rows":[["2026-09-04T10:00:00Z",42,"{\"TenantId\":\"t1\"}",null]]}]}
""";
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody(body);
var (tables, partial) = LogQueryExecutor.ParseKustoBody(body);
Assert.Null(partial);
var table = Assert.Single(tables);
@@ -97,7 +98,7 @@ public class AppInsightsQueryFunctionTests
"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);
var (tables, partial) = LogQueryExecutor.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);
@@ -106,7 +107,7 @@ public class AppInsightsQueryFunctionTests
[Fact]
public void Empty_or_shapeless_body_yields_no_tables_and_no_partial()
{
var (tables, partial) = AppInsightsQueryFunction.ParseKustoBody("{}");
var (tables, partial) = LogQueryExecutor.ParseKustoBody("{}");
Assert.Empty(tables);
Assert.Null(partial);
}
@@ -115,16 +116,16 @@ public class AppInsightsQueryFunctionTests
[Fact]
public void Error_extraction_prefers_innererror_and_tolerates_non_json()
{
var (msg, code) = AppInsightsQueryFunction.ExtractError(
var (msg, code) = LogQueryExecutor.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"}}""");
var (outerMsg, outerCode) = LogQueryExecutor.ExtractError("""{"error":{"message":"Forbidden","code":"InsufficientAccessError"}}""");
Assert.Equal("InsufficientAccessError", outerCode);
Assert.Equal("Forbidden", outerMsg);
var (plainMsg, plainCode) = AppInsightsQueryFunction.ExtractError("<html>502 Bad Gateway</html>");
var (plainMsg, plainCode) = LogQueryExecutor.ExtractError("<html>502 Bad Gateway</html>");
Assert.Null(plainCode);
Assert.Equal("Telemetry store query failed", plainMsg);
}
@@ -181,7 +181,7 @@ public class AuthFunctionSideEffectTests
return BuildRequest(new ClaimsPrincipal(new ClaimsIdentity(claims, "test")));
}
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(ClaimsPrincipal principal)
private static (HttpRequestData Req, FunctionContext Ctx) BuildRequest(ClaimsPrincipal principal, RequestContext? requestContext = null)
{
var services = new ServiceCollection();
services.AddOptions();
@@ -190,9 +190,11 @@ public class AuthFunctionSideEffectTests
new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)));
var provider = services.BuildServiceProvider();
var items = new Dictionary<object, object> { ["ClaimsPrincipal"] = principal };
if (requestContext != null)
items[RequestContext.ItemsKey] = requestContext;
var context = new Mock<FunctionContext>();
context.SetupGet(c => c.Items).Returns(
new Dictionary<object, object> { ["ClaimsPrincipal"] = principal });
context.SetupGet(c => c.Items).Returns(items);
context.SetupGet(c => c.InstanceServices).Returns(provider);
var req = new Mock<HttpRequestData>(context.Object);
@@ -1093,4 +1095,96 @@ public class AuthFunctionSideEffectTests
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(Constants.Push.DeviceStatus.Active, push.Repo.Devices[(Constants.Push.PlatformScope, "ga-phone")].Status);
}
// -------------------------------------------------------------------------
// GetCurrentUser under view-as: a Global Admin reads another tenant as its member
// -------------------------------------------------------------------------
private const string ViewedTenantId = "cccccccc-1111-2222-3333-444444444444";
/// <summary>The context PolicyEnforcementMiddleware builds for an admitted view-as request.</summary>
private static RequestContext ViewingAs(string role) => new()
{
TenantId = ViewedTenantId,
TargetTenantId = ViewedTenantId,
UserPrincipalName = Upn,
UserRole = role,
IsTenantAdmin = role == Constants.TenantRoles.Admin,
ViewAs = new ViewAsContext(TenantId, role),
};
[Fact]
public async Task GetCurrentUser_ViewAs_AnswersAsTheViewedTenantsMember_WithoutAnySignInSideEffect()
{
// The viewed tenant asks for the Terms + DPA; the caller's home row still lacks its domain — exactly the
// state in which an ordinary sign-in would seed it, onboard, and record a login.
var viewed = TenantConfiguration.CreateDefault(ViewedTenantId);
viewed.DomainName = "fabrikam.example";
viewed.CompanyName = "Fabrikam";
viewed.DpaAcceptancePending = true;
var home = DefaultConfig();
home.DomainName = null!;
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(ViewedTenantId)).ReturnsAsync((viewed, true));
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((home, true));
var viewedStore = _tenantConfigMock.StubWrites(ViewedTenantId, row: viewed);
_previewMock.Setup(x => x.IsApprovedAsync(ViewedTenantId)).ReturnsAsync(true);
_mcpUserMock
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
.ReturnsAsync(McpAccessCheckResult.Denied("not allowed"));
var seen = new DateTime(2026, 11, 1, 0, 0, 0, DateTimeKind.Utc);
_metricsRepoMock.Setup(x => x.GetUserWhatsNewSeenAsync(TenantId, Upn)).ReturnsAsync(new UserWhatsNewSeen(platformUtc: seen));
var (req, ctx) = BuildRequest(SignedIn(external: false), ViewingAs(Constants.TenantRoles.Viewer));
var response = await _sut.GetCurrentUser(req, ctx);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await ReadBodyAsync(response);
Assert.Contains($"\"tenantId\":\"{ViewedTenantId}\"", body);
Assert.Contains("\"isGlobalAdmin\":false", body);
Assert.Contains("\"isGlobalReader\":false", body);
Assert.Contains("\"isDelegated\":false", body);
Assert.Contains("\"role\":\"Viewer\"", body);
Assert.Contains("\"isTenantAdmin\":false", body);
Assert.Contains("\"dpaAcceptancePending\":true", body);
Assert.Contains("\"whatsNewSeenPlatformUtc\":\"2026-11-01T00:00:00Z\"", body);
Assert.Contains(
$"\"viewAs\":{{\"tenantId\":\"{ViewedTenantId}\",\"tenantName\":\"Fabrikam\",\"role\":\"Viewer\",\"homeTenantId\":\"{TenantId}\"}}",
body);
// No sign-in side effect in either tenant, and no role lookup of the caller at all.
Assert.Empty(_store.Writes);
Assert.Empty(viewedStore.Writes);
_opsEventsMock.Verify(x => x.RecordTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
_tenantAdminsMock.Verify(x => x.TryAddTenantAdminAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_tenantAdminsMock.Verify(x => x.GetMembershipSnapshotAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
_metricsRepoMock.Verify(x => x.RecordUserLoginAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
_globalAdminMock.Verify(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>()), Times.Never);
}
[Fact]
public async Task GetCurrentUser_ViewAs_AnInactiveTenant_ShowsItsActivationGate()
{
var viewed = TenantConfiguration.CreateDefault(ViewedTenantId);
viewed.DomainName = "fabrikam.example";
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(ViewedTenantId)).ReturnsAsync((viewed, true));
_tenantConfigMock.Setup(x => x.TryGetConfigurationAsync(TenantId)).ReturnsAsync((DefaultConfig(), true));
_previewMock.Setup(x => x.IsApprovedAsync(ViewedTenantId)).ReturnsAsync(false);
_mcpUserMock
.Setup(x => x.IsAllowedAsync(It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<IReadOnlyList<string>?>(), It.IsAny<bool>()))
.ReturnsAsync(McpAccessCheckResult.Denied("not allowed"));
_metricsRepoMock.Setup(x => x.GetUserWhatsNewSeenAsync(TenantId, Upn)).ReturnsAsync(new UserWhatsNewSeen());
var (req, ctx) = BuildRequest(SignedIn(external: false), ViewingAs(Constants.TenantRoles.Admin));
var response = await _sut.GetCurrentUser(req, ctx);
// What the tenant's own admin gets while the tenant waits for activation — the Global Admin bypass is gone.
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Contains("PendingActivation", await ReadBodyAsync(response));
}
}
@@ -1,4 +1,5 @@
using System.Net;
using System.Text.Json;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Functions.Services;
@@ -218,10 +219,12 @@ public class AutopilotDeviceValidatorTests
private const string Upn = "first.last@example.invalid";
private static JsonElement Device(string json) => JsonSerializer.Deserialize<JsonElement>(json);
[Fact]
public void ReadRegistration_MapsTheKeptFields_AndKeepsOnlyWhetherAUserIsAssigned()
{
var device = Newtonsoft.Json.Linq.JObject.Parse(
var device = Device(
"{\"id\":\"ap-1\",\"groupTag\":\" TEST \",\"purchaseOrderIdentifier\":\"PO-7\",\"displayName\":\"LAB-01\"," +
$"\"userPrincipalName\":\"{Upn}\",\"addressableUserName\":\"First Last\",\"productKey\":\"AAAAA-BBBBB\"}}");
@@ -241,7 +244,7 @@ public class AutopilotDeviceValidatorTests
[Fact]
public void ReadRegistration_TreatsBlankMissingAndNonTextValuesAsAbsent()
{
var device = Newtonsoft.Json.Linq.JObject.Parse(
var device = Device(
"{\"id\":\"ap-1\",\"groupTag\":\" \",\"purchaseOrderIdentifier\":null,\"displayName\":42,\"userPrincipalName\":\"\"}");
var registration = AutopilotDeviceValidator.ReadRegistration(device);
@@ -256,7 +259,7 @@ public class AutopilotDeviceValidatorTests
public void ReadRegistration_CutsOverlongText()
{
var longTag = new string('x', AutopilotDeviceValidator.MaxRegistrationFieldLength + 50);
var device = Newtonsoft.Json.Linq.JObject.Parse($"{{\"id\":\"ap-1\",\"groupTag\":\"{longTag}\"}}");
var device = Device($"{{\"id\":\"ap-1\",\"groupTag\":\"{longTag}\"}}");
Assert.Equal(AutopilotDeviceValidator.MaxRegistrationFieldLength, AutopilotDeviceValidator.ReadRegistration(device).GroupTag!.Length);
}
@@ -13,10 +13,10 @@
DataProtection/Cryptography.Xml 10.0.0 pulled in via Microsoft.Identity.Web),
which would otherwise surface NU1903/NU1904 advisories here. -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageReference Include="Moq" Version="4.21.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -101,9 +101,8 @@ public class DeviceAssociationValidatorTests
Assert.Equal("preassociated", result.AssociationState);
Assert.Equal("00000000-0000-0000-0000-000000000000", result.DevicePreparationPolicyId);
Assert.Equal("admin@contoso.com", result.PreAssociatedByUserPrincipalName);
// Newtonsoft.Json yields "" for JSON null when calling .ToString() on the JToken;
// downstream telemetry uses IsNullOrEmpty so both null and "" surface as "absent".
Assert.True(string.IsNullOrEmpty(result.AssignedToUserPrincipalName));
// JSON null is absence (D-420); the former Newtonsoft reader turned it into "".
Assert.Null(result.AssignedToUserPrincipalName);
Assert.NotNull(result.PreAssociationDateTime);
Assert.Null(result.AssociationDateTime); // Graph "0001-01-01" → null (unset DateTimeOffset sentinel)
}
@@ -130,6 +130,36 @@ public class ScriptDisplayNameResolverTests
Assert.True(fix.Repo.Entries[(TenantId, ScriptKind.Platform, "deleted-id")].IsNotFound);
}
[Theory]
[InlineData("{\"id\":\"nameless\",\"displayName\":null}")]
[InlineData("{\"id\":\"nameless\",\"displayName\":42}")]
[InlineData("{\"id\":\"nameless\"}")]
public async Task PerId_script_without_a_name_is_cached_and_not_fetched_again(string body)
{
// JSON null and a non-text name read as absent (D-420); the answer is still final, so the second view
// is a cache hit instead of another Graph call.
var fix = new Fixture();
fix.GivenPermission();
fix.Repo.Meta[(TenantId, ScriptKind.Platform)] = new ScriptNameCacheMeta
{
TenantId = TenantId,
Kind = ScriptKind.Platform,
LastFullRefreshAt = fix.FixedNow - TimeSpan.FromDays(1),
};
var r = new ScriptRef(ScriptKind.Platform, "nameless");
fix.Http.When("deviceManagementScripts/nameless", HttpStatusCode.OK, body);
var first = await fix.Sut.ResolveAsync(TenantId, new[] { r });
var second = await fix.Sut.ResolveAsync(TenantId, new[] { r });
Assert.Null(first[r]);
Assert.Null(second[r]);
Assert.Single(fix.Http.Requests);
var entry = fix.Repo.Entries[(TenantId, ScriptKind.Platform, "nameless")];
Assert.False(entry.IsNotFound);
Assert.Null(entry.DisplayName);
}
[Fact]
public async Task Empty_refs_returns_empty_dict()
{
@@ -0,0 +1,658 @@
using System;
using System.Globalization;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The contract of the Graph and Entra token-endpoint response parsers (D-420): what every realistic body,
/// and every malformed one, yields. Written against the Newtonsoft parsers before their move to
/// System.Text.Json and kept as their specification. Tests whose name ends in _D1 to _D7 pin a
/// deliberate difference of that move (listed in D-420).
/// </summary>
public class GraphResponseParsingTests
{
private const string Serial = "PF3XKQ7";
private const string IntuneId = "6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f";
// ── GraphTokenService.ReadTokenResponse ─────────────────────────────────────
[Theory]
[InlineData("""{"access_token":"t","expires_in":3599}""", 3599L)]
[InlineData("""{"access_token":"t","expires_in":"3599"}""", 3599L)]
[InlineData("""{"access_token":"t","expires_in":" 3599 "}""", 3599L)]
[InlineData("""{"access_token":"t","expires_in":"+3599"}""", 3599L)]
[InlineData("""{"access_token":"t",/*c*/"expires_in":3599,}""", 3599L)]
[InlineData("""{"access_token":"t"}""", null)]
[InlineData("""{"access_token":"t","expires_in":null}""", null)]
public void Token_response_reads_the_token_and_its_lifetime(string body, long? expiresIn)
{
var (token, lifetime) = GraphTokenService.ReadTokenResponse(body);
Assert.Equal("t", token);
Assert.Equal(expiresIn, lifetime);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("{}")]
[InlineData("""{"access_token":""}""")]
[InlineData("""{"access_token":null,"expires_in":"abc"}""")]
public void Token_response_without_a_token_yields_none_and_ignores_the_lifetime(string body)
{
var (token, lifetime) = GraphTokenService.ReadTokenResponse(body);
Assert.True(string.IsNullOrEmpty(token));
Assert.Null(lifetime);
}
[Theory]
[InlineData("<html>busy</html>")]
[InlineData("[]")]
[InlineData("42")]
[InlineData("""{"access_token":"t"} x""")]
public void Token_response_that_is_not_one_object_throws(string body)
=> Assert.ThrowsAny<Exception>(() => GraphTokenService.ReadTokenResponse(body));
// D5: an expires_in that is not a whole number counts as absent; the token stays usable with the
// default lifetime. Newtonsoft threw on the first five and coerced the last two (3600, 1).
[Theory]
[InlineData("\"abc\"")]
[InlineData("\"\"")]
[InlineData("{}")]
[InlineData("[]")]
[InlineData("99999999999999999999")]
[InlineData("3599.5")]
[InlineData("true")]
public void Token_response_with_an_unusable_lifetime_keeps_the_token_D5(string expiresIn)
{
var (token, lifetime) = GraphTokenService.ReadTokenResponse($$"""{"access_token":"t","expires_in":{{expiresIn}}}""");
Assert.Equal("t", token);
Assert.Null(lifetime);
}
// ── EntraTokenRejection.Parse ───────────────────────────────────────────────
[Theory]
[InlineData("""{"error_codes":[7000229],"error_description":"AADSTS7000229: missing"}""", "AADSTS7000229")]
[InlineData("""{"error_codes":["7000229",53003]}""", "AADSTS53003")]
[InlineData("""{"error_codes":[7000229.0],"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
[InlineData("""{"error_codes":7000229,"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
[InlineData("""{"error_codes":[],"error_description":"AADSTS50000: x"}""", "AADSTS50000")]
[InlineData("""{"error_codes":[1],"error_codes":[7000229]}""", "AADSTS7000229")]
[InlineData("""{"error_codes":[7000229,],/*c*/}""", "AADSTS7000229")]
public void Entra_rejection_reads_the_first_integer_code_then_the_description(string body, string code)
=> Assert.Equal(code, EntraTokenRejection.Parse(400, body).ErrorCode);
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("\"AADSTS7000229\"")]
[InlineData("42")]
[InlineData("[1,2]")]
[InlineData("<html>busy</html>")]
[InlineData("""{"error_description":null}""")]
[InlineData("""{"error_description":42}""")]
[InlineData("""{"error_codes":[7000229]} x""")]
public void Entra_rejection_without_a_readable_object_keeps_only_the_status(string? body)
{
var rejection = EntraTokenRejection.Parse(400, body);
Assert.Equal(400, rejection.StatusCode);
Assert.Null(rejection.ErrorCode);
Assert.Null(rejection.Description);
}
// D3: a description is kept as sent, even when it looks like a date (Newtonsoft dropped it).
[Fact]
public void Entra_rejection_keeps_a_date_shaped_description_D3()
=> Assert.Equal("2026-11-01T08:00:00Z", EntraTokenRejection.Parse(400, """{"error_description":"2026-11-01T08:00:00Z"}""").Description);
// D6: an error code beyond Int64 is skipped like any other unusable entry (Newtonsoft threw).
[Fact]
public void Entra_rejection_skips_an_out_of_range_code_D6()
=> Assert.Equal("AADSTS53003", EntraTokenRejection.Parse(400, """{"error_codes":[99999999999999999999,53003]}""").ErrorCode);
// ── AutopilotDeviceValidator.FindRegistration ───────────────────────────────
[Theory]
[InlineData("")]
[InlineData("null")]
[InlineData("{}")]
[InlineData("""{"value":"oops"}""")]
[InlineData("""{"value":[]}""")]
[InlineData("""{"value":[{"serialNumber":null}]}""")]
[InlineData("""{"value":[{"serialNumber":"OTHER"}]}""")]
public void Autopilot_list_without_the_serial_is_not_registered(string body)
=> Assert.Null(AutopilotDeviceValidator.FindRegistration(body, Serial));
[Theory]
[InlineData("not json")]
[InlineData("[]")]
[InlineData("""{"value":[]} x""")]
public void Autopilot_list_that_is_not_one_object_throws(string body)
=> Assert.ThrowsAny<Exception>(() => AutopilotDeviceValidator.FindRegistration(body, Serial));
[Fact]
public void Autopilot_list_with_a_null_entry_before_the_match_throws()
=> Assert.Throws<InvalidOperationException>(() => AutopilotDeviceValidator.FindRegistration(
"""{"value":[null,{"id":"ap-1","serialNumber":"PF3XKQ7"}]}""", Serial));
[Fact]
public void Autopilot_list_matches_the_trimmed_serial_case_insensitively_and_stops_there()
{
var registration = AutopilotDeviceValidator.FindRegistration(
"""{"value":[{"id":"ap-1","serialNumber":" pf3xkq7 ","groupTag":" Sales ","purchaseOrderIdentifier":"PO-7","displayName":"KIOSK-1","userPrincipalName":"u@contoso.com"},null]}""",
Serial);
Assert.NotNull(registration);
Assert.Equal("ap-1", registration!.DeviceId);
Assert.Equal("Sales", registration.GroupTag);
Assert.Equal("PO-7", registration.PurchaseOrderId);
Assert.Equal("KIOSK-1", registration.DisplayName);
Assert.True(registration.UserAssigned);
}
[Fact]
public void Autopilot_registration_drops_blank_and_non_text_values_and_cuts_long_text()
{
var longText = new string('x', AutopilotDeviceValidator.MaxRegistrationFieldLength + 10);
var registration = AutopilotDeviceValidator.FindRegistration(
$$"""{"value":[{"id":" ","serialNumber":"PF3XKQ7","groupTag":true,"purchaseOrderIdentifier":{},"displayName":"{{longText}}","userPrincipalName":null}]}""",
Serial);
Assert.NotNull(registration);
Assert.Null(registration!.DeviceId);
Assert.Null(registration.GroupTag);
Assert.Null(registration.PurchaseOrderId);
Assert.Equal(AutopilotDeviceValidator.MaxRegistrationFieldLength, registration.DisplayName!.Length);
Assert.False(registration.UserAssigned);
}
// D4: a serial that is not text never matches (Newtonsoft matched through its text).
[Fact]
public void Autopilot_list_with_a_numeric_serial_does_not_match_D4()
=> Assert.Null(AutopilotDeviceValidator.FindRegistration("""{"value":[{"id":"ap-1","serialNumber":12345}]}""", "12345"));
// D3: registration text is kept as sent, even when it looks like a date (Newtonsoft dropped it).
[Fact]
public void Autopilot_registration_keeps_date_shaped_text_D3()
{
var registration = AutopilotDeviceValidator.FindRegistration(
"""{"value":[{"id":"ap-1","serialNumber":"PF3XKQ7","groupTag":"2026-01-01T00:00:00Z","displayName":"/Date(1713096021000)/"}]}""",
Serial);
Assert.Equal("2026-01-01T00:00:00Z", registration!.GroupTag);
Assert.Equal("/Date(1713096021000)/", registration.DisplayName);
}
// ── CorporateIdentifierValidator.ReadIdentityPage ───────────────────────────
private static readonly string CorporateId =
CorporateIdentifierValidator.BuildNormalizedIdentifier("Microsoft Corporation", "Virtual Machine", "1234-5678");
[Theory]
[InlineData("", "")]
[InlineData("null", "")]
[InlineData("{}", "")]
[InlineData("""{"value":null}""", "")]
[InlineData("""{"value":[],"@odata.nextLink":""}""", "")]
[InlineData("""{"value":[],"@odata.nextLink":null}""", "")]
[InlineData("""{"value":{},"@odata.nextLink":"https://graph.microsoft.com/beta/next"}""", "https://graph.microsoft.com/beta/next")]
[InlineData("""{"value":[{"importedDeviceIdentityType":"serialNumber","importedDeviceIdentifier":"1234-5678"}]}""", "")]
public void Corporate_page_without_the_identifier_yields_the_next_url(string body, string nextUrl)
=> Assert.Equal((false, nextUrl), CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
[Theory]
[InlineData("""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}],"@odata.nextLink":"https://graph.microsoft.com/beta/next"}""")]
[InlineData("""{"value":[{"importedDeviceIdentityType":"MANUFACTURERMODELSERIAL","importedDeviceIdentifier":"MICROSOFTCORPORATION,VIRTUALMACHINE,12345678"}]}""")]
[InlineData("""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":null},{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}]}""")]
public void Corporate_page_with_the_identifier_is_found(string body)
=> Assert.Equal((true, string.Empty), CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
[Theory]
[InlineData("not json")]
[InlineData("[]")]
public void Corporate_page_that_is_not_one_object_throws(string body)
=> Assert.ThrowsAny<Exception>(() => CorporateIdentifierValidator.ReadIdentityPage(body, CorporateId));
[Fact]
public void Corporate_page_with_a_null_entry_before_the_match_throws()
=> Assert.Throws<InvalidOperationException>(() => CorporateIdentifierValidator.ReadIdentityPage(
"""{"value":[null,{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}]}""",
CorporateId));
[Theory]
[InlineData("5")]
[InlineData("true")]
[InlineData("{}")]
public void Corporate_page_with_a_non_text_next_link_never_yields_a_page_to_follow(string nextLink)
{
// The scan must stop as a failure: either the read throws or the url cannot be requested.
try
{
var (found, nextUrl) = CorporateIdentifierValidator.ReadIdentityPage($$"""{"value":[],"@odata.nextLink":{{nextLink}}}""", CorporateId);
Assert.False(found);
Assert.False(string.IsNullOrEmpty(nextUrl));
Assert.False(Uri.IsWellFormedUriString(nextUrl, UriKind.Absolute));
}
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
{
// Throwing ends the scan as transient, like an unusable url does.
}
}
// D4: an identifier that is not text never matches (Newtonsoft matched an array through its text).
[Fact]
public void Corporate_page_with_an_array_identifier_does_not_match_D4()
=> Assert.False(CorporateIdentifierValidator.ReadIdentityPage(
"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":["Microsoft Corporation,Virtual Machine,1234-5678"]}]}""",
CorporateId).Found);
// ── DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse ─────────
[Theory]
[InlineData("")]
[InlineData("null")]
[InlineData("[]")]
[InlineData("42")]
[InlineData("not json")]
[InlineData("{} x")]
[InlineData("""{"value":null}""")]
[InlineData("""{"value":{}}""")]
[InlineData("""{"value":[{"serialNumber":"OTHER"}]}""")]
public void Association_list_without_the_serial_is_not_found(string body)
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
[Fact]
public void Association_list_with_a_null_entry_before_the_match_throws()
=> Assert.Throws<InvalidOperationException>(() => DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[null,{"serialNumber":"PF3XKQ7"}]}""", Serial));
[Fact]
public void Association_list_reads_the_matching_device_and_stops_there()
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[{"serialNumber":" pf3xkq7 ","associationState":"associated","devicePreparationPolicyId":"pol-1","preassociatedByUserPrincipalName":"a@contoso.com","assignedToUserPrincipalName":"u@contoso.com","managedDeviceId":"md-1","preassociationDateTime":"2026-05-08T12:16:49Z"},null]}""",
Serial);
Assert.True(result.IsValid);
Assert.Equal("associated", result.AssociationState);
Assert.Equal("pol-1", result.DevicePreparationPolicyId);
Assert.Equal("a@contoso.com", result.PreAssociatedByUserPrincipalName);
Assert.Equal("u@contoso.com", result.AssignedToUserPrincipalName);
Assert.Equal("md-1", result.ManagedDeviceId);
Assert.Equal(new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc), result.PreAssociationDateTime);
Assert.Equal(DateTimeKind.Utc, result.PreAssociationDateTime!.Value.Kind);
}
[Theory]
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7"}]}""")]
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":null}]}""")]
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":""}]}""")]
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":"0001-01-01T00:00:00Z"}]}""")]
[InlineData("""{"value":[{"serialNumber":"PF3XKQ7","associationDateTime":"not-a-date"}]}""")]
public void Association_date_that_is_unset_reads_as_null(string body)
=> Assert.Null(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(body, Serial).AssociationDateTime);
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
[Fact]
public void Association_fields_that_are_json_null_read_as_null_D1()
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[{"serialNumber":"PF3XKQ7","associationState":null,"assignedToUserPrincipalName":null,"managedDeviceId":null}]}""",
Serial);
Assert.Null(result.AssociationState);
Assert.Null(result.AssignedToUserPrincipalName);
Assert.Null(result.ManagedDeviceId);
}
// D4: text whose escapes are no valid UTF-16 reads as absent, in the serial of another entry and in a field
// of the match, instead of failing the whole lookup (Newtonsoft read it into a broken string).
[Fact]
public void Association_text_with_a_broken_escape_reads_as_null_D4()
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[{"serialNumber":"\ud800"},{"serialNumber":"PF3XKQ7","assignedToUserPrincipalName":"u\ud800"}]}""", Serial);
Assert.True(result.IsValid);
Assert.Null(result.AssignedToUserPrincipalName);
}
// D2: the date keeps its fraction, in any culture (Newtonsoft cut it to whole seconds).
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("en-GB")]
[InlineData("th-TH")]
public void Association_date_keeps_its_fraction_in_any_culture_D2(string culture)
{
var date = InCulture(culture, () => DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
"""{"value":[{"serialNumber":"PF3XKQ7","preassociationDateTime":"2026-05-08T12:16:49.1234567Z"}]}""",
Serial).PreAssociationDateTime);
Assert.Equal(new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc).AddTicks(1234567), date);
Assert.Equal(DateTimeKind.Utc, date!.Value.Kind);
}
// ── CloudPcDeviceValidator.ParseCloudPcResponse ─────────────────────────────
[Theory]
[InlineData("")]
[InlineData("null")]
[InlineData("[]")]
[InlineData("not json")]
[InlineData("""{"value":[]} x""")]
[InlineData("""{"value":null}""")]
[InlineData("""{"value":[{"managedDeviceId":"other"}]}""")]
public void CloudPc_list_without_the_device_is_not_found(string body)
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, IntuneId);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
[Fact]
public void CloudPc_list_with_a_null_entry_before_the_match_throws()
=> Assert.Throws<InvalidOperationException>(() => CloudPcDeviceValidator.ParseCloudPcResponse(
$$"""{"value":[null,{"managedDeviceId":"{{IntuneId}}"}]}""", IntuneId));
[Fact]
public void CloudPc_list_reads_the_matching_cloud_pc()
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse(
$$"""{"value":[{"id":"cpc-1","managedDeviceId":" {{IntuneId.ToUpperInvariant()}} ","managedDeviceName":"CPC-ADM-1","servicePlanName":"Cloud PC Enterprise"},null]}""",
IntuneId);
Assert.True(result.IsValid);
Assert.Equal("cpc-1", result.CloudPcId);
Assert.Equal("CPC-ADM-1", result.ManagedDeviceName);
Assert.Equal("Cloud PC Enterprise", result.ServicePlanName);
}
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
[Fact]
public void CloudPc_fields_that_are_json_null_read_as_null_D1()
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse(
$$"""{"value":[{"managedDeviceId":"{{IntuneId}}","id":null,"managedDeviceName":null,"servicePlanName":null}]}""", IntuneId);
Assert.True(result.IsValid);
Assert.Null(result.CloudPcId);
Assert.Null(result.ManagedDeviceName);
Assert.Null(result.ServicePlanName);
}
// ── IntuneDeviceBindingValidator.ParseManagedDeviceResponse ─────────────────
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("[]")]
[InlineData("42")]
[InlineData("not json")]
[InlineData("""[{"id":"6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f"}]""")]
[InlineData("""{"id":"6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f"} x""")]
[InlineData("""{"id":null}""")]
[InlineData("""{"id":42}""")]
[InlineData("""{"id":"other"}""")]
public void Managed_device_that_is_not_the_requested_one_is_not_found(string body)
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(body, IntuneId).Outcome);
[Theory]
[InlineData("\"managed\"", IntuneDeviceBindingOutcome.Match)]
[InlineData("null", IntuneDeviceBindingOutcome.Match)]
[InlineData("\"retirePending\"", IntuneDeviceBindingOutcome.NotManaged)]
[InlineData("\"WIPEISSUED\"", IntuneDeviceBindingOutcome.NotManaged)]
[InlineData("\"deletePending\"", IntuneDeviceBindingOutcome.NotManaged)]
public void Managed_device_on_its_way_out_is_not_managed(string managementState, IntuneDeviceBindingOutcome outcome)
=> Assert.Equal(outcome, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":" {{IntuneId.ToUpperInvariant()}} ","managementState":{{managementState}}}""", IntuneId).Outcome);
[Theory]
[InlineData("true", true)]
[InlineData("false", false)]
[InlineData("null", null)]
[InlineData("\"true\"", null)]
[InlineData("1", null)]
public void Managed_device_registration_flag_is_strictly_boolean(string value, bool? expected)
=> Assert.Equal(expected, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","azureADRegistered":{{value}}}""", IntuneId).AzureAdRegistered);
[Theory]
[InlineData("null")]
[InlineData("\"\"")]
[InlineData("\" \"")]
public void Managed_device_telemetry_fields_that_are_blank_read_as_null(string value)
{
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","serialNumber":{{value}},"managedDeviceOwnerType":{{value}},"deviceEnrollmentType":{{value}},"enrolledDateTime":{{value}}}""",
IntuneId);
Assert.Null(result.SerialNumber);
Assert.Null(result.OwnerType);
Assert.Null(result.EnrollmentType);
Assert.Null(result.EnrolledDateTime);
}
[Fact]
public void Managed_device_reads_its_fields()
{
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","deviceName":"DESKTOP-1","azureADDeviceId":"aad-1","managementState":"managed","serialNumber":"PF3XKQ7","managedDeviceOwnerType":"company","deviceEnrollmentType":"windowsAzureADJoin","enrolledDateTime":"2026-05-08T12:16:49Z"}""",
IntuneId);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
Assert.Equal("DESKTOP-1", result.DeviceName);
Assert.Equal("aad-1", result.AzureAdDeviceId);
Assert.Equal("PF3XKQ7", result.SerialNumber);
Assert.Equal("company", result.OwnerType);
Assert.Equal("windowsAzureADJoin", result.EnrollmentType);
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero), result.EnrolledDateTime);
}
[Fact]
public void Managed_device_unset_enrollment_date_reads_as_null()
=> Assert.Null(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"0001-01-01T00:00:00Z"}""", IntuneId).EnrolledDateTime);
// D1: JSON null in a text field is absent (Newtonsoft read it as an empty string).
[Fact]
public void Managed_device_fields_that_are_json_null_read_as_null_D1()
{
var result = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","deviceName":null,"azureADDeviceId":null,"managementState":null}""", IntuneId);
Assert.Equal(IntuneDeviceBindingOutcome.Match, result.Outcome);
Assert.Null(result.DeviceName);
Assert.Null(result.AzureAdDeviceId);
Assert.Null(result.ManagementState);
}
// D2: the enrollment date keeps its fraction and its day and month in any culture (Newtonsoft cut the
// fraction and swapped day and month on day-first cultures).
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("en-GB")]
[InlineData("th-TH")]
public void Managed_device_enrollment_date_is_the_same_in_any_culture_D2(string culture)
{
var date = InCulture(culture, () => IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"2026-05-08T12:16:49.1234567Z"}""", IntuneId).EnrolledDateTime);
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(1234567), date);
Assert.Equal(TimeSpan.Zero, date!.Value.Offset);
}
// D4: an id that is not text never matches (Newtonsoft matched through its text).
[Fact]
public void Managed_device_with_a_numeric_id_is_not_found_D4()
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound,
IntuneDeviceBindingValidator.ParseManagedDeviceResponse("""{"id":12345}""", "12345").Outcome);
// ── Dates in any offset (new with System.Text.Json) ─────────────────────────
// Newtonsoft turned an offset date into machine-local time and read that back as UTC, so the instant
// depended on the host's time zone; it was only right on a UTC host.
[Theory]
[InlineData("2026-05-08T14:16:49+02:00")]
[InlineData("2026-05-08T07:16:49-05:00")]
[InlineData("2026-05-08T12:16:49Z")]
[InlineData("2026-05-08T12:16:49")]
public void Dates_in_any_offset_read_as_the_same_utc_instant(string value)
{
var expected = new DateTime(2026, 5, 8, 12, 16, 49, DateTimeKind.Utc);
var association = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(
$$"""{"value":[{"serialNumber":"PF3XKQ7","preassociationDateTime":"{{value}}"}]}""", Serial).PreAssociationDateTime;
var enrolled = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","enrolledDateTime":"{{value}}"}""", IntuneId).EnrolledDateTime;
Assert.Equal(expected, association);
Assert.Equal(DateTimeKind.Utc, association!.Value.Kind);
Assert.Equal(new DateTimeOffset(expected), enrolled);
Assert.Equal(TimeSpan.Zero, enrolled!.Value.Offset);
}
// ── ScriptDisplayNameResolver.ReadScriptPage / ReadScriptDisplayName ────────
[Fact]
public void Script_page_keeps_object_entries_with_an_id_and_the_next_url()
{
var (scripts, next) = ScriptDisplayNameResolver.ReadScriptPage(
"""{"value":[1,"x",null,[],{"displayName":"no id"},{"id":null},{"id":""},{"id":"s1","displayName":"A","fileName":"a.ps1"},{"id":"s2","displayName":"B"}],"@odata.nextLink":"https://graph.microsoft.com/beta/p2"}""",
withFileName: true);
Assert.Equal(new[]
{
new ScriptDisplayNameResolver.ScriptListItem("s1", "A", "a.ps1"),
new ScriptDisplayNameResolver.ScriptListItem("s2", "B", null),
}, scripts);
Assert.Equal("https://graph.microsoft.com/beta/p2", next);
}
[Theory]
[InlineData("""{"value":[{"id":"s1"}]}""")]
[InlineData("""{"value":[],"@odata.nextLink":null}""")]
[InlineData("""{"value":[],"@odata.nextLink":""}""")]
[InlineData("""{"value":"oops"}""")]
[InlineData("{}")]
public void Script_page_without_a_next_link_ends_the_pull(string body)
=> Assert.True(string.IsNullOrEmpty(ScriptDisplayNameResolver.ReadScriptPage(body, withFileName: false).NextUrl));
[Fact]
public void Script_page_of_health_scripts_reads_no_file_name()
=> Assert.Null(Assert.Single(ScriptDisplayNameResolver.ReadScriptPage(
"""{"value":[{"id":"s1","displayName":"A","fileName":"a.ps1"}]}""", withFileName: false).Scripts).FileName);
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("null")]
[InlineData("[]")]
[InlineData("not json")]
public void Script_page_or_name_that_is_not_one_object_throws(string body)
{
Assert.ThrowsAny<Exception>(() => ScriptDisplayNameResolver.ReadScriptPage(body, withFileName: false));
Assert.ThrowsAny<Exception>(() => ScriptDisplayNameResolver.ReadScriptDisplayName(body));
}
[Theory]
[InlineData("""{"displayName":"Install agent"}""", "Install agent")]
[InlineData("""{"id":"s1"}""", null)]
public void Script_name_reads_the_display_name(string body, string? name)
=> Assert.Equal(name, ScriptDisplayNameResolver.ReadScriptDisplayName(body));
// D1: a display name that is JSON null is absent (Newtonsoft read it as an empty string).
[Fact]
public void Script_name_that_is_json_null_reads_as_null_D1()
{
Assert.Null(ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":null}"""));
Assert.Null(Assert.Single(ScriptDisplayNameResolver.ReadScriptPage(
"""{"value":[{"id":"s1","displayName":null}]}""", withFileName: false).Scripts).DisplayName);
}
// D3: a display name is kept as sent in any culture, even when it looks like a date (Newtonsoft
// rewrote it in the current culture, "1/1/2026 12:00:00 AM" in en-US).
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("th-TH")]
public void Script_name_shaped_like_a_date_is_kept_as_sent_D3(string culture)
=> Assert.Equal("2026-01-01T00:00:00Z", InCulture(culture, () =>
ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":"2026-01-01T00:00:00Z"}""")));
// D4: a display name that is not text is absent (Newtonsoft read a number as its text).
[Fact]
public void Script_name_that_is_a_number_reads_as_null_D4()
=> Assert.Null(ScriptDisplayNameResolver.ReadScriptDisplayName("""{"displayName":42}"""));
// ── D7: a body cut off mid-object is unreadable ─────────────────────────────
// Newtonsoft's DeserializeObject read the part before the cut as if the open containers were closed, so a
// cut-off list could still match. Every parser now answers it like any other unreadable body. HttpClient
// throws on a premature end before a parser sees the body, so only a server-side cut can get here.
[Fact]
public void Token_response_cut_off_throws_D7()
=> Assert.ThrowsAny<Exception>(() => GraphTokenService.ReadTokenResponse("""{"access_token":"t","expires_in":3599"""));
[Fact]
public void Entra_rejection_cut_off_keeps_only_the_status_D7()
{
var rejection = EntraTokenRejection.Parse(400, """{"error_codes":[53003""");
Assert.Null(rejection.ErrorCode);
Assert.Null(rejection.Description);
}
[Fact]
public void Autopilot_list_cut_off_throws_D7()
=> Assert.ThrowsAny<Exception>(() => AutopilotDeviceValidator.FindRegistration(
"""{"value":[{"id":"ap-1","serialNumber":"PF3XKQ7"}""", Serial));
[Fact]
public void Corporate_page_cut_off_throws_D7()
=> Assert.ThrowsAny<Exception>(() => CorporateIdentifierValidator.ReadIdentityPage(
"""{"value":[{"importedDeviceIdentityType":"manufacturerModelSerial","importedDeviceIdentifier":"Microsoft Corporation,Virtual Machine,1234-5678"}""",
CorporateId));
[Fact]
public void Association_list_cut_off_is_not_found_D7()
{
var result = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse("""{"value":[{"serialNumber":"PF3XKQ7"}""", Serial);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
[Fact]
public void CloudPc_list_cut_off_is_not_found_D7()
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse($$"""{"value":[{"managedDeviceId":"{{IntuneId}}"}""", IntuneId);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
[Fact]
public void Managed_device_cut_off_is_not_found_D7()
=> Assert.Equal(IntuneDeviceBindingOutcome.NotFound, IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{"id":"{{IntuneId}}","azureADRegistered":true""", IntuneId).Outcome);
private static T InCulture<T>(string culture, Func<T> read)
{
var saved = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo(culture);
return read();
}
finally
{
CultureInfo.CurrentCulture = saved;
}
}
}
@@ -23,7 +23,7 @@ public class GraphTokenServiceTests
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
private static (GraphTokenService svc, StubHttpMessageHandler handler) Create(StubHttpMessageHandler handler)
private static (GraphTokenService svc, StubHttpMessageHandler handler) Create(StubHttpMessageHandler handler, IMemoryCache? cache = null)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
@@ -33,7 +33,7 @@ public class GraphTokenServiceTests
})
.Build();
var svc = NewService(config, new StubHttpClientFactory(handler));
var svc = NewService(config, new StubHttpClientFactory(handler), cache: cache);
return (svc, handler);
}
@@ -47,11 +47,12 @@ public class GraphTokenServiceTests
IConfiguration config,
System.Net.Http.IHttpClientFactory factory,
IGraphAccessHealthRecorder? accessHealth = null,
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null) =>
Microsoft.Extensions.Logging.ILogger<GraphTokenService>? logger = null,
IMemoryCache? cache = null) =>
new GraphTokenService(
logger ?? NullLogger<GraphTokenService>.Instance,
factory,
new MemoryCache(new MemoryCacheOptions()),
cache ?? new MemoryCache(new MemoryCacheOptions()),
new EntraAppRegistry(config, NullLogger<EntraAppRegistry>.Instance),
new AutopilotMonitor.Functions.Services.TenantConfigurationService(
Moq.Mock.Of<AutopilotMonitor.Shared.DataAccess.IConfigRepository>(),
@@ -131,6 +132,53 @@ public class GraphTokenServiceTests
Assert.Equal(1, TokenPosts(handler, TenantA));
}
// The token stays cached for expires_in minus the 5-minute skew, clamped to 60 s .. 55 min. An absent
// or unusable expires_in (D5 in D-420: text, a fraction, zero or less) falls back to the 35-minute
// default, so 30 minutes; a value far beyond the cap is capped instead of overflowing.
[Theory]
[InlineData("3599", 3299)]
[InlineData("\"3599\"", 3299)]
[InlineData("7200", 3300)]
[InlineData("120", 60)]
[InlineData("null", 1800)]
[InlineData("\"abc\"", 1800)]
[InlineData("3599.5", 1800)]
[InlineData("0", 1800)]
[InlineData("-5", 1800)]
[InlineData("9223372036854775807", 3300)]
public async Task Token_cache_lifetime_follows_expires_in(string expiresIn, int seconds)
{
var cache = new RecordingMemoryCache();
var (svc, _) = Create(new StubHttpMessageHandler()
.When($"/{TenantA}/oauth2", HttpStatusCode.OK, $$"""{"access_token":"tok-A","expires_in":{{expiresIn}}}"""), cache);
Assert.Equal("tok-A", (await svc.GetAccessTokenAsync(TenantA)).AccessToken);
var entry = Assert.Single(cache.Entries, e => e.Key is string key && key.StartsWith($"graph-token:{TenantA}:", StringComparison.Ordinal));
Assert.Equal(TimeSpan.FromSeconds(seconds), entry.AbsoluteExpirationRelativeToNow);
}
/// <summary>A memory cache that keeps every entry it creates, so a test can read the lifetime the service chose.</summary>
private sealed class RecordingMemoryCache : IMemoryCache
{
private readonly MemoryCache _inner = new(new MemoryCacheOptions());
public List<ICacheEntry> Entries { get; } = new();
public ICacheEntry CreateEntry(object key)
{
var entry = _inner.CreateEntry(key);
Entries.Add(entry);
return entry;
}
public bool TryGetValue(object key, out object? value) => _inner.TryGetValue(key, out value);
public void Remove(object key) => _inner.Remove(key);
public void Dispose() => _inner.Dispose();
}
[Fact]
public async Task InvalidateTenant_forces_fresh_token_on_next_call()
{
@@ -451,7 +499,7 @@ public class GraphTokenServiceTests
logger.Verify(l => l.Log(
Microsoft.Extensions.Logging.LogLevel.Warning,
Moq.It.IsAny<Microsoft.Extensions.Logging.EventId>(),
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
Moq.It.Is<Moq.It.IsAnyType>((state, _) => state != null && state.ToString()!.Contains("rejected by Entra") && state.ToString()!.Contains("AADSTS53003")),
Moq.It.IsAny<Exception?>(),
Moq.It.IsAny<Func<Moq.It.IsAnyType, Exception?, string>>()), Moq.Times.Once);
}
@@ -124,32 +124,38 @@ public class IntuneDeviceBindingValidatorTests
Assert.Null(result.EnrolledDateTime);
}
// -- ParseEnrolledDateTime --
// -- enrolledDateTime --
private static DateTimeOffset? EnrolledDateTime(string jsonValue)
=> IntuneDeviceBindingValidator.ParseManagedDeviceResponse(
$$"""{ "id": "{{DeviceId}}", "enrolledDateTime": {{jsonValue}} }""", DeviceId).EnrolledDateTime;
[Fact]
public void ParseEnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
public void EnrolledDateTime_IntuneZeroDate_IsAbsenceNotADate()
{
// Intune returns 0001-01-01T00:00:00Z when it has no enrollment timestamp. Treating that
// as a real date would report an object age of ~2000 years and poison the race analysis.
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime("0001-01-01T00:00:00Z"));
Assert.Null(EnrolledDateTime("\"0001-01-01T00:00:00Z\""));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not-a-date")]
public void ParseEnrolledDateTime_UnusableInput_IsNull(string? raw)
[InlineData("null")]
[InlineData("\"\"")]
[InlineData("\" \"")]
[InlineData("\"not-a-date\"")]
[InlineData("20260824")]
[InlineData("true")]
public void EnrolledDateTime_UnusableValue_IsNull(string jsonValue)
{
Assert.Null(IntuneDeviceBindingValidator.ParseEnrolledDateTime(raw));
Assert.Null(EnrolledDateTime(jsonValue));
}
[Fact]
public void ParseEnrolledDateTime_NormalizesToUtc()
public void EnrolledDateTime_NormalizesToUtc()
{
// A device object created minutes ago is the signature of an enrollment race, so the
// instant has to be comparable regardless of the offset Graph reports it in.
var parsed = IntuneDeviceBindingValidator.ParseEnrolledDateTime("2026-08-24T17:20:00+02:00");
var parsed = EnrolledDateTime("\"2026-08-24T17:20:00+02:00\"");
Assert.NotNull(parsed);
Assert.Equal(TimeSpan.Zero, parsed!.Value.Offset);
@@ -0,0 +1,261 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The Graph and Entra token-endpoint response parsers as they read with Newtonsoft before D-420, copied
/// verbatim as the reference for <see cref="UpstreamJsonDifferentialTests"/>. Temporary: removed together
/// with that test once the System.Text.Json parsers have run cleanly in production (backlog).
/// </summary>
internal static class LegacyUpstreamParsers
{
public static (string? AccessToken, long? ExpiresInSeconds) ReadTokenResponse(string responseBody)
{
var tokenJson = JsonConvert.DeserializeObject<JObject>(responseBody);
var accessToken = tokenJson?["access_token"]?.ToString();
return string.IsNullOrEmpty(accessToken) ? (accessToken, null) : (accessToken, (long?)tokenJson?["expires_in"]);
}
public static EntraTokenRejection ParseEntraRejection(int statusCode, string? body)
{
JObject? json = null;
if (!string.IsNullOrWhiteSpace(body))
{
try { json = JsonConvert.DeserializeObject<JToken>(body) as JObject; }
catch (JsonException) { json = null; }
}
var description = (json?["error_description"] as JValue)?.Value as string;
string? code = null;
var first = (json?["error_codes"] as JArray)?.FirstOrDefault(t => t.Type == JTokenType.Integer);
if (first != null)
{
code = "AADSTS" + first.Value<long>();
}
else
{
var start = description?.IndexOf("AADSTS", StringComparison.Ordinal) ?? -1;
if (start >= 0)
{
var digits = new string(description!.Skip(start + "AADSTS".Length).TakeWhile(char.IsDigit).ToArray());
code = digits.Length == 0 ? null : "AADSTS" + digits;
}
}
string? shortened = null;
if (!string.IsNullOrWhiteSpace(description))
{
var line = description.Split('\n')[0].Trim();
shortened = line.Length <= 300 ? line : line.Substring(0, 300);
}
return new EntraTokenRejection(statusCode, code, shortened);
}
public static AutopilotRegistration? FindRegistration(string responseBody, string normalizedSerial)
{
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
if (data?["value"] is not JArray devices || devices.Count == 0)
return null;
var exactDevice = devices.FirstOrDefault(d => string.Equals(
d?["serialNumber"]?.ToString()?.Trim(), normalizedSerial, StringComparison.OrdinalIgnoreCase));
if (exactDevice == null)
return null;
return new AutopilotRegistration
{
DeviceId = RegistrationText(exactDevice["id"]),
GroupTag = RegistrationText(exactDevice["groupTag"]),
PurchaseOrderId = RegistrationText(exactDevice["purchaseOrderIdentifier"]),
DisplayName = RegistrationText(exactDevice["displayName"]),
UserAssigned = RegistrationText(exactDevice["userPrincipalName"]) != null,
};
}
private static string? RegistrationText(JToken? token)
{
var value = token?.Type == JTokenType.String ? ((string?)token)?.Trim() : null;
if (string.IsNullOrEmpty(value))
return null;
return value!.Length > AutopilotDeviceValidator.MaxRegistrationFieldLength
? value.Substring(0, AutopilotDeviceValidator.MaxRegistrationFieldLength)
: value;
}
public static (bool Found, string NextUrl) ReadIdentityPage(string responseBody, string normalizedIdentifier)
{
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
if (data?["value"] is JArray identities)
{
foreach (var identity in identities)
{
var type = identity?["importedDeviceIdentityType"]?.ToString();
var value = identity?["importedDeviceIdentifier"]?.ToString();
if (string.Equals(type, "manufacturerModelSerial", StringComparison.OrdinalIgnoreCase)
&& value != null
&& string.Equals(CorporateIdentifierValidator.NormalizeStoredIdentifier(value), normalizedIdentifier, StringComparison.Ordinal))
{
return (true, string.Empty);
}
}
}
return (false, data?["@odata.nextLink"]?.ToString() ?? string.Empty);
}
public static DeviceAssociationResult ParseAssociation(string responseBody, string normalizedSerial)
{
var notFound = new DeviceAssociationResult
{
IsValid = false,
SerialNumber = normalizedSerial,
ErrorMessage = $"Device with serial '{normalizedSerial}' is not associated with this tenant (device association)"
};
JObject? data;
try { data = JsonConvert.DeserializeObject<JObject>(responseBody); }
catch (JsonException) { return notFound; }
var devices = data?["value"] as JArray;
if (devices == null || devices.Count == 0)
return notFound;
var match = devices.FirstOrDefault(d => string.Equals(
d?["serialNumber"]?.ToString()?.Trim(), normalizedSerial, StringComparison.OrdinalIgnoreCase));
if (match == null)
return notFound;
return new DeviceAssociationResult
{
IsValid = true,
SerialNumber = normalizedSerial,
AssociationState = match["associationState"]?.ToString(),
DevicePreparationPolicyId = match["devicePreparationPolicyId"]?.ToString(),
PreAssociatedByUserPrincipalName = match["preassociatedByUserPrincipalName"]?.ToString(),
AssignedToUserPrincipalName = match["assignedToUserPrincipalName"]?.ToString(),
PreAssociationDateTime = ParseAssociationDate(match["preassociationDateTime"]),
AssociationDateTime = ParseAssociationDate(match["associationDateTime"]),
ManagedDeviceId = match["managedDeviceId"]?.ToString()
};
}
private static DateTime? ParseAssociationDate(JToken? token)
{
if (token == null || token.Type == JTokenType.Null) return null;
var s = token.ToString();
if (string.IsNullOrEmpty(s)) return null;
if (DateTime.TryParse(s, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var dt))
return dt.Year <= 1 ? null : dt;
return null;
}
public static CloudPcValidationResult ParseCloudPc(string responseBody, string normalizedId)
{
var notFound = new CloudPcValidationResult
{
IsValid = false,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Device '{normalizedId}' is not a Windows 365 Cloud PC in this tenant"
};
JObject? data;
try { data = JsonConvert.DeserializeObject<JObject>(responseBody); }
catch (JsonException) { return notFound; }
var cloudPcs = data?["value"] as JArray;
if (cloudPcs == null || cloudPcs.Count == 0)
return notFound;
var match = cloudPcs.FirstOrDefault(c => string.Equals(
c?["managedDeviceId"]?.ToString()?.Trim(), normalizedId, StringComparison.OrdinalIgnoreCase));
if (match == null)
return notFound;
return new CloudPcValidationResult
{
IsValid = true,
IntuneDeviceId = normalizedId,
CloudPcId = match["id"]?.ToString(),
ManagedDeviceName = match["managedDeviceName"]?.ToString(),
ServicePlanName = match["servicePlanName"]?.ToString()
};
}
private static readonly HashSet<string> LeavingManagementStates = new(StringComparer.OrdinalIgnoreCase)
{
"retirePending", "retireIssued", "wipePending", "wipeIssued", "deletePending",
};
public static IntuneDeviceBindingResult ParseManagedDevice(string responseBody, string normalizedId)
{
var notFound = new IntuneDeviceBindingResult
{
Outcome = IntuneDeviceBindingOutcome.NotFound,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Device '{normalizedId}' is not an enrolled Intune device in this tenant"
};
JObject? device;
try { device = JsonConvert.DeserializeObject<JObject>(responseBody); }
catch (JsonException) { return notFound; }
var returnedId = device?["id"]?.ToString()?.Trim();
if (string.IsNullOrEmpty(returnedId) || !string.Equals(returnedId, normalizedId, StringComparison.OrdinalIgnoreCase))
return notFound;
var managementState = device?["managementState"]?.ToString();
var leaving = managementState != null && LeavingManagementStates.Contains(managementState);
return new IntuneDeviceBindingResult
{
Outcome = leaving ? IntuneDeviceBindingOutcome.NotManaged : IntuneDeviceBindingOutcome.Match,
IntuneDeviceId = normalizedId,
DeviceName = device?["deviceName"]?.ToString(),
AzureAdDeviceId = device?["azureADDeviceId"]?.ToString(),
ManagementState = managementState,
SerialNumber = NullIfEmpty(device?["serialNumber"]?.ToString()),
OwnerType = NullIfEmpty(device?["managedDeviceOwnerType"]?.ToString()),
EnrollmentType = NullIfEmpty(device?["deviceEnrollmentType"]?.ToString()),
AzureAdRegistered = device?["azureADRegistered"]?.Type == JTokenType.Boolean
? device["azureADRegistered"]!.Value<bool>()
: null,
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.ToString()),
ErrorMessage = leaving
? $"Device '{normalizedId}' is being removed from Intune management (state {managementState})"
: null
};
}
private static DateTimeOffset? ParseEnrolledDateTime(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
if (!DateTimeOffset.TryParse(raw, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var parsed))
return null;
return parsed.Year <= 1 ? null : parsed;
}
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
public static (List<ScriptDisplayNameResolver.ScriptListItem> Scripts, string? NextUrl) ReadScriptPage(string body, bool withFileName)
{
var json = JObject.Parse(body);
var values = json["value"] as JArray ?? new JArray();
var scripts = new List<ScriptDisplayNameResolver.ScriptListItem>();
foreach (var item in values.OfType<JObject>())
{
var id = item["id"]?.ToString();
if (string.IsNullOrEmpty(id)) continue;
scripts.Add(new ScriptDisplayNameResolver.ScriptListItem(id!, item["displayName"]?.ToString(), withFileName ? item["fileName"]?.ToString() : null));
}
return (scripts, json["@odata.nextLink"]?.ToString());
}
public static string? ReadScriptDisplayName(string body) => JObject.Parse(body)["displayName"]?.ToString();
}
@@ -45,6 +45,24 @@ public class LiveStreamPrincipalTests
Assert.Equal(new string?[] { SignalRGroupHelper.UserId(TenantId, Upn) }, signalR.NegotiatedUserIds);
}
[Fact]
public async Task Negotiate_under_view_as_binds_the_sign_in_tenant_not_the_viewed_one()
{
// Removing a Global Admin cuts the connections bound to their sign-in tenant; a view-as tab must be among them.
var signalR = new FakeSignalRNotificationService();
var req = BuildRequest(new RequestContext
{
TenantId = OtherTenantId,
UserPrincipalName = OperatorUpn,
ViewAs = new ViewAsContext(TenantId, AutopilotMonitor.Shared.Constants.TenantRoles.Admin),
});
var response = await new SignalRNegotiateFunction(NullLogger<SignalRNegotiateFunction>.Instance, signalR).Negotiate(req);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(new string?[] { SignalRGroupHelper.UserId(TenantId, OperatorUpn) }, signalR.NegotiatedUserIds);
}
[Fact]
public async Task Negotiate_without_a_principal_key_binds_no_user_id()
{
@@ -96,7 +96,7 @@ public class McpOrganizationUsageAggregationTests
Row(Member, "20260903", 7), // read window past today (range only)
};
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
var quota = McpQuotaNodes.BuildOrganizationQuota(
rows, new McpQuotaService.TenantPlanLimits("pro", 1500, 15000), "20260902", "20260901");
Assert.Equal("pro", quota.TenantPlan);
@@ -114,7 +114,7 @@ public class McpOrganizationUsageAggregationTests
public void OrganizationQuota_PurchasedSlots_CarryTheBreakdown()
{
// 3 000 + 3 × 900 = 5 700 — the limits arrive grown; the slot fields let the page show the sum.
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
var quota = McpQuotaNodes.BuildOrganizationQuota(
new[] { Row(Member, "20260902", 3) },
new McpQuotaService.TenantPlanLimits("pro", 5700, 114000, PurchasedDelegatedSlots: 3, SlotTenantDailyLimit: 900, SlotTenantMonthlyLimit: 18000),
"20260902", "20260901");
@@ -145,7 +145,7 @@ public class McpOrganizationUsageAggregationTests
[Fact]
public void OrganizationQuota_LiftedWindows_StillReportTheCounters()
{
var quota = McpUsageMetricsFunction.BuildOrganizationQuota(
var quota = McpQuotaNodes.BuildOrganizationQuota(
new[] { Row(Member, "20260902", 3) }, new McpQuotaService.TenantPlanLimits("pro", 0, 0), "20260902", "20260901");
Assert.Equal(0, quota.DailyLimit);
@@ -96,6 +96,47 @@ public class McpQuotaEnforcementMiddlewareTests
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", body.Error);
}
// ── ExceededAlertKey — one McpQuotaExceeded per account or organization, window and period ──
private const string OidA = "00000000-0000-0000-0000-00000000000a";
private const string OidB = "00000000-0000-0000-0000-00000000000b";
private static McpQuotaDecision UserDailyBlock(DateTime now)
=> McpQuotaService.BuildDecision(new McpPlanLimits("community", 50, 250, "community", 100, 250),
dailyUsed: 50, monthlyUsed: 60, tenantDailyUsed: 60, tenantMonthlyUsed: 60, now);
[Fact]
public void AlertKey_AccountWindow_IsPerAccountAndDay()
{
var key = McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home, UserDailyBlock(Now));
Assert.Equal($"user:daily:{OidA}:20260903", key); // the period is the reset date
Assert.NotEqual(key, McpQuotaEnforcementMiddleware.ExceededAlertKey(OidB, Home, UserDailyBlock(Now)));
Assert.NotEqual(key, McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home, UserDailyBlock(Now.AddDays(1))));
}
[Fact]
public void AlertKey_OrganizationWindow_IsSharedByAllMembers()
{
// The organization's window is one budget: the refusal of any member raises one event for it.
var tenantBlock = Blocked("community");
var a = McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home.ToUpperInvariant(), tenantBlock);
var b = McpQuotaEnforcementMiddleware.ExceededAlertKey(OidB, Home, tenantBlock);
Assert.Equal(a, b);
Assert.Equal($"tenant:daily:{Home}:20260903", a);
}
[Fact]
public void AlertKey_MonthlyWindow_IsItsOwnKey()
{
var monthly = McpQuotaService.BuildDecision(new McpPlanLimits("community", 50, 250, "community", 100, 250),
dailyUsed: 10, monthlyUsed: 250, tenantDailyUsed: 10, tenantMonthlyUsed: 10, Now);
Assert.Equal($"user:monthly:{OidA}:20261001", McpQuotaEnforcementMiddleware.ExceededAlertKey(OidA, Home, monthly));
}
[Fact]
public void UserLevelBlock_ReportsTheUserWindow()
{
@@ -130,7 +130,8 @@ public class McpQuotaServiceTests
Func<string?, TenantEdition>? editionResolver = null,
Func<string?, string?>? tenantPlanOverrideResolver = null,
Func<string?, int>? purchasedSlotsResolver = null,
Func<string?, EditionResolution>? resolutionResolver = null)
Func<string?, EditionResolution>? resolutionResolver = null,
TestTimeProvider? time = null)
{
var adminRepo = new Mock<IAdminRepository>();
adminRepo.Setup(r => r.GetMcpUserAsync(It.IsAny<string>()))
@@ -162,9 +163,8 @@ public class McpQuotaServiceTests
mcpUserService,
adminConfigService,
entitlements,
cache,
NullLogger<McpQuotaService>.Instance,
new TestTimeProvider(Now));
time ?? new TestTimeProvider(Now));
}
private static Mock<IUserUsageRepository> UsageRepo(params (string Date, long Count)[] rows)
@@ -197,7 +197,7 @@ public class McpQuotaServiceTests
// 100 requests today (catalog Community fallback: 100/day) → blocked.
var svc = Build(UsageRepo(("20260707", 100)));
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("community", d.Plan);
@@ -210,7 +210,7 @@ public class McpQuotaServiceTests
{
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Pro);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(d.Allowed);
Assert.Equal("pro", d.Plan);
@@ -224,7 +224,7 @@ public class McpQuotaServiceTests
// 2990 across the month + 20 today = 3010 ≥ 3000 monthly Community limit.
var svc = Build(UsageRepo(("20260701", 1500), ("20260703", 1490), ("20260707", 20)));
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("monthly", d.Scope);
@@ -238,7 +238,7 @@ public class McpQuotaServiceTests
var json = """[{"name":"community","dailyRequestLimit":5,"monthlyRequestLimit":50,"description":""}]""";
var svc = Build(UsageRepo(("20260707", 5)), planDefinitionsJson: json);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal(5, d.DailyLimit);
@@ -251,7 +251,7 @@ public class McpQuotaServiceTests
var json = """[{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"description":""}]""";
var svc = Build(UsageRepo(("20260707", 500)), planDefinitionsJson: json, mcpUserPlanOverride: "power");
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(d.Allowed);
Assert.Equal("power", d.Plan);
@@ -266,7 +266,7 @@ public class McpQuotaServiceTests
var json = """[{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"description":""}]""";
var svc = Build(UsageRepo(("20260707", 500)), planDefinitionsJson: json, mcpUserPlanOverride: "power", bound: false);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("community", d.Plan);
@@ -279,7 +279,7 @@ public class McpQuotaServiceTests
// Override names a plan that exists nowhere → Community fallback limits.
var svc = Build(UsageRepo(("20260707", 100)), mcpUserPlanOverride: "no-such-plan");
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("no-such-plan", d.Plan);
@@ -300,7 +300,7 @@ public class McpQuotaServiceTests
var svc = Build(UsageRepo(("20260707", 1200)), planDefinitionsJson: MspPlanJson,
edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp");
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(d.Allowed);
Assert.Equal("msp", d.Plan);
@@ -318,7 +318,7 @@ public class McpQuotaServiceTests
var svc = Build(UsageRepo(("20260707", 2000)), planDefinitionsJson: json,
edition: TenantEdition.Pro, mcpUserPlanOverride: "power", tenantPlanOverrideResolver: _ => "msp");
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(d.Allowed);
Assert.Equal("power", d.Plan);
@@ -335,7 +335,7 @@ public class McpQuotaServiceTests
// falls back fail-closed (user: Community catalog; tenant: the EDITION's catalog windows).
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp");
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("msp", d.Plan);
@@ -351,7 +351,7 @@ public class McpQuotaServiceTests
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: MspPlanJson,
edition: TenantEdition.Pro, tenantPlanOverrideResolver: _ => "msp", purchasedSlotsResolver: _ => 2);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.Equal("msp", d.Plan);
Assert.Equal(2100, d.DailyLimit); // 1500 + 2 × 300
@@ -367,8 +367,8 @@ public class McpQuotaServiceTests
.ThrowsAsync(new InvalidOperationException("storage down"));
var svc = Build(repo);
var first = await svc.CheckAsync(Oid, Upn, TenantId);
var second = await svc.CheckAsync(Oid, Upn, TenantId);
var first = await svc.AdmitAsync(Oid, Upn, TenantId);
var second = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(first.Allowed);
Assert.True(second.Allowed);
@@ -383,69 +383,144 @@ public class McpQuotaServiceTests
var repo = UsageRepo(("20260707", 1));
var svc = Build(repo);
await svc.CheckAsync(Oid, Upn, TenantId);
await svc.CheckAsync(Oid, Upn, TenantId);
await svc.AdmitAsync(Oid, Upn, TenantId);
await svc.AdmitAsync(Oid, Upn, TenantId);
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()),
Times.Once);
}
// ── Soft-boundary contract (Codex finding 2026-07-07) ─────────────────────────
// The quota is DELIBERATELY soft: the per-user decision is cached for 60s, so an allowed
// decision keeps admitting requests inside that window even after the fire-and-forget
// increments push the stored counters past the limit. Overshoot is bounded (~TTL × request
// rate per instance) — the same posture as the sliding-window rate limiter. These tests PIN
// that contract; if strict limit+1 blocking is ever wanted, the decision cache must be
// reworked and these tests consciously rewritten.
// ── Boundary: exact per instance, soft across instances ──────────────────────
// The counters are read from storage at most every 60 s, and every request the instance admits is
// counted on them at once — so one instance blocks exactly at the limit, also when a tool call fans out
// into parallel requests. What stays soft is the part of OTHER instances: they learn of these requests
// only with their own next read.
[Fact]
public async Task Check_SoftBoundary_CachedAllowedDecision_KeepsAdmitting_WithinTtl()
public async Task Check_SequentialRequests_BlockExactlyAtTheLimit_WithoutRereading()
{
// First read: one request below the daily limit → allowed, decision cached.
var repo = UsageRepo(("20260707", 99));
// Community catalog: 100/day. 98 served before → the 99th and 100th pass, the 101st is refused.
var repo = UsageRepo(("20260707", 98));
var svc = Build(repo);
var first = await svc.CheckAsync(Oid, Upn, TenantId);
Assert.True(first.Allowed);
var ninetyNinth = await svc.AdmitAsync(Oid, Upn, TenantId);
var hundredth = await svc.AdmitAsync(Oid, Upn, TenantId);
var hundredFirst = await svc.AdmitAsync(Oid, Upn, TenantId);
// The async increments land: stored counters are now OVER the limit — but the cached
// allowed decision keeps admitting within the TTL, without re-reading storage.
repo.Setup(r => r.GetUsageByUserAsync(Oid, It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(new List<UserUsageRecord>
{
new() { UserId = Oid, Date = "20260707", RequestCount = 150 }
});
var second = await svc.CheckAsync(Oid, Upn, TenantId);
Assert.True(second.Allowed, "soft boundary: cached allowed decision persists within the TTL");
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()),
Times.Once);
Assert.True(ninetyNinth.Allowed);
Assert.Equal(98, ninetyNinth.DailyUsed); // the decision reports the count BEFORE this request
Assert.True(hundredth.Allowed);
Assert.False(hundredFirst.Allowed);
Assert.Equal("daily", hundredFirst.Scope);
Assert.Equal(100, hundredFirst.DailyUsed);
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
}
[Fact]
public async Task Check_SoftBoundary_AfterCacheEviction_OverLimitCounters_Block()
public async Task ReadState_NeverCounts_OnlyAdmitDoes()
{
// Same scenario as above, but once the cached decision is gone (TTL expiry — simulated
// via eviction), the next check reads the over-limit counters and blocks.
var cache = new MemoryCache(new MemoryCacheOptions());
// The usage page shows the quota; viewing it is not an MCP request. Three reads leave the count at 99,
// so the next real request still passes and only the one after it is refused.
var svc = Build(UsageRepo(("20260707", 99)));
for (var i = 0; i < 3; i++)
{
var view = await svc.ReadStateAsync(Oid, Upn, TenantId);
Assert.True(view.Allowed);
Assert.Equal(99, view.DailyUsed);
}
Assert.True((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
Assert.False((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
Assert.Equal(100, (await svc.ReadStateAsync(Oid, Upn, TenantId)).DailyUsed);
}
[Fact]
public async Task Check_ParallelBurst_AdmitsExactlyUpToTheLimit()
{
// A tool call's parallel requests must not all pass on the same reading: 95 used, 20 at once → 5 pass.
var repo = UsageRepo(("20260707", 95));
var svc = Build(repo);
var decisions = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Run(() => svc.AdmitAsync(Oid, Upn, TenantId))));
Assert.Equal(5, decisions.Count(d => d.Allowed));
// Single-flight: concurrent first checks share one counter, so storage was read once.
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
}
[Fact]
public async Task Check_TenantWindow_CountsEveryMemberOnThisInstance()
{
// Community organization window 300/day, 298 used by the organization. Member A and member B each get one
// request through; the next one of A is refused at the organization level although A's own window is wide.
var repo = UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 298L) });
repo.Setup(r => r.GetUsageByUserAsync(OtherOid, It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(new List<UserUsageRecord> { new() { UserId = OtherOid, Date = "20260707", RequestCount = 1 } });
var svc = Build(repo);
Assert.True((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
Assert.True((await svc.AdmitAsync(OtherOid, "bob@contoso.com", TenantId)).Allowed);
var third = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(third.Allowed);
Assert.Equal("tenant", third.Level);
Assert.Equal(300, third.TenantDailyUsed);
repo.Verify(r => r.GetTenantUsageAsync(TenantId, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
}
[Fact]
public async Task Check_SecondInstance_LearnsOfTheFirstOnlyWithItsOwnRead()
{
// The part that stays soft, pinned: two instances share storage, not memory. Storage still says 99 (the
// increments of instance A are on their way), so instance B admits one more on its own fresh reading.
var repo = UsageRepo(("20260707", 99));
var svc = Build(repo, cache: cache);
var instanceA = Build(repo);
var instanceB = Build(repo);
Assert.True((await svc.CheckAsync(Oid, Upn, TenantId)).Allowed);
Assert.True((await instanceA.AdmitAsync(Oid, Upn, TenantId)).Allowed);
Assert.False((await instanceA.AdmitAsync(Oid, Upn, TenantId)).Allowed);
Assert.True((await instanceB.AdmitAsync(Oid, Upn, TenantId)).Allowed);
Assert.False((await instanceB.AdmitAsync(Oid, Upn, TenantId)).Allowed);
}
[Fact]
public async Task Check_CounterExpiry_RereadsStorage()
{
// After 60 s the counter is read again, so increments of other instances count from then on.
var time = new TestTimeProvider(Now);
var repo = UsageRepo(("20260707", 10));
var svc = Build(repo, time: time);
Assert.True((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
repo.Setup(r => r.GetUsageByUserAsync(Oid, It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync(new List<UserUsageRecord>
{
new() { UserId = Oid, Date = "20260707", RequestCount = 150 }
});
cache.Remove(McpQuotaService.UserCacheKey(Oid)); // stands in for the 60s TTL elapsing
.ReturnsAsync(new List<UserUsageRecord> { new() { UserId = Oid, Date = "20260707", RequestCount = 150 } });
time.SetUtcNow(Now.AddSeconds(61));
var decision = await svc.CheckAsync(Oid, Upn, TenantId);
var decision = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(decision.Allowed);
Assert.Equal("daily", decision.Scope);
Assert.Equal(150, decision.DailyUsed);
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Exactly(2));
}
[Fact]
public async Task Check_Midnight_StartsAFreshCounter_InsideTheSixtySeconds()
{
// A counter read at 23:59:50 never answers a check at 00:00:05: the day is part of the key.
var lateEvening = new DateTime(2026, 7, 7, 23, 59, 50, DateTimeKind.Utc);
var time = new TestTimeProvider(lateEvening);
var repo = UsageRepo(("20260707", 100));
var svc = Build(repo, time: time);
Assert.False((await svc.AdmitAsync(Oid, Upn, TenantId)).Allowed);
time.SetUtcNow(lateEvening.AddSeconds(15));
var afterMidnight = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(afterMidnight.Allowed);
Assert.Equal(0, afterMidnight.DailyUsed);
Assert.Equal(new DateTime(2026, 7, 9, 0, 0, 0, DateTimeKind.Utc), afterMidnight.ResetUtc);
}
// ── Tenant (organization-wide) quota ─────────────────────────────────────────
@@ -456,7 +531,7 @@ public class McpQuotaServiceTests
// Community catalog tenant window = 300/day; other members burned it, this caller made 5 requests.
var svc = Build(UsageRepo(new[] { ("20260707", 5L) }, tenantRows: new[] { ("20260707", 300L) }));
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("tenant", d.Level);
@@ -474,7 +549,7 @@ public class McpQuotaServiceTests
new[] { ("20260707", 1L) },
tenantRows: new[] { ("20260701", 4000L), ("20260703", 4990L), ("20260707", 10L) }));
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("tenant", d.Level);
@@ -492,7 +567,7 @@ public class McpQuotaServiceTests
var svc = Build(UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 10L) }),
planDefinitionsJson: json, mcpUserPlanOverride: "power");
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("power", d.Plan);
@@ -509,7 +584,7 @@ public class McpQuotaServiceTests
var json = """[{"name":"pro","dailyRequestLimit":5,"monthlyRequestLimit":50,"description":""}]""";
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(d.Allowed);
Assert.Equal(5, d.DailyLimit);
@@ -524,7 +599,7 @@ public class McpQuotaServiceTests
var repo = UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 999999L) });
var svc = Build(repo, planDefinitionsJson: json);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(d.Allowed);
Assert.Equal(0, d.TenantDailyLimit);
@@ -537,7 +612,7 @@ public class McpQuotaServiceTests
var repo = UsageRepo(("20260707", 1));
var svc = Build(repo);
var d = await svc.CheckAsync(Oid, Upn, tenantId: null);
var d = await svc.AdmitAsync(Oid, Upn, tenantId: null);
Assert.True(d.Allowed);
repo.Verify(r => r.GetTenantUsageAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
@@ -551,8 +626,8 @@ public class McpQuotaServiceTests
.ThrowsAsync(new InvalidOperationException("storage down"));
var svc = Build(repo);
var first = await svc.CheckAsync(Oid, Upn, TenantId);
var second = await svc.CheckAsync(Oid, Upn, TenantId);
var first = await svc.AdmitAsync(Oid, Upn, TenantId);
var second = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(first.Allowed);
Assert.True(second.Allowed);
@@ -573,7 +648,7 @@ public class McpQuotaServiceTests
// Pro home with 3 purchased slots: 1000 + 3 × 300 per account, 3000 + 3 × 900 for the organization.
var svc = Build(UsageRepo(("20260707", 1)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 3);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.Equal(1900, d.DailyLimit);
Assert.Equal(38000, d.MonthlyLimit);
@@ -592,7 +667,7 @@ public class McpQuotaServiceTests
// A Pro tenant on its two included slots (purchased = 0) keeps the plain Pro windows.
var svc = Build(UsageRepo(("20260707", 1)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 0);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.Equal(1000, d.DailyLimit);
Assert.Equal(3000, d.TenantDailyLimit);
@@ -607,7 +682,7 @@ public class McpQuotaServiceTests
var conferred = new EditionResolution(TenantEdition.Pro, EditionSource.Msp, OwnPro: false);
var svc = Build(UsageRepo(("20260707", 1)), resolutionResolver: _ => conferred, purchasedSlotsResolver: _ => 3);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.Equal("pro", d.Plan);
Assert.Equal(1000, d.DailyLimit);
@@ -625,7 +700,7 @@ public class McpQuotaServiceTests
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro,
mcpUserPlanOverride: "power", purchasedSlotsResolver: _ => 2);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.Equal("power", d.Plan);
Assert.Equal(10600, d.DailyLimit);
@@ -639,7 +714,7 @@ public class McpQuotaServiceTests
var json = """[{"name":"pro","dailyRequestLimit":1000,"monthlyRequestLimit":20000,"slotDailyRequestLimit":0,"slotMonthlyRequestLimit":0,"slotTenantDailyRequestLimit":0,"slotTenantMonthlyRequestLimit":0,"description":""}]""";
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 5);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.Equal(1000, d.DailyLimit);
Assert.Equal(3000, d.TenantDailyLimit);
@@ -653,7 +728,7 @@ public class McpQuotaServiceTests
var json = """[{"name":"pro","dailyRequestLimit":1000,"monthlyRequestLimit":20000,"slotDailyRequestLimit":50,"slotMonthlyRequestLimit":500,"slotTenantDailyRequestLimit":100,"slotTenantMonthlyRequestLimit":1000,"description":""}]""";
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 2);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.Equal(1100, d.DailyLimit);
Assert.Equal(21000, d.MonthlyLimit);
@@ -668,7 +743,7 @@ public class McpQuotaServiceTests
var json = """[{"name":"pro","dailyRequestLimit":1000,"monthlyRequestLimit":20000,"description":""}]""";
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 1);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.Equal(1300, d.DailyLimit);
Assert.Equal(3900, d.TenantDailyLimit);
@@ -682,7 +757,7 @@ public class McpQuotaServiceTests
var json = """[{"name":"pro","dailyRequestLimit":0,"monthlyRequestLimit":20000,"tenantDailyRequestLimit":0,"tenantMonthlyRequestLimit":60000,"description":""}]""";
var svc = Build(UsageRepo(("20260707", 30000)), planDefinitionsJson: json, edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 4);
var d = await svc.CheckAsync(Oid, Upn, TenantId);
var d = await svc.AdmitAsync(Oid, Upn, TenantId);
Assert.True(d.Allowed);
Assert.Equal(0, d.DailyLimit);
@@ -705,10 +780,10 @@ public class McpQuotaServiceTests
{
// Pro 1000 + 1 × 300 = 1300: the 1300th request of the day is the first blocked one.
var below = Build(UsageRepo(("20260707", 1299)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 1);
Assert.True((await below.CheckAsync(Oid, Upn, TenantId)).Allowed);
Assert.True((await below.AdmitAsync(Oid, Upn, TenantId)).Allowed);
var at = Build(UsageRepo(("20260707", 1300)), edition: TenantEdition.Pro, purchasedSlotsResolver: _ => 1);
var d = await at.CheckAsync(Oid, Upn, TenantId);
var d = await at.AdmitAsync(Oid, Upn, TenantId);
Assert.False(d.Allowed);
Assert.Equal("daily", d.Scope);
Assert.Equal("user", d.Level);
@@ -726,8 +801,8 @@ public class McpQuotaServiceTests
.ReturnsAsync(new List<UserUsageRecord>());
var svc = Build(repo);
await svc.CheckAsync(Oid, Upn, TenantId);
await svc.CheckAsync(OtherOid, "bob@contoso.com", TenantId);
await svc.AdmitAsync(Oid, Upn, TenantId);
await svc.AdmitAsync(OtherOid, "bob@contoso.com", TenantId);
repo.Verify(r => r.GetTenantUsageAsync(TenantId, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Exactly(2));
@@ -0,0 +1,323 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using static AutopilotMonitor.Functions.Services.McpUsageOverviewService;
using static AutopilotMonitor.Functions.Services.McpUsageTelemetryQueries;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The pure fold behind GET global/metrics/mcp-usage/overview: which numbers come from the quota counters and
/// which from the telemetry, how platform admins drop out (but never out of a tenant's real organization
/// windows), what a call costs, MCP-only tools, the daily trend and the per-minute ranking.
/// </summary>
public class McpUsageOverviewAssemblyTests
{
private const string TenantA = "aaaaaaaa-0000-0000-0000-00000000000a";
private const string TenantB = "bbbbbbbb-0000-0000-0000-00000000000b";
private const string Alice = "alice@contoso.com";
private const string App = "app:11111111-2222-3333-4444-555555555555";
private const string Operator = "ops@fabrikam.com";
private const string Bob = "bob@fabrikam.com";
private const string Carol = "carol@contoso.com";
// Window 3 days (Nov 3–5); the month started Nov 1, so Nov 1 rows count for the quota, not for the window.
private static readonly Dates ThreeDays = new("20261103", "20261105", "20261101");
private static readonly DateTime At = new(2026, 11, 5, 10, 0, 0, DateTimeKind.Utc);
private static TenantUsageRecord Counter(string tenant, string oid, string upn, string day, long requests)
=> new() { TenantId = tenant, UserId = oid, UserPrincipalName = upn, Date = day, RequestCount = requests, LastRequestAt = At };
private static readonly TenantUsageRecord[] Counters =
{
Counter(TenantA, "oid-alice", Alice, "20261101", 10),
Counter(TenantA, "oid-alice", Alice, "20261104", 20),
Counter(TenantA, "oid-alice", Alice, "20261105", 30),
Counter(TenantA, "oid-app", App, "20261105", 5),
Counter(TenantA, "oid-ops", Operator, "20261105", 100),
Counter(TenantB, "oid-bob", Bob, "20261103", 7),
};
private static CallRow Call(string day, string tool, string tenant, string principal, long calls, long served, long requests, string role)
=> new(day, tool, tenant, principal, calls, served, requests, At, role);
private static readonly CallRow[] Calls =
{
Call("20261104", "get_session_summary", TenantA, Alice, 2, 2, 10, "Admin"),
Call("20261105", "search_events", TenantA, Alice, 1, 1, 14, "Admin"),
Call("20261105", "get_session_events", TenantA, Alice, 3, 3, 3, "Admin"),
Call("20261105", "list_tenants", TenantA, Alice, 1, 1, 1, "Admin"),
Call("20261105", "get_session_summary", TenantA, App, 1, 1, 5, "Viewer"),
Call("20261105", "query_table", TenantA, Operator, 50, 50, 50, "GlobalAdmin"),
Call("20261103", "search_sessions", TenantB, Bob, 2, 2, 7, "Admin"),
Call("20261105", "get_time_attribution", TenantA, Carol, 4, 0, 0, "Admin"),
};
private static McpQuotaDecision State(string plan, int daily, int monthly, long dailyUsed, long monthlyUsed)
=> new() { Allowed = true, Plan = plan, DailyLimit = daily, MonthlyLimit = monthly, DailyUsed = dailyUsed, MonthlyUsed = monthlyUsed };
private static TelemetryResult Telemetry(bool available = true) => !available
? new TelemetryResult(false, false, false, new(), new(), new(), new(), new(), new(), new ServerFacts(null, null))
: new TelemetryResult(
RequestsAvailable: true, McpLogAvailable: true, Partial: false,
Calls: Calls.ToList(),
Routes: new()
{
new("get_session_summary", "GET sessions/{id}/events", 3),
new("get_session_summary", "GET sessions/{id}", 3),
},
Refusals: new()
{
new(RefusalQuota, "20261105", TenantA, Alice, "user", "daily", "community", 3, At, At.AddSeconds(9), 50, 50),
new(RefusalMinute, "20261103", TenantB, Bob, "", "", "", 2, At, At, 120, 120),
},
Peaks: new()
{
new(PeakCalls, TenantA, Alice, 12, At),
new(PeakRequests, TenantA, Alice, 40, At),
new(PeakRequests, TenantB, Bob, 120, At),
new(PeakCalls, TenantB, Bob, 5, At),
},
ToolQuality: new()
{
new("get_session_summary", 3, 0, 0, 0, 600, 19000.4),
new("search_docs", 4, 1, 0, 0, 80, 5000),
},
McpRefusals: new() { new(Alice, 7, At) },
ServerFacts: new ServerFacts(new DateTime(2026, 11, 4, 1, 0, 0, DateTimeKind.Utc), 60));
private static GetGlobalMcpUsageOverviewResponse Overview(bool includePlatformAdmins = false, bool telemetry = true)
{
var result = Telemetry(telemetry);
var facts = new Dictionary<(string Tenant, string Principal), AccountFacts>
{
[(TenantA, Alice)] = new("oid-alice", null, State("community", 50, 250, 30, 60)),
[(TenantA, App)] = new("oid-app", null, State("community", 50, 250, 5, 5)),
[(TenantA, Operator)] = new("oid-ops", "GlobalAdmin", State("community", 50, 250, 100, 100)),
[(TenantB, Bob)] = new("oid-bob", null, State("pro", 500, 6000, 0, 7)),
};
var limits = new Dictionary<string, McpQuotaService.TenantPlanLimits>
{
[TenantA] = new("community", 100, 250),
[TenantB] = new("pro", 1500, 15000),
};
// TenantB has no readable configuration: no name.
var names = new Dictionary<string, string> { [TenantA] = "contoso.com" };
var minute = new Dictionary<(string Tenant, string Principal), int> { [(TenantA, Alice)] = 120, [(TenantB, Bob)] = 150 };
return Assemble(new OverviewInputs(ThreeDays, includePlatformAdmins, Counters, facts, limits, names, LatestRoles(result.Calls), minute, result));
}
[Fact]
public void PlatformAdmins_DropOutOfEveryNumber_ButNotOutOfTheOrganizationWindows()
{
var overview = Overview();
var tenantA = overview.Tenants.Single(t => t.TenantId == TenantA);
Assert.DoesNotContain(tenantA.Accounts, a => a.Principal == Operator);
Assert.DoesNotContain(overview.Tools, t => t.Tool == "query_table");
Assert.Equal(55, tenantA.Requests); // alice 20 + 30, app 5 — the operator's 100 left out
// The organization windows stay the tenant's real counters: today 30 + 5 + 100, the month 10 + 20 + 30 + 5 + 100.
Assert.Equal(135, tenantA.Quota.DailyUsed);
Assert.Equal(165, tenantA.Quota.MonthlyUsed);
Assert.Equal("community", tenantA.Quota.TenantPlan);
}
[Fact]
public void PlatformAdmins_WhenIncluded_AreMarked()
{
var tenantA = Overview(includePlatformAdmins: true).Tenants.Single(t => t.TenantId == TenantA);
var op = tenantA.Accounts.Single(a => a.Principal == Operator);
Assert.True(op.IsPlatformAdmin);
Assert.Equal("GlobalAdmin", op.Role);
Assert.Equal(155, tenantA.Requests);
}
[Fact]
public void Accounts_RequestsComeFromTheCounters_CallsFromTheTelemetry()
{
var tenants = Overview().Tenants;
Assert.Equal(new[] { TenantA, TenantB }, tenants.Select(t => t.TenantId)); // most calls first
var accounts = tenants[0].Accounts;
Assert.Equal(new[] { Alice, Carol, App }, accounts.Select(a => a.Principal));
var alice = accounts[0];
Assert.Equal("oid-alice", alice.UserId);
Assert.Equal(7, alice.Calls);
Assert.Equal(50, alice.Requests); // Nov 1 lies before the window
Assert.Equal("20261105", alice.PeakDay);
Assert.Equal(30, alice.PeakDayRequests);
Assert.Equal(1, alice.QuotaHits);
Assert.Equal("Admin", alice.Role);
Assert.Equal(new[] { ("get_session_events", 3L), ("get_session_summary", 2L), ("list_tenants", 1L) },
alice.TopTools.Select(t => (t.Tool, t.Calls)));
Assert.Equal(("community", 50, 250, 30L, 60L),
(alice.Quota!.Plan, alice.Quota.DailyLimit, alice.Quota.MonthlyLimit, alice.Quota.DailyUsed, alice.Quota.MonthlyUsed));
}
[Fact]
public void AnAccountKnownOnlyFromTheTelemetry_HasNoObjectIdAndNoQuota()
{
// Every request refused: no counter row, but the calls happened.
var carol = Overview().Tenants.Single(t => t.TenantId == TenantA).Accounts.Single(a => a.Principal == Carol);
Assert.Null(carol.UserId);
Assert.Null(carol.Quota);
Assert.Equal(4, carol.Calls);
Assert.Equal(0, carol.Requests);
Assert.Null(carol.PeakDay);
}
[Fact]
public void Tools_RankByCalls_CostPerServedCall_AndMcpOnlyToolsFromTheLog()
{
var tools = Overview().Tools;
Assert.Equal(
new[] { "get_time_attribution", "search_docs", "get_session_events", "get_session_summary", "search_sessions", "list_tenants", "search_events" },
tools.Select(t => t.Tool));
var summary = tools.Single(t => t.Tool == "get_session_summary");
Assert.Equal(3, summary.Calls);
Assert.Equal(2, summary.Accounts);
Assert.Equal(1, summary.Tenants);
Assert.Equal(5.0, summary.RequestsPerCall); // (10 + 5) requests over 3 served calls
Assert.Equal(0.0, summary.ErrorRate);
Assert.Equal(600, summary.P95DurationMs);
Assert.Equal(19000, summary.AvgResultChars);
Assert.Equal(new[] { "GET sessions/{id}", "GET sessions/{id}/events" }, summary.Routes.Select(r => r.Route));
var docs = tools.Single(t => t.Tool == "search_docs");
Assert.Equal(4, docs.Calls);
Assert.Null(docs.Accounts);
Assert.Null(docs.Tenants);
Assert.Equal(0, docs.RequestsPerCall);
Assert.Equal(0.25, docs.ErrorRate);
// Every call refused: no served call, no cost to report.
Assert.Equal(0, tools.Single(t => t.Tool == "get_time_attribution").RequestsPerCall);
}
[Fact]
public void Daily_HasEveryDayOfTheWindow_WithCallsByPlan()
{
var daily = Overview().Daily;
Assert.Equal(new[] { "20261103", "20261104", "20261105" }, daily.Select(d => d.Date));
var today = daily[2];
Assert.Equal(10, today.Calls); // alice 5, app 1, carol 4 — the operator's 50 left out
Assert.Equal(35, today.Requests); // alice 30, app 5
Assert.Equal(3, today.Accounts);
Assert.Equal(new[] { ("community", 10L) }, today.CallsByPlan.Select(p => (p.Plan, p.Calls)));
Assert.Equal(new[] { ("pro", 2L) }, daily[0].CallsByPlan.Select(p => (p.Plan, p.Calls)));
}
[Fact]
public void QuotaHits_AndMinuteLimits_RankTheAccountsClosestToALimit()
{
var overview = Overview();
var hit = Assert.Single(overview.QuotaHits);
Assert.Equal((Alice, "user", "daily", 50L, 50L, 3L), (hit.Principal, hit.Level, hit.Scope, hit.Limit, hit.Used, hit.Refused));
Assert.Equal(60, overview.MinuteLimits.McpServerLimit);
var minute = overview.MinuteLimits.Accounts;
Assert.Equal(new[] { Bob, Alice }, minute.Select(m => m.Principal)); // 120/150 before 40/120 and 12/60
Assert.Equal((120L, 150, 2L), (minute[0].PeakRequests, minute[0].BackendLimit, minute[0].BackendRefused));
Assert.Equal((TenantA, 12L, 7L, 0L), (minute[1].TenantId, minute[1].PeakCalls, minute[1].McpRefused, minute[1].BackendRefused));
// The share the list ranks by, in whole percent: the higher of calls/MCP limit and requests/backend limit.
Assert.Equal(new[] { 80, 33 }, minute.Select(m => m.SharePercent)); // 120/150; max(12/60, 40/120)
}
[Fact]
public void Totals_SumTheLists_OnceForEveryConsumer()
{
var totals = Overview().Totals;
Assert.Equal(18, totals.Calls); // every tool of the ranking, search_docs' 4 MCP-only calls included
Assert.Equal(4, totals.McpOnlyCalls);
Assert.Equal(7, totals.Tools);
Assert.Equal(62, totals.Requests); // tenant A 55, tenant B 7 — the operator's 100 left out
Assert.Equal(2, totals.Tenants);
Assert.Equal(4, totals.Accounts); // alice, carol, the app, bob
Assert.Equal(1, totals.ServicePrincipals);
Assert.Equal(1, totals.QuotaHitAccounts); // alice's daily window
Assert.Equal(2, totals.MinuteRefusedAccounts); // alice at the MCP server, bob at the backend
Assert.Equal(2, totals.LimitAccounts);
}
[Fact]
public void Totals_WithoutTheirStore_AreAbsent_NotAPartialSum()
{
var totals = Overview(telemetry: false).Totals;
Assert.Null(totals.Calls);
Assert.Null(totals.McpOnlyCalls);
Assert.Equal(0, totals.Tools);
Assert.Equal(62, totals.Requests); // the counters still answer
Assert.Equal(3, totals.Accounts);
}
[Fact]
public void Tenants_CarryTheirDomain_WhenTheConfigurationNamesOne()
{
var tenants = Overview().Tenants;
Assert.Equal("contoso.com", tenants.Single(t => t.TenantId == TenantA).DomainName);
Assert.Null(tenants.Single(t => t.TenantId == TenantB).DomainName);
}
[Fact]
public async Task AnUnreadableTenantConfiguration_LeavesOnlyTheNameOut()
{
var config = new Mock<TenantConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()))
{ CallBase = false };
config.Setup(c => c.GetConfigurationIfExistsAsync(TenantA)).ThrowsAsync(new InvalidOperationException("storage down"));
config.Setup(c => c.GetConfigurationIfExistsAsync(TenantB)).ReturnsAsync(new TenantConfiguration { TenantId = TenantB, DomainName = "fabrikam.com" });
// Only the configuration read is exercised; the other collaborators stay unused.
var service = new McpUsageOverviewService(
null!, null!, null!, null!, config.Object, null!, null!, null!, NullLogger<McpUsageOverviewService>.Instance, TimeProvider.System);
Assert.Null(await service.ReadTenantConfigurationAsync(TenantA));
Assert.Equal("fabrikam.com", (await service.ReadTenantConfigurationAsync(TenantB))?.DomainName);
}
[Theory]
[InlineData(null, null, 30, false, null)]
[InlineData("7", "include", 7, true, null)]
[InlineData("90", "exclude", 90, false, null)]
[InlineData("91", null, 0, false, "days must be between 1 and 90")]
[InlineData("0", null, 0, false, "days must be between 1 and 90")]
[InlineData("-5", null, 0, false, "days must be between 1 and 90")]
[InlineData("7.5", null, 0, false, "days must be an integer")]
[InlineData("30", "all", 0, false, "platformAdmins must be include or exclude")]
public void OverviewQuery_DefaultsAndBounds(string? days, string? platformAdmins, int expectedDays, bool expectedInclude, string? expectedError)
{
var parsed = AutopilotMonitor.Functions.Functions.Metrics.McpUsageMetricsFunction.ParseOverviewQuery(days, platformAdmins);
Assert.Equal((expectedDays, expectedInclude, expectedError), parsed);
}
[Fact]
public void Status_NamesTheMcpLogCoverage_AndATelemetryOutageEmptiesOnlyItsParts()
{
Assert.Equal("20261104", Overview().Telemetry.McpLogFrom);
var dark = Overview(telemetry: false);
Assert.False(dark.Telemetry.RequestsAvailable);
Assert.False(dark.Telemetry.McpLogAvailable);
Assert.Empty(dark.Tools);
Assert.Empty(dark.QuotaHits);
// The counters still carry tenants, accounts, requests and the quota.
var tenantA = dark.Tenants.Single(t => t.TenantId == TenantA);
Assert.Equal(new[] { Alice, App }, tenantA.Accounts.Select(a => a.Principal).OrderBy(p => p, StringComparer.Ordinal));
Assert.Equal(0, tenantA.Calls);
Assert.Equal(55, tenantA.Requests);
}
}
@@ -0,0 +1,203 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Xunit;
using static AutopilotMonitor.Functions.Services.McpUsageTelemetryQueries;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The fixed KQL of the MCP usage overview: which rows it reads (the worker's request row, MCP only, no access
/// probe), that it counts calls exactly, leaves platform admins out unless asked, matches log lines by their
/// start, reads only fields the MCP server writes (tests/fixtures/mcp-log-lines, proven on the MCP side by
/// usage-log-fields.test.ts) — and that the parsers read the result tables by column name.
/// </summary>
public class McpUsageTelemetryQueriesTests
{
private static readonly Window Customers = new(new DateTime(2026, 10, 7, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 11, 6, 0, 0, 0, DateTimeKind.Utc), IncludePlatformAdmins: false);
private static readonly Window Everyone = Customers with { IncludePlatformAdmins = true };
private static IEnumerable<string> RequestQueries(Window w) => new[] { Calls(w), Routes(w), Refusals(w), Peaks(w) };
private static IEnumerable<string> McpLogQueries(Window w) => new[] { ToolQuality(w), McpRefusals(w), McpServerFacts(w) };
[Fact]
public void Window_CoversWholeUtcDays()
{
Assert.Equal("datetime(2026-10-07T00:00:00Z)", Customers.From);
Assert.Equal("datetime(2026-11-06T00:00:00Z)", Customers.To);
Assert.Equal("P31D", Customers.Timespan); // the where clauses bound the window; the timespan only has to cover it
foreach (var query in RequestQueries(Customers).Concat(McpLogQueries(Customers)))
{
Assert.Contains(">= datetime(2026-10-07T00:00:00Z)", query);
Assert.Contains("< datetime(2026-11-06T00:00:00Z)", query);
}
}
[Fact]
public void RequestQueries_ReadTheWorkerRow_McpOnly_WithoutTheAccessProbe()
{
foreach (var query in RequestQueries(Customers))
{
Assert.Contains("customDimensions.Source) == 'WorkerMiddleware'", query);
Assert.Contains("customDimensions.ClientSource) =~ 'mcp'", query);
Assert.Contains("customDimensions.FunctionName) != 'GlobalAccessProbe'", query);
Assert.Contains("customDimensions.McpToolName", query);
Assert.Contains("customDimensions.CorrelationId", query);
Assert.Contains("tolower(tostring(customDimensions.TenantId))", query);
Assert.Contains("tolower(tostring(customDimensions.UserId))", query);
}
}
[Fact]
public void Calls_AreCountedOncePerCorrelationId_NeverEstimated()
{
var query = Calls(Customers);
Assert.Contains("by corr, tool, tenant, principal", query);
Assert.Contains("calls = count()", query);
foreach (var q in RequestQueries(Customers).Concat(McpLogQueries(Customers)))
Assert.DoesNotContain("dcount(", q);
}
[Fact]
public void PlatformAdmins_AreLeftOut_UnlessIncluded()
{
foreach (var query in RequestQueries(Customers))
Assert.Contains("role !in ('GlobalAdmin', 'GlobalReader')", query);
Assert.Contains("tostring(d.scope) != 'ga'", ToolQuality(Customers));
foreach (var query in RequestQueries(Everyone).Concat(McpLogQueries(Everyone)))
{
Assert.DoesNotContain("'GlobalAdmin'", query);
Assert.DoesNotContain("!= 'ga'", query);
}
}
[Fact]
public void Refusals_AreClassifiedByTheLinesTheMiddlewaresWrite()
{
var query = Refusals(Customers);
Assert.Contains($"startswith '{McpQuotaEnforcementMiddleware.BlockedLogPrefix}'", query);
Assert.Contains($"startswith '{UserRateLimitMiddleware.ThrottledLogPrefix}'", query);
Assert.Equal("[McpQuota] BLOCKED", McpQuotaEnforcementMiddleware.BlockedLogPrefix);
Assert.Equal("[UserRateLimit] THROTTLED", UserRateLimitMiddleware.ThrottledLogPrefix);
Assert.Contains("join kind=inner lines on operation_Id", query);
}
[Fact]
public void Peaks_SlideASixtySecondWindowInTenSecondSteps()
{
var query = Peaks(Customers);
Assert.Contains("bin(timestamp, 10s)", query);
Assert.Contains("bin(at, 10s)", query);
Assert.Equal(2, Regex.Matches(query, Regex.Escape("mv-expand k = range(0, 5, 1)")).Count);
}
[Fact]
public void McpLogLines_AreMatchedByTheirStart_NeverByHas()
{
Assert.Contains($"Log_s startswith '{ToolCallLinePrefix}'", ToolQuality(Customers));
Assert.Contains($"Log_s startswith '{McpRefusalLinePrefix}'", McpRefusals(Customers));
Assert.Contains($"Log_s startswith '{BootLinePrefix}'", McpServerFacts(Customers));
foreach (var query in McpLogQueries(Customers))
Assert.DoesNotContain("Log_s has", query);
}
[Fact]
public void McpLogQueries_ReadOnlyWhatTheServerWrites()
{
using var contract = JsonDocument.Parse(File.ReadAllText(Path.Combine(FindRepoRoot(), "tests", "fixtures", "mcp-log-lines", "fields.json")));
var prefixes = contract.RootElement.GetProperty("prefixes");
var fields = contract.RootElement.GetProperty("fields");
Assert.Equal(ToolCallLinePrefix, prefixes.GetProperty("tool_call").GetString());
Assert.Equal(BootLinePrefix, prefixes.GetProperty("boot").GetString());
Assert.Equal(McpRefusalLinePrefix, prefixes.GetProperty("mcp_refusal").GetString());
var toolCallFields = fields.GetProperty("tool_call").EnumerateArray().Select(f => f.GetString()!).ToHashSet();
var readFromToolCall = Regex.Matches(ToolQuality(Everyone), @"\bd\.(\w+)").Select(m => m.Groups[1].Value).ToHashSet();
Assert.NotEmpty(readFromToolCall);
Assert.Subset(toolCallFields, readFromToolCall);
var bootFields = fields.GetProperty("boot").EnumerateArray().Select(f => f.GetString()!).ToHashSet();
var readFromBoot = Regex.Matches(McpServerFacts(Everyone), @"parse_json\(Log_s\)\.(\w+)").Select(m => m.Groups[1].Value).ToHashSet();
Assert.Equal(new[] { "rateLimitPerMinute" }, readFromBoot);
Assert.Subset(bootFields, readFromBoot);
}
// ── Parsers ──
private static KqlTable Table(string[] columns, params object?[][] rows)
=> new()
{
Name = "PrimaryResult",
Columns = columns.Select(c => new KqlColumn { Name = c, Type = "dynamic" }).ToList(),
Rows = rows.Select(r => (IReadOnlyList<JsonElement>)r.Select(cell => JsonSerializer.SerializeToElement(cell)).ToList()).ToList(),
};
[Fact]
public void ParseCalls_ReadsByColumnName_WhateverTheOrder()
{
var table = Table(
new[] { "role", "charged", "day", "principal", "tool", "calls", "tenant", "servedCalls", "firstAt" },
new object?[] { "Admin", 59L, "20261028", "user@contoso.com", "get_time_attribution", 64L, "t1", 59L, "2026-10-28T16:12:30.4442709Z" });
var row = Assert.Single(ParseCalls(table));
Assert.Equal(new CallRow("20261028", "get_time_attribution", "t1", "user@contoso.com", 64, 59, 59,
new DateTime(2026, 10, 28, 16, 12, 30, 444, DateTimeKind.Utc).AddTicks(2709), "Admin"), row);
Assert.Equal(DateTimeKind.Utc, row.LastAt!.Value.Kind);
}
[Fact]
public void ParseRefusals_KeepsMissingCountsMissing()
{
var table = Table(
new[] { "gate", "day", "tenant", "principal", "level", "scope", "plan", "refused", "firstAt", "lastAt", "usedCount", "limitValue" },
new object?[] { "minute", "20261026", "t1", "user@contoso.com", "", "", "", 2L, "2026-10-26T07:55:42Z", "2026-10-26T07:55:43Z", null, null });
var row = Assert.Single(ParseRefusals(table));
Assert.Equal("minute", row.Gate);
Assert.Equal(2, row.Refused);
Assert.Null(row.Used);
Assert.Null(row.Limit);
}
[Fact]
public void ParseToolQuality_RoundsAFractionalCount_AndKeepsTheMean()
{
var table = Table(
new[] { "tool", "calls", "errors", "cancelled", "overCap", "p95Ms", "avgChars" },
new object?[] { "search_events", 26L, 1L, 0L, 0L, 1029.6, 10602.5 });
var row = Assert.Single(ParseToolQuality(table));
Assert.Equal(26, row.Calls);
Assert.Equal(1030, row.P95Ms);
Assert.Equal(10602.5, row.AvgChars);
}
[Fact]
public void ParseServerFacts_WithoutABootLine_HasNoLimit()
{
var facts = ParseServerFacts(Table(new[] { "oldest", "rateLimitPerMinute" }, new object?[] { "2026-10-07T01:52:43.167992Z", null }));
Assert.Equal(new DateTime(2026, 10, 7, 1, 52, 43, DateTimeKind.Utc).AddTicks(1_679_920), facts.Oldest);
Assert.Null(facts.RateLimitPerMinute);
Assert.Equal(new ServerFacts(null, null), ParseServerFacts(Table(new[] { "oldest", "rateLimitPerMinute" })));
}
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
dir = dir.Parent;
Assert.NotNull(dir);
return dir!.FullName;
}
}
@@ -108,16 +108,10 @@ public class MetricsFilterInjectionTests
}
[Fact]
public void BuildTenantUsageFilter_TenantInjection_StaysInsideLiteral()
public void BuildUserUsageFilter_BothDates_NormalizeTheShapeAndBoundTheRange()
{
var filter = TableUserUsageRepository.BuildTenantUsageFilter("' or TenantId ne '", null, null);
var filter = TableUserUsageRepository.BuildUserUsageFilter("user1", "2026-11-01", "2026-11-05");
Assert.Equal("TenantId eq ''' or TenantId ne '''", filter);
}
[Fact]
public void BuildTenantUsageFilter_NoScope_ReturnsNull()
{
Assert.Null(TableUserUsageRepository.BuildTenantUsageFilter(null, null, null));
Assert.Equal("PartitionKey eq 'user1' and Date ge '20261101' and Date le '20261105'", filter);
}
}
@@ -548,64 +548,6 @@ public class MetricsWireParityTests
new GetMcpUserUsageResponse { UserId = userId, Records = records });
}
// ---- GetGlobalMcpUsage ---------------------------------------------------------------
[Fact]
public void GetGlobalMcpUsageResponse_matches_the_tenant_usage_shape()
{
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var records = SampleUsageRecords("9cc42e33-0004-4b7c-a1d2-52f3aaaa0010");
AssertParity(
new { tenantId, records },
new GetGlobalMcpUsageResponse { TenantId = tenantId, Records = records });
}
[Fact]
public void GetGlobalMcpUsageResponse_omits_a_null_tenantId()
{
string? tenantId = null;
var records = new List<UserUsageRecord>();
AssertParity(
new { tenantId, records },
new GetGlobalMcpUsageResponse { TenantId = null, Records = records });
}
// ---- GetGlobalMcpUsageDaily ----------------------------------------------------------
[Fact]
public void GetGlobalMcpUsageDailyResponse_matches_the_daily_summary_shape()
{
string? tenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d";
var summaries = new List<UserUsageDailySummary>
{
new UserUsageDailySummary
{
Date = "20260830",
TenantId = tenantId,
TotalRequests = 321,
UniqueUsers = 4,
UniqueEndpoints = 11,
},
};
AssertParity(
new { tenantId, summaries },
new GetGlobalMcpUsageDailyResponse { TenantId = tenantId, Summaries = summaries });
}
[Fact]
public void GetGlobalMcpUsageDailyResponse_omits_a_null_tenantId()
{
string? tenantId = null;
var summaries = new List<UserUsageDailySummary>();
AssertParity(
new { tenantId, summaries },
new GetGlobalMcpUsageDailyResponse { TenantId = null, Summaries = summaries });
}
// ---- MetricsSummary / MetricsSummaryGlobal -------------------------------------------
[Fact]
@@ -832,6 +774,250 @@ public class MetricsWireParityTests
});
}
// ---- GetGlobalMcpUsageOverview ------------------------------------------------------
[Fact]
public void GetGlobalMcpUsageOverviewResponse_matches_the_overview_shape()
{
// One tenant with one account, one tool with one route, one quota refusal, one per-minute row — every
// optional slot filled, so every key is pinned in its wire position.
var at = new DateTime(2026, 11, 5, 10, 0, 0, DateTimeKind.Utc);
var tenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001";
var quota = new McpOrganizationQuotaNode { TenantPlan = "community", DailyLimit = 100, MonthlyLimit = 250, DailyUsed = 35, MonthlyUsed = 60 };
AssertParity(
new
{
dateFrom = "20261103",
dateTo = "20261105",
includesPlatformAdmins = false,
telemetry = new { requestsAvailable = true, mcpLogAvailable = true, mcpLogFrom = "20261104", partial = true },
totals = new
{
calls = 10L, mcpOnlyCalls = 4L, tools = 2, requests = 50L, tenants = 1, accounts = 1, servicePrincipals = 0,
quotaHitAccounts = 1, minuteRefusedAccounts = 1, limitAccounts = 1,
},
daily = new[] { new { date = "20261105", calls = 10L, requests = 35L, accounts = 3, callsByPlan = new[] { new { plan = "community", calls = 10L } } } },
tenants = new[]
{
new
{
tenantId,
domainName = "contoso.com",
quota,
calls = 7L,
requests = 50L,
quotaHits = 1,
lastRequestAt = at,
accounts = new[]
{
new
{
userId = "oid-1",
principal = "admin@contoso.com",
role = "Admin",
isPlatformAdmin = false,
quota = new { plan = "community", dailyLimit = 50, monthlyLimit = 250, dailyUsed = 30L, monthlyUsed = 60L },
calls = 7L,
requests = 50L,
peakDay = "20261105",
peakDayRequests = 30L,
quotaHits = 1,
topTools = new[] { new { tool = "get_session_events", calls = 3L } },
lastRequestAt = at,
},
},
},
},
tools = new[]
{
new
{
tool = "get_session_summary",
calls = 3L,
accounts = 2,
tenants = 1,
requestsPerCall = 5.0,
errorRate = 0.0,
p95DurationMs = 600L,
avgResultChars = 19000L,
overCapCalls = 0L,
routes = new[] { new { route = "GET sessions/{id}", requests = 3L } },
},
},
quotaHits = new[]
{
new
{
date = "20261105", tenantId, principal = "admin@contoso.com", level = "user", scope = "daily", plan = "community",
limit = 50L, used = 50L, refused = 3L, firstAt = at, lastAt = at,
},
},
minuteLimits = new
{
mcpServerLimit = 60,
accounts = new[]
{
new
{
tenantId, principal = "admin@contoso.com", sharePercent = 33,
peakCalls = 12L, peakCallsAt = at, mcpRefused = 7L, mcpLastRefusedAt = at,
peakRequests = 40L, peakRequestsAt = at, backendLimit = 120, backendRefused = 2L, backendLastRefusedAt = at,
},
},
},
},
new GetGlobalMcpUsageOverviewResponse
{
DateFrom = "20261103",
DateTo = "20261105",
IncludesPlatformAdmins = false,
Telemetry = new McpUsageTelemetryStatus { RequestsAvailable = true, McpLogAvailable = true, McpLogFrom = "20261104", Partial = true },
Totals = new McpUsageTotalsNode
{
Calls = 10, McpOnlyCalls = 4, Tools = 2, Requests = 50, Tenants = 1, Accounts = 1, ServicePrincipals = 0,
QuotaHitAccounts = 1, MinuteRefusedAccounts = 1, LimitAccounts = 1,
},
Daily = new List<McpUsageDayItem>
{
new() { Date = "20261105", Calls = 10, Requests = 35, Accounts = 3, CallsByPlan = new List<McpUsagePlanCallsItem> { new() { Plan = "community", Calls = 10 } } },
},
Tenants = new List<McpUsageTenantItem>
{
new()
{
TenantId = tenantId,
DomainName = "contoso.com",
Quota = quota,
Calls = 7,
Requests = 50,
QuotaHits = 1,
LastRequestAt = at,
Accounts = new List<McpUsageAccountItem>
{
new()
{
UserId = "oid-1",
Principal = "admin@contoso.com",
Role = "Admin",
IsPlatformAdmin = false,
Quota = new McpUsageAccountQuotaNode { Plan = "community", DailyLimit = 50, MonthlyLimit = 250, DailyUsed = 30, MonthlyUsed = 60 },
Calls = 7,
Requests = 50,
PeakDay = "20261105",
PeakDayRequests = 30,
QuotaHits = 1,
TopTools = new List<McpUsageToolCallsItem> { new() { Tool = "get_session_events", Calls = 3 } },
LastRequestAt = at,
},
},
},
},
Tools = new List<McpUsageToolItem>
{
new()
{
Tool = "get_session_summary",
Calls = 3,
Accounts = 2,
Tenants = 1,
RequestsPerCall = 5.0,
ErrorRate = 0.0,
P95DurationMs = 600,
AvgResultChars = 19000,
OverCapCalls = 0,
Routes = new List<McpUsageRouteItem> { new() { Route = "GET sessions/{id}", Requests = 3 } },
},
},
QuotaHits = new List<McpUsageQuotaHitItem>
{
new()
{
Date = "20261105", TenantId = tenantId, Principal = "admin@contoso.com", Level = "user", Scope = "daily", Plan = "community",
Limit = 50, Used = 50, Refused = 3, FirstAt = at, LastAt = at,
},
},
MinuteLimits = new McpUsageMinuteLimits
{
McpServerLimit = 60,
Accounts = new List<McpUsageMinuteItem>
{
new()
{
TenantId = tenantId, Principal = "admin@contoso.com", SharePercent = 33,
PeakCalls = 12, PeakCallsAt = at, McpRefused = 7, McpLastRefusedAt = at,
PeakRequests = 40, PeakRequestsAt = at, BackendLimit = 120, BackendRefused = 2, BackendLastRefusedAt = at,
},
},
},
});
}
[Fact]
public void GetGlobalMcpUsageOverviewResponse_omits_what_a_store_did_not_answer()
{
// Telemetry down, an account known only from the telemetry shape (no object id, no quota), an MCP-only
// tool (no reach, no quality), no MCP limit reported yet: every nullable key vanishes.
var tenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001";
var quota = new McpOrganizationQuotaNode { TenantPlan = "community", DailyLimit = 100, MonthlyLimit = 250, DailyUsed = 0, MonthlyUsed = 0 };
AssertParity(
new
{
dateFrom = "20261105",
dateTo = "20261105",
includesPlatformAdmins = true,
telemetry = new { requestsAvailable = false, mcpLogAvailable = false },
totals = new { tools = 1, requests = 0L, tenants = 1, accounts = 1, servicePrincipals = 1, quotaHitAccounts = 0, minuteRefusedAccounts = 0, limitAccounts = 0 },
daily = Array.Empty<object>(),
tenants = new[]
{
new
{
tenantId,
quota,
calls = 4L,
requests = 0L,
quotaHits = 0,
accounts = new[]
{
new { principal = "app:11111111-2222-3333-4444-555555555555", isPlatformAdmin = false, calls = 4L, requests = 0L, quotaHits = 0, topTools = Array.Empty<object>() },
},
},
},
tools = new[] { new { tool = "search_docs", calls = 4L, requestsPerCall = 0.0, routes = Array.Empty<object>() } },
quotaHits = Array.Empty<object>(),
minuteLimits = new { accounts = Array.Empty<object>() },
},
new GetGlobalMcpUsageOverviewResponse
{
DateFrom = "20261105",
DateTo = "20261105",
IncludesPlatformAdmins = true,
Telemetry = new McpUsageTelemetryStatus { RequestsAvailable = false, McpLogAvailable = false },
Totals = new McpUsageTotalsNode { Tools = 1, Requests = 0, Tenants = 1, Accounts = 1, ServicePrincipals = 1 },
Daily = new List<McpUsageDayItem>(),
Tenants = new List<McpUsageTenantItem>
{
new()
{
TenantId = tenantId,
Quota = quota,
Calls = 4,
Requests = 0,
QuotaHits = 0,
Accounts = new List<McpUsageAccountItem>
{
new() { Principal = "app:11111111-2222-3333-4444-555555555555", Calls = 4, TopTools = new List<McpUsageToolCallsItem>() },
},
},
},
Tools = new List<McpUsageToolItem> { new() { Tool = "search_docs", Calls = 4, RequestsPerCall = 0, Routes = new List<McpUsageRouteItem>() } },
QuotaHits = new List<McpUsageQuotaHitItem>(),
MinuteLimits = new McpUsageMinuteLimits { Accounts = new List<McpUsageMinuteItem>() },
});
}
// ---- GetGrowthMetrics ---------------------------------------------------------------
private static GrowthMetricsResponse GrowthResponse(double? median, IReadOnlyList<GrowthTenantRow>? tenants) => new()
@@ -0,0 +1,271 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Contains Newtonsoft.Json (D-420): new code reads and writes JSON with System.Text.Json. Every shipped
/// source file under <c>src/</c> that uses Newtonsoft today is on a frozen baseline that may only shrink,
/// and only the projects in <see cref="ProjectsWithPackage"/> may reference the package. Test projects are
/// out of scope: they exercise Newtonsoft-bound code and never ship.
/// A file uses Newtonsoft when a code line names the <c>Newtonsoft.Json</c> namespace (using, alias,
/// fully qualified name, or the DLL name); comment lines do not count.
/// </summary>
public class NewtonsoftUsageGuardTests
{
private const string AgentWireAndState = "agent (net48): wire format and on-device state, which move only with an agent release";
private const string DecisionCore = "DecisionCore serialization shared by the agent and its tests";
private const string SummaryDialog = "summary dialog (net48): reads the agent's final status and checks the DLL ships beside it";
private const string Ingest = "agent ingest wire (D-200)";
private const string ConfigRules = "Newtonsoft-bound configuration and rule documents (PopulateObject)";
private const string QueueEnvelope = "queue envelope contract shared by producers and consumers";
private const string StoredJson = "stored JSON (table columns, blobs, report files) that existing rows must keep reading";
private const string OutgoingPayload = "outgoing notification payloads";
private const string RuleTemplates = "rule template interpolation over Newtonsoft tokens";
/// <summary>Path relative to <c>src/</c> → why it may still use Newtonsoft. Only shrinks.</summary>
private static readonly Dictionary<string, string> Baseline = new(StringComparer.OrdinalIgnoreCase)
{
["Agent/AutopilotMonitor.Agent.V2.Core/Configuration/BuiltInConfigCatalog.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/CrashDumpCapture.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/CrashRecord.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/PendingCrashReporter.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeLogTracker.Handlers.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/Ime/ImeTrackerStatePersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/EventRecordWatermark.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/MdmRebootPolicyTracker.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ModernDeploymentTracker.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/SystemTimelineTracker.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/SelfUpdater.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Runtime/VersionCheckEventBuilder.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/Collectors/JsonCollector.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleStatePersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Office/OfficeInstallStatePersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/BandwidthStatePersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Periodic/DeliveryOptimizationCollector.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Provisioning/ProvisioningPackageCollector.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/BackendApiClient.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/DistressReporter.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Transport/GeoLocationService.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/OsBuildChangeHost.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Persistence/StartupEventGate.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Security/SessionIdPersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/EventSequencePersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Events/TelemetryEventEmitter.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Signals/TelemetrySignalEmitter.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Telemetry/Transitions/TelemetryTransitionEmitter.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Termination/FinalStatus.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Termination/SummaryDialogLauncher.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/BackendTelemetryUploader.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/TelemetryItemSerializer.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2.Core/Transport/Telemetry/UploadCursorPersistence.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2/Program.GatherRulesMode.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2/Program.Guards.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2/Program.ImeMatchingMode.cs"] = AgentWireAndState,
["Agent/AutopilotMonitor.Agent.V2/Program.InstallMode.cs"] = AgentWireAndState,
["Shared/AutopilotMonitor.DecisionCore/Serialization/DecisionCoreJsonSettings.cs"] = DecisionCore,
["Shared/AutopilotMonitor.DecisionCore/Serialization/SignalSerializer.cs"] = DecisionCore,
["Shared/AutopilotMonitor.DecisionCore/Serialization/StateSerializer.cs"] = DecisionCore,
["Shared/AutopilotMonitor.DecisionCore/Serialization/TransitionSerializer.cs"] = DecisionCore,
["Shared/AutopilotMonitor.DecisionCore/Serialization/UnknownFallbackEnumConverter.cs"] = DecisionCore,
["Agent/AutopilotMonitor.SummaryDialog/App.xaml.cs"] = SummaryDialog,
["Agent/AutopilotMonitor.SummaryDialog/MainWindow.xaml.cs"] = SummaryDialog,
["Agent/AutopilotMonitor.SummaryDialog/Models/FinalStatus.cs"] = SummaryDialog,
["Backend/AutopilotMonitor.Functions/Functions/Bootstrap/BootstrapRegisterSessionFunction.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Functions/Ingest/EventDataNormalizer.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestTelemetryFunction.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Functions/Ingest/TelemetryPayloadParser.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Services/Ime/ImePatternHealthService.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityInventoryLoader.cs"] = Ingest,
["Backend/AutopilotMonitor.Functions/Functions/Config/PatchAdminConfigurationFunction.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Helpers/RequestBody.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/BuiltInAnalyzeRules.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/BuiltInGatherRules.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/BuiltInImeLogPatterns.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/GitHubRuleRepository.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/RulesWrapper.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs"] = ConfigRules,
["Backend/AutopilotMonitor.Functions/Services/Activation/AzureQueueTenantAutoApproveEnqueuer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Analyze/AzureQueueAnalyzeOnEnrollmentEndProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Backup/Queue/AzureQueueCriticalTableBackupProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Backup/Queue/CriticalTableBackupQueueWorker.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Deletion/AzureQueueSessionDeletionMaintenanceTriggerProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Deletion/SessionDeletionWorker.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Ime/AzureQueueImeMsiArchiveProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Maintenance/AzureQueueMaintenanceTriggerProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Notifications/AzureQueueNotificationDispatchProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Offboarding/AzureQueueTenantOffboardingEnqueuer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingWorker.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Queueing/QueueEnvelopeReader.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Queueing/QueuePollingWorker.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/AzureQueueVulnerabilityCorrelateProducer.cs"] = QueueEnvelope,
["Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/FailureSnapshotBuilder.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.Maintenance.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.ServerActions.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelateHandler.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs"] = StoredJson,
["Backend/AutopilotMonitor.Functions/Services/Notifications/DiscordRenderer.cs"] = OutgoingPayload,
["Backend/AutopilotMonitor.Functions/Services/Notifications/GenericJsonRenderer.cs"] = OutgoingPayload,
["Backend/AutopilotMonitor.Functions/Services/Notifications/SlackRenderer.cs"] = OutgoingPayload,
["Backend/AutopilotMonitor.Functions/Services/Notifications/TeamsWorkflowAdaptiveCardRenderer.cs"] = OutgoingPayload,
["Backend/AutopilotMonitor.Functions/Services/RuleTemplateInterpolator.cs"] = RuleTemplates,
};
/// <summary>The projects that may reference the Newtonsoft.Json package directly.</summary>
private static readonly string[] ProjectsWithPackage =
{
"Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj",
"Agent/AutopilotMonitor.Agent.V2.Core/AutopilotMonitor.Agent.V2.Core.csproj",
"Agent/AutopilotMonitor.SummaryDialog/AutopilotMonitor.SummaryDialog.csproj",
"Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj",
"Shared/AutopilotMonitor.DecisionCore/AutopilotMonitor.DecisionCore.csproj",
};
private static readonly Regex NamespaceUse = new(@"\bNewtonsoft\.Json\b", RegexOptions.Compiled);
private static readonly Regex PackageReference = new(
@"<PackageReference\s+Include\s*=\s*""Newtonsoft\.Json""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
[Fact]
public void Newtonsoft_use_is_frozen_per_file()
{
var actual = ShippedSources()
.Where(s => UsesNewtonsoft(s.Text))
.Select(s => s.Relative)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var violations = new List<string>();
foreach (var file in actual.OrderBy(f => f, StringComparer.Ordinal))
{
if (!Baseline.ContainsKey(file))
violations.Add($"{file}: NEW use of Newtonsoft.Json — read and write JSON with System.Text.Json; the Newtonsoft baseline is frozen (D-420).");
}
foreach (var file in Baseline.Keys.OrderBy(f => f, StringComparer.Ordinal))
{
if (!actual.Contains(file))
violations.Add($"{file}: on the baseline but no longer uses Newtonsoft.Json (or is gone) — remove its entry (ratchet down).");
}
Assert.True(violations.Count == 0, "Newtonsoft.Json baseline violated:\n " + string.Join("\n ", violations));
}
[Fact]
public void Newtonsoft_package_only_in_known_projects()
{
var srcRoot = Path.Combine(FindRepoRoot(), "src");
var actual = SourceFiles(srcRoot, "*.csproj", includeTestProjects: true)
.Where(f => PackageReference.IsMatch(File.ReadAllText(f)))
.Select(f => Relative(srcRoot, f))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var violations = actual.Except(ProjectsWithPackage, StringComparer.OrdinalIgnoreCase)
.Select(p => $"{p}: NEW Newtonsoft.Json package reference — use System.Text.Json (D-420).")
.Concat(ProjectsWithPackage.Except(actual, StringComparer.OrdinalIgnoreCase)
.Select(p => $"{p}: listed but no longer references Newtonsoft.Json — remove it from ProjectsWithPackage (ratchet down)."))
.OrderBy(v => v, StringComparer.Ordinal)
.ToList();
Assert.True(violations.Count == 0, "Newtonsoft.Json package references violated:\n " + string.Join("\n ", violations));
}
[Theory]
[InlineData("using Newtonsoft.Json;", true)]
[InlineData("using Newtonsoft.Json.Linq;", true)]
[InlineData("using JsonConvert = Newtonsoft.Json.JsonConvert;", true)]
[InlineData(" catch (Newtonsoft.Json.JsonException) { }", true)]
[InlineData("var dll = Path.Combine(dir, \"Newtonsoft.Json.dll\");", true)]
[InlineData("var url = \"https://x\"; Newtonsoft.Json.Linq.JObject.Parse(s); // trailing", true)]
[InlineData("var x = 1; // Newtonsoft.Json is not used here", false)]
[InlineData(" /// <summary>Newtonsoft.Json defaults on both sides.</summary>", false)]
[InlineData("// Newtonsoft.Json", false)]
[InlineData(" /* Newtonsoft.Json */", false)]
[InlineData(" * Newtonsoft.Json in a block comment", false)]
[InlineData("var rule = await RequestBody.ReadNewtonsoftAsync<AnalyzeRule>(req);", false)]
public void Use_detector_sees_code_but_not_comments(string line, bool expected)
=> Assert.Equal(expected, UsesNewtonsoft(line));
private static bool UsesNewtonsoft(string text)
{
foreach (var raw in text.Split('\n'))
{
var trimmed = raw.TrimStart();
if (trimmed.StartsWith("//", StringComparison.Ordinal) || trimmed.StartsWith("/*", StringComparison.Ordinal) || trimmed.StartsWith('*'))
continue;
var line = raw;
var comment = line.IndexOf("//", StringComparison.Ordinal);
if (comment >= 0 && !line[..comment].Contains('"'))
line = line[..comment];
if (NamespaceUse.IsMatch(line))
return true;
}
return false;
}
private static IEnumerable<(string Relative, string Text)> ShippedSources()
{
var srcRoot = Path.Combine(FindRepoRoot(), "src");
return SourceFiles(srcRoot, "*.cs", includeTestProjects: false)
.Select(f => (Relative(srcRoot, f), File.ReadAllText(f)));
}
/// <summary>
/// Walks <paramref name="root"/> without entering build output or package folders: the web and MCP
/// node_modules alone would make a plain recursive enumeration take tens of seconds.
/// </summary>
private static IEnumerable<string> SourceFiles(string root, string pattern, bool includeTestProjects)
{
var pending = new Stack<string>();
pending.Push(root);
while (pending.Count > 0)
{
var dir = pending.Pop();
foreach (var file in Directory.EnumerateFiles(dir, pattern))
yield return file;
foreach (var sub in Directory.EnumerateDirectories(dir))
{
var name = Path.GetFileName(sub);
if (SkippedFolders.Contains(name) || (!includeTestProjects && name.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase)))
continue;
pending.Push(sub);
}
}
}
private static readonly HashSet<string> SkippedFolders = new(StringComparer.OrdinalIgnoreCase) { "bin", "obj", "node_modules", ".next" };
private static string Relative(string root, string path)
=> Path.GetRelativePath(root, path).Replace(Path.DirectorySeparatorChar, '/');
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
dir = dir.Parent;
Assert.NotNull(dir);
return dir!.FullName;
}
}
@@ -28,7 +28,7 @@ namespace AutopilotMonitor.Functions.Tests;
/// (otherwise admin SignalR group joins + admin-audience notifications regress), while a pure GlobalReader
/// stays read-only. Also covers the cross-tenant gate keying off HasGlobalScope.
/// </summary>
public class PolicyEnforcementMiddlewareTests
public partial class PolicyEnforcementMiddlewareTests
{
private const string TenantA = "11111111-1111-1111-1111-111111111111";
private const string TenantB = "22222222-2222-2222-2222-222222222222";
@@ -1201,16 +1201,17 @@ public class PolicyEnforcementMiddlewareTests
/// <summary>
/// Operator reads whose ?tenantId= is a handler-side filter for a Global Admin / Global Reader: session reports
/// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), a
/// tenant's MCP usage (its accounts and their tools) and verdict calibration (classifier diagnostics, drift
/// alerts). None of it is the managed tenant's telemetry, so a delegated (MSP) reader never reaches them.
/// (GA-only AdminNote, submitter contact data), rule submissions (contact e-mail, reviewer, review comment), MCP
/// usage (a tenant's organization budget by account, the all-tenant overview with every tenant's accounts and
/// tools) and verdict calibration (classifier diagnostics, drift alerts). None of it is the managed tenant's
/// telemetry, so a delegated (MSP) reader never reaches them.
/// </summary>
public static TheoryData<string> OperatorFilterReads => new()
{
"/api/global/session-reports",
"/api/global/rule-submissions",
"/api/global/metrics/mcp-usage",
"/api/global/metrics/mcp-usage/daily",
"/api/global/metrics/mcp-usage/organization",
"/api/global/metrics/mcp-usage/overview",
"/api/global/metrics/verdict-calibration",
};
@@ -0,0 +1,237 @@
using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// "View as tenant": a Global Admin reads one tenant's portal as its member, read-only. DecideAsync admits the
/// headers only for a Global Admin, refuses every write, and decides the rest with the SAME evaluators over an
/// emulated member of the viewed tenant — the parity test pins that the emulation answers every read route
/// exactly like a real member of that tenant.
/// </summary>
public partial class PolicyEnforcementMiddlewareTests
{
private const string GaUpn = "ga@vendor.example";
private static ViewAsTarget ViewB(string role = Constants.TenantRoles.Admin) => new(TenantB, role);
/// <summary>A Global Admin homed in TenantA (the default identity binding) and an onboarded TenantB.</summary>
private static Harness ViewAsHarness()
{
var h = BuildHarness();
h.Repo.Setup(r => r.GetGlobalRoleAsync(GaUpn)).ReturnsAsync(Constants.GlobalRoles.GlobalAdmin);
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantB))
.ReturnsAsync(new TenantConfiguration { TenantId = TenantB });
return h;
}
[Fact]
public async Task ViewAs_GlobalAdmin_ReadsTheViewedTenantAsItsAdmin()
{
var h = ViewAsHarness();
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
Assert.True(r.Allowed);
var ctx = r.Context!;
Assert.Equal(TenantB, ctx.TenantId);
Assert.Equal(TenantB, ctx.TargetTenantId);
Assert.False(ctx.IsGlobalAdmin);
Assert.False(ctx.HasFleetScope);
Assert.Null(ctx.AllowedTenantIds);
Assert.Equal(Constants.TenantRoles.Admin, ctx.UserRole);
Assert.True(ctx.IsTenantAdmin);
// Identity stays the real caller's: throttling, logs and the SignalR binding key off it.
Assert.Equal(GaUpn, ctx.UserPrincipalName);
Assert.Equal(new ViewAsContext(TenantA, Constants.TenantRoles.Admin), ctx.ViewAs);
Assert.Equal(TenantA, ctx.IdentityTenantId());
}
[Theory]
[InlineData("tenant admin")]
[InlineData("global reader")]
[InlineData("delegated admin of the viewed tenant")]
[InlineData("no role")]
public async Task ViewAs_IsRefusedToEveryoneButAGlobalAdmin_AndReported(string caller)
{
const string upn = "someone@contoso.com";
var h = ViewAsHarness();
switch (caller)
{
case "tenant admin": h.AsTenantAdmin(TenantA, upn); break;
case "global reader": h.Repo.Setup(r => r.GetGlobalRoleAsync(upn)).ReturnsAsync(Constants.GlobalRoles.GlobalReader); break;
case "delegated admin of the viewed tenant": h.AsDelegated(TenantB, Constants.DelegatedRoles.DelegatedAdmin); break;
}
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn), viewAs: ViewB());
Assert.False(r.Allowed);
Assert.Equal(403, r.StatusCode);
Assert.Equal(Constants.ApiErrorCodes.ViewAsNotAllowed, r.ErrorCode);
Assert.True(PolicyEnforcementMiddleware.IsPrivilegedDenial(r, viewAsRequested: true));
}
[Fact]
public async Task ViewAs_ThroughMcpOrAForeignClientApp_IsRefusedEvenForAGlobalAdmin()
{
var h = ViewAsHarness();
var capped = AuthedPrincipal(TenantA, GaUpn);
ClientAppBinding.MarkCapped(capped, "33333333-3333-3333-3333-333333333333");
var mcp = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), isMcpClient: true, viewAs: ViewB());
var foreign = await h.Middleware.DecideAsync("GET", "/api/sessions", null, capped, viewAs: ViewB());
Assert.Equal(Constants.ApiErrorCodes.ViewAsNotAllowed, mcp.ErrorCode);
Assert.Equal(Constants.ApiErrorCodes.ViewAsNotAllowed, foreign.ErrorCode);
}
[Fact]
public async Task ViewAs_AdmissionAnswers_ForMalformedHeaders_UnknownTenants_AndAnonymousCallers()
{
var h = ViewAsHarness();
var malformed = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewAsTarget.Invalid);
var unknown = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn),
viewAs: new ViewAsTarget("33333333-3333-3333-3333-333333333333", Constants.TenantRoles.Admin));
var anonymous = await h.Middleware.DecideAsync("GET", "/api/sessions", null, principal: null, viewAs: ViewB());
Assert.Equal((400, Constants.ApiErrorCodes.ViewAsInvalid), (malformed.StatusCode, malformed.ErrorCode));
Assert.Equal((404, Constants.ApiErrorCodes.ViewAsTenantNotFound), (unknown.StatusCode, unknown.ErrorCode));
Assert.Equal((401, Constants.ApiErrorCodes.AuthenticationRequired), (anonymous.StatusCode, anonymous.ErrorCode));
}
[Fact]
public async Task ViewAs_RefusesWrites_RunsTheReadsDeclaredOnPostRoutes_AndRefusesTheGetThatStartsAnAction()
{
var h = ViewAsHarness();
var ga = AuthedPrincipal(TenantA, GaUpn);
// A test send would reach the customer's channels.
var write = await h.Middleware.DecideAsync("POST", $"/api/config/{TenantB}/test-notification", null, ga, viewAs: ViewB());
var join = await h.Middleware.DecideAsync("POST", "/api/realtime/groups/join", null, ga, viewAs: ViewB());
var consentFlow = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}/autopilot-device-validation/consent-url", null, ga, viewAs: ViewB());
Assert.Equal((403, Constants.ApiErrorCodes.ViewAsReadOnly), (write.StatusCode, write.ErrorCode));
Assert.Equal(ViewAsTarget.ReadOnlyMessage, write.ErrorMessage);
Assert.True(join.Allowed);
Assert.Equal((403, Constants.ApiErrorCodes.ViewAsReadOnly), (consentFlow.StatusCode, consentFlow.ErrorCode));
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(write, viewAsRequested: true));
}
[Fact]
public async Task ViewAs_ThePlatformSurface_IsClosedLikeForAMember_AndTheDenialIsNotReported()
{
var h = ViewAsHarness();
var r = await h.Middleware.DecideAsync("GET", "/api/global/raw/access-probe", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
Assert.False(r.Allowed);
Assert.Equal((403, Constants.ApiErrorCodes.InsufficientPermissions), (r.StatusCode, r.ErrorCode));
Assert.False(PolicyEnforcementMiddleware.IsPrivilegedDenial(r, viewAsRequested: true));
// The same denial without view-as headers is the assume-breach report it always was.
Assert.True(PolicyEnforcementMiddleware.IsPrivilegedDenial(r));
}
[Fact]
public async Task ViewAs_AnotherTenantsRoute_IsCrossTenantDenied_EvenTheGlobalAdminsHomeTenant()
{
var h = ViewAsHarness();
var r = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantA}", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
Assert.Equal((403, Constants.ApiErrorCodes.CrossTenantAccessDenied), (r.StatusCode, r.ErrorCode));
}
[Fact]
public async Task ViewAs_ASuspendedTenant_ShowsTheSuspension()
{
var h = ViewAsHarness();
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantB))
.ReturnsAsync(new TenantConfiguration { TenantId = TenantB, Disabled = true, DisabledReason = "Suspended for review" });
var r = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB());
Assert.Equal((403, Constants.ApiErrorCodes.TenantSuspended), (r.StatusCode, r.ErrorCode));
Assert.Equal("Suspended for review", r.ErrorMessage);
}
[Fact]
public async Task ViewAs_AsViewer_ReadsTheTenantButStaysOutOfTheAdminTier()
{
var h = ViewAsHarness();
var ga = AuthedPrincipal(TenantA, GaUpn);
var sessions = await h.Middleware.DecideAsync("GET", "/api/sessions", null, ga, viewAs: ViewB(Constants.TenantRoles.Viewer));
var adminTier = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}/notification-channel-health", null, ga,
viewAs: ViewB(Constants.TenantRoles.Viewer));
Assert.True(sessions.Allowed);
Assert.Equal(Constants.TenantRoles.Viewer, sessions.Context!.UserRole);
Assert.False(sessions.Context.IsTenantAdmin);
Assert.Equal((403, Constants.ApiErrorCodes.InsufficientPermissions), (adminTier.StatusCode, adminTier.ErrorCode));
}
[Fact]
public async Task ViewAs_RoutesWithoutAJwtTier_IgnoreTheHeaders()
{
var h = ViewAsHarness();
var r = await h.Middleware.DecideAsync("GET", "/api/health", null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewAsTarget.Invalid);
Assert.True(r.Allowed);
Assert.Null(r.Context!.ViewAs);
}
/// <summary>
/// The core promise: what the Global Admin sees is what the member sees. For every route that runs in view-as
/// mode and every tenant role, the emulated decision equals the decision for a real member of that role in the
/// viewed tenant — admission, status, error code, role, admin flag, scope and tenants.
/// </summary>
[Fact]
public async Task ViewAs_DecidesEveryReadRoute_ExactlyLikeARealMemberOfTheViewedTenant()
{
const string memberUpn = "member@tenant-b.example";
const string memberOid = "bbbbbbbb-2222-2222-2222-bbbbbbbbbbbb";
var mismatches = new List<string>();
var compared = 0;
foreach (var role in new[] { Constants.TenantRoles.Admin, Constants.TenantRoles.Operator, Constants.TenantRoles.Viewer })
{
var h = ViewAsHarness();
h.Repo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new TenantMember { Upn = memberUpn, TenantId = TenantB, Role = role, IsEnabled = true });
foreach (var entry in EndpointAccessPolicyCatalog.Entries)
{
if (entry.ViewAsAccess != ViewAsAccess.Read
|| entry.Policy is EndpointPolicy.PublicAnonymous or EndpointPolicy.DeviceOrBootstrapAuth)
continue;
var path = "/api/" + Regex.Replace(entry.RouteTemplate, @"\{([^}]+)\}",
m => m.Groups[1].Value == "tenantId" ? TenantB : "x1");
var member = await h.Middleware.DecideAsync(entry.HttpMethod, path, null, AuthedPrincipal(TenantB, memberUpn, memberOid));
var viewed = await h.Middleware.DecideAsync(entry.HttpMethod, path, null, AuthedPrincipal(TenantA, GaUpn), viewAs: ViewB(role));
compared++;
var expected = Describe(member);
var actual = Describe(viewed);
if (expected != actual)
mismatches.Add($"{role} {entry.HttpMethod} {entry.RouteTemplate}: member={expected} viewAs={actual}");
}
}
Assert.Empty(mismatches);
Assert.True(compared > 300, $"only {compared} decisions compared — the catalog walk is broken");
}
private static string Describe(PolicyEnforcementMiddleware.PolicyResult r)
=> r.Allowed
? $"allow role={r.Context!.UserRole} admin={r.Context.IsTenantAdmin} scope={r.Context.HasFleetScope} tenant={r.Context.TenantId} target={r.Context.TargetTenantId}"
: $"deny {r.StatusCode} {r.ErrorCode}";
}
@@ -126,20 +126,21 @@ public class SessionDeletionMaintenanceFunctionTests
[Fact]
public async Task RunCore_emits_LongRunningSevere_when_body_runs_past_severe_threshold()
{
// Deterministic: the body blocks until the SEVERE watchdog records its OpsEvent. Warn has a
// smaller threshold, so it is guaranteed to have fired first — neither depends on a body
// sleep outlasting a fixed wall-clock threshold.
// Deterministic: the body blocks until BOTH watchdogs have recorded their OpsEvents. The two
// delays are independent timers, so under load the severe one can complete first; the cancel
// in the finally would then drop a warn delay whose callback had not run yet.
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(20), watchdogSevere: TimeSpan.FromMilliseconds(60));
harness.SetKillSwitch(false);
harness.Fanout.Setup(f => f.RunAsync(It.IsAny<SessionRetentionFanoutService.FanoutResult>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
.Returns(async (SessionRetentionFanoutService.FanoutResult _, DateTime _, CancellationToken ct) =>
{
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunning");
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunningSevere");
});
await harness.Sut.RunCoreAsync("Test", CancellationToken.None);
// Both watchdogs fire: warning first (smaller threshold), then severe (which the body waited for).
// Both watchdogs fired while the body was still running.
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunning");
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunningSevere");
}
@@ -0,0 +1,65 @@
using System.Text.RegularExpressions;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Ratchet: a function takes the tenant whose data it reads from the request context — <c>TenantHelper.GetTenantId(req)</c>
/// or <c>RequestContext.TenantId</c> — never from the token. Only those follow "view as tenant", where a Global Admin
/// reads another tenant as its member; a data read off the token's <c>tid</c> would silently show the Global Admin's
/// own tenant under the viewed tenant's name. The baseline lists the reads that mean the caller's IDENTITY; it only
/// shrinks.
/// </summary>
public class TenantClaimReadGuardTests
{
private static readonly Regex TokenTenantRead = new(
@"\.GetTenantId\(\)|FindFirst\(""tid""\)|identity/claims/tenantid", RegexOptions.Compiled);
/// <summary>Token-tenant reads that identify the caller: file → why.</summary>
private static readonly IReadOnlyDictionary<string, string> Baseline = new Dictionary<string, string>(StringComparer.Ordinal)
{
["Functions/Infrastructure/AuthFunction.cs"] =
"auth/me identifies and onboards the sign-in tenant (its view-as branch runs first), the caller's own What's-new marker and Terms + DPA",
["Functions/Infrastructure/McpUserFunction.cs"] = "the MCP front door binds the caller's identity",
["Functions/Metrics/McpUsageMetricsFunction.cs"] = "the caller's own MCP usage (me)",
["Functions/Rules/PreviewWhitelistFunction.cs"] = "the caller's own tenant's activation notification address",
};
private static string FunctionsRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
dir = dir.Parent;
Assert.NotNull(dir);
return Path.Combine(dir!.FullName, "src", "Backend", "AutopilotMonitor.Functions");
}
[Fact]
public void Functions_read_the_data_tenant_from_the_request_context_not_from_the_token()
{
var root = FunctionsRoot();
var seen = new HashSet<string>(StringComparer.Ordinal);
var offenders = new List<string>();
foreach (var sub in new[] { "Functions", "Pagination" })
{
foreach (var path in Directory.EnumerateFiles(Path.Combine(root, sub), "*.cs", SearchOption.AllDirectories))
{
if (!TokenTenantRead.IsMatch(File.ReadAllText(path)))
continue;
var rel = Path.GetRelativePath(root, path).Replace('\\', '/');
seen.Add(rel);
if (!Baseline.ContainsKey(rel))
offenders.Add(rel);
}
}
Assert.True(offenders.Count == 0,
"Token tenant read in a function — take the data tenant from TenantHelper.GetTenantId(req) or the request " +
"context, or add the file to the baseline with the identity reason:\n " + string.Join("\n ", offenders));
var stale = Baseline.Keys.Where(k => !seen.Contains(k)).ToList();
Assert.True(stale.Count == 0,
"Baseline entries without a token tenant read any more — remove them (the ratchet only shrinks):\n " +
string.Join("\n ", stale));
}
}
@@ -47,8 +47,6 @@ public class TypedRequestGuardTests
["Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs"] = "agent route (D-200)",
// Foreign schema: Azure Monitor common alert schema, not our contract.
["Functions/Infrastructure/AzureMonitorAlertWebhookFunction.cs"] = "Azure Monitor alert schema",
// Parses the UPSTREAM Kusto response, not the request body.
["Functions/Raw/AppInsightsQueryFunction.cs"] = "upstream response parse",
// Not request bodies at all: telemetry item payloads (agent wire, D-200) and stored table columns.
["Functions/Ingest/TelemetryPayloadParser.cs"] = "telemetry item PayloadJson (agent wire, D-200)",
["Functions/Config/ListTenantConfigBackupsFunction.cs"] = "stored backup diff column",
@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using Xunit;
using static AutopilotMonitor.Functions.Tests.UpstreamParserReadings;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Differential test for the move of the Graph and Entra token-endpoint parsers to System.Text.Json (D-420):
/// <see cref="UpstreamResponseCases"/> builds response bodies from a fixed seed per parser, and the production
/// parser must yield exactly what the Newtonsoft reference in <see cref="LegacyUpstreamParsers"/> yields: same
/// result, or both throw (every caller treats any escaped exception the same way).
/// The bodies leave out the inputs of the deliberate differences D1 to D6; those are pinned in
/// <see cref="GraphResponseParsingTests"/>. A body cut off mid-object (D7) is compared with the production
/// parser's answer to an unreadable body instead: Newtonsoft's DeserializeObject read the part before the cut
/// as if the open containers were closed.
/// Temporary: removed with <see cref="LegacyUpstreamParsers"/> after a clean production run (backlog). The
/// generator and the readings stay for <see cref="UpstreamJsonInvariantTests"/>.
/// </summary>
public class UpstreamJsonDifferentialTests
{
private const int CaseCount = 20_000;
[Fact]
public void Token_response_matches_the_reference()
=> Compare(seed: 4201, g => g.TokenBody(),
b => Describe(GraphTokenService.ReadTokenResponse(b)),
b => Describe(LegacyUpstreamParsers.ReadTokenResponse(b)),
hit: o => o.StartsWith("token=\"", StringComparison.Ordinal));
[Fact]
public void Entra_rejection_matches_the_reference()
=> Compare(seed: 4202, g => g.EntraBody(),
b => Describe(EntraTokenRejection.Parse(400, b)),
b => Describe(LegacyUpstreamParsers.ParseEntraRejection(400, b)),
hit: o => o.Contains("code=\"AADSTS", StringComparison.Ordinal));
[Fact]
public void Autopilot_list_matches_the_reference()
=> Compare(seed: 4203, g => g.AutopilotBody(Serial),
b => Describe(AutopilotDeviceValidator.FindRegistration(b, Serial)),
b => Describe(LegacyUpstreamParsers.FindRegistration(b, Serial)),
hit: o => o.StartsWith("id=", StringComparison.Ordinal));
[Fact]
public void Corporate_page_matches_the_reference()
=> Compare(seed: 4204, g => g.CorporateBody(),
b => Describe(CorporateIdentifierValidator.ReadIdentityPage(b, CorporateId)),
b => Describe(LegacyUpstreamParsers.ReadIdentityPage(b, CorporateId)),
hit: o => o.StartsWith("found=True", StringComparison.Ordinal));
[Fact]
public void Association_list_matches_the_reference()
=> Compare(seed: 4205, g => g.AssociationBody(Serial),
b => Describe(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(b, Serial)),
b => Describe(LegacyUpstreamParsers.ParseAssociation(b, Serial)),
hit: o => o.StartsWith("valid=True", StringComparison.Ordinal));
[Fact]
public void CloudPc_list_matches_the_reference()
=> Compare(seed: 4206, g => g.CloudPcBody(IntuneId),
b => Describe(CloudPcDeviceValidator.ParseCloudPcResponse(b, IntuneId)),
b => Describe(LegacyUpstreamParsers.ParseCloudPc(b, IntuneId)),
hit: o => o.StartsWith("valid=True", StringComparison.Ordinal));
[Fact]
public void Managed_device_matches_the_reference()
=> Compare(seed: 4207, g => g.ManagedDeviceBody(IntuneId),
b => Describe(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(b, IntuneId)),
b => Describe(LegacyUpstreamParsers.ParseManagedDevice(b, IntuneId)),
hit: o => !o.StartsWith("outcome=NotFound", StringComparison.Ordinal) && o != "<throws>");
[Fact]
public void Script_page_matches_the_reference()
=> Compare(seed: 4208, g => g.ScriptPageBody(),
b => Describe(ScriptDisplayNameResolver.ReadScriptPage(b, withFileName: true)),
b => Describe(LegacyUpstreamParsers.ReadScriptPage(b, withFileName: true)),
hit: o => o.StartsWith("\"", StringComparison.Ordinal));
[Fact]
public void Script_name_matches_the_reference()
=> Compare(seed: 4209, g => g.ScriptNameBody(),
b => Describe(ScriptDisplayNameResolver.ReadScriptDisplayName(b)),
b => Describe(LegacyUpstreamParsers.ReadScriptDisplayName(b)),
hit: o => o != "<null>" && o != "<throws>");
private static void Compare(int seed, Func<UpstreamResponseCases, string> body, Func<string, string> actual, Func<string, string> reference, Func<string, bool> hit)
{
// The reference formats dates in the current culture before parsing them back; en-US keeps its
// whole-second "Z" dates exact. The culture swap on other hosts is pinned in GraphResponseParsingTests.
var saved = CultureInfo.CurrentCulture;
CultureInfo.CurrentCulture = new CultureInfo("en-US");
try
{
var generator = new UpstreamResponseCases(seed);
var unreadable = Outcome(() => actual("not json"));
var mismatches = new List<string>();
int hits = 0, throws = 0;
for (var i = 0; i < CaseCount && mismatches.Count < 5; i++)
{
var input = body(generator);
var got = Outcome(() => actual(input));
var want = generator.LastWasCutOff ? unreadable : Outcome(() => reference(input));
if (got != want)
mismatches.Add($"seed {seed}, case {i}: body {input}\n production: {got}\n reference: {want}");
if (hit(want)) hits++;
if (want == "<throws>") throws++;
}
Assert.True(mismatches.Count == 0, "Parser differs from the Newtonsoft reference:\n " + string.Join("\n ", mismatches));
// The generator must exercise the parser, not compare 20,000 identical misses.
Assert.True(hits >= CaseCount / 50, $"only {hits} of {CaseCount} cases reach a result worth comparing");
Assert.True(throws <= CaseCount * 7 / 10, $"{throws} of {CaseCount} cases throw; the generator barely reaches the parser");
}
finally
{
CultureInfo.CurrentCulture = saved;
}
}
}
@@ -0,0 +1,340 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
using Xunit;
using static AutopilotMonitor.Functions.Tests.UpstreamParserReadings;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Invariant fuzz for the Graph and Entra token-endpoint parsers (D-420), without a reference. Over bodies of
/// every value kind, the inputs of D1 to D7 included (<see cref="UpstreamResponseCases"/>), each parser throws
/// only what its callers expect, reads a body the same way however it is laid out, and returns no text the body
/// did not send; <see cref="UpstreamJson.ParseObjectOrNull"/> yields an object, null or a JsonException for any
/// text. Permanent: 500 cases per parser and check.
/// </summary>
public class UpstreamJsonInvariantTests
{
private const int CaseCount = 500;
/// <summary>An exception kind a parser's callers expect, named for the failure message.</summary>
private sealed record Thrown(string Name, Func<Exception, bool> Matches);
// The callers catch JsonException or everything; an unreadable body or next link throws it.
private static readonly Thrown Unreadable = new("JsonException", ex => ex is JsonException);
// A list entry that is no object before the match: the validators answer transient, as before the move.
// Only the exact type counts; ObjectDisposedException derives from it and would mean a disposed document.
private static readonly Thrown NonObjectEntry = new("InvalidOperationException", ex => ex.GetType() == typeof(InvalidOperationException));
/// <summary>A parser as the fuzz drives it.</summary>
/// <param name="Seed">The generator seed; each check offsets it to see other bodies.</param>
/// <param name="Body">The body generator for this parser.</param>
/// <param name="Read">The canonical reading of a body.</param>
/// <param name="Texts">Every text the result took from the body.</param>
/// <param name="MayThrow">The exception kinds that may escape; every one of them must also occur.</param>
private sealed record Parser(
int Seed,
Func<UpstreamResponseCases, string> Body,
Func<string, string> Read,
Func<string, IEnumerable<string?>> Texts,
Thrown[] MayThrow);
private static readonly Dictionary<string, Parser> Parsers = new()
{
["token"] = new(4301, g => g.TokenBody(),
b => Describe(GraphTokenService.ReadTokenResponse(b)),
b => new[] { GraphTokenService.ReadTokenResponse(b).AccessToken },
new[] { Unreadable }),
["entra"] = new(4302, g => g.EntraBody(),
b => Describe(EntraTokenRejection.Parse(400, b)),
b => new[] { EntraTokenRejection.Parse(400, b).Description },
Array.Empty<Thrown>()),
["autopilot"] = new(4303, g => g.AutopilotBody(Serial),
b => Describe(AutopilotDeviceValidator.FindRegistration(b, Serial)),
b => AutopilotDeviceValidator.FindRegistration(b, Serial) is { } r
? new[] { r.DeviceId, r.GroupTag, r.PurchaseOrderId, r.DisplayName }
: Array.Empty<string?>(),
new[] { Unreadable, NonObjectEntry }),
["corporate"] = new(4304, g => g.CorporateBody(),
b => Describe(CorporateIdentifierValidator.ReadIdentityPage(b, CorporateId)),
// An empty next url is the parser's own "no further page", not text from the body.
b => new[] { CorporateIdentifierValidator.ReadIdentityPage(b, CorporateId).NextUrl is { Length: > 0 } next ? next : null },
new[] { Unreadable, NonObjectEntry }),
["association"] = new(4305, g => g.AssociationBody(Serial),
b => Describe(DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(b, Serial)),
b =>
{
var r = DeviceAssociationValidator.ParseTenantAssociatedDevicesResponse(b, Serial);
return new[] { r.AssociationState, r.DevicePreparationPolicyId, r.PreAssociatedByUserPrincipalName, r.AssignedToUserPrincipalName, r.ManagedDeviceId };
},
new[] { NonObjectEntry }),
["cloudpc"] = new(4306, g => g.CloudPcBody(IntuneId),
b => Describe(CloudPcDeviceValidator.ParseCloudPcResponse(b, IntuneId)),
b =>
{
var r = CloudPcDeviceValidator.ParseCloudPcResponse(b, IntuneId);
return new[] { r.CloudPcId, r.ManagedDeviceName, r.ServicePlanName };
},
new[] { NonObjectEntry }),
["managed-device"] = new(4307, g => g.ManagedDeviceBody(IntuneId),
b => Describe(IntuneDeviceBindingValidator.ParseManagedDeviceResponse(b, IntuneId)),
b =>
{
var r = IntuneDeviceBindingValidator.ParseManagedDeviceResponse(b, IntuneId);
return new[] { r.DeviceName, r.AzureAdDeviceId, r.ManagementState, r.SerialNumber, r.OwnerType, r.EnrollmentType };
},
Array.Empty<Thrown>()),
["script-page"] = new(4308, g => g.ScriptPageBody(),
b => Describe(ScriptDisplayNameResolver.ReadScriptPage(b, withFileName: true)),
b =>
{
var (scripts, next) = ScriptDisplayNameResolver.ReadScriptPage(b, withFileName: true);
return scripts.SelectMany(s => new[] { s.Id, s.DisplayName, s.FileName }).Append(next);
},
new[] { Unreadable }),
["script-name"] = new(4309, g => g.ScriptNameBody(),
b => Describe(ScriptDisplayNameResolver.ReadScriptDisplayName(b)),
b => new[] { ScriptDisplayNameResolver.ReadScriptDisplayName(b) },
new[] { Unreadable }),
};
public static IEnumerable<object[]> ParserNames => Parsers.Keys.Select(name => new object[] { name });
[Theory]
[MemberData(nameof(ParserNames))]
public void A_parser_throws_only_what_its_callers_expect(string name)
{
var parser = Parsers[name];
var cases = new UpstreamResponseCases(parser.Seed, everyValueKind: true);
var seen = new HashSet<string>();
for (var i = 0; i < CaseCount; i++)
{
var body = parser.Body(cases);
try
{
parser.Read(body);
}
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
{
var kind = parser.MayThrow.FirstOrDefault(t => t.Matches(ex));
Assert.True(kind != null, $"{name}, case {i}: {ex.GetType().Name} escapes ({ex.Message}) for body {body}");
seen.Add(kind!.Name);
}
}
// Every listed kind still occurs, so the list cannot quietly outgrow what the parser does.
Assert.Equal(parser.MayThrow.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal), seen.OrderBy(n => n, StringComparer.Ordinal));
}
[Theory]
[MemberData(nameof(ParserNames))]
public void Layout_never_changes_what_a_parser_reads(string name)
{
var parser = Parsers[name];
var cases = new UpstreamResponseCases(parser.Seed + 100, everyValueKind: true);
var layout = new Random(parser.Seed);
var compared = 0;
for (var i = 0; i < CaseCount; i++)
{
var body = parser.Body(cases);
if (ReadableObject(body) is not { } root)
continue;
var expected = Outcome(() => parser.Read(body));
foreach (var spread in new[] { false, true })
{
var twin = Render(root, layout, spread);
var actual = Outcome(() => parser.Read(twin));
Assert.True(expected == actual, $"{name}, case {i}: body {body}\n laid out as {twin}\n reads {actual}\n instead of {expected}");
}
compared++;
}
Assert.True(compared >= CaseCount / 2, $"only {compared} of {CaseCount} bodies were readable objects");
}
[Theory]
[MemberData(nameof(ParserNames))]
public void A_parser_returns_only_text_the_body_sent(string name)
{
var parser = Parsers[name];
var cases = new UpstreamResponseCases(parser.Seed + 200, everyValueKind: true);
var checkedTexts = 0;
for (var i = 0; i < CaseCount; i++)
{
var body = parser.Body(cases);
string?[] texts;
try
{
texts = parser.Texts(body).ToArray();
}
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
{
continue; // which exceptions may escape is the first check's business
}
var sent = ReadableObject(body) is { } root ? SentTexts(root) : new List<string>();
foreach (var text in texts.OfType<string>())
{
// Trimming and cutting keep a text inside what was sent; an empty text needs a blank one.
var fromBody = text.Length == 0
? sent.Any(string.IsNullOrWhiteSpace)
: sent.Any(s => s.Contains(text, StringComparison.Ordinal));
Assert.True(fromBody, $"{name}, case {i}: \"{text}\" is no text the body sent: {body}");
checkedTexts++;
}
}
// The corporate page returns its next url as the only text, so the bar is low.
Assert.True(checkedTexts >= CaseCount / 50, $"only {checkedTexts} texts came back from {CaseCount} bodies");
}
[Fact]
public void ParseObjectOrNull_yields_an_object_or_null_or_throws_JsonException_for_any_text()
{
var edits = new Random(4310);
int objects = 0, nulls = 0, refused = 0;
foreach (var parser in Parsers.Values)
{
var cases = new UpstreamResponseCases(parser.Seed + 300, everyValueKind: true);
for (var i = 0; i < CaseCount / 2; i++)
{
var body = parser.Body(cases);
foreach (var text in new[] { body, Mutate(body, edits) })
{
try
{
if (UpstreamJson.ParseObjectOrNull(text) is { } json)
{
Assert.Equal(JsonValueKind.Object, json.ValueKind);
objects++;
}
else
{
nulls++;
}
}
catch (JsonException)
{
refused++;
}
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException)
{
Assert.Fail($"{ex.GetType().Name} ({ex.Message}) for text {text}");
}
}
}
}
Assert.True(objects > 0 && nulls > 0 && refused > 0, $"objects {objects}, nulls {nulls}, refused {refused}");
}
private static JsonElement? ReadableObject(string body)
{
try
{
return UpstreamJson.ParseObjectOrNull(body);
}
catch (JsonException)
{
return null;
}
}
/// <summary>Every string value in the body, decoded; one whose escapes are no valid UTF-16 is skipped.</summary>
private static List<string> SentTexts(JsonElement root)
{
var texts = new List<string>();
Collect(root);
return texts;
void Collect(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
foreach (var member in element.EnumerateObject())
Collect(member.Value);
break;
case JsonValueKind.Array:
foreach (var item in element.EnumerateArray())
Collect(item);
break;
case JsonValueKind.String:
try { texts.Add(element.GetString()!); }
catch (InvalidOperationException) { }
break;
}
}
}
/// <summary>
/// The body written again in another layout: compact, or spread out with block and line comments and
/// trailing commas. Object members change their order unless a name repeats (the last one must stay last);
/// values keep their raw text, escapes and number form included.
/// </summary>
private static string Render(JsonElement root, Random rng, bool spread)
{
var text = new StringBuilder();
Write(root);
return text.ToString();
void Write(JsonElement element)
{
switch (element.ValueKind)
{
case JsonValueKind.Object:
var members = element.EnumerateObject().ToList();
if (members.Select(m => m.Name).Distinct(StringComparer.Ordinal).Count() == members.Count)
members = members.OrderBy(_ => rng.Next()).ToList();
text.Append(spread ? "{ /* object */\n " : "{");
for (var i = 0; i < members.Count; i++)
{
if (i > 0)
text.Append(spread ? (rng.Next(2) == 0 ? ",// next\n " : " ,\t/* next */ ") : ",");
text.Append(JsonSerializer.Serialize(members[i].Name)).Append(spread ? " : " : ":");
Write(members[i].Value);
}
text.Append(spread && members.Count > 0 ? ",\n}" : "}");
break;
case JsonValueKind.Array:
var items = element.EnumerateArray().ToList();
text.Append(spread ? "[\n " : "[");
for (var i = 0; i < items.Count; i++)
{
if (i > 0)
text.Append(spread ? " , " : ",");
Write(items[i]);
}
text.Append(spread && items.Count > 0 ? ",\n]" : "]");
break;
default:
text.Append(element.GetRawText());
break;
}
}
}
/// <summary>One random edit: a character removed, inserted or replaced, or the text cut short.</summary>
private static string Mutate(string text, Random rng)
{
if (text.Length == 0)
return "{";
const string alphabet = "{}[]\",:/*\\ \n0123456789-+.eEtrufalsné";
var at = rng.Next(text.Length);
var c = alphabet[rng.Next(alphabet.Length)].ToString();
return rng.Next(4) switch
{
0 => text.Remove(at, 1),
1 => text.Insert(at, c),
2 => text.Substring(0, at),
_ => text.Remove(at, 1).Insert(at, c),
};
}
}
@@ -0,0 +1,298 @@
using System;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The contract of <see cref="UpstreamJson"/> (D-420), the reader under every Graph and Entra token-endpoint
/// parser: one test group per member, then the culture matrix. The parser-level contract is in
/// <see cref="GraphResponseParsingTests"/>.
/// </summary>
public class UpstreamJsonTests
{
private static JsonElement Obj(string json) => UpstreamJson.ParseObjectOrNull(json)!.Value;
// ── ParseObjectOrNull ───────────────────────────────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("\r\n\t")]
[InlineData("null")]
[InlineData(" null ")]
[InlineData("/*c*/ null")]
public void ParseObjectOrNull_reads_a_blank_body_and_a_null_root_as_no_object(string? body)
=> Assert.Null(UpstreamJson.ParseObjectOrNull(body));
[Theory]
[InlineData("{}")]
[InlineData(" { \"a\" : 1 } ")]
[InlineData("{\"a\":1,}")]
[InlineData("{/*c*/\"a\":[1,2,],// line\n}")]
[InlineData("// leading\n{\"a\":1}")]
public void ParseObjectOrNull_reads_one_object_with_comments_and_trailing_commas(string body)
=> Assert.Equal(JsonValueKind.Object, UpstreamJson.ParseObjectOrNull(body)!.Value.ValueKind);
[Fact]
public void ParseObjectOrNull_returns_an_element_that_outlives_the_parse()
{
// The document is disposed inside the call; only a cloned root stays readable.
var json = Obj("""{"a":"b","list":[{"c":"d"}]}""");
GC.Collect();
Assert.Equal("b", json.GetProperty("a").GetString());
Assert.Equal("d", json.GetProperty("list")[0].GetProperty("c").GetString());
}
// Everything that is not exactly one object throws JsonException, the type the callers catch. The syntax
// only Newtonsoft tolerated (single quotes, bare names, NaN, hex, undefined, raw line breaks in strings)
// is malformed too; Graph and Entra never send it.
[Theory]
[InlineData("[]")]
[InlineData("[{}]")]
[InlineData("42")]
[InlineData("\"text\"")]
[InlineData("true")]
[InlineData("not json")]
[InlineData("<html>busy</html>")]
[InlineData("{")]
[InlineData("{\"a\":}")]
[InlineData("{} x")]
[InlineData("{}{}")]
[InlineData("{} /* unterminated")]
[InlineData("{'a':'b'}")]
[InlineData("{a:1}")]
[InlineData("{\"a\":NaN}")]
[InlineData("{\"a\":0x1F}")]
[InlineData("{\"a\":undefined}")]
[InlineData("{\"a\":\"line\nbreak\"}")]
[InlineData("{\"a\":1,,}")]
public void ParseObjectOrNull_throws_JsonException_for_anything_but_one_object(string body)
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.ParseObjectOrNull(body));
[Fact]
public void ParseObjectOrNull_throws_JsonException_for_nesting_beyond_the_depth_limit()
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.ParseObjectOrNull(
"{\"a\":" + new string('[', 100) + new string(']', 100) + "}"));
// ── GetString ───────────────────────────────────────────────────────────────
[Theory]
[InlineData("""{"a":"value"}""", "value")]
[InlineData("""{"a":""}""", "")]
[InlineData("""{"a":" padded "}""", " padded ")]
[InlineData("""{"a":"quote\" back\\ slash\n tab\t é 😀"}""", "quote\" back\\ slash\n tab\t é 😀")]
[InlineData("""{"a":"2026-01-01T00:00:00Z"}""", "2026-01-01T00:00:00Z")]
[InlineData("""{"a":"/Date(1713096021000)/"}""", "/Date(1713096021000)/")]
[InlineData("""{"a":"42"}""", "42")]
[InlineData("""{"a":"first","a":"last"}""", "last")]
public void GetString_reads_text_as_sent(string json, string expected)
=> Assert.Equal(expected, UpstreamJson.GetString(Obj(json), "a"));
[Theory]
[InlineData("""{}""")]
[InlineData("""{"a":null}""")]
[InlineData("""{"a":42}""")]
[InlineData("""{"a":4.2}""")]
[InlineData("""{"a":true}""")]
[InlineData("""{"a":false}""")]
[InlineData("""{"a":{}}""")]
[InlineData("""{"a":[]}""")]
[InlineData("""{"a":["x"]}""")]
[InlineData("""{"A":"other case"}""")]
[InlineData("""{"a ":"other name"}""")]
public void GetString_reads_anything_but_text_under_the_exact_name_as_absent(string json)
=> Assert.Null(UpstreamJson.GetString(Obj(json), "a"));
[Theory]
[InlineData("null")]
[InlineData("42")]
[InlineData("\"text\"")]
[InlineData("[]")]
public void GetString_on_a_list_entry_that_is_no_object_throws_InvalidOperationException(string entry)
{
// The validators rely on this: a non-object entry before the match keeps ending the lookup as transient.
var item = Obj($$"""{"value":[{{entry}}]}""").GetProperty("value")[0];
Assert.Throws<InvalidOperationException>(() => UpstreamJson.GetString(item, "a"));
}
[Theory]
[InlineData("""{"a":"x\ud800y"}""")]
[InlineData("""{"a":"\udc00"}""")]
[InlineData("""{"a":"\ud800A"}""")]
public void GetString_reads_text_with_a_broken_surrogate_escape_as_absent(string json)
=> Assert.Null(UpstreamJson.GetString(Obj(json), "a"));
// ── Items ───────────────────────────────────────────────────────────────────
[Fact]
public void Items_yields_every_array_entry_in_order_whatever_its_kind()
=> Assert.Equal(
new[] { JsonValueKind.Object, JsonValueKind.Null, JsonValueKind.Number, JsonValueKind.String, JsonValueKind.Array },
UpstreamJson.Items(Obj("""{"value":[{},null,1,"x",[]]}"""), "value").Select(i => i.ValueKind));
[Theory]
[InlineData("""{}""")]
[InlineData("""{"value":null}""")]
[InlineData("""{"value":{}}""")]
[InlineData("""{"value":"oops"}""")]
[InlineData("""{"value":42}""")]
[InlineData("""{"value":[]}""")]
[InlineData("""{"Value":[1]}""")]
public void Items_is_empty_unless_the_property_is_a_non_empty_array(string json)
=> Assert.Empty(UpstreamJson.Items(Obj(json), "value"));
// ── GetNextLink ─────────────────────────────────────────────────────────────
[Theory]
[InlineData("""{}""", null)]
[InlineData("""{"@odata.nextLink":null}""", null)]
[InlineData("""{"@odata.nextLink":""}""", "")]
[InlineData("""{"@odata.nextLink":"https://graph.microsoft.com/beta/x?$skiptoken=a%2Fb"}""", "https://graph.microsoft.com/beta/x?$skiptoken=a%2Fb")]
[InlineData("""{"@odata.nextlink":"https://graph.microsoft.com/beta/other-case"}""", null)]
public void GetNextLink_reads_the_link_or_its_absence(string json, string? expected)
=> Assert.Equal(expected, UpstreamJson.GetNextLink(Obj(json)));
[Theory]
[InlineData("5")]
[InlineData("true")]
[InlineData("{}")]
[InlineData("[]")]
[InlineData("[\"https://graph.microsoft.com/beta/x\"]")]
[InlineData("\"https://graph.microsoft.com/beta/x\\ud800\"")]
public void GetNextLink_throws_for_a_link_that_is_not_text(string link)
=> Assert.ThrowsAny<JsonException>(() => UpstreamJson.GetNextLink(Obj($$"""{"@odata.nextLink":{{link}}}""")));
// ── GetInt64 ────────────────────────────────────────────────────────────────
[Theory]
[InlineData("3599", 3599L)]
[InlineData("0", 0L)]
[InlineData("-5", -5L)]
[InlineData("9223372036854775807", long.MaxValue)]
[InlineData("-9223372036854775808", long.MinValue)]
[InlineData("\"3599\"", 3599L)]
[InlineData("\" 3599 \"", 3599L)]
[InlineData("\"+3599\"", 3599L)]
[InlineData("\"-1\"", -1L)]
public void GetInt64_reads_a_whole_number_or_its_text(string value, long expected)
=> Assert.Equal(expected, UpstreamJson.GetInt64(Obj($$"""{"n":{{value}}}"""), "n"));
[Theory]
[InlineData("3599.5")]
[InlineData("3599.0")]
[InlineData("7e2")]
[InlineData("9223372036854775808")]
[InlineData("99999999999999999999")]
[InlineData("\"3599.0\"")]
[InlineData("\"3,599\"")]
[InlineData("\"3.599\"")]
[InlineData("\"0x10\"")]
[InlineData("\"\"")]
[InlineData("\"abc\"")]
[InlineData("\"\u0661\u0662\u0663\"")]
[InlineData("\"\\ud800\"")]
[InlineData("null")]
[InlineData("true")]
[InlineData("{}")]
[InlineData("[]")]
public void GetInt64_reads_anything_but_a_whole_number_as_absent(string value)
=> Assert.Null(UpstreamJson.GetInt64(Obj($$"""{"n":{{value}}}"""), "n"));
[Fact]
public void GetInt64_reads_a_missing_property_as_absent()
=> Assert.Null(UpstreamJson.GetInt64(Obj("{}"), "n"));
// ── GetBoolean ──────────────────────────────────────────────────────────────
[Theory]
[InlineData("true", true)]
[InlineData("false", false)]
[InlineData("null", null)]
[InlineData("\"true\"", null)]
[InlineData("\"false\"", null)]
[InlineData("1", null)]
[InlineData("0", null)]
[InlineData("{}", null)]
public void GetBoolean_reads_only_true_and_false(string value, bool? expected)
=> Assert.Equal(expected, UpstreamJson.GetBoolean(Obj($$"""{"b":{{value}}}"""), "b"));
[Fact]
public void GetBoolean_reads_a_missing_property_as_absent()
=> Assert.Null(UpstreamJson.GetBoolean(Obj("{}"), "b"));
// ── GetUtcDateTime ──────────────────────────────────────────────────────────
[Theory]
[InlineData("2026-05-08T12:16:49Z", 0L)]
[InlineData("2026-05-08T12:16:49.1234567Z", 1234567L)]
[InlineData("2026-05-08T12:16:49.5Z", 5000000L)]
[InlineData("2026-05-08T14:16:49+02:00", 0L)]
[InlineData("2026-05-08T07:16:49.1234567-05:00", 1234567L)]
[InlineData("2026-05-08T12:16:49", 0L)]
public void GetUtcDateTime_reads_the_instant_in_utc_with_full_precision(string value, long fractionTicks)
{
var date = UpstreamJson.GetUtcDateTime(Obj($$"""{"d":"{{value}}"}"""), "d");
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(fractionTicks), date);
Assert.Equal(TimeSpan.Zero, date!.Value.Offset);
}
[Fact]
public void GetUtcDateTime_reads_a_date_without_time_as_its_utc_midnight()
=> Assert.Equal(new DateTimeOffset(2026, 5, 8, 0, 0, 0, TimeSpan.Zero), UpstreamJson.GetUtcDateTime(Obj("""{"d":"2026-05-08"}"""), "d"));
// Graph sends 0001-01-01 for a date it does not have: absence, not a date two thousand years ago.
[Theory]
[InlineData("\"0001-01-01T00:00:00Z\"")]
[InlineData("\"0001-01-01T00:00:00\"")]
[InlineData("\"0001-01-01\"")]
[InlineData("\"\"")]
[InlineData("\" \"")]
[InlineData("\"not-a-date\"")]
[InlineData("\"2026-13-01T00:00:00Z\"")]
[InlineData("\"/Date(1713096021000)/\"")]
[InlineData("null")]
[InlineData("20260508")]
[InlineData("true")]
[InlineData("{}")]
public void GetUtcDateTime_reads_an_unset_or_unusable_value_as_absent(string value)
=> Assert.Null(UpstreamJson.GetUtcDateTime(Obj($$"""{"d":{{value}}}"""), "d"));
[Fact]
public void GetUtcDateTime_reads_a_missing_property_as_absent()
=> Assert.Null(UpstreamJson.GetUtcDateTime(Obj("{}"), "d"));
// ── Culture matrix ──────────────────────────────────────────────────────────
// No member may depend on the host culture: day-first, Thai Buddhist calendar and the en-US default all
// read the same body the same way.
[Theory]
[InlineData("en-US")]
[InlineData("de-DE")]
[InlineData("en-GB")]
[InlineData("th-TH")]
public void Every_member_reads_the_same_in_any_culture(string culture)
{
var saved = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo(culture);
var json = Obj("""{"d":"2026-05-08T12:16:49.1234567Z","day":"2026-05-08","n":"3599","t":"2026-05-08T12:16:49Z","f":"1.5"}""");
Assert.Equal(new DateTimeOffset(2026, 5, 8, 12, 16, 49, TimeSpan.Zero).AddTicks(1234567), UpstreamJson.GetUtcDateTime(json, "d"));
Assert.Equal(new DateTimeOffset(2026, 5, 8, 0, 0, 0, TimeSpan.Zero), UpstreamJson.GetUtcDateTime(json, "day"));
Assert.Equal(3599L, UpstreamJson.GetInt64(json, "n"));
Assert.Equal("2026-05-08T12:16:49Z", UpstreamJson.GetString(json, "t"));
Assert.Null(UpstreamJson.GetInt64(json, "f"));
}
finally
{
CultureInfo.CurrentCulture = saved;
}
}
}
@@ -0,0 +1,65 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.GraphResolution;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// One canonical text per result of the Graph and Entra token-endpoint parsers (D-420), so two readings of a
/// body compare as strings: the differential test compares production with the Newtonsoft reference, the
/// invariant fuzz compares a body with its reformatted twin.
/// </summary>
internal static class UpstreamParserReadings
{
internal const string Serial = "PF3XKQ7";
internal const string IntuneId = "6f1c4d2a-0b3e-4c5d-8e9f-1a2b3c4d5e6f";
internal static readonly string CorporateId =
CorporateIdentifierValidator.BuildNormalizedIdentifier("Microsoft Corporation", "Virtual Machine", "1234-5678");
/// <summary>The reading, or "&lt;throws&gt;" when the parser throws.</summary>
internal static string Outcome(Func<string> run)
{
try { return run(); }
catch (Exception ex) when (ex is not Xunit.Sdk.XunitException) { return "<throws>"; }
}
private static string Text(string? value) => value is null ? "<null>" : "\"" + value + "\"";
private static string Date(DateTime? value) => value is { } d ? d.ToString("O", CultureInfo.InvariantCulture) + "/" + d.Kind : "<null>";
// Every consumer reads the token with IsNullOrEmpty, so an empty and an absent token describe alike.
internal static string Describe((string? AccessToken, long? ExpiresInSeconds) r) =>
$"token={(string.IsNullOrEmpty(r.AccessToken) ? "<none>" : Text(r.AccessToken))} expires={r.ExpiresInSeconds?.ToString(CultureInfo.InvariantCulture) ?? "<null>"}";
internal static string Describe(EntraTokenRejection r) => $"status={r.StatusCode} code={Text(r.ErrorCode)} description={Text(r.Description)}";
internal static string Describe(AutopilotMonitor.Shared.Models.AutopilotRegistration? r) => r is null
? "<not registered>"
: $"id={Text(r.DeviceId)} tag={Text(r.GroupTag)} po={Text(r.PurchaseOrderId)} name={Text(r.DisplayName)} user={r.UserAssigned}";
internal static string Describe((bool Found, string NextUrl) r) => $"found={r.Found} next={Text(r.NextUrl)}";
internal static string Describe(DeviceAssociationResult r) =>
$"valid={r.IsValid} transient={r.IsTransient} serial={Text(r.SerialNumber)} state={Text(r.AssociationState)} policy={Text(r.DevicePreparationPolicyId)} " +
$"preBy={Text(r.PreAssociatedByUserPrincipalName)} assigned={Text(r.AssignedToUserPrincipalName)} pre={Date(r.PreAssociationDateTime)} " +
$"at={Date(r.AssociationDateTime)} device={Text(r.ManagedDeviceId)} error={Text(r.ErrorMessage)}";
internal static string Describe(CloudPcValidationResult r) =>
$"valid={r.IsValid} transient={r.IsTransient} device={Text(r.IntuneDeviceId)} cpc={Text(r.CloudPcId)} name={Text(r.ManagedDeviceName)} " +
$"plan={Text(r.ServicePlanName)} error={Text(r.ErrorMessage)}";
internal static string Describe(IntuneDeviceBindingResult r) =>
$"outcome={r.Outcome} device={Text(r.IntuneDeviceId)} name={Text(r.DeviceName)} aad={Text(r.AzureAdDeviceId)} state={Text(r.ManagementState)} " +
$"serial={Text(r.SerialNumber)} owner={Text(r.OwnerType)} enrollment={Text(r.EnrollmentType)} registered={r.AzureAdRegistered?.ToString() ?? "<null>"} " +
$"enrolled={(r.EnrolledDateTime is { } e ? e.ToString("O", CultureInfo.InvariantCulture) : "<null>")} error={Text(r.ErrorMessage)}";
// A null or empty next link both end the full pull, so they describe alike.
internal static string Describe((List<ScriptDisplayNameResolver.ScriptListItem> Scripts, string? NextUrl) r) =>
string.Join(";", r.Scripts.Select(s => $"{Text(s.Id)}={Text(s.DisplayName)}/{Text(s.FileName)}")) + $" next={Text(string.IsNullOrEmpty(r.NextUrl) ? null : r.NextUrl)}";
internal static string Describe(string? scriptName) => scriptName ?? "<null>";
}
@@ -0,0 +1,231 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Deterministic Graph and Entra token-endpoint response bodies for the parser tests of D-420. They vary root
/// kind, empty, broken and cut-off bodies, trailing content, comments, trailing commas, whitespace, property
/// order, unknown and duplicate properties, mixed list entries, text with escapes and Unicode, paging links,
/// lifetimes as number or numeric text, and error-code variants.
/// By default they leave out the inputs of the deliberate differences D1 to D6, so the old and the new parser
/// must agree on them. With <c>everyValueKind</c> they add those inputs too: JSON null, numbers, booleans,
/// objects and arrays in text fields, date-shaped text, dates with fractions and offsets, escapes that are no
/// valid UTF-16, unusable lifetimes and error codes.
/// </summary>
internal sealed class UpstreamResponseCases
{
// The same few literals come back thousands of times; serializing each once keeps the fuzz fast. Declared
// first: the pools below call Str while the type initializes.
private static readonly ConcurrentDictionary<string, string> Serialized = new(StringComparer.Ordinal);
private readonly Random _rng;
private readonly bool _everyValueKind;
public UpstreamResponseCases(int seed, bool everyValueKind = false)
{
_rng = new Random(seed);
_everyValueKind = everyValueKind;
}
/// <summary>Whether the last body was the object cut off halfway (D7).</summary>
public bool LastWasCutOff { get; private set; }
// Plain text that is never a date in either library and never JSON null.
private static readonly string[] PlainTexts =
{
Str("value"), Str(" padded "), Str("UPPER lower 123"), "\"Ünïcødé ☃ ε\"", Str("quote\" back\\ slash\nnew line"),
Str(new string('x', 300)), Str(""), Str(" "), Str("a/b?c=d&e"), Str("é\u0001\u007f"), Str("2026"), Str("12:16"),
};
// What D1, D3 and D4 read differently: JSON null, other kinds, date-shaped text, an escape that is no valid UTF-16.
private static readonly string[] DeliberateTexts =
{
"null", "42", "-1.5", "true", "{}", "[]", "[\"x\"]", Str("2026-01-01T00:00:00Z"), Str("/Date(1713096021000)/"), "\"x\\ud800y\"",
};
// A whole-second UTC date, a date without time, or a value both read as no date.
private static readonly string[] Dates =
{
Str("2026-05-08T12:16:49Z"), Str("2025-12-31T23:59:59Z"), Str("2026-05-08"), Str("0001-01-01T00:00:00Z"),
Str(""), Str("not-a-date"), "null",
};
// What D2 reads differently (fraction, offset, no offset) and dates that are no text.
private static readonly string[] DeliberateDates =
{
Str("2026-05-08T12:16:49.1234567Z"), Str("2026-05-08T14:16:49+02:00"), Str("2026-05-08T12:16:49"), "20260508", "true",
};
private T Pick<T>(params T[] items) => items[_rng.Next(items.Length)];
/// <summary>One of <paramref name="values"/>; with every value kind, one of those or of <paramref name="deliberate"/>.</summary>
private string Value(string[] values, params string[] deliberate)
{
var i = _rng.Next(values.Length + (_everyValueKind ? deliberate.Length : 0));
return i < values.Length ? values[i] : deliberate[i - values.Length];
}
private bool Chance(int percent) => _rng.Next(100) < percent;
private static string Str(string s) => Serialized.GetOrAdd(s, value => System.Text.Json.JsonSerializer.Serialize(value));
private string Ws() => Pick("", "", "", " ", "\n", "\r\n ", "\t");
private string Text() => Value(PlainTexts, DeliberateTexts);
private string Url() => Pick(
Str("https://graph.microsoft.com/beta/next?$skiptoken=abc"), Str("https://graph.microsoft.com/v1.0/x?$top=5"));
private string DateValue() => Value(Dates, DeliberateDates);
/// <summary>A property: (name, json) or (name, null) when the property is left out.</summary>
private (string Name, string? Json) Prop(string name, string json, int missingPercent = 15)
=> (name, Chance(missingPercent) ? null : json);
private string Obj(params (string Name, string? Json)[] props)
{
var parts = props.Where(p => p.Json != null).Select(p => $"{Str(p.Name)}:{Ws()}{p.Json}").ToList();
if (Chance(10))
parts.Insert(_rng.Next(parts.Count + 1), $"{Str("unknownProperty")}:{Pick("1", "\"x\"", "{}", "[]", "null", "true")}");
if (Chance(5) && parts.Count > 0)
{
// Duplicate key with the same value: whichever occurrence wins, the meaning stays.
parts.Insert(0, parts[_rng.Next(parts.Count)]);
}
if (Chance(30))
parts = parts.OrderBy(_ => _rng.Next()).ToList();
var separator = Pick(",", ",", ", ", ",\n ", ", /*c*/ ", ",// line\n");
var trailing = parts.Count > 0 && Chance(5) ? "," : "";
return "{" + Ws() + string.Join(separator, parts) + trailing + Ws() + "}";
}
private string ValueList(Func<string> item)
{
switch (_rng.Next(100))
{
case < 5: return "null";
case < 9: return Pick("\"oops\"", "{}", "42");
case < 14: return "[]";
}
var entries = new List<string>();
var count = _rng.Next(1, 6);
for (var i = 0; i < count; i++)
entries.Add(_rng.Next(100) switch { < 85 => item(), < 90 => "null", < 94 => Pick("1", "\"x\"", "true"), < 97 => "[]", _ => "{}" });
return "[" + Ws() + string.Join("," + Ws(), entries) + Ws() + "]";
}
/// <summary>The body around an object: mostly the object itself, else a root or shape both refuse.</summary>
private string Root(string objectJson)
{
var pick = _rng.Next(100);
LastWasCutOff = pick is >= 92 and < 95;
return pick switch
{
< 82 => Ws() + objectJson + Ws(),
< 84 => "",
< 86 => " ",
< 88 => "null",
< 90 => "[" + objectJson + "]",
< 92 => Pick("42", "\"text\"", "true"),
< 95 => objectJson.Substring(0, Math.Max(1, objectJson.Length / 2)),
< 98 => objectJson + Pick(" x", " {}", " 1"),
_ => Pick("<html>busy</html>", "not json"),
};
}
private string Matching(string target) => Value(
new[] { Str(target), Str(target.ToLowerInvariant()), Str(" " + target + " "), Str(target + "X"), Str("OTHER"), Str(""), "12345", "true" },
"null", "{}", "[" + Str(target) + "]", "\"" + target + "\\ud800\"");
public string TokenBody() => Root(Obj(
Prop("access_token", Value(new[] { Str("eyJ0eXAi.token"), Str("t"), Str(""), "null" }, "42", "{}", "\"t\\ud800\"")),
Prop("expires_in", Value(
new[] { "3599", "0", "-5", "7200", "9223372036854775807", "\"3599\"", "\" 3599 \"", "\"+3599\"", "\"-1\"", "null" },
"\"abc\"", "\"\"", "3599.5", "7e2", "true", "99999999999999999999", "{}", "[]", "\"\\ud800\"")),
Prop("token_type", Str("Bearer"))));
public string EntraBody() => Root(Obj(
Prop("error", Pick(Str("invalid_client"), Str("unauthorized_client"))),
Prop("error_description", Value(
new[]
{
Str("AADSTS7000229: The client application is missing a service principal.\r\nTrace ID: x"),
Str("AADSTS53003: Access has been blocked by Conditional Access policies."), Str("no code here"), Str("AADSTS: no digits"),
Str(new string('d', 400)), Str(" "), "null", "42", "true", "{}", "[]",
},
Str("2026-11-01T08:00:00Z"), "\"AADSTS53003: broken \\ud800\"")),
Prop("error_codes", Value(
new[] { "[7000229]", "[53003,7000229]", "[\"7000229\",53003]", "[7000229.5]", "[7e6]", "[-1]", "[]", "7000229", "null", "[" + _rng.Next(1, 99_999_999) + "]" },
"[99999999999999999999,53003]", "[1e3]")),
Prop("timestamp", Str("2026-11-05 08:00:00Z"))));
public string AutopilotBody(string serial) => Root(Obj(
Prop("@odata.context", Str("https://graph.microsoft.com/v1.0/$metadata#deviceManagement/windowsAutopilotDeviceIdentities")),
Prop("value", ValueList(() => Obj(
Prop("id", Pick(Text(), Str("ap-1"), "null", "42")),
Prop("serialNumber", Matching(serial), 10),
Prop("groupTag", Pick(Text(), "null", "true")),
Prop("purchaseOrderIdentifier", Pick(Text(), "null", "{}")),
Prop("displayName", Pick(Text(), "null", "[]")),
Prop("userPrincipalName", Pick(Text(), "null", Str("u@contoso.com"))))), 5)));
public string CorporateBody()
{
var identifier = Value(
new[]
{
Str("Microsoft Corporation,Virtual Machine,1234-5678"), Str("MICROSOFTCORPORATION,VIRTUALMACHINE,12345678"),
Str("microsoft corporation, virtual machine, 1234 5678"), Str("Other,Model,1"), Str(""), "null", "42",
},
"[" + Str("Microsoft Corporation,Virtual Machine,1234-5678") + "]", "true", "\"Microsoft Corporation,Virtual Machine,1234-5678\\ud800\"");
return Root(Obj(
Prop("value", ValueList(() => Obj(
Prop("importedDeviceIdentityType", Value(new[] { Str("manufacturerModelSerial"), Str("MANUFACTURERMODELSERIAL"), Str("serialNumber"), "null" }, "42")),
Prop("importedDeviceIdentifier", identifier))), 5),
Prop("@odata.nextLink", Value(new[] { Url(), Str(""), "null" }, "5", "true", "{}", "\"https://graph.microsoft.com/beta/next\\ud800\""), 50)));
}
public string AssociationBody(string serial) => Root(Obj(
Prop("value", ValueList(() => Obj(
Prop("serialNumber", Matching(serial), 10),
Prop("associationState", Pick(Str("associated"), Str("preAssociated"), Text())),
Prop("devicePreparationPolicyId", Text()),
Prop("preassociatedByUserPrincipalName", Text()),
Prop("assignedToUserPrincipalName", Text()),
Prop("preassociationDateTime", DateValue()),
Prop("associationDateTime", DateValue()),
Prop("managedDeviceId", Text()))), 5)));
public string CloudPcBody(string intuneId) => Root(Obj(
Prop("value", ValueList(() => Obj(
Prop("id", Text()),
Prop("managedDeviceId", Matching(intuneId), 10),
Prop("managedDeviceName", Text()),
Prop("servicePlanName", Text()))), 5)));
public string ManagedDeviceBody(string intuneId) => Root(Obj(
Prop("id", Matching(intuneId), 10),
Prop("deviceName", Text()),
Prop("azureADDeviceId", Text()),
Prop("managementState", Pick(Str("managed"), Str("retirePending"), Str("WIPEISSUED"), Str("deletePending"), Text())),
Prop("serialNumber", Pick(Text(), "null")),
Prop("managedDeviceOwnerType", Pick(Str("company"), Text(), "null")),
Prop("deviceEnrollmentType", Pick(Str("windowsAzureADJoin"), Text(), "null")),
Prop("azureADRegistered", Value(new[] { "true", "false", "null", "\"true\"", "1" }, "\"false\"", "0", "{}")),
Prop("enrolledDateTime", DateValue())));
public string ScriptPageBody() => Root(Obj(
Prop("value", ValueList(() => Obj(
Prop("id", Value(new[] { Str("s1"), Str("4f9e2c1a-0000-0000-0000-000000000001"), Str(""), "null" }, "42", "{}", "\"s1\\ud800\""), 10),
Prop("displayName", Text()),
Prop("fileName", Text()))), 5),
Prop("@odata.nextLink", Value(new[] { Url(), Str(""), "null" }, "5", "{}", "\"https://graph.microsoft.com/beta/p2\\ud800\""), 50)));
public string ScriptNameBody() => Root(Obj(
Prop("id", Str("s1")),
Prop("displayName", Text()),
Prop("fileName", Text())));
}
@@ -0,0 +1,49 @@
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// What runs while a Global Admin views a tenant as its member (read-only "view as"). The class follows the
/// method — GET reads, everything else writes and is refused — so a new route fails closed. Every exception is
/// listed here with its reason: a POST that only reads, or a GET that starts an action. A new exception needs a
/// deliberate line, and a GET that writes must either be listed or skip its write under view-as in the handler
/// (RecomputeTriggerGate, the consent-status auto-flip).
/// </summary>
public class ViewAsAccessCatalogTests
{
private static readonly IReadOnlyDictionary<string, string> Exceptions = new Dictionary<string, string>(StringComparer.Ordinal)
{
["PUT auth/me/whats-new-seen"] = "the caller's own What's-new marker under the JWT tenant; opening What's new sends it",
["POST realtime/negotiate"] = "a live-update subscription, nothing stored",
["POST realtime/groups/join"] = "a live-update subscription, nothing stored",
["POST realtime/groups/leave"] = "a live-update subscription, nothing stored",
["POST diagnostics/download-ticket"] = "a stateless HMAC ticket for a read",
["POST rules/analyze/dryrun"] = "evaluates a draft rule against a session and persists nothing",
["POST rules/gather/test-pattern"] = "a regex against pasted lines, no tenant data",
["POST tenants/{tenantId}/scripts/display-names"] = "a read sent as POST for its body size",
["POST tenants/{tenantId}/graph-permissions/refresh"] = "clears the in-memory Graph cache only",
["GET config/{tenantId}/autopilot-device-validation/consent-url"] = "starts the admin-consent flow (ops events with the caller's UPN)",
};
private static ViewAsAccess MethodDefault(string method) => method == "GET" ? ViewAsAccess.Read : ViewAsAccess.Write;
[Fact]
public void Every_class_that_differs_from_the_method_default_is_listed_with_a_reason()
{
var actual = EndpointAccessPolicyCatalog.Entries
.Where(e => e.ViewAsAccess != MethodDefault(e.HttpMethod))
.Select(e => $"{e.HttpMethod} {e.RouteTemplate}")
.OrderBy(k => k, StringComparer.Ordinal);
Assert.Equal(Exceptions.Keys.OrderBy(k => k, StringComparer.Ordinal), actual);
}
[Theory]
[InlineData("GET", ViewAsAccess.Read)]
[InlineData("post", ViewAsAccess.Write)]
[InlineData("PUT", ViewAsAccess.Write)]
[InlineData("PATCH", ViewAsAccess.Write)]
[InlineData("DELETE", ViewAsAccess.Write)]
public void An_unclassified_route_follows_its_method(string method, ViewAsAccess expected)
=> Assert.Equal(expected, new EndpointPolicyEntry(method, "some/route", EndpointPolicy.MemberRead).ViewAsAccess);
}
@@ -0,0 +1,167 @@
using System.Security.Claims;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Telemetry;
using AutopilotMonitor.Shared;
using Microsoft.ApplicationInsights.Channel;
using Microsoft.ApplicationInsights.DataContracts;
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The pieces of "view as tenant" outside the policy decision: the header parse, the data scope every handler
/// reads (<see cref="TenantHelper"/>), the recompute gate that guards two GET actions, and the places where the
/// real caller — not the viewed tenant — must count (presence, the request row, every other telemetry item).
/// </summary>
public class ViewAsTests
{
private const string Home = "11111111-1111-1111-1111-111111111111";
private const string Viewed = "22222222-2222-2222-2222-222222222222";
private const string GaUpn = "ga@vendor.example";
private static RequestContext ViewingContext(string role = Constants.TenantRoles.Admin) => new()
{
TenantId = Viewed,
TargetTenantId = Viewed,
UserPrincipalName = GaUpn,
UserRole = role,
IsTenantAdmin = role == Constants.TenantRoles.Admin,
ViewAs = new ViewAsContext(Home, role),
};
// ── header parse ─────────────────────────────────────────────────────────
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void No_tenant_header_is_an_ordinary_request(string? tenantHeader)
=> Assert.Null(ViewAsTarget.FromHeaders(tenantHeader, "Viewer"));
[Theory]
[InlineData("not-a-guid", null)]
[InlineData("00000000-0000-0000-0000-000000000000", null)]
[InlineData(Viewed, "GlobalAdmin")]
[InlineData(Viewed, "Member")]
public void Unusable_headers_are_invalid(string tenantHeader, string? roleHeader)
=> Assert.Same(ViewAsTarget.Invalid, ViewAsTarget.FromHeaders(tenantHeader, roleHeader));
[Theory]
[InlineData(null, Constants.TenantRoles.Admin)]
[InlineData("viewer", Constants.TenantRoles.Viewer)]
[InlineData(" OPERATOR ", Constants.TenantRoles.Operator)]
public void A_tenant_id_is_normalized_and_the_role_defaults_to_admin(string? roleHeader, string expectedRole)
{
var request = ViewAsTarget.FromHeaders(" 22222222-2222-2222-2222-22222222222A ", roleHeader);
Assert.Equal(new ViewAsTarget("22222222-2222-2222-2222-22222222222a", expectedRole), request);
Assert.True(request!.IsValid);
}
// ── data scope and identity ──────────────────────────────────────────────
[Fact]
public void TenantHelper_answers_the_viewed_tenant_under_view_as_and_the_token_tenant_otherwise()
{
var principal = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("tid", Home), new Claim("upn", GaUpn) }, "test"));
Assert.Equal(Viewed, TenantHelper.GetTenantId(Request(principal, ViewingContext())));
Assert.Equal(Home, TenantHelper.GetTenantId(Request(principal, new RequestContext { TenantId = Home })));
Assert.Equal(Home, TenantHelper.GetTenantId(Request(principal, context: null)));
}
[Fact]
public void The_identity_tenant_stays_the_home_tenant()
{
Assert.Equal(Home, ViewingContext().IdentityTenantId());
Assert.Equal(Viewed, new RequestContext { TenantId = Viewed }.IdentityTenantId());
}
[Fact]
public void View_as_never_triggers_a_recompute_although_its_admin_owns_the_viewed_tenant()
{
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ViewingContext(), Viewed));
Assert.True(RecomputeTriggerGate.CanTriggerRecompute(ViewingContext() with { ViewAs = null }, Viewed));
}
// ── counting at the real caller ──────────────────────────────────────────
[Fact]
public void View_as_records_no_presence_in_the_viewed_tenant()
{
Assert.False(UserPresenceMiddleware.Records(ViewingContext()));
Assert.True(UserPresenceMiddleware.Records(ViewingContext() with { ViewAs = null }));
}
[Fact]
public void The_request_row_of_a_view_as_request_counts_at_the_home_tenant_as_global_admin()
{
var ctx = ViewingContext();
var properties = new Dictionary<string, string>();
RequestTelemetryMiddleware.StampCaller(properties, ctx,
RequestTelemetryMiddleware.ResolveTenantId(ctx.IdentityTenantId(), new Dictionary<object, object>(), "/api/sessions", null));
Assert.Equal(Home, properties[RequestRowDimensions.TenantId]);
Assert.Equal(GaUpn, properties[RequestRowDimensions.UserId]);
Assert.Equal(Constants.GlobalRoles.GlobalAdmin, properties[RequestRowDimensions.UserRole]);
Assert.Equal(Viewed, properties[RequestRowDimensions.ViewAsTenantId]);
}
[Fact]
public void An_ordinary_request_row_carries_no_view_as_dimension()
{
var ctx = ViewingContext() with { TenantId = Home, ViewAs = null };
var properties = new Dictionary<string, string>();
RequestTelemetryMiddleware.StampCaller(properties, ctx, ctx.TenantId);
Assert.Equal(Home, properties[RequestRowDimensions.TenantId]);
Assert.Equal(Constants.TenantRoles.Admin, properties[RequestRowDimensions.UserRole]);
Assert.False(properties.ContainsKey(RequestRowDimensions.ViewAsTenantId));
}
[Fact]
public async Task Telemetry_items_are_stamped_only_inside_the_view_as_request_flow()
{
var sink = new CapturingProcessor();
var processor = new ViewAsTelemetryProcessor(sink);
await InViewAsFlow(() => processor.Process(new EventTelemetry("inside")));
processor.Process(new EventTelemetry("outside"));
var inside = (EventTelemetry)sink.Items[0];
var outside = (EventTelemetry)sink.Items[1];
Assert.Equal(Viewed, inside.Properties[RequestRowDimensions.ViewAsTenantId]);
Assert.False(outside.Properties.ContainsKey(RequestRowDimensions.ViewAsTenantId));
}
/// <summary>The scope opens inside an awaited method, like the middleware; it ends with that flow.</summary>
private static async Task InViewAsFlow(Action emit)
{
await Task.Yield();
ViewAsTelemetryProcessor.Enter(Viewed);
emit();
}
private sealed class CapturingProcessor : ITelemetryProcessor
{
public List<ITelemetry> Items { get; } = new();
public void Process(ITelemetry item) => Items.Add(item);
}
private static HttpRequestData Request(ClaimsPrincipal principal, RequestContext? context)
{
var items = new Dictionary<object, object> { ["ClaimsPrincipal"] = principal };
if (context != null)
items[RequestContext.ItemsKey] = context;
var functionContext = new Mock<FunctionContext>();
functionContext.SetupGet(c => c.Items).Returns(items);
return new Mock<HttpRequestData>(functionContext.Object).Object;
}
}
@@ -39,23 +39,26 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="Azure.Monitor.Query" Version="1.5.0" />
<PackageReference Include="Azure.Monitor.Query" Version="1.7.1" />
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.23.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.SignalRService" Version="2.0.1" />
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.33.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.52.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.51.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.SignalRService" Version="2.1.0" />
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.33.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Timer" Version="4.3.1" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" Version="5.5.4" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<PackageReference Include="Azure.Data.Tables" Version="12.11.0" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
<PackageReference Include="Azure.Storage.Queues" Version="12.25.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" Version="5.5.5" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.1.0" />
<PackageReference Include="Azure.Data.Tables" Version="12.13.0" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.30.1" />
<PackageReference Include="Azure.Storage.Queues" Version="12.28.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
<PackageReference Include="Microsoft.Identity.Web" Version="4.16.1" />
<!-- Transitive via Microsoft.Identity.Web's token cache, lifted past GHSA-846f-f764-76mx and
GHSA-2fmv-rv2c-59x7. Remove once Microsoft.Identity.Web is 4.17.0 or later (it brings 10.0.13). -->
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.14" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.14" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.14" />
</ItemGroup>
<ItemGroup>
@@ -88,44 +88,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return records;
}
public async Task<List<UserUsageRecord>> GetUsageByTenantAsync(string tenantId, string? dateFrom = null, string? dateTo = null)
{
var filter = BuildTenantUsageFilter(tenantId, dateFrom, dateTo);
var records = new List<UserUsageRecord>();
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
{
records.Add(MapToRecord(entity));
}
return records;
}
public async Task<List<UserUsageDailySummary>> GetDailySummaryAsync(string? tenantId = null, string? dateFrom = null, string? dateTo = null)
{
var filter = BuildTenantUsageFilter(tenantId, dateFrom, dateTo);
var records = new List<UserUsageRecord>();
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
{
records.Add(MapToRecord(entity));
}
var grouped = records
.GroupBy(r => r.Date)
.Select(g => new UserUsageDailySummary
{
Date = g.Key,
TenantId = tenantId,
TotalRequests = g.Sum(r => r.RequestCount),
UniqueUsers = g.Select(r => r.UserId).Distinct().Count(),
UniqueEndpoints = g.Select(r => r.Endpoint).Distinct().Count(),
})
.OrderByDescending(s => s.Date)
.ToList();
return grouped;
}
/// <summary>
/// Builds the OData filter for a per-user usage query. <paramref name="userId"/> is a route
/// segment (AAD oid) and the dates are query params, so all values MUST be OData-escaped before
@@ -135,42 +97,15 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
internal static string BuildUserUsageFilter(string userId, string? dateFrom, string? dateTo)
{
var filter = $"PartitionKey eq '{ODataSanitizer.EscapeValue(userId)}'";
return AppendDateFilter(filter, dateFrom, dateTo);
}
/// <summary>
/// Builds the OData filter for a per-tenant usage query. Same escaping requirement as
/// <see cref="BuildUserUsageFilter"/>; returns null when no tenant/date scope is supplied.
/// </summary>
internal static string? BuildTenantUsageFilter(string? tenantId, string? dateFrom, string? dateTo)
{
string? filter = !string.IsNullOrEmpty(tenantId)
? $"TenantId eq '{ODataSanitizer.EscapeValue(tenantId)}'"
: null;
return AppendDateFilter(filter, dateFrom, dateTo);
}
private static string AppendDateFilter(string? existingFilter, string? dateFrom, string? dateTo)
{
var filter = existingFilter ?? "";
// .Replace("-","") only normalizes the date shape; it does NOT neutralize single quotes,
// so the value must still be OData-escaped before interpolation.
if (!string.IsNullOrEmpty(dateFrom))
{
// .Replace("-","") only normalizes the date shape; it does NOT neutralize single quotes,
// so the value must still be OData-escaped before interpolation.
var dateVal = ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""));
var clause = $"Date ge '{dateVal}'";
filter = string.IsNullOrEmpty(filter) ? clause : $"{filter} and {clause}";
}
filter += $" and Date ge '{ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""))}'";
if (!string.IsNullOrEmpty(dateTo))
{
var dateVal = ODataSanitizer.EscapeValue(dateTo.Replace("-", ""));
var clause = $"Date le '{dateVal}'";
filter = string.IsNullOrEmpty(filter) ? clause : $"{filter} and {clause}";
}
filter += $" and Date le '{ODataSanitizer.EscapeValue(dateTo.Replace("-", ""))}'";
return string.IsNullOrEmpty(filter) ? null! : filter;
return filter;
}
public async Task<int> DeleteRecordsOlderThanAsync(string dateCutoff)
@@ -251,20 +186,28 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return records;
await foreach (var entity in _tenantTableClient.QueryAsync<TableEntity>(filter: BuildTenantCounterFilter(tenantId, dateFrom, dateTo)))
{
records.Add(new TenantUsageRecord
{
TenantId = entity.PartitionKey,
UserId = entity.GetString("UserId") ?? string.Empty,
UserPrincipalName = entity.GetString("UserPrincipalName") ?? string.Empty,
Date = entity.GetString("Date") ?? entity.RowKey.Split('_')[0],
RequestCount = entity.TryGetValue("RequestCount", out var rc) ? Convert.ToInt64(rc) : 0L,
LastRequestAt = entity.GetDateTimeOffset("LastRequestAt")?.UtcDateTime,
});
}
records.Add(MapTenantRecord(entity));
return records;
}
public async Task<List<TenantUsageRecord>> GetAllTenantUsageAsync(string dateFrom, string dateTo)
{
var records = new List<TenantUsageRecord>();
await foreach (var entity in _tenantTableClient.QueryAsync<TableEntity>(filter: BuildDayRangeFilter(dateFrom, dateTo)))
records.Add(MapTenantRecord(entity));
return records;
}
private static TenantUsageRecord MapTenantRecord(TableEntity entity) => new()
{
TenantId = entity.PartitionKey,
UserId = entity.GetString("UserId") ?? string.Empty,
UserPrincipalName = entity.GetString("UserPrincipalName") ?? string.Empty,
Date = entity.GetString("Date") ?? entity.RowKey.Split('_')[0],
RequestCount = entity.TryGetValue("RequestCount", out var rc) ? Convert.ToInt64(rc) : 0L,
LastRequestAt = entity.GetDateTimeOffset("LastRequestAt")?.UtcDateTime,
};
/// <summary>
/// Partition + RowKey range filter for McpTenantUsage: the RowKey starts with the yyyyMMdd day, so a
/// date window is a key range ("{to}~" sorts after every "{to}_{oid}"); no property scan.
@@ -272,11 +215,19 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
internal static string BuildTenantCounterFilter(string tenantId, string? dateFrom, string? dateTo)
{
var filter = $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'";
var days = BuildDayRangeFilter(dateFrom, dateTo);
return days.Length == 0 ? filter : $"{filter} and {days}";
}
/// <summary>The RowKey day range of McpTenantUsage without a partition — empty when neither bound is set.</summary>
internal static string BuildDayRangeFilter(string? dateFrom, string? dateTo)
{
var clauses = new List<string>(2);
if (!string.IsNullOrEmpty(dateFrom))
filter += $" and RowKey ge '{ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""))}'";
clauses.Add($"RowKey ge '{ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""))}'");
if (!string.IsNullOrEmpty(dateTo))
filter += $" and RowKey lt '{ODataSanitizer.EscapeValue(dateTo.Replace("-", ""))}~'";
return filter;
clauses.Add($"RowKey lt '{ODataSanitizer.EscapeValue(dateTo.Replace("-", ""))}~'");
return string.Join(" and ", clauses);
}
public async Task<int> DeleteTenantRecordsOlderThanAsync(string dateCutoff)
@@ -162,8 +162,7 @@ public class AutopilotDeviceValidationConsentFunction
// here (flag-gated, probe-verified, idempotent) makes the status below resolve the newly
// homed app naturally. Unbudgeted on purpose: this runs in the post-redirect callback,
// where the consent-propagation retry chain is exactly what we want to ride.
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
var flip = await _appHomingService.TryAutoFlipToPrimaryAsync(tenantConfig, requestCtx.UserPrincipalName);
var flip = await TryAutoFlipAsync(requestCtx);
var result = await _graphTokenService.GetConsentStatusAsync(requestCtx.TargetTenantId);
@@ -237,9 +236,7 @@ public class AutopilotDeviceValidationConsentFunction
// Consent-driven homing auto-flip BEFORE the fresh snapshot: the "detect existing
// access" / reconcile path never touches consent-status, so this is its flip hook. A
// budget-exhausted probe is transient ⇒ no flip; the frontend already renders "retry".
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
flip = await _appHomingService.TryAutoFlipToPrimaryAsync(
tenantConfig, requestCtx.UserPrincipalName, budgetCts.Token);
flip = await TryAutoFlipAsync(requestCtx, budgetCts.Token);
GraphPermissionSnapshot snapshot;
try
@@ -276,6 +273,19 @@ public class AutopilotDeviceValidationConsentFunction
return response;
}
/// <summary>
/// The consent-driven homing auto-flip. It writes the tenant's homing with the caller as actor plus an
/// audit entry, so it never runs while a Global Admin only views the tenant (read-only "view as").
/// </summary>
private async Task<AppHomingAutoFlipResult> TryAutoFlipAsync(RequestContext requestCtx, CancellationToken ct = default)
{
if (requestCtx.ViewAs != null)
return new AppHomingAutoFlipResult(AppHomingAutoFlipOutcome.NotEligible);
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
return await _appHomingService.TryAutoFlipToPrimaryAsync(tenantConfig, requestCtx.UserPrincipalName, ct);
}
/// <summary>
/// Pure decision for the access-check probe: does the SP snapshot prove the core validation
/// role is effectively granted? Extracted so the reconcile decision is unit testable without
@@ -110,6 +110,11 @@ public class AuthFunction
return await req.BadRequestAsync("Missing required claims");
}
// A Global Admin viewing a tenant as its member: that tenant's standing, none of the sign-in side effects.
var requestContext = req.GetRequestContext();
if (requestContext.ViewAs != null)
return await GetViewAsUserAsync(req, principal, requestContext, upn, displayName, objectId);
// --- Parallel data fetch: all independent queries run concurrently ---
// Fail-fast: if any fetch throws, AggregateException propagates → Azure Functions returns 500.
// This is intentional — the auth-decision queries are required; the What's-new marker read
@@ -231,6 +236,53 @@ public class AuthFunction
return response;
}
/// <summary>
/// auth/me while a Global Admin views a tenant as its member: the viewed tenant's standing (suspension,
/// activation, Terms + DPA, plan) for the emulated member, through the same <see cref="BuildAuthResult"/>.
/// None of the sign-in side effects run — onboarding, re-enable, client-id tracking, auto-admin, the login
/// record and the push sign-in belong to the tenant's own members. Per-user state stays the caller's own:
/// What's-new marks, MCP access and the homed app registration come from the home tenant.
/// </summary>
private async Task<HttpResponseData> GetViewAsUserAsync(
HttpRequestData req, System.Security.Claims.ClaimsPrincipal principal, RequestContext ctx,
string upn, string? displayName, string? objectId)
{
var viewedTenantId = ctx.TenantId;
var homeTenantId = ctx.ViewAs!.HomeTenantId;
var viewedConfigTask = _tenantConfigService.TryGetConfigurationAsync(viewedTenantId);
var homeConfigTask = _tenantConfigService.TryGetConfigurationAsync(homeTenantId);
var isApprovedTask = _previewWhitelistService.IsApprovedAsync(viewedTenantId);
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, homeTenantId, objectId, principal.GetAppRoles());
var whatsNewSeenTask = _metricsRepo.GetUserWhatsNewSeenAsync(homeTenantId, upn);
await Task.WhenAll(viewedConfigTask, homeConfigTask, isApprovedTask, mcpCheckTask, whatsNewSeenTask);
var (viewedConfig, _) = viewedConfigTask.Result;
var (homeConfig, _) = homeConfigTask.Result;
var whatsNewSeen = whatsNewSeenTask.Result;
var decision = BuildAuthResult(
viewedConfig, isGlobalAdmin: false, isGlobalReader: false, isApprovedTask.Result,
new MemberRoleInfo { Role = ctx.ViewAs.Role }, mcpCheckTask.Result, hasTenantAdmins: true,
viewedTenantId, upn, displayName ?? string.Empty, objectId ?? string.Empty,
delegatedTenantIds: null,
homedApp: _appRegistry.ResolveForTenant(homeConfig).IsLegacy ? "legacy" : "primary",
whatsNewSeenPlatformUtc: whatsNewSeen.PlatformUtc,
whatsNewSeenAgentUtc: whatsNewSeen.AgentUtc,
mcpClientRegistrationEnabled: (await _adminConfigService.GetConfigurationAsync())?.McpClientRegistrationEnabled ?? false,
viewAs: new AuthMeViewAs
{
TenantId = viewedTenantId,
TenantName = TenantDisplayName.Of(viewedConfig, viewedTenantId),
Role = ctx.ViewAs.Role,
HomeTenantId = homeTenantId,
});
var response = req.CreateResponse(decision.StatusCode);
await response.WriteAsJsonAsync(decision.Body);
return response;
}
/// <summary>
/// PUT /api/auth/me/whats-new-seen
/// Marks the caller's What's-new channel as seen.
@@ -733,7 +785,8 @@ public class AuthFunction
DateTime? whatsNewSeenPlatformUtc = null,
DateTime? whatsNewSeenAgentUtc = null,
bool mcpClientRegistrationEnabled = false,
bool isExternalIdentity = false)
bool isExternalIdentity = false,
AuthMeViewAs? viewAs = null)
{
// A delegated ("MSP") admin manages a subset of OTHER tenants. They are explicitly authorized, so —
// like a Global Admin / Reader — they bypass the private-preview gate even when their own home tenant
@@ -836,7 +889,8 @@ public class AuthFunction
McpClientRegistrationEnabled = mcpClientRegistrationEnabled,
WhatsNewSeenPlatformUtc = whatsNewSeenPlatformUtc,
WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc,
DpaAcceptancePending = tenantConfig.DpaAcceptancePending
DpaAcceptancePending = tenantConfig.DpaAcceptancePending,
ViewAs = viewAs
}, needsAutoAdmin);
}
@@ -56,10 +56,11 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
// revocation enforceable, and only inside the tenant whose grant was revoked
// (LiveStreamRevoker). Both values come from the middleware-validated JWT, never from
// client-controlled data. Without a principal key the connection has no user id: every
// revocable join gate needs one.
// revocable join gate needs one. Under view-as the binding stays the sign-in tenant
// (IdentityTenantId): removing a Global Admin must cut their view-as tab as well.
var requestCtx = req.GetRequestContext();
var principalKey = string.IsNullOrEmpty(requestCtx.UserPrincipalName) ? null : requestCtx.UserPrincipalName;
var negotiation = await _signalRService.NegotiateClientAsync(requestCtx.TenantId, principalKey);
var negotiation = await _signalRService.NegotiateClientAsync(requestCtx.IdentityTenantId(), principalKey);
if (negotiation == null)
{
return await req.ErrorAsync(HttpStatusCode.ServiceUnavailable, Constants.ApiErrorCodes.ServiceUnavailable, "SignalR negotiation unavailable");
@@ -22,17 +22,70 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
private readonly IUserUsageRepository _userUsageRepo;
private readonly McpUserService _mcpUserService;
private readonly McpQuotaService _quotaService;
private readonly McpUsageOverviewService _overviewService;
public McpUsageMetricsFunction(
ILogger<McpUsageMetricsFunction> logger,
IUserUsageRepository userUsageRepo,
McpUserService mcpUserService,
McpQuotaService quotaService)
McpQuotaService quotaService,
McpUsageOverviewService overviewService)
{
_logger = logger;
_userUsageRepo = userUsageRepo;
_mcpUserService = mcpUserService;
_quotaService = quotaService;
_overviewService = overviewService;
}
/// <summary>
/// GET /api/global/metrics/mcp-usage/overview?days=&amp;platformAdmins= — the all-tenant MCP usage overview of
/// the Global Admin metrics page: tenants with accounts and quota state, the tool ranking, quota refusals,
/// per-minute peaks and the daily trend (see <see cref="McpUsageOverviewService"/>). <c>days</c> = 1–90 (the
/// telemetry keeps 90 days), default 30; <c>platformAdmins=include</c> counts Global Admins and Global Readers,
/// left out by default.
/// </summary>
[Function("GetGlobalMcpUsageOverview")]
public async Task<HttpResponseData> GetGlobalUsageOverview(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage/overview")] HttpRequestData req)
{
var query = ParseOverviewQuery(req.Query["days"], req.Query["platformAdmins"]);
if (query.Error != null)
{
return await req.BadRequestAsync(query.Error);
}
try
{
var overview = await _overviewService.BuildAsync(query.Days, query.IncludePlatformAdmins);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(overview);
return response;
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
}
}
/// <summary>
/// Pure: the overview's query string. <c>days</c> defaults to 30 and must be an integer from 1 to
/// <see cref="McpUsageOverviewService.MaxDays"/>; <c>platformAdmins</c> is <c>include</c> or <c>exclude</c>
/// (the default). Anything else names the problem in <c>Error</c>.
/// </summary>
internal static (int Days, bool IncludePlatformAdmins, string? Error) ParseOverviewQuery(string? rawDays, string? platformAdmins)
{
if (!QueryParams.TryInt(rawDays, "days", 1, McpUsageOverviewService.MaxDays, out var days, out var error))
{
return (0, false, error);
}
if (!string.IsNullOrWhiteSpace(platformAdmins) && platformAdmins is not ("include" or "exclude"))
{
return (0, false, "platformAdmins must be include or exclude");
}
return (days ?? 30, platformAdmins == "include", null);
}
/// <summary>
@@ -65,7 +118,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
// Effective quota state: resolved plan (per-user override → tenant edition),
// limits (SectionUsagePlans definition → catalog fallback) and current counters.
var quota = await _quotaService.CheckAsync(userId, upn, tenantId);
// A read: viewing the page is not an MCP request and must not count as one.
var quota = await _quotaService.ReadStateAsync(userId, upn, tenantId);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new GetMyMcpUsageResponse
@@ -86,11 +140,11 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
TenantMonthlyLimit = quota.TenantMonthlyLimit,
TenantDailyUsed = quota.TenantDailyUsed,
TenantMonthlyUsed = quota.TenantMonthlyUsed,
PurchasedDelegatedSlots = WithSlots(quota.PurchasedDelegatedSlots, quota.PurchasedDelegatedSlots),
SlotDailyLimit = WithSlots(quota.PurchasedDelegatedSlots, quota.SlotDailyLimit),
SlotMonthlyLimit = WithSlots(quota.PurchasedDelegatedSlots, quota.SlotMonthlyLimit),
SlotTenantDailyLimit = WithSlots(quota.PurchasedDelegatedSlots, quota.SlotTenantDailyLimit),
SlotTenantMonthlyLimit = WithSlots(quota.PurchasedDelegatedSlots, quota.SlotTenantMonthlyLimit),
PurchasedDelegatedSlots = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.PurchasedDelegatedSlots),
SlotDailyLimit = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.SlotDailyLimit),
SlotMonthlyLimit = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.SlotMonthlyLimit),
SlotTenantDailyLimit = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.SlotTenantDailyLimit),
SlotTenantMonthlyLimit = McpQuotaNodes.WithSlots(quota.PurchasedDelegatedSlots, quota.SlotTenantMonthlyLimit),
},
Records = records
});
@@ -164,7 +218,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
DateFrom = dateFrom,
DateTo = dateTo,
Users = AggregateOrganizationUsage(records, dateFrom, dateTo, today, monthStart),
Quota = BuildOrganizationQuota(records, limits, today, monthStart),
Quota = McpQuotaNodes.BuildOrganizationQuota(records, limits, today, monthStart),
Daily = BuildOrganizationDaily(records, dateFrom, dateTo),
});
return response;
@@ -190,37 +244,6 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return byDay.Select(kv => new McpOrganizationDailyItem { Date = kv.Key, Requests = kv.Value }).ToList();
}
/// <summary>
/// Pure: the tenant's organization windows from the same counter rows — today's and this month's total over
/// every account charged to the tenant, against the tenant plan's limits (0 = unlimited).
/// </summary>
internal static McpOrganizationQuotaNode BuildOrganizationQuota(
IEnumerable<TenantUsageRecord> records, McpQuotaService.TenantPlanLimits limits, string today, string monthStart)
{
long dailyUsed = 0, monthlyUsed = 0;
foreach (var record in records)
{
if (record.Date == today) dailyUsed += record.RequestCount;
if (string.CompareOrdinal(record.Date, monthStart) >= 0 && string.CompareOrdinal(record.Date, today) <= 0)
monthlyUsed += record.RequestCount;
}
return new McpOrganizationQuotaNode
{
TenantPlan = limits.TenantPlan,
DailyLimit = limits.TenantDailyLimit,
MonthlyLimit = limits.TenantMonthlyLimit,
DailyUsed = dailyUsed,
MonthlyUsed = monthlyUsed,
PurchasedDelegatedSlots = WithSlots(limits.PurchasedDelegatedSlots, limits.PurchasedDelegatedSlots),
SlotDailyLimit = WithSlots(limits.PurchasedDelegatedSlots, limits.SlotTenantDailyLimit),
SlotMonthlyLimit = WithSlots(limits.PurchasedDelegatedSlots, limits.SlotTenantMonthlyLimit),
};
}
/// <summary>The slot breakdown is only worth sending when a slot was actually purchased.</summary>
private static int? WithSlots(int purchasedSlots, int value) => purchasedSlots > 0 ? value : null;
/// <summary>
/// Pure: folds the organization counter rows into one item per account, ordered by this month's usage.
/// </summary>
@@ -318,59 +341,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
}
}
/// <summary>
/// GET /api/global/metrics/mcp-usage?tenantId=&amp;dateFrom=&amp;dateTo= — Global usage
/// </summary>
[Function("GetGlobalMcpUsage")]
public async Task<HttpResponseData> GetGlobalUsage(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage")] HttpRequestData req)
{
_logger.LogInformation("Global MCP usage requested");
try
{
var tenantId = req.Query["tenantId"];
var dateFrom = req.Query["dateFrom"];
var dateTo = req.Query["dateTo"];
var records = await _userUsageRepo.GetUsageByTenantAsync(tenantId ?? "", dateFrom, dateTo);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new GetGlobalMcpUsageResponse { TenantId = tenantId, Records = records });
return response;
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
}
}
/// <summary>
/// GET /api/global/metrics/mcp-usage/daily?tenantId=&amp;dateFrom=&amp;dateTo= — Daily summaries
/// </summary>
[Function("GetGlobalMcpUsageDaily")]
public async Task<HttpResponseData> GetGlobalUsageDaily(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/metrics/mcp-usage/daily")] HttpRequestData req)
{
_logger.LogInformation("Global MCP daily usage requested");
try
{
var tenantId = req.Query["tenantId"];
var dateFrom = req.Query["dateFrom"];
var dateTo = req.Query["dateTo"];
var summaries = await _userUsageRepo.GetDailySummaryAsync(tenantId, dateFrom, dateTo);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new GetGlobalMcpUsageDailyResponse { TenantId = tenantId, Summaries = summaries });
return response;
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, "McpUsageMetrics");
}
}
}
}
@@ -21,7 +21,10 @@ namespace AutopilotMonitor.Functions.Functions.Raw
/// </summary>
public class AccessProbeFunction
{
[Function("GlobalAccessProbe")]
/// <summary>Its request rows carry a tool name for a call that never ran; usage counts leave them out by it.</summary>
internal const string FunctionName = "GlobalAccessProbe";
[Function(FunctionName)]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/access-probe")] HttpRequestData req,
FunctionContext context)
@@ -1,15 +1,9 @@
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 AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared;
using Azure.Core;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -43,19 +37,16 @@ namespace AutopilotMonitor.Functions.Functions.Raw
private readonly ILogger<AppInsightsQueryFunction> _logger;
private readonly LogQuerySourceCatalog _sources;
private readonly IHttpClientFactory _httpClientFactory;
private readonly TokenCredential _credential;
private readonly LogQueryExecutor _executor;
public AppInsightsQueryFunction(
ILogger<AppInsightsQueryFunction> logger,
LogQuerySourceCatalog sources,
IHttpClientFactory httpClientFactory,
TokenCredential credential)
LogQueryExecutor executor)
{
_logger = logger;
_sources = sources;
_httpClientFactory = httpClientFactory;
_credential = credential;
_executor = executor;
}
/// <summary>
@@ -88,63 +79,49 @@ namespace AutopilotMonitor.Functions.Functions.Raw
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 outcome = await _executor.ExecuteAsync(source!, body.Query, timespan, 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)
if (!outcome.Succeeded)
{
var upstreamStatus = outcome.UpstreamStatus ?? HttpStatusCode.BadGateway;
var responseText = outcome.UpstreamBody ?? string.Empty;
_logger.LogWarning("Log query ({Source}) failed: {StatusCode} {Response}",
source.Name, upstreamResponse.StatusCode, LogSanitizer.Clean(Cap(responseText, 2048)));
source!.Name, upstreamStatus, LogSanitizer.Clean(Cap(responseText, 2048)));
var (errorMessage, errorCode) = ExtractError(responseText);
var (errorMessage, errorCode) = LogQueryExecutor.ExtractError(responseText);
// A query the store rejected is the CALLER's error (400): the MCP renders 4xx bodies in
// full (message, hint, upstream) but sanitizes every 5xx down to the error code — and a
// KQL syntax error hidden behind "retry later" is exactly the parity loss this proxy
// exists to avoid. Store-side failures (5xx) and a missing read grant (401/403 — the
// managed identity, not the caller) stay 502.
var status = MapUpstreamFailure(upstreamResponse.StatusCode);
var status = MapUpstreamFailure(upstreamStatus);
return await req.ErrorAsync(status, new QueryBackendLogsErrorResponse
{
Error = errorMessage ?? "The telemetry store rejected the query.",
Code = ApiErrorWriter.DefaultCode(status),
UpstreamCode = errorCode,
StatusCode = (int)upstreamResponse.StatusCode,
StatusCode = (int)upstreamStatus,
Source = source.Name,
Hint = UpstreamHint(upstreamResponse.StatusCode, errorCode, source.Name),
Hint = UpstreamHint(upstreamStatus, errorCode, source.Name),
// Everything the store said, so nothing the CLI would print is lost.
Upstream = Cap(responseText, UpstreamErrorCap),
});
}
var (tables, partialReason) = ParseKustoBody(responseText);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new QueryBackendLogsResponse
{
Success = true,
Source = source.Name,
Source = source!.Name,
Timespan = timespan,
BudgetSeconds = budget,
ElapsedMs = stopwatch.ElapsedMilliseconds,
Partial = partialReason != null ? true : null,
PartialReason = partialReason,
Tables = tables,
ElapsedMs = outcome.ElapsedMs,
Partial = outcome.PartialReason != null ? true : null,
PartialReason = outcome.PartialReason,
Tables = outcome.Tables!,
});
return response;
}
@@ -196,110 +173,6 @@ namespace AutopilotMonitor.Functions.Functions.Raw
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] + "…";
/// <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);
}
}
@@ -63,7 +63,7 @@ namespace AutopilotMonitor.Functions.Functions.Rules
// keeps a read-only GlobalReader from steering cross-tenant writes).
if (reanalyze && !RecomputeTriggerGate.CanTriggerRecompute(requestCtx, effectiveTenantId))
{
return await req.ForbiddenAsync("Your role does not permit triggering re-analysis.");
return await RecomputeTriggerGate.RefuseAsync(req, requestCtx, "Your role does not permit triggering re-analysis.");
}
// Rules whose StoreRuleResultAsync returned false during the on-demand reanalyze.
@@ -63,7 +63,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
// gate it to write-capable callers (see RecomputeTriggerGate).
if (rescan && !RecomputeTriggerGate.CanTriggerRecompute(requestCtx, effectiveTenantId))
{
return await req.ForbiddenAsync("Your role does not permit triggering a vulnerability re-scan.");
return await RecomputeTriggerGate.RefuseAsync(req, requestCtx, "Your role does not permit triggering a vulnerability re-scan.");
}
if (rescan)
@@ -1,4 +1,6 @@
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using Microsoft.Azure.Functions.Worker.Http;
namespace AutopilotMonitor.Functions.Helpers;
@@ -16,9 +18,13 @@ public static class RecomputeTriggerGate
/// True when the caller may trigger a recompute for <paramref name="effectiveTenantId"/>:
/// a Global Admin anywhere, or an OWN-TENANT Admin/Operator. Viewer, Global Reader, and
/// delegated (read-tier) callers are excluded, as is any tenant role applied cross-tenant.
/// View as tenant is read-only, and its emulated Admin would pass every other check.
/// </summary>
public static bool CanTriggerRecompute(RequestContext ctx, string effectiveTenantId)
{
if (ctx.ViewAs != null)
return false;
if (ctx.IsGlobalAdmin)
return true;
@@ -26,4 +32,10 @@ public static class RecomputeTriggerGate
return isOwnTenant
&& (ctx.UserRole == Constants.TenantRoles.Admin || ctx.UserRole == Constants.TenantRoles.Operator);
}
/// <summary>The 403 for a refused trigger: the view-as answer, else the role message of the route.</summary>
public static Task<HttpResponseData> RefuseAsync(HttpRequestData req, RequestContext ctx, string roleMessage)
=> ctx.ViewAs != null
? req.ForbiddenAsync(ViewAsTarget.ReadOnlyMessage, Constants.ApiErrorCodes.ViewAsReadOnly)
: req.ForbiddenAsync(roleMessage);
}
@@ -14,7 +14,11 @@ public sealed record RequestContext
{
internal const string ItemsKey = "RequestContext";
/// <summary>The user's Azure AD tenant ID (from JWT tid claim).</summary>
/// <summary>
/// The tenant the request acts in: the user's Azure AD tenant ID (JWT tid claim), or the viewed tenant
/// under <see cref="ViewAs"/>. Where the caller's IDENTITY is meant (bindings, presence, attribution),
/// use <see cref="RequestContextExtensions.IdentityTenantId"/>.
/// </summary>
public string TenantId { get; init; } = string.Empty;
/// <summary>The user's UPN (from JWT upn/preferred_username claim). Display/audit/row-key use — mutable.</summary>
@@ -111,13 +115,30 @@ public sealed record RequestContext
/// Handlers should use this for data access instead of extracting tenantId from route params manually.
/// </summary>
public string TargetTenantId { get; init; } = string.Empty;
/// <summary>
/// Set when a Global Admin views <see cref="TenantId"/> as one of its members (read-only "view as"):
/// every other field then describes that emulated member, while this keeps the real caller's home
/// tenant. Null on every ordinary request.
/// </summary>
public ViewAsContext? ViewAs { get; init; }
}
/// <summary>The real caller behind a view-as request: its home tenant (JWT tid) and the emulated tenant role.</summary>
public sealed record ViewAsContext(string HomeTenantId, string Role);
/// <summary>
/// Extension methods for accessing the resolved RequestContext.
/// </summary>
public static class RequestContextExtensions
{
/// <summary>
/// The tenant the caller's identity is bound to: the JWT tenant, also under view-as. Use it where the
/// caller is identified (SignalR user id, presence, telemetry attribution), never for the data scope.
/// </summary>
public static string IdentityTenantId(this RequestContext ctx)
=> ctx.ViewAs?.HomeTenantId ?? ctx.TenantId;
/// <summary>
/// Gets the resolved RequestContext from FunctionContext.Items.
/// Populated by PolicyEnforcementMiddleware for authenticated requests.
@@ -0,0 +1,28 @@
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// Names of the request-row dimensions <c>RequestTelemetryMiddleware</c> writes on every API call that the
/// backend itself reads back with KQL (the MCP usage overview). Writer and reader use these constants, so a
/// rename cannot silently empty a query.
/// </summary>
internal static class RequestRowDimensions
{
public const string Source = "Source";
/// <summary>The value of <see cref="Source"/> on the worker's canonical request row.</summary>
public const string WorkerMiddleware = "WorkerMiddleware";
public const string FunctionName = "FunctionName";
public const string HttpMethod = "HttpMethod";
public const string HttpPath = "HttpPath";
public const string ClientSource = "ClientSource";
public const string McpToolName = "McpToolName";
public const string CorrelationId = "CorrelationId";
public const string TenantId = "TenantId";
/// <summary>The caller's UPN, or <c>app:&lt;client-id&gt;</c> for an application principal.</summary>
public const string UserId = "UserId";
public const string UserRole = "UserRole";
/// <summary>
/// The tenant a Global Admin viewed as its member (read-only "view as"). Set on the request row and on every
/// item the request emits; TenantId/UserRole of such a row stay the real caller's, never the viewed tenant's.
/// </summary>
public const string ViewAsTenantId = "ViewAsTenantId";
}
@@ -44,6 +44,11 @@ public static class TenantHelper
public static string GetTenantId(HttpRequestData req)
{
// A view-as request acts in the viewed tenant; only PolicyEnforcementMiddleware sets it, for a Global Admin.
var context = req.FunctionContext.GetRequestContext();
if (context.ViewAs != null)
return context.TenantId;
var user = GetAuthenticatedPrincipal(req);
if (user?.Identity?.IsAuthenticated != true)
@@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// Reads Microsoft Graph and Entra token-endpoint responses with System.Text.Json (D-420). The members keep
/// the outcomes the former Newtonsoft code had for every realistic body; GraphResponseParsingTests holds the
/// contract, including the deliberate differences.
/// </summary>
internal static class UpstreamJson
{
// Newtonsoft skipped comments and accepted trailing commas; keep both.
private static readonly JsonDocumentOptions ReadOptions = new()
{
AllowTrailingCommas = true,
CommentHandling = JsonCommentHandling.Skip,
};
/// <summary>
/// The body's root object, or null for an empty or blank body and for a JSON null root. Malformed JSON,
/// trailing content and any other root kind throw <see cref="JsonException"/>, which is what the callers'
/// existing catch blocks expect.
/// </summary>
internal static JsonElement? ParseObjectOrNull(string? body)
{
if (string.IsNullOrWhiteSpace(body))
return null;
using var document = JsonDocument.Parse(body, ReadOptions);
var root = document.RootElement;
return root.ValueKind switch
{
JsonValueKind.Object => root.Clone(),
JsonValueKind.Null => null,
_ => throw new JsonException($"Expected a JSON object, got {root.ValueKind}."),
};
}
/// <summary>
/// The property's text; null when it is absent, JSON null, not a string, or text whose escapes are no valid
/// UTF-16. <paramref name="obj"/> must be an object: a list entry of another kind throws
/// <see cref="InvalidOperationException"/>, which the validators turn into a transient result, as before.
/// </summary>
internal static string? GetString(JsonElement obj, string name)
=> obj.TryGetProperty(name, out var value) ? TextOf(value) : null;
// A lone surrogate escape such as \ud800 makes GetString throw; it reads as absent like any other unusable
// value, so one corrupt field cannot turn a whole lookup transient.
private static string? TextOf(JsonElement value)
{
if (value.ValueKind != JsonValueKind.String)
return null;
try
{
return value.GetString();
}
catch (InvalidOperationException)
{
return null;
}
}
/// <summary>The entries of an array property, of any kind; empty when it is absent or not an array.</summary>
internal static IEnumerable<JsonElement> Items(JsonElement obj, string name)
=> obj.TryGetProperty(name, out var array) && array.ValueKind == JsonValueKind.Array
? array.EnumerateArray()
: Enumerable.Empty<JsonElement>();
/// <summary>
/// The page's <c>@odata.nextLink</c>; null when absent or JSON null. Any other value, text that is no valid
/// UTF-16 included, throws <see cref="JsonException"/>, so a scan never ends as complete on a link it cannot
/// follow.
/// </summary>
internal static string? GetNextLink(JsonElement page)
{
if (!page.TryGetProperty("@odata.nextLink", out var link) || link.ValueKind == JsonValueKind.Null)
return null;
return TextOf(link) ?? throw new JsonException($"@odata.nextLink is no readable text ({link.ValueKind}).");
}
/// <summary>A whole number, or a string holding one (Entra v1 and ADFS send expires_in as text); else null.</summary>
internal static long? GetInt64(JsonElement obj, string name)
{
if (!obj.TryGetProperty(name, out var value))
return null;
return value.ValueKind switch
{
JsonValueKind.Number when value.TryGetInt64(out var number) => number,
JsonValueKind.String when long.TryParse(TextOf(value), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) => parsed,
_ => null,
};
}
/// <summary>true or false; any other kind, "true" in quotes or 1 included, is null.</summary>
internal static bool? GetBoolean(JsonElement obj, string name)
=> obj.TryGetProperty(name, out var value) && value.ValueKind is JsonValueKind.True or JsonValueKind.False
? value.GetBoolean()
: null;
/// <summary>
/// An ISO date string as a UTC instant, read with the invariant culture and full precision; a value
/// without an offset counts as UTC. Null when absent, not a string, blank, unreadable, or Graph's
/// <c>0001-01-01</c> placeholder for an unset date.
/// </summary>
internal static DateTimeOffset? GetUtcDateTime(JsonElement obj, string name)
{
var raw = GetString(obj, name);
if (string.IsNullOrWhiteSpace(raw)
|| !DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed))
return null;
return parsed.Year <= 1 ? null : parsed;
}
}
@@ -35,33 +35,44 @@ namespace AutopilotMonitor.Functions.Middleware;
/// request can never be blocked by its OWN in-flight increment. The increment stays
/// fire-and-forget (never blocks the request path).
///
/// <b>The quota boundary is deliberately SOFT, not exact.</b> McpQuotaService caches the usage
/// snapshots for 60 seconds (per instance; per user and per tenant), so an ALLOWED decision keeps
/// admitting requests inside that window even after the async increments push the stored counters past
/// the limit. Worst-case overshoot is bounded: ~60s × the request rate against that counter (× instance
/// count on scaled-out Flex Consumption) — the same deliberate posture as the sliding-window
/// rate limiter, trading exactness for one counter read per user / per tenant per minute instead of per
/// request. Pinned by McpQuotaServiceTests soft-boundary tests; do NOT re-document this as an
/// exact limit without reworking the snapshot cache.
/// <b>The quota boundary is exact per instance, soft across instances.</b> McpQuotaService reads the
/// counters from storage at most every 60 seconds per account and per organization and counts every request
/// it admits on them at once, so one instance blocks at the limit. Other instances learn of those requests only
/// with their next read, so on scaled-out Flex Consumption the overshoot is what the other instances admitted
/// inside 60 seconds. Pinned by the McpQuotaServiceTests boundary tests; the customer docs never call the limit exact.
///
/// Over-quota requests get 429 with a structured body, <c>Retry-After</c>, and
/// <c>X-MCP-Quota-*</c> headers; allowed MCP requests get the quota headers too so the MCP
/// client can surface remaining budget. Fail-open on storage/counter errors (handled inside
/// <see cref="McpQuotaService"/>), fail-closed on plan resolution (unknown plan → Community).
/// client can surface remaining budget. The first refusal per account or organization, window and period
/// on an instance raises the ops event <c>McpQuotaExceeded</c>; every refusal writes the Warning line.
/// Fail-open on storage/counter errors (handled inside <see cref="McpQuotaService"/>), fail-closed on plan
/// resolution (unknown plan → Community).
/// </summary>
public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
{
/// <summary>Start of the Warning line every refused request writes; the MCP usage overview finds the refusals by it.</summary>
internal const string BlockedLogPrefix = "[McpQuota] BLOCKED";
private const string BlockedLogTemplate = BlockedLogPrefix
+ " oid={Oid} charged={ChargedTenant} level={Level} scope={Scope} plan={Plan} daily={DailyUsed}/{DailyLimit} monthly={MonthlyUsed}/{MonthlyLimit} tenantDaily={TenantDailyUsed}/{TenantDailyLimit} tenantMonthly={TenantMonthlyUsed}/{TenantMonthlyLimit}";
// The key carries the period (see ExceededAlertKey), so the interval only has to outlast the longest one.
private static readonly LogThrottle ExceededAlerts = new(TimeSpan.FromDays(31));
private readonly McpQuotaService _quotaService;
private readonly IUserUsageRepository _userUsageRepo;
private readonly OpsEventService _opsEvents;
private readonly ILogger<McpQuotaEnforcementMiddleware> _logger;
public McpQuotaEnforcementMiddleware(
McpQuotaService quotaService,
IUserUsageRepository userUsageRepo,
OpsEventService opsEvents,
ILogger<McpQuotaEnforcementMiddleware> logger)
{
_quotaService = quotaService;
_userUsageRepo = userUsageRepo;
_opsEvents = opsEvents;
_logger = logger;
}
@@ -104,7 +115,7 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
McpQuotaDecision decision;
try
{
decision = await _quotaService.CheckAsync(oid, upn, homeTenantId);
decision = await _quotaService.AdmitAsync(oid, upn, homeTenantId);
}
catch (Exception ex)
{
@@ -119,16 +130,40 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
if (decision.Allowed)
{
// Check-then-increment: the decision above reflects previously SERVED requests only.
// The check already counted this request on the instance's counters; storage gets it here.
TrackUsage(httpContext, oid, upn, homeTenantId);
await next(context);
return;
}
LogBlocked(oid, homeTenantId, decision);
RaiseExceeded(oid, upn, homeTenantId, decision);
await WriteExceededAsync(context, httpContext, decision, BuildExceededResponse(decision));
}
/// <summary>
/// Pure: one key per account (level user) or organization (level tenant), window and period. The period is
/// the window's reset date, so the next day or month starts a fresh key.
/// </summary>
internal static string ExceededAlertKey(string oid, string tenantId, McpQuotaDecision decision)
{
var subject = decision.Level == McpQuotaLevel.Tenant ? tenantId.ToLowerInvariant() : oid;
return $"{decision.Level}:{decision.Scope}:{subject}:{decision.ResetUtc:yyyyMMdd}";
}
/// <summary>The ops event for the first refusal of its key on this instance — fire-and-forget, never on the 429's path.</summary>
private void RaiseExceeded(string oid, string? upn, string tenantId, McpQuotaDecision decision)
{
if (!ExceededAlerts.ShouldLog(ExceededAlertKey(oid, tenantId, decision)))
return;
var level = decision.Level ?? McpQuotaLevel.User;
_ = _opsEvents.RecordMcpQuotaExceededAsync(
tenantId, upn ?? oid, level, decision.Scope ?? "daily",
level == McpQuotaLevel.Tenant ? decision.TenantPlan : decision.Plan,
decision.ExceededLimit, decision.ExceededUsed, decision.ResetUtc);
}
/// <summary>
/// Pure: who bypasses the check. Only the Global Admin — platform operations must never be blocked by a
/// budget. The Global Reader is a customer-facing read role and stays within its home tenant's windows.
@@ -182,7 +217,7 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
private void LogBlocked(string oid, string tenantId, McpQuotaDecision decision)
{
_logger.LogWarning(
"[McpQuota] BLOCKED oid={Oid} charged={ChargedTenant} level={Level} scope={Scope} plan={Plan} daily={DailyUsed}/{DailyLimit} monthly={MonthlyUsed}/{MonthlyLimit} tenantDaily={TenantDailyUsed}/{TenantDailyLimit} tenantMonthly={TenantMonthlyUsed}/{TenantMonthlyLimit}",
BlockedLogTemplate,
oid, LogSanitizer.Clean(tenantId), decision.Level, decision.Scope, decision.Plan, decision.DailyUsed, decision.DailyLimit, decision.MonthlyUsed, decision.MonthlyLimit,
decision.TenantDailyUsed, decision.TenantDailyLimit, decision.TenantMonthlyUsed, decision.TenantMonthlyLimit);
}
@@ -92,12 +92,16 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
principal = cp;
}
var viewAs = ViewAsTarget.FromHeaders(
httpContext.Request.Headers[ViewAsHeaders.Tenant].FirstOrDefault(),
httpContext.Request.Headers[ViewAsHeaders.Role].FirstOrDefault());
// All authorization + RequestContext construction is the transport-agnostic DecideAsync seam.
// Fail-closed on service errors: return 503 (not 500) so clients can retry.
PolicyResult result;
try
{
result = await DecideAsync(httpMethod, requestPath, queryTenantId, principal, ClientSourceHeader.IsMcp(httpContext));
result = await DecideAsync(httpMethod, requestPath, queryTenantId, principal, ClientSourceHeader.IsMcp(httpContext), viewAs);
}
catch (Exception ex)
{
@@ -113,6 +117,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
if (result.Allowed)
{
context.Items[RequestContext.ItemsKey] = result.Context!;
if (result.Context!.ViewAs != null)
Telemetry.ViewAsTelemetryProcessor.Enter(result.Context.TenantId);
_logger.LogDebug("[PolicyEnforcement] ALLOW {Method} {Path} user={User} role={Role}",
logMethod, logPath, LogSanitizer.Clean(result.UserIdentifier), result.UserRole);
await next(context);
@@ -125,7 +131,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
_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);
await ReportPrivilegedDenialAsync(context, result, httpMethod, requestPath, principal, clientSource, mcpToolName, viewAs != null);
// Envelope: the human message is `error`, the policy code (Forbidden, CrossTenantAccessDenied,
// TenantSuspended, McpDisabled, AuthenticationRequired, InsufficientPermissions) is `code`.
await ApiErrorWriter.WriteAsync(
@@ -152,9 +158,9 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// </summary>
private async Task ReportPrivilegedDenialAsync(
FunctionContext context, PolicyResult result, string httpMethod, string requestPath,
ClaimsPrincipal? principal, string? clientSource, string? mcpToolName)
ClaimsPrincipal? principal, string? clientSource, string? mcpToolName, bool viewAsRequested)
{
if (!IsPrivilegedDenial(result) || principal == null)
if (!IsPrivilegedDenial(result, viewAsRequested) || principal == null)
return;
try
{
@@ -169,11 +175,17 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
}
}
/// <summary>A 403 by an authenticated caller on a GlobalAdminOnly route — the only denial that is reported.</summary>
internal static bool IsPrivilegedDenial(PolicyResult result)
/// <summary>
/// A 403 by an authenticated caller on a GlobalAdminOnly route — the only denial that is reported. Under
/// view-as the emulated member's denials are the expected picture and stay silent; the report is then the
/// refused view-as itself (someone without Global Admin sending the header).
/// </summary>
internal static bool IsPrivilegedDenial(PolicyResult result, bool viewAsRequested = false)
=> !result.Allowed
&& result.Policy == EndpointPolicy.GlobalAdminOnly
&& result.StatusCode == (int)HttpStatusCode.Forbidden;
&& result.StatusCode == (int)HttpStatusCode.Forbidden
&& (viewAsRequested
? result.ErrorCode == Constants.ApiErrorCodes.ViewAsNotAllowed
: result.Policy == EndpointPolicy.GlobalAdminOnly);
/// <summary>Longest request path kept in a denial record; the route catalog has nothing longer.</summary>
private const int MaxDenialPathLength = 256;
@@ -210,11 +222,12 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// is unit-testable without faking the worker's HttpContext feature. <see cref="Invoke"/> is a thin
/// adapter that reads the request, calls this, and writes the response.
/// <paramref name="isMcpClient"/> is the self-declared <c>X-Client-Source: mcp</c> marker: it only ever
/// narrows (the tenant MCP switch), never grants.
/// narrows (the tenant MCP switch), never grants. <paramref name="viewAs"/> (the view-as headers) only
/// ever narrows too: a Global Admin is decided as the emulated member of the viewed tenant, read-only.
/// </summary>
internal async Task<PolicyResult> DecideAsync(
string httpMethod, string requestPath, string? queryTenantId, ClaimsPrincipal? principal,
bool isMcpClient = false)
bool isMcpClient = false, ViewAsTarget? viewAs = null)
{
// Sanitized copies for logging only — never used for routing/authorization decisions.
var logPath = LogSanitizer.Clean(requestPath);
@@ -229,9 +242,23 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "Forbidden", "Access denied.", "anonymous", "N/A", "Unregistered");
}
var decision = await EvaluateCatalogPolicyAsync(principal, catalogEntry);
// View as tenant: admitted first, then decided by the same evaluators over an emulated member of the
// viewed tenant — everything below runs in that tenant (cross-tenant check, suspension gate, context).
// Routes without a JWT tier ignore the headers.
var jwtTenantId = principal?.GetTenantId() ?? string.Empty;
ViewAsTarget? viewing = null;
if (viewAs != null && catalogEntry.Policy is not (EndpointPolicy.PublicAnonymous or EndpointPolicy.DeviceOrBootstrapAuth))
{
var refusal = await AdmitViewAsAsync(viewAs, principal, isMcpClient, catalogEntry);
if (refusal != null)
return refusal;
viewing = viewAs;
}
var actingTenantId = viewing?.TenantId ?? jwtTenantId;
var roles = viewing == null ? CallerRoles.Real(this, principal) : CallerRoles.Emulated(viewing.Role);
var decision = await EvaluateCatalogPolicyAsync(principal, catalogEntry, roles, actingTenantId);
var upn = principal?.GetUserPrincipalName();
var isGlobalAdmin = decision.IsAllowed && decision.UserRole == Constants.GlobalRoles.GlobalAdmin;
var isGlobalReader = decision.IsAllowed && decision.UserRole == Constants.GlobalRoles.GlobalReader;
@@ -240,9 +267,9 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// Resolve the target tenant for tenant-scoped routes ({tenantId} in path, or ?tenantId= query).
// crossTenant = the route names a tenant OTHER than the caller's JWT tenant.
var (targetTenantId, namedTarget) = ResolveTarget(catalogEntry, requestPath, queryTenantId, jwtTenantId);
var (targetTenantId, namedTarget) = ResolveTarget(catalogEntry, requestPath, queryTenantId, actingTenantId);
var crossTenant = namedTarget != null
&& !string.Equals(namedTarget, jwtTenantId, StringComparison.OrdinalIgnoreCase);
&& !string.Equals(namedTarget, actingTenantId, StringComparison.OrdinalIgnoreCase);
// Delegated ("scoped global" / MSP) resolution. A delegated admin reaches a SUBSET of tenants —
// ONLY on tenant-scoped read routes where the named target is in its scope. We resolve lazily:
@@ -256,7 +283,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
{
// Full identity (upn + tid + oid): the scope resolves only for the bound identity, and the home
// tenant (JWT tid) gates it — MSP seats require a Pro home tenant.
var scope = await _delegatedAdminService.GetScopeAsync(AdminIdentity.FromPrincipal(principal));
var scope = await roles.DelegatedScopeAsync();
delegatedRole = scope.RoleFor(namedTarget);
if (delegatedRole != null)
allowedTenantIds = scope.TenantIds;
@@ -294,7 +321,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
if (crossTenant && !hasGlobalScope && !delegatedGrantsRead)
{
_logger.LogWarning("[PolicyEnforcement] BLOCKED cross-tenant access: user={User} jwtTenant={JwtTenant} target={Target} scoping={Scoping} path={Path}",
LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(jwtTenantId), LogSanitizer.Clean(namedTarget), catalogEntry.TenantScoping, logPath);
LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(actingTenantId), 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", catalogEntry.Policy);
@@ -312,7 +339,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// farewell feedback arrives after their own tenant was tombstoned); any other suspension still holds.
if (principal != null && !hasGlobalScope)
{
var gateConfigs = await LoadGateConfigsAsync(jwtTenantId, targetTenantId);
var gateConfigs = await LoadGateConfigsAsync(actingTenantId, targetTenantId);
var suspended = gateConfigs.FirstOrDefault(c => c.IsCurrentlyDisabled()
&& !(catalogEntry.AllowedDuringOffboarding && TenantOffboardFunction.IsOffboardingTombstone(c)));
if (suspended != null)
@@ -360,8 +387,9 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
var requestContext = new RequestContext
{
TenantId = jwtTenantId,
TenantId = actingTenantId,
TargetTenantId = targetTenantId,
ViewAs = viewing == null ? null : new ViewAsContext(jwtTenantId, viewing.Role),
// ONLY a genuine JWT UPN lands here — never the "anonymous" placeholder that
// decision.UserIdentifier carries on device/anonymous routes. The placeholder used to be
// published here, which collapsed the entire agent fleet (every tenant) into one shared
@@ -431,6 +459,40 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return admitted;
}
/// <summary>
/// Admission of a view-as request before any policy evaluation: usable headers, a Global Admin in person
/// (an MCP call never; a foreign client app's capped identity holds no platform role), an onboarded
/// tenant, and a route that only reads. Null = admitted.
/// </summary>
private async Task<PolicyResult?> AdmitViewAsAsync(
ViewAsTarget viewAs, ClaimsPrincipal? principal, bool isMcpClient, EndpointPolicyEntry entry)
{
var user = principal?.GetUserPrincipalName() ?? "anonymous";
if (principal == null)
return PolicyResult.Deny((int)HttpStatusCode.Unauthorized, Constants.ApiErrorCodes.AuthenticationRequired,
"Authentication required. Please provide a valid JWT token.", user, "N/A", "NoJWT", entry.Policy);
if (!viewAs.IsValid)
return PolicyResult.Deny((int)HttpStatusCode.BadRequest, Constants.ApiErrorCodes.ViewAsInvalid,
"View as: the tenant must be a tenant ID and the role Admin, Operator or Viewer.", user, "N/A", "ViewAsInvalid", entry.Policy);
var globalRole = isMcpClient ? null : await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
if (globalRole != Constants.GlobalRoles.GlobalAdmin)
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, Constants.ApiErrorCodes.ViewAsNotAllowed,
"Viewing a tenant as its member is reserved for Global Admins.", user, globalRole ?? "N/A", "ViewAsNotAllowed", entry.Policy);
var (_, exists) = await _tenantConfigService.TryGetConfigurationAsync(viewAs.TenantId);
if (!exists)
return PolicyResult.Deny((int)HttpStatusCode.NotFound, Constants.ApiErrorCodes.ViewAsTenantNotFound,
"View as: no onboarded tenant has this ID.", user, globalRole, "ViewAsTenantNotFound", entry.Policy);
if (entry.ViewAsAccess == ViewAsAccess.Write)
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, Constants.ApiErrorCodes.ViewAsReadOnly,
ViewAsTarget.ReadOnlyMessage, user, globalRole, "ViewAsReadOnly", entry.Policy);
return null;
}
/// <summary>
/// Resolves the request's target tenant. Returns the effective TargetTenantId (defaults to the JWT
/// tenant) and the explicitly-named tenant from the route/query (null when the route does not name one,
@@ -479,12 +541,13 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// as a delegated read tier so RoleFor(target) can rescue the cross-tenant block.
or EndpointPolicy.GlobalReadOrDelegatedSubset;
private async Task<CatalogDecisionResult> EvaluateCatalogPolicyAsync(
ClaimsPrincipal? principal, EndpointPolicyEntry entry)
private static async Task<CatalogDecisionResult> EvaluateCatalogPolicyAsync(
ClaimsPrincipal? principal, EndpointPolicyEntry entry, CallerRoles roles, string actingTenantId)
{
// principal is the authenticated ClaimsPrincipal (set by AuthenticationMiddleware), or null on
// anonymous/device routes — resolved by the caller (Invoke / DecideAsync).
var tenantId = principal?.GetTenantId();
// anonymous/device routes — resolved by the caller (Invoke / DecideAsync). Every role below comes
// from `roles`, the tenant from actingTenantId (the JWT tenant, or the viewed one under view-as).
var tenantId = principal == null ? null : actingTenantId;
var upn = principal?.GetUserPrincipalName();
var userIdentifier = upn ?? "anonymous";
@@ -510,31 +573,31 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "NoJWT");
case EndpointPolicy.AuthenticatedUserWithRole:
return await EvaluateAuthenticatedUserWithRoleAsync(tenantId, upn, principal, userIdentifier);
return await EvaluateAuthenticatedUserWithRoleAsync(tenantId, upn, principal, roles, userIdentifier);
case EndpointPolicy.MemberRead:
return await EvaluateMemberReadAsync(tenantId, upn, principal, userIdentifier);
return await EvaluateMemberReadAsync(tenantId, upn, roles, userIdentifier);
case EndpointPolicy.TenantAdminOrGlobalReader:
return await EvaluateTenantAdminOrGlobalReaderAsync(tenantId, upn, principal, userIdentifier);
return await EvaluateTenantAdminOrGlobalReaderAsync(tenantId, upn, roles, userIdentifier);
case EndpointPolicy.TenantAdminOrOperator:
return await EvaluateTenantAdminOrOperatorAsync(tenantId, upn, principal, userIdentifier);
return await EvaluateTenantAdminOrOperatorAsync(tenantId, upn, roles, userIdentifier);
case EndpointPolicy.TenantAdminOrGA:
return await EvaluateTenantAdminOrGAAsync(tenantId, upn, principal, userIdentifier);
return await EvaluateTenantAdminOrGAAsync(tenantId, upn, roles, userIdentifier);
case EndpointPolicy.BootstrapManagerOrGA:
return await EvaluateBootstrapManagerOrGAAsync(tenantId, upn, principal, userIdentifier);
return await EvaluateBootstrapManagerOrGAAsync(tenantId, upn, roles, userIdentifier);
case EndpointPolicy.GlobalReadOrAdmin:
return await EvaluateGlobalReadOrAdminAsync(principal, upn, userIdentifier);
return await EvaluateGlobalReadOrAdminAsync(upn, roles, userIdentifier);
case EndpointPolicy.GlobalReadOrDelegatedSubset:
return await EvaluateGlobalReadOrDelegatedSubsetAsync(principal, upn, userIdentifier);
return await EvaluateGlobalReadOrDelegatedSubsetAsync(upn, roles, userIdentifier);
case EndpointPolicy.GlobalAdminOnly:
return await EvaluateGlobalAdminOnlyAsync(principal, upn, userIdentifier);
return await EvaluateGlobalAdminOnlyAsync(upn, roles, userIdentifier);
default:
return CatalogDecisionResult.Deny(userIdentifier, "N/A", $"UnknownPolicy:{entry.Policy}");
@@ -548,8 +611,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// with no special role is admitted as "Authenticated" (role flags stay false) — non-member end
/// users are intentionally allowed here; the function gates the privileged resources.
/// </summary>
private async Task<CatalogDecisionResult> EvaluateAuthenticatedUserWithRoleAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
private static async Task<CatalogDecisionResult> EvaluateAuthenticatedUserWithRoleAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, CallerRoles roles, string userIdentifier)
{
if (principal == null)
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "NoJWT");
@@ -561,11 +624,11 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// (admin notification group, admin-audience notifications).
if (!string.IsNullOrEmpty(upn))
{
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
var globalRole = await roles.GlobalRoleAsync();
if (globalRole != null)
{
var tenantRole = !string.IsNullOrEmpty(tenantId)
? (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role
? (await roles.TenantRoleAsync(tenantId, upn))?.Role
: null;
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "ValidJWT+GlobalScope", tenantRole);
}
@@ -576,7 +639,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// the request is allowed at the middleware tier.
if (!string.IsNullOrEmpty(tenantId) && !string.IsNullOrEmpty(upn))
{
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
var role = await roles.TenantRoleAsync(tenantId, upn);
if (role?.Role != null)
return CatalogDecisionResult.Allow(userIdentifier, role.Role, "ValidJWT+Member", role.Role);
}
@@ -586,8 +649,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return CatalogDecisionResult.Allow(userIdentifier, "Authenticated", "ValidJWT");
}
private async Task<CatalogDecisionResult> EvaluateMemberReadAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
private static async Task<CatalogDecisionResult> EvaluateMemberReadAsync(
string? tenantId, string? upn, CallerRoles roles, string userIdentifier)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
@@ -595,14 +658,14 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
// Any platform role (GlobalAdmin or read-only GlobalReader) satisfies a member-read. ADDITIVE:
// also resolve the own-tenant role and carry it separately so IsTenantAdmin stays correct for a
// GlobalReader who is also their tenant's admin (drives admin-audience on /api/notifications).
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
var globalRole = await roles.GlobalRoleAsync();
if (globalRole != null)
{
var tenantRole = (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role;
var tenantRole = (await roles.TenantRoleAsync(tenantId, upn))?.Role;
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScopeBypass", tenantRole);
}
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
var role = await roles.TenantRoleAsync(tenantId, upn);
if (role == null)
return CatalogDecisionResult.Deny(userIdentifier, "NonMember", "NotInTenant");
@@ -618,36 +681,36 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// carried, so a GlobalReader who is also their tenant's Admin keeps IsTenantAdmin=true (config GET
/// then serves them the FULL config for their own tenant, redacting only the read-only-reader view).
/// </summary>
private async Task<CatalogDecisionResult> EvaluateTenantAdminOrGlobalReaderAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
private static async Task<CatalogDecisionResult> EvaluateTenantAdminOrGlobalReaderAsync(
string? tenantId, string? upn, CallerRoles roles, string userIdentifier)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
var globalRole = await roles.GlobalRoleAsync();
if (globalRole != null)
{
var tenantRole = (await ResolveEffectiveRoleAsync(tenantId, upn, principal))?.Role;
var tenantRole = (await roles.TenantRoleAsync(tenantId, upn))?.Role;
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScopeBypass", tenantRole);
}
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
var role = await roles.TenantRoleAsync(tenantId, upn);
if (role?.Role == Constants.TenantRoles.Admin)
return CatalogDecisionResult.Allow(userIdentifier, Constants.TenantRoles.Admin, "TenantAdmin", Constants.TenantRoles.Admin);
return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NotAdminOrGlobalScope");
}
private async Task<CatalogDecisionResult> EvaluateTenantAdminOrGAAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
private static async Task<CatalogDecisionResult> EvaluateTenantAdminOrGAAsync(
string? tenantId, string? upn, CallerRoles roles, string userIdentifier)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal)))
if (await roles.IsGlobalAdminAsync())
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass");
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
var role = await roles.TenantRoleAsync(tenantId, upn);
if (role?.Role == Constants.TenantRoles.Admin)
return CatalogDecisionResult.Allow(userIdentifier, Constants.TenantRoles.Admin, "TenantAdmin");
@@ -660,32 +723,32 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// serves multiple operation kinds can re-gate the admin-only ones on IsTenantAdmin.
/// The read-only Global Reader is deliberately NOT admitted — this is a write tier.
/// </summary>
private async Task<CatalogDecisionResult> EvaluateTenantAdminOrOperatorAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
private static async Task<CatalogDecisionResult> EvaluateTenantAdminOrOperatorAsync(
string? tenantId, string? upn, CallerRoles roles, string userIdentifier)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal)))
if (await roles.IsGlobalAdminAsync())
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass");
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
var role = await roles.TenantRoleAsync(tenantId, upn);
if (role?.Role == Constants.TenantRoles.Admin || role?.Role == Constants.TenantRoles.Operator)
return CatalogDecisionResult.Allow(userIdentifier, role.Role, "TenantAdminOrOperator");
return CatalogDecisionResult.Deny(userIdentifier, role?.Role ?? "NonMember", "NotAdminOperatorOrGA");
}
private async Task<CatalogDecisionResult> EvaluateBootstrapManagerOrGAAsync(
string? tenantId, string? upn, ClaimsPrincipal? principal, string userIdentifier)
private static async Task<CatalogDecisionResult> EvaluateBootstrapManagerOrGAAsync(
string? tenantId, string? upn, CallerRoles roles, string userIdentifier)
{
if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal)))
if (await roles.IsGlobalAdminAsync())
return CatalogDecisionResult.Allow(userIdentifier, "GlobalAdmin", "GABypass");
var role = await ResolveEffectiveRoleAsync(tenantId, upn, principal);
var role = await roles.TenantRoleAsync(tenantId, upn);
var canManageBootstrap = role != null &&
(role.Role == Constants.TenantRoles.Admin ||
(role.Role == Constants.TenantRoles.Operator && role.CanManageBootstrapTokens));
@@ -697,26 +760,69 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
}
/// <summary>
/// Effective tenant member role (table first, Entra app-role claim as the gated fallback) — see
/// <see cref="TenantMemberRoleResolver"/>, which the MCP access check shares so "member" means the same
/// thing on every path.
/// The role lookups a policy decision reads. An ordinary request resolves them from the platform, member
/// and delegation tables; a view-as request answers "no platform role, no delegated scope, member role R
/// of the viewed tenant". The evaluators take their roles only from here, so one rule set decides both.
/// </summary>
private Task<MemberRoleInfo?> ResolveEffectiveRoleAsync(
string tenantId, string upn, ClaimsPrincipal? principal)
=> _memberRoleResolver.ResolveAsync(tenantId, upn, principal?.GetAppRoles(), principal?.IsClientCapped() ?? false);
private sealed class CallerRoles
{
private readonly PolicyEnforcementMiddleware? _owner;
private readonly ClaimsPrincipal? _principal;
private readonly MemberRoleInfo? _emulated;
private CallerRoles(PolicyEnforcementMiddleware? owner, ClaimsPrincipal? principal, MemberRoleInfo? emulated)
{
_owner = owner;
_principal = principal;
_emulated = emulated;
}
public static CallerRoles Real(PolicyEnforcementMiddleware owner, ClaimsPrincipal? principal)
=> new(owner, principal, null);
/// <summary>A member with this tenant role and nothing else — no bootstrap-token right, no platform role.</summary>
public static CallerRoles Emulated(string tenantRole)
=> new(null, null, new MemberRoleInfo { Role = tenantRole, CanManageBootstrapTokens = false });
public Task<string?> GlobalRoleAsync()
=> _emulated != null
? Task.FromResult<string?>(null)
: _owner!._globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(_principal));
public Task<bool> IsGlobalAdminAsync()
=> _emulated != null
? Task.FromResult(false)
: _owner!._globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(_principal));
/// <summary>
/// Effective tenant member role (table first, Entra app-role claim as the gated fallback) — see
/// <see cref="TenantMemberRoleResolver"/>, which the MCP access check shares so "member" means the same
/// thing on every path.
/// </summary>
public Task<MemberRoleInfo?> TenantRoleAsync(string tenantId, string upn)
=> _emulated != null
? Task.FromResult<MemberRoleInfo?>(_emulated)
: _owner!._memberRoleResolver.ResolveAsync(tenantId, upn, _principal?.GetAppRoles(), _principal?.IsClientCapped() ?? false);
/// <summary>Full identity (upn + tid + oid): the scope resolves only for the bound identity.</summary>
public Task<DelegatedScope> DelegatedScopeAsync()
=> _emulated != null
? Task.FromResult(DelegatedScope.Empty)
: _owner!._delegatedAdminService.GetScopeAsync(AdminIdentity.FromPrincipal(_principal));
}
/// <summary>
/// Platform-wide cross-tenant READ: admits GlobalAdmin and the read-only GlobalReader. The
/// resolved role string flows onto RequestContext so the function still distinguishes GA from
/// Reader (e.g. for any in-function write gate).
/// </summary>
private async Task<CatalogDecisionResult> EvaluateGlobalReadOrAdminAsync(
ClaimsPrincipal? principal, string? upn, string userIdentifier)
private static async Task<CatalogDecisionResult> EvaluateGlobalReadOrAdminAsync(
string? upn, CallerRoles roles, string userIdentifier)
{
if (string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
var globalRole = await roles.GlobalRoleAsync();
if (globalRole != null)
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScope");
@@ -731,19 +837,19 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
/// response to that set. A pure delegated reader of nothing, or any non-global non-delegated caller, is
/// denied. Used by aggregate endpoints whose body can be filtered per tenant (e.g. config/all).
/// </summary>
private async Task<CatalogDecisionResult> EvaluateGlobalReadOrDelegatedSubsetAsync(
ClaimsPrincipal? principal, string? upn, string userIdentifier)
private static async Task<CatalogDecisionResult> EvaluateGlobalReadOrDelegatedSubsetAsync(
string? upn, CallerRoles roles, string userIdentifier)
{
if (string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
var globalRole = await _globalAdminService.GetGlobalRoleAsync(AdminIdentity.FromPrincipal(principal));
var globalRole = await roles.GlobalRoleAsync();
if (globalRole != null)
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScope");
// Full identity (upn + tid + oid): the scope resolves only for the bound identity, and the home
// tenant (JWT tid) gates it — MSP seats require a Pro home tenant.
var scope = await _delegatedAdminService.GetScopeAsync(AdminIdentity.FromPrincipal(principal));
var scope = await roles.DelegatedScopeAsync();
if (!scope.IsEmpty)
return CatalogDecisionResult.Allow(
userIdentifier, Constants.DelegatedRoles.DelegatedReader, "DelegatedSubset",
@@ -752,13 +858,13 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
return CatalogDecisionResult.Deny(userIdentifier, "NonGlobal", "NotGlobalOrDelegated");
}
private async Task<CatalogDecisionResult> EvaluateGlobalAdminOnlyAsync(
ClaimsPrincipal? principal, string? upn, string userIdentifier)
private static async Task<CatalogDecisionResult> EvaluateGlobalAdminOnlyAsync(
string? upn, CallerRoles roles, string userIdentifier)
{
if (string.IsNullOrEmpty(upn))
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
if (await _globalAdminService.IsGlobalAdminAsync(AdminIdentity.FromPrincipal(principal)))
if (await roles.IsGlobalAdminAsync())
return CatalogDecisionResult.Allow(userIdentifier, Constants.GlobalRoles.GlobalAdmin, "IsGA");
return CatalogDecisionResult.Deny(userIdentifier, "NonGA", "NotGlobalAdmin");
@@ -7,6 +7,7 @@ using Microsoft.Azure.Functions.Worker.Middleware;
using AutopilotMonitor.Functions.Extensions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Middleware;
@@ -75,6 +76,26 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
return null;
}
/// <summary>
/// The caller dimensions of the request row. A view-as request counts at the real caller — home tenant
/// (<paramref name="tenantId"/>, resolved from <see cref="RequestContextExtensions.IdentityTenantId"/>) and
/// Global Admin — and names the viewed tenant separately: activity, growth and usage figures read this row.
/// </summary>
internal static void StampCaller(IDictionary<string, string> properties, RequestContext ctx, string? tenantId)
{
if (!string.IsNullOrEmpty(tenantId))
properties[RequestRowDimensions.TenantId] = tenantId;
if (!string.IsNullOrEmpty(ctx.UserPrincipalName))
properties[RequestRowDimensions.UserId] = ctx.UserPrincipalName;
if (ctx.ViewAs != null)
{
properties[RequestRowDimensions.UserRole] = Constants.GlobalRoles.GlobalAdmin;
properties[RequestRowDimensions.ViewAsTenantId] = ctx.TenantId;
}
else if (!string.IsNullOrEmpty(ctx.UserRole))
properties[RequestRowDimensions.UserRole] = ctx.UserRole;
}
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
{
var httpContext = context.GetHttpContext();
@@ -136,18 +157,18 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
// See memory project_ingest_request_multiplication_429 for the full diagnosis.
// Business context from downstream middleware
requestTelemetry.Properties["Source"] = "WorkerMiddleware";
requestTelemetry.Properties["FunctionName"] = functionName;
requestTelemetry.Properties["HttpMethod"] = method;
requestTelemetry.Properties["HttpPath"] = httpContext.Request.Path.Value ?? "";
requestTelemetry.Properties[RequestRowDimensions.Source] = RequestRowDimensions.WorkerMiddleware;
requestTelemetry.Properties[RequestRowDimensions.FunctionName] = functionName;
requestTelemetry.Properties[RequestRowDimensions.HttpMethod] = method;
requestTelemetry.Properties[RequestRowDimensions.HttpPath] = httpContext.Request.Path.Value ?? "";
var clientSource = httpContext.Request.Headers["X-Client-Source"].FirstOrDefault();
if (!string.IsNullOrEmpty(clientSource))
requestTelemetry.Properties["ClientSource"] = clientSource;
requestTelemetry.Properties[RequestRowDimensions.ClientSource] = clientSource;
var mcpToolName = httpContext.Request.Headers["X-MCP-Tool-Name"].FirstOrDefault();
if (!string.IsNullOrEmpty(mcpToolName))
requestTelemetry.Properties["McpToolName"] = mcpToolName;
requestTelemetry.Properties[RequestRowDimensions.McpToolName] = mcpToolName;
// APPLICATION PRINCIPALS — the request-row dimension that lets KQL separate automation
// (app-only tokens) from people: "app" plus the calling client id, nothing for a person.
@@ -167,7 +188,7 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
requestTelemetry.Properties["ClientAppId"] = clientAppId;
if (context.Items.TryGetValue("CorrelationId", out var corrId) && corrId is string correlationId)
requestTelemetry.Properties["CorrelationId"] = correlationId;
requestTelemetry.Properties[RequestRowDimensions.CorrelationId] = correlationId;
// THROTTLE SURFACE — set by UserRateLimitMiddleware from the signed client-auth claim
// (portal = public client, integration = confidential client / app-only). Next to the
@@ -212,16 +233,11 @@ public class RequestTelemetryMiddleware : IFunctionsWorkerMiddleware
var reqCtx = context.GetRequestContext();
var tenantId = ResolveTenantId(
reqCtx.TenantId,
reqCtx.IdentityTenantId(),
context.Items,
httpContext.Request.Path.Value ?? string.Empty,
httpContext.Request.Headers["X-Tenant-Id"].FirstOrDefault());
if (!string.IsNullOrEmpty(tenantId))
requestTelemetry.Properties["TenantId"] = tenantId;
if (!string.IsNullOrEmpty(reqCtx.UserPrincipalName))
requestTelemetry.Properties["UserId"] = reqCtx.UserPrincipalName;
if (!string.IsNullOrEmpty(reqCtx.UserRole))
requestTelemetry.Properties["UserRole"] = reqCtx.UserRole;
StampCaller(requestTelemetry.Properties, reqCtx, tenantId);
if (caughtException != null)
requestTelemetry.Properties["ExceptionType"] = caughtException.GetType().Name;
@@ -36,7 +36,7 @@ public class UserPresenceMiddleware : IFunctionsWorkerMiddleware
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
{
var ctx = context.GetRequestContext();
if (!string.IsNullOrEmpty(ctx.UserPrincipalName) && !string.IsNullOrEmpty(ctx.TenantId))
if (Records(ctx))
{
try
{
@@ -56,6 +56,13 @@ public class UserPresenceMiddleware : IFunctionsWorkerMiddleware
await next(context);
}
/// <summary>
/// Whether the request records presence: a signed-in person acting in their own tenant. A Global Admin
/// viewing a tenant as its member (read-only "view as") is not present in that tenant.
/// </summary>
internal static bool Records(RequestContext ctx)
=> ctx.ViewAs == null && !string.IsNullOrEmpty(ctx.UserPrincipalName) && !string.IsNullOrEmpty(ctx.TenantId);
/// <summary>
/// Returns true at most once per <see cref="ThrottleWindow"/> per user (per process), and stamps
/// the write time when it does. Keeps the hot path from upserting on every single request.
@@ -30,6 +30,12 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
/// </summary>
public const string ThrottleSurfaceItemKey = "ThrottleSurface";
/// <summary>Start of the Warning line every refused request writes; the MCP usage overview finds the refusals by it.</summary>
internal const string ThrottledLogPrefix = "[UserRateLimit] THROTTLED";
private const string ThrottledLogTemplate = ThrottledLogPrefix
+ " caller={Caller} surface={Surface} requests={Count}/{Max} retryAfter={RetryAfter}s";
private readonly RateLimitService _rateLimitService;
private readonly AdminConfigurationService _adminConfigService;
private readonly TenantConfigurationService _tenantConfigService;
@@ -163,7 +169,7 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
: 60;
_logger.LogWarning(
"[UserRateLimit] THROTTLED caller={Caller} surface={Surface} requests={Count}/{Max} retryAfter={RetryAfter}s",
ThrottledLogTemplate,
requestContext.CallerId, surface.ToDimension(), result.RequestsInWindow, result.MaxRequests, retryAfterSeconds);
// Envelope with the retry window; the counters ride in the X-RateLimit-* headers above.
@@ -250,6 +250,7 @@ builder.Services.AddSingleton<AdminConfigurationService>();
builder.Services.AddSingleton<AppHomingService>();
builder.Services.AddSingleton<TenantEntitlementService>();
builder.Services.AddSingleton<McpQuotaService>();
builder.Services.AddSingleton<McpUsageOverviewService>();
builder.Services.AddSingleton<ILatestVersionsService, LatestVersionsService>();
builder.Services.AddSingleton<RateLimitService>();
builder.Services.AddSingleton<DistressRateLimitService>();
@@ -316,11 +317,12 @@ 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.
// Operator KQL proxy (global/raw/logs) and the fixed queries of the MCP usage overview: the three telemetry
// stores, the managed-identity credential as an injectable seam, one executor for both, 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.AddSingleton<LogQueryExecutor>();
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.
@@ -547,6 +549,8 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Services.Push.IPushSess
// Push endpoints are per-device send capabilities: the dependency rows for the push services
// keep scheme + host only (K20).
builder.Services.AddApplicationInsightsTelemetryProcessor<AutopilotMonitor.Functions.Telemetry.PushDependencyRedactionProcessor>();
// Items a Global Admin's read-only "view as" request emits carry the viewed tenant as ViewAsTenantId.
builder.Services.AddApplicationInsightsTelemetryProcessor<AutopilotMonitor.Functions.Telemetry.ViewAsTelemetryProcessor>();
var app = builder.Build();
@@ -7,8 +7,8 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Security
{
@@ -185,27 +185,8 @@ namespace AutopilotMonitor.Functions.Security
};
}
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
var devices = data?["value"] as JArray;
if (devices == null || devices.Count == 0)
{
// Definitive: device not found — cache negative result
return CacheAndReturn(cacheKey, new AutopilotDeviceValidationResult
{
IsValid = false,
SerialNumber = normalizedSerial,
ErrorMessage = $"Device with serial '{normalizedSerial}' is not registered in Autopilot"
}, isPositive: false);
}
// Exact-match guard to avoid false positives from contains(...)
var exactDevice = devices
.FirstOrDefault(d => string.Equals(
d?["serialNumber"]?.ToString()?.Trim(),
normalizedSerial,
StringComparison.OrdinalIgnoreCase));
if (exactDevice == null)
var registration = FindRegistration(responseBody, normalizedSerial);
if (registration == null)
{
// Definitive: device not found — cache negative result
return CacheAndReturn(cacheKey, new AutopilotDeviceValidationResult
@@ -220,7 +201,7 @@ namespace AutopilotMonitor.Functions.Security
{
IsValid = true,
SerialNumber = normalizedSerial,
Registration = ReadRegistration(exactDevice)
Registration = registration
};
_logger.LogInformation(
@@ -272,6 +253,25 @@ namespace AutopilotMonitor.Functions.Security
return $"autopilot-device-validation:{tenantId}:{serialNumber}";
}
/// <summary>
/// Reads a windowsAutopilotDeviceIdentities list: the registration of the device whose serial
/// equals <paramref name="normalizedSerial"/> exactly (the $filter only narrows with contains),
/// or null when there is none. A body that is not a JSON object, or a non-object entry met
/// before the match, throws; the caller treats that as transient.
/// </summary>
internal static AutopilotRegistration? FindRegistration(string responseBody, string normalizedSerial)
{
if (UpstreamJson.ParseObjectOrNull(responseBody) is not { } data)
return null;
var exactDevice = UpstreamJson.Items(data, "value")
.FirstOrDefault(d => string.Equals(
UpstreamJson.GetString(d, "serialNumber")?.Trim(),
normalizedSerial,
StringComparison.OrdinalIgnoreCase));
return exactDevice.ValueKind == JsonValueKind.Undefined ? null : ReadRegistration(exactDevice);
}
/// <summary>Longest registration text a session keeps; anything beyond is cut.</summary>
internal const int MaxRegistrationFieldLength = 256;
@@ -280,18 +280,18 @@ namespace AutopilotMonitor.Functions.Security
/// assigned user's UPN is only tested for presence and never leaves this method; productKey is
/// never read.
/// </summary>
internal static AutopilotRegistration ReadRegistration(JToken device) => new AutopilotRegistration
internal static AutopilotRegistration ReadRegistration(JsonElement device) => new AutopilotRegistration
{
DeviceId = RegistrationText(device["id"]),
GroupTag = RegistrationText(device["groupTag"]),
PurchaseOrderId = RegistrationText(device["purchaseOrderIdentifier"]),
DisplayName = RegistrationText(device["displayName"]),
UserAssigned = RegistrationText(device["userPrincipalName"]) != null,
DeviceId = RegistrationText(device, "id"),
GroupTag = RegistrationText(device, "groupTag"),
PurchaseOrderId = RegistrationText(device, "purchaseOrderIdentifier"),
DisplayName = RegistrationText(device, "displayName"),
UserAssigned = RegistrationText(device, "userPrincipalName") != null,
};
private static string? RegistrationText(JToken? token)
private static string? RegistrationText(JsonElement device, string name)
{
var value = token?.Type == JTokenType.String ? ((string?)token)?.Trim() : null;
var value = UpstreamJson.GetString(device, name)?.Trim();
if (string.IsNullOrEmpty(value))
return null;
return value!.Length > MaxRegistrationFieldLength ? value.Substring(0, MaxRegistrationFieldLength) : value;
@@ -6,8 +6,8 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Security
{
@@ -245,35 +245,34 @@ namespace AutopilotMonitor.Functions.Security
ErrorMessage = $"Device '{normalizedId}' is not a Windows 365 Cloud PC in this tenant"
};
JObject? data;
JsonElement? data;
try
{
data = JsonConvert.DeserializeObject<JObject>(responseBody);
data = UpstreamJson.ParseObjectOrNull(responseBody);
}
catch (JsonException)
{
return notFound;
}
var cloudPcs = data?["value"] as JArray;
if (cloudPcs == null || cloudPcs.Count == 0)
if (data is not { } json)
return notFound;
var match = cloudPcs.FirstOrDefault(c => string.Equals(
c?["managedDeviceId"]?.ToString()?.Trim(),
var match = UpstreamJson.Items(json, "value").FirstOrDefault(c => string.Equals(
UpstreamJson.GetString(c, "managedDeviceId")?.Trim(),
normalizedId,
StringComparison.OrdinalIgnoreCase));
if (match == null)
if (match.ValueKind == JsonValueKind.Undefined)
return notFound;
return new CloudPcValidationResult
{
IsValid = true,
IntuneDeviceId = normalizedId,
CloudPcId = match["id"]?.ToString(),
ManagedDeviceName = match["managedDeviceName"]?.ToString(),
ServicePlanName = match["servicePlanName"]?.ToString()
CloudPcId = UpstreamJson.GetString(match, "id"),
ManagedDeviceName = UpstreamJson.GetString(match, "managedDeviceName"),
ServicePlanName = UpstreamJson.GetString(match, "servicePlanName")
};
}
@@ -7,8 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Security
{
@@ -301,24 +300,11 @@ namespace AutopilotMonitor.Functions.Security
};
}
var data = JsonConvert.DeserializeObject<JObject>(responseBody);
var identities = data?["value"] as JArray;
if (identities != null)
{
foreach (var identity in identities)
{
var type = identity?["importedDeviceIdentityType"]?.ToString();
var value = identity?["importedDeviceIdentifier"]?.ToString();
if (string.Equals(type, "manufacturerModelSerial", StringComparison.OrdinalIgnoreCase)
&& value != null
&& string.Equals(NormalizeStoredIdentifier(value), normalizedIdentifier, StringComparison.Ordinal))
{
return new ScanResult(ScanOutcome.Found);
}
}
}
var (found, nextUrl) = ReadIdentityPage(responseBody, normalizedIdentifier);
if (found)
return new ScanResult(ScanOutcome.Found);
url = data?["@odata.nextLink"]?.ToString() ?? string.Empty;
url = nextUrl;
}
if (!string.IsNullOrEmpty(url))
@@ -334,6 +320,32 @@ namespace AutopilotMonitor.Functions.Security
return new ScanResult(ScanOutcome.NotFound);
}
/// <summary>
/// Reads one importedDeviceIdentities page: whether a manufacturerModelSerial identity on it
/// normalizes to <paramref name="normalizedIdentifier"/>, and otherwise the next page's url
/// (empty when the scan ends). A body that is not a JSON object, or a non-object entry met
/// before the match, throws; the caller treats that as transient.
/// </summary>
internal static (bool Found, string NextUrl) ReadIdentityPage(string responseBody, string normalizedIdentifier)
{
if (UpstreamJson.ParseObjectOrNull(responseBody) is not { } data)
return (false, string.Empty);
foreach (var identity in UpstreamJson.Items(data, "value"))
{
var type = UpstreamJson.GetString(identity, "importedDeviceIdentityType");
var value = UpstreamJson.GetString(identity, "importedDeviceIdentifier");
if (string.Equals(type, "manufacturerModelSerial", StringComparison.OrdinalIgnoreCase)
&& value != null
&& string.Equals(NormalizeStoredIdentifier(value), normalizedIdentifier, StringComparison.Ordinal))
{
return (true, string.Empty);
}
}
return (false, UpstreamJson.GetNextLink(data) ?? string.Empty);
}
/// <summary>
/// Normalizes one identifier component the way the Intune portal does on upload:
/// uppercase, every non-alphanumeric character removed ("Microsoft Corporation" →
@@ -7,8 +7,8 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Security
{
@@ -236,56 +236,42 @@ namespace AutopilotMonitor.Functions.Security
ErrorMessage = $"Device with serial '{normalizedSerial}' is not associated with this tenant (device association)"
};
JObject? data;
JsonElement? data;
try
{
data = JsonConvert.DeserializeObject<JObject>(responseBody);
data = UpstreamJson.ParseObjectOrNull(responseBody);
}
catch (JsonException)
{
return notFound;
}
var devices = data?["value"] as JArray;
if (devices == null || devices.Count == 0)
if (data is not { } json)
return notFound;
var match = devices.FirstOrDefault(d => string.Equals(
d?["serialNumber"]?.ToString()?.Trim(),
var match = UpstreamJson.Items(json, "value").FirstOrDefault(d => string.Equals(
UpstreamJson.GetString(d, "serialNumber")?.Trim(),
normalizedSerial,
StringComparison.OrdinalIgnoreCase));
if (match == null)
if (match.ValueKind == JsonValueKind.Undefined)
return notFound;
return new DeviceAssociationResult
{
IsValid = true,
SerialNumber = normalizedSerial,
AssociationState = match["associationState"]?.ToString(),
DevicePreparationPolicyId = match["devicePreparationPolicyId"]?.ToString(),
PreAssociatedByUserPrincipalName = match["preassociatedByUserPrincipalName"]?.ToString(),
AssignedToUserPrincipalName = match["assignedToUserPrincipalName"]?.ToString(),
PreAssociationDateTime = ParseDateTime(match["preassociationDateTime"]),
AssociationDateTime = ParseDateTime(match["associationDateTime"]),
ManagedDeviceId = match["managedDeviceId"]?.ToString()
AssociationState = UpstreamJson.GetString(match, "associationState"),
DevicePreparationPolicyId = UpstreamJson.GetString(match, "devicePreparationPolicyId"),
PreAssociatedByUserPrincipalName = UpstreamJson.GetString(match, "preassociatedByUserPrincipalName"),
AssignedToUserPrincipalName = UpstreamJson.GetString(match, "assignedToUserPrincipalName"),
// Graph returns "0001-01-01T00:00:00Z" for unset dates; GetUtcDateTime reads it as null.
PreAssociationDateTime = UpstreamJson.GetUtcDateTime(match, "preassociationDateTime")?.UtcDateTime,
AssociationDateTime = UpstreamJson.GetUtcDateTime(match, "associationDateTime")?.UtcDateTime,
ManagedDeviceId = UpstreamJson.GetString(match, "managedDeviceId")
};
}
private static DateTime? ParseDateTime(JToken? token)
{
if (token == null || token.Type == JTokenType.Null) return null;
var s = token.ToString();
if (string.IsNullOrEmpty(s)) return null;
// Graph returns "0001-01-01T00:00:00Z" for unset DateTimeOffset fields — treat as null.
if (DateTime.TryParse(s, null, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var dt))
{
if (dt.Year <= 1) return null;
return dt;
}
return null;
}
internal static string BuildCacheKey(string tenantId, string serialNumber)
=> $"device-association:{tenantId}:{serialNumber}";
@@ -107,6 +107,18 @@ public enum EndpointPolicy
GlobalAdminOnly,
}
/// <summary>
/// Whether a route may run while a Global Admin views a tenant as its member (read-only "view as").
/// </summary>
public enum ViewAsAccess
{
/// <summary>Reads only (or writes nothing but the caller's own per-user marker); runs in view-as mode.</summary>
Read,
/// <summary>Changes state or starts an action; refused in view-as mode with ViewAsReadOnly.</summary>
Write,
}
/// <summary>
/// A single entry in the endpoint access policy catalog.
/// </summary>
@@ -150,12 +162,19 @@ public sealed class EndpointPolicyEntry
/// </summary>
public bool AllowedDuringOffboarding { get; }
/// <summary>
/// Read or write for view-as mode. Defaults from the method — GET reads, everything else writes, so an
/// unclassified route fails closed. Overridden only with a reason (pinned by ViewAsAccessCatalogTests):
/// a POST that reads, or a GET that starts an action.
/// </summary>
public ViewAsAccess ViewAsAccess { get; }
// Pre-compiled regex for matching actual request paths against the route template
internal Regex RouteRegex { get; }
public EndpointPolicyEntry(string httpMethod, string routeTemplate, EndpointPolicy policy,
TenantScoping tenantScoping = TenantScoping.None, bool excludeDelegated = false, bool applicationAllowed = false,
bool allowedDuringOffboarding = false)
bool allowedDuringOffboarding = false, ViewAsAccess? viewAsAccess = null)
{
HttpMethod = httpMethod.ToUpperInvariant();
RouteTemplate = routeTemplate;
@@ -164,6 +183,7 @@ public sealed class EndpointPolicyEntry
ExcludeDelegated = excludeDelegated;
ApplicationAllowed = applicationAllowed;
AllowedDuringOffboarding = allowedDuringOffboarding;
ViewAsAccess = viewAsAccess ?? (HttpMethod == "GET" ? ViewAsAccess.Read : ViewAsAccess.Write);
RouteRegex = BuildRouteRegex(routeTemplate);
}
@@ -236,12 +256,13 @@ public static class EndpointAccessPolicyCatalog
// ── AuthenticatedUser ───────────────────────────────────────────
new("GET", "auth/me", EndpointPolicy.AuthenticatedUser),
new("PUT", "auth/me/whats-new-seen", EndpointPolicy.AuthenticatedUser),
// The caller's own per-user marker under the JWT tenant — also in view-as mode, where What's new opens with it.
new("PUT", "auth/me/whats-new-seen", EndpointPolicy.AuthenticatedUser, viewAsAccess: ViewAsAccess.Read),
// Terms + DPA acceptance: writes only the caller's own home tenant row (token tid), and only
// while that row asks for it — the portal dialog of a tenant onboarded without the signup tick.
new("POST", "auth/dpa-acceptance", EndpointPolicy.AuthenticatedUser),
new("GET", "auth/is-global-admin", EndpointPolicy.AuthenticatedUser),
new("POST", "realtime/negotiate", EndpointPolicy.AuthenticatedUser),
new("POST", "realtime/negotiate", EndpointPolicy.AuthenticatedUser, viewAsAccess: ViewAsAccess.Read),
// Group join/leave is AuthenticatedUserWithRole — NOT MemberRead — on purpose. The real,
// per-group authorization lives inside SignalRAddToGroup/RemoveFromGroupFunction: it
// requires the requested group's tenant to match the caller's JWT tenant (platform-scope
@@ -260,8 +281,8 @@ public static class EndpointAccessPolicyCatalog
// still resolves IsGlobalAdmin/IsTenantAdmin/UserRole — the function's per-group gates
// depend on those flags; plain AuthenticatedUser leaves them always-false and would break
// GA/admin group joins. (Regression history: c4dabeee elevated these to MemberRead.)
new("POST", "realtime/groups/join", EndpointPolicy.AuthenticatedUserWithRole),
new("POST", "realtime/groups/leave", EndpointPolicy.AuthenticatedUserWithRole),
new("POST", "realtime/groups/join", EndpointPolicy.AuthenticatedUserWithRole, viewAsAccess: ViewAsAccess.Read),
new("POST", "realtime/groups/leave", EndpointPolicy.AuthenticatedUserWithRole, viewAsAccess: ViewAsAccess.Read),
// Progress Portal authorization model: serial-number knowledge proof, enforced in-function.
// There is deliberately NO tenant-wide session list at this tier — the lookup resolves at
// most ONE session server-side (roleless callers: exact serial/device-name match; members/GA
@@ -351,7 +372,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "audit/device-not-registered", EndpointPolicy.MemberRead),
new("GET", "audit/tpm-pss-unsupported", EndpointPolicy.MemberRead),
new("GET", "diagnostics/download-url", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("POST", "diagnostics/download-ticket", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("POST", "diagnostics/download-ticket", EndpointPolicy.MemberRead, TenantScoping.QueryParam, viewAsAccess: ViewAsAccess.Read),
// Platform-defined diagnostics collection (built-in catalog + global paths): read-only,
// no secrets, needed by every settings viewer — member tier, JWT-scoped (no tenant parameter).
new("GET", "diagnostics/paths", EndpointPolicy.MemberRead, TenantScoping.Jwt),
@@ -362,10 +383,10 @@ public static class EndpointAccessPolicyCatalog
// against a session's events and persists NOTHING (DryRunAnalyzeRuleFunction). Admin-read
// tier so the read-only Global Reader can help debug rules while tenant Operators/Viewers
// stay excluded from the authoring surface.
new("POST", "rules/analyze/dryrun", EndpointPolicy.TenantAdminOrGlobalReader),
new("POST", "rules/analyze/dryrun", EndpointPolicy.TenantAdminOrGlobalReader, viewAsAccess: ViewAsAccess.Read),
// Same shape: POST body in, pure compute out (regex vs pasted sample lines with the
// agent's exact .NET matching semantics) — touches no tenant data at all.
new("POST", "rules/gather/test-pattern", EndpointPolicy.TenantAdminOrGlobalReader),
new("POST", "rules/gather/test-pattern", EndpointPolicy.TenantAdminOrGlobalReader, viewAsAccess: ViewAsAccess.Read),
// Member tier so Operators/Viewers can see their tenant's configuration read-only in the
// Settings UI. Safe because the handler redacts by default: only a Global Admin or the
// tenant's OWN admin gets the unredacted secrets — every other admitted caller (Operator,
@@ -418,7 +439,8 @@ public static class EndpointAccessPolicyCatalog
// Farewell feedback is submitted AFTER the offboard endpoint has tombstoned the tenant (Disabled=true
// before the 202), so the suspension gate must let the departing admin through — for this route only.
new("POST", "tenants/{tenantId}/offboard/feedback", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam, allowedDuringOffboarding: true),
new("GET", "config/{tenantId}/autopilot-device-validation/consent-url", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
// Starts the admin-consent flow (ops events with the caller's UPN): an action, although a GET.
new("GET", "config/{tenantId}/autopilot-device-validation/consent-url", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam, viewAsAccess: ViewAsAccess.Write),
new("GET", "config/{tenantId}/autopilot-device-validation/consent-status", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("POST", "config/{tenantId}/autopilot-device-validation/consent-failure", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
new("POST", "config/{tenantId}/autopilot-device-validation/consent-success", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
@@ -438,9 +460,10 @@ public static class EndpointAccessPolicyCatalog
// expose ClientId and invalidate caches.
// POST (not GET) so payloads with 100+ script refs comfortably fit in the request
// body without hitting URL-length limits in browsers / Azure Functions front door.
new("POST", "tenants/{tenantId}/scripts/display-names", EndpointPolicy.MemberRead, TenantScoping.RouteParam),
new("POST", "tenants/{tenantId}/scripts/display-names", EndpointPolicy.MemberRead, TenantScoping.RouteParam, viewAsAccess: ViewAsAccess.Read),
new("GET", "tenants/{tenantId}/graph-permissions/status", EndpointPolicy.TenantAdminOrGlobalReader, TenantScoping.RouteParam),
new("POST", "tenants/{tenantId}/graph-permissions/refresh", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
// Clears the in-memory Graph cache only — a re-read, nothing stored.
new("POST", "tenants/{tenantId}/graph-permissions/refresh", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam, viewAsAccess: ViewAsAccess.Read),
// ── BootstrapManagerOrGA ────────────────────────────────────────
new("GET", "bootstrap/sessions", EndpointPolicy.BootstrapManagerOrGA, TenantScoping.QueryParam),
@@ -575,12 +598,11 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/metrics/sla", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/metrics/rule-stats", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/metrics/vulnerability", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
// A named tenant's MCP use — its accounts (UPNs) and their tools, the daily counts, the organization view.
// The handlers read ?tenantId= themselves: deliberately None, not QueryParam, so the delegated path never
// reaches them.
new("GET", "global/metrics/mcp-usage", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/mcp-usage/daily", EndpointPolicy.GlobalReadOrAdmin),
// MCP use across tenants: a named tenant's organization budget by account (UPNs) and the all-tenant overview
// with every tenant's accounts and tools. The organization handler reads ?tenantId= itself: deliberately None,
// not QueryParam, so the delegated path never reaches either.
new("GET", "global/metrics/mcp-usage/organization", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/metrics/mcp-usage/overview", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "global/distress-reports", EndpointPolicy.GlobalReadOrAdmin),
// The operator's report queue: GA-only AdminNote, the submitter's comment and contact data. ?tenantId= is a
// filter the handler reads itself; None, so no delegated reader reaches it.
@@ -1,7 +1,7 @@
using System;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Security
{
@@ -39,16 +39,15 @@ namespace AutopilotMonitor.Functions.Security
public static EntraTokenRejection Parse(int statusCode, string? body)
{
var json = TryParseObject(body);
var description = (json?["error_description"] as JValue)?.Value as string;
var description = json is { } root ? UpstreamJson.GetString(root, "error_description") : null;
return new EntraTokenRejection(statusCode, ReadCode(json, description), Shorten(description));
}
private static JObject? TryParseObject(string? body)
private static JsonElement? TryParseObject(string? body)
{
if (string.IsNullOrWhiteSpace(body)) return null;
try
{
return JsonConvert.DeserializeObject<JToken>(body) as JObject;
return UpstreamJson.ParseObjectOrNull(body);
}
catch (JsonException)
{
@@ -56,10 +55,17 @@ namespace AutopilotMonitor.Functions.Security
}
}
private static string? ReadCode(JObject? json, string? description)
private static string? ReadCode(JsonElement? json, string? description)
{
var first = (json?["error_codes"] as JArray)?.FirstOrDefault(t => t.Type == JTokenType.Integer);
if (first != null) return CodePrefix + first.Value<long>();
if (json is { } root)
{
// The first whole number that fits Int64; text, fractions and larger numbers are skipped.
foreach (var code in UpstreamJson.Items(root, "error_codes"))
{
if (code.ValueKind == JsonValueKind.Number && code.TryGetInt64(out var value))
return CodePrefix + value;
}
}
// Fallback for a body without error_codes: the description starts with the code.
var start = description?.IndexOf(CodePrefix, StringComparison.Ordinal) ?? -1;
@@ -9,8 +9,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Security
{
@@ -304,13 +303,12 @@ namespace AutopilotMonitor.Functions.Security
if (response.IsSuccessStatusCode)
{
var tokenJson = JsonConvert.DeserializeObject<JObject>(responseBody);
var accessToken = tokenJson?["access_token"]?.ToString();
var (accessToken, expiresInSeconds) = ReadTokenResponse(responseBody);
if (!string.IsNullOrEmpty(accessToken))
{
// Cache the freshly minted token (never log its value). TTL derives from
// the token's own expires_in, minus a skew margin, clamped to a safe band.
CacheToken(tenantId, clientId, accessToken!, genAtStart, (long?)tokenJson?["expires_in"]);
CacheToken(tenantId, clientId, accessToken!, genAtStart, expiresInSeconds);
}
return GraphTokenResult.Minted(accessToken);
}
@@ -468,12 +466,26 @@ namespace AutopilotMonitor.Functions.Security
return false;
}
/// <summary>
/// Reads a successful token response: the access token, and expires_in only when a token came
/// back. A body that is not a JSON object throws <see cref="System.Text.Json.JsonException"/>, which the
/// retry loop does not catch: it leaves the token service like any other unexpected failure.
/// </summary>
internal static (string? AccessToken, long? ExpiresInSeconds) ReadTokenResponse(string responseBody)
{
if (UpstreamJson.ParseObjectOrNull(responseBody) is not { } tokenJson)
return (null, null);
var accessToken = UpstreamJson.GetString(tokenJson, "access_token");
return string.IsNullOrEmpty(accessToken) ? (accessToken, null) : (accessToken, UpstreamJson.GetInt64(tokenJson, "expires_in"));
}
private void CacheToken(string tenantId, string clientId, string accessToken, long genAtStart, long? expiresInSeconds)
{
// expires_in is reported by AAD over TLS, so it is trustworthy — but still clamped so a
// missing or pathological value cannot pin a stale token (cap) or cause thrash (floor).
// The seconds are capped before the conversion: a TimeSpan cannot hold the far end of a long.
var lifetime = expiresInSeconds is > 0
? TimeSpan.FromSeconds(expiresInSeconds.Value)
? TimeSpan.FromSeconds(Math.Min(expiresInSeconds.Value, (long)(MaxTokenTtl + TokenSkewMargin).TotalSeconds))
: DefaultTokenLifetime;
var ttl = lifetime - TokenSkewMargin;
if (ttl < MinTokenTtl) ttl = MinTokenTtl;
@@ -8,8 +8,8 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Security
{
@@ -401,40 +401,42 @@ namespace AutopilotMonitor.Functions.Security
ErrorMessage = $"Device '{normalizedId}' is not an enrolled Intune device in this tenant"
};
JObject? device;
JsonElement? parsed;
try
{
device = JsonConvert.DeserializeObject<JObject>(responseBody);
parsed = UpstreamJson.ParseObjectOrNull(responseBody);
}
catch (JsonException)
{
return notFound;
}
var returnedId = device?["id"]?.ToString()?.Trim();
if (parsed is not { } device)
return notFound;
var returnedId = UpstreamJson.GetString(device, "id")?.Trim();
if (string.IsNullOrEmpty(returnedId) ||
!string.Equals(returnedId, normalizedId, StringComparison.OrdinalIgnoreCase))
{
return notFound;
}
var managementState = device?["managementState"]?.ToString();
var managementState = UpstreamJson.GetString(device, "managementState");
var leaving = managementState != null && LeavingManagementStates.Contains(managementState);
return new IntuneDeviceBindingResult
{
Outcome = leaving ? IntuneDeviceBindingOutcome.NotManaged : IntuneDeviceBindingOutcome.Match,
IntuneDeviceId = normalizedId,
DeviceName = device?["deviceName"]?.ToString(),
AzureAdDeviceId = device?["azureADDeviceId"]?.ToString(),
DeviceName = UpstreamJson.GetString(device, "deviceName"),
AzureAdDeviceId = UpstreamJson.GetString(device, "azureADDeviceId"),
ManagementState = managementState,
SerialNumber = NullIfEmpty(device?["serialNumber"]?.ToString()),
OwnerType = NullIfEmpty(device?["managedDeviceOwnerType"]?.ToString()),
EnrollmentType = NullIfEmpty(device?["deviceEnrollmentType"]?.ToString()),
AzureAdRegistered = device?["azureADRegistered"]?.Type == JTokenType.Boolean
? device["azureADRegistered"]!.Value<bool>()
: null,
EnrolledDateTime = ParseEnrolledDateTime(device?["enrolledDateTime"]?.ToString()),
SerialNumber = NullIfEmpty(UpstreamJson.GetString(device, "serialNumber")),
OwnerType = NullIfEmpty(UpstreamJson.GetString(device, "managedDeviceOwnerType")),
EnrollmentType = NullIfEmpty(UpstreamJson.GetString(device, "deviceEnrollmentType")),
AzureAdRegistered = UpstreamJson.GetBoolean(device, "azureADRegistered"),
// Intune returns "0001-01-01T00:00:00Z" when it has no enrollment timestamp: absence, not a date.
EnrolledDateTime = UpstreamJson.GetUtcDateTime(device, "enrolledDateTime"),
ErrorMessage = leaving
? $"Device '{normalizedId}' is being removed from Intune management (state {managementState})"
: null
@@ -448,28 +450,6 @@ namespace AutopilotMonitor.Functions.Security
internal static bool IsRecentEnrollment(DateTimeOffset? certNotBefore, DateTimeOffset now)
=> certNotBefore.HasValue && now - certNotBefore.Value < RecentEnrollmentWindow;
/// <summary>
/// Parses Graph's <c>enrolledDateTime</c>. Intune returns <c>0001-01-01T00:00:00Z</c> for
/// devices it has no enrollment timestamp for; that is absence, not a date, so it maps to
/// <c>null</c> rather than a bogus age of two thousand years.
/// </summary>
internal static DateTimeOffset? ParseEnrolledDateTime(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
if (!DateTimeOffset.TryParse(
raw,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.AdjustToUniversal
| System.Globalization.DateTimeStyles.AssumeUniversal,
out var parsed))
{
return null;
}
return parsed.Year <= 1 ? null : parsed;
}
private static string? NullIfEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
internal static string BuildCacheKey(string tenantId, string intuneDeviceId)
@@ -0,0 +1,56 @@
using AutopilotMonitor.Shared;
namespace AutopilotMonitor.Functions.Security;
/// <summary>
/// "View as tenant": a Global Admin reads one tenant's portal exactly as a member of that tenant sees it,
/// read-only. The portal tab sends these headers on every API call; <c>PolicyEnforcementMiddleware</c>
/// admits the request only for a Global Admin, refuses every write, and builds the request context of the
/// emulated member.
/// </summary>
public static class ViewAsHeaders
{
public const string Tenant = "X-View-As-Tenant";
public const string Role = "X-View-As-Role";
}
/// <summary>A parsed view-as request: the tenant to view (lowercase GUID) and the tenant role to emulate.</summary>
public sealed record ViewAsTarget(string TenantId, string Role)
{
/// <summary>Headers that are present but unusable: not a GUID, or not one of the three tenant roles.</summary>
public static readonly ViewAsTarget Invalid = new(string.Empty, string.Empty);
/// <summary>The one answer to anything that would change state in view-as mode (portal error text).</summary>
public const string ReadOnlyMessage = "Read-only view: changes are disabled while you view this tenant.";
public bool IsValid => TenantId.Length > 0;
/// <summary>
/// Null when no tenant header is sent (an ordinary request); <see cref="Invalid"/> when it is malformed.
/// The role defaults to Admin.
/// </summary>
public static ViewAsTarget? FromHeaders(string? tenantHeader, string? roleHeader)
{
if (string.IsNullOrWhiteSpace(tenantHeader))
return null;
if (!Guid.TryParse(tenantHeader.Trim(), out var tenant) || tenant == Guid.Empty)
return Invalid;
var role = NormalizeRole(roleHeader);
return role == null ? Invalid : new ViewAsTarget(tenant.ToString("D"), role);
}
private static string? NormalizeRole(string? roleHeader)
{
if (string.IsNullOrWhiteSpace(roleHeader))
return Constants.TenantRoles.Admin;
var value = roleHeader.Trim();
foreach (var role in new[] { Constants.TenantRoles.Admin, Constants.TenantRoles.Operator, Constants.TenantRoles.Viewer })
{
if (string.Equals(value, role, StringComparison.OrdinalIgnoreCase))
return role;
}
return null;
}
}
@@ -12,7 +12,8 @@ using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models.Graph;
using Microsoft.ApplicationInsights;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json.Linq;
using System.Text.Json;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Services.GraphResolution;
@@ -204,8 +205,10 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
{
await WriteCacheEntryAsync(tenantId, r, displayName: null, fileName: null, notFound: true, budget).ConfigureAwait(false);
}
else if (displayName != null)
else
{
// A 200 answers for good even without a name: cached like the full pull caches it, a nameless
// script costs no Graph call on every view until the next full pull.
result[r] = displayName;
await WriteCacheEntryAsync(tenantId, r, displayName, fileName: null, notFound: false, budget).ConfigureAwait(false);
}
@@ -284,15 +287,9 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
throw new HttpRequestException($"Graph LIST {url} returned {(int)status}");
}
var json = JObject.Parse(body);
var values = json["value"] as JArray ?? new JArray();
foreach (var item in values.OfType<JObject>())
var (scripts, nextUrl) = ReadScriptPage(body, withFileName: kind == ScriptKind.Platform);
foreach (var (id, displayName, fileName) in scripts)
{
var id = item["id"]?.ToString();
if (string.IsNullOrEmpty(id)) continue;
var displayName = item["displayName"]?.ToString();
var fileName = kind == ScriptKind.Platform ? item["fileName"]?.ToString() : null;
entries.Add(new ScriptDisplayNameEntry
{
TenantId = tenantId,
@@ -314,7 +311,7 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
}
}
url = json["@odata.nextLink"]?.ToString();
url = nextUrl;
}
if (entries.Count > 0)
@@ -352,10 +349,38 @@ public sealed class ScriptDisplayNameResolver : IScriptDisplayNameResolver
throw new HttpRequestException($"Graph GET {url} returned {(int)status}");
}
var json = JObject.Parse(body);
return (json["displayName"]?.ToString(), false);
return (ReadScriptDisplayName(body), false);
}
/// <summary>
/// Reads one page of a script list: id, display name and (platform scripts only) file name of
/// every object entry with an id, plus the next page's url. A body that is not a JSON object
/// throws, which ends the full pull and falls back to per-ID lookups.
/// </summary>
internal static (List<ScriptListItem> Scripts, string? NextUrl) ReadScriptPage(string body, bool withFileName)
{
var json = ParseScriptObject(body);
var scripts = new List<ScriptListItem>();
foreach (var item in UpstreamJson.Items(json, "value").Where(i => i.ValueKind == JsonValueKind.Object))
{
var id = UpstreamJson.GetString(item, "id");
if (string.IsNullOrEmpty(id)) continue;
scripts.Add(new ScriptListItem(id!, UpstreamJson.GetString(item, "displayName"),
withFileName ? UpstreamJson.GetString(item, "fileName") : null));
}
return (scripts, UpstreamJson.GetNextLink(json));
}
/// <summary>Reads the display name of a single-script GET; a body that is not a JSON object throws.</summary>
internal static string? ReadScriptDisplayName(string body) => UpstreamJson.GetString(ParseScriptObject(body), "displayName");
// An empty or null body must throw like any other unreadable one: the full pull then falls back to
// per-ID lookups instead of recording the list as fetched.
private static JsonElement ParseScriptObject(string body)
=> UpstreamJson.ParseObjectOrNull(body) ?? throw new JsonException("The Graph script response is empty or null.");
internal readonly record struct ScriptListItem(string Id, string? DisplayName, string? FileName);
private async Task WriteCacheEntryAsync(
string tenantId, ScriptRef r, string? displayName, string? fileName, bool notFound, CancellationToken ct)
{
@@ -0,0 +1,184 @@
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.Shared.Models;
using Azure.Core;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// What one Kusto query against a telemetry store returned: the tables (plus the store's partial-result
/// note) on success, or the store's status code and body when it answered non-2xx.
/// </summary>
public sealed class LogQueryOutcome
{
public IReadOnlyList<KqlTable>? Tables { get; init; }
public string? PartialReason { get; init; }
public HttpStatusCode? UpstreamStatus { get; init; }
public string? UpstreamBody { get; init; }
public long ElapsedMs { get; init; }
public bool Succeeded => Tables != null;
}
/// <summary>
/// Runs one Kusto query against a store from <see cref="LogQuerySourceCatalog"/> under the Function App's
/// managed identity and a wall-clock budget (<c>Prefer: wait</c> upstream, a cancellation token here, no
/// static client timeout). Shared by the GA-only KQL proxy (<c>QueryBackendLogs</c>), which forwards an
/// operator's query verbatim, and by the fixed aggregate queries of the MCP usage overview. Throws
/// <see cref="OperationCanceledException"/> when the budget runs out and lets credential errors through;
/// each caller decides how to answer.
/// </summary>
public class LogQueryExecutor
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly TokenCredential _credential;
public LogQueryExecutor(IHttpClientFactory httpClientFactory, TokenCredential credential)
{
_httpClientFactory = httpClientFactory;
_credential = credential;
}
public virtual async Task<LogQueryOutcome> ExecuteAsync(LogQuerySource source, string query, string timespan, int budgetSeconds)
{
var stopwatch = Stopwatch.StartNew();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(budgetSeconds));
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={budgetSeconds}");
upstreamRequest.Content = new StringContent(
JsonSerializer.Serialize(new KustoQuery(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)
{
return new LogQueryOutcome
{
UpstreamStatus = upstreamResponse.StatusCode,
UpstreamBody = responseText,
ElapsedMs = stopwatch.ElapsedMilliseconds,
};
}
var (tables, partialReason) = ParseKustoBody(responseText);
return new LogQueryOutcome { Tables = tables, PartialReason = partialReason, ElapsedMs = stopwatch.ElapsedMilliseconds };
}
/// <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);
}
/// <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);
}
}
@@ -13,7 +13,6 @@ using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services
{
@@ -0,0 +1,42 @@
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// The quota wire nodes the MCP usage views share (the tenant's reporting page, the GA overview), built from the
/// quota counters and resolved plan limits — one place, so both views show the same windows the same way.
/// </summary>
internal static class McpQuotaNodes
{
/// <summary>
/// Pure: the tenant's organization windows from its counter rows — today's and this month's total over every
/// account charged to the tenant, against the tenant plan's limits (0 = unlimited).
/// </summary>
internal static McpOrganizationQuotaNode BuildOrganizationQuota(
IEnumerable<TenantUsageRecord> records, McpQuotaService.TenantPlanLimits limits, string today, string monthStart)
{
long dailyUsed = 0, monthlyUsed = 0;
foreach (var record in records)
{
if (record.Date == today) dailyUsed += record.RequestCount;
if (string.CompareOrdinal(record.Date, monthStart) >= 0 && string.CompareOrdinal(record.Date, today) <= 0)
monthlyUsed += record.RequestCount;
}
return new McpOrganizationQuotaNode
{
TenantPlan = limits.TenantPlan,
DailyLimit = limits.TenantDailyLimit,
MonthlyLimit = limits.TenantMonthlyLimit,
DailyUsed = dailyUsed,
MonthlyUsed = monthlyUsed,
PurchasedDelegatedSlots = WithSlots(limits.PurchasedDelegatedSlots, limits.PurchasedDelegatedSlots),
SlotDailyLimit = WithSlots(limits.PurchasedDelegatedSlots, limits.SlotTenantDailyLimit),
SlotMonthlyLimit = WithSlots(limits.PurchasedDelegatedSlots, limits.SlotTenantMonthlyLimit),
};
}
/// <summary>The slot breakdown is only worth sending when a slot was actually purchased.</summary>
internal static int? WithSlots(int purchasedSlots, int value) => purchasedSlots > 0 ? value : null;
}
@@ -3,9 +3,9 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.Caching;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models.Config;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services
@@ -38,23 +38,28 @@ namespace AutopilotMonitor.Functions.Services
/// Counters: user counters ride on the UserUsageLog table (PK = oid), tenant counters on the
/// McpTenantUsage table (PK = home tenantId, RK = {yyyyMMdd}_{oid} — one partition read per check, no
/// hot row); both are written fire-and-forget by McpQuotaEnforcementMiddleware for X-Client-Source: mcp
/// requests. Daily = today's rows, monthly = the sum over the month. The usage SNAPSHOTS are cached for
/// 60 seconds — per user (oid) and per tenant — so the worst-case overshoot is bounded
/// (limit + 60s × request-rate) — the same posture as the sliding-window rate limiter. Limits are
/// re-resolved per check from services that carry their own caches, so a plan change applies at once.
/// requests. Daily = today's rows, monthly = the sum over the month. Each instance reads an account's and an
/// organization's counters from storage at most every 60 seconds and counts every request it admits on
/// them at once (<see cref="AdmitAsync"/>), so one instance blocks exactly at the limit. Across scaled-out
/// instances the boundary stays soft by what the OTHER instances admitted inside those 60 seconds, plus
/// increments still on their way to storage. Limits are re-resolved per check from services that carry their
/// own caches, so a plan change applies at once.
/// </summary>
public class McpQuotaService
{
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60);
internal static string UserCacheKey(string oid) => $"mcp-quota:user:{oid}";
internal static string TenantCacheKey(string tenantId) => $"mcp-quota:tenant:{tenantId.ToLowerInvariant()}";
// The UTC day is part of the key: a counter read before midnight never answers a check after it.
private static string UserCacheKey(string oid, string day) => $"mcp-quota:user:{oid}:{day}";
private static string TenantCacheKey(string tenantId, string day) => $"mcp-quota:tenant:{tenantId.ToLowerInvariant()}:{day}";
private readonly IUserUsageRepository _usageRepo;
private readonly McpUserService _mcpUserService;
private readonly AdminConfigurationService _adminConfigService;
private readonly TenantEntitlementService _entitlementService;
private readonly IMemoryCache _cache;
// Single-flight: concurrent first checks of one account share ONE counter instead of each counting on a
// private copy that the last cache write would discard.
private readonly SingleFlightCache<UsageCounter> _counters;
private readonly ILogger<McpQuotaService> _logger;
private readonly TimeProvider _time;
@@ -63,19 +68,17 @@ namespace AutopilotMonitor.Functions.Services
McpUserService mcpUserService,
AdminConfigurationService adminConfigService,
TenantEntitlementService entitlementService,
IMemoryCache cache,
ILogger<McpQuotaService> logger)
: this(usageRepo, mcpUserService, adminConfigService, entitlementService, cache, logger, TimeProvider.System)
: this(usageRepo, mcpUserService, adminConfigService, entitlementService, logger, TimeProvider.System)
{
}
/// <summary>Test seam — inject a fake <see cref="TimeProvider"/> for deterministic window math.</summary>
/// <summary>Test seam — inject a fake <see cref="TimeProvider"/> for deterministic window math and counter expiry.</summary>
public McpQuotaService(
IUserUsageRepository usageRepo,
McpUserService mcpUserService,
AdminConfigurationService adminConfigService,
TenantEntitlementService entitlementService,
IMemoryCache cache,
ILogger<McpQuotaService> logger,
TimeProvider time)
{
@@ -83,19 +86,32 @@ namespace AutopilotMonitor.Functions.Services
_mcpUserService = mcpUserService;
_adminConfigService = adminConfigService;
_entitlementService = entitlementService;
_cache = cache;
_logger = logger;
_time = time;
_counters = new SingleFlightCache<UsageCounter>(() => time.GetUtcNow().UtcDateTime);
}
/// <summary>
/// Resolves the caller's effective plans + limits and checks the current user AND tenant usage against
/// them. <paramref name="tenantId"/> is the token's tid — the identity the per-user override is bound to
/// and the tenant whose organization budget every request of this caller draws on, a delegated read
/// included. Fail-open on counter/storage errors (a broken quota check must not take down MCP);
/// fail-closed on plan resolution (unknown plan → Community).
/// The enforcement step for ONE MCP request the caller is about to serve: resolves the caller's effective
/// plans + limits, checks the current user AND tenant usage against them and, when allowed, counts the
/// request on this instance's counters at once (the middleware also writes it to storage). Only
/// McpQuotaEnforcementMiddleware calls this; a view of the quota uses <see cref="ReadStateAsync"/>.
/// <paramref name="tenantId"/> is the token's tid — the identity the per-user override is bound to and the
/// tenant whose organization budget every request of this caller draws on, a delegated read included. The
/// decision's used values are the ones BEFORE this request. Fail-open on counter/storage errors (a broken
/// quota check must not take down MCP); fail-closed on plan resolution (unknown plan → Community).
/// </summary>
public virtual async Task<McpQuotaDecision> CheckAsync(string oid, string? upn, string? tenantId)
public virtual Task<McpQuotaDecision> AdmitAsync(string oid, string? upn, string? tenantId)
=> EvaluateAsync(oid, upn, tenantId, count: true);
/// <summary>
/// The same decision as <see cref="AdmitAsync"/> for a page that SHOWS the quota: what the caller's next
/// request would meet, without counting anything.
/// </summary>
public virtual Task<McpQuotaDecision> ReadStateAsync(string oid, string? upn, string? tenantId)
=> EvaluateAsync(oid, upn, tenantId, count: false);
private async Task<McpQuotaDecision> EvaluateAsync(string oid, string? upn, string? tenantId, bool count)
{
var nowUtc = _time.GetUtcNow().UtcDateTime;
var limits = await ResolvePlanAsync(AdminIdentity.Create(upn, tenantId, oid), tenantId);
@@ -108,7 +124,27 @@ namespace AutopilotMonitor.Functions.Services
if (tenant == null)
return McpQuotaDecision.FailOpen(limits);
return BuildDecision(limits, user.DailyUsed, user.MonthlyUsed, tenant.DailyUsed, tenant.MonthlyUsed, nowUtc);
return Decide(limits, user, tenant, nowUtc, count);
}
/// <summary>
/// Decision and count in one step, so parallel requests of one caller (a tool call fans out several) can
/// never all pass on the same reading. Every check locks the account's counter before the organization's,
/// so two members of one tenant never wait on each other in a cycle.
/// </summary>
private static McpQuotaDecision Decide(McpPlanLimits limits, UsageCounter user, UsageCounter tenant, DateTime nowUtc, bool count)
{
lock (user)
lock (tenant)
{
var decision = BuildDecision(limits, user.DailyUsed, user.MonthlyUsed, tenant.DailyUsed, tenant.MonthlyUsed, nowUtc);
if (count && decision.Allowed)
{
user.Count();
tenant.Count();
}
return decision;
}
}
/// <summary>
@@ -222,54 +258,49 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>The caller's own counters (cached snapshot). Null = read failed (fail-open, nothing cached).</summary>
private async Task<UsageSnapshot?> ReadUserUsageAsync(string oid, DateTime nowUtc)
/// <summary>The caller's own counter (cached, single-flight). Null = read failed (fail-open, nothing cached).</summary>
private async Task<UsageCounter?> ReadUserUsageAsync(string oid, DateTime nowUtc)
{
var cacheKey = UserCacheKey(oid);
if (_cache.TryGetValue<UsageSnapshot>(cacheKey, out var cached) && cached != null)
return cached;
var (monthStart, today) = Window(nowUtc);
try
{
var (monthStart, today) = Window(nowUtc);
var records = await _usageRepo.GetUsageByUserAsync(oid, monthStart, today);
var snapshot = new UsageSnapshot(
records.Where(r => r.Date == today).Sum(r => r.RequestCount),
records.Sum(r => r.RequestCount));
_cache.Set(cacheKey, snapshot, CacheDuration);
return snapshot;
return await _counters.GetOrAddAsync(UserCacheKey(oid, today), CacheDuration, async () =>
{
var records = await _usageRepo.GetUsageByUserAsync(oid, monthStart, today);
return new UsageCounter(
records.Where(r => r.Date == today).Sum(r => r.RequestCount),
records.Sum(r => r.RequestCount));
});
}
catch (Exception ex)
{
// Fail-open: usage counters unavailable → allow. Do NOT cache — the next request retries the read.
// Fail-open: usage counters unavailable → allow. The failed read is not cached — the next request retries it.
_logger.LogWarning(ex, "[McpQuota] Usage lookup failed for oid={Oid} — allowing (fail-open)", oid);
return null;
}
}
/// <summary>
/// The tenant's organization counters (cached snapshot, shared by every member of that tenant).
/// Skipped — zero counters, no read, nothing cached — when nothing could ever block on it (no tenant,
/// or both windows lifted). Null = read failed (fail-open, nothing cached).
/// The tenant's organization counter (cached, single-flight, shared by every member of that tenant on this
/// instance). Skipped — no read, nothing cached — when nothing could ever block on it (no tenant, or both
/// windows lifted): the check then gets a zero counter of its own, never a shared one, because
/// <see cref="Decide"/> raises whatever it is handed. Null = read failed (fail-open, nothing cached).
/// </summary>
private async Task<UsageSnapshot?> ReadTenantUsageAsync(string? tenantId, McpPlanLimits limits, DateTime nowUtc)
private async Task<UsageCounter?> ReadTenantUsageAsync(string? tenantId, McpPlanLimits limits, DateTime nowUtc)
{
if (string.IsNullOrWhiteSpace(tenantId) || (limits.TenantDailyLimit <= 0 && limits.TenantMonthlyLimit <= 0))
return UsageSnapshot.Zero;
var cacheKey = TenantCacheKey(tenantId);
if (_cache.TryGetValue<UsageSnapshot>(cacheKey, out var cached) && cached != null)
return cached;
return new UsageCounter(0, 0);
var (monthStart, today) = Window(nowUtc);
try
{
var (monthStart, today) = Window(nowUtc);
var records = await _usageRepo.GetTenantUsageAsync(tenantId, monthStart, today);
var snapshot = new UsageSnapshot(
records.Where(r => r.Date == today).Sum(r => r.RequestCount),
records.Sum(r => r.RequestCount));
_cache.Set(cacheKey, snapshot, CacheDuration);
return snapshot;
return await _counters.GetOrAddAsync(TenantCacheKey(tenantId, today), CacheDuration, async () =>
{
var records = await _usageRepo.GetTenantUsageAsync(tenantId, monthStart, today);
return new UsageCounter(
records.Where(r => r.Date == today).Sum(r => r.RequestCount),
records.Sum(r => r.RequestCount));
});
}
catch (Exception ex)
{
@@ -334,10 +365,27 @@ namespace AutopilotMonitor.Functions.Services
private static bool Exceeded(int limit, long used) => limit > 0 && used >= limit;
/// <summary>Cached counter pair (today / this month) for one user or one tenant.</summary>
private sealed record UsageSnapshot(long DailyUsed, long MonthlyUsed)
/// <summary>
/// Today / this month for one account or one organization as this instance knows it: read from storage at
/// most every <see cref="CacheDuration"/>, raised by every request this instance admits in between. Only
/// <see cref="Decide"/> touches it, under its monitor.
/// </summary>
private sealed class UsageCounter
{
public static readonly UsageSnapshot Zero = new(0, 0);
public UsageCounter(long dailyUsed, long monthlyUsed)
{
DailyUsed = dailyUsed;
MonthlyUsed = monthlyUsed;
}
public long DailyUsed { get; private set; }
public long MonthlyUsed { get; private set; }
public void Count()
{
DailyUsed++;
MonthlyUsed++;
}
}
/// <summary>The home tenant's plan facts a resolution needs, read once per check.</summary>
@@ -0,0 +1,592 @@
using System.Globalization;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
using static AutopilotMonitor.Functions.Services.McpUsageTelemetryQueries;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Builds the all-tenant MCP usage overview of the Global Admin metrics page (D-416). Two kinds of source, never
/// mixed inside one number: the quota counters (<c>McpTenantUsage</c> for tenants, accounts and days; each
/// account's quota state through <see cref="McpQuotaService.ReadStateAsync"/>, exactly what its next request
/// meets) for every "request" and every window, and the telemetry stores (<see cref="McpUsageTelemetryQueries"/>)
/// for calls, tools, refusals and per-minute peaks. A store that does not answer empties only its own parts.
/// </summary>
public class McpUsageOverviewService
{
internal const int MaxDays = 90;
private const int TelemetryBudgetSeconds = 60;
private const int AccountReadParallelism = 8;
private readonly IUserUsageRepository _usageRepo;
private readonly McpQuotaService _quota;
private readonly GlobalAdminService _globalAdmins;
private readonly AdminConfigurationService _adminConfig;
private readonly TenantConfigurationService _tenantConfig;
private readonly TenantEntitlementService _entitlements;
private readonly LogQuerySourceCatalog _sources;
private readonly LogQueryExecutor _executor;
private readonly ILogger<McpUsageOverviewService> _logger;
private readonly TimeProvider _time;
public McpUsageOverviewService(
IUserUsageRepository usageRepo, McpQuotaService quota, GlobalAdminService globalAdmins,
AdminConfigurationService adminConfig, TenantConfigurationService tenantConfig, TenantEntitlementService entitlements,
LogQuerySourceCatalog sources, LogQueryExecutor executor, ILogger<McpUsageOverviewService> logger)
: this(usageRepo, quota, globalAdmins, adminConfig, tenantConfig, entitlements, sources, executor, logger, TimeProvider.System)
{
}
internal McpUsageOverviewService(
IUserUsageRepository usageRepo, McpQuotaService quota, GlobalAdminService globalAdmins,
AdminConfigurationService adminConfig, TenantConfigurationService tenantConfig, TenantEntitlementService entitlements,
LogQuerySourceCatalog sources, LogQueryExecutor executor, ILogger<McpUsageOverviewService> logger, TimeProvider time)
{
_usageRepo = usageRepo;
_quota = quota;
_globalAdmins = globalAdmins;
_adminConfig = adminConfig;
_tenantConfig = tenantConfig;
_entitlements = entitlements;
_sources = sources;
_executor = executor;
_logger = logger;
_time = time;
}
public async Task<GetGlobalMcpUsageOverviewResponse> BuildAsync(int days, bool includePlatformAdmins)
{
var nowUtc = _time.GetUtcNow().UtcDateTime;
var windowDays = Math.Clamp(days, 1, MaxDays);
var window = new Window(nowUtc.Date.AddDays(1 - windowDays), nowUtc.Date.AddDays(1), includePlatformAdmins);
var dates = Dates.For(window, nowUtc);
var countersTask = _usageRepo.GetAllTenantUsageAsync(dates.ReadFrom, dates.Today);
var telemetryTask = ReadTelemetryAsync(window);
await Task.WhenAll(countersTask, telemetryTask);
var counters = countersTask.Result;
var telemetry = telemetryTask.Result;
// Every counter account with use in the window; its quota state and platform role come from the services.
var principals = PrincipalsByAccount(counters);
var counterAccounts = counters
.Where(r => !string.IsNullOrEmpty(r.UserId) && dates.InWindow(r.Date))
.Select(r => (Tenant: r.TenantId.ToLowerInvariant(), r.UserId))
.Distinct()
.ToList();
var telemetryRoles = LatestRoles(telemetry.Calls);
var facts = new System.Collections.Concurrent.ConcurrentDictionary<(string Tenant, string Principal), AccountFacts>();
await Parallel.ForEachAsync(counterAccounts, new ParallelOptions { MaxDegreeOfParallelism = AccountReadParallelism }, async (account, _) =>
{
var principal = principals[account];
var platformRole = await _globalAdmins.GetGlobalRoleAsync(AdminIdentity.Create(principal, account.Tenant, account.UserId));
var state = await _quota.ReadStateAsync(account.UserId, principal, account.Tenant);
facts[(account.Tenant, principal)] = new AccountFacts(account.UserId, platformRole, state);
});
// Every tenant the overview names — the per-minute rows included — with its plan and its configuration, read once.
var tenants = counters.Select(r => r.TenantId.ToLowerInvariant())
.Concat(telemetry.Calls.Select(r => r.Tenant))
.Concat(telemetry.Peaks.Select(p => p.Tenant))
.Concat(telemetry.Refusals.Select(r => r.Tenant))
.Where(t => !string.IsNullOrEmpty(t))
.Distinct(StringComparer.Ordinal)
.ToList();
var tenantLimits = new System.Collections.Concurrent.ConcurrentDictionary<string, McpQuotaService.TenantPlanLimits>(StringComparer.Ordinal);
var tenantConfigs = new System.Collections.Concurrent.ConcurrentDictionary<string, TenantConfiguration?>(StringComparer.Ordinal);
await Parallel.ForEachAsync(tenants, new ParallelOptions { MaxDegreeOfParallelism = AccountReadParallelism }, async (tenant, _) =>
{
tenantLimits[tenant] = await _quota.ResolveTenantPlanAsync(tenant);
tenantConfigs[tenant] = await ReadTenantConfigurationAsync(tenant);
});
var minuteLimits = await ResolveMinuteLimitsAsync(telemetry, facts, telemetryRoles, tenantConfigs);
var tenantNames = tenantConfigs
.Where(c => !string.IsNullOrWhiteSpace(c.Value?.DomainName))
.ToDictionary(c => c.Key, c => c.Value!.DomainName, StringComparer.Ordinal);
return Assemble(new OverviewInputs(
dates, includePlatformAdmins, counters, facts, tenantLimits, tenantNames, telemetryRoles, minuteLimits, telemetry));
}
/// <summary>
/// The tenant's configuration (cached) for its name and its per-minute override. A storage failure leaves both
/// out and is logged; it never fails the overview, which is about usage, not configuration.
/// </summary>
internal async Task<TenantConfiguration?> ReadTenantConfigurationAsync(string tenantId)
{
try
{
return await _tenantConfig.GetConfigurationIfExistsAsync(tenantId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[McpUsageOverview] Configuration of tenant {TenantId} unreadable: name and per-minute override left out",
LogSanitizer.Clean(tenantId));
return null;
}
}
// ── Telemetry ──────────────────────────────────────────────────────────────────
private async Task<TelemetryResult> ReadTelemetryAsync(Window window)
{
var calls = RunAsync(LogQuerySources.Backend, Calls(window), window, nameof(Calls));
var routes = RunAsync(LogQuerySources.Backend, Routes(window), window, nameof(Routes));
var refusals = RunAsync(LogQuerySources.Backend, Refusals(window), window, nameof(Refusals));
var peaks = RunAsync(LogQuerySources.Backend, Peaks(window), window, nameof(Peaks));
var quality = RunAsync(LogQuerySources.Mcp, ToolQuality(window), window, nameof(ToolQuality));
var mcpRefusals = RunAsync(LogQuerySources.Mcp, McpRefusals(window), window, nameof(McpRefusals));
var serverFacts = RunAsync(LogQuerySources.Mcp, McpServerFacts(window), window, nameof(McpServerFacts));
await Task.WhenAll(calls, routes, refusals, peaks, quality, mcpRefusals, serverFacts);
var all = new[] { calls.Result, routes.Result, refusals.Result, peaks.Result, quality.Result, mcpRefusals.Result, serverFacts.Result };
return new TelemetryResult(
RequestsAvailable: calls.Result.Table != null,
McpLogAvailable: quality.Result.Table != null,
Partial: all.Any(r => r.Partial),
Calls: calls.Result.Table is { } c ? ParseCalls(c) : new(),
Routes: routes.Result.Table is { } r ? ParseRoutes(r) : new(),
Refusals: refusals.Result.Table is { } f ? ParseRefusals(f) : new(),
Peaks: peaks.Result.Table is { } p ? ParsePeaks(p) : new(),
ToolQuality: quality.Result.Table is { } q ? ParseToolQuality(q) : new(),
McpRefusals: mcpRefusals.Result.Table is { } m ? ParseMcpRefusals(m) : new(),
ServerFacts: serverFacts.Result.Table is { } s ? ParseServerFacts(s) : new ServerFacts(null, null));
}
/// <summary>One fixed query; any failure is logged with its cause and leaves the part empty, never the page.</summary>
private async Task<(KqlTable? Table, bool Partial)> RunAsync(string sourceName, string query, Window window, string label)
{
if (!_sources.TryResolve(sourceName, out var source, out var unavailable))
{
_logger.LogWarning("[McpUsageOverview] {Query} skipped: {Reason}", label, unavailable);
return (null, false);
}
try
{
var outcome = await _executor.ExecuteAsync(source!, query, window.Timespan, TelemetryBudgetSeconds);
if (!outcome.Succeeded || outcome.Tables!.Count == 0)
{
var (message, code) = LogQueryExecutor.ExtractError(outcome.UpstreamBody ?? string.Empty);
_logger.LogWarning("[McpUsageOverview] {Query} failed on {Source}: {Status} {Code} {Message}",
label, sourceName, outcome.UpstreamStatus, code, message);
return (null, false);
}
if (outcome.PartialReason != null)
_logger.LogWarning("[McpUsageOverview] {Query} on {Source} was cut: {Reason}", label, sourceName, outcome.PartialReason);
return (outcome.Tables[0], outcome.PartialReason != null);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[McpUsageOverview] {Query} failed on {Source}", label, sourceName);
return (null, false);
}
}
// ── Per-minute limits ──────────────────────────────────────────────────────────
/// <summary>
/// The backend per-minute limit each account with a peak or a refusal meets in its HOME tenant — the same
/// precedence as UserRateLimitMiddleware on the integration surface: the Global Admin budget, else the
/// tenant override, else the default raised to the edition floor (a Global Reader gets neither override nor
/// floor). A delegated reader can meet another tenant's override when it reads there; the page shows home.
/// </summary>
private async Task<Dictionary<(string Tenant, string Principal), int>> ResolveMinuteLimitsAsync(
TelemetryResult telemetry, IReadOnlyDictionary<(string Tenant, string Principal), AccountFacts> facts,
IReadOnlyDictionary<(string Tenant, string Principal), string> telemetryRoles,
IReadOnlyDictionary<string, TenantConfiguration?> tenantConfigs)
{
var config = await _adminConfig.GetConfigurationAsync();
var keys = telemetry.Peaks.Select(p => (p.Tenant, p.Principal))
.Concat(telemetry.Refusals.Where(r => r.Gate == RefusalMinute).Select(r => (r.Tenant, r.Principal)))
.Distinct()
.ToList();
var limits = new Dictionary<(string Tenant, string Principal), int>();
var perTenant = new Dictionary<string, (int? Override, int? Floor)>(StringComparer.Ordinal);
foreach (var key in keys)
{
var role = facts.TryGetValue(key, out var f) && f.PlatformRole != null
? f.PlatformRole
: telemetryRoles.GetValueOrDefault(key);
var isGlobalAdmin = role == Constants.GlobalRoles.GlobalAdmin;
var hasGlobalScope = isGlobalAdmin || role == Constants.GlobalRoles.GlobalReader;
(int? Override, int? Floor) tenantSide = (null, null);
if (!hasGlobalScope && !string.IsNullOrEmpty(key.Tenant))
{
if (!perTenant.TryGetValue(key.Tenant, out tenantSide))
{
var entitlements = await _entitlements.GetEntitlementsAsync(key.Tenant);
tenantSide = (tenantConfigs.GetValueOrDefault(key.Tenant)?.CustomUserRateLimitRequestsPerMinute, entitlements.UserRateLimitPerMinute);
perTenant[key.Tenant] = tenantSide;
}
}
limits[key] = RateLimitResolver.ResolveUserLimit(
ThrottleSurface.Integration, isGlobalAdmin, tenantSide.Override,
config.UserRateLimitRequestsPerMinute, config.PortalUserRateLimitRequestsPerMinute,
config.GlobalAdminRateLimitRequestsPerMinute, tenantSide.Floor);
}
return limits;
}
// ── Assembly (pure) ────────────────────────────────────────────────────────────
/// <summary>
/// The principal each counter account (lower-cased tenant, object id) stands for: the UPN of its newest row
/// that carries one, lower-cased like the request telemetry; the object id for an account whose rows all
/// predate the UPN column.
/// </summary>
internal static Dictionary<(string Tenant, string UserId), string> PrincipalsByAccount(IEnumerable<TenantUsageRecord> counters)
=> counters
.Where(r => !string.IsNullOrEmpty(r.UserId))
.GroupBy(r => (Tenant: r.TenantId.ToLowerInvariant(), r.UserId))
.ToDictionary(
g => g.Key,
g => (g.OrderByDescending(r => r.Date, StringComparer.Ordinal)
.Select(r => r.UserPrincipalName)
.FirstOrDefault(u => !string.IsNullOrEmpty(u)) ?? g.Key.UserId).ToLowerInvariant());
/// <summary>What the I/O part learned about one counter account: its object id, platform role and quota state.</summary>
internal sealed record AccountFacts(string UserId, string? PlatformRole, McpQuotaDecision Quota);
internal sealed record TelemetryResult(
bool RequestsAvailable, bool McpLogAvailable, bool Partial,
List<CallRow> Calls, List<RouteRow> Routes, List<RefusalRow> Refusals, List<PeakRow> Peaks,
List<ToolQualityRow> ToolQuality, List<McpRefusalRow> McpRefusals, ServerFacts ServerFacts);
/// <summary>The window as yyyyMMdd strings (they sort as text).</summary>
internal sealed record Dates(string From, string Today, string MonthStart)
{
/// <summary>One read covers the window and the current month (the quota windows).</summary>
public string ReadFrom => string.CompareOrdinal(From, MonthStart) < 0 ? From : MonthStart;
public bool InWindow(string day) => string.CompareOrdinal(day, From) >= 0 && string.CompareOrdinal(day, Today) <= 0;
public IEnumerable<string> Days()
{
var day = DateTime.ParseExact(From, "yyyyMMdd", CultureInfo.InvariantCulture);
var last = DateTime.ParseExact(Today, "yyyyMMdd", CultureInfo.InvariantCulture);
for (; day <= last; day = day.AddDays(1))
yield return day.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
}
public static Dates For(Window window, DateTime nowUtc)
=> new(
window.FromUtc.ToString("yyyyMMdd", CultureInfo.InvariantCulture),
nowUtc.ToString("yyyyMMdd", CultureInfo.InvariantCulture),
new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc).ToString("yyyyMMdd", CultureInfo.InvariantCulture));
}
internal sealed record OverviewInputs(
Dates Dates,
bool IncludePlatformAdmins,
IReadOnlyList<TenantUsageRecord> Counters,
IReadOnlyDictionary<(string Tenant, string Principal), AccountFacts> Accounts,
IReadOnlyDictionary<string, McpQuotaService.TenantPlanLimits> TenantLimits,
IReadOnlyDictionary<string, string> TenantNames,
IReadOnlyDictionary<(string Tenant, string Principal), string> TelemetryRoles,
IReadOnlyDictionary<(string Tenant, string Principal), int> MinuteLimits,
TelemetryResult Telemetry);
/// <summary>The role of each account's latest call (lower-cased tenant and principal as key).</summary>
internal static Dictionary<(string Tenant, string Principal), string> LatestRoles(IEnumerable<CallRow> calls)
=> calls
.Where(c => !string.IsNullOrEmpty(c.Role))
.GroupBy(c => (c.Tenant, c.Principal))
.ToDictionary(g => g.Key, g => g.OrderByDescending(c => c.LastAt ?? DateTime.MinValue).First().Role);
private static bool IsPlatformRole(string? role)
=> role == Constants.GlobalRoles.GlobalAdmin || role == Constants.GlobalRoles.GlobalReader;
/// <summary>
/// Pure: folds counters, account facts and telemetry into the response. Platform admins drop out of every
/// number unless included — except a tenant's organization windows, which stay the real counters.
/// </summary>
internal static GetGlobalMcpUsageOverviewResponse Assemble(OverviewInputs input)
{
var dates = input.Dates;
var telemetry = input.Telemetry;
string? RoleOf((string Tenant, string Principal) key)
=> input.TelemetryRoles.TryGetValue(key, out var role) ? role
: input.Accounts.TryGetValue(key, out var facts) ? facts.PlatformRole
: null;
bool IsPlatform((string Tenant, string Principal) key)
=> IsPlatformRole(RoleOf(key))
|| (input.Accounts.TryGetValue(key, out var facts) && IsPlatformRole(facts.PlatformRole));
bool Counts((string Tenant, string Principal) key) => input.IncludePlatformAdmins || !IsPlatform(key);
// Counter rows per account key — the same principal per account as the I/O part used for its facts.
var principals = PrincipalsByAccount(input.Counters);
var counterRows = input.Counters
.Where(r => !string.IsNullOrEmpty(r.UserId))
.Select(r =>
{
var tenant = r.TenantId.ToLowerInvariant();
return (Row: r, Key: (Tenant: tenant, Principal: principals[(tenant, r.UserId)]));
})
.ToList();
var windowRows = counterRows.Where(x => dates.InWindow(x.Row.Date) && Counts(x.Key)).ToList();
var calls = telemetry.Calls.Where(c => Counts((c.Tenant, c.Principal))).ToList();
var quotaHits = telemetry.Refusals
.Where(r => r.Gate == RefusalQuota && Counts((r.Tenant, r.Principal)))
.OrderByDescending(r => r.LastAt)
.Select(r => new McpUsageQuotaHitItem
{
Date = r.Day,
TenantId = r.Tenant,
Principal = string.IsNullOrEmpty(r.Principal) ? null : r.Principal,
Level = r.Level,
Scope = r.Scope,
Plan = r.Plan,
Limit = r.Limit,
Used = r.Used,
Refused = r.Refused,
FirstAt = r.FirstAt,
LastAt = r.LastAt,
})
.ToList();
// ── Accounts ──
var accountKeys = windowRows.Select(x => x.Key)
.Concat(calls.Select(c => (c.Tenant, c.Principal)))
.Distinct()
.ToList();
var accounts = new Dictionary<(string Tenant, string Principal), McpUsageAccountItem>();
foreach (var key in accountKeys)
{
var rows = windowRows.Where(x => x.Key == key).Select(x => x.Row).ToList();
var accountCalls = calls.Where(c => (c.Tenant, c.Principal) == key).ToList();
var byDay = rows.GroupBy(r => r.Date).Select(g => (Day: g.Key, Requests: g.Sum(r => r.RequestCount)))
.OrderByDescending(d => d.Requests).ThenByDescending(d => d.Day, StringComparer.Ordinal).ToList();
input.Accounts.TryGetValue(key, out var facts);
var role = RoleOf(key);
var lastCounter = rows.Select(r => r.LastRequestAt).Max();
var lastCall = accountCalls.Select(c => c.LastAt).Max();
accounts[key] = new McpUsageAccountItem
{
UserId = facts?.UserId ?? rows.Select(r => r.UserId).FirstOrDefault(),
Principal = key.Principal,
Role = string.IsNullOrEmpty(role) ? null : role,
IsPlatformAdmin = IsPlatform(key),
Quota = facts == null ? null : new McpUsageAccountQuotaNode
{
Plan = facts.Quota.Plan,
DailyLimit = facts.Quota.DailyLimit,
MonthlyLimit = facts.Quota.MonthlyLimit,
DailyUsed = facts.Quota.DailyUsed,
MonthlyUsed = facts.Quota.MonthlyUsed,
},
Calls = accountCalls.Sum(c => c.Calls),
Requests = rows.Sum(r => r.RequestCount),
PeakDay = byDay.Count > 0 ? byDay[0].Day : null,
PeakDayRequests = byDay.Count > 0 ? byDay[0].Requests : null,
QuotaHits = quotaHits.Count(h => h.Level == McpQuotaLevel.User && h.TenantId == key.Tenant && h.Principal == key.Principal),
TopTools = accountCalls.GroupBy(c => c.Tool)
.Select(g => new McpUsageToolCallsItem { Tool = g.Key, Calls = g.Sum(c => c.Calls) })
.OrderByDescending(t => t.Calls).ThenBy(t => t.Tool, StringComparer.Ordinal)
.Take(3).ToList(),
LastRequestAt = Later(lastCounter, lastCall),
};
}
// ── Tenants ──
var tenants = accounts.Keys.Select(k => k.Tenant).Distinct(StringComparer.Ordinal)
.Select(tenant =>
{
var members = accounts.Where(a => a.Key.Tenant == tenant).Select(a => a.Value)
.OrderByDescending(a => a.Calls).ThenByDescending(a => a.Requests)
.ThenBy(a => a.Principal, StringComparer.Ordinal).ToList();
var limits = input.TenantLimits.TryGetValue(tenant, out var l) ? l : new McpQuotaService.TenantPlanLimits(string.Empty, 0, 0);
// The organization windows are the tenant's real counters — every account, platform admins included.
var tenantRows = counterRows.Where(x => x.Key.Tenant == tenant).Select(x => x.Row);
return new McpUsageTenantItem
{
TenantId = tenant,
DomainName = input.TenantNames.GetValueOrDefault(tenant),
Quota = McpQuotaNodes.BuildOrganizationQuota(tenantRows, limits, dates.Today, dates.MonthStart),
Calls = members.Sum(a => a.Calls),
Requests = members.Sum(a => a.Requests),
QuotaHits = quotaHits.Count(h => h.TenantId == tenant),
LastRequestAt = members.Select(a => a.LastRequestAt).Max(),
Accounts = members,
};
})
.OrderByDescending(t => t.Calls).ThenByDescending(t => t.Requests).ThenBy(t => t.TenantId, StringComparer.Ordinal)
.ToList();
// ── Tools ──
var routesByTool = telemetry.Routes
.GroupBy(r => r.Tool)
.ToDictionary(g => g.Key, g => (IReadOnlyList<McpUsageRouteItem>)g
.OrderByDescending(r => r.Requests).ThenBy(r => r.Route, StringComparer.Ordinal)
.Select(r => new McpUsageRouteItem { Route = r.Route, Requests = r.Requests }).ToList());
var quality = telemetry.ToolQuality.ToDictionary(q => q.Tool, StringComparer.Ordinal);
var backendTools = calls.GroupBy(c => c.Tool).ToDictionary(g => g.Key, g => g.ToList(), StringComparer.Ordinal);
var tools = backendTools.Keys.Concat(quality.Keys).Distinct(StringComparer.Ordinal)
.Where(tool => !string.IsNullOrEmpty(tool))
.Select(tool =>
{
backendTools.TryGetValue(tool, out var rows);
quality.TryGetValue(tool, out var q);
var served = rows?.Sum(c => c.ServedCalls) ?? 0;
return new McpUsageToolItem
{
Tool = tool,
// Calls that reached the backend come from the request telemetry, like every account's calls;
// a tool the backend never saw is counted from the MCP log.
Calls = rows != null ? rows.Sum(c => c.Calls) : q!.Calls,
Accounts = rows?.Select(c => (c.Tenant, c.Principal)).Distinct().Count(),
Tenants = rows?.Select(c => c.Tenant).Distinct(StringComparer.Ordinal).Count(),
RequestsPerCall = served > 0 ? Math.Round((double)rows!.Sum(c => c.Requests) / served, 2) : 0,
ErrorRate = q != null && q.Calls > 0 ? Math.Round((double)q.Errors / q.Calls, 4) : null,
P95DurationMs = q?.P95Ms,
AvgResultChars = q?.AvgChars is double chars ? (long)Math.Round(chars) : null,
OverCapCalls = q?.OverCap,
Routes = routesByTool.TryGetValue(tool, out var routes) ? routes : Array.Empty<McpUsageRouteItem>(),
};
})
.OrderByDescending(t => t.Calls).ThenBy(t => t.Tool, StringComparer.Ordinal)
.ToList();
// ── Days ──
string PlanOf(string tenant) => input.TenantLimits.TryGetValue(tenant, out var l) && !string.IsNullOrEmpty(l.TenantPlan) ? l.TenantPlan : "unknown";
var daily = dates.Days().Select(day =>
{
var dayCalls = calls.Where(c => c.Day == day).ToList();
var dayRows = windowRows.Where(x => x.Row.Date == day).ToList();
return new McpUsageDayItem
{
Date = day,
Calls = dayCalls.Sum(c => c.Calls),
Requests = dayRows.Sum(x => x.Row.RequestCount),
Accounts = dayRows.Select(x => x.Key).Concat(dayCalls.Select(c => (c.Tenant, c.Principal))).Distinct().Count(),
CallsByPlan = dayCalls.GroupBy(c => PlanOf(c.Tenant))
.Select(g => new McpUsagePlanCallsItem { Plan = g.Key, Calls = g.Sum(c => c.Calls) })
.OrderByDescending(p => p.Calls).ThenBy(p => p.Plan, StringComparer.Ordinal).ToList(),
};
}).ToList();
// ── Per-minute limits ──
var tenantOfPrincipal = accounts.Keys.GroupBy(k => k.Principal).ToDictionary(g => g.Key, g => g.First().Tenant, StringComparer.Ordinal);
var mcpRefusals = telemetry.McpRefusals
.Where(m => !string.IsNullOrEmpty(m.Principal))
.Select(m => (Key: (Tenant: tenantOfPrincipal.GetValueOrDefault(m.Principal) ?? string.Empty, m.Principal), Row: m))
.Where(m => Counts(m.Key))
.ToDictionary(m => m.Key, m => m.Row);
var backendRefusals = telemetry.Refusals
.Where(r => r.Gate == RefusalMinute && Counts((r.Tenant, r.Principal)))
.GroupBy(r => (r.Tenant, r.Principal))
.ToDictionary(g => g.Key, g => (Refused: g.Sum(r => r.Refused), LastAt: g.Max(r => r.LastAt)));
var peaks = telemetry.Peaks.Where(p => Counts((p.Tenant, p.Principal)))
.GroupBy(p => (p.Tenant, p.Principal))
.ToDictionary(g => g.Key, g => g.ToList());
var mcpLimit = telemetry.ServerFacts.RateLimitPerMinute;
// The higher of the two shares of a minute limit (0–1+); the list ranks by it and shows it in whole percent.
double MinuteShare(long peakCalls, long peakRequests, int backendLimit) => Math.Max(
mcpLimit is int limit && limit > 0 ? (double)peakCalls / limit : 0,
backendLimit > 0 ? (double)peakRequests / backendLimit : 0);
var minuteItems = peaks.Keys.Concat(mcpRefusals.Keys).Concat(backendRefusals.Keys).Distinct()
.Select(key =>
{
peaks.TryGetValue(key, out var accountPeaks);
var callPeak = accountPeaks?.FirstOrDefault(p => p.Metric == PeakCalls);
var requestPeak = accountPeaks?.FirstOrDefault(p => p.Metric == PeakRequests);
mcpRefusals.TryGetValue(key, out var mcpRefused);
var hasBackendRefusal = backendRefusals.TryGetValue(key, out var backendRefused);
var backendLimit = input.MinuteLimits.GetValueOrDefault(key);
var share = MinuteShare(callPeak?.Peak ?? 0, requestPeak?.Peak ?? 0, backendLimit);
return (Share: share, Item: new McpUsageMinuteItem
{
TenantId = key.Tenant,
Principal = key.Principal,
SharePercent = (int)Math.Round(share * 100, MidpointRounding.AwayFromZero),
PeakCalls = callPeak?.Peak ?? 0,
PeakCallsAt = callPeak?.PeakAt,
McpRefused = mcpRefused?.Refused ?? 0,
McpLastRefusedAt = mcpRefused?.LastAt,
PeakRequests = requestPeak?.Peak ?? 0,
PeakRequestsAt = requestPeak?.PeakAt,
BackendLimit = backendLimit,
BackendRefused = hasBackendRefusal ? backendRefused.Refused : 0,
BackendLastRefusedAt = hasBackendRefusal ? backendRefused.LastAt : null,
});
})
.OrderByDescending(m => m.Share)
.ThenBy(m => m.Item.Principal, StringComparer.Ordinal)
.Select(m => m.Item)
.ToList();
var mcpLogFrom = telemetry.ServerFacts.Oldest is DateTime oldest
? oldest.ToString("yyyyMMdd", CultureInfo.InvariantCulture)
: null;
return new GetGlobalMcpUsageOverviewResponse
{
DateFrom = dates.From,
DateTo = dates.Today,
IncludesPlatformAdmins = input.IncludePlatformAdmins,
Telemetry = new McpUsageTelemetryStatus
{
RequestsAvailable = telemetry.RequestsAvailable,
McpLogAvailable = telemetry.McpLogAvailable,
McpLogFrom = mcpLogFrom != null && string.CompareOrdinal(mcpLogFrom, dates.From) > 0 ? mcpLogFrom : null,
Partial = telemetry.Partial ? true : null,
},
Totals = BuildTotals(tenants, tools, quotaHits, minuteItems, telemetry),
Daily = daily,
Tenants = tenants,
Tools = tools,
QuotaHits = quotaHits,
MinuteLimits = new McpUsageMinuteLimits { McpServerLimit = mcpLimit, Accounts = minuteItems },
};
}
/// <summary>
/// Pure: the totals over the assembled lists. Calls need the request telemetry and MCP-only calls the MCP log —
/// without the store the figure is absent, never a misleading partial sum. An account is a tenant and principal;
/// an organization-level refusal counts for the account that met it.
/// </summary>
private static McpUsageTotalsNode BuildTotals(
IReadOnlyList<McpUsageTenantItem> tenants, IReadOnlyList<McpUsageToolItem> tools,
IReadOnlyList<McpUsageQuotaHitItem> quotaHits, IReadOnlyList<McpUsageMinuteItem> minuteItems, TelemetryResult telemetry)
{
var accounts = tenants.SelectMany(t => t.Accounts).ToList();
var quotaHitAccounts = quotaHits.Select(h => (h.TenantId, Principal: h.Principal ?? string.Empty)).ToHashSet();
var minuteRefusedAccounts = minuteItems
.Where(m => m.McpRefused > 0 || m.BackendRefused > 0)
.Select(m => (m.TenantId, m.Principal))
.ToHashSet();
return new McpUsageTotalsNode
{
Calls = telemetry.RequestsAvailable ? tools.Sum(t => t.Calls) : null,
McpOnlyCalls = telemetry.McpLogAvailable ? tools.Where(t => t.Accounts == null).Sum(t => t.Calls) : null,
Tools = tools.Count,
Requests = tenants.Sum(t => t.Requests),
Tenants = tenants.Count,
Accounts = accounts.Count,
ServicePrincipals = accounts.Count(a => Constants.PrincipalKeys.IsApplication(a.Principal)),
QuotaHitAccounts = quotaHitAccounts.Count,
MinuteRefusedAccounts = minuteRefusedAccounts.Count,
LimitAccounts = quotaHitAccounts.Union(minuteRefusedAccounts).Count(),
};
}
private static DateTime? Later(DateTime? a, DateTime? b) => a == null ? b : b == null ? a : (a > b ? a : b);
}
@@ -0,0 +1,257 @@
using System.Globalization;
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Raw;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Middleware;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// The fixed KQL behind the MCP usage overview and the parsers of its result tables. Two stores: the backend's
/// App Insights (one request row per API call; the MCP stamps one correlation id per tool call on every request
/// of that call) and the MCP Container App's Log Analytics workspace (one <c>tool_call</c> line per call, the
/// <c>boot</c> line, the access guard's refusals). Every query covers whole UTC days [from, to) and leaves out
/// platform admins (Global Admin, Global Reader) unless the window includes them.
/// <para>
/// Calls are counted exactly: grouped by correlation id first, then counted — never <c>dcount()</c>, which
/// estimates. Log lines are matched by their START (<c>startswith</c>): a query that carries the text as an
/// argument shows up inside a <c>tool_call</c> line and would match a <c>has</c>.
/// </para>
/// </summary>
internal static class McpUsageTelemetryQueries
{
internal const string RefusalQuota = "quota";
internal const string RefusalMinute = "minute";
internal const string PeakRequests = "requests";
internal const string PeakCalls = "calls";
/// <summary>Whole UTC days [FromUtc, ToUtc) plus whether platform admins count.</summary>
internal sealed record Window(DateTime FromUtc, DateTime ToUtc, bool IncludePlatformAdmins)
{
/// <summary>Query-API timespan that covers the window (the where clauses bound it exactly).</summary>
public string Timespan => $"P{Math.Max(1, (int)Math.Ceiling((ToUtc - FromUtc).TotalDays)) + 1}D";
public string From => Literal(FromUtc);
public string To => Literal(ToUtc);
private static string Literal(DateTime utc) => $"datetime({utc.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)})";
}
// ── App Insights (backend requests) ─────────────────────────────────────────────
/// <summary>
/// The MCP request rows of the window with the columns every request query reads: tool, corr, tenant,
/// principal, role. Leaves out the access probe (its rows name a tool that never ran) and, unless included,
/// the platform admins.
/// </summary>
private static string McpRequests(Window w) => $$"""
requests
| where timestamp >= {{w.From}} and timestamp < {{w.To}}
| where tostring(customDimensions.{{RequestRowDimensions.Source}}) == '{{RequestRowDimensions.WorkerMiddleware}}'
| where tostring(customDimensions.{{RequestRowDimensions.ClientSource}}) =~ '{{ClientSourceHeader.Mcp}}'
| where tostring(customDimensions.{{RequestRowDimensions.FunctionName}}) != '{{AccessProbeFunction.FunctionName}}'
| extend tool = tostring(customDimensions.{{RequestRowDimensions.McpToolName}}), corr = tostring(customDimensions.{{RequestRowDimensions.CorrelationId}}), tenant = tolower(tostring(customDimensions.{{RequestRowDimensions.TenantId}})), principal = tolower(tostring(customDimensions.{{RequestRowDimensions.UserId}})), role = tostring(customDimensions.{{RequestRowDimensions.UserRole}})
{{PlatformFilter(w, "role")}}
""";
private static string PlatformFilter(Window w, string roleColumn) => w.IncludePlatformAdmins
? string.Empty
: $"| where {roleColumn} !in ('{Constants.GlobalRoles.GlobalAdmin}', '{Constants.GlobalRoles.GlobalReader}')";
/// <summary>A refused request (401/403/429) never reached the quota counters; everything else did.</summary>
private const string Refused = "resultCode in ('401', '403', '429')";
/// <summary>
/// Calls and charged requests per day (of the call's first request), tool, tenant and account, with the time
/// and role of the row's latest call (one account can hold two roles: Viewer at home, DelegatedReader in a
/// managed tenant). <c>servedCalls</c> = calls with at least one charged request — the denominator of requests per call.
/// </summary>
internal static string Calls(Window w) => $$"""
{{McpRequests(w)}}
| where isnotempty(tool) and isnotempty(corr)
| summarize served = sum(iff({{Refused}}, 0, itemCount)), firstAt = min(timestamp), role = take_any(role) by corr, tool, tenant, principal
| summarize calls = count(), servedCalls = countif(served > 0), charged = sum(served), arg_max(firstAt, role) by day = format_datetime(firstAt, 'yyyyMMdd'), tool, tenant, principal
""";
/// <summary>Charged requests per tool and route (method plus path without /api/, ids as {id}).</summary>
internal static string Routes(Window w) => $$"""
{{McpRequests(w)}}
| where isnotempty(tool) and not({{Refused}})
| extend route = strcat(tostring(customDimensions.{{RequestRowDimensions.HttpMethod}}), ' ', tolower(replace_regex(replace_regex(tostring(customDimensions.{{RequestRowDimensions.HttpPath}}), @'^/api/', ''), @'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', '{id}')))
| summarize charged = sum(itemCount) by tool, route
""";
/// <summary>
/// Every 429 an MCP request met, classified by the Warning line the same invocation wrote: the daily/monthly
/// quota (<see cref="McpQuotaEnforcementMiddleware.BlockedLogPrefix"/>) or the backend's per-minute limit
/// (<see cref="UserRateLimitMiddleware.ThrottledLogPrefix"/>). One row per gate, day, account and window;
/// <c>usedCount</c> is the window's count at the first refusal of the day.
/// </summary>
internal static string Refusals(Window w) => $$"""
let rejected = {{McpRequests(w)}}
| where resultCode == '429'
| project timestamp, operation_Id, tenant, principal;
let lines = traces
| where timestamp >= {{w.From}} and timestamp < {{w.To}}
| extend f = tostring(customDimensions.OriginalFormat)
| where f startswith '{{McpQuotaEnforcementMiddleware.BlockedLogPrefix}}' or f startswith '{{UserRateLimitMiddleware.ThrottledLogPrefix}}'
| project operation_Id, f, cd = customDimensions;
rejected
| join kind=inner lines on operation_Id
| extend gate = iff(f startswith '{{McpQuotaEnforcementMiddleware.BlockedLogPrefix}}', '{{RefusalQuota}}', '{{RefusalMinute}}'), level = tostring(cd.Level), scope = tostring(cd.Scope), plan = tostring(cd.Plan)
| extend usedCount = case(gate == '{{RefusalMinute}}', tolong(cd.Count), level == 'tenant' and scope == 'monthly', tolong(cd.TenantMonthlyUsed), level == 'tenant', tolong(cd.TenantDailyUsed), scope == 'monthly', tolong(cd.MonthlyUsed), tolong(cd.DailyUsed)),
limitValue = case(gate == '{{RefusalMinute}}', tolong(cd.Max), level == 'tenant' and scope == 'monthly', tolong(cd.TenantMonthlyLimit), level == 'tenant', tolong(cd.TenantDailyLimit), scope == 'monthly', tolong(cd.MonthlyLimit), tolong(cd.DailyLimit))
| summarize refused = count(), firstAt = min(timestamp), lastAt = max(timestamp), usedCount = min(usedCount), limitValue = max(limitValue) by gate, day = format_datetime(timestamp, 'yyyyMMdd'), tenant, principal, level, scope, plan
""";
/// <summary>
/// Each account's busiest 60 seconds, measured like the sliding-window limiters: counts per 10-second bin,
/// every bin added to the six windows it belongs to. <c>requests</c> counts every MCP request (refused ones
/// too — they are demand); <c>calls</c> counts tool calls by their first request.
/// </summary>
internal static string Peaks(Window w) => $$"""
let base = {{McpRequests(w)}}
| project timestamp, corr, tenant, principal, itemCount;
let requestPeaks = base
| summarize n = sum(itemCount) by tenant, principal, b = bin(timestamp, 10s)
| mv-expand k = range(0, 5, 1) to typeof(long)
| summarize w = sum(n) by tenant, principal, e = b + k * 10s
| summarize arg_max(w, e) by tenant, principal
| extend metric = '{{PeakRequests}}';
let callPeaks = base
| where isnotempty(corr)
| summarize at = min(timestamp) by corr, tenant, principal
| summarize n = count() by tenant, principal, b = bin(at, 10s)
| mv-expand k = range(0, 5, 1) to typeof(long)
| summarize w = sum(n) by tenant, principal, e = b + k * 10s
| summarize arg_max(w, e) by tenant, principal
| extend metric = '{{PeakCalls}}';
union requestPeaks, callPeaks
| project metric, tenant, principal, peak = w, peakAt = e
""";
// ── MCP Log Analytics (the MCP server's own lines) ─────────────────────────────
/// <summary>The <c>tool_call</c> line starts with this (its <c>type</c> key comes first).</summary>
internal const string ToolCallLinePrefix = "{\"type\":\"tool_call\"";
/// <summary>The <c>boot</c> line starts with this.</summary>
internal const string BootLinePrefix = "{\"type\":\"boot\"";
/// <summary>The access guard's per-account refusal line: <c>[mcp-auth] 429 kind=upn key=&lt;principal&gt; method=…</c>.</summary>
internal const string McpRefusalLinePrefix = "[mcp-auth] 429 kind=upn ";
/// <summary>Per tool: calls, errors (soft errors included), cancellations, over-cap pages, p95 duration, mean answer size.</summary>
internal static string ToolQuality(Window w) => $$"""
ContainerAppConsoleLogs_CL
| where TimeGenerated >= {{w.From}} and TimeGenerated < {{w.To}}
| where Log_s startswith '{{ToolCallLinePrefix}}'
| extend d = parse_json(Log_s)
{{(w.IncludePlatformAdmins ? string.Empty : "| where tostring(d.scope) != 'ga'")}}
| summarize calls = count(), errors = countif(tobool(d.isError)), cancelled = countif(tobool(d.cancelled)), overCap = countif(tobool(d.overCap)), p95Ms = percentile(tolong(d.durationMs), 95), avgChars = avg(todouble(d.resultChars)) by tool = tostring(d.tool)
""";
/// <summary>The MCP server's per-account refusals (its own per-minute limit), per account.</summary>
internal static string McpRefusals(Window w) => $$"""
ContainerAppConsoleLogs_CL
| where TimeGenerated >= {{w.From}} and TimeGenerated < {{w.To}}
| where Log_s startswith '{{McpRefusalLinePrefix}}'
| extend principal = tolower(extract(@'key=(\S+)', 1, Log_s))
| summarize refused = count(), lastAt = max(TimeGenerated) by principal
""";
/// <summary>How far back the MCP log reaches inside the window, and the per-account limit of the newest start line.</summary>
internal static string McpServerFacts(Window w) => $$"""
let lines = ContainerAppConsoleLogs_CL
| where TimeGenerated >= {{w.From}} and TimeGenerated < {{w.To}};
let oldest = toscalar(lines | summarize min(TimeGenerated));
let bootLimit = toscalar(lines | where Log_s startswith '{{BootLinePrefix}}' | top 1 by TimeGenerated desc | project toint(parse_json(Log_s).rateLimitPerMinute));
print oldest = oldest, rateLimitPerMinute = bootLimit
""";
// ── Parsed rows ────────────────────────────────────────────────────────────────
internal sealed record CallRow(string Day, string Tool, string Tenant, string Principal, long Calls, long ServedCalls, long Requests, DateTime? LastAt, string Role);
internal sealed record RouteRow(string Tool, string Route, long Requests);
internal sealed record RefusalRow(string Gate, string Day, string Tenant, string Principal, string Level, string Scope, string Plan,
long Refused, DateTime FirstAt, DateTime LastAt, long? Used, long? Limit);
internal sealed record PeakRow(string Metric, string Tenant, string Principal, long Peak, DateTime? PeakAt);
internal sealed record ToolQualityRow(string Tool, long Calls, long Errors, long Cancelled, long OverCap, long? P95Ms, double? AvgChars);
internal sealed record McpRefusalRow(string Principal, long Refused, DateTime? LastAt);
internal sealed record ServerFacts(DateTime? Oldest, int? RateLimitPerMinute);
internal static List<CallRow> ParseCalls(KqlTable t) => Read(t, r => new CallRow(
r.Str("day"), r.Str("tool"), r.Str("tenant"), r.Str("principal"),
r.Long("calls"), r.Long("servedCalls"), r.Long("charged"), r.Date("firstAt"), r.Str("role")));
internal static List<RouteRow> ParseRoutes(KqlTable t) => Read(t, r => new RouteRow(r.Str("tool"), r.Str("route"), r.Long("charged")));
internal static List<RefusalRow> ParseRefusals(KqlTable t) => Read(t, r => new RefusalRow(
r.Str("gate"), r.Str("day"), r.Str("tenant"), r.Str("principal"), r.Str("level"), r.Str("scope"), r.Str("plan"),
r.Long("refused"), r.Date("firstAt") ?? DateTime.MinValue, r.Date("lastAt") ?? DateTime.MinValue, r.LongOrNull("usedCount"), r.LongOrNull("limitValue")));
internal static List<PeakRow> ParsePeaks(KqlTable t) => Read(t, r => new PeakRow(
r.Str("metric"), r.Str("tenant"), r.Str("principal"), r.Long("peak"), r.Date("peakAt")));
internal static List<ToolQualityRow> ParseToolQuality(KqlTable t) => Read(t, r => new ToolQualityRow(
r.Str("tool"), r.Long("calls"), r.Long("errors"), r.Long("cancelled"), r.Long("overCap"), r.LongOrNull("p95Ms"), r.DoubleOrNull("avgChars")));
internal static List<McpRefusalRow> ParseMcpRefusals(KqlTable t) => Read(t, r => new McpRefusalRow(r.Str("principal"), r.Long("refused"), r.Date("lastAt")));
internal static ServerFacts ParseServerFacts(KqlTable t)
{
var rows = Read(t, r => new ServerFacts(r.Date("oldest"), (int?)r.LongOrNull("rateLimitPerMinute")));
return rows.Count > 0 ? rows[0] : new ServerFacts(null, null);
}
private static List<T> Read<T>(KqlTable table, Func<RowReader, T> map)
{
var index = new Dictionary<string, int>(StringComparer.Ordinal);
for (var i = 0; i < table.Columns.Count; i++)
index[table.Columns[i].Name] = i;
var result = new List<T>(table.Rows.Count);
foreach (var row in table.Rows)
result.Add(map(new RowReader(index, row)));
return result;
}
/// <summary>Typed access to one Kusto row by column name; a missing column or a null cell reads as empty.</summary>
private readonly struct RowReader
{
private readonly Dictionary<string, int> _index;
private readonly IReadOnlyList<JsonElement> _cells;
public RowReader(Dictionary<string, int> index, IReadOnlyList<JsonElement> cells)
{
_index = index;
_cells = cells;
}
private JsonElement? Cell(string column)
=> _index.TryGetValue(column, out var i) && i < _cells.Count && _cells[i].ValueKind != JsonValueKind.Null
? _cells[i]
: null;
public string Str(string column) => Cell(column) is { ValueKind: JsonValueKind.String } c ? c.GetString() ?? string.Empty : string.Empty;
public long Long(string column) => LongOrNull(column) ?? 0;
public long? LongOrNull(string column) => Cell(column) switch
{
{ ValueKind: JsonValueKind.Number } c when c.TryGetInt64(out var l) => l,
{ ValueKind: JsonValueKind.Number } c => (long)Math.Round(c.GetDouble()),
_ => null,
};
public double? DoubleOrNull(string column) => Cell(column) is { ValueKind: JsonValueKind.Number } c ? c.GetDouble() : null;
public DateTime? Date(string column)
=> Cell(column) is { ValueKind: JsonValueKind.String } c
&& DateTime.TryParse(c.GetString(), CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var d)
? DateTime.SpecifyKind(d, DateTimeKind.Utc)
: null;
}
}
@@ -1072,6 +1072,21 @@ namespace AutopilotMonitor.Functions.Services
tenantId, "System.RuleEngine",
new { ruleId, reason, catalogRule = isCatalogRule, endsRun, sessionId, runKind, skippedRules });
/// <summary>
/// An MCP caller ran into a daily or monthly request quota of its home tenant (<paramref name="level"/>
/// <c>user</c> = the account's own window, <c>tenant</c> = the organization's) and was answered 429.
/// <c>McpQuotaEnforcementMiddleware</c> raises it for the first refusal per instance, account or
/// organization, window and period; every refusal is also in the Warning trace.
/// </summary>
public Task RecordMcpQuotaExceededAsync(
string tenantId, string principal, string level, string scope, string plan, long limit, long used, DateTime resetUtc)
=> WriteAsync(OpsEventCategory.Tenant, OpsEventTypes.McpQuotaExceeded, OpsEventSeverity.Info,
level == McpQuotaLevel.Tenant
? $"MCP {scope} request quota of the organization reached ({used}/{limit}, plan '{plan}'); its members get 429 until {resetUtc:yyyy-MM-ddTHH:mm:ss}Z"
: $"MCP {scope} request quota of {principal} reached ({used}/{limit}, plan '{plan}'); 429 until {resetUtc:yyyy-MM-ddTHH:mm:ss}Z",
tenantId, principal,
new { level, scope, plan, limit, used, resetUtc = $"{resetUtc:yyyy-MM-ddTHH:mm:ss}Z" });
/// <summary>
/// App-version duration regression radar: an app's newest version installs with a median
/// duration ≥2× (and ≥5 min absolute) over the previous version's median. Fired ONCE per
@@ -0,0 +1,36 @@
using AutopilotMonitor.Functions.Helpers;
using Microsoft.ApplicationInsights.Channel;
using Microsoft.ApplicationInsights.DataContracts;
using Microsoft.ApplicationInsights.Extensibility;
namespace AutopilotMonitor.Functions.Telemetry;
/// <summary>
/// Stamps every telemetry item a view-as request emits (customEvents, traces, dependencies) with the viewed
/// tenant, so KQL can leave a Global Admin's read-only "view as" out of that tenant's activity. Handlers put
/// the viewed tenant on their own events; this marks them as not the tenant's doing. The request row is
/// stamped by RequestTelemetryMiddleware, which runs outside the scope <see cref="Enter"/> opens.
/// </summary>
public sealed class ViewAsTelemetryProcessor : ITelemetryProcessor
{
private static readonly AsyncLocal<string?> ViewedTenant = new();
private readonly ITelemetryProcessor _next;
public ViewAsTelemetryProcessor(ITelemetryProcessor next) => _next = next;
/// <summary>Marks the rest of the current async flow — one admitted view-as request — as viewing this tenant.</summary>
public static void Enter(string viewedTenantId) => ViewedTenant.Value = viewedTenantId;
public void Process(ITelemetry item)
{
Stamp(item, ViewedTenant.Value);
_next.Process(item);
}
internal static void Stamp(ITelemetry item, string? viewedTenantId)
{
if (viewedTenantId != null && item is ISupportProperties withProperties)
withProperties.Properties.TryAdd(RequestRowDimensions.ViewAsTenantId, viewedTenantId);
}
}
+8 -8
View File
@@ -16,7 +16,7 @@
"compression": "^1.8.2",
"express": "^5.3.0",
"fuse.js": "^7.5.0",
"jose": "^6.2.13",
"jose": "^6.2.14",
"undici": "^8.12.3",
"zod": "^4.6.7"
},
@@ -30,7 +30,7 @@
"@types/express": "^5.0.6",
"@types/node": "^22.20.5",
"eslint": "^9.39.5",
"knip": "^6.43.0",
"knip": "^6.44.0",
"tsx": "^4.23.15",
"typescript": "^5.0.0",
"typescript-eslint": "^8.72.1",
@@ -4289,9 +4289,9 @@
}
},
"node_modules/jose": {
"version": "6.2.13",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.13.tgz",
"integrity": "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==",
"version": "6.2.14",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.14.tgz",
"integrity": "sha512-3JasLuki1NRLQvY4wCoC37KZk4qxoBx2k2aFqLxT84IezqW0bkJKnNtxnZj/Fu1xsQivoc/lytxaRHJoGLjJ1A==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -4351,9 +4351,9 @@
}
},
"node_modules/knip": {
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.43.0.tgz",
"integrity": "sha512-8l3cCOg/EXoILO0qcSoMra49yQM91+lEIR4fu5Ko8rrArGX8xKdKZiGI9psPiY6rhLmfLERcMfFEByF0wli5CA==",
"version": "6.44.0",
"resolved": "https://registry.npmjs.org/knip/-/knip-6.44.0.tgz",
"integrity": "sha512-mT0fl71Mqrko91azUO2Jw/uhYKwldORKT4liRq6yV0ZAtt6G2eYusdnvx8eBSQJDjvhRGKkpKUUx+vUG6syV4w==",
"dev": true,
"funding": [
{
@@ -29,7 +29,7 @@
"compression": "^1.8.2",
"express": "^5.3.0",
"fuse.js": "^7.5.0",
"jose": "^6.2.13",
"jose": "^6.2.14",
"undici": "^8.12.3",
"zod": "^4.6.7"
},
@@ -40,7 +40,7 @@
"@types/express": "^5.0.6",
"@types/node": "^22.20.5",
"eslint": "^9.39.5",
"knip": "^6.43.0",
"knip": "^6.44.0",
"tsx": "^4.23.15",
"typescript": "^5.0.0",
"typescript-eslint": "^8.72.1",
@@ -12,10 +12,19 @@
* No live backend or real token is needed: fetch is stubbed and JWTs are
* hand-built (the proxy does not verify signatures — the backend does).
*/
import { readFileSync } from 'node:fs';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Request, Response, NextFunction } from 'express';
// RATE_LIMIT is read from the environment at module import. Set a low cap so
/** The refusal line start the backend's usage overview matches (tests/fixtures/mcp-log-lines). */
function refusalLinePrefix(): string {
const contract = JSON.parse(
readFileSync(new URL('../../../../../tests/fixtures/mcp-log-lines/fields.json', import.meta.url), 'utf8'),
) as { prefixes: { mcp_refusal: string } };
return contract.prefixes.mcp_refusal;
}
// RATE_LIMIT_PER_MINUTE is read from the environment at module import. Set a low cap so
// the 429 path is cheap to drive, BEFORE the dynamic import resolves.
process.env.MCP_RATE_LIMIT_PER_MINUTE = '2';
@@ -155,6 +164,7 @@ function uniqueUpn(): string {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('accessGuard — 401 (authentication failures)', () => {
@@ -387,6 +397,7 @@ describe('accessGuard — 429 (rate limit) and caching', () => {
const upn = uniqueUpn();
const token = validToken(upn);
stubBackend({ body: { allowed: true } });
const logSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const first = await runGuard(mockReq(`Bearer ${token}`));
const second = await runGuard(mockReq(`Bearer ${token}`));
@@ -397,6 +408,9 @@ describe('accessGuard — 429 (rate limit) and caching', () => {
expect(third.nextCalled).toBe(false);
expect(third.status).toBe(429);
expect((third.body as { retryAfterSeconds: number }).retryAfterSeconds).toBe(60);
// The GA usage overview counts these refusals by the line start and the key (tests/fixtures/mcp-log-lines).
const refusal = logSpy.mock.calls.map((call) => String(call[0])).find((line) => line.startsWith('[mcp-auth] 429'));
expect(refusal?.startsWith(`${refusalLinePrefix()}key=${upn} `)).toBe(true);
});
it('serves the cached access decision (one backend call across repeat requests)', async () => {
@@ -0,0 +1,274 @@
/**
* get_api_usage: the shaping of the MCP usage views (api-usage-shape.ts) and the tool's routing.
* - the summary cuts every list to its head and states the full length; nothing is cut silently;
* - absent wire values become explicit nulls (calls without request telemetry, unreadable counters);
* - tenants are named on refusal and minute rows; an account's counter keys fold into tool and day sums;
* - organization and account need their id; a UPN is not an object id.
*
* apiFetch is mocked so these run with no backend / token.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
const { apiFetchMock } = vi.hoisted(() => ({ apiFetchMock: vi.fn() }));
vi.mock('../client.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../client.js')>();
return { ...actual, apiFetch: apiFetchMock };
});
import { registerAdminTools } from '../tools/admin.js';
import { ObjectIdSchema } from '../tools/shared.js';
import { NO_TOOL, SUMMARY_HEAD, shapeAccount, shapeOrganization, shapeOverview, usageWindow } from '../api-usage-shape.js';
import type {
GetGlobalMcpUsageOverviewResponse,
GetMcpOrganizationUsageResponse,
GetMcpUserUsageResponse,
McpUsageAccountItem,
McpUsageMinuteItem,
McpUsageQuotaHitItem,
McpUsageTenantItem,
McpUsageToolItem,
} from '../generated/wire-types.generated.js';
const TENANT_A = 'aaaaaaaa-0000-4000-8000-00000000000a';
const TENANT_B = 'bbbbbbbb-0000-4000-8000-00000000000b';
const OID = '11111111-2222-4333-8444-555555555555';
const AT = '2026-11-05T10:00:00Z';
function account(principal: string, over: Partial<McpUsageAccountItem> = {}): McpUsageAccountItem {
return {
userId: OID,
principal,
isPlatformAdmin: false,
quota: { plan: 'community', dailyLimit: 50, monthlyLimit: 250, dailyUsed: 12, monthlyUsed: 40 },
calls: 5,
requests: 12,
quotaHits: 0,
topTools: [{ tool: 'get_session_summary', calls: 3 }],
...over,
};
}
function tenant(tenantId: string, domainName: string | undefined, accounts: McpUsageAccountItem[]): McpUsageTenantItem {
return {
tenantId,
...(domainName ? { domainName } : {}),
quota: { tenantPlan: 'community', dailyLimit: 100, monthlyLimit: 250, dailyUsed: 30, monthlyUsed: 90 },
calls: accounts.reduce((s, a) => s + a.calls, 0),
requests: accounts.reduce((s, a) => s + a.requests, 0),
quotaHits: 1,
accounts,
};
}
const tool = (name: string, calls: number, over: Partial<McpUsageToolItem> = {}): McpUsageToolItem =>
({ tool: name, calls, accounts: 2, tenants: 1, requestsPerCall: 4.5, errorRate: 0.1, p95DurationMs: 900, routes: [{ route: 'GET sessions/{id}', requests: 9 }], ...over });
const hit = (tenantId: string, principal: string): McpUsageQuotaHitItem =>
({ date: '20261105', tenantId, principal, level: 'user', scope: 'daily', plan: 'community', limit: 50, used: 50, refused: 3, firstAt: AT, lastAt: AT });
const minute = (tenantId: string, principal: string, sharePercent: number): McpUsageMinuteItem =>
({ tenantId, principal, sharePercent, peakCalls: 30, peakCallsAt: AT, mcpRefused: 0, peakRequests: 60, peakRequestsAt: AT, backendLimit: 120, backendRefused: 0 });
function overview(over: Partial<GetGlobalMcpUsageOverviewResponse> = {}): GetGlobalMcpUsageOverviewResponse {
return {
dateFrom: '20261007',
dateTo: '20261105',
includesPlatformAdmins: false,
telemetry: { requestsAvailable: true, mcpLogAvailable: true },
totals: {
calls: 40, mcpOnlyCalls: 4, tools: 2, requests: 24, tenants: 2, accounts: 2, servicePrincipals: 0,
quotaHitAccounts: 1, minuteRefusedAccounts: 0, limitAccounts: 1,
},
daily: [{ date: '20261105', calls: 40, requests: 24, accounts: 2, callsByPlan: [{ plan: 'community', calls: 40 }] }],
tenants: [tenant(TENANT_A, 'contoso.com', [account('alice@contoso.com')]), tenant(TENANT_B, undefined, [account('bob@fabrikam.com')])],
tools: [tool('get_session_summary', 36), tool('search_docs', 4, { accounts: undefined, tenants: undefined, requestsPerCall: 0, routes: [] })],
quotaHits: [hit(TENANT_A, 'alice@contoso.com')],
minuteLimits: { mcpServerLimit: 60, accounts: [minute(TENANT_A, 'alice@contoso.com', 50), minute(TENANT_B, 'bob@fabrikam.com', 25)] },
...over,
};
}
describe('usageWindow', () => {
it('is the trailing window of whole UTC days ending today, as yyyyMMdd', () => {
const now = new Date('2026-11-05T23:30:00Z');
expect(usageWindow(1, now)).toEqual({ from: '20261105', to: '20261105' });
expect(usageWindow(30, now)).toEqual({ from: '20261007', to: '20261105' });
});
});
describe('shapeOverview — summary', () => {
it('carries the backend totals and every list head with its full length', () => {
const out = shapeOverview(overview(), 'summary');
expect(out.view).toBe('summary');
expect(out.window).toEqual({ from: '20261007', to: '20261105' });
expect(out.telemetry).toEqual({ requestsAvailable: true, mcpLogAvailable: true, mcpLogFrom: null, partial: false });
expect(out.totals).toMatchObject({ calls: 40, mcpOnlyCalls: 4, requests: 24, limitAccounts: 1 });
expect(out.lists).toEqual({ tools: 2, tenants: 2, quotaHits: 1, minuteLimits: 2 });
expect(out).not.toHaveProperty('note'); // nothing was cut
const [summaryTool, docs] = out.tools as Array<Record<string, unknown>>;
expect(summaryTool).toEqual({ tool: 'get_session_summary', calls: 36, requestsPerCall: 4.5, accounts: 2, tenants: 1, errorRate: 0.1, p95DurationMs: 900 });
expect(docs).toMatchObject({ tool: 'search_docs', accounts: null, tenants: null }); // runs in the MCP server alone
const [contoso, unnamed] = out.tenants as Array<Record<string, unknown>>;
expect(contoso).toMatchObject({ domainName: 'contoso.com', plan: 'community', accounts: 1, today: { used: 30, limit: 100 }, month: { used: 90, limit: 250 } });
expect(unnamed.domainName).toBeNull();
expect((out.quotaHits as Array<Record<string, unknown>>)[0]).toMatchObject({ domainName: 'contoso.com', principal: 'alice@contoso.com', used: 50, limit: 50 });
const minuteLimits = out.minuteLimits as { mcpServerLimit: number; accounts: Array<Record<string, unknown>> };
expect(minuteLimits.mcpServerLimit).toBe(60);
expect(minuteLimits.accounts.map((m) => [m.principal, m.domainName, m.sharePercent])).toEqual([
['alice@contoso.com', 'contoso.com', 50],
['bob@fabrikam.com', null, 25],
]);
});
it('cuts a long list to its head and says so', () => {
const tools = Array.from({ length: SUMMARY_HEAD.tools + 3 }, (_, i) => tool(`tool_${i}`, 100 - i));
const out = shapeOverview(overview({ tools }), 'summary');
expect(out.tools).toHaveLength(SUMMARY_HEAD.tools);
expect((out.lists as Record<string, number>).tools).toBe(SUMMARY_HEAD.tools + 3);
expect(out.note).toMatch(/cut to their first rows/);
});
it('shows calls as null, not 0, when the request telemetry did not answer', () => {
const out = shapeOverview(overview({
telemetry: { requestsAvailable: false, mcpLogAvailable: false, mcpLogFrom: '20261020', partial: true },
totals: { tools: 0, requests: 24, tenants: 2, accounts: 2, servicePrincipals: 0, quotaHitAccounts: 0, minuteRefusedAccounts: 0, limitAccounts: 0 },
}), 'summary');
expect(out.totals).toMatchObject({ calls: null, mcpOnlyCalls: null, requests: 24 });
expect(out.telemetry).toEqual({ requestsAvailable: false, mcpLogAvailable: false, mcpLogFrom: '20261020', partial: true });
});
});
describe('shapeOverview — one list in full', () => {
it('tenants carry every account with explicit windows; an unreadable counter is null', () => {
const unreadable = account('carol@contoso.com', { quota: { plan: 'community', dailyLimit: 50, monthlyLimit: 250, dailyUsed: -1, monthlyUsed: -1 } });
const out = shapeOverview(overview({ tenants: [tenant(TENANT_A, 'contoso.com', [account('alice@contoso.com'), unreadable])] }), 'tenants');
const [only] = out.tenants as Array<{ accounts: Array<Record<string, unknown>> }>;
expect(only.accounts.map((a) => a.principal)).toEqual(['alice@contoso.com', 'carol@contoso.com']);
expect(only.accounts[0]).toMatchObject({ plan: 'community', today: { used: 12, limit: 50 }, month: { used: 40, limit: 250 }, role: null });
expect(only.accounts[1]).toMatchObject({ today: { used: null, limit: 50 }, month: { used: null, limit: 250 } });
});
it('tools keep their routes and quality figures; the other views return their one list', () => {
const data = overview();
const tools = shapeOverview(data, 'tools').tools as Array<Record<string, unknown>>;
expect(tools[0]).toMatchObject({ routes: [{ route: 'GET sessions/{id}', requests: 9 }], avgResultChars: null, overCapCalls: null });
expect(shapeOverview(data, 'quotaHits').quotaHits).toHaveLength(1);
expect(shapeOverview(data, 'minuteLimits')).toMatchObject({ mcpServerLimit: 60 });
expect(shapeOverview(data, 'daily').daily).toEqual(data.daily);
for (const view of ['tools', 'tenants', 'quotaHits', 'minuteLimits', 'daily'] as const) {
expect(shapeOverview(data, view)).not.toHaveProperty('totals');
}
});
});
describe('shapeOrganization', () => {
it('names the windows and sums the accounts of the range', () => {
const data: GetMcpOrganizationUsageResponse = {
tenantId: TENANT_A,
dateFrom: '20261101',
dateTo: '20261105',
users: [
{ userId: OID, userPrincipalName: 'alice@contoso.com', requestsToday: 10, requestsThisMonth: 40, requestsInRange: 40, lastRequestAt: AT },
{ userId: 'oid-2', requestsToday: 0, requestsThisMonth: 2, requestsInRange: 2 },
],
quota: { tenantPlan: 'community', dailyLimit: 100, monthlyLimit: 250, dailyUsed: 10, monthlyUsed: 42 },
daily: [{ date: '20261105', requests: 10 }],
};
const out = shapeOrganization(data);
expect(out).toMatchObject({ view: 'organization', plan: 'community', today: { used: 10, limit: 100 }, month: { used: 42, limit: 250 }, requests: 42 });
expect((out.accounts as Array<Record<string, unknown>>)[1]).toEqual(
{ principal: null, userId: 'oid-2', requestsInRange: 2, requestsToday: 0, requestsThisMonth: 2, lastRequestAt: null });
});
});
describe('shapeAccount', () => {
it('folds the counter rows into tools with their paths and into days', () => {
const row = (date: string, endpoint: string, requestCount: number, lastRequestAt = AT) =>
({ userId: OID, userPrincipalName: 'alice@contoso.com', tenantId: TENANT_A, endpoint, date, requestCount, lastRequestAt });
const data: GetMcpUserUsageResponse = {
userId: OID,
records: [
row('20261104', 'get_session_summary:sessions/{id}', 6),
row('20261105', 'get_session_summary:sessions/{id}/events', 9),
row('20261105', 'get_session_summary:sessions/{id}', 3),
row('20261105', 'auth/me', 1),
],
};
const out = shapeAccount(data, { from: '20261101', to: '20261105' });
expect(out).toMatchObject({ userId: OID, principal: 'alice@contoso.com', tenantId: TENANT_A, requests: 19 });
expect(out.byTool).toEqual([
{ tool: 'get_session_summary', requests: 18, paths: [{ path: 'sessions/{id}', requests: 9 }, { path: 'sessions/{id}/events', requests: 9 }] },
{ tool: NO_TOOL, requests: 1, paths: [{ path: 'auth/me', requests: 1 }] },
]);
expect(out.byDay).toEqual([{ date: '20261104', requests: 6 }, { date: '20261105', requests: 13 }]);
});
it('answers an account without rows with zeros, not an error', () => {
expect(shapeAccount({ userId: OID, records: [] }, { from: '20261105', to: '20261105' }))
.toEqual({ view: 'account', userId: OID, principal: null, tenantId: null, window: { from: '20261105', to: '20261105' }, requests: 0, byTool: [], byDay: [] });
});
});
// ── The tool ──────────────────────────────────────────────────────────────────
type Handler = (args: Record<string, unknown>) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>;
function handler(): Handler {
const handlers: Record<string, Handler> = {};
const fake = { registerTool: (name: string, _def: unknown, h: Handler) => { handlers[name] = h; } };
registerAdminTools(fake as never, true, true, false);
return handlers.get_api_usage;
}
describe('get_api_usage — routing', () => {
beforeEach(() => apiFetchMock.mockReset());
it('reads the overview for the overview views, with the platform-admin switch and a long timeout', async () => {
apiFetchMock.mockResolvedValueOnce(overview());
const result = await handler()({ view: 'summary', days: 7, includePlatformAdmins: true });
expect(result.isError).toBeUndefined();
const [path, init] = apiFetchMock.mock.calls[0] as [string, { signal?: AbortSignal }];
expect(path).toBe('/api/global/metrics/mcp-usage/overview?days=7&platformAdmins=include');
expect(init.signal).toBeDefined();
expect(JSON.parse(result.content[0].text).view).toBe('summary');
});
it('reads one tenant and one account over the trailing window', async () => {
apiFetchMock.mockResolvedValue({ tenantId: TENANT_A, dateFrom: '20261105', dateTo: '20261105', users: [], quota: { tenantPlan: 'pro', dailyLimit: 0, monthlyLimit: 0, dailyUsed: 0, monthlyUsed: 0 }, daily: [] });
await handler()({ view: 'organization', days: 1, tenantId: TENANT_A });
expect(apiFetchMock.mock.calls[0][0]).toMatch(new RegExp(`^/api/global/metrics/mcp-usage/organization\\?tenantId=${TENANT_A}&dateFrom=\\d{8}&dateTo=\\d{8}$`));
apiFetchMock.mockResolvedValue({ userId: OID, records: [] });
await handler()({ view: 'account', days: 1, userId: OID });
expect(apiFetchMock.mock.calls[1][0]).toMatch(new RegExp(`^/api/metrics/mcp-usage/user/${OID}\\?dateFrom=\\d{8}&dateTo=\\d{8}$`));
});
it('refuses organization without tenantId and account without userId, before any request', async () => {
const noTenant = await handler()({ view: 'organization', days: 30 });
const noUser = await handler()({ view: 'account', days: 30 });
expect(noTenant.isError).toBe(true);
expect(noTenant.content[0].text).toMatch(/tenantId is required for view=organization/);
expect(noUser.isError).toBe(true);
expect(noUser.content[0].text).toMatch(/userId .* is required for view=account/);
expect(apiFetchMock).not.toHaveBeenCalled();
});
it('a UPN is not an object id', () => {
expect(ObjectIdSchema.safeParse(OID).success).toBe(true);
expect(ObjectIdSchema.safeParse('alice@contoso.com').success).toBe(false);
expect(ObjectIdSchema.safeParse('../global/metrics').success).toBe(false);
});
});
@@ -42,7 +42,9 @@ interface Role {
const ROLES: Role[] = [
// Raised 2026-10-28 by +1,100 each: the platform-only get_growth_metrics (measured 1,106 chars).
{ label: 'global-admin', isGlobalAdmin: true, isGlobalReader: false, maxCatalogChars: 93_100 },
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 78_100 },
// Raised 2026-11-05 by +200: get_api_usage reads the MCP usage overview — eight views, the window, the
// platform-admin switch and two drill-down ids (measured 78,205 chars; the Global Admin catalog stayed under).
{ label: 'global-reader', isGlobalAdmin: false, isGlobalReader: true, maxCatalogChars: 78_300 },
// Raised 2026-11-04 by +400: search_sessions gained the groupTag filter and five Autopilot
// projection keys (measured 53,318 chars; the other roles stayed under their ceilings).
{ label: 'tenant-admin', isGlobalAdmin: false, isGlobalReader: false, tenantRole: 'Admin', maxCatalogChars: 53_400 },
@@ -56,8 +58,9 @@ const ROLES: Role[] = [
* Shared-mechanics arguments should read the same everywhere (shared.ts helpers); each distinct
* wording is one more contract the model has to reconcile. Ratchet on the variant count in the
* Global Admin catalog (2026-09-22 post-diet baseline; pre-diet: tenantId 28, pageSize 14, continuation 10).
* days 10 since 2026-11-05: get_api_usage's window is bounded by the 90 days the request telemetry keeps.
*/
const MAX_ARG_DESCRIPTION_VARIANTS: Record<string, number> = { tenantId: 13, pageSize: 8, continuation: 3, days: 9 };
const MAX_ARG_DESCRIPTION_VARIANTS: Record<string, number> = { tenantId: 13, pageSize: 8, continuation: 3, days: 10 };
/**
* Domains a description may name. Anything else that looks like a domain is treated as a
@@ -7,7 +7,7 @@
* sessions; the cap and a truncated flag are now surfaced.
*/
import { describe, it, expect } from 'vitest';
import { inferGeoGroupBy, buildGeoLocationParams, platformWindowEcho, paginateUsage, paginateInventory } from '../tools/admin.js';
import { inferGeoGroupBy, buildGeoLocationParams, platformWindowEcho, paginateInventory } from '../tools/admin.js';
describe('inferGeoGroupBy', () => {
it('treats a single-segment key as country', () => {
@@ -90,51 +90,6 @@ describe('platformWindowEcho', () => {
});
});
describe('paginateUsage', () => {
const records = Array.from({ length: 5 }, (_, i) => ({ endpoint: `e${i}`, count: i }));
it('pages the global per-record breakdown and emits a nextLink while more remain', () => {
const out = paginateUsage({ tenantId: 't1', records }, 2);
expect(out.tenantId).toBe('t1'); // top-level fields echoed verbatim
expect(out.totalCount).toBe(5);
expect(out.count).toBe(2);
expect(out.offset).toBe(0);
expect(out.records).toEqual(records.slice(0, 2));
expect(out.nextLink).toBe('usage-offset:2');
});
it('follows a continuation cursor to the next slice', () => {
const out = paginateUsage({ tenantId: 't1', records }, 2, 'usage-offset:2');
expect(out.offset).toBe(2);
expect(out.records).toEqual(records.slice(2, 4));
expect(out.nextLink).toBe('usage-offset:4');
});
it('drops nextLink on the final page', () => {
const out = paginateUsage({ tenantId: 't1', records }, 2, 'usage-offset:4');
expect(out.count).toBe(1);
expect(out.records).toEqual(records.slice(4));
expect(out.nextLink).toBeNull();
});
it('pages the daily "summaries" array too, preserving the key', () => {
const summaries = [{ date: '2026-06-01' }, { date: '2026-06-02' }];
const out = paginateUsage({ tenantId: 't1', summaries }, 1);
expect(out.summaries).toEqual(summaries.slice(0, 1));
expect(Object.prototype.hasOwnProperty.call(out, 'records')).toBe(false);
expect(out.nextLink).toBe('usage-offset:1');
});
it('passes an unexpected shape through untouched', () => {
const weird = { tenantId: 't1', somethingElse: 42 };
expect(paginateUsage(weird, 200)).toEqual(weird);
});
it('handles a null/non-object payload defensively', () => {
expect(paginateUsage(null, 200)).toEqual({ records: [], totalCount: 0, count: 0, offset: 0, nextLink: null });
});
});
describe('paginateInventory', () => {
const inventory = Array.from({ length: 5 }, (_, i) => ({ normalizedName: `app-${i}`, cpeUri: '' }));
const payload = { success: true, tenantId: 't1', total: 5, matched: 0, unmatched: 5, inventory };
@@ -327,3 +327,19 @@ describe('attachToolCallRejectionSniffer', () => {
expect(res.end).toBe(originalEnd);
});
});
describe('bootLine', () => {
it('starts with the type marker and carries the enforced per-account budget', async () => {
// The GA usage page finds the line by its start and reads rateLimitPerMinute from the newest one.
const { bootLine } = await loadTelemetry(false);
const line = bootLine(
{ readyMs: 812, version: '1.2.3', commit: 'abc1234', docsCommit: 'def5678', rateLimitPerMinute: 60 },
new Date('2026-11-05T12:00:00.000Z'),
);
expect(line.startsWith('{"type":"boot"')).toBe(true);
expect(JSON.parse(line)).toEqual({
type: 'boot', readyMs: 812, version: '1.2.3', commit: 'abc1234', docsCommit: 'def5678',
rateLimitPerMinute: 60, timestamp: '2026-11-05T12:00:00.000Z',
});
});
});
@@ -105,19 +105,13 @@ suite('get_agent_efficiency_metrics', () => {
});
suite('get_api_usage', () => {
it('should return daily usage summary', async () => {
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage/daily`);
expect(Array.isArray(data.summaries)).toBe(true);
if (data.summaries.length > 0) {
expect(data.summaries[0]).toHaveProperty('date');
expect(data.summaries[0]).toHaveProperty('totalRequests');
}
});
it('should return global per-record usage', async () => {
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage`);
// Response structure depends on implementation, just verify no error
expect(data).toBeDefined();
it('should return the all-tenant overview with its totals', async () => {
const data = await apiFetch<any>(`/api/global/metrics/mcp-usage/overview?days=7`);
expect(data).toHaveProperty('totals');
expect(typeof data.totals.requests).toBe('number');
expect(Array.isArray(data.tenants)).toBe(true);
expect(Array.isArray(data.tools)).toBe(true);
expect(Array.isArray(data.minuteLimits.accounts)).toBe(true);
});
});
@@ -0,0 +1,68 @@
/**
* The backend's GA MCP usage overview reads three kinds of line from this server's console log with KQL:
* the tool_call line, the boot line and the access guard's per-account refusal. tests/fixtures/mcp-log-lines
* names the line starts and fields it reads; this suite proves the server writes every one of them (the
* backend suite proves its queries read nothing else). A rename here would otherwise empty a column there.
*/
import { readFileSync } from 'node:fs';
import { describe, it, expect, vi, afterEach } from 'vitest';
interface LogLineContract {
prefixes: { tool_call: string; boot: string; mcp_refusal: string };
fields: { tool_call: string[]; boot: string[] };
}
const contract = JSON.parse(
readFileSync(new URL('../../../../../tests/fixtures/mcp-log-lines/fields.json', import.meta.url), 'utf8'),
) as LogLineContract;
async function loadTelemetry() {
vi.resetModules();
vi.stubEnv('MCP_TOOL_LOGGING', 'true');
return await import('../telemetry.js');
}
function loggedLines(spy: ReturnType<typeof vi.spyOn>): string[] {
return spy.mock.calls.map((call: unknown[]) => String(call[0]));
}
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
describe('MCP log lines the usage overview reads', () => {
it('writes the tool_call line with its type first and every field the overview reads', async () => {
const { withToolTelemetry } = await loadTelemetry();
const { runWithCaller } = await import('../client.js');
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
// A completed call carries every field but `cancelled`, which only an abandoned call writes.
await withToolTelemetry('get_session', { sessionId: 's' }, () => ({ content: [{ type: 'text', text: '{}' }] }));
const controller = new AbortController();
await runWithCaller({ token: 't', isGlobalAdmin: false, signal: controller.signal }, () =>
withToolTelemetry('get_session', { sessionId: 's' }, () => {
controller.abort();
return { isError: true, content: [{ type: 'text', text: '**Timeout in get_session**' }] };
}),
);
const lines = loggedLines(spy);
expect(lines).toHaveLength(2);
const written = new Set<string>();
for (const line of lines) {
expect(line.startsWith(contract.prefixes.tool_call)).toBe(true);
for (const key of Object.keys(JSON.parse(line) as object)) written.add(key);
}
for (const field of contract.fields.tool_call) expect(written).toContain(field);
});
it('writes the boot line with its type first and every field the overview reads', async () => {
const { bootLine } = await loadTelemetry();
const line = bootLine({ readyMs: 1, version: 'v', commit: 'c', docsCommit: 'd', rateLimitPerMinute: 60 });
expect(line.startsWith(contract.prefixes.boot)).toBe(true);
const keys = Object.keys(JSON.parse(line) as object);
for (const field of contract.fields.boot) expect(keys).toContain(field);
});
});
@@ -35,6 +35,8 @@ const ALLOWED_INLINE_ENUMS: Record<string, string> = {
'search_knowledge corpus selector — MCP-side, spans two backend catalogs plus the error-code catalog.',
"'inventory', 'unmatched'":
'get_software_inventory scope selector — an MCP-side view switch.',
"...OVERVIEW_VIEWS, 'organization', 'account'":
'get_api_usage view selector — an MCP-side view switch over three backend routes (api-usage-shape.ts).',
"'cmtrace', 'text'":
'test_log_pattern input format — an MCP-side parser switch.',
"'analyze', 'gather'":
@@ -19,8 +19,8 @@ const BASE_URL = API_BASE_URL;
// Per-UPN request budget (sliding 60s window), applied AFTER a token is
// validated. parsePositiveInt guards against a non-numeric override: a bare
// parseInt would yield NaN, and `count >= NaN` is always false — silently
// disabling the limiter.
const RATE_LIMIT = parsePositiveInt(process.env.MCP_RATE_LIMIT_PER_MINUTE, 60);
// disabling the limiter. The boot line reports it, so usage views read the live value.
export const RATE_LIMIT_PER_MINUTE = parsePositiveInt(process.env.MCP_RATE_LIMIT_PER_MINUTE, 60);
// Per-source-IP budget for UNvalidated requests (sliding 60s window), applied
// only on the cache-MISS path that would call the backend. Bounds forged- /
@@ -357,7 +357,7 @@ function getClientIp(req: Request): string {
}
function isRateLimited(upn: string): boolean {
return isWindowExceeded(rateBuckets, upn, RATE_LIMIT);
return isWindowExceeded(rateBuckets, upn, RATE_LIMIT_PER_MINUTE);
}
/**
@@ -0,0 +1,265 @@
/**
* LLM-facing shaping of the MCP usage views of get_api_usage:
* - the all-tenant overview (GET /api/global/metrics/mcp-usage/overview, D-416) — one list per view, or the summary;
* - one tenant's organization budget (GET /api/global/metrics/mcp-usage/organization?tenantId=);
* - one account's quota counters (GET /api/metrics/mcp-usage/user/{userId}).
*
* The backend computes every figure (totals, minute shares, rankings) once for the admin page and this tool. This
* layer only picks what a view shows, states the full length of every list the summary cuts, names tenants, folds an
* account's counter rows into sums per tool and per day, and turns absent values into explicit nulls (the wire omits
* them, which reads as "not applicable"). A quota window is { used, limit }: limit 0 = unlimited, used null = the
* counter could not be read.
*/
import type {
GetGlobalMcpUsageOverviewResponse,
GetMcpOrganizationUsageResponse,
GetMcpUserUsageResponse,
McpUsageAccountItem,
McpUsageMinuteItem,
McpUsageQuotaHitItem,
McpUsageTenantItem,
McpUsageToolItem,
} from './generated/wire-types.generated.js';
export const OVERVIEW_VIEWS = ['summary', 'tools', 'tenants', 'quotaHits', 'minuteLimits', 'daily'] as const;
export type OverviewView = (typeof OVERVIEW_VIEWS)[number];
/** How many rows of each list the summary shows; the full list is one view away. */
export const SUMMARY_HEAD = { tools: 10, tenants: 10, quotaHits: 10, minuteLimits: 5 } as const;
/** The tool of an account's counter key that carries none (a request sent without a tool name). */
export const NO_TOOL = '(no tool)';
interface QuotaWindow {
used: number | null;
limit: number;
}
/** The wire marks an unreadable counter with -1. */
function quotaWindow(used: number, limit: number): QuotaWindow {
return { used: used < 0 ? null : used, limit };
}
const dayKey = (d: Date) => d.toISOString().slice(0, 10).replace(/-/g, '');
/** The trailing window of `days` whole UTC days ending today, as yyyyMMdd — the overview's own window. */
export function usageWindow(days: number, now: Date = new Date()): { from: string; to: string } {
const from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - (days - 1)));
return { from: dayKey(from), to: dayKey(now) };
}
// ── Overview ──────────────────────────────────────────────────────────────────
function overviewHead(data: GetGlobalMcpUsageOverviewResponse) {
return {
window: { from: data.dateFrom, to: data.dateTo },
includesPlatformAdmins: data.includesPlatformAdmins,
telemetry: {
requestsAvailable: data.telemetry.requestsAvailable,
mcpLogAvailable: data.telemetry.mcpLogAvailable,
mcpLogFrom: data.telemetry.mcpLogFrom ?? null,
partial: data.telemetry.partial ?? false,
},
};
}
function toolRow(t: McpUsageToolItem) {
return {
tool: t.tool,
calls: t.calls,
requestsPerCall: t.requestsPerCall,
// null: the tool runs in the MCP server alone and never reaches the backend.
accounts: t.accounts ?? null,
tenants: t.tenants ?? null,
errorRate: t.errorRate ?? null,
p95DurationMs: t.p95DurationMs ?? null,
};
}
function tenantRow(t: McpUsageTenantItem) {
return {
tenantId: t.tenantId,
domainName: t.domainName ?? null,
plan: t.quota.tenantPlan,
today: quotaWindow(t.quota.dailyUsed, t.quota.dailyLimit),
month: quotaWindow(t.quota.monthlyUsed, t.quota.monthlyLimit),
calls: t.calls,
requests: t.requests,
quotaHits: t.quotaHits,
lastRequestAt: t.lastRequestAt ?? null,
};
}
function accountRow(a: McpUsageAccountItem) {
return {
principal: a.principal,
userId: a.userId ?? null,
role: a.role ?? null,
isPlatformAdmin: a.isPlatformAdmin,
plan: a.quota?.plan ?? null,
today: a.quota ? quotaWindow(a.quota.dailyUsed, a.quota.dailyLimit) : null,
month: a.quota ? quotaWindow(a.quota.monthlyUsed, a.quota.monthlyLimit) : null,
calls: a.calls,
requests: a.requests,
peakDay: a.peakDay ?? null,
peakDayRequests: a.peakDayRequests ?? null,
quotaHits: a.quotaHits,
topTools: a.topTools,
lastRequestAt: a.lastRequestAt ?? null,
};
}
function quotaHitRow(h: McpUsageQuotaHitItem, domainName: string | null) {
return {
date: h.date,
tenantId: h.tenantId,
domainName,
principal: h.principal ?? null,
level: h.level,
scope: h.scope,
plan: h.plan,
used: h.used ?? null,
limit: h.limit ?? null,
refused: h.refused,
firstAt: h.firstAt,
lastAt: h.lastAt,
};
}
function minuteRow(m: McpUsageMinuteItem, domainName: string | null) {
return {
tenantId: m.tenantId,
domainName,
principal: m.principal,
sharePercent: m.sharePercent,
peakCalls: m.peakCalls,
peakCallsAt: m.peakCallsAt ?? null,
mcpRefused: m.mcpRefused,
peakRequests: m.peakRequests,
peakRequestsAt: m.peakRequestsAt ?? null,
backendLimit: m.backendLimit,
backendRefused: m.backendRefused,
};
}
/** One view of the overview: the summary (every list cut to its head, full lengths stated) or one list in full. */
export function shapeOverview(data: GetGlobalMcpUsageOverviewResponse, view: OverviewView): Record<string, unknown> {
const names = new Map(data.tenants.filter((t) => t.domainName).map((t) => [t.tenantId.toLowerCase(), t.domainName!]));
const nameOf = (tenantId: string) => names.get(tenantId.toLowerCase()) ?? null;
const head = { view, ...overviewHead(data) };
const minute = data.minuteLimits;
switch (view) {
case 'summary': {
const lists = {
tools: data.tools.length,
tenants: data.tenants.length,
quotaHits: data.quotaHits.length,
minuteLimits: minute.accounts.length,
};
const cut = (Object.keys(lists) as Array<keyof typeof lists>).some((k) => lists[k] > SUMMARY_HEAD[k]);
return {
...head,
totals: { ...data.totals, calls: data.totals.calls ?? null, mcpOnlyCalls: data.totals.mcpOnlyCalls ?? null },
tools: data.tools.slice(0, SUMMARY_HEAD.tools).map(toolRow),
tenants: data.tenants.slice(0, SUMMARY_HEAD.tenants).map((t) => ({ ...tenantRow(t), accounts: t.accounts.length })),
quotaHits: data.quotaHits.slice(0, SUMMARY_HEAD.quotaHits).map((h) => quotaHitRow(h, nameOf(h.tenantId))),
minuteLimits: {
mcpServerLimit: minute.mcpServerLimit ?? null,
accounts: minute.accounts.slice(0, SUMMARY_HEAD.minuteLimits).map((m) => minuteRow(m, nameOf(m.tenantId))),
},
lists,
...(cut ? { note: 'Lists are cut to their first rows; lists gives each full length, the view of the same name shows it in full.' } : {}),
};
}
case 'tools':
return { ...head, tools: data.tools.map((t) => ({ ...toolRow(t), avgResultChars: t.avgResultChars ?? null, overCapCalls: t.overCapCalls ?? null, routes: t.routes })) };
case 'tenants':
return { ...head, tenants: data.tenants.map((t) => ({ ...tenantRow(t), accounts: t.accounts.map(accountRow) })) };
case 'quotaHits':
return { ...head, quotaHits: data.quotaHits.map((h) => quotaHitRow(h, nameOf(h.tenantId))) };
case 'minuteLimits':
return {
...head,
mcpServerLimit: minute.mcpServerLimit ?? null,
accounts: minute.accounts.map((m) => ({
...minuteRow(m, nameOf(m.tenantId)),
mcpLastRefusedAt: m.mcpLastRefusedAt ?? null,
backendLastRefusedAt: m.backendLastRefusedAt ?? null,
})),
};
case 'daily':
return { ...head, daily: data.daily };
}
}
// ── One tenant ────────────────────────────────────────────────────────────────
/** One tenant's organization budget: its windows, every account charged to it, and the days. */
export function shapeOrganization(data: GetMcpOrganizationUsageResponse): Record<string, unknown> {
return {
view: 'organization',
tenantId: data.tenantId,
window: { from: data.dateFrom, to: data.dateTo },
plan: data.quota.tenantPlan,
today: quotaWindow(data.quota.dailyUsed, data.quota.dailyLimit),
month: quotaWindow(data.quota.monthlyUsed, data.quota.monthlyLimit),
requests: data.users.reduce((sum, u) => sum + u.requestsInRange, 0),
accounts: data.users.map((u) => ({
principal: u.userPrincipalName ?? null,
userId: u.userId,
requestsInRange: u.requestsInRange,
requestsToday: u.requestsToday,
requestsThisMonth: u.requestsThisMonth,
lastRequestAt: u.lastRequestAt ?? null,
})),
daily: data.daily,
};
}
// ── One account ───────────────────────────────────────────────────────────────
/** A counter key is `tool:path`, or the bare path when the request carried no tool name. */
function splitEndpoint(endpoint: string): { tool: string; path: string } {
const colon = endpoint.indexOf(':');
return colon > 0 ? { tool: endpoint.slice(0, colon), path: endpoint.slice(colon + 1) } : { tool: NO_TOOL, path: endpoint };
}
const byRequestsThenName = <T extends { requests: number }>(name: (row: T) => string) =>
(a: T, b: T) => b.requests - a.requests || name(a).localeCompare(name(b));
/** One account's charged requests in the window, by tool (with the paths it requested) and by day. */
export function shapeAccount(data: GetMcpUserUsageResponse, window: { from: string; to: string }): Record<string, unknown> {
const tools = new Map<string, Map<string, number>>();
const days = new Map<string, number>();
let newest: (typeof data.records)[number] | undefined;
for (const record of data.records) {
const { tool, path } = splitEndpoint(record.endpoint);
const paths = tools.get(tool) ?? new Map<string, number>();
paths.set(path, (paths.get(path) ?? 0) + record.requestCount);
tools.set(tool, paths);
days.set(record.date, (days.get(record.date) ?? 0) + record.requestCount);
if (!newest || record.lastRequestAt > newest.lastRequestAt) newest = record;
}
const byTool = [...tools.entries()]
.map(([tool, paths]) => ({
tool,
requests: [...paths.values()].reduce((sum, n) => sum + n, 0),
paths: [...paths.entries()].map(([path, requests]) => ({ path, requests })).sort(byRequestsThenName((p) => p.path)),
}))
.sort(byRequestsThenName((t) => t.tool));
return {
view: 'account',
userId: data.userId,
principal: newest?.userPrincipalName || null,
tenantId: newest?.tenantId || null,
window,
requests: byTool.reduce((sum, t) => sum + t.requests, 0),
byTool,
byDay: [...days.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, requests]) => ({ date, requests })),
};
}
@@ -326,7 +326,7 @@ function tenantIdFromNextLink(continuation?: string): string | undefined {
* nextLink's embedded tenantId when present (it is what actually gets sent), else the explicit arg, and
* validate THAT against the managed set — which also blocks a hand-crafted continuation pointing at an
* unmanaged tenant (defense in depth on top of the backend bound). No-op for non-delegated callers and
* for offset-only continuations (geo-offset:/inv-offset:/usage-offset:), which re-send the explicit
* for offset-only continuations (geo-offset:/inv-offset:), which re-send the explicit
* tenantId every page anyway.
*/
export function enforceDelegatedTenantForPage(tenantId?: string, continuation?: string): string | undefined {

Some files were not shown because too many files have changed in this diff Show More